| 1 | #[path = "support/ops.rs"] |
| 2 | mod ops; |
| 3 | use onestore::{ |
| 4 | ExGuid, RevisionIndex, Store, |
| 5 | document::Document, |
| 6 | page::{Ink, InkStroke, Page, PageObject, text::new_id}, |
| 7 | }; |
| 8 | |
| 9 | /// Two drawings made with the mouse in OneNote 2010 (`tools/native/ink.ahk`): a diamond and a |
| 10 | /// diagonal line, whose native read reports their positions and sizes. |
| 11 | const NATIVE: &[u8] = |
| 12 | include_bytes!("../../../corpus/native-ink/cold-ui-ink/notebook/synthetic.one"); |
| 13 | |
| 14 | fn first_page(bytes: &[u8]) -> (ExGuid, Page) { |
| 15 | let store = Store::parse(bytes).unwrap(); |
| 16 | assert!(store.checksum_mismatches.is_empty()); |
| 17 | let index = RevisionIndex::parse(&store).unwrap(); |
| 18 | let document = Document::parse(&index).unwrap(); |
| 19 | let (space, _) = document.pages().unwrap()[0]; |
| 20 | (space, Page::from_space(&document, space).unwrap()) |
| 21 | } |
| 22 | |
| 23 | fn drawings(page: &Page) -> Vec<&Ink> { |
| 24 | page.objects |
| 25 | .iter() |
| 26 | .filter_map(|object| match object { |
| 27 | PageObject::Ink(ink) => Some(ink), |
| 28 | _ => None, |
| 29 | }) |
| 30 | .collect() |
| 31 | } |
| 32 | |
| 33 | fn close(actual: [f32; 4], expected: [f32; 4]) -> bool { |
| 34 | actual |
| 35 | .iter() |
| 36 | .zip(expected) |
| 37 | .all(|(a, e)| (a - e).abs() < 0.002) |
| 38 | } |
| 39 | |
| 40 | #[test] |
| 41 | fn native_ink_drawings_read_as_strokes_in_page_coordinates() { |
| 42 | let (_, page) = first_page(NATIVE); |
| 43 | let found = drawings(&page); |
| 44 | let [diamond, line] = found.as_slice() else { |
| 45 | panic!("{:?}", found.len()); |
| 46 | }; |
| 47 | assert_eq!(diamond.strokes.len(), 1); |
| 48 | assert_eq!(diamond.strokes[0].points.len(), 253); |
| 49 | // OneNote reports a drawing's size one HIMETRIC unit larger than its point extent. |
| 50 | let unit = 72.0 / 2540.0; |
| 51 | let native = |[x, y, w, h]: [f32; 4]| [x, y, w + unit, h + unit]; |
| 52 | let bounds = ( |
| 53 | native(diamond.bounds().unwrap()), |
| 54 | native(line.bounds().unwrap()), |
| 55 | ); |
| 56 | assert!( |
| 57 | close(bounds.0, [421.5118, 84.7559, 60.00945, 60.00944]) |
| 58 | && close(bounds.1, [504.0, 92.23936, 30.0189, 45.04252]), |
| 59 | "{bounds:?}" |
| 60 | ); |
| 61 | let stroke = &diamond.strokes[0]; |
| 62 | assert!(close( |
| 63 | [stroke.points[0][0], stroke.points[0][1], 0.0, 0.0], |
| 64 | [451.50235, 84.755905, 0.0, 0.0] |
| 65 | )); |
| 66 | assert!((stroke.width - 35.0 * 72.0 / 2540.0).abs() < 1e-5); |
| 67 | assert_eq!( |
| 68 | (stroke.color, stroke.transparency, stroke.pen_tip), |
| 69 | (None, None, None) |
| 70 | ); |
| 71 | assert!(diamond.groups.is_empty()); |
| 72 | for stroke in [&diamond.strokes[0], &line.strokes[0]] { |
| 73 | assert!(stroke.points.windows(2).all(|pair| { |
| 74 | let [dx, dy] = [pair[1][0] - pair[0][0], pair[1][1] - pair[0][1]]; |
| 75 | dx.abs() < 3.0 && dy.abs() < 3.0 |
| 76 | })); |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | use onestore::page::ink::snap; |
| 81 | |
| 82 | fn stroke(points: &[[f32; 2]], color: Option<u32>) -> InkStroke { |
| 83 | InkStroke { |
| 84 | id: new_id().unwrap(), |
| 85 | points: points.iter().map(|p| [snap(p[0]), snap(p[1])]).collect(), |
| 86 | width: snap(1.0), |
| 87 | height: snap(1.0), |
| 88 | color, |
| 89 | transparency: None, |
| 90 | pen_tip: None, |
| 91 | raster_operation: None, |
| 92 | pressure: Vec::new(), |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | fn drawing() -> Ink { |
| 97 | Ink { |
| 98 | id: new_id().unwrap(), |
| 99 | layout: Default::default(), |
| 100 | strokes: vec![ |
| 101 | stroke( |
| 102 | &[ |
| 103 | [300.0, 120.0], |
| 104 | [360.0, 120.0], |
| 105 | [360.0, 180.0], |
| 106 | [300.0, 180.0], |
| 107 | [300.0, 120.0], |
| 108 | ], |
| 109 | None, |
| 110 | ), |
| 111 | stroke(&[[300.0, 120.0], [360.0, 180.0]], Some(0x0000ff)), |
| 112 | ], |
| 113 | groups: Vec::new(), |
| 114 | shape: None, |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | fn page_in(bytes: &[u8], space: ExGuid) -> Page { |
| 119 | let store = Store::parse(bytes).unwrap(); |
| 120 | assert!(store.checksum_mismatches.is_empty()); |
| 121 | let index = RevisionIndex::parse(&store).unwrap(); |
| 122 | Page::from_space(&Document::parse(&index).unwrap(), space).unwrap() |
| 123 | } |
| 124 | |
| 125 | fn export(variable: &str, name: &str, written: &[u8]) { |
| 126 | if let Some(directory) = std::env::var_os(variable) { |
| 127 | let directory = std::path::PathBuf::from(directory); |
| 128 | std::fs::create_dir(&directory).unwrap(); |
| 129 | std::fs::write(directory.join(name), written).unwrap(); |
| 130 | let file_id = Store::parse(written).unwrap().header.file_id; |
| 131 | std::fs::write( |
| 132 | directory.join("Open Notebook.onetoc2"), |
| 133 | onestore::create_table_of_contents("Open Notebook.onetoc2", &[(name, file_id)]) |
| 134 | .unwrap(), |
| 135 | ) |
| 136 | .unwrap(); |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | /// `ONESTORE_INK_EXPORT` names a new directory receiving the candidate for a cold reopen. |
| 141 | #[test] |
| 142 | fn an_ink_drawing_is_written_erased_stroke_by_stroke_and_removed() { |
| 143 | let source = onestore::create_section("ink.one", "Beside the drawing", "Author").unwrap(); |
| 144 | let (space, before) = first_page(&source); |
| 145 | let mut after = before.clone(); |
| 146 | let ink = drawing(); |
| 147 | after.objects.push(PageObject::Ink(ink.clone())); |
| 148 | let written = ops::saved(&source, space, &after).unwrap(); |
| 149 | let stored = page_in(written.as_slice(), space); |
| 150 | let mut expected = after.clone(); |
| 151 | expected.title = stored.title.clone(); |
| 152 | assert_eq!(stored, expected); |
| 153 | assert!(close( |
| 154 | drawings(&stored)[0].bounds().unwrap(), |
| 155 | [ |
| 156 | snap(300.0), |
| 157 | snap(120.0), |
| 158 | snap(360.0) - snap(300.0), |
| 159 | snap(180.0) - snap(120.0) |
| 160 | ] |
| 161 | )); |
| 162 | assert_eq!( |
| 163 | ops::saved(written.as_slice(), space, &stored) |
| 164 | .unwrap() |
| 165 | .as_slice(), |
| 166 | written.as_slice() |
| 167 | ); |
| 168 | export("ONESTORE_INK_EXPORT", "ink.one", written.as_slice()); |
| 169 | let mut erased = stored.clone(); |
| 170 | for object in &mut erased.objects { |
| 171 | if let PageObject::Ink(ink) = object { |
| 172 | ink.strokes.remove(1); |
| 173 | ink.strokes |
| 174 | .push(stroke(&[[330.0, 100.0], [330.0, 200.0]], Some(0xff0000))); |
| 175 | } |
| 176 | } |
| 177 | let again = ops::saved(written.as_slice(), space, &erased).unwrap(); |
| 178 | let stored = page_in(again.as_slice(), space); |
| 179 | assert_eq!(stored, erased); |
| 180 | let mut removed = stored.clone(); |
| 181 | removed.objects.retain(|object| object.id() != ink.id); |
| 182 | let last = ops::saved(again.as_slice(), space, &removed).unwrap(); |
| 183 | let stored = page_in(last.as_slice(), space); |
| 184 | assert!(drawings(&stored).is_empty()); |
| 185 | assert_eq!(stored, removed); |
| 186 | } |
| 187 | |
| 188 | /// `ONESTORE_INK_PARAGRAPH_EXPORT` names a new directory receiving the candidate for a cold |
| 189 | /// reopen. |
| 190 | #[test] |
| 191 | fn handwriting_is_written_as_paragraph_content() { |
| 192 | use onestore::page::{PageParagraph, ParagraphContent}; |
| 193 | let source = onestore::create_section("ink.one", "Above the handwriting", "Author").unwrap(); |
| 194 | let (space, before) = first_page(&source); |
| 195 | let mut after = before.clone(); |
| 196 | let outline = after |
| 197 | .objects |
| 198 | .iter_mut() |
| 199 | .find_map(|object| match object { |
| 200 | PageObject::Outline(outline) if !outline.title => Some(outline), |
| 201 | _ => None, |
| 202 | }) |
| 203 | .unwrap(); |
| 204 | let mut paragraph: PageParagraph = outline.paragraphs[0].clone(); |
| 205 | paragraph.id = new_id().unwrap(); |
| 206 | paragraph.parent = None; |
| 207 | paragraph.level = 1; |
| 208 | paragraph.lists.clear(); |
| 209 | paragraph.tags.clear(); |
| 210 | paragraph.style = None; |
| 211 | paragraph.collapsed = false; |
| 212 | paragraph.format = Default::default(); |
| 213 | paragraph.content = ParagraphContent::Ink(drawing()); |
| 214 | outline.paragraphs.push(paragraph); |
| 215 | let written = ops::saved(&source, space, &after).unwrap(); |
| 216 | let stored = page_in(written.as_slice(), space); |
| 217 | let mut expected = after.clone(); |
| 218 | expected.title = stored.title.clone(); |
| 219 | assert_eq!(stored, expected); |
| 220 | export( |
| 221 | "ONESTORE_INK_PARAGRAPH_EXPORT", |
| 222 | "ink.one", |
| 223 | written.as_slice(), |
| 224 | ); |
| 225 | } |
| 226 | |
| 227 | #[test] |
| 228 | fn stored_strokes_keep_their_paths() { |
| 229 | let (space, page) = first_page(NATIVE); |
| 230 | let mut moved = page.clone(); |
| 231 | for object in &mut moved.objects { |
| 232 | if let PageObject::Ink(ink) = object { |
| 233 | ink.strokes[0].points[0][0] += 1.0; |
| 234 | } |
| 235 | } |
| 236 | assert!(ops::saved(NATIVE, space, &moved).is_err()); |
| 237 | } |
| 238 | |
| 239 | /// OneNote 2010's Draw tab used with the mouse (`corpus/ink-tools/native-ui`): every favourite |
| 240 | /// pen and highlighter, shapes, and ink erased, moved and deleted. |
| 241 | const TOOLS: &[u8] = include_bytes!("../../../corpus/ink-tools/native-ui/notebook/ink.one"); |
| 242 | |
| 243 | fn titled(bytes: &[u8], title: &str) -> (ExGuid, Page) { |
| 244 | let store = Store::parse(bytes).unwrap(); |
| 245 | let index = RevisionIndex::parse(&store).unwrap(); |
| 246 | let document = Document::parse(&index).unwrap(); |
| 247 | document |
| 248 | .pages() |
| 249 | .unwrap() |
| 250 | .into_iter() |
| 251 | .map(|(space, _)| (space, Page::from_space(&document, space).unwrap())) |
| 252 | .find(|(_, page)| page.title == title) |
| 253 | .unwrap() |
| 254 | } |
| 255 | |
| 256 | fn himetric(value: f32) -> f32 { |
| 257 | value * 72.0 / 2540.0 |
| 258 | } |
| 259 | |
| 260 | use onestore::page::ink::{InkShape, ShapeKind}; |
| 261 | |
| 262 | #[test] |
| 263 | fn native_pens_highlighters_and_shapes_read_as_drawn() { |
| 264 | let (_, gallery) = titled(TOOLS, "Gallery"); |
| 265 | let pens: Vec<_> = drawings(&gallery) |
| 266 | .iter() |
| 267 | .map(|ink| { |
| 268 | let [stroke] = ink.strokes.as_slice() else { |
| 269 | panic!("OneNote keeps each stroke a drawing of its own") |
| 270 | }; |
| 271 | ( |
| 272 | (stroke.width / himetric(1.0)).round(), |
| 273 | (stroke.height / himetric(1.0)).round(), |
| 274 | stroke.color, |
| 275 | stroke.raster_operation, |
| 276 | ) |
| 277 | }) |
| 278 | .collect(); |
| 279 | let (red, blue, green, grey) = (0x241ced, 0xbb6531, 0x367d17, 0x808080); |
| 280 | let pen = |width, color| (width, width, color, None); |
| 281 | let marker = |color| (70.0, 400.0, Some(color), Some(9)); |
| 282 | assert_eq!( |
| 283 | pens, |
| 284 | [ |
| 285 | pen(35.0, None), |
| 286 | pen(35.0, Some(red)), |
| 287 | pen(35.0, Some(blue)), |
| 288 | pen(35.0, Some(green)), |
| 289 | pen(35.0, Some(grey)), |
| 290 | marker(0x00ffff), |
| 291 | marker(0xffff00), |
| 292 | pen(50.0, None), |
| 293 | pen(50.0, Some(red)), |
| 294 | pen(50.0, Some(blue)), |
| 295 | pen(50.0, Some(green)), |
| 296 | pen(50.0, Some(grey)), |
| 297 | marker(0x00ff00), |
| 298 | marker(0xff00ff), |
| 299 | ] |
| 300 | ); |
| 301 | let highlighter = &drawings(&gallery)[5].strokes[0]; |
| 302 | assert_eq!( |
| 303 | (highlighter.transparency, highlighter.pen_tip), |
| 304 | (Some(127), Some(1)) |
| 305 | ); |
| 306 | |
| 307 | let (_, pens) = titled(TOOLS, "Pens"); |
| 308 | let shapes: Vec<&Ink> = drawings(&pens) |
| 309 | .into_iter() |
| 310 | .filter(|ink| ink.shape.is_some()) |
| 311 | .collect(); |
| 312 | let [rectangle, line, arrow, ellipse] = shapes.as_slice() else { |
| 313 | panic!("{}", shapes.len()) |
| 314 | }; |
| 315 | let pen = InkStroke { |
| 316 | points: Vec::new(), |
| 317 | ..rectangle.strokes[0].clone() |
| 318 | }; |
| 319 | assert!((pen.width - himetric(50.0)).abs() < 1e-5); |
| 320 | let close_points = |a: &[[f32; 2]], b: &[[f32; 2]]| { |
| 321 | a.len() == b.len() |
| 322 | && a.iter() |
| 323 | .zip(b) |
| 324 | .all(|(a, b)| (a[0] - b[0]).abs() < 0.05 && (a[1] - b[1]).abs() < 0.05) |
| 325 | }; |
| 326 | for (native, kind, from, to) in [ |
| 327 | ( |
| 328 | rectangle, |
| 329 | ShapeKind::Rectangle, |
| 330 | [252.0, 86.4], |
| 331 | [324.0, 122.4], |
| 332 | ), |
| 333 | (line, ShapeKind::Line, [342.0, 86.4], [414.0, 122.4]), |
| 334 | (arrow, ShapeKind::Arrow, [252.0, 158.4], [324.0, 194.4]), |
| 335 | (ellipse, ShapeKind::Ellipse, [342.0, 158.4], [414.0, 194.4]), |
| 336 | ] { |
| 337 | let drawn = Ink::drawn(kind, from, to, &pen).unwrap(); |
| 338 | assert_eq!(drawn.strokes.len(), native.strokes.len(), "{kind:?}"); |
| 339 | for (drawn, native) in drawn.strokes.iter().zip(&native.strokes) { |
| 340 | assert!( |
| 341 | close_points(&drawn.points, &native.points), |
| 342 | "{kind:?}: {:?} against {:?}", |
| 343 | drawn.points, |
| 344 | native.points |
| 345 | ); |
| 346 | } |
| 347 | match (drawn.shape.unwrap(), native.shape.clone().unwrap()) { |
| 348 | (InkShape::Line(a), InkShape::Line(b)) => assert!(close_points(&a, &b)), |
| 349 | ( |
| 350 | InkShape::Closed { transform, anchors }, |
| 351 | InkShape::Closed { |
| 352 | transform: native_transform, |
| 353 | anchors: native_anchors, |
| 354 | }, |
| 355 | ) => { |
| 356 | assert_eq!(anchors, native_anchors); |
| 357 | assert!( |
| 358 | transform |
| 359 | .iter() |
| 360 | .zip(native_transform) |
| 361 | .all(|(a, b)| (a - b).abs() < 0.05) |
| 362 | ); |
| 363 | } |
| 364 | shapes => panic!("{shapes:?}"), |
| 365 | } |
| 366 | } |
| 367 | |
| 368 | // Erased and deleted drawings leave the page; a moved one keeps its strokes and takes |
| 369 | // an offset. |
| 370 | let (_, edits) = titled(TOOLS, "Edits"); |
| 371 | let moved: Vec<_> = drawings(&edits) |
| 372 | .into_iter() |
| 373 | .filter(|ink| ink.shape.is_none()) |
| 374 | .map(|ink| (ink.layout.x, ink.layout.y)) |
| 375 | .collect(); |
| 376 | assert_eq!(moved.len(), 2); |
| 377 | assert!( |
| 378 | moved |
| 379 | .iter() |
| 380 | .any(|&(x, y)| x == Some(45.0) && y.is_some_and(|y| (y - 15.0).abs() < 1e-4)) |
| 381 | ); |
| 382 | } |
| 383 | |
| 384 | fn tool_page(source: &[u8]) -> (ExGuid, Page, Vec<Ink>) { |
| 385 | let (space, page) = first_page(source); |
| 386 | let accent = InkStroke { |
| 387 | id: new_id().unwrap(), |
| 388 | points: Vec::new(), |
| 389 | width: snap(himetric(35.0)), |
| 390 | height: snap(himetric(35.0)), |
| 391 | color: Some(0x7a9a1f), |
| 392 | transparency: None, |
| 393 | pen_tip: None, |
| 394 | raster_operation: None, |
| 395 | pressure: Vec::new(), |
| 396 | }; |
| 397 | let marker = InkStroke { |
| 398 | width: himetric(70.0), |
| 399 | height: himetric(400.0), |
| 400 | color: Some(0x00ffff), |
| 401 | transparency: Some(127), |
| 402 | pen_tip: Some(1), |
| 403 | raster_operation: Some(9), |
| 404 | ..accent.clone() |
| 405 | }; |
| 406 | let shape_pen = InkStroke { |
| 407 | width: himetric(50.0), |
| 408 | height: himetric(50.0), |
| 409 | ..accent.clone() |
| 410 | }; |
| 411 | let free = |pen: &InkStroke, points: Vec<[f32; 2]>| Ink { |
| 412 | id: new_id().unwrap(), |
| 413 | layout: Default::default(), |
| 414 | strokes: vec![InkStroke { |
| 415 | id: new_id().unwrap(), |
| 416 | points: points.into_iter().map(|p| p.map(snap)).collect(), |
| 417 | ..pen.clone() |
| 418 | }], |
| 419 | groups: Vec::new(), |
| 420 | shape: None, |
| 421 | }; |
| 422 | let wave: Vec<[f32; 2]> = (0..=40) |
| 423 | .map(|step| { |
| 424 | let x = 60.0 + step as f32 * 4.0; |
| 425 | [x, 150.0 + 12.0 * (x / 18.0).sin()] |
| 426 | }) |
| 427 | .collect(); |
| 428 | let inks = vec![ |
| 429 | free(&marker, vec![[60.0, 190.0], [220.0, 190.0]]), |
| 430 | free(&accent, wave), |
| 431 | Ink::drawn( |
| 432 | ShapeKind::Rectangle, |
| 433 | [252.0, 140.4], |
| 434 | [324.0, 194.4], |
| 435 | &shape_pen, |
| 436 | ) |
| 437 | .unwrap(), |
| 438 | Ink::drawn( |
| 439 | ShapeKind::Ellipse, |
| 440 | [342.0, 140.4], |
| 441 | [432.0, 194.4], |
| 442 | &shape_pen, |
| 443 | ) |
| 444 | .unwrap(), |
| 445 | Ink::drawn(ShapeKind::Arrow, [252.0, 230.4], [360.0, 266.4], &shape_pen).unwrap(), |
| 446 | Ink::drawn(ShapeKind::Line, [378.0, 230.4], [450.0, 230.4], &shape_pen).unwrap(), |
| 447 | ]; |
| 448 | (space, page, inks) |
| 449 | } |
| 450 | |
| 451 | fn added(source: &[u8], space: ExGuid, inks: &[Ink]) -> Vec<u8> { |
| 452 | let mut bytes = source.to_vec(); |
| 453 | for ink in inks { |
| 454 | bytes = ops::page_edited( |
| 455 | &bytes, |
| 456 | space, |
| 457 | vec![onestore::op::PageOp::Add { |
| 458 | object: PageObject::Ink(ink.clone()), |
| 459 | before: None, |
| 460 | }], |
| 461 | ) |
| 462 | .unwrap(); |
| 463 | } |
| 464 | bytes |
| 465 | } |
| 466 | |
| 467 | /// `ONESTORE_INK_TOOLS_EXPORT` names a new directory receiving the candidate for a cold |
| 468 | /// reopen. |
| 469 | #[test] |
| 470 | fn pens_highlighters_and_shapes_are_written_as_onenote_draws_them() { |
| 471 | use onestore::document::{FieldValue, Kind}; |
| 472 | let source = onestore::create_section("ink.one", "Drawn in Snowbound", "Author").unwrap(); |
| 473 | let (space, before, inks) = tool_page(&source); |
| 474 | // Each stroke is one edit, as the canvas stores it. |
| 475 | let written = added(&source, space, &inks); |
| 476 | let stored = page_in(&written, space); |
| 477 | let mut expected = before.clone(); |
| 478 | expected |
| 479 | .objects |
| 480 | .extend(inks.iter().cloned().map(PageObject::Ink)); |
| 481 | expected.title = stored.title.clone(); |
| 482 | assert_eq!(stored, expected); |
| 483 | export("ONESTORE_INK_TOOLS_EXPORT", "ink.one", &written); |
| 484 | |
| 485 | let store = Store::parse(&written).unwrap(); |
| 486 | let index = RevisionIndex::parse(&store).unwrap(); |
| 487 | let document = Document::parse(&index).unwrap(); |
| 488 | let revision = document.active(space).unwrap(); |
| 489 | let node = |id: ExGuid| &revision.nodes[&id]; |
| 490 | let extra = |id: ExGuid, property: u32| { |
| 491 | node(id) |
| 492 | .extra |
| 493 | .iter() |
| 494 | .flatten() |
| 495 | .find(|field| field.id == property) |
| 496 | .map(|field| match field.value { |
| 497 | FieldValue::Bytes(bytes) => bytes.to_vec(), |
| 498 | _ => Vec::new(), |
| 499 | }) |
| 500 | }; |
| 501 | let style = |stroke: ExGuid| match node(stroke).kind { |
| 502 | Kind::InkStroke { |
| 503 | style: Some(style), |
| 504 | bias, |
| 505 | index, |
| 506 | .. |
| 507 | } => (node(style).kind.clone(), bias, index), |
| 508 | _ => panic!(), |
| 509 | }; |
| 510 | let [marker, wave, rectangle, _, arrow, _] = inks.as_slice() else { |
| 511 | unreachable!() |
| 512 | }; |
| 513 | match style(marker.strokes[0].id) { |
| 514 | ( |
| 515 | Kind::InkStyle { |
| 516 | raster_operation: Some(9), |
| 517 | pen_tip: Some(1), |
| 518 | transparency: Some(127), |
| 519 | ignore_pressure: Some(true), |
| 520 | .. |
| 521 | }, |
| 522 | Some(2), |
| 523 | Some(1), |
| 524 | ) => {} |
| 525 | other => panic!("{other:?}"), |
| 526 | } |
| 527 | match style(wave.strokes[0].id) { |
| 528 | ( |
| 529 | Kind::InkStyle { |
| 530 | raster_operation: None, |
| 531 | ignore_pressure: Some(true), |
| 532 | color: Some(0x7a9a1f), |
| 533 | .. |
| 534 | }, |
| 535 | Some(0), |
| 536 | Some(2), |
| 537 | ) => {} |
| 538 | other => panic!("{other:?}"), |
| 539 | } |
| 540 | for stroke in &arrow.strokes { |
| 541 | match style(stroke.id) { |
| 542 | ( |
| 543 | Kind::InkStyle { |
| 544 | ignore_pressure: None, |
| 545 | .. |
| 546 | }, |
| 547 | None, |
| 548 | None, |
| 549 | ) => {} |
| 550 | other => panic!("{other:?}"), |
| 551 | } |
| 552 | } |
| 553 | assert_eq!( |
| 554 | extra(wave.id, 0x14001d4e), |
| 555 | Some(1u32.to_le_bytes().to_vec()) |
| 556 | ); |
| 557 | assert_eq!( |
| 558 | extra(rectangle.id, 0x14001d4e), |
| 559 | Some(2u32.to_le_bytes().to_vec()) |
| 560 | ); |
| 561 | match &node(rectangle.id).kind { |
| 562 | Kind::Ink { |
| 563 | shape_kind: Some(12), |
| 564 | anchors: Some(anchors), |
| 565 | .. |
| 566 | } => assert_eq!(anchors.len(), 24 + 8 * 8), |
| 567 | other => panic!("{other:?}"), |
| 568 | } |
| 569 | } |
| 570 | |
| 571 | /// `ONESTORE_INK_TOOLS_EDIT_EXPORT` names a new directory receiving the candidate for a cold |
| 572 | /// reopen: OneNote's own drawings after Snowbound erases one, moves another and draws more. |
| 573 | #[test] |
| 574 | fn onenote_drawings_take_erasing_moving_and_more_strokes() { |
| 575 | use onestore::{OutlineEdit, op::PageOp}; |
| 576 | let (space, page) = titled(TOOLS, "Pens"); |
| 577 | let native = drawings(&page); |
| 578 | let zigzag = native |
| 579 | .iter() |
| 580 | .find(|ink| ink.strokes[0].color == Some(0x241ced)) |
| 581 | .unwrap() |
| 582 | .id; |
| 583 | let line = native[1].id; |
| 584 | let edited = ops::page_edited( |
| 585 | TOOLS, |
| 586 | space, |
| 587 | vec![ |
| 588 | PageOp::Delete { object: zigzag }, |
| 589 | PageOp::Outline { |
| 590 | object: line, |
| 591 | edit: OutlineEdit::Position { x: 36.0, y: 18.0 }, |
| 592 | }, |
| 593 | ], |
| 594 | ) |
| 595 | .unwrap(); |
| 596 | let (_, _, inks) = tool_page(&onestore::create_section("x.one", "x", "x").unwrap()); |
| 597 | let moved: Vec<Ink> = inks |
| 598 | .into_iter() |
| 599 | .map(|mut ink| { |
| 600 | for stroke in &mut ink.strokes { |
| 601 | for point in &mut stroke.points { |
| 602 | point[1] = snap(point[1] + 200.0); |
| 603 | } |
| 604 | } |
| 605 | if let Some(shape) = &mut ink.shape { |
| 606 | *shape = match shape { |
| 607 | InkShape::Line([a, b]) => { |
| 608 | InkShape::Line([[a[0], a[1] + 200.0], [b[0], b[1] + 200.0]]) |
| 609 | } |
| 610 | InkShape::Closed { transform, anchors } => InkShape::Closed { |
| 611 | transform: { |
| 612 | let mut t = *transform; |
| 613 | t[5] += 200.0; |
| 614 | t |
| 615 | }, |
| 616 | anchors: anchors.clone(), |
| 617 | }, |
| 618 | } |
| 619 | .snapped(); |
| 620 | } |
| 621 | ink |
| 622 | }) |
| 623 | .collect(); |
| 624 | let written = added(&edited, space, &moved); |
| 625 | let stored = page_in(&written, space); |
| 626 | let found = drawings(&stored); |
| 627 | assert!(!found.iter().any(|ink| ink.id == zigzag)); |
| 628 | let line = found.iter().find(|ink| ink.id == line).unwrap(); |
| 629 | assert_eq!((line.layout.x, line.layout.y), (Some(36.0), Some(18.0))); |
| 630 | assert_eq!(line.strokes, native[1].strokes); |
| 631 | for ink in &moved { |
| 632 | assert_eq!(found.iter().find(|found| found.id == ink.id), Some(&ink)); |
| 633 | } |
| 634 | export("ONESTORE_INK_TOOLS_EDIT_EXPORT", "ink.one", &written); |
| 635 | } |
| 636 | |
| 637 | /// OneNote 2010's pressure ink, taken in from ISF (`tools/native/ink-pressure.ps1`). |
| 638 | const PRESSURE: &[u8] = include_bytes!("../../../corpus/ink-pressure/native/notebook/Pressure.one"); |
| 639 | |
| 640 | #[test] |
| 641 | fn onenote_pressure_ink_reads_each_point_s_level_over_the_pen_s_range() { |
| 642 | let (_, page) = titled(PRESSURE, "Levels"); |
| 643 | let [fine, coarse] = drawings(&page)[..] else { |
| 644 | panic!("{:?}", drawings(&page).len()); |
| 645 | }; |
| 646 | for (ink, range) in [(fine, 1023.0), (coarse, 255.0)] { |
| 647 | assert_eq!(ink.strokes.len(), 9); |
| 648 | for (step, stroke) in ink.strokes.iter().enumerate() { |
| 649 | let level = (step as f32 / 8.0 * range).round() / range; |
| 650 | assert_eq!(stroke.pressure.len(), stroke.points.len()); |
| 651 | assert!(stroke.pressure.iter().all(|p| (p - level).abs() < 1e-6)); |
| 652 | assert!((stroke.thickness(0) - (0.25 + 1.5 * level)).abs() < 1e-6); |
| 653 | } |
| 654 | } |
| 655 | let (_, page) = titled(PRESSURE, "Strokes"); |
| 656 | let [ramp, _, flat, tilt, block] = drawings(&page)[..] else { |
| 657 | panic!("{:?}", drawings(&page).len()); |
| 658 | }; |
| 659 | let ramp = &ramp.strokes[0].pressure; |
| 660 | assert_eq!((ramp.len(), ramp[0], ramp[40]), (41, 0.0, 1.0)); |
| 661 | // A pen that ignores the pressure it recorded draws at its width throughout. |
| 662 | assert!(flat.strokes[0].pressure.is_empty()); |
| 663 | assert_eq!(flat.strokes[0].thickness(20), 1.0); |
| 664 | // Tilt beside the pressure leaves the pressure as it is. |
| 665 | let tilted = &tilt.strokes[0].pressure; |
| 666 | assert!((tilted[0] - 205.0 / 1023.0).abs() < 1e-6, "{tilted:?}"); |
| 667 | assert_eq!(block.strokes[0].pen_tip, Some(1)); |
| 668 | assert_eq!(block.strokes[0].pressure.len(), 11); |
| 669 | } |
| 670 | |
| 671 | /// `ONESTORE_INK_PRESSURE_EXPORT` names a new directory receiving the candidate for a cold |
| 672 | /// reopen: OneNote's pressure ink after Snowbound erases a stroke of one drawing, moves one |
| 673 | /// and deletes another, beside Snowbound's own pressure strokes. |
| 674 | #[test] |
| 675 | fn pressure_ink_is_written_as_onenote_keeps_it_and_survives_edits() { |
| 676 | use onestore::document::Kind; |
| 677 | use onestore::{OutlineEdit, op::PageOp}; |
| 678 | let (levels_space, levels) = titled(PRESSURE, "Levels"); |
| 679 | let fine = drawings(&levels)[0].clone(); |
| 680 | let erased = ops::page_edited( |
| 681 | PRESSURE, |
| 682 | levels_space, |
| 683 | vec![PageOp::Strokes { |
| 684 | ink: fine.id, |
| 685 | add: Vec::new(), |
| 686 | remove: vec![fine.strokes[0].id], |
| 687 | }], |
| 688 | ) |
| 689 | .unwrap(); |
| 690 | let pen = |points: Vec<[f32; 2]>, pressure: Vec<f32>| Ink { |
| 691 | id: new_id().unwrap(), |
| 692 | layout: Default::default(), |
| 693 | strokes: vec![InkStroke { |
| 694 | id: new_id().unwrap(), |
| 695 | points: points.into_iter().map(|p| p.map(snap)).collect(), |
| 696 | width: snap(himetric(500.0)), |
| 697 | height: snap(himetric(500.0)), |
| 698 | color: Some(0x7a9a1f), |
| 699 | transparency: None, |
| 700 | pen_tip: None, |
| 701 | raster_operation: None, |
| 702 | pressure: pressure |
| 703 | .into_iter() |
| 704 | .map(onestore::page::ink::level) |
| 705 | .collect(), |
| 706 | }], |
| 707 | groups: Vec::new(), |
| 708 | shape: None, |
| 709 | }; |
| 710 | // Constant levels of none, half and full pressure, and a ramp from none to full. |
| 711 | let mut inks: Vec<Ink> = [0.0, 0.5, 1.0] |
| 712 | .into_iter() |
| 713 | .enumerate() |
| 714 | .map(|(row, pressure)| { |
| 715 | let y = 520.0 + 36.0 * row as f32; |
| 716 | pen(vec![[36.0, y], [108.0, y], [180.0, y]], vec![pressure; 3]) |
| 717 | }) |
| 718 | .collect(); |
| 719 | inks.push(pen( |
| 720 | (0..=40) |
| 721 | .map(|step| [36.0 + 6.0 * step as f32, 640.0]) |
| 722 | .collect(), |
| 723 | (0..=40).map(|step| step as f32 / 40.0).collect(), |
| 724 | )); |
| 725 | let written = added(&erased, levels_space, &inks); |
| 726 | let (strokes_space, strokes) = titled(&written, "Strokes"); |
| 727 | let [ramp, wave, ..] = drawings(&strokes)[..] else { |
| 728 | unreachable!() |
| 729 | }; |
| 730 | let written = ops::page_edited( |
| 731 | &written, |
| 732 | strokes_space, |
| 733 | vec![ |
| 734 | PageOp::Delete { object: wave.id }, |
| 735 | PageOp::Outline { |
| 736 | object: ramp.id, |
| 737 | edit: OutlineEdit::Position { x: 72.0, y: 108.0 }, |
| 738 | }, |
| 739 | ], |
| 740 | ) |
| 741 | .unwrap(); |
| 742 | |
| 743 | let stored = page_in(&written, levels_space); |
| 744 | let found = drawings(&stored); |
| 745 | let kept = found.iter().find(|ink| ink.id == fine.id).unwrap(); |
| 746 | assert_eq!(kept.strokes, fine.strokes[1..]); |
| 747 | for ink in &inks { |
| 748 | assert_eq!(found.iter().find(|found| found.id == ink.id), Some(&ink)); |
| 749 | } |
| 750 | let stored = page_in(&written, strokes_space); |
| 751 | let moved = drawings(&stored); |
| 752 | assert_eq!(moved.len(), 4); |
| 753 | assert_eq!( |
| 754 | (moved[0].layout.x, moved[0].layout.y), |
| 755 | (Some(72.0), Some(108.0)) |
| 756 | ); |
| 757 | assert_eq!(moved[0].strokes, ramp.strokes); |
| 758 | |
| 759 | // New pressure strokes store NormalPressure after X and Y and leave IgnorePressure out, |
| 760 | // as OneNote does with pressure sensitivity on; OneNote's tilt stays as it stored it. |
| 761 | let store = Store::parse(&written).unwrap(); |
| 762 | let index = RevisionIndex::parse(&store).unwrap(); |
| 763 | let document = Document::parse(&index).unwrap(); |
| 764 | let style = |space: ExGuid, stroke: ExGuid| { |
| 765 | let revision = document.active(space).unwrap(); |
| 766 | let Kind::InkStroke { |
| 767 | style: Some(style), .. |
| 768 | } = revision.nodes[&stroke].kind |
| 769 | else { |
| 770 | panic!() |
| 771 | }; |
| 772 | revision.nodes[&style].kind.clone() |
| 773 | }; |
| 774 | match style(levels_space, inks[0].strokes[0].id) { |
| 775 | Kind::InkStyle { |
| 776 | dimensions, |
| 777 | ignore_pressure: None, |
| 778 | .. |
| 779 | } => { |
| 780 | assert_eq!(dimensions.len(), 96); |
| 781 | assert_eq!( |
| 782 | &dimensions[64..], |
| 783 | &[ |
| 784 | 0x2d, 0x50, 0x07, 0x73, 0xf4, 0xf9, 0x18, 0x4e, 0xb3, 0xf2, 0x2c, 0xe1, 0xb1, |
| 785 | 0xa3, 0x61, 0x0c, 0, 0, 0, 0, 0xff, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0x80, 0x3f |
| 786 | ] |
| 787 | ); |
| 788 | } |
| 789 | other => panic!("{other:?}"), |
| 790 | } |
| 791 | match style(strokes_space, moved[2].strokes[0].id) { |
| 792 | Kind::InkStyle { dimensions, .. } => assert_eq!(dimensions.len(), 5 * 32), |
| 793 | other => panic!("{other:?}"), |
| 794 | } |
| 795 | export("ONESTORE_INK_PRESSURE_EXPORT", "Pressure.one", &written); |
| 796 | } |