From 607ffc55919865236cdeab71d51a00024b828049 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 21:55:21 +0300 Subject: [PATCH] Text: set text transparency from Text Fill (#32) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There was no way to make text see-through: the only Transparency slider was the shape fill's. The new format.textTransparency command keeps the text's colour and sets its alpha (replacing any it had, and dropping it at 0). A run without its own colour takes the colour it shows now, so inherited placeholder or theme colours stay as they are. Gradient text gets the alpha on every stop. The Format Shape pane's Text Options page gains a Text Fill section (colour and Transparency), and the Shape Format › Text Fill dropdown has the same slider. Co-authored-by: Cursor --- crates/engine/src/cmd/format.rs | 46 ++++++++++++++++++++++++++++++++- crates/engine/src/tests.rs | 33 +++++++++++++++++++++++ crates/pptx/tests/roundtrip.rs | 16 ++++++++++++ crates/ui-egui/src/panes.rs | 34 ++++++++++++++++++++++++ crates/ui-egui/src/ribbon.rs | 4 +++ 5 files changed, 132 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index c68d1242..5721c4b4 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}; @@ -48,6 +48,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", @@ -293,6 +302,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 e83f72ef..800d0f8a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -835,3 +835,36 @@ fn slide_ranges_parse_like_powerpoints_print_box() { let opts = cmd::file::pdf_options(&json!({"slides": [0, 7, 1]}), 2).unwrap(); assert_eq!(opts.slides, Some(vec![0, 1]), "indices past the end are dropped"); } + +/// 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 db6d0beb..20f12696 100644 --- a/crates/pptx/tests/roundtrip.rs +++ b/crates/pptx/tests/roundtrip.rs @@ -991,3 +991,19 @@ fn transition_defaults_read_like_powerpoint() { assert_eq!((t.kind.as_str(), t.option.as_str(), t.duration_ms), (*kind, *option, 2000), "{el}"); } } + +/// 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 2b3ada3a..3c1d83a2 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -182,6 +182,13 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { ui.data_mut(|d| d.insert_temp(id, 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"); @@ -635,3 +642,30 @@ fn designer(app: &mut SlideApp, ui: &mut Ui) { ui.add_space(8.0); } } + +/// 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 428eff0b..8a5bbf50 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1943,6 +1943,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);