diff --git a/crates/engine/src/cmd/design.rs b/crates/engine/src/cmd/design.rs index 8393480c..85902eea 100644 --- a/crates/engine/src/cmd/design.rs +++ b/crates/engine/src/cmd/design.rs @@ -65,6 +65,24 @@ pub fn specs() -> Vec { in_handout, handout_placeholders ), + cmd!( + "view.notesMaster", + "Notes Master", + ["View", "Master Views"], + None, + "{} (creates the notes master with header, date, slide image, notes body, footer and page number if the deck has none)", + has_doc, + notes_view + ), + cmd!( + "notes.placeholders", + "Notes Placeholders", + ["Notes Master", "Placeholders"], + None, + "{header?, date?, slideImage?, body?, footer?, pageNumber?: bool} (show or hide each)", + in_notes_master, + notes_placeholders + ), cmd!(noundo "view.closeMaster", "Close Master View", ["Slide Master", "Close"], None, "{}", has_doc, close_master), cmd!("master.insertLayout", "Insert Layout", ["Slide Master", "Edit Master"], None, "{name?}", has_doc, insert_layout), cmd!("master.renameLayout", "Rename Layout", ["Slide Master", "Edit Master"], None, "{name, master?, layout?}", has_doc, rename_layout), @@ -100,7 +118,7 @@ fn theme(s: &mut Session, p: &Value) -> Result { fn master_index(doc: &Presentation, sel: &crate::Selection) -> usize { match sel.target { Target::Master { master } | Target::Layout { master, .. } => master, - Target::Slides | Target::Handout => { + Target::Slides | Target::Handout | Target::NotesMaster => { doc.slides.get(sel.slide).and_then(|s| doc.masters.iter().position(|m| m.layout(s.layout).is_some())).unwrap_or(0) } } @@ -216,8 +234,8 @@ fn background(s: &mut Session, p: &Value) -> Result { l.background = bg.clone(); } } - Target::Handout => { - if let Some(m) = doc.handout_master.as_mut() { + t @ (Target::Handout | Target::NotesMaster) => { + if let Some(m) = crate::page_master_mut(doc, t) { Arc::make_mut(m).background = bg.clone(); } } @@ -560,6 +578,110 @@ fn handout_placeholders(s: &mut Session, p: &Value) -> Result { Ok(json!({"header": shown(PhType::Header), "date": shown(PhType::Date), "footer": shown(PhType::Footer), "pageNumber": shown(PhType::SlideNum)})) } +fn in_notes_master(s: &Session) -> std::result::Result<(), String> { + if s.active().is_some_and(|d| d.selection.target == Target::NotesMaster) { Ok(()) } else { Err("open View ▸ Notes Master first".into()) } +} + +const NOTES_KINDS: [(&str, PhType); 6] = [ + ("header", PhType::Header), + ("date", PhType::Date), + ("slideImage", PhType::SlideImage), + ("body", PhType::Body), + ("footer", PhType::Footer), + ("pageNumber", PhType::SlideNum), +]; + +/// A notes-master placeholder in a page of `size`, laid out as PowerPoint's default notes master. +pub fn notes_placeholder(kind: PhType, size: Size, id: ShapeId) -> Shape { + let (w, h) = (size.width, size.height); + match kind { + PhType::SlideImage => Shape { + id, + name: "Slide Image Placeholder 3".into(), + xfrm: Some(deckcraft_geom::Xfrm::new(w * 0.125, h * 0.125, w * 0.75, h * 0.375)), + ph: Some(deckcraft_model::Placeholder { kind, idx: 2, ..Default::default() }), + fill: Some(Fill::None), + line: Some(deckcraft_model::Line::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::BLACK), 1.0)), + ..Default::default() + }, + PhType::Body => { + let mut text = TextBody::default(); + text.paragraphs = vec![deckcraft_model::text::Paragraph::new("Click to edit Master text styles")]; + Shape { + id, + name: "Notes Placeholder 4".into(), + xfrm: Some(deckcraft_geom::Xfrm::new(w * 0.1, h * 0.53, w * 0.8, h * 0.36)), + ph: Some(deckcraft_model::Placeholder { kind, idx: 3, size: Some("quarter".into()), ..Default::default() }), + text: Some(text), + ..Default::default() + } + } + _ => { + let mut s = handout_placeholder(kind, size, id); + if let Some(ph) = s.ph.as_mut() { + ph.idx = match kind { + PhType::Header => 0, + PhType::Date => 1, + PhType::Footer => 4, + _ => 5, + }; + } + s + } + } +} + +fn notes_view(s: &mut Session, _p: &Value) -> Result { + let created = s.doc()?.doc.notes_master.as_ref().is_none_or(|m| m.shapes.is_empty()); + if created { + s.edit(|doc, _| { + let mut m = doc.notes_master.as_deref().cloned().unwrap_or_else(|| deckcraft_model::Master { + name: "Notes Master".into(), + theme: doc.masters.first().map(|m| m.theme.clone()).unwrap_or_default(), + color_map: doc.masters.first().map(|m| m.color_map.clone()).unwrap_or_default(), + ..Default::default() + }); + let size = doc.notes_size; + for (_, k) in NOTES_KINDS { + let id = ShapeId(doc.alloc_id()); + m.shapes.push(notes_placeholder(k, size, id)); + } + doc.notes_master = Some(Arc::new(m)); + Ok(()) + })?; + } + s.select(|_, sel| { + sel.target = Target::NotesMaster; + sel.shapes.clear(); + sel.text = None; + })?; + Ok(json!({"created": created})) +} + +fn notes_placeholders(s: &mut Session, p: &Value) -> Result { + s.edit(|doc, _| { + let size = doc.notes_size; + let ids: Vec = NOTES_KINDS.iter().map(|_| ShapeId(doc.alloc_id())).collect(); + let m = Arc::make_mut(doc.notes_master.as_mut().ok_or_else(|| bad("notes.placeholders", "no notes master"))?); + for ((key, kind), id) in NOTES_KINDS.into_iter().zip(ids) { + let Some(on) = p.get(key).and_then(Value::as_bool) else { continue }; + let has = m.shapes.iter().any(|x| x.ph_type() == Some(kind)); + if on && !has { + m.shapes.push(notes_placeholder(kind, size, id)); + } else if !on { + m.shapes.retain(|x| x.ph_type() != Some(kind)); + } + } + Ok(()) + })?; + let m = s.doc()?.doc.notes_master.clone(); + let mut out = serde_json::Map::new(); + for (key, kind) in NOTES_KINDS { + out.insert(key.into(), json!(m.as_ref().is_some_and(|m| m.shapes.iter().any(|x| x.ph_type() == Some(kind))))); + } + Ok(Value::Object(out)) +} + fn close_master(s: &mut Session, _p: &Value) -> Result { s.select(|_, sel| { sel.target = Target::Slides; diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 8b85fadf..6f5825a9 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -291,7 +291,9 @@ fn ctx_xfrm(doc: &deckcraft_model::Presentation, sel: &crate::Selection, shape: match sel.target { crate::Target::Slides => doc.slides.get(sel.slide).and_then(|s| Ctx::for_slide(doc, s)).map(|c| resolve::xfrm(&c, shape)), crate::Target::Master { master } => doc.masters.get(master).map(|m| resolve::xfrm(&Ctx::for_master(doc, m), shape)), - crate::Target::Handout => doc.handout_master.as_ref().map(|m| resolve::xfrm(&Ctx::for_master(doc, m), shape)), + t @ (crate::Target::Handout | crate::Target::NotesMaster) => { + crate::page_master(doc, t).map(|m| resolve::xfrm(&Ctx::for_master(doc, m), shape)) + } crate::Target::Layout { master, layout } => { doc.masters.get(master).and_then(|m| m.layouts.get(layout).map(|l| resolve::xfrm(&Ctx::for_layout(doc, m, l), shape))) } @@ -345,7 +347,7 @@ pub(crate) fn current_shapes<'a>(doc: &'a deckcraft_model::Presentation, sel: &c match sel.target { crate::Target::Slides => doc.slides.get(sel.slide).map(|s| &s.shapes), crate::Target::Master { master } => doc.masters.get(master).map(|m| &m.shapes), - crate::Target::Handout => doc.handout_master.as_ref().map(|m| &m.shapes), + t @ (crate::Target::Handout | crate::Target::NotesMaster) => crate::page_master(doc, t).map(|m| &m.shapes), crate::Target::Layout { master, layout } => doc.masters.get(master).and_then(|m| m.layouts.get(layout)).map(|l| &l.shapes), } } diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 6d208150..406b4ea9 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -157,7 +157,7 @@ pub(crate) fn ctx_for<'a>(doc: &'a Presentation, sel: &Selection) -> Option doc.slides.get(sel.slide).and_then(|s| Ctx::for_slide(doc, s)), Target::Master { master } => doc.masters.get(master).map(|m| Ctx::for_master(doc, m)), - Target::Handout => doc.handout_master.as_ref().map(|m| Ctx::for_master(doc, m)), + t @ (Target::Handout | Target::NotesMaster) => crate::page_master(doc, t).map(|m| Ctx::for_master(doc, m)), Target::Layout { master, layout } => doc.masters.get(master).and_then(|m| m.layouts.get(layout).map(|l| Ctx::for_layout(doc, m, l))), } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d9b63461..52bf22c3 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -106,6 +106,32 @@ pub enum Target { }, /// The handout master (View ▸ Handout Master), a page at the notes size. Handout, + /// The notes master (View ▸ Notes Master), a page at the notes size. + NotesMaster, +} + +impl Target { + /// A notes-size master page (Handout or Notes Master view). + pub fn is_page_master(self) -> bool { + matches!(self, Target::Handout | Target::NotesMaster) + } +} + +/// The handout or notes master edited in that view. +pub fn page_master(doc: &Presentation, t: Target) -> Option<&Arc> { + match t { + Target::Handout => doc.handout_master.as_ref(), + Target::NotesMaster => doc.notes_master.as_ref(), + _ => None, + } +} + +pub fn page_master_mut(doc: &mut Presentation, t: Target) -> Option<&mut Arc> { + match t { + Target::Handout => doc.handout_master.as_mut(), + Target::NotesMaster => doc.notes_master.as_mut(), + _ => None, + } } #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] @@ -231,7 +257,7 @@ impl DocState { match self.selection.target { Target::Slides => self.current_slide().map(|s| s.shapes.as_slice()).unwrap_or(&[]), Target::Master { master } => self.doc.masters.get(master).map(|m| m.shapes.as_slice()).unwrap_or(&[]), - Target::Handout => self.doc.handout_master.as_ref().map(|m| m.shapes.as_slice()).unwrap_or(&[]), + Target::Handout | Target::NotesMaster => page_master(&self.doc, self.selection.target).map(|m| m.shapes.as_slice()).unwrap_or(&[]), Target::Layout { master, layout } => { self.doc.masters.get(master).and_then(|m| m.layouts.get(layout)).map(|l| l.shapes.as_slice()).unwrap_or(&[]) } @@ -247,13 +273,13 @@ impl DocState { /// The page being edited: the slide size, or the notes size in Handout Master view. pub fn page_size(st: &DocState) -> deckcraft_geom::Size { - if st.selection.target == Target::Handout { st.doc.notes_size } else { st.doc.slide_size } + if st.selection.target.is_page_master() { st.doc.notes_size } else { st.doc.slide_size } } -/// The handout master as a one-master presentation at the handout page size, for drawing it with -/// the layout renderer. -pub fn handout_page(doc: &Presentation) -> Option { - let m = doc.handout_master.clone()?; +/// The handout or notes master as a one-master presentation at the notes page size, for drawing +/// it with the layout renderer. +pub fn master_page(doc: &Presentation, t: Target) -> Option { + let m = page_master(doc, t)?.clone(); let mut p = doc.clone(); p.slide_size = doc.notes_size; p.masters = vec![m]; @@ -266,7 +292,7 @@ pub fn shapes_mut<'a>(doc: &'a mut Presentation, sel: &Selection) -> Option<&'a match sel.target { Target::Slides => doc.slides.get_mut(sel.slide).map(|s| &mut Arc::make_mut(s).shapes), Target::Master { master } => doc.masters.get_mut(master).map(|m| &mut Arc::make_mut(m).shapes), - Target::Handout => doc.handout_master.as_mut().map(|m| &mut Arc::make_mut(m).shapes), + t @ (Target::Handout | Target::NotesMaster) => page_master_mut(doc, t).map(|m| &mut Arc::make_mut(m).shapes), Target::Layout { master, layout } => { doc.masters.get_mut(master).and_then(|m| Arc::make_mut(m).layouts.get_mut(layout)).map(|l| &mut l.shapes) } @@ -601,7 +627,7 @@ fn clamp_selection(st: &mut DocState) { st.selection.slides = slides; match st.selection.target { Target::Master { master } if master >= st.doc.masters.len() => st.selection.target = Target::Slides, - Target::Handout if st.doc.handout_master.is_none() => st.selection.target = Target::Slides, + t @ (Target::Handout | Target::NotesMaster) if page_master(&st.doc, t).is_none() => st.selection.target = Target::Slides, Target::Layout { master, layout } if st.doc.masters.get(master).is_none_or(|m| layout >= m.layouts.len()) => { st.selection.target = Target::Slides } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9151f247..62f4c46a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1760,7 +1760,7 @@ fn handout_master_view_edits_and_round_trips() { s.execute("shape.insert", &json!({"preset": "rect", "rect": [20, 300, 100, 40]})).unwrap(); assert_eq!(s.doc().unwrap().doc.handout_master.as_ref().unwrap().shapes.len(), 4); assert_eq!(s.doc().unwrap().doc.slides[0].shapes.len(), slide_shapes); - assert!(crate::handout_page(&s.doc().unwrap().doc).is_some_and(|p| p.masters.len() == 1 && p.slide_size == p.notes_size)); + assert!(crate::master_page(&s.doc().unwrap().doc, Target::Handout).is_some_and(|p| p.masters.len() == 1 && p.slide_size == p.notes_size)); s.execute("view.closeMaster", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().selection.target, Target::Slides); // Reopening doesn't make a second master, and it survives PPTX. @@ -1771,3 +1771,33 @@ fn handout_master_view_edits_and_round_trips() { assert_eq!(hm.shapes.len(), 4); assert!(hm.shapes.iter().any(|x| x.ph_type() == Some(PhType::SlideNum))); } + +#[test] +fn notes_master_view_edits_and_saves() { + use deckcraft_model::PhType; + let mut s = Session::with_new(); + assert!(s.execute("notes.placeholders", &json!({"header": false})).is_err(), "only in the view"); + assert_eq!(s.execute("view.notesMaster", &json!({})).unwrap()["created"], true); + let st = s.doc().unwrap(); + assert_eq!(st.selection.target, Target::NotesMaster); + assert_eq!(crate::page_size(st), st.doc.notes_size); + let kinds: Vec<_> = st.shapes().iter().filter_map(|x| x.ph_type()).collect(); + assert_eq!(kinds, [PhType::Header, PhType::Date, PhType::SlideImage, PhType::Body, PhType::Footer, PhType::SlideNum]); + let r = s.execute("notes.placeholders", &json!({"header": false, "date": false})).unwrap(); + assert_eq!((r["header"].as_bool(), r["body"].as_bool()), (Some(false), Some(true))); + assert_eq!(s.doc().unwrap().shapes().len(), 4); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().shapes().len(), 6); + s.execute("notes.placeholders", &json!({"header": false})).unwrap(); + // A shape added in the view is on the notes master and is saved, even with no notes in the deck. + s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [20, 20, 60, 60]})).unwrap(); + assert!(s.doc().unwrap().doc.slides.iter().all(|sl| sl.notes.is_empty())); + assert!(crate::master_page(&s.doc().unwrap().doc, Target::NotesMaster).is_some()); + let bytes = deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap(); + let back = deckcraft_pptx::import(&bytes).unwrap(); + let nm = back.notes_master.expect("notes master written"); + assert_eq!(nm.shapes.len(), 6, "{:?}", nm.shapes.iter().map(|x| &x.name).collect::>()); + assert!(!nm.shapes.iter().any(|x| x.ph_type() == Some(PhType::Header))); + s.execute("view.closeMaster", &json!({})).unwrap(); + assert_eq!(s.execute("view.notesMaster", &json!({})).unwrap()["created"], false); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 42a27b89..470c8198 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -139,7 +139,9 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { } Some((Target::Master { master }, d)) => master_tex(ui.ctx(), &d.doc, master, None, px, visible), Some((Target::Layout { master, layout }, d)) => master_tex(ui.ctx(), &d.doc, master, Some(layout), px, visible), - Some((Target::Handout, d)) => deckcraft_engine::handout_page(&d.doc).and_then(|p| master_tex(ui.ctx(), &p, 0, None, px, visible)), + Some((t @ (Target::Handout | Target::NotesMaster), d)) => { + deckcraft_engine::master_page(&d.doc, t).and_then(|p| master_tex(ui.ctx(), &p, 0, None, px, visible)) + } None => None, }; if let Some((tex, region)) = tex { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 7803e7c6..d2a216e5 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -202,6 +202,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> { ("-", ""), ("Slide Master", "view.slideMaster"), ("Handout Master", "view.handoutMaster"), + ("Notes Master", "view.notesMaster"), ("-", ""), ("Ruler", "view.ruler"), ("Gridlines", "view.gridlines"), diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 9b55835e..b5163e60 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -111,6 +111,8 @@ fn contextual(app: &SlideApp) -> Vec<&'static str> { let Some(st) = app.session.active() else { return v }; if st.selection.target == Target::Handout { v.push("Handout Master"); + } else if st.selection.target == Target::NotesMaster { + v.push("Notes Master"); } else if st.selection.target != Target::Slides { v.push("Slide Master"); } @@ -228,6 +230,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { "Playback" => playback(app, ui), "Slide Master" => slide_master(app, ui), "Handout Master" => handout_master(app, ui), + "Notes Master" => notes_master(app, ui), _ => {} } }); @@ -1962,8 +1965,10 @@ fn view(app: &mut SlideApp, ui: &mut Ui) { if big_button_ex(ui, Icon::HandoutMaster, "Handout\nMaster", true, false, in_handout).0.clicked() { let _ = app.run(if in_handout { "view.closeMaster" } else { "view.handoutMaster" }, json!({})); } - let r = big_button(ui, Icon::NotesMaster, "Notes\nMaster", false); - let _ = r.on_hover_text("Notes Master (coming soon)"); + let in_notes = app.session.active().is_some_and(|d| d.selection.target == Target::NotesMaster); + if big_button_ex(ui, Icon::NotesMaster, "Notes\nMaster", true, false, in_notes).0.clicked() { + let _ = app.run(if in_notes { "view.closeMaster" } else { "view.notesMaster" }, json!({})); + } }); group(ui, |ui| { rows(ui, |ui| { @@ -2685,6 +2690,36 @@ fn slide_master(app: &mut SlideApp, ui: &mut Ui) { }); } +fn notes_master(app: &mut SlideApp, ui: &mut Ui) { + group(ui, |ui| { + use deckcraft_model::PhType; + let shown = |app: &SlideApp, k: PhType| { + app.session.active().and_then(|d| d.doc.notes_master.as_ref()).is_some_and(|m| m.shapes.iter().any(|s| s.ph_type() == Some(k))) + }; + for col in [ + [("Header", "header", PhType::Header), ("Slide Image", "slideImage", PhType::SlideImage), ("Footer", "footer", PhType::Footer)], + [("Date", "date", PhType::Date), ("Body", "body", PhType::Body), ("Page Number", "pageNumber", PhType::SlideNum)], + ] { + rows(ui, |ui| { + for (label, key, k) in col { + let mut on = shown(app, k); + if ui.checkbox(&mut on, label).changed() { + run(app, "notes.placeholders", json!({ key: on })); + } + } + }); + } + }); + group(ui, |ui| { + big(app, ui, Icon::FormatBackground, "Background\nStyles", "view.pane", json!({"pane": "background"})); + }); + group(ui, |ui| { + if big_button(ui, Icon::Close, "Close\nMaster View", true).clicked() { + run(app, "view.closeMaster", json!({})); + } + }); +} + fn handout_master(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { let r = big_button(ui, Icon::Layout, &format!("Slides Per\nPage: {}", app.ui.handout_per_page), true); diff --git a/crates/ui-egui/src/thumbs.rs b/crates/ui-egui/src/thumbs.rs index 2fbc43db..067a00c6 100644 --- a/crates/ui-egui/src/thumbs.rs +++ b/crates/ui-egui/src/thumbs.rs @@ -12,7 +12,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let in_master = app.session.active().is_some_and(|d| d.selection.target != Target::Slides); // Handout Master view has one page and no thumbnails, as in PowerPoint. - if app.session.active().is_some_and(|d| d.selection.target == Target::Handout) { + if app.session.active().is_some_and(|d| d.selection.target.is_page_master()) { return; } egui::Panel::left("thumbs") diff --git a/docs/gaps.md b/docs/gaps.md index c58390c0..e2e1c270 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -245,10 +245,11 @@ Detail in [animation-parity.md](animation-parity.md). 10–16 h; the pane is a * - **Estimate:** 4–8 h. ### Notes and Handout masters -- **Evidence:** View ▸ Handout Master edits the handout master (header, date, footer and page - number placeholders, background, slides-per-page preview) and PPTX keeps it. There's no Notes - Master view yet, and PDF handouts don't use the handout master's text. -- **Estimate:** 2–3 h (Notes Master view), 1 h (handout text in PDF). +- **Evidence:** View ▸ Handout Master and View ▸ Notes Master edit both masters (placeholders, + background, a slides-per-page preview for handouts) and PPTX keeps them. The notes master's slide + image is an empty frame rather than a slide preview, and notes pages and PDF handouts don't take + their layout and text from the masters yet. +- **Estimate:** 2–3 h (notes pages and PDF handouts follow the masters). ### Edit points, text warp, 3-D - **Evidence:** checklist rows "Edit points" (P1 missing), "Text warp transforms" (partial: all 40 @@ -383,6 +384,7 @@ Detail in [localization-parity.md](localization-parity.md). | 2026-10-14 | minor | Size and Position dialog landed (position, size, rotation, scale, lock aspect, one undo). The dialog gap remains Font, Bullets and Numbering, Find/Replace, Replace Fonts, Action Settings, Print, Insert Object and the rest of the ~60 | | 2026-10-14 | minor | File › New has six original starter decks; a large template gallery is still open | | 2026-10-13 | minor | Artistic effects: 7 of 22 (blur, pencil grayscale, line drawing, film grain, cutout, glow edges, photocopy) applied, removed and round-tripped (`a14:imgLayer`) | +| 2026-10-14 | minor | Notes Master view: the masters gap is now notes pages and PDF handouts following the masters | | 2026-10-14 | minor | Handout Master view: the masters gap is now the Notes Master view and handout text in PDF | | 2026-10-13 | minor | Owned PPTX corpus for tables, charts, animations, media, sections and masters. Handout-master text is kept; the corpus gap is now a larger set and PowerPoint reference renders | | 2026-10-13 | minor | Shape, picture and table rendering checked against PowerPoint on ~150 synthetic edge-case slides; 3-D bevels, cameras and face lighting now render approximately (gap narrowed to extrusion, rigs and the 3-D pane) |