From 13e06cace2490d980e4d5a3668363ba7f3590283 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Thu, 15 Oct 2026 19:53:39 +0200 Subject: [PATCH 01/37] =?UTF-8?q?Shapes:=20Edit=20Points=20=E2=80=94=20Str?= =?UTF-8?q?aight/Curved=20Segment=20and=20Open/Close=20Path=20from=20the?= =?UTF-8?q?=20right-click=20menu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ROADMAP.md | 1 + crates/engine/src/cmd/points.rs | 192 ++++++++++++++++++++++++++++++-- crates/engine/src/keys.rs | 1 + crates/engine/src/tests.rs | 100 +++++++++++++++++ crates/engine/src/tools.rs | 12 ++ crates/ui-egui/src/canvas.rs | 63 ++++++++++- docs/gaps.md | 8 +- docs/ui-parity.md | 3 +- 8 files changed, 363 insertions(+), 17 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8ea976e6c..5e4b63353 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-15 | Edit Points: right-click a segment for Straight/Curved Segment, a point for Open/Close Path (`shape.segmentType`, `shape.openPath`, `shape.closePath`); Delete Segment and Add Point in the menu still to come | | 2026-10-15 | PDF export and rendering: `anchorCtr` centres the drawn text block — right-aligned text no longer ran past the box edge (#736) | | 2026-10-14 | connected MCP rejects unrelated, malformed, incomplete and oversized replies without replay; buffered unsolicited bytes force a fresh connection before sending (#718) | | 2026-10-14 | Empty bulleted and numbered paragraphs show gray markers while editing, including on first click and after Enter. Markers use the list style and stay out of slide shows and exports. | diff --git a/crates/engine/src/cmd/points.rs b/crates/engine/src/cmd/points.rs index e8385d39d..37cb5deaa 100644 --- a/crates/engine/src/cmd/points.rs +++ b/crates/engine/src/cmd/points.rs @@ -24,7 +24,7 @@ pub fn specs() -> Vec { has_selection, edit_points ), - cmd!(query "shape.points", "Points", [], None, "{id?} → {id, paths: [[[x, y] slide pt]], types: [[smooth|straight|corner]]} vertices of a freeform", has_selection, points), + cmd!(query "shape.points", "Points", [], None, "{id?} → {id, paths: [[[x, y] slide pt]], types: [[smooth|straight|corner]], segments: [[straight|curved|null]]} vertices of a freeform and the segment ending at each", has_selection, points), cmd!("shape.movePoint", "Move Point", [], None, "{path?: 0, index, x, y: slide pt, id?}", has_selection, move_point), cmd!( "shape.addPoint", @@ -54,6 +54,33 @@ pub fn specs() -> Vec { move_handle ), cmd!("shape.deletePoint", "Delete Point", [], None, "{path?: 0, index, id?}", has_selection, delete_point), + cmd!( + "shape.segmentType", + "Segment Type", + [], + None, + "{path?: 0, segment: index of the vertex ending it, type: straight|curved, id?} — straight drops the segment's handles; curved gives it handles at its thirds", + has_selection, + segment_type + ), + cmd!( + "shape.openPath", + "Open Path", + [], + None, + "{path?: 0, index, id?} — break a closed outline at a point, which becomes its two ends", + has_selection, + open_path + ), + cmd!( + "shape.closePath", + "Close Path", + [], + None, + "{path?: 0, index: any point of the open outline, id?} — join its ends with a straight segment (one point when they meet)", + has_selection, + close_path + ), ] } @@ -281,6 +308,76 @@ fn remove_vertex(segs: &mut Vec, si: usize) -> bool { true } +/// The sub-path holding segment `si`: from its move up to the next move (or the end). +fn subpath(segs: &[Seg], si: usize) -> (usize, usize) { + let from = segs.get(..=si).and_then(|s| s.iter().rposition(|x| matches!(x, Seg::M(_)))).unwrap_or(0); + let to = (si + 1..segs.len()).find(|j| matches!(segs.get(*j), Some(Seg::M(_)))).unwrap_or(segs.len()); + (from, to) +} + +/// The segment that ends at the vertex ending segment `si`: the closing edge for a closed +/// sub-path's start, none for an open one's. +fn ending_segment(segs: &[Seg], si: usize) -> Option { + if matches!(segs.get(si), Some(Seg::M(_))) { + (si + 1..segs.len()).take_while(|j| !matches!(segs.get(*j), Some(Seg::M(_)))).find(|j| closes_onto_start(segs, *j)) + } else { + Some(si) + } +} + +/// Whether segment `j` is a straight line or a curve. +fn segment_kind(segs: &[Seg], j: usize) -> Option<&'static str> { + match segs.get(j)? { + Seg::L(_) => Some("straight"), + Seg::C(..) | Seg::Q(..) => Some("curved"), + Seg::M(_) | Seg::Z => None, + } +} + +/// Makes segment `j` a straight line (dropping its handles) or a curve with handles at its +/// thirds, so it keeps its shape until a handle is dragged. +fn set_segment(segs: &mut [Seg], j: usize, curved: bool) { + if curved { + to_cubic(segs, j); + } else if let Some(s) = segs.get_mut(j) + && let Seg::C(_, _, p) | Seg::Q(_, p) = *s + { + *s = Seg::L(p); + } +} + +/// Opens the closed sub-path through the vertex ending segment `si` there: it now starts and ends +/// on that vertex, as two points. Returns false for an open sub-path. +fn open_at(segs: &mut Vec, si: usize) -> bool { + let (from, to) = subpath(segs, si); + let Some(z) = to.checked_sub(1).filter(|z| *z > si && matches!(segs.get(*z), Some(Seg::Z))) else { return false }; + let Some(v) = segs.get(si).and_then(Seg::end) else { return false }; + let (Some(after), Some(before)) = (segs.get(si + 1..z), segs.get(from + 1..=si)) else { return false }; + let mut out = vec![Seg::M(v)]; + out.extend_from_slice(after); + out.extend_from_slice(before); + segs.splice(from..to, out); + true +} + +/// Closes the open sub-path through the vertex ending segment `si`: a straight segment joins its +/// ends, or they become one point when they meet. Returns false for a closed sub-path. +fn close_at(segs: &mut Vec, si: usize) -> bool { + let (from, to) = subpath(segs, si); + let last = to.saturating_sub(1); + if last <= from || matches!(segs.get(last), Some(Seg::Z)) { + return false; + } + let (Some(Seg::M(start)), Some(end)) = (segs.get(from).copied(), segs.get(last).and_then(Seg::end)) else { return false }; + let mut add = Vec::new(); + if (start - end).hypot() >= 1e-6 { + add.push(Seg::L(start)); + } + add.push(Seg::Z); + segs.splice(to..to, add); + true +} + /// Custom paths of a preset shape, in shape-local points. fn to_custom(sh: &Shape, w: f64, h: f64) -> Vec { let geo = deckcraft_render::shape_geometry(sh, w, h); @@ -402,6 +499,7 @@ pub(crate) fn prepare(s: &mut Session, id: ShapeId) -> Result<()> { fn edit_points(s: &mut Session, p: &Value) -> Result { let on = p.get("on").and_then(Value::as_bool).unwrap_or(s.tool.edit_points.is_none()); s.tool.edit_vertex = None; + s.tool.edit_segment = None; if !on { s.tool.edit_points = None; return Ok(json!({"editing": Value::Null})); @@ -436,17 +534,16 @@ fn points(s: &mut Session, p: &Value) -> Result { let sh = st.shape(id).ok_or_else(|| bad("shape.points", "no such shape"))?; let paths: Vec> = slide_vertices(&st.doc, &st.selection, sh).into_iter().map(|v| v.into_iter().map(|q| [q.x, q.y]).collect()).collect(); - let types: Vec> = match &sh.geom { - Geom::Custom { paths } => paths - .iter() - .map(|cp| { - let segs = parse(&cp.d); - vertices(&segs).iter().map(|si| point_kind(&segs, *si)).collect() - }) - .collect(), - Geom::Preset { .. } => vec![], - }; - Ok(json!({"id": id.0, "paths": paths, "types": types})) + let (mut types, mut kinds): (Vec>, Vec>>) = (vec![], vec![]); + if let Geom::Custom { paths } = &sh.geom { + for cp in paths { + let segs = parse(&cp.d); + let vs = vertices(&segs); + types.push(vs.iter().map(|si| point_kind(&segs, *si)).collect()); + kinds.push(vs.iter().map(|si| ending_segment(&segs, *si).and_then(|j| segment_kind(&segs, j))).collect()); + } + } + Ok(json!({"id": id.0, "paths": paths, "types": types, "segments": kinds})) } /// Runs `f` on the segments of path `path` of `id` (in shape-local points), then refits the box. @@ -784,3 +881,74 @@ pub fn kind_of(sh: &Shape, path: usize, index: usize) -> Option<&'static str> { pub(crate) fn move_handle_raw(s: &mut Session, p: &Value) -> Result { move_handle(s, p) } + +fn segment_type(s: &mut Session, p: &Value) -> Result { + let id = target(s, p, "shape.segmentType")?; + let path = p.get("path").and_then(Value::as_u64).unwrap_or(0) as usize; + let seg = index_param(p, "segment", "shape.segmentType")?; + let curved = match str_param(p, "type") { + Some("straight") => false, + Some("curved") => true, + _ => return Err(bad("shape.segmentType", "`type` is straight or curved")), + }; + // Already that type: nothing to change, so no undo step. + let want = if curved { "curved" } else { "straight" }; + if s.doc()?.shape(id).is_some_and(|sh| segment_kind_of(sh, path, seg) == Some(want)) { + return ok(); + } + edit_path(s, id, path, "shape.segmentType", move |segs, vs, _| { + let si = *vs.get(seg).ok_or_else(|| bad("shape.segmentType", "no such segment"))?; + let j = ending_segment(segs, si).ok_or_else(|| bad("shape.segmentType", "the first point of an open outline ends no segment"))?; + set_segment(segs, j, curved); + Ok(true) + }) +} + +fn open_path(s: &mut Session, p: &Value) -> Result { + let id = target(s, p, "shape.openPath")?; + let path = p.get("path").and_then(Value::as_u64).unwrap_or(0) as usize; + let index = index_param(p, "index", "shape.openPath")?; + let r = edit_path(s, id, path, "shape.openPath", move |segs, vs, _| { + let si = *vs.get(index).ok_or_else(|| bad("shape.openPath", "no such point"))?; + if open_at(segs, si) { Ok(true) } else { Err(bad("shape.openPath", "the outline is open already")) } + })?; + forget_kinds(s, path); + Ok(r) +} + +fn close_path(s: &mut Session, p: &Value) -> Result { + let id = target(s, p, "shape.closePath")?; + let path = p.get("path").and_then(Value::as_u64).unwrap_or(0) as usize; + let index = index_param(p, "index", "shape.closePath")?; + let r = edit_path(s, id, path, "shape.closePath", move |segs, vs, _| { + let si = *vs.get(index).ok_or_else(|| bad("shape.closePath", "no such point"))?; + if close_at(segs, si) { Ok(true) } else { Err(bad("shape.closePath", "the outline is closed already")) } + })?; + forget_kinds(s, path); + Ok(r) +} + +/// Opening or closing renumbers the points of `path`: a point type remembered for one of them +/// would follow the wrong point. +fn forget_kinds(s: &mut Session, path: usize) { + if s.tool.vertex_kind.as_ref().is_some_and(|k| k.0 == path) { + s.tool.vertex_kind = None; + } +} + +/// Whether the segment ending at vertex `index` of path `path` of `sh` is straight or curved. +pub fn segment_kind_of(sh: &Shape, path: usize, index: usize) -> Option<&'static str> { + let Geom::Custom { paths } = &sh.geom else { return None }; + let segs = parse(&paths.get(path)?.d); + let si = *vertices(&segs).get(index)?; + segment_kind(&segs, ending_segment(&segs, si)?) +} + +/// Whether the outline through vertex `index` of path `path` of `sh` is closed. +pub fn closed_at(sh: &Shape, path: usize, index: usize) -> Option { + let Geom::Custom { paths } = &sh.geom else { return None }; + let segs = parse(&paths.get(path)?.d); + let si = *vertices(&segs).get(index)?; + let (_, to) = subpath(&segs, si); + Some(matches!(segs.get(to.checked_sub(1)?), Some(Seg::Z))) +} diff --git a/crates/engine/src/keys.rs b/crates/engine/src/keys.rs index 38b5aa713..ebc2eae21 100644 --- a/crates/engine/src/keys.rs +++ b/crates/engine/src/keys.rs @@ -60,6 +60,7 @@ impl Session { if self.tool.edit_points.is_some() && matches!(k, "enter" | "return" | "escape") { self.tool.edit_points = None; self.tool.edit_vertex = None; + self.tool.edit_segment = None; return Ok(json!({"handled": true, "command": "shape.editPoints"})); } if editing && let Some((id, params)) = self.text_key(k, mods) { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index cc872e664..6c1586284 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2048,6 +2048,106 @@ fn edit_points_types_and_handles() { assert!((ho3.unwrap() - ho).hypot() < 1e-3); } +#[test] +fn edit_points_straight_and_curved_segments() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap(); + s.execute("shape.editPoints", &json!({"on": true})).unwrap(); + let q = |s: &mut Session| s.execute("shape.points", &json!({})).unwrap(); + let before = q(&mut s)["paths"].clone(); + // The closing edge ends on the first point, so it is segment 0; an outline starts straight. + assert_eq!(q(&mut s)["segments"][0], json!(["straight", "straight", "straight", "straight"])); + // Curved Segment: the top edge gets handles at its thirds, so it looks the same until dragged. + s.execute("shape.segmentType", &json!({"segment": 1, "type": "curved"})).unwrap(); + assert_eq!(q(&mut s)["segments"][0], json!(["straight", "curved", "straight", "straight"])); + assert_eq!(q(&mut s)["paths"], before); + let id = deckcraft_model::ShapeId(q(&mut s)["id"].as_u64().unwrap() as u32); + let handles = |s: &Session, i| { + let st = s.doc().unwrap(); + crate::cmd::points::slide_handles(&st.doc, &st.selection, st.shape(id).unwrap(), 0, i).unwrap() + }; + let (out0, in1) = (handles(&s, 0).1.unwrap(), handles(&s, 1).0.unwrap()); + assert!((out0 - deckcraft_geom::Point::new(100.0 + 200.0 / 3.0, 100.0)).hypot() < 1e-3, "{out0:?}"); + assert!((in1 - deckcraft_geom::Point::new(100.0 + 400.0 / 3.0, 100.0)).hypot() < 1e-3, "{in1:?}"); + // Bend it, then Straight Segment drops its handles; the points stay where they are. + s.execute("shape.moveHandle", &json!({"index": 1, "side": "in", "x": in1.x, "y": 60})).unwrap(); + let bent = q(&mut s)["paths"].clone(); + s.execute("shape.segmentType", &json!({"segment": 1, "type": "straight"})).unwrap(); + assert_eq!(q(&mut s)["segments"][0][1], "straight"); + assert_eq!((handles(&s, 0).1, handles(&s, 1).0), (None, None)); + assert_eq!(q(&mut s)["paths"], before); + // Each change is one undo step. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(q(&mut s)["segments"][0][1], "curved"); + assert_eq!(q(&mut s)["paths"], bent); + // The closing edge is segment 0. + s.execute("shape.segmentType", &json!({"segment": 0, "type": "curved"})).unwrap(); + assert_eq!(q(&mut s)["segments"][0][0], "curved"); + // Asking for the type a segment already has is not an undo step. + let depth = s.doc().unwrap().history.undo.len(); + s.execute("shape.segmentType", &json!({"segment": 2, "type": "straight"})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), depth); + assert!(s.execute("shape.segmentType", &json!({"segment": 9, "type": "curved"})).is_err()); + assert!(s.execute("shape.segmentType", &json!({"segment": 1, "type": "wavy"})).is_err()); +} + +#[test] +fn edit_points_open_and_close_path() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap(); + s.execute("shape.editPoints", &json!({"on": true})).unwrap(); + let pts = + |s: &mut Session| -> Vec<[f64; 2]> { serde_json::from_value(s.execute("shape.points", &json!({})).unwrap()["paths"][0].clone()).unwrap() }; + let d = |s: &Session| match &s.doc().unwrap().selected_shapes()[0].geom { + deckcraft_model::Geom::Custom { paths } => paths[0].d.clone(), + g => panic!("{g:?}"), + }; + assert!(s.execute("shape.closePath", &json!({"index": 0})).is_err(), "the outline is closed already"); + // A point type remembered for handle drags belongs to the old numbering: opening forgets it. + s.execute("shape.pointType", &json!({"index": 2, "type": "straight"})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.tool.vertex_kind.is_some()); + // Open Path at the bottom-right corner: the outline now starts and ends there, as two points. + s.execute("shape.openPath", &json!({"index": 2})).unwrap(); + assert!(!d(&s).contains('Z'), "{}", d(&s)); + assert_eq!(s.tool.vertex_kind, None); + let v = pts(&mut s); + assert_eq!(v, vec![[300.0, 200.0], [100.0, 200.0], [100.0, 100.0], [300.0, 100.0], [300.0, 200.0]]); + assert!(s.execute("shape.openPath", &json!({"index": 1})).is_err(), "the outline is open already"); + // The two ends move apart; Close Path joins them with a straight segment. + s.execute("shape.movePoint", &json!({"index": 4, "x": 320, "y": 180})).unwrap(); + s.execute("shape.closePath", &json!({"index": 1})).unwrap(); + assert!(d(&s).ends_with('Z'), "{}", d(&s)); + assert_eq!(pts(&mut s).len(), 5); + assert_eq!(s.execute("shape.points", &json!({})).unwrap()["segments"][0][0], "straight"); + // Ends that meet become one point again. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("shape.closePath", &json!({"index": 4})).unwrap(); + assert_eq!(pts(&mut s), vec![[300.0, 200.0], [100.0, 200.0], [100.0, 100.0], [300.0, 100.0]]); + // Opening at the first point keeps the order. + s.execute("shape.openPath", &json!({"index": 0})).unwrap(); + assert_eq!(pts(&mut s), vec![[300.0, 200.0], [100.0, 200.0], [100.0, 100.0], [300.0, 100.0], [300.0, 200.0]]); + assert!(s.execute("shape.openPath", &json!({"index": 9})).is_err()); +} + +#[test] +fn edit_points_right_click_picks_the_point_or_the_segment() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap(); + s.execute("shape.editPoints", &json!({"on": true})).unwrap(); + // The middle of the right edge: the segment ending at point 2. + right_click(&mut s, 300.0, 150.0); + assert_eq!((s.tool.edit_segment, s.tool.edit_vertex), (Some((0, 2)), None)); + right_click(&mut s, 300.0, 100.0); + assert_eq!((s.tool.edit_segment, s.tool.edit_vertex), (None, Some((0, 1)))); + right_click(&mut s, 200.0, 150.0); + assert_eq!((s.tool.edit_segment, s.tool.edit_vertex), (None, None)); +} + /// #392: a `.deckcraft` whose embedded picture is damaged opens as an untitled "(Repaired)" /// copy with a message, so Save can't overwrite the original with the empty media. #[test] diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index f1acd9e9a..4d8a8ddc3 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -261,6 +261,9 @@ pub struct ToolState { pub edit_points: Option, /// The vertex (path, index) whose control handles show in Edit Points. pub edit_vertex: Option<(usize, usize)>, + /// The segment (path, index of the vertex ending it) last right-clicked in Edit Points, for + /// the segment menu. + pub edit_segment: Option<(usize, usize)>, /// The point type last chosen for a vertex (path, index, type): handle drags follow it. pub vertex_kind: Option<(usize, usize, String)>, /// Drawing guides are shown (View › Guides): they can be dragged and shapes snap to them. @@ -1199,6 +1202,11 @@ impl Session { ) => Some(*id), None => None, }; + // Edit Points: the point or else the segment under the pointer picks the menu. + let picked = self.tool.editing_points(st).and_then(|eid| st.shape(eid)).map(|sh| match &hit { + Some(Hit::Vertex { path, index, .. }) => (Some((*path, *index)), None), + _ => (None, cmd::points::segment_at(&st.doc, &st.selection, sh, p, tol * 1.6).map(|(path, seg, _)| (path, seg))), + }); if !id.is_some_and(|id| st.selection.shapes.contains(&id)) { self.select(|_, sel| { sel.text = None; @@ -1206,6 +1214,10 @@ impl Session { sel.shapes = id.into_iter().collect(); })?; } + if let Some((vertex, segment)) = picked { + self.tool.edit_vertex = vertex; + self.tool.edit_segment = segment; + } let st = self.doc()?; Ok(json!({ "hit": serde_json::to_value(&hit).unwrap_or_default(), diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ba66aad81..da69e3aee 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1082,7 +1082,16 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) { resp.context_menu(|ui| { let editing = app.session.active().and_then(|d| app.session.tool.editing_points(d).and_then(|id| d.shape(id).cloned())); if let Some(sh) = editing { - if let Some((path, index)) = app.session.tool.edit_vertex { + if let Some((path, index)) = app.session.tool.edit_segment { + let cur = deckcraft_engine::cmd::points::segment_kind_of(&sh, path, index); + for (l, k) in [("Straight Segment", "straight"), ("Curved Segment", "curved")] { + if ui.radio(cur == Some(k), l).clicked() { + let _ = app.run("shape.segmentType", json!({"path": path, "segment": index, "type": k})); + ui.close(); + } + } + ui.separator(); + } else if let Some((path, index)) = app.session.tool.edit_vertex { let cur = deckcraft_engine::cmd::points::kind_of(&sh, path, index); for (l, k) in [("Smooth Point", "smooth"), ("Straight Point", "straight"), ("Corner Point", "corner")] { if ui.radio(cur == Some(k), l).clicked() { @@ -1095,6 +1104,16 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) { app.session.tool.edit_vertex = None; ui.close(); } + let (label, id) = match deckcraft_engine::cmd::points::closed_at(&sh, path, index) { + Some(false) => ("Close Path", "shape.closePath"), + _ => ("Open Path", "shape.openPath"), + }; + if ui.button(label).clicked() { + let _ = app.run(id, json!({"path": path, "index": index})); + // The points are numbered from the new start now. + app.session.tool.edit_vertex = None; + ui.close(); + } ui.separator(); } if ui.button("Exit Edit Points").clicked() { @@ -1354,6 +1373,7 @@ mod tests { use deckcraft_engine::Session; use egui::{Event, Modifiers, MouseWheelUnit, Pos2, TouchPhase, pos2, vec2}; use egui_kittest::Harness; + use egui_kittest::kittest::Queryable; use super::*; use crate::{Services, SlideApp}; @@ -1583,6 +1603,47 @@ mod tests { assert!((t.cols[0] - 240.0).abs() < 0.5 && (t.cols[1] - 160.0).abs() < 0.5, "{:?}", t.cols); } + fn right_click(h: &mut Harness<'_, SlideApp>, pos: Pos2) { + h.event(Event::PointerMoved(pos)); + h.step(); + for pressed in [true, false] { + h.event(Event::PointerButton { pos, button: egui::PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(2); + } + + /// In Edit Points, right-clicking a segment offers Straight/Curved Segment and right-clicking + /// a point offers Open Path, then Close Path once the outline is open. + #[test] + fn edit_points_menu_sets_segment_types_and_opens_the_path() { + let mut h = harness(); + h.state_mut().run("slide.layout", json!({"layout": "blank"})).unwrap(); + h.state_mut().run("shape.insert", json!({"preset": "rect", "rect": [200, 150, 300, 200]})).unwrap(); + h.state_mut().run("shape.editPoints", json!({"on": true})).unwrap(); + h.run_steps(3); + let q = |h: &mut Harness<'_, SlideApp>| h.state_mut().session.execute("shape.points", &json!({})).unwrap(); + // The middle of the right edge: the segment ending at point 2. + let p = screen(h.state(), 500.0, 250.0); + right_click(&mut h, p); + if let (Ok(path), Ok(img)) = (std::env::var("DECKCRAFT_PNG_OUT_SEGMENTS"), h.render()) { + let _ = img.save(path); + } + h.get_by_label("Curved Segment").click(); + h.run_steps(3); + assert_eq!(q(&mut h)["segments"][0], json!(["straight", "straight", "curved", "straight"])); + // The bottom-right corner: Open Path breaks the outline there. + let p = screen(h.state(), 500.0, 350.0); + right_click(&mut h, p); + h.get_by_label("Open Path").click(); + h.run_steps(3); + assert_eq!(q(&mut h)["paths"][0].as_array().map(Vec::len), Some(5)); + right_click(&mut h, p); + h.get_by_label("Close Path").click(); + h.run_steps(3); + assert_eq!(q(&mut h)["paths"][0].as_array().map(Vec::len), Some(4)); + } + #[test] fn grid_step_follows_spacing_and_rejects_unusable_values() { let step = super::grid_line_step(72.0, 0.5).unwrap_or(0.0); diff --git a/docs/gaps.md b/docs/gaps.md index 4e5649512..628436440 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -232,7 +232,8 @@ of the effect gallery. Detail in [animation-parity.md](animation-parity.md). 8 ### Edit points, text warp, 3-D - **Evidence:** checklist rows "Edit points" (P1 partial: Edit Points converts a shape to a freeform, vertices drag, Ctrl/Cmd-click adds or deletes, smooth/straight/corner points and Bézier - handles; no Straight/Curved Segment or Open/Close Path), "Text warp transforms" (partial: all 40 + handles, Straight/Curved Segment on a segment and Open/Close Path on a point; the menu has no + Delete Segment or Add Point), "Text warp transforms" (partial: all 40 `prstTxWarp` presets render, their adjusts round-trip, Shape Format ▸ Text Effects ▸ Transform applies one with `format.textWarp`, and a selected warp shows a yellow handle per adjust that dragging sets; arc/circle placement can differ from PowerPoint by 1–3 % of the path length), @@ -242,8 +243,8 @@ of the effect gallery. Detail in [animation-parity.md](animation-parity.md). 8 shade bevels and sides approximately (fitted to PowerPoint for three-point/matte only); contours draw as a band round the face outline (not round the extruded silhouette); Format Shape ▸ Effects has 3-D Format and 3-D Rotation). -- **Estimate:** 1 h (edit points: segment types, open/close path), 1–2 h (fitting the other rigs - and materials to PowerPoint renders). +- **Estimate:** 1 h (edit points: Delete Segment and Add Point in the menu), 1–2 h (fitting the + other rigs and materials to PowerPoint renders). ### Proofing: spelling, thesaurus, AutoCorrect - **Evidence:** `review.spelling` checks with Hunspell-format dictionaries the system already has @@ -355,6 +356,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | Edit Points: Straight/Curved Segment on a right-clicked segment and Open/Close Path on a point (`shape.segmentType`, `shape.openPath`, `shape.closePath`); left: Delete Segment and Add Point in the menu | | 2026-10-14 | trivial | Fixed #718: remote MCP no longer accepts unrelated or buffered stale success; loopback regressions cover invalid envelopes, incomplete and oversized frames | | 2026-10-14 | minor | Community PR wave folded: one entry each for the soak tests (engine and UI), the PPTX corpus (seven owned corpus files), edit points / text warp / 3-D, proofing (Hunspell spelling, thesaurus, AutoCorrect), the smaller missing rows, dialogs (41 vs ~60; Print and Insert Object left) and pen/trackpad input; ranked rows 3, 4 and 10 de-duplicated (comment threads #70 and the pane timeline are done); the fixed command-palette entry (#79) removed | | 2026-10-13 | minor | EMF/WMF rectangle clip regions (`INTERSECTCLIPRECT`, `EXCLUDECLIPRECT`, `EXTSELECTCLIPRGN`) limit later drawing. EMF+ records remain | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index cc97dae47..a3ca74c1c 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -86,7 +86,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Nudge (arrows), fine nudge (Option+arrows) | yes | yes | | Shift-constrained move, Option-drag to duplicate | yes | yes | | Connector glue and rerouting | yes | yes | -| Edit points on freeforms | yes | partial: Edit Shape ▸ Edit Points turns any shape into a freeform; drag vertices and their Bézier handles, Ctrl/Cmd-click to add or delete, right-click for Smooth/Straight/Corner Point and Delete Point; no Straight/Curved Segment or Open/Close Path yet | +| Edit points on freeforms | yes | partial: Edit Shape ▸ Edit Points turns any shape into a freeform; drag vertices and their Bézier handles, Ctrl/Cmd-click to add or delete, right-click a point for Smooth/Straight/Corner Point, Delete Point and Open/Close Path, or a segment for Straight/Curved Segment; no Delete Segment or Add Point in the menu yet | | WordArt adjust handles | yellow handles on transformed text | yes, one per adjust when the text has a warp; dragging sets that guide value | | Crop handles, crop to shape, aspect ratio | yes | partial | | Text caret, selection, double/triple click, drag-select | yes | yes | @@ -165,6 +165,7 @@ consistency is open (#73, #56). | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | Edit Points: the right-click menu has Straight/Curved Segment on a segment and Open/Close Path on a point | | 2026-10-14 | minor | Gray list markers on empty paragraphs while editing; caret keeps the list indent before typing | | 2026-10-14 | minor | Community PR wave folded: one row each for Format Shape, Dialogs (41, listed from `dialogs.rs`), Animation Pane (timeline is in), window, Quick Access Toolbar, Format Background, rulers, image paste, context menus, exact size and edit points; estimates re-totalled (~26–46 h) | | 2026-10-13 | trivial | Eyedropper in the colour menus (#367) | From beb883d036c0a5094ca2f316b481ed971b45eca8 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 00:10:13 +0300 Subject: [PATCH 02/37] Files: save and open outlines as RTF Adds PowerPoint's Outline/RTF save type: slide titles at outline level 0 (bold, larger), body paragraphs indented at their outline level, bold, italic and underline kept, non-ASCII as \u escapes. RTF outlines (ours, PowerPoint's or a word processor's, by outline level or indent) open as a presentation or drop in as slides. Co-authored-by: Cursor --- crates/engine/src/cmd/file.rs | 19 +- crates/engine/src/tests.rs | 18 ++ crates/format/src/lib.rs | 1 + crates/format/src/rtf.rs | 321 ++++++++++++++++++++++++++++++++ crates/mcp/src/tools.rs | 2 +- crates/ui-egui/src/backstage.rs | 3 +- crates/ui-egui/src/dialogs.rs | 1 + crates/ui-egui/src/lib.rs | 7 +- docs/file-format-parity.md | 2 +- docs/parity-checklist.md | 6 +- 10 files changed, 367 insertions(+), 13 deletions(-) create mode 100644 crates/format/src/rtf.rs diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 4761dc103..fa3915dd8 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -33,7 +33,7 @@ pub fn specs() -> Vec { cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path, format?}", has_doc, save_as), cmd!(noundo "file.saveTemplate", "Save as Template…", ["File"], None, "{path}", has_doc, save_as), cmd!(query "file.saveBytes", "Save to Data", [], None, "{format?: deckcraft|pptx} → {data: base64}", has_doc, save_bytes_cmd), - cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format?: png|jpeg|pptx|deckcraft|outline|pdf|gif (else by path), slide?: index, all?: bool, scale?: px per pt, layout?: slides|notes|handouts (pdf), perPage?: 1|2|3|4|6|9, paper?: letter|a4|legal|a3 (pdf), orientation?: portrait|landscape (pdf), dpi?, slides?: [index]|range (\"1-3,5\"), includeHidden?, textLayer?, frame?, width?: px 16-3840|small|medium|large|extraLarge (gif), secondsPerSlide?: 0.1-600 (gif; 5), loop?: bool (gif; true: forever)} → {path, bytes} or {files} (png, jpeg); gif adds frames, width, height, secondsPerSlide", has_doc, export), + cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format?: png|jpeg|pptx|deckcraft|outline|rtf|pdf|gif (else by path), slide?: index, all?: bool, scale?: px per pt, layout?: slides|notes|handouts (pdf), perPage?: 1|2|3|4|6|9, paper?: letter|a4|legal|a3 (pdf), orientation?: portrait|landscape (pdf), dpi?, slides?: [index]|range (\"1-3,5\"), includeHidden?, textLayer?, frame?, width?: px 16-3840|small|medium|large|extraLarge (gif), secondsPerSlide?: 0.1-600 (gif; 5), loop?: bool (gif; true: forever)} → {path, bytes} or {files} (png, jpeg); gif adds frames, width, height, secondsPerSlide", has_doc, export), cmd!( query "file.info", "Info", @@ -146,8 +146,10 @@ fn open_other(name: &str, bytes: &[u8]) -> Result { return Err(EngineError::Other(format!("{name} is password-protected. DeckCraft can't open password-protected presentations yet."))); } let lower = name.to_ascii_lowercase(); - if lower.ends_with(".txt") || lower.ends_with(".md") { + let rtf = deckcraft_format::rtf::sniff_rtf(bytes); + if rtf || lower.ends_with(".txt") || lower.ends_with(".md") { let text = String::from_utf8_lossy(bytes); + let text = if rtf { std::borrow::Cow::Owned(deckcraft_format::rtf::rtf_to_outline(&text)) } else { text }; let mut p = defaults::blank_presentation(defaults::WIDE, Default::default(), false); if lower.ends_with(".md") { deckcraft_format::markdown_to_slides(&mut p, &text); @@ -162,6 +164,12 @@ fn open_other(name: &str, bytes: &[u8]) -> Result { Err(EngineError::Other(format!("{name}: not a presentation DeckCraft can read"))) } +/// An outline file's text: RTF is read into the tab-indented outline, anything else as UTF-8. +pub fn outline_text(bytes: &[u8]) -> String { + let text = String::from_utf8_lossy(bytes); + if deckcraft_format::rtf::sniff_rtf(bytes) { deckcraft_format::rtf::rtf_to_outline(&text) } else { text.into_owned() } +} + pub fn add_opened(s: &mut Session, name: &str, path: Option, p: Presentation) -> usize { let title = std::path::Path::new(name).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| name.to_string()); crate::fonts::install_embedded_fonts(&p); @@ -262,6 +270,7 @@ pub fn save_bytes(doc: &Presentation, format: &str) -> Result> { match format { f @ ("pptx" | "potx" | "ppsx") => deckcraft_pptx::export_as(doc, f).map_err(|e| EngineError::Other(e.to_string())), "outline" | "txt" => Ok(deckcraft_format::slides_to_outline(doc).into_bytes()), + "rtf" => Ok(deckcraft_format::rtf::slides_to_rtf(doc).into_bytes()), "pdf" => deckcraft_pdf::export(doc, &Default::default()).map_err(|e| EngineError::Other(e.to_string())), "gif" => gif_bytes(doc, &Value::Null), _ => deckcraft_format::save(doc).map_err(|e| EngineError::Other(e.to_string())), @@ -337,7 +346,7 @@ pub fn pdf_bytes(doc: &Presentation, p: &Value) -> Result> { } /// The output extensions [`format_for_path`] maps to a writer of that format. -pub const WRITABLE_EXTENSIONS: &[&str] = &["deckcraft", "pptx", "potx", "ppsx", "pdf", "png", "jpg", "jpeg", "gif", "txt", "md"]; +pub const WRITABLE_EXTENSIONS: &[&str] = &["deckcraft", "pptx", "potx", "ppsx", "pdf", "png", "jpg", "jpeg", "gif", "txt", "md", "rtf"]; /// The format a file named `path` is written in, or `None` when its extension names no format /// DeckCraft writes (`.odp`, `.svg`, no extension …): [`format_for_path`] would save @@ -370,6 +379,8 @@ pub fn format_for_path(path: &str) -> &'static str { "gif" } else if l.ends_with(".txt") || l.ends_with(".md") { "outline" + } else if l.ends_with(".rtf") { + "rtf" } else { "deckcraft" } @@ -536,7 +547,7 @@ fn export(s: &mut Session, p: &Value) -> Result { "secondsPerSlide": f64::from(out.delay_cs) / 100.0, })) } - "pptx" | "potx" | "ppsx" | "deckcraft" | "outline" | "txt" => { + "pptx" | "potx" | "ppsx" | "deckcraft" | "outline" | "txt" | "rtf" => { let bytes = save_bytes(&st.doc, &format)?; write_file(&path, &bytes)?; Ok(json!({"path": path, "bytes": bytes.len()})) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f7022eab8..bbbf9b989 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1457,6 +1457,24 @@ fn slide_ranges_parse_like_powerpoints_print_box() { assert_eq!(opts.slides, Some(vec![0, 1]), "indices past the end are dropped"); } +#[test] +fn outline_rtf_exports_and_opens_back() { + let mut s = session(); + s.execute("slide.fromOutline", &json!({"text": "Roadmap\n\tShip 1.0\n\t\tLinux first\n"})).unwrap(); + let dir = std::env::temp_dir().join(format!("deckcraft-rtf-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("deck.rtf"); + s.execute("file.export", &json!({"path": path.to_string_lossy()})).unwrap(); + let bytes = std::fs::read(&path).unwrap(); + assert!(bytes.starts_with(b"{\\rtf1")); + let text = crate::cmd::file::outline_text(&bytes); + assert!(text.contains("Roadmap\n\tShip 1.0\n\t\tLinux first\n"), "{text}"); + s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap(); + let d = s.doc().unwrap(); + assert!(d.doc.slides.iter().any(|sl| sl.title() == "Roadmap")); + let _ = std::fs::remove_dir_all(&dir); +} + /// #725: a layout with a small body placeholder ahead of its content placeholder (as Keynote /// exports "Title & Bullets") still gets the body in the content placeholder (`idx="1"`). #[test] diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index a9db66e2b..fc95aba07 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -5,6 +5,7 @@ #![forbid(unsafe_code)] mod finite; +pub mod rtf; use std::io::{Cursor, Read, Write}; use std::sync::Arc; diff --git a/crates/format/src/rtf.rs b/crates/format/src/rtf.rs new file mode 100644 index 000000000..7e1676039 --- /dev/null +++ b/crates/format/src/rtf.rs @@ -0,0 +1,321 @@ +//! Outline / RTF: PowerPoint's "Outline/RTF" save type, and reading such a file back as slides. +//! +//! The export writes one paragraph per slide title (outline level 0, bold, larger) and one per +//! body paragraph (outline level = indent + 1, indented), keeping bold, italic and underline. +//! The import reads paragraphs and their `\outlinelevel` (or, failing that, `\li` indent) into +//! the tab-indented outline [`crate::outline_to_slides`] understands. + +use deckcraft_model::Presentation; +use deckcraft_model::text::{Paragraph, RunKind}; + +/// RTF-escape `s` (non-ASCII as `\uN?`, UTF-16, signed as RTF wants). +fn escape(out: &mut String, s: &str) { + for c in s.chars() { + match c { + '\\' | '{' | '}' => { + out.push('\\'); + out.push(c); + } + '\t' => out.push_str("\\tab "), + '\u{b}' | '\n' => out.push_str("\\line "), + c if c.is_ascii() && !c.is_ascii_control() => out.push(c), + c if c.is_ascii() => {} + c => { + let mut buf = [0u16; 2]; + for u in c.encode_utf16(&mut buf) { + out.push_str(&format!("\\u{}?", *u as i16)); + } + } + } + } +} + +fn runs(out: &mut String, p: &Paragraph) { + for r in &p.runs { + if matches!(r.kind, RunKind::Break) { + out.push_str("\\line "); + continue; + } + let (b, i, u) = (r.props.bold == Some(true), r.props.italic == Some(true), r.props.underline.as_deref().is_some_and(|u| u != "none")); + if !(b || i || u) { + escape(out, &r.text); + continue; + } + out.push('{'); + out.push_str(if b { "\\b" } else { "" }); + out.push_str(if i { "\\i" } else { "" }); + out.push_str(if u { "\\ul" } else { "" }); + out.push(' '); + escape(out, &r.text); + out.push('}'); + } +} + +/// The presentation's outline (slide titles and body text) as RTF. +pub fn slides_to_rtf(p: &Presentation) -> String { + let mut out = String::from("{\\rtf1\\ansi\\ansicpg1252\\deff0\\uc1{\\fonttbl{\\f0\\fswiss Liberation Sans;}}\n"); + for s in &p.slides { + let title = s.shapes.iter().find(|sh| sh.ph_type().is_some_and(|k| k.is_title())).and_then(|sh| sh.text.as_ref()); + out.push_str("\\pard\\outlinelevel0\\sb240\\b\\fs32 "); + match title.and_then(|t| t.paragraphs.iter().find(|p| !p.is_empty())) { + Some(tp) => runs(&mut out, tp), + None => escape(&mut out, &s.title()), + } + out.push_str("\\b0\\par\n"); + for sh in &s.shapes { + if sh.ph_type().is_some_and(|k| k.is_title()) { + continue; + } + let Some(t) = &sh.text else { continue }; + for para in t.paragraphs.iter().filter(|p| !p.is_empty()) { + let lvl = para.level.min(8) as u32; + out.push_str(&format!("\\pard\\outlinelevel{}\\li{}\\fs24 ", lvl + 1, 360 * (lvl + 1))); + runs(&mut out, para); + out.push_str("\\par\n"); + } + } + } + out.push_str("}\n"); + out +} + +/// Whether `bytes` look like RTF. +pub fn sniff_rtf(bytes: &[u8]) -> bool { + bytes.trim_ascii_start().starts_with(b"{\\rtf") +} + +/// Destinations whose text is not document text. +const SKIP: &[&str] = &[ + "fonttbl", + "colortbl", + "stylesheet", + "info", + "pict", + "header", + "footer", + "footnote", + "listtable", + "listoverridetable", + "generator", + "xmlnstbl", + "themedata", + "datastore", + "latentstyles", + "rsidtbl", + "object", + "fldinst", +]; + +/// Read an RTF outline into tab-indented outline text: a paragraph's outline level (or its left +/// indent in 0.25" steps) becomes its number of tabs. +pub fn rtf_to_outline(rtf: &str) -> String { + #[derive(Clone, Copy)] + struct St { + skip: bool, + uc: usize, + } + let b = rtf.as_bytes(); + let mut stack: Vec = vec![]; + let mut st = St { skip: false, uc: 1 }; + let (mut level, mut indent): (Option, i64) = (None, 0); + let mut line = String::new(); + let mut out = String::new(); + let mut pending_skip = 0usize; + let mut i = 0; + let flush = |line: &mut String, level: Option, indent: i64, out: &mut String| { + let text = line.trim(); + if !text.is_empty() { + let n = level.map(|l| l as i64).unwrap_or((indent + 180) / 360).clamp(0, 9) as usize; + out.push_str(&"\t".repeat(n)); + out.push_str(text); + out.push('\n'); + } + line.clear(); + }; + while i < b.len() { + let c = b[i]; + match c { + b'{' => { + stack.push(st); + i += 1; + // `{\*\dest …}`: an ignorable destination. + if b[i..].starts_with(b"\\*") { + st.skip = true; + } + } + b'}' => { + st = stack.pop().unwrap_or(St { skip: false, uc: 1 }); + i += 1; + } + b'\\' => { + i += 1; + let Some(&n) = b.get(i) else { break }; + if n.is_ascii_alphabetic() { + let s = i; + while i < b.len() && b[i].is_ascii_alphabetic() { + i += 1; + } + let word = &rtf[s..i]; + let ns = i; + if i < b.len() && (b[i] == b'-' || b[i].is_ascii_digit()) { + i += 1; + while i < b.len() && b[i].is_ascii_digit() { + i += 1; + } + } + let arg: Option = rtf[ns..i].parse().ok(); + if i < b.len() && b[i] == b' ' { + i += 1; + } + if pending_skip > 0 { + pending_skip -= 1; + continue; + } + if SKIP.contains(&word) { + st.skip = true; + } + if st.skip { + continue; + } + match word { + "par" => { + flush(&mut line, level, indent, &mut out); + } + "pard" => { + level = None; + indent = 0; + } + "outlinelevel" => level = arg.map(|a| a.clamp(0, 9) as u32), + "li" => indent = arg.unwrap_or(0), + "tab" | "line" => line.push(' '), + "uc" => st.uc = arg.unwrap_or(1).clamp(0, 8) as usize, + "u" => { + let v = arg.unwrap_or(0); + let v = if v < 0 { v + 65536 } else { v } as u32; + push_utf16(&mut line, v); + pending_skip = 0; + // Skip the fallback characters. + let mut k = st.uc; + while k > 0 && i < b.len() { + if b[i] == b'\\' && b.get(i + 1) == Some(&b'\'') { + i += 4; + } else if b[i] == b'{' || b[i] == b'}' || b[i] == b'\\' { + break; + } else { + i += 1; + } + k -= 1; + } + } + "emdash" => line.push('—'), + "endash" => line.push('–'), + "bullet" => line.push('•'), + "lquote" => line.push('‘'), + "rquote" => line.push('’'), + "ldblquote" => line.push('“'), + "rdblquote" => line.push('”'), + _ => {} + } + } else if n == b'\'' { + let hex = rtf.get(i + 1..i + 3).and_then(|h| u8::from_str_radix(h, 16).ok()); + i += 3; + if !st.skip + && let Some(h) = hex + { + line.push(cp1252(h)); + } + } else { + i += 1; + if st.skip { + continue; + } + match n { + b'\\' | b'{' | b'}' => line.push(n as char), + b'~' => line.push('\u{a0}'), + b'\n' | b'\r' => flush(&mut line, level, indent, &mut out), + _ => {} + } + } + } + b'\r' | b'\n' => i += 1, + _ => { + let s = i; + while i < b.len() && !matches!(b[i], b'{' | b'}' | b'\\' | b'\r' | b'\n') { + i += 1; + } + if !st.skip { + line.push_str(&rtf[s..i]); + } + } + } + } + flush(&mut line, level, indent, &mut out); + finish_surrogates(out) +} + +/// Push a UTF-16 unit; surrogate halves are parked as private markers and joined afterwards. +fn push_utf16(line: &mut String, v: u32) { + match char::from_u32(v) { + Some(c) => line.push(c), + None => line.push_str(&format!("\u{0}{v:04x}")), + } +} + +fn finish_surrogates(s: String) -> String { + if !s.contains('\u{0}') { + return s; + } + let mut out = String::new(); + let mut rest = s.as_str(); + while let Some(p) = rest.find('\u{0}') { + out.push_str(&rest[..p]); + let hi = rest.get(p + 1..p + 5).and_then(|h| u32::from_str_radix(h, 16).ok()); + let lo = rest.get(p + 6..p + 10).filter(|_| rest.get(p + 5..p + 6) == Some("\u{0}")).and_then(|h| u32::from_str_radix(h, 16).ok()); + match (hi, lo) { + (Some(h), Some(l)) if (0xD800..0xDC00).contains(&h) && (0xDC00..0xE000).contains(&l) => { + out.extend(char::from_u32(0x10000 + ((h - 0xD800) << 10) + (l - 0xDC00))); + rest = &rest[p + 10..]; + } + _ => { + out.push('\u{fffd}'); + rest = rest.get(p + 5..).unwrap_or(""); + } + } + } + out.push_str(rest); + out +} + +fn cp1252(b: u8) -> char { + const HIGH: [char; 32] = [ + '€', '\u{81}', '‚', 'ƒ', '„', '…', '†', '‡', 'ˆ', '‰', 'Š', '‹', 'Œ', '\u{8d}', 'Ž', '\u{8f}', '\u{90}', '‘', '’', '“', '”', '•', '–', '—', + '˜', '™', 'š', '›', 'œ', '\u{9d}', 'ž', 'Ÿ', + ]; + match b { + 0x80..=0x9f => HIGH[(b - 0x80) as usize], + _ => b as char, + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + + #[test] + fn export_reads_back_as_the_same_outline() { + let mut p = deckcraft_model::defaults::blank_presentation(deckcraft_model::defaults::WIDE, Default::default(), false); + let outline = "Plan {Q3}\n\tGrow — fast 😀\n\t\tEurope\\Asia\nCafé résumé\n\tDone\n"; + crate::outline_to_slides(&mut p, outline); + let rtf = slides_to_rtf(&p); + assert!(sniff_rtf(rtf.as_bytes())); + assert!(rtf.contains("\\outlinelevel2") && rtf.contains("Plan \\{Q3\\}"), "{rtf}"); + assert_eq!(rtf_to_outline(&rtf), crate::slides_to_outline(&p)); + } + + #[test] + fn reads_word_style_rtf_by_indent_and_hex_escapes() { + let rtf = r"{\rtf1\ansi{\fonttbl{\f0 Arial;}}{\*\generator X;}\pard\b Title one\b0\par\pard\li720 Point \'93a\'94\par\pard\li1440 Sub\par}"; + assert_eq!(rtf_to_outline(rtf), "Title one\n\t\tPoint “a”\n\t\t\t\tSub\n"); + } +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index b964ea102..c4f7e85da 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -119,7 +119,7 @@ pub fn tool_definitions() -> Vec { obj( json!({ "path": string("Output path"), - "format": {"type": "string", "enum": ["png", "jpeg", "pptx", "deckcraft", "outline", "pdf", "gif"], "description": "Output format (default: from the path's extension)"}, + "format": {"type": "string", "enum": ["png", "jpeg", "pptx", "deckcraft", "outline", "rtf", "pdf", "gif"], "description": "Output format (default: from the path's extension)"}, "slide": int("Slide index for images"), "all": boolean("All slides (images)"), "scale": num("Pixels per point for images (default 2)"), diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index f7bfe2530..3649df01b 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -211,11 +211,12 @@ impl Backstage { } /// Save As and Change File Type: (id for [`SlideApp::export_as`], label, detail). -pub const SAVE_TYPES: [(&str, &str, &str); 6] = [ +pub const SAVE_TYPES: [(&str, &str, &str); 7] = [ ("pptx", "PowerPoint Presentation (*.pptx)", "Opens in PowerPoint and other presentation apps"), ("deckcraft", "DeckCraft Presentation (*.deckcraft)", "DeckCraft's own format: everything is kept"), ("pdf", "PDF (*.pdf)", "A fixed copy that keeps its layout on any device"), ("outline", "Outline (*.txt)", "The slide titles and bullet text as plain text"), + ("rtf", "Outline/RTF (*.rtf)", "The slide titles and bullet text for word processors"), ("png", "PNG Picture (*.png)", "The current slide as a picture"), ("jpeg", "JPEG Picture (*.jpg)", "The current slide as a compressed picture"), ]; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 576a22979..eecc33438 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1550,6 +1550,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ("PowerPoint Presentation (.pptx)", "pptx"), ("Outline (.txt)", "outline"), ("Animated GIF (.gif)", "gif"), + ("Outline/RTF (.rtf)", "rtf"), ] { if ui.radio(fmt == f, l).clicked() { d.fields.insert("fmt".into(), f.into()); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 886740706..4239bf273 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -154,7 +154,7 @@ fn open_command(name: &str) -> Option<&'static str> { let lower = name.to_ascii_lowercase(); if is_presentation_name(&lower) { Some("file.openBytes") - } else if lower.ends_with(".txt") || lower.ends_with(".md") { + } else if lower.ends_with(".txt") || lower.ends_with(".md") || lower.ends_with(".rtf") { Some("slide.fromOutline") } else { None @@ -175,7 +175,7 @@ fn infer_command(name: &str, bytes: &[u8]) -> Option<&'static str> { Some("insert.audio") } else if ct.starts_with("video/") { Some("insert.video") - } else if lower.ends_with(".txt") || lower.ends_with(".md") { + } else if lower.ends_with(".txt") || lower.ends_with(".md") || lower.ends_with(".rtf") { Some("slide.fromOutline") } else { None @@ -741,6 +741,7 @@ impl SlideApp { "jpeg" | "jpg" => ("jpg", "jpeg", false), "pptx" => ("pptx", "pptx", false), "outline" | "txt" => ("txt", "outline", false), + "rtf" => ("rtf", "rtf", false), "deckcraft" => ("deckcraft", "deckcraft", false), "gif" => ("gif", "gif", false), other => { @@ -1418,7 +1419,7 @@ impl SlideApp { } }; let params = if cmd == "slide.fromOutline" { - json!({"text": String::from_utf8_lossy(bytes), "markdown": name.to_ascii_lowercase().ends_with(".md")}) + json!({"text": deckcraft_engine::cmd::file::outline_text(bytes), "markdown": name.to_ascii_lowercase().ends_with(".md")}) } else { json!({"name": name, "data": deckcraft_engine::cmd::base64_encode(bytes)}) }; diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index a5f72fe78..82e5eac61 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -25,8 +25,8 @@ PowerPoint opens and saves; Viewer = opens only). | `.thmx` Office theme | Viewer | no | no | Browse for themes is a P2 row | none | | `.deckcraft` native (zip + JSON) | n/a | yes (also `.slidecraft`) | yes | Lossless for the DeckCraft model, forward compatible | `crates/format` tests | | PDF | export | no | yes | Slides, notes pages, handouts (1–9); real-text layer with embedded subsets, links, bookmarks. Slide artwork is a raster image at print resolution, not vector | `crates/pdf` tests | -| Outline `.rtf` / `.txt` | export (RTF), import (outline) | `.txt` outline; `.md` as Markdown (headings are slides, list items bullets) (`slide.fromOutline`) | `.txt` outline | No RTF | engine tests | | PNG / JPEG slide images | export | n/a | yes (`file.export`, `deckcraft-cli render`) | Matches the editor's renderer. PNG `pHYs` and JPEG JFIF density are `scale` × 72 dpi (default scale 2 is 144 dpi), so Preview shows the slide's real size | render and engine tests | +| Outline `.rtf` / `.txt` | export (RTF), import (outline) | `.txt`/`.md`/`.rtf` outline (open, drop, `slide.fromOutline`) | `.txt` and `.rtf` outline | — | format + engine tests | | TIFF / BMP / GIF slide images | export | n/a | no | Easy (the `image` crate writes them) | none | | Animated GIF of the deck | export | n/a | yes (`file.export` format `gif`, Export…, `deckcraft-cli`, MCP `export`) | One frame per slide from the slide renderer; size small/medium/large/extra large or 16–3840 px wide, 0.1–600 s per slide, loop forever or play once, slide range, hidden slides skipped; a 256-colour palette per frame (exact, or NeuQuant above 256 colours). Transitions and build animations are not animated | `crates/engine/src/gif_export.rs` tests (decoded frames, size, delay, loop, hidden slides, hostile params), MCP, CLI and export dialog tests | | MP4 / MOV video of the deck | export | n/a | no | Needs timeline render + H.264 encode | none | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 94928d8c0..15df0acf3 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -9,12 +9,12 @@ The summary between ` |---|---|---|---|---|---| | P0 | 72 | 61 | 11 | 0 | 92% | | P1 | 65 | 45 | 16 | 4 | 82% | -| P2 | 54 | 17 | 25 | 12 | 55% | +| P2 | 54 | 18 | 24 | 12 | 56% | | Area | Parity | |---|---| | Application shell | 81% | -| Files | 75% | +| Files | 76% | | Slides | 92% | | Views | 98% | | Masters & themes | 92% | @@ -64,7 +64,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Files | Document properties | P2 | D | | Files | Export to PDF (slides, notes pages, handouts; text layer, links, bookmarks; vector artwork pending) | P0 | P | | Files | Export slides as PNG/JPEG | P0 | D | -| Files | Export outline / RTF | P2 | P | +| Files | Export outline / RTF | P2 | D | | Files | Export video (MP4) / animated GIF (GIF: one frame per slide, no transitions; no video) | P2 | P | | Files | Print (slides, handouts, notes, outline) | P1 | M | | Files | Password protection | P2 | M | From afd6d3a3eb392fec0bab486bb6717ad4227cf6bc Mon Sep 17 00:00:00 2001 From: Hamza Date: Sat, 17 Oct 2026 17:17:37 +0500 Subject: [PATCH 03/37] Header & Footer: the automatic date takes a format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `HeaderFooter::date_format` is documented as the `datetime1`…`datetime13` format for automatic dates, and nothing read it: grep found one hit, the field's own declaration. The Date placeholder was hardcoded to `datetime1`, so the footer date was always the short date, and a `dateFormat` parameter was accepted and silently dropped — the command returned Ok, so a caller could not tell. Everything else was already there: `datetime::field_text` renders all thirteen types and Insert ▸ Date & Time already offers them. Missing was the wiring from a choice to the placeholder. `design.headerFooter` now takes `dateFormat`, refuses anything outside the thirteen rather than ignoring it, stores it, creates the placeholder with that field type, and updates an existing one when the format changes — as it already did for a changed `dateText`. A fixed date still wins as plain text, and going back to automatic restores the remembered format. `datetime::DATE_KINDS` is the canonical list; `dialogs.rs` uses it rather than its own copy of the same thirteen strings, and the Header & Footer dialog gains a Format dropdown listing each type by what it would show now, enabled while the date is automatic. Not here: reading a deck does not populate `date_format`, so the dialog shows datetime1 preselected for a deck whose dates are something else until the user picks. That is a pptx read-side change and wants its own PR. --- crates/engine/src/cmd/design.rs | 120 ++++++++++++++++++++++++++++++-- crates/text/src/datetime.rs | 19 +++++ crates/ui-egui/src/dialogs.rs | 36 +++++----- 3 files changed, 152 insertions(+), 23 deletions(-) diff --git a/crates/engine/src/cmd/design.rs b/crates/engine/src/cmd/design.rs index 03468f37f..0bd1d82cd 100644 --- a/crates/engine/src/cmd/design.rs +++ b/crates/engine/src/cmd/design.rs @@ -821,6 +821,14 @@ fn header_footer(s: &mut Session, p: &Value) -> Result { if let Some(v) = str_param(p, "dateText") { hf.date_text = v.into(); } + if let Some(v) = str_param(p, "dateFormat") { + // Refused rather than ignored: a format that renders nothing would leave the + // placeholder showing its saved text for ever. + if !deckcraft_text::datetime::DATE_KINDS.contains(&v) { + return Err(bad("design.headerFooter", "dateFormat: one of datetime1…datetime13")); + } + hf.date_format = v.into(); + } if let Some(v) = bool_param(p, "slideNumber") { hf.slide_number = v; } @@ -836,6 +844,7 @@ fn header_footer(s: &mut Session, p: &Value) -> Result { } let hf = hf.clone(); let date_given = str_param(p, "dateText").is_some(); + let format_given = str_param(p, "dateFormat").is_some(); let all = bool_or(p, "all", true); let list: Vec = if all { (0..doc.slides.len()).collect() } else { vec![sel.slide] }; for i in list { @@ -880,7 +889,11 @@ fn header_footer(s: &mut Session, p: &Value) -> Result { }, PhType::Date if hf.date_text.is_empty() => TextBody { paragraphs: vec![deckcraft_model::Paragraph { - runs: vec![Run { text: today(), props: Default::default(), kind: RunKind::Field { field: "datetime1".into() } }], + runs: vec![Run { + text: date_now(&hf.date_format), + props: Default::default(), + kind: RunKind::Field { field: date_kind(&hf.date_format).into() }, + }], ..Default::default() }], ..Default::default() @@ -901,9 +914,9 @@ fn header_footer(s: &mut Session, p: &Value) -> Result { sh.text = Some(TextBody::from_text(&hf.footer_text)); } } - (Some(pos), true) if k == PhType::Date && date_given => { + (Some(pos), true) if k == PhType::Date && (date_given || format_given) => { if let Some(sh) = sl.shapes.get_mut(pos) { - set_date(sh, &hf.date_text); + set_date(sh, &hf.date_text, &hf.date_format); } } _ => {} @@ -917,13 +930,31 @@ fn header_footer(s: &mut Session, p: &Value) -> Result { /// Point a slide's date placeholder at a fixed date (its own plain text) or, for an empty /// `fixed`, at an automatic date field (one it already has keeps its format). -fn set_date(sh: &mut Shape, fixed: &str) { +/// The date field type `format` names, `datetime1` when it is empty or unknown. +fn date_kind(format: &str) -> &str { + if deckcraft_text::datetime::DATE_KINDS.contains(&format) { format } else { "datetime1" } +} + +/// The saved text of a date field of `format`, as it shows now. +fn date_now(format: &str) -> String { + let kind = date_kind(format); + deckcraft_text::datetime::DateTime::now_local().and_then(|now| deckcraft_text::datetime::field_text(kind, None, now)).unwrap_or_else(today) +} + +fn set_date(sh: &mut Shape, fixed: &str, format: &str) { let runs = || sh.text.iter().flat_map(|t| &t.paragraphs).flat_map(|p| &p.runs); - let has_field = runs().any(|r| matches!(&r.kind, RunKind::Field { field } if deckcraft_text::datetime::is_date_field(field))); + let field_now = runs().find_map(|r| match &r.kind { + RunKind::Field { field } if deckcraft_text::datetime::is_date_field(field) => Some(field.clone()), + _ => None, + }); + let has_field = field_now.is_some(); let props = runs().next().map(|r| r.props.clone()).unwrap_or_default(); + let want = date_kind(format); let (text, kind) = match fixed { - "" if has_field => return, - "" => (today(), RunKind::Field { field: "datetime1".into() }), + // Automatic: keep the field, but follow a format that has changed. + "" if field_now.as_deref() == Some(want) => return, + "" if has_field && format.is_empty() => return, + "" => (date_now(format), RunKind::Field { field: want.into() }), _ if !has_field && sh.text.as_ref().is_some_and(|t| t.text() == fixed) => return, _ => (fixed.to_string(), RunKind::Text), }; @@ -1908,3 +1939,78 @@ mod browse_theme_tests { assert!((box_.w - 840.0).abs() < 0.01 && (box_.h - 104.0).abs() < 0.01); } } + +#[cfg(test)] +mod header_footer_date_tests { + use deckcraft_model::PhType; + use deckcraft_model::text::RunKind; + use serde_json::json; + + use crate::Session; + + /// The date field of the Date placeholders of every slide: (saved text, field type). + fn dates(s: &Session) -> Vec<(String, Option)> { + let d = &s.doc().unwrap().doc; + d.slides + .iter() + .flat_map(|sl| sl.shapes.iter()) + .filter(|sh| sh.ph_type() == Some(PhType::Date)) + .flat_map(|sh| sh.text.iter()) + .flat_map(|t| t.paragraphs.iter()) + .flat_map(|p| p.runs.iter()) + .map(|r| { + let kind = match &r.kind { + RunKind::Field { field } => Some(field.clone()), + _ => None, + }; + (r.text.clone(), kind) + }) + .collect() + } + + fn sample(kind: &str) -> String { + let now = deckcraft_text::datetime::DateTime::now_local().expect("a local date"); + deckcraft_text::datetime::field_text(kind, None, now).expect("a sample") + } + + /// Header & Footer's automatic date takes the format it is given. `HeaderFooter::date_format` + /// existed for this and was never read: the placeholder was always `datetime1`, and a + /// `dateFormat` parameter was accepted and silently dropped. + #[test] + fn the_automatic_date_takes_the_format_it_is_given() { + let mut s = Session::with_new(); + + // Default: the short date, as before. + s.execute("design.headerFooter", &json!({"date": true, "dateText": "", "all": true})).unwrap(); + assert_eq!(dates(&s), vec![(sample("datetime1"), Some("datetime1".into()))]); + + // A format: the field type changes and the saved text is that format's. + s.execute("design.headerFooter", &json!({"date": true, "dateFormat": "datetime5", "all": true})).unwrap(); + assert_eq!(dates(&s), vec![(sample("datetime5"), Some("datetime5".into()))]); + assert_eq!(s.doc().unwrap().doc.header_footer.date_format, "datetime5"); + + // A time format works the same way, and these two differ, so the format is really read. + s.execute("design.headerFooter", &json!({"date": true, "dateFormat": "datetime11", "all": true})).unwrap(); + assert_eq!(dates(&s), vec![(sample("datetime11"), Some("datetime11".into()))]); + assert_ne!(sample("datetime11"), sample("datetime5")); + + // Asking again changes nothing (and does not rewrite the run). + s.execute("design.headerFooter", &json!({"date": true, "dateFormat": "datetime11", "all": true})).unwrap(); + assert_eq!(dates(&s), vec![(sample("datetime11"), Some("datetime11".into()))]); + + // A fixed date still wins: plain text, no field. + s.execute("design.headerFooter", &json!({"date": true, "dateText": "4 July 1776", "all": true})).unwrap(); + assert_eq!(dates(&s), vec![("4 July 1776".to_string(), None)]); + + // Back to automatic: the remembered format comes back, not datetime1. + s.execute("design.headerFooter", &json!({"date": true, "dateText": "", "dateFormat": "datetime11", "all": true})).unwrap(); + assert_eq!(dates(&s), vec![(sample("datetime11"), Some("datetime11".into()))]); + + // An unknown format is refused rather than ignored, and changes nothing. + for bad in ["datetime0", "datetime14", "nonsense", ""] { + assert!(s.execute("design.headerFooter", &json!({"date": true, "dateFormat": bad})).is_err(), "{bad}"); + } + assert_eq!(s.doc().unwrap().doc.header_footer.date_format, "datetime11"); + assert_eq!(dates(&s), vec![(sample("datetime11"), Some("datetime11".into()))]); + } +} diff --git a/crates/text/src/datetime.rs b/crates/text/src/datetime.rs index 8844f2996..abdb317c8 100644 --- a/crates/text/src/datetime.rs +++ b/crates/text/src/datetime.rs @@ -74,6 +74,25 @@ pub fn is_date_field(kind: &str) -> bool { kind.starts_with("datetime") } +/// The date and time field types PowerPoint's Header and Footer and Insert ▸ Date & Time offer, +/// in the order the dialogs list them. `datetime` and `datetimeFigureOut` are read as `datetime1` +/// (see [`field_text`]) but are not offered as a choice. +pub const DATE_KINDS: [&str; 13] = [ + "datetime1", + "datetime2", + "datetime3", + "datetime4", + "datetime5", + "datetime6", + "datetime7", + "datetime8", + "datetime9", + "datetime10", + "datetime11", + "datetime12", + "datetime13", +]; + /// The text date/time field `kind` shows at `now` in a run of language `lang` (BCP 47, e.g. /// `en-US`). `None` when `kind` isn't a date/time type this knows, `now` isn't a real date, or /// the format needs names in a language other than English: the field keeps its saved text. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 576a22979..0696da793 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1076,6 +1076,24 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let hf = app.session.active().map(|s| s.doc.header_footer.clone()).unwrap_or_default(); let date = check(ui, d, "Date and time", "date", hf.date); field(ui, d, " Fixed date (empty = update automatically):", "dateText", &hf.date_text); + // The format applies to an automatic date; a fixed one is the text as typed. + let automatic = d.get("dateText", &hf.date_text).is_empty(); + let fmt_now = if hf.date_format.is_empty() { "datetime1".to_string() } else { hf.date_format.clone() }; + let fmt = d.get("dateFormat", &fmt_now); + let rows = date_rows("en-US"); + let label = rows.iter().find(|(k, _)| *k == fmt).map(|(_, s)| s.clone()).unwrap_or_else(|| fmt.clone()); + ui.add_enabled_ui(date && automatic, |ui| { + ui.horizontal(|ui| { + ui.label(" Format:"); + egui::ComboBox::from_id_salt("hf-date-format").selected_text(label).width(200.0).show_ui(ui, |ui| { + for (kind, sample) in &rows { + if ui.selectable_label(fmt == *kind, sample).clicked() { + d.fields.insert("dateFormat".into(), (*kind).to_string()); + } + } + }); + }); + }); let num_on = check(ui, d, "Slide number", "num", hf.slide_number); let foot = check(ui, d, "Footer", "footer", hf.footer); field(ui, d, " Footer text:", "footerText", &hf.footer_text); @@ -1096,7 +1114,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { run( app, "design.headerFooter", - json!({"date": date, "dateText": dt, "slideNumber": num_on, "footer": foot, "footerText": ft, "hideOnTitle": hide, "all": apply_all}), + json!({"date": date, "dateText": dt, "dateFormat": d.get("dateFormat", &fmt_now), "slideNumber": num_on, "footer": foot, "footerText": ft, "hideOnTitle": hide, "all": apply_all}), ); } apply || apply_all || d.fields.contains_key("cancel") @@ -2440,21 +2458,7 @@ const DATE_LANGS: &[(&str, &str)] = &[ ("sv-SE", "Swedish"), ]; -const DATE_KINDS: [&str; 13] = [ - "datetime1", - "datetime2", - "datetime3", - "datetime4", - "datetime5", - "datetime6", - "datetime7", - "datetime8", - "datetime9", - "datetime10", - "datetime11", - "datetime12", - "datetime13", -]; +use deckcraft_text::datetime::DATE_KINDS; /// Samples `field_text` can compute for `lang`. Named months and AM/PM exist for English only; /// other languages keep the numeric date and the 24-hour times. From 85b994e7cdd0551f33dd28b4ed72ad4b0c039b71 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:48:58 +0200 Subject: [PATCH 04/37] Hide the slide show media controls when the pointer is idle (#967) --- crates/ui-egui/src/show.rs | 130 ++++++++++++++++++++++++++++++++++--- 1 file changed, 122 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index 51f859af7..1f7d39c9d 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -211,6 +211,11 @@ impl Show { } } + /// True while the pointer moved in the last 2.5 s; the media controls and GIF button hide after that. + fn pointer_awake(&self, now: f64) -> bool { + now - self.last_move < 2.5 + } + /// Pen and laser are different pointers: turning one on puts the other away. fn set_pen(&mut self, on: bool) { self.pen = on; @@ -559,7 +564,7 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { && show.zoom.is_none() && !show.zoom_arm && !show.reading_bar_hit(&ctx) - && !pointer_on_media_bar(&ctx, ui, &doc, &show); + && !pointer_on_media_bar(&ctx, ui, &doc, &show, now); let gif_click = if clicked && on_slide && doc.show.show_media_controls { gif_hit(&ctx, ui, &show, doc_uid, &doc, app) } else { None }; let media_click = if clicked && on_slide && gif_click.is_none() { media_hit(&ctx, ui, &show, &doc) } else { None }; let under = if on_slide && gif_click.is_none() && media_click.is_none() { shapes_under_pointer(&ctx, ui, &mut show, &doc, now) } else { None }; @@ -597,7 +602,7 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { } } else if clicked && show.zoom.is_some() && !show.pen && !show.reading_bar_hit(&ctx) { show.dismiss_zoom(); - } else if clicked && !show.pen && !show.reading_bar_hit(&ctx) && !pointer_on_media_bar(&ctx, ui, &doc, &show) { + } else if clicked && !show.pen && !show.reading_bar_hit(&ctx) && !pointer_on_media_bar(&ctx, ui, &doc, &show, now) { if show.ended { keep = false; } else if show.blank.is_some() { @@ -858,7 +863,7 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { // Not while zoomed: the controls sit where the whole slide would be. if doc.show.show_media_controls && !show.zoom_arm && show.zoom.is_none() { let k = srect.width() / sw.max(1.0); - if let Some(p) = ctx.input(|i| i.pointer.hover_pos()).filter(|_| !show.pen && !show.laser && now - show.last_move < 2.5) { + if let Some(p) = ctx.input(|i| i.pointer.hover_pos()).filter(|_| !show.pen && !show.laser && show.pointer_awake(now)) { for g in &gifs { let r = on_screen(srect, k, &g.rect); if r.contains(p) { @@ -867,7 +872,8 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { } } } - if let Some((id, clip, bar)) = media_bar_target(&ctx, &doc, &show, srect) { + if let Some((id, clip, bar)) = media_bar_target(&ctx, &doc, &show, srect).filter(|_| show.pointer_awake(now)) { + ctx.request_repaint_after(std::time::Duration::from_millis(500)); let action = { let status = app.media.status(id); crate::media::control_bar(ui, bar, status.as_ref(), &clip) @@ -918,7 +924,7 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { ctx.set_cursor_icon(egui::CursorIcon::None); } else if show.zoom_arm || show.grid { ctx.set_cursor_icon(egui::CursorIcon::Crosshair); - } else if now - show.last_move > 2.5 && !show.reading { + } else if !show.pointer_awake(now) && !show.reading { // Idle first: a still pointer hides also over a clickable shape, such as a playing clip. ctx.set_cursor_icon(egui::CursorIcon::None); ctx.request_repaint_after(std::time::Duration::from_millis(500)); @@ -978,7 +984,7 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { let a = show.state.prev(&doc); show.apply(a, &doc, now); } - } else if !show.grid && (now - show.last_move < 2.5 || show.zoom_arm) { + } else if !show.grid && (show.pointer_awake(now) || show.zoom_arm) { let bar = tool_bar_rect(full); painter.rect_filled(bar, CornerRadius::same(17), Color32::from_black_alpha(140)); let mut child = @@ -1166,8 +1172,8 @@ fn media_bar_target(ctx: &egui::Context, doc: &Presentation, show: &Show, srect: } /// True when the pointer is on a visible playback bar, so that click must not advance the show. -fn pointer_on_media_bar(ctx: &egui::Context, ui: &Ui, doc: &Presentation, show: &Show) -> bool { - if !doc.show.show_media_controls || show.pen || show.ended || show.blank.is_some() { +fn pointer_on_media_bar(ctx: &egui::Context, ui: &Ui, doc: &Presentation, show: &Show, now: f64) -> bool { + if !doc.show.show_media_controls || !show.pointer_awake(now) || show.pen || show.ended || show.blank.is_some() { return false; } let Some(p) = ctx.input(|i| i.pointer.interact_pos().or(i.pointer.hover_pos())) else { return false }; @@ -1909,6 +1915,114 @@ mod tests { assert_eq!(harness.output().platform_output.cursor_icon, egui::CursorIcon::None, "still for 3 s: hidden"); } + /// A silent mono 8 kHz WAV, so the clip has a real duration to seek in. + fn silent_wav(secs: u32) -> Vec { + let rate = 8_000u32; + let data = vec![0u8; (rate * secs * 2) as usize]; + let mut b = Vec::new(); + b.extend_from_slice(b"RIFF"); + b.extend_from_slice(&(36 + data.len() as u32).to_le_bytes()); + b.extend_from_slice(b"WAVEfmt "); + b.extend_from_slice(&16u32.to_le_bytes()); + b.extend_from_slice(&1u16.to_le_bytes()); + b.extend_from_slice(&1u16.to_le_bytes()); + b.extend_from_slice(&rate.to_le_bytes()); + b.extend_from_slice(&(rate * 2).to_le_bytes()); + b.extend_from_slice(&2u16.to_le_bytes()); + b.extend_from_slice(&16u16.to_le_bytes()); + b.extend_from_slice(b"data"); + b.extend_from_slice(&(data.len() as u32).to_le_bytes()); + b.extend_from_slice(&data); + b + } + + /// A running show on a slide with one 10 s clip. Returns the harness, the clip, a point on the + /// bar's seek track, and what `pointer_on_media_bar` said on the last frame. + #[allow(clippy::type_complexity)] + fn show_with_clip() -> (egui_kittest::Harness<'static, SlideApp>, ShapeId, Pos2, std::rc::Rc>) { + let size = vec2(1000.0, 700.0); + let mut session = deckcraft_engine::Session::with_new(); + let id = ShapeId(4000); + session + .edit(|doc, _| { + let media = doc.add_media("clip.wav", "audio/wav", silent_wav(10)).unwrap(); + let (w, h) = (doc.slide_size.width, doc.slide_size.height); + let clip = deckcraft_model::MediaClip { media, video: true, volume: 1.0, duration_ms: 10_000, ..Default::default() }; + let xfrm = deckcraft_geom::Xfrm::new(w * 0.2, h * 0.2, w * 0.6, h * 0.6); + let slide = std::sync::Arc::make_mut(&mut doc.slides[0]); + slide.shapes.push(deckcraft_model::Shape { + id, + xfrm: Some(xfrm), + kind: deckcraft_model::ShapeKind::Media(clip), + ..Default::default() + }); + Ok(()) + }) + .unwrap(); + let on_bar = std::rc::Rc::new(std::cell::Cell::new(false)); + let seen = on_bar.clone(); + let slide = std::rc::Rc::new(std::cell::Cell::new(Rect::NOTHING)); + let slide_seen = slide.clone(); + let mut harness = egui_kittest::Harness::builder().with_size(size).with_step_dt(0.25).build_ui_state( + move |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + if let (Some(show), Some(d)) = (app.show.as_ref(), app.session.active()) { + let now = ui.input(|i| i.time); + seen.set(pointer_on_media_bar(ui.ctx(), ui, &d.doc, show, now)); + slide_seen.set(shown_slide_rect(ui, show, &d.doc)); + } + app.ui(ui); + }, + SlideApp::new(session, crate::Services::default()), + ); + harness.run_steps(3); + harness.state_mut().start_show(0, false); + harness.run_steps(2); + let doc = harness.state().session.active().unwrap().doc.clone(); + let bar = slide_media_bars(slide.get(), &doc, 0)[0].3; + let track = pos2(bar.min.x + bar.width() * 0.8, bar.center().y); + (harness, id, track, on_bar) + } + + /// Press and release the primary button at `p`. + fn click_at(harness: &mut egui_kittest::Harness<'static, SlideApp>, p: Pos2) { + for pressed in [true, false] { + harness.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + harness.step(); + } + } + + /// #967: hovering a clip shows its bar and a click on the bar seeks. After 3 s without moving + /// the bar is gone, and a click where it was acts on the clip itself (play), not on the bar. + #[test] + fn media_controls_hide_when_pointer_is_idle() { + // Awake: the bar is there and a click on its timeline seeks, leaving the clip paused. + let (mut harness, id, track, on_bar) = show_with_clip(); + harness.hover_at(track); + harness.run_steps(2); + assert!(harness.state().show.as_ref().unwrap().pointer_awake(harness.ctx.input(|i| i.time))); + assert!(on_bar.get(), "the bar is under a pointer that just moved"); + click_at(&mut harness, track); + harness.run_steps(2); + let status = harness.state().media.status(id).expect("the click on the bar cued the clip"); + assert!(!harness.state().media.is_playing(id), "a bar click seeks, it does not play"); + assert!(status.position > 1.0, "seeked along the timeline, at {}", status.position); + + // Idle: same spot, no movement for 3 s. + let (mut harness, id, track, on_bar) = show_with_clip(); + harness.hover_at(track); + harness.run_steps(2); + assert!(on_bar.get()); + harness.run_steps(12); + assert!(!harness.state().show.as_ref().unwrap().pointer_awake(harness.ctx.input(|i| i.time))); + assert!(!on_bar.get(), "the bar is hidden once the pointer is idle"); + click_at(&mut harness, track); + harness.run_steps(2); + assert!(harness.state().media.is_playing(id), "the click reached the clip"); + let at = harness.state().media.status(id).map_or(0.0, |s| s.position); + assert!(at < 5.0, "the click did not seek along the timeline, position {at}"); + } + /// Send real egui key events to the presenter UI, without the audience window's handler. #[test] fn presenter_navigation_uses_its_own_keyboard_events() { From ea98ebb6153d3cd334b836663df2069f41c7a47a Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:47:14 +0200 Subject: [PATCH 05/37] Drop a clippy allow the workspace already sets --- crates/ui-egui/src/show.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index 1f7d39c9d..327edd369 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -1938,7 +1938,6 @@ mod tests { /// A running show on a slide with one 10 s clip. Returns the harness, the clip, a point on the /// bar's seek track, and what `pointer_on_media_bar` said on the last frame. - #[allow(clippy::type_complexity)] fn show_with_clip() -> (egui_kittest::Harness<'static, SlideApp>, ShapeId, Pos2, std::rc::Rc>) { let size = vec2(1000.0, 700.0); let mut session = deckcraft_engine::Session::with_new(); From a7833e094ddf9b426576740b6d287caf31f7353f Mon Sep 17 00:00:00 2001 From: Faabyy <68284158+Faabyy@users.noreply.github.com> Date: Thu, 15 Oct 2026 10:41:19 +0000 Subject: [PATCH 06/37] #807: Browse for Themes brings a template's slide master and layouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design › Browse for Themes… with a .pptx or .potx now does what PowerPoint does: the file's first slide master and its layouts replace the master the current slide uses (a preserved master stays beside it), stretched to this deck's slide size. Each slide on the old master moves to the new layout with the same name, else the same kind, and keeps its content as Layout does. One undo step. A .thmx still brings its theme only. Reuse Slides' master copy (media, fresh master, layout and shape ids) moves into reuse::import_master so both commands share it. Co-Authored-By: Claude Code # Please enter the commit message for your changes. Lines starting # with '#' will be kept; you may remove them yourself if you want to. # An empty message aborts the commit. # # interactive rebase in progress; onto 22db926 # Last command done (1 command done): # pick 8992623 #807: Browse for Themes brings a template's slide master and layouts # No commands remaining. # You are currently rebasing. # # Changes to be committed: # modified: ROADMAP.md # modified: crates/engine/src/cmd/design.rs # modified: crates/engine/src/cmd/reuse.rs # modified: docs/file-format-parity.md # --- ROADMAP.md | 2 + crates/engine/src/cmd/design.rs | 167 +++++++++++++++++++++++++++++++- crates/engine/src/cmd/reuse.rs | 56 +++++++---- docs/file-format-parity.md | 3 +- 4 files changed, 204 insertions(+), 24 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 933bc7d16..244a54caa 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -142,6 +142,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-15 | A `.pptx` slide that can't be read is reported, not dropped in silence: `lostSlides` from `file.open`, a message and a repaired copy in the app, a warning from `deckcraft-cli` (#203) | | 2026-10-16 | AutoFormat as you type: ordinals (1st, 22nd, 103rd) get superscript suffixes after a space or punctuation; Tools ▸ AutoCorrect and `text.autocorrect {ordinals}` turn it off | | 2026-10-16 | A PowerPoint Show (`.ppsx`) opened from the command line or the file association starts its slide show, as in PowerPoint; File › Open still opens it for editing (#862) | +| 2026-10-15 | Design › Browse for Themes… with a `.pptx` or `.potx` brings its slide master and layouts, not just the theme: the master the current slide uses is replaced (a preserved one stays beside it), the template stretches to the deck's slide size, and each slide moves to the layout with the same name or kind and keeps its content. One undo step (#807) | | 2026-10-15 | PDF export and rendering: `anchorCtr` centres the drawn text block — right-aligned text no longer ran past the box edge (#736) | | 2026-10-14 | connected MCP rejects unrelated, malformed, incomplete and oversized replies without replay; buffered unsolicited bytes force a fresh connection before sending (#718) | | 2026-10-14 | Empty bulleted and numbered paragraphs show gray markers while editing, including on first click and after Enter. Markers use the list style and stay out of slide shows and exports. | @@ -240,6 +241,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-15 | trivial | Progress log: PPTX trigger click-step round trip. Feature scores and milestone names unchanged | | 2026-10-16 | trivial | Progress log: PNG export with a transparent background (#784) | | 2026-10-16 | trivial | Progress log: a `.ppsx` from the system starts the slide show (#862) | +| 2026-10-15 | trivial | Progress log: Browse for Themes applies a template's master and layouts (#807) | | 2026-10-14 | trivial | remote MCP checks reply IDs, envelopes and bounded newline framing (#718) | | 2026-10-14 | minor | Gray list markers on empty paragraphs while editing; caret keeps the list indent before typing | | 2026-10-14 | minor | Community PR wave folded: headline breadth 84% over 191 features (P0 92%, P1 82%, P2 55%) and the feature-area breadths from `cargo xtask parity`; one row per area; Upcoming item 2 merged; per-PR count notes dropped from the progress log. Ready stays ~55%, stage alpha | diff --git a/crates/engine/src/cmd/design.rs b/crates/engine/src/cmd/design.rs index 03468f37f..6659adf8d 100644 --- a/crates/engine/src/cmd/design.rs +++ b/crates/engine/src/cmd/design.rs @@ -20,7 +20,7 @@ pub fn specs() -> Vec { "Browse for Themes…", ["Design", "Themes"], None, - "{path} | {name, data: base64} — apply the theme of a .thmx, .pptx or .potx file", + "{path} | {name, data: base64} — apply a .pptx or .potx's slide master and layouts (slides move to the matching layouts), or a .thmx's theme", has_doc, browse_theme ), @@ -160,6 +160,13 @@ fn browse_theme(s: &mut Session, p: &Value) -> Result { if !deckcraft_pptx::sniff(&bytes) { return Err(bad("design.browseTheme", "not a theme or PowerPoint file")); } + // A presentation or template brings its slide master and layouts, as in PowerPoint; a .thmx + // (its main part is a theme, not a presentation) brings the theme alone. + if let Ok(src) = super::file::open_presentation("theme.pptx", &bytes) + && src.masters.first().is_some_and(|m| !m.layouts.is_empty()) + { + return apply_master(s, &src); + } let (theme, media) = deckcraft_pptx::import_theme(&bytes).map_err(|e| bad("design.browseTheme", e.to_string()))?; let name = theme.name.clone(); s.edit(|doc, sel| { @@ -179,6 +186,58 @@ fn browse_theme(s: &mut Session, p: &Value) -> Result { Ok(json!({"theme": name})) } +/// Design › Browse for Themes… with a `.pptx` / `.potx`: its first master and layouts replace the +/// master the current slide uses (a preserved master stays beside it), stretched to this deck's +/// slide size. Each slide on it moves to the new layout with the same name, else the same kind, +/// keeping its content as Layout does. +fn apply_master(s: &mut Session, src: &Presentation) -> Result { + let from = src.masters.first().ok_or_else(|| bad("design.browseTheme", "no master"))?; + let theme = from.theme.name.clone(); + s.edit(|doc, sel| { + let mi = master_index(doc, sel); + let old = doc.masters.get(mi).cloned().ok_or_else(|| bad("design.browseTheme", "no master"))?; + let (mut media, mut layouts) = Default::default(); + let mut m = super::reuse::import_master("design.browseTheme", doc, src, from, &mut media, &mut layouts)?; + let (sx, sy) = (doc.slide_size.width / src.slide_size.width.max(1.0), doc.slide_size.height / src.slide_size.height.max(1.0)); + if (sx - 1.0).abs() > 1e-9 || (sy - 1.0).abs() > 1e-9 { + scale_shapes(&mut m.shapes, sx, sy, 1.0, 0.0, 0.0); + for l in &mut m.layouts { + scale_shapes(&mut l.shapes, sx, sy, 1.0, 0.0, 0.0); + } + } + let moves: Vec<(usize, deckcraft_model::LayoutId)> = + doc.slides.iter().enumerate().filter_map(|(i, sl)| old.layout(sl.layout).and_then(|l| matching_layout(&m, l)).map(|l| (i, l))).collect(); + let layouts = m.layouts.len(); + if old.preserve { + doc.masters.insert(mi, Arc::new(m)); + } else if let Some(slot) = doc.masters.get_mut(mi) { + *slot = Arc::new(m); + } + for (i, l) in &moves { + super::slide::apply_layout(doc, *i, *l)?; + } + if let Target::Layout { master, .. } = sel.target { + sel.target = Target::Master { master }; + } + sel.shapes.clear(); + sel.text = None; + Ok(json!({"theme": theme, "layouts": layouts, "slides": moves.len()})) + }) +} + +/// The layout of `m` a slide on `from` moves to: same name, else same kind (not for custom +/// layouts), else Title and Content, else the first. +fn matching_layout(m: &deckcraft_model::Master, from: &deckcraft_model::Layout) -> Option { + use deckcraft_model::LayoutType; + let ls = &m.layouts; + ls.iter() + .find(|l| !from.name.is_empty() && l.name.trim().eq_ignore_ascii_case(from.name.trim())) + .or_else(|| ls.iter().find(|l| from.kind != LayoutType::Custom && l.kind == from.kind)) + .or_else(|| ls.iter().find(|l| l.kind == LayoutType::TitleAndContent)) + .or_else(|| ls.first()) + .map(|l| l.id) +} + /// `theme` with every `media` id field rewritten through `map` (format-scheme picture fills). fn remap_media_ids(theme: &deckcraft_model::theme::Theme, map: &std::collections::HashMap) -> Result { fn rec(v: &mut Value, map: &std::collections::HashMap, depth: usize) { @@ -1731,8 +1790,10 @@ mod tests { #[cfg(test)] mod browse_theme_tests { - use deckcraft_model::PhType; + use std::sync::Arc; + use deckcraft_model::text::RunKind; + use deckcraft_model::{Background, Fill, PhType}; use serde_json::json; use crate::{Session, Target}; @@ -1758,6 +1819,108 @@ mod browse_theme_tests { assert!(s.execute("design.browseTheme", &json!({})).is_err()); } + /// A 4:3 brand template: a logo and a background on the master, its layouts renamed (so slides + /// can only find them by kind) and its content layout's title moved. + fn brand_template() -> (String, Vec, deckcraft_model::Xfrm) { + let mut img = image::RgbaImage::new(2, 2); + img.put_pixel(0, 0, image::Rgba([200, 20, 30, 255])); + let mut logo = Vec::new(); + image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut logo), image::ImageFormat::Png).unwrap(); + let mut t = Session::with_new(); + t.execute("design.slideSize", &json!({"preset": "standard", "scale": "none"})).unwrap(); + let title = t + .edit(|doc, _| { + let media = doc.add_media("logo.png", "image/png", logo.clone()).unwrap(); + let id = deckcraft_model::ShapeId(doc.alloc_id().unwrap()); + let m = Arc::make_mut(doc.masters.get_mut(0).unwrap()); + m.shapes.push(deckcraft_model::Shape { + id, + name: "Logo".into(), + kind: deckcraft_model::ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { media, ..Default::default() } }, + xfrm: Some(deckcraft_model::Xfrm { x: 600.0, y: 20.0, w: 100.0, h: 40.0, ..Default::default() }), + ..Default::default() + }); + m.background = + Some(Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x0A, 0x2B, 0x4C))) }); + for l in &mut m.layouts { + l.name = format!("Brand {}", l.name); + } + let obj = m.layouts.iter_mut().find(|l| l.kind == deckcraft_model::LayoutType::TitleAndContent).unwrap(); + let title = obj.shapes.iter_mut().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap(); + let x = deckcraft_model::Xfrm { x: 72.0, y: 400.0, w: 576.0, h: 80.0, ..Default::default() }; + title.xfrm = Some(x); + Ok(x) + }) + .unwrap(); + (crate::cmd::base64_encode(&deckcraft_pptx::export_as(&t.doc().unwrap().doc, "potx").unwrap()), logo, title) + } + + #[test] + fn a_template_brings_its_master_and_layouts_and_slides_follow() { + let (data, logo, title) = brand_template(); + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "Agenda", "body": "Point one"})).unwrap(); + let before = s.doc().unwrap().doc.clone(); + let n = undo_len(&s); + let r = s.execute("design.browseTheme", &json!({"name": "Brand.potx", "data": data})).unwrap(); + assert_eq!(r["slides"], json!(2), "{r}"); + assert_eq!(undo_len(&s), n + 1, "one undo step"); + + let doc = &s.doc().unwrap().doc; + assert!(doc.validate().is_empty(), "{:?}", doc.validate()); + assert_eq!(doc.masters.len(), 1, "the old master is replaced"); + let m = &doc.masters[0]; + assert_ne!(m.id, before.masters[0].id); + assert!(m.layouts.iter().all(|l| l.name.starts_with("Brand "))); + assert!(matches!(m.background, Some(Background::Fill { fill: Fill::Solid { .. } }))); + let pic = m.shapes.iter().find(|sh| sh.name == "Logo").expect("the master's logo comes along"); + let deckcraft_model::ShapeKind::Picture { fill } = &pic.kind else { panic!("logo is a picture") }; + assert_eq!(doc.media(fill.media).map(|m| m.data.as_ref().clone()), Some(logo)); + // The 4:3 master stretches to this deck's 16:9. + let k = doc.slide_size.width / 720.0; + let px = pic.xfrm.unwrap(); + assert!((px.x - 600.0 * k).abs() < 0.01 && (px.w - 100.0 * k).abs() < 0.01 && (px.y - 20.0).abs() < 0.01, "{px:?}"); + + // Each slide moved to the brand layout of its kind, and kept its text. + for (old, new) in before.slides.iter().zip(&doc.slides) { + let (_, was) = before.layout(old.layout).unwrap(); + let (owner, now) = doc.layout(new.layout).expect("on a layout of the new master"); + assert_eq!(owner.id, m.id); + assert_eq!(now.kind, was.kind); + } + let agenda = doc.slides.iter().find(|sl| sl.shapes.iter().any(|sh| sh.text.as_ref().is_some_and(|t| t.text() == "Agenda"))).unwrap(); + assert!(agenda.shapes.iter().any(|sh| sh.text.as_ref().is_some_and(|t| t.text() == "Point one"))); + let (_, lay) = doc.layout(agenda.layout).unwrap(); + let lt = lay.shapes.iter().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap().xfrm.unwrap(); + assert!((lt.x - title.x * k).abs() < 0.01 && (lt.y - title.y).abs() < 0.01, "{lt:?}"); + let st = agenda.shapes.iter().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap(); + assert!(st.xfrm.is_none(), "the slide title follows the new layout"); + + s.execute("edit.undo", &json!({})).unwrap(); + let doc = &s.doc().unwrap().doc; + assert_eq!(doc.masters, before.masters); + assert_eq!(doc.slides, before.slides); + } + + #[test] + fn a_preserved_master_stays_beside_the_template() { + let (data, _, _) = brand_template(); + let mut s = Session::with_new(); + s.execute("view.slideMaster", &json!({"master": 0, "layout": 1})).unwrap(); + s.execute("master.preserve", &json!({"preserve": true})).unwrap(); + s.execute("view.slideMaster", &json!({"master": 0})).unwrap(); + s.execute("master.preserve", &json!({"preserve": true})).unwrap(); + let old = s.doc().unwrap().doc.masters[0].id; + s.execute("view.slideMaster", &json!({"master": 0, "layout": 1})).unwrap(); + s.execute("design.browseTheme", &json!({"name": "Brand.pptx", "data": data})).unwrap(); + let st = s.doc().unwrap(); + assert_eq!(st.doc.masters.len(), 2); + assert_eq!(st.doc.masters[1].id, old); + assert_eq!(st.selection.target, Target::Master { master: 0 }, "Slide Master view shows the new master"); + let new = &st.doc.masters[0]; + assert!(st.doc.slides.iter().all(|sl| new.layout(sl.layout).is_some())); + } + fn undo_len(s: &crate::Session) -> usize { s.doc().unwrap().history.undo.len() } diff --git a/crates/engine/src/cmd/reuse.rs b/crates/engine/src/cmd/reuse.rs index 1e6c09acf..bf295f438 100644 --- a/crates/engine/src/cmd/reuse.rs +++ b/crates/engine/src/cmd/reuse.rs @@ -10,7 +10,7 @@ use std::collections::HashMap; use std::sync::Arc; use deckcraft_model::text::{Action, Hyperlink, RunProps, TextBody}; -use deckcraft_model::{LayoutId, MasterId, MediaId, Presentation, ShapeId, ShapeKind, Slide, SlideId}; +use deckcraft_model::{LayoutId, Master, MasterId, MediaId, Presentation, ShapeId, ShapeKind, Slide, SlideId}; use serde_json::{Value, json}; use super::*; @@ -114,9 +114,9 @@ fn relink_shapes(shapes: &mut [deckcraft_model::Shape], map: &HashMap Result> { +fn renumber(cmd: &str, doc: &mut Presentation, shapes: &mut [deckcraft_model::Shape]) -> Result> { let old = shape_ids(shapes); - super::edit::reid("slide.reuse", doc, shapes)?; + super::edit::reid(cmd, doc, shapes)?; let map: HashMap = old.into_iter().zip(shape_ids(shapes)).collect(); remap_connectors(shapes, &map, 0); Ok(map) @@ -171,19 +171,7 @@ fn reuse(s: &mut Session, p: &Value) -> Result { continue; } masters_done.push(m.id); - let mut used = vec![]; - media_refs::visit::master(m, &mut |id: &MediaId| used.push(*id)); - copy_media(doc, &src, &used, &mut media_map)?; - let mut m = m.clone(); - media_refs::visit_mut::master(&mut m, &mut remap(&media_map)); - m.id = MasterId(doc.alloc_id().ok_or_else(|| bad("slide.reuse", NO_ID_SPACE))?); - renumber(doc, &mut m.shapes)?; - for l in &mut m.layouts { - let new = LayoutId(doc.alloc_id().ok_or_else(|| bad("slide.reuse", NO_ID_SPACE))?); - layout_map.insert(l.id, new); - l.id = new; - renumber(doc, &mut l.shapes)?; - } + let m = import_master("slide.reuse", doc, &src, m, &mut media_map, &mut layout_map)?; doc.masters.push(Arc::new(m)); } } @@ -192,11 +180,11 @@ fn reuse(s: &mut Session, p: &Value) -> Result { let mut slide_map: HashMap = HashMap::new(); for &i in &picked { let Some(from) = src.slides.get(i) else { continue }; - copy_media(doc, &src, &media_refs::slide_media(from), &mut media_map)?; + copy_media("slide.reuse", doc, &src, &media_refs::slide_media(from), &mut media_map)?; let mut sl: Slide = from.as_ref().clone(); media_refs::visit_mut::slide(&mut sl, &mut remap(&media_map)); sl.id = SlideId(doc.alloc_id().ok_or_else(|| bad("slide.reuse", NO_ID_SPACE))?); - let map = renumber(doc, &mut sl.shapes)?; + let map = renumber("slide.reuse", doc, &mut sl.shapes)?; for a in &mut sl.animations { a.shape = map.get(&a.shape).copied().unwrap_or(a.shape); a.trigger = a.trigger.map(|t| map.get(&t).copied().unwrap_or(t)); @@ -227,9 +215,35 @@ fn reuse(s: &mut Session, p: &Value) -> Result { }) } +/// A copy of `src`'s master `m` ready to join `doc`: its media copied in, fresh master, layout and +/// shape ids. `layouts` gains each source layout id → its copy's. +pub(crate) fn import_master( + cmd: &str, + doc: &mut Presentation, + src: &Presentation, + m: &Master, + media_map: &mut HashMap, + layouts: &mut HashMap, +) -> Result { + let mut used = vec![]; + media_refs::visit::master(m, &mut |id: &MediaId| used.push(*id)); + copy_media(cmd, doc, src, &used, media_map)?; + let mut m = m.clone(); + media_refs::visit_mut::master(&mut m, &mut remap(media_map)); + m.id = MasterId(doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE))?); + renumber(cmd, doc, &mut m.shapes)?; + for l in &mut m.layouts { + let new = LayoutId(doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE))?); + layouts.insert(l.id, new); + l.id = new; + renumber(cmd, doc, &mut l.shapes)?; + } + Ok(m) +} + /// Copy the source media `used` into `doc` under fresh ids, once each (identical bytes already in /// `doc` are shared; a linked item keeps its link). -fn copy_media(doc: &mut Presentation, src: &Presentation, used: &[MediaId], map: &mut HashMap) -> Result<()> { +fn copy_media(cmd: &str, doc: &mut Presentation, src: &Presentation, used: &[MediaId], map: &mut HashMap) -> Result<()> { for &id in used { if map.contains_key(&id) { continue; @@ -237,12 +251,12 @@ fn copy_media(doc: &mut Presentation, src: &Presentation, used: &[MediaId], map: let Some(m) = src.media(id) else { continue }; let new = if m.link.is_some() { let mut item = m.clone(); - item.id = MediaId(doc.alloc_id().ok_or_else(|| bad("slide.reuse", NO_ID_SPACE))?); + item.id = MediaId(doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE))?); let new = item.id; doc.media.push(item); new } else { - doc.add_media(&m.name, &m.content_type, m.data.as_ref().clone()).ok_or_else(|| bad("slide.reuse", NO_ID_SPACE))? + doc.add_media(&m.name, &m.content_type, m.data.as_ref().clone()).ok_or_else(|| bad(cmd, NO_ID_SPACE))? }; map.insert(id, new); } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index b8bbedb47..844c7f0a0 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -22,7 +22,7 @@ PowerPoint opens and saves; Viewer = opens only). | `.pptm` / `.potm` / `.ppsm` macro-enabled | Editor | no | no | Should open and keep `vbaProject.bin` without running it | none | | `.ppt` / `.pot` / `.pps` 97–2003 binary | Editor | no | no | Owner decision; import only would be enough | none | | `.odp` OpenDocument | Editor | no | no | | none | -| `.thmx` Office theme | Viewer | no | no | Browse for themes is a P2 row | none | +| `.thmx` Office theme | Viewer | theme only (Design › Browse for Themes…) | no | Browse for Themes takes a `.thmx`'s colours, fonts and effects; a `.pptx` or `.potx` brings its slide master and layouts as well, and slides move to the matching layouts (#807). A `.thmx`'s own master and layouts aren't read yet | `crates/pptx/src/lib.rs` (`import_theme_tests`), `crates/engine/src/cmd/design.rs` (`browse_theme_tests`) | | `.deckcraft` native (zip + JSON) | n/a | yes (also `.slidecraft`) | yes | Lossless for the DeckCraft model, forward compatible | `crates/format` tests | | PDF | export | no | yes | Slides, notes pages, handouts (1–9); real-text layer with embedded subsets, links, bookmarks. Slide artwork is a raster image at print resolution, not vector | `crates/pdf` tests | | Outline `.rtf` / `.txt` | export (RTF), import (outline) | `.txt` outline; `.md` as Markdown (headings are slides, list items bullets) (`slide.fromOutline`) | `.txt` outline | No RTF | engine tests | @@ -107,6 +107,7 @@ the round trip even though DeckCraft doesn't model it. | 2026-10-15 | minor | A `.pptx` slide that can't be read is reported instead of dropped in silence: `file.open` and `file.openBytes` return `lostSlides`, the app shows a message and opens a repaired copy (Save asks for a new name), and `deckcraft-cli` warns on stderr (#203) | | 2026-10-16 | minor | PNG slide export with a transparent background (#784) | | 2026-10-16 | trivial | A `.ppsx` opened from the command line or the file association starts the slide show (#862) | +| 2026-10-15 | minor | `.thmx` row: Browse for Themes reads a theme's colours, fonts and effects; a `.pptx`/`.potx` brings its slide master and layouts (#807) | | 2026-10-14 | minor | Community PR wave folded: one row each for `.pptx` (seven owned corpus files), outline, 3-D models, embedded fonts, text, effects, 3-D (extrusion sides draw now), SmartArt (written as a diagram) and VBA (tags and custom XML are kept) | | 2026-10-12 | minor | SmartArt import lays each shape's text out on its `dsp:txXfrm` text rectangle instead of the shape box | | 2026-10-13 | minor | EMF/WMF rectangle clip regions limit later drawing; EMF+ records remain | From 8e83e8bb81449fbf06f4f00fa5b3c5adfc142ad7 Mon Sep 17 00:00:00 2001 From: Sazid Al Bayazid Date: Fri, 16 Oct 2026 02:35:40 +0600 Subject: [PATCH 07/37] Render: quick styles render on empty placeholder text boxes (#832) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The render gate for an empty placeholder's fill checked only s.fill.is_none(). A quick style that sets a style reference without an explicit fill left s.fill as None, so the gate skipped the fill even though the shape had been formatted — the style only appeared after text was added. The gate now also checks s.line and s.style: if any of the three has a local override, the placeholder is considered formatted and the fill is drawn. A truly unformatted empty placeholder still shows the dashed outline and prompt. --- ROADMAP.md | 1 + crates/render/src/lib.rs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 93bc931ee..91cfac6f5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -137,6 +137,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-17 | Edit Points: turning a shape into a freeform is one undo step, so Undo brings the original shape back (it used to stay a freeform) | | 2026-10-17 | While a placeholder's text is being edited, a click on a shape in front of it selects that shape instead of moving the caret, as in PowerPoint (#931) | | 2026-10-15 | Native saves keep an imported chart's embedded workbook, drawings and relationships; headless PPTX → edit → native save/reopen → PPTX tests verify the payloads and stale-workbook rejection | +| 2026-10-15 | Render: quick styles render on empty placeholders — the fill gate now checks for style and line overrides too (#832) | | 2026-10-15 | PPTX trigger sequences keep separate On Click steps, including paragraph builds; synthetic import/edit/save/reopen and headless show regressions | | 2026-10-16 | Export: PNG with a transparent background — the slide background is left out, only the slide's content is drawn (Export… dialog, File › Export, web download, `file.export`/`file.render` `transparentBackground`, `deckcraft-cli render --transparent`) (#784) | | 2026-10-16 | Arrange ▸ Align: Align to Slide and Align Selected Objects, as in PowerPoint. One object aligns to the slide and several to each other until the other choice is ticked; the choice holds for that selection and also drives Distribute | diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index cf124a9f6..96a063b3a 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1106,7 +1106,7 @@ impl Renderer { } _ => { if let Some(fl) = &fill - && !(empty_ph && f.opts.edit && s.fill.is_none()) + && !(empty_ph && f.opts.edit && s.fill.is_none() && s.line.is_none() && s.style.is_none()) { for (i, sp) in geo.paths.iter().enumerate() { if sp.fill == FillMode::None || fade(i).fill <= 0.0 { From 756ca23d313f915e69069967fb579eb086a5213e Mon Sep 17 00:00:00 2001 From: Sazid Al Bayazid Date: Tue, 20 Oct 2026 01:04:43 +0600 Subject: [PATCH 08/37] Canvas: the Format and Animation Painter override the cursor (#830) When the Format Painter or Animation Painter was armed, the mouse cursor stayed as the default arrow. The user had no visual indication that the tool was active and waiting for a click on a shape to apply the copied format. The canvas now sets the Copy cursor while either painter is armed, and goes back to its usual cursor once the tool is done. The override takes precedence over the other cursor assignments (resize, move, crosshair, text), because the painter's whole purpose at that moment is to receive the next click. The kittest covers both painters: it hovers over the canvas, checks the cursor is not the Copy one yet, arms the painter and checks it is. With the painter override removed both fail - the cursor stays Move instead of Copy. ROADMAP progress-log row added. --- ROADMAP.md | 1 + crates/ui-egui/src/canvas.rs | 89 +++++++++++++++++++++++++----------- 2 files changed, 64 insertions(+), 26 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 93bc931ee..2ca97a607 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -144,6 +144,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-15 | A `.pptx` slide that can't be read is reported, not dropped in silence: `lostSlides` from `file.open`, a message and a repaired copy in the app, a warning from `deckcraft-cli` (#203) | | 2026-10-16 | AutoFormat as you type: ordinals (1st, 22nd, 103rd) get superscript suffixes after a space or punctuation; Tools ▸ AutoCorrect and `text.autocorrect {ordinals}` turn it off | | 2026-10-16 | A PowerPoint Show (`.ppsx`) opened from the command line or the file association starts its slide show, as in PowerPoint; File › Open still opens it for editing (#862) | +| 2026-10-15 | Canvas: the Format and Animation Painter override the cursor to show the tool is armed (#830) | | 2026-10-15 | PDF export and rendering: `anchorCtr` centres the drawn text block — right-aligned text no longer ran past the box edge (#736) | | 2026-10-14 | connected MCP rejects unrelated, malformed, incomplete and oversized replies without replay; buffered unsolicited bytes force a fresh connection before sending (#718) | | 2026-10-14 | Empty bulleted and numbered paragraphs show gray markers while editing, including on first click and after Enter. Markers use the list style and stay out of slide shows and exports. | diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ba66aad81..127a991ec 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -753,34 +753,40 @@ fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) { if let Err(e) = r { app.set_status(e.to_string()); } - // Cursor. - let cursor = match (&app.session.tool.kind, &app.session.tool.hover) { - (ToolKind::Shape { .. }, _) => egui::CursorIcon::Crosshair, - (ToolKind::TextBox, _) => egui::CursorIcon::Text, - (ToolKind::Ink { .. }, _) => egui::CursorIcon::Crosshair, - (_, Some(Hit::Resize { handle, .. })) => match handle { - 0 | 4 => egui::CursorIcon::ResizeNwSe, - 2 | 6 => egui::CursorIcon::ResizeNeSw, - 1 | 5 => egui::CursorIcon::ResizeVertical, - _ => egui::CursorIcon::ResizeHorizontal, - }, - (_, Some(Hit::Rotate { .. })) => egui::CursorIcon::Alias, - (_, Some(Hit::TableColumn { .. })) => egui::CursorIcon::ResizeColumn, - (_, Some(Hit::TableRow { .. })) => egui::CursorIcon::ResizeRow, - (_, Some(Hit::Adjust { .. } | Hit::Warp { .. } | Hit::LineEnd { .. })) => egui::CursorIcon::PointingHand, - (_, Some(Hit::Shape { text: true, .. })) => { - if app.session.active().is_some_and(|d| d.selection.text.is_some()) { - egui::CursorIcon::Text - } else { - egui::CursorIcon::Move + // Cursor. The Format and Animation Painters override everything: their whole point is to + // tell the user the tool is armed and waiting for a click (#830). + let painter_armed = app.session.painter.is_some() || app.session.anim_painter.is_some(); + let cursor = if painter_armed { + egui::CursorIcon::Copy + } else { + match (&app.session.tool.kind, &app.session.tool.hover) { + (ToolKind::Shape { .. }, _) => egui::CursorIcon::Crosshair, + (ToolKind::TextBox, _) => egui::CursorIcon::Text, + (ToolKind::Ink { .. }, _) => egui::CursorIcon::Crosshair, + (_, Some(Hit::Resize { handle, .. })) => match handle { + 0 | 4 => egui::CursorIcon::ResizeNwSe, + 2 | 6 => egui::CursorIcon::ResizeNeSw, + 1 | 5 => egui::CursorIcon::ResizeVertical, + _ => egui::CursorIcon::ResizeHorizontal, + }, + (_, Some(Hit::Rotate { .. })) => egui::CursorIcon::Alias, + (_, Some(Hit::TableColumn { .. })) => egui::CursorIcon::ResizeColumn, + (_, Some(Hit::TableRow { .. })) => egui::CursorIcon::ResizeRow, + (_, Some(Hit::Adjust { .. } | Hit::Warp { .. } | Hit::LineEnd { .. })) => egui::CursorIcon::PointingHand, + (_, Some(Hit::Shape { text: true, .. })) => { + if app.session.active().is_some_and(|d| d.selection.text.is_some()) { + egui::CursorIcon::Text + } else { + egui::CursorIcon::Move + } } + (_, Some(Hit::Shape { .. } | Hit::Frame { .. })) => egui::CursorIcon::Move, + _ => match app.session.tool.guide_drag.map(|g| g.0).or(app.session.tool.guide_hover) { + Some(i) if app.session.active().and_then(|d| d.doc.guides.get(i)).is_some_and(|g| g.vertical) => egui::CursorIcon::ResizeHorizontal, + Some(_) => egui::CursorIcon::ResizeVertical, + None => egui::CursorIcon::Default, + }, } - (_, Some(Hit::Shape { .. } | Hit::Frame { .. })) => egui::CursorIcon::Move, - _ => match app.session.tool.guide_drag.map(|g| g.0).or(app.session.tool.guide_hover) { - Some(i) if app.session.active().and_then(|d| d.doc.guides.get(i)).is_some_and(|g| g.vertical) => egui::CursorIcon::ResizeHorizontal, - Some(_) => egui::CursorIcon::ResizeVertical, - None => egui::CursorIcon::Default, - }, }; if resp.hovered() { ui.ctx().set_cursor_icon(cursor); @@ -1594,6 +1600,37 @@ mod tests { assert!(super::grid_line_step(6.0, 0.0).is_none()); assert!(super::grid_line_step(6.0, 0.1).is_none(), "a 6 pt grid at 10% zoom is finer than one screen point"); } + + /// #830: when the Format or Animation Painter is armed, the cursor shows the copy icon so the + /// user can see the tool is waiting for a click. + #[test] + fn format_painter_sets_the_cursor() { + let mut h = harness(); + // Hover over the canvas so the pointer function runs and sets the cursor. + h.hover_at(egui::pos2(600.0, 400.0)); + h.run_steps(2); + let before = h.output().platform_output.cursor_icon; + assert_ne!(before, egui::CursorIcon::Copy, "no painter is armed yet"); + // Arming the format painter overrides the cursor to Copy (#830). + h.state_mut().session.painter = Some((Default::default(), false)); + h.run_steps(3); + let after = h.output().platform_output.cursor_icon; + assert_eq!(after, egui::CursorIcon::Copy, "the format painter arms the Copy cursor"); + } + + /// #830: the Animation Painter arms the same cursor. + #[test] + fn animation_painter_sets_the_cursor() { + let mut h = harness(); + h.hover_at(egui::pos2(600.0, 400.0)); + h.run_steps(2); + let before = h.output().platform_output.cursor_icon; + assert_ne!(before, egui::CursorIcon::Copy, "no painter is armed yet"); + h.state_mut().session.anim_painter = Some((vec![], false)); + h.run_steps(3); + let after = h.output().platform_output.cursor_icon; + assert_eq!(after, egui::CursorIcon::Copy, "the animation painter arms the Copy cursor"); + } } #[cfg(test)] From bc12031d703c0f088617dcffd788522050e89da1 Mon Sep 17 00:00:00 2001 From: Sazid Al Bayazid Date: Thu, 15 Oct 2026 16:18:04 +0600 Subject: [PATCH 09/37] Canvas: the resize cursor follows the handle's diagonal, whatever the shape (#745) Two gaps in the resize cursor, both found by a review of the fix: - The handle's direction was taken from the box centre to the handle, so a wide rectangle's corner pointed almost along an edge: a 400x80 shape's NW corner showed a horizontal cursor. A corner drags along the diagonal whatever the shape's proportions, so the direction is now normalised - the diagonal at 45 degrees - and only the shape's rotation and flips turn it. - The cursor read the shape's own box, but the handles are drawn from the effective one, which a placeholder inherits from its layout. Such a placeholder showed the default arrow instead of a resize cursor. The tests cover both: a wide shape keeps its corner cursors diagonal, and a placeholder that inherits its box has a resize cursor. Red-proof: with the aspect ratio back in the direction, the wide shape's NW corner shows a horizontal cursor; with the shape's own box, the placeholder shows the default arrow. --- ROADMAP.md | 1 + crates/ui-egui/src/canvas.rs | 146 +++++++++++++++++++++++++++++++++-- 2 files changed, 141 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 93bc931ee..9052da957 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -137,6 +137,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-17 | Edit Points: turning a shape into a freeform is one undo step, so Undo brings the original shape back (it used to stay a freeform) | | 2026-10-17 | While a placeholder's text is being edited, a click on a shape in front of it selects that shape instead of moving the caret, as in PowerPoint (#931) | | 2026-10-15 | Native saves keep an imported chart's embedded workbook, drawings and relationships; headless PPTX → edit → native save/reopen → PPTX tests verify the payloads and stale-workbook rejection | +| 2026-10-15 | Canvas: the resize cursor follows the handle's rotated diagonal — rotated shapes showed the wrong diagonal cursor (#745) | | 2026-10-15 | PPTX trigger sequences keep separate On Click steps, including paragraph builds; synthetic import/edit/save/reopen and headless show regressions | | 2026-10-16 | Export: PNG with a transparent background — the slide background is left out, only the slide's content is drawn (Export… dialog, File › Export, web download, `file.export`/`file.render` `transparentBackground`, `deckcraft-cli render --transparent`) (#784) | | 2026-10-16 | Arrange ▸ Align: Align to Slide and Align Selected Objects, as in PowerPoint. One object aligns to the slide and several to each other until the other choice is ticked; the choice holds for that selection and also drives Distribute | diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ba66aad81..4c5ae6add 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -758,12 +758,7 @@ fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) { (ToolKind::Shape { .. }, _) => egui::CursorIcon::Crosshair, (ToolKind::TextBox, _) => egui::CursorIcon::Text, (ToolKind::Ink { .. }, _) => egui::CursorIcon::Crosshair, - (_, Some(Hit::Resize { handle, .. })) => match handle { - 0 | 4 => egui::CursorIcon::ResizeNwSe, - 2 | 6 => egui::CursorIcon::ResizeNeSw, - 1 | 5 => egui::CursorIcon::ResizeVertical, - _ => egui::CursorIcon::ResizeHorizontal, - }, + (_, Some(Hit::Resize { id, handle })) => resize_cursor(&app.session, *id, *handle), (_, Some(Hit::Rotate { .. })) => egui::CursorIcon::Alias, (_, Some(Hit::TableColumn { .. })) => egui::CursorIcon::ResizeColumn, (_, Some(Hit::TableRow { .. })) => egui::CursorIcon::ResizeRow, @@ -1347,10 +1342,63 @@ fn grid_fits(span: f32, step: f32) -> bool { span.is_finite() && span > 0.0 && step >= 1.0 && span / step <= 800.0 } +/// The resize cursor for `handle` of shape `id`: the handle's resize axis is computed in the +/// shape's own space and mapped through its transform, so a rotated or flipped shape shows the +/// diagonal that matches where the drag actually goes (#745). Without a transform the mapping +/// reduces to the classic NW/NE/E/SE/S/SW/W assignment. +fn resize_cursor(session: &deckcraft_engine::Session, id: deckcraft_model::ShapeId, handle: u8) -> egui::CursorIcon { + // The resize handles are drawn from the shape's effective box (a placeholder inherits it + // from its layout), so the cursor follows the same one. + let Some(d) = session.active() else { return egui::CursorIcon::Default }; + let Some(sh) = d.shape(id) else { return egui::CursorIcon::Default }; + let x = deckcraft_engine::cmd::xfrm_of(&d.doc, &d.selection, sh); + // The resize axis in the shape's own coordinates, as (dx, dy) from the box centre toward + // the handle. Corners resize along the diagonal, edge handles along their edge. The + // direction is normalised - the diagonal at 45 degrees whatever the shape's proportions - + // so a wide rectangle still shows a diagonal cursor on its corners. + let (dx, dy): (f64, f64) = match handle { + 0 => (-1.0, -1.0), + 1 => (0.0, -1.0), + 2 => (1.0, -1.0), + 3 => (1.0, 0.0), + 4 => (1.0, 1.0), + 5 => (0.0, 1.0), + 6 => (-1.0, 1.0), + _ => (-1.0, 0.0), + }; + // Map through the linear part of the transform (rotation, flips): two points along the + // axis, the difference is the slide-space direction. + let centre = Point::new(x.x + x.w / 2.0, x.y + x.h / 2.0); + let a = x.affine(); + let p0 = a * centre; + let p1 = a * Point::new(centre.x + dx, centre.y + dy); + let v = p1 - p0; + if !v.x.is_finite() || !v.y.is_finite() || (v.x.abs() < 1e-12 && v.y.abs() < 1e-12) { + return egui::CursorIcon::Default; + } + // Angle in [0, 180): 0° = right (horizontal), 45° = down-right (NW-SE), 90° = down + // (vertical), 135° = down-left (NE-SW). Pick the closest of the four. + let mut deg = v.y.atan2(v.x).to_degrees(); + if deg < 0.0 { + deg += 180.0; + } + if (22.5..67.5).contains(°) { + egui::CursorIcon::ResizeNwSe + } else if (67.5..112.5).contains(°) { + egui::CursorIcon::ResizeVertical + } else if (112.5..157.5).contains(°) { + egui::CursorIcon::ResizeNeSw + } else { + egui::CursorIcon::ResizeHorizontal + } +} + #[cfg(test)] mod tests { #![allow(clippy::unwrap_used, clippy::expect_used)] + use std::sync::Arc; + use deckcraft_engine::Session; use egui::{Event, Modifiers, MouseWheelUnit, Pos2, TouchPhase, pos2, vec2}; use egui_kittest::Harness; @@ -1358,6 +1406,92 @@ mod tests { use super::*; use crate::{Services, SlideApp}; + /// #745: the resize cursor follows the handle's on-screen diagonal. A 45°-rotated shape's + /// NW corner sits at the top-right of the screen, where the drag axis is NE-SW — the old + /// fixed handle-to-cursor mapping showed NW-SE there. + #[test] + fn resize_cursor_tracks_rotation() { + let mut s = Session::with_new(); + // A square shape: the NW handle's diagonal is at 45° unrotated, so each 45° of shape + // rotation cleanly moves the cursor to the next of the four resize directions. + let id = s.execute("shape.insert", &serde_json::json!({"preset": "rect", "rect": [100.0, 100.0, 100.0, 100.0]})).unwrap()["id"] + .as_u64() + .unwrap() as u32; + let sid = deckcraft_model::ShapeId(id); + // Unrotated: the NW handle (0) drags the NW-SE diagonal. + assert_eq!(resize_cursor(&s, sid, 0), egui::CursorIcon::ResizeNwSe); + assert_eq!(resize_cursor(&s, sid, 4), egui::CursorIcon::ResizeNwSe); + assert_eq!(resize_cursor(&s, sid, 2), egui::CursorIcon::ResizeNeSw); + assert_eq!(resize_cursor(&s, sid, 6), egui::CursorIcon::ResizeNeSw); + assert_eq!(resize_cursor(&s, sid, 1), egui::CursorIcon::ResizeVertical); + assert_eq!(resize_cursor(&s, sid, 3), egui::CursorIcon::ResizeHorizontal); + // 45° rotation: the NW corner's drag axis rotates 45° with the shape — it is now vertical. + { + let doc = s.doc_mut().unwrap(); + let pres = Arc::make_mut(&mut doc.doc); + let slide = Arc::make_mut(&mut pres.slides[0]); + if let Some(sh) = slide.shapes.iter_mut().find(|x| x.id == sid) + && let Some(x) = sh.xfrm.as_mut() + { + x.rot = 45.0; + } + } + assert_eq!(resize_cursor(&s, sid, 0), egui::CursorIcon::ResizeVertical, "45°: NW handle drags vertically"); + assert_eq!(resize_cursor(&s, sid, 2), egui::CursorIcon::ResizeHorizontal, "45°: NE handle drags horizontally"); + // 90° rotation: the NW corner is at the top-right; the drag axis is NE-SW. + { + let doc = s.doc_mut().unwrap(); + let pres = Arc::make_mut(&mut doc.doc); + let slide = Arc::make_mut(&mut pres.slides[0]); + if let Some(sh) = slide.shapes.iter_mut().find(|x| x.id == sid) + && let Some(x) = sh.xfrm.as_mut() + { + x.rot = 90.0; + } + } + assert_eq!(resize_cursor(&s, sid, 0), egui::CursorIcon::ResizeNeSw, "90°: the diagonal is NE-SW"); + assert_eq!(resize_cursor(&s, sid, 2), egui::CursorIcon::ResizeNwSe, "90°: NE diagonal is NW-SE"); + assert_eq!(resize_cursor(&s, sid, 1), egui::CursorIcon::ResizeHorizontal, "90°: the top edge is horizontal"); + } + + /// #745: a corner handle shows a diagonal cursor whatever the shape's proportions. The + /// corner's direction is the diagonal, not the line to the box's centre, so a wide + /// rectangle's NW corner still drags along the NW-SE diagonal. + #[test] + fn a_wide_shape_keeps_its_corner_cursors_diagonal() { + let mut s = Session::with_new(); + let id = s.execute("shape.insert", &serde_json::json!({"preset": "rect", "rect": [100.0, 100.0, 400.0, 80.0]})).unwrap()["id"] + .as_u64() + .unwrap() as u32; + let sid = deckcraft_model::ShapeId(id); + assert_eq!(resize_cursor(&s, sid, 0), egui::CursorIcon::ResizeNwSe, "the NW corner drags NW-SE"); + assert_eq!(resize_cursor(&s, sid, 4), egui::CursorIcon::ResizeNwSe, "the SE corner drags NW-SE"); + assert_eq!(resize_cursor(&s, sid, 2), egui::CursorIcon::ResizeNeSw, "the NE corner drags NE-SW"); + assert_eq!(resize_cursor(&s, sid, 6), egui::CursorIcon::ResizeNeSw, "the SW corner drags NE-SW"); + // The edges keep their own cursors. + assert_eq!(resize_cursor(&s, sid, 1), egui::CursorIcon::ResizeVertical, "the top edge drags vertically"); + assert_eq!(resize_cursor(&s, sid, 3), egui::CursorIcon::ResizeHorizontal, "the right edge drags horizontally"); + } + + /// #745: a placeholder that inherits its box from the layout still shows a resize cursor. + /// Its handles are drawn from the inherited box, so the cursor follows that one. + #[test] + fn a_placeholder_that_inherits_its_box_has_a_resize_cursor() { + let s = Session::with_new(); + let st = s.doc().unwrap(); + let ph = st + .current_slide() + .unwrap() + .shapes + .iter() + .find(|sh| sh.xfrm.is_none() && sh.ph.is_some()) + .expect("the new slide has a placeholder without a box of its own") + .id; + assert_eq!(resize_cursor(&s, ph, 0), egui::CursorIcon::ResizeNwSe, "the NW corner drags NW-SE"); + assert_eq!(resize_cursor(&s, ph, 2), egui::CursorIcon::ResizeNeSw, "the NE corner drags NE-SW"); + assert_eq!(resize_cursor(&s, ph, 1), egui::CursorIcon::ResizeVertical, "the top edge drags vertically"); + } + fn harness() -> Harness<'static, SlideApp> { let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SlideApp| { From 52a9a45f6af49ea09e9159335eea2b41576d4e86 Mon Sep 17 00:00:00 2001 From: Sazid Al Bayazid Date: Tue, 20 Oct 2026 00:44:48 +0600 Subject: [PATCH 10/37] Render: a quick style on an empty placeholder is covered by a test (#832) The fix changed the empty-placeholder fill gate, but nothing locked the behaviour in: a quick style's fill has to appear on an empty placeholder, and a placeholder with no style at all still has to stay empty. The new render test draws an empty body placeholder in the editor with a quick style and checks that the accent fill is painted - every channel of it - and that the same placeholder without a style stays white. With the gate reverted to `s.fill.is_none()` alone the first assertion fails - the placeholder stays white - so the test is red on the unfixed code. --- crates/render/src/tests.rs | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 4ee1c889d..da9c0fab9 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -2,7 +2,7 @@ use super::*; use deckcraft_model::chart::{ChartType, Trendline, TrendlineType}; use deckcraft_model::style::ColorRef; use deckcraft_model::text::TextBody; -use deckcraft_model::{Chart, Geom, Presentation, Shape, ShapeId, ShapeKind, ShapeStyle, Table, Xfrm}; +use deckcraft_model::{Chart, Geom, PhType, Placeholder, Presentation, Shape, ShapeId, ShapeKind, ShapeStyle, Table, Xfrm}; fn deck_with(shapes: Vec) -> Presentation { let mut p = Presentation::default(); @@ -35,6 +35,33 @@ fn shape_fill_uses_theme_accent() { assert_eq!(img.pixel(50, 50), [255, 255, 255, 255]); } +/// #832: a quick style on an empty placeholder paints its fill. The style reference +/// (`s.style`) carries the fill, but the empty-placeholder gate skipped the fill whenever the +/// shape had no local fill, so the quick style only appeared once text was added. A +/// placeholder with no style at all still shows nothing but its dashed outline. +#[test] +fn a_quick_style_fills_an_empty_placeholder() { + let placeholder = || Shape { + id: ShapeId(910), + xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)), + ph: Some(Placeholder { kind: PhType::Body, idx: 1, ..Default::default() }), + ..Default::default() + }; + let p = deck_with(vec![Shape { style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent1)), ..placeholder() }]); + let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, edit: true, ..Default::default() }); + let accent = p.masters[0].theme.colors.get(deckcraft_color::SchemeSlot::Accent1); + // Lower right of the shape: the prompt text sits at the top of the text rect. + let px = img.pixel(280, 185); + assert!( + (px[0] as i32 - accent.r as i32).abs() <= 2 && (px[1] as i32 - accent.g as i32).abs() <= 2 && (px[2] as i32 - accent.b as i32).abs() <= 2, + "the quick style's fill is drawn on the empty placeholder: {px:?} vs {accent:?}" + ); + // An unformatted empty placeholder stays empty: the gate is not simply gone. + let p = deck_with(vec![placeholder()]); + let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, edit: true, ..Default::default() }); + assert_eq!(img.pixel(280, 185), [255, 255, 255, 255], "an unformatted placeholder has no fill"); +} + #[test] fn render_shapes_draws_the_selection_on_a_transparent_canvas() { let sh = Shape { From 0405fb73bfc5b912883820244debafb82154d386 Mon Sep 17 00:00:00 2001 From: Sazid Al Bayazid Date: Sun, 18 Oct 2026 12:43:44 +0600 Subject: [PATCH 11/37] Charts: a series' own data-label setting decides whether its labels show (#868) The reader stored a series-level `c:dLbls/c:showVal` in `Series.data_labels`, but every drawing path tested only the chart-wide `Chart.data_labels`: a series that asked for labels got none, and a series that asked for none got them. The series' own setting now wins, and a bare `` (no `val` attribute) means true, as the schema's default. The plot group's own setting is respected too: a group that asks for no labels keeps them off, even when another group of a combo chart asks for them. Before, any group that asked turned the chart-wide setting on, and the group that said no inherited it. Tests: `crates/pptx/tests/chart_data_labels.rs` - a bare series `showVal` shows that series' labels while its siblings stay quiet; an explicit `val="0"` on the first series keeps it quiet under a chart-wide on; a combo chart keeps each group's own setting; all three settings round-trip. Red-proof: with the series-level setting ignored, the first two fail; with the group's setting ignored, the combo chart's bar group shows labels it asked to keep quiet. The model gains `Chart::series_data_labels`, the one place that decides whether a series' labels show; the render paths (bar, column, line, area, pie, doughnut, radar, scatter) call it. --- ROADMAP.md | 1 + crates/model/src/chart.rs | 20 ++++ crates/pptx/src/read/chart.rs | 12 +- crates/pptx/src/write/chart.rs | 2 +- crates/pptx/tests/chart_data_labels.rs | 154 +++++++++++++++++++++++++ crates/render/src/chart.rs | 4 +- crates/render/src/chartex.rs | 4 +- crates/render/src/radar.rs | 2 +- 8 files changed, 191 insertions(+), 8 deletions(-) create mode 100644 crates/pptx/tests/chart_data_labels.rs diff --git a/ROADMAP.md b/ROADMAP.md index 93bc931ee..32fd15b85 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -137,6 +137,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-17 | Edit Points: turning a shape into a freeform is one undo step, so Undo brings the original shape back (it used to stay a freeform) | | 2026-10-17 | While a placeholder's text is being edited, a click on a shape in front of it selects that shape instead of moving the caret, as in PowerPoint (#931) | | 2026-10-15 | Native saves keep an imported chart's embedded workbook, drawings and relationships; headless PPTX → edit → native save/reopen → PPTX tests verify the payloads and stale-workbook rejection | +| 2026-10-18 | Charts: a series' own data-label setting is honoured — numbers PowerPoint shows on a graph no longer disappear, and a bare `c:showVal` means on (#868) | | 2026-10-15 | PPTX trigger sequences keep separate On Click steps, including paragraph builds; synthetic import/edit/save/reopen and headless show regressions | | 2026-10-16 | Export: PNG with a transparent background — the slide background is left out, only the slide's content is drawn (Export… dialog, File › Export, web download, `file.export`/`file.render` `transparentBackground`, `deckcraft-cli render --transparent`) (#784) | | 2026-10-16 | Arrange ▸ Align: Align to Slide and Align Selected Objects, as in PowerPoint. One object aligns to the slide and several to each other until the other choice is ticked; the choice holds for that selection and also drives Distribute | diff --git a/crates/model/src/chart.rs b/crates/model/src/chart.rs index f5aeb1ee1..a71ffec8a 100644 --- a/crates/model/src/chart.rs +++ b/crates/model/src/chart.rs @@ -699,6 +699,12 @@ impl Chart { crate::numfmt::format(code.map_or("General", String::as_str), v, self.lang.as_deref()) } + /// Does series `s` show its data labels? The series' own `c:dLbls/c:showVal` wins — an + /// explicit `false` hides them even when the chart-wide setting is on (#868). + pub fn series_data_labels(&self, s: &Series) -> bool { + s.data_labels.unwrap_or(self.data_labels) + } + /// Value range across series (stacked sums for stacked types). pub fn value_range(&self) -> (f64, f64) { let mut lo: f64 = 0.0; @@ -850,6 +856,20 @@ mod tests { assert_eq!(c.log_axis_scale(2), Some((0.0, 6.0, 3.0))); } + /// #868: a series' own label setting wins; an unset series takes the chart-wide one. + #[test] + fn series_data_labels_override_the_chart_wide_setting() { + let mut c = Chart::sample(ChartType::Column); + c.data_labels = false; + let mut s = c.series[0].clone(); + assert!(!c.series_data_labels(&s), "unset series follows the chart"); + s.data_labels = Some(true); + assert!(c.series_data_labels(&s), "a series that asks for labels gets them"); + c.data_labels = true; + s.data_labels = Some(false); + assert!(!c.series_data_labels(&s), "an explicit series false hides them anyway"); + } + #[test] fn error_bar_ranges() { let vals = [Some(3.0), Some(5.0), Some(2.0), Some(4.0)]; diff --git a/crates/pptx/src/read/chart.rs b/crates/pptx/src/read/chart.rs index 73cc416c5..44fd31244 100644 --- a/crates/pptx/src/read/chart.rs +++ b/crates/pptx/src/read/chart.rs @@ -213,7 +213,11 @@ fn chart_within(root: &El, mut budget: usize) -> Option { if let Some(v) = g.child_val("holeSize").and_then(|v| v.parse::().ok()) { c.hole_size = (v / 100.0).clamp(0.0, 0.9); } - if g.path(&["dLbls", "showVal"]).and_then(|v| v.bool("val")) == Some(true) { + // `` without a val attribute means true, as in the schema's default. + // The setting belongs to this plot group: a group that asks for no labels keeps them + // off, even when another group of a combo chart asks for them (#868). + let group_labels = g.path(&["dLbls", "showVal"]).map(|v| v.bool("val").unwrap_or(true)); + if group_labels == Some(true) { c.data_labels = true; } kinds.push(kind); @@ -230,8 +234,12 @@ fn chart_within(root: &El, mut budget: usize) -> Option { secondary_axis: secondary, ..Default::default() }; - if let Some(d) = s.child("dLbls").and_then(|d| d.child("showVal")).and_then(|v| v.bool("val")) { + // `` without a val attribute means true, as in the schema's default. + if let Some(d) = s.child("dLbls").and_then(|d| d.child("showVal")).map(|v| v.bool("val").unwrap_or(true)) { ser.data_labels = Some(d); + } else if let Some(false) = group_labels { + // The group asks for no labels and the series does not say otherwise. + ser.data_labels = Some(false); } for t in s.children_named("trendline").take(16) { if let Some(t) = trendline(t) { diff --git a/crates/pptx/src/write/chart.rs b/crates/pptx/src/write/chart.rs index 889174507..2ec0959a9 100644 --- a/crates/pptx/src/write/chart.rs +++ b/crates/pptx/src/write/chart.rs @@ -857,7 +857,7 @@ pub fn build_chartex(c: &Chart) -> Vec { for (i, s) in c.series.iter().enumerate().take(if one { 1 } else { usize::MAX }) { w.open("cx:series", A::new().a("layoutId", layout).a("uniqueId", format!("{{DC00000{i}-0000-4000-8000-000000000000}}"))); cx_text(&mut w, &s.name); - if s.data_labels.unwrap_or(c.data_labels) { + if c.series_data_labels(s) { w.open("cx:dataLabels", A::new().a("pos", if c.kind == ChartType::Funnel { "ctr" } else { "outEnd" })); w.empty("cx:visibility", A::new().a("seriesName", "0").a("categoryName", "0").a("value", "1")); w.close("cx:dataLabels"); diff --git a/crates/pptx/tests/chart_data_labels.rs b/crates/pptx/tests/chart_data_labels.rs new file mode 100644 index 000000000..d89fae535 --- /dev/null +++ b/crates/pptx/tests/chart_data_labels.rs @@ -0,0 +1,154 @@ +//! Series-level data labels: `c:dLbls/c:showVal` on a series overrides the chart-wide setting, +//! and a bare `` (no `val` attribute) means true, as the schema's default (#868). + +use std::io::{Cursor, Read, Write}; +use std::sync::Arc; + +use deckcraft_geom::Xfrm; +use deckcraft_model::chart::{Chart, ChartType}; +use deckcraft_model::{LayoutType, Presentation, Shape, ShapeId, ShapeKind, defaults}; + +/// A one-slide deck whose bar chart shows no labels anywhere. +fn bar_chart_pptx() -> Vec { + let mut p = defaults::new_presentation(None); + p.slides.clear(); + let layout = defaults::layout_of_kind(&p, LayoutType::Blank).unwrap_or_default(); + let mut slide = defaults::new_slide(&mut p, layout).expect("slide"); + let mut shape = + Shape { id: ShapeId(p.alloc_id().expect("id")), name: "Bars".into(), xfrm: Some(Xfrm::new(40.0, 40.0, 400.0, 300.0)), ..Default::default() }; + let mut chart = Chart::sample(ChartType::Column); + chart.data_labels = false; + for s in &mut chart.series { + s.data_labels = None; + } + shape.kind = ShapeKind::Chart(Box::new(chart)); + slide.shapes.push(shape); + p.slides.push(Arc::new(slide)); + deckcraft_pptx::export(&p).expect("export") +} + +/// Rewrite the chart part's XML through `f` and re-import the deck. +fn reimport(bytes: &[u8], f: impl Fn(&str) -> String) -> Presentation { + let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip"); + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + let opts = zip::write::SimpleFileOptions::default(); + for i in 0..zip.len() { + let mut entry = zip.by_index(i).expect("entry"); + let name = entry.name().to_string(); + let mut data = vec![]; + entry.read_to_end(&mut data).expect("read"); + if name.starts_with("ppt/charts/chart") && name.ends_with(".xml") && !name.contains("_rels") { + let xml = String::from_utf8(data).expect("chart xml"); + data = f(&xml).into_bytes(); + } + out.start_file(&name, opts).expect("start"); + out.write_all(&data).expect("write"); + } + let patched = out.finish().expect("finish").into_inner(); + deckcraft_pptx::import(&patched).expect("import") +} + +fn chart_of(p: &Presentation) -> &Chart { + let s = p.slides.first().expect("slide"); + for sh in &s.shapes { + if let ShapeKind::Chart(c) = &sh.kind { + return c; + } + } + panic!("no chart in the deck"); +} + +/// `` on the first series: that series shows labels although the chart-wide +/// setting is off; the other series stay quiet. +#[test] +fn bare_series_showval_shows_labels() { + let p = reimport(&bar_chart_pptx(), |xml| xml.replacen("", r#""#, 1)); + let c = chart_of(&p); + assert!(!c.data_labels, "the chart-wide setting stays off"); + assert_eq!(c.series[0].data_labels, Some(true), "a bare showVal means true"); + assert!(c.series_data_labels(&c.series[0]), "the first series' labels show"); + for s in c.series.iter().skip(1) { + assert_eq!(s.data_labels, None, "other series keep the chart's setting"); + assert!(!c.series_data_labels(s), "and stay quiet"); + } +} + +/// `val="0"` on the series hides its labels although the chart-wide setting is on. +#[test] +fn series_showval_false_hides_labels() { + let p = reimport(&bar_chart_pptx(), |xml| { + xml.replacen("", r#""#, 1).replacen( + " Vec { + let mut p = defaults::new_presentation(None); + p.slides.clear(); + let layout = defaults::layout_of_kind(&p, LayoutType::Blank).unwrap_or_default(); + let mut slide = defaults::new_slide(&mut p, layout).expect("slide"); + let mut chart = Chart::sample(ChartType::Combo); + chart.series.truncate(2); + chart.series[1].kind = Some(ChartType::Line); + let mut shape = + Shape { id: ShapeId(p.alloc_id().expect("id")), name: "Bars".into(), xfrm: Some(Xfrm::new(40.0, 40.0, 400.0, 300.0)), ..Default::default() }; + shape.kind = ShapeKind::Chart(Box::new(chart)); + slide.shapes.push(shape); + p.slides.push(Arc::new(slide)); + deckcraft_pptx::export(&p).expect("export") +} + +/// A combo chart's groups keep their own label setting: the group that asks for no labels +/// keeps them off, even when the other group asks for them (#868). +#[test] +fn a_combo_chart_keeps_each_groups_own_label_setting() { + let p = reimport(&combo_chart_pptx(), |xml| { + // The bar group asks for no labels, the line group for labels. + xml.replacen("", + r#""#, + 1, + ) + }); + let c = chart_of(&p); + assert!(c.data_labels, "the line group's ask turns the chart-wide setting on"); + assert_eq!(c.series[0].data_labels, Some(false), "the bar group asks for no labels"); + assert!(!c.series_data_labels(&c.series[0]), "so the bars stay quiet"); + assert!(c.series_data_labels(&c.series[1]), "the line takes the chart-wide on"); +} diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index ac1857807..bf047cb10 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -310,7 +310,7 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, ctx.set_paint(color(Rgba::WHITE, 1.0)); ctx.set_stroke(kurbo::Stroke::new(1.0)); ctx.stroke_path(&p); - if c.data_labels { + if c.series_data_labels(s) { let mid = a0 + sweep / 2.0; let lp = Point::new(cen.x + r * 0.65 * mid.cos(), cen.y + r * 0.65 * mid.sin()); label( @@ -656,7 +656,7 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, err_marks.push((mid, p0, p1, bar.min(base * 0.8) * 0.5, e.end_cap)); } } - if c.data_labels { + if c.series_data_labels(s) { let t = c.data_label(s, s.values.get(i).copied().flatten().unwrap_or(0.0)); let lr = if horiz { Rect::new(r.x1 + 2.0, r.y0, r.x1 + 40.0, r.y1) diff --git a/crates/render/src/chartex.rs b/crates/render/src/chartex.rs index e62105e84..b0f3fffd7 100644 --- a/crates/render/src/chartex.rs +++ b/crates/render/src/chartex.rs @@ -24,7 +24,7 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rec return; } let value_label = |v: f64| c.data_label(s, v); - let labels = c.data_labels || c.kind == ChartType::Funnel; + let labels = c.series_data_labels(s) || c.kind == ChartType::Funnel; match c.kind { ChartType::Funnel => funnel(ctx, rctx, c, m, area, base, text_col, &vals, &value_label), ChartType::Treemap if !c.category_levels.is_empty() => tree_treemap(ctx, rctx, c, m, area, base, labels.then_some(&value_label)), @@ -107,7 +107,7 @@ fn region_tiles(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: ctx.fill_rect(&t); let dark = v.is_some_and(|v| (v - min) / span > 0.55); let mut text = name.clone(); - if c.data_labels + if c.series_data_labels(s) && let Some(v) = v { text = format!( diff --git a/crates/render/src/radar.rs b/crates/render/src/radar.rs index 3416c7ce6..b99c644c5 100644 --- a/crates/render/src/radar.rs +++ b/crates/render/src/radar.rs @@ -120,7 +120,7 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rec } } } - if c.data_labels || s.data_labels == Some(true) { + if c.series_data_labels(s) { for (i, q) in pts.iter().enumerate() { if let Some(v) = s.values.get(i).copied().flatten() { let t = c.data_label(s, v); From 502fb2ba73a3809b36d720982eaea5b5935242f4 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:03:20 +0000 Subject: [PATCH 12/37] Indent table rows in outline export so they stay on their slide Found while testing crates/format/src/lib.rs:660-665 (slides_to_outline) and :573-574 (outline_to_slides) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/format/src/lib.rs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 093cbaa62..c4fdaf8ce 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -660,6 +660,7 @@ pub fn slides_to_outline(p: &Presentation) -> String { if let ShapeKind::Table(t) = &sh.kind { for row in &t.rows { let cells: Vec = row.cells.iter().map(|c| c.text.text().replace(['\u{b}', '\n'], " ")).collect(); + out.push('\t'); out.push_str(&cells.join("\t")); out.push('\n'); } @@ -1087,3 +1088,27 @@ fn outline_includes_table_text() { assert!(out.contains("Cell A1\tCell B1"), "{out}"); assert!(out.contains("Cell A2\tCell B2"), "{out}"); } + +#[test] +fn outline_table_rows_are_indented_and_do_not_become_slides() { + use deckcraft_model::table::{Cell, Row, Table}; + use deckcraft_model::{Shape, ShapeKind, Xfrm}; + let mut p = defaults::new_presentation(None); + let layout = defaults::layout_of_kind(&p, LayoutType::TitleAndContent).unwrap(); + let mut s = defaults::new_slide(&mut p, layout).expect("slide"); + s.shapes.retain(|sh| !sh.ph_type().is_some_and(|k| k.is_title())); + let mut table = Table { cols: vec![200.0, 200.0], ..Default::default() }; + for texts in [["A1", "B1"], ["A2", "B2"]] { + let cells: Vec = texts.iter().map(|t| Cell { text: TextBody::from_text(t), ..Default::default() }).collect(); + table.rows.push(Row { height: 30.0, cells }); + } + let mut tbl = Shape { xfrm: Some(Xfrm::new(0.0, 100.0, 400.0, 60.0)), ..Default::default() }; + tbl.kind = ShapeKind::Table(table); + s.shapes.push(tbl); + p.slides = vec![std::sync::Arc::new(s)]; + + let out = slides_to_outline(&p); + let mut q = defaults::new_presentation(None); + q.slides.clear(); + assert_eq!(outline_to_slides(&mut q, &out), 0, "table rows became slides: {out:?}"); +} From fdfdb9e058bfc5906d24421048b30dbd4f2290ee Mon Sep 17 00:00:00 2001 From: Faabyy <68284158+Faabyy@users.noreply.github.com> Date: Wed, 21 Oct 2026 08:04:19 +0000 Subject: [PATCH 13/37] Reuse Slides: choose which slides of the other file to insert (#1103) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reuse Slides… used to insert every slide of the picked presentation. It now opens a list of that presentation's slides (number and title, all ticked), with Select All / Select None and a Keep source formatting option, and inserts only the ticked ones after the current slide. New query slide.reuseList {path | data} gives the list; slide.reuse already took `slides`. Co-Authored-By: Claude --- crates/engine/src/cmd/reuse.rs | 67 +++++++++++++++++++++++++++++----- crates/ui-egui/src/dialogs.rs | 61 +++++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 15 +++++++- 3 files changed, 133 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/reuse.rs b/crates/engine/src/cmd/reuse.rs index 1e6c09acf..05052ebdf 100644 --- a/crates/engine/src/cmd/reuse.rs +++ b/crates/engine/src/cmd/reuse.rs @@ -17,15 +17,34 @@ use super::*; use crate::{Result, Session, media_refs}; pub fn specs() -> Vec { - vec![cmd!( - "slide.reuse", - "Reuse Slides…", - ["Home", "Slides", "New Slide"], - None, - "{path? | data: base64, name?, slides?: [index] (0-based, default all), keepFormatting?: bool (default false: use the destination theme), at?: index (default after the current slide)}", - has_doc, - reuse - )] + vec![ + cmd!( + query "slide.reuseList", + "List Slides to Reuse", + [], + None, + "{path | data: base64, name?} → {name, slides: [{index, title, hidden}]}: the slides of another presentation, for choosing which ones `slide.reuse` brings in", + has_doc, + reuse_list + ), + cmd!( + "slide.reuse", + "Reuse Slides…", + ["Home", "Slides", "New Slide"], + None, + "{path? | data: base64, name?, slides?: [index] (0-based, default all), keepFormatting?: bool (default false: use the destination theme), at?: index (default after the current slide)}", + has_doc, + reuse + ), + ] +} + +fn reuse_list(_s: &mut Session, p: &Value) -> Result { + let (name, bytes) = super::insert::media_bytes(p, "slide.reuseList")?; + let src = super::file::open_presentation(&name, &bytes)?; + let slides: Vec = + src.slides.iter().enumerate().map(|(index, sl)| json!({"index": index, "title": sl.title(), "hidden": sl.hidden})).collect(); + Ok(json!({"name": name, "slides": slides})) } /// Point every media id under `x` at its copy in the destination (`map`: source → destination). @@ -397,4 +416,34 @@ mod tests { assert_eq!(click(first.shapes.get(2)), Some(Action::Slide { slide: left_behind }), "a slide not reused keeps its link"); assert_eq!(click(copy(1).shapes.first()), to(0), "a link back to an earlier copy follows too"); } + + /// The list that lets a dialog offer the slides of the other file; picking from it brings in + /// exactly those. + #[test] + fn reuse_list_names_the_slides_and_the_picked_ones_come_in() { + let mut src = Session::with_new(); + for t in ["Alpha", "Beta", "Gamma"] { + src.execute("slide.new", &json!({"layout": "titleAndContent", "title": t})).expect("new"); + } + let last_slide = src.doc().expect("doc").doc.slides.len() - 1; + src.execute("slide.hide", &json!({"index": last_slide})).expect("hide"); + let bytes = crate::cmd::file::save_bytes(&src.doc().expect("doc").doc, "deckcraft").expect("save"); + let data = super::super::base64_encode(&bytes); + + let mut s = Session::with_new(); + let listed = s.execute("slide.reuseList", &json!({"data": data, "name": "src.deckcraft"})).expect("list"); + let slides = listed["slides"].as_array().expect("slides"); + let title = |i: usize| slides.iter().find(|x| x["index"] == i).and_then(|x| x["title"].as_str()).unwrap_or_default().to_string(); + assert!(slides.len() >= 3); + let (a, b, g) = (title(slides.len() - 3), title(slides.len() - 2), title(slides.len() - 1)); + assert_eq!((a.as_str(), b.as_str(), g.as_str()), ("Alpha", "Beta", "Gamma")); + assert_eq!(slides.last().map(|x| x["hidden"].clone()), Some(json!(true))); + assert_eq!(s.doc().expect("doc").doc.slides.len(), 1, "listing changes nothing"); + + let last = slides.len() - 1; + s.execute("slide.reuse", &json!({"data": data, "name": "src.deckcraft", "slides": [last - 2, last]})).expect("reuse"); + let titles: Vec = s.doc().expect("doc").doc.slides.iter().map(|x| x.title()).collect(); + assert!(titles.iter().any(|t| t == "Alpha") && titles.iter().any(|t| t == "Gamma")); + assert!(!titles.iter().any(|t| t == "Beta"), "a slide that was not picked stays out"); + } } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2393bd611..d4f184f2f 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -42,6 +42,7 @@ fn title(id: &str) -> &'static str { "altText" => "Alt Text", "outline" => "Slides from Outline", "renameSection" => "Rename Section", + "reuseSlides" => "Reuse Slides", "renameShape" => "Rename", "renameLayout" => "Rename Layout", "paragraph" => "Paragraph", @@ -1522,6 +1523,66 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } ok || cancel } + "reuseSlides" => { + let path = d.params.get("path").and_then(Value::as_str).unwrap_or("").to_string(); + let slides: Vec<(usize, String)> = d + .params + .get("slides") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .filter_map(|x| { + Some((usize::try_from(x.get("index")?.as_u64()?).ok()?, x.get("title").and_then(Value::as_str).unwrap_or("").to_string())) + }) + .collect() + }) + .unwrap_or_default(); + let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(path.clone()); + ui.label(format!("Insert slides from {name} after the current slide:")); + // Unticked slides are remembered by index; everything starts ticked. + let off = |d: &Dialog, i: usize| d.fields.get(&format!("off{i}")).is_some_and(|v| v == "1"); + ui.horizontal(|ui| { + if ui.small_button("Select All").clicked() { + for (i, _) in &slides { + d.fields.remove(&format!("off{i}")); + } + } + if ui.small_button("Select None").clicked() { + for (i, _) in &slides { + d.fields.insert(format!("off{i}"), "1".into()); + } + } + }); + egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| { + for (i, title) in &slides { + let mut on = !off(d, *i); + let label = if title.trim().is_empty() { format!("{} (no title)", i + 1) } else { format!("{} {}", i + 1, title.trim()) }; + if ui.checkbox(&mut on, label).changed() { + if on { + d.fields.remove(&format!("off{i}")); + } else { + d.fields.insert(format!("off{i}"), "1".into()); + } + } + } + }); + let mut keep = d.params.get("keep").and_then(Value::as_bool).unwrap_or(false); + if ui.checkbox(&mut keep, "Keep source formatting").changed() + && let Some(o) = d.params.as_object_mut() + { + o.insert("keep".into(), json!(keep)); + } + let picked: Vec = slides.iter().map(|(i, _)| *i).filter(|i| !off(d, *i)).collect(); + let (ok, cancel) = buttons(ui, "Insert"); + if ok { + if picked.is_empty() { + app.set_status("Tick at least one slide to insert".to_string()); + return false; + } + app.reuse_slides(&path, keep, &picked); + } + ok || cancel + } "renameSection" | "renameShape" | "renameLayout" => { let cur = d.params.get("name").and_then(Value::as_str).unwrap_or("").to_string(); field(ui, d, "Name:", "name", &cur); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index eef9b1b39..2c4f9ee70 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1233,7 +1233,20 @@ impl SlideApp { return; }; let Some(path) = pick("open") else { return }; - match self.session.execute("slide.reuse", &json!({"path": path, "keepFormatting": keep})) { + // Offer the file's slides to choose from; all of them are ticked to begin with. + match self.session.execute("slide.reuseList", &json!({"path": path})) { + Ok(list) => { + let mut d = dialogs::Dialog::new("reuseSlides"); + d.params = json!({"path": path, "keep": keep, "slides": list["slides"]}); + self.dialog = Some(d); + } + Err(e) => self.set_status(e.to_string()), + } + } + + /// Insert the slides `picked` (indices) of the presentation at `path` after the current slide. + pub fn reuse_slides(&mut self, path: &str, keep: bool, picked: &[usize]) { + match self.session.execute("slide.reuse", &json!({"path": path, "keepFormatting": keep, "slides": picked})) { Ok(r) => self.set_status(format!("Inserted {} slides from {path}", r["slides"])), Err(e) => self.set_status(e.to_string()), } From 3cd6a815ca0360be0925610bd279c59655332ea8 Mon Sep 17 00:00:00 2001 From: Faabyy <68284158+Faabyy@users.noreply.github.com> Date: Wed, 21 Oct 2026 08:14:34 +0000 Subject: [PATCH 14/37] Roadmap: record reuse-picker-1103 Co-Authored-By: Claude --- ROADMAP.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 93bc931ee..0c1a9a6ec 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-21 | Reuse Slides: pick which slides of the other presentation to insert from a list (`slide.reuseList`, #1103) | | 2026-10-18 | Native chart sidecar bytes move from JSON arrays into referenced ZIP entries, with shared media byte/item limits and bounded reads; older inline sidecars still load. Synthetic format regressions cover large workbooks and malformed members | | 2026-10-17 | Opening a file caps absurd effect sizes (glow, soft edges, shadows, reflections, on shapes, text and table text) at what Format Shape allows: a glow of 10^18 pt in a .pptx made rendering ask for 34 GB and abort | | 2026-10-17 | Opening a .deckcraft file whose shapes repeat an id gives the repeats fresh ids (groups, layouts and masters too), so the deck validates and edits by id find one shape | @@ -239,6 +240,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-10-21 | trivial | Progress log: Reuse Slides picker (#1103). Scores and milestone names unchanged | | 2026-10-18 | minor | Native chart payloads use referenced ZIP members and share media byte/item limits; old inline sidecars remain readable. Synthetic tests cover multi-megabyte layout, all shape owners, shared budgets, missing/corrupt members and invalid references. Scores and milestone scope unchanged | | 2026-10-15 | trivial | Progress log: chart package parts survive a native save. Scores and milestone names unchanged | | 2026-10-15 | trivial | Progress log: PPTX trigger click-step round trip. Feature scores and milestone names unchanged | From 08b7b5eb15f20daedfc15f50b60bec440df0aa38 Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Thu, 22 Oct 2026 09:02:09 +0200 Subject: [PATCH 15/37] Charts: bars draw their series' pattern fill and no fill A bar series' own fill was read only when solid or a gradient: a pattern (a:pattFill) was dropped, and an a:noFill series was read but drawn in the palette's colour anyway, because the chart renderer turned every fill into one colour and fell back to the palette for anything but solid. PowerPoint for Mac draws the pattern, leaves an a:noFill bar empty and draws its outline (c:spPr/a:ln) where it has one: a synthetic column chart with a red-on-yellow dkDnDiag series, an empty series with a 3 pt blue outline and an empty one without, exported as PDF, shows exactly that, legend keys included. - The chart reader reads a:pattFill (dml::pattern, shared with shapes); the chart writer writes it back for an edited chart. - Bars and their legend keys paint with the series' or point's own fill: a colour, a preset pattern (pattern::fill) or nothing, then the series' solid outline at its width. - Lines, areas and markers, which take one colour, use a pattern's foreground colour instead of the palette's. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 + crates/pptx/src/read/chart.rs | 26 +++++++- crates/pptx/src/read/dml.rs | 15 +++-- crates/pptx/src/write/chart.rs | 12 +++- crates/render/src/chart.rs | 116 +++++++++++++++++++++++++-------- crates/render/src/tests.rs | 37 +++++++++++ 6 files changed, 173 insertions(+), 35 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f5daa2d38..1504ccd90 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-22 | Chart bars and their legend keys draw the series' own pattern fill (`a:pattFill`, now read and written) and draw nothing for `a:noFill`, with the series outline where it has one, as PowerPoint for Mac does (synthetic probe); they took the palette's colour before. Lines, areas and markers use a pattern's foreground colour | | 2026-10-18 | Native chart sidecar bytes move from JSON arrays into referenced ZIP entries, with shared media byte/item limits and bounded reads; older inline sidecars still load. Synthetic format regressions cover large workbooks and malformed members | | 2026-10-17 | Opening a file caps absurd effect sizes (glow, soft edges, shadows, reflections, on shapes, text and table text) at what Format Shape allows: a glow of 10^18 pt in a .pptx made rendering ask for 34 GB and abort | | 2026-10-17 | Opening a .deckcraft file whose shapes repeat an id gives the repeats fresh ids (groups, layouts and masters too), so the deck validates and edits by id find one shape | @@ -245,6 +246,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-15 | trivial | Progress log: PPTX trigger click-step round trip. Feature scores and milestone names unchanged | | 2026-10-16 | trivial | Progress log: PNG export with a transparent background (#784) | | 2026-10-16 | trivial | Progress log: a `.ppsx` from the system starts the slide show (#862) | +| 2026-10-22 | trivial | Progress log: chart series pattern and no fills | | 2026-10-14 | trivial | remote MCP checks reply IDs, envelopes and bounded newline framing (#718) | | 2026-10-14 | minor | Gray list markers on empty paragraphs while editing; caret keeps the list indent before typing | | 2026-10-14 | minor | Community PR wave folded: headline breadth 84% over 191 features (P0 92%, P1 82%, P2 55%) and the feature-area breadths from `cargo xtask parity`; one row per area; Upcoming item 2 merged; per-PR count notes dropped from the progress log. Ready stays ~55%, stage alpha | diff --git a/crates/pptx/src/read/chart.rs b/crates/pptx/src/read/chart.rs index 73cc416c5..d60b764a6 100644 --- a/crates/pptx/src/read/chart.rs +++ b/crates/pptx/src/read/chart.rs @@ -5,7 +5,7 @@ use deckcraft_model::style::Fill; use crate::xml::El; -/// Fills of chart parts never reference images we keep; read colour fills only. +/// Fills of chart parts never reference images we keep; read colour and pattern fills only. fn simple_fill(sp: Option<&El>) -> Option { let sp = sp?; for e in sp.elements() { @@ -13,6 +13,7 @@ fn simple_fill(sp: Option<&El>) -> Option { "noFill" => return Some(Fill::None), "solidFill" => return super::dml::color(e).map(|color| Fill::Solid { color }), "gradFill" => return Some(Fill::Gradient(super::dml::gradient(e))), + "pattFill" => return Some(Fill::Pattern(super::dml::pattern(e))), _ => {} } } @@ -557,6 +558,29 @@ mod tests { assert!(!read_chart_xml(&crate::write::chart::chart_xml(&c).0).unwrap().minor_gridlines); } + #[test] + fn series_pattern_fills_are_read_and_written_back() { + // A pattern-filled series and an empty one with an outline, as PowerPoint writes them. + let xml = br#"53"#; + let c = read_chart_xml(xml).unwrap(); + let accent4 = deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent4); + let pattern = deckcraft_model::style::PatternFill { + preset: "openDmnd".into(), + fg: accent4.clone(), + bg: deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Bg1), + }; + assert_eq!(c.series[0].fill, Some(Fill::Pattern(pattern.clone()))); + assert_eq!(c.series[1].fill, Some(Fill::None)); + assert_eq!(c.series[1].line.as_ref().and_then(|l| l.fill.clone()), Some(Fill::solid(accent4))); + // Written out from the model (an edited chart), the pattern survives. + let mut edited = c.clone(); + edited.raw = None; + edited.title = Some("Edited".into()); + let back = read_chart_xml(&crate::write::chart::chart_xml(&edited).0).unwrap(); + assert_eq!(back.series[0].fill, Some(Fill::Pattern(pattern))); + assert_eq!(back.series[1].fill, Some(Fill::None)); + } + #[test] fn log_base_is_read() { let xml = br#""#; diff --git a/crates/pptx/src/read/dml.rs b/crates/pptx/src/read/dml.rs index c88bd1a9f..ef75eca89 100644 --- a/crates/pptx/src/read/dml.rs +++ b/crates/pptx/src/read/dml.rs @@ -86,16 +86,21 @@ pub fn fill_el(imp: &mut Imp, part: &Part, f: &El) -> Option { Some(p) => Fill::Picture(p), None => Fill::None, }, - "pattFill" => Fill::Pattern(PatternFill { - preset: f.attr("prst").unwrap_or("pct5").to_string(), - fg: f.child("fgClr").and_then(color).unwrap_or(ColorRef::rgb(Rgba::BLACK)), - bg: f.child("bgClr").and_then(color).unwrap_or(ColorRef::rgb(Rgba::WHITE)), - }), + "pattFill" => Fill::Pattern(pattern(f)), "grpFill" => Fill::Group, _ => return None, }) } +/// An `a:pattFill`: its preset (5 % dots when unset), foreground (black) and background (white). +pub fn pattern(f: &El) -> PatternFill { + PatternFill { + preset: f.attr("prst").unwrap_or("pct5").to_string(), + fg: f.child("fgClr").and_then(color).unwrap_or(ColorRef::rgb(Rgba::BLACK)), + bg: f.child("bgClr").and_then(color).unwrap_or(ColorRef::rgb(Rgba::WHITE)), + } +} + pub fn gradient(f: &El) -> Gradient { let mut stops: Vec = f .child("gsLst") diff --git a/crates/pptx/src/write/chart.rs b/crates/pptx/src/write/chart.rs index 889174507..45a190e09 100644 --- a/crates/pptx/src/write/chart.rs +++ b/crates/pptx/src/write/chart.rs @@ -30,7 +30,17 @@ fn simple_fill(w: &mut W, f: &Fill) { match f { Fill::None => w.empty0("a:noFill"), Fill::Solid { color } => super::dml::solid(w, color), - Fill::Gradient(_) | Fill::Picture(_) | Fill::Pattern(_) | Fill::Group | Fill::Background => { + Fill::Pattern(p) => { + w.open("a:pattFill", A::new().a("prst", if p.preset.is_empty() { "pct5" } else { &p.preset })); + w.open0("a:fgClr"); + super::dml::color(w, &p.fg); + w.close("a:fgClr"); + w.open0("a:bgClr"); + super::dml::color(w, &p.bg); + w.close("a:bgClr"); + w.close("a:pattFill"); + } + Fill::Gradient(_) | Fill::Picture(_) | Fill::Group | Fill::Background => { if let Fill::Gradient(g) = f && let Some(s) = g.stops.first() { diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index ac1857807..6c2ea1d5b 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -29,31 +29,70 @@ pub fn series_color(ctx: &Ctx, c: &Chart, i: usize) -> Rgba { } } +/// A series' one colour (lines, areas, markers): its solid fill, a pattern's foreground, else the +/// palette's. pub(crate) fn series_fill(ctx: &Ctx, c: &Chart, i: usize, s: Option<&deckcraft_model::chart::Series>) -> Rgba { match s.and_then(|s| s.fill.as_ref()) { Some(Fill::Solid { color: cc }) => ctx.color(cc, None), + Some(Fill::Pattern(p)) => ctx.color(&p.fg, None), _ => series_color(ctx, c, i), } } -/// The fill of point `k` of a series: its own (`c:dPt`) where the file gives one, else the series'. -fn point_fill(ctx: &Ctx, c: &Chart, i: usize, s: &deckcraft_model::chart::Series, k: usize) -> Rgba { - match s.point_fills.iter().find(|(p, _)| *p == k).map(|(_, f)| f) { - Some(Fill::Solid { color: cc }) => ctx.color(cc, None), - _ => series_fill(ctx, c, i, Some(s)), +/// How a bar, or its legend key, is painted. +#[derive(Clone, Debug, PartialEq)] +enum Paint { + Color(Rgba), + /// A preset pattern: name, foreground, background. + Pattern(String, Rgba, Rgba), + /// `a:noFill`: only the outline, if any, shows. + Nothing, +} + +/// The paint of fill `f` as a bar or legend key draws it; the palette's `fallback` when the file +/// gives none (or one a bar can't show). +fn paint_of(ctx: &Ctx, f: Option<&Fill>, fallback: Rgba) -> Paint { + match f { + Some(Fill::Solid { color: cc }) => Paint::Color(ctx.color(cc, None)), + Some(Fill::Pattern(p)) => Paint::Pattern(p.preset.clone(), ctx.color(&p.fg, None), ctx.color(&p.bg, None)), + Some(Fill::None) => Paint::Nothing, + _ => Paint::Color(fallback), } } -/// Fill of one bar: a `` fill wins, and a single-series chart that varies colours by point uses one accent per point -/// (not waterfalls, which are never read from a ``). -fn bar_fill(ctx: &Ctx, c: &Chart, si: usize, pi: usize, s: &deckcraft_model::chart::Series) -> Rgba { - if s.point_fills.iter().any(|(k, _)| *k == pi) { - return point_fill(ctx, c, si, s, pi); +/// Fill `r` (in the space `m` maps to the output) with `p`. +fn fill_with(ctx: &mut RenderContext, p: &Paint, r: Rect, m: Affine) { + match p { + Paint::Color(c) => { + ctx.set_transform(m); + ctx.set_paint(color(*c, 1.0)); + ctx.fill_rect(&r); + } + Paint::Pattern(name, fg, bg) => crate::pattern::fill(ctx, name, *fg, *bg, 1.0, &r.to_path(0.1), m), + Paint::Nothing => {} + } +} + +/// A bar series' outline (`c:spPr/a:ln`) where the file gives it a solid colour: colour and width. +fn bar_outline(ctx: &Ctx, s: &deckcraft_model::chart::Series) -> Option<(Rgba, f64)> { + let l = s.line.as_ref()?; + match l.fill.as_ref()? { + Fill::Solid { color: cc } => Some((ctx.color(cc, None), l.width.unwrap_or(0.75).clamp(0.0, 20.0))), + _ => None, + } +} + +/// Paint of one bar: a `` fill wins, and a single-series chart that varies colours by point uses one accent per point +/// (not waterfalls, which are never read from a ``). Pattern fills draw as patterns and +/// `a:noFill` leaves the bar empty, as in PowerPoint. +fn bar_paint(ctx: &Ctx, c: &Chart, si: usize, pi: usize, s: &deckcraft_model::chart::Series) -> Paint { + if let Some((_, f)) = s.point_fills.iter().find(|(k, _)| *k == pi) { + return paint_of(ctx, Some(f), series_fill(ctx, c, si, Some(s))); } if c.vary_colors && c.series.len() == 1 && s.fill.is_none() && c.kind != ChartType::Waterfall { - return series_color(ctx, c, pi); + return Paint::Color(series_color(ctx, c, pi)); } - series_fill(ctx, c, si, Some(s)) + paint_of(ctx, s.fill.as_ref(), series_color(ctx, c, si)) } fn polyline(pts: &[Point]) -> BezPath { @@ -235,16 +274,17 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, let pie = matches!(c.kind, ChartType::Pie | ChartType::Doughnut); // Legend placed by `c:legendPos`. let waterfall = c.kind == ChartType::Waterfall; - let legend_items: Vec<(String, Rgba)> = if waterfall { - ["Increase", "Decrease", "Total"].iter().enumerate().map(|(i, n)| (n.to_string(), series_color(rctx, c, i))).collect() + // Each legend entry: its name, its key's paint and the key's outline. + let legend_items: Vec<(String, Paint, Option<(Rgba, f64)>)> = if waterfall { + ["Increase", "Decrease", "Total"].iter().enumerate().map(|(i, n)| (n.to_string(), Paint::Color(series_color(rctx, c, i)), None)).collect() } else if matches!(c.kind, ChartType::Treemap | ChartType::Sunburst) { // A hierarchy's legend names its top-level groups. - c.category_tree().iter().enumerate().map(|(i, n)| (n.name.clone(), series_color(rctx, c, i))).collect() + c.category_tree().iter().enumerate().map(|(i, n)| (n.name.clone(), Paint::Color(series_color(rctx, c, i)), None)).collect() } else if matches!(c.kind, ChartType::Funnel | ChartType::RegionMap) { // A map draws its own colour scale. vec![] } else if pie { - c.categories.iter().enumerate().map(|(i, n)| (n.clone(), series_color(rctx, c, i))).collect() + c.categories.iter().enumerate().map(|(i, n)| (n.clone(), Paint::Color(series_color(rctx, c, i)), None)).collect() } else { c.series .iter() @@ -252,13 +292,24 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, .map(|(i, s)| { let k = c.series_kind(s); let line = !k.is_bar_like() && !matches!(k, ChartType::Area | ChartType::StackedArea | ChartType::FilledRadar); - (s.name.clone(), if line { series_line(rctx, c, i, s) } else { series_fill(rctx, c, i, Some(s)) }) + if line { + (s.name.clone(), Paint::Color(series_line(rctx, c, i, s)), None) + } else if k.is_bar_like() { + let paint = if c.vary_colors && c.series.len() == 1 && s.fill.is_none() { + Paint::Color(series_color(rctx, c, i)) + } else { + paint_of(rctx, s.fill.as_ref(), series_color(rctx, c, i)) + }; + (s.name.clone(), paint, bar_outline(rctx, s)) + } else { + (s.name.clone(), Paint::Color(series_fill(rctx, c, i, Some(s))), None) + } }) .collect() }; if let (Some(pos), false) = (&c.legend, legend_items.is_empty()) { let vertical = matches!(pos.as_str(), "l" | "r" | "tr"); - let item_ws: Vec = legend_items.iter().map(|(n, _)| n.chars().count() as f64 * base * 0.55 + base * 1.6).collect(); + let item_ws: Vec = legend_items.iter().map(|(n, _, _)| n.chars().count() as f64 * base * 0.55 + base * 1.6).collect(); let size = if vertical { (item_ws.iter().copied().fold(0.0, f64::max).min(w * 0.4), legend_items.len() as f64 * base * 1.5) } else { @@ -267,10 +318,15 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, let (origin, plot_area) = legend_layout(pos, size, area, base, c.legend_overlay); area = plot_area; let (mut x, mut y) = (origin.x, origin.y); - for (name, col) in &legend_items { - ctx.set_transform(m); - ctx.set_paint(color(*col, 1.0)); - ctx.fill_rect(&Rect::new(x, y + base * 0.3, x + base * 0.7, y + base)); + for (name, paint, outline) in &legend_items { + let key = Rect::new(x, y + base * 0.3, x + base * 0.7, y + base); + fill_with(ctx, paint, key, m); + if let Some((oc, ow)) = outline.filter(|o| o.1 > 0.0) { + ctx.set_transform(m); + ctx.set_paint(color(oc, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(ow.min(base * 0.2))); + ctx.stroke_path(&key.to_path(0.1)); + } let tw = name.chars().count() as f64 * base * 0.55 + base; label(ctx, rctx, m, name, Rect::new(x + base, y, x + base + tw, y + base * 1.3), base, Align::Left, Anchor::Middle, text_col, false); if vertical { @@ -625,7 +681,7 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, .abs(); // A waterfall colours increases, decreases and totals, and joins each bar's end // to the next bar's start. - let fill = if waterfall { + let paint = if waterfall { let slot = if c.subtotals.contains(&i) { 2 } else if b < a { @@ -633,13 +689,17 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, } else { 0 }; - series_color(rctx, c, slot) + Paint::Color(series_color(rctx, c, slot)) } else { - bar_fill(rctx, c, *si, i, s) + bar_paint(rctx, c, *si, i, s) }; - ctx.set_transform(m); - ctx.set_paint(color(fill, 1.0)); - ctx.fill_rect(&r.abs()); + fill_with(ctx, &paint, r.abs(), m); + if !waterfall && let Some((oc, ow)) = bar_outline(rctx, s).filter(|o| o.1 > 0.0) { + ctx.set_transform(m); + ctx.set_paint(color(oc, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(ow)); + ctx.stroke_path(&r.abs().to_path(0.1)); + } if waterfall && i + 1 < ncat && !horiz { let y = to_v(b); let mut j = BezPath::new(); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 4ee1c889d..8a0222e22 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -340,6 +340,43 @@ fn chart_axis_titles_are_drawn() { assert!(ink(&titled, bottom) > ink(&bare, bottom) + 30, "category axis title"); } +#[test] +fn column_series_draw_their_pattern_fill_and_no_fill_with_their_outline() { + // Three column series, as PowerPoint draws them: a red-on-yellow pattern, no fill with a blue + // outline, and no fill at all. None takes a palette colour; the legend keys match the bars. + use deckcraft_model::style::{Fill, PatternFill}; + let rgb = |r, g, b| ColorRef::rgb(deckcraft_color::Rgba::rgb(r, g, b)); + let mut c = Chart::sample(ChartType::Column); + c.title = None; + c.series.truncate(3); + c.categories = vec!["A".into()]; + for s in &mut c.series { + s.values = vec![Some(10.0)]; + } + c.series[0].fill = Some(Fill::Pattern(PatternFill { preset: "dkDnDiag".into(), fg: rgb(255, 0, 0), bg: rgb(255, 255, 0) })); + c.series[1].fill = Some(Fill::None); + c.series[1].line = Some(deckcraft_model::style::Line { width: Some(3.0), fill: Some(Fill::solid(rgb(0, 0, 255))), ..Default::default() }); + c.series[2].fill = Some(Fill::None); + let sh = + Shape { id: ShapeId(625), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), kind: ShapeKind::Chart(Box::new(c.clone())), ..Default::default() }; + let img = render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() }); + let count = + |f: &dyn Fn([u8; 4]) -> bool| (0..img.width).flat_map(|x| (0..img.height).map(move |y| (x, y))).filter(|(x, y)| f(img.pixel(*x, *y))).count(); + let red = count(&|p| p[0] > 220 && p[1] < 40 && p[2] < 40); + let yellow = count(&|p| p[0] > 220 && p[1] > 220 && p[2] < 40); + let blue = count(&|p| p[0] < 40 && p[1] < 40 && p[2] > 220); + assert!(red > 1000 && yellow > 1000, "the pattern's two colours: {red} red, {yellow} yellow"); + assert!(blue > 300, "the empty series' outline: {blue}"); + // No palette colour: the theme's accents would show for any series drawn the old way. + let p = deck_with(vec![]); + let ctx = deckcraft_model::resolve::Ctx::for_slide(&p, &p.slides[0]).unwrap(); + for i in 0..3 { + let a = crate::chart::series_color(&ctx, &c, i); + let n = count(&|q| q[0].abs_diff(a.r) < 6 && q[1].abs_diff(a.g) < 6 && q[2].abs_diff(a.b) < 6); + assert!(n < 50, "palette colour {i} drawn on {n} pixels"); + } +} + #[test] fn column_points_take_their_own_fill_and_smooth_lines_curve() { // One column series where only the middle point is given a red c:dPt fill. From 53972cf37c2fd20a2383abb851a2a4ee73a6671a Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:07:43 +0000 Subject: [PATCH 16/37] Make chart axis flatness thresholds relative to data magnitude Found while testing crates/model/src/chart.rs:775-794 (axis_scale); same pattern in nice_scale :817 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/model/src/chart.rs | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/crates/model/src/chart.rs b/crates/model/src/chart.rs index f5aeb1ee1..29d28fbb5 100644 --- a/crates/model/src/chart.rs +++ b/crates/model/src/chart.rs @@ -772,7 +772,7 @@ pub fn axis_scale( // Office's automatic limits ("How chart axis limits are determined", as Jon Peltier // summarises it): 5 % of the data range past the data, and zero when the values don't // sit within the top sixth of their largest magnitude. A single value is measured from zero. - let flat = hi - lo < 1e-12; + let flat = hi - lo <= 1e-12 * lo.abs().max(hi.abs()); let pad = if !headroom { 0.0 } else if flat { @@ -784,12 +784,15 @@ pub fn axis_scale( let auto_hi = if hi <= 0.0 && (flat || hi > lo * 5.0 / 6.0) { 0.0 } else { hi + pad }; let mut lo = fmin.unwrap_or(auto_lo); let mut hi = fmax.unwrap_or(auto_hi); - if hi - lo < 1e-12 { + let mag = lo.abs().max(hi.abs()); + // Relative to the data's magnitude, so tiny values don't count as flat; all-zero falls back to 1. + let unit = if mag == 0.0 { 1.0 } else { mag }; + if hi - lo <= 1e-12 * mag { // A fixed limit on the far side of the data: the automatic end gives way. match (fmin, fmax) { - (None, Some(_)) => lo = if hi > 0.0 { 0.0 } else { hi - hi.abs().max(1.0) }, + (None, Some(_)) => lo = if hi > 0.0 { 0.0 } else { hi - hi.abs().max(unit) }, (Some(_), None) if lo < 0.0 => hi = 0.0, - _ => hi = lo + lo.abs().max(1.0), + _ => hi = lo + unit, } } let n = max_intervals.clamp(1, 50) as f64; @@ -814,7 +817,9 @@ pub fn axis_scale( /// "Nice" axis scale: (min, max, major step) covering [lo, hi]. pub fn nice_scale(lo: f64, hi: f64) -> (f64, f64, f64) { let (lo, hi) = if lo.is_finite() && hi.is_finite() { (lo.min(hi), lo.max(hi)) } else { (0.0, 1.0) }; - let span = if hi - lo < 1e-12 { hi.abs().max(1.0) } else { hi - lo }; + let data_mag = lo.abs().max(hi.abs()); + let span = if hi - lo <= 1e-12 * data_mag { data_mag } else { hi - lo }; + let span = if span == 0.0 { 1.0 } else { span }; let raw = span / 5.0; let mag = 10f64.powf(raw.log10().floor()); let norm = raw / mag; @@ -831,7 +836,7 @@ pub fn nice_scale(lo: f64, hi: f64) -> (f64, f64, f64) { } * mag; let min = (lo / step).floor() * step; let max = (hi / step).ceil() * step; - let max = if (max - min).abs() < 1e-12 { min + step } else { max }; + let max = if (max - min).abs() <= 1e-12 * data_mag { min + step } else { max }; (min, max, step) } @@ -839,6 +844,16 @@ pub fn nice_scale(lo: f64, hi: f64) -> (f64, f64, f64) { mod tests { use super::*; + #[test] + fn tiny_data_keeps_its_own_scale() { + let (min, max, step) = axis_scale(1e-13, 5e-13, None, None, None, 8, true); + assert!(min == 0.0 && (5e-13..1e-11).contains(&max), "{min} {max} {step}"); + assert!(step > 0.0 && step < 1e-11); + let (min, max, step) = nice_scale(1e-13, 5e-13); + assert!(min <= 1e-13 && (5e-13..1e-11).contains(&max) && step < 1e-11, "{min} {max} {step}"); + assert_eq!(axis_scale(0.0, 0.0, None, None, None, 8, true).1, 1.0); + } + #[test] fn log_axes_run_over_whole_powers() { let mut c = Chart::sample(ChartType::Line); From adcfa70b4a1ae68c364b42ef1015f0e14c71a2a5 Mon Sep 17 00:00:00 2001 From: xc4pr <88853037+xc4pr@users.noreply.github.com> Date: Fri, 23 Oct 2026 00:45:54 +0200 Subject: [PATCH 17/37] Design Ideas: no ideas for slides with diagrams, connectors or other graphics (#1200) --- crates/engine/src/cmd/ideas.rs | 45 +++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/ideas.rs b/crates/engine/src/cmd/ideas.rs index 456767540..3931cb58d 100644 --- a/crates/engine/src/cmd/ideas.rs +++ b/crates/engine/src/cmd/ideas.rs @@ -57,9 +57,23 @@ fn roles(shapes: &[Shape]) -> Roles { r } -/// The ideas for slide `index` (none when it has nothing to arrange). +/// Whether an idea may move this shape: a picture, a placeholder or a plain text box. Diagram +/// boxes, connectors, groups, tables, charts and media are laid out by their own geometry, which +/// the ideas do not know, so a slide holding one gets no ideas. +fn arrangeable(s: &Shape) -> bool { + match s.kind { + ShapeKind::Picture { .. } => true, + ShapeKind::Shape => s.ph.is_some() || s.text_box, + _ => false, + } +} + +/// The ideas for slide `index` (none when it has nothing to arrange, or other content in the way). pub fn ideas_for(doc: &Presentation, index: usize) -> Vec { let Some(slide) = doc.slides.get(index) else { return vec![] }; + if slide.shapes.iter().any(|s| !s.hidden && !arrangeable(s)) { + return vec![]; + } let r = roles(&slide.shapes); if r.picture.is_some() { PICTURE_IDEAS.to_vec() @@ -228,6 +242,7 @@ mod tests { }; let body = Shape { id: ShapeId(501), + text_box: true, xfrm: Some(Xfrm::new(50.0, 140.0, 860.0, 350.0)), text: Some(deckcraft_model::text::TextBody::from_text("one\ntwo")), ..Default::default() @@ -314,6 +329,34 @@ mod tests { assert!(s.execute("design.applyIdea", &json!({"id": "nope"})).is_err()); } + #[test] + fn slides_with_diagrams_or_other_graphics_get_no_ideas() { + // A filled autoshape with text (a diagram box) is not text to restack. + let mut s = deck(false); + let slide = Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]); + slide.shapes.push(Shape { + id: ShapeId(510), + xfrm: Some(Xfrm::new(300.0, 200.0, 200.0, 80.0)), + fill: Some(Fill::solid(deckcraft_model::ColorRef::rgb(Rgba::rgba(200, 80, 20, 255)))), + text: Some(deckcraft_model::text::TextBody::from_text("Solar wind")), + ..Default::default() + }); + assert!(ideas_for(&s.doc().unwrap().doc, 0).is_empty()); + assert!(s.execute("design.applyIdea", &json!({"id": "titleColumn"})).is_err()); + // A connector too, and a hidden one does not count. + let mut s = deck(false); + let slide = Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]); + slide.shapes.push(Shape { + id: ShapeId(511), + kind: ShapeKind::Connector { start: None, end: None }, + xfrm: Some(Xfrm::new(300.0, 200.0, 100.0, 0.0)), + ..Default::default() + }); + assert!(ideas_for(&s.doc().unwrap().doc, 0).is_empty()); + Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]).shapes[2].hidden = true; + assert_eq!(ideas_for(&s.doc().unwrap().doc, 0).len(), 3); + } + #[test] fn cover_crop_matches_the_box_aspect() { let c = cover_crop(2.0, 1.0); From 6d312e0722b687c8d70210615c870f2237dae135 Mon Sep 17 00:00:00 2001 From: xc4pr <88853037+xc4pr@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:27:59 +0200 Subject: [PATCH 18/37] Design Ideas: an empty text box that shows also blocks the ideas (#1200) --- crates/engine/src/cmd/ideas.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/ideas.rs b/crates/engine/src/cmd/ideas.rs index 3931cb58d..a54823367 100644 --- a/crates/engine/src/cmd/ideas.rs +++ b/crates/engine/src/cmd/ideas.rs @@ -63,7 +63,13 @@ fn roles(shapes: &[Shape]) -> Roles { fn arrangeable(s: &Shape) -> bool { match s.kind { ShapeKind::Picture { .. } => true, - ShapeKind::Shape => s.ph.is_some() || s.text_box, + ShapeKind::Shape => { + let is_title = s.ph.as_ref().is_some_and(|p| p.kind.is_title()); + let has_text = s.text.as_ref().is_some_and(|t| !t.text().trim().is_empty()); + // Only titles and shapes with text are moved (see `roles`): an empty one that shows + // (filled or outlined) would stay where it is, over the rearranged slide. + (s.ph.is_some() || s.text_box) && (is_title || has_text || (s.fill.is_none() && s.line.is_none())) + } _ => false, } } @@ -357,6 +363,17 @@ mod tests { assert_eq!(ideas_for(&s.doc().unwrap().doc, 0).len(), 3); } + #[test] + fn an_empty_text_box_blocks_ideas_only_when_it_shows() { + let mut s = deck(false); + let slide = Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]); + slide.shapes.push(Shape { id: ShapeId(512), text_box: true, xfrm: Some(Xfrm::new(10.0, 10.0, 100.0, 40.0)), ..Default::default() }); + assert_eq!(ideas_for(&s.doc().unwrap().doc, 0).len(), 3, "invisible and empty: nothing in the way"); + let slide = Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]); + slide.shapes[2].fill = Some(Fill::solid(deckcraft_model::ColorRef::rgb(Rgba::rgba(200, 80, 20, 255)))); + assert!(ideas_for(&s.doc().unwrap().doc, 0).is_empty(), "a filled empty box would stay on top"); + } + #[test] fn cover_crop_matches_the_box_aspect() { let c = cover_crop(2.0, 1.0); From e126fad9a7e4c5bf184e881d3eb2f3a7aa30590d Mon Sep 17 00:00:00 2001 From: xc4pr <88853037+xc4pr@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:17:39 +0200 Subject: [PATCH 19/37] Design Ideas: decide whether an empty box shows from its resolved fill and outline (#1200) --- crates/engine/src/cmd/ideas.rs | 48 +++++++++++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/ideas.rs b/crates/engine/src/cmd/ideas.rs index a54823367..2cbd380ee 100644 --- a/crates/engine/src/cmd/ideas.rs +++ b/crates/engine/src/cmd/ideas.rs @@ -4,6 +4,7 @@ //! slide. Everything is computed here, offline. use deckcraft_geom::Xfrm; +use deckcraft_model::resolve::{self, Ctx}; use deckcraft_model::{Fill, Presentation, Rgba, Shape, ShapeKind}; use serde_json::{Value, json}; @@ -57,10 +58,20 @@ fn roles(shapes: &[Shape]) -> Roles { r } +/// Whether `s` paints a fill or an outline once inherited and style-referenced values are +/// resolved, as the renderer does (`fill: None` alone means inherited, not invisible). +fn shows(ctx: Option<&Ctx>, s: &Shape) -> bool { + let visible = |f: &Option| f.as_ref().is_some_and(|f| !f.is_none()); + match ctx { + Some(ctx) => visible(&resolve::fill(ctx, s).0) || visible(&resolve::line(ctx, s).0.fill), + None => visible(&s.fill) || visible(&s.line.as_ref().and_then(|l| l.fill.clone())), + } +} + /// Whether an idea may move this shape: a picture, a placeholder or a plain text box. Diagram /// boxes, connectors, groups, tables, charts and media are laid out by their own geometry, which /// the ideas do not know, so a slide holding one gets no ideas. -fn arrangeable(s: &Shape) -> bool { +fn arrangeable(ctx: Option<&Ctx>, s: &Shape) -> bool { match s.kind { ShapeKind::Picture { .. } => true, ShapeKind::Shape => { @@ -68,7 +79,7 @@ fn arrangeable(s: &Shape) -> bool { let has_text = s.text.as_ref().is_some_and(|t| !t.text().trim().is_empty()); // Only titles and shapes with text are moved (see `roles`): an empty one that shows // (filled or outlined) would stay where it is, over the rearranged slide. - (s.ph.is_some() || s.text_box) && (is_title || has_text || (s.fill.is_none() && s.line.is_none())) + (s.ph.is_some() || s.text_box) && (is_title || has_text || !shows(ctx, s)) } _ => false, } @@ -77,7 +88,8 @@ fn arrangeable(s: &Shape) -> bool { /// The ideas for slide `index` (none when it has nothing to arrange, or other content in the way). pub fn ideas_for(doc: &Presentation, index: usize) -> Vec { let Some(slide) = doc.slides.get(index) else { return vec![] }; - if slide.shapes.iter().any(|s| !s.hidden && !arrangeable(s)) { + let ctx = Ctx::for_slide(doc, slide); + if slide.shapes.iter().any(|s| !s.hidden && !arrangeable(ctx.as_ref(), s)) { return vec![]; } let r = roles(&slide.shapes); @@ -374,6 +386,36 @@ mod tests { assert!(ideas_for(&s.doc().unwrap().doc, 0).is_empty(), "a filled empty box would stay on top"); } + #[test] + fn an_empty_box_with_an_explicit_no_fill_does_not_block_ideas() { + let mut s = deck(false); + let slide = Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]); + slide.shapes.push(Shape { + id: ShapeId(512), + text_box: true, + xfrm: Some(Xfrm::new(10.0, 10.0, 100.0, 40.0)), + fill: Some(Fill::None), + ..Default::default() + }); + assert_eq!(ideas_for(&s.doc().unwrap().doc, 0).len(), 3, "an explicit no fill shows nothing"); + } + + #[test] + fn an_empty_box_filled_through_its_style_blocks_ideas() { + let mut s = deck(false); + let slide = Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]); + let mut style = deckcraft_model::style::ShapeStyle::accent(deckcraft_model::SchemeSlot::Accent1); + style.line_ref.0 = 0; + slide.shapes.push(Shape { + id: ShapeId(512), + text_box: true, + xfrm: Some(Xfrm::new(10.0, 10.0, 100.0, 40.0)), + style: Some(style), + ..Default::default() + }); + assert!(ideas_for(&s.doc().unwrap().doc, 0).is_empty(), "the theme fill shows, so the box would stay on top"); + } + #[test] fn cover_crop_matches_the_box_aspect() { let c = cover_crop(2.0, 1.0); From 8117d5b710071a61fcc19b9d4d266ffe2c1df98b Mon Sep 17 00:00:00 2001 From: Katacc Date: Wed, 21 Oct 2026 17:25:19 +0300 Subject: [PATCH 20/37] arrange.group: nest groups at most 32 deep Grouping a group again and again had no limit, but a .deckcraft saved with 39 or more nested groups could not be opened again (the JSON reader's recursion limit), and past 64 levels shape walks such as format.languageAll stop. arrange.group now refuses to nest past 32, the depth the PPTX reader reads and the renderer draws, and leaves the slide unchanged. The test builds the deepest allowed deck (a table at the bottom) and checks it saves and reopens. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/arrange.rs | 19 +++++++++++++++++++ crates/engine/src/tests.rs | 30 ++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index d54c160f0..c95bb1056 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -145,6 +145,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-23 | Normal view: a slide zoomed past the window gets a horizontal and a vertical scroll bar on the axes that overflow; drag the thumb or click the track to reach every edge (#1157) | | 2026-10-22 | Shape Fill and Shape Outline format the members of a selected group, including nested groups, in one undo step. Fill transparency retains each member's effective colour (#1079) | | 2026-10-21 | 3-D contours, extrusion depths and bevels keep their size in points inside a group whose child coordinates scale its shapes: a 1 pt contour on a small extruded cube in a legacy group drew hundreds of points wide, a band across the slide | +| 2026-10-21 | Groups nest at most 32 deep (`arrange.group` refuses more), as deep as PPTX import reads and slides draw: a deck grouped 39 or more levels deep saved without complaint and then could not be opened again | | 2026-10-20 | Chart number formats with explicit empty positive, negative or zero sections hide the corresponding labels, including all values with three semicolons; a blank whole format still uses General. Synthetic formatter regressions | | 2026-10-18 | Native chart sidecar bytes move from JSON arrays into referenced ZIP entries, with shared media byte/item limits and bounded reads; older inline sidecars still load. Synthetic format regressions cover large workbooks and malformed members | | 2026-10-18 | Slide thumbnails and the Slide Sorter render only the slides in view: on a deck of more than 600 slides they used to evict their own thumbnails, so the pane flashed white and never finished (#969) | diff --git a/crates/engine/src/cmd/arrange.rs b/crates/engine/src/cmd/arrange.rs index a043d9875..2abb9609f 100644 --- a/crates/engine/src/cmd/arrange.rs +++ b/crates/engine/src/cmd/arrange.rs @@ -171,6 +171,22 @@ pub(crate) fn union_bounds(xs: &[Xfrm]) -> Option { Some(it.fold(first, |a, b| a.union(b))) } +/// How deep groups may nest: as deep as the PPTX reader reads and the renderer draws. A saved +/// .deckcraft file stops opening at about 39 (the JSON reader's recursion limit), and walks over +/// the shape tree stop at 64. +pub const MAX_GROUP_DEPTH: usize = 32; + +/// How many groups deep `shapes` nest (0 without groups). +fn group_depth(shapes: &[Shape]) -> usize { + let mut d = 0; + deckcraft_model::walk(shapes, &mut |sh, depth| { + if sh.is_group() { + d = d.max(depth + 1); + } + }); + d +} + fn group(s: &mut Session, p: &Value) -> Result { let ids = targets(s, p)?; s.edit(|doc, sel| { @@ -179,6 +195,9 @@ fn group(s: &mut Session, p: &Value) -> Result { if members.len() < 2 { return Err(bad("arrange.group", "select two or more objects on the slide")); } + if group_depth(&members) + 1 > MAX_GROUP_DEPTH { + return Err(bad("arrange.group", format!("groups can be nested at most {MAX_GROUP_DEPTH} deep"))); + } for m in &mut members { if m.xfrm.is_none() { m.xfrm = Some(xfrm_of(doc, sel, m)); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 35b0ab396..d6d0fecbd 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -322,6 +322,36 @@ fn group_ungroup_roundtrip_positions() { assert!((v["box"]["x"].as_f64().unwrap() - 310.0).abs() < 1e-6, "{v}"); } +/// Grouping a group again and again stops at `MAX_GROUP_DEPTH`: past about 39 levels a saved +/// .deckcraft file no longer opens, and past 64 commands such as format.languageAll miss the text. +#[test] +fn groups_nest_only_as_deep_as_a_saved_file_reopens() { + let mut s = session(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + // A table (deeper in the file than a text box) at the bottom of the nesting. + let mut g = s.execute("insert.table", &json!({"rows": 2, "cols": 2})).unwrap()["id"].as_u64().unwrap(); + for _ in 0..cmd::arrange::MAX_GROUP_DEPTH { + let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 20, 20], "text": "deep"})).unwrap()["id"].as_u64().unwrap(); + s.execute("edit.select", &json!({"ids": [g, r]})).unwrap(); + g = s.execute("arrange.group", &json!({})).unwrap()["id"].as_u64().unwrap(); + } + let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 20, 20]})).unwrap()["id"].as_u64().unwrap(); + s.execute("edit.select", &json!({"ids": [g, r]})).unwrap(); + let before = s.doc().unwrap().revision; + let err = s.execute("arrange.group", &json!({})).unwrap_err().to_string(); + assert!(err.contains(&format!("nested at most {}", cmd::arrange::MAX_GROUP_DEPTH)), "{err}"); + assert_eq!(s.doc().unwrap().revision, before, "a refused group leaves the slide as it was"); + // The deepest deck the command allows saves and opens again. + let bytes = deckcraft_format::save(&s.doc().unwrap().doc).unwrap(); + let back = deckcraft_format::load(&bytes).unwrap(); + let depth = |shapes: &[deckcraft_model::Shape]| { + let mut d = 0; + deckcraft_model::walk(shapes, &mut |sh, depth| d = d.max(depth + usize::from(sh.is_group()))); + d + }; + assert_eq!(depth(&back.slides.last().unwrap().shapes), cmd::arrange::MAX_GROUP_DEPTH); +} + #[test] fn align_and_distribute() { let mut s = session(); From 9b2ab3d2b8a3d188b3b27757cca31f6f7856d117 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:05:02 +0200 Subject: [PATCH 21/37] test: give outline table-row regression test a titled slide and assert one slide Co-Authored-By: Claude Sonnet 5.5 --- crates/format/src/lib.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index c4fdaf8ce..080be746d 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -1096,7 +1096,9 @@ fn outline_table_rows_are_indented_and_do_not_become_slides() { let mut p = defaults::new_presentation(None); let layout = defaults::layout_of_kind(&p, LayoutType::TitleAndContent).unwrap(); let mut s = defaults::new_slide(&mut p, layout).expect("slide"); - s.shapes.retain(|sh| !sh.ph_type().is_some_and(|k| k.is_title())); + for sh in s.shapes.iter_mut().filter(|sh| sh.ph_type().is_some_and(|k| k.is_title())) { + sh.text = Some(TextBody::from_text("Slide title")); + } let mut table = Table { cols: vec![200.0, 200.0], ..Default::default() }; for texts in [["A1", "B1"], ["A2", "B2"]] { let cells: Vec = texts.iter().map(|t| Cell { text: TextBody::from_text(t), ..Default::default() }).collect(); @@ -1110,5 +1112,9 @@ fn outline_table_rows_are_indented_and_do_not_become_slides() { let out = slides_to_outline(&p); let mut q = defaults::new_presentation(None); q.slides.clear(); - assert_eq!(outline_to_slides(&mut q, &out), 0, "table rows became slides: {out:?}"); + assert_eq!(outline_to_slides(&mut q, &out), 1, "table rows became slides: {out:?}"); + assert_eq!(q.slides.len(), 1, "{out:?}"); + let body = q.slides[0].shapes.iter().filter_map(|sh| sh.text.as_ref()).map(|t| t.text()).collect::>().join("\n"); + assert!(body.contains("A1\tB1"), "{body:?}"); + assert!(body.contains("A2\tB2"), "{body:?}"); } From 8789394de1ae19e437bf8310711d9c4007334d94 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:19:17 +0200 Subject: [PATCH 22/37] fix: keep chart axis steps positive and finite and drop absolute floors in rendering Co-Authored-By: Claude Sonnet 5.5 --- crates/model/src/chart.rs | 11 +++++++++++ crates/render/src/chart.rs | 32 +++++++++++++++++++++++--------- crates/render/src/radar.rs | 4 ++-- 3 files changed, 36 insertions(+), 11 deletions(-) diff --git a/crates/model/src/chart.rs b/crates/model/src/chart.rs index 29d28fbb5..11d276544 100644 --- a/crates/model/src/chart.rs +++ b/crates/model/src/chart.rs @@ -800,6 +800,10 @@ pub fn axis_scale( Some(m) => m, None => { let mut step = 10f64.powf(((hi - lo) / n).log10().floor()); + // Subnormal spans underflow to a zero (or non-finite) step: fall back to the unit. + if !(step > 0.0 && step.is_finite()) { + step = unit; + } let mut k = 0; // 1, 2, 5, 10, 20… × the magnitude until the intervals fit. while ((hi / step).ceil() - (lo / step).floor()) > n && k < 30 { @@ -854,6 +858,13 @@ mod tests { assert_eq!(axis_scale(0.0, 0.0, None, None, None, 8, true).1, 1.0); } + #[test] + fn subnormal_data_gives_a_finite_scale() { + let (min, max, step) = axis_scale(0.0, 5e-324, None, None, None, 8, true); + assert!(min.is_finite() && max.is_finite() && step.is_finite(), "{min} {max} {step}"); + assert!(step > 0.0 && max >= min, "{min} {max} {step}"); + } + #[test] fn log_axes_run_over_whole_powers() { let mut c = Chart::sample(ChartType::Line); diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index ac1857807..cbba217f0 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -193,6 +193,17 @@ fn legend_layout(pos: &str, size: (f64, f64), area: Rect, base: f64, overlay: bo (origin, if overlay { area } else { plot }) } +/// An axis span to divide by: the span itself when positive, else 1 (no absolute floor, so tiny data keeps its scale). +pub(crate) fn positive_span(s: f64) -> f64 { + if s > 0.0 && s.is_finite() { s } else { 1.0 } +} + +/// The tick values of an axis (min, max, major step), one per step (at most 51). +fn axis_ticks((lo, hi, st): (f64, f64, f64)) -> impl Iterator { + let n = if st > 0.0 { (((hi - lo) / st).round() as i64).clamp(1, 50) } else { 1 }; + (0..=n).map(move |k| lo + k as f64 * st) +} + pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h: f64) { match c.kind { // Histograms and Pareto charts are drawn as the column (and line) chart of their bins. @@ -379,10 +390,7 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, }; // A combo chart's secondary value axis, on the right (vertical value axes only). let second = if horiz { None } else { c.axis_scale(true, max_intervals) }; - let ticks = |(lo, hi, st): (f64, f64, f64)| { - let n = (((hi - lo) / st.max(1e-12)).round() as i64).clamp(1, 50); - (0..=n).map(move |k| lo + k as f64 * st) - }; + let ticks = axis_ticks; // Label gutters as wide as the longest value label. let primary_ticks: Vec = ticks((min, max, step)).map(|k| log.map_or(k, |(b, _)| b.powf(k))).collect(); let label_w = |sec: bool, sc: (f64, f64, f64)| { @@ -405,7 +413,7 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, if let Some(f) = &c.plot_fill { crate::paint::fill_path(ctx, rctx, f, None, &plot.to_path(0.1), plot, m, deckcraft_geom::preset::FillMode::Norm, 1.0, rctx.pres); } - let span = (max - min).max(1e-9); + let span = positive_span(max - min); // Values past the axis limits (fixed ones, or zero on an axis that starts above it) stop at // the plot's edge. let to_v = |v: f64| { @@ -414,13 +422,13 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, }; // A series' value as a position on its own axis. let to_axis = |sec: bool, v: f64| match (sec, second) { - (true, Some((min2, max2, _))) => plot.y1 - (v.max(min2).min(max2) - min2) / (max2 - min2).max(1e-9) * plot.height(), + (true, Some((min2, max2, _))) => plot.y1 - (v.max(min2).min(max2) - min2) / positive_span(max2 - min2) * plot.height(), _ => to_v(v), }; // Minor gridlines, under the major ones: five to a major unit. if c.minor_gridlines { let minor = step / 5.0; - let n = (((max - min) / minor.max(1e-12)).round() as i64).clamp(1, 500); + let n = if minor > 0.0 { (((max - min) / minor).round() as i64).clamp(1, 500) } else { 1 }; let mut g = BezPath::new(); for k in (0..=n).filter(|k| k % 5 != 0) { // On a logarithmic axis `min`, `max` and `step` are exponents of the base. @@ -514,10 +522,10 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, // Scatter and bubble charts have a value x axis: one scale over every series' x values, with its // own tick labels in place of category names. let xscale = if horiz { None } else { value_x_scale(c, (plot.width() / (base * 2.5)).floor().clamp(2.0, 12.0) as usize) }; - let to_x = |v: f64| xscale.map(|(lo, hi, _)| plot.x0 + (v - lo) / (hi - lo).max(1e-9) * plot.width()); + let to_x = |v: f64| xscale.map(|(lo, hi, _)| plot.x0 + (v - lo) / positive_span(hi - lo) * plot.width()); if let Some(sc) = xscale { for v in ticks(sc) { - let pos = plot.x0 + (v - sc.0) / (sc.1 - sc.0).max(1e-9) * plot.width(); + let pos = plot.x0 + (v - sc.0) / positive_span(sc.1 - sc.0) * plot.width(); label( ctx, rctx, @@ -1054,6 +1062,12 @@ mod tests { const AREA: Rect = Rect::new(0.0, 0.0, 400.0, 300.0); + #[test] + fn tiny_data_gets_several_ticks() { + let sc = deckcraft_model::chart::axis_scale(1e-13, 5e-13, None, None, None, 8, true); + assert!(axis_ticks(sc).count() >= 3, "{sc:?}"); + } + #[test] fn scatter_x_is_a_value_scale_over_all_series() { let mut c = Chart::sample(ChartType::Scatter); diff --git a/crates/render/src/radar.rs b/crates/render/src/radar.rs index 3416c7ce6..6e61c144d 100644 --- a/crates/render/src/radar.rs +++ b/crates/render/src/radar.rs @@ -28,8 +28,8 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rec let r = (area.width().min(area.height()) / 2.0 - base * 1.9).max(base); let cen = area.center(); let (min, max, step) = c.axis_scale(false, 5).unwrap_or((0.0, 1.0, 0.2)); - let span = (max - min).max(1e-9); - let ticks = (((max - min) / step.max(1e-12)).round() as i64).clamp(1, 20); + let span = crate::chart::positive_span(max - min); + let ticks = if step > 0.0 { (((max - min) / step).round() as i64).clamp(1, 20) } else { 1 }; ctx.set_transform(m); ctx.set_stroke(kurbo::Stroke::new(0.75)); ctx.set_paint(color(grid_col, 1.0)); From a48a2d706a79437a4a3e1217a51f21ac35d7543e Mon Sep 17 00:00:00 2001 From: Zach Berry <1112351+zberry@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:52:27 -0500 Subject: [PATCH 23/37] chart.options takes textColor so a chart's text can be coloured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a dark theme a chart's title, axis labels, legend and data labels render in a fixed grey and nothing could change them, though the model already carries `text_color`. `chart.options {textColor}` now sets it (#RRGGBB, accent1…accent6, tx1…, or null for the default) and refuses a string that isn't a colour. docs/mcp.md mentions it. Co-Authored-By: Claude Fable 5.1 --- crates/engine/src/cmd/insert.rs | 13 ++++++++++++- crates/engine/src/tests.rs | 25 +++++++++++++++++++++++++ docs/mcp.md | 2 +- 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 149957df5..901cb01e6 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -118,7 +118,7 @@ pub fn specs() -> Vec { "Chart Elements", ["Chart Design", "Chart Layouts"], None, - "{title?: string|null, legend?: b|t|l|r|null, dataLabels?: bool, gridlines?: bool, palette?, binCount?: n|null, binSize?: n|null, subtotals?: [point index], quartiles?: exclusive|inclusive, upDownBars?: bool, id?}", + "{title?: string|null, legend?: b|t|l|r|null, dataLabels?: bool, gridlines?: bool, palette?, binCount?: n|null, binSize?: n|null, subtotals?: [point index], quartiles?: exclusive|inclusive, upDownBars?: bool, textColor?: #RRGGBB|accent1..6|tx1…|null (title, axes, legend and labels; null for the theme's text colour), id?}", has_selection, chart_options ), @@ -1771,7 +1771,18 @@ fn chart_options(s: &mut Session, p: &Value) -> Result { // Waterfall total points. let subtotals: Option> = p.get("subtotals").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|i| i as usize).collect()); + // Colour of the chart's text (title, axes, legend, data labels); null goes back to the theme's. + let text_color = match p.get("textColor") { + None => None, + Some(Value::Null) => Some(None), + Some(v) => { + Some(Some(color_value(v).ok_or_else(|| bad("chart.options", "`textColor` is a colour: #RRGGBB, accent1…accent6, tx1…, or null"))?)) + } + }; with_chart(s, p, "chart.options", |c| { + if let Some(t) = &text_color { + c.text_color = t.clone(); + } if let Some(n) = bin_count { c.bin_count = n; if n.is_some() { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 35b0ab396..041b221fb 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4403,3 +4403,28 @@ fn duplicating_a_moved_copy_repeats_the_move() { assert_eq!(boxed(&mut s, f), (340.0 + off, 100.0 + off)); assert_eq!(s.doc().unwrap().doc.validate(), Vec::::new()); } + +fn chart_of(s: &Session, id: u64) -> deckcraft_model::Chart { + match &s.doc().unwrap().shape(deckcraft_model::ShapeId(id as u32)).unwrap().kind { + deckcraft_model::ShapeKind::Chart(c) => (**c).clone(), + k => panic!("not a chart: {k:?}"), + } +} + +/// A chart's text (title, axes, legend, labels) can be coloured: `chart.options {textColor}` sets +/// it, `null` goes back to the default, and a colour that isn't one is refused. +#[test] +fn chart_options_text_color_sets_clears_and_refuses() { + let mut s = session(); + let id = s.execute("insert.chart", &json!({"type": "column", "title": "Sales"})).unwrap()["id"].as_u64().unwrap(); + assert_eq!(chart_of(&s, id).text_color, None); + s.execute("chart.options", &json!({"id": id, "textColor": "#FF0000"})).unwrap(); + assert_eq!(chart_of(&s, id).text_color, Some(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0)))); + s.execute("chart.options", &json!({"id": id, "textColor": "accent2"})).unwrap(); + assert_eq!(chart_of(&s, id).text_color, Some(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent2))); + let e = s.execute("chart.options", &json!({"id": id, "textColor": "reddish"})).unwrap_err().to_string(); + assert!(e.contains("textColor"), "{e}"); + assert_eq!(chart_of(&s, id).text_color, Some(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent2)), "refused: unchanged"); + s.execute("chart.options", &json!({"id": id, "textColor": null})).unwrap(); + assert_eq!(chart_of(&s, id).text_color, None); +} diff --git a/docs/mcp.md b/docs/mcp.md index a65fcda32..0b3b3a3d3 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -99,6 +99,6 @@ A tool refuses an argument that is not in its schema (`insert_chart: unknown arg Useful commands for building decks: `slide.new {layout, title, body}`, `shape.insert {preset, rect, text}`, `shape.connect {from, to, preset}`, `shape.freeform {points, closed, smooth}`, `shape.merge {op, ids}`, `format.*`, `design.theme`, `transition.set`, `animation.set`, -`chart.*`, `table.*`, `file.export {path, layout}` (PDF) or `{path, width, secondsPerSlide, loop}` +`chart.*` (`chart.options {textColor}` colours a chart's title, axes, legend and labels), `table.*`, `file.export {path, layout}` (PDF) or `{path, width, secondsPerSlide, loop}` (GIF). `deckcraft-cli describe COMMAND` prints any command's parameters. From 1b03ae95defa4f7e3c7227952431eb0ede070daa Mon Sep 17 00:00:00 2001 From: Zach Berry <1112351+zberry@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:54:45 -0500 Subject: [PATCH 24/37] format.color on a selected chart colours the chart's text Font Color with a chart selected answered and changed nothing: a chart has no text runs to colour. It now sets the chart's text colour (title, axes, legend and data labels), the same thing chart.options {textColor} sets; a mixed selection still colours the other shapes' runs. Co-Authored-By: Claude Fable 5.1 --- crates/engine/src/cmd/format.rs | 23 ++++++++++++++++++++++- crates/engine/src/tests.rs | 10 ++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index b92cd85d1..35a9906a2 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -4,7 +4,7 @@ use deckcraft_model::edit as ed; use deckcraft_model::resolve; use deckcraft_model::text::{Align, Anchor, AutoFit, Bullet, Caps, ParaProps, RunProps, Spacing, Strike, TabStop, TextDir}; -use deckcraft_model::{ColorRef, ColorTransform, Effects, Fill, Line, SchemeSlot, Shape}; +use deckcraft_model::{ColorRef, ColorTransform, Effects, Fill, Line, SchemeSlot, Shape, ShapeKind}; use serde_json::{Value, json}; use super::text::{format_body, format_para, format_run}; @@ -340,6 +340,27 @@ fn shrink(s: &mut Session, _p: &Value) -> Result { } fn color(s: &mut Session, p: &Value) -> Result { let c = color_param(p, "color").ok_or_else(|| bad("format.color", "missing or invalid `color`"))?; + // A selected chart has no runs: Font Color colours its text (title, axes, legend, labels), + // which used to answer and change nothing. + let st = s.doc()?; + if st.selection.text.is_none() { + let charts: Vec = + st.selection.shapes.iter().copied().filter(|id| matches!(st.shape(*id).map(|sh| &sh.kind), Some(ShapeKind::Chart(_)))).collect(); + let only_charts = charts.len() == st.selection.shapes.len(); + if !charts.is_empty() { + let tc = c.clone(); + edit_shapes(s, &json!({"ids": charts}), "format.color", |sh| { + if let ShapeKind::Chart(ch) = &mut sh.kind { + ch.text_color = Some(tc.clone()); + ch.raw = None; + } + Ok(()) + })?; + if only_charts { + return ok(); + } + } + } format_run(s, &move |r| r.fill = Some(Fill::solid(c.clone()))) } /// `a:alpha` for `transparency` (0–1) on `c`, replacing any alpha it had; fully opaque drops it. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 041b221fb..f8b48097f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4428,3 +4428,13 @@ fn chart_options_text_color_sets_clears_and_refuses() { s.execute("chart.options", &json!({"id": id, "textColor": null})).unwrap(); assert_eq!(chart_of(&s, id).text_color, None); } + +/// Font Color on a selected chart colours the chart's text (it used to answer and change nothing). +#[test] +fn format_color_on_a_selected_chart_sets_its_text_color() { + let mut s = session(); + let id = s.execute("insert.chart", &json!({"type": "column", "title": "Sales"})).unwrap()["id"].as_u64().unwrap(); + s.execute("edit.select", &json!({"ids": [id]})).unwrap(); + s.execute("format.color", &json!({"color": "accent2"})).unwrap(); + assert_eq!(chart_of(&s, id).text_color, Some(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent2))); +} From be9bf48eb234877a5ef598795348cdfa04df8b32 Mon Sep 17 00:00:00 2001 From: Zach Berry <1112351+zberry@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:57:13 -0500 Subject: [PATCH 25/37] Chart text without a colour of its own takes the theme's text colour A chart's title, axis labels, legend and data labels were drawn in a fixed grey (0x595959) whenever `text_color` was unset, which is near-invisible on a dark theme. The renderer now resolves the theme's tx1 instead, so a chart is readable by default on Nocturne as well as on Harbor; an explicit colour still wins. Co-Authored-By: Claude Fable 5.1 --- crates/render/src/chart.rs | 4 +++- crates/render/src/tests.rs | 30 ++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index ac1857807..093384056 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -207,7 +207,9 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h #[allow(clippy::too_many_arguments)] fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h: f64, boxes: Option<&crate::chartex::Boxes>) { - let text_col = c.text_color.as_ref().map(|t| rctx.color(t, None)).unwrap_or(Rgba::rgb(0x59, 0x59, 0x59)); + // Text without a colour of its own takes the theme's text colour, so a chart on a dark + // theme is readable (a fixed grey used to vanish there). + let text_col = rctx.color(c.text_color.as_ref().unwrap_or(&ColorRef::scheme(SchemeSlot::Tx1)), None); let grid_col = Rgba::rgb(0xD9, 0xD9, 0xD9); if let Some(f) = &c.chart_fill { crate::paint::fill_path( diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index b65098b0e..baccc9dda 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -2717,3 +2717,33 @@ fn empty_bullet_is_gray_only_while_editing_and_never_in_exports() { assert!(changed.iter().any(|(px, _)| px[0] == 179 && px[1] == 179 && px[2] == 179)); assert!(changed.iter().all(|(px, _)| px[0] >= 179 && px[0] == px[1] && px[1] == px[2])); } + +/// A chart that has no text colour of its own takes the theme's: light on Nocturne's dark slides +/// (it used to be a fixed grey, near-invisible there), dark on Harbor's light ones. +#[test] +fn chart_text_follows_the_theme_when_it_has_no_colour_of_its_own() { + let draw = |theme: &str, title: Option<&str>| { + let mut c = Chart::sample(ChartType::Column); + c.title = title.map(String::from); + c.legend = None; + c.data_labels = false; + let sh = Shape { id: ShapeId(630), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() }; + let mut p = deck_with(vec![sh]); + let t = deckcraft_model::theme::builtin_themes().into_iter().find(|t| t.name == theme).expect("theme"); + std::sync::Arc::make_mut(&mut p.masters[0]).theme = t; + render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }) + }; + let lum = |px: [u8; 4]| (px[0] as u32 * 299 + px[1] as u32 * 587 + px[2] as u32 * 114) / 1000; + // Pixels in the title band that pass `f`. + let count = |img: &Image, f: &dyn Fn(u32) -> bool| { + (200..760u32).flat_map(|x| (4..70u32).map(move |y| (x, y))).filter(|(x, y)| f(lum(img.pixel(*x, *y)))).count() + }; + let (dark_bare, dark_titled) = (draw("Nocturne", None), draw("Nocturne", Some("Quarterly revenue"))); + assert!(lum(dark_bare.pixel(5, 5)) < 60, "Nocturne's slide is dark: {:?}", dark_bare.pixel(5, 5)); + let light_ink = count(&dark_titled, &|l| l > 200) as i64 - count(&dark_bare, &|l| l > 200) as i64; + assert!(light_ink > 100, "the title is light on a dark theme (light pixels added: {light_ink})"); + let (light_bare, light_titled) = (draw("Harbor", None), draw("Harbor", Some("Quarterly revenue"))); + assert!(lum(light_bare.pixel(5, 5)) > 200, "Harbor's slide is light: {:?}", light_bare.pixel(5, 5)); + let dark_ink = count(&light_titled, &|l| l < 80) as i64 - count(&light_bare, &|l| l < 80) as i64; + assert!(dark_ink > 100, "the title is dark on a light theme (dark pixels added: {dark_ink})"); +} From 30b7eff487fe513f84bf593cc41b0e4e6d50edef Mon Sep 17 00:00:00 2001 From: Zach Berry <1112351+zberry@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:37:14 -0500 Subject: [PATCH 26/37] Pie data labels take the chart's text colour when it has one A textColor set on a chart (chart.options, or Font Color with the chart selected) coloured the title, axes and legend but left a pie's data labels white. They stay white by default, on the slices, and follow a colour the chart's text was given. Co-Authored-By: Claude Fable 5.1 --- crates/render/src/chart.rs | 3 ++- crates/render/src/tests.rs | 24 ++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index 093384056..cb07b363b 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -324,7 +324,8 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, base, Align::Center, Anchor::Middle, - Rgba::WHITE, + // On the slices themselves: white, unless the chart's text was given a colour. + if c.text_color.is_some() { text_col } else { Rgba::WHITE }, false, ); } diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index baccc9dda..07794e092 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -2747,3 +2747,27 @@ fn chart_text_follows_the_theme_when_it_has_no_colour_of_its_own() { let dark_ink = count(&light_titled, &|l| l < 80) as i64 - count(&light_bare, &|l| l < 80) as i64; assert!(dark_ink > 100, "the title is dark on a light theme (dark pixels added: {dark_ink})"); } + +/// Pie data labels sit on the slices in white; a colour given to the chart's text (chart.options +/// textColor, Font Color on the chart) colours them too, as it does the title and legend. +#[test] +fn pie_data_labels_take_the_chart_text_colour_when_it_has_one() { + let draw = |colour: Option| { + let mut c = Chart::sample(ChartType::Pie); + c.title = None; + c.legend = None; + c.data_labels = true; + c.text_color = colour; + let sh = Shape { id: ShapeId(632), xfrm: Some(Xfrm::new(0.0, 0.0, 400.0, 400.0)), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() }; + render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() }) + }; + let count = |img: &Image, f: &dyn Fn([u8; 4]) -> bool| { + (0..400u32).flat_map(|x| (0..400u32).map(move |y| (x, y))).filter(|(x, y)| f(img.pixel(*x, *y))).count() + }; + let red = |p: [u8; 4]| p[0] > 200 && p[1] < 60 && p[2] < 60; + let white = |p: [u8; 4]| p[0] > 245 && p[1] > 245 && p[2] > 245; + let (plain, coloured) = (draw(None), draw(Some(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0))))); + assert_eq!(count(&plain, &red), 0, "no red without a text colour"); + assert!(count(&coloured, &red) > 30, "the labels take the chart's red: {} red pixels", count(&coloured, &red)); + assert!(count(&plain, &white) > count(&coloured, &white), "the labels were white before"); +} From 3ac39f9bb6db6d3cb6c6beba70314e651affbb19 Mon Sep 17 00:00:00 2001 From: Zach Berry <1112351+zberry@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:18:18 -0500 Subject: [PATCH 27/37] Chart text colour is written to the chart part, so an imported chart keeps it `Chart::text_color` was read from a .pptx (the axes', legend's or chart's `c:txPr`) but never written, and any edit drops the kept part and rebuilds it from the model, so a save after an edit lost the colour. The writer now puts it where PowerPoint keeps it: `c:txPr` on both axes, the legend, the data labels and the chart root, and the run defaults of the title's rich text (chart and axis titles). Nothing is written without a colour, so the theme default still applies on reading. Round-trip tests: a scheme colour with a lumMod, an RGB colour and no colour come back as set; a chart read with a `tx1` 65 %/35 % colour on its axis keeps it through a title edit and a rebuild of the part. Co-Authored-By: Claude Fable 5.1 --- crates/pptx/src/write/chart.rs | 117 ++++++++++++++++++++++++++++++--- docs/file-format-parity.md | 1 + 2 files changed, 108 insertions(+), 10 deletions(-) diff --git a/crates/pptx/src/write/chart.rs b/crates/pptx/src/write/chart.rs index 889174507..a7d19ffa1 100644 --- a/crates/pptx/src/write/chart.rs +++ b/crates/pptx/src/write/chart.rs @@ -58,14 +58,54 @@ fn sp_pr(w: &mut W, fill: Option<&Fill>, line: Option<&deckcraft_model::Line>) { w.close("c:spPr"); } -fn rich(w: &mut W, text: &str) { +/// `a:defRPr` carrying the chart's text colour, where PowerPoint keeps it: the paragraph +/// defaults of a title's rich text and of every `c:txPr`. +fn def_rpr(w: &mut W, color: Option<&deckcraft_model::ColorRef>) { + match color { + Some(c) => { + w.open0("a:defRPr"); + super::dml::solid(w, c); + w.close("a:defRPr"); + } + None => w.empty0("a:defRPr"), + } +} + +/// `c:txPr` with the chart's text colour (`Chart::text_color`) as the default run properties, on +/// the axes, the legend, the data labels and the chart itself, as PowerPoint writes it. Nothing +/// without a colour: the theme's default then applies on reading. +fn tx_pr(w: &mut W, c: &Chart) { + let Some(color) = &c.text_color else { return }; + w.open0("c:txPr"); + w.empty0("a:bodyPr"); + w.empty0("a:lstStyle"); + w.open0("a:p"); + w.open0("a:pPr"); + def_rpr(w, Some(color)); + w.close("a:pPr"); + w.empty("a:endParaRPr", A::new().a("lang", "en-US")); + w.close("a:p"); + w.close("c:txPr"); +} + +fn rich(w: &mut W, text: &str, color: Option<&deckcraft_model::ColorRef>) { w.open0("c:tx"); w.open0("c:rich"); w.empty0("a:bodyPr"); w.empty0("a:lstStyle"); for line in text.split('\n') { w.open0("a:p"); + if color.is_some() { + w.open0("a:pPr"); + def_rpr(w, color); + w.close("a:pPr"); + } w.open0("a:r"); + if let Some(c) = color { + w.open("a:rPr", A::new().a("lang", "en-US")); + super::dml::solid(w, c); + w.close("a:rPr"); + } w.elt("a:t", line); w.close("a:r"); w.close("a:p"); @@ -74,9 +114,10 @@ fn rich(w: &mut W, text: &str) { w.close("c:tx"); } -fn title(w: &mut W, text: &str) { +/// A title (the chart's or an axis's) in the chart's text colour. +fn title(w: &mut W, text: &str, c: &Chart) { w.open0("c:title"); - rich(w, text); + rich(w, text, c.text_color.as_ref()); w.val("c:overlay", 0); w.close("c:title"); } @@ -217,7 +258,7 @@ fn series(w: &mut W, c: &Chart, s: &Series, i: usize, kind: ChartType, g: Group) } } if let Some(d) = s.data_labels { - data_labels(w, d); + data_labels(w, c, d); } // After labels, before the category and value caches (ECMA-376 series content order). if matches!(g, Group::Bar | Group::Line | Group::Area | Group::Scatter) { @@ -325,8 +366,9 @@ fn trendline(w: &mut W, t: &Trendline) { w.close("c:trendline"); } -fn data_labels(w: &mut W, show: bool) { +fn data_labels(w: &mut W, c: &Chart, show: bool) { w.open0("c:dLbls"); + tx_pr(w, c); for (n, v) in [ ("c:showLegendKey", false), ("c:showVal", show), @@ -355,12 +397,13 @@ fn axes(w: &mut W, c: &Chart, g: Group) { w.val("c:delete", 0); w.val("c:axPos", "b"); if let Some(t) = &c.axis_titles.0 { - title(w, t); + title(w, t, c); } w.empty("c:numFmt", A::new().a("formatCode", "General").a("sourceLinked", 1)); w.val("c:majorTickMark", "out"); w.val("c:minorTickMark", "none"); w.val("c:tickLblPos", "nextTo"); + tx_pr(w, c); w.val("c:crossAx", AX_VAL); w.val("c:crosses", "autoZero"); w.val("c:crossBetween", "midCat"); @@ -374,12 +417,13 @@ fn axes(w: &mut W, c: &Chart, g: Group) { w.val("c:delete", 0); w.val("c:axPos", cat_pos); if let Some(t) = &c.axis_titles.0 { - title(w, t); + title(w, t, c); } w.empty("c:numFmt", A::new().a("formatCode", "General").a("sourceLinked", 1)); w.val("c:majorTickMark", "none"); w.val("c:minorTickMark", "none"); w.val("c:tickLblPos", "nextTo"); + tx_pr(w, c); w.val("c:crossAx", AX_VAL); w.val("c:crosses", "autoZero"); w.val("c:auto", 1); @@ -411,7 +455,7 @@ fn axes(w: &mut W, c: &Chart, g: Group) { w.empty0("c:minorGridlines"); } if let Some(t) = &c.axis_titles.1 { - title(w, t); + title(w, t, c); } match c.y_format.as_deref().filter(|f| !f.is_empty()) { Some(f) => w.empty("c:numFmt", A::new().a("formatCode", f).a("sourceLinked", 0)), @@ -420,6 +464,7 @@ fn axes(w: &mut W, c: &Chart, g: Group) { w.val("c:majorTickMark", "none"); w.val("c:minorTickMark", "none"); w.val("c:tickLblPos", "nextTo"); + tx_pr(w, c); w.val("c:crossAx", AX_CAT); w.val("c:crosses", "autoZero"); w.val("c:crossBetween", if g == Group::Area || g == Group::Scatter { "midCat" } else { "between" }); @@ -468,7 +513,7 @@ fn plot_group(w: &mut W, c: &Chart, kind: ChartType, list: &[(usize, &Series)]) series(w, c, s, *i, kind, g); } if c.data_labels { - data_labels(w, true); + data_labels(w, c, true); } match g { Group::Bar => { @@ -576,7 +621,7 @@ pub fn chart_xml(c: &Chart) -> (Vec, bool) { w.open0("c:chart"); match &c.title { Some(t) => { - title(&mut w, t); + title(&mut w, t, c); w.val("c:autoTitleDeleted", 0); } None => w.val("c:autoTitleDeleted", 1), @@ -654,6 +699,7 @@ pub fn chart_xml(c: &Chart) -> (Vec, bool) { w.open0("c:legend"); w.val("c:legendPos", p); w.val("c:overlay", u8::from(c.legend_overlay)); + tx_pr(&mut w, c); w.close("c:legend"); } w.val("c:plotVisOnly", 1); @@ -662,6 +708,7 @@ pub fn chart_xml(c: &Chart) -> (Vec, bool) { if let Some(f) = &c.chart_fill { sp_pr(&mut w, Some(f), None); } + tx_pr(&mut w, c); w.close("c:chartSpace"); (w.finish(), false) } @@ -971,8 +1018,58 @@ mod region_map_tests { #[cfg(test)] mod tests { + use deckcraft_color::{ColorTransform, Rgba}; + use deckcraft_model::ColorRef; use deckcraft_model::chart::{Chart, ChartType}; + /// The chart's text colour is written where PowerPoint keeps it (`c:txPr` on the axes, legend, + /// data labels and chart, the title's run defaults) and read back as the same colour. + #[test] + fn chart_text_colour_round_trips() { + let mut c = Chart::sample(ChartType::Column); + c.title = Some("Revenue".into()); + c.legend = Some("b".into()); + c.data_labels = true; + c.axis_titles = (Some("Quarter".into()), Some("EUR".into())); + let tinted = ColorRef::scheme(deckcraft_model::SchemeSlot::Accent2).with(ColorTransform::LumMod(75000)); + c.text_color = Some(tinted.clone()); + let xml = String::from_utf8(super::chart_xml(&c).0).unwrap(); + // Axes (2), legend, data labels and the chart root; a title keeps it in its rich text. + assert!(xml.matches("").count() >= 5, "{xml}"); + assert!(xml.contains(r#""#), "{xml}"); + assert!(xml.contains(""), "{xml}"); + assert_eq!(crate::read::chart::read_chart_xml(xml.as_bytes()).unwrap().text_color, Some(tinted)); + // An RGB colour and no colour at all round-trip too. + c.text_color = Some(ColorRef::rgb(Rgba::rgb(255, 255, 255))); + let xml = super::chart_xml(&c).0; + assert_eq!(crate::read::chart::read_chart_xml(&xml).unwrap().text_color, c.text_color); + c.text_color = None; + let xml = String::from_utf8(super::chart_xml(&c).0).unwrap(); + assert!(!xml.contains(""), "{xml}"); + assert_eq!(crate::read::chart::read_chart_xml(xml.as_bytes()).unwrap().text_color, None); + } + + /// An imported chart's `c:txPr` colour survives an edit that rebuilds the part from the model + /// (the colour used to be read but never written, so a save after any edit lost it). + #[test] + fn imported_text_colour_survives_an_edit_and_a_save() { + let xml = r#"1"#; + let c = crate::read::chart::read_chart_xml(xml.as_bytes()).unwrap(); + let grey = ColorRef::scheme(deckcraft_model::SchemeSlot::Tx1).with(ColorTransform::LumMod(65000)).with(ColorTransform::LumOff(35000)); + assert_eq!(c.text_color, Some(grey.clone())); + // Unchanged: the part is written back as read. + assert!(super::chart_xml(&c).1); + // Edited (a title; the editor drops `raw` on any edit): rebuilt with the colour. + let mut e = c.clone(); + e.title = Some("Units".into()); + e.raw = None; + let (out, kept) = super::chart_xml(&e); + assert!(!kept); + let back = crate::read::chart::read_chart_xml(&out).unwrap(); + assert_eq!(back.text_color, Some(grey)); + assert_eq!(back.title.as_deref(), Some("Units")); + } + #[test] fn reversed_category_axis_round_trips() { let mut c = Chart::sample(ChartType::Bar); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d5f67246a..fb4bb2db8 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -124,6 +124,7 @@ the round trip even though DeckCraft doesn't model it. | 2026-10-15 | trivial | PPTX interactive timing sequences keep later On Click steps and paragraph click builds (`trigger_steps.rs`); mixed starts and independent buttons survive import/edit/save/reopen | | 2026-10-15 | minor | A `.pptx` slide that can't be read is reported instead of dropped in silence: `file.open` and `file.openBytes` return `lostSlides`, the app shows a message and opens a repaired copy (Save asks for a new name), and `deckcraft-cli` warns on stderr (#203) | | 2026-10-16 | minor | PNG slide export with a transparent background (#784) | +| 2026-10-23 | minor | Chart text colour (`c:txPr` on the axes, legend, data labels and chart; the title's run defaults) is written as well as read, so an imported chart's colours survive an edit and a save; `chart.options {textColor}`, Font Color on a chart (#1015) | | 2026-10-16 | trivial | A `.ppsx` opened from the command line or the file association starts the slide show (#862) | | 2026-10-14 | minor | Community PR wave folded: one row each for `.pptx` (seven owned corpus files), outline, 3-D models, embedded fonts, text, effects, 3-D (extrusion sides draw now), SmartArt (written as a diagram) and VBA (tags and custom XML are kept) | | 2026-10-12 | minor | SmartArt import lays each shape's text out on its `dsp:txXfrm` text rectangle instead of the shape box | From 0c510426af107a9c8890615ef8935f4c1a5f9313 Mon Sep 17 00:00:00 2001 From: Zach Berry <1112351+zberry@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:18:18 -0500 Subject: [PATCH 28/37] Chart text without a colour of its own takes PowerPoint's default grey The theme's bare text colour made chart text black on a light theme, where it used to be 595959. PowerPoint's default is `tx1` at lumMod 65000 and lumOff 35000: 595959 on a light theme, a light grey on a dark one, so the dark-theme fix stays. The render test now checks the light-theme title is dark but not black; the chart.options doc says what null gives. Co-Authored-By: Claude Fable 5.1 --- crates/engine/src/cmd/insert.rs | 5 +++-- crates/render/src/chart.rs | 14 +++++++++++--- crates/render/src/tests.rs | 10 +++++++--- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 901cb01e6..3bc9643ea 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -118,7 +118,7 @@ pub fn specs() -> Vec { "Chart Elements", ["Chart Design", "Chart Layouts"], None, - "{title?: string|null, legend?: b|t|l|r|null, dataLabels?: bool, gridlines?: bool, palette?, binCount?: n|null, binSize?: n|null, subtotals?: [point index], quartiles?: exclusive|inclusive, upDownBars?: bool, textColor?: #RRGGBB|accent1..6|tx1…|null (title, axes, legend and labels; null for the theme's text colour), id?}", + "{title?: string|null, legend?: b|t|l|r|null, dataLabels?: bool, gridlines?: bool, palette?, binCount?: n|null, binSize?: n|null, subtotals?: [point index], quartiles?: exclusive|inclusive, upDownBars?: bool, textColor?: #RRGGBB|accent1..6|tx1…|null (title, axes, legend and labels; null for PowerPoint's default: the theme's text colour at 65 % plus 35 %, 595959 on a light theme), id?}", has_selection, chart_options ), @@ -1771,7 +1771,8 @@ fn chart_options(s: &mut Session, p: &Value) -> Result { // Waterfall total points. let subtotals: Option> = p.get("subtotals").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|i| i as usize).collect()); - // Colour of the chart's text (title, axes, legend, data labels); null goes back to the theme's. + // Colour of the chart's text (title, axes, legend, data labels); null goes back to PowerPoint's + // default (tx1 at lumMod 65 % / lumOff 35 %). let text_color = match p.get("textColor") { None => None, Some(Value::Null) => Some(None), diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index cb07b363b..a4f209e05 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -205,11 +205,19 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h } } +/// PowerPoint's default chart text colour: `tx1` with `lumMod` 65000 and `lumOff` 35000, which +/// is 595959 on a light theme and a light grey on a dark one. +pub fn default_text_color() -> ColorRef { + use deckcraft_color::ColorTransform; + ColorRef::scheme(SchemeSlot::Tx1).with(ColorTransform::LumMod(65000)).with(ColorTransform::LumOff(35000)) +} + #[allow(clippy::too_many_arguments)] fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h: f64, boxes: Option<&crate::chartex::Boxes>) { - // Text without a colour of its own takes the theme's text colour, so a chart on a dark - // theme is readable (a fixed grey used to vanish there). - let text_col = rctx.color(c.text_color.as_ref().unwrap_or(&ColorRef::scheme(SchemeSlot::Tx1)), None); + // Text without a colour of its own takes PowerPoint's default, the theme's text colour at + // lumMod 65 % / lumOff 35 % (595959 on a light theme, light grey on a dark one), so a chart + // on a dark theme is readable (a fixed grey used to vanish there). + let text_col = rctx.color(c.text_color.as_ref().unwrap_or(&default_text_color()), None); let grid_col = Rgba::rgb(0xD9, 0xD9, 0xD9); if let Some(f) = &c.chart_fill { crate::paint::fill_path( diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 07794e092..c72d45006 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -2718,8 +2718,9 @@ fn empty_bullet_is_gray_only_while_editing_and_never_in_exports() { assert!(changed.iter().all(|(px, _)| px[0] >= 179 && px[0] == px[1] && px[1] == px[2])); } -/// A chart that has no text colour of its own takes the theme's: light on Nocturne's dark slides -/// (it used to be a fixed grey, near-invisible there), dark on Harbor's light ones. +/// A chart that has no text colour of its own takes PowerPoint's default, the theme's text colour +/// at 65 % plus 35 %: light on Nocturne's dark slides (it used to be a fixed grey, near-invisible +/// there), 595959 grey on Harbor's light ones. #[test] fn chart_text_follows_the_theme_when_it_has_no_colour_of_its_own() { let draw = |theme: &str, title: Option<&str>| { @@ -2744,8 +2745,11 @@ fn chart_text_follows_the_theme_when_it_has_no_colour_of_its_own() { assert!(light_ink > 100, "the title is light on a dark theme (light pixels added: {light_ink})"); let (light_bare, light_titled) = (draw("Harbor", None), draw("Harbor", Some("Quarterly revenue"))); assert!(lum(light_bare.pixel(5, 5)) > 200, "Harbor's slide is light: {:?}", light_bare.pixel(5, 5)); - let dark_ink = count(&light_titled, &|l| l < 80) as i64 - count(&light_bare, &|l| l < 80) as i64; + // 595959 has luminance 89: dark, but not black. + let dark_ink = count(&light_titled, &|l| l < 120) as i64 - count(&light_bare, &|l| l < 120) as i64; assert!(dark_ink > 100, "the title is dark on a light theme (dark pixels added: {dark_ink})"); + let black_ink = count(&light_titled, &|l| l < 40) as i64 - count(&light_bare, &|l| l < 40) as i64; + assert!(black_ink < 20, "the title is PowerPoint's 595959 grey, not black (black pixels added: {black_ink})"); } /// Pie data labels sit on the slices in white; a colour given to the chart's text (chart.options From aaba9febc2aba5e6cdc7398581bb8d65f4aec1c8 Mon Sep 17 00:00:00 2001 From: Zach Berry <1112351+zberry@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:18:18 -0500 Subject: [PATCH 29/37] Progress log: chart text colour Co-Authored-By: Claude Fable 5.1 --- ROADMAP.md | 1 + 1 file changed, 1 insertion(+) diff --git a/ROADMAP.md b/ROADMAP.md index d54c160f0..080941c52 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -134,6 +134,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-23 | Chart text colour: `chart.options {textColor}` and Font Color on a selected chart colour its title, axes, legend and data labels; the colour is written to the chart part (`c:txPr` on the axes, legend, labels and chart, the title's run defaults) so an imported chart keeps its `txPr` colours through an edit and a save; text without a colour of its own takes PowerPoint's default, the theme's text colour at 65 % plus 35 % (595959 on a light theme, light grey on a dark one) | | 2026-10-23 | Format Background ▸ Apply to All also sets the background of the slide masters the slides use (and clears their layouts' overrides), so new slides keep it (#891) | | 2026-10-22 | Table text takes the master's other-text style (`otherStyle`), shapes and text boxes the presentation's default text style, as PowerPoint for Mac draws them (synthetic probe: 14 pt cells under a 14 pt `otherStyle` and a 26 pt `defaultTextStyle`); a private deck's table had been drawn 20 pt instead of 18 | | 2026-10-18 | Find (Ctrl+F): "Find what:" has the keyboard as soon as the dialog opens, and keeps it after Enter finds a match, so the search can be typed and changed without clicking the field (#951) | From 52bf97d983642603411b001bcad380d7be5a44da Mon Sep 17 00:00:00 2001 From: Zach Berry <1112351+zberry@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:45:55 -0500 Subject: [PATCH 30/37] Render: table body text on tinted cells uses the theme's dark colour, so it stays readable on dark themes The medium2 (default), medium4 and banded light1 table styles tint body cells towards white but gave their text no colour, so it inherited the theme's text colour: light on a dark theme such as Nocturne, and nearly invisible on the light tint. PowerPoint's medium styles set that text to dk1. DeckCraft's dark themes keep the dark colour in lt1 (Nocturne has dk1 white and lt1 near-black, relying on the default tx1->dk1 map), so the look picks whichever of dk1 and lt1 contrasts more with the cell's actual fill. Cells with no fill keep inheriting, and the "grid" border keeps the text colour. Co-Authored-By: Claude Fable 5.1 --- crates/render/src/table.rs | 71 +++++++++++++++++++++++--------------- crates/render/src/tests.rs | 35 +++++++++++++++++++ 2 files changed, 79 insertions(+), 27 deletions(-) diff --git a/crates/render/src/table.rs b/crates/render/src/table.rs index dd2630f63..ce6f9a864 100644 --- a/crates/render/src/table.rs +++ b/crates/render/src/table.rs @@ -153,18 +153,28 @@ pub fn look(ctx: &Ctx, t: &Table, r: usize, c: usize) -> CellLook { let band_c = t.band_col && c.is_multiple_of(2); let tint = |k: i32| deckcraft_color::apply(a, &[ColorTransform::Tint(k)]); let white = Rgba::WHITE; - let dark_text = ctx.color(&ColorRef::scheme(SchemeSlot::Tx1), None); + let text_color = ctx.color(&ColorRef::scheme(SchemeSlot::Tx1), None); + // Text on a tinted cell never inherits the text colour, which a dark theme makes light and + // unreadable on the light tint: PowerPoint's medium styles set it to the theme's dark colour + // (dk1). A dark theme may keep that in dk1 and map tx1 away from it, or (Nocturne) put it in + // lt1, so of the two the one contrasting more with the cell's fill is used. + let dk1 = ctx.color(&ColorRef::scheme(SchemeSlot::Dk1), None); + let lt1 = ctx.color(&ColorRef::scheme(SchemeSlot::Lt1), None); + let on = |fill: Rgba| if dk1.contrast(fill) >= lt1.contrast(fill) { dk1 } else { lt1 }; match kind { "none" => CellLook { fill: None, text: None, bold: false, border: None, sides: [None; 4], paint: None }, - "grid" => CellLook { fill: None, text: None, bold: false, border: Some((dark_text, 0.75)), sides: [None; 4], paint: None }, - "light1" => CellLook { - fill: if band || band_c { Some(tint(20000)) } else { None }, - text: None, - bold: header || total || first_col || last_col, - border: if header { Some((a, 1.0)) } else { None }, - sides: [None; 4], - paint: None, - }, + "grid" => CellLook { fill: None, text: None, bold: false, border: Some((text_color, 0.75)), sides: [None; 4], paint: None }, + "light1" => { + let fill = (band || band_c).then(|| tint(20000)); + CellLook { + fill, + text: fill.map(on), + bold: header || total || first_col || last_col, + border: if header { Some((a, 1.0)) } else { None }, + sides: [None; 4], + paint: None, + } + } "light2" => CellLook { fill: if header { Some(a) } else { None }, text: if header { Some(white) } else { None }, @@ -187,29 +197,36 @@ pub fn look(ctx: &Ctx, t: &Table, r: usize, c: usize) -> CellLook { sides: [None; 4], paint: None, }, - "medium4" => CellLook { - fill: Some(if header || band || band_c { tint(40000) } else { tint(20000) }), - text: None, - bold: header || total || first_col || last_col, - border: Some((a, 0.75)), - sides: [None; 4], - paint: None, - }, - _ => CellLook { + "medium4" => { + let fill = if header || band || band_c { tint(40000) } else { tint(20000) }; + CellLook { + fill: Some(fill), + text: Some(on(fill)), + bold: header || total || first_col || last_col, + border: Some((a, 0.75)), + sides: [None; 4], + paint: None, + } + } + _ => { // medium2: strong header, banded light tints, white grid. - fill: Some(if header || total || first_col || last_col { + let strong = header || total || first_col || last_col; + let fill = if strong { a } else if band || band_c { tint(40000) } else { tint(20000) - }), - text: if header || total || first_col || last_col { Some(white) } else { None }, - bold: header || total || first_col || last_col, - border: Some((white, 1.0)), - sides: [None; 4], - paint: None, - }, + }; + CellLook { + fill: Some(fill), + text: Some(if strong { white } else { on(fill) }), + bold: strong, + border: Some((white, 1.0)), + sides: [None; 4], + paint: None, + } + } } } diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index b65098b0e..871bbccb3 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1336,6 +1336,41 @@ fn a_table_fill_shows_behind_cells_without_their_own() { assert_eq!(img.pixel(50, 50)[..3], [255, 255, 255]); } +#[test] +fn table_body_text_is_dark_on_the_tinted_cells_of_a_dark_theme() { + // The default style (medium2) tints body cells towards white, so their text must be the + // theme's dark colour, as PowerPoint's medium styles set it (dk1): inheriting the text colour + // leaves it light, and unreadable, on a dark theme such as Nocturne. + let fill_and_darkest = |theme: &str| { + let mut t = Table::new(2, 2, 200.0, 40.0); + for (r, c) in [(0, 0), (0, 1), (1, 0), (1, 1)] { + t.cell_mut(r, c).unwrap().text = TextBody::from_text("Body"); + } + let tbl = Shape { id: ShapeId(796), xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 80.0)), kind: ShapeKind::Table(t), ..Default::default() }; + let mut p = deck_with(vec![tbl]); + let th = deckcraft_model::theme::builtin_themes().into_iter().find(|th| th.name == theme).unwrap(); + std::sync::Arc::make_mut(&mut p.masters[0]).theme = th; + let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }); + let lum = |x, y| { + let px = img.pixel(x, y); + deckcraft_color::Rgba::rgb(px[0], px[1], px[2]).luminance() + }; + // Body cell (1, 1) covers (200, 140)–(300, 180); its top-left corner is clear of the text + // (cell margins) and of the white grid line. + let fill = lum(203, 143); + let darkest = (143..177).flat_map(|y| (203..297).map(move |x| (x, y))).map(|(x, y)| lum(x, y)).fold(1.0_f64, f64::min); + (fill, darkest) + }; + for theme in ["Nocturne", "Harbor"] { + let (fill, darkest) = fill_and_darkest(theme); + assert!(fill > 0.4, "{theme}: the body cell's fill is a light tint, luminance {fill}"); + assert!( + darkest < 0.15 && darkest < fill / 3.0, + "{theme}: darkest pixel in the body cell has luminance {darkest} on a fill of {fill}: its text is not dark" + ); + } +} + /// A white 280x200 pt rectangle at (20, 20) with `effects`, rendered at 1 px/pt. fn effect_box(effects: deckcraft_model::Effects) -> Image { use deckcraft_model::style::{Fill, Line}; From 18e8bb0ded00ba635e26cb2882ecfa5f587b78ba Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:07:34 +0000 Subject: [PATCH 31/37] Fix arrange.distribute span to use the selection's full extent Found while testing crates/engine/src/cmd/arrange.rs:325-327 (distribute) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/arrange.rs | 6 +++--- crates/engine/src/tests.rs | 13 +++++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/arrange.rs b/crates/engine/src/cmd/arrange.rs index a043d9875..903c14444 100644 --- a/crates/engine/src/cmd/arrange.rs +++ b/crates/engine/src/cmd/arrange.rs @@ -322,9 +322,9 @@ fn distribute(s: &mut Session, p: &Value) -> Result { let (start, end) = if to_slide { if horiz { (0.0, size.width) } else { (0.0, size.height) } } else { - let f = boxes.first().map(|b| b.1).unwrap_or_default(); - let l = boxes.last().map(|b| b.1).unwrap_or_default(); - if horiz { (f.x0, l.x1) } else { (f.y0, l.y1) } + let lo = |f: fn(&Rect) -> f64| boxes.iter().map(|b| f(&b.1)).fold(f64::INFINITY, f64::min); + let hi = |f: fn(&Rect) -> f64| boxes.iter().map(|b| f(&b.1)).fold(f64::NEG_INFINITY, f64::max); + if horiz { (lo(|r| r.x0), hi(|r| r.x1)) } else { (lo(|r| r.y0), hi(|r| r.y1)) } }; let n = boxes.len(); let gap = if n > 1 { ((end - start) - total) / (n - 1) as f64 } else { 0.0 }; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 35b0ab396..8af46bcd3 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -338,6 +338,19 @@ fn align_and_distribute() { assert!((x1 - 255.0).abs() < 1e-6, "{x1}"); } +#[test] +fn distribute_wide_middle_stays_in_selection_bounds() { + let mut s = session(); + let mut ids = vec![]; + for (x, w) in [(0.0, 10.0), (20.0, 100.0), (30.0, 10.0)] { + ids.push(s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, 10, w, 10]})).unwrap()["id"].as_u64().unwrap()); + } + s.execute("edit.select", &json!({"ids": ids})).unwrap(); + s.execute("arrange.distribute", &json!({"dir": "horizontal", "to": "selection"})).unwrap(); + let xs: Vec = ids.iter().map(|i| s.execute("shape.inspect", &json!({"id": i})).unwrap()["box"]["x"].as_f64().unwrap()).collect(); + assert!(xs.iter().all(|x| (*x - [0.0, 10.0, 110.0][xs.iter().position(|y| y == x).unwrap()]).abs() < 1e-6), "{xs:?}"); +} + #[test] fn pointer_draw_move_resize() { let mut s = session(); From 6d7b32351ffe980ff1f80c7b56b7015ada31cc0b Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:16:19 +0000 Subject: [PATCH 32/37] Reject invalid index in slide.hide and slide.layout Found while testing crates/engine/src/cmd/slide.rs:376 (hide); same pattern at slide.rs:445 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/slide.rs | 4 ++-- crates/engine/src/tests.rs | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/slide.rs b/crates/engine/src/cmd/slide.rs index 777c479c7..88ee9685e 100644 --- a/crates/engine/src/cmd/slide.rs +++ b/crates/engine/src/cmd/slide.rs @@ -373,7 +373,7 @@ fn select_slides(s: &mut Session, p: &Value) -> Result { } fn hide(s: &mut Session, p: &Value) -> Result { - let targets: Vec = match usize_param(p, "index") { + let targets: Vec = match opt_usize_param(p, "index", "slide.hide")? { Some(i) => vec![i], None => { let st = s.doc()?; @@ -442,7 +442,7 @@ pub fn apply_layout(doc: &mut Presentation, index: usize, layout: LayoutId) -> R fn set_layout(s: &mut Session, p: &Value) -> Result { let key = p.get("layout").cloned().ok_or_else(|| bad("slide.layout", "missing `layout`"))?; - let targets: Vec = match usize_param(p, "index") { + let targets: Vec = match opt_usize_param(p, "index", "slide.layout")? { Some(i) => vec![i], None => { let st = s.doc()?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 35b0ab396..2c1ed0be1 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4403,3 +4403,18 @@ fn duplicating_a_moved_copy_repeats_the_move() { assert_eq!(boxed(&mut s, f), (340.0 + off, 100.0 + off)); assert_eq!(s.doc().unwrap().doc.validate(), Vec::::new()); } + +#[test] +fn slide_hide_and_layout_reject_invalid_index() { + let mut s = session(); + s.execute("slide.new", &json!({})).unwrap(); + s.execute("slide.new", &json!({})).unwrap(); + s.execute("slide.selectSlides", &json!({"indices": [0]})).unwrap(); + for bad in [json!(-1), json!(1.5)] { + let e = s.execute("slide.hide", &json!({"index": bad})).unwrap_err().to_string(); + assert!(e.contains("not a valid index"), "{e}"); + let e = s.execute("slide.layout", &json!({"layout": "blank", "index": bad})).unwrap_err().to_string(); + assert!(e.contains("not a valid index"), "{e}"); + } + assert!(s.doc().unwrap().doc.slides.iter().all(|x| !x.hidden)); +} From aed303e504d9cb07f62c12ebd440efcef1746951 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 06:15:41 +0000 Subject: [PATCH 33/37] Integration: #956 chart.options dataLabels resets the series' own settings Without it, turning Data Labels on did nothing for an imported chart whose series read showVal 0. --- crates/engine/src/cmd/insert.rs | 4 ++++ crates/pptx/tests/chart_data_labels.rs | 21 +++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 4f28ddef1..c0966f990 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -1818,6 +1818,10 @@ fn chart_options(s: &mut Session, p: &Value) -> Result { } if let Some(v) = bool_param(p, "dataLabels") { c.data_labels = v; + // A series' own setting would otherwise keep overriding the chart-wide switch. + for series in &mut c.series { + series.data_labels = None; + } } if let Some(v) = bool_param(p, "gridlines") { c.gridlines = v; diff --git a/crates/pptx/tests/chart_data_labels.rs b/crates/pptx/tests/chart_data_labels.rs index d89fae535..09e35cca6 100644 --- a/crates/pptx/tests/chart_data_labels.rs +++ b/crates/pptx/tests/chart_data_labels.rs @@ -152,3 +152,24 @@ fn a_combo_chart_keeps_each_groups_own_label_setting() { assert!(!c.series_data_labels(&c.series[0]), "so the bars stay quiet"); assert!(c.series_data_labels(&c.series[1]), "the line takes the chart-wide on"); } + +/// Turning Data Labels on through `chart.options` shows the labels of series that the file +/// switched off one by one. +#[test] +fn chart_options_data_labels_on_overrides_series_off() { + use deckcraft_engine::Session; + let p = reimport(&bar_chart_pptx(), |xml| xml.replace("", r#""#)); + assert!(chart_of(&p).series.iter().all(|s| !chart_of(&p).series_data_labels(s)), "every series starts quiet"); + let path = std::env::temp_dir().join("deckcraft_series_labels_off.pptx"); + std::fs::write(&path, deckcraft_pptx::export(&p).expect("export")).expect("write"); + let mut s = Session::with_new(); + s.execute("file.open", &serde_json::json!({"path": path.to_string_lossy()})).expect("open"); + let doc = s.doc().unwrap().doc.clone(); + let id = doc.slides[0].shapes.iter().find(|sh| matches!(sh.kind, ShapeKind::Chart(_))).expect("chart").id.0; + assert!(chart_of(&doc).series.iter().all(|x| !chart_of(&doc).series_data_labels(x))); + s.execute("chart.options", &serde_json::json!({"id": id, "dataLabels": true})).expect("options"); + let doc = s.doc().unwrap().doc.clone(); + let c = chart_of(&doc); + assert!(c.series.iter().all(|x| c.series_data_labels(x)), "every series shows its labels"); + let _ = std::fs::remove_file(path); +} From 29cc5f83bdf9bb7d2066b54a03029397e85be612 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 06:16:27 +0000 Subject: [PATCH 34/37] Integration: #833 drop the leftover PNG debug hook from the Edit Points menu test --- crates/ui-egui/src/canvas.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 7d7cf77d6..c6320dffa 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2026,9 +2026,6 @@ mod tests { // The middle of the right edge: the segment ending at point 2. let p = screen(h.state(), 500.0, 250.0); right_click(&mut h, p); - if let (Ok(path), Ok(img)) = (std::env::var("DECKCRAFT_PNG_OUT_SEGMENTS"), h.render()) { - let _ = img.save(path); - } h.get_by_label("Curved Segment").click(); h.run_steps(3); assert_eq!(q(&mut h)["segments"][0], json!(["straight", "straight", "curved", "straight"])); From f52c911959d2a153d76fbcf60a3a739d46d08b81 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 06:17:19 +0000 Subject: [PATCH 35/37] Integration: #927 Header & Footer dialog sends dateFormat only when picked; set_date doc comment back above set_date Without it, Apply rewrote every slide's existing date field to datetime1. --- crates/engine/src/cmd/design.rs | 4 +- crates/ui-egui/src/dialogs.rs | 68 ++++++++++++++++++++++++++++++--- 2 files changed, 65 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/design.rs b/crates/engine/src/cmd/design.rs index 7baa6b586..3130b38be 100644 --- a/crates/engine/src/cmd/design.rs +++ b/crates/engine/src/cmd/design.rs @@ -961,8 +961,6 @@ fn header_footer(s: &mut Session, p: &Value) -> Result { ok() } -/// Point a slide's date placeholder at a fixed date (its own plain text) or, for an empty -/// `fixed`, at an automatic date field (one it already has keeps its format). /// The date field type `format` names, `datetime1` when it is empty or unknown. fn date_kind(format: &str) -> &str { if deckcraft_text::datetime::DATE_KINDS.contains(&format) { format } else { "datetime1" } @@ -974,6 +972,8 @@ fn date_now(format: &str) -> String { deckcraft_text::datetime::DateTime::now_local().and_then(|now| deckcraft_text::datetime::field_text(kind, None, now)).unwrap_or_else(today) } +/// Point a slide's date placeholder at a fixed date (its own plain text) or, for an empty +/// `fixed`, at an automatic date field (one it already has keeps its format). fn set_date(sh: &mut Shape, fixed: &str, format: &str) { let runs = || sh.text.iter().flat_map(|t| &t.paragraphs).flat_map(|p| &p.runs); let field_now = runs().find_map(|r| match &r.kind { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 4ef2391f5..3e7e4c2ba 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1126,11 +1126,20 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { if apply || apply_all { let dt = d.get("dateText", ""); let ft = d.get("footerText", ""); - run( - app, - "design.headerFooter", - json!({"date": date, "dateText": dt, "dateFormat": d.get("dateFormat", &fmt_now), "slideNumber": num_on, "footer": foot, "footerText": ft, "hideOnTitle": hide, "all": apply_all}), - ); + let mut params = serde_json::Map::new(); + params.insert("date".into(), json!(date)); + params.insert("dateText".into(), json!(dt)); + // Only a format the user picked: sending the default would rewrite every slide's + // existing date field (an imported datetime4, say) to datetime1. + if d.fields.contains_key("dateFormat") { + params.insert("dateFormat".into(), json!(d.get("dateFormat", &fmt_now))); + } + params.insert("slideNumber".into(), json!(num_on)); + params.insert("footer".into(), json!(foot)); + params.insert("footerText".into(), json!(ft)); + params.insert("hideOnTitle".into(), json!(hide)); + params.insert("all".into(), json!(apply_all)); + run(app, "design.headerFooter", serde_json::Value::Object(params)); } apply || apply_all || d.fields.contains_key("cancel") } @@ -3410,6 +3419,55 @@ mod tests { h } + /// Header & Footer ▸ Apply without picking a format leaves a slide's existing date field in + /// its own format instead of rewriting it to the default one. + #[test] + fn header_footer_apply_keeps_an_existing_date_format() { + use std::sync::Arc; + + use egui::vec2; + use egui_kittest::kittest::Queryable as _; + use serde_json::json; + + use crate::SlideApp; + let mut session = deckcraft_engine::Session::with_new(); + session.execute("design.headerFooter", &json!({"date": true, "dateText": "", "dateFormat": "datetime4", "all": true})).unwrap(); + // A file whose document-level format is unset, as an imported deck's is. + Arc::make_mut(&mut session.doc_mut().unwrap().doc).header_footer.date_format = String::new(); + let date_fields = |app: &SlideApp| -> Vec { + app.session + .doc() + .unwrap() + .doc + .slides + .iter() + .flat_map(|sl| sl.shapes.iter()) + .filter(|sh| sh.ph_type() == Some(deckcraft_model::PhType::Date)) + .flat_map(|sh| sh.text.iter()) + .flat_map(|t| t.paragraphs.iter()) + .flat_map(|p| p.runs.iter()) + .filter_map(|r| match &r.kind { + deckcraft_model::text::RunKind::Field { field } => Some(field.clone()), + _ => None, + }) + .collect() + }; + let mut harness = egui_kittest::Harness::builder().with_size(vec2(1200.0, 900.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(session, crate::Services::default()), + ); + harness.run_steps(2); + assert_eq!(date_fields(harness.state()), vec!["datetime4".to_string()]); + harness.state_mut().run("app.dialog", json!({"id": "headerFooter"})).unwrap(); + harness.run_steps(2); + harness.get_by_label("Apply").click_accesskit(); + harness.run_steps(3); + assert_eq!(date_fields(harness.state()), vec!["datetime4".to_string()], "the dialog left the field's format alone"); + } + /// The Export… dialog (Animated GIF) shows the GIF options and exports through `file.export` (#171). #[test] fn export_dialog_writes_an_animated_gif() { From 3a37fb6b7edb32e959106512e2930fbc40b0ccbc Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 06:18:22 +0000 Subject: [PATCH 36/37] Integration: #565 rtf_to_outline never splits a multi-byte character after \u or \' escapes --- crates/format/src/rtf.rs | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/crates/format/src/rtf.rs b/crates/format/src/rtf.rs index 7e1676039..5894cd23a 100644 --- a/crates/format/src/rtf.rs +++ b/crates/format/src/rtf.rs @@ -106,6 +106,16 @@ const SKIP: &[&str] = &[ "fldinst", ]; +/// `i + n`, or one character on when that would land inside a multi-byte character (a fallback +/// run after `\uN` or `\'hh` may be followed by non-ASCII text). +fn advance(rtf: &str, i: usize, n: usize) -> usize { + if rtf.is_char_boundary(i + n) { + i + n + } else { + i + rtf.get(i..).and_then(|r| r.chars().next()).map_or(1, char::len_utf8) + } +} + /// Read an RTF outline into tab-indented outline text: a paragraph's outline level (or its left /// indent in 0.25" steps) becomes its number of tabs. pub fn rtf_to_outline(rtf: &str) -> String { @@ -198,11 +208,11 @@ pub fn rtf_to_outline(rtf: &str) -> String { let mut k = st.uc; while k > 0 && i < b.len() { if b[i] == b'\\' && b.get(i + 1) == Some(&b'\'') { - i += 4; + i = advance(rtf, i, 4); } else if b[i] == b'{' || b[i] == b'}' || b[i] == b'\\' { break; } else { - i += 1; + i = advance(rtf, i, 1); } k -= 1; } @@ -218,7 +228,7 @@ pub fn rtf_to_outline(rtf: &str) -> String { } } else if n == b'\'' { let hex = rtf.get(i + 1..i + 3).and_then(|h| u8::from_str_radix(h, 16).ok()); - i += 3; + i = advance(rtf, i, 3); if !st.skip && let Some(h) = hex { @@ -244,7 +254,9 @@ pub fn rtf_to_outline(rtf: &str) -> String { i += 1; } if !st.skip { - line.push_str(&rtf[s..i]); + if let Some(t) = rtf.get(s..i) { + line.push_str(t); + } } } } @@ -302,6 +314,16 @@ mod tests { #![allow(clippy::unwrap_used)] use super::*; + /// Non-ASCII text after a `\\uN` or `\\'hh` escape used to split a character and panic. + #[test] + fn non_ascii_text_after_escapes_does_not_panic() { + let out = rtf_to_outline(r"{\rtf1\pard \u233é x \'€y\par}"); + assert!(out.contains('x'), "{out:?}"); + for rtf in [r"{\rtf1 \u233é€ z\par}", r"{\rtf1 \'é\par}", r"{\rtf1 \'4", r"{\rtf1 \u233"] { + let _ = rtf_to_outline(rtf); + } + } + #[test] fn export_reads_back_as_the_same_outline() { let mut p = deckcraft_model::defaults::blank_presentation(deckcraft_model::defaults::WIDE, Default::default(), false); From 80e5506456f4fc38b0c100b4a2cd88364b72ce5d Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 06:25:28 +0000 Subject: [PATCH 37/37] Integration: cargo fmt --- crates/format/src/rtf.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/crates/format/src/rtf.rs b/crates/format/src/rtf.rs index 5894cd23a..ad5f99065 100644 --- a/crates/format/src/rtf.rs +++ b/crates/format/src/rtf.rs @@ -109,11 +109,7 @@ const SKIP: &[&str] = &[ /// `i + n`, or one character on when that would land inside a multi-byte character (a fallback /// run after `\uN` or `\'hh` may be followed by non-ASCII text). fn advance(rtf: &str, i: usize, n: usize) -> usize { - if rtf.is_char_boundary(i + n) { - i + n - } else { - i + rtf.get(i..).and_then(|r| r.chars().next()).map_or(1, char::len_utf8) - } + if rtf.is_char_boundary(i + n) { i + n } else { i + rtf.get(i..).and_then(|r| r.chars().next()).map_or(1, char::len_utf8) } } /// Read an RTF outline into tab-indented outline text: a paragraph's outline level (or its left