From 8b4f45b5b7a6d9b8555e7234a123c9887495d265 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Sat, 12 Sep 2026 02:53:41 -0700 Subject: [PATCH] feat: write hyperlinks through the page model A hyperlink in the page model is what OneNote stores: a hidden field-code run (U+FDDF HYPERLINK "url") followed by the visible label, both flagged as hyperlink runs. The text and formatting writers now treat hidden and hyperlink runs as ordinary text with flags instead of refusing them, and the page writer lowers the hidden, hyperlink and hyperlink-label flags as run attributes; equations, embedded objects and runs with associated data stay refused. Boolean run flags compare as absent-equals-false, so clearing a flag to an absent value writes false once and a rewritten run does not churn. corpus/link-edit is the native gate: a Rust-authored page whose paragraph gained a link, cold-read by OneNote 2010 as an anchor with the URL and label; tools/test_link_edit.py checks it without a VM. Assisted-by: claude-fable-5.1 --- corpus/link-edit/README.md | 17 ++ .../link-edit/candidate/Open Notebook.onetoc2 | Bin 0 -> 1604 bytes corpus/link-edit/candidate/links.one | Bin 0 -> 6128 bytes corpus/link-edit/cold/commands.jsonl | 3 + corpus/link-edit/cold/machine.json | 1 + .../cold/notebook/Open Notebook.onetoc2 | Bin 0 -> 3764 bytes corpus/link-edit/cold/notebook/links.one | Bin 0 -> 7728 bytes corpus/link-edit/cold/read/environment.json | 7 + corpus/link-edit/cold/read/hierarchy.xml | 2 + corpus/link-edit/cold/read/page-000.xml | 3 + corpus/link-edit/cold/read/payloads.json | 3 + corpus/link-edit/cold/run.json | 18 ++ corpus/link-edit/cold/scripts/cold.ps1 | 27 +++ corpus/link-edit/cold/scripts/read.ps1 | 142 +++++++++++++ corpus/link-edit/cold/source.json | 14 ++ corpus/link-edit/cold/teardown.json | 1 + crates/notebook/README.md | 2 +- crates/onestore/README.md | 2 +- crates/onestore/src/edit.rs | 14 +- crates/onestore/src/formatting.rs | 19 +- crates/onestore/src/insertion.rs | 2 +- crates/onestore/src/page/write.rs | 19 +- crates/onestore/src/write/tests.rs | 6 +- crates/onestore/tests/edit.rs | 41 ---- crates/onestore/tests/insertion.rs | 2 +- crates/onestore/tests/page_links.rs | 193 ++++++++++++++++++ tools/test_link_edit.py | 28 +++ 27 files changed, 491 insertions(+), 75 deletions(-) create mode 100644 corpus/link-edit/README.md create mode 100644 corpus/link-edit/candidate/Open Notebook.onetoc2 create mode 100644 corpus/link-edit/candidate/links.one create mode 100644 corpus/link-edit/cold/commands.jsonl create mode 100644 corpus/link-edit/cold/machine.json create mode 100644 corpus/link-edit/cold/notebook/Open Notebook.onetoc2 create mode 100644 corpus/link-edit/cold/notebook/links.one create mode 100644 corpus/link-edit/cold/read/environment.json create mode 100644 corpus/link-edit/cold/read/hierarchy.xml create mode 100644 corpus/link-edit/cold/read/page-000.xml create mode 100644 corpus/link-edit/cold/read/payloads.json create mode 100644 corpus/link-edit/cold/run.json create mode 100644 corpus/link-edit/cold/scripts/cold.ps1 create mode 100644 corpus/link-edit/cold/scripts/read.ps1 create mode 100644 corpus/link-edit/cold/source.json create mode 100644 corpus/link-edit/cold/teardown.json create mode 100644 crates/onestore/tests/page_links.rs create mode 100644 tools/test_link_edit.py diff --git a/corpus/link-edit/README.md b/corpus/link-edit/README.md new file mode 100644 index 0000000000000000000000000000000000000000..8523c8e2e4e58af232847a390d61d65e1e365cbb --- /dev/null +++ b/corpus/link-edit/README.md @@ -0,0 +1,17 @@ +# Rust hyperlink authoring + +`candidate/` is the page writer's output for +`a_link_is_added_to_a_fresh_page_and_reads_back` in +`crates/onestore/tests/page_links.rs`: on a section created in Rust, the +paragraph "Read about Rust" gains a hyperlink the way OneNote stores one, a +hidden field-code run `U+FDDF HYPERLINK "https://example.invalid/rust"` and +the visible label "the Rust site", both flagged as hyperlink runs. The text +and formatting writers treat such runs as ordinary text with flags; equations, +embedded objects and runs with associated data stay refused. + +`cold/` is a fresh OneNote 2010 read: the paragraph text is +`Read about Rust the Rust site`. +`tools/test_link_edit.py` checks this without a VM. Regenerate with +`ONESTORE_LINK_EXPORT` set to a new absolute directory while running the test, +then cold-open it with `tools/native_runner.py OUTPUT COLD --expected-pages 1 +--collect-notebook`. diff --git a/corpus/link-edit/candidate/Open Notebook.onetoc2 b/corpus/link-edit/candidate/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..a96df4a2ddf6080043175389c7cc4d200040eb46 GIT binary patch literal 1604 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 literal 0 HcmV?d00001 diff --git a/corpus/link-edit/cold/notebook/links.one b/corpus/link-edit/cold/notebook/links.one new file mode 100644 index 0000000000000000000000000000000000000000..61850565551658dc11190284aea238c27d1fbb2f GIT binary patch literal 7728 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 + diff --git a/corpus/link-edit/cold/read/page-000.xml b/corpus/link-edit/cold/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..ce77e5bfdb6c190842c3386fc63d610de698dfb5 --- /dev/null +++ b/corpus/link-edit/cold/read/page-000.xml @@ -0,0 +1,3 @@ + +the Rust site]]> diff --git a/corpus/link-edit/cold/read/payloads.json b/corpus/link-edit/cold/read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843 --- /dev/null +++ b/corpus/link-edit/cold/read/payloads.json @@ -0,0 +1,3 @@ +[ + +] \ No newline at end of file diff --git a/corpus/link-edit/cold/run.json b/corpus/link-edit/cold/run.json new file mode 100644 index 0000000000000000000000000000000000000000..11a9f1e01c1954789a5fcf7ffdd848202471f1fa --- /dev/null +++ b/corpus/link-edit/cold/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/private/tmp/m4/links", + "expected_pages": 1, + "author": null, + "author_timeout_seconds": 600, + "inspect": false, + "collect_notebook": true, + "base": { + "file": "win7-office-base.qcow2", + "format": "qcow2", + "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346", + "virtual_size": 68719476736 + }, + "scripts": { + "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", + "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41" + } +} diff --git a/corpus/link-edit/cold/scripts/cold.ps1 b/corpus/link-edit/cold/scripts/cold.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f --- /dev/null +++ b/corpus/link-edit/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/link-edit/cold/scripts/read.ps1 b/corpus/link-edit/cold/scripts/read.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..476094ab1e0c47077988a6461db2080cf208642b --- /dev/null +++ b/corpus/link-edit/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/link-edit/cold/source.json b/corpus/link-edit/cold/source.json new file mode 100644 index 0000000000000000000000000000000000000000..61bfc6b4529d96b3bea2e85f058063632c8bdd9e --- /dev/null +++ b/corpus/link-edit/cold/source.json @@ -0,0 +1,14 @@ +[ + { + "path": "Open Notebook.onetoc2", + "bytes": 1604, + "sha256": "6731c4214437fe1cb6aa347b0004542b7f7e4260f47773bf9ec07c4e894de47c", + "mtime_ns": 1789206556176938556 + }, + { + "path": "links.one", + "bytes": 6128, + "sha256": "0cfb356ed374fe919f73fb73fdbe5d66e0baa484d4119f70074d9f869d6db78a", + "mtime_ns": 1789206556176648597 + } +] diff --git a/corpus/link-edit/cold/teardown.json b/corpus/link-edit/cold/teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/link-edit/cold/teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/crates/notebook/README.md b/crates/notebook/README.md index ac5345e728727a4afc3dc882aca1a9d99c620ed0..1d1ae9d4f51e9c682863df7666f5493b7cd69975 100644 --- a/crates/notebook/README.md +++ b/crates/notebook/README.md @@ -14,7 +14,7 @@ diffs the supplied model against the page stored in the supplied local snapshot, writes the difference into the working image and queues one `Operation::Page(PageIntent { before, after, author })`; `before` is the page the edit started from and is the precondition reconciliation checks. Text, styles, -paragraph formatting, bullets, numbering and note tags, paragraph structure, +paragraph formatting, hyperlinks, bullets, numbering and note tags, paragraph structure, outline layout, insertions and deletions are all differences between `before` and `after`; the library never sees editor operations. diff --git a/crates/onestore/README.md b/crates/onestore/README.md index 3dfe7becde89b878f9f43de021d4f6e3df138805..8c58b5d6df89604fd4ee49f3d91bb06f82c6980e 100644 --- a/crates/onestore/README.md +++ b/crates/onestore/README.md @@ -57,7 +57,7 @@ harness also accepts `--client-profile release`. | `ParagraphJoin`, `PreparedEdit::join` | Join adjacent ordinary text while preserving inherited character styles and native text-identity rules | | `TextAttribute`, `PreparedEdit::format` | Change character formatting over a UTF-16 range while sharing immutable styles; preserve unselected runs | | `OutlineEdit`, `PreparedEdit::outline` | Change ordinary outline position/width or a paragraph's saved expansion default, preserving identities and content | -| `PreparedEdit::page` | Publish an edited `page::Page` as one revision per changed space: text, character and paragraph formatting, bullets and numbering, note tags, paragraph insertion/split/join/move/deletion, outline insertion/deletion/position/width and saved collapse state, lowered onto the typed writers with the model's identities | +| `PreparedEdit::page` | Publish an edited `page::Page` as one revision per changed space: text, character and paragraph formatting, hyperlinks, bullets and numbering, note tags, paragraph insertion/split/join/move/deletion, outline insertion/deletion/position/width and saved collapse state, lowered onto the typed writers with the model's identities | | `TreeEdit`, `PreparedEdit::tree` | Move or delete a subtree on one page, normalize surviving containers, and replace an emptied table cell's paragraph atomically | | `PreparedEdit::commit`, `PreparedEdit::commit_file` | Publish the exact prepared image under caller-held exclusion or the conservative filesystem adapter | | `read_file` | Read a snapshot under whole-file exclusion | diff --git a/crates/onestore/src/edit.rs b/crates/onestore/src/edit.rs index 9b23042318ed2bdc97a37c87bfa0ae1c54a63920..f28739636606a7f56878110da26fa6e417cc7ed2 100644 --- a/crates/onestore/src/edit.rs +++ b/crates/onestore/src/edit.rs @@ -36,7 +36,7 @@ pub fn replace_text( replacement: &str, ) -> Result, Error> { let invalid = |message| Error { offset: 0, message }; - if range.start > range.end || replacement.contains(['\0', '\n', '\r', '\u{fffc}', '\u{fddf}']) { + if range.start > range.end || replacement.contains(['\0', '\n', '\r', '\u{fffc}']) { return Err(invalid( "Use a valid text range and ordinary paragraph text", )); @@ -82,15 +82,11 @@ pub fn replace_text( let resolved = revision.text_runs(object)?; for (i, run) in runs.iter().enumerate() { if i == selected || (run.start < range.end && range.start < run.end) { + // Hyperlink field codes and their hidden runs are ordinary text with flags; + // embedded objects and equations are not. let format = &resolved[i].format; - if [ - format.hidden, - format.hyperlink, - format.math, - format.embedded_object, - ] - .contains(&Some(true)) - || resolved[i].text.contains(['\u{fffc}', '\u{fddf}']) + if [format.math, format.embedded_object].contains(&Some(true)) + || resolved[i].text.contains('\u{fffc}') || run.extra_set.is_some_and(|set| !node.extra[set].is_empty()) { return Err(invalid("This text run contains a field or embedded data")); diff --git a/crates/onestore/src/formatting.rs b/crates/onestore/src/formatting.rs index b6cb67e5f346d7843cb829368c9fa889756d8678..d6a4a78f1eac3b36792c1915e337005a88dcb2aa 100644 --- a/crates/onestore/src/formatting.rs +++ b/crates/onestore/src/formatting.rs @@ -35,6 +35,12 @@ pub enum TextAttribute { Color(Option<[u8; 3]>), /// RGB, or None to clear highlighting. Highlight(Option<[u8; 3]>), + /// Hidden text, such as a hyperlink's field code. + Hidden(bool), + /// The run belongs to a hyperlink field. + Hyperlink(bool), + /// The run is a hyperlink's visible label or field code rather than surrounding text. + HyperlinkLabel(bool), } impl TextAttribute { @@ -47,6 +53,9 @@ impl TextAttribute { Self::Strike(value) => boolean(0x08001c07, *value), Self::Superscript(value) => boolean(0x08001c08, *value), Self::Subscript(value) => boolean(0x08001c09, *value), + Self::Hidden(value) => boolean(0x08001e16, *value), + Self::Hyperlink(value) => boolean(0x08001e14, *value), + Self::HyperlinkLabel(value) => boolean(0x08001e19, *value), Self::Font(font) => { if font.is_empty() || font.contains('\0') { return Err(invalid("Font names must be nonempty and contain no NUL")); @@ -141,14 +150,8 @@ pub(crate) fn format_text( }; if selected { let format = &resolved[i].format; - if [ - format.hidden, - format.hyperlink, - format.math, - format.embedded_object, - ] - .contains(&Some(true)) - || resolved[i].text.contains(['\u{fffc}', '\u{fddf}']) + if [format.math, format.embedded_object].contains(&Some(true)) + || resolved[i].text.contains('\u{fffc}') { return Err(invalid( "This format range contains a field or embedded data", diff --git a/crates/onestore/src/insertion.rs b/crates/onestore/src/insertion.rs index 38ccedb89e0665d1182366002935cd3b7dffb01a..7a9989e2c9cb8718a199b5803c159468985b3ff2 100644 --- a/crates/onestore/src/insertion.rs +++ b/crates/onestore/src/insertion.rs @@ -143,7 +143,7 @@ impl Insertion { fn validate(&self) -> Result<(), Error> { if self.guid == [0; 16] || self.parent.guid == [0; 16] - || self.text.contains(['\0', '\n', '\u{fffc}', '\u{fddf}']) + || self.text.contains(['\0', '\n', '\u{fffc}']) || self.author.contains('\0') { return Err(invalid( diff --git a/crates/onestore/src/page/write.rs b/crates/onestore/src/page/write.rs index 2963b0ca719940645b3e343780ae32d2114513a6..c69cc31bf9a1d6b74f23d6306950cb9ff58d826c 100644 --- a/crates/onestore/src/page/write.rs +++ b/crates/onestore/src/page/write.rs @@ -1388,14 +1388,11 @@ fn text_edit(before: &str, after: &str) -> Result<(Range, String), Error> { fn attributes(current: &Format, target: &Format, fresh: bool) -> Result, Error> { let mut out = Vec::new(); let inherited = || invalid("Inherited character formatting cannot be restored"); + // An absent flag and an explicit false are the same formatting. macro_rules! boolean { ($field:ident, $variant:ident) => { - if current.$field != target.$field { - match target.$field { - Some(value) => out.push(TextAttribute::$variant(value)), - None if fresh => {} - None => return Err(inherited()), - } + if current.$field.unwrap_or(false) != target.$field.unwrap_or(false) { + out.push(TextAttribute::$variant(target.$field.unwrap_or(false))); } }; } @@ -1405,6 +1402,9 @@ fn attributes(current: &Format, target: &Format, fresh: bool) -> Result out.push(TextAttribute::Font(font.clone())), @@ -1438,10 +1438,7 @@ fn attributes(current: &Format, target: &Format, fresh: bool) -> Result, b: Option| a.unwrap_or(false) == b.unwrap_or(false); let points = |a: Option, b: Option| a.unwrap_or(0.0) == b.unwrap_or(0.0); - let same_rest = flag(current.hidden, target.hidden) - && flag(current.hyperlink, target.hyperlink) - && flag(current.hyperlink_label, target.hyperlink_label) - && flag(current.math, target.math) + let same_rest = flag(current.math, target.math) && flag(current.embedded_object, target.embedded_object) && current.alignment.unwrap_or(0) == target.alignment.unwrap_or(0) && flag(current.rtl, target.rtl) @@ -1451,7 +1448,7 @@ fn attributes(current: &Format, target: &Format, fresh: bool) -> Result