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
7use canvas::{document::TextPosition, editor::CanvasEditor, layout::TextEngine};
8use onestore::{
9 Arena, ExGuid, RevisionIndex, Section, Store,
10 document::Document,
11 page::{
12 Math, Page, PageObject,
13 link::{LinkTarget, internal_link},
14 },
15};
16use std::path::Path;
17
18const BASE_PATH: &str = r"C:\one-tests\runs\capture\notebook\links.one";
19
20fn 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.
33fn 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
44fn caret(editor: &CanvasEditor) -> TextPosition {
45 editor.selection().positions[1]
46}
47
48/// End, then Enter, returning the new paragraph.
49fn 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
65fn 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]
84fn 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.
397type Runs = Vec<(String, bool, bool, bool, bool, Option<(u32, Option<u32>)>)>;
398
399fn 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]
428fn 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]
510fn 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]
555fn 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]
594fn 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]
708fn 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]
768fn 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]
807fn 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}