feat: resize Save for Web previews and custom palette editing for Gif export (#4129)

* feat: resize Save for Web previews and added palette editing for Gif/PNG8

* Save for Web: add Turkish and Hungarian rows for the preview navigation hint

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-12 20:50:00 -04:00
1 parent 137d7c2df1
commit e627b01169
27 files changed
+908 -179

No files matched your search

@@ -0,0 +1,30 @@
//! Compare generated-palette and edited fixed-palette GIF exports on a 24 MP image.
//! cargo run --release -p photocraft-engine --example bench_web_palette
use std::time::Instant;
use photocraft_engine::web_cmds::{WebSettings, optimize};
use photocraft_geom::Rect;
use serde_json::json;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let (w, h) = (6000usize, 4000usize);
let mut pixels = Vec::new();
pixels.try_reserve_exact(w * h)?;
for y in 0..h {
for x in 0..w {
pixels.push([x as f32 / w as f32, 0.5 + ((x + y) as f32 * 0.002).sin() * 0.3, y as f32 / h as f32, 1.0]);
}
}
let settings = WebSettings::from_params(&json!({"format": "gif", "colors": 128, "dither": "diffusion"}), "bench")?;
let rect = Rect::new(0, 0, w as i32, h as i32);
let started = Instant::now();
let original = optimize(&pixels, w, rect, &settings, None, None, 72.0, false)?;
println!("24 MP generated palette: {:.1} ms", started.elapsed().as_secs_f64() * 1000.0);
let mut edited = settings;
edited.color_table = Some(original.color_table);
edited.color_table_edits.insert(0, [0, 255, 0]);
let started = Instant::now();
let result = optimize(&pixels, w, rect, &edited, None, None, 72.0, false)?;
println!("24 MP edited fixed palette: {:.1} ms ({} bytes)", started.elapsed().as_secs_f64() * 1000.0, result.bytes.len());
Ok(())
}
+87 -20
View File
@@ -79,6 +79,10 @@ pub struct WebSettings {
pub format: WebFormat,
pub palette: PaletteKind,
pub colors: usize,
/// Fixed opaque source palette in index order.
pub color_table: Option<Vec<[u8; 3]>>,
/// Replacements applied after quantization, preserving pixel indices.
pub color_table_edits: std::collections::BTreeMap<usize, [u8; 3]>,
pub dither: Dither,
/// 0..=1.
pub dither_amount: f32,
@@ -104,6 +108,8 @@ impl Default for WebSettings {
format: WebFormat::Png24,
palette: PaletteKind::Selective,
colors: 256,
color_table: None,
color_table_edits: Default::default(),
dither: Dither::Diffusion,
dither_amount: 0.88,
transparency: true,
@@ -196,6 +202,27 @@ impl WebSettings {
m => return Err(bad(cmd, format!("unknown metadata `{m}` (none|copyright|copyrightAndContact|all)"))),
};
}
if let Some(table) = p.get("colorTable") {
let table = table.as_array().filter(|a| (1..=256).contains(&a.len())).ok_or_else(|| bad(cmd, "colorTable must contain 1–256 #rrggbb colours"))?;
st.color_table = Some(
table
.iter()
.map(|v| v.as_str().and_then(hex3).map(|c| c.map(to8)).ok_or_else(|| bad(cmd, "colorTable entries must be #rrggbb colours")))
.collect::<Result<Vec<_>>>()?,
);
}
if let Some(edits) = p.get("colorTableEdits") {
let edits = edits
.as_object()
.filter(|m| m.len() <= 256)
.ok_or_else(|| bad(cmd, "colorTableEdits must be an object of palette indices to #rrggbb colours"))?;
let table = st.color_table.as_ref().ok_or_else(|| bad(cmd, "colorTableEdits requires colorTable"))?;
for (index, value) in edits {
let index = index.parse::<usize>().ok().filter(|i| *i < table.len()).ok_or_else(|| bad(cmd, "colorTableEdits index is outside colorTable"))?;
let color = value.as_str().and_then(hex3).map(|c| c.map(to8)).ok_or_else(|| bad(cmd, "colorTableEdits values must be #rrggbb colours"))?;
st.color_table_edits.insert(index, color);
}
}
Ok(st)
}
}
@@ -233,10 +260,23 @@ impl WebSettings {
WebMetadata::All => "all",
};
let matte = self.matte.map_or("none".to_string(), |c| format!("#{:02x}{:02x}{:02x}", to8(c[0]), to8(c[1]), to8(c[2])));
json!({"format": format, "palette": palette, "colors": self.colors, "dither": dither, "ditherAmount": (self.dither_amount * 100.0).round(), "transparency": self.transparency, "matte": matte, "interlaced": self.interlaced, "webSnap": (self.web_snap * 100.0).round(), "quality": self.quality, "progressive": self.progressive, "optimized": self.optimized, "embedIcc": self.embed_icc, "metadata": metadata, "convertToSrgb": self.convert_to_srgb})
let mut p = json!({"format": format, "palette": palette, "colors": self.colors, "dither": dither, "ditherAmount": (self.dither_amount * 100.0).round(), "transparency": self.transparency, "matte": matte, "interlaced": self.interlaced, "webSnap": (self.web_snap * 100.0).round(), "quality": self.quality, "progressive": self.progressive, "optimized": self.optimized, "embedIcc": self.embed_icc, "metadata": metadata, "convertToSrgb": self.convert_to_srgb});
if let Some(table) = &self.color_table {
p["colorTable"] = json!(table.iter().map(|c| rgb_hex(*c)).collect::<Vec<_>>());
}
if !self.color_table_edits.is_empty() {
p["colorTableEdits"] =
json!(self.color_table_edits.iter().map(|(i, c)| (i.to_string(), rgb_hex(*c))).collect::<std::collections::BTreeMap<_, _>>());
}
p
}
}
/// A palette colour in command/control-channel notation.
pub fn rgb_hex(c: [u8; 3]) -> String {
format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2])
}
/// Save for Web's named presets.
pub const PRESETS: [&str; 12] = [
"GIF 128 Dithered",
@@ -324,6 +364,11 @@ pub struct Optimized {
pub height: u32,
/// Colours in the palette (indexed formats).
pub colors: Option<usize>,
/// Opaque source palette before replacements (empty for non-indexed formats).
pub color_table: Vec<[u8; 3]>,
pub transparent_index: Option<usize>,
/// Indexed pixels for previews; empty for non-indexed formats or when preview is disabled.
pub indices: Vec<u8>,
/// What the optimised file looks like, straight RGBA8 (for the dialog's previews).
pub preview: Vec<u8>,
}
@@ -359,13 +404,14 @@ pub fn web_xmp(doc: &Document, m: WebMetadata) -> Option<String> {
/// the file looks like (the dialog's panes); exports skip it.
#[allow(clippy::too_many_arguments)]
pub fn optimize(px: &[[f32; 4]], bw: usize, rect: Rect, st: &WebSettings, icc: Option<&[u8]>, xmp: Option<&str>, dpi: f32, preview: bool) -> Result<Optimized> {
let (w, h) = (rect.width() as usize, rect.height() as usize);
if w == 0 || h == 0 {
return Err(other("empty slice"));
if bw == 0 || rect.x0 < 0 || rect.y0 < 0 || rect.is_empty() || rect.x1 as usize > bw || (rect.y1 as usize).checked_mul(bw).is_none_or(|end| end > px.len())
{
return Err(other("slice is outside the pixel buffer"));
}
let (w, h) = (rect.width() as usize, rect.height() as usize);
let row = |y: i32| {
let o = y as usize * bw;
&px[o + rect.x0 as usize..o + rect.x1 as usize]
px.get(o + rect.x0 as usize..o + rect.x1 as usize).unwrap_or(&[])
};
let region = || (rect.y0..rect.y1).flat_map(row);
let matte = st.matte.unwrap_or([1.0, 1.0, 1.0]);
@@ -375,6 +421,9 @@ pub fn optimize(px: &[[f32; 4]], bw: usize, rect: Rect, st: &WebSettings, icc: O
let (ww, hh) = (w as u32, h as u32);
let e = |e: photocraft_codecs::CodecError| other(e);
let mut look: Vec<u8> = Vec::new();
let mut color_table = Vec::new();
let mut transparent_index = None;
let mut indices = Vec::new();
let (bytes, colors) = match st.format {
WebFormat::Png24 => {
let alpha = st.transparency && region().any(|p| p[3] < 1.0);
@@ -451,16 +500,22 @@ pub fn optimize(px: &[[f32; 4]], bw: usize, rect: Rect, st: &WebSettings, icc: O
})
.collect();
let room = if transparent { st.colors.saturating_sub(1).max(2) } else { st.colors };
// Large images: build adaptive palettes from an even ~1 MP sample (Exact needs all).
let n_opaque = work.iter().filter(|p| p[3] >= 0.5).count();
let step = if n_opaque > 1 << 20 && st.palette != PaletteKind::Exact { n_opaque / (1 << 20) + 1 } else { 1 };
let mut src: Vec<[f32; 4]> = work.iter().filter(|p| p[3] >= 0.5).step_by(step).copied().collect();
if src.is_empty() {
src = work.clone();
}
let mut pal = quantize::build_palette(&src, st.palette, room, Forced::None).map_err(other)?;
drop(src);
if st.web_snap > 0.0 {
let mut pal = if let Some(table) = &st.color_table {
if table.is_empty() || table.len() > if transparent { 255 } else { 256 } {
return Err(other("colorTable has too many entries for this image's transparency"));
}
table.clone()
} else {
// Large images: build adaptive palettes from an even ~1 MP sample (Exact needs all).
let n_opaque = work.iter().filter(|p| p[3] >= 0.5).count();
let step = if n_opaque > 1 << 20 && st.palette != PaletteKind::Exact { n_opaque / (1 << 20) + 1 } else { 1 };
let mut src: Vec<[f32; 4]> = work.iter().filter(|p| p[3] >= 0.5).step_by(step).copied().collect();
if src.is_empty() {
src = work.clone();
}
quantize::build_palette(&src, st.palette, room, Forced::None).map_err(other)?
};
if st.web_snap > 0.0 && st.color_table.is_none() {
let tol = (st.web_snap * 255.0) as u8;
for c in &mut pal {
let snap = c.map(|x| ((f32::from(x) / 51.0).round() * 51.0) as u8);
@@ -472,16 +527,23 @@ pub fn optimize(px: &[[f32; 4]], bw: usize, rect: Rect, st: &WebSettings, icc: O
if transparent {
pal.truncate(255);
}
let first = pal.first().copied().ok_or_else(|| other("empty colour table"))?;
color_table = pal.clone();
let t = transparent.then(|| {
pal.push(pal[0]);
pal.push(first);
pal.len() - 1
});
transparent_index = t;
let idx = quantize_rows(&mut work, w, &pal, st.dither, st.dither_amount, t);
for (index, color) in &st.color_table_edits {
let slot = pal.get_mut(*index).filter(|_| Some(*index) != t).ok_or_else(|| other("colour edit is outside the opaque palette"))?;
*slot = *color;
}
if preview {
look = idx
.iter()
.flat_map(|k| {
let c = pal[*k as usize];
let c = pal.get(*k as usize).copied().unwrap_or(first);
[c[0], c[1], c[2], if Some(*k as usize) == t { 0 } else { 255 }]
})
.collect();
@@ -492,10 +554,13 @@ pub fn optimize(px: &[[f32; 4]], bw: usize, rect: Rect, st: &WebSettings, icc: O
} else {
photocraft_codecs::encode_png_indexed(ww, hh, &idx, &pal, tt, None).map_err(e)?
};
if preview {
indices = idx;
}
(bytes, Some(pal.len()))
}
};
Ok(Optimized { bytes, ext: st.format.ext(), width: ww, height: hh, colors, preview: look })
Ok(Optimized { bytes, ext: st.format.ext(), width: ww, height: hh, colors, color_table, transparent_index, indices, preview: look })
}
/// Nearest palette entry through a lazily filled 6-bit-per-channel inverse colour map: each
@@ -811,7 +876,9 @@ fn save_for_web_impl(s: &mut Session, p: &Value, write: &mut dyn FnMut(&str, &[u
// Estimate only (the dialog's annotations): the whole image, or each chosen slice.
if single.is_none() && dir.is_none() {
let o = optimize(&buf.px, bw, wdoc.bounds(), &st, icc, xmp.as_deref(), dpi, false)?;
return Ok(json!({"format": o.ext, "bytes": o.bytes.len(), "width": o.width, "height": o.height, "colors": o.colors}));
return Ok(
json!({"format": o.ext, "bytes": o.bytes.len(), "width": o.width, "height": o.height, "colors": o.colors, "colorTable": o.color_table.iter().map(|c| rgb_hex(*c)).collect::<Vec<_>>(), "transparentIndex": o.transparent_index}),
);
}
if let Some(path) = single.filter(|_| dir.is_none() && (all.len() == 1 || p.get("slices").is_none())) {
let o = optimize(&buf.px, bw, wdoc.bounds(), &st, icc, xmp.as_deref(), dpi, false)?;
@@ -1378,7 +1445,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Save for Web (Legacy)…",
&["File", "Export"],
Some("Cmd+Alt+Shift+S"),
r##"{"preset":"GIF 128 Dithered|JPEG High|PNG-24|…"?,"format":"gif|png8|png24|jpeg|wbmp"="png24","palette":"perceptual|selective|adaptive|restrictive|exact|systemMac|systemWindows|uniform"="selective","colors":2..256=256,"dither":"none|diffusion|pattern|noise"="diffusion","ditherAmount":0..100=88,"transparency":bool=true,"matte":"#rrggbb|none"="#ffffff","interlaced":bool=false,"webSnap":0..100=0,"quality":0..100=60,"progressive":bool=false,"optimized":bool=true,"embedIcc":bool=false,"metadata":"none|copyright|copyrightAndContact|all"="copyright","convertToSrgb":bool=true,"width"|"height"|"percent"? (image size),"resample":"bicubic|bilinear|nearest"?,"path":file? (whole image),"dir":folder? (one file per slice in images/),"imagesFolder":relative-folder?="images","html":bool=false,"trustedSliceHtml":bool=false (only for trusted documents; emit raw no-image cell HTML),"slices":"all|user"="all","numbers":[n]?} → no path/dir: {bytes,width,height,colors} estimate; else {files, html, bytes}"##,
r##"{"preset":"GIF 128 Dithered|JPEG High|PNG-24|…"?,"format":"gif|png8|png24|jpeg|wbmp"="png24","palette":"perceptual|selective|adaptive|restrictive|exact|systemMac|systemWindows|uniform"="selective","colors":2..256=256,"colorTable":["#rrggbb",…]? (fixed opaque source palette, 1–256 entries; 255 with transparency),"colorTableEdits":{"0":"#rrggbb",…}? (replace entries after indexing; requires colorTable),"dither":"none|diffusion|pattern|noise"="diffusion","ditherAmount":0..100=88,"transparency":bool=true,"matte":"#rrggbb|none"="#ffffff","interlaced":bool=false,"webSnap":0..100=0,"quality":0..100=60,"progressive":bool=false,"optimized":bool=true,"embedIcc":bool=false,"metadata":"none|copyright|copyrightAndContact|all"="copyright","convertToSrgb":bool=true,"width"|"height"|"percent"? (image size),"resample":"bicubic|bilinear|nearest"?,"path":file? (whole image),"dir":folder? (one file per slice in images/),"imagesFolder":relative-folder?="images","html":bool=false,"trustedSliceHtml":bool=false (only for trusted documents; emit raw no-image cell HTML),"slices":"all|user"="all","numbers":[n]?} → no path/dir: {bytes,width,height,colors,colorTable,transparentIndex} estimate; else {files, html, bytes}"##,
has_doc,
save_for_web
),
@@ -0,0 +1,88 @@
use super::*;
#[test]
fn color_replacement_preserves_pixel_membership_and_transparency() {
let px = vec![[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0], [0.6, 0.0, 0.4, 1.0], [0.0, 0.0, 0.0, 0.0]];
for format in ["gif", "png8"] {
for dither in ["none", "diffusion", "pattern", "noise"] {
let p = json!({"format": format, "colorTable": ["#ff0000", "#0000ff"], "dither": dither, "matte": "none"});
let original = WebSettings::from_params(&p, "test").unwrap();
let before = optimize(&px, 4, Rect::new(0, 0, 4, 1), &original, None, None, 72.0, true).unwrap();
let mut edited = p.clone();
// Replace red with blue: re-quantization would now send all opaque pixels to blue.
edited["colorTableEdits"] = json!({"0": "#0000ff", "1": "#00ff00"});
let settings = WebSettings::from_params(&edited, "test").unwrap();
assert_eq!(WebSettings::from_params(&settings.to_params(), "test").unwrap(), settings);
let after = optimize(&px, 4, Rect::new(0, 0, 4, 1), &settings, None, None, 72.0, true).unwrap();
for (old, new) in before.preview.as_chunks::<4>().0.iter().zip(after.preview.as_chunks::<4>().0) {
if old[3] == 0 {
assert_eq!(new[3], 0);
} else if old[..3] == [255, 0, 0] {
assert_eq!(*new, [0, 0, 255, 255]);
} else {
assert_eq!(*new, [0, 255, 0, 255]);
}
}
assert_eq!(before.indices, after.indices, "palette edits must not remap pixels");
assert_eq!(after.transparent_index, Some(2));
assert_eq!(after.color_table, vec![[255, 0, 0], [0, 0, 255]]);
let decoded = photocraft_codecs::decode(&after.bytes).unwrap().convert(photocraft_codecs::ChannelLayout::Rgba, photocraft_codecs::SampleType::U8);
// Transparent RGB has no significance; opaque preview pixels must equal the file.
for (file, preview) in decoded.data().as_chunks::<4>().0.iter().zip(after.preview.as_chunks::<4>().0) {
assert_eq!(file[3], preview[3]);
if preview[3] != 0 {
assert_eq!(file, preview);
}
}
}
}
}
#[test]
fn exported_palette_edits_work_at_every_document_depth() {
let dir = tmp("table-edits");
for depth in [8, 16, 32] {
let mut s = session(depth);
let mut p = json!({"format": "gif", "colors": 16, "dither": "none"});
let estimate = s.execute("file.export.saveForWebLegacy", p.clone()).unwrap();
let table = estimate["colorTable"].as_array().unwrap();
let red = table.iter().position(|c| c == "#ff0000").unwrap();
p["colorTable"] = estimate["colorTable"].clone();
p["colorTableEdits"] = json!({red.to_string(): "#00ff00"});
let path = format!("{dir}/{depth}.gif");
p["path"] = json!(path);
let original_doc = s.active().unwrap().doc.clone();
s.execute("file.export.saveForWebLegacy", p).unwrap();
assert_eq!(s.active().unwrap().doc, original_doc, "export must not edit the document");
let img = decode(&path).convert(photocraft_codecs::ChannelLayout::Rgba, photocraft_codecs::SampleType::U8);
assert_eq!(&img.data()[(16 * 64 + 16) * 4..(16 * 64 + 16) * 4 + 4], &[0, 255, 0, 255]);
assert_eq!(img.data()[3], 0);
}
}
#[test]
fn malformed_tables_and_out_of_bounds_slices_return_errors() {
for p in [
json!({"colorTable": []}),
json!({"colorTable": "bad"}),
json!({"colorTable": ["#ff00é"]}),
json!({"colorTable": vec!["#000000"; 257]}),
json!({"colorTable": [123]}),
json!({"colorTableEdits": {"0": "#ffffff"}}),
json!({"colorTable": ["#ff0000"], "colorTableEdits": {"-1": "#ffffff"}}),
json!({"colorTable": ["#ff0000"], "colorTableEdits": {"1": "#ffffff"}}),
json!({"colorTable": ["#ff0000"], "colorTableEdits": []}),
] {
assert!(WebSettings::from_params(&p, "test").is_err(), "{p}");
let mut s = session(8);
let doc = s.active().unwrap().doc.clone();
assert!(s.execute("file.export.saveForWebLegacy", p).is_err());
assert_eq!(s.active().unwrap().doc, doc);
}
let settings = WebSettings::default();
for (bw, rect) in [(0, Rect::new(0, 0, 1, 1)), (1, Rect::new(-1, 0, 1, 1)), (1, Rect::new(0, 0, 2, 1)), (1, Rect::new(0, 0, 1, i32::MAX))] {
assert!(optimize(&[[0.0; 4]], bw, rect, &settings, None, None, 72.0, false).is_err());
}
let settings = WebSettings::from_params(&json!({"format": "gif", "colorTable": vec!["#000000"; 256]}), "test").unwrap();
assert!(optimize(&[[0.0; 4]], 1, Rect::new(0, 0, 1, 1), &settings, None, None, 72.0, true).is_err());
}
+3
View File
@@ -656,3 +656,6 @@ fn overlapping_slices_keep_the_later_slice_in_html() {
assert_eq!(area, 64 * 48);
assert!(files.iter().any(|f| f.ends_with("images/under_01.png")), "the covered slice is cut into pieces: {files:?}");
}
#[path = "color_table_tests.rs"]
mod color_table_tests;
+43 -7
View File
@@ -99,6 +99,33 @@ fn older_dialog_above(ctx: &egui::Context, live: egui::LayerId, dialogs: &[Dialo
ctx.memory(|m| m.layer_ids().skip_while(|l| *l != live).skip(1).any(|l| others.contains(&l)))
}
fn show_modal<T>(
modal: egui::Modal,
ctx: &egui::Context,
id: egui::Id,
resizable: bool,
interactive: bool,
contents: impl FnOnce(&mut egui::Ui) -> T,
) -> egui::ModalResponse<T> {
modal.show(ctx, |ui| {
// Disable the entire older modal, including its resize grip, not just its body.
if !interactive {
ui.disable();
}
if resizable {
let max = (ctx.content_rect().size() - egui::vec2(40.0, 40.0)).max(egui::vec2(560.0, 400.0));
egui::Resize::default()
.id(id.with("resize"))
.default_size(egui::vec2(960.0, 900.0).min(max))
.min_size(egui::vec2(560.0, 400.0))
.max_size(max)
.show(ui, contents)
} else {
contents(ui)
}
})
}
pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
crate::layer_style::color_picker::prune(app);
crate::type_panels_ui::color_picker::prune(app);
@@ -147,11 +174,11 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
// Tab / ⇧Tab walk the topmost dialog's text fields, not every widget (field_tab.rs).
let tab = if top == Some(d.id) && !egui::Popup::is_any_open(ctx) { crate::field_tab::take_step(ctx) } else { 0 };
// Photoshop doesn't dim the window behind dialogs: previews must be judged at true contrast.
let modal = modal.show(ctx, |ui| {
if !interactive {
ui.disable();
}
let web = fields.contains_key("__web");
let web_popup_open = web && egui::Popup::is_any_open(ctx);
let modal = show_modal(modal, ctx, id, web, interactive, |ui| {
sizing = ui.is_sizing_pass();
let available = ui.available_size();
ui.set_min_width(380.0);
let wide = crate::prefs_ui::width(&d.fields, (ctx.content_rect().width() - 48.0).max(380.0));
if let Some(w) = wide {
@@ -174,7 +201,10 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
} else {
440.0
}));
if let Some(w) = crate::file_ui::dialog_width(&d.fields) {
if web {
ui.set_max_width(available.x);
ui.set_min_size(available);
} else if let Some(w) = crate::file_ui::dialog_width(&d.fields) {
ui.set_min_width(w);
ui.set_max_width(w);
}
@@ -358,7 +388,13 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
Some(ButtonRole::Cancel) => outcome = Some(false),
Some(ButtonRole::Apply) => apply_requested = true,
Some(_) => outcome = Some(true),
None if interactive && can_ok && ui.input(|i| i.key_pressed(egui::Key::Enter)) => outcome = Some(true),
None if interactive
&& can_ok
&& (!web || (!web_popup_open && !egui::Popup::is_any_open(ui.ctx())))
&& ui.input(|i| i.key_pressed(egui::Key::Enter)) =>
{
outcome = Some(true)
}
None => {}
}
}
@@ -370,7 +406,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
// The picker has a larger frame margin; include that whole frame in the canvas hit guard.
shown.push(if color_picker { modal.response.rect } else { modal.inner });
// Pin once laid out at its real size (the first frame is an invisible sizing pass).
if !sizing && (pinned.is_none() || drag != egui::Vec2::ZERO) {
if !sizing && (web || pinned.is_none() || drag != egui::Vec2::ZERO) {
let screen = ctx.content_rect();
// Keep the whole dialog (and so its title bar) on screen.
let room = (screen.size() - modal.response.rect.size()).max(egui::Vec2::ZERO);
+314 -152
View File
@@ -180,6 +180,9 @@ fn b(f: &Map<String, Value>, k: &str, d: bool) -> bool {
// ---------- Save for Web ----------
#[path = "file_ui/web_preview.rs"]
mod web_preview;
const WEB_FORMATS: [(&str, &str); 5] = [("gif", "GIF"), ("png8", "PNG-8"), ("png24", "PNG-24"), ("jpeg", "JPEG"), ("wbmp", "WBMP")];
const WEB_COMMAND: &str = "file.export.saveForWebLegacy";
@@ -193,7 +196,7 @@ pub fn open_web(app: &mut PhotocraftApp) -> u64 {
let last = app.session.file_menu.last_web.clone().unwrap_or_else(|| json!({"format": "jpeg", "quality": 60}));
if let Value::Object(m) = last {
for (k, v) in m {
if !matches!(k.as_str(), "path" | "dir" | "width" | "height" | "percent") {
if !matches!(k.as_str(), "path" | "dir" | "width" | "height" | "percent" | "colorTable" | "colorTableEdits") {
f.insert(k, v);
}
}
@@ -227,6 +230,10 @@ fn variants(p: &Value) -> Vec<Value> {
let fmt = p["format"].as_str().unwrap_or("jpeg");
for k in [2.0, 4.0] {
let mut v = p.clone();
if let Some(m) = v.as_object_mut() {
m.remove("colorTable");
m.remove("colorTableEdits");
}
if fmt == "jpeg" {
v["quality"] = json!((p["quality"].as_f64().unwrap_or(60.0) / k).round().max(1.0));
} else if fmt == "gif" || fmt == "png8" {
@@ -241,51 +248,65 @@ fn variants(p: &Value) -> Vec<Value> {
}
/// The flattened preview image: pixels, width, height, full-size / proxy pixel ratio.
type WebProxy = (Arc<Vec<[f32; 4]>>, u32, u32, f64);
type WebProxy = (Arc<Vec<[f32; 4]>>, u32, u32, f64, egui::Vec2);
type WebCache = (String, Arc<egui::TextureHandle>, Option<(usize, Option<usize>, &'static str)>);
/// Renders one preview pane: the original (`None`) or an optimised variant.
fn web_pane(app: &PhotocraftApp, ui: &mut egui::Ui, size: egui::Vec2, p: Option<&Value>, full_px: f64) {
fn web_pane(app: &PhotocraftApp, ui: &mut egui::Ui, size: egui::Vec2, p: Option<&Value>, f: &mut Map<String, Value>) {
let t = Tokens::get(ui.ctx());
let Some(st) = app.session.active() else { return };
let doc = st.doc.clone();
let rev = st.revision;
// The proxy: the export size, at most 640 px on the long side.
// A bounded proxy; navigation never recomposites or re-encodes it.
let base = p.cloned().unwrap_or_else(|| json!({}));
let size_sig = format!("{}|{}|{}", base["percent"], base["width"], base["height"]);
let wkey = egui::Id::new(("web-proxy", doc.id.0, rev, size_sig.clone()));
let proxy: Option<WebProxy> = ui.data(|d| d.get_temp(wkey));
let (px, w, h, ratio) = match proxy {
let size_sig = format!("{}|{}|{}|{}", base["percent"], base["width"], base["height"], base["convertToSrgb"]);
let wkey = egui::Id::new(("web-proxy", doc.id.0, p.is_some()));
let proxy_sig = format!("{rev}|{size_sig}");
let cached_proxy: Option<(String, WebProxy)> = ui.data(|d| d.get_temp(wkey));
let proxy = cached_proxy.filter(|(sig, _)| sig == &proxy_sig).map(|(_, proxy)| proxy);
let (px, w, h, ratio, source_size) = match proxy {
Some(v) => v,
None => {
let pct = base["percent"].as_f64().unwrap_or(100.0).max(1.0) / 100.0;
let (fw, fh) = (f64::from(doc.size.width) * pct, f64::from(doc.size.height) * pct);
let k = (640.0 / fw.max(fh)).min(1.0);
let st = WebSettings::default();
let (fw, fh) = match web_preview::dimensions(&doc, &base) {
Ok(size) => size,
Err(error) => {
ui.label(error);
return;
}
};
let k = (2048.0 / fw.max(fh)).min(1.0);
let st = WebSettings::from_params(&base, "preview").unwrap_or_default();
let Ok((wd, _, _)) =
web_cmds::web_document(&doc, &json!({"width": (fw * k).round().max(1.0), "height": (fh * k).round().max(1.0), "resample": "bilinear"}), &st)
else {
return;
};
let buf = photocraft_compose::flatten(&wd);
let v = (Arc::new(buf.px), wd.size.width, wd.size.height, (fw * fh) / (f64::from(wd.size.width) * f64::from(wd.size.height)).max(1.0));
ui.data_mut(|d| d.insert_temp(wkey, v.clone()));
let v = (
Arc::new(buf.px),
wd.size.width,
wd.size.height,
(fw * fh) / (f64::from(wd.size.width) * f64::from(wd.size.height)).max(1.0),
egui::vec2(fw as f32, fh as f32),
);
ui.data_mut(|d| d.insert_temp(wkey, (proxy_sig, v.clone())));
v
}
};
let sig = format!("{}{}", size_sig, p.map(|v| v.to_string()).unwrap_or_else(|| "original".into()));
let key = egui::Id::new(("web-pane", doc.id.0, rev, sig.clone()));
let sig = format!("{}|{rev}|{size_sig}|{}", doc.id.0, p.map(|v| v.to_string()).unwrap_or_else(|| "original".into()));
let key = ui.next_auto_id().with("web-pane");
let cached: Option<WebCache> = ui.data(|d| d.get_temp(key));
let (tex, info) = match cached.filter(|c| c.0 == sig) {
Some((_, tex, info)) => (tex, info),
None => {
let (rgba, info) = match p {
None => (px.iter().flat_map(|q| q.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)).collect::<Vec<u8>>(), None),
Some(p) => match WebSettings::from_params(p, "preview")
.and_then(|st| web_cmds::optimize(&px, w as usize, photocraft_geom::Rect::new(0, 0, w as i32, h as i32), &st, None, None, 72.0, true))
{
Ok(Optimized { preview, bytes, colors, ext, .. }) => (preview, Some(((bytes.len() as f64 * ratio) as usize, colors, ext))),
Err(_) => (vec![0; (w * h * 4) as usize], None),
Some(p) => match web_preview::optimized(ui, key, &format!("{}|{rev}|{size_sig}", doc.id.0), p, &px, w, h) {
Ok((preview, bytes, colors, ext)) => (preview, Some(((bytes as f64 * ratio) as usize, colors, ext))),
Err(error) => {
ui.label(error);
(vec![0; (w * h * 4) as usize], None)
}
},
};
let img = egui::ColorImage::from_rgba_unmultiplied([w as usize, h as usize], &rgba);
@@ -295,14 +316,15 @@ fn web_pane(app: &PhotocraftApp, ui: &mut egui::Ui, size: egui::Vec2, p: Option<
}
};
ui.vertical(|ui| {
let (r, _) = ui.allocate_exact_size(size - egui::vec2(0.0, 34.0), egui::Sense::hover());
ui.set_width(size.x);
let (r, response) = ui.allocate_exact_size((size - egui::vec2(0.0, 42.0)).max(egui::vec2(1.0, 1.0)), egui::Sense::drag());
let response = response.on_hover_cursor(egui::CursorIcon::Grab);
if let Some(rects) = f.get_mut("__previewRects").and_then(Value::as_array_mut) {
rects.push(json!([r.left(), r.top(), r.right(), r.bottom()]));
}
crate::widgets::checker(ui.painter(), r, 8.0);
let ts = tex.size_vec2();
let k = (r.width() / ts.x).min(r.height() / ts.y).min(1.0);
let ir = egui::Rect::from_center_size(r.center(), ts * k);
ui.painter().image(tex.id(), ir, egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), egui::Color32::WHITE);
ui.painter().rect_stroke(r, 0.0, egui::Stroke::new(1.0, t.separator), egui::StrokeKind::Outside);
let _ = full_px;
web_preview::paint(ui, r, &response, &tex, source_size, f);
ui.painter().rect_stroke(r, 0.0, egui::Stroke::new(1.0, t.separator), egui::StrokeKind::Inside);
let (l1, l2) = match (p, info) {
(None, _) => ("Original".to_string(), format!("{} · {} × {} px", doc.name, doc.size.width, doc.size.height)),
(Some(_), Some((bytes, colors, ext))) => {
@@ -315,8 +337,9 @@ fn web_pane(app: &PhotocraftApp, ui: &mut egui::Ui, size: egui::Vec2, p: Option<
}
(Some(_), None) => ("—".into(), String::new()),
};
ui.label(egui::RichText::new(l1).color(t.text).size(11.5));
ui.label(egui::RichText::new(l2).color(t.text_dim).size(11.0));
let l1 = if ratio > 1.001 { format!("{l1} · Proxy preview") } else { l1 };
ui.add(egui::Label::new(egui::RichText::new(l1).color(t.text).size(11.5)).truncate());
ui.add(egui::Label::new(egui::RichText::new(l2).color(t.text_dim).size(11.0)).truncate());
});
}
@@ -357,158 +380,185 @@ fn web_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Valu
f.insert("__view".into(), json!(view));
});
ui.add_space(4.0);
web_preview::controls(ui, f);
if let Err(error) = web_preview::prepare_table(app, f) {
ui.colored_label(t.text_dim, error);
}
let p = params(f);
let full_px = f64::from(doc.size.width) * f64::from(doc.size.height);
f.insert("__previewRects".into(), json!([]));
let area = egui::vec2((ui.available_width() - 270.0 - ui.spacing().item_spacing.x).max(260.0), (ui.available_height() - 48.0).max(180.0));
ui.horizontal_top(|ui| {
// Previews.
let area = egui::vec2(600.0, 470.0);
// Previews grow with the dialog; settings retain their compact width.
ui.vertical(|ui| {
ui.set_width(area.x);
match s(f, "__view", "2up").as_str() {
"original" => web_pane(app, ui, area, None, full_px),
"optimized" => web_pane(app, ui, area, Some(&p), full_px),
"original" => web_pane(app, ui, area, None, f),
"optimized" => web_pane(app, ui, area, Some(&p), f),
"2up" => {
ui.horizontal(|ui| {
web_pane(app, ui, egui::vec2(area.x / 2.0 - 4.0, area.y), None, full_px);
web_pane(app, ui, egui::vec2(area.x / 2.0 - 4.0, area.y), Some(&p), full_px);
web_pane(app, ui, egui::vec2(area.x / 2.0 - 4.0, area.y), None, f);
web_pane(app, ui, egui::vec2(area.x / 2.0 - 4.0, area.y), Some(&p), f);
});
}
_ => {
let v = variants(&p);
let cell = egui::vec2(area.x / 2.0 - 4.0, area.y / 2.0 - 2.0);
let cell = egui::vec2(area.x / 2.0 - 4.0, area.y / 2.0 - 4.0);
ui.horizontal(|ui| {
web_pane(app, ui, cell, None, full_px);
web_pane(app, ui, cell, Some(&v[0]), full_px);
web_pane(app, ui, cell, None, f);
web_pane(app, ui, cell, Some(&v[0]), f);
});
ui.horizontal(|ui| {
web_pane(app, ui, cell, Some(&v[1]), full_px);
web_pane(app, ui, cell, Some(&v[2]), full_px);
web_pane(app, ui, cell, Some(&v[1]), f);
web_pane(app, ui, cell, Some(&v[2]), f);
});
}
}
});
ui.add_space(10.0);
// Settings.
// Settings scroll independently on smaller windows.
ui.vertical(|ui| {
ui.set_width(250.0);
ui.label(egui::RichText::new(tl!("Preset")).color(t.text_dim));
let mut preset = s(f, "__preset", "");
let mut opts: Vec<(String, &str)> = vec![("".into(), "[Unnamed]")];
opts.extend(web_cmds::PRESETS.iter().map(|p| (p.to_string(), *p)));
if crate::widgets::dropdown(ui, "web-preset", &mut preset, &opts, 230.0) && !preset.is_empty() {
if let Some(Value::Object(m)) = web_cmds::preset_settings(&preset).map(|st| st.to_params()) {
f.extend(m);
}
f.insert("__preset".into(), json!(preset));
}
ui.add_space(4.0);
dropdown_str(ui, "web-format", f, "format", &WEB_FORMATS, 230.0);
ui.add_space(4.0);
let fmt = s(f, "format", "jpeg");
match fmt.as_str() {
"gif" | "png8" => {
dropdown_str(
ui,
"web-palette",
f,
"palette",
&[
("perceptual", tl!("Perceptual")),
("selective", tl!("Selective")),
("adaptive", tl!("Adaptive")),
("restrictive", tl!("Restrictive (Web)")),
("exact", tl!("Exact")),
],
150.0,
);
number(ui, f, "colors", tl!("Colors"), 2.0..=256.0, "", 128.0);
dropdown_str(
ui,
"web-dither",
f,
"dither",
&[("none", tl!("No Dither")), ("diffusion", tl!("Diffusion")), ("pattern", tl!("Pattern")), ("noise", tl!("Noise"))],
150.0,
);
number(ui, f, "ditherAmount", tl!("Dither"), 0.0..=100.0, "%", 88.0);
check(ui, f, "transparency", tl!("Transparency"), true);
check(ui, f, "interlaced", tl!("Interlaced"), false);
number(ui, f, "webSnap", tl!("Web Snap"), 0.0..=100.0, "%", 0.0);
}
"jpeg" => {
number(ui, f, "quality", tl!("Quality"), 0.0..=100.0, "", 60.0);
check(ui, f, "progressive", tl!("Progressive"), false);
check(ui, f, "optimized", tl!("Optimized"), true);
check(ui, f, "embedIcc", tl!("Embed Color Profile"), false);
}
"wbmp" => {
dropdown_str(
ui,
"web-dither",
f,
"dither",
&[("none", tl!("No Dither")), ("diffusion", tl!("Diffusion")), ("pattern", tl!("Pattern"))],
150.0,
);
}
_ => {
check(ui, f, "transparency", tl!("Transparency"), true);
check(ui, f, "interlaced", tl!("Interlaced"), false);
}
}
if fmt != "png24" || !b(f, "transparency", true) {
ui.horizontal(|ui| {
ui.label(egui::RichText::new(tl!("Matte")).color(t.text_dim));
let mut m = s(f, "matte", "#ffffff");
if ui.add(egui::TextEdit::singleline(&mut m).desired_width(80.0)).changed() {
f.insert("matte".into(), json!(m));
egui::ScrollArea::vertical().id_salt("web-settings").max_height(area.y).show(ui, |ui| {
ui.label(egui::RichText::new(tl!("Preset")).color(t.text_dim));
let mut preset = s(f, "__preset", "");
let mut opts: Vec<(String, &str)> = vec![("".into(), "[Unnamed]")];
opts.extend(web_cmds::PRESETS.iter().map(|p| (p.to_string(), *p)));
if crate::widgets::dropdown(ui, "web-preset", &mut preset, &opts, 230.0) && !preset.is_empty() {
if let Some(Value::Object(m)) = web_cmds::preset_settings(&preset).map(|st| st.to_params()) {
f.extend(m);
}
});
}
ui.add_space(6.0);
check(ui, f, "convertToSrgb", tl!("Convert to sRGB"), true);
ui.horizontal(|ui| {
ui.label(egui::RichText::new(tl!("Metadata")).color(t.text_dim));
dropdown_str(
ui,
"web-meta",
f,
"metadata",
&[("none", tl!("None")), ("copyright", tl!("Copyright")), ("copyrightAndContact", tl!("Copyright and Contact Info")), ("all", tl!("All"))],
160.0,
);
});
ui.add_space(8.0);
ui.label(egui::RichText::new(tl!("Image Size")).font(crate::theme::semibold(12.0)).color(t.text));
number(ui, f, "percent", tl!("Percent"), 1.0..=1000.0, "%", 100.0);
let pct = n(f, "percent", 100.0) / 100.0;
ui.label(
egui::RichText::new(format!("W: {} px H: {} px", (f64::from(doc.size.width) * pct).round(), (f64::from(doc.size.height) * pct).round()))
.color(t.text_dim)
.size(11.5),
);
let nslices = photocraft_doc::slices::resolve(&doc).len();
if !doc.slices.is_empty() {
f.insert("__preset".into(), json!(preset));
}
ui.add_space(4.0);
dropdown_str(ui, "web-format", f, "format", &WEB_FORMATS, 230.0);
ui.add_space(4.0);
let fmt = s(f, "format", "jpeg");
match fmt.as_str() {
"gif" | "png8" => {
dropdown_str(
ui,
"web-palette",
f,
"palette",
&[
("perceptual", tl!("Perceptual")),
("selective", tl!("Selective")),
("adaptive", tl!("Adaptive")),
("restrictive", tl!("Restrictive (Web)")),
("exact", tl!("Exact")),
],
150.0,
);
number(ui, f, "colors", tl!("Colors"), 2.0..=256.0, "", 128.0);
dropdown_str(
ui,
"web-dither",
f,
"dither",
&[("none", tl!("No Dither")), ("diffusion", tl!("Diffusion")), ("pattern", tl!("Pattern")), ("noise", tl!("Noise"))],
150.0,
);
number(ui, f, "ditherAmount", tl!("Dither"), 0.0..=100.0, "%", 88.0);
check(ui, f, "transparency", tl!("Transparency"), true);
check(ui, f, "interlaced", tl!("Interlaced"), false);
number(ui, f, "webSnap", tl!("Web Snap"), 0.0..=100.0, "%", 0.0);
}
"jpeg" => {
number(ui, f, "quality", tl!("Quality"), 0.0..=100.0, "", 60.0);
check(ui, f, "progressive", tl!("Progressive"), false);
check(ui, f, "optimized", tl!("Optimized"), true);
check(ui, f, "embedIcc", tl!("Embed Color Profile"), false);
}
"wbmp" => {
dropdown_str(
ui,
"web-dither",
f,
"dither",
&[("none", tl!("No Dither")), ("diffusion", tl!("Diffusion")), ("pattern", tl!("Pattern"))],
150.0,
);
}
_ => {
check(ui, f, "transparency", tl!("Transparency"), true);
check(ui, f, "interlaced", tl!("Interlaced"), false);
}
}
if fmt != "png24" || !b(f, "transparency", true) {
ui.horizontal(|ui| {
ui.label(egui::RichText::new(tl!("Matte")).color(t.text_dim));
let mut m = s(f, "matte", "#ffffff");
if ui.add(egui::TextEdit::singleline(&mut m).desired_width(80.0)).changed() {
f.insert("matte".into(), json!(m));
}
});
}
ui.add_space(6.0);
ui.label(egui::RichText::new(crate::i18n::trn(crate::i18n::current(), nslices as u64, "{n} slice", "{n} slices")).color(t.text_dim));
check(ui, f, "html", tl!("Save HTML and Images"), true);
dropdown_str(ui, "web-slices", f, "slices", &[("all", tl!("All Slices")), ("user", tl!("All User Slices"))], 150.0);
}
check(ui, f, "convertToSrgb", tl!("Convert to sRGB"), true);
ui.horizontal(|ui| {
ui.label(egui::RichText::new(tl!("Metadata")).color(t.text_dim));
dropdown_str(
ui,
"web-meta",
f,
"metadata",
&[
("none", tl!("None")),
("copyright", tl!("Copyright")),
("copyrightAndContact", tl!("Copyright and Contact Info")),
("all", tl!("All")),
],
160.0,
);
});
if matches!(fmt.as_str(), "gif" | "png8") {
web_preview::table(ui, f);
}
ui.add_space(8.0);
ui.label(egui::RichText::new(tl!("Image Size")).font(crate::theme::semibold(12.0)).color(t.text));
number(ui, f, "percent", tl!("Percent"), 1.0..=1000.0, "%", 100.0);
let pct = n(f, "percent", 100.0) / 100.0;
ui.label(
egui::RichText::new(format!("W: {} px H: {} px", (f64::from(doc.size.width) * pct).round(), (f64::from(doc.size.height) * pct).round()))
.color(t.text_dim)
.size(11.5),
);
let nslices = photocraft_doc::slices::resolve(&doc).len();
if !doc.slices.is_empty() {
ui.add_space(6.0);
ui.label(egui::RichText::new(crate::i18n::trn(crate::i18n::current(), nslices as u64, "{n} slice", "{n} slices")).color(t.text_dim));
check(ui, f, "html", tl!("Save HTML and Images"), true);
dropdown_str(ui, "web-slices", f, "slices", &[("all", tl!("All Slices")), ("user", tl!("All User Slices"))], 150.0);
}
});
});
});
}
fn web_confirm(app: &mut PhotocraftApp, f: &Map<String, Value>) -> Result<Value, String> {
let mut p = params(f);
// Check while the control/input policy is still installed, before an asynchronous picker
// can outlive the request. A browser download uses the interactive platform writer.
if let Some(authorize) = app.session.authorize.or(if app.automation_input { app.services.automation_authorize } else { None }) {
authorize(WEB_COMMAND, &p).map_err(|e| e.to_string())?;
}
if app.automation_input
&& let Some(authorize) = app.services.automation_command.as_ref()
{
authorize(WEB_COMMAND, &p)?;
let authorize = |p: &Value| -> Result<(), String> {
if let Some(authorize) = app.session.authorize.or(if app.automation_input { app.services.automation_authorize } else { None }) {
authorize(WEB_COMMAND, p).map_err(|e| e.to_string())?;
}
if app.automation_input
&& let Some(authorize) = app.services.automation_command.as_ref()
{
authorize(WEB_COMMAND, p)?;
}
Ok(())
};
let mut p = params(f);
authorize(&p)?;
// Confirmation may follow a field edit without an intervening frame (Enter or automation).
let mut fields = f.clone();
web_preview::prepare_table(app, &mut fields)?;
let prepared = params(&fields);
if prepared != p {
// The policy must also approve the actual palette-bearing export parameters.
authorize(&prepared)?;
p = prepared;
}
if p.get("path").is_some() || p.get("dir").is_some() {
return save_for_web(app, p);
@@ -964,6 +1014,118 @@ mod tests {
}
}
#[test]
fn save_for_web_authorizes_generated_palette_before_opening_a_picker() {
fn deny_palette(_: &str, p: &Value) -> photocraft_engine::Result<()> {
if p.get("colorTable").is_some() { Err(photocraft_engine::EngineError::Other("palette denied".into())) } else { Ok(()) }
}
for synthetic_input in [false, true] {
let (mut app, _) = app();
let id = open_web(&mut app);
let fields = &mut app.ui.dialog_mut(id).unwrap().fields;
fields.insert("format".into(), json!("gif"));
assert!(!fields.contains_key("colorTable"));
if synthetic_input {
app.automation_input = true;
app.services.automation_command = Some(Box::new(|cmd, p| deny_palette(cmd, p).map_err(|e| e.to_string())));
} else {
app.session.authorize = Some(deny_palette);
}
app.services.file_dialog = Some(Box::new(|_, _, _| panic!("denied palette must not show a picker")));
app.services.write = Some(Box::new(|_, _| panic!("denied palette must not write")));
assert_eq!(crate::dialogs::confirm(&mut app, id).unwrap_err(), "palette denied");
assert!(!app.file_dialog_open());
}
}
#[test]
fn web_preview_grows_with_resize_and_accepts_zoom_and_pan() {
let (mut app, _) = app();
let id = open_web(&mut app);
let revision = app.session.active().unwrap().revision;
app.ui.dialog_mut(id).unwrap().fields.insert("__view".into(), json!("optimized"));
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1600.0, 1100.0)).build_ui_state(
|ui, app| {
if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("semibold".into()))) {
crate::dialogs::show(app, ui.ctx());
}
},
app,
);
PhotocraftApp::setup_context(&h.ctx, Default::default());
h.run_steps(5);
let pane = |h: &egui_kittest::Harness<'_, PhotocraftApp>| {
let r = &h.state().ui.dialogs[0].fields["__previewRects"][0];
egui::Rect::from_min_max(
egui::pos2(r[0].as_f64().unwrap() as f32, r[1].as_f64().unwrap() as f32),
egui::pos2(r[2].as_f64().unwrap() as f32, r[3].as_f64().unwrap() as f32),
)
};
let before = pane(&h);
let rects: Vec<egui::Rect> = h.ctx.data(|m| m.get_temp(egui::Id::new("pc-dialog-rects"))).unwrap();
let corner = rects[0].max - egui::vec2(14.0, 14.0);
h.event(egui::Event::PointerMoved(corner));
h.step();
h.event(egui::Event::PointerButton { pos: corner, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE });
h.step();
let end = corner + egui::vec2(180.0, 100.0);
h.event(egui::Event::PointerMoved(end));
h.step();
h.event(egui::Event::PointerButton { pos: end, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE });
h.run_steps(4);
let after = pane(&h);
assert!(after.width() > before.width() + 100.0, "before {before:?}, after {after:?}, corner {corner:?}");
assert!(after.height() > before.height() + 50.0);
// Zoom in enough that the small fixture can pan, then drag the real pane.
h.state_mut().ui.dialogs[0].fields.insert("__zoom".into(), json!(16.0));
h.run_steps(3);
let at = pane(&h).center();
h.event(egui::Event::PointerMoved(at));
h.step();
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE });
h.step();
let end = at + egui::vec2(35.0, 20.0);
h.event(egui::Event::PointerMoved(end));
h.step();
h.event(egui::Event::PointerButton { pos: end, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE });
h.run_steps(3);
assert_ne!(h.state().ui.dialogs[0].fields["__center"], json!([0.5, 0.5]));
assert_eq!(h.state().session.active().unwrap().revision, revision);
// Enter while editing a swatch must not export and close the parent dialog.
h.state_mut().ui.dialogs[0].fields.insert("format".into(), json!("gif"));
h.run_steps(4);
use egui_kittest::kittest::Queryable;
h.get_by_role(egui::accesskit::Role::ColorWell).click();
h.run_steps(3);
assert!(egui::Popup::is_any_open(&h.ctx));
h.key_press(egui::Key::Enter);
h.run_steps(3);
assert_eq!(h.state().ui.dialogs.len(), 1);
}
#[test]
fn palette_draft_survives_navigation_and_resets_when_colors_change() {
let (app, _) = app();
let mut f = serde_json::from_value::<Map<String, Value>>(json!({"format": "gif", "colors": 16})).unwrap();
web_preview::prepare_table(&app, &mut f).unwrap();
let table = f["colorTable"].clone();
f.insert("colorTableEdits".into(), json!({"0": "#12ab34"}));
f.insert("__zoom".into(), json!(2.0));
f.insert("__center".into(), json!([0.25, 0.75]));
web_preview::prepare_table(&app, &mut f).unwrap();
assert_eq!(f["colorTable"], table);
assert_eq!(f["colorTableEdits"]["0"], "#12ab34");
f.insert("metadata".into(), json!("none"));
f.insert("interlaced".into(), json!(true));
f.insert("dither".into(), json!("pattern"));
web_preview::prepare_table(&app, &mut f).unwrap();
assert_eq!(f["colorTableEdits"]["0"], "#12ab34", "non-palette settings must retain edits");
f.insert("colors".into(), json!(8));
web_preview::prepare_table(&app, &mut f).unwrap();
assert!(!f.contains_key("colorTableEdits"));
assert!(f["colorTable"].as_array().unwrap().len() <= 8);
}
#[test]
fn print_dialog_and_toggles() {
let (mut app, ctx) = app();
+323
View File
@@ -0,0 +1,323 @@
//! Save for Web navigation and indexed palette UI. Draft state is serialized in dialog fields.
use super::*;
/// Export dimensions, using the same percent/width/height precedence as web_document.
pub fn dimensions(doc: &photocraft_doc::Document, p: &Value) -> Result<(f64, f64), String> {
let (w0, h0) = (f64::from(doc.size.width).max(1.0), f64::from(doc.size.height).max(1.0));
let positive = |k: &str| p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0);
let (w, h) = match (positive("percent"), positive("width"), positive("height")) {
(Some(pct), _, _) => (w0 * pct / 100.0, h0 * pct / 100.0),
(_, Some(w), Some(h)) => (w, h),
(_, Some(w), None) => (w, h0 * w / w0),
(_, None, Some(h)) => (w0 * h / h0, h),
_ => (w0, h0),
};
if !w.is_finite() || !h.is_finite() || w > 300_000.0 || h > 300_000.0 {
return Err("export dimensions exceed the 300,000 px per-side limit".into());
}
Ok((w.round().max(1.0), h.round().max(1.0)))
}
pub fn prepare_table(app: &PhotocraftApp, f: &mut Map<String, Value>) -> Result<(), String> {
if !matches!(s(f, "format", "jpeg").as_str(), "gif" | "png8") {
return Ok(());
}
let st = app.session.active().ok_or("no document")?;
let mut source = params(f);
let Some(m) = source.as_object_mut() else { return Err("invalid web settings".into()) };
// Only palette-affecting options invalidate index-based edits. Metadata, interlacing,
// dithering and output paths must not discard the user's replacement colours.
m.retain(|k, _| {
matches!(
k.as_str(),
"preset" | "format" | "palette" | "colors" | "transparency" | "matte" | "webSnap" | "convertToSrgb" | "width" | "height" | "percent" | "resample"
)
});
let signature = format!("{}|{}|{}", st.doc.id.0, st.revision, source);
if f.get("__tableSignature").and_then(Value::as_str) == Some(&signature) {
return Ok(());
}
// A regenerated palette invalidates index-based edits. Explicit tables supplied through
// ui.dialog.set remain usable until a palette/size setting changes.
if f.contains_key("__tableSignature") {
f.remove("colorTable");
f.remove("colorTableEdits");
f.remove("__selectedColor");
}
if !f.contains_key("colorTable") {
let settings = WebSettings::from_params(&source, "preview").map_err(|e| e.to_string())?;
// Bound palette preparation as well as the displayed image. The fixed table is then
// passed unchanged to full-resolution export, so the swatches are the file's colours.
let (w, h) = dimensions(&st.doc, &source)?;
let scale = (2048.0 / w.max(h)).min(1.0);
let mut proxy = source.clone();
if let Some(m) = proxy.as_object_mut() {
m.remove("percent");
m.insert("width".into(), json!((w * scale).round().max(1.0)));
m.insert("height".into(), json!((h * scale).round().max(1.0)));
m.insert("resample".into(), json!("bilinear"));
}
let (doc, _, _) = web_cmds::web_document(&st.doc, &proxy, &settings).map_err(|e| e.to_string())?;
f.insert("__paletteApproximate".into(), json!(scale < 1.0));
let buf = photocraft_compose::flatten(&doc);
let mut optimized =
web_cmds::optimize(&buf.px, doc.size.width as usize, doc.bounds(), &settings, None, None, doc.resolution_dpi, false).map_err(|e| e.to_string())?;
// Tiny transparent features may disappear in the proxy; keep room for their index.
if settings.transparency {
optimized.color_table.truncate(255);
}
f.insert("colorTable".into(), json!(optimized.color_table.iter().map(|c| web_cmds::rgb_hex(*c)).collect::<Vec<_>>()));
f.insert("__transparentIndex".into(), json!(optimized.transparent_index));
}
f.insert("__tableSignature".into(), json!(signature));
Ok(())
}
pub fn table(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
let p = params(f);
let Ok(settings) = WebSettings::from_params(&p, "preview") else { return };
let Some(table) = settings.color_table else { return };
ui.add_space(8.0);
ui.label(egui::RichText::new(tl!("Color Table…").trim_end_matches('…')).font(crate::theme::semibold(12.0)));
ui.scope(|ui| {
ui.spacing_mut().interact_size = egui::vec2(14.0, 14.0);
ui.spacing_mut().item_spacing = egui::vec2(1.0, 1.0);
egui::Grid::new("web-color-table").spacing(egui::vec2(1.0, 1.0)).show(ui, |ui| {
for (i, source) in table.iter().enumerate() {
let mut color = settings.color_table_edits.get(&i).copied().unwrap_or(*source);
let response = ui.push_id(i, |ui| crate::widgets::color_edit_button_srgb(ui, &mut color)).inner;
response.clone().on_hover_text(format!("Index {i}: {}", web_cmds::rgb_hex(color)));
if response.clicked() {
f.insert("__selectedColor".into(), json!(i));
}
if f.get("__selectedColor").and_then(Value::as_u64) == Some(i as u64) {
let t = Tokens::get(ui.ctx());
ui.painter().rect_stroke(response.rect, t.radius_sm, egui::Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);
}
if response.changed() {
let edits = f.entry("colorTableEdits").or_insert_with(|| json!({}));
if let Some(edits) = edits.as_object_mut() {
edits.insert(i.to_string(), json!(web_cmds::rgb_hex(color)));
}
}
if i % 16 == 15 {
ui.end_row();
}
}
if f.get("__transparentIndex").and_then(Value::as_u64).is_some() {
let (rect, response) = ui.allocate_exact_size(egui::vec2(14.0, 14.0), egui::Sense::hover());
crate::widgets::checker(ui.painter(), rect, 4.0);
response.on_hover_text(tl!("Transparency"));
}
});
});
ui.horizontal(|ui| {
ui.weak(format!("{}: {}", tl!("Colors"), table.len()));
if ui.small_button(tl!("Reset")).clicked() {
f.remove("colorTableEdits");
}
});
}
type EncodedPreview = (Vec<u8>, usize, Option<usize>, &'static str);
/// Palette replacements reuse the cached index buffer; picker drags do not re-quantize or
/// re-encode the proxy. The GIF payload length is unchanged by RGB table replacements.
pub fn optimized(ui: &mut egui::Ui, key: egui::Id, signature: &str, p: &Value, px: &[[f32; 4]], w: u32, h: u32) -> Result<EncodedPreview, String> {
let settings = WebSettings::from_params(p, "preview").map_err(|e| e.to_string())?;
let mut base = p.clone();
if let Some(m) = base.as_object_mut() {
m.remove("colorTableEdits");
}
let signature = format!("{signature}|{base}");
let key = key.with("indexed-source");
let cached: Option<(String, Arc<Optimized>)> = ui.data(|d| d.get_temp(key));
let encoded = if let Some((_, encoded)) = cached.filter(|(sig, _)| sig == &signature) {
encoded
} else {
let settings = WebSettings::from_params(&base, "preview").map_err(|e| e.to_string())?;
let encoded = Arc::new(
web_cmds::optimize(px, w as usize, photocraft_geom::Rect::new(0, 0, w as i32, h as i32), &settings, None, None, 72.0, true)
.map_err(|e| e.to_string())?,
);
ui.data_mut(|d| d.insert_temp(key, (signature, encoded.clone())));
encoded
};
let rgba = if settings.color_table_edits.is_empty() || !settings.format.indexed() {
encoded.preview.clone()
} else {
encoded
.indices
.iter()
.flat_map(|index| {
let index = usize::from(*index);
let color = settings.color_table_edits.get(&index).copied().or_else(|| encoded.color_table.get(index).copied()).unwrap_or([0; 3]);
[color[0], color[1], color[2], if Some(index) == encoded.transparent_index { 0 } else { 255 }]
})
.collect()
};
Ok((rgba, encoded.bytes.len(), encoded.colors, encoded.ext))
}
#[derive(Clone, Copy, Debug)]
struct Navigation {
zoom: Option<f32>,
center: egui::Vec2,
}
impl Navigation {
fn read(f: &Map<String, Value>) -> Self {
let zoom = f.get("__zoom").and_then(Value::as_f64).filter(|z| z.is_finite() && *z > 0.0).map(|z| (z as f32).clamp(0.0001, 32.0));
let center = f
.get("__center")
.and_then(Value::as_array)
.and_then(|a| Some(egui::vec2(a.first()?.as_f64()? as f32, a.get(1)?.as_f64()? as f32)))
.filter(|c| c.is_finite())
.unwrap_or(egui::vec2(0.5, 0.5));
Self { zoom, center }
}
fn write(self, f: &mut Map<String, Value>) {
f.insert("__zoom".into(), json!(self.zoom));
f.insert("__center".into(), json!([self.center.x, self.center.y]));
}
fn scale(self, viewport: egui::Vec2, source: egui::Vec2, ppp: f32) -> f32 {
self.zoom.map_or((viewport.x / source.x).min(viewport.y / source.y), |z| z / ppp)
}
fn clamp(&mut self, viewport: egui::Vec2, image: egui::Vec2) {
let half = (viewport / image * 0.5).min(egui::vec2(0.5, 0.5));
self.center = self.center.clamp(half, egui::Vec2::splat(1.0) - half);
}
fn zoom_at(&mut self, factor: f32, at: egui::Vec2, viewport: egui::Vec2, source: egui::Vec2, ppp: f32) {
if !factor.is_finite() || factor <= 0.0 {
return;
}
let old = self.scale(viewport, source, ppp);
self.zoom = Some((old * ppp * factor).clamp(0.0001, 32.0));
let new = self.scale(viewport, source, ppp);
self.center += at / source * (1.0 / old - 1.0 / new);
self.clamp(viewport, source * new);
}
}
pub fn controls(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
let mut nav = Navigation::read(f);
let effective = n(f, "__effectiveZoom", 1.0) as f32;
ui.horizontal(|ui| {
if ui.small_button(tl!("Fit")).clicked() {
nav.zoom = None;
nav.center = egui::vec2(0.5, 0.5);
}
if ui.small_button("−").clicked() {
nav.zoom = Some((nav.zoom.unwrap_or(effective) / 1.25).max(0.0001));
}
if ui.small_button("+").clicked() {
nav.zoom = Some((nav.zoom.unwrap_or(effective) * 1.25).min(32.0));
}
let mut percent = nav.zoom.unwrap_or(effective) * 100.0;
if ui.add(egui::DragValue::new(&mut percent).range(0.01..=3200.0).suffix("%").speed(10.0)).changed() {
nav.zoom = Some(percent / 100.0);
}
if ui.small_button(tl!("100%")).clicked() {
nav.zoom = Some(1.0);
}
ui.add(egui::Label::new(egui::RichText::new(tl!("Drag to pan · Scroll to zoom · Double-click to fit")).weak()).truncate());
});
nav.write(f);
}
pub fn paint(ui: &mut egui::Ui, rect: egui::Rect, response: &egui::Response, tex: &egui::TextureHandle, source: egui::Vec2, f: &mut Map<String, Value>) {
let mut nav = Navigation::read(f);
let ppp = ui.ctx().pixels_per_point();
if response.double_clicked() {
nav.zoom = None;
nav.center = egui::Vec2::splat(0.5);
}
let mut scale = nav.scale(rect.size(), source, ppp);
if response.dragged() {
nav.zoom = Some(scale * ppp);
nav.center -= response.drag_delta() / (source * scale);
}
if response.hovered() {
let (scroll, pinch) = ui.input(|i| (i.smooth_scroll_delta.y, i.zoom_delta()));
let factor = if pinch != 1.0 { pinch } else { (scroll * 0.005).exp() };
if factor != 1.0 {
let at = response.hover_pos().unwrap_or(rect.center()) - rect.center();
nav.zoom_at(factor, at, rect.size(), source, ppp);
scale = nav.scale(rect.size(), source, ppp);
ui.ctx().request_repaint();
}
}
nav.clamp(rect.size(), source * scale);
let image = egui::Rect::from_min_size(rect.center() - nav.center * source * scale, source * scale);
ui.painter().with_clip_rect(rect.intersect(ui.clip_rect())).image(
tex.id(),
image,
egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)),
egui::Color32::WHITE,
);
// Fit follows resizing; explicit zoom/pan shares the same normalized centre in every pane.
if nav.zoom.is_some() {
f.insert("__effectiveZoom".into(), json!(nav.zoom));
} else {
f.insert("__effectiveZoom".into(), json!(scale * ppp));
}
nav.write(f);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn anchored_zoom_keeps_pixel_under_pointer_and_pan_is_bounded() {
let mut nav = Navigation { zoom: Some(1.0), center: egui::Vec2::splat(0.5) };
let viewport = egui::vec2(200.0, 100.0);
let source = egui::vec2(800.0, 400.0);
let at = egui::vec2(30.0, 20.0);
let before = nav.center + at / source;
nav.zoom_at(2.0, at, viewport, source, 1.0);
let after = nav.center + at / (source * 2.0);
assert!((before - after).length() < 0.00001);
nav.center = egui::vec2(-5.0, 50.0);
nav.clamp(viewport, source * 2.0);
assert_eq!(nav.center, egui::vec2(0.0625, 0.9375));
nav.zoom = None;
assert_eq!(nav.scale(viewport, source, 1.0), 0.25);
assert_eq!(nav.scale(viewport * 2.0, source, 1.0), 0.5);
}
#[test]
fn palette_picker_reuses_indices_and_updates_preview_pixels() {
let key = egui::Id::new("test-web-preview");
let p = json!({"format": "gif", "colorTable": ["#ff0000", "#0000ff"], "dither": "none"});
let mut h = egui_kittest::Harness::builder().build_ui_state(
move |ui, state: &mut (Value, Vec<u8>)| {
state.1 = optimized(ui, key, "fixture", &state.0, &[[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0]], 2, 1).unwrap().0;
},
(p, Vec::new()),
);
let cached = |ctx: &egui::Context| ctx.data(|d| d.get_temp::<(String, Arc<Optimized>)>(key.with("indexed-source"))).unwrap().1;
let before = cached(&h.ctx);
assert_eq!(h.state().1, [255, 0, 0, 255, 0, 0, 255, 255]);
h.state_mut().0["colorTableEdits"] = json!({"0": "#00ff00"});
h.run_steps(3);
assert!(Arc::ptr_eq(&before, &cached(&h.ctx)), "picker drag must not re-encode");
assert_eq!(h.state().1, [0, 255, 0, 255, 0, 0, 255, 255]);
}
#[test]
fn absurd_dimensions_and_navigation_fields_are_safe() {
let doc =
photocraft_doc::Document::new("fixture", photocraft_geom::Size::new(100, 50), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8);
assert!(dimensions(&doc, &json!({"percent": 1e308})).is_err());
assert!(dimensions(&doc, &json!({"width": 1e300})).is_err());
let f = serde_json::from_value::<Map<String, Value>>(json!({"__zoom": 1e300, "__center": [1e300, -1e300]})).unwrap();
let nav = Navigation::read(&f);
assert_eq!(nav.zoom, Some(32.0));
assert_eq!(nav.center, egui::Vec2::splat(0.5));
let mut nav = nav;
for factor in [f32::NAN, f32::INFINITY, 0.0, -1.0] {
nav.zoom_at(factor, egui::Vec2::ZERO, egui::vec2(200.0, 100.0), egui::vec2(400.0, 200.0), 1.0);
assert_eq!(nav.zoom, Some(32.0));
assert!(nav.center.is_finite());
}
}
}
+1
View File
@@ -2323,6 +2323,7 @@ cameraRaw Color Barvy
Click to pick · Esc to cancel Kliknutí vybere · Esc zruší
Pick a screen pixel or press Esc to cancel Vyberte pixel obrazovky nebo stiskněte Esc pro zrušení
Scroll to zoom Rolováním přibližujte
Drag to pan · Scroll to zoom · Double-click to fit Tažením posouvejte · Rolováním přibližujte · Dvojklikem přizpůsobte
Show/Hide All Panels Zobrazit/skrýt všechny panely
Show/Hide Panels Zobrazit/skrýt panely
Could not open Nelze otevřít
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -2360,6 +2360,7 @@
Click to pick · Esc to cancel Klicken zum Auswählen · Esc zum Abbrechen
Pick a screen pixel or press Esc to cancel Ein Bildschirmpixel auswählen oder mit Esc abbrechen
Scroll to zoom Zum Zoomen scrollen
Drag to pan · Scroll to zoom · Double-click to fit Ziehen zum Verschieben · Scrollen zum Zoomen · Doppelklick zum Einpassen
Show/Hide All Panels Alle Bedienfelder ein-/ausblenden
Show/Hide Panels Bedienfelder ein-/ausblenden
Could not open Öffnen nicht möglich
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -3503,6 +3503,7 @@ fontWeight Thin Πολύ λεπτά
Sample a pixel anywhere on the screen Δειγματοληψία ενός pixel οπουδήποτε στην οθόνη
Click to pick · Esc to cancel Κλικ για επιλογή · Esc για ακύρωση
Scroll to zoom Κύλιση για μεγέθυνση
Drag to pan · Scroll to zoom · Double-click to fit Σύρετε για μετακίνηση · Κυλήστε για μεγέθυνση · Διπλό κλικ για προσαρμογή
# Saved New Document presets (#1516)
Preset Name Όνομα προεπιλογής
Can't render this file because it contains an unexpected character in line 1000 and column 18.
+1
View File
@@ -2391,6 +2391,7 @@ cameraRaw Amount Cantidad
Click to pick · Esc to cancel Clic para elegir · Esc para cancelar
Pick a screen pixel or press Esc to cancel Elija un píxel de la pantalla o pulse Esc para cancelar
Scroll to zoom Desplace para ampliar
Drag to pan · Scroll to zoom · Double-click to fit Arrastre para desplazar · Desplace para ampliar · Doble clic para ajustar
Show/Hide All Panels Mostrar/ocultar todos los paneles
Show/Hide Panels Mostrar/ocultar paneles
Could not open No se pudo abrir
Can't render this file because it contains an unexpected character in line 4 and column 47.
+1
View File
@@ -2745,6 +2745,7 @@ cameraRaw Blending Fusion
Click to pick · Esc to cancel Cliquer pour choisir · Échap pour annuler
Pick a screen pixel or press Esc to cancel Choisissez un pixel à l’écran ou appuyez sur Échap pour annuler
Scroll to zoom Défiler pour zoomer
Drag to pan · Scroll to zoom · Double-click to fit Glisser pour déplacer · Défiler pour zoomer · Double-cliquer pour ajuster
Show/Hide All Panels Afficher/masquer tous les panneaux
Show/Hide Panels Afficher/masquer les panneaux
Could not open Impossible d’ouvrir
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -3993,3 +3993,4 @@ cameraRaw Yellow Sárga
Action playback stopped A művelet lejátszása leállt
Step {step} is waiting for its dialog A(z) {step}. lépés a párbeszédablakára vár
@plural {n} action|{n} actions {n} művelet
Drag to pan · Scroll to zoom · Double-click to fit Húzás: mozgatás · Görgetés: nagyítás · Dupla kattintás: igazítás
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -2732,6 +2732,7 @@ cameraRaw Blending Pembauran
Click to pick · Esc to cancel Klik untuk memilih · Esc untuk membatalkan
Pick a screen pixel or press Esc to cancel Pilih piksel layar atau tekan Esc untuk membatalkan
Scroll to zoom Gulir untuk memperbesar
Drag to pan · Scroll to zoom · Double-click to fit Seret untuk menggeser · Gulir untuk memperbesar · Klik dua kali untuk menyesuaikan
Show/Hide All Panels Tampilkan/Sembunyikan Semua Panel
Show/Hide Panels Tampilkan/Sembunyikan Panel
Could not open Tidak dapat membuka
Can't render this file because it contains an unexpected character in line 4 and column 47.
+1
View File
@@ -2395,6 +2395,7 @@ cameraRaw Amount Quantità
Click to pick · Esc to cancel Clic per scegliere · Esc per annullare
Pick a screen pixel or press Esc to cancel Scegli un pixel dello schermo o premi Esc per annullare
Scroll to zoom Scorri per ingrandire
Drag to pan · Scroll to zoom · Double-click to fit Trascina per spostare · Scorri per ingrandire · Doppio clic per adattare
Show/Hide All Panels Mostra/nascondi tutti i pannelli
Show/Hide Panels Mostra/nascondi pannelli
Could not open Impossibile aprire
1 # Italian (it) catalog. Columns, separated by a TAB: context <TAB> source <TAB> translation
2395
2396
2397
2398
2399
2400
2401
+1
View File
@@ -2295,6 +2295,7 @@ cameraRaw Color カラー
Click to pick · Esc to cancel クリックで選択 · Escでキャンセル
Pick a screen pixel or press Esc to cancel 画面のピクセルを選択するか、Escでキャンセル
Scroll to zoom スクロールしてズーム
Drag to pan · Scroll to zoom · Double-click to fit ドラッグで移動 · スクロールでズーム · ダブルクリックで全体表示
Show/Hide All Panels すべてのパネルを表示/非表示
Show/Hide Panels パネルを表示/非表示
Could not open 開けませんでした
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -2908,6 +2908,7 @@ cameraRaw Color 색상
Click to pick · Esc to cancel 클릭하여 선택 · Esc로 취소
Pick a screen pixel or press Esc to cancel 화면 픽셀을 선택하거나 Esc를 눌러 취소
Scroll to zoom 스크롤하여 확대/축소
Drag to pan · Scroll to zoom · Double-click to fit 드래그하여 이동 · 스크롤하여 확대/축소 · 두 번 클릭하여 맞추기
Show/Hide All Panels 모든 패널 표시/숨기기
Show/Hide Panels 패널 표시/숨기기
Could not open 열 수 없음
1 # Korean (ko) catalog. UTF-8: context<TAB>English source<TAB>translation.
2908
2909
2910
2911
2912
2913
2914
+1
View File
@@ -2331,6 +2331,7 @@
Click to pick · Esc to cancel Klik om te kiezen · Esc om te annuleren
Pick a screen pixel or press Esc to cancel Kies een schermpixel of druk op Esc om te annuleren
Scroll to zoom Scrollen om te zoomen
Drag to pan · Scroll to zoom · Double-click to fit Sleep om te pannen · Scroll om te zoomen · Dubbelklik om passend te maken
Show/Hide All Panels Alle panelen tonen/verbergen
Show/Hide Panels Panelen tonen/verbergen
Could not open Kan niet openen
Can't render this file because it contains an unexpected character in line 8 and column 34.
+1
View File
@@ -3335,6 +3335,7 @@ cameraRaw Roughness Chropowatość
Click to pick · Esc to cancel Kliknij, aby wybrać · Esc, aby anulować
Pick a screen pixel or press Esc to cancel Wybierz piksel ekranu lub naciśnij Esc, aby anulować
Scroll to zoom Przewijaj, aby zmieniać powiększenie
Drag to pan · Scroll to zoom · Double-click to fit Przeciągnij, aby przesunąć · Przewijaj, aby powiększyć · Kliknij dwukrotnie, aby dopasować
Open in camera raw Otwórz w Camera Raw
System title bar Systemowy pasek tytułu
More documents Więcej dokumentów
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -2750,6 +2750,7 @@ cameraRaw Whites Brancos
Click to pick · Esc to cancel Clique para escolher · Esc para cancelar
Pick a screen pixel or press Esc to cancel Escolha um pixel da tela ou pressione Esc para cancelar
Scroll to zoom Role para ampliar
Drag to pan · Scroll to zoom · Double-click to fit Arraste para mover · Role para ampliar · Clique duas vezes para ajustar
Show/Hide All Panels Mostrar/ocultar todos os painéis
Show/Hide Panels Mostrar/ocultar painéis
Could not open Não foi possível abrir
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -2592,6 +2592,7 @@ cameraRaw Roughness Неровность
Click to pick · Esc to cancel Щелчок — выбрать · Esc — отменить
Pick a screen pixel or press Esc to cancel Выберите пиксель на экране или нажмите Esc для отмены
Scroll to zoom Колесо — изменить масштаб
Drag to pan · Scroll to zoom · Double-click to fit Перетаскивание — перемещение · Колесо — масштаб · Двойной щелчок — вписать
Show/Hide All Panels Показать/скрыть все панели
Show/Hide Panels Показать/скрыть панели
Could not open Не удалось открыть
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -3805,3 +3805,4 @@ cameraData Lens Lens
Action playback stopped Eylem oynatma durduruldu
Step {step} is waiting for its dialog {step}. adım iletişim kutusunu bekliyor
@plural {n} action|{n} actions {n} eylem
Drag to pan · Scroll to zoom · Double-click to fit Kaydırmak için sürükleyin · Yakınlaştırmak için kaydırın · Sığdırmak için çift tıklayın
Can't render this file because it contains an unexpected character in line 8 and column 34.
+1
View File
@@ -2564,6 +2564,7 @@ cameraRaw Roughness Шорсткість
Click to pick · Esc to cancel Клацніть для вибору · Esc для скасування
Pick a screen pixel or press Esc to cancel Виберіть піксель екрана або натисніть Esc для скасування
Scroll to zoom Прокрутіть для масштабування
Drag to pan · Scroll to zoom · Double-click to fit Перетягніть для панорамування · Прокрутіть для масштабування · Двічі клацніть, щоб умістити
Show/Hide All Panels Показати/приховати всі панелі
Show/Hide Panels Показати/приховати панелі
Could not open Не вдалося відкрити
Can't render this file because it contains an unexpected character in line 1357 and column 18.
+1
View File
@@ -2038,6 +2038,7 @@ layers Fill: Độ tô:
Sample Size: Kích thước mẫu:
Save As Preset Lưu thành thiết lập sẵn
Scroll to zoom Cuộn để thu phóng
Drag to pan · Scroll to zoom · Double-click to fit Kéo để xem toàn cảnh · Cuộn để thu phóng · Bấm đúp để vừa khung
Selection Preview: Xem trước vùng chọn:
Set additional Crop options Đặt tùy chọn Cắt bổ sung
Set Black Point Đặt điểm đen
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -2330,6 +2330,7 @@ cameraRaw Blending 混合
Click to pick · Esc to cancel 单击选取 · Esc 取消
Pick a screen pixel or press Esc to cancel 选取屏幕像素或按 Esc 取消
Scroll to zoom 滚动以缩放
Drag to pan · Scroll to zoom · Double-click to fit 拖动以平移 · 滚动以缩放 · 双击以适应窗口
Show/Hide All Panels 显示/隐藏所有面板
Show/Hide Panels 显示/隐藏面板
Could not open 无法打开
1 # Simplified Chinese (zh-hans) catalog. UTF-8, TAB-separated columns:
2330
2331
2332
2333
2334
2335
2336
+1
View File
@@ -2300,6 +2300,7 @@ cameraRaw Blending 混合
Click to pick · Esc to cancel 按一下選取 · Esc 取消
Pick a screen pixel or press Esc to cancel 選取螢幕像素或按 Esc 取消
Scroll to zoom 捲動以縮放
Drag to pan · Scroll to zoom · Double-click to fit 拖曳以平移 · 捲動以縮放 · 按兩下以符合視窗
Show/Hide All Panels 顯示/隱藏所有面板
Show/Hide Panels 顯示/隱藏面板
Could not open 無法開啟
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -64,6 +64,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i
- Layer Style (`layerStyle {effect?}` opens on that effect kind): the fields hold the dialog's whole state, so agents read and drive it like a user. `effects` lists every effect instance in the layer's order as `{id, kind, on, params, fx}` (`fx` is the effect snapshot the dialog loaded; OK edits that snapshot with `params`, so anything the dialog doesn't model survives). `selected` picks the page (`blendingOptions` or an instance id), `preview` (default on) gates the live canvas preview, `p:blendingOptions` edits blend mode/opacity/fill opacity, and `patternList` names the usable patterns. OK replaces the layer's effects in one step (`layer.layerStyle.replace`); Cancel discards. "New Style…" saves the pending state via `style.presets.new {effects}`, a swatch click applies a style preset, and Make/Reset Default use `layer.layerStyle.makeDefault` / `layer.layerStyle.defaultFor`
- Preferences (`ui.menu.invoke {id: "edit.preferences.interface"}`) exposes the sections in its `values` field. Apply (`ui.dialog.apply`) and OK (`ui.dialog.confirm`) on Preferences are refused over control because automation denies whole-preferences updates and filesystem-bearing preference sections. A refused `ui.dialog.apply` returns an error and keeps the dialog open; a refused `ui.dialog.confirm` returns an error but closes and removes the dialog. `ui.dialog.cancel` closes the dialog without saving. Individual safe preferences can instead be changed with `engine.execute` using `prefs.set` (e.g. `{"command": "prefs.set", "params": {"path": "interface.showTooltips", "value": false}}`). Settings marked for the next launch still require a restart.
- Edit › Fill… (`ui.menu.invoke {id: "edit.fill"}`, Shift+F5, Shift+Backspace) opens the Fill dialog; its fields are `edit.fill`'s params (`contents`, `color`, `pattern`, `colorAdaptation`, `mode`, `opacity`, `preserveTransparency`), and OK remembers them in the preferences (`dialogs["edit.fill"]`).
- Save for Web (`ui.menu.invoke {id: "file.export.saveForWebLegacy"}`) is resizable with independently scrolling settings. Dialog fields `__view` (`original|optimized|2up|4up`), `__zoom` (`null` = Fit, otherwise a factor; 1 = 100% in physical display pixels), and `__center: [u,v]` control the shared preview navigation. `__previewRects` reports pane rectangles in screen points. Drag a preview to pan, scroll/pinch to zoom at the pointer, or double-click to fit. GIF/PNG-8 expose `colorTable: ["#rrggbb", …]` (opaque source entries) and `colorTableEdits: {"0":"#rrggbb", …}` (zero-based replacements after quantization). A swatch opens a color picker; edits update the preview and saved file without remapping pixel indices or changing the document. Reset clears replacements. Changing palette/size/matte/transparency settings regenerates the table and clears index-based edits. Previews and generated tables use a bounded 2048-pixel proxy; full-size export uses that explicit table unchanged. `__paletteApproximate` reports proxy-derived palette generation, and enlarged proxy previews are labelled. The engine export command accepts the same table/edit parameters; its no-path estimate returns the source `colorTable` and `transparentIndex` as well as size statistics. Opening a new dialog does not carry a previous image's table across.
- A pixel tool pressed on a type, shape, Smart Object or fill layer (e.g. through `ui.pointer`) opens the "Rasterize?" prompt instead of painting: a dialog with `__rasterize` (`type|shape|smartObject|fill`), `message`, `layer`, `tool` and `at`. `ui.dialog.confirm` runs the `layer.rasterize.*` command and then paints at `at` (two history states, `{"rasterized", "painted"}`); `ui.dialog.cancel` does nothing.
- Edit › Transform › Warp on editable type (`ui.menu.invoke {"id":"edit.transform.warp"}`, or the warp toggle of an open transform box) opens Warp mode with no prompt, as in Photoshop: the mesh can't be dragged or split, only the options bar's warp style, Bend, H and V apply, and the commit sets the layer's Warp Text in one step, so the type stays editable. Directly: `engine.execute {"command":"edit.transform.warp","params":{"style":"flag","bend":40}}` on a type layer. Distort and Perspective stay greyed for type.
- `ui.window.open {document?}` / `ui.window.close {window}`: extra document windows