diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index 86870206..17cdc1b1 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, TextDir}; -use deckcraft_model::{Effects, Fill, Line, Shape}; +use deckcraft_model::{ColorRef, ColorTransform, Effects, Fill, Line, SchemeSlot, Shape}; use serde_json::{Value, json}; use super::text::{format_body, format_para, format_run}; @@ -50,6 +50,15 @@ pub fn specs() -> Vec { has_text_or_shapes, color ), + cmd!( + "format.textTransparency", + "Text Transparency", + ["Shape Format", "WordArt Styles"], + None, + "{value: 0..1} — 0 opaque, 1 invisible; keeps the text's colour (inherited colours included)", + has_text_or_shapes, + text_transparency + ), cmd!("format.highlight", "Text Highlight Color", ["Home", "Font"], None, "{color? (absent = none)}", has_text_or_shapes, highlight), cmd!( "format.case", @@ -295,6 +304,41 @@ fn color(s: &mut Session, p: &Value) -> Result { let c = color_param(p, "color").ok_or_else(|| bad("format.color", "missing or invalid `color`"))?; 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. +fn with_transparency(c: &mut ColorRef, transparency: f64) { + c.mods.retain(|m| !matches!(m, ColorTransform::Alpha(_))); + if transparency > 0.0 { + c.mods.push(ColorTransform::Alpha(((1.0 - transparency) * 100_000.0).round() as i32)); + } +} + +/// Text Fill › Transparency: the text keeps its colour and gets an alpha. A run without its own +/// fill takes the colour it shows now (from the placeholder, master or theme) so only its +/// opacity changes. +fn text_transparency(s: &mut Session, p: &Value) -> Result { + let v = f64_param(p, "value").ok_or_else(|| bad("format.textTransparency", "missing `value` (0–1)"))?; + if !v.is_finite() { + return Err(bad("format.textTransparency", "`value` must be a number from 0 to 1")); + } + let v = v.clamp(0.0, 1.0); + let shown = match current_run(s).fill { + Some(f @ (Fill::Solid { .. } | Fill::Gradient(_))) => f, + _ => Fill::solid(ColorRef::scheme(SchemeSlot::Tx1)), + }; + format_run(s, &move |r| { + let mut f = match &r.fill { + Some(f @ (Fill::Solid { .. } | Fill::Gradient(_))) => f.clone(), + Some(Fill::None) => return, + _ => shown.clone(), + }; + match &mut f { + Fill::Solid { color } => with_transparency(color, v), + Fill::Gradient(g) => g.stops.iter_mut().for_each(|st| with_transparency(&mut st.color, v)), + _ => {} + } + r.fill = Some(f); + }) +} fn highlight(s: &mut Session, p: &Value) -> Result { let c = color_param(p, "color"); format_run(s, &move |r| r.highlight = c.clone()) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 059becd1..af0cbe5d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1265,3 +1265,36 @@ fn shape_effects_props_adjust_shadow_glow_and_reflection() { assert!((r.start_alpha - 0.7).abs() < 1e-9); assert_eq!((r.end_pos, r.dist, r.blur), (1.0, 5.0, 0.5)); } + +/// Text Fill › Transparency (#32): the text keeps its colour, own or inherited, and gets an alpha; +/// 0 makes it opaque again, and it is one undo step. +#[test] +fn text_transparency_keeps_the_colour_and_sets_alpha() { + use deckcraft_model::{ColorTransform, Fill}; + let mut s = session(); + let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100], "text": "Hi"})).unwrap(); + let id = deckcraft_model::ShapeId(r["id"].as_u64().unwrap() as u32); + let fill = |s: &Session| { + let st = s.doc().unwrap(); + st.shape(id).unwrap().text.as_ref().unwrap().paragraphs[0].runs[0].props.fill.clone() + }; + // Inherited colour: the run gets the colour it showed, now 25% transparent. + s.execute("format.textTransparency", &json!({"value": 0.25})).unwrap(); + let Some(Fill::Solid { color }) = fill(&s) else { panic!("solid fill expected: {:?}", fill(&s)) }; + assert!((color.alpha() - 0.75).abs() < 1e-9, "{color:?}"); + // Its own colour stays; the alpha is replaced, not stacked. + s.execute("format.color", &json!({"color": "#C00000"})).unwrap(); + s.execute("format.textTransparency", &json!({"value": 0.6})).unwrap(); + s.execute("format.textTransparency", &json!({"value": 0.5})).unwrap(); + let Some(Fill::Solid { color }) = fill(&s) else { panic!() }; + assert_eq!(color.base, deckcraft_model::ColorBase::Rgb { rgb: deckcraft_model::Rgba::rgb(0xC0, 0, 0) }); + assert_eq!(color.mods.iter().filter(|m| matches!(m, ColorTransform::Alpha(_))).count(), 1); + assert!((color.alpha() - 0.5).abs() < 1e-9); + s.execute("format.textTransparency", &json!({"value": 0})).unwrap(); + let Some(Fill::Solid { color }) = fill(&s) else { panic!() }; + assert!(color.mods.is_empty(), "opaque drops the alpha: {color:?}"); + s.execute("edit.undo", &json!({})).unwrap(); + let Some(Fill::Solid { color }) = fill(&s) else { panic!() }; + assert!((color.alpha() - 0.5).abs() < 1e-9); + assert!(s.execute("format.textTransparency", &json!({})).is_err()); +} diff --git a/crates/pptx/tests/roundtrip.rs b/crates/pptx/tests/roundtrip.rs index fdcd2406..bce9a809 100644 --- a/crates/pptx/tests/roundtrip.rs +++ b/crates/pptx/tests/roundtrip.rs @@ -1198,3 +1198,19 @@ fn an_embedded_font_list_already_in_the_package_is_read() { assert!(f.panose.is_none() && f.regular.is_none() && f.italic.is_none() && f.bold_italic.is_none()); assert_eq!(p.media(f.bold.expect("bold")).expect("media").data.as_slice(), font); } + +/// Text transparency (#32) is `a:alpha` on the run's colour, and survives a save. +#[test] +fn text_transparency_round_trips() { + use deckcraft_engine::Session; + let mut s = Session::with_new(); + s.execute("shape.insert", &serde_json::json!({"preset": "rect", "rect": [100, 100, 200, 100], "text": "Hi"})).unwrap(); + s.execute("format.color", &serde_json::json!({"color": "#336699"})).unwrap(); + s.execute("format.textTransparency", &serde_json::json!({"value": 0.4})).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + let bytes = deckcraft_pptx::export(&doc).unwrap(); + let back = deckcraft_pptx::import(&bytes).unwrap(); + let sh = back.slides[0].shapes.iter().find(|sh| sh.text.as_ref().is_some_and(|t| t.text().contains("Hi"))).unwrap(); + let Some(deckcraft_model::Fill::Solid { color }) = &sh.text.as_ref().unwrap().paragraphs[0].runs[0].props.fill else { panic!() }; + assert!((color.alpha() - 0.6).abs() < 1e-9, "{color:?}"); +} diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index fe4fa427..e877b34f 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -244,6 +244,13 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { ui.data_mut(|d| d.insert_temp(id, (sh.descr.clone(), alt))); } "text" => { + ui.label(egui::RichText::new("Text Fill").font(theme::bold(13.0))); + color_button(app, ui, "Color", None, |c| match c { + Some(c) => ("format.color".into(), json!({"color": cref_param(&c)})), + None => ("format.color".into(), json!({"color": "tx1"})), + }); + text_transparency_slider(app, ui, &sh); + ui.add_space(6.0); ui.label(egui::RichText::new("Text Box").font(theme::bold(13.0))); ui.horizontal(|ui| { ui.label("Vertical alignment"); @@ -837,3 +844,30 @@ fn designer(app: &mut SlideApp, ui: &mut Ui) { } } } + +/// The transparency (0–1) of a shape's first text run as stored on it. +pub(crate) fn text_transparency_of(sh: &deckcraft_model::Shape) -> f64 { + let run = sh.text.as_ref().and_then(|t| t.paragraphs.iter().flat_map(|p| p.runs.iter()).next()); + match run.and_then(|r| r.props.fill.as_ref()) { + Some(Fill::Solid { color }) => 1.0 - color.alpha(), + Some(Fill::Gradient(g)) => g.stops.first().map_or(0.0, |s| 1.0 - s.color.alpha()), + _ => 0.0, + } +} + +/// Text Fill › Transparency, as in PowerPoint's Format Shape › Text Options (#32). +pub(crate) fn text_transparency_slider(app: &mut SlideApp, ui: &mut Ui, sh: &deckcraft_model::Shape) { + ui.horizontal(|ui| { + ui.label("Transparency"); + let id = ui.id().with(("text-transparency", sh.id)); + let stored = (text_transparency_of(sh) * 100.0) as f32; + let mut v: f32 = ui.data_mut(|d| d.get_temp(id).unwrap_or(stored)); + let r = ui.add(egui::Slider::new(&mut v, 0.0..=100.0).suffix("%")); + if r.drag_stopped() || r.lost_focus() || (r.changed() && !r.dragged()) { + let _ = app.run("format.textTransparency", json!({"value": v as f64 / 100.0})); + ui.data_mut(|d| d.remove::(id)); + } else if r.dragged() { + ui.data_mut(|d| d.insert_temp(id, v)); + } + }); +} diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 7c99f1be..699dea55 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1954,6 +1954,10 @@ fn shape_format(app: &mut SlideApp, ui: &mut Ui) { if let Some(Some(c)) = widgets::color_grid(ui, &sc, None) { run(app, "format.color", json!({"color": cref_param(&c)})); } + if let Some(sh) = app.session.active().and_then(|d| d.selected_shapes().first().map(|s| (*s).clone())) { + ui.separator(); + crate::panes::text_transparency_slider(app, ui, &sh); + } }); } let (_, a) = small_button_ex(ui, Icon::TextOutline, "Text Outline", sel, false, true);