| 1 | //! Pictures pasted or inserted through the editor, placed as OneNote 2010 places them (lab, |
| 2 | //! 2026-09-30): at a caret in text the paragraph splits around the picture, and at a caret |
| 3 | //! on blank page the picture lies on the page there with the caret on the grid row below. |
| 4 | //! Each is one edit and one undo step. `CANVAS_PICTURE_EXPORT` names a directory receiving |
| 5 | //! the section as a notebook, for a cold reopen in OneNote 2010. |
| 6 | |
| 7 | use canvas::{ |
| 8 | document::TextPosition, |
| 9 | editor::{CanvasEditor, Piece, Selection}, |
| 10 | layout::TextEngine, |
| 11 | }; |
| 12 | use onestore::{ |
| 13 | Arena, PageCreation, Section, Store, |
| 14 | document::Layout, |
| 15 | op::{Edit, Op, SectionOp}, |
| 16 | page::{Image, PageObject, ParagraphContent}, |
| 17 | }; |
| 18 | use parley::Affinity; |
| 19 | |
| 20 | fn picture(size: [f32; 2]) -> Image { |
| 21 | let mut png = Vec::new(); |
| 22 | image::RgbaImage::from_pixel(200, 100, image::Rgba([30, 144, 255, 255])) |
| 23 | .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) |
| 24 | .unwrap(); |
| 25 | Image { |
| 26 | id: onestore::page::text::new_id().unwrap(), |
| 27 | layout: Layout { |
| 28 | max_width: Some(size[0]), |
| 29 | max_height: Some(size[1]), |
| 30 | ..Default::default() |
| 31 | }, |
| 32 | size: Some(size), |
| 33 | bytes: Some(png.into()), |
| 34 | display: None, |
| 35 | alt: None, |
| 36 | background: false, |
| 37 | printout: None, |
| 38 | tags: Vec::new(), |
| 39 | link: None, |
| 40 | text: None, |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | fn caret(paragraph: usize, offset: u32) -> Selection { |
| 45 | let position = TextPosition { paragraph, offset }; |
| 46 | Selection { |
| 47 | positions: [position; 2], |
| 48 | affinities: [Affinity::Downstream; 2], |
| 49 | } |
| 50 | } |
| 51 | |
| 52 | /// The body outline's paragraphs: text as itself, a picture as its size. |
| 53 | fn body(editor: &CanvasEditor) -> Vec<String> { |
| 54 | let page = editor.page().unwrap(); |
| 55 | let outline = page |
| 56 | .objects |
| 57 | .iter() |
| 58 | .find_map(|object| match object { |
| 59 | PageObject::Outline(outline) if !outline.title => Some(outline), |
| 60 | _ => None, |
| 61 | }) |
| 62 | .unwrap(); |
| 63 | outline |
| 64 | .paragraphs |
| 65 | .iter() |
| 66 | .map(|paragraph| match &paragraph.content { |
| 67 | ParagraphContent::Text(text) => text.text.text().to_owned(), |
| 68 | ParagraphContent::Image(image) => format!("{:?}", image.size.unwrap()), |
| 69 | _ => panic!(), |
| 70 | }) |
| 71 | .collect() |
| 72 | } |
| 73 | |
| 74 | fn page_pictures(editor: &CanvasEditor) -> Vec<Image> { |
| 75 | editor |
| 76 | .page() |
| 77 | .unwrap() |
| 78 | .objects |
| 79 | .into_iter() |
| 80 | .filter_map(|object| match object { |
| 81 | PageObject::Image(image) if !image.background => Some(image), |
| 82 | _ => None, |
| 83 | }) |
| 84 | .collect() |
| 85 | } |
| 86 | |
| 87 | #[test] |
| 88 | fn pasted_pictures_split_the_text_or_lie_on_the_page_as_onenote_places_them() { |
| 89 | let source = onestore::create_section("pictures.one", "Pasted pictures", "Author").unwrap(); |
| 90 | let arena = Arena::default(); |
| 91 | let mut section = Section::open(&arena, source.clone()).unwrap(); |
| 92 | let (space, ..) = section.pages().unwrap()[0].clone(); |
| 93 | let mut engine = TextEngine::default(); |
| 94 | let mut editor = CanvasEditor::from_page(section.page(space).unwrap(), &mut engine).unwrap(); |
| 95 | let mut at = 133_000_000_000_000_000; |
| 96 | let mut store = |editor: &mut CanvasEditor| { |
| 97 | let ops = editor.take_ops().unwrap(); |
| 98 | assert!(!ops.is_empty()); |
| 99 | at += 10_000_000; |
| 100 | let ops = ops.into_iter().map(|op| Op::Page { space, op }).collect(); |
| 101 | section.apply("Author", &Edit { at, ops }).unwrap(); |
| 102 | }; |
| 103 | let text = editor |
| 104 | .outlines() |
| 105 | .iter() |
| 106 | .find(|outline| !outline.title) |
| 107 | .unwrap() |
| 108 | .id; |
| 109 | editor.focus_outline(text).unwrap(); |
| 110 | |
| 111 | // Within text, the caret starting the second half. |
| 112 | editor.select(caret(0, 6)).unwrap(); |
| 113 | editor |
| 114 | .insert_picture(&mut engine, picture([150.0, 75.0])) |
| 115 | .unwrap(); |
| 116 | store(&mut editor); |
| 117 | assert_eq!(body(&editor), ["Pasted", "[150.0, 75.0]", " pictures"]); |
| 118 | assert_eq!( |
| 119 | editor.selection().positions, |
| 120 | [TextPosition { |
| 121 | paragraph: 1, |
| 122 | offset: 0 |
| 123 | }; 2] |
| 124 | ); |
| 125 | editor.undo(&mut engine).unwrap(); |
| 126 | store(&mut editor); |
| 127 | assert_eq!(body(&editor), ["Pasted pictures"]); |
| 128 | |
| 129 | // A web page's text and pictures in order, undone in one step. |
| 130 | editor.select(caret(0, 7)).unwrap(); |
| 131 | let png = picture([1.0; 2]).bytes.unwrap().to_vec(); |
| 132 | editor |
| 133 | .paste_pieces( |
| 134 | &mut engine, |
| 135 | vec![ |
| 136 | pasted("web&nbsp;"), |
| 137 | Piece::Picture(png, [150.0, 75.0]), |
| 138 | pasted("page<br>text&nbsp;"), |
| 139 | ], |
| 140 | ) |
| 141 | .unwrap(); |
| 142 | store(&mut editor); |
| 143 | assert_eq!( |
| 144 | body(&editor), |
| 145 | ["Pasted web ", "[150.0, 75.0]", "page", "text ", "pictures"] |
| 146 | ); |
| 147 | editor.undo(&mut engine).unwrap(); |
| 148 | store(&mut editor); |
| 149 | assert_eq!(body(&editor), ["Pasted pictures"]); |
| 150 | |
| 151 | // A web picture still on its way ends the paragraph and goes in when it arrives, an |
| 152 | // undo step of its own, while the caret stays. |
| 153 | editor.select(caret(0, 7)).unwrap(); |
| 154 | let awaited = editor |
| 155 | .paste_pieces( |
| 156 | &mut engine, |
| 157 | vec![pasted("web"), Piece::Awaited, pasted("page&nbsp;")], |
| 158 | ) |
| 159 | .unwrap(); |
| 160 | store(&mut editor); |
| 161 | assert_eq!(body(&editor), ["Pasted web", "page pictures"]); |
| 162 | let typing = editor.selection(); |
| 163 | let arrived = picture([150.0, 75.0]); |
| 164 | assert!( |
| 165 | editor |
| 166 | .insert_awaited(&mut engine, awaited[0], arrived) |
| 167 | .unwrap() |
| 168 | ); |
| 169 | store(&mut editor); |
| 170 | assert_eq!( |
| 171 | body(&editor), |
| 172 | ["Pasted web", "[150.0, 75.0]", "page pictures"] |
| 173 | ); |
| 174 | assert_eq!(editor.selection(), typing); |
| 175 | editor.undo(&mut engine).unwrap(); |
| 176 | store(&mut editor); |
| 177 | assert_eq!(body(&editor), ["Pasted web", "page pictures"]); |
| 178 | editor.undo(&mut engine).unwrap(); |
| 179 | store(&mut editor); |
| 180 | assert_eq!(body(&editor), ["Pasted pictures"]); |
| 181 | |
| 182 | // At the end, an empty paragraph follows for the caret; at an empty paragraph's start |
| 183 | // the picture takes its place. |
| 184 | editor.select(caret(0, 15)).unwrap(); |
| 185 | editor |
| 186 | .insert_picture(&mut engine, picture([150.0, 75.0])) |
| 187 | .unwrap(); |
| 188 | store(&mut editor); |
| 189 | editor |
| 190 | .insert_picture(&mut engine, picture([200.0, 100.0])) |
| 191 | .unwrap(); |
| 192 | store(&mut editor); |
| 193 | assert_eq!( |
| 194 | body(&editor), |
| 195 | ["Pasted pictures", "[150.0, 75.0]", "[200.0, 100.0]", ""] |
| 196 | ); |
| 197 | |
| 198 | // On blank page: at the caret, the caret moving to the grid row below (OneNote put a |
| 199 | // 75 pt picture at y 266.4 and the caret at 356.4, a 72 pt one at 248.4 and 338.4). |
| 200 | for (y, height, below) in [(266.4, 75.0, 356.4), (248.4 + 180.0, 72.0, 338.4 + 180.0)] { |
| 201 | editor.place_caret(&mut engine, [270.0, y], 468.0).unwrap(); |
| 202 | editor |
| 203 | .insert_picture(&mut engine, picture([150.0, height])) |
| 204 | .unwrap(); |
| 205 | store(&mut editor); |
| 206 | let placed = page_pictures(&editor).pop().unwrap(); |
| 207 | assert_eq!([placed.layout.x, placed.layout.y], [Some(270.0), Some(y)]); |
| 208 | assert_eq!(editor.active_outline().origin(), [270.0, below]); |
| 209 | } |
| 210 | editor.insert(&mut engine, "below").unwrap(); |
| 211 | store(&mut editor); |
| 212 | editor.undo(&mut engine).unwrap(); |
| 213 | editor.undo(&mut engine).unwrap(); |
| 214 | store(&mut editor); |
| 215 | assert_eq!(page_pictures(&editor).len(), 1); |
| 216 | |
| 217 | let mut image = source; |
| 218 | section.seal().unwrap().unwrap().apply(&mut image).unwrap(); |
| 219 | let arena = Arena::default(); |
| 220 | let reopened = Section::open(&arena, image.clone()).unwrap(); |
| 221 | let page = reopened.page(space).unwrap(); |
| 222 | let stored: Vec<_> = page |
| 223 | .objects |
| 224 | .iter() |
| 225 | .filter_map(|object| match object { |
| 226 | PageObject::Image(image) => Some(image), |
| 227 | _ => None, |
| 228 | }) |
| 229 | .collect(); |
| 230 | assert_eq!(stored.len(), 1); |
| 231 | assert_eq!( |
| 232 | [stored[0].layout.x, stored[0].layout.y], |
| 233 | [Some(270.0), Some(266.4)] |
| 234 | ); |
| 235 | assert_eq!(*stored[0], page_pictures(&editor)[0]); |
| 236 | |
| 237 | if let Some(directory) = std::env::var_os("CANVAS_PICTURE_EXPORT") { |
| 238 | let directory = std::path::PathBuf::from(directory); |
| 239 | std::fs::create_dir_all(&directory).unwrap(); |
| 240 | std::fs::write(directory.join("pictures.one"), &image).unwrap(); |
| 241 | let file_id = Store::parse(&image).unwrap().header.file_id; |
| 242 | std::fs::write( |
| 243 | directory.join("Open Notebook.onetoc2"), |
| 244 | onestore::create_table_of_contents( |
| 245 | "Open Notebook.onetoc2", |
| 246 | &[("pictures.one", file_id)], |
| 247 | ) |
| 248 | .unwrap(), |
| 249 | ) |
| 250 | .unwrap(); |
| 251 | } |
| 252 | } |
| 253 | |
| 254 | /// From the title, OneNote 2010 (lab, 2026-09-30) put a pasted picture at the end of the |
| 255 | /// outline where the body starts; on a page with no body, in a new outline there; and on a |
| 256 | /// page whose content lay elsewhere, on the page two grid rows below it. |
| 257 | #[test] |
| 258 | fn a_picture_pasted_from_the_title_goes_into_the_body() { |
| 259 | let source = onestore::create_section("pictures.one", "First page", "Author").unwrap(); |
| 260 | let arena = Arena::default(); |
| 261 | let mut section = Section::open(&arena, source).unwrap(); |
| 262 | let creation = PageCreation::new(None, Some("Title paste"), "Author").unwrap(); |
| 263 | section |
| 264 | .apply( |
| 265 | "Author", |
| 266 | &Edit { |
| 267 | at: 133_000_000_000_000_000, |
| 268 | ops: vec![Op::Section(SectionOp::Create(creation))], |
| 269 | }, |
| 270 | ) |
| 271 | .unwrap(); |
| 272 | let (space, ..) = section.pages().unwrap()[1].clone(); |
| 273 | let page = section.page(space).unwrap(); |
| 274 | let mut engine = TextEngine::default(); |
| 275 | let opened = |engine: &mut TextEngine| { |
| 276 | let mut editor = CanvasEditor::from_page(page.clone(), engine).unwrap(); |
| 277 | let title = editor.outlines().iter().find(|o| o.title).unwrap().id; |
| 278 | editor.focus_outline(title).unwrap(); |
| 279 | editor |
| 280 | }; |
| 281 | |
| 282 | // No body: a new outline where it starts. |
| 283 | let mut editor = opened(&mut engine); |
| 284 | let start = editor.body_start().unwrap(); |
| 285 | editor |
| 286 | .insert_picture(&mut engine, picture([150.0, 75.0])) |
| 287 | .unwrap(); |
| 288 | assert_eq!(body(&editor), ["[150.0, 75.0]", ""]); |
| 289 | assert_eq!(editor.active_outline().origin(), start); |
| 290 | |
| 291 | // A body where it starts: at its end. |
| 292 | let mut editor = opened(&mut engine); |
| 293 | editor.leave_title(&mut engine).unwrap(); |
| 294 | editor.insert(&mut engine, "body").unwrap(); |
| 295 | assert_eq!(editor.active_outline().origin(), start); |
| 296 | let title = editor.outlines().iter().find(|o| o.title).unwrap().id; |
| 297 | editor.focus_outline(title).unwrap(); |
| 298 | editor |
| 299 | .insert_picture(&mut engine, picture([150.0, 75.0])) |
| 300 | .unwrap(); |
| 301 | assert_eq!(body(&editor), ["body", "[150.0, 75.0]", ""]); |
| 302 | |
| 303 | // Content elsewhere: on the page two grid rows below it. |
| 304 | let mut editor = opened(&mut engine); |
| 305 | let margin = editor.margin_origin(); |
| 306 | editor |
| 307 | .place_caret(&mut engine, [start[0] + 270.0, start[1] + 72.0], 468.0) |
| 308 | .unwrap(); |
| 309 | editor.insert(&mut engine, "far").unwrap(); |
| 310 | let bottom = editor.active_outline().bounds().y1 as f32; |
| 311 | let title = editor.outlines().iter().find(|o| o.title).unwrap().id; |
| 312 | editor.focus_outline(title).unwrap(); |
| 313 | editor |
| 314 | .insert_picture(&mut engine, picture([150.0, 75.0])) |
| 315 | .unwrap(); |
| 316 | let placed = page_pictures(&editor).pop().unwrap(); |
| 317 | let row = ((bottom - margin[1]) / 18.0).floor() + 2.0; |
| 318 | assert_eq!( |
| 319 | [placed.layout.x, placed.layout.y], |
| 320 | [Some(start[0]), Some(margin[1] + row * 18.0)] |
| 321 | ); |
| 322 | } |
| 323 | |
| 324 | /// Pasted text, as a web page gives it. |
| 325 | fn pasted(html: &str) -> Piece { |
| 326 | canvas::editor::html_pieces(html, 0x0409, |_, _| None) |
| 327 | .pop() |
| 328 | .unwrap() |
| 329 | } |