diff --git a/Cargo.lock b/Cargo.lock
index 1c8b8268362b464f9eaa30a2ca0c8c66c5c8f1c4..6e5c6d204ad123c550aef66d2de29e52bfbdb520 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1560,6 +1560,7 @@ dependencies = [
"onestore",
"parley",
"pollster",
+ "serde_json",
"wgpu",
]
@@ -1657,6 +1658,7 @@ dependencies = [
name = "notebook"
version = "0.1.0"
dependencies = [
+ "libc",
"onestore",
"rusqlite",
"serde",
diff --git a/apps/ios/Snowbound/CanvasView.swift b/apps/ios/Snowbound/CanvasView.swift
index ef59eaa3fedca7b425b9d89f0b582df180894a77..75eb32851ea184dd48d19775463e56ca2c51d890 100644
--- a/apps/ios/Snowbound/CanvasView.swift
+++ b/apps/ios/Snowbound/CanvasView.swift
@@ -241,6 +241,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
inputDelegate?.textWillChange(self)
}
let changed = change()
+ dropEdits()
if external {
inputDelegate?.textDidChange(self)
inputDelegate?.selectionDidChange(self)
@@ -251,6 +252,13 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
}
}
+ /// Takes the edit the page recorded, so its ops do not pile up; the host drops it
+ /// until it holds a notebook session that stores edits.
+ private func dropEdits() {
+ guard let handle, let edit = sb_view_edit(handle) else { return }
+ sb_string_free(edit)
+ }
+
private func revealCaret() {
guard let handle, isFirstResponder else { return }
var range: [UInt32] = [0, 0]
@@ -354,6 +362,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
func unmarkText() {
guard let handle else { return }
sb_unmark(handle)
+ dropEdits()
dirty = true
}
diff --git a/corpus/page-date/README.md b/corpus/page-date/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..f571772840d7d92fbbd4f562ef4b17301f37ccb1
--- /dev/null
+++ b/corpus/page-date/README.md
@@ -0,0 +1,19 @@
+# Page date
+
+`native` is OneNote 2010 changing a page's date (`tools/native/page-date.ps1` on
+`picture-edit/native-page-level/notebook`): it creates two pages, "Kept date" and
+"Changed date", each with OneNote's own title, date and time, then gives "Changed date"
+the `dateTime` 2024-03-05T14:30:00Z through the COM API (`before.xml`, `update.xml`,
+`after.xml`; `kept.xml` is the untouched page). In `notebook/`, the revision that change
+stored sets the page metadata's `TopologyCreationTimeStamp` (`0x18001c65`), rewrites the
+date and time fields' text (`TextExtendedAscii`, `0x1c003498`, in the clone's Pacific
+time: "Tuesday, March 05, 2024", "7:30 AM"), and gives the two fields' elements the
+change time as `CreationTimeStamp` (`0x14001d09`) and `LastModifiedTime`, with the
+date outline's `LastModifiedTime`. The title, the page and the section's page metadata
+keep theirs.
+
+`candidate` is `a_page_date_stores_what_onenote_stores` in `crates/onestore/src/op/tests.rs`
+(`ONESTORE_PAGE_DATE_EXPORT`): the `Date` op dates "Kept date" 2025-07-04T16:45:00Z,
+showing "Friday, July 04, 2025" and "9:45 AM", and changes the same objects OneNote did.
+`cold` is its fresh OneNote 2010 read: the page reads back with that `dateTime`, and
+`read/page-001.png` shows the new date and time under the title.
diff --git a/corpus/page-date/candidate/Open Notebook.onetoc2 b/corpus/page-date/candidate/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..a7bcb6b4cf63c11f7984c722d32485a2ba522578
Binary files /dev/null and b/corpus/page-date/candidate/Open Notebook.onetoc2 differ
diff --git a/corpus/page-date/candidate/pictures.one b/corpus/page-date/candidate/pictures.one
new file mode 100644
index 0000000000000000000000000000000000000000..ffd10d36c6059003feeb168663d492f3cf08bcf4
Binary files /dev/null and b/corpus/page-date/candidate/pictures.one differ
diff --git a/corpus/page-date/cold/commands.jsonl b/corpus/page-date/cold/commands.jsonl
new file mode 100644
index 0000000000000000000000000000000000000000..8edc2a9242976b1dc7c1f46f6d488d2f820133d5
--- /dev/null
+++ b/corpus/page-date/cold/commands.jsonl
@@ -0,0 +1,3 @@
+{"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-814B0AC0 -ExpectedPages 3 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 3 pages.\r\n", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\read -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
diff --git a/corpus/page-date/cold/machine.json b/corpus/page-date/cold/machine.json
new file mode 100644
index 0000000000000000000000000000000000000000..b31dd420258d510a0f12aedd1ddfa0236e4140b8
--- /dev/null
+++ b/corpus/page-date/cold/machine.json
@@ -0,0 +1 @@
+{"name": "m6-814b0ac0", "hostname": "ONE-M6-814B0AC0"}
diff --git a/corpus/page-date/cold/notebook/Open Notebook.onetoc2 b/corpus/page-date/cold/notebook/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..a7bcb6b4cf63c11f7984c722d32485a2ba522578
Binary files /dev/null and b/corpus/page-date/cold/notebook/Open Notebook.onetoc2 differ
diff --git a/corpus/page-date/cold/notebook/pictures.one b/corpus/page-date/cold/notebook/pictures.one
new file mode 100644
index 0000000000000000000000000000000000000000..ffd10d36c6059003feeb168663d492f3cf08bcf4
Binary files /dev/null and b/corpus/page-date/cold/notebook/pictures.one differ
diff --git a/corpus/page-date/cold/read/environment.json b/corpus/page-date/cold/read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..443e6dd40b2bd8c1bee5891e6a51ca8e38570fe0
--- /dev/null
+++ b/corpus/page-date/cold/read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-814B0AC0",
+ "cold": true,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/page-date/cold/read/hierarchy.xml b/corpus/page-date/cold/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..2e3a8cfaf29dce9fcb86fef34985dfc88b8724de
--- /dev/null
+++ b/corpus/page-date/cold/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/page-date/cold/read/page-000.navigation.json b/corpus/page-date/cold/read/page-000.navigation.json
new file mode 100644
index 0000000000000000000000000000000000000000..b9d4d9124849ecffacac7e0b640250c408a9afcd
--- /dev/null
+++ b/corpus/page-date/cold/read/page-000.navigation.json
@@ -0,0 +1,13 @@
+{
+ "exit": 0,
+ "stdout": "",
+ "stderr": "",
+ "w": 800,
+ "h": 600,
+ "error": null,
+ "win": {
+ "title": "Changed date - Microsoft OneNote",
+ "class": "Framework::CFrame",
+ "dialog": false
+ }
+}
\ No newline at end of file
diff --git a/corpus/page-date/cold/read/page-000.png b/corpus/page-date/cold/read/page-000.png
new file mode 100644
index 0000000000000000000000000000000000000000..3ebdf650758fc364e2b5a5f916019149b77afec1
Binary files /dev/null and b/corpus/page-date/cold/read/page-000.png differ
diff --git a/corpus/page-date/cold/read/page-000.xml b/corpus/page-date/cold/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..79bff96685cbc3d2a0e7e9904bd6124fc106fae4
--- /dev/null
+++ b/corpus/page-date/cold/read/page-000.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/page-date/cold/read/page-001.navigation.json b/corpus/page-date/cold/read/page-001.navigation.json
new file mode 100644
index 0000000000000000000000000000000000000000..7337ad212809f6ef8ffa0a1c57c120a2574507da
--- /dev/null
+++ b/corpus/page-date/cold/read/page-001.navigation.json
@@ -0,0 +1,13 @@
+{
+ "exit": 0,
+ "stdout": "",
+ "stderr": "",
+ "w": 800,
+ "h": 600,
+ "error": null,
+ "win": {
+ "title": "Kept date - Microsoft OneNote",
+ "class": "Framework::CFrame",
+ "dialog": false
+ }
+}
\ No newline at end of file
diff --git a/corpus/page-date/cold/read/page-001.png b/corpus/page-date/cold/read/page-001.png
new file mode 100644
index 0000000000000000000000000000000000000000..8f1a1560e286fc7970be279a4d06c4f7a7ebe267
Binary files /dev/null and b/corpus/page-date/cold/read/page-001.png differ
diff --git a/corpus/page-date/cold/read/page-001.xml b/corpus/page-date/cold/read/page-001.xml
new file mode 100644
index 0000000000000000000000000000000000000000..ee7e861468376d19b58f7c4aea5909db67c50ca9
--- /dev/null
+++ b/corpus/page-date/cold/read/page-001.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/page-date/cold/read/page-002.navigation.json b/corpus/page-date/cold/read/page-002.navigation.json
new file mode 100644
index 0000000000000000000000000000000000000000..b65a0c422e83efcb89dd26be239dd1906202f810
--- /dev/null
+++ b/corpus/page-date/cold/read/page-002.navigation.json
@@ -0,0 +1,13 @@
+{
+ "exit": 0,
+ "stdout": "",
+ "stderr": "",
+ "w": 800,
+ "h": 600,
+ "error": null,
+ "win": {
+ "title": "Before the picture - Microsoft OneNote",
+ "class": "Framework::CFrame",
+ "dialog": false
+ }
+}
\ No newline at end of file
diff --git a/corpus/page-date/cold/read/page-002.png b/corpus/page-date/cold/read/page-002.png
new file mode 100644
index 0000000000000000000000000000000000000000..148935b7ed8d1c1ee988154c7dfb5f3c730f7d6c
Binary files /dev/null and b/corpus/page-date/cold/read/page-002.png differ
diff --git a/corpus/page-date/cold/read/page-002.xml b/corpus/page-date/cold/read/page-002.xml
new file mode 100644
index 0000000000000000000000000000000000000000..23d00b0f6993e7bdb6f1de14576f8b9e7cb9f134
--- /dev/null
+++ b/corpus/page-date/cold/read/page-002.xml
@@ -0,0 +1,6 @@
+
+iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4z8AAAAMBAQAY3Y2w
+AAAAAElFTkSuQmCC
+iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4z8AAAAMBAQAY3Y2w
+AAAAAElFTkSuQmCC
+
diff --git a/corpus/page-date/cold/read/payloads.json b/corpus/page-date/cold/read/payloads.json
new file mode 100644
index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843
--- /dev/null
+++ b/corpus/page-date/cold/read/payloads.json
@@ -0,0 +1,3 @@
+[
+
+]
\ No newline at end of file
diff --git a/corpus/page-date/cold/run.json b/corpus/page-date/cold/run.json
new file mode 100644
index 0000000000000000000000000000000000000000..9e148fa431dd55ae9c8edd5133b0e12554ec74a1
--- /dev/null
+++ b/corpus/page-date/cold/run.json
@@ -0,0 +1,18 @@
+{
+ "notebook": "/private/tmp/pagedate/candidate/notebook",
+ "expected_pages": 3,
+ "author": null,
+ "author_timeout_seconds": 600,
+ "inspect": false,
+ "collect_notebook": false,
+ "base": {
+ "file": "win7-office-base.qcow2",
+ "format": "qcow2",
+ "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
+ "virtual_size": 68719476736
+ },
+ "scripts": {
+ "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
+ "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41"
+ }
+}
diff --git a/corpus/page-date/cold/scripts/cold.ps1 b/corpus/page-date/cold/scripts/cold.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f
--- /dev/null
+++ b/corpus/page-date/cold/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/page-date/cold/scripts/read.ps1 b/corpus/page-date/cold/scripts/read.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..476094ab1e0c47077988a6461db2080cf208642b
--- /dev/null
+++ b/corpus/page-date/cold/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/page-date/cold/source.json b/corpus/page-date/cold/source.json
new file mode 100644
index 0000000000000000000000000000000000000000..362e409c5d0b3ad88042c418479bd136cfefaec6
--- /dev/null
+++ b/corpus/page-date/cold/source.json
@@ -0,0 +1,14 @@
+[
+ {
+ "path": "Open Notebook.onetoc2",
+ "bytes": 3764,
+ "sha256": "8bf472d0a9c5829b049c69b817f40b766025431a61c71689bca583a60d28365d",
+ "mtime_ns": 1790452811018151759
+ },
+ {
+ "path": "pictures.one",
+ "bytes": 24184,
+ "sha256": "0fea376be55a62c76dd61e311513b41a4212d671a9316eb8d18897a5baa5a125",
+ "mtime_ns": 1790453075875161561
+ }
+]
diff --git a/corpus/page-date/cold/teardown.json b/corpus/page-date/cold/teardown.json
new file mode 100644
index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70
--- /dev/null
+++ b/corpus/page-date/cold/teardown.json
@@ -0,0 +1 @@
+{"absent": true}
diff --git a/corpus/page-date/native/after.xml b/corpus/page-date/native/after.xml
new file mode 100644
index 0000000000000000000000000000000000000000..8268312536edeccf9d167357fb577761f5be4bd0
--- /dev/null
+++ b/corpus/page-date/native/after.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/page-date/native/before.xml b/corpus/page-date/native/before.xml
new file mode 100644
index 0000000000000000000000000000000000000000..3bd9a83fe09ff593da13c5f2b154817263b5e593
--- /dev/null
+++ b/corpus/page-date/native/before.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/page-date/native/commands.jsonl b/corpus/page-date/native/commands.jsonl
new file mode 100644
index 0000000000000000000000000000000000000000..9be10279c7fff972b889d4664b0e049a1bed9863
--- /dev/null
+++ b/corpus/page-date/native/commands.jsonl
@@ -0,0 +1,4 @@
+{"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\\author.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-CE0C154F", "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-CE0C154F -ExpectedPages 3 -UseCurrentCache", "exit": 0, "stdout": "Read 1 sections and 3 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/page-date/native/kept.xml b/corpus/page-date/native/kept.xml
new file mode 100644
index 0000000000000000000000000000000000000000..d8f81f6e11887924b4a0506d51269c4ff8539a0e
--- /dev/null
+++ b/corpus/page-date/native/kept.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/page-date/native/machine.json b/corpus/page-date/native/machine.json
new file mode 100644
index 0000000000000000000000000000000000000000..9a4e440a3afaae97d40590a5d8b7e6690d5e8953
--- /dev/null
+++ b/corpus/page-date/native/machine.json
@@ -0,0 +1 @@
+{"name": "m6-ce0c154f", "hostname": "ONE-M6-CE0C154F"}
diff --git a/corpus/page-date/native/notebook/Open Notebook.onetoc2 b/corpus/page-date/native/notebook/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..a7bcb6b4cf63c11f7984c722d32485a2ba522578
Binary files /dev/null and b/corpus/page-date/native/notebook/Open Notebook.onetoc2 differ
diff --git a/corpus/page-date/native/notebook/pictures.one b/corpus/page-date/native/notebook/pictures.one
new file mode 100644
index 0000000000000000000000000000000000000000..afa9af53962f787548e1b29e3e3f12075df9f5c2
Binary files /dev/null and b/corpus/page-date/native/notebook/pictures.one differ
diff --git a/corpus/page-date/native/read/environment.json b/corpus/page-date/native/read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..3decb628b4205e11247b739b79595a80660a1980
--- /dev/null
+++ b/corpus/page-date/native/read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-CE0C154F",
+ "cold": false,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/page-date/native/read/hierarchy.xml b/corpus/page-date/native/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..f5aba1f698660d2972e1b09d90c385483ce7c475
--- /dev/null
+++ b/corpus/page-date/native/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/page-date/native/read/page-000.xml b/corpus/page-date/native/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..5556b84c2dfb85dae83861ce2d88dc220a38401c
--- /dev/null
+++ b/corpus/page-date/native/read/page-000.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/page-date/native/read/page-001.xml b/corpus/page-date/native/read/page-001.xml
new file mode 100644
index 0000000000000000000000000000000000000000..f58c14ea32d3a52a94abdea710da3495d416c3bf
--- /dev/null
+++ b/corpus/page-date/native/read/page-001.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/page-date/native/read/page-002.xml b/corpus/page-date/native/read/page-002.xml
new file mode 100644
index 0000000000000000000000000000000000000000..22b9a3272304419443e0ab40794ce4a70f74c776
--- /dev/null
+++ b/corpus/page-date/native/read/page-002.xml
@@ -0,0 +1,6 @@
+
+iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4z8AAAAMBAQAY3Y2w
+AAAAAElFTkSuQmCC
+iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4z8AAAAMBAQAY3Y2w
+AAAAAElFTkSuQmCC
+
diff --git a/corpus/page-date/native/read/payloads.json b/corpus/page-date/native/read/payloads.json
new file mode 100644
index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843
--- /dev/null
+++ b/corpus/page-date/native/read/payloads.json
@@ -0,0 +1,3 @@
+[
+
+]
\ No newline at end of file
diff --git a/corpus/page-date/native/run.json b/corpus/page-date/native/run.json
new file mode 100644
index 0000000000000000000000000000000000000000..9d8d8c78273d3bff01aabb508a50b161f1f1587b
--- /dev/null
+++ b/corpus/page-date/native/run.json
@@ -0,0 +1,19 @@
+{
+ "notebook": "/Users/clo/dev/one/corpus/picture-edit/native-page-level/notebook",
+ "expected_pages": 3,
+ "author": "tools/native/page-date.ps1",
+ "author_timeout_seconds": 600,
+ "inspect": false,
+ "collect_notebook": true,
+ "base": {
+ "file": "win7-office-base.qcow2",
+ "format": "qcow2",
+ "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
+ "virtual_size": 68719476736
+ },
+ "scripts": {
+ "author.ps1": "f7a55c35305dc63b41efdff5bba9a5c0eef621948727f7630c2ebda3248c3490",
+ "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
+ "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41"
+ }
+}
diff --git a/corpus/page-date/native/scripts/author.ps1 b/corpus/page-date/native/scripts/author.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..4003849226b50011740d4a45c123ff734d64f7ce
--- /dev/null
+++ b/corpus/page-date/native/scripts/author.ps1
@@ -0,0 +1,46 @@
+param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost)
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+& "$PSScriptRoot\cold-current.ps1" -Root $Root -CloneHost $CloneHost
+$namespace = 'http://schemas.microsoft.com/office/onenote/2010/onenote'
+$app = New-Object -ComObject OneNote.Application
+$notebookId = ''
+function Save-Page([string]$pageId, [string]$name) {
+ $content = ''
+ $app.GetPageContent($pageId, [ref]$content, 0, 1)
+ [IO.File]::WriteAllText((Join-Path $Root $name), $content, [Text.Encoding]::UTF8)
+}
+try {
+ $app.OpenHierarchy((Join-Path $Root 'notebook'), '', [ref]$notebookId, 0)
+ $sectionId = ''
+ $app.OpenHierarchy('pictures.one', $notebookId, [ref]$sectionId, 0)
+ # Two new pages with OneNote's own title, date and time: one keeps its date, the
+ # other has its date and time changed after it was saved once.
+ $ids = @()
+ foreach ($title in @('Kept date', 'Changed date')) {
+ $pageId = ''
+ $app.CreateNewPage($sectionId, [ref]$pageId, 0)
+ $escaped = [Security.SecurityElement]::Escape($pageId)
+ $xml = "$titleBody of $title"
+ $app.UpdatePageContent($xml, [DateTime]::MinValue, 1, $false)
+ $ids += $pageId
+ }
+ $app.SyncHierarchy($notebookId)
+ Start-Sleep -Seconds 3
+ Save-Page $ids[1] 'before.xml'
+ $escaped = [Security.SecurityElement]::Escape($ids[1])
+ $update = ""
+ [IO.File]::WriteAllText((Join-Path $Root 'update.xml'), $update, [Text.Encoding]::UTF8)
+ $app.UpdatePageContent($update, [DateTime]::MinValue, 1, $false)
+ $app.SyncHierarchy($notebookId)
+ Start-Sleep -Seconds 3
+ Save-Page $ids[1] 'after.xml'
+ Save-Page $ids[0] 'kept.xml'
+ $app.CloseNotebook($notebookId, $false)
+ $notebookId = ''
+} finally {
+ if ($notebookId) { $app.CloseNotebook($notebookId, $false) }
+ [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
+ [GC]::Collect()
+ [GC]::WaitForPendingFinalizers()
+}
diff --git a/corpus/page-date/native/scripts/cold.ps1 b/corpus/page-date/native/scripts/cold.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f
--- /dev/null
+++ b/corpus/page-date/native/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/page-date/native/scripts/read.ps1 b/corpus/page-date/native/scripts/read.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..476094ab1e0c47077988a6461db2080cf208642b
--- /dev/null
+++ b/corpus/page-date/native/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/page-date/native/source.json b/corpus/page-date/native/source.json
new file mode 100644
index 0000000000000000000000000000000000000000..eb9b3087f7a5fd4919a32627e71c4a8b8ee191cd
--- /dev/null
+++ b/corpus/page-date/native/source.json
@@ -0,0 +1,14 @@
+[
+ {
+ "path": "Open Notebook.onetoc2",
+ "bytes": 3764,
+ "sha256": "8bf472d0a9c5829b049c69b817f40b766025431a61c71689bca583a60d28365d",
+ "mtime_ns": 1789249099590321649
+ },
+ {
+ "path": "pictures.one",
+ "bytes": 17504,
+ "sha256": "ea585992f7c450e426e0b98d8d0ce972ecdaf7602df962c938166d3d1b54c820",
+ "mtime_ns": 1789249099590147690
+ }
+]
diff --git a/corpus/page-date/native/teardown.json b/corpus/page-date/native/teardown.json
new file mode 100644
index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70
--- /dev/null
+++ b/corpus/page-date/native/teardown.json
@@ -0,0 +1 @@
+{"absent": true}
diff --git a/corpus/page-date/native/update.xml b/corpus/page-date/native/update.xml
new file mode 100644
index 0000000000000000000000000000000000000000..6d64b05e7b1c6bf275d56190109235fb439a291c
--- /dev/null
+++ b/corpus/page-date/native/update.xml
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/crates/canvas/src/editor.rs b/crates/canvas/src/editor.rs
index dc9e1c2458938d2b05f6bc1b109ab5b7f0d23eee..46fbe3fbfbfca8c9a725832dffe20881fa980c71 100644
--- a/crates/canvas/src/editor.rs
+++ b/crates/canvas/src/editor.rs
@@ -7,6 +7,7 @@ use crate::{
use draw::edit::{self, Movement, SelectionUnit};
use onestore::ExGuid;
use onestore::document::{Format, Kind};
+use onestore::op::PageOp;
use onestore::page::text::{EditError, Paragraph};
use onestore::page::{
Definition, Outline, Page, PageObject, PageParagraph, ParagraphContent, Title,
@@ -26,6 +27,7 @@ pub const DEFAULT_OUTLINE_WIDTH: f32 = 468.0;
#[cfg(test)]
mod evidence;
mod format;
+mod ops;
pub(crate) mod page;
mod table;
pub use format::{Alignment, FormatState, Formatting, NoteTag, Toggle};
@@ -92,6 +94,12 @@ pub struct CanvasEditor {
preferred_x: Option,
/// Formatting chosen at a caret for the text typed there next, until an edit.
pending: Option<(ExGuid, TextPosition, onestore::document::Format)>,
+ /// What `take_ops` hands over next.
+ ops: Result, onestore::Error>,
+ /// The page as stored when the editor last read it and the ops `take_ops` handed out
+ /// since, which `refresh` compares a changed stored page with; none once the editor
+ /// holds an edit that could not be stored.
+ stored: Option<(Page, Vec)>,
}
/// Imported page state the editable content does not carry.
@@ -660,10 +668,13 @@ impl CanvasEditor {
composition: None,
preferred_x: None,
pending: None,
+ ops: Ok(Vec::new()),
+ stored: None,
})
}
pub fn from_page(mut page: Page, engine: &mut TextEngine) -> Result {
+ let stored = page.clone();
let page::Import {
objects,
mut outlines,
@@ -706,9 +717,175 @@ impl CanvasEditor {
if !editor.object_layouts().all(|(id, _)| ids.insert(id)) {
return Err(EditError::InvalidStructure.into());
}
+ editor.stored = Some((stored, Vec::new()));
Ok(editor)
}
+ /// Shows `page`, the stored page after a change made elsewhere. What the change did not
+ /// reach stays as it is, history included: the page as stored before it (as last read,
+ /// with the ops handed out since) is compared with `page`; outlines the change reached
+ /// show anew with the caret and selection kept by paragraph identity, and history entries
+ /// editing them are dropped. False when the change reached nothing shown. Marked text
+ /// must be committed or cancelled first.
+ pub fn refresh(&mut self, page: Page, engine: &mut TextEngine) -> Result {
+ let known = self.stored.take().and_then(|(mut stored, sent)| {
+ sent.iter()
+ .try_for_each(|op| onestore::op::predict(&mut stored, op))
+ .ok()
+ .map(|()| stored)
+ });
+ // Without that, the editor's page stands in, which storage may normalize apart.
+ let known = match known {
+ Some(known) => known,
+ None => self.page()?,
+ };
+ self.stored = Some((page.clone(), Vec::new()));
+ if known == page {
+ return Ok(false);
+ }
+ fn outlines(page: &Page) -> BTreeMap {
+ page.objects
+ .iter()
+ .flat_map(|object| match object {
+ PageObject::Outline(outline) => std::slice::from_ref(outline),
+ PageObject::Title(title) => title.outlines.as_slice(),
+ _ => &[],
+ })
+ .map(|outline| (outline.id, outline))
+ .collect()
+ }
+ let (before, after) = (outlines(&known), outlines(&page));
+ let changed: BTreeSet = before
+ .keys()
+ .chain(after.keys())
+ .filter(|id| before.get(id) != after.get(id))
+ .copied()
+ .collect();
+ // Everything but outline content: the page's objects, geometry and date.
+ let frame = |page: &Page| {
+ let hollow = |outline: &Outline| Outline {
+ id: outline.id,
+ title: false,
+ min_width: None,
+ layout: Default::default(),
+ indents: Vec::new(),
+ paragraphs: Vec::new(),
+ unsupported: Vec::new(),
+ };
+ let objects: Vec = page
+ .objects
+ .iter()
+ .map(|object| match object {
+ PageObject::Outline(outline) => PageObject::Outline(hollow(outline)),
+ PageObject::Title(title) => PageObject::Title(Title {
+ id: title.id,
+ date: title.date,
+ layout: title.layout.clone(),
+ outlines: title.outlines.iter().map(hollow).collect(),
+ }),
+ object => object.clone(),
+ })
+ .collect();
+ (page.created, page.margin_origin, objects)
+ };
+ let objects_changed = frame(&known) != frame(&page);
+ let date_changed = known.created != page.created
+ || self
+ .date
+ .as_ref()
+ .is_some_and(|date| changed.contains(&date.source().id));
+ let mut fresh = Self::from_page(page, engine)?;
+ let shown = self.active_outline();
+ let (id, selection) = (shown.id, shown.selection);
+ let reached = changed.contains(&id);
+ let document = reached.then(|| shown.document.clone());
+ let mut own: BTreeMap = std::mem::take(&mut self.outlines)
+ .into_iter()
+ .map(|outline| (outline.id, outline))
+ .collect();
+ for outline in &mut fresh.outlines {
+ if !changed.contains(&outline.id)
+ && let Some(kept) = own.remove(&outline.id)
+ {
+ *outline = kept;
+ }
+ }
+ let position = fresh.outlines.iter().position(|outline| outline.id == id);
+ match (
+ std::mem::replace(&mut self.active, Focus::Outline(0)),
+ position,
+ ) {
+ (Focus::Caret { outline, index }, _) => {
+ fresh.active = Focus::Caret {
+ outline,
+ index: index.min(fresh.outlines.len()),
+ };
+ }
+ (Focus::Draft { outline, .. }, Some(index)) if !reached => {
+ fresh.active = Focus::Draft { index, outline };
+ }
+ (_, Some(index)) if !reached => fresh.active = Focus::Outline(index),
+ (_, Some(index)) => {
+ fresh.active = Focus::Outline(index);
+ let document = document.expect("a reached outline's document was kept");
+ let mapped = Selection {
+ positions: selection.positions.map(|position| {
+ follow(&document, &fresh.outlines[index].document, position)
+ }),
+ affinities: selection.affinities,
+ };
+ let _ = fresh.select(mapped);
+ self.pending = self.pending.take().filter(|(_, at, _)| {
+ follow(&document, &fresh.active_outline().document, *at) == *at
+ });
+ }
+ (_, None) => self.pending = None,
+ }
+ if !date_changed {
+ fresh.date = self.date.take();
+ }
+ if !objects_changed {
+ fresh.objects = std::mem::take(&mut self.objects);
+ }
+ for (id, definition) in std::mem::take(&mut self.definitions) {
+ fresh.definitions.entry(id).or_insert(definition);
+ }
+ // Undoing an outline's creation focuses the outline focused before, if it is there.
+ let gone = |focus: &RestoreFocus| matches!(focus, RestoreFocus::Outline(id) if !fresh.outlines.iter().any(|o| o.id == *id));
+ let reaches = |history: &History| match history {
+ History::Date(_) => date_changed,
+ History::Image { .. } | History::Picture { .. } => objects_changed,
+ History::Draft { outlines, .. } => changed.contains(&outlines[0].id),
+ History::Text { outline, change } => {
+ changed.contains(outline)
+ || change
+ .positions
+ .iter()
+ .any(|placement| objects_changed || changed.contains(&placement.id))
+ }
+ History::Position { outline, .. } | History::Layout { outline, .. } => {
+ changed.contains(outline)
+ }
+ History::Remove { outline, focus } => changed.contains(outline) || gone(focus),
+ History::Insert { outline, focus, .. } => changed.contains(&outline.id) || gone(focus),
+ History::Restore { source, focus, .. } => changed.contains(&source.id) || gone(focus),
+ };
+ fresh.undo = std::mem::take(&mut self.undo)
+ .into_iter()
+ .filter(|history| !reaches(history))
+ .collect();
+ fresh.redo = std::mem::take(&mut self.redo)
+ .into_iter()
+ .filter(|history| !reaches(history))
+ .collect();
+ fresh.pending = self.pending.take();
+ fresh.preferred_x = self.preferred_x;
+ fresh.ops = std::mem::replace(&mut self.ops, Ok(Vec::new()));
+ fresh.stored = self.stored.take();
+ *self = fresh;
+ Ok(true)
+ }
+
/// Rebuilds the stored page, restoring the title areas and read-only objects import split up.
pub fn page(&self) -> Result {
let mut objects: Vec = Vec::new();
@@ -781,18 +958,7 @@ impl CanvasEditor {
.iter()
.flat_map(|outline| descendants(&outline.paragraphs, None))
{
- let content_tags = match &node.content {
- ParagraphContent::Text(text) => text.tags.as_slice(),
- ParagraphContent::Table(table) => table.tags.as_slice(),
- _ => &[],
- };
- referenced.extend(node.lists.iter().copied().chain(node.style));
- referenced.extend(
- node.tags
- .iter()
- .chain(content_tags)
- .filter_map(|tag| tag.definition),
- );
+ referenced.extend(ops::references(node));
}
}
let mut definitions = self.definitions.clone();
@@ -1027,6 +1193,8 @@ impl CanvasEditor {
composition: None,
preferred_x: None,
pending: None,
+ ops: Ok(Vec::new()),
+ stored: None,
})
}
@@ -1055,6 +1223,7 @@ impl CanvasEditor {
self.undo
.push(History::Date(Box::new(self.date.replace(updated).unwrap())));
self.redo.clear();
+ self.record(Ok(self.date_ops()));
Ok(true)
}
@@ -1261,6 +1430,7 @@ impl CanvasEditor {
self.redo.clear();
self.active = Focus::Outline(self.outlines.len());
self.outlines.push(outline);
+ self.record(self.outline_ops(id, None));
self.preferred_x = None;
Ok(id)
}
@@ -1301,13 +1471,18 @@ impl CanvasEditor {
.position(|outline| outline.id == id)
.unwrap();
let layout = &mut self.outlines[index].layout;
+ let previous = [layout.x, layout.y];
self.undo.push(History::Position {
outline: id,
- position: [layout.x, layout.y],
+ position: previous,
});
self.redo.clear();
layout.x = Some(position[0]);
layout.y = Some(position[1]);
+ self.record(self.placement_ops(&Placement {
+ id,
+ position: previous,
+ }));
self.active = Focus::Outline(index);
self.preferred_x = None;
Ok(())
@@ -1429,6 +1604,8 @@ impl CanvasEditor {
layout: previous,
});
self.redo.clear();
+ let image = self.image(id).unwrap();
+ self.record(Ok(ops::picture_layout(image)));
Ok(())
}
@@ -1531,6 +1708,7 @@ impl CanvasEditor {
image: Some(Box::new(image)),
});
self.redo.clear();
+ self.record(Ok(vec![PageOp::Delete { object: id }]));
Ok(())
}
@@ -1559,6 +1737,11 @@ impl CanvasEditor {
let id = resized.id;
*self.active_outline_mut() = resized;
if self.caret_outline().is_none() {
+ self.record(ops::layout_ops(
+ id,
+ &previous,
+ &self.active_outline().layout,
+ ));
self.undo.push(History::Layout {
outline: id,
layout: previous,
@@ -2669,7 +2852,11 @@ impl CanvasEditor {
return Err((history, EditError::InvalidRange.into()));
}
let inverse = match history {
- History::Date(date) => History::Date(Box::new(self.date.replace(*date).unwrap())),
+ History::Date(date) => {
+ let shown = self.date.replace(*date).unwrap();
+ self.record(Ok(self.date_ops()));
+ History::Date(Box::new(shown))
+ }
History::Draft {
outlines,
index,
@@ -2700,13 +2887,12 @@ impl CanvasEditor {
None
};
let outline = outlines[usize::from(!restore_caret)].clone();
- if self
+ let id = outline.id;
+ let stored = self
.outlines
.get(index)
- .is_some_and(|item| item.id == outline.id)
- {
- self.outlines.remove(index);
- }
+ .is_some_and(|item| item.id == id)
+ .then(|| self.outlines.remove(index));
self.active = if outline.is_empty() {
Focus::Caret {
outline: Box::new(outline),
@@ -2722,6 +2908,7 @@ impl CanvasEditor {
None => Focus::Outline(index),
}
};
+ self.record(self.outline_ops(id, stored.as_ref()));
History::Draft {
outlines,
index,
@@ -2750,6 +2937,7 @@ impl CanvasEditor {
return Err((History::Text { outline, change }, error));
}
};
+ self.record(self.change_ops(&self.outlines[index], &inverse));
History::Text {
outline,
change: Box::new(inverse),
@@ -2762,13 +2950,17 @@ impl CanvasEditor {
.position(|item| item.id == outline)
.unwrap();
let layout = &mut self.outlines[index].layout;
- let inverse = History::Position {
- outline,
- position: [layout.x, layout.y],
- };
+ let previous = [layout.x, layout.y];
[layout.x, layout.y] = position;
self.active = Focus::Outline(index);
- inverse
+ self.record(self.placement_ops(&Placement {
+ id: outline,
+ position: previous,
+ }));
+ History::Position {
+ outline,
+ position: previous,
+ }
}
History::Layout { outline, layout } => {
let index = self
@@ -2793,25 +2985,46 @@ impl CanvasEditor {
};
resized.selection = self.outlines[index].selection;
self.outlines[index] = resized;
+ self.record(ops::layout_ops(
+ outline,
+ &previous,
+ &self.outlines[index].layout,
+ ));
self.active = Focus::Outline(index);
History::Layout {
outline,
layout: previous,
}
}
- History::Image { image, layout } => History::Image {
- image,
- layout: std::mem::replace(&mut self.image_mut(image).unwrap().layout, layout),
- },
+ History::Image { image, layout } => {
+ let previous =
+ std::mem::replace(&mut self.image_mut(image).unwrap().layout, layout);
+ self.record(Ok(ops::picture_layout(self.image(image).unwrap())));
+ History::Image {
+ image,
+ layout: previous,
+ }
+ }
History::Picture { index, image } => History::Picture {
index,
image: match image {
Some(image) => {
self.objects.insert(index, page::Content::Image(*image));
+ let page::Content::Image(image) = &self.objects[index] else {
+ unreachable!()
+ };
+ let ops = vec![PageOp::Add {
+ object: PageObject::Image(image.clone()),
+ before: self.successor(image.id),
+ }];
+ self.record(Ok(ops));
None
}
None => match self.objects.remove(index) {
- page::Content::Image(image) => Some(Box::new(image)),
+ page::Content::Image(image) => {
+ self.record(Ok(vec![PageOp::Delete { object: image.id }]));
+ Some(Box::new(image))
+ }
_ => unreachable!(),
},
},
@@ -2847,6 +3060,7 @@ impl CanvasEditor {
}
};
let outline = Box::new(self.outlines.remove(index));
+ self.record(Ok(vec![PageOp::Delete { object: outline.id }]));
self.active = next_focus;
History::Insert {
index,
@@ -2892,6 +3106,7 @@ impl CanvasEditor {
} => {
let id = outline.id;
self.outlines.insert(index, *outline);
+ self.record(self.outline_ops(id, None));
self.active = Focus::Outline(index);
History::Remove { outline: id, focus }
}
@@ -3152,6 +3367,7 @@ impl CanvasEditor {
} else {
self.outlines[index] = *outline;
}
+ self.record(self.outline_ops(versions[0].id, Some(&versions[0])));
self.undo.push(History::Draft {
outlines: versions,
index,
@@ -3173,8 +3389,9 @@ impl CanvasEditor {
};
let mut source = outline.snapshot();
source.paragraphs = change.edit.replacement;
+ let id = outline.id;
self.undo.push(History::Remove {
- outline: outline.id,
+ outline: id,
focus: RestoreFocus::Caret {
source: Box::new(source),
selection: change.selection,
@@ -3182,11 +3399,13 @@ impl CanvasEditor {
},
});
self.outlines.insert(index, *outline);
+ self.record(self.outline_ops(id, None));
} else if self.active_outline().is_empty() && !self.active_outline().title {
let Focus::Outline(index) = self.active else {
unreachable!()
};
let outline = self.outlines.remove(index);
+ self.record(Ok(vec![PageOp::Delete { object: outline.id }]));
debug_assert_eq!(change.edit.range, 0..1);
let mut source = outline.snapshot();
source.paragraphs = change.edit.replacement;
@@ -3205,6 +3424,7 @@ impl CanvasEditor {
index,
};
} else {
+ self.record(self.change_ops(self.active_outline(), &change));
self.undo.push(History::Text {
outline: self.active_outline().id,
change: Box::new(change),
@@ -3311,6 +3531,59 @@ impl CanvasEditor {
}
}
+/// Where `position` in `old` lies in `new`, the same outline changed elsewhere: in the same
+/// paragraph, past what changed in its text when it lies after it; at the start of the
+/// paragraph now at its place when that one is gone.
+fn follow(old: &TextDocument, new: &TextDocument, position: TextPosition) -> TextPosition {
+ let found = old.leaf(position.paragraph).and_then(|(_, _, node)| {
+ let paragraph = new
+ .text_nodes()
+ .position(|candidate| candidate.id == node.id)?;
+ Some((paragraph, node, new.leaf(paragraph)?.2))
+ });
+ let Some((paragraph, before, after)) = found else {
+ let count = new.text_nodes().count();
+ return TextPosition {
+ paragraph: position.paragraph.min(count.saturating_sub(1)),
+ offset: 0,
+ };
+ };
+ let units = |text: &str| text.encode_utf16().count() as u32;
+ let (a, b) = (
+ before.text().unwrap().text.text(),
+ after.text().unwrap().text.text(),
+ );
+ let prefix = units(
+ &a[..a
+ .char_indices()
+ .zip(b.chars())
+ .find(|((_, x), y)| x != y)
+ .map_or(a.len().min(b.len()), |((at, _), _)| at)],
+ );
+ let suffix = a
+ .chars()
+ .rev()
+ .zip(b.chars().rev())
+ .take_while(|(x, y)| x == y)
+ .map(|(x, _)| x.len_utf16() as u32)
+ .scan(0, |sum, units| {
+ *sum += units;
+ Some(*sum)
+ })
+ .take_while(|sum| prefix + sum <= units(a).min(units(b)))
+ .last()
+ .unwrap_or(0);
+ let (old_length, new_length) = (units(a), units(b));
+ let offset = if position.offset <= prefix {
+ position.offset
+ } else if position.offset >= old_length - suffix {
+ new_length - (old_length - position.offset)
+ } else {
+ new_length - suffix
+ };
+ TextPosition { paragraph, offset }
+}
+
fn inserted_position(
mut position: TextPosition,
text: &str,
@@ -8360,4 +8633,65 @@ mod tests {
assert_eq!(body_text(&reread, body).as_deref(), Some(edited.as_str()));
assert_ne!(body_text(&source, body).as_deref(), Some(edited.as_str()));
}
+
+ /// A page changed elsewhere shows in place: the caret keeps its paragraph and moves
+ /// past text inserted before it; history goes, unless nothing changed.
+ #[test]
+ fn a_refresh_keeps_the_caret_by_identity() {
+ let mut engine = TextEngine::default();
+ let lines =
+ ["Hello world", "Second"].map(|line| Paragraph::new(line.into(), Format::default()));
+ let document = TextDocument::new(lines.to_vec()).unwrap();
+ let mut editor = CanvasEditor::new(&mut engine, document, 400.0).unwrap();
+ editor
+ .select(
+ [TextPosition {
+ paragraph: 1,
+ offset: 3,
+ }; 2]
+ .into(),
+ )
+ .unwrap();
+ editor.insert(&mut engine, "x").unwrap();
+ editor
+ .select(
+ [TextPosition {
+ paragraph: 0,
+ offset: 8,
+ }; 2]
+ .into(),
+ )
+ .unwrap();
+ let _ = editor.take_ops();
+ let page = editor.page().unwrap();
+ assert!(!editor.refresh(page.clone(), &mut engine).unwrap());
+ assert!(!editor.undo.is_empty(), "an unchanged page keeps history");
+
+ let mut remote = page;
+ let PageObject::Outline(outline) = &mut remote.objects[0] else {
+ unreachable!()
+ };
+ for (paragraph, text) in outline
+ .paragraphs
+ .iter_mut()
+ .zip(["Hey, Hello world", "Other"])
+ {
+ paragraph.text_mut().unwrap().text = Paragraph::new(text.into(), Format::default());
+ }
+ let (shown, affinities) = (editor.active_outline().id, editor.selection().affinities);
+ assert!(editor.refresh(remote, &mut engine).unwrap());
+ assert_eq!(editor.active_outline().id, shown);
+ assert_eq!(
+ editor.selection(),
+ Selection {
+ positions: [TextPosition {
+ paragraph: 0,
+ offset: 13
+ }; 2],
+ affinities,
+ }
+ );
+ assert!(editor.undo.is_empty());
+ assert_eq!(editor.take_ops().unwrap(), []);
+ }
}
diff --git a/crates/canvas/src/editor/ops.rs b/crates/canvas/src/editor/ops.rs
new file mode 100644
index 0000000000000000000000000000000000000000..f28952bc4503632331c0ba5666d9cc542095b4fd
--- /dev/null
+++ b/crates/canvas/src/editor/ops.rs
@@ -0,0 +1,743 @@
+//! The ops each stored change lowers to, recorded as the change applies so a page is never
+//! rebuilt to be saved.
+
+use super::*;
+use onestore::op;
+use onestore::{OutlineEdit, document::Layout};
+
+type Lowered = Result, onestore::Error>;
+
+fn refused(message: &'static str) -> onestore::Error {
+ onestore::Error { offset: 0, message }
+}
+
+fn stale(_: EditError) -> onestore::Error {
+ refused("The editor lost track of the stored page")
+}
+
+/// Definitions a paragraph names: its lists, style and note tags.
+pub(super) fn references(node: &PageParagraph) -> impl Iterator- + '_ {
+ let content_tags = match &node.content {
+ ParagraphContent::Text(text) => text.tags.as_slice(),
+ ParagraphContent::Table(table) => table.tags.as_slice(),
+ _ => &[],
+ };
+ node.lists.iter().copied().chain(node.style).chain(
+ node.tags
+ .iter()
+ .chain(content_tags)
+ .filter_map(|tag| tag.definition),
+ )
+}
+
+/// The definitions `paragraphs` and their cells name.
+fn named(
+ definitions: &BTreeMap,
+ paragraphs: &[&[PageParagraph]],
+) -> BTreeMap {
+ paragraphs
+ .iter()
+ .flat_map(|list| descendants(list, None))
+ .flat_map(|(_, _, node)| references(node))
+ .filter_map(|id| Some((id, definitions.get(&id)?.clone())))
+ .collect()
+}
+
+/// `op::lower` of paragraphs `before` of `container` replaced by `nodes[range]` and the
+/// definitions they name, so it stays O(edit). When the structure changes, it also sees the
+/// unchanged paragraphs an op may split, join, move or anchor to: the ones either side and
+/// their ancestors, the range's descendants past it, and after them the next child of each
+/// ancestor.
+fn lower(
+ container: ExGuid,
+ nodes: &[PageParagraph],
+ range: Range,
+ before: &[PageParagraph],
+ definitions: &BTreeMap,
+) -> Lowered {
+ let after = &nodes[range.clone()];
+ if before.is_empty() && after.is_empty() {
+ return Ok(Vec::new());
+ }
+ let mut context = BTreeSet::new();
+ let shape = |node: &PageParagraph| (node.id, node.parent, node.level);
+ if before.iter().map(shape).ne(after.iter().map(shape)) {
+ context.extend(range.start.checked_sub(1));
+ context.extend((range.end < nodes.len()).then_some(range.end));
+ let held: BTreeSet = before.iter().chain(after).map(|node| node.id).collect();
+ let mut wanted: Vec = before
+ .iter()
+ .chain(after)
+ .chain(context.iter().map(|&at| &nodes[at]))
+ .filter_map(|node| node.parent)
+ .collect();
+ let mut open = BTreeMap::from([(None, 0)]);
+ while let Some(id) = wanted.pop() {
+ if held.contains(&id) || context.iter().any(|&at| nodes[at].id == id) {
+ continue;
+ }
+ if let Some(at) = nodes[..range.start].iter().rposition(|node| node.id == id) {
+ context.insert(at);
+ open.insert(Some(id), nodes[at].level);
+ wanted.extend(nodes[at].parent);
+ }
+ }
+ // Descendants of the range move with the paragraphs it splits, joins and moves.
+ let mut members = held;
+ let mut end = range.end;
+ while let Some(node) = nodes.get(end)
+ && node.parent.is_some_and(|parent| members.contains(&parent))
+ {
+ members.insert(node.id);
+ context.insert(end);
+ end += 1;
+ }
+ for (at, node) in nodes.iter().enumerate().skip(end) {
+ if open.remove(&node.parent).is_some() {
+ context.insert(at);
+ }
+ // A container's children end where its subtree does.
+ open.retain(|_, level| *level < node.level);
+ if open.is_empty() {
+ break;
+ }
+ }
+ }
+ let around = |middle: &[PageParagraph]| {
+ let (head, tail): (Vec, Vec) =
+ context.iter().partition(|&&at| at < range.start);
+ head.iter()
+ .map(|&at| &nodes[at])
+ .chain(middle)
+ .chain(tail.iter().map(|&at| &nodes[at]))
+ .cloned()
+ .collect::>()
+ };
+ let (before, after) = (around(before), around(after));
+ let named = named(definitions, &[&before, &after]);
+ // What `op::lower` cannot move or change in place is removed and inserted again under
+ // the identities it had: stored paragraphs a new table cell holds, and, where lowering
+ // fails, a paragraph whose text object a join replaced outside what `Join` expresses
+ // (between empty paragraphs, or back on undo) with the paragraph whose text it takes.
+ let text = |node: &PageParagraph| node.text().map(|text| text.id);
+ let stored: BTreeMap> = descendants(&before, None)
+ .map(|(_, _, node)| (node.id, text(node)))
+ .collect();
+ let cells: BTreeSet = descendants(&before, None)
+ .filter_map(|(cell, _, _)| cell)
+ .collect();
+ let mut removed: BTreeSet = descendants(&after, None)
+ .filter(|(cell, _, node)| {
+ stored.contains_key(&node.id) && cell.is_some_and(|cell| !cells.contains(&cell))
+ })
+ .map(|(_, _, node)| node.id)
+ .collect();
+ let lowered = |removed: &BTreeSet| {
+ let mut ops = Vec::new();
+ let mut deleted = Vec::new();
+ let mut kept = without(&before, removed, &mut ops, &mut deleted).map_err(stale)?;
+ if kept.is_empty() {
+ let anchor = anchor().map_err(stale)?;
+ ops.push(PageOp::Insert {
+ container,
+ before: None,
+ paragraphs: vec![anchor.clone()],
+ });
+ kept.push(anchor);
+ }
+ ops.extend(deleted.into_iter().map(|object| PageOp::Delete { object }));
+ ops.extend(op::lower(container, &kept, &after, None, &named)?);
+ Ok(ops)
+ };
+ let error = match lowered(&removed) {
+ Ok(ops) => return Ok(ops),
+ Err(error) => error,
+ };
+ let taken: BTreeSet = descendants(&after, None)
+ .filter(|(_, _, node)| stored.get(&node.id).is_some_and(|old| *old != text(node)))
+ .filter_map(|(_, _, node)| {
+ removed.insert(node.id);
+ text(node)
+ })
+ .collect();
+ if taken.is_empty() {
+ return Err(error);
+ }
+ removed.extend(
+ descendants(&before, None)
+ .filter(|(_, _, node)| text(node).is_some_and(|id| taken.contains(&id)))
+ .map(|(_, _, node)| node.id),
+ );
+ lowered(&removed)
+}
+
+/// An empty paragraph keeping a container from emptying while its paragraphs are removed
+/// and inserted again; the lowering removes it after.
+fn anchor() -> Result {
+ crate::document::node(
+ Paragraph::new(String::new(), Format::default()),
+ Format::default(),
+ )
+}
+
+/// `nodes` without the subtrees of `removed`, whose roots `deleted` receives; a table cell
+/// they would empty first receives an anchor, which `ops` inserts.
+fn without(
+ nodes: &[PageParagraph],
+ removed: &BTreeSet,
+ ops: &mut Vec,
+ deleted: &mut Vec,
+) -> Result, EditError> {
+ let mut gone = BTreeSet::new();
+ let mut kept = Vec::new();
+ for node in nodes {
+ if node.parent.is_some_and(|parent| gone.contains(&parent)) {
+ gone.insert(node.id);
+ } else if removed.contains(&node.id) {
+ gone.insert(node.id);
+ deleted.push(node.id);
+ } else {
+ let mut node = node.clone();
+ if let ParagraphContent::Table(table) = &mut node.content {
+ for cell in table.rows.iter_mut().flat_map(|row| &mut row.cells) {
+ cell.paragraphs = without(&cell.paragraphs, removed, ops, deleted)?;
+ if cell.paragraphs.is_empty() {
+ let anchor = anchor()?;
+ ops.push(PageOp::Insert {
+ container: cell.id,
+ before: None,
+ paragraphs: vec![anchor.clone()],
+ });
+ cell.paragraphs.push(anchor);
+ }
+ }
+ }
+ kept.push(node);
+ }
+ }
+ Ok(kept)
+}
+
+/// A page-level outline's position and width, as `op::lower_page` compares them.
+pub(super) fn layout_ops(id: ExGuid, old: &Layout, new: &Layout) -> Lowered {
+ let mut ops = Vec::new();
+ if (new.x, new.y) != (old.x, old.y) {
+ let (Some(x), Some(y)) = (new.x, new.y) else {
+ return Err(refused("An outline position needs both coordinates"));
+ };
+ ops.push(PageOp::Outline {
+ object: id,
+ edit: OutlineEdit::Position { x, y },
+ });
+ }
+ if (new.max_width, new.width_set_by_user) != (old.max_width, old.width_set_by_user) {
+ ops.push(PageOp::Outline {
+ object: id,
+ edit: OutlineEdit::Width {
+ points: new
+ .max_width
+ .ok_or_else(|| refused("An outline width cannot be removed"))?,
+ user_set: new.width_set_by_user == Some(true),
+ },
+ });
+ }
+ Ok(ops)
+}
+
+/// A page picture's stored position, size and description.
+pub(super) fn picture_layout(image: &onestore::page::Image) -> Vec {
+ vec![PageOp::Picture {
+ picture: image.id,
+ layout: image.layout.clone(),
+ alt: image.alt.clone(),
+ }]
+}
+
+impl CanvasEditor {
+ /// The ops the stored page took since the last call, in order; an error means an edit
+ /// since then cannot be stored, and the page should be reopened from storage.
+ pub fn take_ops(&mut self) -> Result, onestore::Error> {
+ let taken = std::mem::replace(&mut self.ops, Ok(Vec::new()));
+ match (&taken, &mut self.stored) {
+ (Ok(ops), Some((_, sent))) => sent.extend(ops.iter().cloned()),
+ (Err(_), stored) => *stored = None,
+ (Ok(_), None) => {}
+ }
+ taken
+ }
+
+ pub(super) fn record(&mut self, lowered: Lowered) {
+ if let Ok(ops) = &mut self.ops {
+ match lowered {
+ Ok(mut lowered) => ops.append(&mut lowered),
+ Err(error) => self.ops = Err(error),
+ }
+ }
+ }
+
+ /// Ops for `change`, the inverse of an edit just applied to stored outline `outline`.
+ pub(super) fn change_ops(&self, outline: &TextOutline, change: &TextChange) -> Lowered {
+ let edit = &change.edit;
+ let nodes = outline.document.container(edit.container).map_err(stale)?;
+ let mut ops = lower(
+ edit.container.unwrap_or(outline.id),
+ nodes,
+ edit.range.clone(),
+ &edit.replacement,
+ &self.definitions,
+ )?;
+ for (_, _, node) in descendants(outline.document.nodes(), None) {
+ if let ParagraphContent::Table(table) = &node.content
+ && edit.columns.contains_key(&table.id)
+ {
+ ops.push(PageOp::Table {
+ table: table.id,
+ edit: op::TableEdit::Columns(table.columns.clone()),
+ });
+ }
+ }
+ for placement in &change.positions {
+ ops.extend(self.placement_ops(placement)?);
+ }
+ Ok(ops)
+ }
+
+ /// A page object's move from `placement`, as title flow makes it.
+ pub(super) fn placement_ops(&self, placement: &Placement) -> Lowered {
+ let old = Layout {
+ x: placement.position[0],
+ y: placement.position[1],
+ ..Default::default()
+ };
+ let moved = |layout: &Layout| Layout {
+ x: layout.x,
+ y: layout.y,
+ ..Default::default()
+ };
+ let picture = |image: &onestore::page::Image| {
+ Ok(if [image.layout.x, image.layout.y] == placement.position {
+ Vec::new()
+ } else {
+ picture_layout(image)
+ })
+ };
+ if let Some(outline) = self.outlines.iter().find(|item| item.id == placement.id) {
+ return layout_ops(outline.id, &old, &moved(&outline.layout));
+ }
+ for object in &self.objects {
+ match object {
+ page::Content::Image(image) if image.id == placement.id => return picture(image),
+ page::Content::Outline { source, .. } if source.id == placement.id => {
+ return layout_ops(source.id, &old, &moved(&source.layout));
+ }
+ page::Content::ReadOnly(object) if object.source.id() == placement.id => {
+ return match &object.source {
+ PageObject::Outline(source) => {
+ layout_ops(source.id, &old, &moved(&source.layout))
+ }
+ PageObject::Image(image) => picture(image),
+ _ => Err(refused("Unsupported objects cannot be moved")),
+ };
+ }
+ _ => {}
+ }
+ }
+ Err(refused("The editor lost track of the stored page"))
+ }
+
+ /// Ops turning stored outline `old` into what `outlines` holds under `id` now: added,
+ /// removed, or its changed paragraphs and geometry.
+ pub(super) fn outline_ops(&self, id: ExGuid, old: Option<&TextOutline>) -> Lowered {
+ let new = self.outlines.iter().find(|outline| outline.id == id);
+ match (old, new) {
+ (None, None) => Ok(Vec::new()),
+ (Some(_), None) => Ok(vec![PageOp::Delete { object: id }]),
+ (None, Some(new)) => {
+ let outline = new.snapshot();
+ let page = |objects| Page {
+ title: String::new(),
+ identity: None,
+ created: None,
+ margin_origin: [0.0; 2],
+ objects,
+ definitions: named(&self.definitions, &[&outline.paragraphs]),
+ };
+ let mut ops = op::lower_page(
+ &page(Vec::new()),
+ &page(vec![PageObject::Outline(outline.clone())]),
+ )?;
+ let successor = self.successor(id);
+ for op in &mut ops {
+ if let PageOp::Add { before, .. } = op {
+ *before = successor;
+ }
+ }
+ Ok(ops)
+ }
+ (Some(old), Some(new)) => {
+ let (a, b) = (old.document.nodes(), new.document.nodes());
+ let prefix = a.iter().zip(b).take_while(|(x, y)| x == y).count();
+ let suffix = a[prefix..]
+ .iter()
+ .rev()
+ .zip(b[prefix..].iter().rev())
+ .take_while(|(x, y)| x == y)
+ .count();
+ let mut ops = lower(
+ id,
+ b,
+ prefix..b.len() - suffix,
+ &a[prefix..a.len() - suffix],
+ &self.definitions,
+ )?;
+ ops.extend(layout_ops(id, &old.layout, &new.layout)?);
+ Ok(ops)
+ }
+ }
+ }
+
+ /// The page's date as the editor shows it.
+ pub(super) fn date_ops(&self) -> Vec {
+ let Some(date) = &self.date else {
+ return Vec::new();
+ };
+ vec![PageOp::Date {
+ created: date.timestamp(),
+ fields: date
+ .fields()
+ .filter_map(|(_, paragraph)| {
+ let text = paragraph.text()?;
+ Some((text.id, text.text.text().to_owned()))
+ })
+ .collect(),
+ }]
+ }
+
+ /// The page child `page()` places after `id`, titles aside.
+ pub(super) fn successor(&self, id: ExGuid) -> Option {
+ let mut children: Vec = Vec::new();
+ for content in &self.objects {
+ let child = match content {
+ page::Content::Editable(outline)
+ if self.outlines.iter().any(|o| o.id == *outline) =>
+ {
+ *outline
+ }
+ page::Content::Editable(_) => continue,
+ page::Content::Outline { source, .. } => source.id,
+ page::Content::Date { .. } => continue,
+ page::Content::Image(image) => image.id,
+ page::Content::Ink(ink) => ink.id,
+ page::Content::ReadOnly(object)
+ if matches!(object.source, PageObject::Title(_)) =>
+ {
+ continue;
+ }
+ page::Content::ReadOnly(object) => object.source.id(),
+ };
+ // Titles, the date's among them, are listed apart from what an op places.
+ if !self
+ .header
+ .areas
+ .iter()
+ .any(|area| area.origins.contains_key(&child))
+ {
+ children.push(child);
+ }
+ }
+ children.extend(
+ self.outlines
+ .iter()
+ .filter(|outline| !self.has_page_outline(outline.id))
+ .map(|outline| outline.id),
+ );
+ let at = children.iter().position(|child| *child == id)?;
+ children.get(at + 1).copied()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::layout::TextEngine;
+ use std::time::{Duration, Instant};
+
+ fn median(mut samples: Vec) -> Duration {
+ samples.sort();
+ samples[samples.len() / 2]
+ }
+
+ /// The rows of the data-layer plan's table: each edit and its undo as ops, the undo
+ /// of a join splitting the paragraph back under its own identities.
+ #[test]
+ fn edits_and_their_undo_record_the_plan_s_ops() {
+ let mut engine = TextEngine::default();
+ let lines = ["Hello", "World"].map(|line| Paragraph::new(line.into(), Format::default()));
+ let document = crate::document::TextDocument::new(lines.to_vec()).unwrap();
+ let mut editor = CanvasEditor::new(&mut engine, document, 400.0).unwrap();
+ let nodes = editor.outlines()[0].document().nodes().to_vec();
+ let text = |index: usize| nodes[index].text().unwrap().id;
+ let at = |paragraph, offset| [TextPosition { paragraph, offset }; 2].into();
+ let undone = |editor: &mut CanvasEditor, engine: &mut TextEngine| {
+ editor.undo(engine).unwrap();
+ editor.take_ops().unwrap()
+ };
+
+ editor.select(at(0, 3)).unwrap();
+ editor.insert(&mut engine, "ab").unwrap();
+ let typed = PageOp::Text {
+ text: text(0),
+ range: 3..3,
+ with: "ab".into(),
+ };
+ assert_eq!(editor.take_ops().unwrap(), [typed]);
+ let erased = PageOp::Text {
+ text: text(0),
+ range: 3..5,
+ with: String::new(),
+ };
+ assert_eq!(undone(&mut editor, &mut engine), [erased]);
+
+ editor.select(at(1, 0)).unwrap();
+ editor.delete(&mut engine, true).unwrap();
+ let joined = PageOp::Join {
+ left: text(0),
+ right: text(1),
+ };
+ assert_eq!(editor.take_ops().unwrap(), [joined]);
+ let split = PageOp::Split {
+ text: text(0),
+ at: 5,
+ paragraph: nodes[1].id,
+ right: text(1),
+ lists: Vec::new(),
+ };
+ assert_eq!(undone(&mut editor, &mut engine), [split]);
+
+ editor.select(at(1, 0)).unwrap();
+ editor.tab(&mut engine, false).unwrap();
+ let [PageOp::Move { object, parent, .. }] = &editor.take_ops().unwrap()[..] else {
+ panic!("Tab moves the paragraph under its sibling")
+ };
+ assert_eq!((*object, *parent), (nodes[1].id, Some(nodes[0].id)));
+ // A moved paragraph keeps its level where it lies deeper than its new parent.
+ let ops = undone(&mut editor, &mut engine);
+ let [
+ PageOp::Move { object, parent, .. },
+ PageOp::Level {
+ paragraph,
+ level: 1,
+ },
+ ] = &ops[..]
+ else {
+ panic!("undoing Tab moves it back and outdents it: {ops:?}")
+ };
+ assert_eq!(
+ (*object, *parent, *paragraph),
+ (nodes[1].id, Some(editor.outlines()[0].id), nodes[1].id)
+ );
+
+ editor
+ .select(
+ [
+ TextPosition {
+ paragraph: 0,
+ offset: 1,
+ },
+ TextPosition {
+ paragraph: 0,
+ offset: 4,
+ },
+ ]
+ .into(),
+ )
+ .unwrap();
+ editor
+ .format(&mut engine, Formatting::Toggle(format::Toggle::Bold))
+ .unwrap();
+ let bold = PageOp::Format {
+ text: text(0),
+ range: 1..4,
+ set: vec![onestore::TextAttribute::Bold(true)],
+ clear: Vec::new(),
+ };
+ assert_eq!(editor.take_ops().unwrap(), [bold]);
+ let [
+ PageOp::Format {
+ text: target,
+ range,
+ ..
+ },
+ ] = &undone(&mut editor, &mut engine)[..]
+ else {
+ panic!("undoing bold formats the range back")
+ };
+ assert_eq!((*target, range.clone()), (text(0), 1..4));
+ }
+
+ /// A change from elsewhere reaches only what it changed: a refresh whose stored page is
+ /// what the editor's ops left (though storage normalized it) changes nothing, history
+ /// included; one that changes an outline drops that outline's history and keeps the
+ /// rest, which still undoes into the section.
+ #[test]
+ fn a_refresh_keeps_what_the_change_did_not_reach() {
+ use onestore::op::{Edit, Op};
+ let image =
+ include_bytes!("../../../../corpus/paragraph-edit/before/notebook/synthetic.one");
+ let arena = onestore::Arena::default();
+ let mut section = onestore::Section::open(&arena, image.to_vec()).unwrap();
+ let space = section.pages().unwrap()[0].0;
+ let mut engine = TextEngine::default();
+ let mut editor =
+ CanvasEditor::from_page(section.page(space).unwrap(), &mut engine).unwrap();
+ let mut at = 133_000_000_000_000_000;
+ let mut store = |section: &mut onestore::Section<'_>, ops: Vec| {
+ at += 10_000_000;
+ let ops = ops.into_iter().map(|op| Op::Page { space, op }).collect();
+ section.apply("Test", &Edit { at, ops }).unwrap();
+ };
+ let local = editor.outlines().iter().find(|o| !o.title).unwrap().id;
+ editor.focus_outline(local).unwrap();
+ editor
+ .select(
+ [TextPosition {
+ paragraph: 0,
+ offset: 0,
+ }; 2]
+ .into(),
+ )
+ .unwrap();
+ editor.insert(&mut engine, "Local ").unwrap();
+ let created = editor
+ .create_outline(&mut engine, [400.0, 600.0], 300.0)
+ .unwrap();
+ editor.insert(&mut engine, "Mine").unwrap();
+ store(&mut section, editor.take_ops().unwrap());
+
+ let (shown, history) = (editor.page().unwrap(), editor.undo.len());
+ let stored = section.page(space).unwrap();
+ assert_ne!(
+ Page {
+ title: shown.title.clone(),
+ ..stored.clone()
+ },
+ shown,
+ "storage normalizes"
+ );
+ assert!(!editor.refresh(stored, &mut engine).unwrap());
+ assert_eq!(
+ (
+ editor.page().unwrap(),
+ editor.undo.len(),
+ editor.active_outline().id
+ ),
+ (shown, history, created)
+ );
+
+ let text = section
+ .page(space)
+ .unwrap()
+ .objects
+ .iter()
+ .find_map(|object| match object {
+ onestore::page::PageObject::Outline(outline) if outline.id == local => outline
+ .paragraphs
+ .last()?
+ .text()
+ .map(|text| (text.id, text.text.text().encode_utf16().count() as u32)),
+ _ => None,
+ });
+ let (text, length) = text.unwrap();
+ store(
+ &mut section,
+ vec![PageOp::Text {
+ text,
+ range: length..length,
+ with: " remote".into(),
+ }],
+ );
+ assert!(
+ editor
+ .refresh(section.page(space).unwrap(), &mut engine)
+ .unwrap()
+ );
+ assert_eq!(
+ editor.undo.len(),
+ history - 1,
+ "the typing in the changed outline goes"
+ );
+ assert_eq!(editor.active_outline().id, created);
+ assert!(editor.undo(&mut engine).unwrap());
+ assert!(editor.undo(&mut engine).unwrap());
+ store(&mut section, editor.take_ops().unwrap());
+ assert!(!editor.undo(&mut engine).unwrap());
+ let page = section.page(space).unwrap();
+ assert!(!page.objects.iter().any(|object| object.id() == created));
+ let texts: Vec = editor
+ .outlines()
+ .iter()
+ .flat_map(|o| o.document().paragraphs().map(|p| p.text().to_owned()))
+ .collect();
+ assert!(texts.iter().any(|text| text.starts_with("Local ")));
+ assert!(texts.iter().any(|text| text.ends_with(" remote")));
+ }
+
+ /// One keystroke on the 3000-paragraph probe page, on the frame thread:
+ /// `SECTION_PROBE=path cargo test --release -p canvas --lib keystroke_recording -- --ignored --nocapture`.
+ #[test]
+ #[ignore]
+ fn keystroke_recording() {
+ let path = std::env::var("SECTION_PROBE").unwrap_or("/tmp/probe3000.one".into());
+ let image = std::fs::read(&path).unwrap();
+ let arena = onestore::Arena::default();
+ let mut section = onestore::Section::open(&arena, image).unwrap();
+ let page = section
+ .pages()
+ .unwrap()
+ .into_iter()
+ .map(|(space, ..)| section.page(space).unwrap())
+ .max_by_key(|page| format!("{page:?}").len())
+ .unwrap();
+ let mut engine = TextEngine::default();
+ let mut editor = CanvasEditor::from_page(page, &mut engine).unwrap();
+ let outline = editor
+ .outlines()
+ .iter()
+ .max_by_key(|outline| outline.document().nodes().len())
+ .unwrap();
+ let (id, count) = (outline.id, outline.document().nodes().len());
+ editor.focus_outline(id).unwrap();
+ let caret = TextPosition {
+ paragraph: count / 2,
+ offset: 0,
+ };
+ editor.select([caret; 2].into()).unwrap();
+ let (mut typing, mut recording, mut rebuilding) = (Vec::new(), Vec::new(), Vec::new());
+ let mut previous = editor.page().unwrap();
+ for _ in 0..200 {
+ let start = Instant::now();
+ editor.insert(&mut engine, "x").unwrap();
+ typing.push(start.elapsed());
+ let Some(History::Text { change, .. }) = editor.undo.last() else {
+ unreachable!()
+ };
+ let start = Instant::now();
+ let ops = editor.change_ops(editor.active_outline(), change).unwrap();
+ recording.push(start.elapsed());
+ assert_eq!(ops.len(), 1, "{ops:?}");
+ assert_eq!(editor.take_ops().unwrap().len(), 1);
+ let start = Instant::now();
+ let page = editor.page().unwrap();
+ assert!(page != previous);
+ rebuilding.push(start.elapsed());
+ previous = page;
+ }
+ println!(
+ "{path}: {count} paragraphs; keystroke {:?}, of which recording its op {:?}; \
+ the page rebuilt and compared as persist did {:?}",
+ median(typing),
+ median(recording),
+ median(rebuilding)
+ );
+ }
+}
diff --git a/crates/canvas/src/gpu/page.rs b/crates/canvas/src/gpu/page.rs
index b49ccd5091da072c02cc7cab8a98818336be046c..49af7234797d07cb248cb404c35f6b31f6085a4e 100644
--- a/crates/canvas/src/gpu/page.rs
+++ b/crates/canvas/src/gpu/page.rs
@@ -213,6 +213,33 @@ impl PageScene {
Ok((scene, editor))
}
+ /// Follows `editor` after it refreshed from storage, keeping the rasters of pictures
+ /// whose bytes did not change so they do not blink.
+ pub fn refresh(
+ &mut self,
+ editor: &mut CanvasEditor,
+ engine: &mut TextEngine,
+ ) -> Result<(), SceneError> {
+ let mut scene = Self::pictures(&editor.objects, Some(editor))?;
+ for (id, picture) in &mut scene.pictures {
+ if let Some(shown) = self.pictures.remove(id)
+ && shown.same(picture)
+ {
+ *picture = shown;
+ }
+ }
+ for (id, art) in &mut scene.backgrounds {
+ if let Some(shown) = self.backgrounds.remove(id)
+ && shown.same(art)
+ {
+ *art = shown;
+ }
+ }
+ scene.mark_unavailable(&mut editor.objects, engine)?;
+ *self = scene;
+ Ok(())
+ }
+
/// Turns page pictures the renderer cannot decode into placeholders; their stored data
/// is kept.
fn mark_unavailable(
diff --git a/crates/canvas/src/gpu/page/background.rs b/crates/canvas/src/gpu/page/background.rs
index 8b34f76d6df5d07ed956381f9bc8f06f7b879b97..1cd2c1445aaafc81e1da53fd8359ae3aa57698ec 100644
--- a/crates/canvas/src/gpu/page/background.rs
+++ b/crates/canvas/src/gpu/page/background.rs
@@ -330,6 +330,11 @@ struct Variant {
}
impl Background {
+ /// Whether both recreate the same template picture at the same size.
+ pub fn same(&self, other: &Self) -> bool {
+ self.svg == other.svg && self.size == other.size
+ }
+
pub fn recognise(picture: &onestore::page::Image) -> Option {
let digest: String = Sha256::digest(picture.bytes.as_deref()?)
.iter()
diff --git a/crates/canvas/src/gpu/page/picture.rs b/crates/canvas/src/gpu/page/picture.rs
index 18516bef894d37e7b9684a6910d0faa872bab01e..bd44b77d4d43ec0c89ff94ed6ecce472864b688b 100644
--- a/crates/canvas/src/gpu/page/picture.rs
+++ b/crates/canvas/src/gpu/page/picture.rs
@@ -31,6 +31,11 @@ pub(super) struct Picture {
impl Picture {
/// Reads the size from the picture's header; None when the renderer cannot decode it.
+ /// Whether both show the same stored bytes.
+ pub fn same(&self, other: &Self) -> bool {
+ self.bytes == other.bytes
+ }
+
pub fn new(bytes: &Arc<[u8]>) -> Option {
Some(Self {
native: RasterImage::measure(bytes).ok()?,
diff --git a/crates/canvas/src/interaction/mod.rs b/crates/canvas/src/interaction/mod.rs
index d4ca7e656af4367f77cb57af9af4386847b2c0da..8d5f66f9874d43c8c6ed406f8c210f7088a139dd 100644
--- a/crates/canvas/src/interaction/mod.rs
+++ b/crates/canvas/src/interaction/mod.rs
@@ -257,6 +257,21 @@ impl PageView {
self.object_focus = None;
}
+ /// Shows the stored page after a change made elsewhere in place of the one shown,
+ /// keeping the scroll, the caret and selection, and the pictures already drawn. Marked
+ /// text must be committed or cancelled first.
+ pub fn refresh(&mut self, page: onestore::page::Page) -> Result {
+ if !self.editor.refresh(page, &mut self.engine)? {
+ return Ok(Response::default());
+ }
+ if let Some((scene, _)) = &mut self.scene {
+ scene.refresh(&mut self.editor, &mut self.engine)?;
+ }
+ self.drag = None;
+ self.object_focus = None;
+ self.moved()
+ }
+
/// Shows another page as OneNote opens one, keeping the zoom.
pub fn open(&mut self, editor: CanvasEditor, scene: Option<(PageScene, [f32; 2])>) {
self.replace(editor, scene);
diff --git a/crates/canvas/tests/ops_differential.rs b/crates/canvas/tests/ops_differential.rs
new file mode 100644
index 0000000000000000000000000000000000000000..5b2654946e68b12f9e7ea5205d9f2a0d16b43f4a
--- /dev/null
+++ b/crates/canvas/tests/ops_differential.rs
@@ -0,0 +1,670 @@
+//! The ops the editor records for each edit, applied to the section, store what the edited
+//! page lowered whole (`Section::apply_page(editor.page())`) stores: every editor operation
+//! alone and in random sequences with undo and redo, on every page of the corpus sections
+//! below and of any named in `OPS_SWEEP_SECTIONS` (`:`-separated paths; the structural
+//! probe section, which `structural_roundtrip` sweeps, takes minutes).
+
+use canvas::{
+ document::TextPosition,
+ editor::{Alignment, CanvasEditor, Formatting, NoteTag, Toggle},
+ layout::TextEngine,
+};
+use draw::edit::Movement;
+use onestore::{
+ Arena, ExGuid, Section,
+ op::{Edit, Op},
+ page::Page,
+};
+use std::{collections::BTreeMap, path::Path};
+
+const SECTIONS: [&str; 15] = [
+ "corpus/outline-edit/before/notebook/synthetic.one",
+ "corpus/paragraph-edit/before/notebook/synthetic.one",
+ "corpus/outline-edit/tree/before/notebook/synthetic.one",
+ "corpus/m6/native-features-01/notebook/Features.one",
+ "corpus/table-edit/nested/cold/notebook/synthetic.one",
+ "corpus/m6/native-table-controls-01/notebook/synthetic.one",
+ "corpus/list-edit/cold/notebook/lists.one",
+ "corpus/tag-edit/cold/notebook/tags.one",
+ "corpus/picture-edit/native-page-level/notebook/pictures.one",
+ "corpus/attachment-edit/plain/cold/notebook/files.one",
+ "corpus/ink-edit/drawing/cold/notebook/ink.one",
+ "corpus/math-edit/native-editor/notebook/links.one",
+ "corpus/link-edit/native-links/notebook/links.one",
+ "corpus/paragraph-format/cold/notebook/synthetic.one",
+ "corpus/canvas/baseline-anchors.one",
+];
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
+enum Kind {
+ Type,
+ TypeEnd,
+ Compose,
+ ComposeCancel,
+ DeleteBackward,
+ DeleteForward,
+ DeleteRange,
+ DeleteWord,
+ EnterStart,
+ EnterMiddle,
+ EnterEnd,
+ SoftEnter,
+ Backspace,
+ Delete,
+ Tab,
+ ShiftTab,
+ TabMiddle,
+ Paste,
+ PasteLine,
+ Bold,
+ Italic,
+ Font,
+ FontSize,
+ Color,
+ Highlight,
+ Clear,
+ Center,
+ Bullets,
+ Numbering,
+ Tag,
+ Check,
+ ClickCheck,
+ MoveOutline,
+ ResizeOutline,
+ CreateOutline,
+ EmptyOutline,
+ ArrowPastEnd,
+ PlaceImage,
+ RemoveImage,
+ Date,
+ Undo,
+ Redo,
+}
+
+const KINDS: [Kind; 42] = [
+ Kind::Type,
+ Kind::TypeEnd,
+ Kind::Compose,
+ Kind::ComposeCancel,
+ Kind::DeleteBackward,
+ Kind::DeleteForward,
+ Kind::DeleteRange,
+ Kind::DeleteWord,
+ Kind::EnterStart,
+ Kind::EnterMiddle,
+ Kind::EnterEnd,
+ Kind::SoftEnter,
+ Kind::Backspace,
+ Kind::Delete,
+ Kind::Tab,
+ Kind::ShiftTab,
+ Kind::TabMiddle,
+ Kind::Paste,
+ Kind::PasteLine,
+ Kind::Bold,
+ Kind::Italic,
+ Kind::Font,
+ Kind::FontSize,
+ Kind::Color,
+ Kind::Highlight,
+ Kind::Clear,
+ Kind::Center,
+ Kind::Bullets,
+ Kind::Numbering,
+ Kind::Tag,
+ Kind::Check,
+ Kind::ClickCheck,
+ Kind::MoveOutline,
+ Kind::ResizeOutline,
+ Kind::CreateOutline,
+ Kind::EmptyOutline,
+ Kind::ArrowPastEnd,
+ Kind::PlaceImage,
+ Kind::RemoveImage,
+ Kind::Date,
+ Kind::Undo,
+ Kind::Redo,
+];
+
+struct Random(u64);
+
+impl Random {
+ fn below(&mut self, bound: usize) -> usize {
+ self.0 ^= self.0 << 13;
+ self.0 ^= self.0 >> 7;
+ self.0 ^= self.0 << 17;
+ (self.0 % bound.max(1) as u64) as usize
+ }
+}
+
+/// A random caret in the active outline: the paragraph and its UTF-16 length.
+fn caret(editor: &CanvasEditor, random: &mut Random) -> (usize, u32) {
+ let count = editor.active_outline().document().paragraphs().count();
+ let paragraph = random.below(count);
+ let text = editor
+ .active_outline()
+ .document()
+ .paragraphs()
+ .nth(paragraph)
+ .unwrap();
+ (paragraph, text.text().encode_utf16().count() as u32)
+}
+
+/// A scalar boundary of the paragraph near its middle, or a random one.
+fn inside(editor: &CanvasEditor, paragraph: usize, random: &mut Random) -> u32 {
+ let text = editor
+ .active_outline()
+ .document()
+ .paragraphs()
+ .nth(paragraph)
+ .unwrap();
+ let chars: Vec = text.text().char_indices().map(|(at, _)| at).collect();
+ let at = chars
+ .get(random.below(chars.len() + 1))
+ .copied()
+ .unwrap_or(text.text().len());
+ text.utf16_offset(at).unwrap()
+}
+
+/// Performs `kind` somewhere on the page; false when the editor declines it.
+fn perform(
+ editor: &mut CanvasEditor,
+ engine: &mut TextEngine,
+ kind: Kind,
+ random: &mut Random,
+) -> bool {
+ let outlines: Vec = editor.outlines().iter().map(|outline| outline.id).collect();
+ if !matches!(
+ kind,
+ Kind::Undo
+ | Kind::Redo
+ | Kind::PlaceImage
+ | Kind::RemoveImage
+ | Kind::CreateOutline
+ | Kind::Date
+ ) {
+ if outlines.is_empty() {
+ return false;
+ }
+ let outline = outlines[random.below(outlines.len())];
+ if editor.focus_outline(outline).is_err() {
+ return false;
+ }
+ }
+ let at = |paragraph, offset| [TextPosition { paragraph, offset }; 2].into();
+ let (paragraph, length) = if outlines.is_empty() {
+ (0, 0)
+ } else {
+ caret(editor, random)
+ };
+ let middle = if outlines.is_empty() {
+ 0
+ } else {
+ inside(editor, paragraph, random)
+ };
+ let place = |editor: &mut CanvasEditor, offset| editor.select(at(paragraph, offset)).is_ok();
+ let range = |editor: &mut CanvasEditor, random: &mut Random| {
+ let (last, _) = caret(editor, random);
+ let (first, last) = (paragraph.min(last), paragraph.max(last));
+ let start = inside(editor, first, random);
+ let end = inside(editor, last, random);
+ let (start, end) = if first == last {
+ (start.min(end), start.max(end))
+ } else {
+ (start, end)
+ };
+ editor
+ .select(
+ [
+ TextPosition {
+ paragraph: first,
+ offset: start,
+ },
+ TextPosition {
+ paragraph: last,
+ offset: end,
+ },
+ ]
+ .into(),
+ )
+ .is_ok()
+ };
+ let format =
+ |editor: &mut CanvasEditor, engine: &mut TextEngine, random: &mut Random, command| {
+ range(editor, random) && editor.format(engine, command).is_ok()
+ };
+ match kind {
+ Kind::Type => place(editor, middle) && editor.insert(engine, "ab").is_ok(),
+ Kind::TypeEnd => place(editor, length) && editor.commit_text(engine, "z".into()).is_ok(),
+ Kind::Compose => {
+ place(editor, middle)
+ && editor.compose(engine, "k".into(), 1..1).is_ok()
+ && editor.compose(engine, "ka".into(), 2..2).is_ok()
+ && editor.commit_text(engine, "か".into()).is_ok()
+ }
+ Kind::ComposeCancel => {
+ place(editor, middle)
+ && editor.compose(engine, "k".into(), 1..1).is_ok()
+ && editor.cancel_composition(engine).is_ok()
+ }
+ Kind::DeleteBackward => place(editor, middle) && editor.delete(engine, true).is_ok(),
+ Kind::DeleteForward => place(editor, middle) && editor.delete(engine, false).is_ok(),
+ Kind::DeleteRange => range(editor, random) && editor.delete(engine, true).is_ok(),
+ Kind::DeleteWord => {
+ place(editor, middle) && editor.delete_to(engine, Movement::WordLeft).is_ok()
+ }
+ Kind::EnterStart => place(editor, 0) && editor.enter(engine, false).is_ok(),
+ Kind::EnterMiddle => place(editor, middle) && editor.enter(engine, false).is_ok(),
+ Kind::EnterEnd => place(editor, length) && editor.enter(engine, false).is_ok(),
+ Kind::SoftEnter => place(editor, middle) && editor.enter(engine, true).is_ok(),
+ Kind::Backspace => place(editor, 0) && editor.delete(engine, true).is_ok(),
+ Kind::Delete => place(editor, length) && editor.delete(engine, false).is_ok(),
+ Kind::Tab => place(editor, 0) && editor.tab(engine, false).is_ok(),
+ Kind::ShiftTab => place(editor, 0) && editor.tab(engine, true).is_ok(),
+ Kind::TabMiddle => place(editor, middle) && editor.tab(engine, false).is_ok(),
+ Kind::Paste => {
+ range(editor, random) && editor.paste(engine, "Line one\r\nLine two", 1033).is_ok()
+ }
+ Kind::PasteLine => place(editor, middle) && editor.paste(engine, "pasted", 1033).is_ok(),
+ Kind::Bold => format(editor, engine, random, Formatting::Toggle(Toggle::Bold)),
+ Kind::Italic => format(editor, engine, random, Formatting::Toggle(Toggle::Italic)),
+ Kind::Font => format(editor, engine, random, Formatting::Font("Georgia".into())),
+ Kind::FontSize => format(editor, engine, random, Formatting::FontSize(14.0)),
+ Kind::Color => format(editor, engine, random, Formatting::Color(Some(0x0000_00ff))),
+ Kind::Highlight => format(
+ editor,
+ engine,
+ random,
+ Formatting::Highlight(Some(0x0000_ffff)),
+ ),
+ Kind::Clear => format(editor, engine, random, Formatting::Clear),
+ Kind::Center => format(editor, engine, random, Formatting::Align(Alignment::Center)),
+ Kind::Bullets => format(editor, engine, random, Formatting::Bullets),
+ Kind::Numbering => format(editor, engine, random, Formatting::Numbering),
+ Kind::Tag => {
+ let tag = [
+ NoteTag::ToDo,
+ NoteTag::Important,
+ NoteTag::Question,
+ NoteTag::RememberForLater,
+ NoteTag::Definition,
+ NoteTag::Highlight,
+ NoteTag::Contact,
+ NoteTag::Address,
+ NoteTag::PhoneNumber,
+ ][random.below(9)];
+ format(editor, engine, random, Formatting::Tag(tag))
+ }
+ Kind::Check => format(editor, engine, random, Formatting::Check),
+ Kind::ClickCheck => {
+ let outline = editor.active_outline();
+ let id = outline.document().text_nodes().nth(paragraph).unwrap().id;
+ let outline = outline.id;
+ editor.click_check(engine, outline, id).is_ok()
+ }
+ Kind::MoveOutline => {
+ let outline = editor.active_outline();
+ let [x, y] = outline.origin();
+ let id = outline.id;
+ editor.move_outline(id, [x + 36.0, y + 18.0]).is_ok()
+ }
+ Kind::ResizeOutline => {
+ let width = editor.active_outline().wrap_width();
+ editor.resize(engine, width + 72.0).is_ok()
+ }
+ Kind::CreateOutline => {
+ editor
+ .create_outline(
+ engine,
+ [400.0, 600.0 + 20.0 * random.below(10) as f32],
+ 300.0,
+ )
+ .is_ok()
+ && editor.insert(engine, "Fresh").is_ok()
+ }
+ Kind::EmptyOutline => editor.select_all().is_ok() && editor.delete(engine, true).is_ok(),
+ Kind::ArrowPastEnd => {
+ editor
+ .move_selection(engine, Movement::DocumentEnd, false)
+ .is_ok()
+ && editor.move_selection(engine, Movement::Down, false).is_ok()
+ && editor.move_selection(engine, Movement::Down, false).is_ok()
+ && editor.insert(engine, "Below").is_ok()
+ }
+ Kind::PlaceImage | Kind::RemoveImage => {
+ let images: Vec = editor
+ .object_layouts()
+ .map(|(id, _)| id)
+ .filter(|id| editor.image_placement(*id).is_some())
+ .collect();
+ if images.is_empty() {
+ return false;
+ }
+ let id = images[random.below(images.len())];
+ if kind == Kind::RemoveImage {
+ return editor.remove_image(engine, id).is_ok();
+ }
+ let (origin, size) = editor.image_placement(id).unwrap();
+ editor
+ .place_image(
+ engine,
+ id,
+ [origin[0] + 18.0, origin[1] + 9.0],
+ [size[0] * 1.5, size[1] * 1.5],
+ )
+ .is_ok()
+ }
+ Kind::Date => {
+ let Some(date) = editor.date() else {
+ return false;
+ };
+ let timestamp = date.timestamp() + 864_000_000_000 * (1 + random.below(30) as u64);
+ editor
+ .change_date(
+ engine,
+ timestamp,
+ ["Friday, July 04, 2025".into(), "9:45 AM".into()],
+ )
+ .unwrap_or(false)
+ }
+ Kind::Undo => editor.undo(engine).unwrap_or(false),
+ Kind::Redo => editor.redo(engine).unwrap_or(false),
+ }
+}
+
+/// How a step's ops compared with its page lowered whole.
+#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
+enum Outcome {
+ /// The ops store the editor's page, or what that page lowered whole stores.
+ Same,
+ /// Both refuse the edit.
+ Refused,
+ /// The ops store; the page lowered whole cannot reach the editor's page from what is
+ /// stored.
+ OpsOnly,
+ /// The paths part where the section already stored the page differently from the
+ /// editor's model, as the writers normalize some values.
+ Normalized,
+ Differs(String),
+}
+
+/// The section the editor's ops are applied to, sealed after each step.
+struct Stored<'a> {
+ section: Section<'a>,
+ space: ExGuid,
+ at: u64,
+}
+
+impl Stored<'_> {
+ /// Stores the editor's step as its ops and, unless they store the editor's page,
+ /// compares with that page lowered whole onto the section as it was before the step.
+ fn step(&mut self, editor: &mut CanvasEditor, model: &Page) -> Outcome {
+ self.at += 10_000_000;
+ let image = self.section.image();
+ let reads_as = |stored: Page, model: &Page| {
+ Page {
+ title: model.title.clone(),
+ ..stored
+ } == *model
+ };
+ let normalized = !reads_as(self.section.page(self.space).unwrap(), model);
+ let ops = editor.take_ops().map_err(|error| error.to_string());
+ let edit = ops.clone().map(|ops| Edit {
+ at: self.at,
+ ops: ops
+ .into_iter()
+ .map(|op| Op::Page {
+ space: self.space,
+ op,
+ })
+ .collect(),
+ });
+ let applied = edit.and_then(|edit| {
+ if edit.ops.is_empty() {
+ return Ok(());
+ }
+ self.section
+ .apply("Sweep", &edit)
+ .map_err(|error| error.to_string())
+ });
+ let edited = editor.page().unwrap();
+ if applied.is_ok() && reads_as(self.section.page(self.space).unwrap(), &edited) {
+ self.section.seal().unwrap();
+ return Outcome::Same;
+ }
+ let arena = Arena::default();
+ let mut whole = Section::open(&arena, image).unwrap();
+ let lowered = onestore::op::lower_page(&whole.page(self.space).unwrap(), &edited)
+ .map_err(|error| error.to_string())
+ .and_then(|ops| {
+ let edit = Edit {
+ at: self.at,
+ ops: ops
+ .iter()
+ .map(|op| Op::Page {
+ space: self.space,
+ op: op.clone(),
+ })
+ .collect(),
+ };
+ whole
+ .apply("Sweep", &edit)
+ .map_err(|error| error.to_string())?;
+ Ok(ops)
+ });
+ let outcome = match (applied, &lowered) {
+ (Err(error), Err(whole)) => {
+ if std::env::var_os("OPS_SWEEP_DEBUG").is_some() {
+ eprintln!("BOTH {error} | {whole}");
+ }
+ Outcome::Refused
+ }
+ (Ok(()), Ok(_)) => {
+ let (a, b) = (
+ self.section.page(self.space).unwrap(),
+ whole.page(self.space).unwrap(),
+ );
+ if a == b {
+ Outcome::Same
+ } else if normalized {
+ Outcome::Normalized
+ } else {
+ Outcome::Differs(difference(&a, &b))
+ }
+ }
+ (Err(_), Ok(_)) if normalized => Outcome::Normalized,
+ (Err(error), Ok(_)) => Outcome::Differs(format!("ops refused: {error}")),
+ (Ok(()), Err(_)) => {
+ if std::env::var_os("OPS_SWEEP_DEBUG").is_some() {
+ let stored = self.section.page(self.space).unwrap();
+ eprintln!("OPSONLY-MODEL {}", difference(&stored, &edited));
+ }
+ Outcome::OpsOnly
+ }
+ };
+ if !matches!(outcome, Outcome::Same | Outcome::Refused)
+ && std::env::var_os("OPS_SWEEP_DEBUG").is_some()
+ {
+ let show = |ops: &[onestore::op::PageOp]| {
+ for op in ops {
+ let op = format!("{op:?}");
+ eprintln!(" {}", &op[..op.len().min(600)]);
+ }
+ };
+ eprintln!("DIFFERS {outcome:?}\n ops:");
+ if let Ok(ops) = &ops {
+ show(ops);
+ }
+ eprintln!(" whole:");
+ if let Ok(ops) = &lowered {
+ show(ops);
+ }
+ }
+ self.section.seal().unwrap();
+ outcome
+ }
+}
+
+/// The first place two pages part: an object missing from one, then objects by identity,
+/// then their order.
+fn difference(a: &Page, b: &Page) -> String {
+ let objects = |page: &Page| -> BTreeMap {
+ page.objects
+ .iter()
+ .map(|object| (object.id(), format!("{object:?}")))
+ .collect()
+ };
+ let (x, y) = (objects(a), objects(b));
+ if let Some(id) = x
+ .keys()
+ .chain(y.keys())
+ .find(|id| x.contains_key(id) != y.contains_key(id))
+ {
+ return format!("object {id} on one side only");
+ }
+ for (id, x) in &x {
+ let y = &y[id];
+ if x != y {
+ let at = x.bytes().zip(y.bytes()).take_while(|(p, q)| p == q).count();
+ let from = at.saturating_sub(160);
+ return format!(
+ "object {id} differs\n one: …{}\n other: …{}",
+ &x[from..(at + 160).min(x.len())],
+ &y[from..(at + 160).min(y.len())]
+ );
+ }
+ }
+ if a.objects
+ .iter()
+ .map(|o| o.id())
+ .ne(b.objects.iter().map(|o| o.id()))
+ {
+ return "object order".into();
+ }
+ if a.definitions != b.definitions {
+ return "definitions".into();
+ }
+ "page fields".into()
+}
+
+#[derive(Default)]
+struct Tally {
+ outcomes: BTreeMap<(Kind, Outcome), usize>,
+ examples: BTreeMap,
+}
+
+fn sweep(
+ path: &Path,
+ random: &mut Random,
+ engine: &mut TextEngine,
+ tally: &mut Tally,
+ sequences: usize,
+) {
+ let image = std::fs::read(path).unwrap();
+ let arena = Arena::default();
+ let mut section = Section::open(&arena, image.clone()).unwrap();
+ for (space, title, _) in section.pages().unwrap() {
+ let Ok(page) = section.page(space) else {
+ continue;
+ };
+ if CanvasEditor::from_page(page.clone(), engine).is_err() {
+ continue;
+ }
+ let plans = KINDS
+ .iter()
+ .map(|kind| vec![*kind])
+ .chain(KINDS.iter().map(|kind| vec![*kind, Kind::Undo, Kind::Redo]))
+ .chain((0..sequences).map(|_| {
+ (0..3 + random.below(8))
+ .map(|_| KINDS[random.below(KINDS.len())])
+ .collect()
+ }))
+ .collect::>>();
+ for plan in plans {
+ let arena = Arena::default();
+ let mut stored = Stored {
+ section: Section::open(&arena, image.clone()).unwrap(),
+ space,
+ at: 133_000_000_000_000_000,
+ };
+ let mut editor = CanvasEditor::from_page(page.clone(), engine).unwrap();
+ for kind in &plan {
+ let model = editor.page().unwrap();
+ if !perform(&mut editor, engine, *kind, random) {
+ continue;
+ }
+ if std::env::var_os("OPS_SWEEP_DEBUG").is_some() {
+ eprintln!("STEP {kind:?} of {plan:?} on {title:?}");
+ }
+ let outcome = stored.step(&mut editor, &model);
+ *tally.outcomes.entry((*kind, outcome.clone())).or_default() += 1;
+ if let Outcome::Differs(reason) = &outcome {
+ tally
+ .examples
+ .entry(reason.clone())
+ .or_insert_with(|| format!("{plan:?} on {title:?} in {}", path.display()));
+ }
+ if outcome != Outcome::Same {
+ break;
+ }
+ }
+ }
+ }
+}
+
+#[test]
+fn editor_ops_store_what_the_page_lowered_whole_stores() {
+ let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
+ let mut paths: Vec<_> = SECTIONS.iter().map(|path| root.join(path)).collect();
+ if let Some(extra) = std::env::var_os("OPS_SWEEP_SECTIONS") {
+ paths.extend(std::env::split_paths(&extra));
+ }
+ let seed = std::env::var("OPS_SWEEP_SEED").map_or(1, |seed| seed.parse().unwrap());
+ let sequences = std::env::var("OPS_SWEEP_SEQUENCES").map_or(4, |n| n.parse().unwrap());
+ let mut random = Random(seed);
+ let mut engine = TextEngine::default();
+ let mut tally = Tally::default();
+ for path in &paths {
+ let start = std::time::Instant::now();
+ sweep(path, &mut random, &mut engine, &mut tally, sequences);
+ if std::env::var_os("OPS_SWEEP_DEBUG").is_some() {
+ eprintln!("TIME {:?} {}", start.elapsed(), path.display());
+ }
+ }
+ let mut report = String::new();
+ let mut per_kind: BTreeMap = BTreeMap::new();
+ for ((kind, outcome), count) in &tally.outcomes {
+ let slot = match outcome {
+ Outcome::Same => 0,
+ Outcome::Refused => 1,
+ Outcome::OpsOnly => 2,
+ Outcome::Normalized => 3,
+ Outcome::Differs(_) => 4,
+ };
+ per_kind.entry(*kind).or_default()[slot] += count;
+ }
+ let mut totals = [0; 5];
+ for (kind, counts) in &per_kind {
+ let [same, refused, only, normalized, differs] = counts;
+ report += &format!(
+ "{kind:?}: {same} same, {refused} both refused, {only} only as ops, {normalized} after normalization, {differs} differ\n"
+ );
+ for (total, count) in totals.iter_mut().zip(counts) {
+ *total += count;
+ }
+ }
+ report += &format!(
+ "all: {totals:?} (same, both refused, only as ops, after normalization, differ)\n"
+ );
+ for ((kind, outcome), count) in &tally.outcomes {
+ if let Outcome::Differs(reason) = outcome {
+ report += &format!(
+ "{count} x {kind:?} {reason}\n e.g. {}\n",
+ tally.examples.get(reason).map_or("", String::as_str)
+ );
+ }
+ }
+ println!("{report}");
+ let differs = totals[4];
+ assert_eq!(differs, 0, "{report}");
+}
diff --git a/crates/canvas/tests/structural_roundtrip.rs b/crates/canvas/tests/structural_roundtrip.rs
index 0eefdb43a1876d99934f5cfe19a057f66af64373..78c330f6aa39ac99ba2c9aa088c32204d206be2f 100644
--- a/crates/canvas/tests/structural_roundtrip.rs
+++ b/crates/canvas/tests/structural_roundtrip.rs
@@ -1,7 +1,7 @@
-//! Every structural edit the editor makes saves through the page writer and rereads as the
-//! model it saved: random Enter, Backspace, Delete, Tab, Shift+Tab, list and paste edits, alone
-//! and several to a save, on every outline of the probe section and of any sections named in
-//! `CANVAS_SWEEP_SECTIONS` (`:`-separated paths).
+//! Every structural edit the editor makes saves, through the page writer and as the ops the
+//! editor recorded, and rereads as the model it saved: random Enter, Backspace, Delete, Tab,
+//! Shift+Tab, list and paste edits, alone and several to a save, on every outline of the probe
+//! section and of any sections named in `CANVAS_SWEEP_SECTIONS` (`:`-separated paths).
use canvas::{
document::TextPosition,
@@ -9,7 +9,7 @@ use canvas::{
layout::TextEngine,
};
use onestore::{
- ExGuid, PreparedEdit, RevisionIndex, Store,
+ Arena, ExGuid, PreparedEdit, RevisionIndex, Section, Store,
document::Document,
page::{Outline, Page, PageObject, PageParagraph, ParagraphContent},
};
@@ -334,26 +334,43 @@ fn sweep(path: &Path, random: &mut Random, engine: &mut TextEngine, tally: &mut
)
.unwrap();
}
+ // An untitled page's title follows its first line as the writer stores it.
+ let rereads = |reread: &Page| {
+ Page {
+ title: after.title.clone(),
+ ..reread.clone()
+ } == after
+ };
+ let arena = Arena::default();
+ let mut stored = Section::open(&arena, section.clone()).unwrap();
+ let edit = onestore::op::Edit {
+ at: 133_000_000_000_000_000,
+ ops: editor
+ .take_ops()
+ .unwrap()
+ .into_iter()
+ .map(|op| onestore::op::Op::Page { space, op })
+ .collect(),
+ };
+ let ops = match stored.apply("Sweep", &edit) {
+ Err(error) => Some(format!("ops: {error}")),
+ Ok(()) => {
+ let reread = stored.page(space).unwrap();
+ (!rereads(&reread)).then(|| format!("ops: {}", difference(&after, &reread)))
+ }
+ };
let failure = match PreparedEdit::page(§ion, space, &after, "Sweep") {
Err(error) => Some(error.message.to_owned()),
Ok(saved) => match pages(saved.as_bytes())
.into_iter()
.find(|(id, _)| *id == space)
{
- // An untitled page's title follows its first line as the writer stores it.
- Some((_, reread))
- if Page {
- title: after.title.clone(),
- ..reread.clone()
- } == after =>
- {
- None
- }
+ Some((_, reread)) if rereads(&reread) => None,
Some((_, reread)) => Some(difference(&after, &reread)),
None => Some("the page does not reread".into()),
},
};
- if let Some(failure) = failure {
+ for failure in failure.into_iter().chain(ops) {
tally.failures.entry(failure).or_default().push(format!(
"{plan:?} on {:?} in {}",
page.title,
diff --git a/crates/mobile/Cargo.toml b/crates/mobile/Cargo.toml
index a0d7d497d249ed0e144ff821c4438acd73d25715..08a0a19a63b232cd1d1f282c8472cc8ab8bdefb7 100644
--- a/crates/mobile/Cargo.toml
+++ b/crates/mobile/Cargo.toml
@@ -15,3 +15,4 @@ onestore = { path = "../onestore" }
wgpu.workspace = true
parley.workspace = true
pollster = "0.4"
+serde_json = "1"
diff --git a/crates/mobile/include/snowbound.h b/crates/mobile/include/snowbound.h
index 83fc816a3b1376404b412a395fd90d21bf6650f8..726e45fbef99d7ab41948a9a78dfc511bea9acaa 100644
--- a/crates/mobile/include/snowbound.h
+++ b/crates/mobile/include/snowbound.h
@@ -22,6 +22,7 @@ void sb_view_content(View *view, float bounds[4]);
void sb_view_set_transform(View *view, float zoom, float x, float y);
bool sb_view_tap(View *view, float x, float y);
bool sb_view_in_active_text(const View *view, float x, float y);
+char *sb_view_edit(View *view);
uint32_t sb_text_length(const View *view);
char *sb_text(const View *view, uint32_t start, uint32_t end);
diff --git a/crates/mobile/src/lib.rs b/crates/mobile/src/lib.rs
index ff9b79c1e1462bd4b14fff642abc2cb85aac528e..da46f88aed76a848482c779d784621f072b549c8 100644
--- a/crates/mobile/src/lib.rs
+++ b/crates/mobile/src/lib.rs
@@ -14,7 +14,12 @@ use canvas::{
layout::TextEngine,
};
use draw::edit::{Key, NamedKey, SelectionUnit};
-use onestore::{RevisionIndex, Store, document::Document, page::Page};
+use onestore::{
+ ExGuid, RevisionIndex, Store,
+ document::Document,
+ op::{Edit, Op},
+ page::Page,
+};
use parley::{Affinity, BoundingBox};
use std::{
error::Error,
@@ -40,7 +45,8 @@ const COLORS: TextColors = TextColors {
};
pub struct Section {
- pages: Vec,
+ /// Each page with the object space holding it.
+ pages: Vec<(ExGuid, Page)>,
titles: Vec,
}
@@ -52,11 +58,11 @@ impl Section {
let pages = document
.pages()?
.into_iter()
- .map(|(space, id)| Page::from_revision(document.active(space)?, id))
- .collect::, _>>()?;
+ .map(|(space, id)| Ok((space, Page::from_revision(document.active(space)?, id)?)))
+ .collect::, onestore::Error>>()?;
let titles = pages
.iter()
- .map(|page| CString::new(page.title.replace('\0', "")))
+ .map(|(_, page)| CString::new(page.title.replace('\0', "")))
.collect::>()?;
Ok(Self { pages, titles })
}
@@ -74,6 +80,8 @@ impl Wake for Frame {
pub struct View {
page: PageView,
+ /// The object space of the page shown, which its edits name.
+ space: ExGuid,
surface: wgpu::Surface<'static>,
config: wgpu::SurfaceConfiguration,
renderer: draw::Renderer,
@@ -89,7 +97,12 @@ fn moved(response: Response) -> bool {
}
impl View {
- fn new(layer: *mut c_void, page: Page, size: [f32; 2], scale: f32) -> Result {
+ fn new(
+ layer: *mut c_void,
+ (space, page): (ExGuid, Page),
+ size: [f32; 2],
+ scale: f32,
+ ) -> Result {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
// SAFETY: the host passes a live CAMetalLayer that outlives the view.
let surface = unsafe {
@@ -122,6 +135,7 @@ impl View {
scale,
Duration::from_millis(350),
),
+ space,
surface,
config,
renderer,
@@ -129,6 +143,27 @@ impl View {
})
}
+ /// What the page took since the last call as one edit, or none when it took nothing.
+ fn edit(&mut self) -> Result