authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-12 02:53:41-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-12 17:00:32-07:00
log8b4f45b5b7a6d9b8555e7234a123c9887495d265
treeeb27d721da9a0b2911611aa13b5a46ead0696b50
parent190fb2d10c46ced562a7f0d96f63aa803f4b47d4
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

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

27 files changed, 491 insertions(+), 75 deletions(-)

corpus/link-edit/README.md created+17
...@@ -0,0 +1,17 @@
1# Rust hyperlink authoring
2
3`candidate/` is the page writer's output for
4`a_link_is_added_to_a_fresh_page_and_reads_back` in
5`crates/onestore/tests/page_links.rs`: on a section created in Rust, the
6paragraph "Read about Rust" gains a hyperlink the way OneNote stores one, a
7hidden field-code run `U+FDDF HYPERLINK "https://example.invalid/rust"` and
8the visible label "the Rust site", both flagged as hyperlink runs. The text
9and formatting writers treat such runs as ordinary text with flags; equations,
10embedded objects and runs with associated data stay refused.
11
12`cold/` is a fresh OneNote 2010 read: the paragraph text is
13`Read about Rust <a href="https://example.invalid/rust">the Rust site</a>`.
14`tools/test_link_edit.py` checks this without a VM. Regenerate with
15`ONESTORE_LINK_EXPORT` set to a new absolute directory while running the test,
16then cold-open it with `tools/native_runner.py OUTPUT COLD --expected-pages 1
17--collect-notebook`.
corpus/link-edit/candidate/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/link-edit/candidate/Open Notebook.onetoc2 differ
corpus/link-edit/candidate/links.one created
Binary files /dev/null and b/corpus/link-edit/candidate/links.one differ
corpus/link-edit/cold/commands.jsonl created+3
...@@ -0,0 +1,3 @@
1{"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}
2{"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}
3{"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}
corpus/link-edit/cold/machine.json created+1
...@@ -0,0 +1 @@
1{"name": "m6-d4bd156b", "hostname": "ONE-M6-D4BD156B"}
corpus/link-edit/cold/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/link-edit/cold/notebook/Open Notebook.onetoc2 differ
corpus/link-edit/cold/notebook/links.one created
Binary files /dev/null and b/corpus/link-edit/cold/notebook/links.one differ
corpus/link-edit/cold/read/environment.json created+7
...@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-D4BD156B",
5 "cold": true,
6 "onenote": "14.0.4763.1000"
7}
corpus/link-edit/cold/read/hierarchy.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{6DA07119-26C3-4DA6-A65C-1F33B76209F1}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-12T09:50:50.000Z" color="#B49EDE"><one:Section name="links" ID="{BD233792-FF1C-04B6-06F5-BAA64CF04510}{1}{B0}" path="C:\one-tests\runs\capture\notebook\links.one" lastModifiedTime="2026-09-12T09:50:50.000Z" color="#8AA8E4"><one:Page ID="{1C2EF11B-970B-0B8B-105E-9AC6B413CCEB}{1}{B0}" name="Read about Rust ﷟HYPERLINK &quot;https://example.invalid/rust&quot;the Rust site" dateTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-12T09:50:50.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/link-edit/cold/read/page-000.xml created+3
...@@ -0,0 +1,3 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{1C2EF11B-970B-0B8B-105E-9AC6B413CCEB}{1}{B0}" name="Read about Rust ﷟HYPERLINK &quot;https://example.invalid/rust&quot;the Rust site" dateTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-12T09:50:50.000Z" pageLevel="1" style="font-family:Calibri;font-size:11.0pt" lang="en-US"><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Outline author="Author" lastModifiedBy="Author" lastModifiedTime="2026-09-12T09:49:16.000Z" objectID="{BD233792-FF1C-04B6-06F5-BAA64CF04510}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="198.3904571533203" height="13.4277172088623"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-12T09:49:16.000Z" objectID="{BD233792-FF1C-04B6-06F5-BAA64CF04510}{24}{B0}" alignment="left"><one:T><![CDATA[Read about Rust <a
3href="https://example.invalid/rust">the Rust site</a>]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/link-edit/cold/read/payloads.json created+3
...@@ -0,0 +1,3 @@
1[
2
3]
\ No newline at end of file
corpus/link-edit/cold/run.json created+18
...@@ -0,0 +1,18 @@
1{
2 "notebook": "/private/tmp/m4/links",
3 "expected_pages": 1,
4 "author": null,
5 "author_timeout_seconds": 600,
6 "inspect": false,
7 "collect_notebook": true,
8 "base": {
9 "file": "win7-office-base.qcow2",
10 "format": "qcow2",
11 "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
12 "virtual_size": 68719476736
13 },
14 "scripts": {
15 "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
16 "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41"
17 }
18}
corpus/link-edit/cold/scripts/cold.ps1 created+27
...@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/link-edit/cold/scripts/read.ps1 created+142
...@@ -0,0 +1,142 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID');
103 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
104 sha256 = $hash; bytes = $bytes.Length }
105 }
106 $index++
107 }
108 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
109 $all = ''
110 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
111 [xml]$finalTree = $all
112 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
113 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
114 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
115 }
116 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
117 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
118} catch {
119 $failure = $_
120 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
121 throw
122} finally {
123 try {
124 try {
125 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
126 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
127 $app.CloseNotebook($notebookId, $false)
128 }
129 } catch {
130 if ($null -eq $failure) { throw }
131 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
132 }
133 } finally {
134 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
135 }
136 $app = $null
137 [GC]::Collect()
138 [GC]::WaitForPendingFinalizers()
139 if (-not $UseCurrentCache -and -not $CloneHost) {
140 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
141 }
142}
corpus/link-edit/cold/source.json created+14
...@@ -0,0 +1,14 @@
1[
2 {
3 "path": "Open Notebook.onetoc2",
4 "bytes": 1604,
5 "sha256": "6731c4214437fe1cb6aa347b0004542b7f7e4260f47773bf9ec07c4e894de47c",
6 "mtime_ns": 1789206556176938556
7 },
8 {
9 "path": "links.one",
10 "bytes": 6128,
11 "sha256": "0cfb356ed374fe919f73fb73fdbe5d66e0baa484d4119f70074d9f869d6db78a",
12 "mtime_ns": 1789206556176648597
13 }
14]
corpus/link-edit/cold/teardown.json created+1
...@@ -0,0 +1 @@
1{"absent": true}
crates/notebook/README.md+1-1
...@@ -14,7 +14,7 @@ diffs the supplied model against the page stored in the supplied local snapshot,...@@ -14,7 +14,7 @@ diffs the supplied model against the page stored in the supplied local snapshot,
14writes the difference into the working image and queues one14writes the difference into the working image and queues one
15`Operation::Page(PageIntent { before, after, author })`; `before` is the page the15`Operation::Page(PageIntent { before, after, author })`; `before` is the page the
16edit started from and is the precondition reconciliation checks. Text, styles,16edit started from and is the precondition reconciliation checks. Text, styles,
17paragraph formatting, bullets, numbering and note tags, paragraph structure,17paragraph formatting, hyperlinks, bullets, numbering and note tags, paragraph structure,
18outline layout, insertions and deletions are all differences between `before` and18outline layout, insertions and deletions are all differences between `before` and
19`after`; the library never sees editor operations.19`after`; the library never sees editor operations.
2020
crates/onestore/README.md+1-1
...@@ -57,7 +57,7 @@ harness also accepts `--client-profile release`....@@ -57,7 +57,7 @@ harness also accepts `--client-profile release`.
57| `ParagraphJoin`, `PreparedEdit::join` | Join adjacent ordinary text while preserving inherited character styles and native text-identity rules |57| `ParagraphJoin`, `PreparedEdit::join` | Join adjacent ordinary text while preserving inherited character styles and native text-identity rules |
58| `TextAttribute`, `PreparedEdit::format` | Change character formatting over a UTF-16 range while sharing immutable styles; preserve unselected runs |58| `TextAttribute`, `PreparedEdit::format` | Change character formatting over a UTF-16 range while sharing immutable styles; preserve unselected runs |
59| `OutlineEdit`, `PreparedEdit::outline` | Change ordinary outline position/width or a paragraph's saved expansion default, preserving identities and content |59| `OutlineEdit`, `PreparedEdit::outline` | Change ordinary outline position/width or a paragraph's saved expansion default, preserving identities and content |
60| `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 |60| `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 |
61| `TreeEdit`, `PreparedEdit::tree` | Move or delete a subtree on one page, normalize surviving containers, and replace an emptied table cell's paragraph atomically |61| `TreeEdit`, `PreparedEdit::tree` | Move or delete a subtree on one page, normalize surviving containers, and replace an emptied table cell's paragraph atomically |
62| `PreparedEdit::commit`, `PreparedEdit::commit_file` | Publish the exact prepared image under caller-held exclusion or the conservative filesystem adapter |62| `PreparedEdit::commit`, `PreparedEdit::commit_file` | Publish the exact prepared image under caller-held exclusion or the conservative filesystem adapter |
63| `read_file` | Read a snapshot under whole-file exclusion |63| `read_file` | Read a snapshot under whole-file exclusion |
crates/onestore/src/edit.rs+5-9
...@@ -36,7 +36,7 @@ pub fn replace_text(...@@ -36,7 +36,7 @@ pub fn replace_text(
36 replacement: &str,36 replacement: &str,
37) -> Result<Vec<u8>, Error> {37) -> Result<Vec<u8>, Error> {
38 let invalid = |message| Error { offset: 0, message };38 let invalid = |message| Error { offset: 0, message };
39 if range.start > range.end || replacement.contains(['\0', '\n', '\r', '\u{fffc}', '\u{fddf}']) {39 if range.start > range.end || replacement.contains(['\0', '\n', '\r', '\u{fffc}']) {
40 return Err(invalid(40 return Err(invalid(
41 "Use a valid text range and ordinary paragraph text",41 "Use a valid text range and ordinary paragraph text",
42 ));42 ));
...@@ -82,15 +82,11 @@ pub fn replace_text(...@@ -82,15 +82,11 @@ pub fn replace_text(
82 let resolved = revision.text_runs(object)?;82 let resolved = revision.text_runs(object)?;
83 for (i, run) in runs.iter().enumerate() {83 for (i, run) in runs.iter().enumerate() {
84 if i == selected || (run.start < range.end && range.start < run.end) {84 if i == selected || (run.start < range.end && range.start < run.end) {
85 // Hyperlink field codes and their hidden runs are ordinary text with flags;
86 // embedded objects and equations are not.
85 let format = &resolved[i].format;87 let format = &resolved[i].format;
86 if [88 if [format.math, format.embedded_object].contains(&Some(true))
87 format.hidden,89 || resolved[i].text.contains('\u{fffc}')
88 format.hyperlink,
89 format.math,
90 format.embedded_object,
91 ]
92 .contains(&Some(true))
93 || resolved[i].text.contains(['\u{fffc}', '\u{fddf}'])
94 || run.extra_set.is_some_and(|set| !node.extra[set].is_empty())90 || run.extra_set.is_some_and(|set| !node.extra[set].is_empty())
95 {91 {
96 return Err(invalid("This text run contains a field or embedded data"));92 return Err(invalid("This text run contains a field or embedded data"));
crates/onestore/src/formatting.rs+11-8
...@@ -35,6 +35,12 @@ pub enum TextAttribute {...@@ -35,6 +35,12 @@ pub enum TextAttribute {
35 Color(Option<[u8; 3]>),35 Color(Option<[u8; 3]>),
36 /// RGB, or None to clear highlighting.36 /// RGB, or None to clear highlighting.
37 Highlight(Option<[u8; 3]>),37 Highlight(Option<[u8; 3]>),
38 /// Hidden text, such as a hyperlink's field code.
39 Hidden(bool),
40 /// The run belongs to a hyperlink field.
41 Hyperlink(bool),
42 /// The run is a hyperlink's visible label or field code rather than surrounding text.
43 HyperlinkLabel(bool),
38}44}
3945
40impl TextAttribute {46impl TextAttribute {
...@@ -47,6 +53,9 @@ impl TextAttribute {...@@ -47,6 +53,9 @@ impl TextAttribute {
47 Self::Strike(value) => boolean(0x08001c07, *value),53 Self::Strike(value) => boolean(0x08001c07, *value),
48 Self::Superscript(value) => boolean(0x08001c08, *value),54 Self::Superscript(value) => boolean(0x08001c08, *value),
49 Self::Subscript(value) => boolean(0x08001c09, *value),55 Self::Subscript(value) => boolean(0x08001c09, *value),
56 Self::Hidden(value) => boolean(0x08001e16, *value),
57 Self::Hyperlink(value) => boolean(0x08001e14, *value),
58 Self::HyperlinkLabel(value) => boolean(0x08001e19, *value),
50 Self::Font(font) => {59 Self::Font(font) => {
51 if font.is_empty() || font.contains('\0') {60 if font.is_empty() || font.contains('\0') {
52 return Err(invalid("Font names must be nonempty and contain no NUL"));61 return Err(invalid("Font names must be nonempty and contain no NUL"));
...@@ -141,14 +150,8 @@ pub(crate) fn format_text(...@@ -141,14 +150,8 @@ pub(crate) fn format_text(
141 };150 };
142 if selected {151 if selected {
143 let format = &resolved[i].format;152 let format = &resolved[i].format;
144 if [153 if [format.math, format.embedded_object].contains(&Some(true))
145 format.hidden,154 || resolved[i].text.contains('\u{fffc}')
146 format.hyperlink,
147 format.math,
148 format.embedded_object,
149 ]
150 .contains(&Some(true))
151 || resolved[i].text.contains(['\u{fffc}', '\u{fddf}'])
152 {155 {
153 return Err(invalid(156 return Err(invalid(
154 "This format range contains a field or embedded data",157 "This format range contains a field or embedded data",
crates/onestore/src/insertion.rs+1-1
...@@ -143,7 +143,7 @@ impl Insertion {...@@ -143,7 +143,7 @@ impl Insertion {
143 fn validate(&self) -> Result<(), Error> {143 fn validate(&self) -> Result<(), Error> {
144 if self.guid == [0; 16]144 if self.guid == [0; 16]
145 || self.parent.guid == [0; 16]145 || self.parent.guid == [0; 16]
146 || self.text.contains(['\0', '\n', '\u{fffc}', '\u{fddf}'])146 || self.text.contains(['\0', '\n', '\u{fffc}'])
147 || self.author.contains('\0')147 || self.author.contains('\0')
148 {148 {
149 return Err(invalid(149 return Err(invalid(
crates/onestore/src/page/write.rs+8-11
...@@ -1388,14 +1388,11 @@ fn text_edit(before: &str, after: &str) -> Result<(Range<u32>, String), Error> {...@@ -1388,14 +1388,11 @@ fn text_edit(before: &str, after: &str) -> Result<(Range<u32>, String), Error> {
1388fn attributes(current: &Format, target: &Format, fresh: bool) -> Result<Vec<TextAttribute>, Error> {1388fn attributes(current: &Format, target: &Format, fresh: bool) -> Result<Vec<TextAttribute>, Error> {
1389 let mut out = Vec::new();1389 let mut out = Vec::new();
1390 let inherited = || invalid("Inherited character formatting cannot be restored");1390 let inherited = || invalid("Inherited character formatting cannot be restored");
1391 // An absent flag and an explicit false are the same formatting.
1391 macro_rules! boolean {1392 macro_rules! boolean {
1392 ($field:ident, $variant:ident) => {1393 ($field:ident, $variant:ident) => {
1393 if current.$field != target.$field {1394 if current.$field.unwrap_or(false) != target.$field.unwrap_or(false) {
1394 match target.$field {1395 out.push(TextAttribute::$variant(target.$field.unwrap_or(false)));
1395 Some(value) => out.push(TextAttribute::$variant(value)),
1396 None if fresh => {}
1397 None => return Err(inherited()),
1398 }
1399 }1396 }
1400 };1397 };
1401 }1398 }
...@@ -1405,6 +1402,9 @@ fn attributes(current: &Format, target: &Format, fresh: bool) -> Result<Vec<Text...@@ -1405,6 +1402,9 @@ fn attributes(current: &Format, target: &Format, fresh: bool) -> Result<Vec<Text
1405 boolean!(strike, Strike);1402 boolean!(strike, Strike);
1406 boolean!(superscript, Superscript);1403 boolean!(superscript, Superscript);
1407 boolean!(subscript, Subscript);1404 boolean!(subscript, Subscript);
1405 boolean!(hidden, Hidden);
1406 boolean!(hyperlink, Hyperlink);
1407 boolean!(hyperlink_label, HyperlinkLabel);
1408 if current.font != target.font {1408 if current.font != target.font {
1409 match &target.font {1409 match &target.font {
1410 Some(font) => out.push(TextAttribute::Font(font.clone())),1410 Some(font) => out.push(TextAttribute::Font(font.clone())),
...@@ -1438,10 +1438,7 @@ fn attributes(current: &Format, target: &Format, fresh: bool) -> Result<Vec<Text...@@ -1438,10 +1438,7 @@ fn attributes(current: &Format, target: &Format, fresh: bool) -> Result<Vec<Text
1438 // An absent value and its stored default are the same formatting.1438 // An absent value and its stored default are the same formatting.
1439 let flag = |a: Option<bool>, b: Option<bool>| a.unwrap_or(false) == b.unwrap_or(false);1439 let flag = |a: Option<bool>, b: Option<bool>| a.unwrap_or(false) == b.unwrap_or(false);
1440 let points = |a: Option<f32>, b: Option<f32>| a.unwrap_or(0.0) == b.unwrap_or(0.0);1440 let points = |a: Option<f32>, b: Option<f32>| a.unwrap_or(0.0) == b.unwrap_or(0.0);
1441 let same_rest = flag(current.hidden, target.hidden)1441 let same_rest = flag(current.math, target.math)
1442 && flag(current.hyperlink, target.hyperlink)
1443 && flag(current.hyperlink_label, target.hyperlink_label)
1444 && flag(current.math, target.math)
1445 && flag(current.embedded_object, target.embedded_object)1442 && flag(current.embedded_object, target.embedded_object)
1446 && current.alignment.unwrap_or(0) == target.alignment.unwrap_or(0)1443 && current.alignment.unwrap_or(0) == target.alignment.unwrap_or(0)
1447 && flag(current.rtl, target.rtl)1444 && flag(current.rtl, target.rtl)
...@@ -1451,7 +1448,7 @@ fn attributes(current: &Format, target: &Format, fresh: bool) -> Result<Vec<Text...@@ -1451,7 +1448,7 @@ fn attributes(current: &Format, target: &Format, fresh: bool) -> Result<Vec<Text
1451 && points(current.list_spacing, target.list_spacing);1448 && points(current.list_spacing, target.list_spacing);
1452 if !same_rest {1449 if !same_rest {
1453 return Err(invalid(1450 return Err(invalid(
1454 "Fields, links, language and paragraph spacing cannot be edited through the page model",1451 "Fields, language and paragraph spacing cannot be edited through the page model",
1455 ));1452 ));
1456 }1453 }
1457 Ok(out)1454 Ok(out)
crates/onestore/src/write/tests.rs+4-2
...@@ -884,10 +884,12 @@ fn character_formatting_preserves_inheritance_and_associated_data() {...@@ -884,10 +884,12 @@ fn character_formatting_preserves_inheritance_and_associated_data() {
884 );884 );
885 continue;885 continue;
886 }886 }
887 // Hidden and hyperlink runs are ordinary text with flags; associated data,
888 // equations and embedded objects are not.
887 let text_edit = PreparedEdit::text(&fixture, sid, text, 1..5, "x");889 let text_edit = PreparedEdit::text(&fixture, sid, text, 1..5, "x");
888 assert_eq!(text_edit.is_ok(), variant == 0);890 assert_eq!(text_edit.is_ok(), matches!(variant, 0 | 1 | 4));
889 let edit = PreparedEdit::format(&fixture, sid, text, 0..6, &[TextAttribute::Bold(true)]);891 let edit = PreparedEdit::format(&fixture, sid, text, 0..6, &[TextAttribute::Bold(true)]);
890 if matches!(variant, 1 | 2 | 4 | 5 | 6) {892 if matches!(variant, 2 | 5 | 6) {
891 assert!(edit.is_err());893 assert!(edit.is_err());
892 continue;894 continue;
893 }895 }
crates/onestore/tests/edit.rs-41
...@@ -371,7 +371,6 @@ fn invalid_text_edits_never_touch_storage() {...@@ -371,7 +371,6 @@ fn invalid_text_edits_never_touch_storage() {
371 (0..0, "\n"),371 (0..0, "\n"),
372 (0..0, "\0"),372 (0..0, "\0"),
373 (0..0, "\u{fffc}"),373 (0..0, "\u{fffc}"),
374 (0..0, "\u{fddf}"),
375 ] {374 ] {
376 let mut disk = Disk {375 let mut disk = Disk {
377 visible: source.clone(),376 visible: source.clone(),
...@@ -389,46 +388,6 @@ fn invalid_text_edits_never_touch_storage() {...@@ -389,46 +388,6 @@ fn invalid_text_edits_never_touch_storage() {
389 }388 }
390}389}
391390
392#[test]
393fn cross_run_edits_reject_native_fields_before_storage_io() {
394 let source = include_bytes!("../../../corpus/m6/native-probes-01/notebook/synthetic.one");
395 let store = Store::parse(source).unwrap();
396 let index = RevisionIndex::parse(&store).unwrap();
397 let document = Document::parse(&index).unwrap();
398 let mut fields = 0;
399 for (sid, space) in &document.spaces {
400 let revision = &space.revisions[&space.contexts[&ExGuid::default()]];
401 for (oid, node) in &revision.nodes {
402 let Kind::RichText { text, runs, .. } = &node.kind else {
403 continue;
404 };
405 let resolved = revision.text_runs(*oid).unwrap();
406 for (run, resolved) in runs.iter().zip(resolved) {
407 if resolved.format.hyperlink != Some(true) {
408 continue;
409 }
410 fields += 1;
411 for range in [0..run.end, run.start..text.encode_utf16().count() as u32] {
412 let mut disk = Disk {
413 visible: source.to_vec(),
414 durable: source.to_vec(),
415 operation: 0,
416 fail_at: None,
417 write_limit: 17,
418 random: 9,
419 };
420 let error = onestore::commit_text(&mut disk, source, *sid, *oid, range, "x")
421 .unwrap_err();
422 assert_eq!(error.state, CommitState::NotCommitted);
423 assert_eq!(disk.operation, 0);
424 assert_eq!(disk.durable, source);
425 }
426 }
427 }
428 }
429 assert!(fields > 0);
430}
431
432#[test]391#[test]
433fn cross_run_splices_match_a_character_model_and_preserve_history() {392fn cross_run_splices_match_a_character_model_and_preserve_history() {
434 let (sid, oid) = target(SOURCE);393 let (sid, oid) = target(SOURCE);
crates/onestore/tests/insertion.rs+1-1
...@@ -616,7 +616,7 @@ fn anchors_targets_serialized_identities_and_text_are_validated_before_publicati...@@ -616,7 +616,7 @@ fn anchors_targets_serialized_identities_and_text_are_validated_before_publicati
616 let (sid, page, outline, paragraph, text) = targets(&source);616 let (sid, page, outline, paragraph, text) = targets(&source);
617 assert!(Insertion::outline(page, f32::NAN, 0.0, "Text", "Author").is_err());617 assert!(Insertion::outline(page, f32::NAN, 0.0, "Text", "Author").is_err());
618 assert!(Insertion::outline(page, 0.0, f32::INFINITY, "Text", "Author").is_err());618 assert!(Insertion::outline(page, 0.0, f32::INFINITY, "Text", "Author").is_err());
619 for content in ["a\0b", "a\nb", "a\u{fffc}b", "a\u{fddf}b"] {619 for content in ["a\0b", "a\nb", "a\u{fffc}b"] {
620 assert!(Insertion::paragraph(outline, None, content, "Author").is_err());620 assert!(Insertion::paragraph(outline, None, content, "Author").is_err());
621 }621 }
622 assert!(Insertion::paragraph(outline, None, "Text", "a\0b").is_err());622 assert!(Insertion::paragraph(outline, None, "Text", "a\0b").is_err());
crates/onestore/tests/page_links.rs created+193
...@@ -0,0 +1,193 @@
1use onestore::{
2 ExGuid, PreparedEdit, RevisionIndex, Store,
3 document::Document,
4 page::{Page, PageObject, PageParagraph, Paragraph, text::Edit},
5};
6
7const OUTLINES: &[u8] =
8 include_bytes!("../../../corpus/outline-edit/before/notebook/synthetic.one");
9const AUTHOR: &str = "Link author";
10const CODE: &str = "\u{fddf}HYPERLINK \"https://example.invalid/rust\"";
11
12fn page_by_title(bytes: &[u8], title: &str) -> (ExGuid, Page) {
13 let store = Store::parse(bytes).unwrap();
14 assert!(store.checksum_mismatches.is_empty());
15 let index = RevisionIndex::parse(&store).unwrap();
16 let document = Document::parse(&index).unwrap();
17 document
18 .pages()
19 .unwrap()
20 .into_iter()
21 .find_map(|(space, _)| {
22 let page = Page::from_space(&document, space).unwrap();
23 (page.title == title).then_some((space, page))
24 })
25 .unwrap()
26}
27
28fn page_in(bytes: &[u8], space: ExGuid) -> Page {
29 let store = Store::parse(bytes).unwrap();
30 assert!(store.checksum_mismatches.is_empty());
31 let index = RevisionIndex::parse(&store).unwrap();
32 Page::from_space(&Document::parse(&index).unwrap(), space).unwrap()
33}
34
35fn body_paragraphs(page: &mut Page) -> &mut Vec<PageParagraph> {
36 page.objects
37 .iter_mut()
38 .find_map(|object| match object {
39 PageObject::Outline(outline) if !outline.title => Some(&mut outline.paragraphs),
40 _ => None,
41 })
42 .unwrap()
43}
44
45/// Language tags stay as stored, so the comparison leaves them out.
46fn without_language(mut page: Page) -> Page {
47 for object in &mut page.objects {
48 if let PageObject::Outline(outline) = object {
49 for paragraph in &mut outline.paragraphs {
50 if let Some(text) = paragraph.text_mut() {
51 let mut start = 0;
52 text.text = Paragraph::from_runs(text.text.spans().iter().map(|span| {
53 let value = text.text.text()[start..span.end].to_owned();
54 start = span.end;
55 let mut format = span.format.clone();
56 format.language = None;
57 (value, format)
58 }));
59 }
60 }
61 }
62 }
63 page
64}
65
66fn assert_same(written: &[u8], space: ExGuid, expected: &Page) -> Page {
67 let stored = page_in(written, space);
68 let mut expected = expected.clone();
69 expected.title = stored.title.clone();
70 assert_eq!(without_language(stored.clone()), without_language(expected));
71 stored
72}
73
74fn linked(page: &mut Page) -> usize {
75 body_paragraphs(page)
76 .iter()
77 .position(|p| {
78 p.text()
79 .is_some_and(|t| t.text.text().contains("HYPERLINK"))
80 })
81 .unwrap()
82}
83
84/// Appends a hyperlink: a hidden field code run followed by the visible label, both
85/// flagged as link runs like OneNote writes them.
86fn append_link(text: &mut Paragraph, label: &str) {
87 let end = text.utf16_offset(text.text().len()).unwrap();
88 let base = text.format_at(end).unwrap().clone();
89 let mut code = base.clone();
90 code.hyperlink = Some(true);
91 code.hyperlink_label = Some(true);
92 code.hidden = Some(true);
93 let mut visible = base.clone();
94 visible.hyperlink = Some(true);
95 visible.hyperlink_label = Some(true);
96 text.apply(Edit {
97 range: end..end,
98 replacement: Paragraph::from_runs([
99 (" ".to_owned(), base),
100 (CODE.to_owned(), code),
101 (label.to_owned(), visible),
102 ]),
103 })
104 .unwrap();
105}
106
107#[test]
108fn a_native_link_is_removed_leaving_plain_label_text() {
109 let (space, before) = page_by_title(OUTLINES, "Move leaf down");
110 let mut after = before.clone();
111 let at = linked(&mut after);
112 let text = &mut body_paragraphs(&mut after)[at].text_mut().unwrap().text;
113 let content = text.text().to_owned();
114 let start = content.find('\u{fddf}').unwrap();
115 let end = content.find("Link label").unwrap();
116 let mut plain = text.format_at(0).unwrap().clone();
117 plain.hyperlink = Some(false);
118 plain.hyperlink_label = Some(false);
119 plain.hidden = Some(false);
120 let (from, to) = (
121 text.utf16_offset(start).unwrap(),
122 text.utf16_offset(content.len()).unwrap(),
123 );
124 text.apply(Edit {
125 range: from..to,
126 replacement: Paragraph::new(content[end..].to_owned(), plain),
127 })
128 .unwrap();
129 let written = PreparedEdit::page(OUTLINES, space, &after, AUTHOR).unwrap();
130 let stored = assert_same(written.as_bytes(), space, &after);
131 let mut stored = stored;
132 let text = &body_paragraphs(&mut stored)[at].text().unwrap().text;
133 assert!(text.text().ends_with(" Link label"));
134 assert!(
135 text.spans()
136 .iter()
137 .all(|span| span.format.hyperlink != Some(true) && span.format.hidden != Some(true))
138 );
139}
140
141/// `ONESTORE_LINK_EXPORT` names a new directory receiving the candidate for a cold reopen.
142#[test]
143fn a_link_is_added_to_a_fresh_page_and_reads_back() {
144 let source = onestore::create_section("links.one", "Read about Rust", "Author").unwrap();
145 let store = Store::parse(&source).unwrap();
146 let index = RevisionIndex::parse(&store).unwrap();
147 let document = Document::parse(&index).unwrap();
148 let (space, _) = document.pages().unwrap()[0];
149 let before = Page::from_space(&document, space).unwrap();
150 let mut after = before.clone();
151 append_link(
152 &mut body_paragraphs(&mut after)[0].text_mut().unwrap().text,
153 "the Rust site",
154 );
155 let written = PreparedEdit::page(&source, space, &after, AUTHOR).unwrap();
156 let stored = assert_same(written.as_bytes(), space, &after);
157 let mut stored = stored;
158 let text = &body_paragraphs(&mut stored)[0].text().unwrap().text;
159 let flags: Vec<(Option<bool>, Option<bool>, Option<bool>)> = text
160 .spans()
161 .iter()
162 .map(|s| {
163 (
164 s.format.hyperlink,
165 s.format.hyperlink_label,
166 s.format.hidden,
167 )
168 })
169 .collect();
170 assert_eq!(
171 flags,
172 [
173 (None, None, None),
174 (Some(true), Some(true), Some(true)),
175 (Some(true), Some(true), None),
176 ]
177 );
178 if let Some(directory) = std::env::var_os("ONESTORE_LINK_EXPORT") {
179 let directory = std::path::PathBuf::from(directory);
180 std::fs::create_dir(&directory).unwrap();
181 std::fs::write(directory.join("links.one"), written.as_bytes()).unwrap();
182 let written_store = Store::parse(written.as_bytes()).unwrap();
183 std::fs::write(
184 directory.join("Open Notebook.onetoc2"),
185 onestore::create_table_of_contents(
186 "Open Notebook.onetoc2",
187 &[("links.one", written_store.header.file_id)],
188 )
189 .unwrap(),
190 )
191 .unwrap();
192 }
193}
tools/test_link_edit.py created+28
...@@ -0,0 +1,28 @@
1from pathlib import Path
2import runpy
3import shutil
4from tempfile import TemporaryDirectory
5import unittest
6import xml.etree.ElementTree as ET
7
8from native_xml import ns
9
10ROOT = Path(__file__).resolve().parent.parent
11FIXTURE = ROOT / 'corpus/link-edit'
12compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare']
13
14
15class LinkEditTest(unittest.TestCase):
16 def test_a_rust_hyperlink_renders_natively(self):
17 with TemporaryDirectory() as temporary:
18 read = Path(temporary) / 'read'
19 shutil.copytree(FIXTURE / 'cold/read', read)
20 compare(FIXTURE / 'candidate', read)
21 page, = (ET.parse(path).getroot() for path in sorted((FIXTURE / 'cold/read').glob('page-*.xml')))
22 texts = [oe.find('one:T', ns).text for oe in page.iter('{%s}OE' % ns['one']) if oe.find('one:T', ns) is not None]
23 self.assertEqual([' '.join(text.split()) for text in texts],
24 ['Read about Rust <a href="https://example.invalid/rust">the Rust site</a>'])
25
26
27if __name__ == '__main__':
28 unittest.main()