mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-27 03:17:05 -06:00
* File Handling: wire Image Previews to embed a PSD thumbnail (#204) Preferences > File Handling > Image Previews is live for PSD/PSB saves. With Always Save (the default) and Ask When Saving, the file carries Photoshop's thumbnail image resource (1036: the 28-byte kJpegRGB header and a JFIF stream, at most 160 px on the long side); Never Save writes none. The thumbnail is the reduced composite converted to sRGB through the document's profile with photocraft-cms (8/16/32-bit; Gray, CMYK and Lab become RGB), over white where transparent. Ask When Saving has no Save As checkbox yet, so it saves the preview, as Photoshop's checkbox starts on. The preference reaches Save, Save As, Save a Copy and automation saves in the app (ExportSettings.image_previews), and the engine's headless saves (file.saveACopy, batch, Layers/Comps/Artboards to Files) through SaveOpts. photocraft_io::ExportOptions gains image_previews (default on). Settings that do nothing: 32 of 153 (#204). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * File Handling: rustfmt the Image Previews save path Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
154 lines
7.3 KiB
Rust
154 lines
7.3 KiB
Rust
//! Preferences › File Handling › Image Previews: PSD/PSB saves embed Photoshop's thumbnail
|
||
//! resource (1036: 28-byte header + JFIF, ≤ 160 px) unless the previews are turned off.
|
||
|
||
use photocraft_color::{ColorMode, SampleType};
|
||
use photocraft_doc::{Document, Layer};
|
||
use photocraft_geom::Size;
|
||
use photocraft_io::{ExportOptions, export, import};
|
||
use photocraft_psd::PsdFile;
|
||
use photocraft_psd::resources::ids;
|
||
|
||
/// A `w`×`h` document of `mode`/`depth` holding one layer of `rgba` (given as straight sRGB-ish
|
||
/// RGBA and converted to the document's model), covering the left `cover` fraction.
|
||
fn doc(mode: ColorMode, depth: SampleType, w: u32, h: u32, rgba: [f32; 4], cover: f32) -> Document {
|
||
let mut d = Document::new("thumb", Size::new(w, h), mode, depth);
|
||
let fmt = d.pixel_format();
|
||
let mut px = vec![0.0; fmt.channels()];
|
||
photocraft_raster::from_rgba_into(&fmt, rgba, &mut px);
|
||
let mut layer = Layer::raster("pixels", fmt);
|
||
let right = ((w as f32 * cover).round() as i32).max(1);
|
||
layer.surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(0, 0, right, h as i32), &px);
|
||
d.layers.push(layer);
|
||
d
|
||
}
|
||
|
||
/// The 1036 resource of a saved file, its header checked; returns (width, height, RGB pixels).
|
||
fn thumbnail(bytes: &[u8]) -> Option<(u32, u32, Vec<[f32; 3]>)> {
|
||
let file = PsdFile::from_bytes(bytes).expect("parse");
|
||
assert!(!file.resources.iter().any(|r| r.id == ids::THUMBNAIL_PS4), "only the RGB (1036) thumbnail is written");
|
||
let mut found = file.resources.iter().filter(|r| r.id == ids::THUMBNAIL);
|
||
let r = found.next()?;
|
||
assert!(found.next().is_none(), "one thumbnail resource");
|
||
let d = &r.data;
|
||
let u = |i: usize| u32::from_be_bytes(d[i..i + 4].try_into().unwrap());
|
||
let (format, w, h, width_bytes, total, compressed) = (u(0), u(4), u(8), u(12), u(16), u(20));
|
||
assert_eq!(format, 1, "kJpegRGB");
|
||
assert!(w >= 1 && h >= 1 && w.max(h) <= 160, "{w}x{h}");
|
||
assert_eq!(width_bytes, (w * 24).div_ceil(32) * 4);
|
||
assert_eq!(total, width_bytes * h);
|
||
assert_eq!(compressed as usize, d.len() - 28);
|
||
assert_eq!(&d[24..28], &[0, 24, 0, 1], "24 bits per pixel, 1 plane");
|
||
let jfif = &d[28..];
|
||
assert!(jfif.starts_with(&[0xFF, 0xD8, 0xFF, 0xE0]) && &jfif[6..11] == b"JFIF\0", "a JFIF stream");
|
||
let img = photocraft_codecs::decode(jfif).expect("decodes");
|
||
assert_eq!(img.dimensions(), (w, h));
|
||
let n = img.layout().channels();
|
||
assert_eq!(n, 3, "RGB JPEG");
|
||
let px = img.to_normalized().as_chunks::<3>().0.to_vec();
|
||
Some((w, h, px))
|
||
}
|
||
|
||
fn near(a: [f32; 3], b: [f32; 3], tol: f32) -> bool {
|
||
a.iter().zip(&b).all(|(x, y)| (x - y).abs() <= tol)
|
||
}
|
||
|
||
#[test]
|
||
fn always_embeds_a_160px_jfif_thumbnail() {
|
||
let d = doc(ColorMode::Rgb, SampleType::U8, 640, 320, [1.0, 0.0, 0.0, 1.0], 1.0);
|
||
let out = export(&d, "x.psd", &ExportOptions::default()).unwrap();
|
||
let (w, h, px) = thumbnail(&out.bytes).expect("Always Save writes the thumbnail");
|
||
assert_eq!((w, h), (160, 80));
|
||
let mid = px[(40 * 160 + 80) as usize];
|
||
assert!(near(mid, [1.0, 0.0, 0.0], 0.08), "{mid:?}");
|
||
assert!(out.warnings.iter().all(|w| !w.contains("preview")), "{:?}", out.warnings);
|
||
}
|
||
|
||
#[test]
|
||
fn small_documents_keep_their_size() {
|
||
let d = doc(ColorMode::Rgb, SampleType::U8, 37, 5, [0.0, 0.0, 1.0, 1.0], 1.0);
|
||
let (w, h, _) = thumbnail(&export(&d, "x.psd", &ExportOptions::default()).unwrap().bytes).unwrap();
|
||
assert_eq!((w, h), (37, 5));
|
||
}
|
||
|
||
#[test]
|
||
fn never_writes_no_thumbnail() {
|
||
let d = doc(ColorMode::Rgb, SampleType::U8, 64, 64, [0.0, 1.0, 0.0, 1.0], 1.0);
|
||
for name in ["x.psd", "x.psb"] {
|
||
let out = export(&d, name, &ExportOptions { image_previews: false, ..Default::default() }).unwrap();
|
||
assert!(thumbnail(&out.bytes).is_none(), "{name}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn psb_gets_the_thumbnail_too() {
|
||
let d = doc(ColorMode::Rgb, SampleType::U8, 300, 600, [0.0, 1.0, 0.0, 1.0], 1.0);
|
||
let out = export(&d, "x.psb", &ExportOptions::default()).unwrap();
|
||
assert_eq!(out.bytes[5], 2);
|
||
let (w, h, _) = thumbnail(&out.bytes).unwrap();
|
||
assert_eq!((w, h), (80, 160));
|
||
}
|
||
|
||
#[test]
|
||
fn transparency_is_shown_over_white() {
|
||
let d = doc(ColorMode::Rgb, SampleType::U8, 160, 160, [0.0, 0.0, 0.0, 1.0], 0.5);
|
||
let (w, _, px) = thumbnail(&export(&d, "x.psd", &ExportOptions::default()).unwrap().bytes).unwrap();
|
||
let row = 80 * w as usize;
|
||
assert!(near(px[row + 20], [0.0; 3], 0.08), "{:?}", px[row + 20]);
|
||
assert!(near(px[row + 140], [1.0; 3], 0.08), "{:?}", px[row + 140]);
|
||
}
|
||
|
||
#[test]
|
||
fn every_depth_and_mode_gets_an_rgb_thumbnail() {
|
||
for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab] {
|
||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||
if depth == SampleType::F32 && matches!(mode, ColorMode::Cmyk | ColorMode::Lab) {
|
||
continue; // Photoshop has no 32-bit CMYK or Lab.
|
||
}
|
||
let d = doc(mode, depth, 200, 100, [1.0, 1.0, 1.0, 1.0], 1.0);
|
||
let out = export(&d, "x.psd", &ExportOptions::default()).unwrap();
|
||
let (w, h, px) = thumbnail(&out.bytes).unwrap_or_else(|| panic!("{mode:?} {depth:?}: no thumbnail"));
|
||
assert_eq!((w, h), (160, 80), "{mode:?} {depth:?}");
|
||
let mid = px[(40 * 160 + 80) as usize];
|
||
// White paper is white in every model (CMYK through its profile, with BPC).
|
||
assert!(near(mid, [1.0; 3], 0.1), "{mode:?} {depth:?}: {mid:?}");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn cmyk_and_16_bit_colours_survive_the_conversion() {
|
||
for (mode, depth) in
|
||
[(ColorMode::Rgb, SampleType::U16), (ColorMode::Rgb, SampleType::F32), (ColorMode::Cmyk, SampleType::U8), (ColorMode::Lab, SampleType::U16)]
|
||
{
|
||
let d = doc(mode, depth, 100, 100, [1.0, 0.0, 0.0, 1.0], 1.0);
|
||
let (_, _, px) = thumbnail(&export(&d, "x.psd", &ExportOptions::default()).unwrap().bytes).unwrap();
|
||
let [r, g, b] = px[50 * 100 + 50];
|
||
assert!(r > 0.7 && g < 0.35 && b < 0.35, "{mode:?} {depth:?}: red reads as {:?}", [r, g, b]);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn saved_files_round_trip_and_resave_a_fresh_thumbnail() {
|
||
let d = doc(ColorMode::Rgb, SampleType::U8, 120, 60, [1.0, 0.0, 0.0, 1.0], 1.0);
|
||
let first = export(&d, "x.psd", &ExportOptions::default()).unwrap();
|
||
let back = import("x.psd", &first.bytes).unwrap().document;
|
||
assert_eq!((back.size.width, back.size.height), (120, 60));
|
||
// The thumbnail is regenerated, never kept as an opaque resource that would go stale.
|
||
assert!(back.metadata.psd_resources.iter().all(|(id, _, _)| *id != ids::THUMBNAIL));
|
||
let again = export(&back, "x.psd", &ExportOptions::default()).unwrap();
|
||
thumbnail(&again.bytes).expect("one fresh thumbnail");
|
||
let off = export(&back, "x.psd", &ExportOptions { image_previews: false, ..Default::default() }).unwrap();
|
||
assert!(thumbnail(&off.bytes).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn the_document_profile_is_honoured() {
|
||
// Mid gray in a linear-sRGB document is sRGB ≈ 0.735, not 0.5: the thumbnail goes through
|
||
// the embedded profile rather than assuming sRGB.
|
||
let mut d = doc(ColorMode::Rgb, SampleType::U16, 50, 50, [0.5, 0.5, 0.5, 1.0], 1.0);
|
||
d.icc_profile = Some(std::sync::Arc::new(photocraft_cms::Builtin::LinearSrgb.profile().to_bytes().to_vec()));
|
||
let (_, _, px) = thumbnail(&export(&d, "x.psd", &ExportOptions::default()).unwrap().bytes).unwrap();
|
||
let mid = px[25 * 50 + 25];
|
||
assert!(near(mid, [0.735; 3], 0.03), "{mid:?}");
|
||
}
|