mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-07 12:13:10 +00:00
fix(export): preserve high-quality JPEG colour detail in Export As (#7985)
* fix(export): preserve full JPEG chroma in Export As * style(ui): simplify blend preview condition for Clippy * test(export): verify full JPEG chroma at every quality Exercise real JPEG export and decode at five qualities across U8, U16 and F32. Read all sampling factors from the main frame while skipping metadata segments and embedded thumbnails. Keep the colour-error limit at quality 100 and verify that the layered source is unchanged. * Keep JPEG regression compatible with current Clippy Restore the upstream blend-preview and IME predicates, removing unrelated lint-only changes. Use array chunks in the JPEG colour-error assertion for current stable Clippy. * Use quality-dependent JPEG sampling in Export As Keep 4:2:0 through quality 50 and full 4:4:4 above it, using the existing percentage-scale cutoff from Save for Web. Photoshop 27.11 modern Export As measurements show 4:2:0 at levels 1-4 and 4:4:4 at 5-7; no exact numerical mapping of the two scales is claimed. Exercise both sampling regimes through 15 real JPEG exports across U8/U16/F32, check all percentage qualities in settings, and retain the Save As default. Boundary mutations fail at 50 and 51 as expected.
This commit is contained in:
@@ -201,6 +201,9 @@ sync_github_name = true
|
||||
real_name = "Guillem Hermida"
|
||||
sync_github_name = true
|
||||
|
||||
[people.fokas-hoa]
|
||||
display_name = "Tasos Fokas"
|
||||
|
||||
[people.fjamesprice]
|
||||
sync_github_name = true
|
||||
|
||||
|
||||
@@ -462,6 +462,9 @@ fn settings(f: &Map<String, Value>) -> ExportSettings {
|
||||
let metadata = s(f, "metadata");
|
||||
ExportSettings {
|
||||
jpeg_quality: (fmt == "jpg").then_some(quality),
|
||||
// Photoshop's 1-7 Export As presets measured 4:2:0 at 1-4 and 4:4:4 at 5-7.
|
||||
// An exact mapping to 1-100 is unestablished; reuse PhotoCraft's Save for Web cutoff.
|
||||
jpeg_chroma_subsampling: fmt == "jpg" && quality <= 50,
|
||||
webp_lossless: fmt != "webp" || lossless(f),
|
||||
webp_quality: (fmt == "webp" && !lossless(f)).then_some(quality),
|
||||
xmp_all: metadata == "all",
|
||||
|
||||
@@ -526,6 +526,109 @@ fn export_document_scales_and_flattens() {
|
||||
assert_eq!(settings(&f).jpeg_quality, Some(85));
|
||||
}
|
||||
|
||||
/// Read the main JPEG frame, skipping complete segments rather than searching marker bytes:
|
||||
/// APP metadata can contain a thumbnail JPEG with its own frame and different sampling.
|
||||
fn jpeg_sampling_factors(bytes: &[u8]) -> [(u8, u8); 3] {
|
||||
assert_eq!(bytes.get(..2), Some([0xff, 0xd8].as_slice()), "JPEG SOI");
|
||||
let mut offset = 2;
|
||||
loop {
|
||||
assert_eq!(bytes.get(offset), Some(&0xff), "JPEG marker prefix");
|
||||
while bytes.get(offset) == Some(&0xff) {
|
||||
offset += 1;
|
||||
}
|
||||
let marker = *bytes.get(offset).expect("JPEG marker");
|
||||
offset += 1;
|
||||
match marker {
|
||||
0x01 | 0xd0..=0xd8 => continue, // Standalone markers have no length.
|
||||
0x00 | 0xd9 | 0xda => panic!("JPEG has no frame before scan/end"),
|
||||
_ => {}
|
||||
}
|
||||
let length_bytes = bytes.get(offset..offset + 2).expect("JPEG segment length");
|
||||
let length = usize::from(u16::from_be_bytes([length_bytes[0], length_bytes[1]]));
|
||||
assert!(length >= 2, "JPEG segment length includes its two length bytes");
|
||||
let end = offset.checked_add(length).expect("JPEG segment end");
|
||||
let segment = bytes.get(offset + 2..end).expect("complete JPEG segment");
|
||||
if matches!(marker, 0xc0..=0xc3 | 0xc5..=0xc7 | 0xc9..=0xcb | 0xcd..=0xcf) {
|
||||
assert_eq!(segment.get(5), Some(&3), "JPEG frame must have three components");
|
||||
assert_eq!(segment.len(), 6 + 3 * 3, "JPEG frame component descriptors");
|
||||
return std::array::from_fn(|i| {
|
||||
let sampling = segment[6 + 3 * i + 1];
|
||||
(sampling >> 4, sampling & 0x0f)
|
||||
});
|
||||
}
|
||||
offset = end;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpeg_export_sampling_skips_embedded_thumbnail() {
|
||||
// An APP segment contains an entire 4:2:0 thumbnail; the main frame is 4:4:4.
|
||||
let frame_420 = [0xff, 0xc0, 0, 17, 8, 0, 1, 0, 1, 3, 1, 0x22, 0, 2, 0x11, 1, 3, 0x11, 1];
|
||||
let mut thumbnail = vec![0xff, 0xd8];
|
||||
thumbnail.extend_from_slice(&frame_420);
|
||||
thumbnail.extend_from_slice(&[0xff, 0xd9]);
|
||||
assert_eq!(jpeg_sampling_factors(&thumbnail), [(2, 2), (1, 1), (1, 1)]);
|
||||
let mut jpeg = vec![0xff, 0xd8, 0xff, 0xe1];
|
||||
jpeg.extend_from_slice(&u16::try_from(thumbnail.len() + 2).unwrap().to_be_bytes());
|
||||
jpeg.extend_from_slice(&thumbnail);
|
||||
jpeg.push(0xff); // Marker fill byte before the main frame.
|
||||
let mut frame_444 = frame_420;
|
||||
frame_444[11] = 0x11;
|
||||
jpeg.extend_from_slice(&frame_444);
|
||||
assert_eq!(jpeg_sampling_factors(&jpeg), [(1, 1); 3]);
|
||||
}
|
||||
|
||||
/// High-quality JPEG must keep the colour detail of a transparent graphic over its background;
|
||||
/// lower qualities use chroma subsampling, including at the quality-50 boundary.
|
||||
#[test]
|
||||
fn jpeg_export_preserves_fine_colour_edges() {
|
||||
use photocraft_doc::{Color, ColorMode, Layer, SampleType, Size};
|
||||
use photocraft_geom::Rect;
|
||||
|
||||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
let mut doc = Document::with_background("colour detail", Size::new(32, 16), ColorMode::Rgb, depth, Color::WHITE);
|
||||
let mut graphic = Layer::raster("transparent graphic", doc.pixel_format());
|
||||
for x in (0..32).step_by(2) {
|
||||
graphic.surface_mut().unwrap().fill_rect(Rect::from_xywh(x, 0, 1, 16), &[1.0, 0.0, 0.0, 1.0]);
|
||||
}
|
||||
doc.layers.push(graphic);
|
||||
let before = doc.clone();
|
||||
for quality in [1, 50, 51, 85, 100] {
|
||||
let fields = json!({"format": "jpg", "quality": quality, "scale": 100}).as_object().unwrap().clone();
|
||||
let out = default_export_document(&doc, &fields, None).unwrap();
|
||||
let encoded = photocraft_io::export(&out, "detail.jpg", &settings(&fields).io_options()).unwrap();
|
||||
let expected_sampling = if quality <= 50 { [(2, 2), (1, 1), (1, 1)] } else { [(1, 1); 3] };
|
||||
assert_eq!(jpeg_sampling_factors(&encoded.bytes), expected_sampling, "{depth:?}, quality {quality}: JPEG sampling");
|
||||
let decoded = photocraft_codecs::decode(&encoded.bytes).unwrap().convert(photocraft_codecs::ChannelLayout::Rgb, photocraft_codecs::SampleType::U8);
|
||||
assert_eq!(decoded.dimensions(), (32, 16), "{depth:?}, quality {quality}");
|
||||
// Lower qualities also lose detail to quantization, independently of chroma sampling.
|
||||
if quality == 100 {
|
||||
let mut max_error = 0;
|
||||
for (i, pixel) in decoded.data().as_chunks::<3>().0.iter().enumerate() {
|
||||
let expected: [u8; 3] = if i % 32 % 2 == 0 { [255, 0, 0] } else { [255, 255, 255] };
|
||||
for (actual, expected) in pixel.iter().zip(expected) {
|
||||
max_error = max_error.max(actual.abs_diff(expected));
|
||||
}
|
||||
}
|
||||
assert!(max_error <= 8, "{depth:?}: JPEG at quality 100 loses colour detail by {max_error} levels");
|
||||
}
|
||||
assert_eq!(doc, before, "{depth:?}, quality {quality}: export leaves the layered source unchanged");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpeg_export_settings_follow_quality_sampling_cutoff() {
|
||||
for quality in 1..=100 {
|
||||
let fields = json!({"format": "jpg", "quality": quality}).as_object().unwrap().clone();
|
||||
let options = settings(&fields).io_options();
|
||||
assert_eq!(options.encode.jpeg_quality, quality);
|
||||
assert_eq!(options.encode.jpeg_chroma_subsampling, quality <= 50, "quality {quality}");
|
||||
}
|
||||
// Save As retains the codec's existing sampling default.
|
||||
assert!(ExportSettings::default().io_options().encode.jpeg_chroma_subsampling);
|
||||
}
|
||||
|
||||
/// #1353: a scale that changes only the height (the width rounds back to the original)
|
||||
/// used to be skipped, so the exported file kept the original size while the dialog
|
||||
/// labelled it as scaled.
|
||||
|
||||
@@ -79,6 +79,9 @@ pub type ImportFn = Box<dyn Fn(&str, &[u8], &photocraft_io::ImportOptions) -> Re
|
||||
pub struct ExportSettings {
|
||||
/// JPEG quality 1–100 (None = codec default).
|
||||
pub jpeg_quality: Option<u8>,
|
||||
/// JPEG chroma sampling: `true` is 4:2:0, `false` keeps full 4:4:4 colour detail.
|
||||
/// Export As keeps full chroma above quality 50 so saturated text and graphics stay sharp.
|
||||
pub jpeg_chroma_subsampling: bool,
|
||||
/// JPEG Options › Format Options: baseline, baseline optimized or progressive.
|
||||
pub jpeg_format: photocraft_codecs::JpegFormat,
|
||||
/// WebP: lossless (VP8L) rather than lossy (VP8 at [`Self::webp_quality`]).
|
||||
@@ -133,6 +136,7 @@ impl Default for ExportSettings {
|
||||
fn default() -> Self {
|
||||
ExportSettings {
|
||||
jpeg_quality: None,
|
||||
jpeg_chroma_subsampling: true,
|
||||
jpeg_format: photocraft_codecs::JpegFormat::Baseline,
|
||||
webp_lossless: true,
|
||||
webp_quality: None,
|
||||
@@ -163,6 +167,7 @@ impl ExportSettings {
|
||||
if let Some(q) = self.jpeg_quality {
|
||||
opts.encode.jpeg_quality = q;
|
||||
}
|
||||
opts.encode.jpeg_chroma_subsampling = self.jpeg_chroma_subsampling;
|
||||
opts.encode.jpeg_format = self.jpeg_format;
|
||||
opts.encode.webp_lossless = self.webp_lossless;
|
||||
if let Some(q) = self.webp_quality {
|
||||
|
||||
Reference in New Issue
Block a user