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:
Brandon Thomas
2026-10-14 15:36:54 -04:00
6 changed files with 348 additions and 12 deletions
+252
View File
@@ -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);
}
}
+2
View File
@@ -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
})
}
+45
View File
@@ -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());
}
+41
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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 |