From 9af5a7d17b8f685deeebe462c75dbaf1aab67756 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 22:35:55 +0300 Subject: [PATCH] =?UTF-8?q?Pictures:=20Artistic=20Effects=20=E2=80=94=20ap?= =?UTF-8?q?ply,=20change,=20remove=20and=20keep=20in=20PPTX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New picture.artisticEffect applies one of 7 effects (blur, pencil grayscale, line drawing, film grain, cutout, glow edges, photocopy) to a picture's pixels, as PowerPoint does: the processed PNG becomes the picture and the original is kept on PictureFill::artistic, so another effect starts from the original and `none` (or Reset Picture) restores it. Change Picture drops the effect. PPTX: written and read as PowerPoint does, the blip being the processed picture with the original and the effect name in a:extLst/a14:imgProps/a14:imgLayer, so a deck from PowerPoint keeps its effect, original and all, when saved again. Picture Format gets an Artistic Effects menu. Co-authored-by: Cursor --- crates/engine/src/cmd/artistic.rs | 199 ++++++++++++++++++++++++++++++ crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/shape.rs | 4 + crates/engine/src/tests.rs | 54 ++++++++ crates/model/src/style.rs | 12 ++ crates/pptx/src/read/dml.rs | 11 ++ crates/pptx/src/write/dml.rs | 22 +++- crates/pptx/tests/roundtrip.rs | 1 + crates/ui-egui/src/ribbon.rs | 11 ++ docs/gaps.md | 3 +- docs/parity-checklist.md | 2 +- 11 files changed, 318 insertions(+), 3 deletions(-) create mode 100644 crates/engine/src/cmd/artistic.rs diff --git a/crates/engine/src/cmd/artistic.rs b/crates/engine/src/cmd/artistic.rs new file mode 100644 index 00000000..8bdba6d1 --- /dev/null +++ b/crates/engine/src/cmd/artistic.rs @@ -0,0 +1,199 @@ +//! Picture Format ▸ Artistic Effects: a picture redrawn as a pencil sketch, blurred, grainy… +//! +//! The effect is applied to the pixels and the result becomes the picture, as PowerPoint does; +//! the original is kept (`PictureFill::artistic`) so the effect can be changed or removed, and +//! both are saved (`a14:imgLayer`). The effects approximate PowerPoint's look. + +use deckcraft_model::style::Artistic; +use deckcraft_model::{MediaId, ShapeId, ShapeKind}; +use image::{GrayImage, Rgba, RgbaImage}; +use serde_json::{Value, json}; + +use super::*; +use crate::{Result, Session}; + +/// The effects offered: `a14` element name and label. +pub const EFFECTS: &[(&str, &str)] = &[ + ("artisticBlur", "Blur"), + ("artisticPencilGrayscale", "Pencil Grayscale"), + ("artisticLineDrawing", "Line Drawing"), + ("artisticFilmGrain", "Film Grain"), + ("artisticCutout", "Cutout"), + ("artisticGlowEdges", "Glow Edges"), + ("artisticPhotocopy", "Photocopy"), +]; + +pub fn specs() -> Vec { + vec![cmd!( + "picture.artisticEffect", + "Artistic Effects", + ["Picture Format", "Adjust"], + None, + "{effect: none|artisticBlur|artisticPencilGrayscale|artisticLineDrawing|artisticFilmGrain|artisticCutout|artisticGlowEdges|artisticPhotocopy, ids?}", + has_selection, + artistic_effect + )] +} + +/// Largest side processed; bigger pictures are scaled down first. +const MAX_SIDE: u32 = 3000; + +fn luma(p: &Rgba) -> f32 { + 0.299 * p[0] as f32 + 0.587 * p[1] as f32 + 0.114 * p[2] as f32 +} + +fn gray(img: &RgbaImage) -> GrayImage { + GrayImage::from_fn(img.width(), img.height(), |x, y| image::Luma([luma(img.get_pixel(x, y)).round().clamp(0.0, 255.0) as u8])) +} + +/// Sobel edge strength, 0–255. +fn edges(g: &GrayImage) -> GrayImage { + let (w, h) = g.dimensions(); + let at = |x: i64, y: i64| g.get_pixel(x.clamp(0, w as i64 - 1) as u32, y.clamp(0, h as i64 - 1) as u32)[0] as f32; + GrayImage::from_fn(w, h, |x, y| { + let (x, y) = (x as i64, y as i64); + let gx = at(x + 1, y - 1) + 2.0 * at(x + 1, y) + at(x + 1, y + 1) - at(x - 1, y - 1) - 2.0 * at(x - 1, y) - at(x - 1, y + 1); + let gy = at(x - 1, y + 1) + 2.0 * at(x, y + 1) + at(x + 1, y + 1) - at(x - 1, y - 1) - 2.0 * at(x, y - 1) - at(x + 1, y - 1); + image::Luma([((gx * gx + gy * gy).sqrt() / 4.0).clamp(0.0, 255.0) as u8]) + }) +} + +/// Applies `effect` to `img` (alpha kept). +pub fn apply(effect: &str, img: &RgbaImage) -> Option { + let (w, h) = img.dimensions(); + let scale = ((w * w + h * h) as f32).sqrt() / 1000.0; + let alpha = |x: u32, y: u32| img.get_pixel(x, y)[3]; + let out = match effect { + "artisticBlur" => image::imageops::blur(img, (6.0 * scale).max(1.0)), + "artisticPencilGrayscale" | "artisticLineDrawing" | "artisticPhotocopy" => { + let soft = image::imageops::blur(&gray(img), (0.8 * scale).max(0.5)); + let e = edges(&soft); + RgbaImage::from_fn(w, h, |x, y| { + let ev = e.get_pixel(x, y)[0] as f32; + let gv = soft.get_pixel(x, y)[0] as f32; + let v = match effect { + "artisticPencilGrayscale" => 255.0 - ev * 2.0 - (255.0 - gv) * 0.25, + "artisticLineDrawing" => { + if ev > 28.0 { + 255.0 - (ev - 28.0) * 3.0 + } else { + 255.0 + } + } + _ => { + if ev > 40.0 || gv < 70.0 { + 0.0 + } else { + 255.0 + } + } + }; + let v = v.clamp(0.0, 255.0) as u8; + Rgba([v, v, v, alpha(x, y)]) + }) + } + "artisticFilmGrain" => { + // Deterministic noise, so the same picture gives the same grain. + let mut seed: u32 = 0x9E37_79B9; + RgbaImage::from_fn(w, h, |x, y| { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + let n = (seed % 61) as f32 - 30.0; + let p = img.get_pixel(x, y); + let c = |v: u8| ((v as f32 - 128.0) * 1.1 + 128.0 + n).clamp(0.0, 255.0) as u8; + Rgba([c(p[0]), c(p[1]), c(p[2]), p[3]]) + }) + } + "artisticCutout" => { + let soft = image::imageops::blur(img, (1.5 * scale).max(0.5)); + RgbaImage::from_fn(w, h, |x, y| { + let p = soft.get_pixel(x, y); + let q = |v: u8| ((v as f32 / 255.0 * 3.0).round() / 3.0 * 255.0) as u8; + Rgba([q(p[0]), q(p[1]), q(p[2]), alpha(x, y)]) + }) + } + "artisticGlowEdges" => { + let e = edges(&gray(img)); + RgbaImage::from_fn(w, h, |x, y| { + let k = (e.get_pixel(x, y)[0] as f32 / 255.0 * 2.5).min(1.0); + let p = img.get_pixel(x, y); + let c = |v: u8| (v as f32 * k + 255.0 * k * 0.3).clamp(0.0, 255.0) as u8; + Rgba([c(p[0]), c(p[1]), c(p[2]), p[3]]) + }) + } + _ => return None, + }; + Some(out) +} + +/// Decodes `data`, applies `effect` and encodes a PNG. +fn process(effect: &str, data: &[u8]) -> Result> { + let img = image::load_from_memory(data).map_err(|_| bad("picture.artisticEffect", "this picture's format can't be processed"))?; + let img = if img.width().max(img.height()) > MAX_SIDE { img.thumbnail(MAX_SIDE, MAX_SIDE) } else { img }; + let out = apply(effect, &img.to_rgba8()).ok_or_else(|| bad("picture.artisticEffect", "unknown effect"))?; + let mut png = Vec::new(); + image::DynamicImage::ImageRgba8(out) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .map_err(|_| bad("picture.artisticEffect", "the processed picture couldn't be stored"))?; + Ok(png) +} + +fn artistic_effect(s: &mut Session, p: &Value) -> Result { + let effect = match str_param(p, "effect") { + Some("none") => None, + Some(e) if EFFECTS.iter().any(|(n, _)| *n == e) => Some(e.to_string()), + _ => return Err(bad("picture.artisticEffect", "`effect` is none or one of artisticBlur, artisticPencilGrayscale, …")), + }; + let ids = targets(s, p)?; + let st = s.doc()?; + // (shape, original picture, its bytes and name) + let mut work: Vec<(ShapeId, MediaId, Option<(Vec, String)>)> = Vec::new(); + for id in ids { + let Some(ShapeKind::Picture { fill }) = st.shape(id).map(|sh| &sh.kind) else { continue }; + let original = fill.artistic.as_ref().map_or(fill.media, |a| a.original); + let item = st.doc.media(original).map(|m| (m.data.to_vec(), m.name.clone())); + work.push((id, original, item)); + } + if work.is_empty() { + return Err(bad("picture.artisticEffect", "select a picture")); + } + let mut results: Vec<(ShapeId, MediaId, Option<(String, Vec)>)> = Vec::new(); + for (id, original, item) in work { + let processed = match (&effect, item) { + (None, _) => None, + (Some(e), Some((data, name))) => { + Some((format!("{}-{}.png", name.rsplit_once('.').map_or(name.as_str(), |x| x.0), e), process(e, &data)?)) + } + (Some(_), None) => return Err(bad("picture.artisticEffect", "the picture's data is missing")), + }; + results.push((id, original, processed)); + } + let effect2 = effect.clone(); + s.edit(move |doc, sel| { + let mut set: Vec<(ShapeId, MediaId, Option)> = Vec::new(); + for (id, original, processed) in &results { + let m = processed.as_ref().map(|(name, png)| doc.add_media(name, "image/png", png.clone())); + set.push((*id, *original, m)); + } + let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("picture.artisticEffect", "no slide"))?; + for (id, original, m) in set { + if let Some(sh) = deckcraft_model::find_shape_mut(list, id) + && let ShapeKind::Picture { fill } = &mut sh.kind + { + match (m, &effect2) { + (Some(m), Some(e)) => { + fill.media = m; + fill.artistic = Some(Artistic { effect: e.clone(), original }); + } + _ => { + fill.media = original; + fill.artistic = None; + } + } + } + } + Ok(()) + })?; + Ok(json!({"effect": effect})) +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index ce909b02..bb64e1a0 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -2,6 +2,7 @@ pub mod animation; pub mod arrange; +pub mod artistic; pub mod design; pub mod edit; pub mod file; @@ -142,6 +143,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(review::specs()); v.extend(inspect::specs()); v.extend(media::specs()); + v.extend(artistic::specs()); v }) } diff --git a/crates/engine/src/cmd/shape.rs b/crates/engine/src/cmd/shape.rs index b4c6aa4e..898d7a7e 100644 --- a/crates/engine/src/cmd/shape.rs +++ b/crates/engine/src/cmd/shape.rs @@ -664,6 +664,9 @@ fn picture_reset(s: &mut Session, p: &Value) -> Result { fill.adjust = Default::default(); fill.crop = [0.0; 4]; fill.alpha = None; + if let Some(a) = fill.artistic.take() { + fill.media = a.original; + } } sh.effects = None; sh.line = None; @@ -679,6 +682,7 @@ fn picture_change(s: &mut Session, p: &Value) -> Result { if let deckcraft_model::ShapeKind::Picture { fill } = &mut sh.kind { fill.media = media; fill.crop = [0.0; 4]; + fill.artistic = None; } Ok(()) }) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e83f72ef..c5edc306 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -835,3 +835,57 @@ 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 artistic_effects_apply_change_remove_and_round_trip() { + use deckcraft_model::ShapeKind; + let mut img = image::RgbaImage::new(40, 30); + for (x, y, p) in img.enumerate_pixels_mut() { + *p = if x < 20 { image::Rgba([200, 30, 30, 255]) } else { image::Rgba([20, 20, 220, if y < 5 { 0 } else { 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("insert.picture", &json!({"name": "a.png", "data": cmd::base64_encode(&png)})).unwrap(); + let fill = |s: &Session| match &s.doc().unwrap().selected_shapes()[0].kind { + ShapeKind::Picture { fill } => fill.clone(), + k => panic!("{k:?}"), + }; + let original = fill(&s).media; + assert!(s.execute("picture.artisticEffect", &json!({"effect": "artisticBogus"})).is_err()); + // Every effect draws something of the same size and keeps transparency. + for (e, _) in cmd::artistic::EFFECTS { + s.execute("picture.artisticEffect", &json!({"effect": e})).unwrap(); + let f = fill(&s); + assert_eq!(f.artistic.as_ref().map(|a| (a.effect.as_str(), a.original)), Some((*e, original))); + assert_ne!(f.media, original); + let data = s.doc().unwrap().doc.media(f.media).unwrap().data.clone(); + let out = image::load_from_memory(&data).unwrap().to_rgba8(); + assert_eq!(out.dimensions(), (40, 30)); + assert_eq!(out.get_pixel(30, 1)[3], 0, "{e}"); + } + // A pencil sketch is grey. + s.execute("picture.artisticEffect", &json!({"effect": "artisticPencilGrayscale"})).unwrap(); + let data = s.doc().unwrap().doc.media(fill(&s).media).unwrap().data.clone(); + let out = image::load_from_memory(&data).unwrap().to_rgba8(); + assert!(out.pixels().all(|p| p[0] == p[1] && p[1] == p[2])); + // Saved and reopened, the effect and the original are both there. + let bytes = cmd::file::save_bytes(&s.doc().unwrap().doc, "pptx").unwrap(); + let mut t = Session::new(); + t.execute("file.openBytes", &json!({"name": "a.pptx", "data": cmd::base64_encode(&bytes)})).unwrap(); + let back = t.doc().unwrap().doc.slides.iter().flat_map(|sl| sl.shapes.iter()).find_map(|sh| match &sh.kind { + ShapeKind::Picture { fill } => Some(fill.clone()), + _ => None, + }); + let back = back.unwrap(); + let art = back.artistic.clone().unwrap(); + assert_eq!(art.effect, "artisticPencilGrayscale"); + assert_eq!(*t.doc().unwrap().doc.media(art.original).unwrap().data, png); + // None (and Reset Picture) puts the original back. + s.execute("picture.artisticEffect", &json!({"effect": "none"})).unwrap(); + assert_eq!((fill(&s).media, fill(&s).artistic), (original, None)); + s.execute("picture.artisticEffect", &json!({"effect": "artisticBlur"})).unwrap(); + s.execute("picture.reset", &json!({})).unwrap(); + assert_eq!((fill(&s).media, fill(&s).artistic), (original, None)); +} diff --git a/crates/model/src/style.rs b/crates/model/src/style.rs index efb30e48..b067c904 100644 --- a/crates/model/src/style.rs +++ b/crates/model/src/style.rs @@ -143,6 +143,18 @@ pub struct PictureFill { /// flipped. #[serde(skip_serializing_if = "std::ops::Not::not")] pub upright: bool, + /// An artistic effect (Picture Format ▸ Artistic Effects). `media` is then the processed + /// picture; the original is kept to change or remove the effect. + #[serde(skip_serializing_if = "Option::is_none")] + pub artistic: Option, +} + +/// An applied artistic effect: its `a14` name (e.g. `artisticBlur`) and the original picture. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Artistic { + pub effect: String, + pub original: MediaId, } /// Picture corrections and colour (Picture Format ▸ Corrections / Color / Transparency). diff --git a/crates/pptx/src/read/dml.rs b/crates/pptx/src/read/dml.rs index 0aee935c..71281c9a 100644 --- a/crates/pptx/src/read/dml.rs +++ b/crates/pptx/src/read/dml.rs @@ -126,6 +126,17 @@ pub fn blip_fill(imp: &mut Imp, part: &Part, bf: &El) -> Option { let media = blip.and_then(|b| media_ref(imp, part, b)); let media = media?; let mut pf = PictureFill { media, upright: !bf.bool("rotWithShape").unwrap_or(true), ..Default::default() }; + if let Some(layer) = blip.and_then(|b| b.child("extLst")).and_then(|x| x.find("imgLayer")) + && let Some(effect) = layer + .elements() + .filter(|e| e.local() == "imgEffect") + .flat_map(|e| e.elements()) + .map(|e| e.local().to_string()) + .find(|n| n.starts_with("artistic")) + && let Some(original) = media_ref(imp, part, layer) + { + pf.artistic = Some(deckcraft_model::style::Artistic { effect, original }); + } if let Some(b) = blip { for e in b.elements() { match e.local() { diff --git a/crates/pptx/src/write/dml.rs b/crates/pptx/src/write/dml.rs index 16a069fe..4185043d 100644 --- a/crates/pptx/src/write/dml.rs +++ b/crates/pptx/src/write/dml.rs @@ -105,7 +105,8 @@ pub fn blip_fill(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, pf: &PictureFil || adj.duotone.is_some() || adj.clear_color.is_some() || adj.bilevel.is_some() - || adj.alpha_inv; + || adj.alpha_inv + || pf.artistic.is_some(); let a = A::new().a("r:embed", &rid); if has_kids { w.open("a:blip", a); @@ -140,6 +141,25 @@ pub fn blip_fill(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, pf: &PictureFil if adj.brightness != 0.0 || adj.contrast != 0.0 { w.empty("a:lum", A::new().a("bright", pct(adj.brightness.clamp(-1.0, 1.0))).a("contrast", pct(adj.contrast.clamp(-1.0, 1.0)))); } + // Artistic effect: the blip is the processed picture; the original rides in an + // `a14:imgLayer` with the effect's name, as PowerPoint writes it. + if let Some(art) = &pf.artistic + && art.effect.starts_with("artistic") + && art.effect.chars().all(|c| c.is_ascii_alphanumeric()) + && let Some(orig) = x.media_rel(o, art.original, "image") + { + w.open0("a:extLst"); + w.open("a:ext", A::new().a("uri", "{BEBA8EAE-BF5A-486C-A8C5-ECC9F3942E4B}")); + w.open("a14:imgProps", A::new().a("xmlns:a14", crate::opc::NS_A14)); + w.open("a14:imgLayer", A::new().a("r:embed", &orig)); + w.open0("a14:imgEffect"); + w.empty0(&format!("a14:{}", art.effect)); + w.close("a14:imgEffect"); + w.close("a14:imgLayer"); + w.close("a14:imgProps"); + w.close("a:ext"); + w.close("a:extLst"); + } w.close("a:blip"); } else { w.empty("a:blip", a); diff --git a/crates/pptx/tests/roundtrip.rs b/crates/pptx/tests/roundtrip.rs index db6d0beb..1f64688b 100644 --- a/crates/pptx/tests/roundtrip.rs +++ b/crates/pptx/tests/roundtrip.rs @@ -232,6 +232,7 @@ pub fn rich_deck() -> Presentation { alpha: Some(0.5), adjust: PictureAdjust { brightness: 0.2, contrast: -0.1, grayscale: true, ..Default::default() }, upright: false, + artistic: None, }, }; let mut tiled = shape(&mut p, "Tiled", 350.0, 120.0, 200.0, 150.0); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 428eff0b..5b9dc32e 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2076,6 +2076,17 @@ fn picture_format(app: &mut SlideApp, ui: &mut Ui) { run(app, "picture.adjust", json!({"grayscale": true})); } }); + let r = big_button(ui, Icon::ArtisticEffects, "Artistic\nEffects", enabled(app, "picture.artisticEffect")); + egui::Popup::menu(&r).show(|ui| { + if ui.button("None").clicked() { + run(app, "picture.artisticEffect", json!({"effect": "none"})); + } + for (e, l) in deckcraft_engine::cmd::artistic::EFFECTS { + if ui.button(*l).clicked() { + run(app, "picture.artisticEffect", json!({"effect": e})); + } + } + }); let r = big_button(ui, Icon::Transparency, "Transparency", true); egui::Popup::menu(&r).show(|ui| { for tr in [0.0, 0.15, 0.3, 0.5, 0.65, 0.8, 0.95] { diff --git a/docs/gaps.md b/docs/gaps.md index a2894b42..9afa4185 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -185,7 +185,7 @@ Detail in [animation-parity.md](animation-parity.md). 10–16 h; the pane is a * ### Smaller missing rows Zoom (summary/section/slide, 6–10 h), reuse slides (2–4 h), laser pointer (1–2 h), transition -sounds (1–2 h), artistic effects (6–10 h), remove background (6–10 h; needs a model or a classical +sounds (1–2 h), artistic effects (3–6 h for the other 15 of 22; 7 approximated and round-tripped), remove background (6–10 h; needs a model or a classical matte), compress pictures (2–3 h), insert screenshot (2–3 h), record audio/screen and record slide show (8–12 h), compare presentations (6–10 h), password protection (4–6 h, ECMA-376 agile encryption), ink to shape/text (6–10 h), icons and 3-D models (8–14 h, needs an open icon set). @@ -256,6 +256,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Artistic effects: 7 of 22 (blur, pencil grayscale, line drawing, film grain, cutout, glow edges, photocopy) applied, removed and round-tripped (`a14:imgLayer`) | | 2026-10-13 | minor | Shape, picture and table rendering checked against PowerPoint on ~150 synthetic edge-case slides; 3-D bevels, cameras and face lighting now render approximately (gap narrowed to extrusion, rigs and the 3-D pane) | | 2026-10-12 | minor | IME composition in slide text and notes, and CJK interface text with real glyphs (#101): the IME gap is now Korean on the web and table-cell underlines | | 2026-10-12 | minor | EMF/WMF text, pattern and hatch brushes drawn; legacy OLE and ActiveX previews shown; the SVG gap keeps only EMF clip regions | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 2a3d450c..65a5d21b 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -182,7 +182,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Pictures | SVG pictures | P1 | M | | Pictures | Crop (and crop to shape, aspect ratio) | P0 | P | | Pictures | Corrections, colour, transparency | P1 | P | -| Pictures | Artistic effects | P2 | M | +| Pictures | Artistic effects | P2 | P | | Pictures | Remove background | P2 | M | | Pictures | Picture styles and borders | P1 | P | | Pictures | Change / reset picture | P1 | D |