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,
1414writes the difference into the working image and queues one
1515`Operation::Page(PageIntent { before, after, author })`; `before` is the page the
1616edit 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,
1818outline layout, insertions and deletions are all differences between `before` and
1919`after`; the library never sees editor operations.
2020
crates/onestore/README.md+1-1
......@@ -57,7 +57,7 @@ harness also accepts `--client-profile release`.
5757| `ParagraphJoin`, `PreparedEdit::join` | Join adjacent ordinary text while preserving inherited character styles and native text-identity rules |
5858| `TextAttribute`, `PreparedEdit::format` | Change character formatting over a UTF-16 range while sharing immutable styles; preserve unselected runs |
5959| `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 |
6161| `TreeEdit`, `PreparedEdit::tree` | Move or delete a subtree on one page, normalize surviving containers, and replace an emptied table cell's paragraph atomically |
6262| `PreparedEdit::commit`, `PreparedEdit::commit_file` | Publish the exact prepared image under caller-held exclusion or the conservative filesystem adapter |
6363| `read_file` | Read a snapshot under whole-file exclusion |
crates/onestore/src/edit.rs+5-9
......@@ -36,7 +36,7 @@ pub fn replace_text(
3636 replacement: &str,
3737) -> Result<Vec<u8>, Error> {
3838 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}']) {
4040 return Err(invalid(
4141 "Use a valid text range and ordinary paragraph text",
4242 ));
......@@ -82,15 +82,11 @@ pub fn replace_text(
8282 let resolved = revision.text_runs(object)?;
8383 for (i, run) in runs.iter().enumerate() {
8484 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.
8587 let format = &resolved[i].format;
86 if [
87 format.hidden,
88 format.hyperlink,
89 format.math,
90 format.embedded_object,
91 ]
92 .contains(&Some(true))
93 || resolved[i].text.contains(['\u{fffc}', '\u{fddf}'])
88 if [format.math, format.embedded_object].contains(&Some(true))
89 || resolved[i].text.contains('\u{fffc}')
9490 || run.extra_set.is_some_and(|set| !node.extra[set].is_empty())
9591 {
9692 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 {
3535 Color(Option<[u8; 3]>),
3636 /// RGB, or None to clear highlighting.
3737 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),
3844}
3945
4046impl TextAttribute {
......@@ -47,6 +53,9 @@ impl TextAttribute {
4753 Self::Strike(value) => boolean(0x08001c07, *value),
4854 Self::Superscript(value) => boolean(0x08001c08, *value),
4955 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),
5059 Self::Font(font) => {
5160 if font.is_empty() || font.contains('\0') {
5261 return Err(invalid("Font names must be nonempty and contain no NUL"));
......@@ -141,14 +150,8 @@ pub(crate) fn format_text(
141150 };
142151 if selected {
143152 let format = &resolved[i].format;
144 if [
145 format.hidden,
146 format.hyperlink,
147 format.math,
148 format.embedded_object,
149 ]
150 .contains(&Some(true))
151 || resolved[i].text.contains(['\u{fffc}', '\u{fddf}'])
153 if [format.math, format.embedded_object].contains(&Some(true))
154 || resolved[i].text.contains('\u{fffc}')
152155 {
153156 return Err(invalid(
154157 "This format range contains a field or embedded data",
crates/onestore/src/insertion.rs+1-1
......@@ -143,7 +143,7 @@ impl Insertion {
143143 fn validate(&self) -> Result<(), Error> {
144144 if self.guid == [0; 16]
145145 || self.parent.guid == [0; 16]
146 || self.text.contains(['\0', '\n', '\u{fffc}', '\u{fddf}'])
146 || self.text.contains(['\0', '\n', '\u{fffc}'])
147147 || self.author.contains('\0')
148148 {
149149 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> {
13881388fn attributes(current: &Format, target: &Format, fresh: bool) -> Result<Vec<TextAttribute>, Error> {
13891389 let mut out = Vec::new();
13901390 let inherited = || invalid("Inherited character formatting cannot be restored");
1391 // An absent flag and an explicit false are the same formatting.
13911392 macro_rules! boolean {
13921393 ($field:ident, $variant:ident) => {
1393 if current.$field != target.$field {
1394 match target.$field {
1395 Some(value) => out.push(TextAttribute::$variant(value)),
1396 None if fresh => {}
1397 None => return Err(inherited()),
1398 }
1394 if current.$field.unwrap_or(false) != target.$field.unwrap_or(false) {
1395 out.push(TextAttribute::$variant(target.$field.unwrap_or(false)));
13991396 }
14001397 };
14011398 }
......@@ -1405,6 +1402,9 @@ fn attributes(current: &Format, target: &Format, fresh: bool) -> Result<Vec<Text
14051402 boolean!(strike, Strike);
14061403 boolean!(superscript, Superscript);
14071404 boolean!(subscript, Subscript);
1405 boolean!(hidden, Hidden);
1406 boolean!(hyperlink, Hyperlink);
1407 boolean!(hyperlink_label, HyperlinkLabel);
14081408 if current.font != target.font {
14091409 match &target.font {
14101410 Some(font) => out.push(TextAttribute::Font(font.clone())),
......@@ -1438,10 +1438,7 @@ fn attributes(current: &Format, target: &Format, fresh: bool) -> Result<Vec<Text
14381438 // An absent value and its stored default are the same formatting.
14391439 let flag = |a: Option<bool>, b: Option<bool>| a.unwrap_or(false) == b.unwrap_or(false);
14401440 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)
1442 && flag(current.hyperlink, target.hyperlink)
1443 && flag(current.hyperlink_label, target.hyperlink_label)
1444 && flag(current.math, target.math)
1441 let same_rest = flag(current.math, target.math)
14451442 && flag(current.embedded_object, target.embedded_object)
14461443 && current.alignment.unwrap_or(0) == target.alignment.unwrap_or(0)
14471444 && flag(current.rtl, target.rtl)
......@@ -1451,7 +1448,7 @@ fn attributes(current: &Format, target: &Format, fresh: bool) -> Result<Vec<Text
14511448 && points(current.list_spacing, target.list_spacing);
14521449 if !same_rest {
14531450 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",
14551452 ));
14561453 }
14571454 Ok(out)
crates/onestore/src/write/tests.rs+4-2
......@@ -884,10 +884,12 @@ fn character_formatting_preserves_inheritance_and_associated_data() {
884884 );
885885 continue;
886886 }
887 // Hidden and hyperlink runs are ordinary text with flags; associated data,
888 // equations and embedded objects are not.
887889 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));
889891 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) {
891893 assert!(edit.is_err());
892894 continue;
893895 }
crates/onestore/tests/edit.rs-41
......@@ -371,7 +371,6 @@ fn invalid_text_edits_never_touch_storage() {
371371 (0..0, "\n"),
372372 (0..0, "\0"),
373373 (0..0, "\u{fffc}"),
374 (0..0, "\u{fddf}"),
375374 ] {
376375 let mut disk = Disk {
377376 visible: source.clone(),
......@@ -389,46 +388,6 @@ fn invalid_text_edits_never_touch_storage() {
389388 }
390389}
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
432391#[test]
433392fn cross_run_splices_match_a_character_model_and_preserve_history() {
434393 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
616616 let (sid, page, outline, paragraph, text) = targets(&source);
617617 assert!(Insertion::outline(page, f32::NAN, 0.0, "Text", "Author").is_err());
618618 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"] {
620620 assert!(Insertion::paragraph(outline, None, content, "Author").is_err());
621621 }
622622 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()