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");
19const WORD: &str = include_str!("../../../corpus/clipboard/word-2010.html");
20
21/// Each paragraph of a body outline: its level, list kind, text and bold runs.
22fn outline(page: &Page, outline: &Outline) -> Vec<String> {
23 fn lines(page: &Page, nodes: &[PageParagraph], out: &mut Vec<String>) {
24 for node in nodes {
25 let list = match node.lists.last().map(|id| &page.definitions[id].kind) {
26 Some(Kind::List {
27 format: Some(format),
28 ..
29 }) if !format.contains('\u{fffd}') => "• ",
30 Some(Kind::List { .. }) => "# ",
31 _ => "",
32 };
33 let indent = " ".repeat(node.level as usize - 1);
34 match &node.content {
35 ParagraphContent::Text(text) => {
36 let shown = text.text.project().unwrap();
37 let bold = shown
38 .text()
39 .spans()
40 .iter()
41 .any(|span| span.format.bold == Some(true));
42 let bold = if bold { " (bold)" } else { "" };
43 out.push(format!("{indent}{list}{}{bold}", shown.text().text()));
44 }
45 ParagraphContent::Table(table) => {
46 out.push(format!("{indent}table"));
47 for cell in table.rows.iter().flat_map(|row| &row.cells) {
48 lines(page, &cell.paragraphs, out);
49 }
50 }
51 _ => out.push(format!("{indent}other")),
52 }
53 }
54 }
55 let mut out = Vec::new();
56 lines(page, &outline.paragraphs, &mut out);
57 out
58}
59
60fn bodies(page: &Page) -> Vec<Vec<String>> {
61 page.objects
62 .iter()
63 .filter_map(|object| match object {
64 PageObject::Outline(body) if !body.title => Some(outline(page, body)),
65 _ => None,
66 })
67 .collect()
68}
69
70#[test]
71fn onenotes_html_and_snowbounds_own_copy_paste_and_store() {
72 let source = onestore::create_section("clips.one", "Pasted clips", "Author").unwrap();
73 let arena = Arena::default();
74 let mut section = Section::open(&arena, source.clone()).unwrap();
75 let (space, ..) = section.pages().unwrap()[0].clone();
76 let mut engine = TextEngine::default();
77 let mut editor = CanvasEditor::from_page(section.page(space).unwrap(), &mut engine).unwrap();
78 let mut at = 133_000_000_000_000_000;
79 let mut store = |editor: &mut CanvasEditor| {
80 let ops = editor.take_ops().unwrap();
81 assert!(!ops.is_empty());
82 at += 10_000_000;
83 let ops = ops.into_iter().map(|op| Op::Page { space, op }).collect();
84 section.apply("Author", &Edit { at, ops }).unwrap();
85 };
86
87 editor
88 .place_caret(&mut engine, [72.0, 120.0], 400.0)
89 .unwrap();
90 let pieces = html_pieces(ONENOTE, 0x409, |_, _| None);
91 assert!(matches!(pieces[..], [Piece::Clip(_)]));
92 editor.paste_pieces(&mut engine, pieces).unwrap();
93 store(&mut editor);
94
95 editor.select_all().unwrap();
96 let copied = editor.clip().unwrap().unwrap();
97 let copied = Clip::decode(&copied.encode()).unwrap();
98 editor
99 .place_caret(&mut engine, [72.0, 500.0], 400.0)
100 .unwrap();
101 editor.paste_clip(&mut engine, copied).unwrap();
102 store(&mut editor);
103
104 let mut image = source;
105 section.seal().unwrap().unwrap().apply(&mut image).unwrap();
106 let arena = Arena::default();
107 let reopened = Section::open(&arena, image.clone()).unwrap();
108 let page = reopened.page(space).unwrap();
109 let pasted = [
110 "Bold line (bold)",
111 "plain italic red big times under hi link",
112 "• bullet one",
113 " • nested bullet",
114 "• bullet two",
115 "# number one",
116 "# number two",
117 "tagged todo",
118 "after",
119 "table",
120 "a1",
121 "b1 (bold)",
122 "a2",
123 "b2",
124 "last line",
125 ];
126 assert_eq!(
127 bodies(&page),
128 [vec!["Pasted clips"], pasted.to_vec(), pasted.to_vec()]
129 );
130 assert_eq!(bodies(&page), bodies(&editor.page().unwrap()));
131
132 if let Some(directory) = std::env::var_os("CANVAS_CLIP_EXPORT") {
133 let directory = std::path::PathBuf::from(directory);
134 std::fs::create_dir_all(&directory).unwrap();
135 std::fs::write(directory.join("clips.one"), &image).unwrap();
136 let file_id = Store::parse(&image).unwrap().header.file_id;
137 std::fs::write(
138 directory.join("Open Notebook.onetoc2"),
139 onestore::create_table_of_contents("Open Notebook.onetoc2", &[("clips.one", file_id)])
140 .unwrap(),
141 )
142 .unwrap();
143 }
144}
145
146/// A title takes a clip's text alone, as OneNote 2010's does (lab, 2026-10-02).
147#[test]
148fn a_title_takes_the_text_alone() {
149 let source = onestore::create_section("clips.one", "First page", "Author").unwrap();
150 let arena = Arena::default();
151 let mut section = Section::open(&arena, source).unwrap();
152 let creation = onestore::PageCreation::new(None, Some(""), "Author").unwrap();
153 let create = Op::Section(onestore::op::SectionOp::Create(creation));
154 let at = 133_000_000_000_000_000;
155 section
156 .apply(
157 "Author",
158 &Edit {
159 at,
160 ops: vec![create],
161 },
162 )
163 .unwrap();
164 let (space, ..) = section.pages().unwrap()[1].clone();
165 let mut engine = TextEngine::default();
166 let mut editor = CanvasEditor::from_page(section.page(space).unwrap(), &mut engine).unwrap();
167 let title = editor
168 .outlines()
169 .iter()
170 .find(|outline| outline.title)
171 .unwrap()
172 .id;
173 editor.focus_outline(title).unwrap();
174 let pieces = html_pieces("<b>Bold</b> <i>title</i>", 0x409, |_, _| None);
175 editor.paste_pieces(&mut engine, pieces).unwrap();
176 let ops = editor.take_ops().unwrap();
177 let ops = ops.into_iter().map(|op| Op::Page { space, op }).collect();
178 section
179 .apply(
180 "Author",
181 &Edit {
182 at: at + 10_000_000,
183 ops,
184 },
185 )
186 .unwrap();
187 let page = section.page(space).unwrap();
188 assert_eq!(page.title, "Bold title");
189 let title = editor
190 .outlines()
191 .iter()
192 .find(|outline| outline.title)
193 .unwrap();
194 assert_eq!(title.shown_text(), "Bold title");
195}
196
197/// Several lines pasted from the title go into the body as OneNote 2010 puts them (lab,
198/// 2026-10-02), plain or formatted, and the title keeps its text: at the end of the outline
199/// where the body starts, in a new one there on a page without body content, and otherwise
200/// in a new one two grid rows below the content.
201#[test]
202fn several_lines_pasted_from_the_title_go_into_the_body() {
203 let source = onestore::create_section("clips.one", "First page", "Author").unwrap();
204 let arena = Arena::default();
205 let mut section = Section::open(&arena, source.clone()).unwrap();
206 let mut at = 133_000_000_000_000_000;
207 let mut spaces = Vec::new();
208 for title in ["Title paste", "Far content"] {
209 let creation = onestore::PageCreation::new(None, Some(title), "Author").unwrap();
210 let create = Op::Section(onestore::op::SectionOp::Create(creation));
211 at += 10_000_000;
212 section
213 .apply(
214 "Author",
215 &Edit {
216 at,
217 ops: vec![create],
218 },
219 )
220 .unwrap();
221 spaces.push(section.pages().unwrap().last().unwrap().0);
222 }
223 let mut engine = TextEngine::default();
224 let mut store = |section: &mut Section, editor: &mut CanvasEditor, space| {
225 let ops = editor.take_ops().unwrap();
226 assert!(!ops.is_empty());
227 at += 10_000_000;
228 let ops = ops.into_iter().map(|op| Op::Page { space, op }).collect();
229 section.apply("Author", &Edit { at, ops }).unwrap();
230 };
231 let title = |editor: &mut CanvasEditor| {
232 let title = editor.outlines().iter().find(|o| o.title).unwrap().id;
233 editor.focus_outline(title).unwrap();
234 };
235 let origins = |editor: &CanvasEditor| {
236 editor
237 .outlines()
238 .iter()
239 .filter(|outline| !outline.title)
240 .map(|outline| outline.origin())
241 .collect::<Vec<_>>()
242 };
243
244 let page = section.page(spaces[0]).unwrap();
245 let mut editor = CanvasEditor::from_page(page, &mut engine).unwrap();
246 let start = editor.body_start().unwrap();
247 title(&mut editor);
248 editor
249 .paste(
250 &mut engine,
251 "First line\r\nSecond line\r\nThird line",
252 0x409,
253 )
254 .unwrap();
255 store(&mut section, &mut editor, spaces[0]);
256 title(&mut editor);
257 editor.paste(&mut engine, "Alpha\r\nBeta", 0x409).unwrap();
258 store(&mut section, &mut editor, spaces[0]);
259 assert_eq!(origins(&editor), [start]);
260
261 let page = section.page(spaces[1]).unwrap();
262 let mut far = CanvasEditor::from_page(page, &mut engine).unwrap();
263 let margin = far.margin_origin();
264 far.place_caret(&mut engine, [start[0] + 270.0, start[1] + 72.0], 468.0)
265 .unwrap();
266 far.insert(&mut engine, "far").unwrap();
267 let bottom = far.active_outline().bounds().y1 as f32;
268 title(&mut far);
269 let pieces = html_pieces("<p><b>One</b></p><ul><li>Two</li></ul>", 0x409, |_, _| None);
270 far.paste_pieces(&mut engine, pieces).unwrap();
271 store(&mut section, &mut far, spaces[1]);
272 let row = ((bottom - margin[1]) / 18.0).floor() + 2.0;
273 assert_eq!(
274 origins(&far),
275 [
276 [start[0] + 270.0, start[1] + 72.0],
277 [start[0], margin[1] + row * 18.0]
278 ]
279 );
280
281 let mut image = source;
282 section.seal().unwrap().unwrap().apply(&mut image).unwrap();
283 let arena = Arena::default();
284 let reopened = Section::open(&arena, image.clone()).unwrap();
285 let page = reopened.page(spaces[0]).unwrap();
286 assert_eq!(page.title, "Title paste");
287 assert_eq!(
288 bodies(&page),
289 [vec![
290 "First line",
291 "Second line",
292 "Third line",
293 "Alpha",
294 "Beta"
295 ]]
296 );
297 assert_eq!(bodies(&page), bodies(&editor.page().unwrap()));
298 let page = reopened.page(spaces[1]).unwrap();
299 assert_eq!(page.title, "Far content");
300 assert_eq!(bodies(&page), [vec!["far"], vec!["One (bold)", "• Two"]]);
301
302 if let Some(directory) = std::env::var_os("CANVAS_TITLE_PASTE_EXPORT") {
303 let directory = std::path::PathBuf::from(directory);
304 std::fs::create_dir_all(&directory).unwrap();
305 std::fs::write(directory.join("clips.one"), &image).unwrap();
306 let file_id = Store::parse(&image).unwrap().header.file_id;
307 std::fs::write(
308 directory.join("Open Notebook.onetoc2"),
309 onestore::create_table_of_contents("Open Notebook.onetoc2", &[("clips.one", file_id)])
310 .unwrap(),
311 )
312 .unwrap();
313 }
314}
315
316/// Word 2010's lists (`corpus/clipboard/word-2010.html`) paste as lists that the section
317/// stores and reads back. `CANVAS_WORD_LIST_EXPORT` names a directory receiving the section
318/// as a notebook, for a cold reopen in OneNote 2010.
319#[test]
320fn words_lists_paste_and_store() {
321 let source = onestore::create_section("word.one", "Word lists", "Author").unwrap();
322 let arena = Arena::default();
323 let mut section = Section::open(&arena, source.clone()).unwrap();
324 let (space, ..) = section.pages().unwrap()[0].clone();
325 let mut engine = TextEngine::default();
326 let mut editor = CanvasEditor::from_page(section.page(space).unwrap(), &mut engine).unwrap();
327 editor
328 .place_caret(&mut engine, [72.0, 120.0], 400.0)
329 .unwrap();
330 editor
331 .paste_pieces(&mut engine, html_pieces(WORD, 0x409, |_, _| None))
332 .unwrap();
333 let ops = editor.take_ops().unwrap();
334 let ops = ops.into_iter().map(|op| Op::Page { space, op }).collect();
335 section
336 .apply(
337 "Author",
338 &Edit {
339 at: 133_000_000_000_000_000,
340 ops,
341 },
342 )
343 .unwrap();
344
345 let mut image = source;
346 section.seal().unwrap().unwrap().apply(&mut image).unwrap();
347 let arena = Arena::default();
348 let reopened = Section::open(&arena, image.clone()).unwrap();
349 let page = reopened.page(space).unwrap();
350 assert_eq!(
351 bodies(&page),
352 [
353 vec!["Word lists"],
354 vec![
355 "• Bullet one",
356 " • Bullet nested",
357 " • Bullet deeper",
358 "• Bullet two",
359 "Plain paragraph",
360 "# Number one",
361 " # Number nested",
362 " # Number deeper",
363 "# Number two",
364 "Last plain",
365 ]
366 ]
367 );
368 assert_eq!(bodies(&page), bodies(&editor.page().unwrap()));
369
370 if let Some(directory) = std::env::var_os("CANVAS_WORD_LIST_EXPORT") {
371 let directory = std::path::PathBuf::from(directory);
372 std::fs::create_dir_all(&directory).unwrap();
373 std::fs::write(directory.join("word.one"), &image).unwrap();
374 let file_id = Store::parse(&image).unwrap().header.file_id;
375 std::fs::write(
376 directory.join("Open Notebook.onetoc2"),
377 onestore::create_table_of_contents("Open Notebook.onetoc2", &[("word.one", file_id)])
378 .unwrap(),
379 )
380 .unwrap();
381 }
382}