mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-02 18:13:39 +00:00
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:
@@ -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"))?;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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"}))]);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"}))));
|
||||
}
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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")?;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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) |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user