Files
photocraft/crates/engine/tests/save_keeps_encoding.rs
Brandon ThomasandClaude Opus 5.5 62346f7fb6 feat(save): File › Save keeps a JPEG's quality and a WebP's lossy/lossless encoding (#2399) (#4032)
Opening a JPEG now estimates the quality it was saved at from its quantisation
tables (the Annex K tables of ITU-T T.81 scaled by the IJG quality curve; exact
for such tables, the closest fit otherwise). Opening a WebP records whether it
is lossless (VP8L) or lossy (VP8), and a lossy one's quality from its base
quantiser index. The settings live on the document (`Document::file_encoding`,
saved in .pcraft) and a save that asks for no quality writes the file back the
same way, as Photoshop's Save does: File › Save, Save As and Save a Copy start
there instead of the fixed codec defaults (quality 90, WebP always lossless).
An explicit quality still wins; new documents and other formats keep the
defaults. Hostile or truncated DQT/VP8 headers give no estimate, never a panic.

Refs #2399

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:45:50 -04:00

90 lines
4.6 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! #2399: a save writes a JPEG or WebP back the way it was opened. A JPEG keeps the quality
//! estimated from its quantisation tables, a lossy WebP stays lossy and a lossless one lossless,
//! unless the save asks for a quality; the remembered settings survive a .pcraft round trip.
use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, SampleType, WebpEncoding, jpeg_quality, webp_encoding};
use photocraft_engine::Session;
use photocraft_engine::file_cmds::open_bytes_as;
use serde_json::json;
fn photo() -> Image {
let (w, h) = (48u32, 32u32);
let px = (0..w * h).flat_map(|i| {
let (x, y) = (i % w, i / w);
[(x * 5) as u8, (y * 7) as u8, ((x * y) % 251) as u8]
});
Image::from_raw(w, h, ChannelLayout::Rgb, SampleType::U8, px.collect()).unwrap()
}
fn tmp(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("photocraft-2399-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir.join(name)
}
/// Opens `bytes` as `name`, saves a copy to `out` with `params`, and returns the saved file.
fn open_and_save(name: &str, bytes: &[u8], out: &str, params: serde_json::Value) -> (Session, Vec<u8>) {
let mut s = Session::new();
open_bytes_as(&mut s, name, bytes, None, None).unwrap();
let path = tmp(out);
let mut p = params;
p["path"] = json!(path.to_string_lossy());
s.execute("file.saveACopy", p).unwrap();
let saved = std::fs::read(&path).unwrap();
(s, saved)
}
#[test]
fn a_jpeg_saves_at_its_own_quality() {
for q in [35u8, 60, 77, 95] {
let src = photocraft_codecs::encode(&photo(), Format::Jpeg, &EncodeOptions { jpeg_quality: q, ..Default::default() }).unwrap();
let (s, saved) = open_and_save("in.jpg", &src, &format!("q{q}.jpg"), json!({}));
let remembered = s.active().unwrap().doc.file_encoding.jpeg_quality.unwrap();
assert!(remembered.abs_diff(q) <= 2, "opened at {q}, remembered {remembered}");
let est = jpeg_quality(&saved).unwrap();
assert!(est.abs_diff(q) <= 2, "opened at {q}, saved at {est}");
}
// A quality the save asks for still wins (Photoshop's 0–12 scale: 12 is the top).
let src = photocraft_codecs::encode(&photo(), Format::Jpeg, &EncodeOptions { jpeg_quality: 40, ..Default::default() }).unwrap();
let (_, saved) = open_and_save("in.jpg", &src, "explicit.jpg", json!({"quality": 12}));
assert_eq!(jpeg_quality(&saved), Some(100));
}
#[test]
fn a_webp_stays_lossy_or_lossless() {
let lossy = photocraft_codecs::encode(&photo(), Format::WebP, &EncodeOptions { webp_lossless: false, webp_quality: 60, ..Default::default() }).unwrap();
let (s, saved) = open_and_save("in.webp", &lossy, "lossy.webp", json!({}));
let e = s.active().unwrap().doc.file_encoding;
assert_eq!(e.webp_lossless, Some(false));
let Some(WebpEncoding::Lossy { quality: Some(q) }) = webp_encoding(&saved) else { panic!("saved {:?}", webp_encoding(&saved)) };
assert!(q.abs_diff(60) <= 2, "lossy WebP at 60 saved at {q}");
let lossless = photocraft_codecs::encode(&photo(), Format::WebP, &EncodeOptions::default()).unwrap();
let (s, saved) = open_and_save("in.webp", &lossless, "lossless.webp", json!({}));
assert_eq!(s.active().unwrap().doc.file_encoding.webp_lossless, Some(true));
assert_eq!(webp_encoding(&saved), Some(WebpEncoding::Lossless));
assert_eq!(photocraft_codecs::decode(&saved).unwrap().data(), photocraft_codecs::decode(&lossless).unwrap().data(), "lossless round trip");
}
#[test]
fn a_new_document_and_a_png_save_at_the_defaults() {
let png = photocraft_codecs::encode(&photo(), Format::Png, &EncodeOptions::default()).unwrap();
let (s, saved) = open_and_save("in.png", &png, "from-png.jpg", json!({}));
assert!(s.active().unwrap().doc.file_encoding.is_empty());
assert_eq!(jpeg_quality(&saved), Some(EncodeOptions::default().jpeg_quality));
let (_, saved) = open_and_save("in.png", &png, "from-png.webp", json!({}));
assert_eq!(webp_encoding(&saved), Some(WebpEncoding::Lossless));
}
#[test]
fn the_remembered_encoding_survives_pcraft() {
let src = photocraft_codecs::encode(&photo(), Format::Jpeg, &EncodeOptions { jpeg_quality: 70, ..Default::default() }).unwrap();
let mut s = Session::new();
open_bytes_as(&mut s, "in.jpg", &src, None, None).unwrap();
let doc = &s.active().unwrap().doc;
let bytes = photocraft_format::save_to_bytes(doc, &photocraft_format::SaveOptions::default()).unwrap();
let back = photocraft_format::load_from_bytes(&bytes).unwrap();
assert_eq!(back.file_encoding, doc.file_encoding);
assert!(back.file_encoding.jpeg_quality.is_some());
}