Export: record slide resolution in PNG and JPEG pictures

A default export is 2 pixels per point. Without a resolution, Preview
treats those pixels as 72 dpi and the picture opens at twice the slide's
size. PNG pHYs and JPEG JFIF density now say scale times 72, so the
default is 144 dpi. Screenshots stay untagged.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nikita Rakitin
2026-10-14 05:35:37 +03:00
co-authored by Cursor
parent 457406a7a5
commit d04df05249
11 changed files with 208 additions and 18 deletions
+15 -10
View File
@@ -254,9 +254,18 @@ fn save_bytes_cmd(s: &mut Session, p: &Value) -> Result<Value> {
/// Render slide `index` (current when absent) to PNG bytes.
pub fn render_png(doc: &Presentation, index: usize, scale: f64, edit: bool) -> (Vec<u8>, u32, u32) {
let img =
deckcraft_render::render_slide(doc, index, &deckcraft_render::RenderOpts { scale: scale.clamp(0.01, 16.0), edit, ..Default::default() });
(img.to_png(), img.width, img.height)
let scale = finite_scale(scale);
let img = deckcraft_render::render_slide(doc, index, &deckcraft_render::RenderOpts { scale, edit, ..Default::default() });
(img.to_png_at(deckcraft_render::dpi_for_scale(scale)), img.width, img.height)
}
fn finite_scale(scale: f64) -> f64 {
if scale.is_finite() { scale.clamp(0.01, 16.0) } else { 1.0 }
}
fn picture_bytes(img: &deckcraft_render::Image, format: &str, scale: f64) -> Vec<u8> {
let dpi = deckcraft_render::dpi_for_scale(scale);
if format == "png" { img.to_png_at(dpi) } else { img.to_jpeg_at(92, Some(dpi)) }
}
fn render(s: &mut Session, p: &Value) -> Result<Value> {
@@ -273,7 +282,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_param(p, "path").ok_or_else(|| bad("file.export", "missing `path`"))?.to_string();
let format = str_param(p, "format").unwrap_or(format_for_path(&path)).to_string();
let st = s.doc()?;
let scale = f64_or(p, "scale", 2.0);
let scale = finite_scale(f64_or(p, "scale", 2.0));
match format.as_str() {
"png" | "jpeg" | "jpg" => {
let slides: Vec<usize> = match usize_param(p, "slide") {
@@ -286,12 +295,8 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
if *i >= st.doc.slides.len() {
return Err(bad("file.export", format!("no slide {i}")));
}
let img = deckcraft_render::render_slide(
&st.doc,
*i,
&deckcraft_render::RenderOpts { scale: scale.clamp(0.01, 16.0), ..Default::default() },
);
let bytes = if format == "png" { img.to_png() } else { img.to_jpeg(92) };
let img = deckcraft_render::render_slide(&st.doc, *i, &deckcraft_render::RenderOpts { scale, ..Default::default() });
let bytes = picture_bytes(&img, &format, scale);
let out = if slides.len() == 1 {
path.clone()
} else {
+28
View File
@@ -835,3 +835,31 @@ 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");
}
/// Default picture export is 2 px per point. The file says 144 dpi, so Preview shows the slide's size.
#[test]
fn a_png_export_records_the_slide_resolution() {
let mut s = session();
let dir = std::env::temp_dir().join(format!("deckcraft-dpi-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let png = dir.join("slide.png");
s.execute("file.export", &json!({"path": png.to_str().unwrap(), "format": "png"})).unwrap();
let bytes = std::fs::read(&png).unwrap();
assert_eq!(deckcraft_render::image_dpi(&bytes), Some((144.0, 144.0)));
assert_eq!(png_size(&bytes), Some((1920, 1080)), "960×540 pt at 2 px per point");
let one = dir.join("one.png");
s.execute("file.export", &json!({"path": one.to_str().unwrap(), "format": "png", "scale": 1})).unwrap();
let one_bytes = std::fs::read(&one).unwrap();
assert_eq!(deckcraft_render::image_dpi(&one_bytes), Some((72.0, 72.0)));
assert_eq!(png_size(&one_bytes), Some((960, 540)));
let jpg = dir.join("slide.jpg");
s.execute("file.export", &json!({"path": jpg.to_str().unwrap(), "format": "jpeg"})).unwrap();
assert_eq!(deckcraft_render::image_dpi(&std::fs::read(&jpg).unwrap()), Some((144.0, 144.0)));
let _ = std::fs::remove_dir_all(&dir);
}
fn png_size(bytes: &[u8]) -> Option<(u32, u32)> {
let w: [u8; 4] = bytes.get(16..20)?.try_into().ok()?;
let h: [u8; 4] = bytes.get(20..24)?.try_into().ok()?;
Some((u32::from_be_bytes(w), u32::from_be_bytes(h)))
}