Files
photocraft/crates/engine/tests/jpeg_exif_resolution.rs
T
Gert Pellin - GPE 58289b2822 fix(codecs): read the resolution from EXIF, XMP and Photoshop APP13; write it consistently (#1691) (#1744)
A camera JPEG (e.g. Nikon D3200) records its resolution only in EXIF IFD0, so it
opened at 72 ppi instead of 300, and saving wrote JFIF 72 next to the source's
EXIF 300.

Reading: a JPEG takes the first usable of APP13 Photoshop ResolutionInfo
(0x03ED), XMP tiff:X/YResolution, EXIF IFD0 X/YResolution + ResolutionUnit
(2 inch, also when absent; 3 cm), and only then the JFIF density. That is the
order Photoshop resolves them in; JFIF comes last because cameras write EXIF
only while many tools write a default JFIF 72. Other formats (WebP, HEIF, PNG
without pHYs, ...) fall back to XMP then EXIF when they have no resolution of
their own. All parsing is bounds-checked; malformed data reads as no
resolution.

Writing: JPEG, PNG, WebP, TIFF (XMP), PSD and Save for Web JPEG rewrite the
EXIF/XMP resolution to the image's: RATIONAL values in place, ResolutionUnit 2,
and an entry that can't be rewritten safely is removed from IFD0 without moving
any other data.
2026-10-09 22:07:59 +07:00

85 lines
3.8 KiB
Rust

//! #1691: a camera JPEG with its resolution only in EXIF opens at that resolution, and a JPEG
//! saved after Image Size (resolution only) carries the new resolution in JFIF and EXIF alike.
use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, Metadata, SampleType, exif_resolution};
use photocraft_engine::Session;
use photocraft_engine::file_cmds::open_bytes_as;
use serde_json::json;
/// Little-endian EXIF: Make "NIKON", XResolution 300/1, YResolution 300/1, ResolutionUnit 2.
fn camera_exif() -> Vec<u8> {
let mut v = b"II*\0".to_vec();
v.extend_from_slice(&8u32.to_le_bytes());
v.extend_from_slice(&4u16.to_le_bytes());
let data = 8 + 2 + 4 * 12 + 4;
let entry = |v: &mut Vec<u8>, tag: u16, ty: u16, count: u32, value: u32| {
v.extend_from_slice(&tag.to_le_bytes());
v.extend_from_slice(&ty.to_le_bytes());
v.extend_from_slice(&count.to_le_bytes());
v.extend_from_slice(&value.to_le_bytes());
};
entry(&mut v, 0x010F, 2, 6, data as u32);
entry(&mut v, 282, 5, 1, data as u32 + 6);
entry(&mut v, 283, 5, 1, data as u32 + 14);
entry(&mut v, 296, 3, 1, 2);
v.extend_from_slice(&0u32.to_le_bytes());
v.extend_from_slice(b"NIKON\0");
for _ in 0..2 {
v.extend_from_slice(&300u32.to_le_bytes());
v.extend_from_slice(&1u32.to_le_bytes());
}
v
}
/// A camera-style JPEG: no JFIF header, the resolution only in EXIF.
fn camera_jpeg() -> Vec<u8> {
let meta = Metadata { exif: Some(camera_exif()), ..Default::default() };
let img = Image::from_raw(16, 8, ChannelLayout::Rgb, SampleType::U8, vec![120; 16 * 8 * 3]).unwrap().with_meta(meta);
let b = photocraft_codecs::encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap();
assert_eq!(&b[2..4], &[0xFF, 0xE0], "the encoder starts with JFIF");
let len = u16::from_be_bytes([b[4], b[5]]) as usize;
[&b[..2], &b[4 + len..]].concat()
}
/// (marker, payload) of each APPn segment.
fn app_segments(b: &[u8]) -> Vec<(u8, Vec<u8>)> {
let mut out = Vec::new();
let mut i = 2;
while i + 4 <= b.len() && b[i] == 0xFF && b[i + 1] != 0xDA {
let len = u16::from_be_bytes([b[i + 2], b[i + 3]]) as usize;
if (0xE0..=0xEF).contains(&b[i + 1]) {
out.push((b[i + 1], b[i + 4..i + 2 + len].to_vec()));
}
i += 2 + len;
}
out
}
#[test]
fn exif_resolution_opens_and_saves_consistently() {
let src = camera_jpeg();
assert!(app_segments(&src).iter().all(|(m, _)| *m != 0xE0));
let mut s = Session::new();
open_bytes_as(&mut s, "DSC_0001.JPG", &src, None, None).unwrap();
assert_eq!(s.active().unwrap().doc.resolution_dpi, 300.0);
s.execute("image.imageSize", json!({"resolution": 240, "resample": "none"})).unwrap();
let d = &s.active().unwrap().doc;
assert_eq!((d.resolution_dpi, d.size.width, d.size.height), (240.0, 16, 8));
let dir = std::env::temp_dir().join(format!("photocraft-1691-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let out = dir.join("saved.jpg");
s.execute("file.saveACopy", json!({"path": out.to_string_lossy()})).unwrap();
let saved = std::fs::read(&out).unwrap();
println!("saved {}", out.display());
let segs = app_segments(&saved);
let (_, jfif) = segs.iter().find(|(m, p)| *m == 0xE0 && p.starts_with(b"JFIF\0")).unwrap();
assert_eq!((jfif[7], u16::from_be_bytes([jfif[8], jfif[9]]), u16::from_be_bytes([jfif[10], jfif[11]])), (1, 240, 240));
let (_, exif) = segs.iter().find(|(m, p)| *m == 0xE1 && p.starts_with(b"Exif\0\0")).unwrap();
assert_eq!(exif_resolution(exif), Some((240.0, 240.0)));
assert!(exif.windows(6).any(|w| w == b"NIKON\0"), "the rest of the EXIF is kept");
assert_eq!(photocraft_io::import("saved.jpg", &saved).unwrap().document.resolution_dpi, 240.0);
}