authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 00:42:05-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 00:55:30-07:00
loga7388496fddd44f7037f7224f6300a31b9a784ea
tree6b9bc64cf103ed4d186a42d18d3d21225e9c3e58
parentbf28ad621ca5962e64fe4ab4c73a90d92204d181
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

fix: copy and paste keep formatting

Copy put plain text alone on the clipboard, so a bold line pasted back came out regular. It now offers what OneNote 2010 does beside text (lab): HTML with each run's font, size, weight, slant, decoration and colours inline, links, lists as nested ul and ol, and bordered tables, which OneNote 2010 and Word 2010 paste as copied. Beside them goes Snowbound's own format, the copied paragraphs and the styles, lists and tags they name, which pastes back losslessly, pictures included: an NSPasteboard type on macOS, a registered clipboard format on Windows, and on X11 this process's last copy while the clipboard still holds its HTML. Paste prefers that format, then HTML, then text. HTML now pastes as formatted paragraphs, lists and tables rather than text, so what OneNote copies (corpus/clipboard) pastes as it shows. Several paragraphs go between the halves of the caret's paragraph, an empty half dropped, as OneNote does; a title takes the text alone. A pasted clip stores and cold-opens in OneNote 2010. Assisted-by: claude-opus-5.5

23 files changed, 2442 insertions(+), 382 deletions(-)

