mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
# Conflicts: # crates/engine/src/tests.rs # crates/pptx/tests/roundtrip.rs
This commit is contained in:
@@ -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())
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user