Compare commits

...
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 ffd811d679 feat(save): File › Save keeps a JPEG's quality and a WebP's lossy/lossless encoding (#2399)
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 <[email protected]>
2026-10-12 04:13:51 -04:00
14 changed files with 679 additions and 6 deletions

No files matched your search

+2
View File
@@ -22,6 +22,7 @@ mod image;
mod options;
pub mod orientation;
pub mod resolution;
pub mod source_settings;
pub mod web;
pub use crate::codecs::exr_cryptomatte::{cryptomatte_id, cryptomatte_key, cryptomatte_preview_color};
@@ -39,6 +40,7 @@ pub use crate::image::{
pub use crate::options::{DecodeOptions, EncodeOptions, ExrCompression, Limits, PngCompression, TiffCompression};
pub use crate::orientation::{exif_orientation, upright_exif, upright_xmp};
pub use crate::resolution::{exif_resolution, export_exif, export_xmp, photoshop_resolution, xmp_resolution};
pub use crate::source_settings::{WebpEncoding, jpeg_quality, webp_encoding};
pub use half::f16;
use crate::codecs::{exr, fits, heif, jpeg, png, pnm, tiff, via_image, webp};
+355
View File
@@ -0,0 +1,355 @@
//! The encoder settings a lossy file was written with, read back from its own data, so a save
//! can write it again the way it came (File › Save keeps a JPEG's quality and a WebP's lossy or
//! lossless encoding).
//!
//! * **JPEG**: the quality is estimated from the quantisation tables (DQT). Most encoders scale
//! the example tables of ITU-T T.81 Annex K (K.1 luminance, K.2 chrominance) by the
//! Independent JPEG Group's quality curve, so the quality whose scaled tables match the file's
//! best is the one it was saved at (exact for such tables; the closest fit for others).
//! * **WebP**: a `VP8L` image is lossless; a `VP8 ` image is lossy, and its base quantiser index
//! (RFC 6386 §9.6) maps back to a quality on the scale our encoder uses.
//!
//! Everything here reads hostile bytes: every index is checked and a malformed file gives `None`.
use crate::codecs::vp8;
/// ITU-T T.81 Annex K.1, the example luminance quantisation table, in natural (row) order.
const ANNEX_K_LUMA: [u16; 64] = [
16, 11, 10, 16, 24, 40, 51, 61, //
12, 12, 14, 19, 26, 58, 60, 55, //
14, 13, 16, 24, 40, 57, 69, 56, //
14, 17, 22, 29, 51, 87, 80, 62, //
18, 22, 37, 56, 68, 109, 103, 77, //
24, 35, 55, 64, 81, 104, 113, 92, //
49, 64, 78, 87, 103, 121, 120, 101, //
72, 92, 95, 98, 112, 100, 103, 99,
];
/// ITU-T T.81 Annex K.2, the example chrominance quantisation table, in natural order.
const ANNEX_K_CHROMA: [u16; 64] = [
17, 18, 24, 47, 99, 99, 99, 99, //
18, 21, 26, 66, 99, 99, 99, 99, //
24, 26, 56, 99, 99, 99, 99, 99, //
47, 66, 99, 99, 99, 99, 99, 99, //
99, 99, 99, 99, 99, 99, 99, 99, //
99, 99, 99, 99, 99, 99, 99, 99, //
99, 99, 99, 99, 99, 99, 99, 99, //
99, 99, 99, 99, 99, 99, 99, 99,
];
/// The natural-order index of the k-th coefficient in zig-zag order (T.81 Figure A.6): DQT
/// stores its 64 values in this order.
const ZIGZAG: [usize; 64] = [
0, 1, 8, 16, 9, 2, 3, 10, 17, 24, 32, 25, 18, 11, 4, 5, 12, 19, 26, 33, 40, 48, 41, 34, 27, 20, 13, 6, 7, 14, 21, 28, 35, 42, 49, 56, 57, 50, 43, 36, 29,
22, 15, 23, 30, 37, 44, 51, 58, 59, 52, 45, 38, 31, 39, 46, 53, 60, 61, 54, 47, 55, 62, 63,
];
/// One quantisation table from a DQT segment, in natural order, with its precision.
#[derive(Debug, Clone, Copy)]
struct Table {
values: [u16; 64],
/// 16-bit entries (Pq = 1) rather than 8-bit.
wide: bool,
}
/// The quality (1–100, the Independent JPEG Group's scale that Save As and Export As use) a
/// JPEG was most likely saved at, from its quantisation tables; `None` when it has no usable
/// luminance table (or isn't a JPEG).
pub fn jpeg_quality(bytes: &[u8]) -> Option<u8> {
let tables = jpeg_tables(bytes);
let [luma, chroma, ..] = tables;
let luma = luma?;
let mut best: Option<(u64, u8)> = None;
for q in 1..=100u8 {
let mut err = table_error(&luma, &ANNEX_K_LUMA, q);
if let Some(c) = chroma {
err = err.saturating_add(table_error(&c, &ANNEX_K_CHROMA, q));
}
if best.is_none_or(|(e, _)| err < e) {
best = Some((err, q));
}
}
best.map(|(_, q)| q)
}
/// The Independent JPEG Group's quality scaling of `base` (libjpeg's `jpeg_quality_scaling` and
/// `jpeg_add_quant_table`, as published with its source): a percentage scale of 5000 / q below
/// quality 50 and 200 − 2q from it, rounded, and kept within 1..=255 (8-bit) or 1..=32767.
fn scaled(base: u16, q: u8, wide: bool) -> u32 {
let q = u32::from(q.clamp(1, 100));
let scale = if q < 50 { 5000 / q } else { 200 - 2 * q };
let v = (u32::from(base) * scale + 50) / 100;
v.clamp(1, if wide { 32767 } else { 255 })
}
/// How far `t` is from `base` scaled to quality `q`: the summed absolute difference.
fn table_error(t: &Table, base: &[u16; 64], q: u8) -> u64 {
t.values.iter().zip(base).map(|(&v, &b)| u64::from(u32::from(v).abs_diff(scaled(b, q, t.wide)))).sum()
}
/// The quantisation tables 0..=3 defined before the first scan (a later definition of the same
/// slot replaces an earlier one).
fn jpeg_tables(b: &[u8]) -> [Option<Table>; 4] {
let mut tables = [None; 4];
if b.get(..2) != Some(&[0xFF, 0xD8]) {
return tables;
}
let mut i = 2usize;
while let (Some(&0xFF), Some(&marker)) = (b.get(i), b.get(i.saturating_add(1))) {
if marker == 0xFF {
i = i.saturating_add(1);
continue;
}
if marker == 0xD8 || (0xD0..=0xD7).contains(&marker) || marker == 0x01 {
i = i.saturating_add(2);
continue;
}
if marker == 0xDA || marker == 0xD9 {
break;
}
let Some(len) = b.get(i + 2..i + 4).map(|l| usize::from(u16::from_be_bytes([l[0], l[1]]))) else { break };
let Some(seg) = len.checked_sub(2).and_then(|n| b.get(i + 4..i + 4 + n)) else { break };
if marker == 0xDB {
read_dqt(seg, &mut tables);
}
i = i.saturating_add(2).saturating_add(len);
}
tables
}
/// The tables of one DQT segment; a truncated or malformed table ends the segment.
fn read_dqt(mut seg: &[u8], tables: &mut [Option<Table>; 4]) {
while let Some((&pq_tq, rest)) = seg.split_first() {
let wide = pq_tq >> 4 == 1;
if pq_tq >> 4 > 1 {
return;
}
let slot = usize::from(pq_tq & 0x0F);
let n = if wide { 128 } else { 64 };
let Some(data) = rest.get(..n) else { return };
let mut values = [0u16; 64];
for (k, &natural) in ZIGZAG.iter().enumerate() {
let v = if wide { data.get(2 * k..2 * k + 2).map_or(0, |p| u16::from_be_bytes([p[0], p[1]])) } else { data.get(k).map_or(0, |&p| u16::from(p)) };
if let Some(dst) = values.get_mut(natural) {
*dst = v;
}
}
if let Some(dst) = tables.get_mut(slot) {
*dst = Some(Table { values, wide });
}
seg = rest.get(n..).unwrap_or(&[]);
}
}
/// How a WebP file's image is compressed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WebpEncoding {
/// VP8L.
Lossless,
/// VP8, with the quality (0–100, our encoder's scale) its quantiser maps back to, when the
/// frame header could be read.
Lossy { quality: Option<u8> },
}
/// The compression of a WebP file's (first) image, or `None` when it isn't a WebP or holds no
/// image chunk.
pub fn webp_encoding(bytes: &[u8]) -> Option<WebpEncoding> {
if bytes.get(..4) != Some(b"RIFF") || bytes.get(8..12) != Some(b"WEBP") {
return None;
}
find_image_chunk(bytes.get(12..)?, 0)
}
/// The first `VP8 ` or `VP8L` chunk among `chunks`, looking inside animation frames.
fn find_image_chunk(mut chunks: &[u8], depth: u8) -> Option<WebpEncoding> {
while let Some(head) = chunks.get(..8) {
let fourcc = head.get(..4)?;
let size = usize::try_from(u32::from_le_bytes([head[4], head[5], head[6], head[7]])).ok()?;
let end = 8usize.checked_add(size)?;
// A truncated last chunk still holds its header.
let payload = chunks.get(8..end.min(chunks.len()))?;
match fourcc {
b"VP8L" => return Some(WebpEncoding::Lossless),
b"VP8 " => return Some(WebpEncoding::Lossy { quality: vp8_quality(payload) }),
// An animation frame: a 16-byte header, then the frame's own chunks.
b"ANMF" if depth == 0 => {
if let Some(found) = payload.get(16..).and_then(|c| find_image_chunk(c, 1)) {
return Some(found);
}
}
_ => {}
}
chunks = chunks.get(end.checked_add(size % 2)?..).unwrap_or(&[]);
}
None
}
/// The quality a VP8 key frame's base quantiser index maps back to on our encoder's scale (the
/// quality whose index is nearest; the middle one when several share it).
fn vp8_quality(frame: &[u8]) -> Option<u8> {
let qi = vp8_base_quantizer(frame)?;
let distance = |q: u8| vp8::quantizer_index(q).abs_diff(qi);
let best = (0..=100u8).map(distance).min()?;
let matches: Vec<u8> = (0..=100u8).filter(|&q| distance(q) == best).collect();
let mid = matches.get(matches.len() / 2)?;
Some(*mid)
}
/// The base quantiser index of a VP8 key frame: `y_ac_qi` from its frame header (RFC 6386
/// §9.2–9.6, §19.2), averaged over the segments when segmentation sets absolute quantisers or
/// adjusted by their mean delta otherwise.
fn vp8_base_quantizer(frame: &[u8]) -> Option<u8> {
let tag = frame.get(..3)?;
// Bit 0 clear: a key frame, which carries the start code and dimensions.
if tag[0] & 1 != 0 || frame.get(3..6)? != [0x9D, 0x01, 0x2A] {
return None;
}
let mut d = BoolDecoder::new(frame.get(10..)?);
d.literal(1); // color_space
d.literal(1); // clamping_type
let mut segments: Option<(bool, [i32; 4])> = None;
if d.flag() {
let update_map = d.flag();
if d.flag() {
let absolute = d.flag();
let mut q = [0i32; 4];
for v in &mut q {
*v = if d.flag() { d.signed(7) } else { 0 };
}
for _ in 0..4 {
if d.flag() {
d.signed(6);
}
}
segments = Some((absolute, q));
}
if update_map {
for _ in 0..3 {
if d.flag() {
d.literal(8);
}
}
}
}
d.literal(1); // filter_type
d.literal(6); // loop_filter_level
d.literal(3); // sharpness_level
if d.flag() && d.flag() {
// loop_filter_adj_enable, mode_ref_lf_delta_update: 4 reference and 4 mode deltas.
for _ in 0..8 {
if d.flag() {
d.signed(6);
}
}
}
d.literal(2); // log2_nbr_of_dct_partitions
if d.exhausted() {
return None;
}
let base = d.literal(7) as i32;
let qi = match segments {
Some((true, q)) => q.iter().sum::<i32>() / 4,
Some((false, q)) => base + q.iter().sum::<i32>() / 4,
None => base,
};
u8::try_from(qi.clamp(0, 127)).ok()
}
/// RFC 6386 §7's boolean decoder, reading zeros past the end of its data.
struct BoolDecoder<'a> {
data: &'a [u8],
pos: usize,
value: u32,
range: u32,
bit_count: u32,
/// Bytes read past the end of the data.
overrun: usize,
}
impl<'a> BoolDecoder<'a> {
fn new(data: &'a [u8]) -> Self {
let mut d = BoolDecoder { data, pos: 0, value: 0, range: 255, bit_count: 0, overrun: 0 };
d.value = (d.next_byte() << 8) | d.next_byte();
d
}
fn next_byte(&mut self) -> u32 {
match self.data.get(self.pos) {
Some(&b) => {
self.pos += 1;
u32::from(b)
}
None => {
self.overrun = self.overrun.saturating_add(1);
0
}
}
}
/// The header was read from beyond the end of the data (a couple of bytes of look-ahead
/// are normal).
fn exhausted(&self) -> bool {
self.overrun > 2
}
fn bool(&mut self, prob: u32) -> bool {
let split = 1 + (((self.range - 1) * prob) >> 8);
let big_split = split << 8;
let bit = if self.value >= big_split {
self.range -= split;
self.value -= big_split;
true
} else {
self.range = split;
false
};
while self.range < 128 {
self.value <<= 1;
self.range <<= 1;
self.bit_count += 1;
if self.bit_count == 8 {
self.bit_count = 0;
self.value |= self.next_byte();
}
}
bit
}
fn flag(&mut self) -> bool {
self.bool(128)
}
fn literal(&mut self, bits: u32) -> u32 {
(0..bits).fold(0, |v, _| (v << 1) | u32::from(self.flag()))
}
/// A magnitude of `bits` bits followed by a sign bit.
fn signed(&mut self, bits: u32) -> i32 {
let v = self.literal(bits) as i32;
if self.flag() { -v } else { v }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scaling_matches_the_published_curve() {
// Quality 50 is the Annex K table itself; 100 is all ones; 75 halves it.
assert_eq!(scaled(16, 50, false), 16);
assert_eq!(scaled(121, 100, false), 1);
assert_eq!(scaled(16, 75, false), 8);
assert_eq!(scaled(121, 1, false), 255);
assert_eq!(scaled(121, 1, true), 6050);
}
#[test]
fn zigzag_is_a_permutation() {
let mut seen = [false; 64];
for &n in &ZIGZAG {
seen[n] = true;
}
assert!(seen.iter().all(|&s| s));
}
}
+111
View File
@@ -0,0 +1,111 @@
//! Reading back the settings a lossy file was saved with: a JPEG's quality from its
//! quantisation tables, a WebP's lossy or lossless encoding (and its quality).
mod common;
use common::*;
use photocraft_codecs::*;
fn photo(w: u32, h: u32, layout: ChannelLayout) -> Image {
synth(w, h, layout, SampleType::U8, 5, 0.03)
}
#[test]
fn jpeg_quality_is_estimated_within_two() {
for layout in [ChannelLayout::Rgb, ChannelLayout::Gray] {
let img = photo(40, 24, layout);
for q in [1u8, 5, 10, 25, 40, 50, 60, 70, 75, 80, 85, 90, 95, 98, 100] {
let bytes = encode(&img, Format::Jpeg, &EncodeOptions { jpeg_quality: q, ..Default::default() }).unwrap();
let est = jpeg_quality(&bytes).unwrap_or_else(|| panic!("{layout:?} q{q}: no estimate"));
assert!(est.abs_diff(q) <= 2, "{layout:?}: saved at {q}, estimated {est}");
}
}
}
#[test]
fn jpeg_quality_of_hand_built_tables() {
// A minimal JPEG prefix: SOI, one DQT holding the Annex K luminance table (quality 50) in
// zig-zag order, then SOS.
let luma: [u8; 64] = [
16, 11, 12, 14, 12, 10, 16, 14, 13, 14, 18, 17, 16, 19, 24, 40, 26, 24, 22, 22, 24, 49, 35, 37, 29, 40, 58, 51, 61, 60, 57, 51, 56, 55, 64, 72, 92, 78,
64, 68, 87, 69, 55, 56, 80, 109, 81, 87, 95, 98, 103, 104, 103, 62, 77, 113, 121, 112, 100, 120, 92, 101, 103, 99,
];
let mut b = vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 67, 0x00];
b.extend_from_slice(&luma);
b.extend_from_slice(&[0xFF, 0xDA, 0, 2]);
assert_eq!(jpeg_quality(&b), Some(50));
}
#[test]
fn hostile_jpeg_tables_give_no_estimate_and_never_panic() {
let img = photo(16, 16, ChannelLayout::Rgb);
let good = encode(&img, Format::Jpeg, &EncodeOptions { jpeg_quality: 70, ..Default::default() }).unwrap();
// Every truncation of a real file.
for n in 0..good.len().min(800) {
let _ = jpeg_quality(&good[..n]);
}
let cases: Vec<Vec<u8>> = vec![
vec![],
vec![0xFF],
vec![0xFF, 0xD8],
// A DQT whose length runs past the end.
vec![0xFF, 0xD8, 0xFF, 0xDB, 0xFF, 0xFF, 0x00, 1, 2, 3],
// A DQT of length 0 and 1 (less than its own length field).
vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 0x00],
vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 0x01],
// A table cut short, a bad precision, a slot past 3, a 16-bit table cut mid-entry.
[vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 12, 0x00], vec![9; 9]].concat(),
[vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 67, 0x70], vec![9; 64]].concat(),
[vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 67, 0x0F], vec![9; 64]].concat(),
[vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 68, 0x10], vec![0xFF; 65]].concat(),
// All-zero and all-0xFF tables.
[vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 67, 0x00], vec![0; 64]].concat(),
[vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 131, 0x10], vec![0xFF; 128]].concat(),
vec![0xFF; 64],
];
for c in &cases {
let _ = jpeg_quality(c);
}
assert_eq!(jpeg_quality(&[0xFF, 0xD8, 0xFF, 0xDB, 0x00, 0x00]), None);
assert_eq!(jpeg_quality(b"not a jpeg at all"), None);
let png = encode(&img, Format::Png, &EncodeOptions::default()).unwrap();
assert_eq!(jpeg_quality(&png), None);
}
#[test]
fn webp_lossless_and_lossy_are_told_apart() {
let img = photo(33, 21, ChannelLayout::Rgb);
let lossless = encode(&img, Format::WebP, &EncodeOptions::default()).unwrap();
assert_eq!(webp_encoding(&lossless), Some(WebpEncoding::Lossless));
for q in [10u8, 30, 50, 75, 80, 90, 100] {
let bytes = encode(&img, Format::WebP, &EncodeOptions { webp_lossless: false, webp_quality: q, ..Default::default() }).unwrap();
let Some(WebpEncoding::Lossy { quality: Some(est) }) = webp_encoding(&bytes) else { panic!("q{q}: {:?}", webp_encoding(&bytes)) };
assert!(est.abs_diff(q) <= 2, "lossy WebP saved at {q}, estimated {est}");
}
// With alpha and metadata (the extended container).
let rgba = photo(20, 20, ChannelLayout::Rgba);
let bytes = encode(&rgba, Format::WebP, &EncodeOptions { webp_lossless: false, webp_quality: 60, ..Default::default() }).unwrap();
assert!(matches!(webp_encoding(&bytes), Some(WebpEncoding::Lossy { quality: Some(58..=62) })), "{:?}", webp_encoding(&bytes));
}
#[test]
fn hostile_webp_never_panics() {
let img = photo(24, 24, ChannelLayout::Rgb);
for opts in [EncodeOptions::default(), EncodeOptions { webp_lossless: false, ..Default::default() }] {
let good = encode(&img, Format::WebP, &opts).unwrap();
for n in 0..good.len().min(400) {
let _ = webp_encoding(&good[..n]);
}
let mut flipped = good.clone();
for i in 12..flipped.len().min(64) {
flipped[i] ^= 0xA5;
let _ = webp_encoding(&flipped);
}
}
let mut huge = b"RIFF\0\0\0\0WEBPVP8 ".to_vec();
huge.extend_from_slice(&u32::MAX.to_le_bytes());
let _ = webp_encoding(&huge);
let mut anmf = b"RIFF\0\0\0\0WEBPANMF".to_vec();
anmf.extend_from_slice(&3u32.to_le_bytes());
let _ = webp_encoding(&anmf);
assert_eq!(webp_encoding(b"RIFF\0\0\0\0WAVE"), None);
}
+27
View File
@@ -875,6 +875,32 @@ pub struct Document {
pub variables: Variables,
/// Window › Timeline (None until a video timeline is created).
pub timeline: Option<Timeline>,
/// How the document's lossy file was encoded (its JPEG quality, WebP lossy or lossless), so
/// a save writes it back the same way rather than at fixed defaults.
pub file_encoding: FileEncoding,
}
/// The encoder settings a document's file was opened or saved with: what File › Save and the
/// Save As defaults reuse for that format. Empty for a new document or a file without lossy
/// options. Qualities are on the codecs' 1–100 scale.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct FileEncoding {
/// JPEG quality (estimated from the file's quantisation tables on open).
#[serde(skip_serializing_if = "Option::is_none")]
pub jpeg_quality: Option<u8>,
/// WebP: `Some(true)` lossless (VP8L), `Some(false)` lossy (VP8 at [`Self::webp_quality`]).
#[serde(skip_serializing_if = "Option::is_none")]
pub webp_lossless: Option<bool>,
/// Lossy WebP quality.
#[serde(skip_serializing_if = "Option::is_none")]
pub webp_quality: Option<u8>,
}
impl FileEncoding {
pub fn is_empty(&self) -> bool {
*self == FileEncoding::default()
}
}
/// Where a layer lives in the tree: indices from the root down.
@@ -913,6 +939,7 @@ impl Document {
slices: Slices::default(),
variables: Variables::default(),
timeline: None,
file_encoding: FileEncoding::default(),
}
}
+3
View File
@@ -285,6 +285,9 @@ pub(crate) fn encode(doc: &Document, path: &str, save: impl Into<SaveOpts>) -> R
let save = save.into();
let mut opts =
photocraft_io::ExportOptions { tiff_layers: save.tiff_layers, tga_bits: save.tga_bits, image_previews: save.image_previews, ..Default::default() };
// No quality chosen: write the file the way it was opened (a JPEG at its own quality, a WebP
// lossy or lossless as it was), as Photoshop's Save does.
photocraft_io::apply_file_encoding(doc, &mut opts.encode);
if let Some(q) = save.quality {
let q = (q.clamp(0.0, 12.0) / 12.0 * 99.0 + 1.0).round() as u8;
opts.encode.jpeg_quality = q;
+1
View File
@@ -597,6 +597,7 @@ fn doc_pixels_equal(a: &Document, b: &Document) -> bool {
slices: _,
variables: _,
timeline: _,
file_encoding: _,
} = a;
*size == b.size
&& resolution_dpi.to_bits() == b.resolution_dpi.to_bits()
@@ -0,0 +1,89 @@
//! #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());
}
+2
View File
@@ -253,6 +253,7 @@ pub(crate) fn doc_m(d: &Document, sink: &mut dyn Sink) -> DocM {
duotone: d.duotone.clone(),
variables: d.variables.clone(),
timeline: d.timeline.clone(),
file_encoding: d.file_encoding,
layer_comps: d.layer_comps.iter().map(|c| comp_m(c, sink)).collect(),
last_applied_comp: d.last_applied_comp,
last_document_state: d.last_document_state.as_ref().map(|c| comp_m(c, sink)),
@@ -631,6 +632,7 @@ impl Loader<'_> {
duotone: m.duotone.clone(),
variables: m.variables.clone(),
timeline: m.timeline.clone(),
file_encoding: m.file_encoding,
layer_comps,
last_applied_comp: m.last_applied_comp,
last_document_state,
+3
View File
@@ -84,6 +84,9 @@ pub struct DocM {
/// Window › Timeline.
#[serde(default)]
pub timeline: Option<photocraft_doc::Timeline>,
/// The JPEG quality / WebP encoding File › Save reuses.
#[serde(default, skip_serializing_if = "photocraft_doc::FileEncoding::is_empty")]
pub file_encoding: photocraft_doc::FileEncoding,
/// Image › Analysis: measurement scale, count groups, ruler.
#[serde(default)]
pub measurement: photocraft_doc::Measurement,
+40
View File
@@ -21,6 +21,7 @@ pub fn import_flat(name: &str, bytes: &[u8]) -> Result<ImportResult, IoError> {
}
let img = codecs::decode(bytes)?;
let mut r = image_to_document(name, &img)?;
r.document.file_encoding = file_encoding(bytes);
if codecs::detect(bytes) == Some(Format::Tga) && img.layout() == ChannelLayout::Rgba {
tga_alpha_to_channel(&mut r.document)?;
}
@@ -50,6 +51,45 @@ pub fn import_gif_frames(name: &str, bytes: &[u8], each: &mut dyn FnMut(Document
Ok(n)
}
/// The lossy settings `bytes` were encoded with, for File › Save to keep: a JPEG's quality
/// (estimated from its quantisation tables) and a WebP's lossless or lossy encoding with its
/// quality. A lossy WebP whose quality can't be read gets [`DEFAULT_LOSSY_WEBP_QUALITY`].
pub fn file_encoding(bytes: &[u8]) -> photocraft_doc::FileEncoding {
let mut e = photocraft_doc::FileEncoding::default();
match codecs::detect(bytes) {
Some(Format::Jpeg) => e.jpeg_quality = codecs::jpeg_quality(bytes),
Some(Format::WebP) => match codecs::webp_encoding(bytes) {
Some(codecs::WebpEncoding::Lossless) => e.webp_lossless = Some(true),
Some(codecs::WebpEncoding::Lossy { quality }) => {
e.webp_lossless = Some(false);
e.webp_quality = Some(quality.unwrap_or(DEFAULT_LOSSY_WEBP_QUALITY));
}
None => {}
},
_ => {}
}
e
}
/// The quality a lossy WebP is saved at when its own can't be read.
pub const DEFAULT_LOSSY_WEBP_QUALITY: u8 = 75;
/// Applies the settings `doc`'s file was encoded with (see [`file_encoding`]) to `opts`: what a
/// save that chose no quality writes, so a JPEG keeps its quality and a WebP stays lossy or
/// lossless.
pub fn apply_file_encoding(doc: &Document, opts: &mut codecs::EncodeOptions) {
let e = &doc.file_encoding;
if let Some(q) = e.jpeg_quality {
opts.jpeg_quality = q.clamp(1, 100);
}
if let Some(lossless) = e.webp_lossless {
opts.webp_lossless = lossless;
}
if let Some(q) = e.webp_quality {
opts.webp_quality = q.min(100);
}
}
/// Photoshop opens a 32-bit Targa as an opaque Background plus an "Alpha 1" channel holding the
/// fourth byte, even when the header declares no alpha bits, and keeps the stored colours (#2225;
/// measured on Photoshop 27.11). Layer transparency would hide those colours and lose the channel
+4 -1
View File
@@ -64,7 +64,10 @@ use photocraft_doc::Document;
use photocraft_psd::{PsdError, PsdFile};
pub use adjust_map::ADJUSTMENT_KEYS;
pub use flat::{document_to_image, import_gif_frames, import_tiff_page, tga_alpha_channel_to_transparency, tga_default_bits};
pub use flat::{
DEFAULT_LOSSY_WEBP_QUALITY, apply_file_encoding, document_to_image, file_encoding, import_gif_frames, import_tiff_page, tga_alpha_channel_to_transparency,
tga_default_bits,
};
pub use psd_export::{PsdExportOptions, document_to_psd, document_to_psd_with};
pub use psd_import::{psd_to_document, psd_to_document_with};
+11 -2
View File
@@ -1284,10 +1284,19 @@ impl PhotocraftApp {
/// What a save writes by default under the preferences: File Handling › Image Previews
/// decides whether a PSD/PSB gets its thumbnail. Ask When Saving has no Save As checkbox
/// here yet, so it saves the preview, as Photoshop's checkbox starts on.
/// here yet, so it saves the preview, as Photoshop's checkbox starts on. A JPEG or WebP
/// is written the way the active document's file was (its JPEG quality, a WebP lossy at
/// its quality or lossless), as Photoshop's Save and Save As options start there.
pub(crate) fn save_settings(&self) -> ExportSettings {
let previews = self.session.prefs().file_handling.image_previews != photocraft_engine::prefs::Ask::Never;
ExportSettings { image_previews: previews, ..ExportSettings::default() }
let mut settings = ExportSettings { image_previews: previews, ..ExportSettings::default() };
if let Some(st) = self.session.active() {
let e = &st.doc.file_encoding;
settings.jpeg_quality = e.jpeg_quality.map(|q| q.clamp(1, 100));
settings.webp_lossless = e.webp_lossless.unwrap_or(settings.webp_lossless);
settings.webp_quality = e.webp_quality.map(|q| q.min(100));
}
settings
}
/// Whether saving the active document to `path` writes a copy. `tiff_layers` is the TIFF
+27
View File
@@ -204,3 +204,30 @@ fn image_previews_preference_reaches_the_exporter() {
assert_eq!(*seen.borrow(), [expect; 3], "{value}");
}
}
/// #2399: Save and Save As start at the opened file's own encoding: a JPEG's estimated quality,
/// a WebP lossy (at its quality) or lossless; a new document keeps the codec defaults.
#[test]
fn save_settings_start_at_the_opened_files_encoding() {
use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, SampleType};
let px: Vec<u8> = (0..32u32 * 24).flat_map(|i| [(i * 3) as u8, (i / 32 * 9) as u8, (i % 97) as u8]).collect();
let img = Image::from_raw(32, 24, ChannelLayout::Rgb, SampleType::U8, px).unwrap();
let mut app = app();
assert_eq!(app.save_settings().jpeg_quality, None);
assert!(app.save_settings().webp_lossless);
let jpeg = photocraft_codecs::encode(&img, Format::Jpeg, &EncodeOptions { jpeg_quality: 65, ..Default::default() }).unwrap();
app.open_bytes("photo.jpg", &jpeg).unwrap();
let q = app.save_settings().jpeg_quality.unwrap();
assert!(q.abs_diff(65) <= 2, "estimated {q}");
let lossy = photocraft_codecs::encode(&img, Format::WebP, &EncodeOptions { webp_lossless: false, webp_quality: 80, ..Default::default() }).unwrap();
app.open_bytes("photo.webp", &lossy).unwrap();
let s = app.save_settings();
assert!(!s.webp_lossless);
assert!(s.webp_quality.unwrap().abs_diff(80) <= 2, "{:?}", s.webp_quality);
let lossless = photocraft_codecs::encode(&img, Format::WebP, &EncodeOptions::default()).unwrap();
app.open_bytes("art.webp", &lossless).unwrap();
assert!(app.save_settings().webp_lossless);
}
+4 -3
View File
@@ -1,6 +1,6 @@
# File-format parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (D5100 crop and Camera Raw tuning validation) · **Target:** Adobe Photoshop 2026
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Save keeps a JPEG's quality and a WebP's lossy/lossless encoding) · **Target:** Adobe Photoshop 2026
Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and
tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write
@@ -25,11 +25,11 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity.
| PSB | R/W | yes | yes | Auto-switches above 2 GB (REL-212-2); 4–5 GB fixtures generated (FILE-216-2); > 4 GB fails on Windows (#375) |
| Cloud PSD (.psdc) | R/W | no | no | Out of scope (Adobe cloud) |
| TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; layered TIFF both byte orders (FILE-215-7: extra alpha channels not written) |
| JPEG | R/W | yes | yes | 8-bit Gray/RGB/CMYK, ICC/EXIF/XMP; no JPEG Options dialog (quality 0–12, baseline/progressive) (FILE-215-19) |
| JPEG | R/W | yes | yes | 8-bit Gray/RGB/CMYK, ICC/EXIF/XMP; Save keeps the opened file's quality (estimated from its quantisation tables, #2399); no JPEG Options dialog (quality 0–12, baseline/progressive) (FILE-215-19) |
| PNG | R/W | yes | yes | 8/16-bit, ICC/text; APNG first frame only; alpha channels on open (#2225) |
| GIF | R/W | yes | yes | File › Open keeps the first frame; New Video Layer from File and Video Frames to Layers take every frame, with the timeline rate from the delays (#572). Per-frame delays and keeping the frames in `.pcraft` are not done |
| BMP | R/W | yes | yes | 8-bit |
| WebP | R/W | yes | yes | Lossless and our own lossy VP8 encoder; ICC/EXIF/XMP |
| WebP | R/W | yes | yes | Lossless and our own lossy VP8 encoder; ICC/EXIF/XMP; Save keeps an opened file lossy (at its quality) or lossless (#2399) |
| AVIF | R/W | no (#463) | optional feature, off by default | |
| JPEG XL | R/W | inside DNG only | no | |
| HEIF/HEIC | R/W(HEIF) | yes (pure-Rust heic-rs, `heif` feature, on in releases) | no | 8/16-bit |
@@ -140,5 +140,6 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings.
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | Save writes a JPEG at its own estimated quality and a WebP lossy or lossless as opened (#2399) |
| 2026-10-11 | minor | D5100 image area and fitted-look preservation in Camera Raw tuning |
| 2026-10-10 | major | First version: Photoshop 2026 27.11.0's formats from its bundle, against our codecs; Affinity notes moved from `roadmap.md` |