diff --git a/corpus/math-edit/README.md b/corpus/math-edit/README.md
index 304e67a5ab432fa9f62be01d2a078b684b5f3b95..76b3c8eee75a54973364e4a431c410aa5fdc837a 100644
--- a/corpus/math-edit/README.md
+++ b/corpus/math-edit/README.md
@@ -25,6 +25,16 @@ degree second), a lower limit (19: `munder`, with the upright `lim` as one
overbar (23: `mover accent="false"` with a stretchy bar) and an accent (10:
`mover accent="true"` with the combining circumflex exported as `^`).
+`native-editor-3/` is a third session: `\matrix(1&2@3&4)` (exported inline,
+its trailing `(\matrix(a&b@c&d))` staying literal text), `\eqarray(x&=1@y&=2)`,
+`x\above 2`, `x\below 2`, `\box(x)`, `\rect(x)`, `\underline(x)` (a literal
+`▱` before fences), `\iint x dx dy` (a bare operator), `f(x)/(x^2+1)` and
+`\sum^n x`. Its MathML adds matrices (20: `mtable` of cells row by row, the
+column count on the opening run's `0x0c003451`), equation arrays (15: rows
+with `maligngroup` and a `malignmark` for each `&`), an upper limit (33:
+`mover`), boxes (11: `mpadded`; 12: `menclose notation="box"`) and an n-ary
+operator with one empty limit (`mover`, or `msup` for integrals).
+
`written/candidate` is the page writer's output for
`equations_are_written_and_read_back`: on a fresh section, every native
equation of both fixtures copied as a new paragraph, then two expressions
diff --git a/corpus/math-edit/native-editor-3/before-read/environment.json b/corpus/math-edit/native-editor-3/before-read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..17bdc24f0944680caf415698e8c1fa9dd89308c9
--- /dev/null
+++ b/corpus/math-edit/native-editor-3/before-read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-22B0E73B",
+ "cold": true,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/math-edit/native-editor-3/before-read/hierarchy.xml b/corpus/math-edit/native-editor-3/before-read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..1728c88c790d94b8aca20c92d9e3737d6a787245
--- /dev/null
+++ b/corpus/math-edit/native-editor-3/before-read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/math-edit/native-editor-3/before-read/page-000.xml b/corpus/math-edit/native-editor-3/before-read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..29d91e34592525489edde90a4fd495470924e546
--- /dev/null
+++ b/corpus/math-edit/native-editor-3/before-read/page-000.xml
@@ -0,0 +1,3 @@
+
+the Rust site]]>
diff --git a/corpus/math-edit/native-editor-3/before-read/payloads.json b/corpus/math-edit/native-editor-3/before-read/payloads.json
new file mode 100644
index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843
--- /dev/null
+++ b/corpus/math-edit/native-editor-3/before-read/payloads.json
@@ -0,0 +1,3 @@
+[
+
+]
\ No newline at end of file
diff --git a/corpus/math-edit/native-editor-3/commands.jsonl b/corpus/math-edit/native-editor-3/commands.jsonl
new file mode 100644
index 0000000000000000000000000000000000000000..38513d47fc25a01ccdb338cb2cd4289f115d661e
--- /dev/null
+++ b/corpus/math-edit/native-editor-3/commands.jsonl
@@ -0,0 +1,6 @@
+{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-22B0E73B -ExpectedPages 1 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 1 pages.\r\n", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -Command \"Rename-Item C:\\one-tests\\runs\\capture\\read before-read\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-22B0E73B -UseCurrentCache -ExpectedPages -1", "exit": 0, "stdout": "Read 1 sections and 1 pages.\r\n", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
diff --git a/corpus/math-edit/native-editor-3/equations.ahk b/corpus/math-edit/native-editor-3/equations.ahk
new file mode 100644
index 0000000000000000000000000000000000000000..facaa25bb9f46c12b0b0e67707cdddee0f35af63
--- /dev/null
+++ b/corpus/math-edit/native-editor-3/equations.ahk
@@ -0,0 +1,113 @@
+ComObject("OneNote.Application").NavigateTo("{ACFEEB7F-AA18-03BD-3300-EB94605262CF}{1}{B0}", "", false)
+WinWait("ahk_exe ONENOTE.EXE", , 30)
+WinActivate("ahk_exe ONENOTE.EXE")
+WinWaitActive("ahk_exe ONENOTE.EXE", , 30)
+WinMaximize("ahk_exe ONENOTE.EXE")
+Sleep(3000)
+CoordMode("Mouse", "Screen")
+Click(300, 140)
+Sleep(500)
+Send("{End}")
+Sleep(300)
+Send("{Enter}")
+Sleep(500)
+Send("!=")
+Sleep(600)
+SendText("\matrix(1&2@3&4)")
+Send(" ")
+Sleep(600)
+Send("{End}")
+Sleep(300)
+Send("{Enter}")
+Sleep(600)
+Send("!=")
+Sleep(600)
+SendText("(\matrix(a&b@c&d))")
+Send(" ")
+Sleep(600)
+Send("{End}")
+Sleep(300)
+Send("{Enter}")
+Sleep(600)
+Send("!=")
+Sleep(600)
+SendText("\eqarray(x&=1@y&=2)")
+Send(" ")
+Sleep(600)
+Send("{End}")
+Sleep(300)
+Send("{Enter}")
+Sleep(600)
+Send("!=")
+Sleep(600)
+SendText("x\above 2")
+Send(" ")
+Sleep(600)
+Send("{End}")
+Sleep(300)
+Send("{Enter}")
+Sleep(600)
+Send("!=")
+Sleep(600)
+SendText("x\below 2")
+Send(" ")
+Sleep(600)
+Send("{End}")
+Sleep(300)
+Send("{Enter}")
+Sleep(600)
+Send("!=")
+Sleep(600)
+SendText("\box(x)")
+Send(" ")
+Sleep(600)
+Send("{End}")
+Sleep(300)
+Send("{Enter}")
+Sleep(600)
+Send("!=")
+Sleep(600)
+SendText("\rect(x)")
+Send(" ")
+Sleep(600)
+Send("{End}")
+Sleep(300)
+Send("{Enter}")
+Sleep(600)
+Send("!=")
+Sleep(600)
+SendText("\underline(x)")
+Send(" ")
+Sleep(600)
+Send("{End}")
+Sleep(300)
+Send("{Enter}")
+Sleep(600)
+Send("!=")
+Sleep(600)
+SendText("\iint x dx dy")
+Send(" ")
+Sleep(600)
+Send("{End}")
+Sleep(300)
+Send("{Enter}")
+Sleep(600)
+Send("!=")
+Sleep(600)
+SendText("f(x)/(x^2+1)")
+Send(" ")
+Sleep(600)
+Send("{End}")
+Sleep(300)
+Send("{Enter}")
+Sleep(600)
+Send("!=")
+Sleep(600)
+SendText("\sum^n x")
+Send(" ")
+Sleep(600)
+Send("{End}")
+Sleep(300)
+Send("{Enter}")
+Sleep(600)
+Sleep(2000)
diff --git a/corpus/math-edit/native-editor-3/equations.json b/corpus/math-edit/native-editor-3/equations.json
new file mode 100644
index 0000000000000000000000000000000000000000..83fb4df3362bb2ac7e9fa657fe6db76614b54e31
--- /dev/null
+++ b/corpus/math-edit/native-editor-3/equations.json
@@ -0,0 +1,13 @@
+{
+ "exit": 0,
+ "stdout": "",
+ "stderr": "",
+ "w": 800,
+ "h": 600,
+ "error": null,
+ "win": {
+ "title": "Read about Rust the Rust site - Microsoft OneNote",
+ "class": "Framework::CFrame",
+ "dialog": false
+ }
+}
\ No newline at end of file
diff --git a/corpus/math-edit/native-editor-3/equations.png b/corpus/math-edit/native-editor-3/equations.png
new file mode 100644
index 0000000000000000000000000000000000000000..500c3242eca41b831707f25fc362f3ccc01a50b1
Binary files /dev/null and b/corpus/math-edit/native-editor-3/equations.png differ
diff --git a/corpus/math-edit/native-editor-3/machine.json b/corpus/math-edit/native-editor-3/machine.json
new file mode 100644
index 0000000000000000000000000000000000000000..d7d4d34d19b6c1181649801adcaf1184d756a7e5
--- /dev/null
+++ b/corpus/math-edit/native-editor-3/machine.json
@@ -0,0 +1 @@
+{"name": "m6-22b0e73b", "hostname": "ONE-M6-22B0E73B"}
diff --git a/corpus/math-edit/native-editor-3/notebook/Open Notebook.onetoc2 b/corpus/math-edit/native-editor-3/notebook/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..683f16023dd5b88365e2b90e14aed2142289722a
Binary files /dev/null and b/corpus/math-edit/native-editor-3/notebook/Open Notebook.onetoc2 differ
diff --git a/corpus/math-edit/native-editor-3/notebook/links.one b/corpus/math-edit/native-editor-3/notebook/links.one
new file mode 100644
index 0000000000000000000000000000000000000000..84ad1c8a997f60a3ffb15dff1fdf9a05cf8ebb93
Binary files /dev/null and b/corpus/math-edit/native-editor-3/notebook/links.one differ
diff --git a/corpus/math-edit/native-editor-3/read/environment.json b/corpus/math-edit/native-editor-3/read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..8e3eb4ad62be1899f637b947aad655f17332a2ae
--- /dev/null
+++ b/corpus/math-edit/native-editor-3/read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-22B0E73B",
+ "cold": false,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/math-edit/native-editor-3/read/hierarchy.xml b/corpus/math-edit/native-editor-3/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..35fb7bbc0bc299a1ca060e49fe87912606e4a523
--- /dev/null
+++ b/corpus/math-edit/native-editor-3/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/math-edit/native-editor-3/read/page-000.xml b/corpus/math-edit/native-editor-3/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..812bc80878b6da3405d3d981abedc93d794a3dff
--- /dev/null
+++ b/corpus/math-edit/native-editor-3/read/page-000.xml
@@ -0,0 +1,4 @@
+
+the Rust site]]>1234(\matrix(a&b@c&d)) ]]>x=1y=2]]>x2]]>x2]]>x]]>x]]>▱x]]>∬x dx dy ]]>fxx2+1]]>∑nx ]]>
diff --git a/corpus/math-edit/native-editor-3/read/payloads.json b/corpus/math-edit/native-editor-3/read/payloads.json
new file mode 100644
index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843
--- /dev/null
+++ b/corpus/math-edit/native-editor-3/read/payloads.json
@@ -0,0 +1,3 @@
+[
+
+]
\ No newline at end of file
diff --git a/corpus/math-edit/native-editor-3/run.json b/corpus/math-edit/native-editor-3/run.json
new file mode 100644
index 0000000000000000000000000000000000000000..9e670c7143179c57e2107d7c15c06d0cb8b81a64
--- /dev/null
+++ b/corpus/math-edit/native-editor-3/run.json
@@ -0,0 +1,18 @@
+{
+ "notebook": "/Users/clo/dev/one/corpus/link-edit/candidate",
+ "expected_pages": 1,
+ "author": null,
+ "author_timeout_seconds": 600,
+ "inspect": false,
+ "collect_notebook": true,
+ "base": {
+ "file": "win7-office-base.qcow2",
+ "format": "qcow2",
+ "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
+ "virtual_size": 68719476736
+ },
+ "scripts": {
+ "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
+ "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41"
+ }
+}
diff --git a/corpus/math-edit/native-editor-3/scripts/cold.ps1 b/corpus/math-edit/native-editor-3/scripts/cold.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f
--- /dev/null
+++ b/corpus/math-edit/native-editor-3/scripts/cold.ps1
@@ -0,0 +1,27 @@
+param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
+$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
+if ($CloneHost) {
+ if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
+ throw 'The disposable clone hostname does not match this machine.'
+ }
+ New-Item "$key\Options\Paths" -Force | Out-Null
+ New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
+} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
+ -not (Test-Path 'C:\one-tests\profile-original-cache')) {
+ throw 'Park the personal OneNote profile before resetting the test cache.'
+}
+$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
+$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
+if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
+New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
+if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
+if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
+New-Item "$key\OpenNotebooks" | Out-Null
+New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
diff --git a/corpus/math-edit/native-editor-3/scripts/read.ps1 b/corpus/math-edit/native-editor-3/scripts/read.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..476094ab1e0c47077988a6461db2080cf208642b
--- /dev/null
+++ b/corpus/math-edit/native-editor-3/scripts/read.ps1
@@ -0,0 +1,142 @@
+param(
+ [Parameter(Mandatory=$true)][string]$Root,
+ [int]$ExpectedPages = -1,
+ [switch]$UseCurrentCache,
+ [switch]$Pdf,
+ [switch]$KeepOpen,
+ [string]$CloneHost = ''
+)
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+$notebook = Join-Path $root 'notebook'
+$output = Join-Path $root 'read'
+if (Test-Path $output) { throw 'Choose a new read destination.' }
+if ($UseCurrentCache) {
+ if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
+ throw 'Park the personal OneNote profile before reading test notebooks.'
+ }
+} else {
+ & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
+}
+New-Item -ItemType Directory -Path $output | Out-Null
+$app = New-Object -ComObject OneNote.Application
+$notebookId = ''
+$failure = $null
+try {
+ $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
+ $process = Get-Process ONENOTE
+ @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
+ powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
+ ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
+ $sections = @()
+ foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
+ $id = ''
+ $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
+ $sections += $id
+ }
+ $deadline = [DateTime]::UtcNow.AddSeconds(300)
+ $previous = ''
+ $lastChange = ''
+ $stableSince = [DateTime]::UtcNow
+ $settled = $false
+ do {
+ $pages = @{}
+ foreach ($section in $sections) {
+ $hierarchy = ''
+ $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
+ [xml]$xml = $hierarchy
+ foreach ($node in $xml.SelectNodes('//*[@path]')) {
+ if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
+ throw 'OneNote opened a section outside the copied notebook.'
+ }
+ }
+ foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
+ $id = $node.GetAttribute('ID')
+ $content = ''
+ $app.GetPageContent($id, [ref]$content, 1, 1)
+ $pages[$id] = $content
+ }
+ }
+ $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
+ if ($signature -ne $previous) {
+ $lastChange = $previous
+ $previous = $signature
+ $stableSince = [DateTime]::UtcNow
+ }
+ if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
+ ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
+ ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
+ Start-Sleep -Milliseconds 250
+ } while ([DateTime]::UtcNow -lt $deadline)
+ if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
+ [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
+ $index = 0
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ $index++
+ }
+ $hierarchy = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
+ [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
+ throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
+ }
+ $index = 0
+ $payloads = @()
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ if ($Pdf) {
+ $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
+ $app.NavigateTo($id, '', $false)
+ $app.Publish($id, $pdfPath, 3, '')
+ if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
+ }
+ [xml]$page = $pages[$id]
+ foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
+ $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
+ $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
+ [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
+ $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID');
+ kind = $file.LocalName; name = $file.GetAttribute('preferredName');
+ sha256 = $hash; bytes = $bytes.Length }
+ }
+ $index++
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
+ $all = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
+ [xml]$finalTree = $all
+ $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
+ if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
+ throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
+ Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
+} catch {
+ $failure = $_
+ [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ throw
+} finally {
+ try {
+ try {
+ if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
+ if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
+ $app.CloseNotebook($notebookId, $false)
+ }
+ } catch {
+ if ($null -eq $failure) { throw }
+ [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ }
+ } finally {
+ [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
+ }
+ $app = $null
+ [GC]::Collect()
+ [GC]::WaitForPendingFinalizers()
+ if (-not $UseCurrentCache -and -not $CloneHost) {
+ Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
+ }
+}
diff --git a/corpus/math-edit/native-editor-3/source.json b/corpus/math-edit/native-editor-3/source.json
new file mode 100644
index 0000000000000000000000000000000000000000..14786084a474f1cc0a6f522bd49c8104ad3627f0
--- /dev/null
+++ b/corpus/math-edit/native-editor-3/source.json
@@ -0,0 +1,14 @@
+[
+ {
+ "path": "Open Notebook.onetoc2",
+ "bytes": 1604,
+ "sha256": "6731c4214437fe1cb6aa347b0004542b7f7e4260f47773bf9ec07c4e894de47c",
+ "mtime_ns": 1789206821144296891
+ },
+ {
+ "path": "links.one",
+ "bytes": 6128,
+ "sha256": "0cfb356ed374fe919f73fb73fdbe5d66e0baa484d4119f70074d9f869d6db78a",
+ "mtime_ns": 1789206821116200533
+ }
+]
diff --git a/corpus/math-edit/native-editor-3/teardown.json b/corpus/math-edit/native-editor-3/teardown.json
new file mode 100644
index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70
--- /dev/null
+++ b/corpus/math-edit/native-editor-3/teardown.json
@@ -0,0 +1 @@
+{"absent": true}
diff --git a/crates/onestore/src/document.rs b/crates/onestore/src/document.rs
index 1edbd7d627ce6c6d63bf2045d2659e32b0d27b54..d0590f7da2d8b145c496f728a84329d031f7c6f4 100644
--- a/crates/onestore/src/document.rs
+++ b/crates/onestore/src/document.rs
@@ -136,6 +136,8 @@ format_fields! {
pub struct MathObject {
pub kind: u32,
pub arguments: Option,
+ /// Column count of a matrix or equation array.
+ pub columns: Option,
pub symbols: Vec,
}
@@ -232,6 +234,7 @@ impl Revision<'_> {
})
})
.transpose()?,
+ columns: scalar(0x0c003451).and_then(|b| b.first().copied()),
symbols: [0x10003453, 0x10003454, 0x10003455]
.into_iter()
.filter_map(scalar)
diff --git a/crates/onestore/src/page/math.rs b/crates/onestore/src/page/math.rs
index a822a0b7d7fb75eb776766f9c99c83fa28b2103c..fe596d1829df6f894bd675fab62ecab097f7437c 100644
--- a/crates/onestore/src/page/math.rs
+++ b/crates/onestore/src/page/math.rs
@@ -1,9 +1,9 @@
//! Equations as OneNote stores them: a linear text where U+FDD0 opens an inline object,
//! U+FDEE separates its arguments and U+FDEF closes it, with the object's kind on the run
//! data of the opening character. `Math::parse` builds the tree and `mathml` renders it the
-//! way OneNote's own export does for the kinds native fixtures have verified (superscript,
-//! subscript, both, fraction, brackets, n-ary operators with limits); radicals, under/over
-//! limits and the rest follow the text object model's meaning without a native check.
+//! way OneNote's own export does, verified against native fixtures for every kind the
+//! equation editor produced: scripts, fractions, fences, n-ary operators with any limits,
+//! radicals, limit objects, accents, overbars, boxes, matrices and equation arrays.
use super::text::Paragraph;
use crate::document::MathObject;
@@ -18,6 +18,9 @@ pub enum Math {
Object {
kind: u32,
symbols: Vec,
+ /// Column count of a matrix (arguments are its cells, row by row) or an equation
+ /// array (arguments are its rows).
+ columns: Option,
arguments: Vec>,
},
}
@@ -48,6 +51,7 @@ impl Math {
struct Frame {
kind: u32,
symbols: Vec,
+ columns: Option,
arguments: Vec>,
}
let mut frames: Vec = Vec::new();
@@ -83,6 +87,7 @@ impl Math {
frames.push(Frame {
kind: object.kind,
symbols: object.symbols.clone(),
+ columns: object.columns,
arguments: Vec::new(),
});
sequences.push(Vec::new());
@@ -105,6 +110,7 @@ impl Math {
sequences.last_mut().unwrap().push(Math::Object {
kind: frame.kind,
symbols: frame.symbols,
+ columns: frame.columns,
arguments: frame.arguments,
});
}
@@ -147,6 +153,7 @@ impl Math {
Math::Object {
kind,
symbols,
+ columns,
arguments,
} => {
// A lone letter, number or operator stands bare; anything else is a row.
@@ -176,6 +183,44 @@ impl Math {
out.push_str(&format!(""));
};
match (kind, arguments.len()) {
+ (11, 1) => wrapped(out, "mpadded", &[0]),
+ (12, 1) => {
+ out.push_str("");
+ argument(out, 0);
+ out.push_str("");
+ }
+ // Matrix cells row by row; an equation array's rows carry `&` alignment
+ // marks, exported as an alignment group at the row start and a mark at each.
+ (20, _) | (15, _) => {
+ let width = if *kind == 20 {
+ usize::from(columns.unwrap_or(1).max(1))
+ } else {
+ 1
+ };
+ out.push_str("");
+ for (r, row) in arguments.chunks(width).enumerate() {
+ out.push_str("");
+ for (c, cell) in row.iter().enumerate() {
+ out.push_str("");
+ if *kind == 15 {
+ out.push_str("");
+ for node in cell {
+ match node {
+ Math::Operator('&') => {
+ out.push_str("")
+ }
+ node => node.write(out),
+ }
+ }
+ } else {
+ argument(out, r * width + c);
+ }
+ out.push_str("");
+ }
+ out.push_str("");
+ }
+ out.push_str("");
+ }
(31, 2) => wrapped(out, "msup", &[0, 1]),
(29, 2) => wrapped(out, "msub", &[0, 1]),
(30, 3) => wrapped(out, "msubsup", &[0, 1, 2]),
@@ -220,19 +265,29 @@ impl Math {
out.push_str("");
}
// Lower limit, upper limit, body; integrals take their limits as scripts,
- // other operators above and below.
+ // other operators above and below, and an empty limit leaves its side out.
(21, 3) => {
let operator = symbols.first().copied().unwrap_or('∑');
- let element = if ('\u{222b}'..='\u{2233}').contains(&operator) {
- "msubsup"
- } else {
- "munderover"
+ let scripts = ('\u{222b}'..='\u{2233}').contains(&operator);
+ let (lower, upper) = (!arguments[0].is_empty(), !arguments[1].is_empty());
+ let element = match (lower, upper, scripts) {
+ (true, true, true) => "msubsup",
+ (true, true, false) => "munderover",
+ (true, false, true) => "msub",
+ (true, false, false) => "munder",
+ (false, true, true) => "msup",
+ (false, true, false) => "mover",
+ (false, false, _) => "mrow",
};
out.push_str(&format!(
"{operator}"
));
- argument(out, 0);
- argument(out, 1);
+ if lower {
+ argument(out, 0);
+ }
+ if upper {
+ argument(out, 1);
+ }
out.push_str(&format!(""));
out.push_str("");
for node in &arguments[2] {
@@ -320,6 +375,7 @@ impl Math {
format.math_object = Some(object.unwrap_or(MathObject {
kind: PLAIN_RUN,
arguments: None,
+ columns: None,
symbols: Vec::new(),
}));
(text, format)
@@ -341,6 +397,7 @@ fn write_sequence(nodes: &[Math], runs: &mut Vec<(String, Option)>)
Math::Object {
kind,
symbols,
+ columns,
arguments,
} => {
if !leaf.is_empty() {
@@ -351,6 +408,7 @@ fn write_sequence(nodes: &[Math], runs: &mut Vec<(String, Option)>)
Some(MathObject {
kind: *kind,
arguments: Some(arguments.len() as u32),
+ columns: *columns,
symbols: symbols.clone(),
}),
));
@@ -365,6 +423,7 @@ fn write_sequence(nodes: &[Math], runs: &mut Vec<(String, Option)>)
let object = MathObject {
kind: *kind,
arguments: (index > 0).then_some(index as u32),
+ columns: None,
symbols: Vec::new(),
};
let extended = runs.len() > before;
diff --git a/crates/onestore/src/page/write.rs b/crates/onestore/src/page/write.rs
index 8c2285ac616c3957dbf7d5a44981a2e8c51f70b2..8cb3217bf672b5da8e43c65ec37a6a45bb12d9b5 100644
--- a/crates/onestore/src/page/write.rs
+++ b/crates/onestore/src/page/write.rs
@@ -1770,6 +1770,9 @@ impl Lowering<'_> {
if let Some(count) = object.arguments {
set.push((0x14003450, count.to_le_bytes().to_vec()));
}
+ if let Some(columns) = object.columns {
+ set.push((0x0c003451, vec![columns]));
+ }
for (id, symbol) in [0x10003453, 0x10003454, 0x10003455]
.into_iter()
.zip(&object.symbols)
diff --git a/crates/onestore/tests/page_math.rs b/crates/onestore/tests/page_math.rs
index 45e4b4c35ce028c7eb5a84f1abb3229c04fb1ba6..be3249abf1cbe63c07a12e33ec5229c14dd8ebc0 100644
--- a/crates/onestore/tests/page_math.rs
+++ b/crates/onestore/tests/page_math.rs
@@ -18,6 +18,12 @@ const EDITOR_2: &[u8] =
include_bytes!("../../../corpus/math-edit/native-editor-2/notebook/links.one");
const EDITOR_2_READ: &str =
include_str!("../../../corpus/math-edit/native-editor-2/read/page-000.xml");
+/// A third session: a matrix, an equation array, upper and lower limit objects, boxes, a
+/// nested fraction and a sum with only an upper limit.
+const EDITOR_3: &[u8] =
+ include_bytes!("../../../corpus/math-edit/native-editor-3/notebook/links.one");
+const EDITOR_3_READ: &str =
+ include_str!("../../../corpus/math-edit/native-editor-3/read/page-000.xml");
fn native_mathml(read: &str) -> Vec {
read.split(">) -> Math {
Math::Object {
kind,
symbols: symbols.chars().collect(),
+ columns: None,
arguments,
}
}
diff --git a/tools/test_math_edit.py b/tools/test_math_edit.py
index 83227267fa7834792960f48dc92b32a35d597c97..63bd9370a4884f79ceb5adf5cdd1a38516c5fac2 100644
--- a/tools/test_math_edit.py
+++ b/tools/test_math_edit.py
@@ -23,6 +23,7 @@ class MathEditTest(unittest.TestCase):
def test_equation_editor_expressions_export_as_mathml(self):
self.assertEqual(len(exported_mathml(FIXTURE / 'native-editor/read/page-000.xml')), 5)
self.assertEqual(len(exported_mathml(FIXTURE / 'native-editor-2/read/page-000.xml')), 9)
+ self.assertEqual(len(exported_mathml(FIXTURE / 'native-editor-3/read/page-000.xml')), 10)
def test_rust_written_equations_export_the_expected_mathml(self):
with TemporaryDirectory() as temporary: