diff --git a/corpus/ink-edit/README.md b/corpus/ink-edit/README.md
index 6c197490dcf039a5f94109e14922dd906bd360c8..3019ee1db0ab1a6f071cff4d43513f04c05f9f20 100644
--- a/corpus/ink-edit/README.md
+++ b/corpus/ink-edit/README.md
@@ -24,3 +24,9 @@ outline grown to contain the strokes' absolute page coordinates.
directories while running the tests, then cold-open each with
`tools/native_runner.py OUTPUT COLD --expected-pages 1 --collect-notebook
--screenshots`.
+
+`native-mouse-over-text/` is OneNote 2010 receiving mouse strokes over an
+outline's text and in empty space (`tools/native_handwriting.py`): both
+became page-level `InkDrawing` objects. Mouse ink never produces
+handwriting paragraphs or embedded ink in text runs, so those forms stay
+read-only retained content until a pen-authored fixture exists.
diff --git a/corpus/ink-edit/native-mouse-over-text/before-read/environment.json b/corpus/ink-edit/native-mouse-over-text/before-read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..48170eb10e7c124bec03bbefa92489448abc1c7b
--- /dev/null
+++ b/corpus/ink-edit/native-mouse-over-text/before-read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-9C262230",
+ "cold": true,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/ink-edit/native-mouse-over-text/before-read/hierarchy.xml b/corpus/ink-edit/native-mouse-over-text/before-read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..aafe6fe16b418054fefe67554bd25b4c3a8c2696
--- /dev/null
+++ b/corpus/ink-edit/native-mouse-over-text/before-read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/ink-edit/native-mouse-over-text/before-read/page-000.xml b/corpus/ink-edit/native-mouse-over-text/before-read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..64309b8beab4fba6f61ff424a57c1ff003946095
--- /dev/null
+++ b/corpus/ink-edit/native-mouse-over-text/before-read/page-000.xml
@@ -0,0 +1,3 @@
+
+the Rust site]]>
diff --git a/corpus/ink-edit/native-mouse-over-text/before-read/payloads.json b/corpus/ink-edit/native-mouse-over-text/before-read/payloads.json
new file mode 100644
index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843
--- /dev/null
+++ b/corpus/ink-edit/native-mouse-over-text/before-read/payloads.json
@@ -0,0 +1,3 @@
+[
+
+]
\ No newline at end of file
diff --git a/corpus/ink-edit/native-mouse-over-text/commands.jsonl b/corpus/ink-edit/native-mouse-over-text/commands.jsonl
new file mode 100644
index 0000000000000000000000000000000000000000..5396301c658933b761dcd5c744950040598a1a2b
--- /dev/null
+++ b/corpus/ink-edit/native-mouse-over-text/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-9C262230 -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-9C262230 -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/ink-edit/native-mouse-over-text/handwriting.ahk b/corpus/ink-edit/native-mouse-over-text/handwriting.ahk
new file mode 100644
index 0000000000000000000000000000000000000000..d9eed5cc7ae66da901ae822d1d4a93a9ad4c1be2
--- /dev/null
+++ b/corpus/ink-edit/native-mouse-over-text/handwriting.ahk
@@ -0,0 +1,34 @@
+ComObject("OneNote.Application").NavigateTo("{B2A882BE-DBA1-0E8F-3F13-C2840E6C5065}{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)
+SendMode("Event")
+CoordMode("Mouse", "Screen")
+Click(258,36)
+Sleep(600)
+Click(205,69)
+Sleep(600)
+; handwriting inside the outline, below its text line
+MouseMove(140,160)
+Click("Down")
+MouseMove(160,175,25)
+MouseMove(180,160,25)
+MouseMove(200,175,25)
+MouseMove(220,160,25)
+Click("Up")
+Sleep(300)
+MouseClickDrag("Left",240,158,300,178,25)
+Sleep(300)
+; a drawing in empty page space
+MouseMove(450,320)
+Click("Down")
+MouseMove(500,360,25)
+MouseMove(450,400,25)
+MouseMove(400,360,25)
+MouseMove(450,320,25)
+Click("Up")
+Sleep(1000)
+Click(21,74)
+Sleep(2000)
diff --git a/corpus/ink-edit/native-mouse-over-text/handwriting.json b/corpus/ink-edit/native-mouse-over-text/handwriting.json
new file mode 100644
index 0000000000000000000000000000000000000000..83fb4df3362bb2ac7e9fa657fe6db76614b54e31
--- /dev/null
+++ b/corpus/ink-edit/native-mouse-over-text/handwriting.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/ink-edit/native-mouse-over-text/handwriting.png b/corpus/ink-edit/native-mouse-over-text/handwriting.png
new file mode 100644
index 0000000000000000000000000000000000000000..70de49a8fabc7dc476321f80881a96835b90d3f1
Binary files /dev/null and b/corpus/ink-edit/native-mouse-over-text/handwriting.png differ
diff --git a/corpus/ink-edit/native-mouse-over-text/machine.json b/corpus/ink-edit/native-mouse-over-text/machine.json
new file mode 100644
index 0000000000000000000000000000000000000000..f24fec724c6f926a619cc2619251ac87e48c5c43
--- /dev/null
+++ b/corpus/ink-edit/native-mouse-over-text/machine.json
@@ -0,0 +1 @@
+{"name": "m6-9c262230", "hostname": "ONE-M6-9C262230"}
diff --git a/corpus/ink-edit/native-mouse-over-text/notebook/Open Notebook.onetoc2 b/corpus/ink-edit/native-mouse-over-text/notebook/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..e778e73c6e795c17476d16d2c979a1ef85eedad6
Binary files /dev/null and b/corpus/ink-edit/native-mouse-over-text/notebook/Open Notebook.onetoc2 differ
diff --git a/corpus/ink-edit/native-mouse-over-text/notebook/links.one b/corpus/ink-edit/native-mouse-over-text/notebook/links.one
new file mode 100644
index 0000000000000000000000000000000000000000..a4e70cdeec681787cfac62b97f9d68edc05c8dbb
Binary files /dev/null and b/corpus/ink-edit/native-mouse-over-text/notebook/links.one differ
diff --git a/corpus/ink-edit/native-mouse-over-text/read/environment.json b/corpus/ink-edit/native-mouse-over-text/read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..8a0d4507eddf90ca3c6204a2171411402e8660cc
--- /dev/null
+++ b/corpus/ink-edit/native-mouse-over-text/read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-9C262230",
+ "cold": false,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/ink-edit/native-mouse-over-text/read/hierarchy.xml b/corpus/ink-edit/native-mouse-over-text/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..77f6810a1a91f6557d089eb1644600c7f98c00ce
--- /dev/null
+++ b/corpus/ink-edit/native-mouse-over-text/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/ink-edit/native-mouse-over-text/read/page-000.xml b/corpus/ink-edit/native-mouse-over-text/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..0697afb220185b97a11ad2e68741d3cd0e73de1c
--- /dev/null
+++ b/corpus/ink-edit/native-mouse-over-text/read/page-000.xml
@@ -0,0 +1,22 @@
+
+the Rust site]]>ANoCHQJ6KgFgKgAaH4tCcEWEdYC+0EXZBPqHlTLy7BdBuMAWv/Mr9HreuxOL5V6zSJyboSVat1ES
+v+xxzMwisUGJDrsTLO56oVjPVIrml8VPjwb4utLhmyLEZm2nDbJYSZodTkA0BmwQAwtIFETtuZAB
+RSNGIwUIC2VPZmdoZGkZFDIIAMAMAgAAyEEzCACwCQIAAMhBZAMVRgBACr4BSIfwsXeFcUmUzTWa
+EzmU1mk1gEBmkzgMAgKAQGATWZQCAzOaQGZzSAzOaTWZTOZTNNJrMpmmk1mUzTSazKZppNUymc0m
+qZTOZTOH7f7t/5pAZnNIDM5pAZnNJrMpnMpmmk1mUzTSaplMyaTVMpmTSaplMyaTVMpmmk1AAAAF
+FEYAQAAACQQFFEYAAAAAEgARILeLLBPpguVJo6ME9ct7COwKP0AjYQMhZDLQO6ADFUYAQAo/QCKS
+wgP7+zf4wA==AOMFHQTKAaIBAWAqABofi0JwRYR1gL7QRdkE+oeVMvLsF0G4wBa/8yv0et67E4vlXrNInJuhJVq3
+URK/7HHMzCKxQYkOuxMs7nqhWM9UiuaXxU+PBvi60uGbIsRmbacNslhJmh1OQDQGbBADC0gURO25
+kAFFI0YjBQgLZU9mZ2hkaRkUMggAwAwCAADIQTMIALAJAgAAyEFkAxVGAEAKxQSNAofyDLeQS2Zz
+KAwCZzKazSZzCYwGAQGAQGAQGAQGAQFM5pAZnNIDM5pNZlM5pNZlM5pNUymcymaaTWZTNNJqmUzm
+k1TKZzSaplM7SIBMJnNJrMpnNEBgEBgEBgEBgEBgEBmkzTKawCZTWaTOZTWaTOZTWaJnMprNEzmU
+zmSazRM5lNZomcyms0FpIBAZlM5pNZlMZhAIDAIDAIDAIDAIDAEymsAmU1gEyms0mcyms0mcyms0
+TOZTOZJrNJnMk1miZzKazRM5lNZoLRM5kgMxmU1mkzmU1QCAwCAwCAwCAwCAwCazJM5pAZnNJrMp
+nNJrMpnNJqmUzmk1TKZzKZppNUymc0mqZTOaTVMpmIfw++eH0maTWaTOAwCZTWAQGAQFAIDM5pAY
+BNZlAJrMoBNZlM5lM5pNUymc0mqZTMmk1TKZppNSZTNNJqTKZplM00gM0mcymsAgM0msAgMAgMAg
+MAmsygEBmc0gMzmUAmsymc0msymaaTWZTNNJqTKZppNUymZMpmmk1JlM00mqZYdTSazSAwCazKAQ
+GAIDAIDNJnAYBMprAJlNYBMprNJnMpnMk1mkzmSazQmcyTWaJnMiazRM5kTWaJnMi0ppNZlAIDM5
+ogMAgMAgMAmU1gEBmkzTKawCZTWaTOZTWaJnMprNEzmRNZomcyTWaEzmSZzIms0TOZAFFEYAgAAA
+CQQFFEYAAAAAEgARIHXkv4XbUV9Btx3j0SO9N8YKABEgsI0hZS1D3QEDFUYAQAoAESDcOupAw5Sd
+QA==
diff --git a/corpus/ink-edit/native-mouse-over-text/read/payloads.json b/corpus/ink-edit/native-mouse-over-text/read/payloads.json
new file mode 100644
index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843
--- /dev/null
+++ b/corpus/ink-edit/native-mouse-over-text/read/payloads.json
@@ -0,0 +1,3 @@
+[
+
+]
\ No newline at end of file
diff --git a/corpus/ink-edit/native-mouse-over-text/run.json b/corpus/ink-edit/native-mouse-over-text/run.json
new file mode 100644
index 0000000000000000000000000000000000000000..9e670c7143179c57e2107d7c15c06d0cb8b81a64
--- /dev/null
+++ b/corpus/ink-edit/native-mouse-over-text/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/ink-edit/native-mouse-over-text/scripts/cold.ps1 b/corpus/ink-edit/native-mouse-over-text/scripts/cold.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f
--- /dev/null
+++ b/corpus/ink-edit/native-mouse-over-text/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/ink-edit/native-mouse-over-text/scripts/read.ps1 b/corpus/ink-edit/native-mouse-over-text/scripts/read.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..476094ab1e0c47077988a6461db2080cf208642b
--- /dev/null
+++ b/corpus/ink-edit/native-mouse-over-text/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/ink-edit/native-mouse-over-text/source.json b/corpus/ink-edit/native-mouse-over-text/source.json
new file mode 100644
index 0000000000000000000000000000000000000000..14786084a474f1cc0a6f522bd49c8104ad3627f0
--- /dev/null
+++ b/corpus/ink-edit/native-mouse-over-text/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/ink-edit/native-mouse-over-text/teardown.json b/corpus/ink-edit/native-mouse-over-text/teardown.json
new file mode 100644
index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70
--- /dev/null
+++ b/corpus/ink-edit/native-mouse-over-text/teardown.json
@@ -0,0 +1 @@
+{"absent": true}
diff --git a/corpus/math-edit/README.md b/corpus/math-edit/README.md
index 41c825e97f632849b7de0c268b6da349d45c3a74..304e67a5ab432fa9f62be01d2a078b684b5f3b95 100644
--- a/corpus/math-edit/README.md
+++ b/corpus/math-edit/README.md
@@ -15,6 +15,16 @@ the stored section and compares it with the exported MathML byte for byte;
together with `corpus/m6/native-math-01` (superscripts, a fraction, inline
Greek) that covers the kinds `onestore::page::Math` claims.
+`native-editor-2/` is a second session (`ONESTORE_MATH_EQUATIONS` on
+`tools/native_math.py`): `\sqrt x+1`, `\cbrt(x)`, `\sqrt(n&x)`, `a/b`,
+`\lim_(x\to 0) f(x)` (the `\lim` stayed literal), `\prod_(k=1)^n k`,
+`[a+b]`, `\overline(x)` and `x\hat`; `\sqrt (x+1)` again left an empty
+paragraph. Its MathML adds radicals (25: `msqrt`, or `mroot` with the
+degree second), a lower limit (19: `munder`, with the upright `lim` as one
+`mi` in a row), a product, named fences (`mfenced open="[" close="]"`), an
+overbar (23: `mover accent="false"` with a stretchy bar) and an accent (10:
+`mover accent="true"` with the combining circumflex exported as `^`).
+
`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-2/before-read/environment.json b/corpus/math-edit/native-editor-2/before-read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..42f84b6fc2dbbf352f2d0577245ce2bb557c15f6
--- /dev/null
+++ b/corpus/math-edit/native-editor-2/before-read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-C31DFB94",
+ "cold": true,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/math-edit/native-editor-2/before-read/hierarchy.xml b/corpus/math-edit/native-editor-2/before-read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..a57e93db3ba3980e173f8bfcf23b4a0efb2b28a5
--- /dev/null
+++ b/corpus/math-edit/native-editor-2/before-read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/math-edit/native-editor-2/before-read/page-000.xml b/corpus/math-edit/native-editor-2/before-read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..fa67ad142ef8b71a9ae0cdc02d3bf4cc04039036
--- /dev/null
+++ b/corpus/math-edit/native-editor-2/before-read/page-000.xml
@@ -0,0 +1,3 @@
+
+the Rust site]]>
diff --git a/corpus/math-edit/native-editor-2/before-read/payloads.json b/corpus/math-edit/native-editor-2/before-read/payloads.json
new file mode 100644
index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843
--- /dev/null
+++ b/corpus/math-edit/native-editor-2/before-read/payloads.json
@@ -0,0 +1,3 @@
+[
+
+]
\ No newline at end of file
diff --git a/corpus/math-edit/native-editor-2/commands.jsonl b/corpus/math-edit/native-editor-2/commands.jsonl
new file mode 100644
index 0000000000000000000000000000000000000000..decd2dea6386a77a6eb8a9d3b054398420a80d2a
--- /dev/null
+++ b/corpus/math-edit/native-editor-2/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-C31DFB94 -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-C31DFB94 -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-2/equations.ahk b/corpus/math-edit/native-editor-2/equations.ahk
new file mode 100644
index 0000000000000000000000000000000000000000..1f09c896c0d801ac0b64e3d9b516d0afdc1f07b7
--- /dev/null
+++ b/corpus/math-edit/native-editor-2/equations.ahk
@@ -0,0 +1,104 @@
+ComObject("OneNote.Application").NavigateTo("{2846FEB9-1B89-0608-2064-808478C45E8D}{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("\sqrt x+1")
+Send(" ")
+Sleep(600)
+Send("{End}")
+Sleep(300)
+Send("{Enter}")
+Sleep(600)
+Send("!=")
+Sleep(600)
+SendText("\sqrt (x+1)")
+Send(" ")
+Sleep(600)
+Send("{End}")
+Sleep(300)
+Send("{Enter}")
+Sleep(600)
+Send("!=")
+Sleep(600)
+SendText("\cbrt(x)")
+Send(" ")
+Sleep(600)
+Send("{End}")
+Sleep(300)
+Send("{Enter}")
+Sleep(600)
+Send("!=")
+Sleep(600)
+SendText("\sqrt(n&x)")
+Send(" ")
+Sleep(600)
+Send("{End}")
+Sleep(300)
+Send("{Enter}")
+Sleep(600)
+Send("!=")
+Sleep(600)
+SendText("a/b")
+Send(" ")
+Sleep(600)
+Send("{End}")
+Sleep(300)
+Send("{Enter}")
+Sleep(600)
+Send("!=")
+Sleep(600)
+SendText("\lim_(x\to 0) f(x)")
+Send(" ")
+Sleep(600)
+Send("{End}")
+Sleep(300)
+Send("{Enter}")
+Sleep(600)
+Send("!=")
+Sleep(600)
+SendText("\prod_(k=1)^n k")
+Send(" ")
+Sleep(600)
+Send("{End}")
+Sleep(300)
+Send("{Enter}")
+Sleep(600)
+Send("!=")
+Sleep(600)
+SendText("[a+b]")
+Send(" ")
+Sleep(600)
+Send("{End}")
+Sleep(300)
+Send("{Enter}")
+Sleep(600)
+Send("!=")
+Sleep(600)
+SendText("\overline(x)")
+Send(" ")
+Sleep(600)
+Send("{End}")
+Sleep(300)
+Send("{Enter}")
+Sleep(600)
+Send("!=")
+Sleep(600)
+SendText("x\hat")
+Send(" ")
+Sleep(600)
+Send("{End}")
+Sleep(300)
+Send("{Enter}")
+Sleep(600)
+Sleep(2000)
diff --git a/corpus/math-edit/native-editor-2/equations.json b/corpus/math-edit/native-editor-2/equations.json
new file mode 100644
index 0000000000000000000000000000000000000000..83fb4df3362bb2ac7e9fa657fe6db76614b54e31
--- /dev/null
+++ b/corpus/math-edit/native-editor-2/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-2/equations.png b/corpus/math-edit/native-editor-2/equations.png
new file mode 100644
index 0000000000000000000000000000000000000000..aa4789453f675e82152192a262158c2cd7deea7e
Binary files /dev/null and b/corpus/math-edit/native-editor-2/equations.png differ
diff --git a/corpus/math-edit/native-editor-2/machine.json b/corpus/math-edit/native-editor-2/machine.json
new file mode 100644
index 0000000000000000000000000000000000000000..1d2b98c5232a3003763b9fd19208af32c90473be
--- /dev/null
+++ b/corpus/math-edit/native-editor-2/machine.json
@@ -0,0 +1 @@
+{"name": "m6-c31dfb94", "hostname": "ONE-M6-C31DFB94"}
diff --git a/corpus/math-edit/native-editor-2/notebook/Open Notebook.onetoc2 b/corpus/math-edit/native-editor-2/notebook/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..c23568f599b56d2a22787596ff606c9525c44e9a
Binary files /dev/null and b/corpus/math-edit/native-editor-2/notebook/Open Notebook.onetoc2 differ
diff --git a/corpus/math-edit/native-editor-2/notebook/links.one b/corpus/math-edit/native-editor-2/notebook/links.one
new file mode 100644
index 0000000000000000000000000000000000000000..8366ac8d85d2220aa1c88ec5843b433e587c2b22
Binary files /dev/null and b/corpus/math-edit/native-editor-2/notebook/links.one differ
diff --git a/corpus/math-edit/native-editor-2/read/environment.json b/corpus/math-edit/native-editor-2/read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..d1628c99dec1df6f1adf3df990fd989b57af406b
--- /dev/null
+++ b/corpus/math-edit/native-editor-2/read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-C31DFB94",
+ "cold": false,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/math-edit/native-editor-2/read/hierarchy.xml b/corpus/math-edit/native-editor-2/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..b3b602a571daf7729689daf483a4cfce6ea3f32e
--- /dev/null
+++ b/corpus/math-edit/native-editor-2/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/math-edit/native-editor-2/read/page-000.xml b/corpus/math-edit/native-editor-2/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..e5c9ed12b5b6c41679950cd7d67d6af6c9929c6a
--- /dev/null
+++ b/corpus/math-edit/native-editor-2/read/page-000.xml
@@ -0,0 +1,3 @@
+
+the Rust site]]>x+1 ]]>x3]]>xn]]>ab]]>\limx→0fx]]>∏k=1nk ]]>a+b]]>x¯]]>x^]]>
diff --git a/corpus/math-edit/native-editor-2/read/payloads.json b/corpus/math-edit/native-editor-2/read/payloads.json
new file mode 100644
index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843
--- /dev/null
+++ b/corpus/math-edit/native-editor-2/read/payloads.json
@@ -0,0 +1,3 @@
+[
+
+]
\ No newline at end of file
diff --git a/corpus/math-edit/native-editor-2/run.json b/corpus/math-edit/native-editor-2/run.json
new file mode 100644
index 0000000000000000000000000000000000000000..9e670c7143179c57e2107d7c15c06d0cb8b81a64
--- /dev/null
+++ b/corpus/math-edit/native-editor-2/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-2/scripts/cold.ps1 b/corpus/math-edit/native-editor-2/scripts/cold.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f
--- /dev/null
+++ b/corpus/math-edit/native-editor-2/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-2/scripts/read.ps1 b/corpus/math-edit/native-editor-2/scripts/read.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..476094ab1e0c47077988a6461db2080cf208642b
--- /dev/null
+++ b/corpus/math-edit/native-editor-2/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-2/source.json b/corpus/math-edit/native-editor-2/source.json
new file mode 100644
index 0000000000000000000000000000000000000000..14786084a474f1cc0a6f522bd49c8104ad3627f0
--- /dev/null
+++ b/corpus/math-edit/native-editor-2/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-2/teardown.json b/corpus/math-edit/native-editor-2/teardown.json
new file mode 100644
index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70
--- /dev/null
+++ b/corpus/math-edit/native-editor-2/teardown.json
@@ -0,0 +1 @@
+{"absent": true}
diff --git a/crates/onestore/src/page/math.rs b/crates/onestore/src/page/math.rs
index 7ae2f63d495c769e352107e0daab7da9f9d5f289..a822a0b7d7fb75eb776766f9c99c83fa28b2103c 100644
--- a/crates/onestore/src/page/math.rs
+++ b/crates/onestore/src/page/math.rs
@@ -11,6 +11,8 @@ use crate::document::MathObject;
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum Math {
Identifier(char),
+ /// Upright letters, as the editor stores a function name such as `lim`.
+ Function(String),
Number(String),
Operator(char),
Object {
@@ -51,6 +53,7 @@ impl Math {
let mut frames: Vec = Vec::new();
let mut sequences: Vec> = vec![Vec::new()];
let mut number = String::new();
+ let mut word = String::new();
let mut offset = 0;
for character in paragraph.text().chars() {
let here = offset;
@@ -63,6 +66,14 @@ impl Math {
let number = std::mem::take(&mut number);
sequences.last_mut().unwrap().push(Math::Number(number));
}
+ if character.is_ascii_alphabetic() {
+ word.push(character);
+ continue;
+ }
+ if !word.is_empty() {
+ let word = std::mem::take(&mut word);
+ sequences.last_mut().unwrap().push(Math::Function(word));
+ }
match character {
OBJECT_START => {
// The span lookup treats a boundary offset as the span ending there, so the
@@ -108,6 +119,9 @@ impl Math {
if !number.is_empty() {
sequences.last_mut().unwrap().push(Math::Number(number));
}
+ if !word.is_empty() {
+ sequences.last_mut().unwrap().push(Math::Function(word));
+ }
if !frames.is_empty() {
return Err(invalid("An equation object never ends"));
}
@@ -127,6 +141,7 @@ impl Math {
fn write(&self, out: &mut String) {
match self {
Math::Identifier(c) => tag(out, "mi", &c.to_string()),
+ Math::Function(name) => tag(out, "mi", name),
Math::Number(n) => tag(out, "mn", n),
Math::Operator(c) => tag(out, "mo", &c.to_string()),
Math::Object {
@@ -134,12 +149,16 @@ impl Math {
symbols,
arguments,
} => {
- // A lone identifier, number or operator stands bare; anything else is a row.
+ // A lone letter, number or operator stands bare; anything else is a row.
let argument = |out: &mut String, index: usize| match arguments
.get(index)
.map(Vec::as_slice)
{
- Some([single]) if !matches!(single, Math::Object { .. }) => single.write(out),
+ Some([single])
+ if !matches!(single, Math::Object { .. } | Math::Function(_)) =>
+ {
+ single.write(out)
+ }
Some(many) => {
out.push_str("");
for node in many {
@@ -165,8 +184,41 @@ impl Math {
(25, _) => wrapped(out, "msqrt", &[arguments.len() - 1]),
(19, 2) => wrapped(out, "munder", &[0, 1]),
(33, 2) => wrapped(out, "mover", &[0, 1]),
- // OneNote exports parentheses as default fences, whatever the pair.
- (13, _) => wrapped(out, "mfenced", &[0]),
+ // Parentheses are the default fences; other pairs name themselves.
+ (13, _) => {
+ let (open, close) = (symbols.first(), symbols.get(1));
+ if open == Some(&'(') && close == Some(&')') {
+ wrapped(out, "mfenced", &[0]);
+ } else {
+ out.push_str("');
+ argument(out, 0);
+ out.push_str("");
+ }
+ }
+ (10, 1) | (23, 1) => {
+ let accent = *kind == 10;
+ out.push_str(&format!(""));
+ argument(out, 0);
+ if accent {
+ tag(
+ out,
+ "mo",
+ &spacing(symbols.first().copied().unwrap_or('^')).to_string(),
+ );
+ } else {
+ out.push_str("");
+ out.push(symbols.first().copied().unwrap_or('¯'));
+ out.push_str("");
+ }
+ out.push_str("");
+ }
// Lower limit, upper limit, body; integrals take their limits as scripts,
// other operators above and below.
(21, 3) => {
@@ -204,6 +256,25 @@ impl Math {
}
}
+fn escaped(c: char) -> String {
+ match c {
+ '<' => "<".into(),
+ '&' => "&".into(),
+ '"' => """.into(),
+ c => c.to_string(),
+ }
+}
+
+/// The spacing character OneNote exports for a combining accent.
+fn spacing(c: char) -> char {
+ match c {
+ '\u{302}' => '^',
+ '\u{303}' => '~',
+ '\u{308}' => '¨',
+ c => c,
+ }
+}
+
fn tag(out: &mut String, element: &str, content: &str) {
out.push_str(&format!(""));
for c in content.chars() {
@@ -264,6 +335,7 @@ fn write_sequence(nodes: &[Math], runs: &mut Vec<(String, Option)>)
for node in nodes {
match node {
Math::Identifier(c) => leaf.push(italic(*c)),
+ Math::Function(name) => leaf.push_str(name),
Math::Number(n) => leaf.push_str(n),
Math::Operator(c) => leaf.push(*c),
Math::Object {
diff --git a/crates/onestore/tests/page_math.rs b/crates/onestore/tests/page_math.rs
index c62f12b9b596b23eb78a70f9848833a44647f738..45e4b4c35ce028c7eb5a84f1abb3229c04fb1ba6 100644
--- a/crates/onestore/tests/page_math.rs
+++ b/crates/onestore/tests/page_math.rs
@@ -12,6 +12,12 @@ const NATIVE_READ: &str = include_str!("../../../corpus/m6/native-math-01/read/p
/// subscripts, a sub-superscript, parentheses, an integral and a sum with limits.
const EDITOR: &[u8] = include_bytes!("../../../corpus/math-edit/native-editor/notebook/links.one");
const EDITOR_READ: &str = include_str!("../../../corpus/math-edit/native-editor/read/page-000.xml");
+/// A second editor session: square and n-th roots, a plain fraction, a limit, a product,
+/// square brackets, an overbar and a hat.
+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");
fn native_mathml(read: &str) -> Vec {
read.split(">) -> Math {
diff --git a/tools/native_handwriting.py b/tools/native_handwriting.py
new file mode 100644
index 0000000000000000000000000000000000000000..ce00fa7a585787d65b6ea6f2dfd232968cc4d624
--- /dev/null
+++ b/tools/native_handwriting.py
@@ -0,0 +1,67 @@
+"""Draw ink over an outline's text and in empty space with the mouse in OneNote 2010 on a
+Rust-written page, then capture the COM read: the oracle for handwriting held by paragraphs.
+
+Usage: native_handwriting.py NOTEBOOK_DIR OUTPUT_DIR
+"""
+import base64
+import json
+import sys
+from pathlib import Path
+import xml.etree.ElementTree as ET
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+import native_runner as runner
+
+windows = runner.windows
+
+
+def interaction(name, output):
+ page = ET.parse(sorted((output / 'read').glob('page-*.xml'))[0]).getroot().attrib['ID']
+ script = '''ComObject("OneNote.Application").NavigateTo("PAGE_ID", "", 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)
+SendMode("Event")
+CoordMode("Mouse", "Screen")
+Click(258,36)
+Sleep(600)
+Click(205,69)
+Sleep(600)
+; handwriting inside the outline, below its text line
+MouseMove(140,160)
+Click("Down")
+MouseMove(160,175,25)
+MouseMove(180,160,25)
+MouseMove(200,175,25)
+MouseMove(220,160,25)
+Click("Up")
+Sleep(300)
+MouseClickDrag("Left",240,158,300,178,25)
+Sleep(300)
+; a drawing in empty page space
+MouseMove(450,320)
+Click("Down")
+MouseMove(500,360,25)
+MouseMove(450,400,25)
+MouseMove(400,360,25)
+MouseMove(450,320,25)
+Click("Up")
+Sleep(1000)
+Click(21,74)
+Sleep(2000)
+'''.replace('PAGE_ID', page)
+ (output / 'handwriting.ahk').write_text(script)
+ result = windows.do_exec(script, target=name, timeout_ms=120000, shot_delay_ms=1500)
+ screenshot = result.pop('png_b64', None)
+ if screenshot:
+ (output / 'handwriting.png').write_bytes(base64.b64decode(screenshot))
+ (output / 'handwriting.json').write_text(json.dumps(result, indent=2))
+ if result.get('error') or result.get('exit') != 0:
+ raise RuntimeError(str(result))
+
+
+if __name__ == '__main__':
+ notebook, output = Path(sys.argv[1]), Path(sys.argv[2])
+ runner.capture(notebook, output, expected_pages=1, collect_notebook=True, interaction=interaction)
diff --git a/tools/native_math.py b/tools/native_math.py
index 749c70f1c3b4076e6a41fe9d14fd4769c72424cb..94f381f5ff329df3a0ae1d0d510c93880ac5fb9a 100644
--- a/tools/native_math.py
+++ b/tools/native_math.py
@@ -5,6 +5,7 @@ Usage: native_math.py NOTEBOOK_DIR OUTPUT_DIR
"""
import base64
import json
+import os
import sys
from pathlib import Path
import xml.etree.ElementTree as ET
@@ -23,6 +24,8 @@ EQUATIONS = [
'\\int_0^1 x dx',
'\\sum_(i=1)^n i',
]
+if os.environ.get('ONESTORE_MATH_EQUATIONS'):
+ EQUATIONS = os.environ['ONESTORE_MATH_EQUATIONS'].split('|')
def interaction(name, output):
diff --git a/tools/test_math_edit.py b/tools/test_math_edit.py
index 2d6866d8e785b558777dfb321b72a8d4b4a2ffa4..83227267fa7834792960f48dc92b32a35d597c97 100644
--- a/tools/test_math_edit.py
+++ b/tools/test_math_edit.py
@@ -22,6 +22,7 @@ def exported_mathml(path):
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)
def test_rust_written_equations_export_the_expected_mathml(self):
with TemporaryDirectory() as temporary: