mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Pictures: compress pictures to a target resolution
Picture Format › Adjust gains Compress Pictures, with PowerPoint's options: HD 330, Print 220, Web 150 or E-mail 96 ppi, apply to the selected pictures or all, and delete cropped areas. The new picture.compress command downsamples each PNG, JPEG, BMP, TIFF or WebP picture to the pixels its shown size needs at that resolution (JPEG stays JPEG at quality 85, the rest become PNG), cuts away cropped areas when asked, and only replaces a picture when the result is smaller or a crop was removed. Originals nothing else uses are dropped so the saved file shrinks; one shared with a picture that wasn't compressed stays. One undo step restores everything. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
//! 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);
|
||||
}
|
||||
// Embedded fonts are (name, id) pairs.
|
||||
out.extend(doc.embedded_fonts.iter().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,6 +2,7 @@
|
||||
|
||||
pub mod animation;
|
||||
pub mod arrange;
|
||||
pub mod compress;
|
||||
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(compress::specs());
|
||||
v
|
||||
})
|
||||
}
|
||||
|
||||
@@ -991,3 +991,48 @@ fn transition_defaults_read_like_powerpoint() {
|
||||
assert_eq!((t.kind.as_str(), t.option.as_str(), t.duration_ms), (*kind, *option, 2000), "{el}");
|
||||
}
|
||||
}
|
||||
|
||||
/// 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());
|
||||
}
|
||||
|
||||
@@ -2087,6 +2087,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| {
|
||||
@@ -2420,3 +2424,40 @@ fn paint_freeform_icon(p: &egui::Painter, r: Rect, name: &str, ink: Color32) {
|
||||
};
|
||||
p.add(egui::Shape::line(pts, egui::Stroke::new(1.3, ink)));
|
||||
}
|
||||
|
||||
/// 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();
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -186,7 +186,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
|
||||
matte), compress pictures (2–3 h), insert screenshot (2–3 h), record audio/screen and record slide
|
||||
matte), 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).
|
||||
|
||||
|
||||
@@ -186,7 +186,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou
|
||||
| Pictures | Remove background | P2 | M |
|
||||
| Pictures | Picture styles and borders | P1 | P |
|
||||
| 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