| 1 | //! Links and equations made as a user makes them, through the editor's typing, Link |
| 2 | //! dialog, Remove Link, Alt+=, Linear and Professional, save as the ops the editor recorded |
| 3 | //! and read back as the page it saved. `CANVAS_LINKS_EQUATIONS_EXPORT` names a new directory |
| 4 | //! receiving the section for a cold OneNote reopen, with the links and MathML its read must |
| 5 | //! show (`corpus/link-edit/editor`). |
| 6 | |
| 7 | use canvas::{document::TextPosition, editor::CanvasEditor, layout::TextEngine}; |
| 8 | use onestore::{ |
| 9 | Arena, ExGuid, RevisionIndex, Section, Store, |
| 10 | document::Document, |
| 11 | page::{ |
| 12 | Math, Page, PageObject, |
| 13 | link::{LinkTarget, internal_link}, |
| 14 | }, |
| 15 | }; |
| 16 | use std::path::Path; |
| 17 | |
| 18 | const BASE_PATH: &str = r"C:\one-tests\runs\capture\notebook\links.one"; |
| 19 | |
| 20 | fn pages(section: &[u8]) -> Vec<(ExGuid, Page)> { |
| 21 | let store = Store::parse(section).unwrap(); |
| 22 | let index = RevisionIndex::parse(&store).unwrap(); |
| 23 | let document = Document::parse(&index).unwrap(); |
| 24 | document |
| 25 | .pages() |
| 26 | .unwrap() |
| 27 | .into_iter() |
| 28 | .map(|(space, _)| (space, Page::from_space(&document, space).unwrap())) |
| 29 | .collect() |
| 30 | } |
| 31 | |
| 32 | /// Types `text`, each space on its own as a key does. |
| 33 | fn typed(editor: &mut CanvasEditor, engine: &mut TextEngine, text: &str) { |
| 34 | for (index, word) in text.split(' ').enumerate() { |
| 35 | if index > 0 { |
| 36 | editor.insert(engine, " ").unwrap(); |
| 37 | } |
| 38 | if !word.is_empty() { |
| 39 | editor.insert(engine, word).unwrap(); |
| 40 | } |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | fn caret(editor: &CanvasEditor) -> TextPosition { |
| 45 | editor.selection().positions[1] |
| 46 | } |
| 47 | |
| 48 | /// End, then Enter, returning the new paragraph. |
| 49 | fn line(editor: &mut CanvasEditor, engine: &mut TextEngine) -> usize { |
| 50 | let paragraph = caret(editor).paragraph; |
| 51 | let text = editor |
| 52 | .active_outline() |
| 53 | .document() |
| 54 | .paragraphs() |
| 55 | .nth(paragraph) |
| 56 | .unwrap(); |
| 57 | let offset = text.utf16_offset(text.text().len()).unwrap(); |
| 58 | editor |
| 59 | .select([TextPosition { paragraph, offset }; 2].into()) |
| 60 | .unwrap(); |
| 61 | editor.enter(engine, false).unwrap(); |
| 62 | caret(editor).paragraph |
| 63 | } |
| 64 | |
| 65 | fn select(editor: &mut CanvasEditor, paragraph: usize, range: std::ops::Range<u32>) { |
| 66 | editor |
| 67 | .select( |
| 68 | [ |
| 69 | TextPosition { |
| 70 | paragraph, |
| 71 | offset: range.start, |
| 72 | }, |
| 73 | TextPosition { |
| 74 | paragraph, |
| 75 | offset: range.end, |
| 76 | }, |
| 77 | ] |
| 78 | .into(), |
| 79 | ) |
| 80 | .unwrap(); |
| 81 | } |
| 82 | |
| 83 | #[test] |
| 84 | fn links_and_equations_save_as_the_editor_shows_them() { |
| 85 | let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); |
| 86 | let source = std::fs::read(root.join("corpus/link-edit/internal/candidate/links.one")).unwrap(); |
| 87 | let section_id = Store::parse(&source).unwrap().header.file_id; |
| 88 | let all = pages(&source); |
| 89 | let (space, page) = all |
| 90 | .iter() |
| 91 | .find(|(_, page)| page.title.starts_with("Linking page")) |
| 92 | .cloned() |
| 93 | .unwrap(); |
| 94 | let target = all |
| 95 | .iter() |
| 96 | .map(|(_, page)| page) |
| 97 | .find(|page| page.title == "Link target") |
| 98 | .unwrap(); |
| 99 | let paragraph_id = page |
| 100 | .objects |
| 101 | .iter() |
| 102 | .find_map(|object| match object { |
| 103 | PageObject::Outline(outline) if !outline.title => Some(outline.paragraphs[0].id), |
| 104 | _ => None, |
| 105 | }) |
| 106 | .unwrap(); |
| 107 | let mut engine = TextEngine::default(); |
| 108 | let engine = &mut engine; |
| 109 | let mut editor = CanvasEditor::from_page(page.clone(), engine).unwrap(); |
| 110 | let body = editor |
| 111 | .outlines() |
| 112 | .iter() |
| 113 | .find(|outline| !outline.title) |
| 114 | .unwrap() |
| 115 | .id; |
| 116 | editor.focus_outline(body).unwrap(); |
| 117 | let end = editor |
| 118 | .active_outline() |
| 119 | .document() |
| 120 | .paragraphs() |
| 121 | .next() |
| 122 | .unwrap(); |
| 123 | let end = end.utf16_offset(end.text().len()).unwrap(); |
| 124 | editor |
| 125 | .select( |
| 126 | [TextPosition { |
| 127 | paragraph: 0, |
| 128 | offset: end, |
| 129 | }; 2] |
| 130 | .into(), |
| 131 | ) |
| 132 | .unwrap(); |
| 133 | let mut hrefs = vec![internal_link( |
| 134 | section_id, |
| 135 | BASE_PATH, |
| 136 | LinkTarget::Page { |
| 137 | identity: target.identity.unwrap(), |
| 138 | title: "Link target", |
| 139 | }, |
| 140 | )]; |
| 141 | // Typed URLs ended by a space and by Enter. |
| 142 | line(&mut editor, engine); |
| 143 | typed( |
| 144 | &mut editor, |
| 145 | engine, |
| 146 | "Visit www.example.com or (https://example.org/a?b=1). ", |
| 147 | ); |
| 148 | hrefs.extend([ |
| 149 | "http://www.example.com".into(), |
| 150 | "https://example.org/a?b=1".into(), |
| 151 | ]); |
| 152 | line(&mut editor, engine); |
| 153 | typed(&mut editor, engine, "Ends with http://example.net/end"); |
| 154 | hrefs.push("http://example.net/end".into()); |
| 155 | // The Link dialog on a selection, on the word at the caret and on nothing. |
| 156 | let at = line(&mut editor, engine); |
| 157 | typed(&mut editor, engine, "Read the Rust book today"); |
| 158 | select(&mut editor, at, 9..18); |
| 159 | assert_eq!( |
| 160 | editor.link_prefill(), |
| 161 | Some(("Rust book".into(), String::new())) |
| 162 | ); |
| 163 | editor |
| 164 | .set_link(engine, "Rust book", "https://doc.rust-lang.org/book/") |
| 165 | .unwrap(); |
| 166 | hrefs.push("https://doc.rust-lang.org/book/".into()); |
| 167 | let at = line(&mut editor, engine); |
| 168 | typed(&mut editor, engine, "A word here"); |
| 169 | select(&mut editor, at, 3..3); |
| 170 | editor.set_link(engine, "word", "example.net/x").unwrap(); |
| 171 | hrefs.push("http://example.net/x".into()); |
| 172 | line(&mut editor, engine); |
| 173 | editor |
| 174 | .set_link(engine, "", "https://example.com/only") |
| 175 | .unwrap(); |
| 176 | typed(&mut editor, engine, " tail"); |
| 177 | hrefs.push("https://example.com/only".into()); |
| 178 | // A link to the other page and one to this page's first paragraph, as picked or pasted. |
| 179 | line(&mut editor, engine); |
| 180 | typed(&mut editor, engine, "See "); |
| 181 | editor |
| 182 | .set_link(engine, "the target page", &hrefs[0].clone()) |
| 183 | .unwrap(); |
| 184 | hrefs.push(hrefs[0].clone()); |
| 185 | line(&mut editor, engine); |
| 186 | let paragraph_link = internal_link( |
| 187 | section_id, |
| 188 | BASE_PATH, |
| 189 | LinkTarget::Object { |
| 190 | identity: page.identity.unwrap(), |
| 191 | title: "Linking page", |
| 192 | object: paragraph_id, |
| 193 | }, |
| 194 | ); |
| 195 | editor |
| 196 | .set_link(engine, "Back to the top", &paragraph_link) |
| 197 | .unwrap(); |
| 198 | hrefs.push(paragraph_link); |
| 199 | // Remove Link. |
| 200 | let at = line(&mut editor, engine); |
| 201 | typed(&mut editor, engine, "Gone ftp://h.example/f z"); |
| 202 | select(&mut editor, at, 8..8); |
| 203 | assert!(editor.remove_link(engine).unwrap()); |
| 204 | // Equations typed after Alt+=, one shown in its linear form, one after text. |
| 205 | for linear in [ |
| 206 | "a_1+b_2", |
| 207 | "\\int_0^1 x dx", |
| 208 | "(a+b)/(c+d)", |
| 209 | "\\sqrt x+1", |
| 210 | "\\sum_(i=1)^n i", |
| 211 | "\\matrix(1&2@3&4)", |
| 212 | "e^(x+1)", |
| 213 | ] { |
| 214 | line(&mut editor, engine); |
| 215 | editor.insert_equation(engine).unwrap(); |
| 216 | typed(&mut editor, engine, &format!("{linear} ")); |
| 217 | } |
| 218 | assert!(editor.linear_equation(engine).unwrap()); |
| 219 | let at = line(&mut editor, engine); |
| 220 | typed(&mut editor, engine, "Area "); |
| 221 | editor.insert_equation(engine).unwrap(); |
| 222 | typed(&mut editor, engine, "\\pi r^2 "); |
| 223 | // Linear, then Professional, rebuilds an equation as it was. |
| 224 | let built = editor |
| 225 | .active_outline() |
| 226 | .document() |
| 227 | .paragraphs() |
| 228 | .nth(at) |
| 229 | .unwrap() |
| 230 | .clone(); |
| 231 | select(&mut editor, at, 7..7); |
| 232 | assert!(editor.linear_equation(engine).unwrap()); |
| 233 | assert!(editor.build_equation(engine).unwrap()); |
| 234 | assert_eq!( |
| 235 | editor |
| 236 | .active_outline() |
| 237 | .document() |
| 238 | .paragraphs() |
| 239 | .nth(at) |
| 240 | .unwrap(), |
| 241 | &built |
| 242 | ); |
| 243 | // Enter at an equation's start, inside its row, inside a fraction's argument, and at a |
| 244 | // link's start. |
| 245 | let paragraph = |editor: &CanvasEditor, at: usize| { |
| 246 | editor |
| 247 | .active_outline() |
| 248 | .document() |
| 249 | .paragraphs() |
| 250 | .nth(at) |
| 251 | .unwrap() |
| 252 | .clone() |
| 253 | }; |
| 254 | let at = line(&mut editor, engine); |
| 255 | typed(&mut editor, engine, "Split "); |
| 256 | editor.insert_equation(engine).unwrap(); |
| 257 | typed(&mut editor, engine, "y^2+1 "); |
| 258 | select(&mut editor, at, 6..6); |
| 259 | editor.enter(engine, false).unwrap(); |
| 260 | let text = paragraph(&editor, at + 1); |
| 261 | let one = text.utf16_offset(text.text().find('1').unwrap()).unwrap(); |
| 262 | select(&mut editor, at + 1, one..one); |
| 263 | editor.enter(engine, false).unwrap(); |
| 264 | let at = line(&mut editor, engine); |
| 265 | editor.insert_equation(engine).unwrap(); |
| 266 | typed(&mut editor, engine, "(a+b)/(c+d) "); |
| 267 | let text = paragraph(&editor, at); |
| 268 | let plus = text.utf16_offset(text.text().rfind('+').unwrap()).unwrap(); |
| 269 | select(&mut editor, at, plus..plus); |
| 270 | editor.enter(engine, false).unwrap(); |
| 271 | let at = line(&mut editor, engine); |
| 272 | typed(&mut editor, engine, "Moved "); |
| 273 | editor |
| 274 | .set_link(engine, "down", "https://example.com/moved") |
| 275 | .unwrap(); |
| 276 | hrefs.push("https://example.com/moved".into()); |
| 277 | let text = paragraph(&editor, at); |
| 278 | let label = text |
| 279 | .utf16_offset(text.text().find("down").unwrap()) |
| 280 | .unwrap(); |
| 281 | select(&mut editor, at, label..label); |
| 282 | editor.enter(engine, false).unwrap(); |
| 283 | |
| 284 | let after = editor.page().unwrap(); |
| 285 | let arena = Arena::default(); |
| 286 | let mut stored = Section::open(&arena, source.clone()).unwrap(); |
| 287 | let edit = onestore::op::Edit { |
| 288 | at: 133_000_000_000_000_000, |
| 289 | ops: editor |
| 290 | .take_ops() |
| 291 | .unwrap() |
| 292 | .into_iter() |
| 293 | .map(|op| onestore::op::Op::Page { space, op }) |
| 294 | .collect(), |
| 295 | }; |
| 296 | stored.apply("Snowbound", &edit).unwrap(); |
| 297 | stored.seal().unwrap(); |
| 298 | let saved = stored.image(); |
| 299 | let (_, reread) = pages(&saved) |
| 300 | .into_iter() |
| 301 | .find(|(id, _)| *id == space) |
| 302 | .unwrap(); |
| 303 | let body = |page: &Page| -> Vec<onestore::page::PageParagraph> { |
| 304 | page.objects |
| 305 | .iter() |
| 306 | .filter_map(|object| match object { |
| 307 | PageObject::Outline(outline) if !outline.title => Some(outline), |
| 308 | _ => None, |
| 309 | }) |
| 310 | .flat_map(|outline| outline.paragraphs.clone()) |
| 311 | .collect() |
| 312 | }; |
| 313 | for (stored, shown) in body(&reread).iter().zip(body(&after)) { |
| 314 | assert_eq!(stored, &shown); |
| 315 | } |
| 316 | assert_eq!( |
| 317 | Page { |
| 318 | title: after.title.clone(), |
| 319 | ..reread.clone() |
| 320 | }, |
| 321 | after |
| 322 | ); |
| 323 | |
| 324 | let paragraphs: Vec<_> = reread |
| 325 | .objects |
| 326 | .iter() |
| 327 | .filter_map(|object| match object { |
| 328 | PageObject::Outline(outline) if !outline.title => Some(outline), |
| 329 | _ => None, |
| 330 | }) |
| 331 | .flat_map(|outline| &outline.paragraphs) |
| 332 | .filter_map(|paragraph| paragraph.text()) |
| 333 | .collect(); |
| 334 | let stored_hrefs: Vec<String> = paragraphs |
| 335 | .iter() |
| 336 | .flat_map(|text| { |
| 337 | text.text |
| 338 | .spans() |
| 339 | .iter() |
| 340 | .scan(0, |start, span| { |
| 341 | let run = &text.text.text()[*start..span.end]; |
| 342 | *start = span.end; |
| 343 | Some((run, span.format.clone())) |
| 344 | }) |
| 345 | .filter(|(_, format)| format.hyperlink == Some(true) && format.hidden != Some(true)) |
| 346 | .map(|(run, _)| run.to_owned()) |
| 347 | .collect::<Vec<_>>() |
| 348 | }) |
| 349 | .collect(); |
| 350 | assert_eq!(stored_hrefs.len(), hrefs.len(), "{stored_hrefs:?}"); |
| 351 | // OneNote links URL text again when it opens a page, after its own Remove Link too |
| 352 | // (`corpus/link-edit/native-typed`, cold read). |
| 353 | let moved = hrefs.len() - 1; |
| 354 | hrefs.insert(moved, "ftp://h.example/f".into()); |
| 355 | // The MathML OneNote exports for each run of math, an equation of its own. |
| 356 | let mut mathml = Vec::new(); |
| 357 | for text in &paragraphs { |
| 358 | let mut start = 0; |
| 359 | let mut zone = Vec::new(); |
| 360 | for span in text.text.spans() { |
| 361 | let run = text.text.text()[start..span.end].to_owned(); |
| 362 | start = span.end; |
| 363 | if span.format.math == Some(true) { |
| 364 | zone.push((run, span.format.clone())); |
| 365 | } |
| 366 | let ends = span.format.math != Some(true) || start == text.text.text().len(); |
| 367 | if ends && !zone.is_empty() { |
| 368 | let math = onestore::page::Paragraph::from_runs(std::mem::take(&mut zone)); |
| 369 | mathml.push(Math::mathml(&Math::parse(&math).unwrap())); |
| 370 | } |
| 371 | } |
| 372 | } |
| 373 | assert_eq!(mathml.len(), 11); |
| 374 | if let Some(directory) = std::env::var_os("CANVAS_LINKS_EQUATIONS_EXPORT") { |
| 375 | let directory = Path::new(&directory); |
| 376 | std::fs::create_dir(directory).unwrap(); |
| 377 | std::fs::write(directory.join("links.one"), &saved).unwrap(); |
| 378 | std::fs::copy( |
| 379 | root.join("corpus/link-edit/internal/candidate/Open Notebook.onetoc2"), |
| 380 | directory.join("Open Notebook.onetoc2"), |
| 381 | ) |
| 382 | .unwrap(); |
| 383 | std::fs::write( |
| 384 | directory.join("expected.json"), |
| 385 | serde_json::to_string_pretty(&serde_json::json!({ |
| 386 | "hrefs": hrefs, |
| 387 | "mathml": mathml, |
| 388 | })) |
| 389 | .unwrap(), |
| 390 | ) |
| 391 | .unwrap(); |
| 392 | } |
| 393 | } |
| 394 | |
| 395 | /// A paragraph's runs as (text, hidden, hyperlink, math, italic, run data), with a run |
| 396 | /// between objects stored either with or without run data. |
| 397 | type Runs = Vec<(String, bool, bool, bool, bool, Option<(u32, Option<u32>)>)>; |
| 398 | |
| 399 | fn runs(text: &onestore::page::Paragraph) -> Runs { |
| 400 | let mut start = 0; |
| 401 | text.spans() |
| 402 | .iter() |
| 403 | .map(|span| { |
| 404 | let run = text.text()[start..span.end].to_owned(); |
| 405 | start = span.end; |
| 406 | let format = &span.format; |
| 407 | let object = format |
| 408 | .math_object |
| 409 | .as_ref() |
| 410 | .map(|object| (object.kind, object.arguments)) |
| 411 | .filter(|(kind, _)| *kind != 0x9000_0000); |
| 412 | ( |
| 413 | run, |
| 414 | format.hidden == Some(true), |
| 415 | format.hyperlink == Some(true), |
| 416 | format.math == Some(true), |
| 417 | format.italic == Some(true), |
| 418 | object, |
| 419 | ) |
| 420 | }) |
| 421 | .collect() |
| 422 | } |
| 423 | |
| 424 | /// Enter at an equation's start, inside its row and inside a fraction's argument, and at a |
| 425 | /// link's start, stores what OneNote 2010 stored for the same keys (`tools/native_enter.py`, |
| 426 | /// `corpus/math-edit/native-enter`). |
| 427 | #[test] |
| 428 | fn enter_at_and_inside_equations_and_links_stores_what_onenote_stored() { |
| 429 | let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); |
| 430 | let native = |
| 431 | std::fs::read(root.join("corpus/math-edit/native-enter/notebook/links.one")).unwrap(); |
| 432 | let (_, page) = pages(&native).remove(0); |
| 433 | let native: Vec<Runs> = page |
| 434 | .objects |
| 435 | .iter() |
| 436 | .filter_map(|object| match object { |
| 437 | PageObject::Outline(outline) if !outline.title => Some(outline), |
| 438 | _ => None, |
| 439 | }) |
| 440 | .flat_map(|outline| &outline.paragraphs) |
| 441 | .filter_map(|paragraph| paragraph.text()) |
| 442 | .skip(1) |
| 443 | .map(|text| runs(&text.text)) |
| 444 | .collect(); |
| 445 | let mut engine = TextEngine::default(); |
| 446 | let engine = &mut engine; |
| 447 | let mut editor = CanvasEditor::new( |
| 448 | engine, |
| 449 | canvas::document::TextDocument::new(vec![onestore::page::Paragraph::new( |
| 450 | String::new(), |
| 451 | onestore::document::Format { |
| 452 | font: Some("Calibri".into()), |
| 453 | ..Default::default() |
| 454 | }, |
| 455 | )]) |
| 456 | .unwrap(), |
| 457 | 400.0, |
| 458 | ) |
| 459 | .unwrap(); |
| 460 | let text = |editor: &CanvasEditor, paragraph: usize| { |
| 461 | editor |
| 462 | .active_outline() |
| 463 | .document() |
| 464 | .paragraphs() |
| 465 | .nth(paragraph) |
| 466 | .unwrap() |
| 467 | .clone() |
| 468 | }; |
| 469 | let before = |editor: &CanvasEditor, paragraph: usize, needle: &str| { |
| 470 | let text = text(editor, paragraph); |
| 471 | let at = text.text().find(needle).unwrap(); |
| 472 | text.utf16_offset(at).unwrap() |
| 473 | }; |
| 474 | typed(&mut editor, engine, "Before "); |
| 475 | editor.insert_equation(engine).unwrap(); |
| 476 | typed(&mut editor, engine, "y^2+1 "); |
| 477 | select(&mut editor, 0, 7..7); |
| 478 | editor.enter(engine, false).unwrap(); |
| 479 | let at = before(&editor, 1, "1"); |
| 480 | select(&mut editor, 1, at..at); |
| 481 | editor.enter(engine, false).unwrap(); |
| 482 | line(&mut editor, engine); |
| 483 | editor.insert_equation(engine).unwrap(); |
| 484 | typed(&mut editor, engine, "(a+b)/(c+d) "); |
| 485 | let at = before(&editor, 2, "+𝑑"); |
| 486 | select(&mut editor, 2, at..at); |
| 487 | editor.enter(engine, false).unwrap(); |
| 488 | let paragraph = line(&mut editor, engine); |
| 489 | typed(&mut editor, engine, "Select me please"); |
| 490 | select(&mut editor, paragraph, 7..16); |
| 491 | editor |
| 492 | .set_link(engine, "me please", "https://example.com/selected") |
| 493 | .unwrap(); |
| 494 | let start = before(&editor, paragraph, "me please"); |
| 495 | select(&mut editor, paragraph, start..start); |
| 496 | editor.enter(engine, false).unwrap(); |
| 497 | let written: Vec<Runs> = editor |
| 498 | .active_outline() |
| 499 | .document() |
| 500 | .paragraphs() |
| 501 | .map(runs) |
| 502 | .collect(); |
| 503 | assert_eq!(written, native); |
| 504 | } |
| 505 | |
| 506 | /// Enter and Shift+Enter inside a link's label leave it whole, as in OneNote 2010, where |
| 507 | /// Shift+Enter does nothing and Enter follows the link; at its end Enter splits as anywhere |
| 508 | /// else. |
| 509 | #[test] |
| 510 | fn enter_inside_a_link_is_ignored() { |
| 511 | let mut engine = TextEngine::default(); |
| 512 | let engine = &mut engine; |
| 513 | let mut editor = CanvasEditor::new( |
| 514 | engine, |
| 515 | canvas::document::TextDocument::new(vec![onestore::page::Paragraph::new( |
| 516 | String::new(), |
| 517 | Default::default(), |
| 518 | )]) |
| 519 | .unwrap(), |
| 520 | 400.0, |
| 521 | ) |
| 522 | .unwrap(); |
| 523 | typed(&mut editor, engine, "Visit the example site today"); |
| 524 | select(&mut editor, 0, 10..22); |
| 525 | editor |
| 526 | .set_link(engine, "example site", "https://example.com/") |
| 527 | .unwrap(); |
| 528 | editor.take_ops().unwrap(); |
| 529 | let document = editor.active_outline().document().clone(); |
| 530 | let text = document.paragraphs().next().unwrap(); |
| 531 | let end = text |
| 532 | .utf16_offset(text.text().find(" today").unwrap()) |
| 533 | .unwrap(); |
| 534 | let label = editor |
| 535 | .link_at(TextPosition { |
| 536 | paragraph: 0, |
| 537 | offset: end, |
| 538 | }) |
| 539 | .unwrap() |
| 540 | .label; |
| 541 | let inside = label.start + 7; |
| 542 | for soft in [false, true] { |
| 543 | select(&mut editor, 0, inside..inside); |
| 544 | editor.enter(engine, soft).unwrap(); |
| 545 | assert_eq!(*editor.active_outline().document(), document); |
| 546 | assert!(editor.take_ops().unwrap().is_empty()); |
| 547 | } |
| 548 | select(&mut editor, 0, label.end..label.end); |
| 549 | editor.enter(engine, false).unwrap(); |
| 550 | assert_eq!(editor.active_outline().document().paragraphs().count(), 2); |
| 551 | } |
| 552 | |
| 553 | /// Link over a selection across paragraphs does nothing, as OneNote 2010's does. |
| 554 | #[test] |
| 555 | fn link_over_paragraphs_does_nothing() { |
| 556 | let mut engine = TextEngine::default(); |
| 557 | let engine = &mut engine; |
| 558 | let mut editor = CanvasEditor::new( |
| 559 | engine, |
| 560 | canvas::document::TextDocument::new(vec![ |
| 561 | onestore::page::Paragraph::new("One two".into(), Default::default()), |
| 562 | onestore::page::Paragraph::new("Three four".into(), Default::default()), |
| 563 | ]) |
| 564 | .unwrap(), |
| 565 | 400.0, |
| 566 | ) |
| 567 | .unwrap(); |
| 568 | let document = editor.active_outline().document().clone(); |
| 569 | editor |
| 570 | .select( |
| 571 | [ |
| 572 | TextPosition { |
| 573 | paragraph: 0, |
| 574 | offset: 4, |
| 575 | }, |
| 576 | TextPosition { |
| 577 | paragraph: 1, |
| 578 | offset: 5, |
| 579 | }, |
| 580 | ] |
| 581 | .into(), |
| 582 | ) |
| 583 | .unwrap(); |
| 584 | assert_eq!(editor.link_prefill(), None); |
| 585 | editor.set_link(engine, "", "https://example.com/").unwrap(); |
| 586 | assert_eq!(*editor.active_outline().document(), document); |
| 587 | assert!(editor.take_ops().unwrap().is_empty()); |
| 588 | } |
| 589 | |
| 590 | /// Deleting from inside one equation into the next joins them into one, as OneNote 2010 |
| 591 | /// joined "aa+bb" and "cc+dd" to "aa++dd" in the lab; the section it stores reopens in OneNote |
| 592 | /// (`corpus/equation-join`, exported to `CANVAS_EQUATION_JOIN_EXPORT`). |
| 593 | #[test] |
| 594 | fn deleting_between_equations_joins_them() { |
| 595 | let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); |
| 596 | let source = std::fs::read(root.join("corpus/link-edit/candidate/links.one")).unwrap(); |
| 597 | let (space, page) = pages(&source).remove(0); |
| 598 | let mut engine = TextEngine::default(); |
| 599 | let engine = &mut engine; |
| 600 | let mut editor = CanvasEditor::from_page(page, engine).unwrap(); |
| 601 | let body = editor |
| 602 | .outlines() |
| 603 | .iter() |
| 604 | .find(|outline| !outline.title) |
| 605 | .unwrap() |
| 606 | .id; |
| 607 | editor.focus_outline(body).unwrap(); |
| 608 | let first = line(&mut editor, engine); |
| 609 | editor.insert_equation(engine).unwrap(); |
| 610 | typed(&mut editor, engine, "aa+bb "); |
| 611 | let second = line(&mut editor, engine); |
| 612 | editor.insert_equation(engine).unwrap(); |
| 613 | typed(&mut editor, engine, "cc+dd "); |
| 614 | let text = |editor: &CanvasEditor, paragraph: usize| { |
| 615 | editor |
| 616 | .active_outline() |
| 617 | .document() |
| 618 | .paragraphs() |
| 619 | .nth(paragraph) |
| 620 | .unwrap() |
| 621 | .clone() |
| 622 | }; |
| 623 | let before = |editor: &CanvasEditor, paragraph: usize, needle: &str| { |
| 624 | let text = text(editor, paragraph); |
| 625 | text.utf16_offset(text.text().find(needle).unwrap()) |
| 626 | .unwrap() |
| 627 | }; |
| 628 | let (from, to) = (before(&editor, first, "𝑏𝑏"), before(&editor, second, "+𝑑")); |
| 629 | editor |
| 630 | .select( |
| 631 | [ |
| 632 | TextPosition { |
| 633 | paragraph: first, |
| 634 | offset: from, |
| 635 | }, |
| 636 | TextPosition { |
| 637 | paragraph: second, |
| 638 | offset: to, |
| 639 | }, |
| 640 | ] |
| 641 | .into(), |
| 642 | ) |
| 643 | .unwrap(); |
| 644 | editor.delete(engine, false).unwrap(); |
| 645 | let joined = text(&editor, first); |
| 646 | assert_eq!(joined.text().trim_end(), "𝑎𝑎++𝑑𝑑"); |
| 647 | assert!( |
| 648 | joined |
| 649 | .spans() |
| 650 | .iter() |
| 651 | .all(|span| span.format.math == Some(true)) |
| 652 | ); |
| 653 | let arena = Arena::default(); |
| 654 | let mut section = Section::open(&arena, source).unwrap(); |
| 655 | let ops = editor |
| 656 | .take_ops() |
| 657 | .unwrap() |
| 658 | .into_iter() |
| 659 | .map(|op| onestore::op::Op::Page { space, op }) |
| 660 | .collect(); |
| 661 | section |
| 662 | .apply( |
| 663 | "Author", |
| 664 | &onestore::op::Edit { |
| 665 | at: 134_000_000_000_000_000, |
| 666 | ops, |
| 667 | }, |
| 668 | ) |
| 669 | .unwrap(); |
| 670 | section.seal().unwrap(); |
| 671 | let written = section.image(); |
| 672 | let (_, stored) = pages(&written).remove(0); |
| 673 | let equations: Vec<String> = stored |
| 674 | .objects |
| 675 | .iter() |
| 676 | .filter_map(|object| match object { |
| 677 | PageObject::Outline(outline) if !outline.title => Some(outline), |
| 678 | _ => None, |
| 679 | }) |
| 680 | .flat_map(|outline| &outline.paragraphs) |
| 681 | .filter_map(|paragraph| paragraph.text()) |
| 682 | .filter(|text| { |
| 683 | text.text |
| 684 | .spans() |
| 685 | .iter() |
| 686 | .any(|span| span.format.math == Some(true)) |
| 687 | }) |
| 688 | .map(|text| text.text.text().trim_end().to_owned()) |
| 689 | .collect(); |
| 690 | assert_eq!(equations, ["𝑎𝑎++𝑑𝑑"]); |
| 691 | if let Some(directory) = std::env::var_os("CANVAS_EQUATION_JOIN_EXPORT") { |
| 692 | let directory = std::path::PathBuf::from(directory); |
| 693 | std::fs::create_dir(&directory).unwrap(); |
| 694 | std::fs::write(directory.join("links.one"), &written).unwrap(); |
| 695 | let file_id = Store::parse(&written).unwrap().header.file_id; |
| 696 | std::fs::write( |
| 697 | directory.join("Open Notebook.onetoc2"), |
| 698 | onestore::create_table_of_contents("Open Notebook.onetoc2", &[("links.one", file_id)]) |
| 699 | .unwrap(), |
| 700 | ) |
| 701 | .unwrap(); |
| 702 | } |
| 703 | } |
| 704 | |
| 705 | /// Alt+= over a selection across paragraphs makes each paragraph's part an equation of its |
| 706 | /// own, as OneNote 2010 does, and one undo takes them back. |
| 707 | #[test] |
| 708 | fn equation_over_paragraphs_makes_one_each() { |
| 709 | let mut engine = TextEngine::default(); |
| 710 | let engine = &mut engine; |
| 711 | let mut editor = CanvasEditor::new( |
| 712 | engine, |
| 713 | canvas::document::TextDocument::new(vec![ |
| 714 | onestore::page::Paragraph::new("One two".into(), Default::default()), |
| 715 | onestore::page::Paragraph::new("Three four".into(), Default::default()), |
| 716 | ]) |
| 717 | .unwrap(), |
| 718 | 400.0, |
| 719 | ) |
| 720 | .unwrap(); |
| 721 | let document = editor.active_outline().document().clone(); |
| 722 | editor |
| 723 | .select( |
| 724 | [ |
| 725 | TextPosition { |
| 726 | paragraph: 0, |
| 727 | offset: 4, |
| 728 | }, |
| 729 | TextPosition { |
| 730 | paragraph: 1, |
| 731 | offset: 5, |
| 732 | }, |
| 733 | ] |
| 734 | .into(), |
| 735 | ) |
| 736 | .unwrap(); |
| 737 | editor.insert_equation(engine).unwrap(); |
| 738 | let math: Vec<Vec<(String, bool)>> = editor |
| 739 | .active_outline() |
| 740 | .document() |
| 741 | .paragraphs() |
| 742 | .map(|text| { |
| 743 | let mut start = 0; |
| 744 | text.spans() |
| 745 | .iter() |
| 746 | .map(|span| { |
| 747 | let part = text.text()[start..span.end].to_owned(); |
| 748 | start = span.end; |
| 749 | (part, span.format.math == Some(true)) |
| 750 | }) |
| 751 | .collect() |
| 752 | }) |
| 753 | .collect(); |
| 754 | assert_eq!( |
| 755 | math, |
| 756 | [ |
| 757 | vec![("One ".to_owned(), false), ("𝑡𝑤𝑜".to_owned(), true)], |
| 758 | vec![("𝑇ℎ𝑟𝑒𝑒".to_owned(), true), (" four".to_owned(), false)], |
| 759 | ] |
| 760 | ); |
| 761 | assert!(editor.undo(engine).unwrap()); |
| 762 | assert_eq!(*editor.active_outline().document(), document); |
| 763 | } |
| 764 | |
| 765 | /// An equation among text draws in its line, which grows to hold it, and URL text shows as a |
| 766 | /// link without being stored as one. |
| 767 | #[test] |
| 768 | fn equations_draw_inline_and_url_text_shows_as_a_link() { |
| 769 | let mut engine = TextEngine::default(); |
| 770 | let engine = &mut engine; |
| 771 | let mut editor = CanvasEditor::new( |
| 772 | engine, |
| 773 | canvas::document::TextDocument::new(vec![onestore::page::Paragraph::new( |
| 774 | String::new(), |
| 775 | Default::default(), |
| 776 | )]) |
| 777 | .unwrap(), |
| 778 | 400.0, |
| 779 | ) |
| 780 | .unwrap(); |
| 781 | typed(&mut editor, engine, "Area "); |
| 782 | editor.insert_equation(engine).unwrap(); |
| 783 | typed(&mut editor, engine, "(a+b)/(c+d) "); |
| 784 | let outline = editor.active_outline(); |
| 785 | let layout = outline.paragraph_layout(0).unwrap(); |
| 786 | assert_eq!(layout.math.len(), 1); |
| 787 | let spaces: Vec<_> = layout.text.spaces().collect(); |
| 788 | let [(0, [x, baseline])] = spaces[..] else { |
| 789 | panic!("{spaces:?}") |
| 790 | }; |
| 791 | let (_, line) = layout.text.lines().next().unwrap(); |
| 792 | assert!(x > 20.0, "the equation follows the text"); |
| 793 | assert!(line.height >= layout.math[0].size[1]); |
| 794 | assert!(baseline - line.top >= layout.math[0].baseline); |
| 795 | |
| 796 | let url = onestore::page::Paragraph::new("Gone ftp://h.example/f z".into(), Default::default()); |
| 797 | assert_eq!(canvas::editor::shown_urls(&url), vec![5..22]); |
| 798 | assert!(url.spans()[0].format.hyperlink.is_none()); |
| 799 | } |
| 800 | |
| 801 | /// Selections reaching into equation objects, and Enter in a placeholder and in a limit, act |
| 802 | /// as OneNote 2010's equation editor acted on the same page (`corpus/equation-select`): an |
| 803 | /// end inside an object the other end is outside takes the whole object, and across |
| 804 | /// paragraphs the end of the paragraph with it; a placeholder is taken whole; Enter inside a |
| 805 | /// limit makes its argument an equation array. Each action saves as one revision. |
| 806 | #[test] |
| 807 | fn selections_into_equation_objects_take_them_whole() { |
| 808 | let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); |
| 809 | let source = |
| 810 | std::fs::read(root.join("corpus/math-edit/native-editor-4/notebook/links.one")).unwrap(); |
| 811 | let (space, page) = pages(&source).remove(0); |
| 812 | let mut engine = TextEngine::default(); |
| 813 | let engine = &mut engine; |
| 814 | let mut editor = CanvasEditor::from_page(page, engine).unwrap(); |
| 815 | let body = editor |
| 816 | .outlines() |
| 817 | .iter() |
| 818 | .find(|outline| !outline.title) |
| 819 | .unwrap() |
| 820 | .id; |
| 821 | editor.focus_outline(body).unwrap(); |
| 822 | let texts = |editor: &CanvasEditor| -> Vec<String> { |
| 823 | editor |
| 824 | .active_outline() |
| 825 | .document() |
| 826 | .paragraphs() |
| 827 | .map(|text| text.text().to_owned()) |
| 828 | .collect() |
| 829 | }; |
| 830 | let original = texts(&editor); |
| 831 | let find = |editor: &CanvasEditor, needle: &str| { |
| 832 | let paragraph = texts(editor) |
| 833 | .iter() |
| 834 | .position(|text| text.contains(needle)) |
| 835 | .unwrap(); |
| 836 | let text = editor |
| 837 | .active_outline() |
| 838 | .document() |
| 839 | .paragraphs() |
| 840 | .nth(paragraph) |
| 841 | .unwrap() |
| 842 | .clone(); |
| 843 | TextPosition { |
| 844 | paragraph, |
| 845 | offset: text |
| 846 | .utf16_offset(text.text().find(needle).unwrap()) |
| 847 | .unwrap(), |
| 848 | } |
| 849 | }; |
| 850 | let arena = Arena::default(); |
| 851 | let mut section = Section::open(&arena, source).unwrap(); |
| 852 | let mut save = |editor: &mut CanvasEditor| { |
| 853 | let ops = editor |
| 854 | .take_ops() |
| 855 | .unwrap() |
| 856 | .into_iter() |
| 857 | .map(|op| onestore::op::Op::Page { space, op }) |
| 858 | .collect(); |
| 859 | section |
| 860 | .apply( |
| 861 | "Author", |
| 862 | &onestore::op::Edit { |
| 863 | at: 134_000_000_000_000_000, |
| 864 | ops, |
| 865 | }, |
| 866 | ) |
| 867 | .unwrap(); |
| 868 | section.seal().unwrap().unwrap(); |
| 869 | }; |
| 870 | let delete = |editor: &mut CanvasEditor, engine: &mut TextEngine, from, to| { |
| 871 | editor.select([from, to].into()).unwrap(); |
| 872 | assert!(editor.delete(engine, true).unwrap()); |
| 873 | }; |
| 874 | |
| 875 | // Enter in "Type equation here." leaves the placeholder's paragraph empty above a new one. |
| 876 | let placeholder = find(&editor, "equation here"); |
| 877 | select( |
| 878 | &mut editor, |
| 879 | placeholder.paragraph, |
| 880 | placeholder.offset..placeholder.offset, |
| 881 | ); |
| 882 | editor.enter(engine, false).unwrap(); |
| 883 | save(&mut editor); |
| 884 | let after = texts(&editor); |
| 885 | assert_eq!( |
| 886 | after[placeholder.paragraph..=placeholder.paragraph + 1], |
| 887 | ["", ""] |
| 888 | ); |
| 889 | assert_eq!(caret(&editor).paragraph, placeholder.paragraph + 1); |
| 890 | |
| 891 | // From inside a square root into a cube root: both paragraphs go whole. |
| 892 | let (from, to) = (find(&editor, "\u{fdef}+1"), find(&editor, "3\u{fdee}𝑥")); |
| 893 | let to = TextPosition { |
| 894 | offset: to.offset + 4, |
| 895 | ..to |
| 896 | }; |
| 897 | delete(&mut editor, engine, from, to); |
| 898 | save(&mut editor); |
| 899 | let mut expected = after.clone(); |
| 900 | expected.drain(from.paragraph..=to.paragraph); |
| 901 | assert_eq!(texts(&editor), expected); |
| 902 | |
| 903 | // From after a₁ into a sum: the sum goes whole with its paragraph's end, joining the |
| 904 | // integral below it. |
| 905 | let from = find(&editor, "+\u{fdd0}𝑏"); |
| 906 | let to = find(&editor, "𝑖 \u{fdef}"); |
| 907 | let to = TextPosition { |
| 908 | offset: to.offset + 2, |
| 909 | ..to |
| 910 | }; |
| 911 | delete(&mut editor, engine, from, to); |
| 912 | save(&mut editor); |
| 913 | assert_eq!( |
| 914 | texts(&editor)[from.paragraph], |
| 915 | "\u{fdd0}𝑎\u{fdee}1\u{fdef}\u{fdd0}0\u{fdee}1\u{fdee}𝑥 𝑑𝑥 \u{fdef}" |
| 916 | ); |
| 917 | |
| 918 | // Enter inside a limit's argument makes it an equation array. |
| 919 | let limit = find(&editor, "∞"); |
| 920 | select(&mut editor, limit.paragraph, limit.offset..limit.offset); |
| 921 | editor.enter(engine, false).unwrap(); |
| 922 | save(&mut editor); |
| 923 | assert!(texts(&editor)[limit.paragraph].contains("𝑛→\u{fdee}∞\u{fdef}")); |
| 924 | |
| 925 | // Text around equations: from a fraction's numerator into a root, both taken whole. |
| 926 | let end = texts(&editor).len() - 1; |
| 927 | let offset = editor |
| 928 | .active_outline() |
| 929 | .document() |
| 930 | .paragraphs() |
| 931 | .nth(end) |
| 932 | .unwrap() |
| 933 | .utf16_offset(texts(&editor)[end].len()) |
| 934 | .unwrap(); |
| 935 | select(&mut editor, end, offset..offset); |
| 936 | for (before, linear, after) in [ |
| 937 | ("One ", "a/b+c ", " two"), |
| 938 | ("Three ", "\\sqrt x+y ", " four"), |
| 939 | ] { |
| 940 | typed(&mut editor, engine, before); |
| 941 | editor.insert_equation(engine).unwrap(); |
| 942 | typed(&mut editor, engine, linear); |
| 943 | editor |
| 944 | .move_selection(engine, draw::edit::Movement::Right, false) |
| 945 | .unwrap(); |
| 946 | typed(&mut editor, engine, after); |
| 947 | line(&mut editor, engine); |
| 948 | } |
| 949 | save(&mut editor); |
| 950 | let from = find(&editor, "𝑎\u{fdee}𝑏\u{fdef}+𝑐"); |
| 951 | let to = find(&editor, "𝑥\u{fdef}+𝑦"); |
| 952 | let (from, to) = ( |
| 953 | TextPosition { |
| 954 | offset: from.offset + 2, |
| 955 | ..from |
| 956 | }, |
| 957 | TextPosition { |
| 958 | offset: to.offset + 2, |
| 959 | ..to |
| 960 | }, |
| 961 | ); |
| 962 | delete(&mut editor, engine, from, to); |
| 963 | save(&mut editor); |
| 964 | let joined = &texts(&editor)[from.paragraph]; |
| 965 | assert!(joined.starts_with("One +𝑦"), "{joined:?}"); |
| 966 | assert!(joined.ends_with(" four"), "{joined:?}"); |
| 967 | |
| 968 | let written = section.image(); |
| 969 | let (_, stored) = pages(&written).remove(0); |
| 970 | let stored: Vec<String> = stored |
| 971 | .objects |
| 972 | .iter() |
| 973 | .filter_map(|object| match object { |
| 974 | PageObject::Outline(outline) if !outline.title => Some(outline), |
| 975 | _ => None, |
| 976 | }) |
| 977 | .flat_map(|outline| &outline.paragraphs) |
| 978 | .filter_map(|paragraph| paragraph.text()) |
| 979 | .map(|text| text.text.text().to_owned()) |
| 980 | .collect(); |
| 981 | assert_eq!(stored, texts(&editor)); |
| 982 | if let Some(directory) = std::env::var_os("CANVAS_EQUATION_SELECT_EXPORT") { |
| 983 | let directory = std::path::PathBuf::from(directory); |
| 984 | std::fs::create_dir(&directory).unwrap(); |
| 985 | std::fs::write(directory.join("links.one"), &written).unwrap(); |
| 986 | let file_id = Store::parse(&written).unwrap().header.file_id; |
| 987 | std::fs::write( |
| 988 | directory.join("Open Notebook.onetoc2"), |
| 989 | onestore::create_table_of_contents("Open Notebook.onetoc2", &[("links.one", file_id)]) |
| 990 | .unwrap(), |
| 991 | ) |
| 992 | .unwrap(); |
| 993 | } |
| 994 | while editor.undo(engine).unwrap() {} |
| 995 | assert_eq!(texts(&editor), original); |
| 996 | } |