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.
147147From the title it joins the outline where the body starts, or lies two grid rows
148148below the page's content when none starts there. A picture takes the size its
149149resolution 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 holding
151pictures, its text and pictures in order as one undo step, then text, then a
152picture: Finder offers a copied file's name and icon beside it, and other apps a
153picture of copied text.
150file as its picture, as a dropped one goes in), then Snowbound's own copy, then
151a web page, its formatted text, lists, tables and pictures in order as one undo
152step, then text, then a picture: Finder offers a copied file's name and icon
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
155162## 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 {
757757 Err(EditError::InvalidRange)
758758 }
759759
760 pub(crate) fn slice(&self, range: Range<TextPosition>) -> Result<Vec<Paragraph>, EditError> {
761 if range.start > range.end {
762 return Err(EditError::InvalidRange);
763 }
764 let count = range
765 .end
766 .paragraph
767 .checked_sub(range.start.paragraph)
768 .and_then(|n| n.checked_add(1))
760 /// The nodes `range` covers, its end paragraphs cut to it: those of its container from
761 /// one end to the other, or where it crosses a table's edge, the root nodes holding them.
762 pub(crate) fn selected(
763 &self,
764 range: Range<TextPosition>,
765 ) -> Result<Vec<PageParagraph>, EditError> {
766 let (start_container, start, first) = self
767 .leaf(range.start.paragraph)
769768 .ok_or(EditError::InvalidRange)?;
770 let result = self
771 .paragraphs()
772 .skip(range.start.paragraph)
773 .take(count)
774 .enumerate()
775 .map(|(index, paragraph)| {
776 let start = if index == 0 { range.start.offset } else { 0 };
777 let end = if index == count - 1 {
778 range.end.offset
779 } else {
780 paragraph.utf16_offset(paragraph.text().len())?
781 };
782 paragraph.slice(start..end)
783 })
784 .collect::<Result<Vec<_>, EditError>>()?;
785 if result.len() != count {
786 return Err(EditError::InvalidRange);
769 let (end_container, end, last) = self
770 .leaf(range.end.paragraph)
771 .ok_or(EditError::InvalidRange)?;
772 let (first, last) = (first.id, last.id);
773 let (nodes, start, end) = if self.crosses_table(range.clone())? {
774 let root = |container: Option<ExGuid>, index| match container {
775 Some(cell) => self.root(cell),
776 None => Ok(index),
777 };
778 (
779 &self.nodes[..],
780 root(start_container, start)?,
781 root(end_container, end)?,
782 )
783 } else {
784 (self.container(start_container)?, start, end)
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)?;
787792 }
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)
789799 }
790800
791801 /// Replaces `range` as OneNote's typing, Enter and deletion do: the first paragraph keeps
......@@ -1332,14 +1342,20 @@ mod tests {
13321342 .collect::<Vec<_>>(),
13331343 ["before", "a🌲b", "second", "right", "after"]
13341344 );
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 );
13351356 assert_eq!(
1336 document
1337 .slice(position(1, 1)..position(3, 2))
1338 .unwrap()
1339 .iter()
1340 .map(Paragraph::text)
1341 .collect::<Vec<_>>(),
1342 ["🌲b", "second", "ri"]
1357 selected(position(0, 2)..position(4, 1)),
1358 ["fore", "a🌲b", "second", "right", "a"]
13431359 );
13441360 let edit = document
13451361 .replace(
crates/canvas/src/editor.rs+9-7
......@@ -24,10 +24,12 @@ use std::{
2424
2525pub const DEFAULT_OUTLINE_WIDTH: f32 = 468.0;
2626
27mod clip;
2728mod equation;
2829#[cfg(test)]
2930mod evidence;
3031mod format;
32mod html;
3133mod ink;
3234mod link;
3335mod ops;
......@@ -35,10 +37,12 @@ pub(crate) mod page;
3537mod recording;
3638mod space;
3739mod table;
40pub use clip::Clip;
3841pub use format::{
3942 Alignment, BULLET_LIBRARY, DefaultFont, FormatState, Formatting, ListStyle, NUMBER_LIBRARY,
4043 NoteTag, Toggle,
4144};
45pub use html::html_pieces;
4246pub use ink::{FAVORITES, Pen};
4347pub use link::{Link, shown_urls};
4448pub use page::ReadOnlyObject;
......@@ -84,7 +88,7 @@ pub struct Selection {
8488
8589/// A piece of pasted content, in the order it comes.
8690pub enum Piece {
87 Text(String),
91 Clip(Clip),
8892 /// An encoded picture and its size in points.
8993 Picture(Vec<u8>, [f32; 2]),
9094 /// A picture still on its way, which ends the paragraph and goes in where
......@@ -2333,20 +2337,19 @@ impl CanvasEditor {
23332337 self.place_caret(engine, [x, margin[1] + row * 18.0], DEFAULT_OUTLINE_WIDTH)
23342338 }
23352339
2336 /// Pastes text in `language` and pictures in order, as one undo step, as OneNote 2010
2337 /// pastes a web page's text with its pictures; the places of the awaited ones, in order.
2340 /// Pastes clips and pictures in order, as one undo step, as OneNote 2010 pastes a web
2341 /// page's text with its pictures; the places of the awaited ones, in order.
23382342 pub fn paste_pieces(
23392343 &mut self,
23402344 engine: &mut TextEngine,
23412345 pieces: Vec<Piece>,
2342 language: u32,
23432346 ) -> Result<Vec<Awaited>, EditorError> {
23442347 let depth = self.undo.len();
23452348 let mut result = Ok(());
23462349 let mut awaited = Vec::new();
23472350 for piece in pieces {
23482351 result = match piece {
2349 Piece::Text(text) => self.paste(engine, &text, language),
2352 Piece::Clip(clip) => self.paste_clip(engine, clip),
23502353 Piece::Picture(bytes, size) => picture(bytes, size)
23512354 .map_err(EditorError::from)
23522355 .and_then(|image| self.insert_picture(engine, image)),
......@@ -10659,10 +10662,9 @@ mod tests {
1065910662 .paste_pieces(
1066010663 &mut engine,
1066110664 vec![
10662 Piece::Text("Pasted".into()),
10665 html_pieces("Pasted", 0x409, |_, _| None).pop().unwrap(),
1066310666 Piece::Picture(vec![0; 8], [10.0; 2]),
1066410667 ],
10665 0x409,
1066610668 )
1066710669 .unwrap();
1066810670 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;
88use serde::{Deserialize, Serialize};
99
1010/// 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;
1212/// To Do's check box.
1313const CHECKBOX: u16 = 3;
1414
......@@ -143,7 +143,7 @@ impl ListStyle {
143143 pub(super) const NUMBER: Self = Self::Number(0);
144144
145145 /// 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> {
147147 let Kind::List {
148148 font,
149149 format: Some(format),
......@@ -1010,7 +1010,7 @@ impl CanvasEditor {
10101010 }
10111011
10121012 /// 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> {
10141014 if let Some((id, _)) = self
10151015 .definitions
10161016 .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) {
159159}
160160
161161/// 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 {
163163 let address: String = address
164164 .trim()
165165 .chars()
crates/canvas/src/editor/ops.rs+1-1
......@@ -72,7 +72,7 @@ pub(super) fn references(node: &PageParagraph) -> impl Iterator<Item = ExGuid> +
7272}
7373
7474/// The definitions `paragraphs` and their cells name.
75fn named(
75pub(super) fn named(
7676 definitions: &BTreeMap<ExGuid, Definition>,
7777 paragraphs: &[&[PageParagraph]],
7878) -> 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
141141}
142142
143143/// 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;
145145/// What OneNote 2010 fits an unlocked column to beyond its widest line (lab, 2026-09-30).
146146const 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};
1717use crate::{
1818 date::DateField,
1919 editor::{
20 Awaited, CanvasEditor, DEFAULT_OUTLINE_WIDTH, Formatting, Piece, Selection, TextOutline,
21 Whole,
20 Awaited, CanvasEditor, Clip, DEFAULT_OUTLINE_WIDTH, Formatting, Piece, Selection,
21 TextOutline, Whole,
2222 },
2323 layout::TextEngine,
2424};
......@@ -58,8 +58,8 @@ pub enum Cursor {
5858pub enum Request {
5959 /// Show the date or time picker, then report the choice through `PageView::change_date`.
6060 EditDate(DateField),
61 /// Put this text on the clipboard.
62 Copy(String),
61 /// Put this on the clipboard.
62 Copy(Clip),
6363 /// Read the clipboard and hand its text to `PageView::commit_text`.
6464 Paste,
6565 /// Open a link's address, as a click on a link does.
......@@ -1494,6 +1494,16 @@ impl PageView {
14941494 self.edited()
14951495 }
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
14971507 /// Clipboard text in `language`, an LCID; see [`CanvasEditor::paste`].
14981508 pub fn paste(&mut self, text: &str, language: u32) -> Result<Response> {
14991509 if !self.accepts_text() {
......@@ -1551,53 +1561,19 @@ impl PageView {
15511561 self.edited()
15521562 }
15531563
1554 /// Copy, or Cut with `cut`: the selected text goes to the clipboard, the page selection's
1555 /// outlines top to bottom with a blank line between, as OneNote 2010 copies them.
1564 /// Copy, or Cut with `cut`: [`CanvasEditor::clip`] goes to the clipboard.
15561565 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 };
15711566 let page = self.editor.whole() == Some(Whole::Page);
1572 let text = if page {
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() {
1567 let Some(clip) = self.editor.clip()? else {
15921568 return Ok(Response::default());
1593 }
1569 };
15941570 if cut && page {
15951571 self.editor.delete(&mut self.engine, false)?;
15961572 } else if cut {
15971573 self.editor.insert(&mut self.engine, "")?;
15981574 }
15991575 Ok(Response {
1600 request: Some(Request::Copy(text)),
1576 request: Some(Request::Copy(clip)),
16011577 ..self.edited()?
16021578 })
16031579 }
......@@ -1821,19 +1797,13 @@ impl PageView {
18211797 self.edited()
18221798 }
18231799
1824 /// Pasted text in `language`, an LCID, and pictures, in order and as one undo step; see
1800 /// Pasted clips and pictures, in order and as one undo step; see
18251801 /// [`CanvasEditor::paste_pieces`].
1826 pub fn paste_pieces(
1827 &mut self,
1828 pieces: Vec<Piece>,
1829 language: u32,
1830 ) -> Result<(Vec<Awaited>, Response)> {
1802 pub fn paste_pieces(&mut self, pieces: Vec<Piece>) -> Result<(Vec<Awaited>, Response)> {
18311803 if self.edits_wait() {
18321804 return Ok((Vec::new(), Response::default()));
18331805 }
1834 let awaited = self
1835 .editor
1836 .paste_pieces(&mut self.engine, pieces, language)?;
1806 let awaited = self.editor.paste_pieces(&mut self.engine, pieces)?;
18371807 self.follow_pictures()?;
18381808 Ok((awaited, self.edited()?))
18391809 }
crates/canvas/src/interaction/tests.rs+12-9
......@@ -1681,10 +1681,7 @@ fn events_route_through_the_view_as_the_host_delivers_them() {
16811681 })
16821682 .unwrap();
16831683 let _ = view.key(&Key::Named(NamedKey::ArrowRight), None).unwrap();
1684 assert_eq!(
1685 view.copy(false).unwrap().request,
1686 Some(Request::Copy("B".into()))
1687 );
1684 assert_eq!(copied(view.copy(false).unwrap()).as_deref(), Some("B"));
16881685 let _ = view
16891686 .modifiers_changed(Modifiers {
16901687 command: true,
......@@ -2363,12 +2360,10 @@ fn keys_act_on_the_page_selection_as_onenote_does() {
23632360fn copy_and_cut_take_the_page_selection() {
23642361 let (mut view, [alpha, ..]) = page_selection_view();
23652362 select_page(&mut view, alpha);
2366 let copied = Some(Request::Copy(
2367 "Delta\n\nAlpha one\nAlpha two\n\nBeta\n\nGamma long line of text".into(),
2368 ));
2369 assert_eq!(view.copy(false).unwrap().request, copied);
2363 let text = "Delta\n\nAlpha one\nAlpha two\n\nBeta\n\nGamma long line of text";
2364 assert_eq!(copied(view.copy(false).unwrap()).as_deref(), Some(text));
23702365 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));
23722367 assert!(view.editor.outlines().iter().all(|outline| outline.title));
23732368 assert!(view.editor.caret_outline().is_some());
23742369}
......@@ -3357,3 +3352,11 @@ fn a_column_border_drags_with_a_resize_pointer_and_stores_on_release() {
33573352 assert!((table.columns[0].width - (37.11 + 50.0)).abs() < 0.01);
33583353 assert_eq!(view.editor.take_ops().unwrap().len(), 1);
33593354}
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() {
133133 .paste_pieces(
134134 &mut engine,
135135 vec![
136 Piece::Text("web ".into()),
136 pasted("web&nbsp;"),
137137 Piece::Picture(png, [150.0, 75.0]),
138 Piece::Text("page\ntext ".into()),
138 pasted("page<br>text&nbsp;"),
139139 ],
140 0x0409,
141140 )
142141 .unwrap();
143142 store(&mut editor);
144143 assert_eq!(
145144 body(&editor),
146 [
147 "Pasted web ",
148 "[150.0, 75.0]",
149 "",
150 "page",
151 "text ",
152 "pictures"
153 ]
145 ["Pasted web ", "[150.0, 75.0]", "page", "text ", "pictures"]
154146 );
155147 editor.undo(&mut engine).unwrap();
156148 store(&mut editor);
......@@ -162,12 +154,7 @@ fn pasted_pictures_split_the_text_or_lie_on_the_page_as_onenote_places_them() {
162154 let awaited = editor
163155 .paste_pieces(
164156 &mut engine,
165 vec![
166 Piece::Text("web".into()),
167 Piece::Awaited,
168 Piece::Text("page ".into()),
169 ],
170 0x0409,
157 vec![pasted("web"), Piece::Awaited, pasted("page&nbsp;")],
171158 )
172159 .unwrap();
173160 store(&mut editor);
......@@ -333,3 +320,10 @@ fn a_picture_pasted_from_the_title_goes_into_the_body() {
333320 [Some(start[0]), Some(margin[1] + row * 18.0)]
334321 );
335322}
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_
12251225pub extern "C" fn sb_view_copy(view: &mut View, cut: bool) -> *mut c_char {
12261226 let mut copied = None;
12271227 let result = view.canvas.page.copy(cut).map(|response| {
1228 if let Some(Request::Copy(text)) = &response.request {
1229 copied = Some(text.clone());
1228 if let Some(Request::Copy(clip)) = &response.request {
1229 copied = Some(clip.text());
12301230 }
12311231 moved(response)
12321232 });
crates/snowbound/Cargo.toml+3
......@@ -77,10 +77,13 @@ windows-sys = { version = "0.61", features = [
7777 "Win32_Storage_FileSystem",
7878 "Win32_System_Com",
7979 "Win32_System_Console",
80 "Win32_System_DataExchange",
8081 "Win32_System_Diagnostics_Debug",
8182 "Win32_System_IO",
8283 "Win32_System_Kernel",
8384 "Win32_System_LibraryLoader",
85 "Win32_System_Memory",
86 "Win32_System_Ole",
8487 "Win32_System_Pipes",
8588 "Win32_System_Registry",
8689 "Win32_System_Threading",
crates/snowbound/src/linux.rs+32
......@@ -197,6 +197,9 @@ pub fn install_title_bar(window: &Window) {
197197 crate::desktop::set_toplevel_icon(window);
198198}
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
200203/// Wayland's clipboard through the window's own connection, as not every compositor offers
201204/// a clipboard to clients without a window; X11's otherwise.
202205pub enum Clipboard {
......@@ -226,6 +229,35 @@ impl Clipboard {
226229 }
227230 }
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
229261 pub fn get_text(&mut self) -> Result<String, Box<dyn std::error::Error>> {
230262 Ok(match self {
231263 Self::Wayland(clipboard) => clipboard.load()?,
crates/snowbound/src/macos.rs+40-3
......@@ -195,10 +195,33 @@ impl Clipboard {
195195 set.then_some(()).ok_or("The pasteboard refused the text")
196196 }
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
198216 pub fn get_text(&mut self) -> Result<String, &'static str> {
199217 self.string(TEXT).ok_or("The pasteboard holds no text")
200218 }
201219
220 /// What Snowbound itself copied, if it did.
221 pub fn get_clip(&mut self) -> Option<String> {
222 self.string(CLIP)
223 }
224
202225 /// The files copied, as Finder puts them on the pasteboard.
203226 pub fn get_files(&mut self) -> Vec<std::path::PathBuf> {
204227 objc2::rc::autoreleasepool(|_| unsafe {
......@@ -262,6 +285,9 @@ impl Clipboard {
262285/// NSPasteboardTypeString.
263286const TEXT: &str = "public.utf8-plain-text";
264287
288/// Snowbound's own format's pasteboard type.
289const CLIP: &str = "net.paperclover.snowbound.clip";
290
265291/// TIFF `data` as PNG data, keeping its resolution.
266292unsafe fn tiff_png(data: &AnyObject) -> Option<Retained<AnyObject>> {
267293 const PNG: usize = 4; // NSBitmapImageFileTypePNG
......@@ -1541,10 +1567,10 @@ mod tests {
15411567 use super::*;
15421568 use crate::library::{Login, Mount};
15431569
1544 /// Text, a page and files read back as written, from a pasteboard of the test's own,
1545 /// never the user's.
1570 /// Text, a page, files and a copy's every format read back as written, from a
1571 /// pasteboard of the test's own, never the user's.
15461572 #[test]
1547 fn the_pasteboard_reads_back_text_pages_and_files() {
1573 fn the_pasteboard_reads_back_text_pages_files_and_copies() {
15481574 let board: Retained<AnyObject> =
15491575 unsafe { msg_send_id![class!(NSPasteboard), pasteboardWithUniqueName] };
15501576 let mut clipboard = Clipboard(board.clone());
......@@ -1567,6 +1593,17 @@ mod tests {
15671593 let _: bool = msg_send![&board, writeObjects: &*urls];
15681594 }
15691595 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);
15701607 unsafe { msg_send![&board, releaseGlobally] }
15711608 }
15721609
crates/snowbound/src/main.rs+26-12
......@@ -89,6 +89,7 @@ mod watch;
8989
9090#[cfg(not(target_arch = "wasm32"))]
9191use accesskit_winit::Adapter as AccessAdapter;
92use canvas::editor::Clip;
9293#[cfg(not(target_arch = "wasm32"))]
9394use canvas::editor::{DEFAULT_OUTLINE_WIDTH, TextOutline};
9495use canvas::gpu::colorref;
......@@ -255,14 +256,23 @@ struct Redraw(EventLoopProxy<UserEvent>);
255256
256257enum Clipboard {
257258 System(platform::Clipboard),
258 Memory(String),
259 /// What was last copied, text alone or a clip.
260 Memory(String, Option<Clip>),
259261}
260262
261263impl Clipboard {
262264 fn set_text(&mut self, text: String) -> Result<(), Box<dyn Error>> {
263265 match self {
264266 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)),
266276 }
267277 Ok(())
268278 }
......@@ -271,21 +281,25 @@ impl Clipboard {
271281 match self {
272282 Self::System(clipboard) => {
273283 let files = clipboard.get_files();
284 let text = |clipboard: &mut platform::Clipboard| {
285 clipboard.get_text().ok().filter(|text| !text.is_empty())
286 };
274287 if !files.is_empty() {
275288 Some(paste::Pasted::Files(files))
276 } else if let Some(html) = clipboard
277 .get_html()
278 .filter(|html| html.to_ascii_lowercase().contains("<img"))
279 {
280 Some(paste::Pasted::Page(html))
281 } else if let Some(text) = clipboard.get_text().ok().filter(|text| !text.is_empty())
282 {
289 } else if let Some(clip) = clipboard.get_clip().as_deref().and_then(Clip::decode) {
290 Some(paste::Pasted::Clip(clip))
291 } else if let Some(html) = clipboard.get_html() {
292 Some(paste::Pasted::Page(html, text(clipboard)))
293 } else if let Some(text) = text(clipboard) {
283294 Some(paste::Pasted::Text(text))
284295 } else {
285296 clipboard.get_picture().map(paste::Pasted::Picture)
286297 }
287298 }
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 }),
289303 }
290304 }
291305}
......@@ -1068,7 +1082,7 @@ impl State {
10681082 let clipboard = if visible && std::env::var_os("SNOWBOUND_REPLAY").is_none() {
10691083 Clipboard::System(platform::Clipboard::new(&window)?)
10701084 } else {
1071 Clipboard::Memory(String::new())
1085 Clipboard::Memory(String::new(), None)
10721086 };
10731087 let redraw: std::task::Waker = Arc::new(Redraw(proxy.clone())).into();
10741088 let search = search::Search::new(stored.search_scope, redraw.clone());
......@@ -3159,7 +3173,7 @@ impl State {
31593173 }
31603174 Command::Template(choice) => self.apply_template(choice)?,
31613175 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)?,
31633177 Command::Page(Request::Paste) => self.paste()?,
31643178 Command::Page(Request::OpenLink(address)) => self.open_link(&address)?,
31653179 Command::Page(Request::OpenAttachment(file)) => self.open_attachment(&file)?,
crates/snowbound/src/paste.rs+76-234
......@@ -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
34use crate::{State, platform};
4use canvas::editor::Piece;
5use canvas::editor::{Clip, Piece};
56use std::{
67 error::Error,
78 path::{Path, PathBuf},
89};
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
1051/// 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 alone
12/// would lose them; text before a picture, as Office and browsers offer a picture of copied
13/// text beside it.
52/// as text and their icons as pictures; then Snowbound's own copy, then a page, as its text
53/// alone would lose its formatting and pictures; text before a picture, as Office and
54/// browsers offer a picture of copied text beside it.
1455pub(crate) enum Pasted {
1556 Files(Vec<PathBuf>),
16 /// HTML holding a picture.
17 Page(String),
57 Clip(Clip),
58 /// A web page, and the clipboard's text for one that holds nothing to paste.
59 Page(String, Option<String>),
1860 Text(String),
1961 Picture(Vec<u8>),
2062}
......@@ -27,10 +69,15 @@ impl State {
2769 self.place_file(&path, None)?;
2870 }
2971 }
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)) => {
3177 // Web pictures come later, so the window never waits on the network.
3278 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| {
3481 if source.starts_with("https://") || source.starts_with("http://") {
3582 fetched.push((source.to_owned(), css));
3683 return Some(Piece::Awaited);
......@@ -39,8 +86,14 @@ impl State {
3986 let size = sized(&bytes, css)?;
4087 Some(Piece::Picture(bytes, size))
4188 });
42 let language = canvas::language::lcid(&platform::input_language());
43 let (places, response) = self.view.paste_pieces(pieces, language)?;
89 if pieces.is_empty() {
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)?;
4497 self.respond(response);
4598 for (place, (source, css)) in places.into_iter().zip(fetched) {
4699 let proxy = self.proxy.clone();
......@@ -111,179 +164,6 @@ impl State {
111164 }
112165}
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
287167/// A picture's size in points from its `width` and `height` in CSS pixels, either giving
288168/// the other by its proportions, or else from its resolution; none where it doesn't decode.
289169fn sized(bytes: &[u8], css: [Option<f32>; 2]) -> Option<[f32; 2]> {
......@@ -597,58 +477,20 @@ mod tests {
597477 bytes
598478 }
599479
600 fn shown(pieces: &[Piece]) -> Vec<String> {
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.
480 /// The header's offsets find the page and the fragment, counted in bytes.
613481 #[test]
614 fn a_pasted_page_gives_its_text_and_pictures_in_order() {
615 let html = "Version:0.9\r\n<html><head><style>p{}</style><title>T</title></head>\
616 <body><!--StartFragment--><p>before <img alt=\"a > b\" \
617 src=\"file:///C:/work/pic.png\" width=\"200\" height=\"100px\"> after</p>\
618 <p>Fish &amp; chips&nbsp;&#x2014;&#8212;</p><ul><li>one<li>two<br>three</ul>\
619 <img src=\"missing.png\"><IMG SRC='https://example.invalid/a.png' width=40>\
620 <!--EndFragment--></body></html>";
621 let mut asked = Vec::new();
622 let pieces = html_pieces(html, |source, css| {
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 );
482 fn the_html_format_header_gives_its_offsets() {
483 let html =
484 "<html><body><!--StartFragment--><p>\u{e9}t\u{e9}</p><!--EndFragment--></body></html>";
485 let wrapped = cf_html(html);
486 let offset = |name: &str| {
487 let at = wrapped.find(name).unwrap() + name.len() + 1;
488 wrapped[at..at + 10].parse::<usize>().unwrap()
489 };
490 assert_eq!(&wrapped[offset("StartHTML")..offset("EndHTML")], html);
644491 assert_eq!(
645 shown(&pieces),
646 [
647 "before",
648 "[3 [150.0, 75.0]]",
649 "after\nFish & chips \u{2014}\u{2014}\none\ntwo\nthree",
650 "[awaited]",
651 ]
492 &wrapped[offset("StartFragment")..offset("EndFragment")],
493 "<p>\u{e9}t\u{e9}</p>"
652494 );
653495 }
654496
crates/snowbound/src/web.rs+9
......@@ -447,6 +447,15 @@ impl Clipboard {
447447 Ok(())
448448 }
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
450459 pub fn get_text(&mut self) -> Result<String, Box<dyn Error>> {
451460 PASTED
452461 .with_borrow(|pasted| pasted.text.clone())
crates/snowbound/src/windows.rs+105-3
......@@ -1082,17 +1082,89 @@ pub fn appearance(window: &Window) -> Theme {
10821082 window.theme().unwrap_or(Theme::Light)
10831083}
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
10871092impl Clipboard {
1088 pub fn new(_: &Window) -> Result<Self, arboard::Error> {
1089 arboard::Clipboard::new().map(Self)
1093 pub fn new(window: &Window) -> Result<Self, arboard::Error> {
1094 Ok(Self(arboard::Clipboard::new()?, hwnd(window)))
10901095 }
10911096
10921097 pub fn set_text(&mut self, text: String) -> Result<(), arboard::Error> {
10931098 self.0.set_text(text)
10941099 }
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
10961168 pub fn get_text(&mut self) -> Result<String, arboard::Error> {
10971169 self.0.get_text()
10981170 }
......@@ -1111,6 +1183,36 @@ impl Clipboard {
11111183 }
11121184}
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
11141216/// The DWORD `value` under `key` in the user's registry hive.
11151217fn registry_dword(key: &str, value: &str) -> Option<u32> {
11161218 use windows_sys::Win32::System::Registry;