diff --git a/crates/engine/src/cmd/compress.rs b/crates/engine/src/cmd/compress.rs new file mode 100644 index 00000000..37dfe2ea --- /dev/null +++ b/crates/engine/src/cmd/compress.rs @@ -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 { + 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, + 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 { + 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 { + fn scan(v: &Value, out: &mut HashSet, 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 { + 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 = 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 = 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 = 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 { + 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"", [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); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 66551b2e..3bff37c0 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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 }) } diff --git a/crates/pptx/tests/roundtrip.rs b/crates/pptx/tests/roundtrip.rs index 1ca7b62f..5177281c 100644 --- a/crates/pptx/tests/roundtrip.rs +++ b/crates/pptx/tests/roundtrip.rs @@ -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()); +} diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index b4f2a922..652a2e60 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -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(); + } +} diff --git a/docs/gaps.md b/docs/gaps.md index 5723c8ba..6cb3fd4e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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 diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index c778c013..eda1bc43 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -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 |