| 1 | //! Pictures as OneNote 2010 stores them in the wild. |
| 2 | |
| 3 | use canvas::{editor::CanvasEditor, layout::TextEngine}; |
| 4 | use onestore::{ |
| 5 | Arena, Section, |
| 6 | op::{Edit, Op}, |
| 7 | page::{Page, PageObject, ParagraphContent}, |
| 8 | }; |
| 9 | |
| 10 | fn page(bytes: &[u8], title: &str) -> Page { |
| 11 | let arena = Arena::default(); |
| 12 | let mut section = Section::open(&arena, bytes.to_vec()).unwrap(); |
| 13 | let (space, ..) = section |
| 14 | .pages() |
| 15 | .unwrap() |
| 16 | .into_iter() |
| 17 | .find(|page| page.1 == title) |
| 18 | .unwrap(); |
| 19 | section.page(space).unwrap() |
| 20 | } |
| 21 | |
| 22 | /// A picture COM put on the page stores no layout size, only its own (PictureWidth and |
| 23 | /// PictureHeight), and shows at that size (`corpus/print`). |
| 24 | #[test] |
| 25 | fn a_page_picture_without_a_layout_size_shows_at_its_own() { |
| 26 | let page = page( |
| 27 | include_bytes!("../../../corpus/print/native/Print.one"), |
| 28 | "Printing test", |
| 29 | ); |
| 30 | let [picture] = &page |
| 31 | .objects |
| 32 | .iter() |
| 33 | .filter_map(|object| match object { |
| 34 | PageObject::Image(image) => Some(image), |
| 35 | _ => None, |
| 36 | }) |
| 37 | .collect::<Vec<_>>()[..] |
| 38 | else { |
| 39 | panic!("one picture"); |
| 40 | }; |
| 41 | assert_eq!(picture.layout.max_width, None); |
| 42 | let mut engine = TextEngine::default(); |
| 43 | let editor = CanvasEditor::from_page(page.clone(), &mut engine).unwrap(); |
| 44 | let origin = [picture.layout.x.unwrap(), picture.layout.y.unwrap()]; |
| 45 | assert_eq!( |
| 46 | editor.image_placement(picture.id), |
| 47 | Some((origin, [150.0, 90.0])) |
| 48 | ); |
| 49 | #[cfg(feature = "gpu")] |
| 50 | { |
| 51 | let (scene, editor) = canvas::gpu::page::PageScene::from_page(page, &mut engine).unwrap(); |
| 52 | assert_eq!(scene.read_only(Some(&editor)).count(), 0); |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | const OBJECTS: &[u8] = include_bytes!("../../../corpus/object-tags/native/notebook/files.one"); |
| 57 | |
| 58 | /// The picture or file `id` holds, wherever it is on the page. |
| 59 | fn tags_of(page: &Page, id: onestore::ExGuid) -> Vec<onestore::document::Tag> { |
| 60 | let contents = page.objects.iter().flat_map(|object| match object { |
| 61 | PageObject::Outline(outline) => outline |
| 62 | .paragraphs |
| 63 | .iter() |
| 64 | .map(|paragraph| match &paragraph.content { |
| 65 | ParagraphContent::Image(image) => (image.id, image.tags.clone()), |
| 66 | ParagraphContent::Attachment(file) => (file.id, file.tags.clone()), |
| 67 | _ => (paragraph.id, Vec::new()), |
| 68 | }) |
| 69 | .collect(), |
| 70 | PageObject::Image(image) => vec![(image.id, image.tags.clone())], |
| 71 | PageObject::Attachment(file) => vec![(file.id, file.tags.clone())], |
| 72 | _ => Vec::new(), |
| 73 | }); |
| 74 | contents |
| 75 | .filter(|(object, _)| *object == id) |
| 76 | .map(|(_, tags)| tags) |
| 77 | .next() |
| 78 | .unwrap() |
| 79 | } |
| 80 | |
| 81 | /// OneNote 2010 stores a tag given to a picture or file on the object itself, in an |
| 82 | /// outline or on the page, and draws it in a column to its left centred on it; a click on a |
| 83 | /// check box checks it, one revision each, and undo clears it again |
| 84 | /// (`corpus/object-tags`). `CANVAS_OBJECT_TAGS_EXPORT` names a new directory receiving the |
| 85 | /// candidate for a cold reopen. |
| 86 | #[test] |
| 87 | fn tags_on_pictures_and_files_draw_and_check() { |
| 88 | let arena = Arena::default(); |
| 89 | let mut section = Section::open(&arena, OBJECTS.to_vec()).unwrap(); |
| 90 | let (space, ..) = section |
| 91 | .pages() |
| 92 | .unwrap() |
| 93 | .into_iter() |
| 94 | .find(|page| page.1 == "Pictures and files") |
| 95 | .unwrap(); |
| 96 | let page = section.page(space).unwrap(); |
| 97 | let outline = page |
| 98 | .objects |
| 99 | .iter() |
| 100 | .find_map(|object| match object { |
| 101 | PageObject::Outline(outline) if outline.paragraphs.len() == 4 => Some(outline), |
| 102 | _ => None, |
| 103 | }) |
| 104 | .unwrap(); |
| 105 | let [(picture_paragraph, picture), (_, file)] = [1, 2].map(|index| { |
| 106 | let paragraph = &outline.paragraphs[index]; |
| 107 | let id = match &paragraph.content { |
| 108 | ParagraphContent::Image(image) => image.id, |
| 109 | ParagraphContent::Attachment(file) => file.id, |
| 110 | _ => panic!("a picture and a file"), |
| 111 | }; |
| 112 | (paragraph.id, id) |
| 113 | }); |
| 114 | let outline_id = outline.id; |
| 115 | let (page_picture, page_file) = ( |
| 116 | page.objects |
| 117 | .iter() |
| 118 | .find_map(|object| match object { |
| 119 | PageObject::Image(image) if !image.tags.is_empty() => Some(image.id), |
| 120 | _ => None, |
| 121 | }) |
| 122 | .unwrap(), |
| 123 | page.objects |
| 124 | .iter() |
| 125 | .find_map(|object| match object { |
| 126 | PageObject::Attachment(file) => Some(file.id), |
| 127 | _ => None, |
| 128 | }) |
| 129 | .unwrap(), |
| 130 | ); |
| 131 | for id in [picture, file, page_picture, page_file] { |
| 132 | let tags = tags_of(&page, id); |
| 133 | assert_eq!(tags.len(), 1); |
| 134 | assert_eq!(tags[0].status & 1, 0); |
| 135 | assert!(page.definitions.contains_key(&tags[0].definition.unwrap())); |
| 136 | } |
| 137 | |
| 138 | let mut engine = TextEngine::default(); |
| 139 | let mut editor = CanvasEditor::from_page(page.clone(), &mut engine).unwrap(); |
| 140 | // The outline's tags stand beside the picture and the file they mark. |
| 141 | let shaped = editor |
| 142 | .outlines() |
| 143 | .iter() |
| 144 | .find(|item| item.id == outline_id) |
| 145 | .unwrap() |
| 146 | .shaped(); |
| 147 | let marked: Vec<_> = shaped.tags().map(|(paragraph, ..)| paragraph).collect(); |
| 148 | assert_eq!(marked, [picture_paragraph, outline.paragraphs[2].id]); |
| 149 | #[cfg(feature = "gpu")] |
| 150 | { |
| 151 | // The page picture at (400, 86), 120 by 90, has its tag 24.75 points to its left, |
| 152 | // centred on it. |
| 153 | let (scene, shown) = |
| 154 | canvas::gpu::page::PageScene::from_page(page.clone(), &mut engine).unwrap(); |
| 155 | let hit = |point| scene.hit_test(point, Some(&shown), |_| None::<()>); |
| 156 | assert_eq!( |
| 157 | hit([376.0, 126.0]), |
| 158 | Some(canvas::gpu::page::SceneHit::Check(page_picture)) |
| 159 | ); |
| 160 | assert_eq!( |
| 161 | hit([374.0, 126.0]), |
| 162 | None, |
| 163 | "the tag's column starts 24.75 points left" |
| 164 | ); |
| 165 | } |
| 166 | |
| 167 | let save = |editor: &mut CanvasEditor, section: &mut Section| { |
| 168 | let ops = editor |
| 169 | .take_ops() |
| 170 | .unwrap() |
| 171 | .into_iter() |
| 172 | .map(|op| Op::Page { space, op }) |
| 173 | .collect(); |
| 174 | section |
| 175 | .apply( |
| 176 | "Author", |
| 177 | &Edit { |
| 178 | at: 134_000_000_000_000_000, |
| 179 | ops, |
| 180 | }, |
| 181 | ) |
| 182 | .unwrap(); |
| 183 | section.seal().unwrap().unwrap(); |
| 184 | }; |
| 185 | editor.click_object_check(page_picture).unwrap(); |
| 186 | save(&mut editor, &mut section); |
| 187 | editor.click_object_check(page_file).unwrap(); |
| 188 | save(&mut editor, &mut section); |
| 189 | editor |
| 190 | .click_check(&mut engine, outline_id, picture_paragraph) |
| 191 | .unwrap(); |
| 192 | save(&mut editor, &mut section); |
| 193 | let stored = section.page(space).unwrap(); |
| 194 | for (id, checked) in [ |
| 195 | (page_picture, true), |
| 196 | (page_file, true), |
| 197 | (picture, true), |
| 198 | (file, false), |
| 199 | ] { |
| 200 | assert_eq!(tags_of(&stored, id)[0].status & 1 != 0, checked, "{id:?}"); |
| 201 | } |
| 202 | if let Some(directory) = std::env::var_os("CANVAS_OBJECT_TAGS_EXPORT") { |
| 203 | let directory = std::path::PathBuf::from(directory); |
| 204 | std::fs::create_dir(&directory).unwrap(); |
| 205 | let written = section.image(); |
| 206 | std::fs::write(directory.join("files.one"), &written).unwrap(); |
| 207 | let file_id = onestore::Store::parse(&written).unwrap().header.file_id; |
| 208 | std::fs::write( |
| 209 | directory.join("Open Notebook.onetoc2"), |
| 210 | onestore::create_table_of_contents("Open Notebook.onetoc2", &[("files.one", file_id)]) |
| 211 | .unwrap(), |
| 212 | ) |
| 213 | .unwrap(); |
| 214 | } |
| 215 | assert!(editor.undo(&mut engine).unwrap()); |
| 216 | assert!(editor.undo(&mut engine).unwrap()); |
| 217 | save(&mut editor, &mut section); |
| 218 | let stored = section.page(space).unwrap(); |
| 219 | assert_eq!(tags_of(&stored, page_file)[0].status & 1, 0); |
| 220 | assert_eq!(tags_of(&stored, picture)[0].status & 1, 0); |
| 221 | assert_eq!(tags_of(&stored, page_picture)[0].status & 1, 1); |
| 222 | } |
| 223 | |
| 224 | /// Text OneNote recognised in a picture is found by search, as OneNote finds it |
| 225 | /// (`corpus/object-tags`). |
| 226 | #[test] |
| 227 | fn search_finds_text_recognised_in_pictures() { |
| 228 | let page = page(OBJECTS, "Pictures and files"); |
| 229 | let recognised: Vec<_> = page |
| 230 | .objects |
| 231 | .iter() |
| 232 | .filter_map(|object| match object { |
| 233 | PageObject::Image(image) => image.text.as_ref().map(|text| text.text.as_str()), |
| 234 | _ => None, |
| 235 | }) |
| 236 | .collect(); |
| 237 | assert_eq!(recognised, ["GLACIER HARBOR\nInvoice 4471"]); |
| 238 | let mut index = canvas::search::Index::default(); |
| 239 | index.set(canvas::search::Entry::new( |
| 240 | "files", |
| 241 | onestore::ExGuid::default(), |
| 242 | &page, |
| 243 | 0, |
| 244 | )); |
| 245 | let found = index.search(&canvas::search::Query::new("invoice 4471"), |_| true); |
| 246 | assert_eq!(found.len(), 1); |
| 247 | assert_eq!(found[0].snippet, "Invoice 4471"); |
| 248 | assert!( |
| 249 | index |
| 250 | .search(&canvas::search::Query::new("harb"), |_| true) |
| 251 | .len() |
| 252 | == 1 |
| 253 | ); |
| 254 | } |
| 255 | |
| 256 | /// A picture's link reads back from where OneNote stores it (WzHyperlinkUrl), and a linked |
| 257 | /// picture deleted and brought back by undo keeps it (`corpus/picture-link`). |
| 258 | /// `CANVAS_PICTURE_LINK_EXPORT` names a new directory receiving the candidate for a cold |
| 259 | /// reopen. |
| 260 | #[test] |
| 261 | fn a_linked_picture_keeps_its_link_through_undo() { |
| 262 | let source = include_bytes!("../../../corpus/m6/native-features-01/notebook/Features.one"); |
| 263 | let arena = Arena::default(); |
| 264 | let mut section = Section::open(&arena, source.to_vec()).unwrap(); |
| 265 | let (space, ..) = section |
| 266 | .pages() |
| 267 | .unwrap() |
| 268 | .into_iter() |
| 269 | .find(|page| page.1 == "Image png") |
| 270 | .unwrap(); |
| 271 | let page = section.page(space).unwrap(); |
| 272 | let linked = |page: &Page| -> Vec<(onestore::ExGuid, Option<String>)> { |
| 273 | page.objects |
| 274 | .iter() |
| 275 | .filter_map(|object| match object { |
| 276 | PageObject::Image(image) => Some((image.id, image.link.clone())), |
| 277 | _ => None, |
| 278 | }) |
| 279 | .collect() |
| 280 | }; |
| 281 | let before = linked(&page); |
| 282 | let [(id, Some(link))] = &before[..] else { |
| 283 | panic!("one linked picture"); |
| 284 | }; |
| 285 | assert_eq!(link, "https://example.invalid/image/png"); |
| 286 | let mut engine = TextEngine::default(); |
| 287 | let mut editor = CanvasEditor::from_page(page, &mut engine).unwrap(); |
| 288 | editor.remove_image(&mut engine, *id).unwrap(); |
| 289 | assert!(editor.undo(&mut engine).unwrap()); |
| 290 | let ops = editor |
| 291 | .take_ops() |
| 292 | .unwrap() |
| 293 | .into_iter() |
| 294 | .map(|op| Op::Page { space, op }) |
| 295 | .collect(); |
| 296 | section |
| 297 | .apply( |
| 298 | "Author", |
| 299 | &Edit { |
| 300 | at: 134_000_000_000_000_000, |
| 301 | ops, |
| 302 | }, |
| 303 | ) |
| 304 | .unwrap(); |
| 305 | section.seal().unwrap(); |
| 306 | assert_eq!(linked(&section.page(space).unwrap()), before); |
| 307 | if let Some(directory) = std::env::var_os("CANVAS_PICTURE_LINK_EXPORT") { |
| 308 | let directory = std::path::PathBuf::from(directory); |
| 309 | std::fs::create_dir(&directory).unwrap(); |
| 310 | let written = section.image(); |
| 311 | std::fs::write(directory.join("Features.one"), &written).unwrap(); |
| 312 | let file_id = onestore::Store::parse(&written).unwrap().header.file_id; |
| 313 | std::fs::write( |
| 314 | directory.join("Open Notebook.onetoc2"), |
| 315 | onestore::create_table_of_contents( |
| 316 | "Open Notebook.onetoc2", |
| 317 | &[("Features.one", file_id)], |
| 318 | ) |
| 319 | .unwrap(), |
| 320 | ) |
| 321 | .unwrap(); |
| 322 | } |
| 323 | } |
| 324 | |
| 325 | /// A tag command on a selected picture or file tags the object itself, as OneNote 2010 |
| 326 | /// does (`corpus/object-tags`): a tag it has comes off, one it lacks goes on unchecked. |
| 327 | #[test] |
| 328 | fn tag_commands_tag_a_selected_picture_or_file() { |
| 329 | use canvas::editor::{Formatting, NoteTag}; |
| 330 | let arena = Arena::default(); |
| 331 | let mut section = Section::open(&arena, OBJECTS.to_vec()).unwrap(); |
| 332 | let (space, ..) = section |
| 333 | .pages() |
| 334 | .unwrap() |
| 335 | .into_iter() |
| 336 | .find(|page| page.1 == "Pictures and files") |
| 337 | .unwrap(); |
| 338 | let page = section.page(space).unwrap(); |
| 339 | let page_picture = page |
| 340 | .objects |
| 341 | .iter() |
| 342 | .find_map(|object| match object { |
| 343 | PageObject::Image(image) if !image.tags.is_empty() => Some(image.id), |
| 344 | _ => None, |
| 345 | }) |
| 346 | .unwrap(); |
| 347 | let file = page |
| 348 | .objects |
| 349 | .iter() |
| 350 | .find_map(|object| match object { |
| 351 | PageObject::Outline(outline) => { |
| 352 | outline.paragraphs.iter().find_map(|p| match &p.content { |
| 353 | ParagraphContent::Attachment(file) => Some(file.id), |
| 354 | _ => None, |
| 355 | }) |
| 356 | } |
| 357 | _ => None, |
| 358 | }) |
| 359 | .unwrap(); |
| 360 | let mut engine = TextEngine::default(); |
| 361 | let mut editor = CanvasEditor::from_page(page, &mut engine).unwrap(); |
| 362 | let to_do = Formatting::Tag(NoteTag::defaults()[0].clone(), 0); |
| 363 | let important = Formatting::Tag(NoteTag::defaults()[1].clone(), 1); |
| 364 | assert!( |
| 365 | editor |
| 366 | .format_object(&mut engine, page_picture, &to_do) |
| 367 | .unwrap() |
| 368 | ); |
| 369 | assert!(editor.format_object(&mut engine, file, &important).unwrap()); |
| 370 | assert!( |
| 371 | !editor |
| 372 | .format_object(&mut engine, file, &Formatting::Bullets) |
| 373 | .unwrap() |
| 374 | ); |
| 375 | let ops = editor |
| 376 | .take_ops() |
| 377 | .unwrap() |
| 378 | .into_iter() |
| 379 | .map(|op| Op::Page { space, op }) |
| 380 | .collect(); |
| 381 | section |
| 382 | .apply( |
| 383 | "Author", |
| 384 | &Edit { |
| 385 | at: 134_000_000_000_000_000, |
| 386 | ops, |
| 387 | }, |
| 388 | ) |
| 389 | .unwrap(); |
| 390 | section.seal().unwrap(); |
| 391 | let stored = section.page(space).unwrap(); |
| 392 | assert!(tags_of(&stored, page_picture).is_empty()); |
| 393 | let tags = tags_of(&stored, file); |
| 394 | assert_eq!(tags.len(), 2); |
| 395 | assert!(matches!( |
| 396 | &stored.definitions[&tags[0].definition.unwrap()].kind, |
| 397 | onestore::document::Kind::TagDefinition { label: Some(label), .. } if label == "Important" |
| 398 | )); |
| 399 | } |
| 400 | |
| 401 | /// A picture deleted and brought back by undo keeps the text OneNote recognised in it, |
| 402 | /// written back as OneNote stores it (`corpus/object-tags/restored`). |
| 403 | /// `CANVAS_RECOGNIZED_EXPORT` names a new directory receiving the candidate for a cold |
| 404 | /// reopen. |
| 405 | #[test] |
| 406 | fn a_restored_picture_keeps_its_recognised_text() { |
| 407 | let arena = Arena::default(); |
| 408 | let mut section = Section::open(&arena, OBJECTS.to_vec()).unwrap(); |
| 409 | let (space, ..) = section |
| 410 | .pages() |
| 411 | .unwrap() |
| 412 | .into_iter() |
| 413 | .find(|page| page.1 == "Pictures and files") |
| 414 | .unwrap(); |
| 415 | let page = section.page(space).unwrap(); |
| 416 | let recognized = |page: &Page| { |
| 417 | page.objects |
| 418 | .iter() |
| 419 | .find_map(|object| match object { |
| 420 | PageObject::Image(image) if image.text.is_some() => { |
| 421 | Some((image.id, image.text.clone().unwrap())) |
| 422 | } |
| 423 | _ => None, |
| 424 | }) |
| 425 | .unwrap() |
| 426 | }; |
| 427 | let (id, text) = recognized(&page); |
| 428 | assert_eq!(text.stored.len(), 2, "its language and word layout"); |
| 429 | let mut engine = TextEngine::default(); |
| 430 | let mut editor = CanvasEditor::from_page(page, &mut engine).unwrap(); |
| 431 | editor.remove_image(&mut engine, id).unwrap(); |
| 432 | assert!(editor.undo(&mut engine).unwrap()); |
| 433 | let ops = editor |
| 434 | .take_ops() |
| 435 | .unwrap() |
| 436 | .into_iter() |
| 437 | .map(|op| Op::Page { space, op }) |
| 438 | .collect(); |
| 439 | section |
| 440 | .apply( |
| 441 | "Author", |
| 442 | &Edit { |
| 443 | at: 134_000_000_000_000_000, |
| 444 | ops, |
| 445 | }, |
| 446 | ) |
| 447 | .unwrap(); |
| 448 | section.seal().unwrap(); |
| 449 | assert_eq!(recognized(&section.page(space).unwrap()), (id, text)); |
| 450 | if let Some(directory) = std::env::var_os("CANVAS_RECOGNIZED_EXPORT") { |
| 451 | let directory = std::path::PathBuf::from(directory); |
| 452 | std::fs::create_dir(&directory).unwrap(); |
| 453 | let written = section.image(); |
| 454 | std::fs::write(directory.join("files.one"), &written).unwrap(); |
| 455 | let file_id = onestore::Store::parse(&written).unwrap().header.file_id; |
| 456 | std::fs::write( |
| 457 | directory.join("Open Notebook.onetoc2"), |
| 458 | onestore::create_table_of_contents("Open Notebook.onetoc2", &[("files.one", file_id)]) |
| 459 | .unwrap(), |
| 460 | ) |
| 461 | .unwrap(); |
| 462 | } |
| 463 | } |