1//! Markdown shortcuts: Markdown typed at a paragraph's start or around text becomes the
2//! OneNote formatting it stands for, as one undo step after the typing, which Undo takes back
3//! to the characters typed, as Word's AutoFormat As You Type does.
4
5use super::format::{ListStyle, NoteTag, list_definition, styled, time32};
6use super::*;
7
8/// What a marker typed at a paragraph's start makes of it.
9#[derive(Debug, PartialEq)]
10enum Block {
11 /// A style of the Styles gallery, by stored name.
12 Style(String),
13 /// A list, its count restarting at the number given.
14 List(ListStyle, Option<u32>),
15 /// A To Do check box, checked or not.
16 ToDo(bool),
17}
18
19impl Block {
20 /// The lists are those OneNote 2010's AutoFormat makes of the same markers, but for `>`,
21 /// which it makes an arrow bullet.
22 fn of(marker: &str) -> Option<Self> {
23 Some(match marker {
24 "*" => Self::List(ListStyle::BULLET, None),
25 "-" => Self::List(ListStyle::Bullet(25), None),
26 "a." => Self::List(ListStyle::Number(2), None),
27 ">" => Self::Style("blockquote".into()),
28 "[ ]" => Self::ToDo(false),
29 "[x]" | "[X]" => Self::ToDo(true),
30 _ if marker.len() <= 6 && !marker.is_empty() && marker.bytes().all(|b| b == b'#') => {
31 Self::Style(format!("h{}", marker.len()))
32 }
33 _ => {
34 let (digits, style) = match marker.as_bytes().last()? {
35 b'.' => (&marker[..marker.len() - 1], ListStyle::NUMBER),
36 b')' => (&marker[..marker.len() - 1], ListStyle::Number(6)),
37 _ => return None,
38 };
39 if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
40 return None;
41 }
42 let number = digits.parse::<u32>().ok()?;
43 Self::List(style, (number != 1).then_some(number))
44 }
45 })
46 }
47}
48
49#[derive(Clone, Copy)]
50enum Inline {
51 Bold,
52 Italic,
53 Strike,
54 Code,
55}
56
57/// Markers typed around text, a pair before its half.
58const INLINE: [(&str, Inline); 5] = [
59 ("**", Inline::Bold),
60 ("~~", Inline::Strike),
61 ("*", Inline::Italic),
62 ("_", Inline::Italic),
63 ("`", Inline::Code),
64];
65
66/// Whether no character of bytes `range` belongs to a link, field or equation, or is set in
67/// the Code style's font.
68fn plain(text: &Paragraph, range: Range<usize>, code_font: Option<&str>) -> bool {
69 let mut start = 0;
70 text.spans().iter().all(|span| {
71 let overlaps = start < range.end && span.end > range.start;
72 start = span.end;
73 let format = &span.format;
74 !overlaps
75 || ![
76 format.hidden,
77 format.hyperlink,
78 format.math,
79 format.embedded_object,
80 ]
81 .contains(&Some(true))
82 && (code_font.is_none() || format.font.as_deref() != code_font)
83 })
84}
85
86/// `text` without bytes `markers`, what lies between them changed by `change`; a paragraph
87/// left empty keeps the format of the first marker.
88fn unmarked(
89 text: &Paragraph,
90 markers: [Range<usize>; 2],
91 change: impl Fn(&mut Format),
92) -> Paragraph {
93 let [open, close] = markers;
94 let kept = [
95 (0..open.start, false),
96 (open.end..close.start, true),
97 (close.end..text.text().len(), false),
98 ];
99 let mut runs = Vec::new();
100 let mut start = 0;
101 for span in text.spans() {
102 for (range, inside) in &kept {
103 let [from, to] = [range.start, range.end].map(|at| at.clamp(start, span.end));
104 if from < to {
105 let mut format = span.format.clone();
106 if *inside {
107 change(&mut format);
108 }
109 runs.push((text.text()[from..to].to_owned(), format));
110 }
111 }
112 start = span.end;
113 }
114 if runs.is_empty() {
115 let first = text.spans().partition_point(|span| span.end <= open.start);
116 let format = text.spans()[first.min(text.spans().len() - 1)]
117 .format
118 .clone();
119 return Paragraph::new(String::new(), format);
120 }
121 Paragraph::from_runs(runs)
122}
123
124impl CanvasEditor {
125 /// The Styles gallery's style `name`: the page's theme's, else OneNote 2010's.
126 fn gallery(&self, name: &str) -> Option<Definition> {
127 self.styles
128 .get(name)
129 .or_else(|| self.markdown.as_ref()?.get(name))
130 .cloned()
131 }
132
133 /// Applies the Markdown shortcut that `typed`, just typed at the caret, completes, as its
134 /// own undo step; a marker at the paragraph's start can then be taken back by Backspace.
135 pub(super) fn markdown(
136 &mut self,
137 engine: &mut TextEngine,
138 typed: &str,
139 ) -> Result<(), EditorError> {
140 let outline = self.active_outline();
141 let [anchor, focus] = outline.selection.positions;
142 if self.markdown.is_none()
143 || !matches!(typed, " " | "*" | "_" | "~" | "`")
144 || outline.title
145 || anchor != focus
146 || self.page_selected()
147 {
148 return Ok(());
149 }
150 let (container, index, node) = outline
151 .document
152 .leaf(focus.paragraph)
153 .ok_or(EditError::InvalidRange)?;
154 let id = outline.id;
155 let mut node = node.clone();
156 if node
157 .style
158 .and_then(|style| self.definitions.get(&style))
159 .is_some_and(|style| matches!(&style.kind, Kind::Style { name: Some(name), .. } if name == "code"))
160 {
161 return Ok(());
162 }
163 let text = node.text().ok_or(EditError::InvalidRange)?.text.clone();
164 let caret = text.byte_offset(focus.offset)?;
165 let code_font = self.gallery("code").and_then(|code| code.format.font);
166 let code_font = code_font.as_deref();
167 let (replacement, at, block) = if typed == " " {
168 let Some(block) = text.text()[..caret].strip_suffix(' ').and_then(Block::of) else {
169 return Ok(());
170 };
171 if !plain(&text, 0..caret, code_font) {
172 return Ok(());
173 }
174 let rest = unmarked(&text, [0..caret, caret..caret], |_| {});
175 (rest, 0, Some(block))
176 } else {
177 let Some((rest, at)) = inline(&text, caret, code_font) else {
178 return Ok(());
179 };
180 (rest, at, None)
181 };
182 let typing = text.format_at(focus.offset)?.clone();
183 let text = &mut node.text_mut().unwrap().text;
184 *text = replacement;
185 let offset = text.utf16_offset(at)?;
186 let line = block.is_some();
187 match block {
188 Some(Block::Style(name)) => {
189 let Some(definition) = self.gallery(&name) else {
190 return Ok(());
191 };
192 let old = self.style_format(node.style)?;
193 node.style = Some(self.define_style(&definition)?);
194 let text = &mut node.text_mut().unwrap().text;
195 *text = styled(text, &old, &definition);
196 }
197 Some(Block::List(style, restart)) => {
198 let format = &node.text().unwrap().text.spans()[0].format;
199 let mut definition = list_definition(style, format);
200 if let Kind::List { restart: count, .. } = &mut definition.kind {
201 *count = restart;
202 }
203 let list = onestore::page::text::new_id()?;
204 self.definitions.insert(list, definition);
205 node.lists = vec![list];
206 }
207 // A check box takes a list's place, as To Do List does.
208 Some(Block::ToDo(checked)) => {
209 node.lists.clear();
210 let tag = &NoteTag::defaults()[0];
211 let definition = tag.definition(0);
212 let tag_id = self.define_tag(&definition)?;
213 let ParagraphContent::Text(text) = &mut node.content else {
214 unreachable!()
215 };
216 self.retag(
217 [&mut node.tags, &mut text.tags],
218 &definition.kind,
219 Some((tag_id, tag.shape, time32())),
220 );
221 if checked {
222 self.toggle_checks(node.tags.iter_mut().chain(&mut text.tags).collect());
223 }
224 }
225 None => {}
226 }
227 let caret = TextPosition {
228 paragraph: focus.paragraph,
229 offset,
230 };
231 self.commit(
232 engine,
233 DocumentEdit {
234 columns: BTreeMap::new(),
235 container,
236 range: index..index + 1,
237 replacement: vec![node],
238 },
239 Selection {
240 positions: [caret; 2],
241 affinities: [Affinity::Upstream; 2],
242 },
243 )?;
244 self.typing = None;
245 if line {
246 self.formatted = Some((id, caret, self.undo.len()));
247 } else {
248 // Typing goes on in the format the closing marker was typed in.
249 self.pending = Some((id, caret, typing));
250 }
251 Ok(())
252 }
253}
254
255/// `text` with the span whose closing marker ends at byte `caret` formatted and its markers
256/// gone, and where the caret goes then; none where no span closes there, or it would
257/// close inside a word, a URL, a link, an equation or code.
258fn inline(text: &Paragraph, caret: usize, code_font: Option<&str>) -> Option<(Paragraph, usize)> {
259 let source = text.text();
260 let before = &source[..caret];
261 let (marker, inline) = INLINE
262 .into_iter()
263 .find(|(marker, _)| before.ends_with(marker))?;
264 let edge = marker.chars().next()?;
265 let close = caret - marker.len();
266 let open = before[..close].rfind(marker)?;
267 let content = &before[open + marker.len()..close];
268 let word = |c: Option<char>| c.is_some_and(|c| c.is_alphanumeric() || c == edge);
269 if content.is_empty()
270 || content.starts_with(char::is_whitespace)
271 || content.ends_with(char::is_whitespace)
272 || content.starts_with(edge)
273 || content.ends_with(edge)
274 || word(before[..open].chars().next_back())
275 || source[caret..].chars().next().is_some_and(char::is_alphanumeric)
276 || !plain(text, open..caret, code_font)
277 // An odd backtick before the span opens code around it.
278 || !matches!(inline, Inline::Code) && before[..open].matches('`').count() % 2 == 1
279 {
280 return None;
281 }
282 let token = before[..open]
283 .rfind(char::is_whitespace)
284 .map_or(0, |space| space + 1);
285 let url = &before[token..];
286 if url.contains("://") || url.starts_with("www.") {
287 return None;
288 }
289 let font = code_font.unwrap_or("Consolas").to_owned();
290 let rest = unmarked(
291 text,
292 [open..open + marker.len(), close..caret],
293 |format| match inline {
294 Inline::Bold => format.bold = Some(true),
295 Inline::Italic => format.italic = Some(true),
296 Inline::Strike => format.strike = Some(true),
297 Inline::Code => format.font = Some(font.clone()),
298 },
299 );
300 Some((rest, caret - 2 * marker.len()))
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306
307 #[test]
308 fn markers_name_their_blocks() {
309 assert_eq!(Block::of("###"), Some(Block::Style("h3".into())));
310 assert_eq!(Block::of("#######"), None);
311 assert_eq!(
312 Block::of("2."),
313 Some(Block::List(ListStyle::NUMBER, Some(2)))
314 );
315 assert_eq!(
316 Block::of("1)"),
317 Some(Block::List(ListStyle::Number(6), None))
318 );
319 assert_eq!(Block::of("."), None);
320 assert_eq!(Block::of("1a."), None);
321 assert_eq!(Block::of("[x]"), Some(Block::ToDo(true)));
322 }
323
324 fn calibri() -> Format {
325 Format {
326 font: Some("Calibri".into()),
327 font_size: Some(11.0),
328 ..Format::default()
329 }
330 }
331
332 fn style(name: &str, font: &str) -> Definition {
333 Definition {
334 kind: Kind::Style {
335 name: Some(name.into()),
336 next: name.starts_with('h').then(|| "p".into()),
337 },
338 format: Format {
339 bold: Some(name.starts_with('h')),
340 font: Some(font.into()),
341 font_size: Some(14.0),
342 ..Format::default()
343 },
344 }
345 }
346
347 /// An editor with Markdown shortcuts on, its one paragraph holding `text`, the caret
348 /// at its end.
349 fn opened(engine: &mut TextEngine, text: &str) -> CanvasEditor {
350 let paragraph = Paragraph::new(text.into(), calibri());
351 let mut editor =
352 CanvasEditor::new(engine, TextDocument::new(vec![paragraph]).unwrap(), 300.0).unwrap();
353 let gallery = ["h1", "h2", "h3", "h4", "h5", "h6", "blockquote", "p"]
354 .map(|name| (name.to_owned(), style(name, "Calibri")));
355 editor.markdown = Some(
356 gallery
357 .into_iter()
358 .chain([("code".to_owned(), style("code", "Consolas"))])
359 .collect(),
360 );
361 let end = text.encode_utf16().count() as u32;
362 editor
363 .select(
364 [TextPosition {
365 paragraph: 0,
366 offset: end,
367 }; 2]
368 .into(),
369 )
370 .unwrap();
371 editor
372 }
373
374 fn typed(engine: &mut TextEngine, editor: &mut CanvasEditor, text: &str) {
375 for character in text.chars() {
376 editor
377 .insert(engine, character.encode_utf8(&mut [0; 4]))
378 .unwrap();
379 }
380 }
381
382 fn text(editor: &CanvasEditor) -> String {
383 editor
384 .active_outline()
385 .document
386 .paragraphs()
387 .next()
388 .unwrap()
389 .text()
390 .to_owned()
391 }
392
393 /// The paragraph's runs, with whether each is bold, italic, struck and in Consolas.
394 fn runs(editor: &CanvasEditor) -> Vec<(String, [bool; 4])> {
395 let paragraph = editor
396 .active_outline()
397 .document
398 .paragraphs()
399 .next()
400 .unwrap();
401 let mut start = 0;
402 paragraph
403 .spans()
404 .iter()
405 .map(|span| {
406 let text = paragraph.text()[start..span.end].to_owned();
407 start = span.end;
408 let format = &span.format;
409 let on = |value: Option<bool>| value == Some(true);
410 let code = format.font.as_deref() == Some("Consolas");
411 (
412 text,
413 [on(format.bold), on(format.italic), on(format.strike), code],
414 )
415 })
416 .collect()
417 }
418
419 #[test]
420 fn hashes_make_headings_that_undo_and_backspace_take_back_to_the_typed_text() {
421 let mut engine = TextEngine::default();
422 for level in 1..=6 {
423 let mut editor = opened(&mut engine, "");
424 typed(&mut engine, &mut editor, &format!("{} ", "#".repeat(level)));
425 assert_eq!(text(&editor), "");
426 let state = editor.format_state().unwrap();
427 assert_eq!(state.style, Some(format!("h{level}")));
428 typed(&mut engine, &mut editor, "Title");
429 assert_eq!(text(&editor), "Title");
430 assert_eq!(
431 runs(&editor),
432 [("Title".into(), [true, false, false, false])]
433 );
434 // Undo takes the typing, then the heading back to the marker.
435 editor.undo(&mut engine).unwrap();
436 assert_eq!(text(&editor), "");
437 editor.undo(&mut engine).unwrap();
438 assert_eq!(text(&editor), format!("{} ", "#".repeat(level)));
439 assert_eq!(editor.format_state().unwrap().style, None);
440 }
441 let mut editor = opened(&mut engine, "");
442 typed(&mut engine, &mut editor, "## ");
443 editor.delete(&mut engine, true).unwrap();
444 assert_eq!(text(&editor), "## ");
445 assert_eq!(editor.format_state().unwrap().style, None);
446 // A second Backspace deletes as ever.
447 editor.delete(&mut engine, true).unwrap();
448 assert_eq!(text(&editor), "##");
449 }
450
451 #[test]
452 fn markers_start_lists_to_dos_and_quotes_as_onenote_s_autoformat_does() {
453 let mut engine = TextEngine::default();
454 for (marker, list) in [
455 ("* ", ListStyle::BULLET),
456 ("- ", ListStyle::Bullet(25)),
457 ("1. ", ListStyle::NUMBER),
458 ("1) ", ListStyle::Number(6)),
459 ("a. ", ListStyle::Number(2)),
460 ] {
461 let mut editor = opened(&mut engine, "");
462 typed(&mut engine, &mut editor, marker);
463 assert_eq!(text(&editor), "", "{marker}");
464 assert_eq!(editor.format_state().unwrap().list, Some(list), "{marker}");
465 editor.delete(&mut engine, true).unwrap();
466 assert_eq!(text(&editor), marker);
467 assert_eq!(editor.format_state().unwrap().list, None);
468 }
469 let mut editor = opened(&mut engine, "");
470 typed(&mut engine, &mut editor, "3. ");
471 let node = &editor.active_outline().document.nodes()[0];
472 assert!(matches!(
473 editor.definitions[&node.lists[0]].kind,
474 Kind::List {
475 restart: Some(3),
476 ..
477 }
478 ));
479 for (marker, checked) in [("[ ] ", false), ("[x] ", true)] {
480 let mut editor = opened(&mut engine, "");
481 typed(&mut engine, &mut editor, marker);
482 assert_eq!(text(&editor), "");
483 let state = editor.format_state().unwrap();
484 assert_eq!(state.tags[0].0.label, "To Do");
485 let tag = &editor.active_outline().document.nodes()[0]
486 .text()
487 .unwrap()
488 .tags[0];
489 assert_eq!(tag.status & 1 != 0, checked);
490 }
491 let mut editor = opened(&mut engine, "");
492 typed(&mut engine, &mut editor, "> ");
493 assert_eq!(
494 editor.format_state().unwrap().style.as_deref(),
495 Some("blockquote")
496 );
497 // A marker before text already there formats it too.
498 let mut editor = opened(&mut engine, "words");
499 editor
500 .select(
501 [TextPosition {
502 paragraph: 0,
503 offset: 0,
504 }; 2]
505 .into(),
506 )
507 .unwrap();
508 typed(&mut engine, &mut editor, "# ");
509 assert_eq!(text(&editor), "words");
510 assert_eq!(editor.format_state().unwrap().style.as_deref(), Some("h1"));
511 }
512
513 #[test]
514 fn closing_markers_format_the_text_between_and_undo_returns_the_markers() {
515 let mut engine = TextEngine::default();
516 for (input, format) in [
517 ("**bold**", [true, false, false, false]),
518 ("*it*", [false, true, false, false]),
519 ("_it_", [false, true, false, false]),
520 ("~~gone~~", [false, false, true, false]),
521 ("`a*b*c`", [false, false, false, true]),
522 ] {
523 let mut editor = opened(&mut engine, "say ");
524 typed(&mut engine, &mut editor, input);
525 let inner = input.trim_matches(['*', '_', '~', '`']);
526 assert_eq!(
527 runs(&editor),
528 [("say ".into(), [false; 4]), (inner.into(), format),],
529 "{input}"
530 );
531 // What is typed next is plain.
532 typed(&mut engine, &mut editor, " on");
533 assert_eq!(runs(&editor).last().unwrap(), &(" on".into(), [false; 4]));
534 editor.undo(&mut engine).unwrap();
535 editor.undo(&mut engine).unwrap();
536 assert_eq!(text(&editor), format!("say {input}"), "{input}");
537 }
538 }
539
540 #[test]
541 fn markers_inside_words_urls_code_and_titles_or_with_shortcuts_off_stay_typed() {
542 let mut engine = TextEngine::default();
543 for input in [
544 "snake_case_name",
545 "2*3*4",
546 "a * b *",
547 "http://example.com/_a_",
548 "www.example.com/*a*",
549 "`a *b*",
550 "**",
551 "#tag ",
552 "x # ",
553 "####### ",
554 ] {
555 let mut editor = opened(&mut engine, "");
556 typed(&mut engine, &mut editor, input);
557 assert_eq!(text(&editor), input);
558 assert_eq!(runs(&editor), [(input.into(), [false; 4])], "{input}");
559 }
560 // A closing marker typed before a word's letters leaves them unformatted.
561 let mut editor = opened(&mut engine, "*a b");
562 editor
563 .select(
564 [TextPosition {
565 paragraph: 0,
566 offset: 3,
567 }; 2]
568 .into(),
569 )
570 .unwrap();
571 typed(&mut engine, &mut editor, "*");
572 assert_eq!(text(&editor), "*a *b");
573 let mut editor = opened(&mut engine, "");
574 editor.markdown = None;
575 typed(&mut engine, &mut editor, "# **a** ");
576 assert_eq!(text(&editor), "# **a** ");
577 // Code keeps what it holds.
578 let mut editor = opened(&mut engine, "");
579 let code = editor.gallery("code").unwrap();
580 editor.format(&mut engine, Formatting::Style(code)).unwrap();
581 typed(&mut engine, &mut editor, "# *a* ");
582 assert_eq!(text(&editor), "# *a* ");
583 }
584
585 /// Every shortcut stores ordinary OneNote formatting that reads back as itself.
586 /// `SNOWBOUND_MARKDOWN_EXPORT` names a new directory receiving the notebook, for a cold
587 /// open in OneNote.
588 #[test]
589 fn shortcuts_write_as_onenote_formatting() {
590 use onestore::{RevisionIndex, Store, document::Document};
591 const NOTEBOOK: &str = "../../corpus/paragraph-edit/before/notebook";
592 let section = std::fs::read(format!("{NOTEBOOK}/synthetic.one")).unwrap();
593 let page = |bytes: &[u8]| {
594 let store = Store::parse(bytes).unwrap();
595 let index = RevisionIndex::parse(&store).unwrap();
596 let document = Document::parse(&index).unwrap();
597 document
598 .pages()
599 .unwrap()
600 .into_iter()
601 .map(|(space, _)| (space, Page::from_space(&document, space).unwrap()))
602 .find(|(_, page)| page.title == "Split middle")
603 .unwrap()
604 };
605 let (space, source) = page(&section);
606 let mut engine = TextEngine::default();
607 let mut editor = CanvasEditor::from_page(source, &mut engine).unwrap();
608 editor.markdown = opened(&mut engine, "").markdown;
609 let body = editor
610 .outlines()
611 .iter()
612 .find(|outline| !outline.title)
613 .unwrap()
614 .id;
615 editor.focus_outline(body).unwrap();
616 editor
617 .move_selection(&mut engine, Movement::DocumentEnd, false)
618 .unwrap();
619 for line in [
620 "Plain **bold** *italic* _also_ ~~struck~~ `code` end",
621 "# Heading one",
622 "## Heading two",
623 "### Heading three",
624 "#### Heading four",
625 "##### Heading five",
626 "###### Heading six",
627 "* Star",
628 "- Dash",
629 "1. One",
630 "2) Two",
631 "a. Alpha",
632 "3. Three",
633 "[ ] To do",
634 "[x] Done",
635 "[X] Done too",
636 "> Quoted",
637 ] {
638 editor.enter(&mut engine, false).unwrap();
639 typed(&mut engine, &mut editor, line);
640 }
641 let edited = editor.page().unwrap();
642 let written = super::super::ops::saved(&section, space, &mut editor);
643 let (_, reread) = page(&written);
644 let settled = |mut page: Page| {
645 for object in &mut page.objects {
646 if let PageObject::Outline(outline) = object {
647 for text in outline.paragraphs.iter_mut().filter_map(|p| p.text_mut()) {
648 for tag in &mut text.tags {
649 tag.extra_set = 0;
650 }
651 }
652 }
653 }
654 page
655 };
656 assert_eq!(settled(reread).objects, settled(edited).objects);
657 if let Some(directory) = std::env::var_os("SNOWBOUND_MARKDOWN_EXPORT") {
658 let directory = std::path::Path::new(&directory);
659 std::fs::create_dir(directory).unwrap();
660 std::fs::write(directory.join("synthetic.one"), &written).unwrap();
661 std::fs::copy(
662 format!("{NOTEBOOK}/Open Notebook.onetoc2"),
663 directory.join("Open Notebook.onetoc2"),
664 )
665 .unwrap();
666 }
667 }
668}