Merge PR #504 from NikitkaRa: Text: set text transparency from Text Fill (#32)

# Conflicts:
#	crates/engine/src/tests.rs
#	crates/pptx/tests/roundtrip.rs
This commit is contained in:
Brandon Thomas
2026-10-14 13:08:47 -04:00
5 changed files with 132 additions and 1 deletions
+45 -1
View File
@@ -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<CommandSpec> {
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<Value> {
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<Value> {
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<Value> {
let c = color_param(p, "color");
format_run(s, &move |r| r.highlight = c.clone())
+33
View File
@@ -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());
}
+16
View File
@@ -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:?}");
}
+34
View File
@@ -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::<f32>(id));
} else if r.dragged() {
ui.data_mut(|d| d.insert_temp(id, v));
}
});
}
+4
View File
@@ -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);