mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-26 19:46:58 -06:00
Pictures: Artistic Effects — apply, change, remove and keep in PPTX
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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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<CommandSpec> {
|
||||
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<u8>) -> 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<RgbaImage> {
|
||||
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<Vec<u8>> {
|
||||
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<Value> {
|
||||
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<u8>, 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<u8>)>)> = 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<MediaId>)> = 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}))
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -664,6 +664,9 @@ fn picture_reset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
if let deckcraft_model::ShapeKind::Picture { fill } = &mut sh.kind {
|
||||
fill.media = media;
|
||||
fill.crop = [0.0; 4];
|
||||
fill.artistic = None;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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<Artistic>,
|
||||
}
|
||||
|
||||
/// 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).
|
||||
|
||||
@@ -126,6 +126,17 @@ pub fn blip_fill(imp: &mut Imp, part: &Part, bf: &El) -> Option<PictureFill> {
|
||||
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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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] {
|
||||
|
||||
+2
-1
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user