Read Markdown outlines as Markdown (#211)

File ▸ Open's Outline filter accepts `.md`, but a `.md` was read with the
plain outline format (unindented line = slide, indent = bullet), so a
Markdown outline written the usual way kept `#` in its titles and made
every top-level list item a slide of its own.

`deckcraft_format::markdown_to_slides` reads ATX headings (`#`–`######`,
closing `#`s dropped) as slide titles, list items (`-`, `*`, `+`, `1.`,
`1)`) as bullets nested by their indentation, and other lines under a
heading as body paragraphs. A `.md` without headings is still read as a
plain outline, so existing indented `.md` outlines open as before; `.txt`
is unchanged. Opening a `.md` and dropping one on the window
(`slide.fromOutline {markdown: true}`) use it. Both readers now share the
slide builder.

Tests: the outline from the issue (2 slides, nested bullets), numbered and
`*`/`+` lists with text under a heading, `slide.fromOutline` with
`markdown`, plain `.txt`/`.md` outlines unchanged, and heading / list-item
edge cases (not-a-heading forms, closing hashes, Unicode, long digit runs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
lloyd
2026-10-12 14:22:47 +02:00
co-authored by Claude Opus 5.5
parent 0ec0cc5e81
commit 9c64754f20
6 changed files with 183 additions and 31 deletions
+5 -1
View File
@@ -68,7 +68,11 @@ pub fn open_presentation(name: &str, bytes: &[u8]) -> Result<Presentation> {
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);
}
+12 -3
View File
@@ -47,7 +47,15 @@ pub fn specs() -> Vec<CommandSpec> {
),
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<Value> {
fn from_outline(s: &mut Session, p: &Value) -> Result<Value> {
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() {
+55
View File
@@ -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"));
}
+105 -25
View File
@@ -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<deckcraft_model::Slide> = None;
let flush = |p: &mut Presentation, s: Option<deckcraft_model::Slide>, 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:?}");
}
}
}
+5 -1
View File
@@ -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!({}));
}
+1 -1
View File
@@ -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 |