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 Binary files /dev/null and b/corpus/link-edit/candidate/Open Notebook.onetoc2 differ diff --git a/corpus/link-edit/candidate/links.one b/corpus/link-edit/candidate/links.one new file mode 100644 index 0000000000000000000000000000000000000000..4cbca97725d44caeb050bf5bacec8c1573656f90 Binary files /dev/null and b/corpus/link-edit/candidate/links.one differ diff --git a/corpus/link-edit/cold/commands.jsonl b/corpus/link-edit/cold/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..153cecc2b43816e26564e2809d53a5f2b78d6730 --- /dev/null +++ b/corpus/link-edit/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-D4BD156B -ExpectedPages 1", "exit": 0, "stdout": "Read 1 sections and 1 pages.\r\n", "stderr": "", "error": null} +{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null} diff --git a/corpus/link-edit/cold/machine.json b/corpus/link-edit/cold/machine.json new file mode 100644 index 0000000000000000000000000000000000000000..3ab2844349f6e45bfa35663d2d96273dea51c924 --- /dev/null +++ b/corpus/link-edit/cold/machine.json @@ -0,0 +1 @@ +{"name": "m6-d4bd156b", "hostname": "ONE-M6-D4BD156B"} diff --git a/corpus/link-edit/cold/notebook/Open Notebook.onetoc2 b/corpus/link-edit/cold/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..e5fa4f8b677df1680df48d5cbd4e87bc169de374 Binary files /dev/null and b/corpus/link-edit/cold/notebook/Open Notebook.onetoc2 differ 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 Binary files /dev/null and b/corpus/link-edit/cold/notebook/links.one differ diff --git a/corpus/link-edit/cold/read/environment.json b/corpus/link-edit/cold/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..c6c02c7f48b10e1d5f5b68dc58650b7a79d5d2d6 --- /dev/null +++ b/corpus/link-edit/cold/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-D4BD156B", + "cold": true, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/link-edit/cold/read/hierarchy.xml b/corpus/link-edit/cold/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..52900b36abcad9c6630cf100c0374fb2806e552f --- /dev/null +++ b/corpus/link-edit/cold/read/hierarchy.xml @@ -0,0 +1,2 @@ + + 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