| 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 | |
| 7 | use canvas::{ |
| 8 | editor::{CanvasEditor, Clip, Piece, html_pieces}, |
| 9 | layout::TextEngine, |
| 10 | }; |
| 11 | use onestore::{ |
| 12 | Arena, Section, Store, |
| 13 | document::Kind, |
| 14 | op::{Edit, Op}, |
| 15 | page::{Outline, Page, PageObject, PageParagraph, ParagraphContent}, |
| 16 | }; |
| 17 | |
| 18 | const ONENOTE: &str = include_str!("../../../corpus/clipboard/onenote-2010.html"); |
| 19 | const 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. |
| 22 | fn 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 | |
| 60 | fn 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] |
| 71 | fn 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] |
| 148 | fn 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] |
| 202 | fn 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] |
| 320 | fn 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 | } |