Merge PR #168 from Mathiasko: Notes: speaker notes show their formatting and keep it when edited

# Conflicts:
#	crates/engine/src/tests.rs
#	crates/model/src/edit.rs
#	crates/text/src/lib.rs
#	crates/ui-egui/src/lib.rs
#	crates/ui-egui/src/sorter.rs
#	crates/ui-egui/src/status.rs
This commit is contained in:
Brandon Thomas
2026-10-14 14:45:13 -04:00
11 changed files with 706 additions and 52 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),
@@ -468,10 +476,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
@@ -2828,3 +2828,46 @@ fn tables_with_a_file_defined_style_edit_and_resize() {
assert_eq!(table_of(&s, id).cols, vec![230.0, 170.0]);
assert_eq!(text_sel(&s).unwrap().cell, Some((1, 1)));
}
#[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
@@ -401,6 +401,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);
@@ -676,4 +714,53 @@ mod tests {
assert_eq!(next_case("hello. world is HERE"), "upper");
assert_eq!(next_case("123"), "upper");
}
/// 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
@@ -1170,7 +1170,8 @@ pub fn symbol_char(face: &FontFace, font: &str, c: char) -> Option<char> {
Some(symbol_font_char(face, &s, Some(font)).unwrap_or_else(|| map_symbol_bullet(&s, Some(font)).chars().next().unwrap_or('•')))
}
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.starts_with("webdings") {
+89 -2
View File
@@ -18,6 +18,7 @@ pub mod fillui;
pub mod icons;
pub mod media;
pub mod menus;
pub mod notes;
pub mod panes;
pub mod qat;
pub mod ribbon;
@@ -277,7 +278,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),
/// Faces borrowed from the font database for text the interface fonts can't draw (#101).
pub ui_fonts: uifonts::UiFonts,
/// Thumbnail pane drag (slide index, current drop index).
@@ -334,7 +335,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()),
ui_fonts: uifonts::UiFonts::default(),
thumb_drag: None,
selpane: selpane::State::default(),
@@ -2003,4 +2004,90 @@ mod tests {
let body = d.doc.slides[0].shapes[0].text.as_ref().unwrap();
assert_eq!(body.paragraphs[0].runs[0].props.lang.as_deref(), Some("fr-FR"));
}
/// 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));
}
}
+25 -15
View File
@@ -394,23 +394,33 @@ 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 = to_screen((notes_box.x0, notes_box.y0, notes_box.x1, notes_box.y1));
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();
// A draft belongs to this document and to the notes it started from (#413): another document,
// a reorder or an undo that changes them drops it.
let id = egui::Id::new(("notespage", st.uid, idx));
let stored: Option<(String, String)> = child.data_mut(|d| d.get_temp(id));
let mut buf = widgets::text_draft(stored, &current);
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 notes_of = |app: &SlideApp| app.session.active().and_then(|st| st.doc.slides.get(idx)).map(|s| s.notes.clone()).unwrap_or_default();
let notes = notes_of(app);
let stored: Option<(deckcraft_model::text::TextBody, crate::notes::Draft)> = child.data_mut(|d| d.get_temp(id));
let mut draft = stored.filter(|(base, _)| *base == notes).map(|(_, d)| d);
// 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, (current, buf)));
let notes = notes_of(app);
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}));
}
let base = notes_of(app);
child.data_mut(|d| d.insert_temp(id, (base, 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 -31
View File
@@ -111,9 +111,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)
@@ -124,42 +133,26 @@ 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.
let id = egui::Id::new("notes_edit");
let focused = ui.ctx().memory(|m| m.has_focus(id));
// Focus can leave in the frame before the field is drawn (Escape, a click on a
// thumbnail): what was typed is still committed, to the slide it was typed for.
let was_focused = ui.ctx().memory(|m| m.had_focus_last_frame(id));
if app.notes_buf.0 != uid || app.notes_buf.1 != idx {
let (old_uid, old_idx, typed) = std::mem::replace(&mut app.notes_buf, (uid, idx, current.clone()));
let old = app.session.active().and_then(|d| d.doc.slides.get(old_idx)).map(|s| s.notes_text());
if was_focused && old_uid == uid && old.is_some_and(|o| o != typed) {
let _ = app.run("slide.notes", json!({"text": typed, "index": old_idx}));
}
// 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));
}
if !focused && !was_focused && app.notes_buf.2 != current {
app.notes_buf.2 = current.clone();
}
let mut buf = app.notes_buf.2.clone();
let mut draft = app.notes_buf.2.clone();
let look = crate::notes::Look { size: 13.0, color: None };
// Long notes scroll inside the pane instead of spilling over the slide (#221).
let r = egui::ScrollArea::vertical()
.max_height(ui.available_height())
.auto_shrink([false, false])
.show(ui, |ui| {
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)),
)
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)
})
})
.inner;
app.notes_buf.2 = buf.clone();
if r.lost_focus() && buf != current {
let _ = app.run("slide.notes", json!({"text": buf, "index": idx}));
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;
});
}
+5
View File
@@ -12,6 +12,7 @@
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (owned corpus for tables, charts, animations, media, sections and masters; handout-master text kept; previous: trivial, groups with a small legacy child coordinate space) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (a PNG or JPEG slide export records its resolution, so the default 2× picture is 144 dpi; previous: groups with a small legacy child coordinate space draw outlines at their own width, #233) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (chart trendlines are drawn; previously minor: chart trendlines round-trip; groups with a small legacy child coordinate space draw outlines, dashes, arrowheads and ink at their own width (#233); 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**
@@ -83,6 +84,9 @@ the round trip even though DeckCraft doesn't model it.
| Notes slides and notes master | yes | yes | Header, footer, date and slide-number text on the notes master is kept. Edited in View ▸ Notes Master |
| Handout master | yes | yes | Header, footer, date and slide-number text is kept. Edited in View ▸ Handout Master |
| Shapes: preset geometry (~180 presets), custom geometry, groups, connectors | yes | yes | Presets re-derived in `crates/geom/src/preset.rs` (and `preset/extra.rs`). Outlines, dashes, arrowheads and ink inside a group, and text, keep their size in points whatever the group's child-coordinate scale (#233). |
| 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 | |
| Lines: dash, compound, caps, joins, arrowheads | yes | yes | |
| Effects: shadow, glow, soft edges, reflection | yes | yes | |
@@ -147,5 +151,6 @@ the round trip even though DeckCraft doesn't model it.
| 2026-10-12 | minor | EMF/WMF text and pattern brushes drawn; OLE (VML) and ActiveX previews read |
| 2026-10-12 | minor | Text warps rendered; their adjust values read and written |
| 2026-10-12 | trivial | Groups with a small legacy child coordinate space draw outlines, dashes, arrowheads and ink at their own width (#233) |
| 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 |
| 2026-10-13 | minor | Comments: the writer emits the modern threaded part (`p188`) next to the flattened legacy one; the reader prefers the modern part and scales its EMU positions (#70) |