Merge PR #7883: Save As JPEG: JPEG Options dialog (quality 0–12, Baseline / Optimized / Progressive) (FILE-215-19)

crates/ui-egui/src/lib.rs: replay_save doc comment names both Export As and JPEG; replay_save uses the PR's FormatChoices::from_step, after main's export_dialog::replay branch.
docs/file-format-parity.md: header keeps main's text plus the JPEG Options item (last updated 2026-10-19); TIFF row is main's, JPEG row is the PR's; change-log rows once each, newest first.
docs/gaps.md: header keeps main's text plus the G10 item (last updated 2026-10-19).
This commit is contained in:
aiongg
2026-10-22 02:27:46 +00:00
48 changed files with 1288 additions and 126 deletions
+13 -6
View File
@@ -15,7 +15,7 @@ pub const USAGE: &str = "\
photocraft-cli: headless PhotoCraft
USAGE:
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression <none|bc1|bc2|bc3>] [--dds-mipmaps]
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression <none|bc1|bc2|bc3>] [--dds-mipmaps] [--jpeg-format <baseline|optimized|progressive>]
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 <file> [--compact]
Print the document as JSON (size, mode, depth, layer tree).
photocraft-cli run (<file> | --new <json>) --cmd <id> [--params <json>] [--cmd …] [--out <file>] [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression <none|bc1|bc2|bc3>] [--dds-mipmaps]
photocraft-cli run (<file> | --new <json>) --cmd <id> [--params <json>] [--cmd …] [--out <file>] [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression <none|bc1|bc2|bc3>] [--dds-mipmaps] [--jpeg-format <baseline|optimized|progressive>]
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 <actions.json|set.atn> [--action <name>] --in <dir> --out <dir> [--format <ext>] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression <none|bc1|bc2|bc3>] [--dds-mipmaps]
photocraft-cli batch --actions <actions.json|set.atn> [--action <name>] --in <dir> --out <dir> [--format <ext>] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression <none|bc1|bc2|bc3>] [--dds-mipmaps] [--jpeg-format <baseline|optimized|progressive>]
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<ExportOptions, String> {
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"))?;
+27
View File
@@ -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]
+1
View File
@@ -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
+11 -6
View File
@@ -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<Ve
let mut enc = jpeg_encoder::Encoder::new(&mut out, opts.jpeg_quality.clamp(1, 100));
// CMYK is always written 4:4:4: subsampled 4-component JPEGs are not
// decoded consistently across implementations.
let sampling = if opts.jpeg_chroma_subsampling && img.layout() == ChannelLayout::Rgb {
jpeg_encoder::SamplingFactor::R_4_2_0
} else {
jpeg_encoder::SamplingFactor::R_4_4_4
};
let subsampled = opts.jpeg_chroma_subsampling && img.layout() == ChannelLayout::Rgb;
let sampling = if subsampled { jpeg_encoder::SamplingFactor::R_4_2_0 } else { jpeg_encoder::SamplingFactor::R_4_4_4 };
enc.set_sampling_factor(sampling);
// JPEG Options › Format Options. Fitted Huffman tables make a baseline scan non-interleaved
// (one scan per component), which zune-jpeg 0.5.15 decodes wrongly with 4:2:0 chroma
// (etemesi254/zune-image#419; see `web::encode_jpeg_rgb8`), so a subsampled Baseline
// Optimized keeps the standard tables until that decoder is updated. Progressive is fine.
let progressive = opts.jpeg_format == JpegFormat::Progressive;
enc.set_progressive(progressive);
let optimized = opts.jpeg_format == JpegFormat::Optimized && !subsampled;
enc.set_optimized_huffman_tables(progressive || optimized);
let e = |e: jpeg_encoder::EncodingError| CodecError::encode(F, e);
if opts.embed_metadata {
if img.layout() != ChannelLayout::Cmyk
+1 -1
View File
@@ -40,7 +40,7 @@ pub use crate::image::{
ChannelLayout, CryptomatteBuffer, CryptomatteLayer, DecodeWarning, DeepChannel, DeepImage, ExrChannelInfo, ExrPartInfo, Image, Metadata, SampleType,
};
pub use crate::options::{
DdsCompression, DdsOptions, DecodeOptions, DpxBits, EncodeOptions, ExrCompression, Limits, PngCompression, PngStereo, TiffCompression,
DdsCompression, DdsOptions, DecodeOptions, DpxBits, EncodeOptions, ExrCompression, JpegFormat, Limits, PngCompression, PngStereo, TiffCompression,
};
pub use crate::orientation::{exif_orientation, upright_exif, upright_xmp};
pub use crate::resolution::{exif_resolution, export_exif, export_xmp, photoshop_resolution, xmp_resolution};
+34
View File
@@ -139,6 +139,37 @@ pub enum TiffCompression {
PackBits,
}
/// JPEG scan layout: Photoshop's JPEG Options › Format Options.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum JpegFormat {
/// Baseline ("Standard"): one sequential scan with the standard Huffman tables.
#[default]
Baseline,
/// Baseline Optimized: one sequential scan with Huffman tables fitted to the image. Only at
/// 4:4:4 for now: a 4:2:0 (subsampled RGB) baseline scan keeps the standard tables, because
/// fitted tables make jpeg-encoder write one scan per component, which zune-jpeg 0.5.15 (our
/// decoder) reads back wrongly when chroma is subsampled (see `web::encode_jpeg_rgb8`).
Optimized,
/// Progressive: the image refines over several scans as it loads.
Progressive,
}
impl JpegFormat {
/// `"baseline"`, `"optimized"` or `"progressive"`, as parameters and scripts spell it.
pub fn name(self) -> &'static str {
match self {
JpegFormat::Baseline => "baseline",
JpegFormat::Optimized => "optimized",
JpegFormat::Progressive => "progressive",
}
}
/// The inverse of [`Self::name`].
pub fn parse(s: &str) -> Option<Self> {
[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,
+42
View File
@@ -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);
+38 -11
View File
@@ -296,12 +296,14 @@ pub(crate) fn import(name: &str, bytes: &[u8]) -> Result<Document> {
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<f64>,
/// JPEG Options › Format Options (`"jpegFormat"`); unset, Baseline ("Standard").
pub jpeg_format: Option<photocraft_codecs::JpegFormat>,
/// 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<Self> {
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<Option<photocraft_io::TgaB
}
}
/// The JPEG format in `p` (`"jpegFormat": "baseline" | "optimized" | "progressive"`; absent or
/// null: Baseline). Anything else is a bad parameter of command `cmd`. Shared like
/// [`tga_bits_param`].
pub fn jpeg_format_param(p: &Value, cmd: &str) -> Result<Option<photocraft_codecs::JpegFormat>> {
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<Option<photocraft_io::DpxBits>> {
@@ -433,12 +457,15 @@ pub(crate) fn encode(doc: &Document, path: &str, save: impl Into<SaveOpts>) -> 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<CommandSpec> {
"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<CommandSpec> {
"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<CommandSpec> {
"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<CommandSpec> {
"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
),
+51
View File
@@ -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]
+1
View File
@@ -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;
+48
View File
@@ -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<RefCell<Vec<(Option<u8>, JpegFormat)>>>;
let asked: Asked = Rc::default();
let written: Rc<RefCell<Vec<String>>> = 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]
+1 -1
View File
@@ -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 });
+3
View File
@@ -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<egui::ViewportCommand> {
let mut info = egui::ViewportInfo::default();
@@ -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<RefCell<Vec<(String, Vec<u8>)>>>;
/// The JSON a write records.
fn asked(quality: Option<u8>, format: JpegFormat) -> Vec<u8> {
serde_json::to_vec(&(quality, format.name())).unwrap()
}
/// The encoder quality for Photoshop quality `q`.
fn pct(q: u8) -> Option<u8> {
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"}))]);
}
+6 -3
View File
@@ -20,7 +20,7 @@ pub fn owns(fields: &Map<String, Value>) -> 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<Result<Value, String>> {
pub(crate) fn intercept(app: &mut PhotocraftApp, doc: DocId, path: &str, flat: bool, choices: crate::FormatChoices) -> Option<Result<Value, String>> {
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<String, Value>) -> Result<V
let doc = fields.get("document").and_then(Value::as_u64).map(DocId).ok_or(tl!("The document is closed"))?;
let path = fields.get("path").and_then(Value::as_str).unwrap_or_default().to_string();
let flat = fields.get("flat").and_then(Value::as_bool) == Some(true);
let choices = crate::FormatChoices::from_step(&Value::Object(fields.clone()), "file.save")?;
app.with_document(doc, |app| {
if flat {
let r = app.save_to(path.clone(), crate::FormatChoices::default())?;
let r = app.save_to(path.clone(), choices)?;
app.journal_as_plain_save(&path);
Ok(r)
} else {
+116 -3
View File
@@ -143,18 +143,86 @@ fn layered_save_respects_explicit_jpeg_choices() {
app.poll_file_dialog(&ctx, None);
answer(&open, Some(FileDialogAnswer::SaveTo("/exports/chosen.JPEG".into())));
app.poll_file_dialog(&ctx, None);
// JPEG Options asks first (FILE-215-19, #5563). The JPEG can't hold the layers, so it is a
// copy and the document keeps its file (#2550).
assert_eq!(app.jpeg_options.as_ref().map(|p| (p.path.as_str(), p.copy)), Some(("/exports/chosen.jpeg", true)));
assert!(written.borrow().is_empty());
crate::jpeg_options_ui::confirm(&mut app, &ctx);
assert_eq!(*written.borrow(), ["/exports/chosen.jpeg"]);
// The JPEG can't hold the layers, so it is a copy and the document keeps its file (#2550).
assert_eq!(app.session.active().unwrap().path.as_deref(), Some("/pics/My image.JPEG"));
// Explicit command paths carry the same choice and bypass the picker.
// Explicit command paths carry the same choice and bypass the picker: without a JPEG answer
// JPEG Options still asks, and a `quality` answers it.
for command in ["file.save", "file.saveAs"] {
let (mut app, open, written) = jpeg_app(Some("/pics/My image.JPEG"), true);
menus::invoke(&mut app, &ctx, command, json!({"path": "/exports/chosen.jpg"})).unwrap();
assert!(!app.file_dialog_open() && open.borrow().is_empty());
assert!(app.jpeg_options.as_ref().is_some_and(|p| p.copy), "{command}");
crate::jpeg_options_ui::confirm(&mut app, &ctx);
assert_eq!(*written.borrow(), ["/exports/chosen.jpg"]);
let (mut app, open, written) = jpeg_app(Some("/pics/My image.JPEG"), true);
menus::invoke(&mut app, &ctx, command, json!({"path": "/exports/chosen.jpg", "quality": 8})).unwrap();
assert!(!app.file_dialog_open() && open.borrow().is_empty() && app.jpeg_options.is_none(), "{command}");
assert_eq!(*written.borrow(), ["/exports/chosen.jpg"]);
assert_eq!(app.session.active().unwrap().path.as_deref(), Some("/pics/My image.JPEG"));
}
}
/// File › Save on a flat JPEG asks for its name; confirming its own file asks JPEG Options and,
/// once answered, records a plain Save that writes the file an action is played on (#3531).
#[test]
fn plain_save_of_a_flat_jpeg_asks_jpeg_options_and_records_a_plain_save() {
let ctx = egui::Context::default();
let (mut app, open, written) = jpeg_app(Some("/pics/photo.jpg"), false);
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);
assert_eq!(app.jpeg_options.as_ref().map(|p| (p.record, p.copy)), Some(("file.save", false)));
assert!(written.borrow().is_empty());
crate::jpeg_options_ui::confirm(&mut app, &ctx);
app.run("actions.stop", json!({})).unwrap();
assert_eq!(*written.borrow(), ["/pics/photo.jpg"]);
assert_eq!(app.session.actions.list[0].steps, [("file.save".to_string(), json!({}))]);
}
/// A plain Save confirmed in the dialog keeps a changed JPEG Options answer in its recorded step,
/// and playing it writes the active flat JPEG with that answer, without asking.
#[test]
fn plain_save_of_a_flat_jpeg_records_and_replays_a_changed_quality() {
let ctx = egui::Context::default();
let (mut app, open, written) = jpeg_app(Some("/pics/photo.jpg"), false);
let qualities: Rc<RefCell<Vec<Option<u8>>>> = 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<FileDialogAnswer>) {
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<RefCell<Vec<Option<u8>>>> = 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"}))));
}
+5
View File
@@ -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.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -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.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -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. Η συμπερίληψη επιπέδων θα αυξήσει το μέγεθος του αρχείου.
Can't render this file because it contains an unexpected character in line 1030 and column 18.
+5
View File
@@ -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.
Can't render this file because it contains an unexpected character in line 4 and column 47.
+5
View File
@@ -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.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -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}.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -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.
Can't render this file because it contains an unexpected character in line 4 and column 47.
+5
View File
@@ -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.
Can't render this file because it contains an unexpected character in line 2387 and column 12.
+5
View File
@@ -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. レイヤーを含めるとファイルサイズが大きくなります。
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -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. 레이어를 포함하면 파일 크기가 커집니다.
Can't render this file because it contains an unexpected character in line 2912 and column 12.
+5
View File
@@ -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.
Can't render this file because it contains an unexpected character in line 8 and column 34.
+5
View File
@@ -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.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -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.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -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. Включение слоёв увеличит размер файла.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -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.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -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.
Can't render this file because it contains an unexpected character in line 8 and column 34.
+5
View File
@@ -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. Додавання шарів збільшить розмір файлу.
Can't render this file because it contains an unexpected character in line 1394 and column 18.
+5
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -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. 包含图层会增加文件大小。
Can't render this file because it contains an unexpected character in line 2335 and column 12.
+5
View File
@@ -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. 包含圖層會增加檔案大小。
Can't render this file because it contains an unexpected character in line 6 and column 47.
+350
View File
@@ -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<u8>,
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<Option<JpegChoice>, 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<JpegChoice>) -> 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<Option<JpegChoice>, 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<u8>,
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<u8>,
/// 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");
}
}
+135 -63
View File
@@ -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<photocraft_io::TgaBits>,
pub dds: Option<photocraft_io::DdsOptions>,
pub jpeg: Option<jpeg_options_ui::JpegChoice>,
}
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<Self, String> {
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<Self, String> {
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<dyn Fn(&str, &[u8], &photocraft_io::ImportOptions) -> Result<(Document, Vec<String>), String>>;
/// Encoder settings chosen in Export As (the file format comes from the name's extension).
@@ -268,6 +326,8 @@ pub type ImportFn = Box<dyn Fn(&str, &[u8], &photocraft_io::ImportOptions) -> Re
pub struct ExportSettings {
/// JPEG quality 1–100 (None = codec default).
pub jpeg_quality: Option<u8>,
/// JPEG 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<photocraft_io::TgaBits>,
pub dds: Option<photocraft_io::DdsOptions>,
}
impl FormatChoices {
/// The answers in command `cmd`'s params; a bad value is an error.
pub fn from_params(params: &Value, cmd: &str) -> Result<Self, String> {
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<photocraft_doc::DocId, (String, FileStamp)>,
/// A save to an RGB Targa parked behind the Targa Options prompt (see `targa_options_ui`).
pub(crate) targa_options: Option<targa_options_ui::Prompt>,
/// A save to a JPEG parked behind the JPEG Options prompt (see `jpeg_options_ui`).
pub(crate) jpeg_options: Option<jpeg_options_ui::Prompt>,
/// A save to a DDS texture parked behind the DDS Options prompt (see `dds_options_ui`).
pub(crate) dds_options: Option<dds_options_ui::Prompt>,
/// 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<String>, choices: FormatChoices) -> Result<Value, String> {
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<String>, choices: FormatChoices, plain_save: bool) -> Result<Value, String> {
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<Value, String> {
// 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<Value, String> {
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<bool>, choices: FormatChoices) -> Result<Value, String> {
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<Value, String> {
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<bool>, choices: FormatChoices) -> Result<Value, String> {
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<String>, choices: FormatChoices) -> Result<Value, String> {
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);
+1 -1
View File
@@ -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) => {
+12 -2
View File
@@ -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);
+3 -15
View File
@@ -47,7 +47,7 @@ pub fn bits_param(params: &serde_json::Value, cmd: &str) -> Result<Option<TgaBit
/// A prompt already waiting is never replaced (a shortcut can still reach Save a Copy).
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()?;
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();
+8 -3
View File
@@ -1486,8 +1486,8 @@ fn save_a_copy(app: &mut PhotocraftApp) -> Result<Value, String> {
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<Value, String> {
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<Value, String> {
}
/// 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<Value, String> {
let settings = choices.apply(app.save_settings());
let export = app.services.export.as_ref().ok_or("no exporter configured")?;
+1 -1
View File
@@ -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
+3 -2
View File
@@ -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) |
+3 -2
View File
@@ -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 |
+2 -2
View File
@@ -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) |
+3 -2
View File
@@ -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 |
+2 -2
View File
@@ -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"