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:
Matúš Laco
2026-10-11 21:21:33 +02:00
co-authored by Claude Opus 5.5
parent be14248577
commit 1ce5f686e8
11 changed files with 698 additions and 46 deletions
+2 -1
View File
@@ -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,
}))
+20 -2
View File
@@ -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(())
})?;
+43
View File
@@ -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 &notes.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());
}
+87
View File
@@ -355,6 +355,44 @@ pub fn char_move(body: &TextBody, pos: Pos, forward: bool) -> Pos {
}
}
/// The position of the `n`th character of the body's plain text (`\n` between paragraphs).
fn pos_of_char(body: &TextBody, mut n: usize) -> Pos {
for (pi, p) in body.paragraphs.iter().enumerate() {
let len = p.char_len();
if n <= len {
return (pi, n);
}
n -= len + 1;
}
end(body)
}
/// Make the body's plain text (paragraphs joined by `\n`) read `text`, as if it had been typed in
/// place: only the span between the longest common prefix and suffix changes, so untouched text
/// keeps its run and paragraph formatting and new text takes the formatting where it lands. Text
/// that shares nothing with the old text replaces it as plain text.
pub fn replace_text(body: &mut TextBody, text: &str) {
let old: Vec<char> = body.text().chars().collect();
let new: Vec<char> = text.chars().collect();
if old == new {
return;
}
let pre = old.iter().zip(&new).take_while(|(a, b)| a == b).count();
let room = old.len().min(new.len()) - pre;
let suf = old.iter().rev().zip(new.iter().rev()).take(room).take_while(|(a, b)| a == b).count();
if pre == 0 && suf == 0 {
*body = TextBody { body: body.body.clone(), list_style: body.list_style.clone(), ..TextBody::from_text(text) };
return;
}
let a = pos_of_char(body, pre);
let b = pos_of_char(body, old.len() - suf);
let at = delete(body, a, b);
let mid: String = new.get(pre..new.len() - suf).unwrap_or(&[]).iter().collect();
if !mid.is_empty() {
insert(body, at, &mid, None);
}
}
/// End of the body.
pub fn end(body: &TextBody) -> Pos {
let pi = body.paragraphs.len().saturating_sub(1);
@@ -472,4 +510,53 @@ mod tests {
assert_eq!(b.paragraphs[0].char_len(), 3);
assert_eq!(b.paragraphs.len(), 1);
}
/// Speaker notes: a bold heading over indented bullet points.
fn notes() -> TextBody {
use crate::text::{Bullet, ParaProps};
let mut b = body("Overview\nFirst point\nSecond point");
format(&mut b, (0, 0), (0, 8), &|p| p.bold = Some(true));
for p in b.paragraphs.iter_mut().skip(1) {
p.level = 1;
p.props =
ParaProps { margin_left: Some(36.0), indent: Some(-18.0), bullet: Some(Bullet::Char { char: "•".into() }), ..Default::default() };
}
b
}
#[test]
fn replace_text_keeps_formatting() {
let before = notes();
// Same text: nothing changes.
let mut b = before.clone();
replace_text(&mut b, "Overview\nFirst point\nSecond point");
assert_eq!(b, before);
// A word typed into a bullet: heading stays bold, bullets keep level, indent and bullet.
replace_text(&mut b, "Overview\nFirst main point\nSecond point");
assert_eq!(b.text(), "Overview\nFirst main point\nSecond point");
assert_eq!(b.paragraphs[0].runs[0].props.bold, Some(true));
assert_eq!(b.paragraphs[1].props, before.paragraphs[1].props);
assert_eq!((b.paragraphs[1].level, b.paragraphs[2].level), (1, 1));
// Typing at the end of the heading continues it in bold.
replace_text(&mut b, "Overview (5 min)\nFirst main point\nSecond point");
assert_eq!(b.paragraphs[0].runs.len(), 1);
assert_eq!(b.paragraphs[0].runs[0].props.bold, Some(true));
// Enter after a bullet starts another bullet; deleting one joins nothing else.
replace_text(&mut b, "Overview (5 min)\nFirst main point\nThird\nSecond point");
assert_eq!(b.paragraphs.len(), 4);
assert_eq!((b.paragraphs[2].level, &b.paragraphs[2].props), (1, &before.paragraphs[1].props));
replace_text(&mut b, "Overview (5 min)\nSecond point");
assert_eq!(b.text(), "Overview (5 min)\nSecond point");
assert_eq!(b.paragraphs[0].runs[0].props.bold, Some(true));
assert_eq!(b.paragraphs[1].level, 1);
// Text with nothing in common is new plain text.
replace_text(&mut b, "Plain");
assert_eq!(b, body("Plain"));
replace_text(&mut b, "");
assert!(b.is_empty());
// Into an empty body.
let mut e = TextBody::default();
replace_text(&mut e, "a\nb");
assert_eq!(e.text(), "a\nb");
}
}
+82
View File
@@ -0,0 +1,82 @@
//! Speaker notes keep their run and paragraph formatting: a bold heading, indented points with
//! custom bullets, plain lines.
use std::io::{Cursor, Read, Write};
use std::sync::Arc;
use deckcraft_model::defaults;
use deckcraft_model::text::{Bullet, ParaProps, TextBody};
const NOTES: &str = "ppt/notesSlides/notesSlide1.xml";
/// A notes slide written the way PowerPoint writes one (Slovak text): a bold heading, a plain
/// line, two level-2 points with a hanging indent and a Wingdings square bullet (one mixing a bold
/// label and plain text), a level-2 paragraph with buNone, and a closing plain line.
const NOTES_XML: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:notes xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr><p:sp><p:nvSpPr><p:cNvPr id="2" name="Slide Image Placeholder 1"/><p:cNvSpPr><a:spLocks noGrp="1" noRot="1" noChangeAspect="1"/></p:cNvSpPr><p:nvPr><p:ph type="sldImg"/></p:nvPr></p:nvSpPr><p:spPr/></p:sp><p:sp><p:nvSpPr><p:cNvPr id="3" name="Notes Placeholder 2"/><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr><p:nvPr><p:ph type="body" idx="1"/></p:nvPr></p:nvSpPr><p:spPr/><p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:r><a:rPr lang="sk-SK" b="1" dirty="0"/><a:t>Metodický prehľad</a:t></a:r></a:p><a:p><a:r><a:rPr lang="sk-SK" dirty="0"/><a:t>Žiaci pracujú vo dvojiciach.</a:t></a:r></a:p><a:p><a:pPr marL="285750" lvl="1" indent="-285750"><a:buFont typeface="Wingdings" panose="05000000000000000000" pitchFamily="2" charset="2"/><a:buChar char="&#167;"/></a:pPr><a:r><a:rPr lang="sk-SK" b="1" dirty="0"/><a:t>Cieľ: </a:t></a:r><a:r><a:rPr lang="sk-SK" dirty="0"/><a:t>vysvetliť cyklus</a:t></a:r></a:p><a:p><a:pPr marL="285750" lvl="1" indent="-285750"><a:buFont typeface="Wingdings" panose="05000000000000000000" pitchFamily="2" charset="2"/><a:buChar char="&#167;"/></a:pPr><a:r><a:rPr lang="sk-SK" i="1" dirty="0"/><a:t>Reflexia</a:t></a:r></a:p><a:p><a:pPr marL="285750" lvl="1" indent="-285750"><a:buNone/></a:pPr><a:r><a:rPr lang="sk-SK" dirty="0"/><a:t>Bez odrážky</a:t></a:r></a:p><a:p><a:r><a:rPr lang="sk-SK" dirty="0"/><a:t>Pomôcky: počítač</a:t></a:r></a:p></p:txBody></p:sp></p:spTree></p:cSld><p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr></p:notes>"#;
/// A deck with one notes slide, its notes replaced by [`NOTES_XML`].
fn deck() -> Vec<u8> {
let mut p = defaults::new_presentation(None);
Arc::make_mut(&mut p.slides[0]).notes = TextBody::from_text("placeholder");
let bytes = deckcraft_pptx::export(&p).expect("export");
let mut src = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut found = false;
for i in 0..src.len() {
let mut f = src.by_index(i).expect("entry");
let name = f.name().to_string();
let mut data = vec![];
f.read_to_end(&mut data).expect("read");
if name == NOTES {
data = NOTES_XML.as_bytes().to_vec();
found = true;
}
out.start_file(name, zip::write::SimpleFileOptions::default()).expect("start");
out.write_all(&data).expect("write");
}
assert!(found, "the export has no {NOTES}");
out.finish().expect("finish").into_inner()
}
fn assert_formatted(notes: &TextBody) {
assert_eq!(notes.text(), "Metodický prehľad\nŽiaci pracujú vo dvojiciach.\nCieľ: vysvetliť cyklus\nReflexia\nBez odrážky\nPomôcky: počítač");
let [head, plain, goal, reflect, unbulleted, closing] = notes.paragraphs.as_slice() else { panic!("six paragraphs: {notes:?}") };
assert_eq!(head.runs[0].props.bold, Some(true), "bold heading");
for para in [head, plain, closing] {
assert_eq!((para.level, &para.props), (0, &ParaProps::default()), "{:?}", para.text());
}
assert_eq!(plain.runs[0].props.bold, None);
for para in [goal, reflect] {
assert_eq!(para.level, 1, "level 2");
assert_eq!(para.props.margin_left, Some(22.5), "marL 285750 EMU");
assert_eq!(para.props.indent, Some(-22.5), "hanging indent");
assert_eq!(para.props.bullet, Some(Bullet::Char { char: "§".into() }));
assert_eq!(para.props.bullet_font.as_deref(), Some("Wingdings"));
}
assert_eq!((goal.runs[0].text.as_str(), goal.runs[0].props.bold), ("Cieľ: ", Some(true)), "bold label");
assert_eq!((goal.runs[1].text.as_str(), goal.runs[1].props.bold), ("vysvetliť cyklus", None), "then plain");
assert_eq!(reflect.runs[0].props.italic, Some(true));
assert_eq!((unbulleted.level, unbulleted.props.bullet.clone(), unbulleted.props.margin_left), (1, Some(Bullet::None), Some(22.5)));
}
#[test]
fn notes_import_keeps_bold_levels_and_indents() {
let p = deckcraft_pptx::import(&deck()).expect("import");
assert_formatted(&p.slides[0].notes);
}
#[test]
fn notes_formatting_survives_a_round_trip() {
let p = deckcraft_pptx::import(&deck()).expect("import");
let again = deckcraft_pptx::import(&deckcraft_pptx::export(&p).expect("export")).expect("reimport");
assert_formatted(&again.slides[0].notes);
// And the written XML says so, for PowerPoint.
let bytes = deckcraft_pptx::export(&again).expect("export");
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
let mut xml = String::new();
z.by_name(NOTES).expect("notes part").read_to_string(&mut xml).expect("utf-8");
assert!(xml.contains(r#"b="1""#), "{xml}");
assert!(xml.contains(r#"lvl="1""#) && xml.contains(r#"marL="285750""#) && xml.contains(r#"indent="-285750""#), "{xml}");
assert!(xml.contains(r#"<a:buFont typeface="Wingdings""#) && xml.contains("<a:buChar") && xml.contains("<a:buNone/>"), "{xml}");
}
+2 -1
View File
@@ -659,7 +659,8 @@ fn shape_item(
}
}
fn map_symbol_bullet(c: &str, font: Option<&str>) -> String {
/// The Unicode bullet for a symbol-font bullet character (Wingdings, Symbol), or the character.
pub fn map_symbol_bullet(c: &str, font: Option<&str>) -> String {
let f = font.unwrap_or("").to_ascii_lowercase();
let first = c.chars().next().unwrap_or('•');
if f.contains("wingdings") || f.contains("symbol") || ('\u{f000}'..='\u{f0ff}').contains(&first) {
+89 -2
View File
@@ -14,6 +14,7 @@ pub mod fillui;
pub mod icons;
pub mod media;
pub mod menus;
pub mod notes;
pub mod panes;
pub mod ribbon;
pub mod show;
@@ -206,7 +207,7 @@ pub struct SlideApp {
pub last_recovery: f64,
/// Quit was confirmed (changes saved or discarded): let the window close.
pub quit_confirmed: bool,
pub(crate) notes_buf: (u64, usize, String),
pub(crate) notes_buf: (u64, usize, notes::Draft),
/// Thumbnail pane drag (slide index, current drop index).
pub(crate) thumb_drag: Option<(usize, usize)>,
}
@@ -242,7 +243,7 @@ impl SlideApp {
last_time: 0.0,
last_recovery: 0.0,
quit_confirmed: false,
notes_buf: (0, usize::MAX, String::new()),
notes_buf: (0, usize::MAX, notes::Draft::default()),
thumb_drag: None,
}
}
@@ -938,4 +939,90 @@ mod tests {
app.run("app.home", json!({})).unwrap();
assert!(!app.home_open() && app.home.is_none(), "with nothing open the start screen shows anyway");
}
/// Speaker notes keep their formatting when edited in the notes pane: typing into a bold
/// heading, Enter in a bulleted point, then saving as .pptx and reopening.
#[test]
fn editing_notes_in_the_pane_keeps_their_formatting() {
use deckcraft_model::text::{Bullet, ParaProps, Paragraph, Run, RunProps, TextBody};
let point = |runs: Vec<Run>| Paragraph {
level: 1,
props: ParaProps {
margin_left: Some(22.5),
indent: Some(-22.5),
bullet: Some(Bullet::Char { char: "§".into() }),
bullet_font: Some("Wingdings".into()),
..Default::default()
},
runs,
..Default::default()
};
let bold = RunProps { bold: Some(true), ..Default::default() };
let notes = TextBody {
paragraphs: vec![
Paragraph { runs: vec![Run::with("Metodický prehľad", bold.clone())], ..Default::default() },
Paragraph::new("Žiaci pracujú vo dvojiciach."),
point(vec![Run::with("Cieľ: ", bold.clone()), Run::new("vysvetliť cyklus")]),
point(vec![Run::new("Reflexia")]),
Paragraph::new("Pomôcky: počítač"),
],
..Default::default()
};
let mut session = Session::with_new();
session.execute("slide.notes", &json!({"index": 0, "body": notes})).unwrap();
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|ui, app: &mut SlideApp| {
app.logic(ui.ctx());
app.ui(ui);
},
SlideApp::new(session, Services::default()),
);
harness.run_steps(2);
let id = egui::Id::new("notes_edit");
let caret = |h: &mut egui_kittest::Harness<'_, SlideApp>, at: usize| {
let mut st = egui::TextEdit::load_state(&h.ctx, id).unwrap_or_default();
st.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(at))));
egui::TextEdit::store_state(&h.ctx, id, st);
};
let chars = |s: &str, of: &str| s.find(of).map(|b| s[..b].chars().count() + of.chars().count()).unwrap();
let text = notes.text();
harness.ctx.memory_mut(|m| m.request_focus(id));
harness.run_steps(1);
// Type into the bold heading.
caret(&mut harness, chars(&text, "prehľad"));
harness.run_steps(1);
harness.event(egui::Event::Text(" hodiny".into()));
harness.run_steps(1);
// Enter after a bulleted point, and type the next one.
let text = text.replacen("prehľad", "prehľad hodiny", 1);
caret(&mut harness, chars(&text, "cyklus"));
harness.run_steps(1);
harness.key_press(egui::Key::Enter);
harness.event(egui::Event::Text("Nový bod".into()));
harness.run_steps(1);
// Leave the field: the notes are saved.
harness.ctx.memory_mut(|m| m.surrender_focus(id));
harness.run_steps(2);
let check = |n: &TextBody| {
assert_eq!(
n.text(),
"Metodický prehľad hodiny\nŽiaci pracujú vo dvojiciach.\nCieľ: vysvetliť cyklus\nNový bod\nReflexia\nPomôcky: počítač"
);
assert_eq!(n.paragraphs[0].runs.len(), 1, "{n:?}");
assert_eq!(n.paragraphs[0].runs[0].props.bold, Some(true));
assert_eq!(n.paragraphs[1].props, ParaProps::default());
assert_eq!(n.paragraphs[2].runs[0].props.bold, Some(true), "the bold label");
assert_eq!(n.paragraphs[2].runs[1].props.bold, None);
for i in [2, 3, 4] {
assert_eq!((n.paragraphs[i].level, &n.paragraphs[i].props), (1, &notes.paragraphs[2].props), "point {i}");
}
assert_eq!((n.paragraphs[5].level, &n.paragraphs[5].props), (0, &ParaProps::default()));
};
check(&harness.state().session.active().unwrap().doc.slides[0].notes);
// Saved as .pptx and reopened.
let s = &mut harness.state_mut().session;
let data = s.execute("file.saveBytes", &json!({"format": "pptx"})).unwrap()["data"].clone();
s.execute("file.openBytes", &json!({"name": "notes.pptx", "data": data})).unwrap();
check(&s.active().unwrap().doc.slides[0].notes);
}
}
+327
View File
@@ -0,0 +1,327 @@
//! Speaker notes editing (the notes pane and Notes Page view). The notes are edited as text and
//! drawn with their formatting: bold, italic, underline, strikethrough and size from the runs,
//! indents (wrapped lines included) and bullets or numbers from the paragraphs. Each edit is
//! applied to a formatted draft, so typed text takes the formatting where it lands and the rest
//! keeps its own; the draft is saved with `slide.notes {body}` once the editor loses focus.
use std::sync::Arc;
use deckcraft_model::edit;
use deckcraft_model::text::{Bullet, Paragraph, RunProps, Strike, TextBody};
use egui::text::{CCursor, Galley, LayoutJob, TextFormat};
use egui::{Align2, Color32, Stroke, Ui, vec2};
use crate::theme;
/// Size and colour of a notes editor's text.
pub struct Look {
/// Pixels for 12 pt text, the notes' default size; indents scale with it.
pub size: f32,
/// `None` = the widget text colour.
pub color: Option<Color32>,
}
/// Notes being edited: the text in the editor and the formatted notes it stands for.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Draft {
pub text: String,
pub body: TextBody,
/// Edited since it was saved.
pub edited: bool,
}
impl Draft {
pub fn new(body: &TextBody) -> Self {
Draft { text: body.text(), body: body.clone(), edited: false }
}
}
/// The draft as it reads with `text` typed in (one edit since the draft): `None` when that can't
/// be mapped onto it.
fn typed(body: &TextBody, text: &str) -> Option<TextBody> {
let mut b = body.clone();
edit::replace_text(&mut b, text);
(b.text() == text).then_some(b)
}
/// A length in points from the file as pixels, bounded.
fn px(pt: f64, k: f32) -> f32 {
if pt.is_finite() { pt.clamp(0.0, 400.0) as f32 * k } else { 0.0 }
}
/// Where a paragraph's first line starts and its other lines start (points): a level without its
/// own margin is indented 36 pt per level, as in the default notes master.
fn indents(p: &Paragraph) -> (f64, f64) {
let margin = p.props.margin_left.unwrap_or(36.0 * f64::from(p.level.min(8)));
let first = margin + p.props.indent.unwrap_or(0.0);
(first, margin)
}
/// Each paragraph's bullet or number, numbered as on a slide (per level, restarting after an
/// unnumbered paragraph at the same or a lower level).
fn bullets(body: &TextBody) -> Vec<Option<String>> {
let mut numbering = [0u32; 9];
body.paragraphs
.iter()
.map(|p| {
let lvl = usize::from(p.level.min(8));
if p.is_empty() {
return None;
}
let auto = matches!(p.props.bullet, Some(Bullet::AutoNum { .. }));
for (i, n) in numbering.iter_mut().enumerate().skip(lvl) {
if i > lvl || !auto {
*n = 0;
}
}
match p.props.bullet.as_ref()? {
Bullet::None => None,
Bullet::Char { char } => Some(deckcraft_text::map_symbol_bullet(char, p.props.bullet_font.as_deref())),
Bullet::AutoNum { scheme, start_at } => {
let n = numbering.get_mut(lvl)?;
*n = n.saturating_add(1);
Some(deckcraft_text::autonum(scheme, start_at.saturating_sub(1).saturating_add(*n)))
}
Bullet::Picture { .. } => Some("•".into()),
}
})
.collect()
}
fn format(props: &RunProps, size: f32, color: Color32) -> TextFormat {
let size = match props.size {
Some(pt) if pt.is_finite() && pt > 0.0 => (size * pt as f32 / 12.0).clamp(size * 0.75, size * 2.0),
_ => size,
};
let font = if props.bold == Some(true) { theme::bold(size) } else { theme::font(size) };
let mut f = TextFormat::simple(font, color);
f.italics = props.italic == Some(true);
if props.underline.as_deref().is_some_and(|u| u != "none") {
f.underline = Stroke::new(1.0, color);
}
if matches!(props.strike, Some(Strike::Single | Strike::Double)) {
f.strikethrough = Stroke::new(1.0, color);
}
f
}
/// One paragraph to lay out: wrapped lines start at the margin (pixels), the first line's text at
/// `text`, clear of a bullet.
struct ParaLayout {
job: LayoutJob,
margin: f32,
/// x of the first line's text.
text: f32,
}
fn para_layout(p: &Paragraph, bullet: Option<&str>, size: f32, color: Color32, wrap: f32) -> ParaLayout {
let k = size / 12.0;
let (first, margin) = indents(p);
let (first, margin) = (px(first, k), px(margin, k));
let text = match bullet {
// The text starts at the margin, after the bullet if it's wider than the hanging indent.
Some(b) => margin.max(first + size * (0.4 + 0.6 * b.chars().count() as f32)),
None => first,
};
let mut job = LayoutJob::default();
job.wrap.max_width = (wrap - margin).max(size * 4.0);
let mut lead = (text - margin).max(0.0);
for r in &p.runs {
let t = r.content();
if !t.is_empty() {
job.append(t, lead, format(&r.props, size, color));
lead = 0.0;
}
}
if job.text.is_empty() {
job.append("", 0.0, format(&p.end_props, size, color));
}
ParaLayout { job, margin, text }
}
/// The whole notes as one job (the galley's text and formats).
fn job(body: &TextBody, size: f32, color: Color32, wrap: f32) -> LayoutJob {
let mut job = LayoutJob::default();
job.wrap.max_width = wrap;
for (i, (p, bullet)) in body.paragraphs.iter().zip(bullets(body)).enumerate() {
if i > 0 {
job.append("\n", 0.0, TextFormat::simple(theme::font(size), color));
}
let pl = para_layout(p, bullet.as_deref(), size, color, wrap);
for s in pl.job.sections {
if let Some(t) = pl.job.text.get(s.byte_range.start.0..s.byte_range.end.0) {
job.append(t, s.leading_space, s.format);
}
}
}
job
}
/// Lay the notes out paragraph by paragraph, each moved to its indent, so wrapped lines line up
/// under the text and not under the bullet.
fn galley(ui: &Ui, body: &TextBody, size: f32, color: Color32, wrap: f32) -> Arc<Galley> {
let whole = job(body, size, color, wrap);
if body.paragraphs.is_empty() {
return ui.fonts_mut(|f| f.layout_job(whole));
}
let parts: Vec<Arc<Galley>> = ui.fonts_mut(|f| {
body.paragraphs
.iter()
.zip(bullets(body))
.map(|(p, bullet)| {
let pl = para_layout(p, bullet.as_deref(), size, color, wrap);
let mut g = (*f.layout_job(pl.job)).clone();
for (i, row) in g.rows.iter_mut().enumerate() {
// The first line already holds the gap after a bullet; a first line hanging
// left of the margin without one starts there.
row.pos.x += if i == 0 { pl.margin.min(pl.text) } else { pl.margin };
}
Arc::new(g)
})
.collect()
});
Arc::new(Galley::concat(Arc::new(whole), &parts, ui.ctx().pixels_per_point()))
}
/// A multiline editor for the draft that draws its formatting; `configure` sets the id, hint,
/// size and frame. Edits update the draft's text and formatted body.
pub fn editor(
ui: &mut Ui,
draft: &mut Draft,
look: &Look,
configure: impl for<'a> FnOnce(egui::TextEdit<'a>) -> egui::TextEdit<'a>,
) -> egui::Response {
let color = look.color.unwrap_or_else(|| ui.visuals().override_text_color.unwrap_or_else(|| ui.visuals().widgets.inactive.text_color()));
let size = look.size;
let before = draft.body.clone();
let mut layouter = |ui: &Ui, text: &dyn egui::TextBuffer, wrap: f32| match typed(&before, text.as_str()) {
Some(b) => galley(ui, &b, size, color, wrap),
None => ui.fonts_mut(|f| f.layout_job(LayoutJob::simple(text.as_str().to_string(), theme::font(size), color, wrap))),
};
let out = configure(egui::TextEdit::multiline(&mut draft.text)).layouter(&mut layouter).show(ui);
if draft.body.text() != draft.text {
edit::replace_text(&mut draft.body, &draft.text);
draft.edited = true;
}
// Bullets and numbers sit at the start of each paragraph's first line.
if draft.body.text() == draft.text {
let painter = ui.painter().with_clip_rect(out.text_clip_rect);
let k = size / 12.0;
let mut at = 0usize;
for (p, bullet) in draft.body.paragraphs.iter().zip(bullets(&draft.body)) {
if let Some(b) = bullet {
let row = out.galley.pos_from_cursor(CCursor::new(at));
let pos = out.galley_pos + vec2(px(indents(p).0, k), row.center().y);
let font = theme::font(row.height().clamp(size * 0.75, size * 2.0) * 0.8);
painter.text(pos, Align2::LEFT_CENTER, b, font, color);
}
at += p.char_len() + 1;
}
}
out.response.response
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use deckcraft_model::text::{ParaProps, Run};
use super::*;
/// Notes as PowerPoint users write them: a bold heading, a plain line, points with a
/// Wingdings square bullet and a hanging indent (one long enough to wrap), numbered points,
/// a point with an explicit buNone, closing plain lines; Slovak text throughout.
fn notes() -> TextBody {
let props = |bullet: Bullet| ParaProps {
margin_left: Some(22.5),
indent: Some(-22.5),
bullet: Some(bullet),
bullet_font: Some("Wingdings".into()),
..Default::default()
};
let point = |runs: Vec<Run>, bullet: Bullet| Paragraph { level: 1, props: props(bullet), runs, ..Default::default() };
let square = || Bullet::Char { char: "§".into() };
let num = || Bullet::AutoNum { scheme: "arabicPeriod".into(), start_at: 1 };
let bold = RunProps { bold: Some(true), ..Default::default() };
TextBody {
paragraphs: vec![
Paragraph { runs: vec![Run::with("Metodický prehľad", bold.clone())], ..Default::default() },
Paragraph::new("Žiaci pracujú vo dvojiciach."),
point(vec![Run::with("Cieľ: ", bold), Run::new("vysvetliť cyklus")], square()),
point(vec![Run::new("Dlhý bod, ktorý sa zalomí na viac riadkov, lebo je naozaj veľmi dlhý a pokračuje ďalej.")], square()),
point(vec![Run::new("Jeden")], num()),
point(vec![Run::new("Dva")], num()),
point(vec![Run::new("Bez odrážky")], Bullet::None),
Paragraph::new("Pomôcky: počítač"),
],
..Default::default()
}
}
#[test]
fn bullets_follow_the_paragraphs() {
let b = bullets(&notes());
assert_eq!(b, vec![None, None, Some("▪".into()), Some("▪".into()), Some("1.".into()), Some("2.".into()), None, None]);
}
#[test]
fn notes_show_bold_runs_indents_and_wrap_under_the_text() {
let body = notes();
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
let mut laid = None;
let mut out = ctx.run_ui(Default::default(), |ui| laid = Some(galley(ui, &body, 12.0, Color32::BLACK, 300.0)));
out.textures_delta.clear();
let g = laid.unwrap();
assert_eq!(g.job.text, body.text(), "the galley reads the notes");
assert_eq!(g.rows.iter().map(|r| r.char_count_including_newline().0).sum::<usize>(), body.text().chars().count());
// Bold where the runs are bold, regular elsewhere.
let font_of = |s: &str| {
let b = g.job.text.find(s).unwrap();
g.job.sections.iter().find(|x| (x.byte_range.start.0..x.byte_range.end.0).contains(&b)).unwrap().format.font_id.clone()
};
assert_eq!(font_of("Metodický"), theme::bold(12.0));
assert_eq!(font_of("Cieľ"), theme::bold(12.0));
assert_eq!(font_of("vysvetliť"), theme::font(12.0));
assert_eq!(font_of("Pomôcky"), theme::font(12.0));
// Rows: x where each paragraph's text starts.
let row_x = |s: &str| {
let at = body.text().find(s).map(|b| body.text()[..b].chars().count()).unwrap();
g.pos_from_cursor(CCursor::new(at)).min.x
};
assert!(row_x("Metodický") < 1.0 && row_x("Žiaci") < 1.0 && row_x("Pomôcky") < 1.0, "plain paragraphs aren't indented");
assert!((row_x("Cieľ") - 22.5).abs() < 1.0, "bulleted text at the 22.5 pt margin: {}", row_x("Cieľ"));
assert!((row_x("Bez odrážky") - 0.0).abs() < 1.0, "buNone: no bullet, the first line hangs to 0");
// The long point wraps, and its wrapped lines start at the margin, not under the bullet.
let long = body.text().find("Dlhý").map(|b| body.text()[..b].chars().count()).unwrap();
let first_row = g.pos_from_cursor(CCursor::new(long));
let wrapped: Vec<_> =
g.rows.iter().filter(|r| r.pos.y > first_row.min.y + 1.0 && r.pos.y < g.pos_from_cursor(CCursor::new(long + 80)).max.y).collect();
assert!(!wrapped.is_empty(), "the long point wraps");
assert!(wrapped.iter().all(|r| (r.pos.x - 22.5).abs() < 1.0), "wrapped lines align with the text");
}
#[test]
fn typing_keeps_the_formatting_around_it() {
let mut d = Draft::new(&notes());
// Typing into the bold heading continues it in bold.
let t = d.text.replacen("prehľad", "prehľad hodiny", 1);
d.body = typed(&d.body, &t).unwrap();
assert_eq!(d.body.paragraphs[0].runs.len(), 1);
assert_eq!(d.body.paragraphs[0].runs[0].props.bold, Some(true));
// Enter at the end of a bulleted point starts another one with the same bullet and indent.
let t = d.body.text().replacen("cyklus", "cyklus\nnový bod", 1);
d.body = typed(&d.body, &t).unwrap();
let new = &d.body.paragraphs[3];
assert_eq!(new.text(), "nový bod");
assert_eq!((new.level, &new.props), (1, &notes().paragraphs[2].props));
assert_eq!(d.body.paragraphs[2].runs[0].props.bold, Some(true), "the bold label stays bold");
// Hostile sizes and indents stay bounded.
let mut wild = notes();
wild.paragraphs[2].props.margin_left = Some(f64::NAN);
wild.paragraphs[0].runs[0].props.size = Some(f64::INFINITY);
let j = job(&wild, 12.0, Color32::BLACK, 300.0);
assert!(j.sections.iter().all(|s| s.leading_space.is_finite() && s.format.font_id.size <= 24.0));
}
}
+19 -14
View File
@@ -132,22 +132,27 @@ pub fn notes_page(app: &mut SlideApp, ui: &mut Ui) {
ui.painter().rect_stroke(srect, CornerRadius::ZERO, Stroke::new(1.0, Color32::GRAY), egui::StrokeKind::Outside);
let notes_rect = Rect::from_min_max(pos2(srect.min.x, srect.max.y + 24.0), pos2(srect.max.x, page.max.y - page_h * 0.06));
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(notes_rect));
let current = st.current_slide().map(|s| s.notes_text()).unwrap_or_default();
let id = egui::Id::new(("notespage", idx));
let mut buf: String = child.data_mut(|d| d.get_temp(id).unwrap_or_else(|| current.clone()));
let r = child.add(
egui::TextEdit::multiline(&mut buf)
.hint_text("Click to add text")
.desired_width(notes_rect.width())
.desired_rows(12)
.frame(egui::Frame::NONE)
.text_color(Color32::from_gray(30))
.font(theme::font(14.0)),
);
if r.lost_focus() && buf != current {
let _ = app.run("slide.notes", json!({"text": buf, "index": idx}));
let edit_id = egui::Id::new(("notespage-edit", idx));
let focused = child.ctx().memory(|m| m.has_focus(edit_id));
let mut draft: Option<crate::notes::Draft> = child.data_mut(|d| d.get_temp(id));
// Save an edited draft once the field has lost focus, before anything reloads it.
if !focused && let Some(d) = draft.as_mut().filter(|d| d.edited) {
d.edited = false;
let _ = app.run("slide.notes", json!({"body": d.body, "index": idx}));
}
child.data_mut(|d| d.insert_temp(id, buf));
let notes = app.session.active().and_then(|st| st.doc.slides.get(idx)).map(|s| s.notes.clone()).unwrap_or_default();
let mut draft = draft.filter(|d| focused || d.body == notes).unwrap_or_else(|| crate::notes::Draft::new(&notes));
let look = crate::notes::Look { size: 14.0, color: Some(Color32::from_gray(30)) };
let width = notes_rect.width();
let r = crate::notes::editor(&mut child, &mut draft, &look, |e| {
e.id(edit_id).hint_text("Click to add text").desired_width(width).desired_rows(12).frame(egui::Frame::NONE)
});
if r.lost_focus() && draft.edited {
draft.edited = false;
let _ = app.run("slide.notes", json!({"body": draft.body, "index": idx}));
}
child.data_mut(|d| d.insert_temp(id, draft));
let nav = ui.input(|i| (i.key_pressed(egui::Key::PageDown), i.key_pressed(egui::Key::PageUp)));
if nav.0 {
let _ = app.run("slide.next", json!({}));
+24 -24
View File
@@ -115,9 +115,18 @@ pub fn notes_pane(app: &mut SlideApp, ui: &mut Ui) {
if st.selection.target != deckcraft_engine::Target::Slides {
return;
}
let idx = st.selection.slide;
let uid = st.uid;
let current = st.current_slide().map(|s| s.notes_text()).unwrap_or_default();
let (idx, uid) = (st.selection.slide, st.uid);
// Save an edited draft once the field has lost focus or another slide is shown (before
// anything reloads it), to the slide it was written on.
let id = egui::Id::new("notes_edit");
let focused = ui.ctx().memory(|m| m.has_focus(id));
if app.notes_buf.2.edited && (!focused || app.notes_buf.0 != uid || app.notes_buf.1 != idx) {
app.notes_buf.2.edited = false;
if app.notes_buf.0 == uid {
let _ = app.run("slide.notes", json!({"body": app.notes_buf.2.body, "index": app.notes_buf.1}));
}
}
let notes = app.session.active().and_then(|st| st.current_slide()).map(|s| s.notes.clone()).unwrap_or_default();
egui::Panel::bottom("notes")
.resizable(true)
.default_size(app.ui.notes_height)
@@ -128,28 +137,19 @@ pub fn notes_pane(app: &mut SlideApp, ui: &mut Ui) {
let rect = ui.max_rect();
ui.painter()
.line_segment([pos2(rect.min.x - 8.0, rect.min.y - 6.0), pos2(rect.max.x + 8.0, rect.min.y - 6.0)], Stroke::new(1.0, t.separator));
// Keep a local buffer per slide; commit when it changes and the field loses focus.
if app.notes_buf.0 != uid || app.notes_buf.1 != idx {
app.notes_buf = (uid, idx, current.clone());
// Keep a local draft per slide, following the notes while the field isn't focused.
if app.notes_buf.0 != uid || app.notes_buf.1 != idx || (!focused && app.notes_buf.2.body != notes) {
app.notes_buf = (uid, idx, crate::notes::Draft::new(&notes));
}
let id = egui::Id::new("notes_edit");
let focused = ui.ctx().memory(|m| m.has_focus(id));
if !focused && app.notes_buf.2 != current {
app.notes_buf.2 = current.clone();
}
let mut buf = app.notes_buf.2.clone();
let r = ui.add(
egui::TextEdit::multiline(&mut buf)
.id(id)
.hint_text("Click to add notes")
.frame(egui::Frame::NONE)
.desired_width(f32::INFINITY)
.desired_rows(2)
.font(theme::font(13.0)),
);
app.notes_buf.2 = buf.clone();
if r.lost_focus() && buf != current {
let _ = app.run("slide.notes", json!({"text": buf, "index": idx}));
let mut draft = app.notes_buf.2.clone();
let look = crate::notes::Look { size: 13.0, color: None };
let r = crate::notes::editor(ui, &mut draft, &look, |e| {
e.id(id).hint_text("Click to add notes").frame(egui::Frame::NONE).desired_width(f32::INFINITY).desired_rows(2)
});
if r.lost_focus() && draft.edited {
draft.edited = false;
let _ = app.run("slide.notes", json!({"body": draft.body, "index": idx}));
}
app.notes_buf.2 = draft;
});
}
+3 -2
View File
@@ -1,6 +1,6 @@
# File-format parity with PowerPoint
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; formats taken from PowerPoint 16.113.4's Info.plist, PPTX coverage measured from `crates/pptx`) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (formatted speaker notes) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
Every format PowerPoint reads or writes, DeckCraft's support, fidelity and how it is tested.
Gaps are itemized in [gaps.md](gaps.md#file-formats). Dimension score: **~58% ready**
@@ -61,7 +61,7 @@ the round trip even though DeckCraft doesn't model it.
| Presentation, slide size, sections (p14), custom shows | yes | yes | |
| Slide masters, layouts, placeholders and inheritance | yes | yes | |
| Themes: colour, font (Latin/EA/CS), effect schemes, background styles | yes | yes | Unknown theme parts kept |
| Notes slides and notes master | yes | yes | No notes-master editing view |
| Notes slides and notes master | yes | yes | Notes keep their run and paragraph formatting (bold, italic, underline, size, levels, indents, bullets), shown and kept when edited in the notes pane and Notes Page (`crates/pptx/tests/notes.rs`). PDF notes pages print them as plain text; levels and bullets inherited from a custom notes master aren't shown; no notes-master editing view |
| Handout master | no | no | |
| Shapes: preset geometry (~150 presets), custom geometry, groups, connectors | yes | yes | Presets re-derived in `crates/geom/src/preset.rs` |
| Fills: solid, gradient, picture (stretch/tile/crop), pattern, group | yes | yes | |
@@ -99,4 +99,5 @@ the round trip even though DeckCraft doesn't model it.
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | Speaker notes: formatting shown in the notes pane and Notes Page and kept when they are edited |
| 2026-10-10 | major | Created from PowerPoint 16.113.4's `CFBundleDocumentTypes`, `crates/pptx` reader/writer and tests, and the open round-trip issues |