diff --git a/ROADMAP.md b/ROADMAP.md
index d54c160f0..b4c490e7d 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) |
@@ -145,7 +146,10 @@ 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-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-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-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) |
| 2026-10-18 | Resizing with the mouse snaps the edge being dragged to the slide, guides and other shapes: a fixed edge that already lined up used to take the snap and leave the dragged edge free, and a fixed edge near a line shifted the dragged one (#961) |
@@ -158,6 +162,9 @@ app's JSON control channel (`deckcraft --control PORT`).
| 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-16 | Symbol-font characters: a run's U+F0xx symbol code (from ``) now draws from the installed Symbol face even when it has no private-use mapping — Apple's Symbol on macOS maps the glyphs to real Unicode, so the code is remapped to that character (the Adobe Symbol encoding) instead of falling to a look-alike. Bullets and metafile text take the same remap |
| 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 | Render: quick styles render on empty placeholders — the fill gate now checks for style and line overrides too (#832) |
+| 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 |
@@ -166,6 +173,9 @@ 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 | 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 | 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. |
@@ -265,6 +275,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| 2026-10-22 | trivial | Progress log: Animation Pane ▸ Play From. Scores unchanged |
| 2026-10-23 | trivial | Progress log: scroll bars on the zoomed slide pane (#1157). Scores and milestone names unchanged |
| 2026-10-20 | trivial | Explicit empty chart number-format sections hide their values; blank whole-code General fallback is retained. Synthetic formatter regressions; chart-element scores and readiness estimates unchanged |
+| 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-16 | minor | Symbol-font U+F0xx codes remap to the installed Symbol face's real Unicode (Apple Symbol on macOS) |
| 2026-10-15 | trivial | Progress log: chart package parts survive a native save. Scores and milestone names unchanged |
@@ -276,6 +287,8 @@ app's JSON control channel (`deckcraft --control PORT`).
| 2026-10-16 | trivial | Progress log: responsive mouse-wheel navigation on the fitted canvas |
| 2026-10-21 | trivial | Progress log: theme `spDef` anchor no longer applied to a file's own shapes |
| 2026-10-21 | trivial | Progress log: 3-D sizes in scaled groups stay in points |
+| 2026-10-22 | trivial | Progress log: chart series pattern and no fills |
+| 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/arrange.rs b/crates/engine/src/cmd/arrange.rs
index a043d9875..a679f3e96 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));
@@ -322,9 +341,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/cmd/design.rs b/crates/engine/src/cmd/design.rs
index 008e013a5..430256dee 100644
--- a/crates/engine/src/cmd/design.rs
+++ b/crates/engine/src/cmd/design.rs
@@ -21,7 +21,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
),
@@ -161,6 +161,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| {
@@ -180,6 +187,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) {
@@ -854,6 +913,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;
}
@@ -869,6 +936,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 {
@@ -913,7 +981,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()
@@ -934,9 +1006,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);
}
}
_ => {}
@@ -948,15 +1020,33 @@ fn header_footer(s: &mut Session, p: &Value) -> Result {
ok()
}
+/// 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)
+}
+
/// 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) {
+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),
};
@@ -1955,8 +2045,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};
@@ -1982,6 +2074,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()
}
@@ -2132,3 +2326,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/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs
index 83e730d9e..c9526c3c6 100644
--- a/crates/engine/src/cmd/file.rs
+++ b/crates/engine/src/cmd/file.rs
@@ -37,7 +37,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?, transparentBackground?: bool (png: leave the slide background out), 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?, transparentBackground?: bool (png: leave the slide background out), 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",
@@ -167,8 +167,10 @@ fn open_other(name: &str, bytes: &[u8]) -> Result<(Presentation, Vec)> {
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);
@@ -219,6 +221,12 @@ fn probe_clips(p: &mut Presentation) {
}
}
+/// 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);
@@ -334,6 +342,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())),
@@ -409,7 +418,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
@@ -442,6 +451,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"
}
@@ -622,7 +633,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/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/cmd/ideas.rs b/crates/engine/src/cmd/ideas.rs
index ac760d0fd..44fa42c9e 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,9 +58,40 @@ fn roles(shapes: &[Shape]) -> Roles {
r
}
-/// The ideas for slide `index` (none when it has nothing to arrange).
+/// 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(ctx: Option<&Ctx>, s: &Shape) -> bool {
+ match s.kind {
+ ShapeKind::Picture { .. } => true,
+ 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 || !shows(ctx, s))
+ }
+ _ => 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![] };
+ 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);
if r.picture.is_some() {
PICTURE_IDEAS.to_vec()
@@ -249,6 +281,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()
@@ -414,6 +447,75 @@ mod tests {
assert_eq!(shape(&s, 500).fill, Some(band_fill()));
}
+ #[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 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 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);
diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs
index 4f28ddef1..d860a3bdd 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 PowerPoint's default: the theme's text colour at 65 % plus 35 %, 595959 on a light theme), id?}",
has_selection,
chart_options
),
@@ -1804,7 +1804,19 @@ 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 PowerPoint's
+ // default (tx1 at lumMod 65 % / lumOff 35 %).
+ 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();
+ }
apply_bin_options(c, bin_count, bin_size);
apply_subtotals_and_bars(c, p, subtotals.as_deref());
if bin_count.is_some() || bin_size.is_some() || subtotals.is_some() {
@@ -1818,6 +1830,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/engine/src/cmd/points.rs b/crates/engine/src/cmd/points.rs
index 3af2c65f4..7354dff49 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/cmd/reuse.rs b/crates/engine/src/cmd/reuse.rs
index 1e6c09acf..94f1e1b0e 100644
--- a/crates/engine/src/cmd/reuse.rs
+++ b/crates/engine/src/cmd/reuse.rs
@@ -10,22 +10,41 @@ 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::*;
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).
@@ -114,9 +133,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 +190,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 +199,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 +234,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 +270,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);
}
@@ -397,4 +430,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/engine/src/cmd/slide.rs b/crates/engine/src/cmd/slide.rs
index 8ab3b3c31..8002aac9f 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/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 35b0ab396..3fbdfffa4 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();
@@ -338,6 +368,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();
@@ -1537,6 +1580,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]
@@ -2128,6 +2189,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]
@@ -4403,3 +4564,53 @@ 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);
+}
+
+/// 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)));
+}
+
+#[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));
+}
diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs
index aa72b1280..aa08648e3 100644
--- a/crates/engine/src/tools.rs
+++ b/crates/engine/src/tools.rs
@@ -266,6 +266,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.
@@ -1243,6 +1246,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;
@@ -1250,6 +1258,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/format/src/lib.rs b/crates/format/src/lib.rs
index 73712d282..cea34a6a6 100644
--- a/crates/format/src/lib.rs
+++ b/crates/format/src/lib.rs
@@ -6,6 +6,7 @@
mod chart_parts;
mod finite;
+pub mod rtf;
#[cfg(test)]
mod chart_parts_tests;
@@ -661,6 +662,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');
}
@@ -1100,3 +1102,33 @@ mod tests {
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");
+ 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();
+ 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), 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:?}");
+}
diff --git a/crates/format/src/rtf.rs b/crates/format/src/rtf.rs
new file mode 100644
index 000000000..ad5f99065
--- /dev/null
+++ b/crates/format/src/rtf.rs
@@ -0,0 +1,339 @@
+//! 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",
+];
+
+/// `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 {
+ #[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 = advance(rtf, i, 4);
+ } else if b[i] == b'{' || b[i] == b'}' || b[i] == b'\\' {
+ break;
+ } else {
+ i = advance(rtf, 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 = advance(rtf, 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 {
+ if let Some(t) = rtf.get(s..i) {
+ line.push_str(t);
+ }
+ }
+ }
+ }
+ }
+ 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::*;
+
+ /// 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);
+ 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/model/src/chart.rs b/crates/model/src/chart.rs
index 98214f887..7fcdd9c9f 100644
--- a/crates/model/src/chart.rs
+++ b/crates/model/src/chart.rs
@@ -1,1499 +1,1545 @@
-//! Charts: an embedded data grid plus type and element options.
-
-use serde::{Deserialize, Serialize};
-
-use crate::style::{ColorRef, Fill, Line};
-
-#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
-#[serde(rename_all = "camelCase")]
-pub enum ChartType {
- #[default]
- Column,
- StackedColumn,
- PercentColumn,
- Bar,
- StackedBar,
- PercentBar,
- Line,
- LineMarkers,
- StackedLine,
- Area,
- StackedArea,
- Pie,
- Doughnut,
- Scatter,
- Bubble,
- Radar,
- FilledRadar,
- Stock,
- Surface,
- Histogram,
- Pareto,
- BoxWhisker,
- Waterfall,
- Funnel,
- Treemap,
- Sunburst,
- /// A filled (choropleth) map of regions coloured by value, kept and drawn from its data; it
- /// is not offered in the chart menus (PowerPoint needs an online map service to make one).
- RegionMap,
- Combo,
-}
-
-impl ChartType {
- /// The Office 2016 (`chartEx`) types, offered after [`ChartType::MAIN`] in chart menus.
- pub const OFFICE_2016: [ChartType; 7] = [
- ChartType::Waterfall,
- ChartType::Funnel,
- ChartType::Histogram,
- ChartType::Pareto,
- ChartType::BoxWhisker,
- ChartType::Treemap,
- ChartType::Sunburst,
- ];
-
- /// Whether the type is saved as a `chartEx` part (with a plain chart as the fallback).
- pub fn is_chartex(self) -> bool {
- Self::OFFICE_2016.contains(&self) || self == ChartType::RegionMap
- }
-
- pub const MAIN: [ChartType; 12] = [
- ChartType::Column,
- ChartType::Bar,
- ChartType::Line,
- ChartType::Area,
- ChartType::Pie,
- ChartType::Doughnut,
- ChartType::Scatter,
- ChartType::Radar,
- ChartType::Waterfall,
- ChartType::Funnel,
- ChartType::Histogram,
- ChartType::Treemap,
- ];
- pub fn label(self) -> &'static str {
- match self {
- ChartType::Column => "Clustered Column",
- ChartType::StackedColumn => "Stacked Column",
- ChartType::PercentColumn => "100% Stacked Column",
- ChartType::Bar => "Clustered Bar",
- ChartType::StackedBar => "Stacked Bar",
- ChartType::PercentBar => "100% Stacked Bar",
- ChartType::Line => "Line",
- ChartType::LineMarkers => "Line with Markers",
- ChartType::StackedLine => "Stacked Line",
- ChartType::Area => "Area",
- ChartType::StackedArea => "Stacked Area",
- ChartType::Pie => "Pie",
- ChartType::Doughnut => "Doughnut",
- ChartType::Scatter => "Scatter",
- ChartType::Bubble => "Bubble",
- ChartType::Radar => "Radar",
- ChartType::FilledRadar => "Filled Radar",
- ChartType::Stock => "Stock",
- ChartType::Surface => "Surface",
- ChartType::Histogram => "Histogram",
- ChartType::Pareto => "Pareto",
- ChartType::BoxWhisker => "Box and Whisker",
- ChartType::Waterfall => "Waterfall",
- ChartType::Funnel => "Funnel",
- ChartType::Treemap => "Treemap",
- ChartType::Sunburst => "Sunburst",
- ChartType::RegionMap => "Map",
- ChartType::Combo => "Combo",
- }
- }
- pub fn is_bar_like(self) -> bool {
- matches!(
- self,
- ChartType::Column
- | ChartType::StackedColumn
- | ChartType::PercentColumn
- | ChartType::Bar
- | ChartType::StackedBar
- | ChartType::PercentBar
- | ChartType::Histogram
- | ChartType::Pareto
- | ChartType::Waterfall
- )
- }
- pub fn horizontal(self) -> bool {
- matches!(self, ChartType::Bar | ChartType::StackedBar | ChartType::PercentBar | ChartType::Funnel)
- }
- pub fn stacked(self) -> bool {
- matches!(
- self,
- ChartType::StackedColumn
- | ChartType::StackedBar
- | ChartType::StackedLine
- | ChartType::StackedArea
- | ChartType::PercentColumn
- | ChartType::PercentBar
- )
- }
- pub fn percent(self) -> bool {
- matches!(self, ChartType::PercentColumn | ChartType::PercentBar)
- }
-}
-
-/// `c:trendlineType`: the six trendlines DrawingML charts store.
-#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
-#[serde(rename_all = "camelCase")]
-pub enum TrendlineType {
- Exp,
- #[default]
- Linear,
- Log,
- MovingAvg,
- Poly,
- Power,
-}
-
-impl TrendlineType {
- pub fn xml(self) -> &'static str {
- match self {
- TrendlineType::Exp => "exp",
- TrendlineType::Linear => "linear",
- TrendlineType::Log => "log",
- TrendlineType::MovingAvg => "movingAvg",
- TrendlineType::Poly => "poly",
- TrendlineType::Power => "power",
- }
- }
- pub fn from_xml(s: &str) -> Option {
- Some(match s {
- "exp" => TrendlineType::Exp,
- "linear" => TrendlineType::Linear,
- "log" => TrendlineType::Log,
- "movingAvg" => TrendlineType::MovingAvg,
- "poly" => TrendlineType::Poly,
- "power" => TrendlineType::Power,
- _ => return None,
- })
- }
-}
-
-/// One `c:trendline` on a series. Polynomials use `order` (2–6); moving averages use `period`.
-#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
-#[serde(default, rename_all = "camelCase")]
-pub struct Trendline {
- pub kind: TrendlineType,
- /// `c:name`, when the file sets one.
- #[serde(skip_serializing_if = "Option::is_none")]
- pub name: Option,
- /// Polynomial order (`c:order`).
- #[serde(skip_serializing_if = "Option::is_none")]
- pub order: Option,
- /// Moving-average period (`c:period`).
- #[serde(skip_serializing_if = "Option::is_none")]
- pub period: Option,
- /// Forecast forward and backward, in category units (`c:forward`, `c:backward`).
- #[serde(skip_serializing_if = "Option::is_none")]
- pub forward: Option,
- #[serde(skip_serializing_if = "Option::is_none")]
- pub backward: Option,
- /// Cross the value axis at this value (`c:intercept`).
- #[serde(skip_serializing_if = "Option::is_none")]
- pub intercept: Option,
- /// Show the equation (`c:dispEq`).
- pub disp_eq: bool,
- /// Show R² (`c:dispRSqr`).
- pub disp_r_sqr: bool,
-}
-
-impl Default for Trendline {
- fn default() -> Self {
- Trendline {
- kind: TrendlineType::Linear,
- name: None,
- order: None,
- period: None,
- forward: None,
- backward: None,
- intercept: None,
- disp_eq: false,
- disp_r_sqr: false,
- }
- }
-}
-
-#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
-#[serde(default, rename_all = "camelCase")]
-pub struct Series {
- pub name: String,
- pub values: Vec |