mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-01 22:47:04 -06:00
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:
@@ -173,6 +173,97 @@ pub fn dpi(bytes: &[u8]) -> Option<(f64, f64)> {
|
||||
None
|
||||
}
|
||||
|
||||
/// A copy of a PNG with a `pHYs` chunk saying `dpi` dots per inch.
|
||||
///
|
||||
/// Anything that is not a PNG, or a resolution the reader would reject, is returned unchanged.
|
||||
/// A file that already has `pHYs` is left alone.
|
||||
pub(crate) fn stamp_png_dpi(png: &[u8], dpi: f64) -> Vec<u8> {
|
||||
let Some(ppm) = pixels_per_metre(dpi) else { return png.to_vec() };
|
||||
let Some(end) = ihdr_end(png) else { return png.to_vec() };
|
||||
if has_phys(png) {
|
||||
return png.to_vec();
|
||||
}
|
||||
let mut body = Vec::with_capacity(13);
|
||||
body.extend_from_slice(b"pHYs");
|
||||
body.extend_from_slice(&ppm.to_be_bytes());
|
||||
body.extend_from_slice(&ppm.to_be_bytes());
|
||||
body.push(1);
|
||||
let crc = png_crc(&body);
|
||||
let mut out = Vec::with_capacity(png.len() + 21);
|
||||
out.extend_from_slice(png.get(..end).unwrap_or(png));
|
||||
out.extend_from_slice(&9u32.to_be_bytes());
|
||||
out.extend_from_slice(&body);
|
||||
out.extend_from_slice(&crc.to_be_bytes());
|
||||
if let Some(rest) = png.get(end..) {
|
||||
out.extend_from_slice(rest);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Dots per inch as pixels per metre, the unit a PNG `pHYs` chunk stores.
|
||||
fn pixels_per_metre(dpi: f64) -> Option<u32> {
|
||||
if !dpi.is_finite() || !(1.0..=10_000.0).contains(&dpi) {
|
||||
return None;
|
||||
}
|
||||
let ppm = (dpi / 0.0254).round();
|
||||
if !ppm.is_finite() || !(1.0..=u32::MAX as f64).contains(&ppm) {
|
||||
return None;
|
||||
}
|
||||
Some(ppm as u32)
|
||||
}
|
||||
|
||||
fn ihdr_end(png: &[u8]) -> Option<usize> {
|
||||
if !png.starts_with(b"\x89PNG\r\n\x1a\n") {
|
||||
return None;
|
||||
}
|
||||
let len = be_u32(png, 8)? as usize;
|
||||
if png.get(12..16) != Some(b"IHDR".as_slice()) {
|
||||
return None;
|
||||
}
|
||||
let end = 8usize.checked_add(12)?.checked_add(len)?;
|
||||
(end <= png.len()).then_some(end)
|
||||
}
|
||||
|
||||
fn has_phys(png: &[u8]) -> bool {
|
||||
let mut i = 8usize;
|
||||
for _ in 0..64 {
|
||||
let len = match be_u32(png, i) {
|
||||
Some(n) => n as usize,
|
||||
None => return false,
|
||||
};
|
||||
let Some(kind) = png.get(i + 4..i + 8) else { return false };
|
||||
if kind == b"pHYs" {
|
||||
return true;
|
||||
}
|
||||
if kind == b"IDAT" || kind == b"IEND" {
|
||||
return false;
|
||||
}
|
||||
i = match i.checked_add(12).and_then(|n| n.checked_add(len)) {
|
||||
Some(n) if n <= png.len() => n,
|
||||
_ => return false,
|
||||
};
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn be_u32(bytes: &[u8], i: usize) -> Option<u32> {
|
||||
let s = bytes.get(i..i + 4)?;
|
||||
let s: [u8; 4] = s.try_into().ok()?;
|
||||
Some(u32::from_be_bytes(s))
|
||||
}
|
||||
|
||||
fn png_crc(data: &[u8]) -> u32 {
|
||||
let mut c = 0xFFFF_FFFFu32;
|
||||
for &b in data {
|
||||
c ^= u32::from(b);
|
||||
for _ in 0..8 {
|
||||
let mask = 0u32.wrapping_sub(c & 1);
|
||||
c = (c >> 1) ^ (0xEDB8_8320 & mask);
|
||||
}
|
||||
}
|
||||
!c
|
||||
}
|
||||
|
||||
/// A premultiplied pixmap of the picture, downsampled so its longer side is near `want` px.
|
||||
pub fn decode_for(bytes: &Arc<Vec<u8>>, want: f64, adj: &PictureAdjust) -> Option<Arc<Pixmap>> {
|
||||
let addr = Arc::as_ptr(bytes) as usize;
|
||||
|
||||
@@ -89,12 +89,30 @@ impl Image {
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
/// PNG of this image whose `pHYs` chunk says `dpi` dots per inch.
|
||||
///
|
||||
/// Slide space is points, 72 per inch, so a render at `scale` pixels per point is
|
||||
/// [`dpi_for_scale`]`(scale)`. Without the chunk, Preview treats the pixels as 72 dpi and a
|
||||
/// Retina (2×) export opens at twice the slide's size.
|
||||
pub fn to_png_at(&self, dpi: f64) -> Vec<u8> {
|
||||
images::stamp_png_dpi(&self.to_png(), dpi)
|
||||
}
|
||||
|
||||
pub fn to_jpeg(&self, quality: u8) -> Vec<u8> {
|
||||
self.to_jpeg_at(quality, None)
|
||||
}
|
||||
|
||||
/// JPEG of this image. `dpi` is stored as JFIF density in dots per inch when it is in range.
|
||||
pub fn to_jpeg_at(&self, quality: u8, dpi: Option<f64>) -> Vec<u8> {
|
||||
let straight = self.to_straight();
|
||||
let rgb: Vec<u8> = straight.as_chunks::<4>().0.iter().flat_map(|p| [p[0], p[1], p[2]]).collect();
|
||||
let Some(img) = image::RgbImage::from_raw(self.width, self.height, rgb) else { return Vec::new() };
|
||||
let mut buf = Vec::new();
|
||||
let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, quality.clamp(1, 100));
|
||||
let mut enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, quality.clamp(1, 100));
|
||||
if let Some(d) = dpi.and_then(dpi_u16) {
|
||||
enc.set_pixel_density(image::codecs::jpeg::PixelDensity::dpi(d));
|
||||
}
|
||||
if let Err(e) = img.write_with_encoder(enc) {
|
||||
log::error!("JPEG encode: {e}");
|
||||
return Vec::new();
|
||||
@@ -111,6 +129,29 @@ impl Image {
|
||||
}
|
||||
}
|
||||
|
||||
/// Dots per inch for a slide drawn at `pixels_per_point`.
|
||||
///
|
||||
/// A point is 1/72 inch. The default picture export uses 2 pixels per point, which is 144 dpi:
|
||||
/// the same pixels, at the slide's real size, on a Retina display.
|
||||
pub fn dpi_for_scale(pixels_per_point: f64) -> f64 {
|
||||
if !pixels_per_point.is_finite() || pixels_per_point <= 0.0 {
|
||||
return 72.0;
|
||||
}
|
||||
(pixels_per_point * 72.0).clamp(1.0, 10_000.0)
|
||||
}
|
||||
|
||||
fn dpi_u16(dpi: f64) -> Option<u16> {
|
||||
if !dpi.is_finite() || !(1.0..=f64::from(u16::MAX)).contains(&dpi) {
|
||||
return None;
|
||||
}
|
||||
Some(dpi.round() as u16)
|
||||
}
|
||||
|
||||
/// Resolution a PNG (`pHYs`) or JPEG (JFIF) records, in whole dots per inch.
|
||||
pub fn image_dpi(bytes: &[u8]) -> Option<(f64, f64)> {
|
||||
images::dpi(bytes)
|
||||
}
|
||||
|
||||
/// Animation state of one shape during a slide show or preview.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ShapeState {
|
||||
|
||||
@@ -171,9 +171,28 @@ fn png_encodes() {
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 0.1, ..Default::default() });
|
||||
let png = img.to_png();
|
||||
assert!(png.starts_with(&[0x89, b'P', b'N', b'G']));
|
||||
assert_eq!(image_dpi(&png), None, "an untagged screenshot stays without a resolution");
|
||||
assert!(!img.to_jpeg(80).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_retina_export_records_144_dpi_and_still_decodes() {
|
||||
assert_eq!(dpi_for_scale(2.0), 144.0);
|
||||
assert_eq!(dpi_for_scale(1.0), 72.0);
|
||||
assert_eq!(dpi_for_scale(f64::NAN), 72.0);
|
||||
let img = Image { width: 4, height: 2, pixels: [255, 0, 0, 255].repeat(8) };
|
||||
let png = img.to_png_at(144.0);
|
||||
assert_eq!(image_dpi(&png), Some((144.0, 144.0)));
|
||||
let decoded = image::load_from_memory(&png).unwrap();
|
||||
assert_eq!((decoded.width(), decoded.height()), (4, 2));
|
||||
let again = images::stamp_png_dpi(&png, 72.0);
|
||||
assert_eq!(again.len(), png.len(), "a second pHYs chunk is not written");
|
||||
assert_eq!(image_dpi(&again), Some((144.0, 144.0)));
|
||||
let jpeg = img.to_jpeg_at(90, Some(144.0));
|
||||
assert_eq!(image_dpi(&jpeg), Some((144.0, 144.0)));
|
||||
assert_eq!(image_dpi(&img.to_jpeg(90)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_dest_math() {
|
||||
let r = crop_dest(Rect::new(0.0, 0.0, 100.0, 100.0), [0.5, 0.0, 0.0, 0.0]);
|
||||
|
||||
Reference in New Issue
Block a user