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 0);
-}
-
#[test]
fn cross_run_splices_match_a_character_model_and_preserve_history() {
let (sid, oid) = target(SOURCE);
diff --git a/crates/onestore/tests/insertion.rs b/crates/onestore/tests/insertion.rs
index 2b215f02768379f4a4961f46f47f90f3c1e68097..cd04819fdb60c9c6e6adabf559eb69d00f3a7cd0 100644
--- a/crates/onestore/tests/insertion.rs
+++ b/crates/onestore/tests/insertion.rs
@@ -616,7 +616,7 @@ fn anchors_targets_serialized_identities_and_text_are_validated_before_publicati
let (sid, page, outline, paragraph, text) = targets(&source);
assert!(Insertion::outline(page, f32::NAN, 0.0, "Text", "Author").is_err());
assert!(Insertion::outline(page, 0.0, f32::INFINITY, "Text", "Author").is_err());
- for content in ["a\0b", "a\nb", "a\u{fffc}b", "a\u{fddf}b"] {
+ for content in ["a\0b", "a\nb", "a\u{fffc}b"] {
assert!(Insertion::paragraph(outline, None, content, "Author").is_err());
}
assert!(Insertion::paragraph(outline, None, "Text", "a\0b").is_err());
diff --git a/crates/onestore/tests/page_links.rs b/crates/onestore/tests/page_links.rs
new file mode 100644
index 0000000000000000000000000000000000000000..0aac0f7b91b586bf2a8d1c2b85a8bc2af6577afa
--- /dev/null
+++ b/crates/onestore/tests/page_links.rs
@@ -0,0 +1,193 @@
+use onestore::{
+ ExGuid, PreparedEdit, RevisionIndex, Store,
+ document::Document,
+ page::{Page, PageObject, PageParagraph, Paragraph, text::Edit},
+};
+
+const OUTLINES: &[u8] =
+ include_bytes!("../../../corpus/outline-edit/before/notebook/synthetic.one");
+const AUTHOR: &str = "Link author";
+const CODE: &str = "\u{fddf}HYPERLINK \"https://example.invalid/rust\"";
+
+fn page_by_title(bytes: &[u8], title: &str) -> (ExGuid, Page) {
+ let store = Store::parse(bytes).unwrap();
+ assert!(store.checksum_mismatches.is_empty());
+ let index = RevisionIndex::parse(&store).unwrap();
+ let document = Document::parse(&index).unwrap();
+ document
+ .pages()
+ .unwrap()
+ .into_iter()
+ .find_map(|(space, _)| {
+ let page = Page::from_space(&document, space).unwrap();
+ (page.title == title).then_some((space, page))
+ })
+ .unwrap()
+}
+
+fn page_in(bytes: &[u8], space: ExGuid) -> Page {
+ let store = Store::parse(bytes).unwrap();
+ assert!(store.checksum_mismatches.is_empty());
+ let index = RevisionIndex::parse(&store).unwrap();
+ Page::from_space(&Document::parse(&index).unwrap(), space).unwrap()
+}
+
+fn body_paragraphs(page: &mut Page) -> &mut Vec {
+ page.objects
+ .iter_mut()
+ .find_map(|object| match object {
+ PageObject::Outline(outline) if !outline.title => Some(&mut outline.paragraphs),
+ _ => None,
+ })
+ .unwrap()
+}
+
+/// Language tags stay as stored, so the comparison leaves them out.
+fn without_language(mut page: Page) -> Page {
+ for object in &mut page.objects {
+ if let PageObject::Outline(outline) = object {
+ for paragraph in &mut outline.paragraphs {
+ if let Some(text) = paragraph.text_mut() {
+ let mut start = 0;
+ text.text = Paragraph::from_runs(text.text.spans().iter().map(|span| {
+ let value = text.text.text()[start..span.end].to_owned();
+ start = span.end;
+ let mut format = span.format.clone();
+ format.language = None;
+ (value, format)
+ }));
+ }
+ }
+ }
+ }
+ page
+}
+
+fn assert_same(written: &[u8], space: ExGuid, expected: &Page) -> Page {
+ let stored = page_in(written, space);
+ let mut expected = expected.clone();
+ expected.title = stored.title.clone();
+ assert_eq!(without_language(stored.clone()), without_language(expected));
+ stored
+}
+
+fn linked(page: &mut Page) -> usize {
+ body_paragraphs(page)
+ .iter()
+ .position(|p| {
+ p.text()
+ .is_some_and(|t| t.text.text().contains("HYPERLINK"))
+ })
+ .unwrap()
+}
+
+/// Appends a hyperlink: a hidden field code run followed by the visible label, both
+/// flagged as link runs like OneNote writes them.
+fn append_link(text: &mut Paragraph, label: &str) {
+ let end = text.utf16_offset(text.text().len()).unwrap();
+ let base = text.format_at(end).unwrap().clone();
+ let mut code = base.clone();
+ code.hyperlink = Some(true);
+ code.hyperlink_label = Some(true);
+ code.hidden = Some(true);
+ let mut visible = base.clone();
+ visible.hyperlink = Some(true);
+ visible.hyperlink_label = Some(true);
+ text.apply(Edit {
+ range: end..end,
+ replacement: Paragraph::from_runs([
+ (" ".to_owned(), base),
+ (CODE.to_owned(), code),
+ (label.to_owned(), visible),
+ ]),
+ })
+ .unwrap();
+}
+
+#[test]
+fn a_native_link_is_removed_leaving_plain_label_text() {
+ let (space, before) = page_by_title(OUTLINES, "Move leaf down");
+ let mut after = before.clone();
+ let at = linked(&mut after);
+ let text = &mut body_paragraphs(&mut after)[at].text_mut().unwrap().text;
+ let content = text.text().to_owned();
+ let start = content.find('\u{fddf}').unwrap();
+ let end = content.find("Link label").unwrap();
+ let mut plain = text.format_at(0).unwrap().clone();
+ plain.hyperlink = Some(false);
+ plain.hyperlink_label = Some(false);
+ plain.hidden = Some(false);
+ let (from, to) = (
+ text.utf16_offset(start).unwrap(),
+ text.utf16_offset(content.len()).unwrap(),
+ );
+ text.apply(Edit {
+ range: from..to,
+ replacement: Paragraph::new(content[end..].to_owned(), plain),
+ })
+ .unwrap();
+ let written = PreparedEdit::page(OUTLINES, space, &after, AUTHOR).unwrap();
+ let stored = assert_same(written.as_bytes(), space, &after);
+ let mut stored = stored;
+ let text = &body_paragraphs(&mut stored)[at].text().unwrap().text;
+ assert!(text.text().ends_with(" Link label"));
+ assert!(
+ text.spans()
+ .iter()
+ .all(|span| span.format.hyperlink != Some(true) && span.format.hidden != Some(true))
+ );
+}
+
+/// `ONESTORE_LINK_EXPORT` names a new directory receiving the candidate for a cold reopen.
+#[test]
+fn a_link_is_added_to_a_fresh_page_and_reads_back() {
+ let source = onestore::create_section("links.one", "Read about Rust", "Author").unwrap();
+ let store = Store::parse(&source).unwrap();
+ let index = RevisionIndex::parse(&store).unwrap();
+ let document = Document::parse(&index).unwrap();
+ let (space, _) = document.pages().unwrap()[0];
+ let before = Page::from_space(&document, space).unwrap();
+ let mut after = before.clone();
+ append_link(
+ &mut body_paragraphs(&mut after)[0].text_mut().unwrap().text,
+ "the Rust site",
+ );
+ let written = PreparedEdit::page(&source, space, &after, AUTHOR).unwrap();
+ let stored = assert_same(written.as_bytes(), space, &after);
+ let mut stored = stored;
+ let text = &body_paragraphs(&mut stored)[0].text().unwrap().text;
+ let flags: Vec<(Option, Option, Option)> = text
+ .spans()
+ .iter()
+ .map(|s| {
+ (
+ s.format.hyperlink,
+ s.format.hyperlink_label,
+ s.format.hidden,
+ )
+ })
+ .collect();
+ assert_eq!(
+ flags,
+ [
+ (None, None, None),
+ (Some(true), Some(true), Some(true)),
+ (Some(true), Some(true), None),
+ ]
+ );
+ if let Some(directory) = std::env::var_os("ONESTORE_LINK_EXPORT") {
+ let directory = std::path::PathBuf::from(directory);
+ std::fs::create_dir(&directory).unwrap();
+ std::fs::write(directory.join("links.one"), written.as_bytes()).unwrap();
+ let written_store = Store::parse(written.as_bytes()).unwrap();
+ std::fs::write(
+ directory.join("Open Notebook.onetoc2"),
+ onestore::create_table_of_contents(
+ "Open Notebook.onetoc2",
+ &[("links.one", written_store.header.file_id)],
+ )
+ .unwrap(),
+ )
+ .unwrap();
+ }
+}
diff --git a/tools/test_link_edit.py b/tools/test_link_edit.py
new file mode 100644
index 0000000000000000000000000000000000000000..25fe7c74d3b716abc3688e1ef14f8a19ab86793a
--- /dev/null
+++ b/tools/test_link_edit.py
@@ -0,0 +1,28 @@
+from pathlib import Path
+import runpy
+import shutil
+from tempfile import TemporaryDirectory
+import unittest
+import xml.etree.ElementTree as ET
+
+from native_xml import ns
+
+ROOT = Path(__file__).resolve().parent.parent
+FIXTURE = ROOT / 'corpus/link-edit'
+compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare']
+
+
+class LinkEditTest(unittest.TestCase):
+ def test_a_rust_hyperlink_renders_natively(self):
+ with TemporaryDirectory() as temporary:
+ read = Path(temporary) / 'read'
+ shutil.copytree(FIXTURE / 'cold/read', read)
+ compare(FIXTURE / 'candidate', read)
+ page, = (ET.parse(path).getroot() for path in sorted((FIXTURE / 'cold/read').glob('page-*.xml')))
+ 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]
+ self.assertEqual([' '.join(text.split()) for text in texts],
+ ['Read about Rust the Rust site'])
+
+
+if __name__ == '__main__':
+ unittest.main()