arc/canvas.md+11-4
...@@ -147,10 +147,17 @@ lies on the page at the caret, and the caret moves to the grid row below it....@@ -147,10 +147,17 @@ lies on the page at the caret, and the caret moves to the grid row below it.
147From the title it joins the outline where the body starts, or lies two grid rows147From the title it joins the outline where the body starts, or lies two grid rows
148below the page's content when none starts there. A picture takes the size its148below the page's content when none starts there. A picture takes the size its
149resolution gives it, or 96 dpi without one. Paste takes files first (a picture149resolution gives it, or 96 dpi without one. Paste takes files first (a picture
150file as its picture, as a dropped one goes in), then a web page holding150file as its picture, as a dropped one goes in), then Snowbound's own copy, then
151pictures, its text and pictures in order as one undo step, then text, then a151a web page, its formatted text, lists, tables and pictures in order as one undo
152picture: Finder offers a copied file's name and icon beside it, and other apps a152step, then text, then a picture: Finder offers a copied file's name and icon
153picture of copied text.153beside it, and other apps a picture of copied text.
154
155Copy offers what OneNote 2010 does beside text, HTML with each run's formatting
156inline, lists as `ul` and `ol` and tables bordered, which OneNote and Word paste
157as copied (`corpus/clipboard`). Snowbound's own format, a `Clip` of paragraphs
158and the definitions they name as JSON, carries styles, tags and pictures too.
159Several paragraphs go between the halves of the caret's paragraph, an empty
160half dropped; a title takes the text alone.
154161
155## Recordings162## Recordings
156163
corpus/clipboard/README.md created+11
...@@ -0,0 +1,11 @@
1# Clipboard
2
3`onenote-2010.html` is the `HTML Format` OneNote 2010 put on the clipboard for a
4whole outline holding bold, italic, coloured, resized, re-fonted, underlined and
5highlighted runs, a link, nested bullets, a numbered list, a To Do tag, a 2 by 2
6table with a bold cell, and a last line (lab, 2026-10-02; `tools/w7`, a page
7built through COM and copied with Ctrl+A, Ctrl+C). Beside it OneNote offered
8`OneNote 2010 Internal` (56 bytes naming the copy, not its content), Unicode and
9ANSI text, an enhanced metafile and a device-independent bitmap, but no RTF. The
10tag is absent from the HTML. `crates/canvas/src/editor/html.rs` reads it in its
11tests.
corpus/clipboard/onenote-2010.html created+96
...@@ -0,0 +1,96 @@
1Version:1.0
2StartHTML:0000000105
3EndHTML:0000004577
4StartFragment:0000000482
5EndFragment:0000004537
6
7<html xmlns:o="urn:schemas-microsoft-com:office:office"
8xmlns:dt="uuid:C2F41010-65B3-11d1-A29F-00AA00C14882"
9xmlns="http://www.w3.org/TR/REC-html40">
10
11<head>
12<meta http-equiv=Content-Type content="text/html; charset=utf-8">
13<meta name=ProgId content=OneNote.File>
14<meta name=Generator content="Microsoft OneNote 14">
15</head>
16
17<body lang=en-US>
18<!--StartFragment-->
19
20<ul style='margin-left:.4069in;direction:ltr;unicode-bidi:embed;margin-top:
21 0in;margin-bottom:0in'>
22 <p style='margin:0in;font-family:Calibri;font-size:11.0pt'><span
23 style='font-weight:bold'>Bold line</span></p>
24 <p style='margin:0in'><span style='font-family:Calibri;font-size:11.0pt'>plain
25 </span><span style='font-style:italic;font-family:Calibri;font-size:11.0pt'>italic</span><span
26 style='font-family:Calibri;font-size:11.0pt'> </span><span style='font-family:
27 Calibri;font-size:11.0pt;color:#C00000'>red</span><span style='font-family:
28 Calibri;font-size:11.0pt'> </span><span style='font-family:Calibri;font-size:
29 16.0pt'>big</span><span style='font-family:Calibri;font-size:11.0pt'> </span><span
30 style='font-family:"Times New Roman";font-size:11.0pt'>times</span><span
31 style='font-family:Calibri;font-size:11.0pt'> </span><span style='text-decoration:
32 underline;font-family:Calibri;font-size:11.0pt'>under</span><span
33 style='font-family:Calibri;font-size:11.0pt'> </span><span style='font-family:
34 Calibri;font-size:11.0pt;background:yellow;mso-highlight:yellow'>hi</span><span
35 style='font-family:Calibri;font-size:11.0pt'> </span><a
36 href="https://example.com/"><span style='font-family:Calibri;font-size:11.0pt'>link</span></a></p>
37 <ul type=disc style='margin-left:0in;direction:ltr;unicode-bidi:embed;
38 margin-top:0in;margin-bottom:0in'>
39 <li style='margin-top:0;margin-bottom:0;vertical-align:middle'><span
40 style='font-family:Calibri;font-size:11.0pt'>bullet one</span></li>
41 <ul type=circle style='margin-left:.375in;direction:ltr;unicode-bidi:embed;
42 margin-top:0in;margin-bottom:0in'>
43 <li style='margin-top:0;margin-bottom:0;vertical-align:middle'><span
44 style='font-family:Calibri;font-size:11.0pt'>nested bullet</span></li>
45 </ul>
46 </ul>
47 <ul type=disc style='margin-left:0in;direction:ltr;unicode-bidi:embed;
48 margin-top:0in;margin-bottom:0in'>
49 <li style='margin-top:0;margin-bottom:0;vertical-align:middle'><span
50 style='font-family:Calibri;font-size:11.0pt'>bullet two</span></li>
51 </ul>
52 <ol type=1 style='margin-left:0in;direction:ltr;unicode-bidi:embed;margin-top:
53 0in;margin-bottom:0in;font-family:Verdana;font-size:11.0pt;font-weight:normal;
54 font-style:normal'>
55 <li value=1 style='margin-top:0;margin-bottom:0;vertical-align:middle'><span
56 style='font-family:Calibri;font-size:11.0pt;font-weight:normal;
57 font-style:normal;font-family:Calibri;font-size:11.0pt'>number one</span></li>
58 <li style='margin-top:0;margin-bottom:0;vertical-align:middle'><span
59 style='font-family:Calibri;font-size:11.0pt'>number two</span></li>
60 </ol>
61 <p style='margin:0in;font-family:Calibri;font-size:11.0pt'>tagged todo</p>
62 <p style='margin:0in;font-family:Calibri;font-size:11.0pt'>after</p>
63 <div style='direction:ltr'>
64 <table border=1 cellpadding=0 cellspacing=0 valign=top style='direction:ltr;
65 border-collapse:collapse;border-style:solid;border-color:#A3A3A3;border-width:
66 1pt'>
67 <tr>
68 <td style='border-style:solid;border-color:#A3A3A3;border-width:1pt;
69 vertical-align:top;width:.6673in;padding:4pt 4pt 4pt 4pt'>
70 <p style='margin:0in;font-family:Calibri;font-size:11.0pt'>a1</p>
71 </td>
72 <td style='border-style:solid;border-color:#A3A3A3;border-width:1pt;
73 vertical-align:top;width:.7166in;padding:4pt 4pt 4pt 4pt'>
74 <p style='margin:0in;font-family:Calibri;font-size:11.0pt'><span
75 style='font-weight:bold'>b1</span></p>
76 </td>
77 </tr>
78 <tr>
79 <td style='border-style:solid;border-color:#A3A3A3;border-width:1pt;
80 vertical-align:top;width:.6673in;padding:4pt 4pt 4pt 4pt'>
81 <p style='margin:0in;font-family:Calibri;font-size:11.0pt'>a2</p>
82 </td>
83 <td style='border-style:solid;border-color:#A3A3A3;border-width:1pt;
84 vertical-align:top;width:.7166in;padding:4pt 4pt 4pt 4pt'>
85 <p style='margin:0in;font-family:Calibri;font-size:11.0pt'>b2</p>
86 </td>
87 </tr>
88 </table>
89 </div>
90 <p style='margin:0in;font-family:Calibri;font-size:11.0pt'>last line</p>
91</ul>
92
93<!--EndFragment-->
94</body>
95
96</html>
crates/canvas/src/document.rs+50-34
...@@ -757,35 +757,45 @@ impl TextDocument {...@@ -757,35 +757,45 @@ impl TextDocument {
757 Err(EditError::InvalidRange)757 Err(EditError::InvalidRange)
758 }758 }
759759
760 pub(crate) fn slice(&self, range: Range<TextPosition>) -> Result<Vec<Paragraph>, EditError> {760 /// The nodes `range` covers, its end paragraphs cut to it: those of its container from
761 if range.start > range.end {761 /// one end to the other, or where it crosses a table's edge, the root nodes holding them.
762 return Err(EditError::InvalidRange);762 pub(crate) fn selected(
763 }763 &self,
764 let count = range764 range: Range<TextPosition>,
765 .end765 ) -> Result<Vec<PageParagraph>, EditError> {
766 .paragraph766 let (start_container, start, first) = self
767 .checked_sub(range.start.paragraph)767 .leaf(range.start.paragraph)
768 .and_then(|n| n.checked_add(1))
769 .ok_or(EditError::InvalidRange)?;768 .ok_or(EditError::InvalidRange)?;
770 let result = self769 let (end_container, end, last) = self
771 .paragraphs()770 .leaf(range.end.paragraph)
772 .skip(range.start.paragraph)771 .ok_or(EditError::InvalidRange)?;
773 .take(count)772 let (first, last) = (first.id, last.id);
774 .enumerate()773 let (nodes, start, end) = if self.crosses_table(range.clone())? {
775 .map(|(index, paragraph)| {774 let root = |container: Option<ExGuid>, index| match container {
776 let start = if index == 0 { range.start.offset } else { 0 };775 Some(cell) => self.root(cell),
777 let end = if index == count - 1 {776 None => Ok(index),
778 range.end.offset777 };
779 } else {778 (
780 paragraph.utf16_offset(paragraph.text().len())?779 &self.nodes[..],
781 };780 root(start_container, start)?,
782 paragraph.slice(start..end)781 root(end_container, end)?,
783 })782 )
784 .collect::<Result<Vec<_>, EditError>>()?;783 } else {
785 if result.len() != count {784 (self.container(start_container)?, start, end)
786 return Err(EditError::InvalidRange);785 };
786 let mut nodes = nodes[start..=end].to_vec();
787 let tail = nodes.len() - 1;
788 if nodes[tail].id == last {
789 let from = if first == last { range.start.offset } else { 0 };
790 let text = &mut nodes[tail].text_mut().unwrap().text;
791 *text = text.slice(from..range.end.offset)?;
787 }792 }
788 Ok(result)793 if nodes[0].id == first && first != last {
794 let text = &mut nodes[0].text_mut().unwrap().text;
795 let length = text.utf16_offset(text.text().len())?;
796 *text = text.slice(range.start.offset..length)?;
797 }
798 Ok(nodes)
789 }799 }
790800
791 /// Replaces `range` as OneNote's typing, Enter and deletion do: the first paragraph keeps801 /// Replaces `range` as OneNote's typing, Enter and deletion do: the first paragraph keeps
...@@ -1332,14 +1342,20 @@ mod tests {...@@ -1332,14 +1342,20 @@ mod tests {
1332 .collect::<Vec<_>>(),1342 .collect::<Vec<_>>(),
1333 ["before", "a🌲b", "second", "right", "after"]1343 ["before", "a🌲b", "second", "right", "after"]
1334 );1344 );
1345 let shown = |nodes: Vec<PageParagraph>| {
1346 leaves(&nodes, None)
1347 .map(|(.., node)| node.text().unwrap().text.text().to_owned())
1348 .collect::<Vec<_>>()
1349 };
1350 let selected = |range| shown(document.selected(range).unwrap());
1351 assert_eq!(selected(position(1, 1)..position(2, 3)), ["🌲b", "sec"]);
1352 assert_eq!(
1353 selected(position(1, 1)..position(3, 2)),
1354 ["a🌲b", "second", "right"]
1355 );
1335 assert_eq!(1356 assert_eq!(
1336 document1357 selected(position(0, 2)..position(4, 1)),
1337 .slice(position(1, 1)..position(3, 2))1358 ["fore", "a🌲b", "second", "right", "a"]
1338 .unwrap()
1339 .iter()
1340 .map(Paragraph::text)
1341 .collect::<Vec<_>>(),
1342 ["🌲b", "second", "ri"]
1343 );1359 );
1344 let edit = document1360 let edit = document
1345 .replace(1361 .replace(
crates/canvas/src/editor.rs+9-7
...@@ -24,10 +24,12 @@ use std::{...@@ -24,10 +24,12 @@ use std::{
2424
25pub const DEFAULT_OUTLINE_WIDTH: f32 = 468.0;25pub const DEFAULT_OUTLINE_WIDTH: f32 = 468.0;
2626
27mod clip;
27mod equation;28mod equation;
28#[cfg(test)]29#[cfg(test)]
29mod evidence;30mod evidence;
30mod format;31mod format;
32mod html;
31mod ink;33mod ink;
32mod link;34mod link;
33mod ops;35mod ops;
...@@ -35,10 +37,12 @@ pub(crate) mod page;...@@ -35,10 +37,12 @@ pub(crate) mod page;
35mod recording;37mod recording;
36mod space;38mod space;
37mod table;39mod table;
40pub use clip::Clip;
38pub use format::{41pub use format::{
39 Alignment, BULLET_LIBRARY, DefaultFont, FormatState, Formatting, ListStyle, NUMBER_LIBRARY,42 Alignment, BULLET_LIBRARY, DefaultFont, FormatState, Formatting, ListStyle, NUMBER_LIBRARY,
40 NoteTag, Toggle,43 NoteTag, Toggle,
41};44};
45pub use html::html_pieces;
42pub use ink::{FAVORITES, Pen};46pub use ink::{FAVORITES, Pen};
43pub use link::{Link, shown_urls};47pub use link::{Link, shown_urls};
44pub use page::ReadOnlyObject;48pub use page::ReadOnlyObject;
...@@ -84,7 +88,7 @@ pub struct Selection {...@@ -84,7 +88,7 @@ pub struct Selection {
8488
85/// A piece of pasted content, in the order it comes.89/// A piece of pasted content, in the order it comes.
86pub enum Piece {90pub enum Piece {
87 Text(String),91 Clip(Clip),
88 /// An encoded picture and its size in points.92 /// An encoded picture and its size in points.
89 Picture(Vec<u8>, [f32; 2]),93 Picture(Vec<u8>, [f32; 2]),
90 /// A picture still on its way, which ends the paragraph and goes in where94 /// A picture still on its way, which ends the paragraph and goes in where
...@@ -2333,20 +2337,19 @@ impl CanvasEditor {...@@ -2333,20 +2337,19 @@ impl CanvasEditor {
2333 self.place_caret(engine, [x, margin[1] + row * 18.0], DEFAULT_OUTLINE_WIDTH)2337 self.place_caret(engine, [x, margin[1] + row * 18.0], DEFAULT_OUTLINE_WIDTH)
2334 }2338 }
23352339
2336 /// Pastes text in `language` and pictures in order, as one undo step, as OneNote 20102340 /// Pastes clips and pictures in order, as one undo step, as OneNote 2010 pastes a web
2337 /// pastes a web page's text with its pictures; the places of the awaited ones, in order.2341 /// page's text with its pictures; the places of the awaited ones, in order.
2338 pub fn paste_pieces(2342 pub fn paste_pieces(
2339 &mut self,2343 &mut self,
2340 engine: &mut TextEngine,2344 engine: &mut TextEngine,
2341 pieces: Vec<Piece>,2345 pieces: Vec<Piece>,
2342 language: u32,
2343 ) -> Result<Vec<Awaited>, EditorError> {2346 ) -> Result<Vec<Awaited>, EditorError> {
2344 let depth = self.undo.len();2347 let depth = self.undo.len();
2345 let mut result = Ok(());2348 let mut result = Ok(());
2346 let mut awaited = Vec::new();2349 let mut awaited = Vec::new();
2347 for piece in pieces {2350 for piece in pieces {
2348 result = match piece {2351 result = match piece {
2349 Piece::Text(text) => self.paste(engine, &text, language),2352 Piece::Clip(clip) => self.paste_clip(engine, clip),
2350 Piece::Picture(bytes, size) => picture(bytes, size)2353 Piece::Picture(bytes, size) => picture(bytes, size)
2351 .map_err(EditorError::from)2354 .map_err(EditorError::from)
2352 .and_then(|image| self.insert_picture(engine, image)),2355 .and_then(|image| self.insert_picture(engine, image)),
...@@ -10659,10 +10662,9 @@ mod tests {...@@ -10659,10 +10662,9 @@ mod tests {
10659 .paste_pieces(10662 .paste_pieces(
10660 &mut engine,10663 &mut engine,
10661 vec![10664 vec![
10662 Piece::Text("Pasted".into()),10665 html_pieces("Pasted", 0x409, |_, _| None).pop().unwrap(),
10663 Piece::Picture(vec![0; 8], [10.0; 2]),10666 Piece::Picture(vec![0; 8], [10.0; 2]),
10664 ],10667 ],
10665 0x409,
10666 )10668 )
10667 .unwrap();10669 .unwrap();
10668 assert_eq!(editor.history_depth(), [1, 0]);10670 assert_eq!(editor.history_depth(), [1, 0]);
crates/canvas/src/editor/clip.rs created+548
...@@ -0,0 +1,548 @@
1//! Copy and Paste of formatted content: a selection's paragraphs with the definitions they
2//! name, which hosts put on the clipboard in Snowbound's own format beside HTML and text.
3
4use super::*;
5use onestore::page::text::new_id;
6
7/// Paragraphs copied from a page, in document order with each parent before its children;
8/// levels count from 1 and a paragraph whose parent was not copied has none.
9#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
10pub struct Clip {
11 pub paragraphs: Vec<PageParagraph>,
12 /// The styles, lists and tags the paragraphs name.
13 pub definitions: BTreeMap<ExGuid, Definition>,
14}
15
16impl Clip {
17 pub(crate) fn new(
18 mut paragraphs: Vec<PageParagraph>,
19 definitions: &BTreeMap<ExGuid, Definition>,
20 ) -> Self {
21 let held: BTreeSet<ExGuid> = paragraphs.iter().map(|node| node.id).collect();
22 let shift = paragraphs.iter().map(|node| node.level).min().unwrap_or(1) - 1;
23 for node in &mut paragraphs {
24 node.parent = node.parent.filter(|parent| held.contains(parent));
25 node.level -= shift;
26 }
27 Self {
28 definitions: ops::named(definitions, &[&paragraphs]),
29 paragraphs,
30 }
31 }
32
33 /// The text as shown, a line to each paragraph, table cells included.
34 pub fn text(&self) -> String {
35 leaves(&self.paragraphs, None)
36 .map(|(.., node)| {
37 let text = &node.text().unwrap().text;
38 text.project().map_or_else(
39 |_| text.text().to_owned(),
40 |shown| shown.text().text().to_owned(),
41 )
42 })
43 .collect::<Vec<_>>()
44 .join("\n")
45 }
46
47 /// Snowbound's own clipboard format.
48 pub fn encode(&self) -> String {
49 serde_json::to_string(self).expect("a clip serializes")
50 }
51
52 /// None for a clip another version of Snowbound wrote in a shape this one can't read.
53 pub fn decode(json: &str) -> Option<Self> {
54 serde_json::from_str(json).ok()
55 }
56}
57
58impl CanvasEditor {
59 /// What Copy takes: the selection's paragraphs with the first and last cut to it, or the
60 /// page selection's outlines top to bottom with an empty paragraph between, as OneNote
61 /// 2010 copies them. None where nothing shows.
62 pub fn clip(&self) -> Result<Option<Clip>, EditError> {
63 let paragraphs = if self.page_selected() {
64 let mut outlines = self
65 .outlines
66 .iter()
67 .filter(|outline| !outline.title)
68 .collect::<Vec<_>>();
69 outlines.sort_by(|a, b| {
70 let ([ax, ay], [bx, by]) = (a.origin(), b.origin());
71 ay.total_cmp(&by).then(ax.total_cmp(&bx))
72 });
73 let mut paragraphs = Vec::new();
74 for outline in outlines {
75 if !paragraphs.is_empty() {
76 let empty = Paragraph::new(String::new(), Format::default());
77 paragraphs.push(crate::document::node(empty, Format::default())?);
78 }
79 paragraphs.extend_from_slice(outline.document.nodes());
80 }
81 paragraphs
82 } else {
83 let [anchor, focus] = self.selection().positions;
84 self.active_outline()
85 .document
86 .selected(anchor.min(focus)..anchor.max(focus))?
87 };
88 let clip = Clip::new(paragraphs, &self.definitions);
89 Ok((!clip.text().is_empty()).then_some(clip))
90 }
91
92 /// Pastes `clip` as one undo step: one paragraph's runs go in at the selection; more
93 /// paragraphs go between the halves of the caret's paragraph, each keeping its
94 /// formatting, style, list, tags and indentation below that paragraph's. A title takes
95 /// the text alone.
96 pub fn paste_clip(&mut self, engine: &mut TextEngine, clip: Clip) -> Result<(), EditorError> {
97 if self.page_selected() {
98 return self.grouped(|editor| {
99 editor.remove_page(engine, true)?;
100 editor.paste_clip(engine, clip)
101 });
102 }
103 if self.active_outline().title {
104 // A title takes text alone, as OneNote 2010's does (lab, 2026-10-02).
105 let language = leaves(&clip.paragraphs, None)
106 .find_map(|(.., node)| node.text()?.text.spans()[0].format.language)
107 .unwrap_or(0x409);
108 return self.paste(engine, &clip.text(), language);
109 }
110 self.take_objects()?;
111 if let Some(pasted) = self.across(engine, |editor, engine| {
112 editor.paste_clip(engine, clip.clone())
113 })? {
114 return Ok(pasted);
115 }
116 let mut nodes = self.admit(clip)?;
117 if nodes.is_empty() {
118 return Ok(());
119 }
120 if let [node] = &nodes[..]
121 && let Some(text) = node.text()
122 {
123 return self.replace(engine, vec![text.text.clone()]);
124 }
125 let [anchor, focus] = self.active_outline().selection.positions;
126 let (start, end) = (anchor.min(focus), anchor.max(focus));
127 let edge = Paragraph::new(String::new(), self.typing_format(start)?);
128 let texts = nodes.iter().map(|node| {
129 node.text()
130 .map_or_else(|| edge.clone(), |text| text.text.clone())
131 });
132 let replacement = std::iter::once(edge.clone())
133 .chain(texts)
134 .chain([edge.clone()])
135 .collect();
136 let mut edit = self
137 .active_outline()
138 .document
139 .replace(start..end, replacement)?;
140 let head = &edit.replacement[0];
141 let (parent, level) = (head.parent, head.level);
142 for node in &mut nodes {
143 if node.parent.is_none() {
144 node.parent = parent;
145 }
146 node.level += level - 1;
147 }
148 let ends_in_text = nodes.last().is_some_and(|node| node.text().is_some());
149 let last = nodes
150 .last()
151 .and_then(|node| node.text())
152 .map_or(Ok(0), |text| text.text.utf16_offset(text.text.text().len()))?;
153 let count = nodes.len();
154 let added = leaves(&nodes, None).count();
155 edit.replacement.splice(1..=count, nodes);
156 // A half of the caret's paragraph goes where empty, as OneNote 2010 pastes (lab,
157 // 2026-10-02), unless it holds children or the caret.
158 let gone = |edit: &DocumentEdit, at: usize| {
159 let node = &edit.replacement[at];
160 node.text().is_some_and(|text| text.text.text().is_empty())
161 && !edit
162 .replacement
163 .iter()
164 .any(|child| child.parent == Some(node.id))
165 };
166 if ends_in_text && gone(&edit, count + 1) {
167 edit.replacement.remove(count + 1);
168 }
169 let head = gone(&edit, 0);
170 if head {
171 edit.replacement.remove(0);
172 }
173 let paragraph = start.paragraph + added - usize::from(head);
174 let caret = if ends_in_text {
175 TextPosition {
176 paragraph,
177 offset: last,
178 }
179 } else {
180 TextPosition {
181 paragraph: paragraph + 1,
182 offset: 0,
183 }
184 };
185 self.commit(engine, edit, [caret; 2].into())
186 }
187
188 /// `clip`'s paragraphs under identities of their own, naming this page's definitions:
189 /// a style or tag the page already has, else one added beside its own.
190 fn admit(&mut self, clip: Clip) -> Result<Vec<PageParagraph>, EditError> {
191 let mut renamed = BTreeMap::new();
192 for (id, definition) in clip.definitions {
193 let own = match definition.kind {
194 Kind::Style { .. } => self.define_style(&definition)?,
195 Kind::TagDefinition { .. } => self.define_tag(&definition)?,
196 _ => {
197 let own = new_id()?;
198 self.definitions.insert(own, definition);
199 own
200 }
201 };
202 renamed.insert(id, own);
203 }
204 let mut nodes = clip.paragraphs;
205 renew(&mut nodes, &renamed, &self.active_outline().indents)?;
206 Ok(nodes)
207 }
208}
209
210/// Gives `nodes` and everything they hold new identities, keeping their tree, and points
211/// their definitions at `renamed`'s, dropping what names none; a cell without indents takes
212/// `indents`.
213fn renew(
214 nodes: &mut Vec<PageParagraph>,
215 renamed: &BTreeMap<ExGuid, ExGuid>,
216 indents: &[f32],
217) -> Result<(), EditError> {
218 let tags = |tags: &mut Vec<onestore::document::Tag>| {
219 tags.retain_mut(|tag| match tag.definition {
220 Some(id) => renamed
221 .get(&id)
222 .map(|own| tag.definition = Some(*own))
223 .is_some(),
224 None => true,
225 });
226 };
227 // What a clip from elsewhere holds without its bytes can't be pasted; their
228 // children lose their parent.
229 nodes.retain(|node| match &node.content {
230 ParagraphContent::Image(image) => image.bytes.is_some(),
231 ParagraphContent::Attachment(file) => file.bytes.is_some(),
232 ParagraphContent::Unsupported(_) => false,
233 _ => true,
234 });
235 let mut parents = BTreeMap::new();
236 for node in nodes.iter_mut() {
237 let id = new_id()?;
238 parents.insert(node.id, id);
239 node.id = id;
240 node.parent = node.parent.and_then(|parent| parents.get(&parent).copied());
241 node.style = node.style.and_then(|style| renamed.get(&style).copied());
242 node.lists = node
243 .lists
244 .iter()
245 .filter_map(|list| renamed.get(list).copied())
246 .collect();
247 tags(&mut node.tags);
248 node.media = Default::default();
249 match &mut node.content {
250 ParagraphContent::Text(text) => {
251 text.id = new_id()?;
252 text.date_field = None;
253 tags(&mut text.tags);
254 }
255 ParagraphContent::Image(image) => {
256 image.id = new_id()?;
257 tags(&mut image.tags);
258 }
259 ParagraphContent::Attachment(file) => file.id = new_id()?,
260 ParagraphContent::Ink(ink) => ink.id = new_id()?,
261 ParagraphContent::Unsupported(unsupported) => unsupported.id = new_id()?,
262 ParagraphContent::Table(table) => {
263 table.id = new_id()?;
264 tags(&mut table.tags);
265 for row in &mut table.rows {
266 row.id = new_id()?;
267 for cell in &mut row.cells {
268 cell.id = new_id()?;
269 if cell.indents.is_empty() {
270 cell.indents = indents.to_vec();
271 }
272 renew(&mut cell.paragraphs, renamed, indents)?;
273 }
274 }
275 }
276 }
277 }
278 Ok(())
279}
280
281#[cfg(test)]
282impl Clip {
283 /// A line to each paragraph: its indentation, list, runs with their formatting, links,
284 /// tags and style, then each table's cells below it.
285 pub(super) fn outline(&self) -> Vec<String> {
286 let mut lines = Vec::new();
287 outline(&self.paragraphs, &self.definitions, "", &mut lines);
288 lines
289 }
290}
291
292#[cfg(test)]
293fn outline(
294 nodes: &[PageParagraph],
295 definitions: &BTreeMap<ExGuid, Definition>,
296 prefix: &str,
297 lines: &mut Vec<String>,
298) {
299 use super::format::AUTOMATIC;
300 for node in nodes {
301 let mut line = format!("{prefix}{}", " ".repeat(node.level as usize - 1));
302 match node.lists.last().map(|list| &definitions[list].kind) {
303 Some(Kind::List {
304 bullet: Some(_), ..
305 }) => line.push_str("• "),
306 Some(kind @ Kind::List { .. }) => {
307 let (_, kind) = super::html::list_tag(kind);
308 line.push_str(&format!("{kind}. "));
309 }
310 _ => {}
311 }
312 let mut tags = node.tags.clone();
313 match &node.content {
314 ParagraphContent::Text(text) => {
315 tags.extend(text.tags.iter().cloned());
316 let shown = text.text.project().unwrap();
317 let shown = shown.text();
318 let mut byte = 0;
319 for span in shown.spans() {
320 let fragment = &shown.text()[byte..span.end];
321 byte = span.end;
322 let format = &span.format;
323 let mut marks = Vec::new();
324 for (set, mark) in [
325 (format.bold, "b"),
326 (format.italic, "i"),
327 (format.underline, "u"),
328 (format.strike, "s"),
329 (format.superscript, "sup"),
330 (format.subscript, "sub"),
331 (format.hyperlink, "link"),
332 ] {
333 if set == Some(true) {
334 marks.push(mark.to_owned());
335 }
336 }
337 if let Some(color) = format.color.filter(|color| *color != AUTOMATIC) {
338 marks.push(format!("color={color:06x}"));
339 }
340 if let Some(color) = format.highlight.filter(|color| *color != AUTOMATIC) {
341 marks.push(format!("highlight={color:06x}"));
342 }
343 if let Some(font) = format.font.as_deref().filter(|font| *font != "Calibri") {
344 marks.push(format!("font={font}"));
345 }
346 if let Some(size) = format.font_size.filter(|size| *size != 11.0) {
347 marks.push(format!("size={size}"));
348 }
349 if marks.is_empty() {
350 line.push_str(fragment);
351 } else {
352 line.push_str(&format!("<{}>{fragment}</>", marks.join(" ")));
353 }
354 }
355 for link in super::link::links(&text.text, 0) {
356 line.push_str(&format!(" -> {}", link.target));
357 }
358 }
359 ParagraphContent::Table(table) => {
360 line.push_str(&format!(
361 "table {}x{}",
362 table.rows.len(),
363 table.columns.len()
364 ));
365 }
366 ParagraphContent::Image(image) => line.push_str(&format!("picture {:?}", image.size)),
367 _ => line.push_str("other"),
368 }
369 for tag in &tags {
370 if let Some(Kind::TagDefinition { label, .. }) =
371 tag.definition.map(|id| &definitions[&id].kind)
372 {
373 line.push_str(&format!(" [{}]", label.as_deref().unwrap_or("")));
374 }
375 }
376 if let Some(Kind::Style {
377 name: Some(name), ..
378 }) = node.style.map(|id| &definitions[&id].kind)
379 {
380 line.push_str(&format!(" ({name})"));
381 }
382 lines.push(line);
383 if let ParagraphContent::Table(table) = &node.content {
384 for (row, cells) in table.rows.iter().enumerate() {
385 for (column, cell) in cells.cells.iter().enumerate() {
386 let prefix = format!("{prefix} | {row},{column}: ");
387 outline(&cell.paragraphs, definitions, &prefix, lines);
388 }
389 }
390 }
391 }
392}
393
394#[cfg(test)]
395mod tests {
396 use super::*;
397 use crate::editor::format::{NoteTag, Toggle};
398 use crate::editor::{Formatting, html_pieces};
399
400 fn at(paragraph: usize, offset: u32) -> TextPosition {
401 TextPosition { paragraph, offset }
402 }
403
404 fn editor(engine: &mut TextEngine, lines: &[&str]) -> CanvasEditor {
405 let paragraphs = lines
406 .iter()
407 .map(|line| {
408 Paragraph::new(
409 (*line).to_owned(),
410 Format {
411 font: Some("Calibri".into()),
412 font_size: Some(11.0),
413 ..Format::default()
414 },
415 )
416 })
417 .collect();
418 CanvasEditor::new(engine, TextDocument::new(paragraphs).unwrap(), 400.0).unwrap()
419 }
420
421 fn page(editor: &mut CanvasEditor) -> Clip {
422 editor.select_page().unwrap();
423 let clip = editor.clip().unwrap().unwrap();
424 editor.select([at(0, 0); 2].into()).unwrap();
425 clip
426 }
427
428 /// The report's case: a bold line copied, through Snowbound's own format, into a new
429 /// paragraph stays bold.
430 #[test]
431 fn a_copied_bold_line_pastes_bold() {
432 let mut engine = TextEngine::default();
433 let mut editor = editor(&mut engine, &["Bold line", ""]);
434 editor.select([at(0, 0), at(0, 9)].into()).unwrap();
435 editor
436 .format(&mut engine, Formatting::Toggle(Toggle::Bold))
437 .unwrap();
438 let clip = editor.clip().unwrap().unwrap();
439 assert_eq!(clip.text(), "Bold line");
440 let clip = Clip::decode(&clip.encode()).unwrap();
441 editor.select([at(1, 0); 2].into()).unwrap();
442 editor.paste_clip(&mut engine, clip).unwrap();
443 assert_eq!(editor.selection().positions, [at(1, 9); 2]);
444 assert_eq!(
445 page(&mut editor).outline(),
446 ["<b>Bold line</>", "<b>Bold line</>"]
447 );
448 }
449
450 /// Part of a paragraph copies only what is selected, and pastes into the middle of
451 /// another's text.
452 #[test]
453 fn a_partial_selection_pastes_inline() {
454 let mut engine = TextEngine::default();
455 let mut editor = editor(&mut engine, &["one two three", "ab"]);
456 editor.select([at(0, 4), at(0, 7)].into()).unwrap();
457 editor
458 .format(&mut engine, Formatting::Toggle(Toggle::Italic))
459 .unwrap();
460 let clip = editor.clip().unwrap().unwrap();
461 editor.select([at(1, 1); 2].into()).unwrap();
462 editor.paste_clip(&mut engine, clip).unwrap();
463 assert_eq!(
464 page(&mut editor).outline(),
465 ["one <i>two</> three", "a<i>two</>b"]
466 );
467 }
468
469 /// Styles, lists, indentation, tags and tables go through Snowbound's own format to
470 /// another page between the halves of the caret's paragraph, as one undo step.
471 #[test]
472 fn a_clip_carries_styles_lists_tags_and_tables_to_another_page() {
473 let mut engine = TextEngine::default();
474 let mut source = editor(&mut engine, &["Heading", "item one", "child", "tagged"]);
475 let heading = Definition {
476 kind: Kind::Style {
477 name: Some("h1".into()),
478 next: None,
479 },
480 format: Format {
481 bold: Some(true),
482 font: Some("Calibri".into()),
483 font_size: Some(16.0),
484 color: Some(0x0060_3a1f),
485 ..Format::default()
486 },
487 };
488 let mut apply = |editor: &mut CanvasEditor, selection: [TextPosition; 2], command| {
489 editor.select(selection.into()).unwrap();
490 editor.format(&mut engine, command).unwrap();
491 };
492 apply(&mut source, [at(0, 0); 2], Formatting::Style(heading));
493 apply(&mut source, [at(1, 0), at(2, 5)], Formatting::Bullets);
494 apply(&mut source, [at(2, 2); 2], Formatting::Indent);
495 apply(
496 &mut source,
497 [at(3, 0); 2],
498 Formatting::Tag(NoteTag::defaults()[0].clone(), 0),
499 );
500 source.select([at(3, 6); 2].into()).unwrap();
501 source.insert_table(&mut engine, 2, 2).unwrap();
502 source.insert(&mut engine, "a1").unwrap();
503 let copied = page(&mut source);
504 let expected = [
505 "<b color=603a1f size=16>Heading</> (h1)",
506 " • item one",
507 " • child",
508 "tagged [To Do]",
509 "table 2x2",
510 " | 0,0: a1",
511 " | 0,1: ",
512 " | 1,0: ",
513 " | 1,1: ",
514 ];
515 assert_eq!(copied.outline(), expected);
516
517 let mut target = editor(&mut engine, &["before after"]);
518 target.select([at(0, 7); 2].into()).unwrap();
519 let clip = Clip::decode(&copied.encode()).unwrap();
520 target.paste_clip(&mut engine, clip).unwrap();
521 let pasted = page(&mut target).outline();
522 assert_eq!(pasted[0], "before ");
523 assert_eq!(pasted[1..pasted.len() - 1], expected);
524 assert_eq!(pasted.last().unwrap(), "after");
525 target.page().unwrap();
526 assert!(target.undo(&mut engine).unwrap());
527 assert_eq!(page(&mut target).outline(), ["before after"]);
528 }
529
530 /// A page's HTML pasted into an empty paragraph takes its place, keeping its formatting
531 /// and lists, as OneNote 2010 pastes (lab, 2026-10-02).
532 #[test]
533 fn pasted_html_keeps_formatting_and_lists() {
534 let mut engine = TextEngine::default();
535 let mut editor = editor(&mut engine, &[""]);
536 let pieces = html_pieces(
537 "<p><b>Bold</b> and <span style='color:#C00000'>red</span></p>\
538 <ul><li>one<ul><li>two</li></ul></li></ul>",
539 0x409,
540 |_, _| None,
541 );
542 editor.paste_pieces(&mut engine, pieces).unwrap();
543 assert_eq!(
544 page(&mut editor).outline(),
545 ["<b>Bold</> and <color=0000c0>red</>", "• one", " • two"]
546 );
547 }
548}
crates/canvas/src/editor/format.rs+3-3
...@@ -8,7 +8,7 @@ use onestore::page::text::new_id;...@@ -8,7 +8,7 @@ use onestore::page::text::new_id;
8use serde::{Deserialize, Serialize};8use serde::{Deserialize, Serialize};
99
10/// The COLORREF OneNote stores for automatic colour, which overrides a style's colour.10/// The COLORREF OneNote stores for automatic colour, which overrides a style's colour.
11const AUTOMATIC: u32 = 0xff00_0000;11pub(super) const AUTOMATIC: u32 = 0xff00_0000;
12/// To Do's check box.12/// To Do's check box.
13const CHECKBOX: u16 = 3;13const CHECKBOX: u16 = 3;
1414
...@@ -143,7 +143,7 @@ impl ListStyle {...@@ -143,7 +143,7 @@ impl ListStyle {
143 pub(super) const NUMBER: Self = Self::Number(0);143 pub(super) const NUMBER: Self = Self::Number(0);
144144
145 /// The library style a stored list has, if it is one.145 /// The library style a stored list has, if it is one.
146 fn of(kind: &Kind) -> Option<Self> {146 pub(super) fn of(kind: &Kind) -> Option<Self> {
147 let Kind::List {147 let Kind::List {
148 font,148 font,
149 format: Some(format),149 format: Some(format),
...@@ -1010,7 +1010,7 @@ impl CanvasEditor {...@@ -1010,7 +1010,7 @@ impl CanvasEditor {
1010 }1010 }
10111011
1012 /// The page's definition of the tag `definition` describes, made where it has none.1012 /// The page's definition of the tag `definition` describes, made where it has none.
1013 fn define_tag(&mut self, definition: &Definition) -> Result<ExGuid, EditError> {1013 pub(super) fn define_tag(&mut self, definition: &Definition) -> Result<ExGuid, EditError> {
1014 if let Some((id, _)) = self1014 if let Some((id, _)) = self
1015 .definitions1015 .definitions
1016 .iter()1016 .iter()
crates/canvas/src/editor/html.rs created+1181
...@@ -0,0 +1,1181 @@
1//! Clips as HTML, both ways. Copy writes what OneNote 2010 puts on the clipboard (lab,
2//! 2026-10-02): every run's formatting inline on a `span`, lists as `ul` and `ol` nested by
3//! level, tables bordered, pictures and note tags left out. Paste reads that and what other
4//! applications write into paragraphs, lists, tables and pictures.
5
6use super::Piece;
7use super::clip::Clip;
8use super::format::{AUTOMATIC, ListStyle, NUMBER_LIBRARY, list_definition};
9use super::link::{field_code, links};
10use super::table::COLUMN_WIDTH;
11use crate::document::node;
12use crate::layout::{DEFAULT_FONT, DEFAULT_FONT_SIZE};
13use onestore::ExGuid;
14use onestore::document::{Format, Kind};
15use onestore::page::text::{EditError, Paragraph, new_id};
16use onestore::page::{
17 Definition, PageParagraph, ParagraphContent, Table, TableCell, TableColumn, TableRow,
18};
19use std::collections::BTreeMap;
20use std::fmt::Write;
21
22impl Clip {
23 /// The clip as a web page whose fragment the comments OneNote's mark out hold.
24 pub fn html(&self) -> String {
25 let mut body = String::new();
26 blocks(&self.paragraphs, &self.definitions, &mut body);
27 format!(
28 "<html>\n<head>\n<meta http-equiv=Content-Type content=\"text/html; charset=utf-8\">\n\
29 <meta name=Generator content=\"Snowbound\">\n</head>\n<body>\n\
30 <!--StartFragment-->\n{body}<!--EndFragment-->\n</body>\n</html>\n"
31 )
32 }
33}
34
35/// `nodes` as blocks: a list opens at a paragraph whose list differs from the one open at
36/// its level, and a block's left margin steps it in from the list it sits in, which OneNote
37/// and Word take its level from.
38fn blocks(nodes: &[PageParagraph], definitions: &BTreeMap<ExGuid, Definition>, out: &mut String) {
39 let mut open: Vec<(u32, &str)> = Vec::new();
40 for node in nodes {
41 let list = node
42 .lists
43 .last()
44 .and_then(|id| definitions.get(id))
45 .filter(|definition| matches!(definition.kind, Kind::List { .. }))
46 .map(|definition| list_tag(&definition.kind));
47 while let Some(&(level, tag)) = open.last()
48 && (level > node.level || level == node.level && list.map(|(tag, _)| tag) != Some(tag))
49 {
50 let _ = writeln!(out, "</{tag}>");
51 open.pop();
52 }
53 // A level's step in, past the list it sits in, as OneNote indents its nested lists.
54 let indent = (node.level - open.last().map_or(1, |(level, _)| *level)) as f32 * 0.375;
55 match (list, &node.content) {
56 (Some((tag, kind)), ParagraphContent::Text(text)) => {
57 if open.last().map(|(level, _)| *level) != Some(node.level) {
58 let _ = writeln!(
59 out,
60 "<{tag} type={kind} style='margin-left:{indent}in;margin-top:0in;margin-bottom:0in'>"
61 );
62 open.push((node.level, tag));
63 }
64 out.push_str("<li style='margin-top:0;margin-bottom:0'>");
65 runs(&text.text, out);
66 out.push_str("</li>\n");
67 }
68 (_, ParagraphContent::Text(text)) => {
69 let _ = write!(out, "<p style='margin:0in;margin-left:{indent}in'>");
70 runs(&text.text, out);
71 out.push_str("</p>\n");
72 }
73 (_, ParagraphContent::Table(table)) => {
74 out.push_str(
75 "<table border=1 cellpadding=0 cellspacing=0 style='border-collapse:collapse;\
76 border-style:solid;border-color:#A3A3A3;border-width:1pt'>\n",
77 );
78 for row in &table.rows {
79 out.push_str("<tr>\n");
80 for (cell, column) in row.cells.iter().zip(&table.columns) {
81 let _ = writeln!(
82 out,
83 "<td style='border-style:solid;border-color:#A3A3A3;border-width:1pt;\
84 vertical-align:top;width:{}pt;padding:4pt 4pt 4pt 4pt'>",
85 column.width
86 );
87 blocks(&cell.paragraphs, definitions, out);
88 out.push_str("</td>\n");
89 }
90 out.push_str("</tr>\n");
91 }
92 out.push_str("</table>\n");
93 }
94 _ => {}
95 }
96 }
97 for (_, tag) in open.into_iter().rev() {
98 let _ = writeln!(out, "</{tag}>");
99 }
100}
101
102/// The element and `type` a stored list writes as.
103pub(super) fn list_tag(kind: &Kind) -> (&'static str, &'static str) {
104 let Kind::List { bullet, format, .. } = kind else {
105 return ("ul", "disc");
106 };
107 if bullet.is_some() {
108 let kind = match ListStyle::of(kind) {
109 Some(ListStyle::Bullet(3)) => "circle",
110 Some(ListStyle::Bullet(11 | 14)) => "square",
111 _ => "disc",
112 };
113 return ("ul", kind);
114 }
115 let sequence = format
116 .as_deref()
117 .and_then(|format| format.split('\u{fffd}').nth(1)?.chars().next());
118 let kind = match sequence.map(u32::from) {
119 Some(1) => "I",
120 Some(2) => "i",
121 Some(3) => "A",
122 Some(4) => "a",
123 _ => "1",
124 };
125 ("ol", kind)
126}
127
128/// A paragraph's shown runs as spans, its links' around theirs.
129fn runs(text: &Paragraph, out: &mut String) {
130 let links = links(text, 0);
131 let (mut byte, mut unit) = (0, 0);
132 let mut shown = false;
133 let mut space = true;
134 for span in text.spans() {
135 let fragment = &text.text()[byte..span.end];
136 let start = unit;
137 (byte, unit) = (span.end, unit + fragment.encode_utf16().count() as u32);
138 if span.format.hidden == Some(true) || fragment.is_empty() {
139 continue;
140 }
141 shown = true;
142 let link = links.iter().find(|link| link.label.contains(&start));
143 if let Some(link) = link {
144 let _ = write!(out, "<a href=\"{}\">", escape(&link.target, &mut false));
145 }
146 let _ = write!(
147 out,
148 "<span style='{}'>{}</span>",
149 css(&span.format),
150 escape(fragment, &mut space)
151 );
152 if link.is_some() {
153 out.push_str("</a>");
154 }
155 }
156 if !shown {
157 out.push_str("&nbsp;");
158 }
159}
160
161fn css(format: &Format) -> String {
162 let font = format.font.as_deref().unwrap_or(DEFAULT_FONT);
163 let font = if font.contains(' ') {
164 format!("\"{font}\"")
165 } else {
166 font.to_owned()
167 };
168 let mut css = format!(
169 "font-family:{font};font-size:{}pt",
170 format.font_size.unwrap_or(DEFAULT_FONT_SIZE)
171 );
172 let set = |value: Option<bool>| value == Some(true);
173 match format.bold {
174 Some(true) => css.push_str(";font-weight:bold"),
175 Some(false) => css.push_str(";font-weight:normal"),
176 None => {}
177 }
178 match format.italic {
179 Some(true) => css.push_str(";font-style:italic"),
180 Some(false) => css.push_str(";font-style:normal"),
181 None => {}
182 }
183 match (set(format.underline), set(format.strike)) {
184 (true, true) => css.push_str(";text-decoration:underline line-through"),
185 (true, false) => css.push_str(";text-decoration:underline"),
186 (false, true) => css.push_str(";text-decoration:line-through"),
187 (false, false) if format.underline.is_some() || format.strike.is_some() => {
188 css.push_str(";text-decoration:none")
189 }
190 (false, false) => {}
191 }
192 if set(format.superscript) {
193 css.push_str(";vertical-align:super");
194 } else if set(format.subscript) {
195 css.push_str(";vertical-align:sub");
196 }
197 let hex = |color: u32| {
198 let [red, green, blue, _] = color.to_le_bytes();
199 format!("#{red:02X}{green:02X}{blue:02X}")
200 };
201 if let Some(color) = format.color.filter(|color| *color != AUTOMATIC) {
202 let _ = write!(css, ";color:{}", hex(color));
203 }
204 if let Some(color) = format.highlight.filter(|color| *color != AUTOMATIC) {
205 let _ = write!(css, ";background:{}", hex(color));
206 }
207 css.replace('\'', "&#39;")
208}
209
210/// Text as HTML keeps it: markup characters escaped, line breaks as `br`, and a space after
211/// `space`, whether the text follows a space or starts its paragraph, unbreakable so it
212/// doesn't collapse.
213fn escape(text: &str, space: &mut bool) -> String {
214 let mut escaped = String::with_capacity(text.len());
215 for c in text.chars() {
216 match c {
217 '&' => escaped.push_str("&amp;"),
218 '<' => escaped.push_str("&lt;"),
219 '>' => escaped.push_str("&gt;"),
220 '"' => escaped.push_str("&quot;"),
221 '\u{b}' | '\n' => escaped.push_str("<br>"),
222 '\t' => escaped.push_str("&nbsp;&nbsp;&nbsp; "),
223 ' ' if *space => escaped.push_str("&nbsp;"),
224 c => escaped.push(c),
225 }
226 *space = c == ' ';
227 }
228 escaped
229}
230
231/// A web page fragment's paragraphs, lists and tables as clips, split around its pictures
232/// in order, as OneNote 2010 pastes them (lab, 2026-09-30). Text without a font or size of
233/// its own is Calibri 11 in `language`, an LCID, as pasted text is. `load` makes a picture's
234/// piece from its source and its `width` and `height` in CSS pixels; one it can't, or one
235/// in a table, is left out.
236pub fn html_pieces(
237 html: &str,
238 language: u32,
239 load: impl FnMut(&str, [Option<f32>; 2]) -> Option<Piece>,
240) -> Vec<Piece> {
241 let html = html
242 .split_once("<!--StartFragment-->")
243 .map_or(html, |(_, fragment)| fragment);
244 let html = html
245 .split_once("<!--EndFragment-->")
246 .map_or(html, |(fragment, _)| fragment);
247 let mut reader = Reader {
248 base: Format {
249 font: Some("Calibri".into()),
250 font_size: Some(11.0),
251 language: Some(language),
252 ..Format::default()
253 },
254 load,
255 pieces: Vec::new(),
256 blocks: Vec::new(),
257 tables: Vec::new(),
258 lists: Vec::new(),
259 item: None,
260 open: Vec::new(),
261 runs: Vec::new(),
262 shown: false,
263 space: None,
264 };
265 reader.read(html);
266 reader.end_clip();
267 reader.pieces
268}
269
270/// A paragraph or table read, before it becomes a clip's.
271enum Block {
272 Text {
273 runs: Vec<(String, Format)>,
274 /// How many lists it sits in, its own not counted.
275 depth: usize,
276 list: Option<ListStyle>,
277 },
278 Table {
279 rows: Vec<Vec<Vec<Block>>>,
280 widths: Vec<Option<f32>>,
281 depth: usize,
282 },
283}
284
285#[derive(Default)]
286struct TableRead {
287 rows: Vec<Vec<Vec<Block>>>,
288 widths: Vec<Option<f32>>,
289 /// The open cell's blocks.
290 cell: Option<Vec<Block>>,
291}
292
293/// An open element: its name, the formatting it gives what it holds, and where a link's
294/// field code went in the runs, if it is one.
295struct Open {
296 name: String,
297 format: Format,
298 link: Option<usize>,
299}
300
301struct Reader<F> {
302 base: Format,
303 load: F,
304 pieces: Vec<Piece>,
305 blocks: Vec<Block>,
306 tables: Vec<TableRead>,
307 lists: Vec<ListStyle>,
308 /// The list of the item whose text has yet to come.
309 item: Option<ListStyle>,
310 open: Vec<Open>,
311 runs: Vec<(String, Format)>,
312 /// Whether the paragraph read so far shows anything.
313 shown: bool,
314 /// The formatting of white space after what it shows, which shows as one space if
315 /// more follows.
316 space: Option<Format>,
317}
318
319/// Elements that hold no text and have no closing tag.
320const VOID: [&str; 9] = [
321 "br", "img", "meta", "link", "input", "hr", "col", "wbr", "area",
322];
323
324/// Elements that begin and end a paragraph.
325const BLOCKS: [&str; 20] = [
326 "p",
327 "div",
328 "li",
329 "h1",
330 "h2",
331 "h3",
332 "h4",
333 "h5",
334 "h6",
335 "blockquote",
336 "pre",
337 "dt",
338 "dd",
339 "section",
340 "article",
341 "header",
342 "footer",
343 "figure",
344 "figcaption",
345 "address",
346];
347
348impl<F: FnMut(&str, [Option<f32>; 2]) -> Option<Piece>> Reader<F> {
349 fn read(&mut self, html: &str) {
350 // Elements whose content is not text: script, style, head and title.
351 let mut hidden: Option<String> = None;
352 let mut rest = html;
353 while !rest.is_empty() {
354 if let Some(after) = rest.strip_prefix("<!--") {
355 rest = after.split_once("-->").map_or("", |(_, after)| after);
356 continue;
357 }
358 let Some(after) = rest.strip_prefix('<') else {
359 let end = rest.find('<').unwrap_or(rest.len());
360 if hidden.is_none() {
361 self.text(&decode_entities(&rest[..end]));
362 }
363 rest = &rest[end..];
364 continue;
365 };
366 let end = tag_end(after);
367 let tag = &after[..end];
368 rest = after.get(end + 1..).unwrap_or("");
369 let (closing, tag) = match tag.strip_prefix('/') {
370 Some(tag) => (true, tag),
371 None => (false, tag),
372 };
373 let name = tag
374 .split(|c: char| c.is_whitespace() || c == '/')
375 .next()
376 .unwrap_or_default()
377 .to_ascii_lowercase();
378 if let Some(until) = &hidden {
379 if closing && name == *until {
380 hidden = None;
381 }
382 continue;
383 }
384 match (name.as_str(), closing) {
385 ("script" | "style" | "head" | "title", false) => hidden = Some(name),
386 (_, false) => self.start(&name, tag),
387 (_, true) => self.end(&name),
388 }
389 }
390 }
391
392 fn start(&mut self, name: &str, tag: &str) {
393 let block = BLOCKS.contains(&name);
394 if block || matches!(name, "br" | "table" | "tr" | "td" | "th" | "ul" | "ol") {
395 self.paragraph();
396 }
397 match name {
398 "br" => return,
399 "img" => {
400 let css = |name| {
401 attribute(tag, name)
402 .and_then(|value| value.trim_end_matches("px").parse::<f32>().ok())
403 .filter(|pixels| *pixels > 0.0)
404 };
405 if self.tables.is_empty()
406 && let Some(picture) = attribute(tag, "src").and_then(|source| {
407 (self.load)(&decode_entities(source), [css("width"), css("height")])
408 })
409 {
410 self.end_clip();
411 self.pieces.push(picture);
412 }
413 return;
414 }
415 "ul" | "ol" => {
416 let kind = attribute(tag, "type")
417 .map(str::to_owned)
418 .or_else(|| style(tag, "list-style-type"));
419 self.lists.push(list_style(name, kind.as_deref()));
420 }
421 "li" => {
422 self.implicit_end("li");
423 self.item = Some(self.lists.last().copied().unwrap_or(ListStyle::BULLET));
424 }
425 "p" => self.implicit_end("p"),
426 "table" => self.tables.push(TableRead::default()),
427 "tr" => {
428 if let Some(table) = self.tables.last_mut() {
429 table.end_cell();
430 table.rows.push(Vec::new());
431 }
432 }
433 "td" | "th" => {
434 if let Some(table) = self.tables.last_mut() {
435 table.end_cell();
436 if table.rows.is_empty() {
437 table.rows.push(Vec::new());
438 }
439 let column = table.rows.last().unwrap().len();
440 if table.widths.len() <= column {
441 table.widths.resize(column + 1, None);
442 }
443 let width = style(tag, "width")
444 .and_then(|width| length(&width, 12.0))
445 .or_else(|| {
446 attribute(tag, "width")?
447 .parse::<f32>()
448 .ok()
449 .map(|px| px * 0.75)
450 });
451 table.widths[column] = table.widths[column].or(width);
452 table.cell = Some(Vec::new());
453 }
454 }
455 _ => {}
456 }
457 if VOID.contains(&name) {
458 return;
459 }
460 let current = self.format();
461 let mut format = element_format(name, tag, &current);
462 let link = (name == "a")
463 .then(|| attribute(tag, "href"))
464 .flatten()
465 .map(|href| {
466 if let Some(space) = self.space.take() {
467 self.push(' ', space);
468 }
469 let mut code = format.inherit(&current);
470 code.hidden = Some(true);
471 code.hyperlink = Some(true);
472 code.hyperlink_label = Some(true);
473 self.runs.push((field_code(&decode_entities(href)), code));
474 format.hyperlink = Some(true);
475 format.hyperlink_label = Some(true);
476 self.runs.len() - 1
477 });
478 self.open.push(Open {
479 name: name.to_owned(),
480 format,
481 link,
482 });
483 }
484
485 fn end(&mut self, name: &str) {
486 if BLOCKS.contains(&name) || matches!(name, "table" | "tr" | "td" | "th" | "ul" | "ol") {
487 self.paragraph();
488 }
489 if let Some(at) = self.open.iter().rposition(|open| open.name == name) {
490 for open in self.open.drain(at..) {
491 // A link that showed nothing keeps no field code.
492 if let Some(code) = open.link
493 && code + 1 == self.runs.len()
494 {
495 self.runs.pop();
496 }
497 }
498 }
499 match name {
500 "ul" | "ol" => {
501 self.lists.pop();
502 }
503 "li" => self.item = None,
504 "td" | "th" => {
505 if let Some(table) = self.tables.last_mut() {
506 table.end_cell();
507 }
508 }
509 "table" => {
510 if let Some(mut table) = self.tables.pop() {
511 table.end_cell();
512 table.rows.retain(|row| !row.is_empty());
513 if !table.rows.is_empty() {
514 let block = Block::Table {
515 rows: table.rows,
516 widths: table.widths,
517 depth: self.lists.len(),
518 };
519 self.sink().push(block);
520 }
521 }
522 }
523 _ => {}
524 }
525 }
526
527 /// Ends an unclosed `name`, as a `p` or `li` is by the next, within the innermost list
528 /// or table.
529 fn implicit_end(&mut self, name: &str) {
530 let boundary =
531 |open: &Open| matches!(open.name.as_str(), "ul" | "ol" | "table" | "td" | "th");
532 if let Some(at) = self
533 .open
534 .iter()
535 .rposition(|open| open.name == name || boundary(open))
536 && self.open[at].name == name
537 {
538 self.open.truncate(at);
539 }
540 }
541
542 fn format(&self) -> Format {
543 self.open.iter().fold(self.base.clone(), |format, open| {
544 open.format.inherit(&format)
545 })
546 }
547
548 fn text(&mut self, text: &str) {
549 let format = self.format();
550 for c in text.chars() {
551 if c.is_whitespace() && c != '\u{a0}' {
552 if self.shown && self.space.is_none() {
553 self.space = Some(format.clone());
554 }
555 continue;
556 }
557 if let Some(space) = self.space.take() {
558 self.push(' ', space);
559 }
560 self.shown = true;
561 self.push(if c == '\u{a0}' { ' ' } else { c }, format.clone());
562 }
563 }
564
565 fn push(&mut self, c: char, format: Format) {
566 match self.runs.last_mut() {
567 Some((text, last)) if *last == format => text.push(c),
568 _ => self.runs.push((c.to_string(), format)),
569 }
570 }
571
572 /// Ends the paragraph being read, which goes in if it showed anything; a link's field
573 /// code begun in one that didn't carries on into the next.
574 fn paragraph(&mut self) {
575 self.space = None;
576 if !std::mem::take(&mut self.shown) {
577 return;
578 }
579 let mut runs = std::mem::take(&mut self.runs);
580 for open in &mut self.open {
581 open.link = None;
582 }
583 let list = self.item.take();
584 let depth = self.lists.len() - usize::from(list.is_some() && !self.lists.is_empty());
585 runs.retain(|(text, _)| !text.is_empty());
586 if runs.is_empty() {
587 runs.push((String::new(), self.format()));
588 }
589 self.sink().push(Block::Text { runs, depth, list });
590 }
591
592 /// Where a block read goes: the open cell, else the clip.
593 fn sink(&mut self) -> &mut Vec<Block> {
594 match self.tables.last_mut() {
595 Some(table) => table.cell.get_or_insert_with(Vec::new),
596 None => &mut self.blocks,
597 }
598 }
599
600 /// Makes what was read since the last picture a clip.
601 fn end_clip(&mut self) {
602 self.paragraph();
603 let blocks = std::mem::take(&mut self.blocks);
604 if blocks.is_empty() {
605 return;
606 }
607 let mut definitions = BTreeMap::new();
608 if let Ok(paragraphs) = nodes(blocks, &mut definitions)
609 && !paragraphs.is_empty()
610 {
611 self.pieces.push(Piece::Clip(Clip {
612 paragraphs,
613 definitions,
614 }));
615 }
616 }
617}
618
619impl TableRead {
620 fn end_cell(&mut self) {
621 if let Some(cell) = self.cell.take() {
622 match self.rows.last_mut() {
623 Some(row) => row.push(cell),
624 None => self.rows.push(vec![cell]),
625 }
626 }
627 }
628}
629
630/// `blocks` as paragraphs, each the child of the one before it at a lower level, with the
631/// list definitions they name added to `definitions`.
632fn nodes(
633 blocks: Vec<Block>,
634 definitions: &mut BTreeMap<ExGuid, Definition>,
635) -> Result<Vec<PageParagraph>, EditError> {
636 let depth = |block: &Block| match block {
637 Block::Text { depth, .. } | Block::Table { depth, .. } => *depth,
638 };
639 let least = blocks.iter().map(depth).min().unwrap_or(0);
640 let mut nodes = Vec::new();
641 let mut ancestors: Vec<(u32, ExGuid)> = Vec::new();
642 for block in blocks {
643 let level = (depth(&block) - least + 1) as u32;
644 let mut paragraph = match block {
645 Block::Text { runs, list, .. } => {
646 let first = runs[0].1.clone();
647 let mut paragraph = node(Paragraph::from_runs(runs), Format::default())?;
648 if let Some(style) = list {
649 let id = new_id()?;
650 definitions.insert(id, list_definition(style, &first));
651 paragraph.lists = vec![id];
652 }
653 paragraph
654 }
655 Block::Table { rows, widths, .. } => table(rows, widths, definitions)?,
656 };
657 while ancestors.last().is_some_and(|(above, _)| *above >= level) {
658 ancestors.pop();
659 }
660 paragraph.parent = ancestors.last().map(|(_, id)| *id);
661 paragraph.level = level;
662 ancestors.push((level, paragraph.id));
663 nodes.push(paragraph);
664 }
665 Ok(nodes)
666}
667
668fn table(
669 rows: Vec<Vec<Vec<Block>>>,
670 widths: Vec<Option<f32>>,
671 definitions: &mut BTreeMap<ExGuid, Definition>,
672) -> Result<PageParagraph, EditError> {
673 let columns = rows.iter().map(Vec::len).max().unwrap_or(1).max(1);
674 let rows = rows
675 .into_iter()
676 .map(|mut cells| {
677 cells.resize_with(columns, Vec::new);
678 Ok(TableRow {
679 id: new_id()?,
680 cells: cells
681 .into_iter()
682 .map(|blocks| {
683 let mut paragraphs = nodes(blocks, definitions)?;
684 if paragraphs.is_empty() {
685 let empty = Paragraph::new(String::new(), Format::default());
686 paragraphs.push(node(empty, Format::default())?);
687 }
688 Ok(TableCell {
689 id: new_id()?,
690 layout: Default::default(),
691 indents: Vec::new(),
692 shading: None,
693 paragraphs,
694 unsupported: Vec::new(),
695 })
696 })
697 .collect::<Result<_, EditError>>()?,
698 })
699 })
700 .collect::<Result<_, EditError>>()?;
701 let mut paragraph = node(
702 Paragraph::new(String::new(), Format::default()),
703 Format::default(),
704 )?;
705 paragraph.content = ParagraphContent::Table(Table {
706 id: new_id()?,
707 columns: (0..columns)
708 .map(|column| TableColumn {
709 width: widths
710 .get(column)
711 .copied()
712 .flatten()
713 .unwrap_or(COLUMN_WIDTH)
714 .max(COLUMN_WIDTH),
715 locked: false,
716 })
717 .collect(),
718 rows,
719 borders: Some(true),
720 layout: Default::default(),
721 tags: Vec::new(),
722 });
723 Ok(paragraph)
724}
725
726/// The list style an `ul` or `ol` of `kind`, its `type` or `list-style-type`, gives.
727fn list_style(name: &str, kind: Option<&str>) -> ListStyle {
728 let number = |format: &str| {
729 let index = NUMBER_LIBRARY.iter().position(|known| *known == format);
730 ListStyle::Number(index.unwrap_or(0))
731 };
732 match (name, kind.map(str::trim)) {
733 ("ul", Some("circle")) => ListStyle::Bullet(3),
734 ("ul", Some("square")) => ListStyle::Bullet(11),
735 ("ul", _) => ListStyle::BULLET,
736 (_, Some("a" | "lower-alpha" | "lower-latin")) => number("\u{fffd}\u{4}."),
737 (_, Some("A" | "upper-alpha" | "upper-latin")) => number("\u{fffd}\u{3}."),
738 (_, Some("i" | "lower-roman")) => number("\u{fffd}\u{2}."),
739 (_, Some("I" | "upper-roman")) => number("\u{fffd}\u{1}."),
740 _ => ListStyle::NUMBER,
741 }
742}
743
744/// The formatting an element gives its text: its own, as `b` and `font` give, then its
745/// `style`'s. `current` is the formatting around it.
746fn element_format(name: &str, tag: &str, current: &Format) -> Format {
747 let mut format = Format::default();
748 match name {
749 "b" | "strong" => format.bold = Some(true),
750 "i" | "em" | "cite" | "dfn" | "var" => format.italic = Some(true),
751 "u" | "ins" => format.underline = Some(true),
752 "s" | "strike" | "del" => format.strike = Some(true),
753 "sup" => format.superscript = Some(true),
754 "sub" => format.subscript = Some(true),
755 "code" | "tt" | "pre" | "kbd" | "samp" => format.font = Some("Courier New".into()),
756 "h1" | "h2" | "h3" | "h4" | "h5" | "h6" => {
757 const SIZES: [f32; 6] = [24.0, 18.0, 14.0, 12.0, 10.0, 8.0];
758 format.bold = Some(true);
759 format.font_size = Some(SIZES[usize::from(name.as_bytes()[1] - b'1')]);
760 }
761 "font" => {
762 format.font = attribute(tag, "face").and_then(family);
763 format.color = attribute(tag, "color").and_then(color).flatten();
764 format.font_size = attribute(tag, "size")
765 .and_then(|size| size.trim().parse::<usize>().ok())
766 .map(|size| [8.0, 10.0, 12.0, 14.0, 18.0, 24.0, 36.0][size.clamp(1, 7) - 1]);
767 }
768 _ => {}
769 }
770 let Some(css) = attribute(tag, "style") else {
771 return format;
772 };
773 let size = format
774 .font_size
775 .or(current.font_size)
776 .unwrap_or(DEFAULT_FONT_SIZE);
777 for declaration in decode_entities(css).split(';') {
778 let Some((property, value)) = declaration.split_once(':') else {
779 continue;
780 };
781 let value = value.trim().trim_end_matches("!important").trim();
782 match property.trim().to_ascii_lowercase().as_str() {
783 "font-weight" => {
784 format.bold = match value {
785 "bold" | "bolder" => Some(true),
786 "normal" | "lighter" => Some(false),
787 weight => weight.parse::<u32>().ok().map(|weight| weight >= 600),
788 }
789 }
790 "font-style" => format.italic = Some(matches!(value, "italic" | "oblique")),
791 "text-decoration" | "text-decoration-line" => {
792 format.underline = Some(value.contains("underline"));
793 format.strike = Some(value.contains("line-through"));
794 }
795 "font-family" => format.font = family(value).or(format.font),
796 "font-size" => format.font_size = length(value, size).or(format.font_size),
797 "color" => {
798 if let Some(color) = color(value) {
799 format.color = color;
800 }
801 }
802 "background" | "background-color" | "mso-highlight" => {
803 if let Some(color) = value.split_whitespace().find_map(color) {
804 format.highlight = color;
805 }
806 }
807 "vertical-align" => match value {
808 "super" => format.superscript = Some(true),
809 "sub" => format.subscript = Some(true),
810 _ => {}
811 },
812 _ => {}
813 }
814 }
815 format
816}
817
818/// The first family a `font-family` names, unless it is a generic one or a browser's name
819/// for the system's.
820fn family(value: &str) -> Option<String> {
821 let first = value
822 .split(',')
823 .next()?
824 .trim()
825 .trim_matches(['"', '\''])
826 .trim();
827 let generic = [
828 "serif",
829 "sans-serif",
830 "monospace",
831 "cursive",
832 "fantasy",
833 "system-ui",
834 "inherit",
835 "blinkmacsystemfont",
836 ];
837 let lower = first.to_ascii_lowercase();
838 (!first.is_empty() && !first.starts_with('-') && !generic.contains(&lower.as_str()))
839 .then(|| first.to_owned())
840}
841
842/// A CSS length in points, `em` and `%` of `size`.
843fn length(value: &str, size: f32) -> Option<f32> {
844 let value = value.trim();
845 let keyword = match value {
846 "xx-small" => Some(7.0),
847 "x-small" => Some(7.5),
848 "small" => Some(10.0),
849 "medium" => Some(12.0),
850 "large" => Some(13.5),
851 "x-large" => Some(18.0),
852 "xx-large" => Some(24.0),
853 _ => None,
854 };
855 if keyword.is_some() {
856 return keyword;
857 }
858 let split = value
859 .find(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-'))
860 .unwrap_or(value.len());
861 let (number, unit) = value.split_at(split);
862 let number: f32 = number.parse().ok()?;
863 let points = match unit.trim().to_ascii_lowercase().as_str() {
864 "pt" => number,
865 "px" | "" => number * 0.75,
866 "in" => number * 72.0,
867 "cm" => number * 72.0 / 2.54,
868 "mm" => number * 72.0 / 25.4,
869 "pc" => number * 12.0,
870 "em" | "rem" => number * size,
871 "%" => number * size / 100.0,
872 _ => return None,
873 };
874 (points.is_finite() && points > 0.0).then_some(points)
875}
876
877/// A CSS colour as a COLORREF: `Some(None)` for black and the defaults, which pasted text
878/// takes as automatic so it follows the page; none where it isn't one.
879fn color(value: &str) -> Option<Option<u32>> {
880 let value = value.trim().to_ascii_lowercase();
881 let rgb = |red: u32, green: u32, blue: u32| Some(red | green << 8 | blue << 16);
882 let parsed = if let Some(hex) = value.strip_prefix('#') {
883 let digits: Vec<u32> = hex.chars().map(|c| c.to_digit(16)).collect::<Option<_>>()?;
884 match digits[..] {
885 [r, g, b] => rgb(r * 17, g * 17, b * 17),
886 [r1, r0, g1, g0, b1, b0] => rgb(r1 * 16 + r0, g1 * 16 + g0, b1 * 16 + b0),
887 _ => return None,
888 }
889 } else if let Some(arguments) = value
890 .strip_prefix("rgb(")
891 .or_else(|| value.strip_prefix("rgba("))
892 {
893 let channels: Vec<u32> = arguments
894 .trim_end_matches(')')
895 .split(',')
896 .take(3)
897 .map(|channel| {
898 channel
899 .trim()
900 .parse::<f32>()
901 .ok()
902 .map(|c| c.clamp(0.0, 255.0) as u32)
903 })
904 .collect::<Option<_>>()?;
905 match channels[..] {
906 [r, g, b] => rgb(r, g, b),
907 _ => return None,
908 }
909 } else {
910 match value.as_str() {
911 "black" | "windowtext" | "auto" | "inherit" | "initial" | "currentcolor" | "none"
912 | "transparent" => None,
913 "white" => rgb(255, 255, 255),
914 "red" => rgb(255, 0, 0),
915 "green" => rgb(0, 128, 0),
916 "lime" => rgb(0, 255, 0),
917 "blue" => rgb(0, 0, 255),
918 "yellow" => rgb(255, 255, 0),
919 "orange" => rgb(255, 165, 0),
920 "purple" => rgb(128, 0, 128),
921 "gray" | "grey" => rgb(128, 128, 128),
922 "silver" => rgb(192, 192, 192),
923 "maroon" => rgb(128, 0, 0),
924 "navy" => rgb(0, 0, 128),
925 "teal" => rgb(0, 128, 128),
926 "olive" => rgb(128, 128, 0),
927 "aqua" | "cyan" => rgb(0, 255, 255),
928 "fuchsia" | "magenta" => rgb(255, 0, 255),
929 "darkred" => rgb(139, 0, 0),
930 "darkblue" => rgb(0, 0, 139),
931 "darkgreen" => rgb(0, 100, 0),
932 _ => return None,
933 }
934 };
935 Some(parsed.filter(|color| *color != 0))
936}
937
938/// The value `name` takes in a tag's `style`.
939fn style(tag: &str, name: &str) -> Option<String> {
940 decode_entities(attribute(tag, "style")?)
941 .split(';')
942 .filter_map(|declaration| declaration.split_once(':'))
943 .find(|(property, _)| property.trim().eq_ignore_ascii_case(name))
944 .map(|(_, value)| value.trim().to_owned())
945}
946
947/// Where a tag's text ends, before its `>`, outside quoted attribute values.
948fn tag_end(tag: &str) -> usize {
949 let mut quote = None;
950 for (at, c) in tag.char_indices() {
951 match (quote, c) {
952 (None, '"' | '\'') => quote = Some(c),
953 (Some(open), _) if c == open => quote = None,
954 (None, '>') => return at,
955 _ => {}
956 }
957 }
958 tag.len()
959}
960
961/// The value of attribute `name` in `tag`, entities still encoded.
962fn attribute<'a>(tag: &'a str, name: &str) -> Option<&'a str> {
963 let mut rest = tag;
964 while let Some(at) = rest.find('=') {
965 let key = rest[..at].trim_end().rsplit(char::is_whitespace).next()?;
966 let value = rest[at + 1..].trim_start();
967 let (value, after) = match value.chars().next()? {
968 quote @ ('"' | '\'') => value[1..].split_once(quote)?,
969 _ => value.split_at(value.find(char::is_whitespace).unwrap_or(value.len())),
970 };
971 if key.eq_ignore_ascii_case(name) {
972 return Some(value);
973 }
974 rest = after;
975 }
976 None
977}
978
979fn decode_entities(text: &str) -> String {
980 let mut decoded = String::with_capacity(text.len());
981 let mut rest = text;
982 while let Some(at) = rest.find('&') {
983 decoded.push_str(&rest[..at]);
984 rest = &rest[at..];
985 let entity = rest[1..]
986 .find(';')
987 .filter(|end| *end <= 10)
988 .map(|end| &rest[1..end + 1]);
989 let c = entity.and_then(|entity| match entity {
990 "amp" => Some('&'),
991 "lt" => Some('<'),
992 "gt" => Some('>'),
993 "quot" => Some('"'),
994 "apos" => Some('\''),
995 "nbsp" => Some('\u{a0}'),
996 _ => {
997 let number = entity.strip_prefix('#')?;
998 let code = match number.strip_prefix(['x', 'X']) {
999 Some(hex) => u32::from_str_radix(hex, 16).ok()?,
1000 None => number.parse().ok()?,
1001 };
1002 char::from_u32(code)
1003 }
1004 });
1005 match (c, entity) {
1006 (Some(c), Some(entity)) => {
1007 decoded.push(c);
1008 rest = &rest[entity.len() + 2..];
1009 }
1010 _ => {
1011 decoded.push('&');
1012 rest = &rest[1..];
1013 }
1014 }
1015 }
1016 decoded.push_str(rest);
1017 decoded
1018}
1019
1020#[cfg(test)]
1021mod tests {
1022 use super::*;
1023
1024 fn clips(html: &str) -> Vec<Clip> {
1025 html_pieces(html, 0x409, |_, _| None)
1026 .into_iter()
1027 .map(|piece| match piece {
1028 Piece::Clip(clip) => clip,
1029 _ => panic!("a picture where none was loaded"),
1030 })
1031 .collect()
1032 }
1033
1034 const ONENOTE: &str = include_str!("../../../../corpus/clipboard/onenote-2010.html");
1035
1036 /// What OneNote 2010 copied (`corpus/clipboard`) pastes with its formatting, links,
1037 /// lists and table; its tag is not in its HTML.
1038 #[test]
1039 fn onenotes_html_pastes_with_its_formatting() {
1040 let [clip] = &clips(ONENOTE)[..] else {
1041 panic!("one clip")
1042 };
1043 assert_eq!(
1044 clip.outline(),
1045 [
1046 "<b>Bold line</>",
1047 "plain <i>italic</> <color=0000c0>red</> <size=16>big</> \
1048 <font=Times New Roman>times</> <u>under</> <highlight=00ffff>hi</> \
1049 <link>link</> -> https://example.com/",
1050 "• bullet one",
1051 " • nested bullet",
1052 "• bullet two",
1053 "1. number one",
1054 "1. number two",
1055 "tagged todo",
1056 "after",
1057 "table 2x2",
1058 " | 0,0: a1",
1059 " | 0,1: <b>b1</>",
1060 " | 1,0: a2",
1061 " | 1,1: b2",
1062 "last line",
1063 ]
1064 );
1065 }
1066
1067 /// Snowbound's HTML reads back as the clip it was written from.
1068 #[test]
1069 fn snowbound_html_reads_back_as_written() {
1070 let [clip] = &clips(ONENOTE)[..] else {
1071 panic!("one clip")
1072 };
1073 let written = clip.html();
1074 let [read] = &clips(&written)[..] else {
1075 panic!("one clip")
1076 };
1077 assert_eq!(read.outline(), clip.outline());
1078 let runs = |clip: &Clip| {
1079 crate::document::leaves(&clip.paragraphs, None)
1080 .map(|(.., node)| node.text().unwrap().text.clone())
1081 .collect::<Vec<_>>()
1082 };
1083 assert_eq!(runs(read), runs(clip));
1084 }
1085
1086 /// Markup in text is escaped, spaces kept, and a paragraph that shows nothing still
1087 /// takes a line.
1088 #[test]
1089 fn text_survives_html() {
1090 let clip = &clips("<p>a &lt;b&gt; &amp;&nbsp;&nbsp;c</p><p>&nbsp;</p><p>d</p>")[0];
1091 assert_eq!(clip.text(), "a <b> & c\n \nd");
1092 let read = &clips(&clip.html())[0];
1093 assert_eq!(read.text(), clip.text());
1094 }
1095
1096 /// Other applications' markup: tags and inline styles, unclosed items, sizes in pixels
1097 /// and colours by name.
1098 #[test]
1099 fn web_markup_gives_formatting() {
1100 let clip = &clips(
1101 "<div style=\"font-family: Arial, sans-serif; font-size: 16px\">\
1102 <strong>Bold</strong> <em>it</em> <font color=red size=5>big</font> \
1103 <span style=\"font-weight:700; text-decoration:line-through\">gone</span>\
1104 x<sup>2</sup></div><ol type=a><li>first<li>second</ol>",
1105 )[0];
1106 assert_eq!(
1107 clip.outline(),
1108 [
1109 "<b font=Arial size=12>Bold</><font=Arial size=12> </>\
1110 <i font=Arial size=12>it</><font=Arial size=12> </>\
1111 <color=0000ff font=Arial size=18>big</><font=Arial size=12> </>\
1112 <b s font=Arial size=12>gone</><font=Arial size=12>x</>\
1113 <sup font=Arial size=12>2</>",
1114 "a. first",
1115 "a. second",
1116 ]
1117 );
1118 }
1119
1120 /// As OneNote 2010 pasted `<p>before <img> after</p>` (lab, 2026-09-30): the text on
1121 /// either side of the picture, the picture at the size its attributes give; one in a
1122 /// table is left out.
1123 #[test]
1124 fn a_pasted_page_gives_its_text_and_pictures_in_order() {
1125 let html = "Version:0.9\r\n<html><head><style>p{}</style><title>T</title></head>\
1126 <body><!--StartFragment--><p>before <img alt=\"a > b\" \
1127 src=\"file:///C:/work/pic.png\" width=\"200\" height=\"100px\"> after</p>\
1128 <p>Fish &amp; chips&nbsp;&#x2014;&#8212;</p><ul><li>one<li>two<br>three</ul>\
1129 <table><tr><td><img src=\"cell.png\">cell</td></tr></table>\
1130 <img src=\"missing.png\"><IMG SRC='https://example.invalid/a.png' width=40>\
1131 <!--EndFragment--></body></html>";
1132 let mut asked = Vec::new();
1133 let pieces = html_pieces(html, 0x409, |source, css| {
1134 asked.push((source.to_owned(), css));
1135 match source {
1136 "missing.png" => None,
1137 "https://example.invalid/a.png" => Some(Piece::Awaited),
1138 _ => Some(Piece::Picture(vec![0; 3], [150.0, 75.0])),
1139 }
1140 });
1141 assert_eq!(
1142 asked,
1143 [
1144 (
1145 "file:///C:/work/pic.png".to_owned(),
1146 [Some(200.0), Some(100.0)]
1147 ),
1148 ("missing.png".to_owned(), [None, None]),
1149 (
1150 "https://example.invalid/a.png".to_owned(),
1151 [Some(40.0), None]
1152 ),
1153 ]
1154 );
1155 let shown: Vec<Vec<String>> = pieces
1156 .iter()
1157 .map(|piece| match piece {
1158 Piece::Clip(clip) => clip.outline(),
1159 Piece::Picture(bytes, size) => vec![format!("[{} {size:?}]", bytes.len())],
1160 Piece::Awaited => vec!["[awaited]".to_owned()],
1161 })
1162 .collect();
1163 assert_eq!(
1164 shown,
1165 [
1166 vec!["before"],
1167 vec!["[3 [150.0, 75.0]]"],
1168 vec![
1169 "after",
1170 "Fish & chips \u{2014}\u{2014}",
1171 "• one",
1172 "• two",
1173 " three",
1174 "table 1x1",
1175 " | 0,0: cell",
1176 ],
1177 vec!["[awaited]"],
1178 ]
1179 );
1180 }
1181}
crates/canvas/src/editor/link.rs+1-1
...@@ -159,7 +159,7 @@ pub(crate) fn unlinked(format: &mut Format) {...@@ -159,7 +159,7 @@ pub(crate) fn unlinked(format: &mut Format) {
159}159}
160160
161/// A field code naming `address`, which cannot hold its quotes or line breaks.161/// A field code naming `address`, which cannot hold its quotes or line breaks.
162fn field_code(address: &str) -> String {162pub(super) fn field_code(address: &str) -> String {
163 let address: String = address163 let address: String = address
164 .trim()164 .trim()
165 .chars()165 .chars()
crates/canvas/src/editor/ops.rs+1-1
...@@ -72,7 +72,7 @@ pub(super) fn references(node: &PageParagraph) -> impl Iterator<Item = ExGuid> +...@@ -72,7 +72,7 @@ pub(super) fn references(node: &PageParagraph) -> impl Iterator<Item = ExGuid> +
72}72}
7373
74/// The definitions `paragraphs` and their cells name.74/// The definitions `paragraphs` and their cells name.
75fn named(75pub(super) fn named(
76 definitions: &BTreeMap<ExGuid, Definition>,76 definitions: &BTreeMap<ExGuid, Definition>,
77 paragraphs: &[&[PageParagraph]],77 paragraphs: &[&[PageParagraph]],
78) -> BTreeMap<ExGuid, Definition> {78) -> BTreeMap<ExGuid, Definition> {
crates/canvas/src/editor/table.rs+1-1
...@@ -141,7 +141,7 @@ fn cell_range(document: &TextDocument, cell: &TableCell) -> Result<Range<TextPos...@@ -141,7 +141,7 @@ fn cell_range(document: &TextDocument, cell: &TableCell) -> Result<Range<TextPos
141}141}
142142
143/// OneNote 2010's width for a new column, and the narrowest it fits or drags one to.143/// OneNote 2010's width for a new column, and the narrowest it fits or drags one to.
144const COLUMN_WIDTH: f32 = 37.11;144pub(super) const COLUMN_WIDTH: f32 = 37.11;
145/// What OneNote 2010 fits an unlocked column to beyond its widest line (lab, 2026-09-30).145/// What OneNote 2010 fits an unlocked column to beyond its widest line (lab, 2026-09-30).
146const COLUMN_ROOM: f32 = 4.347;146const COLUMN_ROOM: f32 = 4.347;
147147
crates/canvas/src/interaction/mod.rs+21-51
...@@ -17,8 +17,8 @@ use crate::gpu::{Paper, Viewport, page::PageScene};...@@ -17,8 +17,8 @@ use crate::gpu::{Paper, Viewport, page::PageScene};
17use crate::{17use crate::{
18 date::DateField,18 date::DateField,
19 editor::{19 editor::{
20 Awaited, CanvasEditor, DEFAULT_OUTLINE_WIDTH, Formatting, Piece, Selection, TextOutline,20 Awaited, CanvasEditor, Clip, DEFAULT_OUTLINE_WIDTH, Formatting, Piece, Selection,
21 Whole,21 TextOutline, Whole,
22 },22 },
23 layout::TextEngine,23 layout::TextEngine,
24};24};
...@@ -58,8 +58,8 @@ pub enum Cursor {...@@ -58,8 +58,8 @@ pub enum Cursor {
58pub enum Request {58pub enum Request {
59 /// Show the date or time picker, then report the choice through `PageView::change_date`.59 /// Show the date or time picker, then report the choice through `PageView::change_date`.
60 EditDate(DateField),60 EditDate(DateField),
61 /// Put this text on the clipboard.61 /// Put this on the clipboard.
62 Copy(String),62 Copy(Clip),
63 /// Read the clipboard and hand its text to `PageView::commit_text`.63 /// Read the clipboard and hand its text to `PageView::commit_text`.
64 Paste,64 Paste,
65 /// Open a link's address, as a click on a link does.65 /// Open a link's address, as a click on a link does.
...@@ -1494,6 +1494,16 @@ impl PageView {...@@ -1494,6 +1494,16 @@ impl PageView {
1494 self.edited()1494 self.edited()
1495 }1495 }
14961496
1497 /// Content Snowbound copied; see [`CanvasEditor::paste_clip`].
1498 pub fn paste_clip(&mut self, clip: Clip) -> Result<Response> {
1499 if !self.accepts_text() {
1500 return Ok(Response::default());
1501 }
1502 self.editor.paste_clip(&mut self.engine, clip)?;
1503 self.follow_pictures()?;
1504 self.edited()
1505 }
1506
1497 /// Clipboard text in `language`, an LCID; see [`CanvasEditor::paste`].1507 /// Clipboard text in `language`, an LCID; see [`CanvasEditor::paste`].
1498 pub fn paste(&mut self, text: &str, language: u32) -> Result<Response> {1508 pub fn paste(&mut self, text: &str, language: u32) -> Result<Response> {
1499 if !self.accepts_text() {1509 if !self.accepts_text() {
...@@ -1551,53 +1561,19 @@ impl PageView {...@@ -1551,53 +1561,19 @@ impl PageView {
1551 self.edited()1561 self.edited()
1552 }1562 }
15531563
1554 /// Copy, or Cut with `cut`: the selected text goes to the clipboard, the page selection's1564 /// Copy, or Cut with `cut`: [`CanvasEditor::clip`] goes to the clipboard.
1555 /// outlines top to bottom with a blank line between, as OneNote 2010 copies them.
1556 pub fn copy(&mut self, cut: bool) -> Result<Response> {1565 pub fn copy(&mut self, cut: bool) -> Result<Response> {
1557 let text = |outline: &TextOutline, selection: Selection| -> Result<String> {
1558 let [anchor, focus] = selection.positions;
1559 Ok(outline
1560 .document()
1561 .slice(anchor.min(focus)..anchor.max(focus))?
1562 .iter()
1563 .map(|paragraph| {
1564 paragraph
1565 .project()
1566 .map(|projection| projection.text().text().to_owned())
1567 })
1568 .collect::<std::result::Result<Vec<_>, _>>()?
1569 .join("\n"))
1570 };
1571 let page = self.editor.whole() == Some(Whole::Page);1566 let page = self.editor.whole() == Some(Whole::Page);
1572 let text = if page {1567 let Some(clip) = self.editor.clip()? else {
1573 let mut outlines = self
1574 .editor
1575 .outlines()
1576 .iter()
1577 .filter(|outline| !outline.title)
1578 .collect::<Vec<_>>();
1579 outlines.sort_by(|a, b| {
1580 let ([ax, ay], [bx, by]) = (a.origin(), b.origin());
1581 ay.total_cmp(&by).then(ax.total_cmp(&bx))
1582 });
1583 outlines
1584 .into_iter()
1585 .map(|outline| text(outline, outline.whole()))
1586 .collect::<Result<Vec<_>>>()?
1587 .join("\n\n")
1588 } else {
1589 text(self.editor.active_outline(), self.editor.selection())?
1590 };
1591 if text.is_empty() {
1592 return Ok(Response::default());1568 return Ok(Response::default());
1593 }1569 };
1594 if cut && page {1570 if cut && page {
1595 self.editor.delete(&mut self.engine, false)?;1571 self.editor.delete(&mut self.engine, false)?;
1596 } else if cut {1572 } else if cut {
1597 self.editor.insert(&mut self.engine, "")?;1573 self.editor.insert(&mut self.engine, "")?;
1598 }1574 }
1599 Ok(Response {1575 Ok(Response {
1600 request: Some(Request::Copy(text)),1576 request: Some(Request::Copy(clip)),
1601 ..self.edited()?1577 ..self.edited()?
1602 })1578 })
1603 }1579 }
...@@ -1821,19 +1797,13 @@ impl PageView {...@@ -1821,19 +1797,13 @@ impl PageView {
1821 self.edited()1797 self.edited()
1822 }1798 }
18231799
1824 /// Pasted text in `language`, an LCID, and pictures, in order and as one undo step; see1800 /// Pasted clips and pictures, in order and as one undo step; see
1825 /// [`CanvasEditor::paste_pieces`].1801 /// [`CanvasEditor::paste_pieces`].
1826 pub fn paste_pieces(1802 pub fn paste_pieces(&mut self, pieces: Vec<Piece>) -> Result<(Vec<Awaited>, Response)> {
1827 &mut self,
1828 pieces: Vec<Piece>,
1829 language: u32,
1830 ) -> Result<(Vec<Awaited>, Response)> {
1831 if self.edits_wait() {1803 if self.edits_wait() {
1832 return Ok((Vec::new(), Response::default()));1804 return Ok((Vec::new(), Response::default()));
1833 }1805 }
1834 let awaited = self1806 let awaited = self.editor.paste_pieces(&mut self.engine, pieces)?;
1835 .editor
1836 .paste_pieces(&mut self.engine, pieces, language)?;
1837 self.follow_pictures()?;1807 self.follow_pictures()?;
1838 Ok((awaited, self.edited()?))1808 Ok((awaited, self.edited()?))
1839 }1809 }
crates/canvas/src/interaction/tests.rs+12-9
...@@ -1681,10 +1681,7 @@ fn events_route_through_the_view_as_the_host_delivers_them() {...@@ -1681,10 +1681,7 @@ fn events_route_through_the_view_as_the_host_delivers_them() {
1681 })1681 })
1682 .unwrap();1682 .unwrap();
1683 let _ = view.key(&Key::Named(NamedKey::ArrowRight), None).unwrap();1683 let _ = view.key(&Key::Named(NamedKey::ArrowRight), None).unwrap();
1684 assert_eq!(1684 assert_eq!(copied(view.copy(false).unwrap()).as_deref(), Some("B"));
1685 view.copy(false).unwrap().request,
1686 Some(Request::Copy("B".into()))
1687 );
1688 let _ = view1685 let _ = view
1689 .modifiers_changed(Modifiers {1686 .modifiers_changed(Modifiers {
1690 command: true,1687 command: true,
...@@ -2363,12 +2360,10 @@ fn keys_act_on_the_page_selection_as_onenote_does() {...@@ -2363,12 +2360,10 @@ fn keys_act_on_the_page_selection_as_onenote_does() {
2363fn copy_and_cut_take_the_page_selection() {2360fn copy_and_cut_take_the_page_selection() {
2364 let (mut view, [alpha, ..]) = page_selection_view();2361 let (mut view, [alpha, ..]) = page_selection_view();
2365 select_page(&mut view, alpha);2362 select_page(&mut view, alpha);
2366 let copied = Some(Request::Copy(2363 let text = "Delta\n\nAlpha one\nAlpha two\n\nBeta\n\nGamma long line of text";
2367 "Delta\n\nAlpha one\nAlpha two\n\nBeta\n\nGamma long line of text".into(),2364 assert_eq!(copied(view.copy(false).unwrap()).as_deref(), Some(text));
2368 ));
2369 assert_eq!(view.copy(false).unwrap().request, copied);
2370 assert_eq!(view.editor.whole(), Some(Whole::Page));2365 assert_eq!(view.editor.whole(), Some(Whole::Page));
2371 assert_eq!(view.copy(true).unwrap().request, copied);2366 assert_eq!(copied(view.copy(true).unwrap()).as_deref(), Some(text));
2372 assert!(view.editor.outlines().iter().all(|outline| outline.title));2367 assert!(view.editor.outlines().iter().all(|outline| outline.title));
2373 assert!(view.editor.caret_outline().is_some());2368 assert!(view.editor.caret_outline().is_some());
2374}2369}
...@@ -3357,3 +3352,11 @@ fn a_column_border_drags_with_a_resize_pointer_and_stores_on_release() {...@@ -3357,3 +3352,11 @@ fn a_column_border_drags_with_a_resize_pointer_and_stores_on_release() {
3357 assert!((table.columns[0].width - (37.11 + 50.0)).abs() < 0.01);3352 assert!((table.columns[0].width - (37.11 + 50.0)).abs() < 0.01);
3358 assert_eq!(view.editor.take_ops().unwrap().len(), 1);3353 assert_eq!(view.editor.take_ops().unwrap().len(), 1);
3359}3354}
3355
3356/// The text of what a response asks the host to copy.
3357fn copied(response: Response) -> Option<String> {
3358 match response.request {
3359 Some(Request::Copy(clip)) => Some(clip.text()),
3360 _ => None,
3361 }
3362}
crates/canvas/tests/clip_paste.rs created+193
...@@ -0,0 +1,193 @@
1//! Formatted content pasted through the editor and stored: what OneNote 2010 copied as HTML
2//! (`corpus/clipboard`), then that outline copied in Snowbound's own format and pasted again
3//! elsewhere on the page, each one edit that the section stores and reads back.
4//! `CANVAS_CLIP_EXPORT` names a directory receiving the section as a notebook, for a cold
5//! reopen in OneNote 2010.
6
7use canvas::{
8 editor::{CanvasEditor, Clip, Piece, html_pieces},
9 layout::TextEngine,
10};
11use onestore::{
12 Arena, Section, Store,
13 document::Kind,
14 op::{Edit, Op},
15 page::{Outline, Page, PageObject, PageParagraph, ParagraphContent},
16};
17
18const ONENOTE: &str = include_str!("../../../corpus/clipboard/onenote-2010.html");
19
20/// Each paragraph of a body outline: its level, list kind, text and bold runs.
21fn outline(page: &Page, outline: &Outline) -> Vec<String> {
22 fn lines(page: &Page, nodes: &[PageParagraph], out: &mut Vec<String>) {
23 for node in nodes {
24 let list = match node.lists.last().map(|id| &page.definitions[id].kind) {
25 Some(Kind::List {
26 bullet: Some(_), ..
27 }) => "• ",
28 Some(Kind::List { .. }) => "# ",
29 _ => "",
30 };
31 let indent = " ".repeat(node.level as usize - 1);
32 match &node.content {
33 ParagraphContent::Text(text) => {
34 let shown = text.text.project().unwrap();
35 let bold = shown
36 .text()
37 .spans()
38 .iter()
39 .any(|span| span.format.bold == Some(true));
40 let bold = if bold { " (bold)" } else { "" };
41 out.push(format!("{indent}{list}{}{bold}", shown.text().text()));
42 }
43 ParagraphContent::Table(table) => {
44 out.push(format!("{indent}table"));
45 for cell in table.rows.iter().flat_map(|row| &row.cells) {
46 lines(page, &cell.paragraphs, out);
47 }
48 }
49 _ => out.push(format!("{indent}other")),
50 }
51 }
52 }
53 let mut out = Vec::new();
54 lines(page, &outline.paragraphs, &mut out);
55 out
56}
57
58fn bodies(page: &Page) -> Vec<Vec<String>> {
59 page.objects
60 .iter()
61 .filter_map(|object| match object {
62 PageObject::Outline(body) if !body.title => Some(outline(page, body)),
63 _ => None,
64 })
65 .collect()
66}
67
68#[test]
69fn onenotes_html_and_snowbounds_own_copy_paste_and_store() {
70 let source = onestore::create_section("clips.one", "Pasted clips", "Author").unwrap();
71 let arena = Arena::default();
72 let mut section = Section::open(&arena, source.clone()).unwrap();
73 let (space, ..) = section.pages().unwrap()[0].clone();
74 let mut engine = TextEngine::default();
75 let mut editor = CanvasEditor::from_page(section.page(space).unwrap(), &mut engine).unwrap();
76 let mut at = 133_000_000_000_000_000;
77 let mut store = |editor: &mut CanvasEditor| {
78 let ops = editor.take_ops().unwrap();
79 assert!(!ops.is_empty());
80 at += 10_000_000;
81 let ops = ops.into_iter().map(|op| Op::Page { space, op }).collect();
82 section.apply("Author", &Edit { at, ops }).unwrap();
83 };
84
85 editor
86 .place_caret(&mut engine, [72.0, 120.0], 400.0)
87 .unwrap();
88 let pieces = html_pieces(ONENOTE, 0x409, |_, _| None);
89 assert!(matches!(pieces[..], [Piece::Clip(_)]));
90 editor.paste_pieces(&mut engine, pieces).unwrap();
91 store(&mut editor);
92
93 editor.select_all().unwrap();
94 let copied = editor.clip().unwrap().unwrap();
95 let copied = Clip::decode(&copied.encode()).unwrap();
96 editor
97 .place_caret(&mut engine, [72.0, 500.0], 400.0)
98 .unwrap();
99 editor.paste_clip(&mut engine, copied).unwrap();
100 store(&mut editor);
101
102 let mut image = source;
103 section.seal().unwrap().unwrap().apply(&mut image).unwrap();
104 let arena = Arena::default();
105 let reopened = Section::open(&arena, image.clone()).unwrap();
106 let page = reopened.page(space).unwrap();
107 let pasted = [
108 "Bold line (bold)",
109 "plain italic red big times under hi link",
110 "• bullet one",
111 " • nested bullet",
112 "• bullet two",
113 "# number one",
114 "# number two",
115 "tagged todo",
116 "after",
117 "table",
118 "a1",
119 "b1 (bold)",
120 "a2",
121 "b2",
122 "last line",
123 ];
124 assert_eq!(
125 bodies(&page),
126 [vec!["Pasted clips"], pasted.to_vec(), pasted.to_vec()]
127 );
128 assert_eq!(bodies(&page), bodies(&editor.page().unwrap()));
129
130 if let Some(directory) = std::env::var_os("CANVAS_CLIP_EXPORT") {
131 let directory = std::path::PathBuf::from(directory);
132 std::fs::create_dir_all(&directory).unwrap();
133 std::fs::write(directory.join("clips.one"), &image).unwrap();
134 let file_id = Store::parse(&image).unwrap().header.file_id;
135 std::fs::write(
136 directory.join("Open Notebook.onetoc2"),
137 onestore::create_table_of_contents("Open Notebook.onetoc2", &[("clips.one", file_id)])
138 .unwrap(),
139 )
140 .unwrap();
141 }
142}
143
144/// A title takes a clip's text alone, as OneNote 2010's does (lab, 2026-10-02).
145#[test]
146fn a_title_takes_the_text_alone() {
147 let source = onestore::create_section("clips.one", "First page", "Author").unwrap();
148 let arena = Arena::default();
149 let mut section = Section::open(&arena, source).unwrap();
150 let creation = onestore::PageCreation::new(None, Some(""), "Author").unwrap();
151 let create = Op::Section(onestore::op::SectionOp::Create(creation));
152 let at = 133_000_000_000_000_000;
153 section
154 .apply(
155 "Author",
156 &Edit {
157 at,
158 ops: vec![create],
159 },
160 )
161 .unwrap();
162 let (space, ..) = section.pages().unwrap()[1].clone();
163 let mut engine = TextEngine::default();
164 let mut editor = CanvasEditor::from_page(section.page(space).unwrap(), &mut engine).unwrap();
165 let title = editor
166 .outlines()
167 .iter()
168 .find(|outline| outline.title)
169 .unwrap()
170 .id;
171 editor.focus_outline(title).unwrap();
172 let pieces = html_pieces("<b>Bold</b> <i>title</i>", 0x409, |_, _| None);
173 editor.paste_pieces(&mut engine, pieces).unwrap();
174 let ops = editor.take_ops().unwrap();
175 let ops = ops.into_iter().map(|op| Op::Page { space, op }).collect();
176 section
177 .apply(
178 "Author",
179 &Edit {
180 at: at + 10_000_000,
181 ops,
182 },
183 )
184 .unwrap();
185 let page = section.page(space).unwrap();
186 assert_eq!(page.title, "Bold title");
187 let title = editor
188 .outlines()
189 .iter()
190 .find(|outline| outline.title)
191 .unwrap();
192 assert_eq!(title.shown_text(), "Bold title");
193}
crates/canvas/tests/picture_paste.rs+11-17
...@@ -133,24 +133,16 @@ fn pasted_pictures_split_the_text_or_lie_on_the_page_as_onenote_places_them() {...@@ -133,24 +133,16 @@ fn pasted_pictures_split_the_text_or_lie_on_the_page_as_onenote_places_them() {
133 .paste_pieces(133 .paste_pieces(
134 &mut engine,134 &mut engine,
135 vec![135 vec![
136 Piece::Text("web ".into()),136 pasted("web&nbsp;"),
137 Piece::Picture(png, [150.0, 75.0]),137 Piece::Picture(png, [150.0, 75.0]),
138 Piece::Text("page\ntext ".into()),138 pasted("page<br>text&nbsp;"),
139 ],139 ],
140 0x0409,
141 )140 )
142 .unwrap();141 .unwrap();
143 store(&mut editor);142 store(&mut editor);
144 assert_eq!(143 assert_eq!(
145 body(&editor),144 body(&editor),
146 [145 ["Pasted web ", "[150.0, 75.0]", "page", "text ", "pictures"]
147 "Pasted web ",
148 "[150.0, 75.0]",
149 "",
150 "page",
151 "text ",
152 "pictures"
153 ]
154 );146 );
155 editor.undo(&mut engine).unwrap();147 editor.undo(&mut engine).unwrap();
156 store(&mut editor);148 store(&mut editor);
...@@ -162,12 +154,7 @@ fn pasted_pictures_split_the_text_or_lie_on_the_page_as_onenote_places_them() {...@@ -162,12 +154,7 @@ fn pasted_pictures_split_the_text_or_lie_on_the_page_as_onenote_places_them() {
162 let awaited = editor154 let awaited = editor
163 .paste_pieces(155 .paste_pieces(
164 &mut engine,156 &mut engine,
165 vec![157 vec![pasted("web"), Piece::Awaited, pasted("page&nbsp;")],
166 Piece::Text("web".into()),
167 Piece::Awaited,
168 Piece::Text("page ".into()),
169 ],
170 0x0409,
171 )158 )
172 .unwrap();159 .unwrap();
173 store(&mut editor);160 store(&mut editor);
...@@ -333,3 +320,10 @@ fn a_picture_pasted_from_the_title_goes_into_the_body() {...@@ -333,3 +320,10 @@ fn a_picture_pasted_from_the_title_goes_into_the_body() {
333 [Some(start[0]), Some(margin[1] + row * 18.0)]320 [Some(start[0]), Some(margin[1] + row * 18.0)]
334 );321 );
335}322}
323
324/// Pasted text, as a web page gives it.
325fn pasted(html: &str) -> Piece {
326 canvas::editor::html_pieces(html, 0x0409, |_, _| None)
327 .pop()
328 .unwrap()
329}
crates/mobile/src/lib.rs+2-2
...@@ -1225,8 +1225,8 @@ pub unsafe extern "C" fn sb_view_select_paragraph(view: &mut View, id: *const c_...@@ -1225,8 +1225,8 @@ pub unsafe extern "C" fn sb_view_select_paragraph(view: &mut View, id: *const c_
1225pub extern "C" fn sb_view_copy(view: &mut View, cut: bool) -> *mut c_char {1225pub extern "C" fn sb_view_copy(view: &mut View, cut: bool) -> *mut c_char {
1226 let mut copied = None;1226 let mut copied = None;
1227 let result = view.canvas.page.copy(cut).map(|response| {1227 let result = view.canvas.page.copy(cut).map(|response| {
1228 if let Some(Request::Copy(text)) = &response.request {1228 if let Some(Request::Copy(clip)) = &response.request {
1229 copied = Some(text.clone());1229 copied = Some(clip.text());
1230 }1230 }
1231 moved(response)1231 moved(response)
1232 });1232 });
crates/snowbound/Cargo.toml+3
...@@ -77,10 +77,13 @@ windows-sys = { version = "0.61", features = [...@@ -77,10 +77,13 @@ windows-sys = { version = "0.61", features = [
77 "Win32_Storage_FileSystem",77 "Win32_Storage_FileSystem",
78 "Win32_System_Com",78 "Win32_System_Com",
79 "Win32_System_Console",79 "Win32_System_Console",
80 "Win32_System_DataExchange",
80 "Win32_System_Diagnostics_Debug",81 "Win32_System_Diagnostics_Debug",
81 "Win32_System_IO",82 "Win32_System_IO",
82 "Win32_System_Kernel",83 "Win32_System_Kernel",
83 "Win32_System_LibraryLoader",84 "Win32_System_LibraryLoader",
85 "Win32_System_Memory",
86 "Win32_System_Ole",
84 "Win32_System_Pipes",87 "Win32_System_Pipes",
85 "Win32_System_Registry",88 "Win32_System_Registry",
86 "Win32_System_Threading",89 "Win32_System_Threading",
crates/snowbound/src/linux.rs+32
...@@ -197,6 +197,9 @@ pub fn install_title_bar(window: &Window) {...@@ -197,6 +197,9 @@ pub fn install_title_bar(window: &Window) {
197 crate::desktop::set_toplevel_icon(window);197 crate::desktop::set_toplevel_icon(window);
198}198}
199199
200/// The HTML and Snowbound's own format of this process's last copy through X11.
201static LAST_COPY: std::sync::Mutex<Option<(String, String)>> = std::sync::Mutex::new(None);
202
200/// Wayland's clipboard through the window's own connection, as not every compositor offers203/// Wayland's clipboard through the window's own connection, as not every compositor offers
201/// a clipboard to clients without a window; X11's otherwise.204/// a clipboard to clients without a window; X11's otherwise.
202pub enum Clipboard {205pub enum Clipboard {
...@@ -226,6 +229,35 @@ impl Clipboard {...@@ -226,6 +229,35 @@ impl Clipboard {
226 }229 }
227 }230 }
228231
232 /// Text, HTML and Snowbound's own format, as one copy: see [`crate::paste::Copied`].
233 /// smithay-clipboard offers text alone.
234 pub fn set(&mut self, copied: &crate::paste::Copied) -> Result<(), arboard::Error> {
235 match self {
236 Self::Wayland(clipboard) => {
237 clipboard.store(copied.text.clone());
238 Ok(())
239 }
240 Self::X11(clipboard) => {
241 clipboard.set().html(&copied.html, Some(&copied.text))?;
242 *LAST_COPY.lock().unwrap() = Some((copied.html.clone(), copied.clip.clone()));
243 Ok(())
244 }
245 }
246 }
247
248 /// What Snowbound itself copied, if it did: on X11, which arboard offers no format of
249 /// Snowbound's own through, the last copy's, while the clipboard holds its HTML.
250 pub fn get_clip(&mut self) -> Option<String> {
251 if !matches!(self, Self::X11(_)) {
252 return None;
253 }
254 let html = self.get_html()?;
255 match &*LAST_COPY.lock().unwrap() {
256 Some((copied, clip)) if *copied == html => Some(clip.clone()),
257 _ => None,
258 }
259 }
260
229 pub fn get_text(&mut self) -> Result<String, Box<dyn std::error::Error>> {261 pub fn get_text(&mut self) -> Result<String, Box<dyn std::error::Error>> {
230 Ok(match self {262 Ok(match self {
231 Self::Wayland(clipboard) => clipboard.load()?,263 Self::Wayland(clipboard) => clipboard.load()?,
crates/snowbound/src/macos.rs+40-3
...@@ -195,10 +195,33 @@ impl Clipboard {...@@ -195,10 +195,33 @@ impl Clipboard {
195 set.then_some(()).ok_or("The pasteboard refused the text")195 set.then_some(()).ok_or("The pasteboard refused the text")
196 }196 }
197197
198 /// Text, HTML and Snowbound's own format, as one copy.
199 pub fn set(&mut self, copied: &crate::paste::Copied) -> Result<(), &'static str> {
200 let set = unsafe {
201 let _: isize = msg_send![&self.0, clearContents];
202 [
203 (TEXT, &copied.text),
204 ("public.html", &copied.html),
205 (CLIP, &copied.clip),
206 ]
207 .into_iter()
208 .all(|(kind, value)| {
209 let (value, kind) = (NSString::from_str(value), NSString::from_str(kind));
210 msg_send![&self.0, setString: &*value, forType: &*kind]
211 })
212 };
213 set.then_some(()).ok_or("The pasteboard refused the copy")
214 }
215
198 pub fn get_text(&mut self) -> Result<String, &'static str> {216 pub fn get_text(&mut self) -> Result<String, &'static str> {
199 self.string(TEXT).ok_or("The pasteboard holds no text")217 self.string(TEXT).ok_or("The pasteboard holds no text")
200 }218 }
201219
220 /// What Snowbound itself copied, if it did.
221 pub fn get_clip(&mut self) -> Option<String> {
222 self.string(CLIP)
223 }
224
202 /// The files copied, as Finder puts them on the pasteboard.225 /// The files copied, as Finder puts them on the pasteboard.
203 pub fn get_files(&mut self) -> Vec<std::path::PathBuf> {226 pub fn get_files(&mut self) -> Vec<std::path::PathBuf> {
204 objc2::rc::autoreleasepool(|_| unsafe {227 objc2::rc::autoreleasepool(|_| unsafe {
...@@ -262,6 +285,9 @@ impl Clipboard {...@@ -262,6 +285,9 @@ impl Clipboard {
262/// NSPasteboardTypeString.285/// NSPasteboardTypeString.
263const TEXT: &str = "public.utf8-plain-text";286const TEXT: &str = "public.utf8-plain-text";
264287
288/// Snowbound's own format's pasteboard type.
289const CLIP: &str = "net.paperclover.snowbound.clip";
290
265/// TIFF `data` as PNG data, keeping its resolution.291/// TIFF `data` as PNG data, keeping its resolution.
266unsafe fn tiff_png(data: &AnyObject) -> Option<Retained<AnyObject>> {292unsafe fn tiff_png(data: &AnyObject) -> Option<Retained<AnyObject>> {
267 const PNG: usize = 4; // NSBitmapImageFileTypePNG293 const PNG: usize = 4; // NSBitmapImageFileTypePNG
...@@ -1541,10 +1567,10 @@ mod tests {...@@ -1541,10 +1567,10 @@ mod tests {
1541 use super::*;1567 use super::*;
1542 use crate::library::{Login, Mount};1568 use crate::library::{Login, Mount};
15431569
1544 /// Text, a page and files read back as written, from a pasteboard of the test's own,1570 /// Text, a page, files and a copy's every format read back as written, from a
1545 /// never the user's.1571 /// pasteboard of the test's own, never the user's.
1546 #[test]1572 #[test]
1547 fn the_pasteboard_reads_back_text_pages_and_files() {1573 fn the_pasteboard_reads_back_text_pages_files_and_copies() {
1548 let board: Retained<AnyObject> =1574 let board: Retained<AnyObject> =
1549 unsafe { msg_send_id![class!(NSPasteboard), pasteboardWithUniqueName] };1575 unsafe { msg_send_id![class!(NSPasteboard), pasteboardWithUniqueName] };
1550 let mut clipboard = Clipboard(board.clone());1576 let mut clipboard = Clipboard(board.clone());
...@@ -1567,6 +1593,17 @@ mod tests {...@@ -1567,6 +1593,17 @@ mod tests {
1567 let _: bool = msg_send![&board, writeObjects: &*urls];1593 let _: bool = msg_send![&board, writeObjects: &*urls];
1568 }1594 }
1569 assert_eq!(clipboard.get_files(), [std::path::PathBuf::from(path)]);1595 assert_eq!(clipboard.get_files(), [std::path::PathBuf::from(path)]);
1596 let copied = crate::paste::Copied {
1597 text: "Copied".into(),
1598 html: "<p>Copied</p>".into(),
1599 clip: "{}".into(),
1600 };
1601 clipboard.set(&copied).unwrap();
1602 assert_eq!(clipboard.get_text().unwrap(), "Copied");
1603 assert_eq!(clipboard.get_html().as_deref(), Some("<p>Copied</p>"));
1604 assert_eq!(clipboard.get_clip().as_deref(), Some("{}"));
1605 clipboard.set_text("Plain".into()).unwrap();
1606 assert_eq!(clipboard.get_clip(), None);
1570 unsafe { msg_send![&board, releaseGlobally] }1607 unsafe { msg_send![&board, releaseGlobally] }
1571 }1608 }
15721609
crates/snowbound/src/main.rs+26-12
...@@ -89,6 +89,7 @@ mod watch;...@@ -89,6 +89,7 @@ mod watch;
8989
90#[cfg(not(target_arch = "wasm32"))]90#[cfg(not(target_arch = "wasm32"))]
91use accesskit_winit::Adapter as AccessAdapter;91use accesskit_winit::Adapter as AccessAdapter;
92use canvas::editor::Clip;
92#[cfg(not(target_arch = "wasm32"))]93#[cfg(not(target_arch = "wasm32"))]
93use canvas::editor::{DEFAULT_OUTLINE_WIDTH, TextOutline};94use canvas::editor::{DEFAULT_OUTLINE_WIDTH, TextOutline};
94use canvas::gpu::colorref;95use canvas::gpu::colorref;
...@@ -255,14 +256,23 @@ struct Redraw(EventLoopProxy<UserEvent>);...@@ -255,14 +256,23 @@ struct Redraw(EventLoopProxy<UserEvent>);
255256
256enum Clipboard {257enum Clipboard {
257 System(platform::Clipboard),258 System(platform::Clipboard),
258 Memory(String),259 /// What was last copied, text alone or a clip.
260 Memory(String, Option<Clip>),
259}261}
260262
261impl Clipboard {263impl Clipboard {
262 fn set_text(&mut self, text: String) -> Result<(), Box<dyn Error>> {264 fn set_text(&mut self, text: String) -> Result<(), Box<dyn Error>> {
263 match self {265 match self {
264 Self::System(clipboard) => clipboard.set_text(text)?,266 Self::System(clipboard) => clipboard.set_text(text)?,
265 Self::Memory(held) => *held = text,267 Self::Memory(held, clip) => (*held, *clip) = (text, None),
268 }
269 Ok(())
270 }
271
272 fn set(&mut self, clip: Clip) -> Result<(), Box<dyn Error>> {
273 match self {
274 Self::System(clipboard) => clipboard.set(&paste::Copied::new(&clip))?,
275 Self::Memory(held, kept) => (*held, *kept) = (clip.text(), Some(clip)),
266 }276 }
267 Ok(())277 Ok(())
268 }278 }
...@@ -271,21 +281,25 @@ impl Clipboard {...@@ -271,21 +281,25 @@ impl Clipboard {
271 match self {281 match self {
272 Self::System(clipboard) => {282 Self::System(clipboard) => {
273 let files = clipboard.get_files();283 let files = clipboard.get_files();
284 let text = |clipboard: &mut platform::Clipboard| {
285 clipboard.get_text().ok().filter(|text| !text.is_empty())
286 };
274 if !files.is_empty() {287 if !files.is_empty() {
275 Some(paste::Pasted::Files(files))288 Some(paste::Pasted::Files(files))
276 } else if let Some(html) = clipboard289 } else if let Some(clip) = clipboard.get_clip().as_deref().and_then(Clip::decode) {
277 .get_html()290 Some(paste::Pasted::Clip(clip))
278 .filter(|html| html.to_ascii_lowercase().contains("<img"))291 } else if let Some(html) = clipboard.get_html() {
279 {292 Some(paste::Pasted::Page(html, text(clipboard)))
280 Some(paste::Pasted::Page(html))293 } else if let Some(text) = text(clipboard) {
281 } else if let Some(text) = clipboard.get_text().ok().filter(|text| !text.is_empty())
282 {
283 Some(paste::Pasted::Text(text))294 Some(paste::Pasted::Text(text))
284 } else {295 } else {
285 clipboard.get_picture().map(paste::Pasted::Picture)296 clipboard.get_picture().map(paste::Pasted::Picture)
286 }297 }
287 }298 }
288 Self::Memory(held) => Some(paste::Pasted::Text(held.clone())),299 Self::Memory(held, clip) => Some(match clip {
300 Some(clip) => paste::Pasted::Clip(clip.clone()),
301 None => paste::Pasted::Text(held.clone()),
302 }),
289 }303 }
290 }304 }
291}305}
...@@ -1068,7 +1082,7 @@ impl State {...@@ -1068,7 +1082,7 @@ impl State {
1068 let clipboard = if visible && std::env::var_os("SNOWBOUND_REPLAY").is_none() {1082 let clipboard = if visible && std::env::var_os("SNOWBOUND_REPLAY").is_none() {
1069 Clipboard::System(platform::Clipboard::new(&window)?)1083 Clipboard::System(platform::Clipboard::new(&window)?)
1070 } else {1084 } else {
1071 Clipboard::Memory(String::new())1085 Clipboard::Memory(String::new(), None)
1072 };1086 };
1073 let redraw: std::task::Waker = Arc::new(Redraw(proxy.clone())).into();1087 let redraw: std::task::Waker = Arc::new(Redraw(proxy.clone())).into();
1074 let search = search::Search::new(stored.search_scope, redraw.clone());1088 let search = search::Search::new(stored.search_scope, redraw.clone());
...@@ -3159,7 +3173,7 @@ impl State {...@@ -3159,7 +3173,7 @@ impl State {
3159 }3173 }
3160 Command::Template(choice) => self.apply_template(choice)?,3174 Command::Template(choice) => self.apply_template(choice)?,
3161 Command::Page(Request::EditDate(field)) => self.edit_date(field)?,3175 Command::Page(Request::EditDate(field)) => self.edit_date(field)?,
3162 Command::Page(Request::Copy(text)) => self.clipboard.set_text(text)?,3176 Command::Page(Request::Copy(clip)) => self.clipboard.set(clip)?,
3163 Command::Page(Request::Paste) => self.paste()?,3177 Command::Page(Request::Paste) => self.paste()?,
3164 Command::Page(Request::OpenLink(address)) => self.open_link(&address)?,3178 Command::Page(Request::OpenLink(address)) => self.open_link(&address)?,
3165 Command::Page(Request::OpenAttachment(file)) => self.open_attachment(&file)?,3179 Command::Page(Request::OpenAttachment(file)) => self.open_attachment(&file)?,
crates/snowbound/src/paste.rs+76-234
...@@ -1,20 +1,62 @@...@@ -1,20 +1,62 @@
1//! Pictures and files from the clipboard, and pictures inserted, as OneNote 2010 places them.1//! The clipboard's formats: what Copy offers, and pictures, files and pages Paste takes from
2//! it, with pictures inserted, as OneNote 2010 places them.
23
3use crate::{State, platform};4use crate::{State, platform};
4use canvas::editor::Piece;5use canvas::editor::{Clip, Piece};
5use std::{6use std::{
6 error::Error,7 error::Error,
7 path::{Path, PathBuf},8 path::{Path, PathBuf},
8};9};
910
11/// Every format Copy puts on the clipboard for one selection, which each platform's clipboard
12/// offers together: text; HTML, as OneNote 2010 offers it beside text (lab, 2026-10-02); and
13/// Snowbound's own format, [`Clip::encode`]'s JSON, which pastes losslessly into Snowbound.
14/// Its name is `net.paperclover.snowbound.clip` on macOS, `Snowbound Clip` on Windows and
15/// `application/x-snowbound-clip` where formats are MIME types.
16pub(crate) struct Copied {
17 pub text: String,
18 /// A whole page, the copy between `<!--StartFragment-->` and `<!--EndFragment-->`.
19 pub html: String,
20 pub clip: String,
21}
22
23impl Copied {
24 pub(crate) fn new(clip: &Clip) -> Self {
25 Self {
26 text: clip.text(),
27 html: clip.html(),
28 clip: clip.encode(),
29 }
30 }
31}
32
33/// `html` behind the header Windows' `HTML Format` begins with, which gives the byte offsets
34/// of the page and of the fragment its comments mark out.
35#[cfg(any(windows, test))]
36pub(crate) fn cf_html(html: &str) -> String {
37 let header = |offsets: [usize; 4]| {
38 format!(
39 "Version:0.9\r\nStartHTML:{:010}\r\nEndHTML:{:010}\r\nStartFragment:{:010}\r\nEndFragment:{:010}\r\n",
40 offsets[0], offsets[1], offsets[2], offsets[3]
41 )
42 };
43 let start = header([0; 4]).len();
44 let fragment = html
45 .find("<!--StartFragment-->")
46 .map_or(0, |at| at + "<!--StartFragment-->".len());
47 let end = html.find("<!--EndFragment-->").unwrap_or(html.len());
48 header([start, start + html.len(), start + fragment, start + end]) + html
49}
50
10/// What Paste takes from the clipboard. Files come first, as Finder also offers their names51/// What Paste takes from the clipboard. Files come first, as Finder also offers their names
11/// as text and their icons as pictures; then a page holding pictures, as its text alone52/// as text and their icons as pictures; then Snowbound's own copy, then a page, as its text
12/// would lose them; text before a picture, as Office and browsers offer a picture of copied53/// alone would lose its formatting and pictures; text before a picture, as Office and
13/// text beside it.54/// browsers offer a picture of copied text beside it.
14pub(crate) enum Pasted {55pub(crate) enum Pasted {
15 Files(Vec<PathBuf>),56 Files(Vec<PathBuf>),
16 /// HTML holding a picture.57 Clip(Clip),
17 Page(String),58 /// A web page, and the clipboard's text for one that holds nothing to paste.
59 Page(String, Option<String>),
18 Text(String),60 Text(String),
19 Picture(Vec<u8>),61 Picture(Vec<u8>),
20}62}
...@@ -27,10 +69,15 @@ impl State {...@@ -27,10 +69,15 @@ impl State {
27 self.place_file(&path, None)?;69 self.place_file(&path, None)?;
28 }70 }
29 }71 }
30 Some(Pasted::Page(html)) => {72 Some(Pasted::Clip(clip)) => {
73 let response = self.view.paste_clip(clip)?;
74 self.respond(response);
75 }
76 Some(Pasted::Page(html, text)) => {
31 // Web pictures come later, so the window never waits on the network.77 // Web pictures come later, so the window never waits on the network.
32 let mut fetched = Vec::new();78 let mut fetched = Vec::new();
33 let pieces = html_pieces(&html, |source, css| {79 let language = canvas::language::lcid(&platform::input_language());
80 let pieces = canvas::editor::html_pieces(&html, language, |source, css| {
34 if source.starts_with("https://") || source.starts_with("http://") {81 if source.starts_with("https://") || source.starts_with("http://") {
35 fetched.push((source.to_owned(), css));82 fetched.push((source.to_owned(), css));
36 return Some(Piece::Awaited);83 return Some(Piece::Awaited);
...@@ -39,8 +86,14 @@ impl State {...@@ -39,8 +86,14 @@ impl State {
39 let size = sized(&bytes, css)?;86 let size = sized(&bytes, css)?;
40 Some(Piece::Picture(bytes, size))87 Some(Piece::Picture(bytes, size))
41 });88 });
42 let language = canvas::language::lcid(&platform::input_language());89 if pieces.is_empty() {
43 let (places, response) = self.view.paste_pieces(pieces, language)?;90 if let Some(text) = text {
91 let response = self.view.paste(&text, language)?;
92 self.respond(response);
93 }
94 return Ok(());
95 }
96 let (places, response) = self.view.paste_pieces(pieces)?;
44 self.respond(response);97 self.respond(response);
45 for (place, (source, css)) in places.into_iter().zip(fetched) {98 for (place, (source, css)) in places.into_iter().zip(fetched) {
46 let proxy = self.proxy.clone();99 let proxy = self.proxy.clone();
...@@ -111,179 +164,6 @@ impl State {...@@ -111,179 +164,6 @@ impl State {
111 }164 }
112}165}
113166
114/// A web page fragment's text, a line to each block, and its pictures in order, as OneNote
115/// 2010 pastes them (lab, 2026-09-30). `load` makes a picture's piece from its source and
116/// its `width` and `height` in CSS pixels; one it can't is left out.
117fn html_pieces(
118 html: &str,
119 mut load: impl FnMut(&str, [Option<f32>; 2]) -> Option<Piece>,
120) -> Vec<Piece> {
121 let html = html
122 .split_once("<!--StartFragment-->")
123 .map_or(html, |(_, fragment)| fragment);
124 let html = html
125 .split_once("<!--EndFragment-->")
126 .map_or(html, |(fragment, _)| fragment);
127 let mut pieces = Vec::new();
128 let mut text = String::new();
129 let flush = |text: &mut String, pieces: &mut Vec<Piece>| {
130 let trimmed = text.trim_matches(|c: char| c.is_whitespace());
131 if !trimmed.is_empty() {
132 pieces.push(Piece::Text(trimmed.to_owned()));
133 }
134 text.clear();
135 };
136 let line = |text: &mut String| {
137 text.truncate(text.trim_end_matches(' ').len());
138 if !text.is_empty() && !text.ends_with('\n') {
139 text.push('\n');
140 }
141 };
142 // Elements whose content is not text: script, style, head and title.
143 let mut hidden: Option<String> = None;
144 let mut rest = html;
145 while !rest.is_empty() {
146 if let Some(after) = rest.strip_prefix("<!--") {
147 rest = after.split_once("-->").map_or("", |(_, after)| after);
148 continue;
149 }
150 let Some(after) = rest.strip_prefix('<') else {
151 let end = rest.find('<').unwrap_or(rest.len());
152 if hidden.is_none() {
153 for c in decode_entities(&rest[..end]).chars() {
154 if c.is_whitespace() && c != '\u{a0}' {
155 if !text.is_empty() && !text.ends_with([' ', '\n']) {
156 text.push(' ');
157 }
158 } else {
159 text.push(if c == '\u{a0}' { ' ' } else { c });
160 }
161 }
162 }
163 rest = &rest[end..];
164 continue;
165 };
166 let end = tag_end(after);
167 let tag = &after[..end];
168 rest = after.get(end + 1..).unwrap_or("");
169 let (closing, tag) = match tag.strip_prefix('/') {
170 Some(tag) => (true, tag),
171 None => (false, tag),
172 };
173 let name = tag
174 .split(|c: char| c.is_whitespace() || c == '/')
175 .next()
176 .unwrap_or_default()
177 .to_ascii_lowercase();
178 if let Some(until) = &hidden {
179 if closing && name == *until {
180 hidden = None;
181 }
182 continue;
183 }
184 match name.as_str() {
185 "script" | "style" | "head" | "title" if !closing => hidden = Some(name),
186 "br" => {
187 text.truncate(text.trim_end_matches(' ').len());
188 text.push('\n');
189 }
190 "p" | "div" | "li" | "tr" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "blockquote"
191 | "pre" | "table" | "ul" | "ol" | "dt" | "dd" | "section" | "article" | "header"
192 | "footer" | "figure" | "figcaption" => line(&mut text),
193 "img" if !closing => {
194 let css = |name| {
195 attribute(tag, name)
196 .and_then(|value| value.trim_end_matches("px").parse::<f32>().ok())
197 .filter(|pixels| *pixels > 0.0)
198 };
199 let Some(picture) = attribute(tag, "src").and_then(|source| {
200 load(&decode_entities(source), [css("width"), css("height")])
201 }) else {
202 continue;
203 };
204 flush(&mut text, &mut pieces);
205 pieces.push(picture);
206 }
207 _ => {}
208 }
209 }
210 flush(&mut text, &mut pieces);
211 pieces
212}
213
214/// Where a tag's text ends, before its `>`, outside quoted attribute values.
215fn tag_end(tag: &str) -> usize {
216 let mut quote = None;
217 for (at, c) in tag.char_indices() {
218 match (quote, c) {
219 (None, '"' | '\'') => quote = Some(c),
220 (Some(open), _) if c == open => quote = None,
221 (None, '>') => return at,
222 _ => {}
223 }
224 }
225 tag.len()
226}
227
228/// The value of attribute `name` in `tag`, entities still encoded.
229fn attribute<'a>(tag: &'a str, name: &str) -> Option<&'a str> {
230 let mut rest = tag;
231 while let Some(at) = rest.find('=') {
232 let key = rest[..at].trim_end().rsplit(char::is_whitespace).next()?;
233 let value = rest[at + 1..].trim_start();
234 let (value, after) = match value.chars().next()? {
235 quote @ ('"' | '\'') => value[1..].split_once(quote)?,
236 _ => value.split_at(value.find(char::is_whitespace).unwrap_or(value.len())),
237 };
238 if key.eq_ignore_ascii_case(name) {
239 return Some(value);
240 }
241 rest = after;
242 }
243 None
244}
245
246fn decode_entities(text: &str) -> String {
247 let mut decoded = String::with_capacity(text.len());
248 let mut rest = text;
249 while let Some(at) = rest.find('&') {
250 decoded.push_str(&rest[..at]);
251 rest = &rest[at..];
252 let entity = rest[1..]
253 .find(';')
254 .filter(|end| *end <= 10)
255 .map(|end| &rest[1..end + 1]);
256 let c = entity.and_then(|entity| match entity {
257 "amp" => Some('&'),
258 "lt" => Some('<'),
259 "gt" => Some('>'),
260 "quot" => Some('"'),
261 "apos" => Some('\''),
262 "nbsp" => Some('\u{a0}'),
263 _ => {
264 let number = entity.strip_prefix('#')?;
265 let code = match number.strip_prefix(['x', 'X']) {
266 Some(hex) => u32::from_str_radix(hex, 16).ok()?,
267 None => number.parse().ok()?,
268 };
269 char::from_u32(code)
270 }
271 });
272 match (c, entity) {
273 (Some(c), Some(entity)) => {
274 decoded.push(c);
275 rest = &rest[entity.len() + 2..];
276 }
277 _ => {
278 decoded.push('&');
279 rest = &rest[1..];
280 }
281 }
282 }
283 decoded.push_str(rest);
284 decoded
285}
286
287/// A picture's size in points from its `width` and `height` in CSS pixels, either giving167/// A picture's size in points from its `width` and `height` in CSS pixels, either giving
288/// the other by its proportions, or else from its resolution; none where it doesn't decode.168/// the other by its proportions, or else from its resolution; none where it doesn't decode.
289fn sized(bytes: &[u8], css: [Option<f32>; 2]) -> Option<[f32; 2]> {169fn sized(bytes: &[u8], css: [Option<f32>; 2]) -> Option<[f32; 2]> {
...@@ -597,58 +477,20 @@ mod tests {...@@ -597,58 +477,20 @@ mod tests {
597 bytes477 bytes
598 }478 }
599479
600 fn shown(pieces: &[Piece]) -> Vec<String> {480 /// The header's offsets find the page and the fragment, counted in bytes.
601 pieces
602 .iter()
603 .map(|piece| match piece {
604 Piece::Text(text) => text.clone(),
605 Piece::Picture(bytes, size) => format!("[{} {size:?}]", bytes.len()),
606 Piece::Awaited => "[awaited]".to_owned(),
607 })
608 .collect()
609 }
610
611 /// As OneNote 2010 pasted `<p>before <img> after</p>` (lab, 2026-09-30): the text on
612 /// either side of the picture, the picture at the size its attributes give.
613 #[test]481 #[test]
614 fn a_pasted_page_gives_its_text_and_pictures_in_order() {482 fn the_html_format_header_gives_its_offsets() {
615 let html = "Version:0.9\r\n<html><head><style>p{}</style><title>T</title></head>\483 let html =
616 <body><!--StartFragment--><p>before <img alt=\"a > b\" \484 "<html><body><!--StartFragment--><p>\u{e9}t\u{e9}</p><!--EndFragment--></body></html>";
617 src=\"file:///C:/work/pic.png\" width=\"200\" height=\"100px\"> after</p>\485 let wrapped = cf_html(html);
618 <p>Fish &amp; chips&nbsp;&#x2014;&#8212;</p><ul><li>one<li>two<br>three</ul>\486 let offset = |name: &str| {
619 <img src=\"missing.png\"><IMG SRC='https://example.invalid/a.png' width=40>\487 let at = wrapped.find(name).unwrap() + name.len() + 1;
620 <!--EndFragment--></body></html>";488 wrapped[at..at + 10].parse::<usize>().unwrap()
621 let mut asked = Vec::new();489 };
622 let pieces = html_pieces(html, |source, css| {490 assert_eq!(&wrapped[offset("StartHTML")..offset("EndHTML")], html);
623 asked.push((source.to_owned(), css));
624 match source {
625 "missing.png" => None,
626 "https://example.invalid/a.png" => Some(Piece::Awaited),
627 _ => Some(Piece::Picture(vec![0; 3], [150.0, 75.0])),
628 }
629 });
630 assert_eq!(
631 asked,
632 [
633 (
634 "file:///C:/work/pic.png".to_owned(),
635 [Some(200.0), Some(100.0)]
636 ),
637 ("missing.png".to_owned(), [None, None]),
638 (
639 "https://example.invalid/a.png".to_owned(),
640 [Some(40.0), None]
641 ),
642 ]
643 );
644 assert_eq!(491 assert_eq!(
645 shown(&pieces),492 &wrapped[offset("StartFragment")..offset("EndFragment")],
646 [493 "<p>\u{e9}t\u{e9}</p>"
647 "before",
648 "[3 [150.0, 75.0]]",
649 "after\nFish & chips \u{2014}\u{2014}\none\ntwo\nthree",
650 "[awaited]",
651 ]
652 );494 );
653 }495 }
654496
crates/snowbound/src/web.rs+9
...@@ -447,6 +447,15 @@ impl Clipboard {...@@ -447,6 +447,15 @@ impl Clipboard {
447 Ok(())447 Ok(())
448 }448 }
449449
450 /// A copy's text, the format every browser's clipboard takes.
451 pub fn set(&mut self, copied: &crate::paste::Copied) -> Result<(), Box<dyn Error>> {
452 self.set_text(copied.text.clone())
453 }
454
455 pub fn get_clip(&mut self) -> Option<String> {
456 None
457 }
458
450 pub fn get_text(&mut self) -> Result<String, Box<dyn Error>> {459 pub fn get_text(&mut self) -> Result<String, Box<dyn Error>> {
451 PASTED460 PASTED
452 .with_borrow(|pasted| pasted.text.clone())461 .with_borrow(|pasted| pasted.text.clone())
crates/snowbound/src/windows.rs+105-3
...@@ -1082,17 +1082,89 @@ pub fn appearance(window: &Window) -> Theme {...@@ -1082,17 +1082,89 @@ pub fn appearance(window: &Window) -> Theme {
1082 window.theme().unwrap_or(Theme::Light)1082 window.theme().unwrap_or(Theme::Light)
1083}1083}
10841084
1085pub struct Clipboard(arboard::Clipboard);1085/// The clipboard: arboard reads it; Copy writes its formats itself, as arboard can't offer
1086/// Snowbound's own.
1087pub struct Clipboard(arboard::Clipboard, HWND);
1088
1089/// Snowbound's own format's registered name.
1090const CLIP: &str = "Snowbound Clip";
10861091
1087impl Clipboard {1092impl Clipboard {
1088 pub fn new(_: &Window) -> Result<Self, arboard::Error> {1093 pub fn new(window: &Window) -> Result<Self, arboard::Error> {
1089 arboard::Clipboard::new().map(Self)1094 Ok(Self(arboard::Clipboard::new()?, hwnd(window)))
1090 }1095 }
10911096
1092 pub fn set_text(&mut self, text: String) -> Result<(), arboard::Error> {1097 pub fn set_text(&mut self, text: String) -> Result<(), arboard::Error> {
1093 self.0.set_text(text)1098 self.0.set_text(text)
1094 }1099 }
10951100
1101 /// Text, `HTML Format` and Snowbound's own, as one copy.
1102 pub fn set(&mut self, copied: &crate::paste::Copied) -> Result<(), &'static str> {
1103 use windows_sys::Win32::System::{DataExchange as clip, Memory, Ole::CF_UNICODETEXT};
1104 let text: Vec<u8> = copied
1105 .text
1106 .replace('\n', "\r\n")
1107 .encode_utf16()
1108 .chain([0])
1109 .flat_map(u16::to_le_bytes)
1110 .collect();
1111 let html = crate::paste::cf_html(&copied.html);
1112 let formats = [
1113 (u32::from(CF_UNICODETEXT), text),
1114 (registered("HTML Format"), [html.as_bytes(), &[0]].concat()),
1115 (registered(CLIP), [copied.clip.as_bytes(), &[0]].concat()),
1116 ];
1117 let _open = Open::new(self.1)?;
1118 if unsafe { clip::EmptyClipboard() } == 0 {
1119 return Err("The clipboard couldn't be emptied");
1120 }
1121 for (format, bytes) in formats {
1122 unsafe {
1123 let memory = Memory::GlobalAlloc(Memory::GMEM_MOVEABLE, bytes.len());
1124 if memory.is_null() {
1125 return Err("No memory for the clipboard");
1126 }
1127 let target = Memory::GlobalLock(memory);
1128 std::ptr::copy_nonoverlapping(bytes.as_ptr(), target.cast(), bytes.len());
1129 Memory::GlobalUnlock(memory);
1130 // The clipboard owns the memory once it takes it.
1131 if clip::SetClipboardData(format, memory).is_null() {
1132 windows_sys::Win32::Foundation::GlobalFree(memory);
1133 return Err("The clipboard refused a format");
1134 }
1135 }
1136 }
1137 Ok(())
1138 }
1139
1140 /// What Snowbound itself copied, if it did.
1141 pub fn get_clip(&mut self) -> Option<String> {
1142 use windows_sys::Win32::System::{DataExchange as clip, Memory};
1143 let format = registered(CLIP);
1144 if unsafe { clip::IsClipboardFormatAvailable(format) } == 0 {
1145 return None;
1146 }
1147 let _open = Open::new(self.1).ok()?;
1148 unsafe {
1149 let memory = clip::GetClipboardData(format);
1150 if memory.is_null() {
1151 return None;
1152 }
1153 let data = Memory::GlobalLock(memory);
1154 if data.is_null() {
1155 return None;
1156 }
1157 let bytes = std::slice::from_raw_parts(data.cast::<u8>(), Memory::GlobalSize(memory));
1158 let end = bytes
1159 .iter()
1160 .position(|&byte| byte == 0)
1161 .unwrap_or(bytes.len());
1162 let text = String::from_utf8(bytes[..end].to_vec()).ok();
1163 Memory::GlobalUnlock(memory);
1164 text
1165 }
1166 }
1167
1096 pub fn get_text(&mut self) -> Result<String, arboard::Error> {1168 pub fn get_text(&mut self) -> Result<String, arboard::Error> {
1097 self.0.get_text()1169 self.0.get_text()
1098 }1170 }
...@@ -1111,6 +1183,36 @@ impl Clipboard {...@@ -1111,6 +1183,36 @@ impl Clipboard {
1111 }1183 }
1112}1184}
11131185
1186/// The clipboard, open until dropped; another application may hold it a moment, so this
1187/// retries for a while as arboard does.
1188struct Open;
1189
1190impl Open {
1191 fn new(owner: HWND) -> Result<Self, &'static str> {
1192 use windows_sys::Win32::System::DataExchange::OpenClipboard;
1193 for _ in 0..10 {
1194 if unsafe { OpenClipboard(owner) } != 0 {
1195 return Ok(Self);
1196 }
1197 std::thread::sleep(Duration::from_millis(10));
1198 }
1199 Err("Another application is holding the clipboard")
1200 }
1201}
1202
1203impl Drop for Open {
1204 fn drop(&mut self) {
1205 unsafe { windows_sys::Win32::System::DataExchange::CloseClipboard() };
1206 }
1207}
1208
1209/// The number Windows gives clipboard format `name`.
1210fn registered(name: &str) -> u32 {
1211 unsafe {
1212 windows_sys::Win32::System::DataExchange::RegisterClipboardFormatW(wide(name).as_ptr())
1213 }
1214}
1215
1114/// The DWORD `value` under `key` in the user's registry hive.1216/// The DWORD `value` under `key` in the user's registry hive.
1115fn registry_dword(key: &str, value: &str) -> Option<u32> {1217fn registry_dword(key: &str, value: &str) -> Option<u32> {
1116 use windows_sys::Win32::System::Registry;1218 use windows_sys::Win32::System::Registry;