mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-14 14:22:39 +00:00
Merge pull request #2651 from dotCooCoo/fix/placed-image-resolution
Place: images come in at the resolution stored in the file
This commit is contained in:
5 files changed
+670
-5
No files matched your search
@@ -589,6 +589,96 @@ pub(super) mod image_place_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A `width` x `height` PNG with a pHYs chunk of `ppm` pixels per meter on both axes, or none.
|
||||
fn png_with_density(width: u32, height: u32, ppm: Option<u32>) -> std::io::Result<tempfile::NamedTempFile> {
|
||||
fn crc32(data: &[u8]) -> u32 {
|
||||
let mut crc = 0xFFFF_FFFFu32;
|
||||
for &b in data {
|
||||
crc ^= b as u32;
|
||||
for _ in 0..8 {
|
||||
crc = if crc & 1 == 1 { (crc >> 1) ^ 0xEDB8_8320 } else { crc >> 1 };
|
||||
}
|
||||
}
|
||||
!crc
|
||||
}
|
||||
let pixels = [30, 90, 180, 255].repeat((width * height) as usize);
|
||||
let png = designcraft_render::Rendered { width, height, pixels }.to_png();
|
||||
assert_eq!(&png[12..16], b"IHDR");
|
||||
let mut bytes = png[..33].to_vec();
|
||||
if let Some(ppm) = ppm {
|
||||
let mut phys = Vec::new();
|
||||
phys.extend_from_slice(&ppm.to_be_bytes());
|
||||
phys.extend_from_slice(&ppm.to_be_bytes());
|
||||
phys.push(1);
|
||||
bytes.extend_from_slice(&(phys.len() as u32).to_be_bytes());
|
||||
bytes.extend_from_slice(b"pHYs");
|
||||
bytes.extend_from_slice(&phys);
|
||||
let mut c = b"pHYs".to_vec();
|
||||
c.extend_from_slice(&phys);
|
||||
bytes.extend_from_slice(&crc32(&c).to_be_bytes());
|
||||
}
|
||||
bytes.extend_from_slice(&png[33..]);
|
||||
let file = tempfile::Builder::new().suffix(".png").tempfile()?;
|
||||
std::fs::write(file.path(), bytes)?;
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placed_photo_honors_its_embedded_resolution() {
|
||||
// #1991: a placed graphic's embedded density sets its size (600 dpi: 40 px place
|
||||
// 4.8 pt wide), instead of a pixel always becoming a point.
|
||||
let file = png_with_density(40, 20, Some(23622)).unwrap();
|
||||
let mut session = Session::new();
|
||||
session.execute("file.new", &json!({})).unwrap();
|
||||
let placed = session.execute("file.place", &json!({"path": file.path(), "x": 10.0, "y": 20.0})).unwrap();
|
||||
let document = &session.doc().unwrap().doc;
|
||||
let item = document.item(ItemId(placed["id"].as_u64().unwrap())).unwrap();
|
||||
let b = item.bounds();
|
||||
assert!((b.x0 - 10.0).abs() < 1e-9 && (b.y0 - 20.0).abs() < 1e-9, "{b:?}");
|
||||
assert!((b.width() - 4.8).abs() < 0.01 && (b.height() - 2.4).abs() < 0.01, "{b:?}");
|
||||
}
|
||||
|
||||
/// File › Place, then a click: an 11 x 17 in image (330 x 510 px at 30 ppi) comes in at
|
||||
/// 792 x 1224 pt, keeps that size through a save and reopen, and an image with no stored
|
||||
/// resolution comes in at 72 ppi (330 x 510 pt).
|
||||
#[test]
|
||||
fn a_clicked_image_keeps_its_print_size_through_save_and_reopen() {
|
||||
// 1181 pixels per meter is 29.9974 ppi.
|
||||
let (w, h) = (330.0 * 72.0 / (1181.0 * 0.0254), 510.0 * 72.0 / (1181.0 * 0.0254));
|
||||
for (ppm, (ew, eh)) in [(Some(1181), (w, h)), (None, (330.0, 510.0))] {
|
||||
let image = png_with_density(330, 510, ppm).unwrap();
|
||||
let mut session = Session::new();
|
||||
session.execute("file.new", &json!({})).unwrap();
|
||||
session.execute("file.place", &json!({"path": image.path(), "loadCursor": true})).unwrap();
|
||||
session.execute("place.drop", &json!({"x": 0, "y": 0})).unwrap();
|
||||
// The bounds and natural size of the document's one placed graphic.
|
||||
let shown = |session: &Session| {
|
||||
let doc = &session.doc().unwrap().doc;
|
||||
let graphics: Vec<_> = doc
|
||||
.all_items()
|
||||
.into_iter()
|
||||
.filter_map(|id| doc.item(id))
|
||||
.filter_map(|item| match &item.content {
|
||||
Content::Graphic(g) => Some((item.bounds(), g.size)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(graphics.len(), 1, "{graphics:?}");
|
||||
graphics[0]
|
||||
};
|
||||
let (b, size) = shown(&session);
|
||||
assert!((b.width() - ew).abs() < 1e-6 && (b.height() - eh).abs() < 1e-6, "{ppm:?}: {b:?}");
|
||||
assert!((size.0 - ew).abs() < 1e-6 && (size.1 - eh).abs() < 1e-6, "{ppm:?}: {size:?}");
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("placed.designcraft");
|
||||
session.execute("file.save", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
let mut reopened = Session::new();
|
||||
reopened.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
assert_eq!(shown(&reopened), (b, size), "{ppm:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_frame_wins_over_placement_and_selection() {
|
||||
let image = image_file().unwrap();
|
||||
|
||||
@@ -12,6 +12,7 @@ pub mod colour;
|
||||
mod eps;
|
||||
mod jpeg;
|
||||
mod psd;
|
||||
mod resolution;
|
||||
mod tone;
|
||||
pub use eps::{bounding_box as eps_bounding_box, eps_pixel_size, eps_png, eps_proxy, eps_size, is_eps};
|
||||
pub use tone::{Tone, tone};
|
||||
@@ -358,8 +359,11 @@ pub fn svg_tree(bytes: &[u8]) -> Option<usvg::Tree> {
|
||||
usvg::Tree::from_data(&xml, svg_options()).ok()
|
||||
}
|
||||
|
||||
/// Natural size of a placed graphic in points: rasters at 72 ppi (one pixel per point), PSDs
|
||||
/// likewise, SVGs from their width/height (CSS pixels), PDFs are the caller's.
|
||||
pub use resolution::raster_resolution;
|
||||
|
||||
/// Natural size of a placed graphic in points: rasters at their embedded resolution when sane
|
||||
/// (one pixel per point, 72 ppi, without usable metadata), PSDs likewise, SVGs from their
|
||||
/// width/height (CSS pixels), PDFs are the caller's.
|
||||
pub fn natural_size(bytes: &[u8]) -> Option<(f64, f64)> {
|
||||
if is_svg(bytes) {
|
||||
let s = svg_tree(bytes)?.size();
|
||||
@@ -370,7 +374,12 @@ pub fn natural_size(bytes: &[u8]) -> Option<(f64, f64)> {
|
||||
return Some((bb[2] - bb[0], bb[3] - bb[1])).filter(|(w, h)| w.is_finite() && h.is_finite() && *w > 0.0 && *h > 0.0);
|
||||
}
|
||||
let (w, h) = pixel_size(bytes)?;
|
||||
Some((w as f64, h as f64))
|
||||
// Honor the file's density (#1991): a 600 px photo saved at 600 dpi places one inch wide.
|
||||
// Absurd metadata falls back to a pixel per point.
|
||||
match raster_resolution(bytes) {
|
||||
Some(dpi) if resolution::sane_resolution(dpi) => Some((w as f64 * 72.0 / dpi.0, h as f64 * 72.0 / dpi.1)),
|
||||
_ => Some((w as f64, h as f64)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Unpremultiplied RGBA pixels, rows top to bottom, as a PNG file with no resolution stored.
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
//! Embedded raster resolution: the density a placed graphic was saved with, so placing honors
|
||||
//! it instead of assuming 72 ppi (#1991: a 600 px photo saved at 600 dpi places one inch wide).
|
||||
//! Reads JFIF/EXIF from JPEG (and EXIF from WebP), pHYs from PNG, the resolution
|
||||
//! tags from TIFF, and the header density from BMP. GIF, PSD and files without (usable) metadata
|
||||
//! report nothing and keep the pixel-is-a-point size.
|
||||
|
||||
/// Dots per inch in x and y, when the file carries usable resolution metadata.
|
||||
pub fn raster_resolution(bytes: &[u8]) -> Option<(f64, f64)> {
|
||||
if bytes.len() < 8 {
|
||||
return None;
|
||||
}
|
||||
if bytes.starts_with(b"\xFF\xD8\xFF") {
|
||||
return jpeg_resolution(bytes);
|
||||
}
|
||||
if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
|
||||
return png_resolution(bytes);
|
||||
}
|
||||
if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP".as_slice()) {
|
||||
return webp_resolution(bytes);
|
||||
}
|
||||
if bytes.starts_with(b"BM") {
|
||||
return bmp_resolution(bytes);
|
||||
}
|
||||
if is_classic_tiff(bytes) {
|
||||
return tiff_resolution(bytes);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// A parsed density is usable when both axes are finite, positive and below the absurdity cap
|
||||
/// (a corrupt or hostile value falls back to 72 ppi at the call site).
|
||||
pub fn sane_resolution(dpi: (f64, f64)) -> bool {
|
||||
let (dx, dy) = dpi;
|
||||
dx.is_finite() && dy.is_finite() && dx >= 1.0 && dy >= 1.0 && dx <= 100_000.0 && dy <= 100_000.0
|
||||
}
|
||||
|
||||
fn is_classic_tiff(bytes: &[u8]) -> bool {
|
||||
bytes.starts_with(b"II*\x00") || bytes.starts_with(b"MM\x00*")
|
||||
}
|
||||
|
||||
// ---------- JPEG ----------
|
||||
|
||||
/// Scan JPEG markers for a JFIF density, preferring an EXIF resolution when both are present.
|
||||
/// Stops at the start of scan (entropy data can contain marker-like bytes).
|
||||
fn jpeg_resolution(bytes: &[u8]) -> Option<(f64, f64)> {
|
||||
let mut pos = 2;
|
||||
let mut jfif: Option<(f64, f64)> = None;
|
||||
for _ in 0..512 {
|
||||
// Markers are FF-prefixed; skip fill bytes. Running out of bytes ends the scan
|
||||
// (a truncated file keeps a JFIF density found before the truncation).
|
||||
let Some(mut m) = bytes.get(pos).copied() else { break };
|
||||
while m == 0xFF {
|
||||
pos += 1;
|
||||
let Some(b) = bytes.get(pos).copied() else { break };
|
||||
m = b;
|
||||
if m != 0xFF {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if m == 0xFF {
|
||||
break;
|
||||
}
|
||||
pos += 1;
|
||||
// Standalone markers carry no payload; SOS starts entropy-coded data.
|
||||
if m == 0xD8 || m == 0xD9 || (0xD0..=0xD7).contains(&m) || m == 0x01 {
|
||||
continue;
|
||||
}
|
||||
if m == 0xDA {
|
||||
break;
|
||||
}
|
||||
let Some(&a) = bytes.get(pos) else { break };
|
||||
let Some(&b) = bytes.get(pos + 1) else { break };
|
||||
let len = u16::from_be_bytes([a, b]) as usize;
|
||||
if len < 2 || pos + len > bytes.len() {
|
||||
break;
|
||||
}
|
||||
let payload = &bytes[pos + 2..pos + len];
|
||||
if m == 0xE0 && jfif.is_none() {
|
||||
jfif = jfif_density(payload);
|
||||
} else if m == 0xE1
|
||||
&& let Some(dpi) = exif_resolution(payload).filter(|dpi| sane_resolution(*dpi))
|
||||
{
|
||||
// A non-EXIF APP1 (XMP, ICC, ...) or an unusable EXIF density parses to nothing:
|
||||
// keep scanning, so a later APP1 or the JFIF density still sets the size.
|
||||
return Some(dpi);
|
||||
}
|
||||
pos += len;
|
||||
}
|
||||
jfif
|
||||
}
|
||||
|
||||
/// JFIF APP0 density: version, units (0 aspect, 1 dpi, 2 dots/cm), x/y density.
|
||||
fn jfif_density(payload: &[u8]) -> Option<(f64, f64)> {
|
||||
if !payload.starts_with(b"JFIF\x00") || payload.len() < 14 {
|
||||
return None;
|
||||
}
|
||||
let units = payload[7];
|
||||
let (x, y) = (u16::from_be_bytes([payload[8], payload[9]]), u16::from_be_bytes([payload[10], payload[11]]));
|
||||
match (units, x, y) {
|
||||
(1, 1.., 1..) => Some((x as f64, y as f64)),
|
||||
(2, 1.., 1..) => Some((x as f64 * 2.54, y as f64 * 2.54)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// EXIF APP1 payload ("Exif\0\0" + a TIFF header): the resolution tags of IFD0.
|
||||
fn exif_resolution(payload: &[u8]) -> Option<(f64, f64)> {
|
||||
let tiff = payload.strip_prefix(b"Exif\x00\x00")?;
|
||||
if !is_classic_tiff(tiff) {
|
||||
return None;
|
||||
}
|
||||
tiff_resolution(tiff)
|
||||
}
|
||||
|
||||
// ---------- TIFF (also the body of EXIF) ----------
|
||||
|
||||
/// Classic TIFF resolution tags of the first IFD: XResolution/YResolution rationals scaled by
|
||||
/// ResolutionUnit (2 = inch, 3 = cm; a missing unit defaults to inch per the spec).
|
||||
pub fn tiff_resolution(tiff: &[u8]) -> Option<(f64, f64)> {
|
||||
let le = match tiff.get(..2)? {
|
||||
b"II" => true,
|
||||
b"MM" => false,
|
||||
_ => return None,
|
||||
};
|
||||
let u16at = |i: usize| -> Option<u16> {
|
||||
let b: [u8; 2] = tiff.get(i..i.checked_add(2)?)?.try_into().ok()?;
|
||||
Some(if le { u16::from_le_bytes(b) } else { u16::from_be_bytes(b) })
|
||||
};
|
||||
let u32at = |i: usize| -> Option<u32> {
|
||||
let b: [u8; 4] = tiff.get(i..i.checked_add(4)?)?.try_into().ok()?;
|
||||
Some(if le { u32::from_le_bytes(b) } else { u32::from_be_bytes(b) })
|
||||
};
|
||||
let ifd = u32at(4)? as usize;
|
||||
let count = u16at(ifd)? as usize;
|
||||
if count > 512 {
|
||||
return None;
|
||||
}
|
||||
let mut xres: Option<(u32, u32)> = None;
|
||||
let mut yres: Option<(u32, u32)> = None;
|
||||
let mut unit: Option<u16> = None;
|
||||
for n in 0..count {
|
||||
let e = ifd.checked_add(2)?.checked_add(n.checked_mul(12)?)?;
|
||||
let (tag, typ, cnt) = (u16at(e)?, u16at(e + 2)?, u32at(e + 4)?);
|
||||
// SHORT values of count 1 live inline in the value field (low bytes LE, high bytes BE).
|
||||
let short_inline = || -> Option<u16> {
|
||||
let raw = u32at(e + 8)?;
|
||||
Some(if le { (raw & 0xFFFF) as u16 } else { (raw >> 16) as u16 })
|
||||
};
|
||||
// RATIONAL values of count 1 live at the offset (relative to the TIFF header start).
|
||||
let rational_at = || -> Option<(u32, u32)> {
|
||||
if cnt != 1 {
|
||||
return None;
|
||||
}
|
||||
let off = u32at(e + 8)? as usize;
|
||||
if off.checked_add(8)? > tiff.len() {
|
||||
return None;
|
||||
}
|
||||
Some((u32at(off)?, u32at(off + 4)?))
|
||||
};
|
||||
match (tag, typ) {
|
||||
(0x011A, 5) if xres.is_none() => xres = rational_at(),
|
||||
(0x011B, 5) if yres.is_none() => yres = rational_at(),
|
||||
(0x0128, 3) if unit.is_none() => unit = short_inline(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let num = |(n, d): (u32, u32)| (d != 0).then(|| n as f64 / d as f64);
|
||||
let (x, y) = (num(xres?)?, num(yres?)?);
|
||||
match unit.unwrap_or(2) {
|
||||
2 => Some((x, y)),
|
||||
3 => Some((x * 2.54, y * 2.54)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- PNG ----------
|
||||
|
||||
/// The pHYs chunk: pixels-per-unit in x and y plus the unit (1 = meter → dpi).
|
||||
fn png_resolution(bytes: &[u8]) -> Option<(f64, f64)> {
|
||||
let mut pos = 8;
|
||||
loop {
|
||||
let len = u32::from_be_bytes(bytes.get(pos..pos + 4)?.try_into().ok()?) as usize;
|
||||
let kind = bytes.get(pos + 4..pos + 8)?;
|
||||
if kind == b"pHYs" {
|
||||
let data = bytes.get(pos + 8..pos + 8 + 9)?;
|
||||
let (x, y) = (u32::from_be_bytes(data[0..4].try_into().ok()?), u32::from_be_bytes(data[4..8].try_into().ok()?));
|
||||
if data[8] == 1 && x > 0 && y > 0 {
|
||||
return Some((x as f64 * 0.0254, y as f64 * 0.0254));
|
||||
}
|
||||
return None;
|
||||
}
|
||||
if kind == b"IEND" || kind == b"IDAT" {
|
||||
return None;
|
||||
}
|
||||
pos = pos.checked_add(8)?.checked_add(len)?.checked_add(4)?;
|
||||
if pos >= bytes.len() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- WebP ----------
|
||||
|
||||
/// A WebP EXIF chunk carries a TIFF header: reuse the TIFF resolution tags.
|
||||
fn webp_resolution(bytes: &[u8]) -> Option<(f64, f64)> {
|
||||
// The RIFF size counts the bytes after the size field itself (offset 8).
|
||||
let riff = u32::from_le_bytes(bytes.get(4..8)?.try_into().ok()?) as usize;
|
||||
let end = 8usize.checked_add(riff)?.min(bytes.len());
|
||||
let mut pos = 12;
|
||||
loop {
|
||||
let kind = bytes.get(pos..pos + 4)?;
|
||||
let size = u32::from_le_bytes(bytes.get(pos + 4..pos + 8)?.try_into().ok()?) as usize;
|
||||
let data = pos.checked_add(8)?;
|
||||
if data.checked_add(size)? > end {
|
||||
return None;
|
||||
}
|
||||
if kind == b"EXIF" {
|
||||
let exif = bytes.get(data..data + size)?;
|
||||
let tiff = exif.strip_prefix(b"Exif\x00\x00").unwrap_or(exif);
|
||||
if is_classic_tiff(tiff) {
|
||||
return tiff_resolution(tiff);
|
||||
}
|
||||
}
|
||||
pos = data.checked_add(size.checked_add(size & 1)?)?;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- BMP ----------
|
||||
|
||||
/// BITMAPINFOHEADER pixels-per-meter (offset 38/42): meters → dpi.
|
||||
fn bmp_resolution(bytes: &[u8]) -> Option<(f64, f64)> {
|
||||
if bytes.len() < 46 || u32::from_le_bytes(bytes.get(14..18)?.try_into().ok()?) < 40 {
|
||||
return None;
|
||||
}
|
||||
let ppm = |i: usize| -> Option<i32> { Some(i32::from_le_bytes(bytes.get(i..i + 4)?.try_into().ok()?)) };
|
||||
let (x, y) = (ppm(38)?, ppm(42)?);
|
||||
if x > 0 && y > 0 {
|
||||
return Some((x as f64 * 0.0254, y as f64 * 0.0254));
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -432,6 +432,331 @@ fn cmyk_tiff_with_alpha_reports_its_pixel_size() {
|
||||
assert_eq!(pixel_size(&b), Some((2, 1)));
|
||||
}
|
||||
|
||||
// ---------- embedded resolution (#1991) ----------
|
||||
|
||||
/// A real encoder-written 600x900 JPEG with an APPn segment inserted after SOI (decoders skip
|
||||
/// APPn, so the file stays fully valid and dimension readers exercise the real path).
|
||||
fn jpeg_with(app: &[u8]) -> Vec<u8> {
|
||||
use image::codecs::jpeg::JpegEncoder;
|
||||
let img = image::RgbaImage::from_pixel(600, 900, image::Rgba([200, 30, 30, 255]));
|
||||
let mut b = Vec::new();
|
||||
JpegEncoder::new_with_quality(&mut b, 90).encode_image(&img).unwrap();
|
||||
assert_eq!(&b[..2], &[0xFF, 0xD8]);
|
||||
let mut out = b[..2].to_vec();
|
||||
out.extend_from_slice(app);
|
||||
out.extend_from_slice(&b[2..]);
|
||||
out
|
||||
}
|
||||
|
||||
/// JFIF APP0 with the given units (0 aspect, 1 dpi, 2 per cm) and densities.
|
||||
fn jfif_app(units: u8, x: u16, y: u16) -> Vec<u8> {
|
||||
let mut a = vec![0xFF, 0xE0, 0x00, 0x10, b'J', b'F', b'I', b'F', 0x00, 0x01, 0x02, units];
|
||||
a.extend_from_slice(&x.to_be_bytes());
|
||||
a.extend_from_slice(&y.to_be_bytes());
|
||||
a.extend_from_slice(&[0x00, 0x00]);
|
||||
a
|
||||
}
|
||||
|
||||
/// Minimal little-endian TIFF with X/YResolution rationals and an optional ResolutionUnit.
|
||||
fn tiff_bytes(x: (u32, u32), y: (u32, u32), unit: Option<u16>) -> Vec<u8> {
|
||||
let mut t = vec![b'I', b'I', 0x2A, 0x00, 0x08, 0x00, 0x00, 0x00];
|
||||
let n = if unit.is_some() { 3 } else { 2 };
|
||||
t.extend_from_slice(&(n as u16).to_le_bytes());
|
||||
let data = 8 + 2 + n * 12 + 4;
|
||||
let mut entry = |tag: u16, typ: u16, val: u32| {
|
||||
t.extend_from_slice(&tag.to_le_bytes());
|
||||
t.extend_from_slice(&typ.to_le_bytes());
|
||||
t.extend_from_slice(&1u32.to_le_bytes());
|
||||
t.extend_from_slice(&val.to_le_bytes());
|
||||
};
|
||||
entry(0x011A, 5, data as u32);
|
||||
entry(0x011B, 5, (data + 8) as u32);
|
||||
if let Some(u) = unit {
|
||||
// SHORT count 1: the value sits inline in the low bytes.
|
||||
t.extend_from_slice(&0x0128u16.to_le_bytes());
|
||||
t.extend_from_slice(&3u16.to_le_bytes());
|
||||
t.extend_from_slice(&1u32.to_le_bytes());
|
||||
t.extend_from_slice(&u.to_le_bytes());
|
||||
t.extend_from_slice(&[0, 0]);
|
||||
}
|
||||
t.extend_from_slice(&[0, 0, 0, 0]);
|
||||
assert_eq!(t.len(), data);
|
||||
for (num, den) in [x, y] {
|
||||
t.extend_from_slice(&num.to_le_bytes());
|
||||
t.extend_from_slice(&den.to_le_bytes());
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
/// EXIF APP1 wrapping a TIFF header.
|
||||
fn exif_app(tiff: &[u8]) -> Vec<u8> {
|
||||
let mut a = vec![0xFF, 0xE1];
|
||||
a.extend_from_slice(&((tiff.len() + 8) as u16).to_be_bytes());
|
||||
a.extend_from_slice(b"Exif\x00\x00");
|
||||
a.extend_from_slice(tiff);
|
||||
a
|
||||
}
|
||||
|
||||
/// #1991: a 600x900 px photo saved at 600 dpi places 1 x 1.5 in, not 600 x 900 pt.
|
||||
#[test]
|
||||
fn jpeg_jfif_density_sets_natural_size() {
|
||||
let jpg = jpeg_with(&jfif_app(1, 600, 600));
|
||||
assert_eq!(pixel_size(&jpg), Some((600, 900)));
|
||||
assert_eq!(raster_resolution(&jpg), Some((600.0, 600.0)));
|
||||
assert_eq!(natural_size(&jpg), Some((72.0, 108.0)));
|
||||
// Dots per cm convert to dpi; aspect-only JFIF (units 0) carries no density.
|
||||
let cm = jpeg_with(&jfif_app(2, 100, 100));
|
||||
assert_eq!(raster_resolution(&cm), Some((254.0, 254.0)));
|
||||
let aspect = jpeg_with(&jfif_app(0, 600, 600));
|
||||
assert_eq!(raster_resolution(&aspect), None);
|
||||
assert_eq!(natural_size(&aspect), Some((600.0, 900.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpeg_exif_resolution_beats_jfif() {
|
||||
// EXIF wins over a conflicting JFIF density.
|
||||
let mut app = jfif_app(1, 72, 72);
|
||||
app.extend_from_slice(&exif_app(&tiff_bytes((300, 1), (300, 1), Some(2))));
|
||||
let jpg = jpeg_with(&app);
|
||||
assert_eq!(raster_resolution(&jpg), Some((300.0, 300.0)));
|
||||
assert_eq!(natural_size(&jpg), Some((600.0 * 72.0 / 300.0, 900.0 * 72.0 / 300.0)));
|
||||
// A missing unit defaults to inch; per-cm scales by 2.54.
|
||||
let no_unit = jpeg_with(&exif_app(&tiff_bytes((300, 1), (300, 1), None)));
|
||||
assert_eq!(raster_resolution(&no_unit), Some((300.0, 300.0)));
|
||||
// A non-EXIF APP1 (XMP) before the EXIF one does not stop the scan.
|
||||
let mut xmp = vec![0xFF, 0xE1];
|
||||
let xmp_payload = b"http://ns.adobe.com/xap/1.0/\x00<x:xmpmeta/>";
|
||||
xmp.extend_from_slice(&((xmp_payload.len() + 2) as u16).to_be_bytes());
|
||||
xmp.extend_from_slice(xmp_payload);
|
||||
xmp.extend_from_slice(&exif_app(&tiff_bytes((300, 1), (300, 1), Some(2))));
|
||||
assert_eq!(raster_resolution(&jpeg_with(&xmp)), Some((300.0, 300.0)));
|
||||
let cm = jpeg_with(&exif_app(&tiff_bytes((100, 1), (100, 1), Some(3))));
|
||||
assert_eq!(raster_resolution(&cm), Some((254.0, 254.0)));
|
||||
// A zero denominator or a missing axis is unusable: back to a pixel per point.
|
||||
let zero = jpeg_with(&exif_app(&tiff_bytes((300, 0), (300, 1), Some(2))));
|
||||
assert_eq!(raster_resolution(&zero), None);
|
||||
}
|
||||
|
||||
/// An EXIF density of 0 ppi (or any value outside the usable range) does not hide the JFIF
|
||||
/// density: the 600x900 px photo at a JFIF 300 dpi still places 144 x 216 pt.
|
||||
#[test]
|
||||
fn unusable_exif_density_keeps_the_jfif_density() {
|
||||
for exif in [(0, 1), (1, 4), (1_000_000, 1)] {
|
||||
let mut app = jfif_app(1, 300, 300);
|
||||
app.extend_from_slice(&exif_app(&tiff_bytes(exif, exif, Some(2))));
|
||||
let jpg = jpeg_with(&app);
|
||||
assert_eq!(raster_resolution(&jpg), Some((300.0, 300.0)), "EXIF {exif:?}");
|
||||
assert_eq!(natural_size(&jpg), Some((144.0, 216.0)), "EXIF {exif:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Big-endian EXIF parses too.
|
||||
#[test]
|
||||
fn jpeg_exif_big_endian_parses() {
|
||||
let mut t = vec![b'M', b'M', 0x00, 0x2A, 0x00, 0x00, 0x00, 0x08];
|
||||
t.extend_from_slice(&[0x00, 0x02]);
|
||||
let mut entry = |tag: u16, val: u32| {
|
||||
t.extend_from_slice(&tag.to_be_bytes());
|
||||
t.extend_from_slice(&5u16.to_be_bytes());
|
||||
t.extend_from_slice(&1u32.to_be_bytes());
|
||||
t.extend_from_slice(&val.to_be_bytes());
|
||||
};
|
||||
entry(0x011A, 38);
|
||||
entry(0x011B, 46);
|
||||
t.extend_from_slice(&[0, 0, 0, 0]);
|
||||
assert_eq!(t.len(), 38);
|
||||
for (num, den) in [(600u32, 2u32), (600u32, 2u32)] {
|
||||
t.extend_from_slice(&num.to_be_bytes());
|
||||
t.extend_from_slice(&den.to_be_bytes());
|
||||
}
|
||||
let jpg = jpeg_with(&exif_app(&t));
|
||||
assert_eq!(raster_resolution(&jpg), Some((300.0, 300.0)));
|
||||
assert_eq!(natural_size(&jpg), Some((144.0, 216.0)));
|
||||
}
|
||||
|
||||
fn crc32(data: &[u8]) -> u32 {
|
||||
let mut crc = 0xFFFF_FFFFu32;
|
||||
for &b in data {
|
||||
crc ^= b as u32;
|
||||
for _ in 0..8 {
|
||||
crc = if crc & 1 == 1 { (crc >> 1) ^ 0xEDB8_8320 } else { crc >> 1 };
|
||||
}
|
||||
}
|
||||
!crc
|
||||
}
|
||||
|
||||
/// A real encoder-written PNG with a pHYs chunk spliced in after IHDR (valid CRC, so the
|
||||
/// file stays fully decodable and exercises the real dimension path).
|
||||
fn png_with_phys(ppu: u32) -> Vec<u8> {
|
||||
let img = image::RgbaImage::from_pixel(600, 900, image::Rgba([30, 200, 30, 255]));
|
||||
let mut b = Vec::new();
|
||||
img.write_to(&mut std::io::Cursor::new(&mut b), image::ImageFormat::Png).unwrap();
|
||||
// Signature (8) + IHDR chunk (length 4 + type 4 + data 13 + CRC 4).
|
||||
assert_eq!(&b[..8], b"\x89PNG\r\n\x1a\n");
|
||||
assert_eq!(&b[12..16], b"IHDR");
|
||||
let mut out = b[..33].to_vec();
|
||||
let mut phys = Vec::new();
|
||||
phys.extend_from_slice(&ppu.to_be_bytes());
|
||||
phys.extend_from_slice(&ppu.to_be_bytes());
|
||||
phys.push(1);
|
||||
out.extend_from_slice(&(phys.len() as u32).to_be_bytes());
|
||||
out.extend_from_slice(b"pHYs");
|
||||
out.extend_from_slice(&phys);
|
||||
let mut c = b"pHYs".to_vec();
|
||||
c.extend_from_slice(&phys);
|
||||
out.extend_from_slice(&crc32(&c).to_be_bytes());
|
||||
out.extend_from_slice(&b[33..]);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn png_phys_sets_natural_size() {
|
||||
// 23622 px/m = 600 dpi: the photo places 1 x 1.5 in.
|
||||
let png = png_with_phys(23622);
|
||||
assert_eq!(pixel_size(&png), Some((600, 900)));
|
||||
let (dx, _) = raster_resolution(&png).unwrap();
|
||||
assert!((dx - 600.0).abs() < 0.05, "{dx}");
|
||||
let (w, h) = natural_size(&png).unwrap();
|
||||
assert!((w - 72.0).abs() < 0.01 && (h - 108.0).abs() < 0.02, "{w} x {h}");
|
||||
// No pHYs: a pixel is a point.
|
||||
let plain_img = image::RgbaImage::from_pixel(4, 4, image::Rgba([0, 0, 0, 255]));
|
||||
let mut plain = Vec::new();
|
||||
plain_img.write_to(&mut std::io::Cursor::new(&mut plain), image::ImageFormat::Png).unwrap();
|
||||
assert_eq!(raster_resolution(&plain), None);
|
||||
assert_eq!(natural_size(&plain), Some((4.0, 4.0)));
|
||||
}
|
||||
|
||||
/// A pHYs chunk behind 100 text chunks is still read, and the scan stops at the first IDAT.
|
||||
#[test]
|
||||
fn png_phys_after_many_chunks_is_read() {
|
||||
fn chunk(out: &mut Vec<u8>, kind: &[u8; 4], data: &[u8]) {
|
||||
out.extend_from_slice(&(data.len() as u32).to_be_bytes());
|
||||
out.extend_from_slice(kind);
|
||||
out.extend_from_slice(data);
|
||||
out.extend_from_slice(&[0, 0, 0, 0]);
|
||||
}
|
||||
let mut phys = 11811u32.to_be_bytes().repeat(2);
|
||||
phys.push(1);
|
||||
let mut png = b"\x89PNG\r\n\x1a\n".to_vec();
|
||||
chunk(&mut png, b"IHDR", &[0; 13]);
|
||||
for _ in 0..100 {
|
||||
chunk(&mut png, b"tEXt", b"Comment\x00x");
|
||||
}
|
||||
let mut late = png.clone();
|
||||
chunk(&mut png, b"pHYs", &phys);
|
||||
let (dx, dy) = raster_resolution(&png).unwrap();
|
||||
assert!((dx - 300.0).abs() < 0.05 && (dy - 300.0).abs() < 0.05, "{dx} x {dy}");
|
||||
// A pHYs after the image data is not read.
|
||||
chunk(&mut late, b"IDAT", &[0; 4]);
|
||||
chunk(&mut late, b"pHYs", &phys);
|
||||
assert_eq!(raster_resolution(&late), None);
|
||||
}
|
||||
|
||||
/// An EXIF chunk stored after 100 animation frames of a WebP is still read.
|
||||
#[test]
|
||||
fn webp_exif_after_many_frames_is_read() {
|
||||
fn chunk(out: &mut Vec<u8>, kind: &[u8; 4], data: &[u8]) {
|
||||
out.extend_from_slice(kind);
|
||||
out.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(data);
|
||||
if data.len() % 2 == 1 {
|
||||
out.push(0);
|
||||
}
|
||||
}
|
||||
let mut body = b"WEBP".to_vec();
|
||||
chunk(&mut body, b"VP8X", &[0; 10]);
|
||||
chunk(&mut body, b"ANIM", &[0; 6]);
|
||||
for _ in 0..100 {
|
||||
chunk(&mut body, b"ANMF", &[0; 17]);
|
||||
}
|
||||
chunk(&mut body, b"EXIF", &tiff_bytes((300, 1), (300, 1), Some(2)));
|
||||
let mut webp = b"RIFF".to_vec();
|
||||
webp.extend_from_slice(&(body.len() as u32).to_le_bytes());
|
||||
webp.extend_from_slice(&body);
|
||||
assert_eq!(raster_resolution(&webp), Some((300.0, 300.0)));
|
||||
}
|
||||
|
||||
/// The RIFF size counts the bytes after its own field (offset 8): an EXIF chunk that runs past
|
||||
/// the declared container is not read, even when trailing bytes follow the container.
|
||||
#[test]
|
||||
fn webp_exif_past_the_declared_riff_end_is_not_read() {
|
||||
let mut body = b"WEBP".to_vec();
|
||||
body.extend_from_slice(b"VP8X");
|
||||
body.extend_from_slice(&10u32.to_le_bytes());
|
||||
body.extend_from_slice(&[0; 10]);
|
||||
let exif = tiff_bytes((300, 1), (300, 1), Some(2));
|
||||
body.extend_from_slice(b"EXIF");
|
||||
body.extend_from_slice(&(exif.len() as u32).to_le_bytes());
|
||||
body.extend_from_slice(&exif);
|
||||
if exif.len() % 2 == 1 {
|
||||
body.push(0);
|
||||
}
|
||||
let riff = |size: usize, trailing: usize| {
|
||||
let mut webp = b"RIFF".to_vec();
|
||||
webp.extend_from_slice(&(size as u32).to_le_bytes());
|
||||
webp.extend_from_slice(&body);
|
||||
webp.extend_from_slice(&vec![0xAA; trailing]);
|
||||
webp
|
||||
};
|
||||
// The exact size, with or without trailing data, reads the chunk.
|
||||
assert_eq!(raster_resolution(&riff(body.len(), 0)), Some((300.0, 300.0)));
|
||||
assert_eq!(raster_resolution(&riff(body.len(), 4)), Some((300.0, 300.0)));
|
||||
// A size declared 4 bytes short ends the container inside the EXIF chunk.
|
||||
assert_eq!(raster_resolution(&riff(body.len() - 4, 4)), None);
|
||||
assert_eq!(raster_resolution(&riff(body.len() - 4, 0)), None);
|
||||
// A file cut inside the EXIF chunk is not read either.
|
||||
let mut cut = riff(body.len(), 0);
|
||||
cut.truncate(cut.len() - 6);
|
||||
assert_eq!(raster_resolution(&cut), None);
|
||||
}
|
||||
|
||||
/// A real encoder-written BMP with the header pixels-per-meter patched (compression and image
|
||||
/// size stay zero, as the encoder writes them).
|
||||
fn bmp_with_ppm(ppm: i32) -> Vec<u8> {
|
||||
let img = image::RgbaImage::from_pixel(600, 900, image::Rgba([30, 30, 200, 255]));
|
||||
let mut b = Vec::new();
|
||||
img.write_to(&mut std::io::Cursor::new(&mut b), image::ImageFormat::Bmp).unwrap();
|
||||
assert_eq!(&b[..2], b"BM");
|
||||
b[38..42].copy_from_slice(&ppm.to_le_bytes());
|
||||
b[42..46].copy_from_slice(&ppm.to_le_bytes());
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bmp_density_sets_natural_size() {
|
||||
// 2835 px/m = 72 dpi: the bitmap keeps its pixel size in points.
|
||||
let bmp = bmp_with_ppm(2835);
|
||||
assert_eq!(pixel_size(&bmp), Some((600, 900)));
|
||||
let (dx, _) = raster_resolution(&bmp).unwrap();
|
||||
assert!((dx - 72.0).abs() < 0.05, "{dx}");
|
||||
// 600 dpi (23622 px/m): the bitmap places 1 x 1.5 in.
|
||||
let dense = bmp_with_ppm(23622);
|
||||
let (w, h) = natural_size(&dense).unwrap();
|
||||
assert!((w - 72.0).abs() < 0.01 && (h - 108.0).abs() < 0.02, "{w} x {h}");
|
||||
// Zero density: no metadata, a pixel is a point.
|
||||
let flat = bmp_with_ppm(0);
|
||||
assert_eq!(raster_resolution(&flat), None);
|
||||
assert_eq!(natural_size(&flat), Some((600.0, 900.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiff_resolution_tags_set_natural_size() {
|
||||
use tiff::encoder::colortype::Gray8;
|
||||
// A TIFF the encoder writes carries no density: pixels are points.
|
||||
let mut plain = Vec::new();
|
||||
{
|
||||
let mut enc = tiff::encoder::TiffEncoder::new(std::io::Cursor::new(&mut plain)).unwrap();
|
||||
enc.new_image::<Gray8>(6, 9).unwrap().write_data(&[0u8; 54]).unwrap();
|
||||
}
|
||||
assert_eq!(pixel_size(&plain), Some((6, 9)));
|
||||
assert_eq!(natural_size(&plain), Some((6.0, 9.0)));
|
||||
// The hand-built tag body parses (300 dpi, inch default and explicit).
|
||||
for unit in [None, Some(2)] {
|
||||
let t = tiff_bytes((300, 1), (300, 1), unit);
|
||||
assert_eq!(resolution::tiff_resolution(&t), Some((300.0, 300.0)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bmp_decodes() {
|
||||
let mut bmp = Vec::new();
|
||||
|
||||
@@ -87,8 +87,8 @@ pub fn image_size(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
}
|
||||
|
||||
/// Natural size of a placed graphic, unrounded: a PDF page (crop box) and an SVG in points, a raster
|
||||
/// in pixels, an EPS its bounding box independently of preview pixels. Placed graphics are drawn at
|
||||
/// this size; `image_size` rounds it to whole pixels for display.
|
||||
/// at its stored resolution (72 ppi without one), an EPS its bounding box independently of preview pixels. Placed graphics are drawn at
|
||||
/// this size; `image_size` gives a raster's pixel size and rounds a PDF page or SVG to whole points.
|
||||
pub fn natural_size(bytes: &[u8], page: usize) -> Option<(f64, f64)> {
|
||||
if is_pdf(bytes) {
|
||||
return pdf_page_size(bytes, page).map(|(w, h)| (w.max(1e-3), h.max(1e-3)));
|
||||
|
||||
Reference in new issue
Block a user