Format Shape: picture or texture fill options

shape.fill gains pictureProps for a picture fill: tile on/off, tile
offset X/Y (pt), scale X/Y, alignment and mirror type, stretch offsets
(left/top/right/bottom), and rotate with shape. `transparency` now also
sets a picture fill's opacity. Values are clamped and unknown keywords or
non-finite numbers ignored. The Fill page of the Format Shape pane gets a
"Picture or texture fill" option with a picture source button and these
controls. The model, renderer and PPTX reader/writer already carry the
fields; a new engine test checks they survive a PPTX save and reopen.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nikita Rakitin
2026-10-13 22:09:33 +03:00
co-authored by Cursor
parent 457406a7a5
commit ad24f545e2
4 changed files with 203 additions and 8 deletions
+61 -4
View File
@@ -2,7 +2,7 @@
//! names and alt text.
use deckcraft_color::{ColorTransform, Rgba, SchemeSlot};
use deckcraft_model::style::{Dash, Glow, Gradient, GradientShape, GradientStop, LineEnd, PatternFill, PictureFill, Reflection, Shadow};
use deckcraft_model::style::{Dash, Glow, Gradient, GradientShape, GradientStop, LineEnd, PatternFill, PictureFill, PictureMode, Reflection, Shadow};
use deckcraft_model::{ColorRef, Effects, Fill, Geom, Line, ShapeStyle};
use serde_json::{Value, json};
@@ -42,7 +42,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Shape Fill",
["Shape Format", "Shape Styles"],
None,
"{color? | none?: true | gradient?: {stops: [[pos, color]], angle?, kind?: linear|radial} | picture?: base64 | pattern?: {preset, fg, bg} | transparency?: 0..1 | reset?: true, ids?}",
"{color? | none?: true | gradient?: {stops: [[pos, color]], angle?, kind?: linear|radial} | picture?: base64 | pattern?: {preset, fg, bg} | transparency?: 0..1 | reset?: true, ids?}; pictureProps?: {tile?: bool, offsetX?, offsetY?: pt, scaleX?, scaleY?: 0..10, align?: tl|t|tr|l|ctr|r|bl|b|br, mirror?: none|x|y|xy, stretch?: [l, t, r, b] fractions, rotateWithShape?: bool} adjusts a picture fill",
has_selection,
fill
),
@@ -290,8 +290,9 @@ fn fill(s: &mut Session, p: &Value) -> Result<Value> {
None
};
let transparency = f64_param(p, "transparency");
if new_fill.is_none() && transparency.is_none() {
return Err(bad("shape.fill", "give `color`, `none`, `gradient`, `pattern`, `picture`, `transparency` or `reset`"));
let pic_props = p.get("pictureProps").filter(|v| v.is_object());
if new_fill.is_none() && transparency.is_none() && pic_props.is_none() {
return Err(bad("shape.fill", "give `color`, `none`, `gradient`, `pattern`, `picture`, `pictureProps`, `transparency` or `reset`"));
}
// Transparency on an inherited (style) fill needs the effective colour.
let st = s.doc()?;
@@ -319,6 +320,14 @@ fn fill(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(f) = &new_fill {
sh.fill.clone_from(f);
}
if let (Some(pp), Some(Fill::Picture(pf))) = (pic_props, sh.fill.as_mut()) {
picture_props(pf, pp);
}
if let (Some(t), Some(Fill::Picture(pf))) = (transparency, sh.fill.as_mut()) {
let a = 1.0 - t.clamp(0.0, 1.0);
pf.alpha = (a < 1.0).then_some(a);
return Ok(());
}
if let Some(t) = transparency {
let base = sh.fill.clone().or_else(|| ctx_fill.iter().find(|(i, _)| *i == sh.id).and_then(|(_, f)| f.clone()));
if let Some(Fill::Solid { mut color }) = base {
@@ -333,6 +342,54 @@ fn fill(s: &mut Session, p: &Value) -> Result<Value> {
})
}
/// Applies Format Shape ▸ Picture or texture fill options to a picture fill.
fn picture_props(pf: &mut PictureFill, pp: &Value) {
let f = |k: &str| pp.get(k).and_then(Value::as_f64).filter(|v| v.is_finite());
if let Some(up) = pp.get("rotateWithShape").and_then(Value::as_bool) {
pf.upright = !up;
}
match pp.get("tile").and_then(Value::as_bool) {
Some(true) if !matches!(pf.mode, PictureMode::Tile { .. }) => {
pf.mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 1.0, sy: 1.0, flip: "none".into(), align: "tl".into() };
}
Some(false) if !matches!(pf.mode, PictureMode::Stretch { .. }) => pf.mode = PictureMode::default(),
_ => {}
}
match &mut pf.mode {
PictureMode::Tile { tx, ty, sx, sy, flip, align } => {
if let Some(v) = f("offsetX") {
*tx = v.clamp(-4000.0, 4000.0);
}
if let Some(v) = f("offsetY") {
*ty = v.clamp(-4000.0, 4000.0);
}
if let Some(v) = f("scaleX") {
*sx = v.clamp(0.01, 10.0);
}
if let Some(v) = f("scaleY") {
*sy = v.clamp(0.01, 10.0);
}
if let Some(m) = pp.get("mirror").and_then(Value::as_str).filter(|m| matches!(*m, "none" | "x" | "y" | "xy")) {
*flip = m.to_string();
}
if let Some(a) =
pp.get("align").and_then(Value::as_str).filter(|a| matches!(*a, "tl" | "t" | "tr" | "l" | "ctr" | "r" | "bl" | "b" | "br"))
{
*align = a.to_string();
}
}
PictureMode::Stretch { fill_rect } => {
if let Some(arr) = pp.get("stretch").and_then(Value::as_array) {
for (slot, v) in fill_rect.iter_mut().zip(arr) {
if let Some(v) = v.as_f64().filter(|v| v.is_finite()) {
*slot = v.clamp(-10.0, 0.99);
}
}
}
}
}
}
fn line_end(v: &str) -> Option<LineEnd> {
if v == "none" { None } else { Some(LineEnd { kind: v.to_string(), w: "med".into(), len: "med".into() }) }
}
+40
View File
@@ -835,3 +835,43 @@ 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");
}
#[test]
fn shape_picture_fill_options_tile_offset_and_round_trip() {
use deckcraft_model::style::PictureMode;
let mut img = image::RgbaImage::new(4, 4);
img.put_pixel(0, 0, image::Rgba([255, 0, 0, 255]));
let mut png = Vec::new();
image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
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();
let pf = |s: &Session| match s.doc().unwrap().selected_shapes()[0].fill.clone() {
Some(deckcraft_model::Fill::Picture(pf)) => pf,
other => panic!("{other:?}"),
};
// Options without a picture fill are a no-op, not an error.
s.execute("shape.fill", &json!({"pictureProps": {"tile": true}})).unwrap();
s.execute("shape.fill", &json!({"picture": cmd::base64_encode(&png)})).unwrap();
s.execute("shape.fill", &json!({"pictureProps": {"stretch": [0.1, 0.2, 5, "x"]}})).unwrap();
assert_eq!(pf(&s).mode, PictureMode::Stretch { fill_rect: [0.1, 0.2, 0.99, 0.0] });
s.execute("shape.fill", &json!({"pictureProps": {"tile": true, "offsetX": 12, "offsetY": -3, "scaleX": 0.5, "scaleY": 50, "align": "ctr", "mirror": "xy", "rotateWithShape": false}})).unwrap();
s.execute("shape.fill", &json!({"pictureProps": {"align": "bogus", "offsetX": f64::NAN}, "transparency": 0.4})).unwrap();
let want = PictureMode::Tile { tx: 12.0, ty: -3.0, sx: 0.5, sy: 10.0, flip: "xy".into(), align: "ctr".into() };
let got = pf(&s);
assert_eq!(got.mode, want);
assert!(got.upright);
assert!((got.alpha.unwrap() - 0.6).abs() < 1e-9);
// PPTX round trip keeps the options.
let bytes = cmd::file::save_bytes(&s.doc().unwrap().doc, "pptx").unwrap();
s.execute("file.openBytes", &json!({"name": "p.pptx", "data": cmd::base64_encode(&bytes)})).unwrap();
let doc = &s.doc().unwrap().doc;
let back = doc.slides.iter().flat_map(|sl| sl.shapes.iter()).find_map(|sh| match &sh.fill {
Some(deckcraft_model::Fill::Picture(pf)) => Some(pf.clone()),
_ => None,
});
let back = back.unwrap();
assert_eq!(back.mode, want);
assert!(back.upright);
assert!((back.alpha.unwrap() - 0.6).abs() < 1e-6);
}
+101 -3
View File
@@ -262,9 +262,14 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) {
Some(Fill::Pattern(_)) => "pattern",
_ => "auto",
};
for (l, k) in
[("No fill", "none"), ("Solid fill", "solid"), ("Gradient fill", "gradient"), ("Pattern fill", "pattern"), ("Automatic", "auto")]
{
for (l, k) in [
("No fill", "none"),
("Solid fill", "solid"),
("Gradient fill", "gradient"),
("Picture or texture fill", "picture"),
("Pattern fill", "pattern"),
("Automatic", "auto"),
] {
if ui.radio(kind == k, l).clicked() {
match k {
"none" => run(app, "shape.fill", json!({"none": true})),
@@ -275,6 +280,7 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) {
json!({"gradient": {"stops": [[0, "accent1"], [1, {"scheme": "accent1", "lumMod": 50000}]], "angle": 90}}),
),
"pattern" => run(app, "shape.fill", json!({"pattern": {"preset": "pct25", "fg": "accent1", "bg": "bg1"}})),
"picture" => pick_fill_picture(app),
_ => run(app, "shape.fill", json!({"reset": true})),
}
}
@@ -293,6 +299,9 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) {
let _ = app.run("shape.fill", json!({"transparency": v as f64 / 100.0}));
}
});
if let Some(Fill::Picture(pf)) = &sh.fill {
picture_fill_options(app, ui, sh.id.0, pf);
}
if let Some(Fill::Gradient(g)) = &sh.fill {
let sc = scheme(app);
if let Some(ng) = crate::fillui::gradient_editor(ui, "shape-grad", g, &sc) {
@@ -339,6 +348,95 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) {
}
}
/// Asks for a picture file and makes it the selected shapes' fill.
fn pick_fill_picture(app: &mut SlideApp) {
if let Some(pick) = app.services.pick_open.as_mut()
&& let Some(path) = pick("picture")
&& let Some(Ok(bytes)) = app.services.read.as_ref().map(|r| r(&path))
{
run(app, "shape.fill", json!({"picture": deckcraft_engine::cmd::base64_encode(&bytes)}));
}
}
/// A number field for one picture-fill option; runs `shape.fill` with `{pictureProps: {key: v × scale}}`
/// when it changes.
fn pic_drag(app: &mut SlideApp, ui: &mut Ui, label: &str, value: f64, suffix: &str, key: &str, scale: f64) {
ui.horizontal(|ui| {
ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label));
let mut v = value;
let r = ui.add(egui::DragValue::new(&mut v).suffix(suffix).max_decimals(1));
if r.drag_stopped() || r.lost_focus() || (r.changed() && !r.dragged()) {
run(app, "shape.fill", json!({"pictureProps": {key: v * scale}}));
}
});
}
/// Format Shape ▸ Fill ▸ Picture or texture fill: source, tiling, offsets, transparency.
fn picture_fill_options(app: &mut SlideApp, ui: &mut Ui, id: u32, pf: &deckcraft_model::style::PictureFill) {
use deckcraft_model::style::PictureMode;
ui.horizontal(|ui| {
ui.label("Picture source");
if ui.button("Insert…").clicked() {
pick_fill_picture(app);
}
});
let mut tile = matches!(pf.mode, PictureMode::Tile { .. });
if ui.checkbox(&mut tile, "Tile picture as texture").changed() {
run(app, "shape.fill", json!({"pictureProps": {"tile": tile}}));
}
match &pf.mode {
PictureMode::Tile { tx, ty, sx, sy, flip, align } => {
pic_drag(app, ui, "Offset X", *tx, " pt", "offsetX", 1.0);
pic_drag(app, ui, "Offset Y", *ty, " pt", "offsetY", 1.0);
pic_drag(app, ui, "Scale X", sx * 100.0, "%", "scaleX", 0.01);
pic_drag(app, ui, "Scale Y", sy * 100.0, "%", "scaleY", 0.01);
let aligns = [
("tl", "Top left"),
("t", "Top"),
("tr", "Top right"),
("l", "Left"),
("ctr", "Center"),
("r", "Right"),
("bl", "Bottom left"),
("b", "Bottom"),
("br", "Bottom right"),
];
let mirrors = [("none", "None"), ("x", "Horizontal"), ("y", "Vertical"), ("xy", "Both")];
for (label, key, opts, cur) in [("Alignment", "align", &aligns[..], align), ("Mirror type", "mirror", &mirrors[..], flip)] {
ui.horizontal(|ui| {
ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label));
let shown = opts.iter().find(|(k, _)| k == cur).map_or(opts[0].1, |(_, l)| *l);
egui::ComboBox::from_id_salt(("picfill", id, key)).selected_text(shown).show_ui(ui, |ui| {
for (k, l) in opts {
if ui.selectable_label(*k == cur.as_str(), *l).clicked() {
run(app, "shape.fill", json!({"pictureProps": {key: k}}));
}
}
});
});
}
}
PictureMode::Stretch { fill_rect } => {
for (i, label) in ["Offset left", "Offset top", "Offset right", "Offset bottom"].into_iter().enumerate() {
ui.horizontal(|ui| {
ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label));
let mut v = fill_rect[i] * 100.0;
let r = ui.add(egui::DragValue::new(&mut v).suffix("%").max_decimals(0));
if r.drag_stopped() || r.lost_focus() || (r.changed() && !r.dragged()) {
let mut rect = *fill_rect;
rect[i] = v / 100.0;
run(app, "shape.fill", json!({"pictureProps": {"stretch": rect}}));
}
});
}
}
}
let mut rot = !pf.upright;
if ui.checkbox(&mut rot, "Rotate with shape").changed() {
run(app, "shape.fill", json!({"pictureProps": {"rotateWithShape": rot}}));
}
}
fn background(app: &mut SlideApp, ui: &mut Ui) {
let sc = scheme(app);
let bg = app.session.active().and_then(|d| d.current_slide().and_then(|s| s.background.clone()));
+1 -1
View File
@@ -58,7 +58,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`).
| Pane / dialog | PowerPoint | DeckCraft |
|---|---|---|
| Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill, gradient editor, line, some effects, size; no 3-D, thin picture/texture options |
| Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill, gradient editor, picture/texture fill (source, tile, offsets, scale, alignment, mirror, transparency, rotate with shape), line, some effects, size; no 3-D |
| Format Background pane | yes | yes |
| Selection Pane | front-most first, groups fold, drag to restack, Bring Forward / Send Backward arrows, Show All / Hide All, eye, padlock (Lock), rename in place | same: drag restacks among siblings (a child stays in its group), Ctrl+click toggles, Shift+click selects a run, arrows / Home / End / F2 / Esc in the list, right-click menu |
| Animation Pane | list + advanced timeline | list, reorder, timing; no timeline |