mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!({}));
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user