diff --git a/apps/photocraft-cli/src/lib.rs b/apps/photocraft-cli/src/lib.rs index 03001ab02d..e79e63a701 100644 --- a/apps/photocraft-cli/src/lib.rs +++ b/apps/photocraft-cli/src/lib.rs @@ -15,19 +15,22 @@ pub const USAGE: &str = "\ photocraft-cli: headless PhotoCraft USAGE: - photocraft-cli convert [--format ] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] + photocraft-cli convert [--format ] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--jpeg-format ] Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …). TIFF output is flat unless --tiff-layers keeps the layers (Photoshop layer data). --quality sets the JPEG or WebP quality; a WebP written with a quality is lossy, without one lossless. --tga-bits sets a TGA's bits per pixel: 32 writes the alpha channel (or else the transparency) as its alpha, 24 none. Without it: 32 with an alpha channel or transparency, else 24. --dpx-bits sets a DPX's bits per sample. Without it: 8 for an 8-bit document, else 16. + --jpeg-format picks a JPEG's scans, as JPEG Options does: baseline (the default), optimized + (Huffman tables fitted to the image; RGB is written 4:2:0 and keeps the standard tables, so + only Gray and CMYK shrink) or progressive. photocraft-cli info [--compact] Print the document as JSON (size, mode, depth, layer tree). - photocraft-cli run ( | --new ) --cmd [--params ] [--cmd …] [--out ] [--format ] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] + photocraft-cli run ( | --new ) --cmd [--params ] [--cmd …] [--out ] [--format ] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--jpeg-format ] Open a file, run engine commands in order, save the result. Each --params applies to the preceding --cmd. Prints each command's JSON result. - photocraft-cli batch --actions [--action ] --in --out [--format ] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] + photocraft-cli batch --actions [--action ] --in --out [--format ] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--jpeg-format ] Apply an action list to every image in a directory. A Photoshop Actions file (.atn) is a set: its action --action names plays, as below. JSON steps are [id, params] pairs, {\"command\": id, \"params\": {…}} objects or bare ids, as a recorded action or droplet stores them @@ -69,17 +72,17 @@ struct Subcommand { } const SUBCOMMANDS: &[Subcommand] = &[ - Subcommand { name: "convert", values: &["--format", "--quality", "--tga-bits", "--dpx-bits"], bare: &["--tiff-layers"], run: convert }, + Subcommand { name: "convert", values: &["--format", "--quality", "--tga-bits", "--dpx-bits", "--jpeg-format"], bare: &["--tiff-layers"], run: convert }, Subcommand { name: "info", values: &[], bare: &["--compact"], run: |a, out, _| info(a, out) }, Subcommand { name: "run", - values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits"], + values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits", "--jpeg-format"], bare: &["--tiff-layers"], run: run_cmds, }, Subcommand { name: "batch", - values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits"], + values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits", "--jpeg-format"], bare: &["--in-place", "--tiff-layers"], run: batch, }, @@ -219,6 +222,10 @@ fn export_opts(a: &Args) -> Result { o.encode.dpx_bits = Some(b.parse().ok().and_then(photocraft_io::DpxBits::from_bits).ok_or_else(|| format!("bad --dpx-bits `{b}`: expected 8, 10, 12 or 16"))?); } + if let Some(f) = a.get("--jpeg-format") { + o.encode.jpeg_format = + photocraft_codecs::JpegFormat::parse(f).ok_or_else(|| format!("bad --jpeg-format `{f}`: expected baseline, optimized or progressive"))?; + } Ok(o) } diff --git a/apps/photocraft-cli/tests/cli.rs b/apps/photocraft-cli/tests/cli.rs index 3c28a8196e..7553702381 100644 --- a/apps/photocraft-cli/tests/cli.rs +++ b/apps/photocraft-cli/tests/cli.rs @@ -520,6 +520,33 @@ fn tga_bits_choose_the_targa_depth() { } } +/// `--jpeg-format` picks the JPEG's scans: progressive writes a progressive frame (SOF2). One it +/// doesn't name is refused and nothing is written. +#[test] +fn jpeg_format_chooses_the_scans() { + let d = tmp("jpeg-format"); + let a = d.join("a.png"); + write_png(&a, 16, 8, 3); + let marker = |b: &[u8], m: u8| b.windows(2).any(|w| w == [0xFF, m]); + for (format, progressive) in [(None, false), (Some("baseline"), false), (Some("optimized"), false), (Some("progressive"), true)] { + let out = d.join("a.jpg"); + let mut convert = bin(); + convert.arg("convert").arg(&a).arg(&out); + if let Some(f) = format { + convert.args(["--jpeg-format", f]); + } + ok(&mut convert); + assert_eq!(marker(&std::fs::read(&out).unwrap(), 0xC2), progressive, "--jpeg-format {format:?}"); + } + for bad in ["Progressive", "1", ""] { + let out = d.join("bad.jpg"); + let o = bin().arg("convert").arg(&a).arg(&out).args(["--jpeg-format", bad]).output().unwrap(); + assert_eq!(o.status.code(), Some(1), "--jpeg-format {bad}"); + assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --jpeg-format `{bad}`")), "--jpeg-format {bad}"); + assert!(!out.exists(), "--jpeg-format {bad} wrote a file"); + } +} + /// #492: an `--out` folder that is the `--in` folder, however it is spelt, is refused before /// anything is written, unless `--in-place` asks for it. #[test] diff --git a/book/src/formats/raster-formats.md b/book/src/formats/raster-formats.md index 2e65d51550..4411a0b45f 100644 --- a/book/src/formats/raster-formats.md +++ b/book/src/formats/raster-formats.md @@ -19,6 +19,7 @@ Every route that writes a document to a flat format (Save As, the CLI, MCP `doc_ - Formats without transparency (JPEG, Radiance HDR) get the image composited over white, as Export As and Quick Export do; in CMYK, white is no ink. - Formats that can't embed an ICC profile (GIF, BMP, TGA, QOI, ICO, Netpbm/PFM) get RGB documents in another profile (linear light, Display P3, …) converted to sRGB through the colour engine (perceptual intent), since an untagged file is read as sRGB. OpenEXR and Radiance HDR get linear sRGB instead (linear gray for a grayscale OpenEXR), which is how they are tagged when opened. - TGA, as Photoshop's Targa: an RGB Targa is 24 or 32 bits per pixel. Save As and Save a Copy ask (Targa Options, starting at 32 with an alpha channel, else 24); Export As has a Resolution choice (Auto, 24 or 32 bits/pixel); scripts pass `tgaBits` (`--tga-bits` on the CLI). At 32 bits the document's alpha channel is the file's alpha when it has exactly one (spot channels don't count; with several, none is used), and the colours are written exactly, never premultiplied or cleared where the alpha is 0, so a game texture's mask and its colours both survive. Without a usable alpha channel the layers' transparency is the alpha (opaque without any). 24 bits write no alpha, compositing transparency over white. Without a choice: 32 with an alpha channel or transparency, else 24. A 32-bit Targa opens as an opaque Background with its alpha as "Alpha 1", so it round-trips. Grayscale Targas are 8-bit. +- JPEG, as Photoshop's JPEG Options: Save As and Save a Copy ask for the Quality on Photoshop's 0–12 scale (with its Low, Medium, High and Maximum presets) and a Format Option: Baseline ("Standard"), Baseline Optimized (Huffman tables fitted to the image) or Progressive. The quality starts at the opened JPEG's own quality, else at the Export preference's JPEG quality; left there, an opened JPEG is written at exactly its own quality. Scripts pass `"quality": 0..12` and `"jpegFormat": "baseline" | "optimized" | "progressive"` (on the CLI, `--quality` on the encoder's 1–100 scale and `--jpeg-format`) and aren't asked; recorded actions replay the answer. The scale maps onto the encoder's 1–100 as `round(q / 12 × 99 + 1)`. RGB JPEGs are written 4:2:0, where Baseline Optimized keeps the standard tables for now (Gray and CMYK, written 4:4:4, get fitted ones). Matte and the file-size preview aren't offered. Flat TIFF and JPEG exports of composited CMYK documents convert the RGB composite back through the document's CMYK ICC profile (relative colorimetric with black point compensation) and embed diff --git a/crates/codecs/src/codecs/jpeg.rs b/crates/codecs/src/codecs/jpeg.rs index a300de4bd1..24385ab8a8 100644 --- a/crates/codecs/src/codecs/jpeg.rs +++ b/crates/codecs/src/codecs/jpeg.rs @@ -18,7 +18,7 @@ use crate::Format; use crate::error::CodecError; use crate::fidelity::Plan; use crate::image::{ChannelLayout, DecodeWarning, Image, Metadata, SampleType}; -use crate::options::{EncodeOptions, Limits}; +use crate::options::{EncodeOptions, JpegFormat, Limits}; use crate::resolution::{exif_resolution, export_exif, export_xmp, photoshop_resolution, xmp_resolution}; const F: Format = Format::Jpeg; @@ -255,12 +255,17 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result &'static str { + match self { + JpegFormat::Baseline => "baseline", + JpegFormat::Optimized => "optimized", + JpegFormat::Progressive => "progressive", + } + } + + /// The inverse of [`Self::name`]. + pub fn parse(s: &str) -> Option { + [JpegFormat::Baseline, JpegFormat::Optimized, JpegFormat::Progressive].into_iter().find(|f| f.name() == s) + } +} + /// OpenEXR compression (lossless methods only). #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum ExrCompression { @@ -189,6 +220,8 @@ pub struct EncodeOptions { pub jpeg_quality: u8, /// JPEG 4:2:0 chroma subsampling (`false` = 4:4:4). pub jpeg_chroma_subsampling: bool, + /// JPEG scan layout (baseline, baseline optimized or progressive). + pub jpeg_format: JpegFormat, pub png_compression: PngCompression, /// Write Adam7-interlaced PNG. pub png_interlaced: bool, @@ -218,6 +251,7 @@ impl Default for EncodeOptions { EncodeOptions { jpeg_quality: 90, jpeg_chroma_subsampling: true, + jpeg_format: JpegFormat::Baseline, png_compression: PngCompression::Default, png_interlaced: false, png_stereo: None, diff --git a/crates/codecs/tests/roundtrip.rs b/crates/codecs/tests/roundtrip.rs index 7918d313b6..ce0ef7ae8f 100644 --- a/crates/codecs/tests/roundtrip.rs +++ b/crates/codecs/tests/roundtrip.rs @@ -239,6 +239,48 @@ fn jpeg_444_beats_420_on_chroma_detail() { assert!(psnr(&img, &decode(&a).unwrap()) > psnr(&img, &decode(&b).unwrap())); } +/// JPEG Options › Format Options: each scan layout decodes back to the same image, Progressive +/// writes a progressive frame (SOF2), and Baseline Optimized fits its Huffman tables (smaller at +/// 4:4:4; at 4:2:0 it keeps the standard tables, see `JpegFormat::Optimized`). +#[test] +fn jpeg_format_options_round_trip() { + let img = synth(64, 48, ChannelLayout::Rgb, SampleType::U8, 3, 0.5); + let sof = |b: &[u8], m: u8| b.windows(2).any(|w| w == [0xFF, m]); + for subsampled in [false, true] { + let enc = |f| { + encode(&img, Format::Jpeg, &EncodeOptions { jpeg_format: f, jpeg_chroma_subsampling: subsampled, jpeg_quality: 90, ..Default::default() }).unwrap() + }; + let (base, opt, prog) = (enc(JpegFormat::Baseline), enc(JpegFormat::Optimized), enc(JpegFormat::Progressive)); + // The scan layout only changes how the same quantized coefficients are stored, so every + // format decodes as faithfully as Baseline. + let reference = psnr(&img, &decode(&base).unwrap()); + for (name, b) in [("optimized", &opt), ("progressive", &prog)] { + let back = decode(b).unwrap(); + assert_eq!(back.dimensions(), img.dimensions(), "{name}"); + let p = psnr(&img, &back); + assert!((p - reference).abs() < 0.5, "{name}, subsampled {subsampled}: {p} vs baseline {reference}"); + } + assert!(sof(&base, 0xC0) && !sof(&base, 0xC2)); + assert!(sof(&prog, 0xC2), "progressive frame"); + if subsampled { + assert_eq!(opt, base, "4:2:0 keeps the standard tables"); + } else { + assert!(opt.len() < base.len(), "fitted tables are smaller: {} vs {}", opt.len(), base.len()); + } + } + // Gray is 4:4:4 too, so Baseline Optimized shrinks it. + let gray = synth(64, 48, ChannelLayout::Gray, SampleType::U8, 3, 0.5); + let enc = |f| encode(&gray, Format::Jpeg, &EncodeOptions { jpeg_format: f, ..Default::default() }).unwrap(); + let (base, opt) = (enc(JpegFormat::Baseline), enc(JpegFormat::Optimized)); + assert!(opt.len() < base.len(), "gray: {} vs {}", opt.len(), base.len()); + assert!((psnr(&gray, &decode(&opt).unwrap()) - psnr(&gray, &decode(&base).unwrap())).abs() < 0.5); + assert_eq!(JpegFormat::parse("progressive"), Some(JpegFormat::Progressive)); + assert_eq!(JpegFormat::parse(JpegFormat::Optimized.name()), Some(JpegFormat::Optimized)); + for bad in ["Progressive", "", "standard"] { + assert_eq!(JpegFormat::parse(bad), None, "{bad}"); + } +} + #[test] fn webp_lossy_request_is_a_lossy_file() { let img = test_image(ChannelLayout::Rgb, SampleType::U8); diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index 2299aa3e49..396d22157d 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -288,12 +288,14 @@ pub(crate) fn import(name: &str, bytes: &[u8]) -> Result { Ok(r.document) } -/// What a headless save writes beyond the format: JPEG quality, TIFF layers, Targa bits, PSD -/// previews and the PSD merged image. +/// What a headless save writes beyond the format: JPEG quality and format, TIFF layers, Targa bits, +/// PSD previews and the PSD merged image. #[derive(Debug, Clone, Copy)] pub(crate) struct SaveOpts { /// Photoshop's 0–12 JPEG scale. pub quality: Option, + /// JPEG Options › Format Options (`"jpegFormat"`); unset, Baseline ("Standard"). + pub jpeg_format: Option, /// TIFF: keep the layers. Off unless a command's params ask (`"tiffLayers": true`). pub tiff_layers: bool, /// Targa: 24 or 32 bits per pixel (`"tgaBits"`); unset, 32 with an alpha channel or @@ -310,19 +312,29 @@ pub(crate) struct SaveOpts { impl Default for SaveOpts { fn default() -> Self { - SaveOpts { quality: None, tiff_layers: false, tga_bits: None, dpx_bits: None, image_previews: true, psd_maximize_compatibility: true } + SaveOpts { + quality: None, + jpeg_format: None, + tiff_layers: false, + tga_bits: None, + dpx_bits: None, + image_previews: true, + psd_maximize_compatibility: true, + } } } impl SaveOpts { - /// `quality`, `tiffLayers` and `tgaBits` from command `cmd`'s params (a `tgaBits` other than - /// 24 or 32 is an error); the image previews and the PSD merged image from the session's File - /// Handling preferences (Ask When Saving has no dialog here, so it saves them, as Photoshop's - /// Save As checkbox and its Maximize Compatibility checkbox both start on). + /// `quality`, `jpegFormat`, `tiffLayers` and `tgaBits` from command `cmd`'s params (a + /// `jpegFormat` it doesn't name or a `tgaBits` other than 24 or 32 is an error); the image + /// previews and the PSD merged image from the session's File Handling preferences (Ask When + /// Saving has no dialog here, so it saves them, as Photoshop's Save As checkbox and its + /// Maximize Compatibility checkbox both start on). pub(crate) fn from_params(s: &Session, p: &Value, cmd: &str) -> Result { let file_handling = &s.prefs().file_handling; Ok(SaveOpts { quality: f64_param(p, "quality"), + jpeg_format: jpeg_format_param(p, cmd)?, tiff_layers: p.get("tiffLayers").and_then(Value::as_bool).unwrap_or(false), tga_bits: tga_bits_param(p, cmd)?, dpx_bits: dpx_bits_param(p, cmd)?, @@ -357,6 +369,25 @@ pub fn tga_bits_param(p: &Value, cmd: &str) -> Result Result> { + match p.get("jpegFormat") { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_str().and_then(photocraft_codecs::JpegFormat::parse).map(Some).ok_or_else(|| EngineError::BadParams { + cmd: cmd.into(), + msg: format!("`jpegFormat` must be \"baseline\", \"optimized\" or \"progressive\", got {v}"), + }), + } +} + +/// Photoshop's 0–12 JPEG quality as the encoder's 1–100 (0 → 1, 12 → 100, linear between; out of +/// range is clamped, and NaN or infinite is 100). +pub fn jpeg_quality(q: f64) -> u8 { + if q.is_finite() { (q.clamp(0.0, 12.0) / 12.0 * 99.0 + 1.0).round() as u8 } else { 100 } +} + /// The DPX bits per sample in `p` (`"dpxBits": 8 | 10 | 12 | 16`; absent or null: the document's /// depth). Anything else is a bad parameter of command `cmd`. pub fn dpx_bits_param(p: &Value, cmd: &str) -> Result> { @@ -388,12 +419,15 @@ pub(crate) fn encode(doc: &Document, path: &str, save: impl Into) -> R photocraft_io::apply_file_encoding(doc, &mut opts.encode); opts.encode.dpx_bits = save.dpx_bits; if let Some(q) = save.quality { - let q = (q.clamp(0.0, 12.0) / 12.0 * 99.0 + 1.0).round() as u8; + let q = jpeg_quality(q); opts.encode.jpeg_quality = q; // A quality on a WebP save asks for the lossy encoder; the default WebP stays lossless. opts.encode.webp_quality = q; opts.encode.webp_lossless = false; } + if let Some(f) = save.jpeg_format { + opts.encode.jpeg_format = f; + } photocraft_io::export(doc, path, &opts).map(|r| (r.bytes, r.warnings)).map_err(|e| EngineError::Other(format!("{path}: {e}"))) } @@ -1651,7 +1685,7 @@ pub fn specs() -> Vec { "Save a Copy…", &["File"], Some("Cmd+Alt+S"), - r##"{"path":str (format from the extension),"quality":0..12? (JPEG),"layers":bool=true,"overwrite":bool=false (replace the document's own file),"tiffLayers":bool=false (TIFF: keep the layers; flat by default),"tgaBits":24|32? (TGA: 32 writes the alpha channel, or else the transparency, as the alpha; unset, 32 with an alpha channel or transparency, else 24),"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16)}"##, + r##"{"path":str (format from the extension),"quality":0..12? (JPEG),"jpegFormat":"baseline|optimized|progressive"? (JPEG; default baseline),"layers":bool=true,"overwrite":bool=false (replace the document's own file),"tiffLayers":bool=false (TIFF: keep the layers; flat by default),"tgaBits":24|32? (TGA: 32 writes the alpha channel, or else the transparency, as the alpha; unset, 32 with an alpha channel or transparency, else 24),"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16)}"##, native_doc, save_a_copy ), @@ -1714,7 +1748,7 @@ pub fn specs() -> Vec { "Batch…", &["File", "Automate"], None, - r##"{"steps":[[commandId,params]|{"command":id,"params":{}}…] (a recorded action),"input":folder|[paths],"includeSubfolders":bool=false (a folder input also takes the images in its subfolders, up to 32 levels and 100000 files; each result keeps its subfolder under output),"output":folder,"format":"same|png|jpg|psd|tiff|…"="same","quality":0..12?,"naming":string? (blank keeps source names; {name}, {serial} padded to 3 digits, {date} as YYYY-MM-DD UTC; extension added automatically),"startingSerial":1..4294967295=1 (input position, including failed inputs),"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"calledActions":[{"name","steps"}…]? (the actions Play Action steps call; default: the session's)} → {files, errors} (an input whose output name was already written in the run goes to errors)"##, + r##"{"steps":[[commandId,params]|{"command":id,"params":{}}…] (a recorded action),"input":folder|[paths],"includeSubfolders":bool=false (a folder input also takes the images in its subfolders, up to 32 levels and 100000 files; each result keeps its subfolder under output),"output":folder,"format":"same|png|jpg|psd|tiff|…"="same","quality":0..12?,"jpegFormat":"baseline|optimized|progressive"?,"naming":string? (blank keeps source names; {name}, {serial} padded to 3 digits, {date} as YYYY-MM-DD UTC; extension added automatically),"startingSerial":1..4294967295=1 (input position, including failed inputs),"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"calledActions":[{"name","steps"}…]? (the actions Play Action steps call; default: the session's)} → {files, errors} (an input whose output name was already written in the run goes to errors)"##, native, batch ), @@ -1723,7 +1757,7 @@ pub fn specs() -> Vec { "Image Processor…", &["File", "Scripts"], None, - r##"{"input":folder|[paths],"includeSubfolders":bool=false (a folder input also takes the images in its subfolders, up to 32 levels and 100000 files; each result keeps its subfolder under output),"output":folder,"format":"jpg|png|psd|tiff|…"="jpg","quality":0..12=8,"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"width":px?,"height":px? (fit, never enlarge),"convertToSrgb":bool=false} → {files, errors} (an input whose output name was already written in the run goes to errors)"##, + r##"{"input":folder|[paths],"includeSubfolders":bool=false (a folder input also takes the images in its subfolders, up to 32 levels and 100000 files; each result keeps its subfolder under output),"output":folder,"format":"jpg|png|psd|tiff|…"="jpg","quality":0..12=8,"jpegFormat":"baseline|optimized|progressive"?,"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"width":px?,"height":px? (fit, never enlarge),"convertToSrgb":bool=false} → {files, errors} (an input whose output name was already written in the run goes to errors)"##, native, image_processor ), @@ -1751,7 +1785,7 @@ pub fn specs() -> Vec { "Layers to Files…", &["File", "Export"], None, - r##"{"dir":folder,"format":"png|jpg|psd|tiff|…"="png","prefix":str=document name,"visibleOnly":bool=true,"quality":0..12?,"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16)} → {files}"##, + r##"{"dir":folder,"format":"png|jpg|psd|tiff|…"="png","prefix":str=document name,"visibleOnly":bool=true,"quality":0..12?,"jpegFormat":"baseline|optimized|progressive"?,"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16)} → {files}"##, native_doc, layers_to_files ), diff --git a/crates/engine/src/file_cmds/tests.rs b/crates/engine/src/file_cmds/tests.rs index b07c649850..729ca35c07 100644 --- a/crates/engine/src/file_cmds/tests.rs +++ b/crates/engine/src/file_cmds/tests.rs @@ -235,6 +235,57 @@ fn bad_targa_bits_are_refused_before_writing() { } } +/// `jpegFormat` picks JPEG Options' format: Progressive writes a progressive frame (SOF2), the +/// others a baseline one (SOF0); `quality` keeps Photoshop's 0–12 scale. +#[test] +fn save_a_copy_writes_the_jpeg_format_asked_for() { + let dir = tmp("jpeg-format"); + let mut s = session(40, 30, 8); + let marker = |b: &[u8], m: u8| b.windows(2).any(|w| w == [0xFF, m]); + for (params, progressive) in [ + (json!({}), false), + (json!({"jpegFormat": null}), false), + (json!({"jpegFormat": "baseline", "quality": 12}), false), + (json!({"jpegFormat": "optimized", "quality": 10}), false), + (json!({"jpegFormat": "progressive", "quality": 3}), true), + ] { + let out = join(&dir, "a.jpg"); + let mut p = params.clone(); + p["path"] = json!(out); + s.execute("file.saveACopy", p).unwrap(); + let bytes = std::fs::read(&out).unwrap(); + assert_eq!(marker(&bytes, 0xC2), progressive, "{params}"); + assert_eq!(marker(&bytes, 0xC0), !progressive, "{params}"); + assert_eq!(photocraft_io::import("a.jpg", &bytes).unwrap().document.size.width, 40, "{params}"); + } + // Layers to Files takes it too. + s.execute("file.export.layersToFiles", json!({"dir": dir, "format": "jpg", "jpegFormat": "progressive"})).unwrap(); + let layers: Vec<_> = std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok()).map(|e| e.path()).filter(|p| !p.ends_with("a.jpg")).collect(); + assert!(!layers.is_empty()); + for p in layers { + assert!(marker(&std::fs::read(&p).unwrap(), 0xC2), "{p:?} is progressive"); + } + assert_eq!((jpeg_quality(0.0), jpeg_quality(12.0), jpeg_quality(6.0)), (1, 100, 51)); + assert_eq!((jpeg_quality(-3.0), jpeg_quality(40.0), jpeg_quality(f64::NAN), jpeg_quality(f64::NEG_INFINITY)), (1, 100, 100, 100)); +} + +/// A `jpegFormat` other than the three names is a bad parameter, reported before anything is +/// written. +#[test] +fn bad_jpeg_formats_are_refused_before_writing() { + let dir = tmp("jpeg-bad"); + let mut s = session(8, 8, 8); + for bad in [json!("Progressive"), json!(""), json!(2), json!(true), json!(["baseline"]), json!({"format": "baseline"})] { + let out = join(&dir, "a.jpg"); + let r = s.execute("file.saveACopy", json!({"path": out, "jpegFormat": bad})); + assert!(matches!(r, Err(EngineError::BadParams { .. })), "{bad}: {r:?}"); + assert!(!std::path::Path::new(&out).exists(), "{bad}: nothing written"); + let r = s.execute("file.export.layersToFiles", json!({"dir": dir, "format": "jpg", "jpegFormat": bad})); + assert!(matches!(r, Err(EngineError::BadParams { .. })), "{bad}: {r:?}"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "{bad}: no layer written"); + } +} + /// Preferences › File Handling › Image Previews decides whether a PSD gets Photoshop's /// thumbnail resource (1036): Always and Ask When Saving write it, Never doesn't. #[test] diff --git a/crates/ui-egui/examples/snapshot.rs b/crates/ui-egui/examples/snapshot.rs index 0d227255db..5480d553b0 100644 --- a/crates/ui-egui/examples/snapshot.rs +++ b/crates/ui-egui/examples/snapshot.rs @@ -49,6 +49,7 @@ fn main() { if let Some(q) = settings.jpeg_quality { opts.encode.jpeg_quality = q; } + opts.encode.jpeg_format = settings.jpeg_format; opts.encode.webp_lossless = settings.webp_lossless; if let Some(q) = settings.webp_quality { opts.encode.webp_quality = q; diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 581925f83c..fe6692bdec 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -1280,10 +1280,10 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) } None => err("missing `path`"), }, - "app.save" => match crate::targa_options_ui::bits_param(p, "app.save") { - Ok(tga_bits) => { + "app.save" => match crate::SaveChoices::from_params(p, "app.save") { + Ok(choices) => { let path = s("path").map(str::to_string); - run_waiting(app, p.get("wait").and_then(Value::as_bool).unwrap_or(true), move |app| app.save_automation_with(path, tga_bits)) + run_waiting(app, p.get("wait").and_then(Value::as_bool).unwrap_or(true), move |app| app.save_automation_with(path, choices)) } Err(e) => err(e), }, @@ -2441,6 +2441,54 @@ mod tests { assert_eq!(written.borrow().len(), 3, "a bad tgaBits writes nothing"); } + /// `app.save` takes JPEG Options (`quality` 0–12, `jpegFormat`) and never stops at the prompt; + /// a value it can't read is an error that writes nothing. + #[test] + fn automation_save_passes_jpeg_options_without_asking() { + use photocraft_codecs::JpegFormat; + use std::cell::RefCell; + use std::rc::Rc; + // The JPEG quality (1–100) and format each export was asked for. + type Asked = Rc, JpegFormat)>>>; + let asked: Asked = Rc::default(); + let written: Rc>> = Rc::default(); + let (a, w) = (asked.clone(), written.clone()); + let services = crate::Services { + export: Some(Box::new(move |_d: &photocraft_doc::Document, _p: &str, s: &crate::ExportSettings| { + a.borrow_mut().push((s.jpeg_quality, s.jpeg_format)); + Ok((b"out".to_vec(), Vec::new())) + })), + automation_write: Some(Box::new(move |path: &str, _b: &[u8]| { + w.borrow_mut().push(path.to_string()); + Ok(()) + })), + ..Default::default() + }; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services); + let ctx = egui::Context::default(); + app.run("file.new", json!({"width": 8, "height": 8})).unwrap(); + for params in [ + json!({"path": "a.jpg"}), + json!({"path": "b.jpg", "quality": 12, "jpegFormat": "progressive"}), + json!({"path": "c.jpg", "jpegFormat": "optimized"}), + ] { + let r = call(&mut app, &ctx, "app.save", params.clone()); + assert_eq!(r["result"]["path"], params["path"], "{r}"); + assert!(app.jpeg_options.is_none(), "automation saves never prompt"); + } + assert_eq!(*asked.borrow(), [(None, JpegFormat::Baseline), (Some(100), JpegFormat::Progressive), (None, JpegFormat::Optimized)]); + for bad in [ + json!({"path": "d.jpg", "quality": 13}), + json!({"path": "d.jpg", "quality": "high"}), + json!({"path": "d.jpg", "jpegFormat": "fast"}), + json!({"path": "d.jpg", "jpegFormat": 2}), + ] { + let r = call(&mut app, &ctx, "app.save", bad.clone()); + assert!(r["error"].is_string(), "{bad}: {r}"); + } + assert_eq!(written.borrow().len(), 3, "a bad JPEG option writes nothing"); + } + #[test] fn automation_open_and_save_use_the_roots_and_reply_with_warnings() { use photocraft_color::{ColorMode, SampleType}; diff --git a/crates/ui-egui/src/discard_ui.rs b/crates/ui-egui/src/discard_ui.rs index 82e4497d92..455ccbef52 100644 --- a/crates/ui-egui/src/discard_ui.rs +++ b/crates/ui-egui/src/discard_ui.rs @@ -959,6 +959,9 @@ mod tests { #[path = "targa_tests.rs"] mod targa_tests; + #[path = "jpeg_tests.rs"] + mod jpeg_tests; + /// One frame with the window's close button pressed; the commands the guard sent. fn window_close_commands(app: &mut PhotocraftApp) -> Vec { let mut info = egui::ViewportInfo::default(); diff --git a/crates/ui-egui/src/discard_ui/tests/jpeg_tests.rs b/crates/ui-egui/src/discard_ui/tests/jpeg_tests.rs new file mode 100644 index 0000000000..ebeb97fb13 --- /dev/null +++ b/crates/ui-egui/src/discard_ui/tests/jpeg_tests.rs @@ -0,0 +1,272 @@ +//! JPEG Options on Save As and Save a Copy (FILE-215-19): the quality and format it asks for, the +//! scripted `quality` / `jpegFormat` that skip it, recording and playback, and the close prompt +//! waiting behind it. +use super::*; +use crate::{FileDialogAnswer, jpeg_options_ui}; +use egui_kittest::kittest::Queryable; +use photocraft_codecs::JpegFormat; +use std::{cell::RefCell, rc::Rc}; + +/// Each write: the path, and the JPEG quality (1–100) and format the export was asked for. +type Writes = Rc)>>>; + +/// The JSON a write records. +fn asked(quality: Option, format: JpegFormat) -> Vec { + serde_json::to_vec(&(quality, format.name())).unwrap() +} + +/// The encoder quality for Photoshop quality `q`. +fn pct(q: u8) -> Option { + Some(photocraft_engine::file_cmds::jpeg_quality(f64::from(q))) +} + +/// One 8×8 RGB document whose exports record their JPEG settings, in a harness showing JPEG +/// Options and answering a save dialog with `to`. +fn jpeg_app(to: &str) -> (Prompted, Writes) { + let mut app = app_with_docs(1); + let writes: Writes = Rc::default(); + let written = writes.clone(); + app.services.file_dialog = Some(crate::file_dialog::fake(vec![Some(FileDialogAnswer::SaveTo(to.into()))]).0); + app.services.export = Some(Box::new(|_, _, settings| Ok((asked(settings.jpeg_quality, settings.jpeg_format), Vec::new())))); + app.services.write = Some(Box::new(move |path, bytes| { + written.borrow_mut().push((path.to_string(), bytes.to_vec())); + Ok(()) + })); + let h = egui_kittest::Harness::builder().with_size(egui::vec2(800.0, 600.0)).build_ui_state( + |ui, app| { + show(app, ui.ctx()); + jpeg_options_ui::show(app, ui.ctx()); + app.poll_file_dialog(ui.ctx(), None); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.ctx.set_os(egui::os::OperatingSystem::Windows); + (h, writes) +} + +/// Save As to a JPEG asks first, starting at the Export preferences' quality (85 → 10, Maximum) +/// and Baseline, and writes only once answered. +#[test] +fn save_as_to_a_jpeg_asks_for_jpeg_options() { + let (mut h, writes) = jpeg_app("unused.jpg"); + h.state_mut().save_as(Some("p.jpg".into())).unwrap(); + h.run_steps(2); + assert_eq!(h.state().jpeg_options.as_ref().map(|p| (p.quality, p.format, p.copy)), Some((10, JpegFormat::Baseline, false))); + assert!(h.query_by_label("JPEG Options").is_some()); + assert!(writes.borrow().is_empty(), "nothing is written before the answer"); + assert!(h.state_mut().save_as(Some("other.jpg".into())).is_err(), "one save at a time"); + h.key_press(Key::Enter); + h.run_steps(3); + assert_eq!(writes.borrow().as_slice(), &[("p.jpg".to_string(), asked(pct(10), JpegFormat::Baseline))]); + assert!(h.state().jpeg_options.is_none()); + assert_eq!(h.state().session.active().unwrap().path.as_deref(), Some("p.jpg")); +} + +/// Re-saving an opened JPEG starts at the file's own quality (`file_encoding`, as File › Save +/// keeps it since #4032), not the Export preference's; left there, OK writes that quality exactly +/// rather than its nearest step on the 0–12 scale (95 would come back as 92), and the recorded step +/// leaves the quality out, so playback keeps it too. Save a Copy does the same; a quality changed +/// in the prompt is written and recorded on the scale. +#[test] +fn resaving_an_opened_jpeg_starts_at_its_own_quality() { + let (mut h, writes) = jpeg_app("copy.jpg"); + std::sync::Arc::make_mut(&mut h.state_mut().session.active_mut().unwrap().doc).file_encoding.jpeg_quality = Some(95); + h.state_mut().run("actions.record", json!({})).unwrap(); + h.state_mut().save_as(Some("p.jpg".into())).unwrap(); + h.run_steps(2); + assert_eq!(h.state().jpeg_options.as_ref().map(|p| p.quality), Some(11), "95 is 11 on Photoshop's scale"); + h.key_press(Key::Enter); + h.run_steps(3); + let ctx = h.ctx.clone(); + crate::menus::invoke(h.state_mut(), &ctx, "file.saveACopy", json!({})).unwrap(); + h.run_steps(3); + assert_eq!(h.state().jpeg_options.as_ref().map(|p| (p.quality, p.copy)), Some((11, true))); + h.key_press(Key::Enter); + h.run_steps(3); + h.state_mut().save_as(Some("q.jpg".into())).unwrap(); + h.run_steps(2); + h.state_mut().jpeg_options.as_mut().unwrap().quality = 12; + h.run_steps(2); + h.key_press(Key::Enter); + h.run_steps(3); + h.state_mut().run("actions.stop", json!({})).unwrap(); + let expected = [ + ("p.jpg".to_string(), asked(Some(95), JpegFormat::Baseline)), + ("copy.jpg".to_string(), asked(Some(95), JpegFormat::Baseline)), + ("q.jpg".to_string(), asked(pct(12), JpegFormat::Baseline)), + ]; + assert_eq!(writes.borrow().as_slice(), &expected); + assert_eq!( + h.state().session.actions.list[0].steps, + [ + ("file.saveAs".into(), json!({"path": "p.jpg", "jpegFormat": "baseline"})), + ("file.saveACopy".into(), json!({"path": "copy.jpg", "jpegFormat": "baseline"})), + ("file.saveAs".into(), json!({"path": "q.jpg", "quality": 12, "jpegFormat": "baseline"})), + ] + ); + writes.borrow_mut().clear(); + let played = h.state_mut().run("actions.play", json!({"action": 0})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + assert_eq!(writes.borrow().as_slice(), &expected, "playback writes the file's own quality again"); +} + +/// A `file.saveACopy` step recorded from the engine command keeps its params as given, and the +/// engine clamps a quality past 12; playing it back writes the copy the same way instead of +/// failing on the quality. +#[test] +fn a_copy_recorded_from_the_engine_command_replays() { + let (mut h, writes) = jpeg_app("unused.jpg"); + let path = std::env::temp_dir().join(format!("photocraft-jpeg-copy-{}.jpg", std::process::id())).to_string_lossy().into_owned(); + h.state_mut().run("actions.record", json!({})).unwrap(); + h.state_mut().run("file.saveACopy", json!({"path": path, "quality": 90})).unwrap(); + h.state_mut().run("actions.stop", json!({})).unwrap(); + let _ = std::fs::remove_file(&path); + assert_eq!(h.state().session.actions.list[0].steps, [("file.saveACopy".into(), json!({"path": path, "quality": 90}))]); + let played = h.state_mut().run("actions.play", json!({"action": 0})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + assert_eq!(writes.borrow().as_slice(), &[(path, asked(pct(12), JpegFormat::Baseline))]); +} + +/// The quality and format chosen in the prompt are the ones written; Cancel writes nothing. +#[test] +fn jpeg_options_writes_the_choice_or_cancels() { + let (mut h, writes) = jpeg_app("unused.jpg"); + h.state_mut().save_as(Some("p.jpg".into())).unwrap(); + h.run_steps(2); + h.get_by_label("Progressive").click(); + h.run_steps(2); + h.state_mut().jpeg_options.as_mut().unwrap().quality = 3; + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(writes.borrow().as_slice(), &[("p.jpg".to_string(), asked(pct(3), JpegFormat::Progressive))]); + + h.state_mut().save_as(Some("c.jpg".into())).unwrap(); + h.run_steps(2); + h.key_press(Key::Escape); + h.run_steps(3); + assert!(h.state().jpeg_options.is_none()); + assert_eq!(writes.borrow().len(), 1, "cancelled: nothing more written"); + assert_eq!(h.state().ui.status, "Save cancelled"); +} + +/// Scripts choose with `quality` or `jpegFormat` and are never asked; a bad value writes nothing; +/// other formats aren't asked. +#[test] +fn scripted_jpeg_options_and_other_formats_skip_the_prompt() { + let (mut h, writes) = jpeg_app("unused.jpg"); + let ctx = h.ctx.clone(); + crate::menus::invoke(h.state_mut(), &ctx, "file.saveAs", json!({"path": "s.jpg", "quality": 12, "jpegFormat": "optimized"})).unwrap(); + crate::menus::invoke(h.state_mut(), &ctx, "file.saveAs", json!({"path": "f.jpeg", "jpegFormat": "progressive"})).unwrap(); + assert!(h.state().jpeg_options.is_none()); + for bad in [json!({"quality": 13}), json!({"quality": "8"}), json!({"jpegFormat": "fast"})] { + let mut p = bad.clone(); + p["path"] = json!("s.jpg"); + assert!(crate::menus::invoke(h.state_mut(), &ctx, "file.saveAs", p).is_err(), "{bad}"); + } + crate::menus::invoke(h.state_mut(), &ctx, "file.saveAs", json!({"path": "p.png"})).unwrap(); + assert!(h.state().jpeg_options.is_none()); + assert_eq!( + writes.borrow().as_slice(), + &[ + ("s.jpg".to_string(), asked(Some(100), JpegFormat::Optimized)), + ("f.jpeg".to_string(), asked(None, JpegFormat::Progressive)), + ("p.png".to_string(), asked(None, JpegFormat::Baseline)), + ] + ); +} + +/// Save a Copy to a JPEG asks too, and the copy leaves the document's path and unsaved state. +#[test] +fn save_a_copy_to_a_jpeg_asks_and_keeps_the_document_unsaved() { + let (mut h, writes) = jpeg_app("copy.jpg"); + make_dirty(h.state_mut(), 0); + let ctx = h.ctx.clone(); + crate::menus::invoke(h.state_mut(), &ctx, "file.saveACopy", json!({})).unwrap(); + h.run_steps(3); + assert_eq!(h.state().jpeg_options.as_ref().map(|p| p.copy), Some(true)); + assert!(jpeg_options_ui::park(h.state_mut(), "other.jpg".into(), false, "file.saveAs").is_err(), "the waiting prompt is kept"); + h.key_press(Key::Enter); + h.run_steps(3); + assert_eq!(writes.borrow().as_slice(), &[("copy.jpg".to_string(), asked(pct(10), JpegFormat::Baseline))]); + let st = h.state().session.active().unwrap(); + assert!(st.is_dirty()); + assert_eq!(st.path, None); +} + +/// An action being recorded keeps a JPEG Save As or Save a Copy with its answer (#2032, #4373) +/// and plays them back without asking. +#[test] +fn recorded_jpeg_saves_keep_their_options_and_replay_without_asking() { + let (mut h, writes) = jpeg_app("copy.jpg"); + let ctx = h.ctx.clone(); + h.state_mut().run("actions.record", json!({})).unwrap(); + h.state_mut().save_as(Some("p.jpg".into())).unwrap(); + h.run_steps(2); + h.get_by_label("Baseline Optimized").click(); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + crate::menus::invoke(h.state_mut(), &ctx, "file.saveACopy", json!({})).unwrap(); + h.run_steps(3); + h.state_mut().jpeg_options.as_mut().unwrap().quality = 6; + h.run_steps(2); + h.key_press(Key::Enter); + h.run_steps(3); + h.state_mut().run("actions.stop", json!({})).unwrap(); + let steps = &h.state().session.actions.list[0].steps; + assert_eq!( + steps, + &[ + ("file.saveAs".into(), json!({"path": "p.jpg", "quality": 10, "jpegFormat": "optimized"})), + ("file.saveACopy".into(), json!({"path": "copy.jpg", "quality": 6, "jpegFormat": "baseline"})), + ] + ); + let expected = [("p.jpg".to_string(), asked(pct(10), JpegFormat::Optimized)), ("copy.jpg".to_string(), asked(pct(6), JpegFormat::Baseline))]; + assert_eq!(writes.borrow().as_slice(), &expected); + writes.borrow_mut().clear(); + let played = h.state_mut().run("actions.play", json!({"action": 0})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + assert!(h.state().jpeg_options.is_none(), "playback doesn't ask"); + assert_eq!(writes.borrow().as_slice(), &expected); + assert_eq!(h.state().session.active().unwrap().path.as_deref(), Some("p.jpg"), "the replayed copy leaves the document's file"); +} + +/// Closing an unsaved document whose save goes to a JPEG waits behind JPEG Options, then closes. +#[test] +fn closing_after_a_jpeg_save_waits_for_jpeg_options() { + let (mut h, writes) = jpeg_app("kept.jpg"); + // An edit that keeps the lone Background, so the JPEG holds the document and isn't a copy. + h.state_mut().run("edit.fill", json!({"color": "#000000"})).unwrap(); + assert!(intercept(h.state_mut(), "file.close", &Value::Null)); + h.run_steps(2); + h.key_press(Key::Y); + h.run_steps(3); + assert!(h.state().jpeg_options.is_some()); + assert_eq!(h.state().session.documents().len(), 1, "nothing closes before JPEG Options saves"); + assert!(h.query_by_label("(Y)es").is_none(), "the close prompt waits behind JPEG Options"); + h.key_press(Key::Enter); + h.run_steps(3); + assert_eq!(writes.borrow().as_slice(), &[("kept.jpg".to_string(), asked(pct(10), JpegFormat::Baseline))]); + assert!(h.state().discard.is_none()); + assert!(h.state().session.documents().is_empty()); +} + +/// Another save can park the prompt with a starting point of its own (`park_at`): an own quality +/// left alone is written exactly and recorded without `quality`; the starting format is kept. +#[test] +fn a_save_can_choose_where_the_prompt_starts() { + let (mut h, writes) = jpeg_app("unused.jpg"); + h.state_mut().run("actions.record", json!({})).unwrap(); + let start = jpeg_options_ui::Start { format: JpegFormat::Progressive, ..jpeg_options_ui::Start::own(70) }; + jpeg_options_ui::park_at(h.state_mut(), "s.jpg".into(), false, "file.saveAs", start).unwrap(); + h.run_steps(2); + assert_eq!(h.state().jpeg_options.as_ref().map(|p| (p.quality, p.format, p.own_quality)), Some((8, JpegFormat::Progressive, Some(70)))); + assert!(jpeg_options_ui::park_at(h.state_mut(), "t.jpg".into(), false, "file.saveAs", start).is_err(), "one prompt at a time"); + h.key_press(Key::Enter); + h.run_steps(3); + h.state_mut().run("actions.stop", json!({})).unwrap(); + assert_eq!(writes.borrow().as_slice(), &[("s.jpg".to_string(), asked(Some(70), JpegFormat::Progressive))]); + assert_eq!(h.state().session.actions.list[0].steps, [("file.saveAs".into(), json!({"path": "s.jpg", "jpegFormat": "progressive"}))]); +} diff --git a/crates/ui-egui/src/disk_change_prompt.rs b/crates/ui-egui/src/disk_change_prompt.rs index dd9b8215b9..4a058016ab 100644 --- a/crates/ui-egui/src/disk_change_prompt.rs +++ b/crates/ui-egui/src/disk_change_prompt.rs @@ -47,7 +47,7 @@ pub fn confirm(app: &mut PhotocraftApp, fields: &Map) -> Result) { let (_, reply) = open.borrow_mut().pop().expect("a dialog is open"); @@ -1124,7 +1156,11 @@ fn save_of_a_flat_file_asks_after_the_dialog_when_another_app_changed_it() { assert!(written.borrow().is_empty(), "the changed file is not replaced before the answer"); let id = disk_prompt(&app).expect("Save asks first").id; crate::dialogs::confirm(&mut app, id).unwrap(); + // Then JPEG Options (FILE-215-19), whose answer is still recorded as a plain Save. + assert!(written.borrow().is_empty()); + assert_eq!(app.jpeg_options.as_ref().map(|p| p.record), Some("file.save")); + crate::jpeg_options_ui::confirm(&mut app, &ctx); assert_eq!(*written.borrow(), [path]); assert!(!app.session.active().unwrap().is_dirty()); - assert_eq!(app.session.journal.last().map(|s| s.0.as_str()), Some("file.save"), "still recorded as a plain Save"); + assert_eq!(app.session.journal.last(), Some(&("file.save".to_string(), json!({}))), "still recorded as a plain Save"); } diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index f6046fc207..52ae2f5ea4 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2306,6 +2306,11 @@ cameraRaw Color Barvy Content-Aware Move Tool Nástroj přesun s ohledem na obsah TIFF Options Možnosti TIFF Targa Options Možnosti Targa + JPEG Options Možnosti JPEG + Image Options Možnosti obrazu + Format Options Možnosti formátu + Baseline ("Standard") Základní („Standardní“) + Baseline Optimized Základní optimalizovaný 24 bits/pixel 24 bitů/pixel 32 bits/pixel 32 bitů/pixel Including layers will increase file size. Zahrnutí vrstev zvětší velikost souboru. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 0ad7366c4b..940fa9d114 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2342,6 +2342,11 @@ Content-Aware Move Tool Inhaltsbasiert-verschieben-Werkzeug TIFF Options TIFF-Optionen Targa Options Targa-Optionen + JPEG Options JPEG-Optionen + Image Options Bildoptionen + Format Options Formatoptionen + Baseline ("Standard") Baseline („Standard“) + Baseline Optimized Baseline optimiert 24 bits/pixel 24 Bit/Pixel 32 bits/pixel 32 Bit/Pixel Including layers will increase file size. Das Einbeziehen von Ebenen vergrößert die Datei. diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 83621629fe..f5e7479927 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1117,6 +1117,11 @@ Hold Alt while drawing for straight segments Κρατήστε πατημένο το Alt κατά τη σχεδίαση για ευθύγραμμα τμήματα TIFF Options Επιλογές TIFF Targa Options Επιλογές Targa + JPEG Options Επιλογές JPEG + Image Options Επιλογές εικόνας + Format Options Επιλογές μορφής + Baseline ("Standard") Βασική («Τυπική») + Baseline Optimized Βασική βελτιστοποιημένη 24 bits/pixel 24 bit/εικονοστοιχείο 32 bits/pixel 32 bit/εικονοστοιχείο Including layers will increase file size. Η συμπερίληψη επιπέδων θα αυξήσει το μέγεθος του αρχείου. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index e23b4e84c7..9b204cb80f 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2380,6 +2380,11 @@ cameraRaw Amount Cantidad Content-Aware Move Tool Herramienta Mover con detección de contenido TIFF Options Opciones de TIFF Targa Options Opciones de Targa + JPEG Options Opciones de JPEG + Image Options Opciones de imagen + Format Options Opciones de formato + Baseline ("Standard") Línea de base ("Estándar") + Baseline Optimized Línea de base optimizada 24 bits/pixel 24 bits/píxel 32 bits/pixel 32 bits/píxel Including layers will increase file size. Incluir las capas aumentará el tamaño del archivo. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 3bcca7037c..6ef4c4d0cf 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2730,6 +2730,11 @@ cameraRaw Blending Fusion Content-Aware Move Tool Outil Déplacement selon le contenu TIFF Options Options TIFF Targa Options Options Targa + JPEG Options Options JPEG + Image Options Options d’image + Format Options Options de format + Baseline ("Standard") Ligne de base (« Standard ») + Baseline Optimized Ligne de base optimisée 24 bits/pixel 24 bits/pixel 32 bits/pixel 32 bits/pixel Including layers will increase file size. L'inclusion des calques augmentera la taille du fichier. diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 4907e2da0c..7d00818471 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2984,6 +2984,11 @@ cameraRaw Yellow Sárga Table Táblázat Tabloid Tabloid Targa Options Targa-beállítások + JPEG Options JPEG-beállítások + Image Options Képbeállítások + Format Options Formátumbeállítások + Baseline ("Standard") Alapvonalas („Szabványos”) + Baseline Optimized Optimalizált alapvonalas Target: Cél: Teal Orange Kékeszöld-narancs The document does not have an embedded colour profile. Working space: {working}. A dokumentumnak nincs beágyazott színprofilja. Munkaszíntér: {working}. diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index fc0d66afae..8d893dea41 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2717,6 +2717,11 @@ cameraRaw Blending Pembauran Content-Aware Move Tool Alat Content-Aware Move TIFF Options Opsi TIFF Targa Options Opsi Targa + JPEG Options Opsi JPEG + Image Options Opsi Gambar + Format Options Opsi Format + Baseline ("Standard") Baseline ("Standar") + Baseline Optimized Baseline Dioptimalkan 24 bits/pixel 24 Bit/Piksel 32 bits/pixel 32 Bit/Piksel Including layers will increase file size. Menyertakan layer akan memperbesar ukuran file. diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 609469635f..386b15f0f1 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2373,6 +2373,11 @@ cameraRaw Amount Quantità Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts Trascina o fai clic sui punti del poligono · tieni premuto Alt durante il lazo per segmenti dritti · {add} aggiunge · {sub} prima di disegnare sottrae TIFF Options Opzioni TIFF Targa Options Opzioni Targa + JPEG Options Opzioni JPEG + Image Options Opzioni immagine + Format Options Opzioni formato + Baseline ("Standard") Linea di base ("Standard") + Baseline Optimized Linea di base ottimizzata 24 bits/pixel 24 bit/pixel 32 bits/pixel 32 bit/pixel Including layers will increase file size. Includere i livelli aumenterà le dimensioni del file. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 39a38a1142..b84d8a10b5 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2281,6 +2281,11 @@ cameraRaw Color カラー Content-Aware Move Tool コンテンツに応じた移動ツール TIFF Options TIFF オプション Targa Options Targa オプション + JPEG Options JPEG オプション + Image Options 画像オプション + Format Options 形式オプション + Baseline ("Standard") ベースライン(標準) + Baseline Optimized ベースライン(最適化) 24 bits/pixel 24 ビット/ピクセル 32 bits/pixel 32 ビット/ピクセル Including layers will increase file size. レイヤーを含めるとファイルサイズが大きくなります。 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 241c81bef5..4e16ecf5dd 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2898,6 +2898,11 @@ cameraRaw Color 색상 Content-Aware Move Tool 내용 인식 이동 도구 TIFF Options TIFF 옵션 Targa Options Targa 옵션 + JPEG Options JPEG 옵션 + Image Options 이미지 옵션 + Format Options 포맷 옵션 + Baseline ("Standard") 기본("표준") + Baseline Optimized 기본 최적화 24 bits/pixel 24비트/픽셀 32 bits/pixel 32비트/픽셀 Including layers will increase file size. 레이어를 포함하면 파일 크기가 커집니다. diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index e0dd17ff57..39cd030295 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2319,6 +2319,11 @@ Content-Aware Move Tool Inhoudsbewust verplaatsen TIFF Options TIFF-opties Targa Options Targa-opties + JPEG Options JPEG-opties + Image Options Afbeeldingsopties + Format Options Indelingsopties + Baseline ("Standard") Basislijn ("Standaard") + Baseline Optimized Basislijn geoptimaliseerd 24 bits/pixel 24 bits/pixel 32 bits/pixel 32 bits/pixel Including layers will increase file size. Lagen meenemen vergroot de bestandsgrootte. diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 10435771f5..64f806d3ca 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2745,6 +2745,11 @@ cameraRaw Roughness Chropowatość Content-Aware Move Tool Narzędzie Przesunięcie z uwzględnieniem zawartości TIFF Options Opcje TIFF Targa Options Opcje Targa + JPEG Options Opcje JPEG + Image Options Opcje obrazu + Format Options Opcje formatu + Baseline ("Standard") Linia bazowa („Standardowa”) + Baseline Optimized Linia bazowa zoptymalizowana 24 bits/pixel 24 bity/piksel 32 bits/pixel 32 bity/piksel Including layers will increase file size. Dołączenie warstw zwiększy rozmiar pliku. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f4dc857f7d..c7f71386fb 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2728,6 +2728,11 @@ cameraRaw Whites Brancos Content-Aware Move Tool Ferramenta Mover sensível ao conteúdo TIFF Options Opções de TIFF Targa Options Opções de Targa + JPEG Options Opções de JPEG + Image Options Opções de imagem + Format Options Opções de formato + Baseline ("Standard") Linha de base ("Padrão") + Baseline Optimized Linha de base otimizada 24 bits/pixel 24 bits/pixel 32 bits/pixel 32 bits/pixel Including layers will increase file size. Incluir as camadas aumentará o tamanho do arquivo. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index b497b41855..d24895207c 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2578,6 +2578,11 @@ cameraRaw Roughness Неровность Content-Aware Move Tool Перемещение с учётом содержимого TIFF Options Параметры TIFF Targa Options Параметры Targa + JPEG Options Параметры JPEG + Image Options Параметры изображения + Format Options Разновидность формата + Baseline ("Standard") Базовая («Стандартная») + Baseline Optimized Базовая оптимизированная 24 bits/pixel 24 бит/пиксел 32 bits/pixel 32 бит/пиксел Including layers will increase file size. Включение слоёв увеличит размер файла. diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index dc3608c514..e06c6443b9 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -2707,6 +2707,11 @@ cameraRaw Blending Stapanje Content-Aware Move Tool Alatka za pomeranje prema sadržaju TIFF Options TIFF opcije Targa Options Targa opcije + JPEG Options JPEG opcije + Image Options Opcije slike + Format Options Opcije formata + Baseline ("Standard") Osnovno („standardno“) + Baseline Optimized Osnovno optimizovano 24 bits/pixel 24 bita/piksel 32 bits/pixel 32 bita/piksel Including layers will increase file size. Uključivanje slojeva povećaće veličinu datoteke. diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 5e5326f9f0..845869371c 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2289,6 +2289,11 @@ Content-Aware Move Tool İçeriğe Uygun Taşıma Aracı TIFF Options TIFF Seçenekleri Targa Options Targa Seçenekleri + JPEG Options JPEG Seçenekleri + Image Options Görüntü Seçenekleri + Format Options Biçim Seçenekleri + Baseline ("Standard") Temel (“Standart”) + Baseline Optimized Optimize edilmiş temel 24 bits/pixel 24 bit/piksel 32 bits/pixel 32 bit/piksel Including layers will increase file size. Katmanların eklenmesi dosya boyutunu artıracaktır. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index cc3df88348..755e00a29c 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2547,6 +2547,11 @@ cameraRaw Roughness Шорсткість Content-Aware Move Tool Інструмент «Переміщення з урахуванням вмісту» TIFF Options Параметри TIFF Targa Options Параметри Targa + JPEG Options Параметри JPEG + Image Options Параметри зображення + Format Options Різновид формату + Baseline ("Standard") Базовий («Стандартний») + Baseline Optimized Базовий оптимізований 24 bits/pixel 24 біт/піксель 32 bits/pixel 32 біт/піксель Including layers will increase file size. Додавання шарів збільшить розмір файлу. diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index d19e3d576a..6149cf770b 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2495,6 +2495,11 @@ shape Appearance Diện mạo Snap to Origin Bám vào gốc tọa độ Solid Liền Targa Options Tùy chọn Targa + JPEG Options Tùy chọn JPEG + Image Options Tùy chọn hình ảnh + Format Options Tùy chọn định dạng + Baseline ("Standard") Đường cơ sở ("Chuẩn") + Baseline Optimized Đường cơ sở tối ưu Target: Đích: The layer or its mask changed. Drag the mask onto the Trash again. Lớp hoặc mặt nạ của nó đã thay đổi. Hãy kéo mặt nạ vào Thùng rác lần nữa. There is no crop size or ratio to save Không có kích thước hoặc tỷ lệ cắt để lưu diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 675a544509..e0420a17cd 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2321,6 +2321,11 @@ cameraRaw Blending 混合 Content-Aware Move Tool 内容感知移动工具 TIFF Options TIFF 选项 Targa Options Targa 选项 + JPEG Options JPEG 选项 + Image Options 图像选项 + Format Options 格式选项 + Baseline ("Standard") 基线(“标准”) + Baseline Optimized 基线已优化 24 bits/pixel 24 位/像素 32 bits/pixel 32 位/像素 Including layers will increase file size. 包含图层会增加文件大小。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 982fa97319..989fbc2dc2 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2291,6 +2291,11 @@ cameraRaw Blending 混合 Content-Aware Move Tool 內容感知移動工具 TIFF Options TIFF 選項 Targa Options Targa 選項 + JPEG Options JPEG 選項 + Image Options 影像選項 + Format Options 格式選項 + Baseline ("Standard") 基線(「標準」) + Baseline Optimized 基線已最佳化 24 bits/pixel 24 位元/像素 32 bits/pixel 32 位元/像素 Including layers will increase file size. 包含圖層會增加檔案大小。 diff --git a/crates/ui-egui/src/jpeg_options_ui.rs b/crates/ui-egui/src/jpeg_options_ui.rs new file mode 100644 index 0000000000..107f478a73 --- /dev/null +++ b/crates/ui-egui/src/jpeg_options_ui.rs @@ -0,0 +1,331 @@ +//! JPEG Options: the quality and format File › Save As and Save a Copy ask for before writing a +//! JPEG, as Photoshop's JPEG Options does. Quality is Photoshop's 0–12 scale (Low 0–4, Medium 5–7, +//! High 8–9, Maximum 10–12), mapped to the encoder's 1–100 by +//! `photocraft_engine::file_cmds::jpeg_quality`; Format Options are Baseline ("Standard"), +//! Baseline Optimized and Progressive. Matte and the size preview aren't offered yet. Re-saving an +//! opened JPEG starts at the file's own quality, as File › Save keeps it (#4032). +//! +//! The save is parked in [`Prompt`] while the modal is up (egui is immediate-mode) and written +//! once answered. Cancel drops the save. Scripts skip the prompt by passing `quality` or +//! `jpegFormat`. +//! +//! Any save can ask: check [`wants_prompt`], then [`park`] (which starts where +//! [`Start::for_active`] says) or [`park_at`] with a [`Start`] of its own, and return. The answer +//! is written by [`confirm`] and journalled as the `record` command passed to `park`: with its +//! path, `quality` (0–12, left out when the file's own quality was kept) and `jpegFormat`, or with +//! no params for a plain `file.save`, which writes whichever file an action is played on. + +use photocraft_codecs::JpegFormat; +use serde_json::Value; + +use crate::{ExportSettings, PhotocraftApp}; + +/// The highest quality on Photoshop's JPEG scale. +pub const MAX_QUALITY: u8 = 12; + +/// A JPEG Options answer, from the prompt or from a command's `quality` and `jpegFormat`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct JpegChoice { + /// Photoshop's 0–12 scale; `None` keeps the quality the save would use anyway (the opened + /// file's own, else the encoder's default). + pub quality: Option, + pub format: JpegFormat, +} + +impl JpegChoice { + /// `settings` writing this choice. + pub fn apply(self, settings: ExportSettings) -> ExportSettings { + ExportSettings { + jpeg_quality: self.quality.map(|q| photocraft_engine::file_cmds::jpeg_quality(f64::from(q))).or(settings.jpeg_quality), + jpeg_format: self.format, + ..settings + } + } + + /// Adds `quality` and `jpegFormat` to a recorded save step, so replaying it doesn't ask again. + pub fn record(self, step: &mut Value) { + if let Some(q) = self.quality { + step["quality"] = q.into(); + } + step["jpegFormat"] = self.format.name().into(); + } +} + +/// The JPEG Options a command or control call gives (`"quality": 0..12`, `"jpegFormat"`), which +/// skip the prompt. Neither given: `None`. A quality that isn't a number from 0 to 12, or a +/// format other than the three names, is an error. +pub fn choice_param(params: &Value, cmd: &str) -> Result, String> { + let format = photocraft_engine::file_cmds::jpeg_format_param(params, cmd).map_err(|e| e.to_string())?; + let quality = match params.get("quality") { + None | Some(Value::Null) => None, + Some(v) => Some( + v.as_f64() + .filter(|q| (0.0..=f64::from(MAX_QUALITY)).contains(q)) + .map(|q| q.round() as u8) + .ok_or_else(|| format!("{cmd}: `quality` must be a number from 0 to 12 (Photoshop's JPEG scale), got {v}"))?, + ), + }; + Ok((quality.is_some() || format.is_some()).then(|| JpegChoice { quality, format: format.unwrap_or_default() })) +} + +/// The JPEG Options in a recorded save step, which replays without asking. A `file.saveACopy` +/// step from the engine command keeps its params as they were given, and the engine clamps a +/// `quality` to 0–12 and ignores one that isn't a number, so the replay reads it the same way. A +/// format other than the three names is still an error, as it is for the engine. +pub fn recorded_choice(params: &Value, cmd: &str) -> Result, String> { + let format = photocraft_engine::file_cmds::jpeg_format_param(params, cmd).map_err(|e| e.to_string())?; + let quality = params.get("quality").and_then(Value::as_f64).filter(|q| q.is_finite()).map(|q| q.clamp(0.0, f64::from(MAX_QUALITY)).round() as u8); + Ok((quality.is_some() || format.is_some()).then(|| JpegChoice { quality, format: format.unwrap_or_default() })) +} + +/// Photoshop's name for a quality, in the UI language: Low, Medium, High or Maximum. +pub fn quality_label(q: u8) -> &'static str { + match q { + 0..=4 => tl!("Low"), + 5..=7 => tl!("Medium"), + 8..=9 => tl!("High"), + _ => tl!("Maximum"), + } +} + +/// The 0–12 quality nearest an encoder quality (1–100), the inverse of the engine's mapping. +pub fn quality_from_percent(q: u32) -> u8 { + let q = f64::from(q.clamp(1, 100)); + ((q - 1.0) / 99.0 * f64::from(MAX_QUALITY)).round() as u8 +} + +/// Where the prompt starts. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Start { + /// Quality on the 0–12 scale. + pub quality: u8, + /// The opened file's own encoder quality (1–100) that `quality` was rounded from. Left on + /// that step, OK writes it exactly and records no `quality`, so a replay keeps it too. + pub own_quality: Option, + pub format: JpegFormat, +} + +impl Start { + /// Starts at an opened JPEG's own encoder quality (1–100; out of range is clamped). + pub fn own(quality: u8) -> Self { + let q = quality.clamp(1, 100); + Start { quality: quality_from_percent(u32::from(q)), own_quality: Some(q), format: JpegFormat::Baseline } + } + + /// Starts at an encoder quality (1–100) that isn't the file's own, such as a preference. + pub fn percent(quality: u32) -> Self { + Start { quality: quality_from_percent(quality), own_quality: None, format: JpegFormat::Baseline } + } + + /// For the active document: its opened JPEG's own quality (`Document::file_encoding`), or + /// else the Export preferences' JPEG quality (the one Quick Export uses). + pub fn for_active(app: &PhotocraftApp) -> Self { + match app.session.active().and_then(|st| st.doc.file_encoding.jpeg_quality) { + Some(q) => Start::own(q), + None => Start::percent(app.session.prefs().export.jpeg_quality), + } + } +} + +/// A save waiting on the JPEG Options answer. +pub struct Prompt { + /// The document that requested the save, even if tabs change while this is open. + doc: photocraft_doc::DocId, + /// Where the document goes. + pub path: String, + /// Quality, 0–12. + pub quality: u8, + pub format: JpegFormat, + /// See [`Start::own_quality`]. + pub own_quality: Option, + /// Save a Copy: the document keeps its path and unsaved changes. + pub copy: bool, + /// The step the answered save is recorded as, so an action being recorded keeps it: + /// `file.saveAs` for Save As (#2032), `file.saveACopy` for Save a Copy (#4373), and + /// `file.save` (with no params) for File › Save writing the document's own file (#3531). + pub record: &'static str, +} + +/// `true` when `path` names a JPEG (`.jpg`, `.jpeg`, `.jpe`, …). +pub fn is_jpeg_path(path: &str) -> bool { + photocraft_engine::file_cmds::extension(path).and_then(|e| photocraft_codecs::from_extension(&e)) == Some(photocraft_codecs::Format::Jpeg) +} + +/// Whether saving the active document to `path` asks for JPEG Options. +pub fn wants_prompt(app: &PhotocraftApp, path: &str) -> bool { + is_jpeg_path(path) && app.session.active().is_some() +} + +/// Parks the save of the active document to `path` behind the prompt, starting at +/// [`Start::for_active`]. A prompt already waiting is never replaced. +pub fn park(app: &mut PhotocraftApp, path: String, copy: bool, record: &'static str) -> Result<(), String> { + let start = Start::for_active(app); + park_at(app, path, copy, record, start) +} + +/// [`park`], starting at `start`. +pub fn park_at(app: &mut PhotocraftApp, path: String, copy: bool, record: &'static str, start: Start) -> Result<(), String> { + if app.save_options_open() { + return Err(crate::SAVE_OPTIONS_OPEN.into()); + } + let doc = app.active_doc_id()?; + let Start { quality, own_quality, format } = start; + app.jpeg_options = Some(Prompt { doc, path, quality: quality.min(MAX_QUALITY), format, own_quality, copy, record }); + Ok(()) +} + +pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { + let Some(p) = &mut app.jpeg_options else { return }; + let (mut ok, mut cancel) = (false, false); + let (mut quality, mut format) = (p.quality, p.format); + let modal = egui::Modal::new(egui::Id::new("jpeg-options")).show(ctx, |ui| { + ui.set_max_width(360.0); + ui.label(egui::RichText::new(tl!("JPEG Options")).font(crate::theme::semibold(15.0))); + ui.add_space(4.0); + crate::widgets::hairline(ui); + ui.add_space(8.0); + crate::widgets::section_label(ui, tl!("Image Options")); + ui.horizontal(|ui| { + ui.label(tl!("Quality")); + ui.add(egui::DragValue::new(&mut quality).range(0..=MAX_QUALITY).speed(0.1)); + // Photoshop's presets: picking one sets the quality it starts at. + egui::ComboBox::from_id_salt("jpeg-quality-preset").selected_text(quality_label(quality)).show_ui(ui, |ui| { + for q in [3, 5, 8, 10] { + if ui.selectable_label(quality_label(quality) == quality_label(q), quality_label(q)).clicked() { + quality = q; + } + } + }); + }); + ui.add(egui::Slider::new(&mut quality, 0..=MAX_QUALITY).show_value(false)); + ui.add_space(8.0); + crate::widgets::section_label(ui, tl!("Format Options")); + ui.radio_value(&mut format, JpegFormat::Baseline, tl!("Baseline (\"Standard\")")); + ui.radio_value(&mut format, JpegFormat::Optimized, tl!("Baseline Optimized")); + ui.radio_value(&mut format, JpegFormat::Progressive, tl!("Progressive")); + ui.add_space(12.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.spacing_mut().item_spacing.x = 10.0; + ok = crate::widgets::primary_button(ui, tl!("OK"), 84.0).clicked(); + cancel = crate::widgets::secondary_button(ui, tl!("Cancel"), 84.0).clicked(); + }); + }); + cancel |= modal.should_close(); + p.quality = quality.min(MAX_QUALITY); + p.format = format; + if ok || ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)) { + confirm(app, ctx); + } else if cancel { + app.jpeg_options = None; + app.ui.status = tl!("Save cancelled").into(); + app.ui.status_error = false; + } +} + +/// OK: writes the waiting save with the prompt's quality and format, and journals it. +pub fn confirm(app: &mut PhotocraftApp, ctx: &egui::Context) { + let Some(p) = app.jpeg_options.take() else { return }; + // Left where it started, an opened JPEG keeps its own quality exactly rather than the nearest + // step on the 0–12 scale (95 would be written as 92). The recorded step then leaves the + // quality out, so a replay keeps the file's own quality too, as File › Save does (#4032). + let own = p.own_quality.filter(|&q| quality_from_percent(u32::from(q)) == p.quality); + let choice = JpegChoice { quality: own.is_none().then_some(p.quality), format: p.format }; + let step = if p.record == "file.save" { + // A plain Save writes whichever file the action is played on (#3531). + Value::Object(Default::default()) + } else { + let mut step = serde_json::json!({"path": p.path}); + choice.record(&mut step); + step + }; + let mut settings = choice.apply(app.save_settings()); + if own.is_some() { + settings.jpeg_quality = own; + } + app.write_answered_save(ctx, p.doc, p.path, &settings, p.copy, (p.record, step)); +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn jpeg_names() { + for p in ["a.jpg", "dir/sub/a.JPEG", "C:\\x\\y.jpe", "a.jpg.jpg"] { + assert!(is_jpeg_path(p), "{p}"); + } + for p in ["a.jpg.psd", ".jpg", "jpg", "a.png", "a.jp2", ""] { + assert!(!is_jpeg_path(p), "{p}"); + } + } + + #[test] + fn choice_params() { + let base = JpegFormat::Baseline; + assert_eq!(choice_param(&json!({}), "file.saveAs"), Ok(None)); + assert_eq!(choice_param(&json!({"quality": null, "jpegFormat": null}), "file.saveAs"), Ok(None)); + assert_eq!(choice_param(&json!({"quality": 12}), "file.saveAs"), Ok(Some(JpegChoice { quality: Some(12), format: base }))); + assert_eq!(choice_param(&json!({"quality": 7.6}), "file.saveAs"), Ok(Some(JpegChoice { quality: Some(8), format: base }))); + assert_eq!(choice_param(&json!({"jpegFormat": "progressive"}), "file.saveAs"), Ok(Some(JpegChoice { quality: None, format: JpegFormat::Progressive }))); + for bad in [ + json!({"quality": 13}), + json!({"quality": -1}), + json!({"quality": 1e300}), + json!({"quality": "8"}), + json!({"quality": true}), + json!({"quality": [8]}), + json!({"jpegFormat": "Optimized"}), + json!({"jpegFormat": 1}), + json!({"quality": 8, "jpegFormat": ""}), + ] { + assert!(choice_param(&bad, "file.saveAs").is_err(), "{bad}"); + } + } + + #[test] + fn recorded_steps_read_like_the_engine() { + let base = JpegFormat::Baseline; + assert_eq!(recorded_choice(&json!({"path": "a.jpg"}), "file.saveACopy"), Ok(None)); + assert_eq!(recorded_choice(&json!({"quality": 90}), "file.saveACopy"), Ok(Some(JpegChoice { quality: Some(12), format: base }))); + assert_eq!(recorded_choice(&json!({"quality": -3}), "file.saveACopy"), Ok(Some(JpegChoice { quality: Some(0), format: base }))); + assert_eq!(recorded_choice(&json!({"quality": "8"}), "file.saveACopy"), Ok(None), "a quality that isn't a number is ignored"); + assert_eq!( + recorded_choice(&json!({"quality": 7.6, "jpegFormat": "optimized"}), "file.saveAs"), + Ok(Some(JpegChoice { quality: Some(8), format: JpegFormat::Optimized })) + ); + assert!(recorded_choice(&json!({"jpegFormat": "fast"}), "file.saveACopy").is_err()); + } + + #[test] + fn quality_scale() { + assert_eq!([0, 4, 5, 7, 8, 9, 10, 12].map(quality_label), ["Low", "Low", "Medium", "Medium", "High", "High", "Maximum", "Maximum"]); + // The prompt's starting quality maps back to the encoder quality it came from, near enough. + for q in 0..=MAX_QUALITY { + assert_eq!(quality_from_percent(u32::from(photocraft_engine::file_cmds::jpeg_quality(f64::from(q)))), q); + } + assert_eq!((quality_from_percent(0), quality_from_percent(85), quality_from_percent(1000)), (0, 10, 12)); + } + + #[test] + fn starts() { + assert_eq!(Start::own(95), Start { quality: 11, own_quality: Some(95), format: JpegFormat::Baseline }); + assert_eq!(Start::own(0), Start { quality: 0, own_quality: Some(1), format: JpegFormat::Baseline }); + assert_eq!(Start::own(255).own_quality, Some(100)); + assert_eq!(Start::percent(85), Start { quality: 10, own_quality: None, format: JpegFormat::Baseline }); + assert_eq!(Start::percent(u32::MAX).quality, MAX_QUALITY); + } + + #[test] + fn choices_apply_and_record() { + let c = JpegChoice { quality: Some(10), format: JpegFormat::Optimized }; + let s = c.apply(ExportSettings::default()); + assert_eq!((s.jpeg_quality, s.jpeg_format), (Some(84), JpegFormat::Optimized)); + let mut step = json!({"path": "a.jpg"}); + c.record(&mut step); + assert_eq!(step, json!({"path": "a.jpg", "quality": 10, "jpegFormat": "optimized"})); + assert_eq!(choice_param(&step, "file.saveAs"), Ok(Some(c)), "a recorded step replays the same answer"); + let unset = JpegChoice { quality: None, format: JpegFormat::Progressive }.apply(ExportSettings { jpeg_quality: Some(95), ..ExportSettings::default() }); + assert_eq!(unset.jpeg_quality, Some(95), "no quality keeps the quality the save had"); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 89f293a2cb..00950a5de9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -110,6 +110,7 @@ pub mod i18n; mod icon_data; pub mod icons; pub mod jobs_ui; +pub mod jpeg_options_ui; pub mod kys_import; pub mod lasso_ui; pub(crate) mod last_brush; @@ -252,6 +253,49 @@ pub use file_dialog::{FileDialogAnswer, FileDialogFn, FileDialogReply, FileDialo pub use file_open::OsEvent; pub use state::{Tool, UiState}; +/// The error a save gets while another save waits on its format prompt. +pub(crate) const SAVE_OPTIONS_OPEN: &str = "Answer TIFF, Targa or JPEG Options before starting another save"; + +/// What a save was told beyond its path, so it skips the prompts that would ask: Targa bits +/// (`tgaBits`) and JPEG Options (`quality` 0–12, `jpegFormat`). +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct SaveChoices { + pub tga_bits: Option, + pub jpeg: Option, +} + +impl SaveChoices { + /// The choices in command or control call `cmd`'s params; one it can't read is an error. + pub fn from_params(params: &Value, cmd: &str) -> Result { + Ok(SaveChoices { tga_bits: targa_options_ui::bits_param(params, cmd)?, jpeg: jpeg_options_ui::choice_param(params, cmd)? }) + } + + /// The choices in a recorded save step, read as leniently as the engine command that may have + /// recorded it (see [`jpeg_options_ui::recorded_choice`]). + fn from_step(params: &Value, cmd: &str) -> Result { + Ok(SaveChoices { tga_bits: targa_options_ui::bits_param(params, cmd)?, jpeg: jpeg_options_ui::recorded_choice(params, cmd)? }) + } + + /// `settings` writing these choices. + pub fn apply(self, settings: ExportSettings) -> ExportSettings { + let settings = ExportSettings { tga_bits: self.tga_bits.or(settings.tga_bits), ..settings }; + match self.jpeg { + Some(j) => j.apply(settings), + None => settings, + } + } + + /// Adds the choices to a recorded save step. + fn record(self, step: &mut Value) { + if let Some(bits) = self.tga_bits { + step["tgaBits"] = bits.bits().into(); + } + if let Some(j) = self.jpeg { + j.record(step); + } + } +} + /// Decode a file: (document, warnings about anything approximated or dropped). pub type ImportFn = Box Result<(Document, Vec), String>>; /// Encoder settings chosen in Export As (the file format comes from the name's extension). @@ -259,6 +303,8 @@ pub type ImportFn = Box Result<(Document, Vec, + /// 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`]). pub webp_lossless: bool, /// Lossy WebP quality 1–100 (None = codec default). @@ -293,6 +339,7 @@ impl Default for ExportSettings { fn default() -> Self { ExportSettings { jpeg_quality: None, + jpeg_format: photocraft_codecs::JpegFormat::Baseline, webp_lossless: true, webp_quality: None, tiff_layers: true, @@ -315,6 +362,7 @@ impl ExportSettings { if let Some(q) = self.jpeg_quality { opts.encode.jpeg_quality = q; } + opts.encode.jpeg_format = self.jpeg_format; opts.encode.webp_lossless = self.webp_lossless; if let Some(q) = self.webp_quality { opts.encode.webp_quality = q; @@ -840,6 +888,8 @@ pub struct PhotocraftApp { pub(crate) disk_stamps: HashMap, /// A save to an RGB Targa parked behind the Targa Options prompt (see `targa_options_ui`). pub(crate) targa_options: Option, + /// A save to a JPEG parked behind the JPEG Options prompt (see `jpeg_options_ui`). + pub(crate) jpeg_options: Option, /// Files read but not opened yet: the first waits on its profile prompt, the rest behind it /// (see `open_prompt`). pub(crate) held_opens: std::collections::VecDeque, @@ -991,6 +1041,7 @@ impl PhotocraftApp { save_dirs: HashMap::new(), disk_stamps: HashMap::new(), targa_options: None, + jpeg_options: None, held_opens: Default::default(), allow_close: false, stylus: Default::default(), @@ -1442,21 +1493,22 @@ impl PhotocraftApp { /// continues on a later frame, see `file_dialog`). Returns `{"path", "warnings"}` (the export /// warnings are also shown to the user). pub fn save_as(&mut self, path: Option) -> Result { - self.save_as_with(path, None) + self.save_as_with(path, SaveChoices::default()) } - /// [`Self::save_as`], writing a Targa with `tga_bits` per pixel when given (no Targa Options - /// prompt): `file.save` and `file.saveAs` with `tgaBits`. - pub fn save_as_with(&mut self, path: Option, tga_bits: Option) -> Result { - self.save_dialog_flow(path, tga_bits, false) + /// [`Self::save_as`] with the format choices already made, which skip their prompts: + /// `file.save` and `file.saveAs` with `tgaBits` (no Targa Options), or `quality` or + /// `jpegFormat` (no JPEG Options). + pub fn save_as_with(&mut self, path: Option, choices: SaveChoices) -> Result { + self.save_dialog_flow(path, choices, false) } /// [`Self::save_as_with`]. `plain_save` marks File › Save that had to ask for a file name /// (a flat file): confirming the document's own file is recorded as a `file.save` step, /// which writes whichever file the action is played on, not as a Save As to a fixed path. - pub(crate) fn save_dialog_flow(&mut self, path: Option, tga_bits: Option, plain_save: bool) -> Result { + pub(crate) fn save_dialog_flow(&mut self, path: Option, choices: SaveChoices, plain_save: bool) -> Result { if self.save_options_open() { - return Err("Answer TIFF or Targa Options before starting another save".into()); + return Err(SAVE_OPTIONS_OPEN.into()); } // Edit Contents documents save back into their smart object. if path.is_none() && self.session.is_enabled("layer.smartObjects.saveContents") { @@ -1464,7 +1516,7 @@ impl PhotocraftApp { return Ok(serde_json::json!({"path": "smart object", "warnings": []})); } if let Some(path) = path { - return self.save_to(path, tga_bits); + return self.save_to(path, choices); } let st = self.session.active().ok_or("no document")?; // PDN imports default to our native format, which preserves Paint.NET's blend modes; @@ -1504,7 +1556,7 @@ impl PhotocraftApp { { return r; } - let r = app.save_to(path.clone(), tga_bits)?; + let r = app.save_to(path.clone(), choices)?; if plain_save && own.as_deref() == Some(path.as_str()) { app.journal_as_plain_save(&path); } @@ -1514,51 +1566,61 @@ impl PhotocraftApp { } /// [`Self::save_as_with`] once the path is known. - pub(crate) fn save_to(&mut self, path: String, tga_bits: Option) -> Result { - // An RGB Targa asks for its bits per pixel, unless the caller chose them; the save - // continues from the prompt (still a copy when it would be one without the prompt), - // which records the step once answered. - if tga_bits.is_none() && targa_options_ui::wants_prompt(self, &path) { + pub(crate) fn save_to(&mut self, path: String, choices: SaveChoices) -> Result { + // An RGB Targa asks for its bits per pixel, and a JPEG for its quality and format, unless + // the caller chose them; the save continues from the prompt (still a copy when it would + // be one without the prompt), which records the step once answered. + if choices.tga_bits.is_none() && targa_options_ui::wants_prompt(self, &path) { let copy = self.saves_copy(&path, None)?; targa_options_ui::park(self, path.clone(), copy, "file.saveAs")?; return Ok(serde_json::json!({"path": path, "warnings": []})); } + if choices.jpeg.is_none() && jpeg_options_ui::wants_prompt(self, &path) { + let copy = self.saves_copy(&path, None)?; + jpeg_options_ui::park(self, path.clone(), copy, "file.saveAs")?; + return Ok(serde_json::json!({"path": path, "warnings": []})); + } // A layered TIFF asks about its layers first (Preferences › File Handling); the save // continues from the prompt, which records the step once answered. if tiff_options_ui::wants_prompt(self, &path) { tiff_options_ui::park(self, path.clone())?; return Ok(serde_json::json!({"path": path, "warnings": []})); } - self.save_as_step(path, None, tga_bits) + self.save_as_step(path, None, choices) } /// File › Save back to the document's own layered file, recorded as a `file.save` step. pub(crate) fn save_in_place(&mut self, path: String) -> Result { if self.save_options_open() { - return Err("Answer TIFF or Targa Options before starting another save".into()); + return Err(SAVE_OPTIONS_OPEN.into()); } - let r = self.write_save(path, None, None)?; + let r = self.write_save(path, None, SaveChoices::default())?; self.session.journal.push(("file.save".into(), Value::Object(Default::default()))); Ok(r) } - /// Writes the Save As and journals it as a `file.saveAs` step with its path (and TIFF or - /// Targa answer), so an action being recorded keeps it (#2032). - fn save_as_step(&mut self, path: String, tiff_layers: Option, tga_bits: Option) -> Result { - let r = self.write_save(path.clone(), tiff_layers, tga_bits)?; + /// Writes the Save As and journals it as a `file.saveAs` step with its path (and TIFF, + /// Targa or JPEG answer), so an action being recorded keeps it (#2032). + fn save_as_step(&mut self, path: String, tiff_layers: Option, choices: SaveChoices) -> Result { + let r = self.write_save(path.clone(), tiff_layers, choices)?; let mut step = serde_json::json!({"path": path}); if let Some(layers) = tiff_layers { step["tiffLayers"] = layers.into(); } - if let Some(bits) = tga_bits { - step["tgaBits"] = bits.bits().into(); - } + choices.record(&mut step); self.session.journal.push(("file.saveAs".into(), step)); Ok(r) } /// Re-label the Save As just journaled for `path` as File › Save (see [`Self::save_dialog_flow`]). + /// A JPEG save still waiting on JPEG Options is recorded as File › Save once answered. pub(crate) fn journal_as_plain_save(&mut self, path: &str) { + if let Some(p) = self.jpeg_options.as_mut() + && p.path == path + { + p.record = "file.save"; + return; + } if let Some(last) = self.session.journal.last_mut() && last.0 == "file.saveAs" && last.1.get("path").and_then(Value::as_str) == Some(path) @@ -1568,11 +1630,11 @@ impl PhotocraftApp { } } - /// Replays a recorded `file.save` / `file.saveAs` / `file.saveACopy` step: no dialog or prompt, and the - /// recorded TIFF or Targa answer (if any) stands in for the TIFF or Targa Options prompt. + /// Replays a recorded `file.save` / `file.saveAs` / `file.saveACopy` step: no dialog or + /// prompt, and the recorded TIFF, Targa or JPEG answer (if any) stands in for its prompt. pub(crate) fn replay_save(&mut self, id: &str, params: &Value) -> Result { if self.save_options_open() { - return Err("Answer TIFF or Targa Options before starting another save".into()); + return Err(SAVE_OPTIONS_OPEN.into()); } if id == "file.save" { // The file the action is played on: one that holds the whole document, or a flat @@ -1590,13 +1652,13 @@ impl PhotocraftApp { return self.save_in_place(path); } let path = params.get("path").and_then(Value::as_str).filter(|p| !p.is_empty()).ok_or("Save As needs a `path`")?; - let tga_bits = targa_options_ui::bits_param(params, id)?; + let choices = SaveChoices::from_step(params, id)?; if id == "file.saveACopy" { // A copy: the document keeps its own file and unsaved state, as when recorded (#4373). let doc = self.session.active().ok_or("no document")?.doc.clone(); - return crate::view_cmds::write_copy(self, &doc, path, tga_bits); + return crate::view_cmds::write_copy(self, &doc, path, choices); } - self.save_as_step(path.to_string(), params.get("tiffLayers").and_then(Value::as_bool), tga_bits) + self.save_as_step(path.to_string(), params.get("tiffLayers").and_then(Value::as_bool), choices) } /// What a save writes by default under the preferences: File Handling › Image Previews @@ -1636,11 +1698,11 @@ impl PhotocraftApp { } /// Writes the active document to a known path. `tiff_layers` is the TIFF Options answer: - /// `Some(false)` discards the layers and saves a copy. `tga_bits` is the Targa bits per - /// pixel (`None`: 32 with an alpha channel or transparency, else 24). - fn write_save(&mut self, path: String, tiff_layers: Option, tga_bits: Option) -> Result { + /// `Some(false)` discards the layers and saves a copy. `choices` are the Targa bits per pixel + /// (`None`: 32 with an alpha channel or transparency, else 24) and the JPEG Options. + fn write_save(&mut self, path: String, tiff_layers: Option, choices: SaveChoices) -> Result { let copy = self.saves_copy(&path, tiff_layers)?; - let settings = ExportSettings { tiff_layers: tiff_layers.unwrap_or(ExportSettings::default().tiff_layers), tga_bits, ..self.save_settings() }; + let settings = choices.apply(ExportSettings { tiff_layers: tiff_layers.unwrap_or(ExportSettings::default().tiff_layers), ..self.save_settings() }); match self.write_document(path.clone(), &settings, copy)? { Some((path, warnings)) => Ok(serde_json::json!({"path": path, "warnings": warnings})), None => Ok(serde_json::json!({"path": path, "warnings": [], "pending": true, "job": self.jobs.last_started.map(|j| j.0)})), @@ -1746,9 +1808,30 @@ impl PhotocraftApp { own == path && self.services.file_stamp.as_ref().and_then(|stamp| stamp(path)).is_some_and(|now| now != *then) } - /// TIFF Options or Targa Options is waiting on an answer before a save is written. + /// TIFF, Targa or JPEG Options is waiting on an answer before a save is written. pub(crate) fn save_options_open(&self) -> bool { - self.tiff_options.is_some() || self.targa_options.is_some() + self.tiff_options.is_some() || self.targa_options.is_some() || self.jpeg_options.is_some() + } + + /// Writes a save that waited on a format prompt (Targa or JPEG Options) once answered: + /// journals `step` as its command (`file.saveAs` for Save As, `file.saveACopy` for Save a + /// Copy, `file.save` for a plain Save), so an action being recorded keeps it with this answer + /// (#2032, #4373), reports a failure in the status bar, and releases a close prompt waiting on + /// the save. + pub(crate) fn write_answered_save(&mut self, ctx: &egui::Context, doc: DocId, path: String, settings: &ExportSettings, copy: bool, step: (&str, Value)) { + let written = self.with_document(doc, |app| app.write_document(path, settings, copy)); + if written.is_ok() { + self.session.journal.push((step.0.into(), step.1)); + } + match written { + Err(e) => { + self.ui.status = crate::i18n::fmt(tl!("Save failed: {error}"), &[("error", &e)]); + self.ui.status_error = true; + } + Ok(Some(_)) if !copy => crate::discard_ui::saved_document(self, ctx, doc), + // A background save releases the close prompt when it finishes (`jobs_ui`). + Ok(_) => {} + } } /// A print worker may still be handing a job to the system queue. @@ -1765,12 +1848,12 @@ impl PhotocraftApp { /// picker or ambient writer is reachable from this path. Returns the path /// written and the export warnings (also shown to the user). pub fn save_automation(&mut self, path: Option) -> Result { - self.save_automation_with(path, None) + self.save_automation_with(path, SaveChoices::default()) } - /// [`Self::save_automation`] writing a Targa with `tga_bits` per pixel (`None`: 32 with an - /// alpha channel or transparency, else 24). Automation saves never prompt. - pub fn save_automation_with(&mut self, path: Option, tga_bits: Option) -> Result { + /// [`Self::save_automation`] with the Targa bits per pixel (`None`: 32 with an alpha channel + /// or transparency, else 24) and JPEG Options in `choices`. Automation saves never prompt. + pub fn save_automation_with(&mut self, path: Option, choices: SaveChoices) -> Result { let target = { let state = self.session.active().ok_or("no document")?; // As File › Save: without `path` only a layered file is written back (#416). @@ -1783,7 +1866,7 @@ impl PhotocraftApp { if self.background_jobs && let Some(save) = self.services.automation_save.clone() { - return match jobs_ui::start_save(self, target.clone(), ExportSettings { tga_bits, ..self.save_settings() }, false, save, true)? { + return match jobs_ui::start_save(self, target.clone(), choices.apply(self.save_settings()), false, save, true)? { Some((path, warnings)) => Ok(reply(&path, &warnings)), None => { let job = self.jobs.last_started; @@ -1793,7 +1876,7 @@ impl PhotocraftApp { } let state = self.session.active().ok_or("no document")?; let (doc, revision) = (state.doc.id, state.revision); - let settings = ExportSettings { tga_bits, ..self.save_settings() }; + let settings = choices.apply(self.save_settings()); let export = self.services.export.as_ref().ok_or("no exporter configured")?; let (bytes, warnings) = export(&state.doc, &target, &settings)?; let write = self.services.automation_write.as_mut().ok_or("automation write authority is not configured")?; @@ -2093,6 +2176,7 @@ impl eframe::App for PhotocraftApp { discard_ui::show(self, &ctx); tiff_options_ui::show(self, &ctx); targa_options_ui::show(self, &ctx); + jpeg_options_ui::show(self, &ctx); distort_ui::show(self, &ctx); camera_raw_ui::show(self, &ctx); wide_angle_ui::show(self, &ctx); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index ea94379685..090e675d86 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -231,9 +231,9 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: "file.save" => { // Writes back only to a layered file that holds the whole document; anything else // goes through Save As, so the original is never overwritten with less (#3806). - let tga_bits = crate::targa_options_ui::bits_param(¶ms, id)?; + let choices = crate::SaveChoices::from_params(¶ms, id)?; match params.get("path").and_then(Value::as_str) { - Some(path) => app.save_as_with(Some(path.to_string()), tga_bits), + Some(path) => app.save_as_with(Some(path.to_string()), choices), None => { let own = app.session.active().and_then(|d| d.path.clone().map(|p| (photocraft_engine::file_cmds::holds_at(&d.doc, &p), p, d))); match own { @@ -250,11 +250,11 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: let mut lines = losses; lines.push(tl!("Choose a format that keeps it, such as Photoshop (PSD), or save a copy.").to_string()); crate::notices::post(app, title, lines, false, None); - app.save_as_with(None, tga_bits) + app.save_as_with(None, choices) } // A flat file asks for its name; confirming its own file records a // plain Save that writes the file an action is played on (#3531). - _ => app.save_dialog_flow(None, tga_bits, true), + _ => app.save_dialog_flow(None, choices, true), } } } @@ -288,8 +288,8 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: } } "file.saveAs" => { - let tga_bits = crate::targa_options_ui::bits_param(¶ms, id)?; - app.save_as_with(params.get("path").and_then(Value::as_str).map(str::to_string), tga_bits) + let choices = crate::SaveChoices::from_params(¶ms, id)?; + app.save_as_with(params.get("path").and_then(Value::as_str).map(str::to_string), choices) } "view.zoomIn" | "view.zoomOut" | "view.fitOnScreen" | "view.actualPixels" => app.run(id, params), "window.newWindowForDocument" => { diff --git a/crates/ui-egui/src/save_identity_tests.rs b/crates/ui-egui/src/save_identity_tests.rs index 3396dfcdd5..4ecb7eff71 100644 --- a/crates/ui-egui/src/save_identity_tests.rs +++ b/crates/ui-egui/src/save_identity_tests.rs @@ -139,7 +139,8 @@ fn save_as_flat_keeps_the_layered_original() { app.run("edit.fill", json!({"color": "#000000"})).unwrap(); let before = identity(&app); for flat in ["art.jpg", "art.png"] { - menus::invoke(&mut app, &ctx, "file.saveAs", json!({"path": flat})).unwrap(); + // A JPEG Options answer, so the JPEG doesn't wait on the prompt. + menus::invoke(&mut app, &ctx, "file.saveAs", json!({"path": flat, "jpegFormat": "baseline"})).unwrap(); assert_eq!(identity(&app), before, "{flat}, layered: {layered}"); } menus::invoke(&mut app, &ctx, "file.save", json!({})).unwrap(); @@ -149,8 +150,15 @@ fn save_as_flat_keeps_the_layered_original() { } // An untitled layered document saved as a JPEG is still untitled and unsaved. let mut app = app(); + let written = Rc::new(RefCell::new(Vec::new())); + let w = written.clone(); + app.services.write = Some(Box::new(move |path, _| { + w.borrow_mut().push(path.to_string()); + Ok(()) + })); let before = identity(&app); - app.save_as(Some("flat.jpg".into())).unwrap(); + menus::invoke(&mut app, &ctx, "file.saveAs", json!({"path": "flat.jpg", "jpegFormat": "baseline"})).unwrap(); + assert_eq!(*written.borrow(), ["flat.jpg"]); assert_eq!(identity(&app), before); } @@ -162,6 +170,8 @@ fn save_as_flat_retargets_a_flat_document() { app.save_as(Some("shot.png".into())).unwrap(); assert_eq!(app.session.active().unwrap().path.as_deref(), Some("shot.png")); app.save_as(Some("shot.jpg".into())).unwrap(); + // JPEG Options asks first; OK writes. + crate::jpeg_options_ui::confirm(&mut app, &egui::Context::default()); let st = app.session.active().unwrap(); assert_eq!((st.doc.name.as_str(), st.path.as_deref()), ("shot.jpg", Some("shot.jpg"))); assert_eq!(st.saved_revision, st.revision); diff --git a/crates/ui-egui/src/targa_options_ui.rs b/crates/ui-egui/src/targa_options_ui.rs index c2161a0c9b..8abb64e902 100644 --- a/crates/ui-egui/src/targa_options_ui.rs +++ b/crates/ui-egui/src/targa_options_ui.rs @@ -46,7 +46,7 @@ pub fn bits_param(params: &serde_json::Value, cmd: &str) -> Result Result<(), String> { if app.save_options_open() { - return Err("Answer TIFF or Targa Options before starting another save".into()); + return Err(crate::SAVE_OPTIONS_OPEN.into()); } let doc = app.active_doc_id()?; let bits = app.session.active().map_or(TgaBits::Bits24, |st| photocraft_io::tga_default_bits(&st.doc)); @@ -80,20 +80,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { let Some(p) = app.targa_options.take() else { return }; let settings = ExportSettings { tga_bits: Some(p.bits), ..app.save_settings() }; let step = serde_json::json!({"path": p.path, "tgaBits": p.bits.bits()}); - let written = app.with_document(p.doc, |app| app.write_document(p.path, &settings, p.copy)); - if written.is_ok() { - // An action being recorded keeps the Save As or Save a Copy with this answer (#2032). - app.session.journal.push((p.record.into(), step)); - } - match written { - Err(e) => { - app.ui.status = crate::i18n::fmt(tl!("Save failed: {error}"), &[("error", &e)]); - app.ui.status_error = true; - } - Ok(Some(_)) if !p.copy => crate::discard_ui::saved_document(app, ctx, p.doc), - // A background save releases the close prompt when it finishes (`jobs_ui`). - Ok(_) => {} - } + app.write_answered_save(ctx, p.doc, p.path, &settings, p.copy, (p.record, step)); } else if cancel { app.targa_options = None; app.ui.status = tl!("Save cancelled").into(); diff --git a/crates/ui-egui/src/view_cmds.rs b/crates/ui-egui/src/view_cmds.rs index 1da1f918d2..445d82b3d7 100644 --- a/crates/ui-egui/src/view_cmds.rs +++ b/crates/ui-egui/src/view_cmds.rs @@ -1442,32 +1442,32 @@ fn save_a_copy(app: &mut PhotocraftApp) -> Result { let suggested = format!("{stem} copy.psd"); // The copy is of the document as it was when asked. app.pick_save(&suggested, move |app, path| { - // An RGB Targa asks for its bits per pixel first; the copy is written from the prompt. + // An RGB Targa asks for its bits per pixel first, and a JPEG for its JPEG Options; the + // copy is written from the prompt. let parked = app.with_document(doc.id, |app| { - let ask = crate::targa_options_ui::wants_prompt(app, &path); - if ask { + if crate::targa_options_ui::wants_prompt(app, &path) { crate::targa_options_ui::park(app, path.clone(), true, "file.saveACopy")?; + return Ok(true); } - Ok(ask) + if crate::jpeg_options_ui::wants_prompt(app, &path) { + crate::jpeg_options_ui::park(app, path.clone(), true, "file.saveACopy")?; + return Ok(true); + } + Ok(false) })?; if parked { return Ok(json!({"path": path, "warnings": []})); } - let r = write_copy(app, &doc, &path, None)?; + let r = write_copy(app, &doc, &path, crate::SaveChoices::default())?; app.session.journal.push(("file.saveACopy".into(), json!({"path": path}))); Ok(r) }) } /// Writes `doc` to `path` as a copy (Save a Copy, and its replayed action step): the document's -/// path and saved state are untouched. `tga_bits` is the Targa answer, when there is one. -pub(crate) fn write_copy( - app: &mut PhotocraftApp, - doc: &photocraft_doc::Document, - path: &str, - tga_bits: Option, -) -> Result { - let settings = crate::ExportSettings { tga_bits, ..app.save_settings() }; +/// path and saved state are untouched. `choices` are the Targa and JPEG answers, when there are any. +pub(crate) fn write_copy(app: &mut PhotocraftApp, doc: &photocraft_doc::Document, path: &str, choices: crate::SaveChoices) -> Result { + let settings = choices.apply(app.save_settings()); let export = app.services.export.as_ref().ok_or("no exporter configured")?; let (bytes, warnings) = export(doc, path, &settings)?; let write = app.services.write.as_mut().ok_or("no writer configured")?; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 78f44b07d7..2551b86bff 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -88,7 +88,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i base64 PNG data; a path is relative to the automation write root. Raises the window first (default) because occluded macOS windows stop rendering - `ui.focus`: bring the main window to the front -- `app.open {path}` / `app.save {path?, tgaBits?}`: relative file I/O through the configured automation roots (`app.open` reads under the read root, `app.save` writes under the write root; absolute paths, `..` and paths escaping the root are refused, and both fail closed when no root was granted). Both reply with `warnings` (import/export notes such as "adjustment layer flattened"; `[]` when none), also shown to the user in the status bar and as a notice (`notices` in `ui.inspect`); `app.open` also returns the `path` and document `name` (directory `.pcraft` — the native on-disk form — loads through the read root's capability; the single-file (zip) form takes the bytes path), `app.save` the `path` written. `app.save` without `path` writes back only to the document's own PSD, PSB or `.pcraft` file, like File › Save. `tgaBits` (24 or 32) sets a TGA's bits per pixel: 32 writes the alpha channel (or else the transparency) as its alpha, 24 none; without it, 32 with an alpha channel or transparency, else 24. Automation saves never show TGA or TIFF options. Automation opens and saves never fire script events. Use these two rather than `file.open`, `file.save`, `file.saveAs` or `file.saveACopy`, which the control channel refuses (see [Engine commands](#engine-commands)) +- `app.open {path}` / `app.save {path?, tgaBits?, quality?, jpegFormat?}` (JPEG: `quality` on the 0–12 scale, `jpegFormat` `baseline`, `optimized` or `progressive`; automation never shows JPEG Options): relative file I/O through the configured automation roots (`app.open` reads under the read root, `app.save` writes under the write root; absolute paths, `..` and paths escaping the root are refused, and both fail closed when no root was granted). Both reply with `warnings` (import/export notes such as "adjustment layer flattened"; `[]` when none), also shown to the user in the status bar and as a notice (`notices` in `ui.inspect`); `app.open` also returns the `path` and document `name` (directory `.pcraft` — the native on-disk form — loads through the read root's capability; the single-file (zip) form takes the bytes path), `app.save` the `path` written. `app.save` without `path` writes back only to the document's own PSD, PSB or `.pcraft` file, like File › Save. `tgaBits` (24 or 32) sets a TGA's bits per pixel: 32 writes the alpha channel (or else the transparency) as its alpha, 24 none; without it, 32 with an alpha channel or transparency, else 24. Automation saves never show TGA or TIFF options. Automation opens and saves never fire script events. Use these two rather than `file.open`, `file.save`, `file.saveAs` or `file.saveACopy`, which the control channel refuses (see [Engine commands](#engine-commands)) - `app.quit` ## Engine commands diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index a2a21c9c37..001cac406e 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-15 · **Change:** minor (PNG `sTER` stereo pairing kept on save, #6063; earlier: PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-19 · **Change:** minor (JPEG Options on Save As and Save a Copy, FILE-215-19; PNG `sTER` stereo pairing kept on save, #6063; earlier: PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's 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,7 +25,7 @@ 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; 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) | +| 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); Save As and Save a Copy ask JPEG Options: Quality 0–12, starting at the opened file's quality or else the Export preference, and Baseline ("Standard") / Baseline Optimized / Progressive; scripts pass `quality`/`jpegFormat`, the CLI `--jpeg-format` (FILE-215-19). Baseline Optimized fits the Huffman tables only at 4:4:4 (Gray, CMYK) or progressive: RGB is written 4:2:0, which zune-jpeg 0.5.15 decodes wrongly with fitted tables, as in Save for Web. No Matte or size preview | | PNG | R/W | yes | yes | 8/16-bit, ICC/text; APNG first frame only; alpha channels on open (#2225); palette PNGs open in Indexed Color mode with their transparent entry (#4804); Save keeps a stereo PNG's `sTER` pairing (#6063) | | 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; palette BMPs open in Indexed Color mode | @@ -142,6 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-19 | minor | JPEG Options on Save As and Save a Copy (FILE-215-19) | | 2026-10-15 | minor | PNG: a stereo PNG's `sTER` pairing (PNG Extensions 1.4.0 §3.6) is read on open and written back on save, so the file still says it is a stereo pair (#6063) | | 2026-10-14 | minor | PSD smart filter masks: Photoshop stores a smart object's filtered pixels without its filter mask and mixes the unfiltered ones (`FEid`) back in; import now mixes them and export writes them that way, so sf-filter-mask-hard, -gradient and sf-stack-blend-and-mask pass; Photoshop-oracle floor 185 → 188 of 258 | | 2026-10-13 | minor | Lossy DNG (compression 34892) decodes: 8-bit baseline JPEG tiles and strips, CFA and LinearRaw, partial edge tiles; the stored values take the normal levels and LinearizationTable (FILE-215-11, lossy DNG part) | diff --git a/docs/gaps.md b/docs/gaps.md index 4457a87dde..a7b9a85013 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (G10: JPEG Options dialog landed, FILE-215-19; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -222,7 +222,7 @@ what an agent can't finish alone. ## File-format gaps ### G10. Missing and partial file formats -- **Missing:** JPEG Options dialog (FILE-215-19), Photoshop PDF read and its vector/text/multi-page +- **Missing:** Photoshop PDF read and its vector/text/multi-page write (FILE-215-10, #2541; a single flattened page now saves via Save As / Save a Copy / CLI, 2026-10-11, #3401), AVIF read (#463) and default-build AVIF write, JPEG XL, JPEG 2000 (#1047), Cineon write (read landed), DICOM, EPS/DCS, Pixar, IFF, WBMP, MPO/JPS, PICT, Scitex CT, Photoshop @@ -270,6 +270,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-19 | minor | G10: JPEG Options dialog landed (FILE-215-19); no readiness percentage or estimate changed | | 2026-10-18 | minor | G9: with no selection, Copy takes fill and adjustment layers whole and Paste recreates them (#2344); UI-217-23 stays partial (a single pixel layer copies its pixels only); estimate unchanged | | 2026-10-14 | minor | G1: a PSD smart object's filter mask shows when the file opens (Photoshop stores the filtered pixels unmasked); export stores them that way too; Photoshop-oracle floor 185 → 188 of 258 | | 2026-10-13 | minor | G14: D90 image area measured (4288 × 2848 at (10, 10)); gap remains open | diff --git a/docs/scorecard.md b/docs/scorecard.md index c4b53a20f8..8c59e8abe9 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -7,7 +7,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | Area | Done | Partial | Missing | Key numbers | |---|---:|---:|---:|---| | [Performance](#performance) | 3 | 22 | 24 | 58 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 19 not run, 5 not measurable yet (baseline 2026-10-05) | -| [File compatibility](#file-compatibility) | 7 | 14 | 6 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 188/256, round trip ≥ 258/256 | +| [File compatibility](#file-compatibility) | 8 | 14 | 5 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 188/256, round trip ≥ 258/256 | | [Tools](#tools) | 33 | 11 | 0 | | | [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 25 of 169 (target 0) | | [Type](#type) | 4 | 3 | 2 | | @@ -118,7 +118,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | FILE-215-16 | Advanced Blending (knockout, blend as group, mask hides effects) end to end | partial | [#215](https://github.com/storytold/photocraft/issues/215) | doc advanced.rs + .pcraft + PSD/layered TIFF (knko infx clbl tsly lmgm vmgm), CPU compose advanced.rs (GPU falls back), blendingOptions command + dialog; 7 psd-tools knockout oracles now pass; knockout-isolated-groups (0.45 % AA edges), advanced-blending, blend-and-clipping still differ | | FILE-215-17 | CMYK/RGB documents preview spot inks; spot plates export (TIFF extra samples, PDF) | missing | [#215](https://github.com/storytold/photocraft/issues/215) | compose multichannel.rs:1 spot preview for Multichannel only | | FILE-215-18 | Save for Web per-slice settings remembered; Export As remembers and offers nearest scaling | partial | [#215](https://github.com/storytold/photocraft/issues/215) | Save for Web settings survive a restart through the remembered dialog settings in prefs.dialogs (web_cmds::remember_web_settings stores them on a complete export, never the destination; file_ui.rs open_web seeds from the session's last export, else the preferences). export_dialog seeds Export As the same way (seed_remembered: format, quality, transparency, scale, metadata, TGA bits) and confirm remembers them as used. Missing: Export As nearest-neighbour scaling | -| FILE-215-19 | Save As JPEG opens a JPEG Options dialog (quality 0-12, format options) | missing | [#215](https://github.com/storytold/photocraft/issues/215) | ExportSettings has jpeg_quality 1-100 only (lib.rs:118) | +| FILE-215-19 | Save As JPEG opens a JPEG Options dialog (quality 0-12, format options) | done | [#215](https://github.com/storytold/photocraft/issues/215) | jpeg_options_ui.rs: Save As and Save a Copy to a JPEG ask for Quality 0-12 (Low/Medium/High/Maximum) and Baseline / Baseline Optimized / Progressive before writing, starting at an opened JPEG's own quality (file_encoding) or else the Export preference; scripts pass quality/jpegFormat (file.saveAs, file.saveACopy, app.save, CLI --jpeg-format) and recorded actions replay the answer (discard_ui/tests/jpeg_tests.rs, codecs roundtrip jpeg_format_options_round_trip). Baseline Optimized fits the Huffman tables only at 4:4:4 (Gray, CMYK) or progressive: RGB is written 4:2:0, which zune-jpeg 0.5.15 decodes wrongly with fitted tables, as in Save for Web. No Matte or size preview yet | | FILE-215-20 | Artboard tool with handles and + buttons, Duplicate Artboard beside, vector PDF pages | partial | [#215](https://github.com/storytold/photocraft/issues/215) | artboard_tool.rs: Artboard tool draws, selects, moves and resizes boards by their handles (⇧ keeps proportions), its + buttons add a board or (⌥) a copy beside the selected one (#3033); Duplicate Artboard places the copy beside (#1584); raster PDF pages only | | FILE-215-21 | Local libraries of colours, patterns, images and layers, as linked items or copies | missing | [#215](https://github.com/storytold/photocraft/issues/215) | Only preset libraries (preset_store.rs) | | FILE-216-1 | Every corpus fetched and enforced in CI, cached by pin | done | [#216](https://github.com/storytold/photocraft/issues/216) | ci.yml has a corpus job (name: corpus tests) on every PR and on main: the corpora are pinned in xtask/src/corpus_pins.rs, sha256-verified after both a cache restore and a download, and a missing corpus fails the tests. The cache key follows the pins, so it is cached by pin as this row asks (#190) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3eca518c27..2a85aae496 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # PhotoCraft vs Adobe Photoshop: target-app parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-18 · **Change:** minor (fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-19 · **Change:** minor (JPEG Options dialog landed, FILE-215-19; fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged) · **Target:** Adobe Photoshop 2026 The authoritative answer to "how close is PhotoCraft to Photoshop, and how much work is left". The one-page summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md); @@ -179,7 +179,7 @@ parity for the area and are already contained in the dimension rows above. | Camera Raw and computational photo (Photomerge, HDR, lens) | 45% | 120–200 | Raw for DNG/CR2/NEF/ARW/RW2/ORF/RAF(uncompressed)/PEF; no CR3; ACR dialog partial (no masking, calibration); generic lens profiles only | | Automation, actions, batch, scripting | 45% | 120–200 | CLI/MCP/control channel ahead of Photoshop; Actions panel and recording not document-independent (AUTO-219-1/2); `.atn` load/save since 2026-10-12 (112 events mapped, the rest kept verbatim); no ExtendScript/UXP | | File I/O | 60% | 300–500 | See [file-format-parity.md](file-format-parity.md) | -| Print, export, Save for Web | 50% | 60–100 | JPEG Options dialog missing (FILE-215-19), no Photoshop PDF, no separations, Save for Web settings not remembered | +| Print, export, Save for Web | 50% | 60–100 | no Photoshop PDF, no separations, Save for Web settings not remembered | | Video / timeline, data-driven graphics | 30% | 100–200 | Menu items live; frame model persists in `.pcraft`; no PSD video-layer mapping | | Workspace, panels, preferences | 40% | 150–250 | 46 of 149 prefs do nothing; no floating/OS-window panel groups; Libraries, Comments, Contextual Task Bar missing | | AI / generative | 10% | 300–600 | See the dimension row | @@ -565,6 +565,7 @@ See [method, raw measurements and reproduction](five-algorithm-performance.md). | Date | Change | Summary | |---|---|---| +| 2026-10-19 | minor | JPEG Options dialog landed (FILE-215-19); percentages unchanged | | 2026-10-18 | minor | Layers: with no selection, Copy takes fill and adjustment layers whole and Paste recreates them (#2344); UI-217-23 stays partial; percentages unchanged | | 2026-10-13 | minor | Vector shapes: on-canvas corner radius widgets on live rectangles (TOOL-214-12); estimates unchanged | | 2026-10-13 | minor | Stale "missing tool" rows corrected against main: Color Replacement (`paint.colorReplacement`), Red Eye (`paint.redEye`) and Add/Delete Anchor Point (the Pen flyout) are live toolbar tools and Frame landed, so Art History Brush is the only tool still missing; percentages unchanged | diff --git a/scorecard/file_compat.toml b/scorecard/file_compat.toml index d7e82c9aea..22aa5071d1 100644 --- a/scorecard/file_compat.toml +++ b/scorecard/file_compat.toml @@ -134,9 +134,9 @@ note = "Save for Web settings survive a restart through the remembered dialog se [[item]] id = "FILE-215-19" target = "Save As JPEG opens a JPEG Options dialog (quality 0-12, format options)" -status = "missing" +status = "done" issue = 215 -note = "ExportSettings has jpeg_quality 1-100 only (lib.rs:118)" +note = "jpeg_options_ui.rs: Save As and Save a Copy to a JPEG ask for Quality 0-12 (Low/Medium/High/Maximum) and Baseline / Baseline Optimized / Progressive before writing, starting at an opened JPEG's own quality (file_encoding) or else the Export preference; scripts pass quality/jpegFormat (file.saveAs, file.saveACopy, app.save, CLI --jpeg-format) and recorded actions replay the answer (discard_ui/tests/jpeg_tests.rs, codecs roundtrip jpeg_format_options_round_trip). Baseline Optimized fits the Huffman tables only at 4:4:4 (Gray, CMYK) or progressive: RGB is written 4:2:0, which zune-jpeg 0.5.15 decodes wrongly with fitted tables, as in Save for Web. No Matte or size preview yet" [[item]] id = "FILE-215-20"