| 1 | //! Files attached through the editor, as Snowbound's Attach File and drops store them: the |
| 2 | //! ops the editor records seal into a section that reads back with the exact bytes. |
| 3 | //! `CANVAS_ATTACHMENT_EXPORT` names a directory receiving that section as a notebook, for |
| 4 | //! a cold reopen in OneNote 2010 (`corpus/attachment-insert`), and `CANVAS_FLOATING_EXPORT` |
| 5 | //! the section of files attached on blank page (`corpus/attachment-floating`). |
| 6 | |
| 7 | use canvas::{editor::CanvasEditor, layout::TextEngine}; |
| 8 | use draw::edit::Movement; |
| 9 | use onestore::{ |
| 10 | Arena, PageCreation, Section, Store, |
| 11 | op::{Edit, Op, SectionOp}, |
| 12 | page::{Attachment, PageObject, ParagraphContent}, |
| 13 | }; |
| 14 | use std::sync::Arc; |
| 15 | |
| 16 | /// The icon OneNote 2010 stored with a text file (`corpus/native/cold-05-06-attachment`). |
| 17 | fn native_icon() -> Arc<[u8]> { |
| 18 | let native = |
| 19 | include_bytes!("../../../corpus/native/cold-05-06-attachment/notebook/synthetic.one"); |
| 20 | let arena = Arena::default(); |
| 21 | let mut section = Section::open(&arena, native.to_vec()).unwrap(); |
| 22 | let (space, ..) = section.pages().unwrap()[0].clone(); |
| 23 | let page = section.page(space).unwrap(); |
| 24 | page.objects |
| 25 | .iter() |
| 26 | .filter_map(|object| match object { |
| 27 | PageObject::Outline(outline) => Some(&outline.paragraphs), |
| 28 | _ => None, |
| 29 | }) |
| 30 | .flatten() |
| 31 | .find_map(|paragraph| match &paragraph.content { |
| 32 | ParagraphContent::Attachment(file) => file.preview.clone(), |
| 33 | _ => None, |
| 34 | }) |
| 35 | .unwrap() |
| 36 | } |
| 37 | |
| 38 | /// Two mebibytes no compressor shortens, larger than any buffer the writer fills at once. |
| 39 | fn large() -> Vec<u8> { |
| 40 | let mut state = 0x2545_f491_4f6c_dd1d_u64; |
| 41 | (0..2 << 20) |
| 42 | .map(|_| { |
| 43 | state ^= state << 13; |
| 44 | state ^= state >> 7; |
| 45 | state ^= state << 17; |
| 46 | state as u8 |
| 47 | }) |
| 48 | .collect() |
| 49 | } |
| 50 | |
| 51 | fn file(name: &str, bytes: Vec<u8>, preview: Option<Arc<[u8]>>) -> Attachment { |
| 52 | Attachment { |
| 53 | id: onestore::page::text::new_id().unwrap(), |
| 54 | filename: name.into(), |
| 55 | source_path: Some(format!("/Users/snowbound/Documents/{name}")), |
| 56 | size: Some([24.0, 24.0]), |
| 57 | layout: Default::default(), |
| 58 | bytes: Some(bytes.into()), |
| 59 | preview, |
| 60 | recording: None, |
| 61 | tags: Vec::new(), |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | #[test] |
| 66 | fn a_file_attached_from_the_title_stores_in_the_body() { |
| 67 | let mut image = onestore::create_section("files.one", "First page", "Author").unwrap(); |
| 68 | let arena = Arena::default(); |
| 69 | let mut section = Section::open(&arena, image.clone()).unwrap(); |
| 70 | section |
| 71 | .apply( |
| 72 | "Author", |
| 73 | &Edit { |
| 74 | at: 133_000_000_000_000_000, |
| 75 | ops: vec![Op::Section(SectionOp::Create( |
| 76 | PageCreation::new(None, Some("Attachment from title"), "Author").unwrap(), |
| 77 | ))], |
| 78 | }, |
| 79 | ) |
| 80 | .unwrap(); |
| 81 | let (space, ..) = section.pages().unwrap()[1].clone(); |
| 82 | let mut engine = TextEngine::default(); |
| 83 | let mut editor = CanvasEditor::from_page(section.page(space).unwrap(), &mut engine).unwrap(); |
| 84 | let title = editor |
| 85 | .outlines() |
| 86 | .iter() |
| 87 | .find(|outline| outline.title) |
| 88 | .unwrap() |
| 89 | .id; |
| 90 | editor.focus_outline(title).unwrap(); |
| 91 | let attached = file( |
| 92 | "attachment.txt", |
| 93 | b"Snowbound bounded import fixture\n".to_vec(), |
| 94 | Some(native_icon()), |
| 95 | ); |
| 96 | editor |
| 97 | .insert_attachment(&mut engine, attached.clone()) |
| 98 | .unwrap(); |
| 99 | assert!(!editor.active_outline().title); |
| 100 | let ops = editor |
| 101 | .take_ops() |
| 102 | .unwrap() |
| 103 | .into_iter() |
| 104 | .map(|op| Op::Page { space, op }) |
| 105 | .collect(); |
| 106 | section |
| 107 | .apply( |
| 108 | "Author", |
| 109 | &Edit { |
| 110 | at: 133_000_000_010_000_000, |
| 111 | ops, |
| 112 | }, |
| 113 | ) |
| 114 | .unwrap(); |
| 115 | section.seal().unwrap().unwrap().apply(&mut image).unwrap(); |
| 116 | let arena = Arena::default(); |
| 117 | let mut reopened = Section::open(&arena, image.clone()).unwrap(); |
| 118 | assert_eq!(reopened.pages().unwrap()[1].1, "Attachment from title"); |
| 119 | let page = reopened.page(space).unwrap(); |
| 120 | let files: Vec<_> = page |
| 121 | .objects |
| 122 | .iter() |
| 123 | .filter_map(|object| match object { |
| 124 | PageObject::Outline(outline) if !outline.title => Some(&outline.paragraphs), |
| 125 | _ => None, |
| 126 | }) |
| 127 | .flatten() |
| 128 | .filter_map(|paragraph| match &paragraph.content { |
| 129 | ParagraphContent::Attachment(file) => Some(file), |
| 130 | _ => None, |
| 131 | }) |
| 132 | .collect(); |
| 133 | assert_eq!(files, [&attached]); |
| 134 | if let Some(path) = std::env::var_os("CANVAS_TITLE_ATTACHMENT_EXPORT") { |
| 135 | std::fs::write(path, image).unwrap(); |
| 136 | } |
| 137 | } |
| 138 | |
| 139 | #[test] |
| 140 | fn attached_files_store_their_bytes_between_the_text_around_them() { |
| 141 | let source = onestore::create_section("files.one", "Before the file", "Author").unwrap(); |
| 142 | let arena = Arena::default(); |
| 143 | let mut section = Section::open(&arena, source.clone()).unwrap(); |
| 144 | let (space, ..) = section.pages().unwrap()[0].clone(); |
| 145 | let mut engine = TextEngine::default(); |
| 146 | let mut editor = CanvasEditor::from_page(section.page(space).unwrap(), &mut engine).unwrap(); |
| 147 | let body = editor |
| 148 | .outlines() |
| 149 | .iter() |
| 150 | .find(|outline| !outline.title) |
| 151 | .unwrap() |
| 152 | .id; |
| 153 | editor.focus_outline(body).unwrap(); |
| 154 | editor |
| 155 | .move_selection(&mut engine, Movement::DocumentEnd, false) |
| 156 | .unwrap(); |
| 157 | let small = file( |
| 158 | "notes 🦀.txt", |
| 159 | b"Snowbound attached this file.\n".to_vec(), |
| 160 | Some(native_icon()), |
| 161 | ); |
| 162 | // The icon a host without the system's draws, which `PageView::insert_attachment` gives. |
| 163 | let large = file( |
| 164 | "large.bin", |
| 165 | large(), |
| 166 | Some(canvas::gpu::page::file_icon().into()), |
| 167 | ); |
| 168 | let mut at = 133_000_000_000_000_000; |
| 169 | let mut store = |editor: &mut CanvasEditor| { |
| 170 | let ops = editor.take_ops().unwrap(); |
| 171 | assert!(!ops.is_empty()); |
| 172 | at += 10_000_000; |
| 173 | let ops = ops.into_iter().map(|op| Op::Page { space, op }).collect(); |
| 174 | section.apply("Author", &Edit { at, ops }).unwrap(); |
| 175 | }; |
| 176 | editor |
| 177 | .insert_attachment(&mut engine, small.clone()) |
| 178 | .unwrap(); |
| 179 | store(&mut editor); |
| 180 | editor.insert(&mut engine, "After the file").unwrap(); |
| 181 | store(&mut editor); |
| 182 | editor |
| 183 | .insert_attachment(&mut engine, large.clone()) |
| 184 | .unwrap(); |
| 185 | store(&mut editor); |
| 186 | let mut image = source; |
| 187 | section.seal().unwrap().unwrap().apply(&mut image).unwrap(); |
| 188 | |
| 189 | let arena = Arena::default(); |
| 190 | let reopened = Section::open(&arena, image.clone()).unwrap(); |
| 191 | let page = reopened.page(space).unwrap(); |
| 192 | let body = page |
| 193 | .objects |
| 194 | .iter() |
| 195 | .find_map(|object| match object { |
| 196 | PageObject::Outline(outline) if !outline.title => Some(outline), |
| 197 | _ => None, |
| 198 | }) |
| 199 | .unwrap(); |
| 200 | let shown: Vec<String> = body |
| 201 | .paragraphs |
| 202 | .iter() |
| 203 | .map(|paragraph| match &paragraph.content { |
| 204 | ParagraphContent::Text(text) => text.text.text().to_owned(), |
| 205 | ParagraphContent::Attachment(file) => { |
| 206 | let expected = [&small, &large] |
| 207 | .into_iter() |
| 208 | .find(|expected| expected.id == file.id) |
| 209 | .unwrap(); |
| 210 | assert_eq!(file, expected); |
| 211 | assert_eq!(file.bytes, expected.bytes); |
| 212 | assert_eq!(file.preview, expected.preview); |
| 213 | format!("[{}]", file.filename) |
| 214 | } |
| 215 | _ => panic!(), |
| 216 | }) |
| 217 | .collect(); |
| 218 | assert_eq!( |
| 219 | shown, |
| 220 | [ |
| 221 | "Before the file", |
| 222 | "[notes 🦀.txt]", |
| 223 | "After the file", |
| 224 | "[large.bin]", |
| 225 | "" |
| 226 | ] |
| 227 | ); |
| 228 | |
| 229 | if let Some(directory) = std::env::var_os("CANVAS_ATTACHMENT_EXPORT") { |
| 230 | let directory = std::path::PathBuf::from(directory); |
| 231 | std::fs::create_dir_all(&directory).unwrap(); |
| 232 | std::fs::write(directory.join("files.one"), &image).unwrap(); |
| 233 | let file_id = Store::parse(&image).unwrap().header.file_id; |
| 234 | std::fs::write( |
| 235 | directory.join("Open Notebook.onetoc2"), |
| 236 | onestore::create_table_of_contents("Open Notebook.onetoc2", &[("files.one", file_id)]) |
| 237 | .unwrap(), |
| 238 | ) |
| 239 | .unwrap(); |
| 240 | } |
| 241 | } |
| 242 | |
| 243 | /// Files attached where a click on blank page left the caret lie on the page there, as OneNote |
| 244 | /// 2010 places them, and move as one does. |
| 245 | #[test] |
| 246 | fn files_attached_on_blank_page_store_their_bytes_where_they_were_placed_and_moved() { |
| 247 | let source = onestore::create_section("files.one", "Floating files", "Author").unwrap(); |
| 248 | let arena = Arena::default(); |
| 249 | let mut section = Section::open(&arena, source.clone()).unwrap(); |
| 250 | let (space, ..) = section.pages().unwrap()[0].clone(); |
| 251 | let mut engine = TextEngine::default(); |
| 252 | let mut editor = CanvasEditor::from_page(section.page(space).unwrap(), &mut engine).unwrap(); |
| 253 | let small = file( |
| 254 | "float 🦀.txt", |
| 255 | b"Snowbound put this file on the page.\n".to_vec(), |
| 256 | Some(native_icon()), |
| 257 | ); |
| 258 | let large = file( |
| 259 | "large.bin", |
| 260 | large(), |
| 261 | Some(canvas::gpu::page::file_icon().into()), |
| 262 | ); |
| 263 | let mut at = 133_000_000_000_000_000; |
| 264 | let mut store = |editor: &mut CanvasEditor| { |
| 265 | let ops = editor.take_ops().unwrap(); |
| 266 | assert_eq!(ops.len(), 1, "{ops:?}"); |
| 267 | at += 10_000_000; |
| 268 | let ops = ops.into_iter().map(|op| Op::Page { space, op }).collect(); |
| 269 | section.apply("Author", &Edit { at, ops }).unwrap(); |
| 270 | }; |
| 271 | editor |
| 272 | .place_caret(&mut engine, [342.0, 284.4], 240.0) |
| 273 | .unwrap(); |
| 274 | editor |
| 275 | .insert_attachment(&mut engine, small.clone()) |
| 276 | .unwrap(); |
| 277 | store(&mut editor); |
| 278 | editor |
| 279 | .place_caret(&mut engine, [126.0, 410.4], 240.0) |
| 280 | .unwrap(); |
| 281 | editor |
| 282 | .insert_attachment(&mut engine, large.clone()) |
| 283 | .unwrap(); |
| 284 | store(&mut editor); |
| 285 | let (_, size) = editor.image_placement(large.id).unwrap(); |
| 286 | editor |
| 287 | .place_image(&mut engine, large.id, [288.0, 140.4], size) |
| 288 | .unwrap(); |
| 289 | store(&mut editor); |
| 290 | let mut image = source; |
| 291 | section.seal().unwrap().unwrap().apply(&mut image).unwrap(); |
| 292 | |
| 293 | let arena = Arena::default(); |
| 294 | let reopened = Section::open(&arena, image.clone()).unwrap(); |
| 295 | let page = reopened.page(space).unwrap(); |
| 296 | let files: Vec<&Attachment> = page |
| 297 | .objects |
| 298 | .iter() |
| 299 | .filter_map(|object| match object { |
| 300 | PageObject::Attachment(file) => Some(file), |
| 301 | _ => None, |
| 302 | }) |
| 303 | .collect(); |
| 304 | let [first, second] = files.as_slice() else { |
| 305 | panic!("{page:?}") |
| 306 | }; |
| 307 | for (stored, expected, at) in [ |
| 308 | (first, &small, [342.0, 284.4]), |
| 309 | (second, &large, [288.0, 140.4]), |
| 310 | ] { |
| 311 | assert_eq!(stored.id, expected.id); |
| 312 | assert_eq!([stored.layout.x, stored.layout.y], at.map(Some)); |
| 313 | assert_eq!(stored.layout.max_width, Some(54.0)); |
| 314 | assert_eq!(stored.bytes, expected.bytes); |
| 315 | assert_eq!(stored.preview, expected.preview); |
| 316 | } |
| 317 | assert_eq!(editor.page().unwrap(), page); |
| 318 | |
| 319 | if let Some(directory) = std::env::var_os("CANVAS_FLOATING_EXPORT") { |
| 320 | let directory = std::path::PathBuf::from(directory); |
| 321 | std::fs::create_dir_all(&directory).unwrap(); |
| 322 | std::fs::write(directory.join("files.one"), &image).unwrap(); |
| 323 | let file_id = Store::parse(&image).unwrap().header.file_id; |
| 324 | std::fs::write( |
| 325 | directory.join("Open Notebook.onetoc2"), |
| 326 | onestore::create_table_of_contents("Open Notebook.onetoc2", &[("files.one", file_id)]) |
| 327 | .unwrap(), |
| 328 | ) |
| 329 | .unwrap(); |
| 330 | } |
| 331 | } |