mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-01 14:37:05 -06:00
Notes: speaker notes show their formatting and keep it when edited
Notes from a .pptx were read and written with their formatting, but the notes pane and Notes Page drew them as plain text (Slide::notes_text in a plain TextEdit), and any edit there saved the text back through slide.notes as TextBody::from_text, dropping every bold run, level, indent and bullet of the slide's notes. - ui-egui: a notes editor (notes.rs) shared by the notes pane and Notes Page. It draws bold, italic, underline, strikethrough and size from the runs, and each paragraph's margin, hanging indent and bullet or number (Wingdings/Symbol bullets mapped as on slides); paragraphs are laid out one by one so wrapped lines align with the text, not the bullet. Each edit is applied to a formatted draft, so typed text takes the formatting where it lands (Enter in a bulleted point starts another), and the draft is saved once the field loses focus or the slide changes. - engine: slide.notes takes `body` (formatted notes, as slide.inspect's new `notesBody`), and `text` now changes only what was retyped (model edit::replace_text); text sharing nothing with the old notes is still plain. - text: map_symbol_bullet is public for the notes editor. PDF notes pages still print the notes as plain text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
be14248577
commit
1ce5f686e8
@@ -10,7 +10,7 @@ use crate::{Result, Session};
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "document.inspect", "Inspect Presentation", [], None, "{} → slides with titles, layouts, shape counts; selection; size; theme", has_doc, document),
|
||||
cmd!(query "slide.inspect", "Inspect Slide", [], None, "{index?} → the slide's shapes with ids, kinds, boxes, text, fills, animations, transition, notes", has_doc, slide),
|
||||
cmd!(query "slide.inspect", "Inspect Slide", [], None, "{index?} → the slide's shapes with ids, kinds, boxes, text, fills, animations, transition, notes (text, and notesBody with its formatting)", has_doc, slide),
|
||||
cmd!(query "shape.inspect", "Inspect Shape", [], None, "{id} → full shape JSON plus its effective box", has_doc, shape),
|
||||
cmd!(query "commands.list", "List Commands", [], None, "{filter?: substring} → [{id, label, params, enabled}]", always, commands),
|
||||
]
|
||||
@@ -129,6 +129,7 @@ fn slide(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"transition": serde_json::to_value(&sl.transition).unwrap_or_default(),
|
||||
"animations": serde_json::to_value(&sl.animations).unwrap_or_default(),
|
||||
"notes": sl.notes_text(),
|
||||
"notesBody": sl.notes,
|
||||
"comments": sl.comments.len(),
|
||||
"hidden": sl.hidden,
|
||||
}))
|
||||
|
||||
@@ -32,7 +32,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("slide.hide", "Hide Slide", ["Slide Show", "Set Up"], None, "{index?, hidden?: bool}", has_slide, hide),
|
||||
cmd!("slide.layout", "Layout", ["Home", "Slides"], None, "{layout: name or kind, index?}", has_slide, set_layout),
|
||||
cmd!("slide.reset", "Reset", ["Home", "Slides"], None, "{index?}", has_slide, reset),
|
||||
cmd!("slide.notes", "Notes", ["View"], None, "{text, index?}", has_slide, notes),
|
||||
cmd!(
|
||||
"slide.notes",
|
||||
"Notes",
|
||||
["View"],
|
||||
None,
|
||||
"{text | body: formatted notes (as in slide.inspect's notesBody), index?} (text as typed: untouched notes keep their formatting)",
|
||||
has_slide,
|
||||
notes
|
||||
),
|
||||
cmd!("slide.rename", "Rename Slide", [], None, "{name, index?}", has_slide, rename),
|
||||
cmd!("section.add", "Add Section", ["Home", "Slides", "Section"], None, "{name?, at?: slide index}", has_slide, section_add),
|
||||
cmd!("section.rename", "Rename Section", ["Home", "Slides", "Section"], None, "{index, name}", has_slide, section_rename),
|
||||
@@ -463,10 +471,20 @@ fn reset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn notes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = index_of(s, p)?;
|
||||
// Formatted notes as a whole (the notes editors), or text: then only what was retyped
|
||||
// changes, so bold, levels and bullets around it survive.
|
||||
let body = match p.get("body") {
|
||||
Some(b) => Some(serde_json::from_value::<TextBody>(b.clone()).map_err(|e| bad("slide.notes", format!("invalid `body`: {e}")))?),
|
||||
None => None,
|
||||
};
|
||||
let text = str_param(p, "text").unwrap_or("").to_string();
|
||||
s.edit(|doc, _| {
|
||||
if let Some(sl) = doc.slides.get_mut(i) {
|
||||
Arc::make_mut(sl).notes = TextBody::from_text(&text);
|
||||
let notes = &mut Arc::make_mut(sl).notes;
|
||||
match body {
|
||||
Some(b) => *notes = b,
|
||||
None => deckcraft_model::edit::replace_text(notes, &text),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
@@ -543,3 +543,46 @@ fn inserted_hyperlinks_look_like_links() {
|
||||
s.execute("format.underline", &json!({})).unwrap();
|
||||
assert_eq!(s.execute("format.state", &json!({})).unwrap()["underline"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_notes_keeps_their_formatting_through_pptx() {
|
||||
use deckcraft_model::text::{Bullet, ParaProps, Paragraph, Run, RunProps, TextBody};
|
||||
// A deck whose notes have a bold heading over indented bullet points, as saved by PowerPoint.
|
||||
let mut p = deckcraft_model::defaults::new_presentation(None);
|
||||
let bullet = ParaProps { margin_left: Some(22.5), indent: Some(-22.5), bullet: Some(Bullet::Char { char: "•".into() }), ..Default::default() };
|
||||
let point = |t: &str| Paragraph { level: 1, props: bullet.clone(), runs: vec![Run::new(t)], ..Default::default() };
|
||||
std::sync::Arc::make_mut(&mut p.slides[0]).notes = TextBody {
|
||||
paragraphs: vec![
|
||||
Paragraph { runs: vec![Run::with("Overview", RunProps { bold: Some(true), ..Default::default() })], ..Default::default() },
|
||||
point("First point"),
|
||||
point("Second point"),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
let bytes = deckcraft_pptx::export(&p).unwrap();
|
||||
let mut s = session();
|
||||
s.execute("file.openBytes", &json!({"name": "notes.pptx", "data": crate::cmd::base64_encode(&bytes)})).unwrap();
|
||||
// Type a word into the second bullet, as the notes pane does.
|
||||
s.execute("slide.notes", &json!({"index": 0, "text": "Overview\nFirst point\nSecond main point"})).unwrap();
|
||||
let check = |notes: &TextBody| {
|
||||
assert_eq!(notes.text(), "Overview\nFirst point\nSecond main point");
|
||||
assert_eq!(notes.paragraphs[0].runs[0].props.bold, Some(true));
|
||||
for para in ¬es.paragraphs[1..] {
|
||||
assert_eq!(para.level, 1);
|
||||
assert_eq!(para.props.margin_left, Some(22.5));
|
||||
assert_eq!(para.props.indent, Some(-22.5));
|
||||
assert_eq!(para.props.bullet, Some(Bullet::Char { char: "•".into() }));
|
||||
}
|
||||
};
|
||||
check(&s.doc().unwrap().doc.slides[0].notes);
|
||||
let saved = s.execute("file.saveBytes", &json!({"format": "pptx"})).unwrap()["data"].as_str().unwrap().to_string();
|
||||
let reopened = deckcraft_pptx::import(&crate::cmd::base64_decode(&saved).unwrap()).unwrap();
|
||||
check(&reopened.slides[0].notes);
|
||||
// slide.inspect hands out the formatted notes, and slide.notes {body} takes them back as they are.
|
||||
let body = s.execute("slide.inspect", &json!({"index": 0})).unwrap()["notesBody"].clone();
|
||||
s.execute("slide.notes", &json!({"index": 0, "text": "Plain"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.slides[0].notes, TextBody::from_text("Plain"), "new text shares nothing: plain");
|
||||
s.execute("slide.notes", &json!({"index": 0, "body": body})).unwrap();
|
||||
check(&s.doc().unwrap().doc.slides[0].notes);
|
||||
assert!(s.execute("slide.notes", &json!({"index": 0, "body": 7})).is_err());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user