diff --git a/apps/photocraft-cli/src/lib.rs b/apps/photocraft-cli/src/lib.rs index 076f0a1e98..b8c77c3446 100644 --- a/apps/photocraft-cli/src/lib.rs +++ b/apps/photocraft-cli/src/lib.rs @@ -15,7 +15,7 @@ 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>] [--dds-compression ] [--dds-mipmaps] + photocraft-cli convert [--format ] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression ] [--dds-mipmaps] [--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. @@ -24,12 +24,15 @@ USAGE: --dpx-bits sets a DPX's bits per sample. Without it: 8 for an 8-bit document, else 16. --dds-compression sets a DDS texture's compression: none (uncompressed, the default), bc1 (DXT1: alpha on or off), bc2 (DXT3) or bc3 (DXT5). --dds-mipmaps also writes the full mip chain. + --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>] [--dds-compression ] [--dds-mipmaps] + photocraft-cli run ( | --new ) --cmd [--params ] [--cmd …] [--out ] [--format ] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression ] [--dds-mipmaps] [--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>] [--dds-compression ] [--dds-mipmaps] + 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>] [--dds-compression ] [--dds-mipmaps] [--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 @@ -73,20 +76,20 @@ struct Subcommand { const SUBCOMMANDS: &[Subcommand] = &[ Subcommand { name: "convert", - values: &["--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression"], + values: &["--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression", "--jpeg-format"], bare: &["--tiff-layers", "--dds-mipmaps"], 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", "--dds-compression"], + values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression", "--jpeg-format"], bare: &["--tiff-layers", "--dds-mipmaps"], run: run_cmds, }, Subcommand { name: "batch", - values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression"], + values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression", "--jpeg-format"], bare: &["--in-place", "--tiff-layers", "--dds-mipmaps"], run: batch, }, @@ -226,6 +229,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"))?; + } if let Some(c) = a.get("--dds-compression") { o.encode.dds.compression = photocraft_io::DdsCompression::from_name(c).ok_or_else(|| format!("bad --dds-compression `{c}`: expected none, bc1, bc2 or bc3"))?; diff --git a/apps/photocraft-cli/tests/cli.rs b/apps/photocraft-cli/tests/cli.rs index 19258ca9ed..eacad848df 100644 --- a/apps/photocraft-cli/tests/cli.rs +++ b/apps/photocraft-cli/tests/cli.rs @@ -563,6 +563,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 c333cbc737..8733da8b2e 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. - DDS, for game and 3D textures: Save As and Save a Copy ask (DDS Options) for the compression (None, uncompressed and lossless; BC1/DXT1, whose alpha is on or off; BC2/DXT3; BC3/DXT5) and whether to generate mipmaps (the full chain down to 1×1, each level a box filter of the one above). Scripts pass `ddsCompression` (`none`, `bc1`, `bc2`, `bc3`) and `ddsMipmaps` (`--dds-compression`, `--dds-mipmaps` on the CLI). Opening one reads its top mip level. Flat TIFF and JPEG exports of composited CMYK documents convert the RGB composite back through diff --git a/crates/codecs/src/codecs/jpeg.rs b/crates/codecs/src/codecs/jpeg.rs index c769244cd4..cf1c9fca43 100644 --- a/crates/codecs/src/codecs/jpeg.rs +++ b/crates/codecs/src/codecs/jpeg.rs @@ -19,7 +19,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; @@ -256,12 +256,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 { @@ -244,6 +275,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, @@ -275,6 +308,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 9c6cb8cb6f..9ba9423509 100644 --- a/crates/codecs/tests/roundtrip.rs +++ b/crates/codecs/tests/roundtrip.rs @@ -242,6 +242,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 5b2c1dc088..c68b3f97a5 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -296,12 +296,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, DPX -/// bits, DDS compression and mipmaps, PSD previews and the PSD merged image. +/// What a headless save writes beyond the format: JPEG quality and format, TIFF layers, Targa +/// bits, DPX bits, DDS compression and mipmaps, 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 @@ -323,6 +325,7 @@ impl Default for SaveOpts { fn default() -> Self { SaveOpts { quality: None, + jpeg_format: None, tiff_layers: false, tga_bits: None, dpx_bits: None, @@ -334,14 +337,16 @@ impl Default for SaveOpts { } 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)?, @@ -377,6 +382,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> { @@ -433,12 +457,15 @@ pub(crate) fn encode(doc: &Document, path: &str, save: impl Into) -> R opts.encode.dpx_bits = save.dpx_bits; opts.encode.dds = save.dds; 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}"))) } @@ -1723,7 +1750,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),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain)}"##, + 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),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain)}"##, native_doc, save_a_copy ), @@ -1786,7 +1813,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),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain),"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),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain),"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 ), @@ -1795,7 +1822,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),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain),"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),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain),"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 ), @@ -1823,7 +1850,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),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain)} → {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),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain)} → {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 913335afe1..5bd51619b2 100644 --- a/crates/engine/src/file_cmds/tests.rs +++ b/crates/engine/src/file_cmds/tests.rs @@ -309,6 +309,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 12b8248d4f..f4912a537b 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 163a93d19a..c30ed4b8f1 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -2509,6 +2509,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"); + } + /// `app.save` takes the DDS options (`ddsCompression`, `ddsMipmaps`) and never stops at DDS /// Options. #[test] diff --git a/crates/ui-egui/src/dds_options_ui.rs b/crates/ui-egui/src/dds_options_ui.rs index c795643a7d..3216b51a30 100644 --- a/crates/ui-egui/src/dds_options_ui.rs +++ b/crates/ui-egui/src/dds_options_ui.rs @@ -47,7 +47,7 @@ pub fn record(options: DdsOptions, step: &mut serde_json::Value) { /// Parks the save behind the prompt. A prompt already waiting is never replaced. pub fn park(app: &mut PhotocraftApp, path: String, copy: bool, record: &'static str) -> Result<(), String> { if app.save_options_open() { - return Err("Answer the TIFF, Targa or DDS Options before starting another save".into()); + return Err(crate::SAVE_OPTIONS_OPEN.into()); } let doc = app.active_doc_id()?; app.dds_options = Some(Prompt { doc, path, options: DdsOptions::default(), copy, record }); diff --git a/crates/ui-egui/src/discard_ui.rs b/crates/ui-egui/src/discard_ui.rs index e9b5504bea..616da8409a 100644 --- a/crates/ui-egui/src/discard_ui.rs +++ b/crates/ui-egui/src/discard_ui.rs @@ -962,6 +962,9 @@ mod tests { #[path = "dds_tests.rs"] mod dds_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 b65985cd40..34d83075e1 100644 --- a/crates/ui-egui/src/disk_change_prompt.rs +++ b/crates/ui-egui/src/disk_change_prompt.rs @@ -20,7 +20,7 @@ pub fn owns(fields: &Map) -> bool { /// Opens the prompt instead of saving `doc` to its own file at `path` when another app changed /// that file; `None` saves as usual. `flat` is a flat file confirmed in the Save dialog, which /// is saved (and recorded) the way [`PhotocraftApp::save_dialog_flow`] does it. -pub(crate) fn intercept(app: &mut PhotocraftApp, doc: DocId, path: &str, flat: bool) -> Option> { +pub(crate) fn intercept(app: &mut PhotocraftApp, doc: DocId, path: &str, flat: bool, choices: crate::FormatChoices) -> Option> { if !app.changed_on_disk(doc, path) { return None; } @@ -29,7 +29,9 @@ pub(crate) fn intercept(app: &mut PhotocraftApp, doc: DocId, path: &str, flat: b } let name = crate::file_open::display_name(path); let message = crate::i18n::fmt(tl!("{name} was changed by another app after it was opened or saved here."), &[("name", name.as_str())]); - let fields = json!({MARK: true, "__label": "Save", "message": message, "document": doc.0, "path": path, "flat": flat}); + let mut fields = json!({MARK: true, "__label": "Save", "message": message, "document": doc.0, "path": path, "flat": flat}); + // A script's Targa, DDS or JPEG answer is kept for the save Replace writes. + choices.record(&mut fields); let id = app.ui.open_dialog(DialogKind::Command, fields.as_object().cloned().unwrap_or_default()); Some(Ok(json!({"dialog": id, "confirmationPending": true}))) } @@ -45,9 +47,10 @@ pub fn confirm(app: &mut PhotocraftApp, fields: &Map) -> Result>>> = Rc::default(); + let q = qualities.clone(); + app.services.export = Some(Box::new(move |_: &photocraft_doc::Document, _: &str, s: &crate::ExportSettings| { + q.borrow_mut().push(s.jpeg_quality); + Ok((b"out".to_vec(), Vec::new())) + })); + app.run("actions.record", json!({})).unwrap(); + menus::invoke(&mut app, &ctx, "file.save", json!({})).unwrap(); + app.poll_file_dialog(&ctx, None); + answer(&open, Some(FileDialogAnswer::SaveTo("/pics/photo.jpg".into()))); + app.poll_file_dialog(&ctx, None); + let prompt = app.jpeg_options.as_mut().expect("JPEG Options"); + prompt.quality = 3; + prompt.format = photocraft_codecs::JpegFormat::Progressive; + crate::jpeg_options_ui::confirm(&mut app, &ctx); + app.run("actions.stop", json!({})).unwrap(); + assert_eq!(app.session.actions.list[0].steps, [("file.save".to_string(), json!({"quality": 3, "jpegFormat": "progressive"}))]); + // Played on another flat JPEG: written over itself at quality 3, with no dialog or prompt. + let bytes = photocraft_io::export(&app.session.active().unwrap().doc, "other.jpg", &Default::default()).unwrap().bytes; + let index = app.session.add_document(photocraft_io::import("other.jpg", &bytes).unwrap().document, Some("/pics/other.jpg".into())); + app.session.set_active(index); + written.borrow_mut().clear(); + let played = app.run("actions.play", json!({"action": 0})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + assert!(app.jpeg_options.is_none(), "playback does not ask"); + assert!(open.borrow().is_empty(), "no Save dialog"); + assert_eq!(*written.borrow(), ["/pics/other.jpg"]); + assert_eq!(qualities.borrow().last().copied().flatten(), Some(photocraft_engine::file_cmds::jpeg_quality(3.0))); +} + /// The user answers the open dialog, from the dialog's own thread. fn answer(open: &Open, answer: Option) { let (_, reply) = open.borrow_mut().pop().expect("a dialog is open"); @@ -1124,7 +1192,52 @@ 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"); +} + +/// A replayed plain Save keeps its Targa answer in the step it records, as well as its JPEG one. +#[test] +fn a_replayed_plain_save_records_its_targa_answer() { + let (mut app, _, written) = jpeg_app(Some("/pics/photo.tga"), false); + app.replay_save("file.save", &json!({"tgaBits": 24})).unwrap(); + assert_eq!(*written.borrow(), ["/pics/photo.tga"]); + assert_eq!(app.session.journal.last(), Some(&("file.save".to_string(), json!({"tgaBits": 24})))); +} + +/// A script's JPEG Options answer survives the changed-on-disk prompt: Replace writes with it, +/// without asking JPEG Options. +#[test] +fn a_scripted_quality_survives_the_changed_on_disk_prompt() { + let ctx = egui::Context::default(); + let path = "/pics/My image.jpg"; + let (mut app, open, written) = jpeg_app(None, false); + let qualities: Rc>>> = Rc::default(); + let q = qualities.clone(); + app.services.export = Some(Box::new(move |_: &photocraft_doc::Document, _: &str, s: &crate::ExportSettings| { + q.borrow_mut().push(s.jpeg_quality); + Ok((b"out".to_vec(), Vec::new())) + })); + let disk = on_disk(&mut app); + changed_by_another_app(&disk, path); + app.opened_from(path); + app.run("image.adjustments.invert", json!({})).unwrap(); + changed_by_another_app(&disk, path); + menus::invoke(&mut app, &ctx, "file.save", json!({"quality": 5})).unwrap(); + app.poll_file_dialog(&ctx, None); + answer(&open, Some(FileDialogAnswer::SaveTo(path.into()))); + app.poll_file_dialog(&ctx, None); + 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(); + assert!(app.jpeg_options.is_none(), "the script answered JPEG Options"); + assert_eq!(*written.borrow(), [path]); + assert_eq!(qualities.borrow().last().copied().flatten(), Some(photocraft_engine::file_cmds::jpeg_quality(5.0))); + // Recorded as a plain Save keeping the answer, so a replay writes the file it's played on. + assert_eq!(app.session.journal.last(), Some(&("file.save".to_string(), json!({"quality": 5, "jpegFormat": "baseline"})))); } diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index f60a504ca9..359b175465 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2314,6 +2314,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 7b25742713..0f91c6c9a8 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2350,6 +2350,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 6bfa8454fc..1fb21b6c35 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1119,6 +1119,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 ee99a81b9d..51dd5ff0ad 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2388,6 +2388,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 a72a3b9bf3..83c91cdbed 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2738,6 +2738,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 8a6f4fc9cb..3b90221150 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2992,6 +2992,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 d39decf19f..5e4b15da25 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2725,6 +2725,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 a912fe2a5e..e2a604cbc8 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2381,6 +2381,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 c8f5653a0c..00bc802414 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2289,6 +2289,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 fe0a65a6f4..c17462cc09 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2906,6 +2906,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 3d77757305..679d0818e6 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2327,6 +2327,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 e587da59e3..47afcfa6d6 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2753,6 +2753,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 51b3df27ec..ea9eee583e 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2736,6 +2736,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 0dbc55ec4a..8c7213d50b 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2586,6 +2586,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 34ca8fcb1a..3916a18ba6 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -2719,6 +2719,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 a153549052..842d723d34 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2299,6 +2299,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 ceade5627f..b16669585d 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2555,6 +2555,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 86deaa291c..e9eaceb684 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2503,6 +2503,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 25f62f5e9f..7fe1f4535a 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2329,6 +2329,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 ae52c6a218..9e1305208e 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2299,6 +2299,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..97b4f45649 --- /dev/null +++ b/crates/ui-egui/src/jpeg_options_ui.rs @@ -0,0 +1,350 @@ +//! 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 +//! without the path for a plain `file.save`, which writes whichever file an action is played on +//! ([`plain_save_step`]). + +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 params of a recorded plain `file.save` (which writes whichever file an action is played +/// on): none, unless JPEG Options changed something, so that a replay writes it too without +/// asking. `quality` is left out when the file's own quality was kept, and `jpegFormat` when it +/// is Baseline, the default. +pub fn plain_save_step(choice: Option) -> Value { + let mut step = Value::Object(Default::default()); + if let Some(c) = choice { + if let Some(q) = c.quality { + step["quality"] = q.into(); + } + if c.format != JpegFormat::Baseline { + step["jpegFormat"] = c.format.name().into(); + } + } + step +} + +/// 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; + // A focused button takes Enter as its click without consuming the key: Enter on Cancel cancels. + if ok || (!cancel && 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), with the answer. + plain_save_step(Some(choice)) + } 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 723eba48d5..adb9d5f310 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -114,6 +114,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; @@ -261,6 +262,63 @@ 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 the TIFF, Targa, DDS or JPEG Options before starting another save"; + +/// The format answers a save takes instead of asking: the Targa bits per pixel (`"tgaBits"`), +/// the DDS options (`"ddsCompression"`, `"ddsMipmaps"`) and JPEG Options (`"quality"` 0–12, +/// `"jpegFormat"`). `None` asks in Save As and takes the default in automation saves, which never +/// prompt. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct FormatChoices { + pub tga_bits: Option, + pub dds: Option, + pub jpeg: Option, +} + +impl FormatChoices { + /// The answers in command or control call `cmd`'s params; a bad value is an error. + pub fn from_params(params: &Value, cmd: &str) -> Result { + Ok(FormatChoices { + tga_bits: targa_options_ui::bits_param(params, cmd)?, + dds: dds_options_ui::options_param(params, cmd)?, + jpeg: jpeg_options_ui::choice_param(params, cmd)?, + }) + } + + /// The answers in a recorded save step, read as leniently as the engine command that may have + /// recorded it (see [`jpeg_options_ui::recorded_choice`]). + pub(crate) fn from_step(params: &Value, cmd: &str) -> Result { + Ok(FormatChoices { + tga_bits: targa_options_ui::bits_param(params, cmd)?, + dds: dds_options_ui::options_param(params, cmd)?, + jpeg: jpeg_options_ui::recorded_choice(params, cmd)?, + }) + } + + /// The export settings with these answers applied. + pub fn apply(self, settings: ExportSettings) -> ExportSettings { + let settings = ExportSettings { tga_bits: self.tga_bits.or(settings.tga_bits), dds: self.dds.unwrap_or(settings.dds), ..settings }; + match self.jpeg { + Some(j) => j.apply(settings), + None => settings, + } + } + + /// Adds the answers to a recorded save step. + pub(crate) fn record(self, step: &mut Value) { + if let Some(bits) = self.tga_bits { + step["tgaBits"] = bits.bits().into(); + } + if let Some(dds) = self.dds { + dds_options_ui::record(dds, step); + } + 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). @@ -268,6 +326,8 @@ pub type ImportFn = Box Re pub struct ExportSettings { /// JPEG quality 1–100 (None = codec default). pub jpeg_quality: Option, + /// 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). @@ -307,6 +367,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, @@ -331,6 +392,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; @@ -349,37 +411,6 @@ impl ExportSettings { } } -/// The format answers a save takes instead of asking: the Targa bits per pixel and the DDS options -/// (`"tgaBits"`, `"ddsCompression"`, `"ddsMipmaps"`). `None` asks in Save As and takes the -/// default in automation saves, which never prompt. -#[derive(Clone, Copy, Debug, Default, PartialEq)] -pub struct FormatChoices { - pub tga_bits: Option, - pub dds: Option, -} - -impl FormatChoices { - /// The answers in command `cmd`'s params; a bad value is an error. - pub fn from_params(params: &Value, cmd: &str) -> Result { - Ok(FormatChoices { tga_bits: targa_options_ui::bits_param(params, cmd)?, dds: dds_options_ui::options_param(params, cmd)? }) - } - - /// The export settings with these answers applied. - pub(crate) fn apply(self, settings: ExportSettings) -> ExportSettings { - ExportSettings { tga_bits: self.tga_bits, dds: self.dds.unwrap_or(settings.dds), ..settings } - } - - /// Adds the answers 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(dds) = self.dds { - dds_options_ui::record(dds, step); - } - } -} - /// The document's lone layer when it is a plain raster a flat file keeps: visible, at full /// opacity, in Normal mode, with no masks, effects or blending restrictions (#2234). pub(crate) fn plain_raster(doc: &Document) -> Option<&photocraft_doc::Layer> { @@ -922,6 +953,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, /// A save to a DDS texture parked behind the DDS Options prompt (see `dds_options_ui`). pub(crate) dds_options: Option, /// Files read but not opened yet: the first waits on its profile prompt, the rest behind it @@ -1085,6 +1118,7 @@ impl PhotocraftApp { save_dirs: HashMap::new(), disk_stamps: HashMap::new(), targa_options: None, + jpeg_options: None, dds_options: None, held_opens: Default::default(), allow_close: false, @@ -1597,9 +1631,9 @@ impl PhotocraftApp { self.save_as_with(path, FormatChoices::default()) } - /// [`Self::save_as`], writing a Targa or DDS texture with the `choices` given instead of - /// asking for them (no Targa or DDS Options prompt): `file.save` and `file.saveAs` with - /// `tgaBits`, `ddsCompression` or `ddsMipmaps`. + /// [`Self::save_as`] with the format choices already made, which skip their prompts: + /// `file.save` and `file.saveAs` with `tgaBits` (no Targa Options), `ddsCompression` or + /// `ddsMipmaps` (no DDS Options), or `quality` or `jpegFormat` (no JPEG Options). pub fn save_as_with(&mut self, path: Option, choices: FormatChoices) -> Result { self.save_dialog_flow(path, choices, false) } @@ -1609,7 +1643,7 @@ impl PhotocraftApp { /// 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, choices: FormatChoices, plain_save: bool) -> Result { if self.save_options_open() { - return Err("Answer the TIFF, Targa or DDS 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") { @@ -1653,7 +1687,7 @@ impl PhotocraftApp { // Confirming the document's own file: ask first if another app changed it (#6596). if plain_save && own.as_deref() == Some(path.as_str()) - && let Some(r) = crate::disk_change_prompt::intercept(app, doc, &path, true) + && let Some(r) = crate::disk_change_prompt::intercept(app, doc, &path, true, choices) { return r; } @@ -1668,9 +1702,10 @@ impl PhotocraftApp { /// [`Self::save_as_with`] once the path is known. pub(crate) fn save_to(&mut self, path: String, choices: FormatChoices) -> Result { - // An RGB Targa asks for its bits per pixel, and a DDS texture for its compression, 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. + // An RGB Targa asks for its bits per pixel, a DDS texture for its compression, 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")?; @@ -1681,6 +1716,11 @@ impl PhotocraftApp { dds_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) { @@ -1691,20 +1731,23 @@ impl PhotocraftApp { } /// File › Save back to the document's own file, recorded as a `file.save` step with the - /// Targa or DDS answer in `choices` (a replayed Save of a flat file brings its own). + /// Targa, DDS or JPEG answer in `choices` (a replayed or scripted Save of a flat file brings + /// its own). pub(crate) fn save_in_place(&mut self, path: String, choices: FormatChoices) -> Result { if self.save_options_open() { - return Err("Answer the TIFF, Targa or DDS Options before starting another save".into()); + return Err(SAVE_OPTIONS_OPEN.into()); } let r = self.write_save(path, None, choices)?; - let mut step = Value::Object(Default::default()); - choices.record(&mut step); + // JPEG Options are recorded only where they change something + // ([`jpeg_options_ui::plain_save_step`]); the Targa and DDS answers as given. + let mut step = jpeg_options_ui::plain_save_step(choices.jpeg); + FormatChoices { jpeg: None, ..choices }.record(&mut step); self.session.journal.push(("file.save".into(), step)); Ok(r) } /// Writes the Save As and journals it as a `file.saveAs` step with its path (and TIFF, - /// Targa or DDS answer), so an action being recorded keeps it (#2032). + /// Targa, DDS or JPEG answer), so an action being recorded keeps it (#2032). fn save_as_step(&mut self, path: String, tiff_layers: Option, choices: FormatChoices) -> Result { let r = self.write_save(path.clone(), tiff_layers, choices)?; let mut step = serde_json::json!({"path": path}); @@ -1717,20 +1760,26 @@ impl PhotocraftApp { } /// Re-label the Save As just journaled for `path` as File › Save (see [`Self::save_dialog_flow`]), - /// keeping its Targa or DDS answer so playback writes the file the same way. A save parked - /// behind Targa or DDS Options hasn't been journaled yet: it is recorded as File › Save once - /// answered. + /// keeping its Targa, DDS or JPEG answer so playback writes the file the same way. A save + /// parked behind Targa, DDS or JPEG Options hasn't been journaled yet: it is recorded as + /// File › Save once answered. pub(crate) fn journal_as_plain_save(&mut self, path: &str) { - let parked = [self.targa_options.as_mut().map(|p| (&p.path, &mut p.record)), self.dds_options.as_mut().map(|p| (&p.path, &mut p.record))]; + let parked = [ + self.targa_options.as_mut().map(|p| (&p.path, &mut p.record)), + self.dds_options.as_mut().map(|p| (&p.path, &mut p.record)), + self.jpeg_options.as_mut().map(|p| (&p.path, &mut p.record)), + ]; if let Some((_, record)) = parked.into_iter().flatten().find(|(p, record)| p.as_str() == path && **record == "file.saveAs") { *record = "file.save"; return; } + // Only the path goes: a script's format answer stays, so a replay writes the file it is + // played on the same way. Any other answer (TIFF layers) keeps the Save As. if let Some(last) = self.session.journal.last_mut() && last.0 == "file.saveAs" && last.1.get("path").and_then(Value::as_str) == Some(path) && let Some(step) = last.1.as_object() - && step.keys().all(|k| matches!(k.as_str(), "path" | "tgaBits" | "ddsCompression" | "ddsMipmaps")) + && step.keys().all(|k| matches!(k.as_str(), "path" | "tgaBits" | "ddsCompression" | "ddsMipmaps" | "quality" | "jpegFormat")) { let mut step = step.clone(); step.remove("path"); @@ -1739,18 +1788,18 @@ impl PhotocraftApp { } /// Replays a recorded `file.save` / `file.saveAs` / `file.saveACopy` / `file.export.exportAs` - /// step: no dialog or prompt, and the recorded TIFF, Targa or DDS answer (if any) stands in for - /// the TIFF, Targa or DDS Options prompt. + /// step: no dialog or prompt, and the recorded TIFF, Targa, DDS 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 the TIFF, Targa or DDS 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 // file a recorded Save confirmed in the dialog while the document is still a plain - // raster, so it is never flattened over (#3531, #3806). A flat file's recorded Targa - // or DDS answer stands in for the prompt. - let choices = FormatChoices::from_params(params, id)?; + // raster, so it is never flattened over (#3531, #3806). A flat file's recorded Targa, + // DDS or JPEG answer writes the same way, without asking. + let choices = FormatChoices::from_step(params, id)?; let st = self.session.active().ok_or("no document")?; let flat_own = |p: &str| { !photocraft_engine::file_cmds::saves_in_place(p) @@ -1767,7 +1816,7 @@ impl PhotocraftApp { return crate::export_dialog::replay(self, params); } let path = params.get("path").and_then(Value::as_str).filter(|p| !p.is_empty()).ok_or("Save As needs a `path`")?; - let choices = FormatChoices::from_params(params, id)?; + let choices = FormatChoices::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(); @@ -1814,8 +1863,9 @@ 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. `choices` are the Targa bits per - /// pixel (`None`: 32 with an alpha channel or transparency, else 24) and the DDS options. + /// `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), the DDS options and the JPEG + /// Options. fn write_save(&mut self, path: String, tiff_layers: Option, choices: FormatChoices) -> Result { let copy = self.saves_copy(&path, tiff_layers)?; let settings = choices.apply(ExportSettings { tiff_layers: tiff_layers.unwrap_or(ExportSettings::default().tiff_layers), ..self.save_settings() }); @@ -1924,9 +1974,30 @@ impl PhotocraftApp { own == path && self.services.file_stamp.as_ref().and_then(|stamp| stamp(path)).is_some_and(|now| now != *then) } - /// TIFF, Targa or DDS Options is waiting on an answer before a save is written. + /// TIFF, Targa, DDS 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.dds_options.is_some() + self.tiff_options.is_some() || self.targa_options.is_some() || self.dds_options.is_some() || self.jpeg_options.is_some() + } + + /// Writes a save that waited on a format prompt (Targa, DDS 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. @@ -1946,9 +2017,9 @@ impl PhotocraftApp { self.save_automation_with(path, FormatChoices::default()) } - /// [`Self::save_automation`] writing a Targa or DDS texture with the `choices` given (a - /// Targa's `None`: 32 with an alpha channel or transparency, else 24; a DDS texture's: - /// uncompressed without mipmaps). Automation saves never prompt. + /// [`Self::save_automation`] writing with the format `choices` given (a Targa's `None`: 32 + /// with an alpha channel or transparency, else 24; a DDS texture's: uncompressed without + /// mipmaps; a JPEG's: the file's own quality, Baseline). Automation saves never prompt. pub fn save_automation_with(&mut self, path: Option, choices: FormatChoices) -> Result { let target = { let state = self.session.active().ok_or("no document")?; @@ -2281,6 +2352,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); dds_options_ui::show(self, &ctx); distort_ui::show(self, &ctx); camera_raw_ui::show(self, &ctx); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index f52cbfb8f1..a73734967b 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -240,7 +240,7 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: Some((true, path, d)) => { // Another app changed the file since it was opened or saved: ask first (#6596). let doc = d.doc.id; - crate::disk_change_prompt::intercept(app, doc, &path, false) + crate::disk_change_prompt::intercept(app, doc, &path, false, crate::FormatChoices::default()) .unwrap_or_else(|| app.save_in_place(path, crate::FormatChoices::default())) } Some((false, path, d)) if photocraft_engine::file_cmds::saves_in_place(&path) => { diff --git a/crates/ui-egui/src/save_identity_tests.rs b/crates/ui-egui/src/save_identity_tests.rs index 416e276bd8..97f252772f 100644 --- a/crates/ui-egui/src/save_identity_tests.rs +++ b/crates/ui-egui/src/save_identity_tests.rs @@ -149,7 +149,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(); @@ -159,8 +160,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); } @@ -172,6 +180,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 5a8b47d589..446bd0cb7f 100644 --- a/crates/ui-egui/src/targa_options_ui.rs +++ b/crates/ui-egui/src/targa_options_ui.rs @@ -47,7 +47,7 @@ pub fn bits_param(params: &serde_json::Value, cmd: &str) -> Result Result<(), String> { if app.save_options_open() { - return Err("Answer the TIFF, Targa or DDS 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)); @@ -81,22 +81,10 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { if ok || (!cancel && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter))) { let Some(p) = app.targa_options.take() else { return }; let settings = ExportSettings { tga_bits: Some(p.bits), ..app.save_settings() }; + // A plain Save writes whichever file the action is played on (#3531): no path. let mut step = if p.record == "file.save" { serde_json::json!({}) } else { serde_json::json!({"path": p.path}) }; step["tgaBits"] = p.bits.bits().into(); - 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 f4bd976195..e73aedc256 100644 --- a/crates/ui-egui/src/view_cmds.rs +++ b/crates/ui-egui/src/view_cmds.rs @@ -1486,8 +1486,8 @@ 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, and a DDS texture for its compression; - // the copy is written from the prompt. + // An RGB Targa asks for its bits per pixel first, a DDS texture for its compression, and + // a JPEG for its JPEG Options; the copy is written from the prompt. let parked = app.with_document(doc.id, |app| { if crate::targa_options_ui::wants_prompt(app, &path) { crate::targa_options_ui::park(app, path.clone(), true, "file.saveACopy")?; @@ -1497,6 +1497,10 @@ fn save_a_copy(app: &mut PhotocraftApp) -> Result { crate::dds_options_ui::park(app, path.clone(), true, "file.saveACopy")?; return Ok(true); } + 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 { @@ -1509,7 +1513,8 @@ fn save_a_copy(app: &mut PhotocraftApp) -> Result { } /// Writes `doc` to `path` as a copy (Save a Copy, and its replayed action step): the document's -/// path and saved state are untouched. `choices` are the Targa and DDS answers, when there are any. +/// path and saved state are untouched. `choices` are the Targa, DDS and JPEG answers, when there +/// are any. pub(crate) fn write_copy(app: &mut PhotocraftApp, doc: &photocraft_doc::Document, path: &str, choices: crate::FormatChoices) -> Result { let settings = choices.apply(app.save_settings()); let export = app.services.export.as_ref().ok_or("no exporter configured")?; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 1551f78585..5c28224cfb 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?, ddsCompression?, ddsMipmaps?}`: 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. `ddsCompression` (`none`, `bc1`, `bc2` or `bc3`) and `ddsMipmaps` (bool) set a DDS texture's compression and mip chain; without them, uncompressed without mipmaps. Automation saves never show TGA, DDS 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?, ddsCompression?, ddsMipmaps?, 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. `ddsCompression` (`none`, `bc1`, `bc2` or `bc3`) and `ddsMipmaps` (bool) set a DDS texture's compression and mip chain; without them, uncompressed without mipmaps. Automation saves never show TGA, DDS, JPEG 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 8cccdd1bea..d77d044202 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 (Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 45 filters (31 more: Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: PNG `sTER` stereo pairing kept on save, #6063; 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; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 45 filters (31 more: Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: PNG `sTER` stereo pairing kept on save, #6063; 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); generated 20 MP, 150-layer, 2 GB PSD and 4.4 GB PSB fixtures opened, saved and re-opened by io tests/large_files.rs (FILE-216-2, 2026-10-13; the 4.4 GB PSB opens on Windows); > 4 GB from the app's File › Open 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, including Photoshop mixed-order type blocks (#4758); undecodable type keeps saved pixels (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 and Indexed Color documents save as palette BMPs (no transparency) | @@ -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 | PSD smart filters: Clouds, Difference Clouds, Fibers and Lens Flare open as editable filters with their seeds, colours and lens, and save like Photoshop (checked against a Photoshop 27 file); render filters on a smart object work within the object, so a flare sits where Photoshop puts it | | 2026-10-15 | minor | DDS textures save (#1327): uncompressed or BC1/BC2/BC3 (DXT1/3/5) from a clean-room block encoder, optional box-filtered mip chain, legacy header; DDS Options in Save As and Save a Copy, `ddsCompression`/`ddsMipmaps` for scripts, the CLI and MCP | | 2026-10-16 | minor | PSD smart filters: Perspective Warp (`perspectiveWarpTransform`: `vertices`, `warpedVertices`, `quads` of vertex indices, document pixels) opens as `layer.smartObjects.perspectiveWarp`, one plane per quad, and re-renders warped; save writes the original item with only the geometry rewritten, or a new one in Photoshop's layout; inconsistent descriptors stay verbatim (FILE-215-2, #6268) | diff --git a/docs/gaps.md b/docs/gaps.md index 9b281d6786..cade40710a 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: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; 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: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; 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 @@ -242,7 +242,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 @@ -290,6 +290,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-15 | minor | G1 / #5972 A1: render only provably constant-RGB `GdFl` noise fills; variable noise synthesis remains open | | 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-15 | minor | G2: a cancelled 24 MP Gaussian Blur job stops within 44 ms max (P25, budget 200 ms; was about 400 ms; local runs, baseline not re-run), the uncancelled job on a locked-transparency layer about 40% faster; #6205 | diff --git a/docs/scorecard.md b/docs/scorecard.md index f8a769a5b2..30255628f2 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 | 26 | 60 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 22 not run, 4 not measurable yet (baseline 2026-10-05) | -| [File compatibility](#file-compatibility) | 9 | 12 | 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) | 10 | 12 | 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) | 36 | 8 | 0 | | | [Workspace and UI](#workspace-and-ui) | 14 | 6 | 3 | settings that do nothing: 24 of 171 (target 0) | | [Type](#type) | 5 | 3 | 1 | | @@ -120,7 +120,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 | done | [#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, resample, metadata, TGA bits) and confirm remembers them as used. Export As › Image Size › Resample offers Photoshop's six methods (Bicubic Automatic default, Nearest Neighbor included; export_preset_cmds::RESAMPLE, unknown values are errors) for the exported copy only; also layer.exportAs `resample` and export presets (#5838). Tests: export_dialog nearest_neighbor_doubles_each_pixel_into_an_exact_block (8/16-bit), resample_choices_are_checked, resample_is_remembered_and_a_bad_saved_value_falls_back; layer_menu_cmds export_layer_resamples_with_the_chosen_method; tests/export_as_scaling.rs export_as_resample_menu_writes_nearest_neighbor_blocks | -| 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 a2581c4b46..76c56c2999 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 (Content-Aware Move live preview for every selection size, TOOL-213-4 done, #6680; retouching evidence: Pattern Stamp and Background Eraser draw live, only the Mixer Brush is left of TOOL-213-7; fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; shape layers take Density and Feather, TOOL-214-12 done, #5846; 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; Content-Aware Move live preview for every selection size, TOOL-213-4 done, #6680; retouching evidence: Pattern Stamp and Background Eraser draw live, only the Mixer Brush is left of TOOL-213-7; fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; shape layers take Density and Feather, TOOL-214-12 done, #5846; 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-16 | minor | Content-Aware Move: live result preview for every selection the command takes (TOOL-213-4 done, #6680); percentages unchanged | | 2026-10-15 | minor | Vector shapes: shape layers take Density and Feather (Properties › Masks), PSD and .pcraft round trip; TOOL-214-12 done (#5846); estimates unchanged | diff --git a/scorecard/file_compat.toml b/scorecard/file_compat.toml index c53611628e..a51b7db03d 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"