diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 55aac8bd..2a2fed78 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -68,7 +68,11 @@ pub fn open_presentation(name: &str, bytes: &[u8]) -> Result { if lower.ends_with(".txt") || lower.ends_with(".md") { let text = String::from_utf8_lossy(bytes); let mut p = defaults::blank_presentation(defaults::WIDE, Default::default(), false); - deckcraft_format::outline_to_slides(&mut p, &text); + if lower.ends_with(".md") { + deckcraft_format::markdown_to_slides(&mut p, &text); + } else { + deckcraft_format::outline_to_slides(&mut p, &text); + } if p.slides.is_empty() { p = defaults::new_presentation(None); } diff --git a/crates/engine/src/cmd/slide.rs b/crates/engine/src/cmd/slide.rs index 3d8ad344..df9accd8 100644 --- a/crates/engine/src/cmd/slide.rs +++ b/crates/engine/src/cmd/slide.rs @@ -47,7 +47,15 @@ pub fn specs() -> Vec { ), cmd!("section.removeAll", "Remove All Sections", ["Home", "Slides", "Section"], None, "{}", has_slide, section_remove_all), cmd!("section.move", "Move Section", [], None, "{index, to}", has_slide, section_move), - cmd!("slide.fromOutline", "Slides from Outline…", ["Insert", "Slides From"], None, "{text}", has_doc, from_outline), + cmd!( + "slide.fromOutline", + "Slides from Outline…", + ["Insert", "Slides From"], + None, + "{text, markdown?: bool (headings are slides, list items bullets)}", + has_doc, + from_outline + ), ] } @@ -572,10 +580,11 @@ fn section_move(s: &mut Session, p: &Value) -> Result { fn from_outline(s: &mut Session, p: &Value) -> Result { let text = str_param(p, "text").unwrap_or("").to_string(); + let markdown = bool_param(p, "markdown").unwrap_or(false); s.edit(|doc, sel| { let before = doc.slides.len(); - let n = deckcraft_format::outline_to_slides(doc, &text); - // outline_to_slides appends; move them after the current slide. + let n = if markdown { deckcraft_format::markdown_to_slides(doc, &text) } else { deckcraft_format::outline_to_slides(doc, &text) }; + // both append; move them after the current slide. let at = (sel.slide + 1).min(before); let new: Vec<_> = doc.slides.drain(before..).collect(); for (k, sl) in new.into_iter().enumerate() { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9aefd0b7..3864a34e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -543,3 +543,58 @@ fn inserted_hyperlinks_look_like_links() { s.execute("format.underline", &json!({})).unwrap(); assert_eq!(s.execute("format.state", &json!({})).unwrap()["underline"], false); } + +/// Each slide as (title, [(level, bullet text)]). +fn outline_of(p: &deckcraft_model::Presentation) -> Vec<(String, Vec<(u8, String)>)> { + p.slides + .iter() + .map(|s| { + let body = + s.shapes.get(1).and_then(|b| b.text.as_ref()).map(|t| t.paragraphs.iter().map(|p| (p.level, p.text())).collect()).unwrap_or_default(); + (s.title(), body) + }) + .collect() +} + +const MD_OUTLINE: &str = "# Intro\n- Point one\n- Point two\n - Sub point\n\n# Second\n- More\n"; + +fn md_expected() -> Vec<(String, Vec<(u8, String)>)> { + vec![ + ("Intro".into(), vec![(0, "Point one".into()), (0, "Point two".into()), (1, "Sub point".into())]), + ("Second".into(), vec![(0, "More".into())]), + ] +} + +#[test] +fn markdown_outlines_take_headings_as_slides_and_lists_as_bullets() { + let p = crate::cmd::file::open_presentation("outline.md", MD_OUTLINE.as_bytes()).unwrap(); + assert_eq!(outline_of(&p), md_expected()); + // numbered lists, `*` / `+` bullets, text under a heading, and closing #s + let p = crate::cmd::file::open_presentation("o.md", b"## Plan ##\n1. First\n2) Second\n* star\n+ plus\nA plain line\n").unwrap(); + assert_eq!( + outline_of(&p), + vec![("Plan".into(), vec![(0, "First".into()), (0, "Second".into()), (0, "star".into()), (0, "plus".into()), (0, "A plain line".into())])] + ); +} + +#[test] +fn slides_from_a_markdown_outline_file() { + let mut s = session(); + s.execute("slide.fromOutline", &json!({"text": MD_OUTLINE, "markdown": true})).unwrap(); + let doc = &s.doc().unwrap().doc; + assert_eq!(outline_of(doc)[1..], md_expected()[..]); +} + +#[test] +fn plain_outlines_are_unchanged() { + // .txt keeps the indentation format, and so does a .md without headings + let indented = "Intro\n\tPoint one\n\t\tSub point\nSecond\n\tMore\n"; + let want = + vec![("Intro".to_string(), vec![(0, "Point one".to_string()), (1, "Sub point".to_string())]), ("Second".into(), vec![(0, "More".into())])]; + for name in ["outline.txt", "outline.md"] { + let p = crate::cmd::file::open_presentation(name, indented.as_bytes()).unwrap(); + assert_eq!(outline_of(&p), want, "{name}"); + } + let p = crate::cmd::file::open_presentation("outline.txt", MD_OUTLINE.as_bytes()).unwrap(); + assert_eq!(p.slides.first().map(|s| s.title()).as_deref(), Some("# Intro")); +} diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 1b37a232..47357399 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -147,18 +147,35 @@ pub fn repair(p: &mut Presentation) { p.slide_size.height = p.slide_size.height.min(4000.0); } +/// Slides read from an outline: each title with its body paragraphs (level 0–8, text). +type OutlineSlides = Vec<(String, Vec<(u8, String)>)>; + +/// Append a Title and Content slide per outline entry; the number made. +fn add_outline_slides(p: &mut Presentation, slides: OutlineSlides) -> usize { + let Some(lay) = defaults::layout_of_kind(p, LayoutType::TitleAndContent) else { return 0 }; + let made = slides.len(); + for (title, body) in slides { + let mut s = defaults::new_slide(p, lay); + if let Some(t) = s.shapes.first_mut() { + t.text = Some(TextBody::from_text(&title)); + } + if let Some(b) = s.shapes.get_mut(1) { + for (level, line) in body { + let tb = b.text.get_or_insert_with(TextBody::default); + let mut para = Paragraph::new(&line); + para.level = level.min(8); + tb.paragraphs.push(para); + } + } + p.slides.push(Arc::new(s)); + } + made +} + /// Outline text: one slide per unindented line; tab-indented lines become body bullets at their /// level (Insert ▸ Slides from Outline, View ▸ Outline copy). pub fn outline_to_slides(p: &mut Presentation, text: &str) -> usize { - let Some(lay) = defaults::layout_of_kind(p, LayoutType::TitleAndContent) else { return 0 }; - let mut made = 0; - let mut cur: Option = None; - let flush = |p: &mut Presentation, s: Option, made: &mut usize| { - if let Some(s) = s { - p.slides.push(Arc::new(s)); - *made += 1; - } - }; + let mut slides: OutlineSlides = Vec::new(); for raw in text.lines() { if raw.trim().is_empty() { continue; @@ -166,25 +183,69 @@ pub fn outline_to_slides(p: &mut Presentation, text: &str) -> usize { let tabs = raw.chars().take_while(|c| *c == '\t').count(); let spaces = raw.chars().take_while(|c| *c == ' ').count() / 2; let level = tabs.max(spaces); - let line = raw.trim().trim_start_matches(['-', '*', '•']).trim(); + let line = raw.trim().trim_start_matches(['-', '*', '•']).trim().to_string(); if level == 0 { - flush(p, cur.take(), &mut made); - let mut s = defaults::new_slide(p, lay); - if let Some(t) = s.shapes.first_mut() { - t.text = Some(TextBody::from_text(line)); - } - cur = Some(s); - } else if let Some(s) = cur.as_mut() - && let Some(body) = s.shapes.get_mut(1) - { - let tb = body.text.get_or_insert_with(TextBody::default); - let mut para = Paragraph::new(line); - para.level = (level - 1).min(8) as u8; - tb.paragraphs.push(para); + slides.push((line, Vec::new())); + } else if let Some((_, body)) = slides.last_mut() { + body.push(((level - 1).min(8) as u8, line)); } } - flush(p, cur, &mut made); - made + add_outline_slides(p, slides) +} + +/// An ATX heading's text (CommonMark: up to three spaces, one to six `#`, then a space or the end; +/// a closing run of `#` is dropped). +fn md_heading(line: &str) -> Option<&str> { + let t = line.strip_prefix(" ").or_else(|| line.strip_prefix(" ")).or_else(|| line.strip_prefix(' ')).unwrap_or(line); + let hashes = t.chars().take_while(|c| *c == '#').count(); + let rest = t.get(hashes..)?; + if !(1..=6).contains(&hashes) || !(rest.is_empty() || rest.starts_with([' ', '\t'])) { + return None; + } + let rest = rest.trim(); + let closed = rest.trim_end_matches('#'); + Some(if closed.is_empty() || closed.ends_with([' ', '\t']) { closed.trim_end() } else { rest }) +} + +/// A list item's nesting level (two spaces or a tab each) and text: `-`, `*` or `+`, or digits +/// then `.` or `)`, followed by a space. +fn md_list_item(line: &str) -> Option<(u8, &str)> { + let indent: usize = line.chars().take_while(|c| matches!(c, ' ' | '\t')).map(|c| if c == '\t' { 2 } else { 1 }).sum(); + let t = line.trim_start(); + let rest = match t.strip_prefix(['-', '*', '+']) { + Some(r) => r, + None => { + let digits = t.chars().take_while(char::is_ascii_digit).count(); + if digits == 0 || digits > 9 { + return None; + } + t.get(digits..)?.strip_prefix(['.', ')'])? + } + }; + rest.starts_with([' ', '\t']).then(|| ((indent / 2).min(8) as u8, rest.trim())) +} + +/// Markdown outline text (`.md`): each heading starts a slide with its text as the title; list +/// items under it become bullets nested by their indentation, other lines body paragraphs. Text +/// before the first heading is skipped, as indented lines before the first title are in the +/// plain format. A file without headings is read as a plain outline ([`outline_to_slides`]). +pub fn markdown_to_slides(p: &mut Presentation, text: &str) -> usize { + if !text.lines().any(|l| md_heading(l).is_some()) { + return outline_to_slides(p, text); + } + let mut slides: OutlineSlides = Vec::new(); + for line in text.lines() { + if line.trim().is_empty() { + continue; + } + if let Some(title) = md_heading(line) { + slides.push((title.to_string(), Vec::new())); + } else if let Some((_, body)) = slides.last_mut() { + let (level, text) = md_list_item(line).unwrap_or((0, line.trim())); + body.push((level, text.to_string())); + } + } + add_outline_slides(p, slides) } /// The deck as outline text (titles unindented, body paragraphs tab-indented by level). @@ -259,4 +320,23 @@ mod tests { let o = slides_to_outline(&p); assert!(o.contains("Intro\n\tPoint one\n\t\tSub point\nSecond"), "{o}"); } + + #[test] + fn markdown_headings_and_list_items() { + assert_eq!(md_heading("# Intro"), Some("Intro")); + assert_eq!(md_heading(" ### Deep ### "), Some("Deep")); + assert_eq!(md_heading("## C# and F#"), Some("C# and F#")); + assert_eq!(md_heading("#"), Some("")); + assert_eq!(md_heading("## Ünïcödé"), Some("Ünïcödé")); + for not in ["#5 priority", "####### seven", " # code", "text", "", "é#"] { + assert_eq!(md_heading(not), None, "{not:?}"); + } + assert_eq!(md_list_item("- a"), Some((0, "a"))); + assert_eq!(md_list_item(" * b"), Some((2, "b"))); + assert_eq!(md_list_item("\t+ c"), Some((1, "c"))); + assert_eq!(md_list_item("12) d"), Some((0, "d"))); + for not in ["-x", "1.x", "1", "1234567890. long", "• bullet", "é. x", ""] { + assert_eq!(md_list_item(not), None, "{not:?}"); + } + } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bef64a4f..24e2006f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -519,7 +519,11 @@ impl SlideApp { self.set_status(format!("{name}: DeckCraft can't open this kind of file")); return; }; - let p = if cmd == "slide.fromOutline" { json!({"text": String::from_utf8_lossy(bytes)}) } else { json!({"name": name, "data": data}) }; + let p = if cmd == "slide.fromOutline" { + json!({"text": String::from_utf8_lossy(bytes), "markdown": lower.ends_with(".md")}) + } else { + json!({"name": name, "data": data}) + }; if self.session.active().is_none() && cmd != "file.openBytes" { let _ = self.session.execute("file.new", &json!({})); } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 1d87c9ac..8cca7d6a 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -25,7 +25,7 @@ PowerPoint opens and saves; Viewer = opens only). | `.thmx` Office theme | Viewer | no | no | Browse for themes is a P2 row | none | | `.deckcraft` native (zip + JSON) | n/a | yes (also `.slidecraft`) | yes | Lossless for the DeckCraft model, forward compatible | `crates/format` tests | | PDF | export | no | yes | Slides, notes pages, handouts (1–9); real-text layer with embedded subsets, links, bookmarks. Slide artwork is a raster image at print resolution, not vector | `crates/pdf` tests | -| Outline `.rtf` / `.txt` | export (RTF), import (outline) | `.txt`/`.md` outline (`slide.fromOutline`) | `.txt` outline | No RTF | engine tests | +| Outline `.rtf` / `.txt` | export (RTF), import (outline) | `.txt` outline; `.md` as Markdown (headings are slides, list items bullets) (`slide.fromOutline`) | `.txt` outline | No RTF | engine tests | | PNG / JPEG slide images | export | n/a | yes (`file.export`, `deckcraft-cli render`) | Matches the editor's renderer | render tests | | TIFF / BMP / GIF slide images | export | n/a | no | Easy (the `image` crate writes them) | none | | Animated GIF of the deck | export | n/a | no | | none |