mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-29 13:06:56 -06:00
Merge PR #513 from NikitkaRa: Pictures: compress pictures to a target resolution
# Conflicts: # crates/engine/src/cmd/mod.rs # crates/pptx/tests/roundtrip.rs # crates/ui-egui/src/ribbon.rs
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
//! Picture Format ▸ Adjust ▸ Compress Pictures: downsample pictures to a target resolution for
|
||||
//! the size they are shown at, and optionally delete their cropped-away areas.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::style::PictureFill;
|
||||
use deckcraft_model::{MediaId, Presentation, Shape, ShapeId, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
"picture.compress",
|
||||
"Compress Pictures",
|
||||
["Picture Format", "Adjust"],
|
||||
None,
|
||||
"{ppi?: 330|220|150|96 (default 220), deleteCropped?: bool (default true), all?: bool (every picture in the presentation; default the selected pictures), ids?} → {pictures, before, after}",
|
||||
has_doc,
|
||||
compress
|
||||
)]
|
||||
}
|
||||
|
||||
/// A picture compressed for one shape: new bytes, content type and the crop it now needs.
|
||||
struct Packed {
|
||||
bytes: Vec<u8>,
|
||||
content_type: &'static str,
|
||||
crop: [f64; 4],
|
||||
}
|
||||
|
||||
/// Whether `crop` cuts something away (and only cuts: negative values pad, and are kept).
|
||||
fn real_crop(c: &[f64; 4]) -> bool {
|
||||
c.iter().all(|v| (0.0..1.0).contains(v)) && c.iter().any(|v| *v > 0.0) && c[0] + c[2] < 1.0 && c[1] + c[3] < 1.0
|
||||
}
|
||||
|
||||
/// Compress `data` for a picture shown `w_pt` × `h_pt` with `crop`, at `ppi`. `None` when the
|
||||
/// format isn't a raster we re-encode (SVG, EMF, GIF…) or nothing would get smaller.
|
||||
fn pack(data: &[u8], crop: [f64; 4], w_pt: f64, h_pt: f64, ppi: f64, delete_cropped: bool) -> Option<Packed> {
|
||||
use image::ImageFormat as F;
|
||||
let format = image::guess_format(data).ok()?;
|
||||
if !matches!(format, F::Png | F::Jpeg | F::Bmp | F::Tiff | F::WebP) {
|
||||
return None;
|
||||
}
|
||||
let mut img = image::load_from_memory_with_format(data, format).ok()?;
|
||||
let (iw, ih) = (img.width() as f64, img.height() as f64);
|
||||
if iw < 1.0 || ih < 1.0 || iw * ih > 400_000_000.0 {
|
||||
return None;
|
||||
}
|
||||
let cut = delete_cropped && real_crop(&crop);
|
||||
let mut new_crop = crop;
|
||||
// The share of the image that is visible across and down.
|
||||
let (mut fx, mut fy) = ((1.0 - crop[0] - crop[2]).max(0.01), (1.0 - crop[1] - crop[3]).max(0.01));
|
||||
if cut {
|
||||
let x = (crop[0] * iw).floor() as u32;
|
||||
let y = (crop[1] * ih).floor() as u32;
|
||||
let w = ((fx * iw).round() as u32).clamp(1, img.width() - x.min(img.width() - 1));
|
||||
let h = ((fy * ih).round() as u32).clamp(1, img.height() - y.min(img.height() - 1));
|
||||
img = img.crop_imm(x, y, w, h);
|
||||
new_crop = [0.0; 4];
|
||||
(fx, fy) = (1.0, 1.0);
|
||||
} else if !crop.iter().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
// Pixels needed for the whole (possibly still cropped) image at `ppi`.
|
||||
let need_w = (w_pt.abs() / 72.0 * ppi / fx).ceil().max(1.0);
|
||||
let need_h = (h_pt.abs() / 72.0 * ppi / fy).ceil().max(1.0);
|
||||
let k = (need_w / img.width() as f64).max(need_h / img.height() as f64);
|
||||
let resized = k < 1.0;
|
||||
if resized {
|
||||
let w = ((img.width() as f64 * k).round() as u32).max(1);
|
||||
let h = ((img.height() as f64 * k).round() as u32).max(1);
|
||||
img = img.resize_exact(w, h, image::imageops::FilterType::Lanczos3);
|
||||
}
|
||||
if !resized && !cut {
|
||||
return None;
|
||||
}
|
||||
let alpha = img.color().has_alpha() && img.to_rgba8().pixels().any(|p| p.0[3] < 255);
|
||||
let mut out = std::io::Cursor::new(Vec::new());
|
||||
let content_type = if format == F::Jpeg && !alpha {
|
||||
let rgb = img.to_rgb8();
|
||||
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 85).encode_image(&rgb).ok()?;
|
||||
"image/jpeg"
|
||||
} else {
|
||||
img.write_to(&mut out, F::Png).ok()?;
|
||||
"image/png"
|
||||
};
|
||||
let bytes = out.into_inner();
|
||||
// Never make a picture bigger just to resample it; deleting a crop is worth it regardless.
|
||||
if bytes.len() >= data.len() && !cut {
|
||||
return None;
|
||||
}
|
||||
Some(Packed { bytes, content_type, crop: new_crop })
|
||||
}
|
||||
|
||||
/// Picture shapes in `shapes` (groups included) that `want` accepts.
|
||||
fn pictures_mut<'a>(shapes: &'a mut [Shape], want: &dyn Fn(ShapeId) -> bool, out: &mut Vec<&'a mut Shape>, depth: usize) {
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
for sh in shapes {
|
||||
if matches!(sh.kind, ShapeKind::Picture { .. }) && want(sh.id) {
|
||||
out.push(sh);
|
||||
} else if let Some(ch) = sh.children_mut() {
|
||||
pictures_mut(ch, want, out, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Media ids anything in the presentation still refers to (everything but the media list).
|
||||
fn referenced_media(doc: &Presentation) -> HashSet<u32> {
|
||||
fn scan(v: &Value, out: &mut HashSet<u32>, depth: usize) {
|
||||
if depth > 256 {
|
||||
return;
|
||||
}
|
||||
match v {
|
||||
Value::Object(o) => {
|
||||
for (k, x) in o {
|
||||
match (k.as_str(), x.as_u64()) {
|
||||
("media" | "poster" | "preview" | "sound", Some(n)) => {
|
||||
out.insert(n as u32);
|
||||
}
|
||||
_ => scan(x, out, depth + 1),
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Array(a) => a.iter().for_each(|x| scan(x, out, depth + 1)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut out = HashSet::new();
|
||||
let mut shell = doc.clone();
|
||||
shell.media.clear();
|
||||
if let Ok(v) = serde_json::to_value(&shell) {
|
||||
scan(&v, &mut out, 0);
|
||||
}
|
||||
out.extend(doc.embedded_fonts.iter().flat_map(|e| [e.regular, e.bold, e.italic, e.bold_italic]).flatten().map(|m| m.0));
|
||||
out
|
||||
}
|
||||
|
||||
fn compress(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ppi = f64_param(p, "ppi").unwrap_or(220.0);
|
||||
if !ppi.is_finite() || !(36.0..=1200.0).contains(&ppi) {
|
||||
return Err(bad("picture.compress", "`ppi` must be between 36 and 1200 (PowerPoint offers 330, 220, 150 and 96)"));
|
||||
}
|
||||
let delete_cropped = bool_or(p, "deleteCropped", true);
|
||||
let all = bool_or(p, "all", false);
|
||||
let ids: HashSet<ShapeId> = if all { HashSet::new() } else { targets(s, p)?.into_iter().collect() };
|
||||
if !all && ids.is_empty() {
|
||||
return Err(bad("picture.compress", "select a picture, or pass all: true"));
|
||||
}
|
||||
let current = s.doc()?.selection.slide;
|
||||
s.edit(|doc, _| {
|
||||
let want = |id: ShapeId| all || ids.contains(&id);
|
||||
let mut before = 0usize;
|
||||
let mut after = 0usize;
|
||||
let mut count = 0usize;
|
||||
let mut changed: Vec<MediaId> = vec![];
|
||||
// The same picture at the same size and crop compresses once.
|
||||
let mut cache: HashMap<(u32, [u64; 4], u64, u64), Option<(MediaId, [f64; 4], usize)>> = HashMap::new();
|
||||
let range: Vec<usize> = if all { (0..doc.slides.len()).collect() } else { vec![current] };
|
||||
for i in range {
|
||||
let Some(slide) = doc.slides.get(i).cloned() else { continue };
|
||||
let mut slide = slide.as_ref().clone();
|
||||
let mut pics = vec![];
|
||||
pictures_mut(&mut slide.shapes, &want, &mut pics, 0);
|
||||
let mut touched = false;
|
||||
for sh in pics {
|
||||
let (w, h) = sh.xfrm.map(|x| (x.w, x.h)).unwrap_or((0.0, 0.0));
|
||||
let ShapeKind::Picture { fill } = &mut sh.kind else { continue };
|
||||
let PictureFill { media, crop, .. } = fill.clone();
|
||||
let key = (media.0, crop.map(f64::to_bits), w.to_bits(), h.to_bits());
|
||||
let hit = match cache.get(&key) {
|
||||
Some(v) => *v,
|
||||
None => {
|
||||
let packed = doc.media(media).filter(|m| !m.data.is_empty()).and_then(|m| {
|
||||
let old = m.data.len();
|
||||
pack(&m.data, crop, w, h, ppi, delete_cropped).map(|pk| (pk, old, m.name.clone()))
|
||||
});
|
||||
let v = packed.map(|(pk, old, name)| {
|
||||
before += old;
|
||||
after += pk.bytes.len();
|
||||
let stem = std::path::Path::new(&name).file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or(name);
|
||||
let ext = if pk.content_type == "image/jpeg" { "jpg" } else { "png" };
|
||||
let id = doc.add_media(&format!("{stem}.{ext}"), pk.content_type, pk.bytes);
|
||||
(id, pk.crop, old)
|
||||
});
|
||||
cache.insert(key, v);
|
||||
v
|
||||
}
|
||||
};
|
||||
if let Some((id, new_crop, _)) = hit {
|
||||
changed.push(media);
|
||||
fill.media = id;
|
||||
fill.crop = new_crop;
|
||||
count += 1;
|
||||
touched = true;
|
||||
}
|
||||
}
|
||||
if touched && let Some(sl) = doc.slides.get_mut(i) {
|
||||
*sl = Arc::new(slide);
|
||||
}
|
||||
}
|
||||
// Drop the originals nothing uses any more, so the saved file actually shrinks.
|
||||
if !changed.is_empty() {
|
||||
let used = referenced_media(doc);
|
||||
doc.media.retain(|m| !changed.contains(&m.id) || used.contains(&m.id.0));
|
||||
}
|
||||
Ok(json!({"pictures": count, "before": before, "after": after, "ppi": ppi}))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn png(w: u32, h: u32) -> Vec<u8> {
|
||||
let img = image::RgbImage::from_fn(w, h, |x, y| image::Rgb([(x * 7 % 256) as u8, (y * 5 % 256) as u8, ((x ^ y) % 256) as u8]));
|
||||
let mut out = std::io::Cursor::new(Vec::new());
|
||||
image::DynamicImage::ImageRgb8(img).write_to(&mut out, image::ImageFormat::Png).unwrap();
|
||||
out.into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pack_downsamples_to_the_shown_size() {
|
||||
// 2000 px shown at 1 inch: 150 ppi needs 150 px.
|
||||
let pk = pack(&png(2000, 1000), [0.0; 4], 72.0, 36.0, 150.0, true).unwrap();
|
||||
let img = image::load_from_memory(&pk.bytes).unwrap();
|
||||
assert_eq!((img.width(), img.height()), (150, 75));
|
||||
assert_eq!(pk.content_type, "image/png");
|
||||
// Already small enough: nothing to do.
|
||||
assert!(pack(&png(100, 50), [0.0; 4], 72.0, 36.0, 150.0, true).is_none());
|
||||
// Not a raster we re-encode.
|
||||
assert!(pack(b"<svg xmlns='http://www.w3.org/2000/svg'/>", [0.0; 4], 72.0, 72.0, 150.0, true).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pack_deletes_cropped_areas_or_keeps_them() {
|
||||
let src = png(400, 400);
|
||||
// Left half cropped away, shown 2 in wide at 100 ppi → 200 px of the visible half.
|
||||
let pk = pack(&src, [0.5, 0.0, 0.0, 0.0], 144.0, 288.0, 100.0, true).unwrap();
|
||||
assert_eq!(pk.crop, [0.0; 4]);
|
||||
let img = image::load_from_memory(&pk.bytes).unwrap();
|
||||
assert_eq!((img.width(), img.height()), (200, 400));
|
||||
// Keeping the crop: the whole image stays, scaled so the visible half has its pixels.
|
||||
let pk = pack(&src, [0.5, 0.0, 0.0, 0.0], 72.0, 72.0, 100.0, false).unwrap();
|
||||
assert_eq!(pk.crop, [0.5, 0.0, 0.0, 0.0]);
|
||||
let img = image::load_from_memory(&pk.bytes).unwrap();
|
||||
assert_eq!(img.width(), 200);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ pub mod animation;
|
||||
pub mod arrange;
|
||||
pub mod artistic;
|
||||
pub mod background;
|
||||
pub mod compress;
|
||||
pub mod design;
|
||||
pub mod edit;
|
||||
pub mod file;
|
||||
@@ -161,6 +162,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(inspect::specs());
|
||||
v.extend(media::specs());
|
||||
v.extend(artistic::specs());
|
||||
v.extend(compress::specs());
|
||||
v
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1744,3 +1744,48 @@ fn reuse_slides_from_another_presentation() {
|
||||
assert!(s.execute("slide.reuse", &json!({"data": b64(&src_bytes), "name": "src.pptx", "slides": [7]})).is_err());
|
||||
assert!(s.execute("slide.reuse", &json!({"data": b64(&[0u8, 159, 146, 150]), "name": "x.bin"})).is_err());
|
||||
}
|
||||
|
||||
/// Picture Format › Compress Pictures: a big picture shown small is downsampled, its original
|
||||
/// goes when nothing else uses it, the saved file shrinks, a copy that wasn't compressed keeps
|
||||
/// the original, and undo brings everything back.
|
||||
#[test]
|
||||
fn compress_pictures_shrinks_the_file_and_keeps_shared_originals() {
|
||||
use deckcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
let b64 = deckcraft_engine::cmd::base64_encode;
|
||||
// A noisy 1600 × 1600 picture (doesn't compress away on its own).
|
||||
let big = {
|
||||
let img = image::RgbImage::from_fn(1600, 1600, |x, y| image::Rgb([(x * 31 % 251) as u8, (y * 17 % 253) as u8, ((x * y) % 255) as u8]));
|
||||
let mut out = std::io::Cursor::new(Vec::new());
|
||||
image::DynamicImage::ImageRgb8(img).write_to(&mut out, image::ImageFormat::Png).unwrap();
|
||||
out.into_inner()
|
||||
};
|
||||
let mut s = Session::with_new();
|
||||
s.execute("insert.picture", &json!({"data": b64(&big), "name": "noise.png"})).unwrap();
|
||||
s.execute("shape.resize", &json!({"w": 144, "h": 144})).unwrap();
|
||||
let size_before = deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap().len();
|
||||
let r = s.execute("picture.compress", &json!({"ppi": 150})).unwrap();
|
||||
assert_eq!(r["pictures"], 1, "{r}");
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
assert_eq!(doc.media.len(), 1, "the original is gone");
|
||||
let img = image::load_from_memory(&doc.media[0].data).unwrap();
|
||||
assert_eq!((img.width(), img.height()), (300, 300), "2 in at 150 ppi");
|
||||
let size_after = deckcraft_pptx::export(&doc).unwrap().len();
|
||||
assert!(size_after * 4 < size_before, "{size_before} → {size_after}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.media[0].data.len(), big.len(), "undo restores the original");
|
||||
|
||||
// A duplicate that isn't compressed still needs the original.
|
||||
s.execute("edit.duplicate", &json!({})).unwrap();
|
||||
let copy = s.doc().unwrap().selection.shapes.clone();
|
||||
let first: Vec<_> = s.doc().unwrap().doc.slides[0].shapes.iter().map(|sh| sh.id).filter(|id| !copy.contains(id)).collect();
|
||||
s.execute("picture.compress", &json!({"ppi": 150, "ids": [first.last().unwrap().0]})).unwrap();
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
assert_eq!(doc.media.len(), 2);
|
||||
assert!(doc.media.iter().any(|m| m.data.len() == big.len()), "the copy keeps the original");
|
||||
// All pictures: the copy gets compressed too, and the original goes.
|
||||
s.execute("picture.compress", &json!({"ppi": 150, "all": true})).unwrap();
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
assert!(doc.media.iter().all(|m| m.data.len() < big.len() / 4));
|
||||
assert!(s.execute("picture.compress", &json!({"ppi": 5, "all": true})).is_err());
|
||||
}
|
||||
|
||||
@@ -2648,6 +2648,10 @@ fn picture_format(app: &mut SlideApp, ui: &mut Ui) {
|
||||
rows(ui, |ui| {
|
||||
small(app, ui, Icon::ChangePicture, "Change Picture", "app.insertPictureDialog", json!({}));
|
||||
small(app, ui, Icon::ResetPicture, "Reset Picture", "picture.reset", json!({}));
|
||||
let (_, r) = small_button_ex(ui, Icon::CompressPictures, "Compress Pictures", enabled(app, "picture.compress"), false, true);
|
||||
if let Some(r) = r {
|
||||
egui::Popup::menu(&r).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).width(260.0).show(|ui| compress_menu(app, ui));
|
||||
}
|
||||
});
|
||||
});
|
||||
group(ui, |ui| {
|
||||
@@ -3515,3 +3519,40 @@ fn smooth_corners_row(app: &mut SlideApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Compress Pictures options, as in PowerPoint's dialog: resolution, apply to this picture or
|
||||
/// all, delete cropped areas. The choices last for the session.
|
||||
fn compress_menu(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let id = egui::Id::new("compress_pictures_opts");
|
||||
let (mut ppi, mut only_selected, mut delete_cropped): (u32, bool, bool) = ui.data_mut(|d| d.get_temp(id).unwrap_or((220, true, true)));
|
||||
ui.label(egui::RichText::new("Compression options").strong());
|
||||
ui.checkbox(&mut only_selected, "Apply only to this picture");
|
||||
ui.checkbox(&mut delete_cropped, "Delete cropped areas of pictures");
|
||||
ui.separator();
|
||||
ui.label(egui::RichText::new("Resolution").strong());
|
||||
for (v, label) in [
|
||||
(330, "HD (330 ppi): good quality for high-definition displays"),
|
||||
(220, "Print (220 ppi): excellent quality on most printers and screens"),
|
||||
(150, "Web (150 ppi): good for web pages and projectors"),
|
||||
(96, "E-mail (96 ppi): minimise document size for sharing"),
|
||||
] {
|
||||
ui.radio_value(&mut ppi, v, label);
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(id, (ppi, only_selected, delete_cropped)));
|
||||
ui.separator();
|
||||
if ui.button("Compress").clicked() {
|
||||
let p = json!({"ppi": ppi, "deleteCropped": delete_cropped, "all": !only_selected});
|
||||
match app.session.execute("picture.compress", &p) {
|
||||
Ok(r) => {
|
||||
let kb = |v: &Value| v.as_u64().unwrap_or(0) / 1024;
|
||||
let msg = match r["pictures"].as_u64().unwrap_or(0) {
|
||||
0 => "Pictures are already at or below that resolution".to_string(),
|
||||
n => format!("Compressed {n} picture(s): {} KB → {} KB", kb(&r["before"]), kb(&r["after"])),
|
||||
};
|
||||
app.set_status(msg);
|
||||
}
|
||||
Err(e) => app.set_status(e.to_string()),
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
|
||||
+7
-11
@@ -349,17 +349,13 @@ selected effect, and most of the effect gallery. Detail in
|
||||
- **Estimate:** 3–6 h to review, land and round-trip.
|
||||
|
||||
### Smaller missing rows
|
||||
Zoom (summary/section/slide, 6–10 h), reuse slides pane with thumbnails (1–2 h; `slide.reuse` inserts from a file), laser pointer (1–2 h), transition
|
||||
sounds (1–2 h), artistic effects (6–10 h), compress pictures (2–3 h), insert screenshot (2–3 h), record audio/screen and record slide
|
||||
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
|
||||
Zoom (summary/section/slide, 6–10 h), reuse slides pane with thumbnails (1–2 h), transition
|
||||
sounds (1–2 h), artistic effects (6–10 h), 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), 3-D models and stock content (6–10 h; Insert › Icons places a Lucide stroke set).
|
||||
show (8–12 h), compare presentations beyond slide-level accept (reject, per-object accept,
|
||||
markers on the slide: 3–5 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).
|
||||
Zoom (summary/section/slide, 6–10 h), reuse slides pane with thumbnails (1–2 h; `slide.reuse`
|
||||
inserts from a file), laser pointer (1–2 h), transition 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), insert screenshot (2–3 h), record audio/screen and record slide show
|
||||
(8–12 h), compare presentations beyond slide-level accept (reject, per-object accept, markers on
|
||||
the slide: 3–5 h), password protection (4–6 h, ECMA-376 agile encryption), ink to shape/text
|
||||
(6–10 h), 3-D models and stock content (6–10 h; Insert › Icons places a Lucide stroke set).
|
||||
|
||||
## UI/UX
|
||||
|
||||
|
||||
@@ -210,7 +210,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou
|
||||
| Pictures | Remove background | P2 | M |
|
||||
| Pictures | Picture styles and borders | P1 | D |
|
||||
| Pictures | Change / reset picture | P1 | D |
|
||||
| Pictures | Compress pictures | P2 | M |
|
||||
| Pictures | Compress pictures | P2 | D |
|
||||
| Pictures | Paste image from clipboard (fails on Windows and Linux, #53) | P0 | P |
|
||||
| Pictures | Insert screenshot | P2 | M |
|
||||
| Media | Insert audio (any common format) | P0 | D |
|
||||
|
||||
Reference in New Issue
Block a user