From f7eb6ea9e724dbcf97af2d8162785c8063089edc Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 4 Oct 2026 10:20:46 -0400 Subject: [PATCH] Format: adopt the craft-family rustfmt.toml and format the workspace CI's `cargo fmt --all -- --check` has failed on every run because the repo had no rustfmt config and was never formatted. Use the same settings as EffectCraft/FilmCraft (max_width 160, use_small_heuristics Max), which also keeps the churn small. Formatting only; no code changes. Co-Authored-By: Claude Opus 5.5 --- apps/photocraft-cli/src/lib.rs | 190 +-- apps/photocraft-cli/tests/cli.rs | 208 +-- apps/photocraft-web/src/main.rs | 4 +- apps/photocraft-web/src/web.rs | 104 +- apps/photocraft/src/main.rs | 8 +- apps/photocraft/src/services.rs | 20 +- crates/algo/examples/bench_filters2.rs | 15 +- crates/algo/examples/bench_liquify.rs | 14 +- crates/algo/examples/bench_photo.rs | 96 +- crates/algo/examples/bench_retouch.rs | 24 +- crates/algo/src/artistic.rs | 6 +- crates/algo/src/artistic_fx.rs | 34 +- crates/algo/src/camera_raw.rs | 30 +- crates/algo/src/features.rs | 38 +- crates/algo/src/gallery.rs | 13 +- crates/algo/src/hdr.rs | 51 +- crates/algo/src/lens.rs | 14 +- crates/algo/src/lib.rs | 414 +++-- crates/algo/src/liquify.rs | 10 +- crates/algo/src/matting.rs | 20 +- crates/algo/src/other.rs | 8 +- crates/algo/src/other2.rs | 3 +- crates/algo/src/paint.rs | 14 +- crates/algo/src/panorama.rs | 30 +- crates/algo/src/perspective.rs | 31 +- crates/algo/src/pixelate.rs | 17 +- crates/algo/src/poisson.rs | 10 +- crates/algo/src/puppet.rs | 49 +- crates/algo/src/quantize.rs | 164 +- crates/algo/src/render2.rs | 89 +- crates/algo/src/retouch.rs | 11 +- crates/algo/src/segment/focus.rs | 3 +- crates/algo/src/segment/gmm.rs | 15 +- crates/algo/src/segment/grabcut.rs | 2 +- crates/algo/src/segment/mod.rs | 2 +- crates/algo/src/segment/quick.rs | 2 +- crates/algo/src/selection.rs | 19 +- crates/algo/src/stack.rs | 26 +- crates/algo/src/stylize2.rs | 3 +- crates/algo/src/tests.rs | 94 +- crates/algo/src/tests_ext.rs | 93 +- crates/algo/src/tone.rs | 271 +--- crates/algo/src/transform.rs | 17 +- crates/algo/src/vanishing.rs | 6 +- crates/algo/src/video.rs | 2 +- crates/algo/src/warp.rs | 6 +- crates/algo/src/wideangle.rs | 28 +- crates/automation/src/bridge.rs | 49 +- crates/automation/src/files.rs | 56 +- crates/automation/src/headless.rs | 49 +- crates/automation/src/rpc.rs | 89 +- crates/automation/src/server.rs | 255 +--- crates/automation/tests/agent_tasks.rs | 191 +-- crates/automation/tests/mcp.rs | 197 +-- crates/cms/src/builtin.rs | 2 +- crates/cms/src/curve.rs | 5 +- crates/cms/src/lutfile.rs | 125 +- crates/cms/src/math.rs | 33 +- crates/cms/src/pipeline.rs | 10 +- crates/cms/src/profile.rs | 14 +- crates/cms/src/synth.rs | 18 +- crates/cms/src/transform.rs | 14 +- crates/cms/tests/cms.rs | 13 +- crates/codecs/src/codecs/exr.rs | 72 +- crates/codecs/src/codecs/jpeg.rs | 36 +- crates/codecs/src/codecs/png.rs | 86 +- crates/codecs/src/codecs/pnm.rs | 104 +- crates/codecs/src/codecs/tiff.rs | 194 +-- crates/codecs/src/codecs/via_image.rs | 73 +- crates/codecs/src/codecs/webp.rs | 30 +- crates/codecs/src/error.rs | 15 +- crates/codecs/src/fidelity.rs | 59 +- crates/codecs/src/format.rs | 114 +- crates/codecs/src/image.rs | 217 +-- crates/codecs/src/lib.rs | 47 +- crates/codecs/src/options.rs | 52 +- crates/codecs/src/web.rs | 28 +- crates/codecs/tests/camera_raw.rs | 10 +- crates/codecs/tests/common/mod.rs | 26 +- crates/codecs/tests/detect_caps.rs | 77 +- crates/codecs/tests/fidelity.rs | 194 +-- crates/codecs/tests/limits.rs | 94 +- crates/codecs/tests/malformed.rs | 77 +- crates/codecs/tests/metadata.rs | 82 +- crates/codecs/tests/png_jpeg.rs | 144 +- crates/codecs/tests/roundtrip.rs | 149 +- crates/color/src/blend.rs | 27 +- crates/color/src/convert.rs | 20 +- crates/compose/src/adjust.rs | 21 +- crates/compose/src/bounds.rs | 1 - crates/compose/src/effects.rs | 78 +- crates/compose/src/lib.rs | 77 +- crates/compose/src/masks.rs | 8 +- crates/compose/src/psblend.rs | 15 +- crates/compose/src/tests.rs | 132 +- crates/doc/src/adjust.rs | 80 +- crates/doc/src/analysis.rs | 10 +- crates/doc/src/blend_if.rs | 42 +- crates/doc/src/comps.rs | 20 +- crates/doc/src/effects.rs | 21 +- crates/doc/src/lib.rs | 25 +- crates/doc/src/mode.rs | 46 +- crates/doc/src/pattern.rs | 14 +- crates/doc/src/text.rs | 59 +- crates/doc/src/text_styles.rs | 17 +- crates/doc/src/vector.rs | 21 +- crates/engine/examples/bench_web.rs | 5 +- crates/engine/examples/retouch_bench.rs | 3 +- crates/engine/src/adjust_cmds.rs | 198 +-- crates/engine/src/adjust_cmds/tests.rs | 223 +-- crates/engine/src/align_cmds.rs | 16 +- crates/engine/src/analysis_cmds.rs | 113 +- crates/engine/src/artboard_cmds.rs | 83 +- crates/engine/src/artboard_cmds/tests.rs | 4 +- crates/engine/src/automate_cmds.rs | 88 +- crates/engine/src/automate_cmds/tests.rs | 24 +- crates/engine/src/brush_cmds.rs | 27 +- crates/engine/src/brush_cmds/tests.rs | 13 +- crates/engine/src/channel_cmds.rs | 166 +- crates/engine/src/channel_cmds/tests.rs | 16 +- crates/engine/src/color_cmds.rs | 111 +- crates/engine/src/commands.rs | 571 ++++--- crates/engine/src/comps_cmds.rs | 61 +- crates/engine/src/comps_cmds/tests.rs | 4 +- crates/engine/src/distort_cmds.rs | 100 +- crates/engine/src/distort_cmds/tests.rs | 10 +- crates/engine/src/edit_cmds.rs | 28 +- crates/engine/src/edit_menu_cmds.rs | 127 +- crates/engine/src/edit_menu_cmds/tests.rs | 15 +- crates/engine/src/extra_cmds.rs | 124 +- crates/engine/src/file_cmds.rs | 192 ++- crates/engine/src/file_cmds/tests.rs | 8 +- crates/engine/src/filters.rs | 113 +- crates/engine/src/filters_ext.rs | 144 +- crates/engine/src/filters_ext/tests.rs | 9 +- crates/engine/src/frame_cmds.rs | 15 +- crates/engine/src/gallery_cmds.rs | 55 +- crates/engine/src/image_cmds.rs | 83 +- crates/engine/src/inspect.rs | 7 +- crates/engine/src/layer_menu_cmds.rs | 405 +---- crates/engine/src/layer_menu_cmds/tests.rs | 370 +---- crates/engine/src/layer_multi_cmds.rs | 42 +- crates/engine/src/layer_style.rs | 58 +- crates/engine/src/lens_cmds.rs | 37 +- crates/engine/src/lib.rs | 86 +- crates/engine/src/migrate_cmds.rs | 8 +- crates/engine/src/mode_cmds.rs | 302 +--- crates/engine/src/mode_cmds/tests.rs | 214 +-- crates/engine/src/multichannel_cmds.rs | 26 +- crates/engine/src/notes_cmds.rs | 55 +- crates/engine/src/paint_cmds.rs | 16 +- crates/engine/src/pattern_cmds.rs | 80 +- crates/engine/src/photo_cmds.rs | 74 +- crates/engine/src/pixels.rs | 7 +- crates/engine/src/prefs.rs | 128 +- crates/engine/src/prefs/tests.rs | 3 +- crates/engine/src/presets/clone_source.rs | 59 +- crates/engine/src/presets/gradients.rs | 194 ++- crates/engine/src/presets/mod.rs | 6 +- crates/engine/src/presets/patterns.rs | 51 +- crates/engine/src/presets/shapes.rs | 85 +- crates/engine/src/presets/styles.rs | 118 +- crates/engine/src/presets/tests.rs | 7 +- crates/engine/src/presets/tools.rs | 78 +- crates/engine/src/print_cmds.rs | 88 +- crates/engine/src/print_cmds/tests.rs | 20 +- crates/engine/src/proof_sim.rs | 23 +- crates/engine/src/render_cmds.rs | 10 +- crates/engine/src/retouch_cmds.rs | 18 +- crates/engine/src/retouch_cmds/tests.rs | 17 +- crates/engine/src/selection_cmds.rs | 38 +- crates/engine/src/slice_cmds.rs | 72 +- crates/engine/src/smart_cmds.rs | 134 +- crates/engine/src/smart_cmds/tests.rs | 16 +- crates/engine/src/smartselect_cmds.rs | 54 +- crates/engine/src/snap.rs | 11 +- crates/engine/src/tests.rs | 3 +- crates/engine/src/timeline_cmds.rs | 7 +- crates/engine/src/transform_cmds.rs | 3 +- crates/engine/src/trap_cmds.rs | 13 +- crates/engine/src/type_cmds.rs | 269 +--- crates/engine/src/type_extra_cmds.rs | 65 +- crates/engine/src/type_extra_cmds/tests.rs | 7 +- crates/engine/src/type_spell_cmds.rs | 16 +- crates/engine/src/type_styles_cmds.rs | 28 +- crates/engine/src/type_styles_cmds/tests.rs | 3 +- crates/engine/src/variables_cmds.rs | 68 +- crates/engine/src/variables_cmds/tests.rs | 84 +- crates/engine/src/vector_cmds.rs | 88 +- crates/engine/src/vector_cmds/tests.rs | 12 +- crates/engine/src/video_cmds.rs | 24 +- crates/engine/src/video_cmds/tests.rs | 2 +- crates/engine/src/vp_cmds.rs | 30 +- crates/engine/src/warp_cmds.rs | 69 +- crates/engine/src/web_cmds.rs | 123 +- crates/engine/src/web_cmds/tests.rs | 19 +- crates/engine/tests/panic_hunt.rs | 6 +- crates/format/src/autosave.rs | 60 +- crates/format/src/convert.rs | 289 +--- crates/format/src/lib.rs | 18 +- crates/format/src/manifest.rs | 3 +- crates/format/src/migrate.rs | 22 +- crates/format/src/store.rs | 129 +- crates/format/src/zip.rs | 88 +- crates/format/tests/analysis.rs | 18 +- crates/format/tests/autosave.rs | 39 +- crates/format/tests/common/mod.rs | 110 +- crates/format/tests/corruption.rs | 60 +- crates/format/tests/roundtrip.rs | 107 +- crates/geom/src/lib.rs | 15 +- crates/geom/src/warp.rs | 15 +- crates/gpu/src/fx.rs | 19 +- crates/gpu/src/lib.rs | 276 +++- crates/gpu/src/plan.rs | 112 +- crates/gpu/tests/parity.rs | 180 ++- crates/io/examples/oracle_diff.rs | 90 +- crates/io/src/adjust_map.rs | 44 +- crates/io/src/blocks.rs | 116 +- crates/io/src/comps_map.rs | 17 +- crates/io/src/effects_map.rs | 107 +- crates/io/src/lib.rs | 6 +- crates/io/src/pattern_map.rs | 16 +- crates/io/src/pixels.rs | 1 - crates/io/src/psd_export.rs | 28 +- crates/io/src/psd_import.rs | 57 +- crates/io/src/slices_map.rs | 19 +- crates/io/src/vector_map.rs | 48 +- crates/io/tests/common/mod.rs | 20 +- crates/io/tests/comps_artboards.rs | 33 +- crates/io/tests/corpus.rs | 11 +- crates/io/tests/modes.rs | 35 +- crates/io/tests/psd_structure.rs | 24 +- crates/io/tests/roundtrip.rs | 73 +- crates/io/tests/text_corpus.rs | 129 +- crates/io/tests/text_styles.rs | 13 +- crates/io/tests/vector.rs | 21 +- crates/paint/examples/bench_brush.rs | 5 +- crates/paint/src/brush.rs | 11 +- crates/paint/src/brush_tests.rs | 70 +- crates/paint/src/dynamics.rs | 12 +- crates/paint/src/lib.rs | 17 +- crates/paint/src/presets.rs | 62 +- crates/paint/src/render.rs | 17 +- crates/paint/src/replace.rs | 28 +- crates/paint/src/retouch.rs | 5 +- crates/psd/src/blend.rs | 6 +- crates/psd/src/builder.rs | 21 +- crates/psd/src/compression.rs | 20 +- crates/psd/src/descriptor.rs | 54 +- crates/psd/src/file.rs | 21 +- crates/psd/src/image_data.rs | 4 +- crates/psd/src/io.rs | 30 +- crates/psd/src/layer.rs | 74 +- crates/psd/src/lib.rs | 4 +- crates/psd/src/path.rs | 18 +- crates/psd/src/patterns.rs | 11 +- crates/psd/src/pixels.rs | 13 +- crates/psd/src/resources.rs | 4 +- crates/psd/src/slices.rs | 39 +- crates/psd/src/tagged.rs | 16 +- crates/psd/src/testgen.rs | 75 +- crates/psd/src/tree.rs | 3 +- crates/psd/tests/builder.rs | 7 +- crates/psd/tests/corpus.rs | 9 +- crates/psd/tests/proptests.rs | 7 +- crates/psd/tests/roundtrip.rs | 4 +- crates/psd/tests/tree.rs | 9 +- crates/text/src/engine_data.rs | 65 +- crates/text/src/fonts.rs | 117 +- crates/text/src/glyphs.rs | 103 +- crates/text/src/layout.rs | 169 +-- crates/text/src/lib.rs | 37 +- crates/text/src/psd.rs | 369 +---- crates/text/src/psd_styles.rs | 213 +-- crates/text/src/raster.rs | 50 +- crates/text/src/render.rs | 155 +- crates/text/src/spell.rs | 86 +- crates/text/src/tests.rs | 532 +------ crates/text/src/warp.rs | 36 +- crates/ui-egui/examples/fx_bench.rs | 145 +- crates/ui-egui/examples/interactive_bench.rs | 13 +- crates/ui-egui/examples/snapshot.rs | 32 +- crates/ui-egui/src/actions.rs | 14 +- crates/ui-egui/src/adjust_ui.rs | 62 +- crates/ui-egui/src/analysis_ui.rs | 59 +- crates/ui-egui/src/artboard_ui.rs | 10 +- crates/ui-egui/src/camera_raw_ui.rs | 29 +- crates/ui-egui/src/canvas.rs | 123 +- crates/ui-egui/src/channels_panel.rs | 39 +- crates/ui-egui/src/chrome_ui.rs | 10 +- crates/ui-egui/src/comps_ui.rs | 12 +- crates/ui-egui/src/control.rs | 29 +- crates/ui-egui/src/distort_ui.rs | 16 +- crates/ui-egui/src/doc_props_ui.rs | 31 +- crates/ui-egui/src/enable_rules.rs | 19 +- crates/ui-egui/src/export_dialog.rs | 11 +- crates/ui-egui/src/file_ui.rs | 241 ++- crates/ui-egui/src/filter_dialog.rs | 83 +- crates/ui-egui/src/gallery_ui.rs | 24 +- crates/ui-egui/src/gpu_canvas.rs | 229 ++- crates/ui-egui/src/icons.rs | 8 +- crates/ui-egui/src/layer_menu_ui.rs | 5 +- crates/ui-egui/src/layer_props_ui.rs | 8 +- crates/ui-egui/src/layer_style.rs | 146 +- crates/ui-egui/src/lib.rs | 49 +- crates/ui-egui/src/links.rs | 15 +- crates/ui-egui/src/liquify_ui.rs | 70 +- crates/ui-egui/src/menus.rs | 103 +- crates/ui-egui/src/new_doc_ui.rs | 104 +- crates/ui-egui/src/palette.rs | 175 ++- crates/ui-egui/src/panels.rs | 1425 ++++++++++-------- crates/ui-egui/src/perspective_ui.rs | 30 +- crates/ui-egui/src/prefs_ui.rs | 101 +- crates/ui-egui/src/preset_panels.rs | 122 +- crates/ui-egui/src/puppet_ui.rs | 24 +- crates/ui-egui/src/retouch_ui.rs | 21 +- crates/ui-egui/src/rulers.rs | 13 +- crates/ui-egui/src/shortcuts.rs | 29 +- crates/ui-egui/src/sizing.rs | 15 +- crates/ui-egui/src/slice_ui.rs | 22 +- crates/ui-egui/src/smart_ui.rs | 3 +- crates/ui-egui/src/snap_ui.rs | 40 +- crates/ui-egui/src/state.rs | 28 +- crates/ui-egui/src/theme.rs | 36 +- crates/ui-egui/src/timeline_ui.rs | 13 +- crates/ui-egui/src/tone.rs | 24 +- crates/ui-egui/src/transform_tool.rs | 33 +- crates/ui-egui/src/type_panels_ui.rs | 53 +- crates/ui-egui/src/type_tool.rs | 69 +- crates/ui-egui/src/variables_ui.rs | 21 +- crates/ui-egui/src/vector_ui.rs | 20 +- crates/ui-egui/src/view_cmds.rs | 151 +- crates/ui-egui/src/view_cmds/tests.rs | 12 +- crates/ui-egui/src/wide_angle_ui.rs | 94 +- crates/ui-egui/src/widgets.rs | 36 +- crates/ui-egui/src/workspace_ui.rs | 52 +- crates/vector/src/lib.rs | 9 +- crates/vector/src/tests.rs | 9 +- crates/vector/src/trace.rs | 11 +- rustfmt.toml | 3 + xtask/src/ico.rs | 5 +- xtask/src/layers.rs | 208 +-- xtask/src/main.rs | 73 +- xtask/src/stats.rs | 51 +- xtask/src/version.rs | 18 +- 345 files changed, 11250 insertions(+), 11481 deletions(-) create mode 100644 rustfmt.toml diff --git a/apps/photocraft-cli/src/lib.rs b/apps/photocraft-cli/src/lib.rs index a2b4edc002..a01aaee3d6 100644 --- a/apps/photocraft-cli/src/lib.rs +++ b/apps/photocraft-cli/src/lib.rs @@ -38,34 +38,17 @@ struct Args { flags: Vec<(String, Option)>, } -const VALUE_FLAGS: &[&str] = &[ - "--format", - "--quality", - "--new", - "--cmd", - "--params", - "--out", - "--actions", - "--in", - "--filter", - "--bridge", - "--port", -]; +const VALUE_FLAGS: &[&str] = &["--format", "--quality", "--new", "--cmd", "--params", "--out", "--actions", "--in", "--filter", "--bridge", "--port"]; fn parse(args: &[String]) -> Result { - let mut a = Args { - positional: Vec::new(), - flags: Vec::new(), - }; + let mut a = Args { positional: Vec::new(), flags: Vec::new() }; let mut i = 0; while i < args.len() { let s = &args[i]; if let Some((k, v)) = s.split_once('=').filter(|(k, _)| k.starts_with("--")) { a.flags.push((k.to_owned(), Some(v.to_owned()))); } else if VALUE_FLAGS.contains(&s.as_str()) { - let v = args - .get(i + 1) - .ok_or_else(|| format!("{s} needs a value"))?; + let v = args.get(i + 1).ok_or_else(|| format!("{s} needs a value"))?; a.flags.push((s.clone(), Some(v.clone()))); i += 1; } else if s.starts_with("--") { @@ -80,11 +63,7 @@ fn parse(args: &[String]) -> Result { impl Args { fn get(&self, k: &str) -> Option<&str> { - self.flags - .iter() - .rev() - .find(|(f, _)| f == k) - .and_then(|(_, v)| v.as_deref()) + self.flags.iter().rev().find(|(f, _)| f == k).and_then(|(_, v)| v.as_deref()) } fn has(&self, k: &str) -> bool { self.flags.iter().any(|(f, _)| f == k) @@ -120,11 +99,7 @@ pub fn run(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 { return 0; } "--version" | "version" => { - let _ = writeln!( - out, - "photocraft-cli {}", - photocraft_engine::build_info::long_version() - ); + let _ = writeln!(out, "photocraft-cli {}", photocraft_engine::build_info::long_version()); return 0; } other => { @@ -162,25 +137,13 @@ fn convert(a: &Args, _out: &mut dyn Write, err: &mut dyn Write) -> R { }; let o = files::open(Path::new(input)).map_err(|e| e.to_string())?; warn_all(err, &o.warnings); - let ws = files::save( - &o.document, - Path::new(output), - a.get("--format"), - &export_opts(a)?, - None, - ) - .map_err(|e| e.to_string())?; + let ws = files::save(&o.document, Path::new(output), a.get("--format"), &export_opts(a)?, None).map_err(|e| e.to_string())?; warn_all(err, &ws); Ok(()) } fn print_json(out: &mut dyn Write, v: &Value, compact: bool) -> R { - let s = if compact { - serde_json::to_string(v) - } else { - serde_json::to_string_pretty(v) - } - .map_err(|e| e.to_string())?; + let s = if compact { serde_json::to_string(v) } else { serde_json::to_string_pretty(v) }.map_err(|e| e.to_string())?; writeln!(out, "{s}").map_err(|e| e.to_string()) } @@ -210,8 +173,7 @@ fn command_list(a: &Args) -> Result, String> { ("--cmd", Some(id)) => cmds.push((id.clone(), json!({}))), ("--params", Some(p)) => { let last = cmds.last_mut().ok_or("--params must follow a --cmd")?; - last.1 = serde_json::from_str(p) - .map_err(|e| format!("bad --params JSON for `{}`: {e}", last.0))?; + last.1 = serde_json::from_str(p).map_err(|e| format!("bad --params JSON for `{}`: {e}", last.0))?; } _ => {} } @@ -238,20 +200,11 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R { return Err("run needs at least one --cmd (or --out)".into()); } for (id, params) in cmds { - let r = h - .command_run(&id, params) - .map_err(|e| format!("`{id}`: {e}"))?; + let r = h.command_run(&id, params).map_err(|e| format!("`{id}`: {e}"))?; print_json(out, &json!({"command": id, "result": r}), true)?; } if let Some(o) = a.get("--out") { - let r = h - .save( - None, - Some(Path::new(o)), - a.get("--format"), - &export_opts(a)?, - ) - .map_err(|e| e.to_string())?; + let r = h.save(None, Some(Path::new(o)), a.get("--format"), &export_opts(a)?).map_err(|e| e.to_string())?; let ws: Vec = serde_json::from_value(r["warnings"].clone()).unwrap_or_default(); warn_all(err, &ws); } @@ -264,37 +217,21 @@ pub fn parse_actions(text: &str) -> Result, String> { let v: Value = serde_json::from_str(text).map_err(|e| format!("actions JSON: {e}"))?; let list = match &v { Value::Array(a) => a.clone(), - Value::Object(m) => m - .get("actions") - .and_then(Value::as_array) - .cloned() - .ok_or("actions JSON: expected an array or {\"actions\": [...]}")?, + Value::Object(m) => m.get("actions").and_then(Value::as_array).cloned().ok_or("actions JSON: expected an array or {\"actions\": [...]}")?, _ => return Err("actions JSON: expected an array".into()), }; list.into_iter() .enumerate() .map(|(i, a)| { - let id = a - .get("command") - .or_else(|| a.get("id")) - .and_then(Value::as_str) - .ok_or_else(|| format!("action {i}: missing \"command\""))?; - Ok(( - id.to_owned(), - a.get("params").cloned().unwrap_or_else(|| json!({})), - )) + let id = a.get("command").or_else(|| a.get("id")).and_then(Value::as_str).ok_or_else(|| format!("action {i}: missing \"command\""))?; + Ok((id.to_owned(), a.get("params").cloned().unwrap_or_else(|| json!({})))) }) .collect() } fn is_input(p: &Path) -> bool { - let known = |e: &str| { - matches!(e, "pcraft" | "psd" | "psb") - || photocraft_codecs::from_extension(e).is_some_and(|f| photocraft_codecs::caps(f).read) - }; - p.is_file() - && p.extension() - .is_some_and(|e| known(&e.to_string_lossy().to_ascii_lowercase())) + let known = |e: &str| matches!(e, "pcraft" | "psd" | "psb") || photocraft_codecs::from_extension(e).is_some_and(|f| photocraft_codecs::caps(f).read); + p.is_file() && p.extension().is_some_and(|e| known(&e.to_string_lossy().to_ascii_lowercase())) } fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R { @@ -305,35 +242,21 @@ fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R { let actions = parse_actions(&text)?; let opts = export_opts(a)?; std::fs::create_dir_all(&out_dir).map_err(|e| e.to_string())?; - let mut inputs: Vec = std::fs::read_dir(&in_dir) - .map_err(|e| format!("{}: {e}", in_dir.display()))? - .flatten() - .map(|e| e.path()) - .filter(|p| is_input(p)) - .collect(); + let mut inputs: Vec = + std::fs::read_dir(&in_dir).map_err(|e| format!("{}: {e}", in_dir.display()))?.flatten().map(|e| e.path()).filter(|p| is_input(p)).collect(); inputs.sort(); let (mut ok, mut failed) = (0, 0); for input in &inputs { - let ext = a - .get("--format") - .map(str::to_owned) - .or_else(|| input.extension().map(|e| e.to_string_lossy().into_owned())) - .unwrap_or_else(|| "png".into()); - let stem = input - .file_stem() - .map(|s| s.to_string_lossy().into_owned()) - .unwrap_or_default(); + let ext = a.get("--format").map(str::to_owned).or_else(|| input.extension().map(|e| e.to_string_lossy().into_owned())).unwrap_or_else(|| "png".into()); + let stem = input.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(); let target = out_dir.join(format!("{stem}.{ext}")); let r = (|| -> Result, String> { let mut h = Headless::new(); h.open(input).map_err(|e| e.to_string())?; for (id, p) in &actions { - h.command_run(id, p.clone()) - .map_err(|e| format!("`{id}`: {e}"))?; + h.command_run(id, p.clone()).map_err(|e| format!("`{id}`: {e}"))?; } - let r = h - .save(None, Some(&target), Some(&ext), &opts) - .map_err(|e| e.to_string())?; + let r = h.save(None, Some(&target), Some(&ext), &opts).map_err(|e| e.to_string())?; Ok(serde_json::from_value(r["warnings"].clone()).unwrap_or_default()) })(); match r { @@ -349,18 +272,11 @@ fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R { } } let _ = writeln!(out, "{ok} succeeded, {failed} failed"); - if failed > 0 { - Err(format!("{failed} file(s) failed")) - } else { - Ok(()) - } + if failed > 0 { Err(format!("{failed} file(s) failed")) } else { Ok(()) } } fn droplet(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R { - let (file, inputs) = a - .positional - .split_first() - .ok_or("droplet needs and inputs")?; + let (file, inputs) = a.positional.split_first().ok_or("droplet needs and inputs")?; if inputs.is_empty() { return Err("droplet needs at least one input file or folder".into()); } @@ -369,26 +285,15 @@ fn droplet(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R { p["output"] = json!(o); } let mut s = photocraft_engine::Session::new(); - let r = s - .execute("file.automate.runDroplet", p) - .map_err(|e| e.to_string())?; + let r = s.execute("file.automate.runDroplet", p).map_err(|e| e.to_string())?; for f in r["files"].as_array().into_iter().flatten() { let _ = writeln!(out, "ok {}", f.as_str().unwrap_or_default()); } let errors = r["errors"].as_array().cloned().unwrap_or_default(); for e in &errors { - let _ = writeln!( - err, - "FAIL {}: {}", - e["file"].as_str().unwrap_or_default(), - e["error"].as_str().unwrap_or_default() - ); - } - if errors.is_empty() { - Ok(()) - } else { - Err(format!("{} file(s) failed", errors.len())) + let _ = writeln!(err, "FAIL {}: {}", e["file"].as_str().unwrap_or_default(), e["error"].as_str().unwrap_or_default()); } + if errors.is_empty() { Ok(()) } else { Err(format!("{} file(s) failed", errors.len())) } } fn commands(a: &Args, out: &mut dyn Write) -> R { @@ -401,42 +306,15 @@ fn commands(a: &Args, out: &mut dyn Write) -> R { .unwrap_or_default() .into_iter() .filter(|c| { - needle.as_ref().is_none_or(|n| { - format!( - "{} {}", - c["id"].as_str().unwrap_or(""), - c["label"].as_str().unwrap_or("") - ) - .to_lowercase() - .contains(n) - }) + needle.as_ref().is_none_or(|n| format!("{} {}", c["id"].as_str().unwrap_or(""), c["label"].as_str().unwrap_or("")).to_lowercase().contains(n)) }) .collect(); if a.has("--json") { - return print_json( - out, - &Value::Array(items.into_iter().cloned().collect()), - false, - ); + return print_json(out, &Value::Array(items.into_iter().cloned().collect()), false); } for c in items { - let menu = c["menu"] - .as_array() - .map(|m| { - m.iter() - .filter_map(Value::as_str) - .collect::>() - .join(" > ") - }) - .unwrap_or_default(); - writeln!( - out, - "{:<44} {:<32} {}", - c["id"].as_str().unwrap_or(""), - c["label"].as_str().unwrap_or(""), - menu - ) - .map_err(|e| e.to_string())?; + let menu = c["menu"].as_array().map(|m| m.iter().filter_map(Value::as_str).collect::>().join(" > ")).unwrap_or_default(); + writeln!(out, "{:<44} {:<32} {}", c["id"].as_str().unwrap_or(""), c["label"].as_str().unwrap_or(""), menu).map_err(|e| e.to_string())?; } Ok(()) } @@ -455,8 +333,7 @@ fn serve(a: &Args, err: &mut dyn Write) -> R { } None => { let stdin = std::io::stdin(); - photocraft_automation::rpc::serve_lines(&h, stdin.lock(), std::io::stdout()) - .map_err(|e| e.to_string()) + photocraft_automation::rpc::serve_lines(&h, stdin.lock(), std::io::stdout()).map_err(|e| e.to_string()) } } } @@ -466,9 +343,6 @@ fn mcp(a: &Args) -> R { Some(addr) => PhotocraftMcp::bridge(addr).map_err(|e| e.to_string())?, None => PhotocraftMcp::headless(), }; - let rt = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - .map_err(|e| e.to_string())?; + let rt = tokio::runtime::Builder::new_multi_thread().enable_all().build().map_err(|e| e.to_string())?; rt.block_on(server.serve_stdio()).map_err(|e| e.to_string()) } diff --git a/apps/photocraft-cli/tests/cli.rs b/apps/photocraft-cli/tests/cli.rs index b2d6e3dad7..47ce1e85a8 100644 --- a/apps/photocraft-cli/tests/cli.rs +++ b/apps/photocraft-cli/tests/cli.rs @@ -18,30 +18,15 @@ fn tmp(name: &str) -> PathBuf { } fn write_png(path: &Path, w: u32, h: u32, seed: u8) { - let data: Vec = (0..w * h * 4) - .map(|i| (i as u8).wrapping_mul(seed).wrapping_add(seed)) - .collect(); - let img = photocraft_codecs::Image::from_u8(w, h, photocraft_codecs::ChannelLayout::Rgba, data) - .unwrap(); - std::fs::write( - path, - photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()) - .unwrap(), - ) - .unwrap(); + let data: Vec = (0..w * h * 4).map(|i| (i as u8).wrapping_mul(seed).wrapping_add(seed)).collect(); + let img = photocraft_codecs::Image::from_u8(w, h, photocraft_codecs::ChannelLayout::Rgba, data).unwrap(); + std::fs::write(path, photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap(); } fn ok(cmd: &mut Command) -> (String, String) { let o = cmd.output().unwrap(); - let (out, err) = ( - String::from_utf8_lossy(&o.stdout).into_owned(), - String::from_utf8_lossy(&o.stderr).into_owned(), - ); - assert!( - o.status.success(), - "exit {:?}\nstdout: {out}\nstderr: {err}", - o.status - ); + let (out, err) = (String::from_utf8_lossy(&o.stdout).into_owned(), String::from_utf8_lossy(&o.stderr).into_owned()); + assert!(o.status.success(), "exit {:?}\nstdout: {out}\nstderr: {err}", o.status); (out, err) } @@ -70,26 +55,15 @@ fn convert_png_pcraft_png_is_lossless() { let y = photocraft_codecs::decode(&std::fs::read(&c).unwrap()).unwrap(); assert_eq!(x.to_rgba8(), y.to_rgba8()); // Format override and lossy warning path. - ok(bin() - .args(["convert"]) - .arg(&a) - .arg(d.join("out.bin")) - .args(["--format", "jpg", "--quality", "60"])); - assert!( - photocraft_codecs::detect(&std::fs::read(d.join("out.bin")).unwrap()) - == Some(photocraft_codecs::Format::Jpeg) - ); + ok(bin().args(["convert"]).arg(&a).arg(d.join("out.bin")).args(["--format", "jpg", "--quality", "60"])); + assert!(photocraft_codecs::detect(&std::fs::read(d.join("out.bin")).unwrap()) == Some(photocraft_codecs::Format::Jpeg)); std::fs::remove_dir_all(d).unwrap(); } #[test] fn convert_errors() { let d = tmp("converr"); - let o = bin() - .args(["convert", "/missing/in.png"]) - .arg(d.join("x.png")) - .output() - .unwrap(); + let o = bin().args(["convert", "/missing/in.png"]).arg(d.join("x.png")).output().unwrap(); assert_eq!(o.status.code(), Some(1)); let o = bin().args(["convert", "only-one"]).output().unwrap(); assert_eq!(o.status.code(), Some(1)); @@ -119,65 +93,33 @@ fn run_commands_and_save() { let (out, _) = ok(bin() .args(["run", "--new", r#"{"width":40,"height":30,"name":"Run"}"#]) .args(["--cmd", "layer.new.layer", "--params", r#"{"name":"Ink"}"#]) - .args([ - "--cmd", - "paint.stroke", - "--params", - r##"{"points":[[2,2,1],[30,20,1]],"size":5,"color":"#00ff00"}"##, - ]) + .args(["--cmd", "paint.stroke", "--params", r##"{"points":[[2,2,1],[30,20,1]],"size":5,"color":"#00ff00"}"##]) .args(["--cmd", "document.inspect"]) .arg("--out") .arg(&out_pc)); - let lines: Vec = out - .lines() - .map(|l| serde_json::from_str(l).unwrap()) - .collect(); + let lines: Vec = out.lines().map(|l| serde_json::from_str(l).unwrap()).collect(); assert_eq!(lines.len(), 3); assert_eq!(lines[2]["command"], "document.inspect"); let (info, _) = ok(bin().arg("info").arg(&out_pc)); let v: Value = serde_json::from_str(&info).unwrap(); assert_eq!(v["name"], "Run"); - let names: Vec<&str> = v["layers"] - .as_array() - .unwrap() - .iter() - .filter_map(|l| l["name"].as_str()) - .collect(); + let names: Vec<&str> = v["layers"].as_array().unwrap().iter().filter_map(|l| l["name"].as_str()).collect(); assert_eq!(names, ["Ink", "Background"]); // Continue from the saved file and export PNG. let png = d.join("r.png"); - ok(bin() - .arg("run") - .arg(&out_pc) - .args(["--cmd", "layer.new.layer"]) - .arg("--out") - .arg(&png)); - assert_eq!( - photocraft_codecs::decode(&std::fs::read(&png).unwrap()) - .unwrap() - .dimensions(), - (40, 30) - ); + ok(bin().arg("run").arg(&out_pc).args(["--cmd", "layer.new.layer"]).arg("--out").arg(&png)); + assert_eq!(photocraft_codecs::decode(&std::fs::read(&png).unwrap()).unwrap().dimensions(), (40, 30)); std::fs::remove_dir_all(d).unwrap(); } #[test] fn run_errors() { - let o = bin() - .args(["run", "--new", "{}", "--cmd", "no.such"]) - .output() - .unwrap(); + let o = bin().args(["run", "--new", "{}", "--cmd", "no.such"]).output().unwrap(); assert_eq!(o.status.code(), Some(1)); assert!(String::from_utf8_lossy(&o.stderr).contains("unknown command")); - let o = bin() - .args(["run", "--new", "{}", "--params", "{}"]) - .output() - .unwrap(); + let o = bin().args(["run", "--new", "{}", "--params", "{}"]).output().unwrap(); assert_eq!(o.status.code(), Some(1)); - let o = bin() - .args(["run", "--new", "{", "--cmd", "x"]) - .output() - .unwrap(); + let o = bin().args(["run", "--new", "{", "--cmd", "x"]).output().unwrap(); assert_eq!(o.status.code(), Some(1)); } @@ -190,19 +132,8 @@ fn batch_applies_actions_to_directory() { write_png(&inp.join("two.png"), 9, 9, 2); std::fs::write(inp.join("notes.txt"), "ignored").unwrap(); let actions = d.join("actions.json"); - std::fs::write( - &actions, - json!([{"command": "layer.new.layer", "params": {"name": "Batch"}}]).to_string(), - ) - .unwrap(); - let (out, _) = ok(bin() - .args(["batch", "--actions"]) - .arg(&actions) - .arg("--in") - .arg(&inp) - .arg("--out") - .arg(&outp) - .args(["--format", "pcraft"])); + std::fs::write(&actions, json!([{"command": "layer.new.layer", "params": {"name": "Batch"}}]).to_string()).unwrap(); + let (out, _) = ok(bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&inp).arg("--out").arg(&outp).args(["--format", "pcraft"])); assert!(out.contains("2 succeeded, 0 failed"), "{out}"); for n in ["one", "two"] { let (info, _) = ok(bin().arg("info").arg(outp.join(format!("{n}.pcraft")))); @@ -211,15 +142,7 @@ fn batch_applies_actions_to_directory() { } // A failing action reports and exits 1. std::fs::write(&actions, r#"{"actions":[{"id":"no.such.command"}]}"#).unwrap(); - let o = bin() - .args(["batch", "--actions"]) - .arg(&actions) - .arg("--in") - .arg(&inp) - .arg("--out") - .arg(&outp) - .output() - .unwrap(); + let o = bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&inp).arg("--out").arg(&outp).output().unwrap(); assert_eq!(o.status.code(), Some(1)); assert!(String::from_utf8_lossy(&o.stderr).contains("FAIL")); std::fs::remove_dir_all(d).unwrap(); @@ -227,21 +150,9 @@ fn batch_applies_actions_to_directory() { #[test] fn parse_actions_formats() { - let a = - photocraft_cli::parse_actions(r#"[{"command":"a","params":{"x":1}},{"id":"b"}]"#).unwrap(); - assert_eq!( - a, - vec![ - ("a".to_string(), json!({"x":1})), - ("b".to_string(), json!({})) - ] - ); - assert!( - photocraft_cli::parse_actions(r#"{"actions":[{"command":"c"}]}"#) - .unwrap() - .len() - == 1 - ); + let a = photocraft_cli::parse_actions(r#"[{"command":"a","params":{"x":1}},{"id":"b"}]"#).unwrap(); + assert_eq!(a, vec![("a".to_string(), json!({"x":1})), ("b".to_string(), json!({}))]); + assert!(photocraft_cli::parse_actions(r#"{"actions":[{"command":"c"}]}"#).unwrap().len() == 1); assert!(photocraft_cli::parse_actions(r#"[{"params":{}}]"#).is_err()); assert!(photocraft_cli::parse_actions("7").is_err()); } @@ -252,38 +163,26 @@ fn commands_listing() { assert!(out.contains("file.new")); let (out, _) = ok(bin().args(["commands", "--json", "--filter", "layer.new"])); let v: Value = serde_json::from_str(&out).unwrap(); - assert!(v.as_array().unwrap().iter().all(|c| { - c["id"].as_str().unwrap().contains("layer.new") - || c["label"] - .as_str() - .unwrap() - .to_lowercase() - .contains("layer.new") - })); + assert!( + v.as_array() + .unwrap() + .iter() + .all(|c| { c["id"].as_str().unwrap().contains("layer.new") || c["label"].as_str().unwrap().to_lowercase().contains("layer.new") }) + ); } #[test] fn in_process_run_matches_binary() { let mut out = Vec::new(); let mut err = Vec::new(); - let code = photocraft_cli::run( - &["commands".into(), "--filter".into(), "file.new".into()], - &mut out, - &mut err, - ); + let code = photocraft_cli::run(&["commands".into(), "--filter".into(), "file.new".into()], &mut out, &mut err); assert_eq!(code, 0); assert!(String::from_utf8(out).unwrap().contains("file.new")); } #[test] fn mcp_stdio_handshake_and_tool_call() { - let mut child = bin() - .arg("mcp") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - .unwrap(); + let mut child = bin().arg("mcp").stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap(); let mut stdin = child.stdin.take().unwrap(); let mut stdout = BufReader::new(child.stdout.take().unwrap()); let mut send = |v: Value| { @@ -300,33 +199,19 @@ fn mcp_stdio_handshake_and_tool_call() { } } }; - send( - json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{ - "protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}), - ); + send(json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{ + "protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"0"}}})); let init = recv(1); assert_eq!(init["result"]["serverInfo"]["name"], "photocraft", "{init}"); send(json!({"jsonrpc":"2.0","method":"notifications/initialized"})); send(json!({"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}})); let tools = recv(2); - let names: Vec<&str> = tools["result"]["tools"] - .as_array() - .unwrap() - .iter() - .filter_map(|t| t["name"].as_str()) - .collect(); - assert!( - names.contains(&"command_run") && names.contains(&"doc_render_preview"), - "{names:?}" - ); - send( - json!({"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"doc_new","arguments":{"width":8,"height":8}}}), - ); + let names: Vec<&str> = tools["result"]["tools"].as_array().unwrap().iter().filter_map(|t| t["name"].as_str()).collect(); + assert!(names.contains(&"command_run") && names.contains(&"doc_render_preview"), "{names:?}"); + send(json!({"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"doc_new","arguments":{"width":8,"height":8}}})); let r = recv(3); assert_ne!(r["result"]["isError"], true, "{r}"); - send( - json!({"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"doc_render_preview","arguments":{}}}), - ); + send(json!({"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"doc_render_preview","arguments":{}}})); let r = recv(4); assert_eq!(r["result"]["content"][0]["type"], "image", "{r}"); let _ = child.kill(); @@ -345,25 +230,10 @@ fn droplet_runs_an_action_on_files() { ) .unwrap(); let out_dir = d.join("out"); - let (out, _) = ok(bin() - .arg("droplet") - .arg(&droplet) - .arg(d.join("a.png")) - .arg(d.join("b.png")) - .arg("--out") - .arg(&out_dir)); - assert_eq!( - out.lines().filter(|l| l.starts_with("ok")).count(), - 2, - "{out}" - ); + let (out, _) = ok(bin().arg("droplet").arg(&droplet).arg(d.join("a.png")).arg(d.join("b.png")).arg("--out").arg(&out_dir)); + assert_eq!(out.lines().filter(|l| l.starts_with("ok")).count(), 2, "{out}"); let img = photocraft_codecs::decode(&std::fs::read(out_dir.join("a.png")).unwrap()).unwrap(); assert_eq!(img.dimensions(), (4, 8)); - let o = bin() - .arg("droplet") - .arg(d.join("a.png")) - .arg(d.join("b.png")) - .output() - .unwrap(); + let o = bin().arg("droplet").arg(d.join("a.png")).arg(d.join("b.png")).output().unwrap(); assert!(!o.status.success(), "not a droplet"); } diff --git a/apps/photocraft-web/src/main.rs b/apps/photocraft-web/src/main.rs index 34264020e3..8ee27fea10 100644 --- a/apps/photocraft-web/src/main.rs +++ b/apps/photocraft-web/src/main.rs @@ -24,7 +24,5 @@ fn main() { #[cfg(not(target_arch = "wasm32"))] fn main() { - eprintln!( - "photocraft-web only runs in the browser: build it with `trunk build --release` in apps/photocraft-web" - ); + eprintln!("photocraft-web only runs in the browser: build it with `trunk build --release` in apps/photocraft-web"); } diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index 57d9569c10..059f4b2019 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -11,10 +11,8 @@ use wasm_bindgen::JsCast as _; type Inbox = Arc)>>>; -const IMAGE_EXTS: &[&str] = &[ - "psd", "psb", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", - "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm", -]; +const IMAGE_EXTS: &[&str] = + &["psd", "psb", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm"]; const CANVAS_ID: &str = "photocraft_canvas"; pub fn start() { @@ -24,10 +22,7 @@ pub fn start() { log::error!("no document"); return; }; - let Some(canvas) = document - .get_element_by_id(CANVAS_ID) - .and_then(|e| e.dyn_into::().ok()) - else { + let Some(canvas) = document.get_element_by_id(CANVAS_ID).and_then(|e| e.dyn_into::().ok()) else { log::error!("missing "); return; }; @@ -35,8 +30,7 @@ pub fn start() { let force_cpu = q.contains("cpu"); let mut options = eframe::WebOptions::default(); if q.contains("webgl") - && let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = - &mut options.wgpu_options.wgpu_setup + && let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup { create.instance_descriptor.backends = eframe::wgpu::Backends::GL; } @@ -47,18 +41,12 @@ pub fn start() { Box::new(move |cc| { PhotocraftApp::setup_context(&cc.egui_ctx, ThemeKind::Pro); let inbox: Inbox = Arc::default(); - let mut app = PhotocraftApp::new( - Session::new(), - services(inbox.clone(), cc.egui_ctx.clone()), - ); + let mut app = PhotocraftApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone())); app.set_theme(&cc.egui_ctx, ThemeKind::Pro); if let Some(rs) = cc.wgpu_render_state.clone() && !force_cpu { - log::info!( - "photocraft-web: wgpu backend {:?}", - rs.adapter.get_info().backend - ); + log::info!("photocraft-web: wgpu backend {:?}", rs.adapter.get_info().backend); app.set_wgpu(rs); } Ok(Box::new(WebShell { app, inbox })) @@ -75,9 +63,7 @@ pub fn start() { } fn query() -> String { - web_sys::window() - .and_then(|w| w.location().search().ok()) - .unwrap_or_default() + web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default() } /// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously, @@ -94,17 +80,10 @@ impl eframe::App for WebShell { let inbox = self.inbox.clone(); let ctx = ctx.clone(); wasm_bindgen_futures::spawn_local(async move { - let name = f - .path() - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_else(|| "dropped".into()); + let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into()); match f.bytes_async().await { Ok(bytes) => { - inbox - .lock() - .unwrap_or_else(|e| e.into_inner()) - .push((name, bytes)); + inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes)); ctx.request_repaint(); } Err(e) => log::error!("couldn't read dropped file {name}: {e}"), @@ -122,68 +101,41 @@ impl eframe::App for WebShell { fn services(inbox: Inbox, ctx: egui::Context) -> Services { let open_inbox = inbox.clone(); Services { - import: Some(Box::new(|name: &str, bytes: &[u8]| { - photocraft_io::import(name, bytes) - .map(|r| r.document) - .map_err(|e| e.to_string()) - })), + import: Some(Box::new(|name: &str, bytes: &[u8]| photocraft_io::import(name, bytes).map(|r| r.document).map_err(|e| e.to_string()))), export: Some(Box::new(|doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings| { let mut opts = photocraft_io::ExportOptions::default(); if let Some(q) = settings.jpeg_quality { opts.encode.jpeg_quality = q; } - photocraft_io::export(doc, path, &opts) - .map(|r| r.bytes) - .map_err(|e| e.to_string()) + photocraft_io::export(doc, path, &opts).map(|r| r.bytes).map_err(|e| e.to_string()) })), pick_open: Some(Box::new(move || { let inbox = open_inbox.clone(); let ctx = ctx.clone(); wasm_bindgen_futures::spawn_local(async move { - let Some(file) = rfd::AsyncFileDialog::new() - .add_filter("Images", IMAGE_EXTS) - .pick_file() - .await - else { + let Some(file) = rfd::AsyncFileDialog::new().add_filter("Images", IMAGE_EXTS).pick_file().await else { return; }; let bytes = file.read().await; - inbox - .lock() - .unwrap_or_else(|e| e.into_inner()) - .push((file.file_name(), bytes)); + inbox.lock().unwrap_or_else(|e| e.into_inner()).push((file.file_name(), bytes)); ctx.request_repaint(); }); None })), // No save dialog on the web: the suggested name becomes the download name. pick_save: Some(Box::new(|suggested: &str| { - let name = std::path::Path::new(suggested) - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_else(|| suggested.to_string()); + let name = std::path::Path::new(suggested).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| suggested.to_string()); Some(name) })), write: Some(Box::new(|path: &str, bytes: &[u8]| download(path, bytes))), encode_png: Some(Box::new(|w, h, rgba| { - let img = Image::from_u8(w, h, ChannelLayout::Rgba, rgba.to_vec()) - .map_err(|e| e.to_string())?; - photocraft_codecs::encode( - &img, - photocraft_codecs::Format::Png, - &EncodeOptions::default(), - ) - .map_err(|e| e.to_string()) + let img = Image::from_u8(w, h, ChannelLayout::Rgba, rgba.to_vec()).map_err(|e| e.to_string())?; + photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &EncodeOptions::default()).map_err(|e| e.to_string()) })), inbox: Some(inbox), // Preferences live in the browser's localStorage. load_prefs: Some(Box::new(|| local_storage()?.get_item(PREFS_KEY).ok().flatten())), - save_prefs: Some(Box::new(|text: &str| { - local_storage() - .ok_or("no localStorage")? - .set_item(PREFS_KEY, text) - .map_err(|e| format!("{e:?}")) - })), + save_prefs: Some(Box::new(|text: &str| local_storage().ok_or("no localStorage")?.set_item(PREFS_KEY, text).map_err(|e| format!("{e:?}")))), ..Default::default() } } @@ -197,10 +149,7 @@ fn local_storage() -> Option { /// Trigger a browser download of `bytes` named after the last component of `path`. fn download(path: &str, bytes: &[u8]) -> Result<(), String> { let js = |e: wasm_bindgen::JsValue| format!("{e:?}"); - let name = std::path::Path::new(path) - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_else(|| "photocraft".into()); + let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "photocraft".into()); let window = web_sys::window().ok_or("no window")?; let document = window.document().ok_or("no document")?; let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes)); @@ -208,11 +157,7 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> { opts.set_type(mime_for(&name)); let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(js)?; let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js)?; - let a: web_sys::HtmlAnchorElement = document - .create_element("a") - .map_err(js)? - .dyn_into() - .map_err(|_| "not an anchor")?; + let a: web_sys::HtmlAnchorElement = document.create_element("a").map_err(js)?.dyn_into().map_err(|_| "not an anchor")?; a.set_href(&url); a.set_download(&name); a.style().set_property("display", "none").map_err(js)?; @@ -224,19 +169,12 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> { let revoke = wasm_bindgen::closure::Closure::once_into_js(move || { web_sys::Url::revoke_object_url(&url).ok(); }); - window - .set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000) - .map_err(js)?; + window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000).map_err(js)?; Ok(()) } fn mime_for(name: &str) -> &'static str { - match name - .rsplit('.') - .next() - .map(str::to_ascii_lowercase) - .as_deref() - { + match name.rsplit('.').next().map(str::to_ascii_lowercase).as_deref() { Some("png") => "image/png", Some("jpg" | "jpeg") => "image/jpeg", Some("tif" | "tiff") => "image/tiff", diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index 02b551a7be..c8ea2c0706 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -50,7 +50,13 @@ fn main() -> eframe::Result { viewport: egui::ViewportBuilder::default() .with_icon(app_icon()) .with_app_id(APP_ID) - .with_title("PhotoCraft").with_inner_size([1440.0, 900.0]).with_min_inner_size([760.0, 480.0]).with_drag_and_drop(true).with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false), + .with_title("PhotoCraft") + .with_inner_size([1440.0, 900.0]) + .with_min_inner_size([760.0, 480.0]) + .with_drag_and_drop(true) + .with_fullsize_content_view(true) + .with_titlebar_shown(false) + .with_title_shown(false), ..Default::default() }; eframe::run_native( diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index d16c67937b..ad05406360 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -3,8 +3,8 @@ use photocraft_codecs::{ChannelLayout, EncodeOptions, Image, SampleType as CS}; use photocraft_color::{ColorMode, SampleType}; use photocraft_doc::{Document, Layer, Size}; -use photocraft_geom::Rect; use photocraft_format::Autosaver; +use photocraft_geom::Rect; use photocraft_ui_egui::Services; use std::cell::RefCell; use std::collections::HashMap; @@ -12,7 +12,8 @@ use std::path::{Path, PathBuf}; use std::rc::Rc; use std::sync::Arc; -const IMAGE_EXTS: &[&str] = &["psd", "psb", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm"]; +const IMAGE_EXTS: &[&str] = + &["psd", "psb", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm"]; /// Per-user settings directory: `PHOTOCRAFT_CONFIG_DIR`, else the platform convention /// (macOS `~/Library/Application Support/Photocraft`, Windows `%APPDATA%\Photocraft`, Linux @@ -68,7 +69,12 @@ pub fn native() -> Services { })), pick_save: Some(Box::new(|suggested: &str| { let p = std::path::Path::new(suggested); - let mut d = rfd::FileDialog::new().add_filter("Photoshop", &["psd", "psb"]).add_filter("PNG", &["png"]).add_filter("JPEG", &["jpg"]).add_filter("TIFF", &["tif"]).add_filter("OpenEXR", &["exr"]); + let mut d = rfd::FileDialog::new() + .add_filter("Photoshop", &["psd", "psb"]) + .add_filter("PNG", &["png"]) + .add_filter("JPEG", &["jpg"]) + .add_filter("TIFF", &["tif"]) + .add_filter("OpenEXR", &["exr"]); if let Some(name) = p.file_name() { d = d.set_file_name(name.to_string_lossy()); } @@ -136,7 +142,13 @@ pub fn import_flat(name: &str, bytes: &[u8]) -> Result { }; let gray = matches!(img.layout(), ChannelLayout::Gray | ChannelLayout::GrayA); let cmyk = matches!(img.layout(), ChannelLayout::Cmyk | ChannelLayout::CmykA); - let mode = if gray { ColorMode::Grayscale } else if cmyk { ColorMode::Cmyk } else { ColorMode::Rgb }; + let mode = if gray { + ColorMode::Grayscale + } else if cmyk { + ColorMode::Cmyk + } else { + ColorMode::Rgb + }; let target = match mode { ColorMode::Grayscale => ChannelLayout::GrayA, ColorMode::Cmyk => ChannelLayout::CmykA, diff --git a/crates/algo/examples/bench_filters2.rs b/crates/algo/examples/bench_filters2.rs index 4485cb7aca..03a5198706 100644 --- a/crates/algo/examples/bench_filters2.rs +++ b/crates/algo/examples/bench_filters2.rs @@ -11,9 +11,7 @@ fn main() { let r = Rect::new(0, 0, w, h); let mut s = Surface::new(PixelFormat::RGBA8); // Busy deterministic content (gradients plus texture) so edge-aware filters do real work. - let bytes: Vec = (0..h) - .flat_map(|y| (0..w).flat_map(move |x| [(x % 256) as u8, ((x * 3 + y) / 11 % 256) as u8, ((x ^ y) % 256) as u8, 255])) - .collect(); + let bytes: Vec = (0..h).flat_map(|y| (0..w).flat_map(move |x| [(x % 256) as u8, ((x * 3 + y) / 11 % 256) as u8, ((x ^ y) % 256) as u8, 255])).collect(); s.write_interleaved(r, &bytes); let cases = vec![ FilterParams::OilPaint { stylization: 4.0, cleanliness: 5.0, scale: 1.0, bristle_detail: 5.0, lighting: true, angle: -60.0, shine: 1.0 }, @@ -43,7 +41,16 @@ fn main() { FilterParams::PathBlur { paths: vec![BlurPath::default()] }, FilterParams::Crystallize { cell_size: 10.0, seed: 0 }, FilterParams::ColorHalftone { max_radius: 8.0, angles: [108.0, 162.0, 90.0, 45.0] }, - FilterParams::LightingEffects { lights: vec![Light::default()], gloss: 0.0, metallic: 0.0, exposure: 0.0, ambience: 8.0, texture: TextureChannel::Luminance, height: 50.0, white_is_high: true }, + FilterParams::LightingEffects { + lights: vec![Light::default()], + gloss: 0.0, + metallic: 0.0, + exposure: 0.0, + ambience: 8.0, + texture: TextureChannel::Luminance, + height: 50.0, + white_is_high: true, + }, ]; for p in cases { if only.as_deref().is_some_and(|o| !p.label().to_lowercase().contains(&o.to_lowercase())) { diff --git a/crates/algo/examples/bench_liquify.rs b/crates/algo/examples/bench_liquify.rs index f8e4213d43..a953463f07 100644 --- a/crates/algo/examples/bench_liquify.rs +++ b/crates/algo/examples/bench_liquify.rs @@ -93,14 +93,24 @@ fn main() { // Puppet warp on a 24 MP layer. let pw = PuppetWarp { - pins: vec![PuppetPin { src: [1000.0, 2000.0], dst: [1000.0, 2000.0], rotate: None, depth: 0 }, PuppetPin { src: [5000.0, 2000.0], dst: [4800.0, 1200.0], rotate: None, depth: 0 }, PuppetPin { src: [3000.0, 3500.0], dst: [3100.0, 3500.0], rotate: None, depth: 0 }], + pins: vec![ + PuppetPin { src: [1000.0, 2000.0], dst: [1000.0, 2000.0], rotate: None, depth: 0 }, + PuppetPin { src: [5000.0, 2000.0], dst: [4800.0, 1200.0], rotate: None, depth: 0 }, + PuppetPin { src: [3000.0, 3500.0], dst: [3100.0, 3500.0], rotate: None, depth: 0 }, + ], mode: PuppetMode::Normal, density: PuppetDensity::Normal, expansion: 2.0, }; let t = Instant::now(); let (solver, v, order) = deform(&s, b, &pw, photocraft_algo::puppet::ITERATIONS); - println!("puppet mesh {} verts / {} tris + ARAP ({} rounds): {:.1} ms", solver.mesh.verts.len(), solver.mesh.tris.len(), photocraft_algo::puppet::ITERATIONS, t.elapsed().as_secs_f64() * 1e3); + println!( + "puppet mesh {} verts / {} tris + ARAP ({} rounds): {:.1} ms", + solver.mesh.verts.len(), + solver.mesh.tris.len(), + photocraft_algo::puppet::ITERATIONS, + t.elapsed().as_secs_f64() * 1e3 + ); let dst: Vec<[f64; 2]> = pw.pins.iter().map(|p| p.dst).collect(); let t = Instant::now(); let _ = solver.solve(&dst, &[None; 3], PuppetMode::Normal, 4, Some(&v)); diff --git a/crates/algo/examples/bench_photo.rs b/crates/algo/examples/bench_photo.rs index c9e11228d6..dc0354886d 100644 --- a/crates/algo/examples/bench_photo.rs +++ b/crates/algo/examples/bench_photo.rs @@ -46,7 +46,23 @@ fn main() { time("camera raw: basic (wb, exposure, tone)", &only, || { let mut a = px.clone(); - develop(&mut a, w, h, &CameraRaw { temperature: 20.0, exposure: 0.3, contrast: 20.0, highlights: -40.0, shadows: 40.0, whites: 10.0, blacks: -10.0, vibrance: 20.0, ..Default::default() }, false); + develop( + &mut a, + w, + h, + &CameraRaw { + temperature: 20.0, + exposure: 0.3, + contrast: 20.0, + highlights: -40.0, + shadows: 40.0, + whites: 10.0, + blacks: -10.0, + vibrance: 20.0, + ..Default::default() + }, + false, + ); }); time("camera raw: clarity + texture + dehaze", &only, || { let mut a = px.clone(); @@ -56,26 +72,59 @@ fn main() { let mut a = px.clone(); let mut hs = [0.0; 8]; hs[2] = 30.0; - develop(&mut a, w, h, &CameraRaw { hsl_sat: hs, grade_shadows: Wheel { hue: 220.0, sat: 30.0, lum: 0.0 }, point_curve: vec![[0.0, 10.0], [128.0, 140.0], [255.0, 250.0]], ..Default::default() }, false); + develop( + &mut a, + w, + h, + &CameraRaw { + hsl_sat: hs, + grade_shadows: Wheel { hue: 220.0, sat: 30.0, lum: 0.0 }, + point_curve: vec![[0.0, 10.0], [128.0, 140.0], [255.0, 250.0]], + ..Default::default() + }, + false, + ); }); time("camera raw: sharpen + NR + grain + vignette", &only, || { let mut a = px.clone(); - develop(&mut a, w, h, &CameraRaw { sharpen_amount: 40.0, noise_luminance: 30.0, noise_color: 25.0, grain_amount: 20.0, vignette_amount: -30.0, ..Default::default() }, false); + develop( + &mut a, + w, + h, + &CameraRaw { sharpen_amount: 40.0, noise_luminance: 30.0, noise_color: 25.0, grain_amount: 20.0, vignette_amount: -30.0, ..Default::default() }, + false, + ); }); time("lens correction (distortion, CA, vignette, persp)", &only, || { - lens::correct(&s, r, &LensCorrection { profile: Some(lens::generic_profile(24.0)), distortion: 10.0, red_cyan: 20.0, vignette_amount: 30.0, vertical: -20.0, angle: 1.5, ..Default::default() }) + lens::correct( + &s, + r, + &LensCorrection { + profile: Some(lens::generic_profile(24.0)), + distortion: 10.0, + red_cyan: 20.0, + vignette_amount: 30.0, + vertical: -20.0, + angle: 1.5, + ..Default::default() + }, + ) }); time("adaptive wide angle (fisheye, 2 constraints)", &only, || { let p = WideAngle { model: WideModel::Fisheye, focal_length: 12.0, - constraints: vec![Constraint { a: [500.0, 600.0], b: [5500.0, 600.0], orientation: Orientation::Horizontal }, Constraint { a: [700.0, 500.0], b: [700.0, 3500.0], orientation: Orientation::Vertical }], + constraints: vec![ + Constraint { a: [500.0, 600.0], b: [5500.0, 600.0], orientation: Orientation::Horizontal }, + Constraint { a: [700.0, 500.0], b: [700.0, 3500.0], orientation: Orientation::Vertical }, + ], ..Default::default() }; wideangle::apply(&s, r, &p, Interp::Bicubic) }); time("hdr: merge 3 exposures + local adaptation", &only, || { - let shots: Vec> = [0.5f32, 1.0, 2.0].iter().map(|k| px.iter().map(|q| [(q[0] * k).min(1.0), (q[1] * k).min(1.0), (q[2] * k).min(1.0), 1.0]).collect()).collect(); + let shots: Vec> = + [0.5f32, 1.0, 2.0].iter().map(|k| px.iter().map(|q| [(q[0] * k).min(1.0), (q[1] * k).min(1.0), (q[2] * k).min(1.0), 1.0]).collect()).collect(); let refs: Vec<&[[f32; 4]]> = shots.iter().map(Vec::as_slice).collect(); let t0 = Instant::now(); let mut m = hdr::merge(w, h, &refs, &MergeOptions { exposures: vec![0.5, 1.0, 2.0], remove_ghosts: true, ghost_base: None, response: None }); @@ -85,16 +134,43 @@ fn main() { time("crop and straighten: detection", &only, || photocraft_algo::scancrop::find_photos(w, h, &px)); time("panorama: 4 × 2 MP register + blend", &only, || { let (sw, sh) = (1700usize, 1200usize); - let views: Vec> = (0..4).map(|k| (0..sw * sh).map(|i| { let q = px[(i / sw + 300) * w + i % sw + 1100 * k]; 0.3 * q[0] + 0.6 * q[1] + 0.1 * q[2] }).collect()).collect(); + let views: Vec> = (0..4) + .map(|k| { + (0..sw * sh) + .map(|i| { + let q = px[(i / sw + 300) * w + i % sw + 1100 * k]; + 0.3 * q[0] + 0.6 * q[1] + 0.1 * q[2] + }) + .collect() + }) + .collect(); let prepared: Vec = views.iter().map(|v| (sw, sh, v.clone(), None)).collect(); let t0 = Instant::now(); let feats = panorama::detect_many(&prepared, 800); let matches = panorama::match_pairs(&feats); let al = panorama::align(&feats, &matches, &AlignOptions { layout: Layout::Reposition, focal: None, reference: None, geometric: false }); - println!(" register {:.1} ms ({} pairs, placed {})", t0.elapsed().as_secs_f64() * 1000.0, matches.len(), al.as_ref().map_or(0, |a| a.placements.iter().flatten().count())); - let rois: Vec = views.iter().enumerate().map(|(k, v)| RoiImage { x0: 1100 * k as i32, y0: 0, w: sw, h: sh, ch: 1, px: v.clone(), alpha: vec![1.0; sw * sh] }).collect(); + println!( + " register {:.1} ms ({} pairs, placed {})", + t0.elapsed().as_secs_f64() * 1000.0, + matches.len(), + al.as_ref().map_or(0, |a| a.placements.iter().flatten().count()) + ); + let rois: Vec = views + .iter() + .enumerate() + .map(|(k, v)| RoiImage { x0: 1100 * k as i32, y0: 0, w: sw, h: sh, ch: 1, px: v.clone(), alpha: vec![1.0; sw * sh] }) + .collect(); let cw = 1100 * 3 + sw; - let wts: Vec> = (0..4i32).map(|k| (0..sw * sh).map(|i| { let x = (i % sw) as i32 + 1100 * k; if (x / 1100).min(3) == k { 1.0 } else { 0.0 } }).collect()).collect(); + let wts: Vec> = (0..4i32) + .map(|k| { + (0..sw * sh) + .map(|i| { + let x = (i % sw) as i32 + 1100 * k; + if (x / 1100).min(3) == k { 1.0 } else { 0.0 } + }) + .collect() + }) + .collect(); let t1 = Instant::now(); let _ = panorama::multiband(cw, sh, &rois, &wts, panorama::blend_levels(cw, sh)); println!(" multiband {:.1} ms", t1.elapsed().as_secs_f64() * 1000.0); diff --git a/crates/algo/examples/bench_retouch.rs b/crates/algo/examples/bench_retouch.rs index b36c8af929..50b5f45e8b 100644 --- a/crates/algo/examples/bench_retouch.rs +++ b/crates/algo/examples/bench_retouch.rs @@ -9,11 +9,13 @@ fn main() { for d in [50usize, 100, 200, 300] { let n = d + 6; let (w, h, ch) = (n, n, 4); - let img: Vec = (0..w * h).flat_map(|i| { - let (x, y) = ((i % w) as f32, (i / w) as f32); - let v = 0.5 + 0.2 * (x * 0.1).sin() * (y * 0.13).cos(); - [v, v * 0.8, 0.3, 1.0] - }).collect(); + let img: Vec = (0..w * h) + .flat_map(|i| { + let (x, y) = ((i % w) as f32, (i / w) as f32); + let v = 0.5 + 0.2 * (x * 0.1).sin() * (y * 0.13).cos(); + [v, v * 0.8, 0.3, 1.0] + }) + .collect(); let src: Vec = img.iter().map(|v| v * 0.7).collect(); let r = d as f32 / 2.0; let mask: Vec = (0..w * h).map(|i| ((i % w) as f32 - n as f32 / 2.0).hypot((i / w) as f32 - n as f32 / 2.0) < r).collect(); @@ -23,11 +25,13 @@ fn main() { // Completion on a region with a margin, like Spot Healing. let m = d + 16; let (cw, chh) = (d + 2 * m, d + 2 * m); - let cimg: Vec = (0..cw * chh).flat_map(|i| { - let (x, y) = ((i % cw) as f32, (i / cw) as f32); - let v = 0.5 + 0.2 * (x * 0.1).sin() * (y * 0.13).cos(); - [v, v * 0.8, 0.3, 1.0] - }).collect(); + let cimg: Vec = (0..cw * chh) + .flat_map(|i| { + let (x, y) = ((i % cw) as f32, (i / cw) as f32); + let v = 0.5 + 0.2 * (x * 0.1).sin() * (y * 0.13).cos(); + [v, v * 0.8, 0.3, 1.0] + }) + .collect(); let c = cw as f32 / 2.0; let hole: Vec = (0..cw * chh).map(|i| ((i % cw) as f32 - c).hypot((i / cw) as f32 - c) < r + 2.0).collect(); let t = Instant::now(); diff --git a/crates/algo/src/artistic.rs b/crates/algo/src/artistic.rs index b77e634308..5aab3564e9 100644 --- a/crates/algo/src/artistic.rs +++ b/crates/algo/src/artistic.rs @@ -97,7 +97,11 @@ pub const GALLERY_CATEGORIES: [&str; 6] = ["Artistic", "Brush Strokes", "Distort /// One parameter of a gallery filter. #[derive(Clone, Debug, PartialEq)] pub enum GalleryParamKind { - Range { min: f32, max: f32, default: f32 }, + Range { + min: f32, + max: f32, + default: f32, + }, /// Choices; the first is the default. Stored as the index. Choice(Vec<&'static str>), Bool(bool), diff --git a/crates/algo/src/artistic_fx.rs b/crates/algo/src/artistic_fx.rs index ac33856707..4374e94b1f 100644 --- a/crates/algo/src/artistic_fx.rs +++ b/crates/algo/src/artistic_fx.rs @@ -673,7 +673,8 @@ fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) { let eb = g("edgeBrightness") / 50.0; for i in 0..win.c.len() { let k = clamp01(edge[i] * 6.0); - win.c[i] = if eb >= 0.5 { map(win.c[i], |v| v + (1.0 - v) * k * (eb - 0.5) * 2.0) } else { map(win.c[i], |v| v * (1.0 - k * (0.5 - eb) * 2.0)) }; + win.c[i] = + if eb >= 0.5 { map(win.c[i], |v| v + (1.0 - v) * k * (eb - 0.5) * 2.0) } else { map(win.c[i], |v| v * (1.0 - k * (0.5 - eb) * 2.0)) }; } } F::AngledStrokes | F::Crosshatch => { @@ -863,10 +864,12 @@ fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) { let r = (g("strokeLength") / 2.0) as usize; let bal = g("lightDarkBalance") / 100.0; let l = win.lum(); - let mut n: Vec = (0..win.c.len()).map(|i| { - let (x, y) = win.xy(i); - hash01(x, y, 0, 151) - }).collect(); + let mut n: Vec = (0..win.c.len()) + .map(|i| { + let (x, y) = win.xy(i); + hash01(x, y, 0, 151) + }) + .collect(); line_blur(&mut n, 1, w, h, direction(e.choice("strokeDirection")), r); let sd = 0.2887 / ((2 * r + 1) as f32).sqrt(); for i in 0..win.c.len() { @@ -1034,7 +1037,11 @@ fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) { let q = match ty { "soft" => [p[0] + hc(0) * it * 0.4, p[1] + hc(1) * it * 0.4, p[2] + hc(2) * it * 0.4], "sprinkles" => { - if hash01(x, y, 3, 211) < it * 0.15 { bg } else { p } + if hash01(x, y, 3, 211) < it * 0.15 { + bg + } else { + p + } } "clumped" => map(p, |v| v + (vnoise(x as f32 / 2.0, y as f32 / 2.0, 212) - 0.5) * it * 1.2), "contrasty" => map(p, |v| (v - 0.5) * (1.0 + it * 2.0) + 0.5 + hc(4) * it * 0.5), @@ -1049,7 +1056,11 @@ fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) { "horizontal" => map(p, |v| v + (vnoise(x as f32 / 25.0, y as f32, 216) - 0.5) * it), "vertical" => map(p, |v| v + (vnoise(x as f32, y as f32 / 25.0, 217) - 0.5) * it), "speckle" => { - if hash01(x, y, 6, 211) < it * 0.1 { mix(p, fg, 0.85) } else { p } + if hash01(x, y, 6, 211) < it * 0.1 { + mix(p, fg, 0.85) + } else { + p + } } _ => [p[0] + hc(0) * it * 0.8, p[1] + hc(1) * it * 0.8, p[2] + hc(2) * it * 0.8], }; @@ -1191,7 +1202,14 @@ mod tests { s.fill_rect(Rect::new(0, y, w, y + 3), &[0.9, 0.8, 0.2, 1.0]); } let t = std::time::Instant::now(); - std::hint::black_box(crate::apply_in(&s, &FilterParams::AddNoise { amount: 10.0, distribution: crate::Distribution::Uniform, monochromatic: true, seed: 1 }, b, b, None, b)); + std::hint::black_box(crate::apply_in( + &s, + &FilterParams::AddNoise { amount: 10.0, distribution: crate::Distribution::Uniform, monochromatic: true, seed: 1 }, + b, + b, + None, + b, + )); println!("{:>18}: {:>7.0} ms", "(addNoise baseline)", t.elapsed().as_secs_f64() * 1000.0); let mut total = 0.0; for f in crate::GalleryFilter::ALL { diff --git a/crates/algo/src/camera_raw.rs b/crates/algo/src/camera_raw.rs index dfd4c250db..0480d43457 100644 --- a/crates/algo/src/camera_raw.rs +++ b/crates/algo/src/camera_raw.rs @@ -779,11 +779,11 @@ fn detail(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, ps: f32) { let s = gauss(&y, w, h, 1.0 * ps.max(0.5)); let t = p.sharpen_masking / 100.0 * 0.08; crate::photo_util::par_map(w * h, |i| { - let (x, yy) = (i % w, i / w); - let gx = s[yy * w + (x + 1).min(w - 1)] - s[yy * w + x.saturating_sub(1)]; - let gy = s[(yy + 1).min(h - 1) * w + x] - s[yy.saturating_sub(1) * w + x]; - smoothstep(t * 0.5, t + 1e-4, gx.hypot(gy)) - }) + let (x, yy) = (i % w, i / w); + let gx = s[yy * w + (x + 1).min(w - 1)] - s[yy * w + x.saturating_sub(1)]; + let gy = s[(yy + 1).min(h - 1) * w + x] - s[yy.saturating_sub(1) * w + x]; + smoothstep(t * 0.5, t + 1e-4, gx.hypot(gy)) + }) }); let amt = p.sharpen_amount / 100.0 * 1.2; let det = p.sharpen_detail / 100.0; @@ -916,7 +916,13 @@ mod tests { let vib = run(CameraRaw { vibrance: 100.0, ..Default::default() }); let spread = |q: [f32; 4]| q[0].max(q[1]).max(q[2]) - q[0].min(q[1]).min(q[2]); assert!(spread(vib[w * h / 2]) > spread(base[w * h / 2])); - for p in [CameraRaw { clarity: 80.0, ..Default::default() }, CameraRaw { texture: 80.0, ..Default::default() }, CameraRaw { dehaze: 60.0, ..Default::default() }, CameraRaw { dehaze: -60.0, ..Default::default() }, CameraRaw { whites: 50.0, blacks: -50.0, ..Default::default() }] { + for p in [ + CameraRaw { clarity: 80.0, ..Default::default() }, + CameraRaw { texture: 80.0, ..Default::default() }, + CameraRaw { dehaze: 60.0, ..Default::default() }, + CameraRaw { dehaze: -60.0, ..Default::default() }, + CameraRaw { whites: 50.0, blacks: -50.0, ..Default::default() }, + ] { let out = run(p.clone()); assert!(out.iter().all(|q| q[..3].iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v))), "{p:?}"); assert_ne!(out, base); @@ -954,8 +960,16 @@ mod tests { develop(&mut c, w, h, &CameraRaw { point_curve: vec![[0.0, 30.0], [255.0, 255.0]], curve_shadows: 50.0, ..Default::default() }, false); assert!(c.iter().all(|q| q[1] >= 30.0 / 255.0 - 1e-4)); // Noise reduction reduces variance; sharpening increases it; grain is deterministic. - let noisy: Vec<[f32; 4]> = (0..w * h).map(|i| { let n = hash01(i as i64, 0, 3) * 0.2; [0.4 + n, 0.4 + n, 0.4 + n, 1.0] }).collect(); - let var = |a: &[[f32; 4]]| { let m = mean(a, 1); a.iter().map(|q| (q[1] - m).powi(2)).sum::() / a.len() as f32 }; + let noisy: Vec<[f32; 4]> = (0..w * h) + .map(|i| { + let n = hash01(i as i64, 0, 3) * 0.2; + [0.4 + n, 0.4 + n, 0.4 + n, 1.0] + }) + .collect(); + let var = |a: &[[f32; 4]]| { + let m = mean(a, 1); + a.iter().map(|q| (q[1] - m).powi(2)).sum::() / a.len() as f32 + }; let mut nr = noisy.clone(); develop(&mut nr, w, h, &CameraRaw { noise_luminance: 80.0, ..Default::default() }, false); assert!(var(&nr) < var(&noisy) * 0.5); diff --git a/crates/algo/src/features.rs b/crates/algo/src/features.rs index a95fb56737..ab98de757e 100644 --- a/crates/algo/src/features.rs +++ b/crates/algo/src/features.rs @@ -136,7 +136,8 @@ pub fn harris(w: usize, h: usize, img: &[f32], max: usize, min_dist: f32, valid: let mut out = Vec::new(); for c in cand { let (gx, gy) = ((c.x / cell) as usize, (c.y / cell) as usize); - let near = (gy.saturating_sub(1)..=(gy + 1).min(gh - 1)).any(|yy| (gx.saturating_sub(1)..=(gx + 1).min(gw - 1)).any(|xx| grid[yy * gw + xx].iter().any(|&(px, py)| (px - c.x).hypot(py - c.y) < min_dist))); + let near = (gy.saturating_sub(1)..=(gy + 1).min(gh - 1)) + .any(|yy| (gx.saturating_sub(1)..=(gx + 1).min(gw - 1)).any(|xx| grid[yy * gw + xx].iter().any(|&(px, py)| (px - c.x).hypot(py - c.y) < min_dist))); if near { continue; } @@ -311,7 +312,8 @@ pub fn fit(model: Model, src: &[[f64; 2]], dst: &[[f64; 2]]) -> Option, f64)> = src.iter().zip(dst).flat_map(|(s, d)| [(vec![s[0], -s[1], 1.0, 0.0], d[0]), (vec![s[1], s[0], 0.0, 1.0], d[1])]).collect(); + let rows: Vec<(Vec, f64)> = + src.iter().zip(dst).flat_map(|(s, d)| [(vec![s[0], -s[1], 1.0, 0.0], d[0]), (vec![s[1], s[0], 0.0, 1.0], d[1])]).collect(); let v = least_squares(&rows, 4)?; Some(Homography([v[0], -v[1], v[2], v[1], v[0], v[3], 0.0, 0.0, 1.0])) } @@ -396,7 +398,15 @@ pub fn ransac(model: Model, src: &[[f64; 2]], dst: &[[f64; 2]], iters: usize, th /// Register `moving` onto `reference` (both `w × h` luminance; optional validity masks for /// transparent areas). Returns the model mapping `moving` coordinates to `reference` /// coordinates and the inlier count. -pub fn register(w: usize, h: usize, reference: &[f32], moving: &[f32], valid_ref: Option<&[bool]>, valid_mov: Option<&[bool]>, model: Model) -> Option<(Homography, usize)> { +pub fn register( + w: usize, + h: usize, + reference: &[f32], + moving: &[f32], + valid_ref: Option<&[bool]>, + valid_mov: Option<&[bool]>, + model: Model, +) -> Option<(Homography, usize)> { let upright = model == Model::Translation; let min_dist = (w.max(h) as f32 / 60.0).clamp(4.0, 24.0); let ca = harris(w, h, reference, 600, min_dist, valid_ref); @@ -479,20 +489,26 @@ mod tests { fn fits_are_exact_on_clean_data() { let src = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0], [5.0, 3.0]]; let truth = Homography([1.1, 0.05, 3.0, -0.02, 0.95, -2.0, 0.001, 0.0005, 1.0]); - let dst: Vec<[f64; 2]> = src.iter().map(|p| { - let (x, y) = truth.apply(p[0], p[1]); - [x, y] - }).collect(); + let dst: Vec<[f64; 2]> = src + .iter() + .map(|p| { + let (x, y) = truth.apply(p[0], p[1]); + [x, y] + }) + .collect(); let h = fit(Model::Homography, &src, &dst).unwrap(); for p in &src { let (a, b) = (h.apply(p[0], p[1]), truth.apply(p[0], p[1])); assert!((a.0 - b.0).abs() < 1e-6 && (a.1 - b.1).abs() < 1e-6); } let sim = Homography([0.9, -0.2, 4.0, 0.2, 0.9, 1.0, 0.0, 0.0, 1.0]); - let dst: Vec<[f64; 2]> = src.iter().map(|p| { - let (x, y) = sim.apply(p[0], p[1]); - [x, y] - }).collect(); + let dst: Vec<[f64; 2]> = src + .iter() + .map(|p| { + let (x, y) = sim.apply(p[0], p[1]); + [x, y] + }) + .collect(); let h = fit(Model::Similarity, &src, &dst).unwrap(); assert!(h.0.iter().zip(sim.0).all(|(a, b)| (a - b).abs() < 1e-9)); } diff --git a/crates/algo/src/gallery.rs b/crates/algo/src/gallery.rs index 9cfff37640..1ccaa6e06c 100644 --- a/crates/algo/src/gallery.rs +++ b/crates/algo/src/gallery.rs @@ -35,10 +35,12 @@ fn variable_blur(src: &Image, out: Rect, ctx: &Ctx, smax: f32, sigma: impl Fn(f3 let (ow, oh) = (out.width() as usize, out.height() as usize); // Level σs: 0, then smax / 2^(LEVELS-1) … smax. let lv: Vec = std::iter::once(0.0).chain((0..LEVELS).map(|k| smax / 2f32.powi((LEVELS - 1 - k) as i32))).collect(); - let sig: Vec = (0..ow * oh).map(|i| { - let (x, y) = xy(out, i); - sigma(x as f32 + 0.5, y as f32 + 0.5).clamp(0.0, smax) - }).collect(); + let sig: Vec = (0..ow * oh) + .map(|i| { + let (x, y) = xy(out, i); + sigma(x as f32 + 0.5, y as f32 + 0.5).clamp(0.0, smax) + }) + .collect(); // Hat-function weight of level k for σ s. let weight = |k: usize, s: f32| -> f32 { let c = lv[k]; @@ -146,7 +148,8 @@ pub(crate) fn field(src: &Image, out: Rect, ctx: &Ctx, pins: &[FieldPin]) -> Vec return src.crop(out); } let smax = pins.iter().map(|p| sigma_of(p.blur)).fold(0.0, f32::max); - let pts: Vec<(f32, f32, f32)> = pins.iter().map(|p| (b.x0 as f32 + p.x * b.width() as f32, b.y0 as f32 + p.y * b.height() as f32, sigma_of(p.blur))).collect(); + let pts: Vec<(f32, f32, f32)> = + pins.iter().map(|p| (b.x0 as f32 + p.x * b.width() as f32, b.y0 as f32 + p.y * b.height() as f32, sigma_of(p.blur))).collect(); variable_blur(src, out, ctx, smax, |x, y| { let (mut ws, mut s) = (0.0f32, 0.0f32); for &(px, py, sg) in &pts { diff --git a/crates/algo/src/hdr.rs b/crates/algo/src/hdr.rs index b05e58ce58..ddd9a8fc73 100644 --- a/crates/algo/src/hdr.rs +++ b/crates/algo/src/hdr.rs @@ -277,24 +277,24 @@ pub fn merge(w: usize, h: usize, imgs: &[&[[f32; 4]]], opts: &MergeOptions) -> M if opts.remove_ghosts && p > 1 { // Weighted variance of per-exposure log radiance (luminance). let score: Vec = crate::photo_util::par_map(w * h, |i| { - let (mut s, mut s2, mut sw) = (0.0f64, 0.0f64, 0.0f64); - for j in 0..p { - let q = imgs[j][i]; - let l = luma(q); - let wt = hat(l as f64 * 255.0); - if wt < 20.0 { - continue; - } - let lr = (0..3).map(|c| 0.33 * (g_at(&response[c], q[c]) - log_dt[j])).sum::(); - s += wt * lr; - s2 += wt * lr * lr; - sw += wt; + let (mut s, mut s2, mut sw) = (0.0f64, 0.0f64, 0.0f64); + for j in 0..p { + let q = imgs[j][i]; + let l = luma(q); + let wt = hat(l as f64 * 255.0); + if wt < 20.0 { + continue; } - if sw <= 0.0 { - return 0.0; - } - let m = s / sw; - ((s2 / sw - m * m).max(0.0)).sqrt() as f32 + let lr = (0..3).map(|c| 0.33 * (g_at(&response[c], q[c]) - log_dt[j])).sum::(); + s += wt * lr; + s2 += wt * lr * lr; + sw += wt; + } + if sw <= 0.0 { + return 0.0; + } + let m = s / sw; + ((s2 / sw - m * m).max(0.0)).sqrt() as f32 }); for i in 0..w * h { ghost[i] = if score[i] > 0.35 { 1.0 } else { 0.0 }; @@ -459,7 +459,8 @@ mod tests { let r = est[k] / est[k - 1]; assert!((2.0..8.0).contains(&r), "ratio {r} ({est:?})"); } - let m = merge(w, h, &refs, &MergeOptions { exposures: dts.iter().map(|d| *d as f64).collect(), remove_ghosts: false, ghost_base: None, response: None }); + let m = + merge(w, h, &refs, &MergeOptions { exposures: dts.iter().map(|d| *d as f64).collect(), remove_ghosts: false, ghost_base: None, response: None }); // Radiance proportional to the scene: check log-ratios across the 12-stop ramp. let probe = |x: usize| m.px[(h / 2) * w + x][1] as f64 / sc[(h / 2) * w + x][1] as f64; let k0 = probe(w / 2); @@ -498,7 +499,12 @@ mod tests { #[test] fn mtb_finds_shifts() { let (w, h) = (128usize, 96usize); - let img: Vec = (0..w * h).map(|i| { let (x, y) = ((i % w) as f32, (i / w) as f32); 0.5 + 0.4 * ((x * 0.21).sin() * (y * 0.17).cos()) }).collect(); + let img: Vec = (0..w * h) + .map(|i| { + let (x, y) = ((i % w) as f32, (i / w) as f32); + 0.5 + 0.4 * ((x * 0.21).sin() * (y * 0.17).cos()) + }) + .collect(); let (dx, dy) = (5i32, -3i32); let shifted: Vec = (0..w * h) .map(|i| { @@ -515,7 +521,12 @@ mod tests { fn tone_mapping_methods_produce_display_range() { let (w, h) = (96usize, 32usize); let sc = scene(w, h); - for method in [ToneMethod::LocalAdaptation(HdrToning { radius: 7.0, strength: 0.52, ..Default::default() }), ToneMethod::ExposureGamma { exposure: 0.0, gamma: 1.0 }, ToneMethod::HighlightCompression, ToneMethod::EqualizeHistogram] { + for method in [ + ToneMethod::LocalAdaptation(HdrToning { radius: 7.0, strength: 0.52, ..Default::default() }), + ToneMethod::ExposureGamma { exposure: 0.0, gamma: 1.0 }, + ToneMethod::HighlightCompression, + ToneMethod::EqualizeHistogram, + ] { let mut px: Vec<[f32; 4]> = sc.iter().map(|r| [r[0], r[1], r[2], 1.0]).collect(); tone_map(&mut px, w, h, &method); assert!(px.iter().all(|q| q[..3].iter().all(|v| (0.0..=1.0).contains(v)))); diff --git a/crates/algo/src/lens.rs b/crates/algo/src/lens.rs index f77b9f4500..13933b0f7c 100644 --- a/crates/algo/src/lens.rs +++ b/crates/algo/src/lens.rs @@ -234,9 +234,9 @@ pub fn auto_scale(lc: &LensCorrection, frame: Rect) -> f64 { let (x0, y0, x1, y1) = (frame.x0 as f64, frame.y0 as f64, frame.x1 as f64, frame.y1 as f64); (0..=40).all(|i| { let t = i as f64 / 40.0; - [(x0 + t * (x1 - x0), y0), (x0 + t * (x1 - x0), y1), (x0, y0 + t * (y1 - y0)), (x1, y0 + t * (y1 - y0))].iter().all(|&(x, y)| { - m.sample(x, y).is_some_and(|s| s.p[0] >= x0 - 0.5 && s.p[0] <= x1 + 0.5 && s.p[1] >= y0 - 0.5 && s.p[1] <= y1 + 0.5) - }) + [(x0 + t * (x1 - x0), y0), (x0 + t * (x1 - x0), y1), (x0, y0 + t * (y1 - y0)), (x1, y0 + t * (y1 - y0))] + .iter() + .all(|&(x, y)| m.sample(x, y).is_some_and(|s| s.p[0] >= x0 - 0.5 && s.p[0] <= x1 + 0.5 && s.p[1] >= y0 - 0.5 && s.p[1] <= y1 + 0.5)) }) }; if covers(lc.scale) { @@ -366,7 +366,13 @@ pub fn remap(src: &Surface, frame: Rect, out_area: Rect, edge: EdgeMode, map: &( let alpha = if sfmt.alpha { bicubic(n_in - 1, s.p).clamp(0.0, 1.0) } else { 1.0 }; let mut col = [0.0f32; 8]; for (c, v) in col.iter_mut().enumerate().take(cc) { - let p = if rgb && c == 0 { s.pr } else if rgb && c == 2 { s.pb } else { s.p }; + let p = if rgb && c == 0 { + s.pr + } else if rgb && c == 2 { + s.pb + } else { + s.p + }; let pm = bicubic(c, p); let straight = if sfmt.alpha { if alpha > 1e-6 { pm / alpha } else { 0.0 } } else { pm }; let g = s.gain; diff --git a/crates/algo/src/lib.rs b/crates/algo/src/lib.rs index bb3a428cf7..0c1c0a9611 100644 --- a/crates/algo/src/lib.rs +++ b/crates/algo/src/lib.rs @@ -21,53 +21,53 @@ mod artistic; mod artistic_fx; mod blur; mod blur2; +pub mod camera_raw; +pub mod content_aware; mod denoise; mod distort; mod distort2; +pub mod exif; +pub mod features; mod fxutil; mod gallery; +pub mod hdr; mod image; +pub mod inpaint; +pub mod lens; +pub mod liquify; +pub mod matting; +mod noise; mod oil; +mod other; mod other2; +pub mod paint; +pub mod panorama; +pub mod perspective; +mod photo_util; mod pixelate; +pub mod poisson; +pub mod puppet; +pub mod pyramid; +pub mod quantize; mod render; pub mod render2; -mod stylize2; -mod video; -mod noise; -mod other; -pub mod paint; pub mod resample; -pub mod selection; -pub mod segment; -pub mod matting; -pub mod quantize; -pub mod stack; -pub mod tone; -pub mod trap; -pub mod transform; -pub mod warp; -pub mod liquify; -pub mod puppet; -pub mod perspective; -pub mod poisson; -pub mod inpaint; pub mod retouch; -pub mod seam; -pub mod content_aware; -pub mod features; -pub mod pyramid; -pub mod panorama; -pub mod hdr; -pub mod exif; -pub mod lens; -pub mod wideangle; pub mod scancrop; -pub mod camera_raw; -pub mod vanishing; -mod photo_util; +pub mod seam; +pub mod segment; +pub mod selection; mod sharpen; +pub mod stack; mod stylize; +mod stylize2; +pub mod tone; +pub mod transform; +pub mod trap; +pub mod vanishing; +mod video; +pub mod warp; +pub mod wideangle; pub use artistic::{GALLERY_CATEGORIES, GalleryEffect, GalleryFilter, GalleryParam, GalleryParamKind}; pub use image::{Edge, Image}; @@ -173,23 +173,58 @@ pub enum PolarMode { #[serde(tag = "filter", rename_all = "camelCase", rename_all_fields = "camelCase")] pub enum FilterParams { /// Radius = Gaussian standard deviation in pixels (0.1–1000). - GaussianBlur { radius: f32 }, + GaussianBlur { + radius: f32, + }, /// Box of `2·radius + 1` pixels. - BoxBlur { radius: f32 }, - MotionBlur { angle: f32, distance: f32 }, + BoxBlur { + radius: f32, + }, + MotionBlur { + angle: f32, + distance: f32, + }, /// Amount 1–100; centre as a fraction of the bounds (0.5, 0.5 = centre). - RadialBlur { amount: f32, method: RadialMethod, center_x: f32, center_y: f32 }, + RadialBlur { + amount: f32, + method: RadialMethod, + center_x: f32, + center_y: f32, + }, /// Radius px, threshold in levels. - SurfaceBlur { radius: f32, threshold: f32 }, + SurfaceBlur { + radius: f32, + threshold: f32, + }, /// Amount %, radius px, threshold levels. - UnsharpMask { amount: f32, radius: f32, threshold: f32 }, + UnsharpMask { + amount: f32, + radius: f32, + threshold: f32, + }, /// Amount %, radius px, noise reduction % (basic: Gaussian unsharp). - SmartSharpen { amount: f32, radius: f32, reduce_noise: f32 }, - HighPass { radius: f32 }, + SmartSharpen { + amount: f32, + radius: f32, + reduce_noise: f32, + }, + HighPass { + radius: f32, + }, /// Amount 0–400 %. - AddNoise { amount: f32, distribution: Distribution, monochromatic: bool, seed: u32 }, - Median { radius: f32 }, - DustAndScratches { radius: f32, threshold: f32 }, + AddNoise { + amount: f32, + distribution: Distribution, + monochromatic: bool, + seed: u32, + }, + Median { + radius: f32, + }, + DustAndScratches { + radius: f32, + threshold: f32, + }, /// Radius px; `preserve` is the dialog's Preserve (older params default to Squareness). Minimum { radius: f32, @@ -201,20 +236,37 @@ pub enum FilterParams { #[serde(default)] preserve: Preserve, }, - Offset { horizontal: i32, vertical: i32, undefined: UndefinedAreas }, - Mosaic { cell_size: f32 }, + Offset { + horizontal: i32, + vertical: i32, + undefined: UndefinedAreas, + }, + Mosaic { + cell_size: f32, + }, /// Angle degrees, height px, amount %. - Emboss { angle: f32, height: f32, amount: f32 }, + Emboss { + angle: f32, + height: f32, + amount: f32, + }, FindEdges, Solarize, Invert, Desaturate, /// Angle −999…999 degrees. - Twirl { angle: f32 }, + Twirl { + angle: f32, + }, /// Amount −100…100 %. - Pinch { amount: f32 }, + Pinch { + amount: f32, + }, /// Amount −100…100 %. - Spherize { amount: f32, mode: SpherizeMode }, + Spherize { + amount: f32, + mode: SpherizeMode, + }, Wave { generators: u32, wavelength_min: f32, @@ -226,32 +278,83 @@ pub enum FilterParams { seed: u32, }, /// Amount −999…999 %. - Ripple { amount: f32, size: RippleSize }, - PolarCoordinates { mode: PolarMode }, + Ripple { + amount: f32, + size: RippleSize, + }, + PolarCoordinates { + mode: PolarMode, + }, // ---- Pixelate ---- /// Max dot radius px (4–127); screen angles per channel in degrees. - ColorHalftone { max_radius: f32, angles: [f32; 4] }, + ColorHalftone { + max_radius: f32, + angles: [f32; 4], + }, /// Cell size px (3–300). - Crystallize { cell_size: f32, seed: u32 }, + Crystallize { + cell_size: f32, + seed: u32, + }, Facet, Fragment, - Mezzotint { kind: MezzotintType, seed: u32 }, + Mezzotint { + kind: MezzotintType, + seed: u32, + }, /// Cell size px (3–300); `background` is straight sRGB RGBA. - Pointillize { cell_size: f32, seed: u32, background: [f32; 4] }, + Pointillize { + cell_size: f32, + seed: u32, + background: [f32; 4], + }, // ---- Stylize ---- - Diffuse { mode: DiffuseMode, seed: u32 }, + Diffuse { + mode: DiffuseMode, + seed: u32, + }, /// Size px (2–255), depth (1–255). - Extrude { kind: ExtrudeType, size: f32, depth: f32, level_based: bool, solid_front: bool, mask_incomplete: bool, seed: u32 }, + Extrude { + kind: ExtrudeType, + size: f32, + depth: f32, + level_based: bool, + solid_front: bool, + mask_incomplete: bool, + seed: u32, + }, /// Photoshop's Oil Paint units: stylization 0.1–10, cleanliness 0–10, scale 0.1–10, /// bristle detail 0–10, lighting angle degrees, shine 0–10. - OilPaint { stylization: f32, cleanliness: f32, scale: f32, bristle_detail: f32, lighting: bool, angle: f32, shine: f32 }, + OilPaint { + stylization: f32, + cleanliness: f32, + scale: f32, + bristle_detail: f32, + lighting: bool, + angle: f32, + shine: f32, + }, /// Tiles along the shorter side (1–99), max offset % (1–99). - Tiles { count: u32, max_offset: f32, fill: TileFill, foreground: [f32; 4], background: [f32; 4], seed: u32 }, + Tiles { + count: u32, + max_offset: f32, + fill: TileFill, + foreground: [f32; 4], + background: [f32; 4], + seed: u32, + }, /// Level 0–255; `upper` traces the side above the level. - TraceContour { level: f32, upper: bool }, - Wind { method: WindMethod, from_right: bool, seed: u32 }, + TraceContour { + level: f32, + upper: bool, + }, + Wind { + method: WindMethod, + from_right: bool, + seed: u32, + }, // ---- Distort ---- /// Scales in % (−999…999; 100 % = 128 px at full map contrast). The map is @@ -265,25 +368,63 @@ pub enum FilterParams { map: Option>, }, /// Curve points `[t, offset]`: t 0–1 top→bottom, offset −1…1 of half the width. - Shear { points: Vec<[f32; 2]>, undefined: UndefinedAreas }, + Shear { + points: Vec<[f32; 2]>, + undefined: UndefinedAreas, + }, /// Amount −100…100, ridges 0–20. - ZigZag { amount: f32, ridges: f32, style: ZigZagStyle }, + ZigZag { + amount: f32, + ridges: f32, + style: ZigZagStyle, + }, // ---- Render ---- /// Variance 1–64, strength 1–64; colours straight sRGB RGBA. - Fibers { variance: f32, strength: f32, seed: u32, foreground: [f32; 4], background: [f32; 4] }, + Fibers { + variance: f32, + strength: f32, + seed: u32, + foreground: [f32; 4], + background: [f32; 4], + }, /// Brightness 10–300 %, centre as a fraction of the bounds. - LensFlare { brightness: f32, center_x: f32, center_y: f32, lens: LensType }, + LensFlare { + brightness: f32, + center_x: f32, + center_y: f32, + lens: LensType, + }, /// Gloss/metallic/exposure/ambience −100…100; bump height 0–100. - LightingEffects { lights: Vec, gloss: f32, metallic: f32, exposure: f32, ambience: f32, texture: TextureChannel, height: f32, white_is_high: bool }, + LightingEffects { + lights: Vec, + gloss: f32, + metallic: f32, + exposure: f32, + ambience: f32, + texture: TextureChannel, + height: f32, + white_is_high: bool, + }, // ---- Noise ---- /// Strength 0–10, the rest 0–100 %. - ReduceNoise { strength: f32, preserve_details: f32, reduce_color_noise: f32, sharpen_details: f32, remove_jpeg_artifact: bool }, + ReduceNoise { + strength: f32, + preserve_details: f32, + reduce_color_noise: f32, + sharpen_details: f32, + remove_jpeg_artifact: bool, + }, // ---- Blur ---- /// Radius 0.1–100 px, threshold 0.1–100 levels. - SmartBlur { radius: f32, threshold: f32, quality: BlurQuality, mode: SmartBlurMode }, + SmartBlur { + radius: f32, + threshold: f32, + quality: BlurQuality, + mode: SmartBlurMode, + }, /// Iris radius 0–100 px, `blades` 3–8, curvature 0–100, rotation degrees; /// focal distance 0–255; specular brightness 0–100 / threshold 0–255; noise 0–100. LensBlur { @@ -305,27 +446,57 @@ pub enum FilterParams { depth_map: Option>, }, /// Radius 5–1000 px. - ShapeBlur { radius: f32, shape: BlurShape }, + ShapeBlur { + radius: f32, + shape: BlurShape, + }, // ---- Blur Gallery ---- /// Centre fractions, angle degrees, focus/transition as fractions of the bounds' shorter side. - TiltShift { blur: f32, center_x: f32, center_y: f32, angle: f32, focus: f32, transition: f32 }, - IrisBlur { pins: Vec }, - FieldBlur { pins: Vec }, - SpinBlur { pins: Vec }, - PathBlur { paths: Vec }, + TiltShift { + blur: f32, + center_x: f32, + center_y: f32, + angle: f32, + focus: f32, + transition: f32, + }, + IrisBlur { + pins: Vec, + }, + FieldBlur { + pins: Vec, + }, + SpinBlur { + pins: Vec, + }, + PathBlur { + paths: Vec, + }, // ---- Other ---- /// 5×5 kernel (row-major, centre = index 12), divided by `scale`, plus `offset` levels. - Custom { kernel: Vec, scale: f32, offset: f32 }, - HsbHsl { input: HsbModel, output: HsbModel }, + Custom { + kernel: Vec, + scale: f32, + offset: f32, + }, + HsbHsl { + input: HsbModel, + output: HsbModel, + }, // ---- Video ---- /// Removes the even (or odd) lines and rebuilds them by interpolation (or duplication). - DeInterlace { eliminate_even: bool, interpolate: bool }, + DeInterlace { + eliminate_even: bool, + interpolate: bool, + }, NtscColors, /// Filter Gallery: a stack of gallery effects applied in order. - FilterGallery { effects: Vec }, + FilterGallery { + effects: Vec, + }, } impl FilterParams { @@ -528,37 +699,92 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec pixelate::mezzotint(src, out, ctx, *kind, *seed), FilterParams::Pointillize { cell_size, seed, background } => pixelate::pointillize(src, out, ctx, *cell_size, *seed, *background), FilterParams::Diffuse { mode, seed } => stylize2::diffuse(src, out, ctx, *mode, *seed), - FilterParams::Extrude { kind, size, depth, level_based, solid_front, mask_incomplete, seed } => { - stylize2::extrude(src, out, ctx, &stylize2::ExtrudeSpec { kind: *kind, size: *size, depth: *depth, level_based: *level_based, solid_front: *solid_front, mask_incomplete: *mask_incomplete, seed: *seed }) - } + FilterParams::Extrude { kind, size, depth, level_based, solid_front, mask_incomplete, seed } => stylize2::extrude( + src, + out, + ctx, + &stylize2::ExtrudeSpec { + kind: *kind, + size: *size, + depth: *depth, + level_based: *level_based, + solid_front: *solid_front, + mask_incomplete: *mask_incomplete, + seed: *seed, + }, + ), FilterParams::OilPaint { stylization, cleanliness, scale, bristle_detail, lighting, angle, shine } => oil::oil_paint( src, out, ctx, - &oil::OilSpec { stylization: *stylization, cleanliness: *cleanliness, scale: *scale, bristle_detail: *bristle_detail, lighting: *lighting, angle: *angle, shine: *shine }, + &oil::OilSpec { + stylization: *stylization, + cleanliness: *cleanliness, + scale: *scale, + bristle_detail: *bristle_detail, + lighting: *lighting, + angle: *angle, + shine: *shine, + }, ), - FilterParams::Tiles { count, max_offset, fill, foreground, background, seed } => stylize2::tiles(src, out, ctx, *count, *max_offset, *fill, (*foreground, *background), *seed), + FilterParams::Tiles { count, max_offset, fill, foreground, background, seed } => { + stylize2::tiles(src, out, ctx, *count, *max_offset, *fill, (*foreground, *background), *seed) + } FilterParams::TraceContour { level, upper } => stylize2::trace_contour(src, out, ctx, *level, *upper), FilterParams::Wind { method, from_right, seed } => stylize2::wind(src, out, ctx, *method, *from_right, *seed), - FilterParams::Displace { horizontal, vertical, stretch, undefined, map } => distort2::displace(src, out, ctx, (*horizontal, *vertical), *stretch, *undefined, map.as_deref()), + FilterParams::Displace { horizontal, vertical, stretch, undefined, map } => { + distort2::displace(src, out, ctx, (*horizontal, *vertical), *stretch, *undefined, map.as_deref()) + } FilterParams::Shear { points, undefined } => distort2::shear(src, out, ctx, points, *undefined), FilterParams::ZigZag { amount, ridges, style } => distort2::zigzag(src, out, ctx, *amount, *ridges, *style), - FilterParams::Fibers { variance, strength, seed, foreground, background } => render::fibers(src, out, ctx, *variance, *strength, *seed, (*foreground, *background)), + FilterParams::Fibers { variance, strength, seed, foreground, background } => { + render::fibers(src, out, ctx, *variance, *strength, *seed, (*foreground, *background)) + } FilterParams::LensFlare { brightness, center_x, center_y, lens } => render::lens_flare(src, out, ctx, *brightness, (*center_x, *center_y), *lens), FilterParams::LightingEffects { lights, gloss, metallic, exposure, ambience, texture, height, white_is_high } => render::lighting( src, out, ctx, - &render::LightingSpec { lights, gloss: *gloss, metallic: *metallic, exposure: *exposure, ambience: *ambience, texture: *texture, height: *height, white_is_high: *white_is_high }, + &render::LightingSpec { + lights, + gloss: *gloss, + metallic: *metallic, + exposure: *exposure, + ambience: *ambience, + texture: *texture, + height: *height, + white_is_high: *white_is_high, + }, ), FilterParams::ReduceNoise { strength, preserve_details, reduce_color_noise, sharpen_details, remove_jpeg_artifact } => denoise::reduce_noise( src, out, ctx, - &denoise::DenoiseSpec { strength: *strength, preserve: *preserve_details, color: *reduce_color_noise, sharpen: *sharpen_details, jpeg: *remove_jpeg_artifact }, + &denoise::DenoiseSpec { + strength: *strength, + preserve: *preserve_details, + color: *reduce_color_noise, + sharpen: *sharpen_details, + jpeg: *remove_jpeg_artifact, + }, ), FilterParams::SmartBlur { radius, threshold, quality, mode } => blur2::smart_blur(src, out, ctx, *radius, *threshold, *quality, *mode), - FilterParams::LensBlur { radius, blades, curvature, rotation, depth, focal_distance, invert_depth, brightness, threshold, noise, distribution, monochromatic, seed, depth_map } => blur2::lens_blur( + FilterParams::LensBlur { + radius, + blades, + curvature, + rotation, + depth, + focal_distance, + invert_depth, + brightness, + threshold, + noise, + distribution, + monochromatic, + seed, + depth_map, + } => blur2::lens_blur( src, out, ctx, @@ -580,7 +806,9 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec blur2::shape_blur(src, out, ctx, *radius, *shape), - FilterParams::TiltShift { blur, center_x, center_y, angle, focus, transition } => gallery::tilt_shift(src, out, ctx, *blur, (*center_x, *center_y), *angle, *focus, *transition), + FilterParams::TiltShift { blur, center_x, center_y, angle, focus, transition } => { + gallery::tilt_shift(src, out, ctx, *blur, (*center_x, *center_y), *angle, *focus, *transition) + } FilterParams::IrisBlur { pins } => gallery::iris(src, out, ctx, pins), FilterParams::FieldBlur { pins } => gallery::field(src, out, ctx, pins), FilterParams::SpinBlur { pins } => gallery::spin(src, out, ctx, pins), @@ -640,7 +868,15 @@ fn auto_tile(params: &FilterParams) -> i32 { } /// [`apply`] / [`apply_in`] with an explicit tile size (tests use it to check tile independence). -pub fn apply_tiled(surface: &Surface, params: &FilterParams, area: Rect, bounds: Rect, selection: Option<&Surface>, tile: i32, extent: Option) -> Surface { +pub fn apply_tiled( + surface: &Surface, + params: &FilterParams, + area: Rect, + bounds: Rect, + selection: Option<&Surface>, + tile: i32, + extent: Option, +) -> Surface { let mut out = surface.clone(); if area.is_empty() { return out; diff --git a/crates/algo/src/liquify.rs b/crates/algo/src/liquify.rs index 76301fb327..0ccfc544c4 100644 --- a/crates/algo/src/liquify.rs +++ b/crates/algo/src/liquify.rs @@ -494,7 +494,8 @@ pub fn apply_liquify(src: &Surface, field: &LiquifyField) -> Surface { if !moved { return None; } - let foot = Rect::new(fx0.floor() as i32 - 3, fy0.floor() as i32 - 3, fx1.ceil() as i32 + 3, fy1.ceil() as i32 + 3).intersect(&t.inflate(maxd)).intersect(&b); + let foot = + Rect::new(fx0.floor() as i32 - 3, fy0.floor() as i32 - 3, fx1.ceil() as i32 + 3, fy1.ceil() as i32 + 3).intersect(&t.inflate(maxd)).intersect(&b); if foot.is_empty() { return None; } @@ -665,7 +666,12 @@ impl ProxyImage { pub fn proxy_rect(&self, r: Rect) -> [usize; 4] { let s = self.scale; let f = |v: i32, o: i32| (f64::from(v - o) / s).max(0.0); - [f(r.x0, self.bounds.x0).floor() as usize, f(r.y0, self.bounds.y0).floor() as usize, (f(r.x1, self.bounds.x0).ceil() as usize + 1).min(self.w), (f(r.y1, self.bounds.y0).ceil() as usize + 1).min(self.h)] + [ + f(r.x0, self.bounds.x0).floor() as usize, + f(r.y0, self.bounds.y0).floor() as usize, + (f(r.x1, self.bounds.x0).ceil() as usize + 1).min(self.w), + (f(r.y1, self.bounds.y0).ceil() as usize + 1).min(self.h), + ] } } diff --git a/crates/algo/src/matting.rs b/crates/algo/src/matting.rs index 256401b7ab..f55fe2f780 100644 --- a/crates/algo/src/matting.rs +++ b/crates/algo/src/matting.rs @@ -77,7 +77,11 @@ impl RefineParams { } /// How far the refined mask can extend beyond the original. pub fn spread(&self) -> i32 { - (if self.uses_image() { self.radius.ceil() as i32 } else { 0 }) + (3.0 * self.smooth_sigma()).ceil() as i32 + (3.0 * self.feather_sigma()).ceil() as i32 + self.shift_px().max(0) + 1 + (if self.uses_image() { self.radius.ceil() as i32 } else { 0 }) + + (3.0 * self.smooth_sigma()).ceil() as i32 + + (3.0 * self.feather_sigma()).ceil() as i32 + + self.shift_px().max(0) + + 1 } } @@ -163,7 +167,11 @@ pub fn guided_filter_color(guide: &RgbImage, p: &[f32], r: usize, eps: f32) -> V let (a0, a1, a2) = if det.abs() < 1e-30 { (0.0, 0.0, 0.0) } else { - ((i00 * cov[0] + i01 * cov[1] + i02 * cov[2]) / det, (i01 * cov[0] + i11 * cov[1] + i12 * cov[2]) / det, (i02 * cov[0] + i12 * cov[1] + i22 * cov[2]) / det) + ( + (i00 * cov[0] + i01 * cov[1] + i02 * cov[2]) / det, + (i01 * cov[0] + i11 * cov[1] + i12 * cov[2]) / det, + (i02 * cov[0] + i12 * cov[1] + i22 * cov[2]) / det, + ) }; av[0][i] = a0 as f32; av[1][i] = a1 as f32; @@ -708,7 +716,13 @@ mod tests { s.fill_rect(Rect::new(0, 0, 10, 8), &[1.0, 0.0, 0.0, 1.0]); s.fill_rect(Rect::new(10, 0, 20, 8), &[0.5, 0.0, 0.5, 1.0]); s.fill_rect(Rect::new(11, 0, 20, 8), &[0.0, 0.0, 1.0, 1.0]); - let mask: Vec = (0..20 * 8).map(|i| match i % 20 { x if x < 10 => 255, 10 => 128, _ => 0 }).collect(); + let mask: Vec = (0..20 * 8) + .map(|i| match i % 20 { + x if x < 10 => 255, + 10 => 128, + _ => 0, + }) + .collect(); let a = Region { bbox: Rect::new(0, 0, 20, 8), mask }; let d = decontaminate(&s, &a, 3.0, 100.0); let p = d.pixel(10, 4); diff --git a/crates/algo/src/other.rs b/crates/algo/src/other.rs index dd34e17a00..de8c3aec8f 100644 --- a/crates/algo/src/other.rs +++ b/crates/algo/src/other.rs @@ -7,13 +7,7 @@ use crate::{Ctx, Preserve, UndefinedAreas}; /// Minimum or Maximum with the dialog's Preserve option: a square (`min_max`) or a disc /// (`min_max_round`). -pub(crate) fn min_max_preserve( - src: &Image, - out: Rect, - radius: f32, - max: bool, - preserve: Preserve, -) -> Vec { +pub(crate) fn min_max_preserve(src: &Image, out: Rect, radius: f32, max: bool, preserve: Preserve) -> Vec { match preserve { Preserve::Squareness => min_max(src, out, radius, max), Preserve::Roundness => min_max_round(src, out, radius, max), diff --git a/crates/algo/src/other2.rs b/crates/algo/src/other2.rs index 59682f3616..b5a16f06ea 100644 --- a/crates/algo/src/other2.rs +++ b/crates/algo/src/other2.rs @@ -12,7 +12,8 @@ use crate::{Ctx, HsbModel}; pub(crate) fn custom(src: &Image, out: Rect, ctx: &Ctx, kernel: &[f32], scale: f32, offset: f32) -> Vec { let n = src.ch; let cc = ncol(ctx, n); - let k: Vec<(i32, i32, f32)> = (0..25).filter_map(|i| kernel.get(i).copied().filter(|v| *v != 0.0).map(|v| (i as i32 % 5 - 2, i as i32 / 5 - 2, v))).collect(); + let k: Vec<(i32, i32, f32)> = + (0..25).filter_map(|i| kernel.get(i).copied().filter(|v| *v != 0.0).map(|v| (i as i32 % 5 - 2, i as i32 / 5 - 2, v))).collect(); let s = if scale == 0.0 { 1.0 } else { scale }; let off = offset / 255.0; let mut res = src.crop(out); diff --git a/crates/algo/src/paint.rs b/crates/algo/src/paint.rs index e6244d29f5..13bfc51102 100644 --- a/crates/algo/src/paint.rs +++ b/crates/algo/src/paint.rs @@ -221,7 +221,19 @@ mod tests { let a = Rect::new(0, 0, 11, 2); let mut sel = Surface::new(PixelFormat::GRAY8); sel.fill_rect(Rect::new(0, 0, 11, 1), &[1.0]); - paint_gradient(&mut s, a, (0.5, 0.0), (10.5, 0.0), GradientShape::Linear, &[(0.0, [0.0, 0.0, 0.0, 1.0]), (1.0, [1.0, 1.0, 1.0, 1.0])], false, 1.0, BlendMode::Normal, false, Some(&sel)); + paint_gradient( + &mut s, + a, + (0.5, 0.0), + (10.5, 0.0), + GradientShape::Linear, + &[(0.0, [0.0, 0.0, 0.0, 1.0]), (1.0, [1.0, 1.0, 1.0, 1.0])], + false, + 1.0, + BlendMode::Normal, + false, + Some(&sel), + ); assert!(s.pixel(0, 0)[0] < 0.01 && s.pixel(10, 0)[0] > 0.99); assert!((s.pixel(5, 0)[0] - 0.5).abs() < 0.01); assert_eq!(s.pixel(5, 1)[3], 0.0, "outside the selection"); diff --git a/crates/algo/src/panorama.rs b/crates/algo/src/panorama.rs index 5328a0a674..0246e15651 100644 --- a/crates/algo/src/panorama.rs +++ b/crates/algo/src/panorama.rs @@ -313,7 +313,13 @@ impl Placement { pub fn scaled(&self, k: f64) -> Placement { let s = Homography([k, 0.0, 0.0, 0.0, k, 0.0, 0.0, 0.0, 1.0]); let si = Homography([1.0 / k, 0.0, 0.0, 0.0, 1.0 / k, 0.0, 0.0, 0.0, 1.0]); - Placement { projection: self.projection, focal: self.focal * k, center: [self.center[0] * k, self.center[1] * k], k1: self.k1, h: s.mul(&self.h).mul(&si) } + Placement { + projection: self.projection, + focal: self.focal * k, + center: [self.center[0] * k, self.center[1] * k], + k1: self.k1, + h: s.mul(&self.h).mul(&si), + } } /// Shifted in the panorama. @@ -1337,7 +1343,8 @@ mod tests { #[test] fn placement_round_trips_all_projections() { for projection in [Projection::Plane, Projection::Cylinder, Projection::Sphere] { - let p = Placement { projection, focal: 400.0, center: [200.0, 150.0], k1: 0.05, h: Homography([0.98, -0.1, 30.0, 0.1, 0.98, -12.0, 0.0, 0.0, 1.0]) }; + let p = + Placement { projection, focal: 400.0, center: [200.0, 150.0], k1: 0.05, h: Homography([0.98, -0.1, 30.0, 0.1, 0.98, -12.0, 0.0, 0.0, 1.0]) }; for (x, y) in [(0.0, 0.0), (390.0, 20.0), (123.0, 280.0)] { let (u, v) = p.forward(x, y); let (bx, by) = p.inverse(u, v).unwrap(); @@ -1365,7 +1372,13 @@ mod tests { } } imgs.push(RoiImage { x0, y0: 0, w, h, ch: 1, px, alpha: vec![1.0; w * h] }); - pls.push(Placement { projection: Projection::Plane, focal: 200.0, center: [100.0, 50.0], k1: 0.0, h: Homography([1.0, 0.0, 100.0 + x0 as f64, 0.0, 1.0, 50.0, 0.0, 0.0, 1.0]) }); + pls.push(Placement { + projection: Projection::Plane, + focal: 200.0, + center: [100.0, 50.0], + k1: 0.0, + h: Homography([1.0, 0.0, 100.0 + x0 as f64, 0.0, 1.0, 50.0, 0.0, 0.0, 1.0]), + }); let _ = k; } let ph = photometric(&imgs, Some((&pls, &[(w, h), (w, h)])), true, false); @@ -1399,7 +1412,16 @@ mod tests { // Multi-band blend of two flat images at different levels joins smoothly. let mk = |x0: i32, v: f32| RoiImage { x0, y0: 0, w: 80, h, ch: 1, px: vec![v; 80 * h], alpha: vec![1.0; 80 * h] }; let imgs = [mk(0, 0.3), mk(40, 0.7)]; - let wts: Vec> = (0..2).map(|k| (0..80 * h).map(|i| { let x = (i % 80) as i32 + imgs[k].x0; if (x < 60) == (k == 0) { 1.0 } else { 0.0 } }).collect()).collect(); + let wts: Vec> = (0..2) + .map(|k| { + (0..80 * h) + .map(|i| { + let x = (i % 80) as i32 + imgs[k].x0; + if (x < 60) == (k == 0) { 1.0 } else { 0.0 } + }) + .collect() + }) + .collect(); let (out, cov) = multiband(w, h, &imgs, &wts, 4); let row: Vec = (0..w).map(|x| out[20 * w + x]).collect(); assert!((row[2] - 0.3).abs() < 0.02 && (row[w - 3] - 0.7).abs() < 0.02, "{row:?}"); diff --git a/crates/algo/src/perspective.rs b/crates/algo/src/perspective.rs index 4984bfa589..6759599d30 100644 --- a/crates/algo/src/perspective.rs +++ b/crates/algo/src/perspective.rs @@ -129,7 +129,8 @@ pub fn straighten(planes: &mut [Plane], mode: Straighten) { let groups = linked_corners(planes); let group_of = |p: usize, c: usize| groups.iter().position(|g| g.contains(&(p, c))).unwrap_or(0); let mut pos: Vec<[f64; 2]> = groups.iter().map(|g| planes[g[0].0].dst[g[0].1]).collect(); - let edges: Vec<(usize, usize)> = (0..planes.len()).flat_map(|p| (0..4).map(move |k| (p, k))).map(|(p, k)| (group_of(p, k), group_of(p, (k + 1) % 4))).collect(); + let edges: Vec<(usize, usize)> = + (0..planes.len()).flat_map(|p| (0..4).map(move |k| (p, k))).map(|(p, k)| (group_of(p, k), group_of(p, (k + 1) % 4))).collect(); for (a, b) in edges { let (pa, pb) = (pos[a], pos[b]); let (dx, dy) = ((pb[0] - pa[0]).abs(), (pb[1] - pa[1]).abs()); @@ -167,10 +168,17 @@ impl PerspectiveMap { let mut shared = [None; 4]; for (k, s) in shared.iter_mut().enumerate() { let (a, b) = (p.src[k], p.src[(k + 1) % 4]); - *s = planes.iter().enumerate().find(|(j, o)| *j != i && (0..4).any(|m| { - let (c, d) = (o.src[m], o.src[(m + 1) % 4]); - (near(a, c) && near(b, d)) || (near(a, d) && near(b, c)) - })).map(|(j, _)| j); + *s = planes + .iter() + .enumerate() + .find(|(j, o)| { + *j != i + && (0..4).any(|m| { + let (c, d) = (o.src[m], o.src[(m + 1) % 4]); + (near(a, c) && near(b, d)) || (near(a, d) && near(b, c)) + }) + }) + .map(|(j, _)| j); } maps.push(PlaneMap { src, src_inv, dst, quad: p.src, shared }); } @@ -246,10 +254,12 @@ pub fn unit_corners() -> [[f64; 2]; 4] { /// A point of plane `p` at plane coordinates `(u, v)`, in source space. pub fn plane_point(p: &Plane, u: f64, v: f64, dst: bool) -> [f64; 2] { let q = if dst { &p.dst } else { &p.src }; - unit_to_quad(q).map(|h| { - let (x, y) = h.apply(u, v); - [x, y] - }).unwrap_or(q[0]) + unit_to_quad(q) + .map(|h| { + let (x, y) = h.apply(u, v); + [x, y] + }) + .unwrap_or(q[0]) } #[cfg(test)] @@ -299,7 +309,8 @@ mod tests { #[test] fn straighten_levels_and_plumbs_edges() { - let mut ps = vec![Plane { src: [[0.0, 0.0], [100.0, 0.0], [100.0, 100.0], [0.0, 100.0]], dst: [[0.0, 3.0], [100.0, -2.0], [96.0, 100.0], [4.0, 104.0]] }]; + let mut ps = + vec![Plane { src: [[0.0, 0.0], [100.0, 0.0], [100.0, 100.0], [0.0, 100.0]], dst: [[0.0, 3.0], [100.0, -2.0], [96.0, 100.0], [4.0, 104.0]] }]; straighten(&mut ps, Straighten::Vertical); assert_eq!(ps[0].dst[1][0], ps[0].dst[2][0]); assert_eq!(ps[0].dst[0][0], ps[0].dst[3][0]); diff --git a/crates/algo/src/pixelate.rs b/crates/algo/src/pixelate.rs index 5114c5f3fb..acf08fdc34 100644 --- a/crates/algo/src/pixelate.rs +++ b/crates/algo/src/pixelate.rs @@ -72,7 +72,11 @@ pub(crate) fn color_halftone(src: &Image, out: Rect, ctx: &Ctx, max_radius: f32, let ink = if sub && !rgb { raw } else { 1.0 - raw }; // Dot area tracks ink: πr² = ink·cell² until dots touch, then grow to cover the corners. let touch = std::f32::consts::FRAC_PI_4; - let r = if ink <= touch { cell * (ink / std::f32::consts::PI).sqrt() } else { cell * (0.5 + (ink - touch) / (1.0 - touch) * (std::f32::consts::FRAC_1_SQRT_2 - 0.5)) }; + let r = if ink <= touch { + cell * (ink / std::f32::consts::PI).sqrt() + } else { + cell * (0.5 + (ink - touch) / (1.0 - touch) * (std::f32::consts::FRAC_1_SQRT_2 - 0.5)) + }; // Tiny dots cannot cover more than their own area. cov = cov.max((r - d + 0.5).clamp(0.0, 1.0).min(std::f32::consts::PI * r * r)); } @@ -116,9 +120,14 @@ pub(crate) fn crystallize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, see best.1 }; let mut acc = vec![0.0f32; gw * gh * (n + 1)]; - let win = Rect::new(b.x0 + (c0.0 as f32 * cell).floor() as i32, b.y0 + (c0.1 as f32 * cell).floor() as i32, b.x0 + ((c1.0 + 1) as f32 * cell).ceil() as i32, b.y0 + ((c1.1 + 1) as f32 * cell).ceil() as i32) - .intersect(&src.rect) - .intersect(&b); + let win = Rect::new( + b.x0 + (c0.0 as f32 * cell).floor() as i32, + b.y0 + (c0.1 as f32 * cell).floor() as i32, + b.x0 + ((c1.0 + 1) as f32 * cell).ceil() as i32, + b.y0 + ((c1.1 + 1) as f32 * cell).ceil() as i32, + ) + .intersect(&src.rect) + .intersect(&b); let mut owners = vec![(0i32, 0i32); out.width() as usize * out.height() as usize]; for y in win.y0..win.y1 { for x in win.x0..win.x1 { diff --git a/crates/algo/src/poisson.rs b/crates/algo/src/poisson.rs index 9ad965070a..55b0bd8a23 100644 --- a/crates/algo/src/poisson.rs +++ b/crates/algo/src/poisson.rs @@ -272,10 +272,12 @@ mod tests { // Destination: flat reddish; source: bluish with a stripe texture. let dst: Vec = (0..w * h).flat_map(|_| [0.7, 0.3, 0.2]).collect(); let tex = |x: usize| if (x / 4).is_multiple_of(2) { 0.05 } else { -0.05 }; - let src: Vec = (0..w * h).flat_map(|i| { - let t = tex(i % w); - [0.1 + t, 0.2 + t, 0.8 + t] - }).collect(); + let src: Vec = (0..w * h) + .flat_map(|i| { + let t = tex(i % w); + [0.1 + t, 0.2 + t, 0.8 + t] + }) + .collect(); let out = seamless_clone(w, h, ch, &src, &dst, &mask); // Mean colour inside the mask matches the destination. let inside: Vec = (0..w * h).filter(|&i| mask[i]).collect(); diff --git a/crates/algo/src/puppet.rs b/crates/algo/src/puppet.rs index ca0e786081..275c0ac8de 100644 --- a/crates/algo/src/puppet.rs +++ b/crates/algo/src/puppet.rs @@ -651,7 +651,14 @@ impl PuppetSolver { /// Deforms the mesh: `pin_dst` (one per pin given to [`Self::new`]), optional fixed pin /// rotations (degrees), `iterations` local/global rounds, warm-started from `init` when /// given. Returns the deformed vertices and the rigid ARAP energy after each round. - pub fn solve(&self, pin_dst: &[[f64; 2]], rotations: &[Option], mode: PuppetMode, iterations: usize, init: Option<&[[f64; 2]]>) -> (Vec<[f64; 2]>, Vec) { + pub fn solve( + &self, + pin_dst: &[[f64; 2]], + rotations: &[Option], + mode: PuppetMode, + iterations: usize, + init: Option<&[[f64; 2]]>, + ) -> (Vec<[f64; 2]>, Vec) { let nv = self.mesh.verts.len(); let mut v: Vec<[f64; 2]> = match init { Some(i) if i.len() == nv => i.to_vec(), @@ -667,7 +674,16 @@ impl PuppetSolver { .bound .iter() .zip(rotations) - .filter_map(|(b, r)| r.map(|deg| ((0..3).fold([0.0, 0.0], |a, k| [a[0] + b.bary[k] * self.mesh.verts[b.verts[k]][0], a[1] + b.bary[k] * self.mesh.verts[b.verts[k]][1]]), deg.to_radians()))) + .filter_map(|(b, r)| { + r.map(|deg| { + ( + (0..3).fold([0.0, 0.0], |a, k| { + [a[0] + b.bary[k] * self.mesh.verts[b.verts[k]][0], a[1] + b.bary[k] * self.mesh.verts[b.verts[k]][1]] + }), + deg.to_radians(), + ) + }) + }) .collect(); let r: Vec<[f64; 4]> = self .mesh @@ -781,12 +797,14 @@ mod tests { let b = s.content_bounds(); let m = build_mesh(&s, b, PuppetDensity::Normal, 2.0); assert!(m.tris.len() > 20); - let inside = |p: [f64; 2]| m.tris.iter().any(|t| { - let xs = t.map(|i| m.verts[i]); - let (x0, x1) = (xs.iter().map(|v| v[0]).fold(f64::MAX, f64::min), xs.iter().map(|v| v[0]).fold(f64::MIN, f64::max)); - let (y0, y1) = (xs.iter().map(|v| v[1]).fold(f64::MAX, f64::min), xs.iter().map(|v| v[1]).fold(f64::MIN, f64::max)); - p[0] >= x0 && p[0] <= x1 && p[1] >= y0 && p[1] <= y1 - }); + let inside = |p: [f64; 2]| { + m.tris.iter().any(|t| { + let xs = t.map(|i| m.verts[i]); + let (x0, x1) = (xs.iter().map(|v| v[0]).fold(f64::MAX, f64::min), xs.iter().map(|v| v[0]).fold(f64::MIN, f64::max)); + let (y0, y1) = (xs.iter().map(|v| v[1]).fold(f64::MAX, f64::min), xs.iter().map(|v| v[1]).fold(f64::MIN, f64::max)); + p[0] >= x0 && p[0] <= x1 && p[1] >= y0 && p[1] <= y1 + }) + }; assert!(inside([28.0, 30.0]) && inside([60.0, 62.0])); assert!(!inside([60.0, 20.0]), "the empty corner of the L has no mesh"); let more = build_mesh(&s, b, PuppetDensity::More, 2.0); @@ -799,7 +817,13 @@ mod tests { for st in [SampleType::U8, SampleType::U16, SampleType::F32] { let s = blob(st); let b = s.content_bounds(); - for pins in [vec![], vec![PuppetPin { src: [28.0, 20.0], dst: [28.0, 20.0], rotate: None, depth: 0 }, PuppetPin { src: [60.0, 62.0], dst: [60.0, 62.0], rotate: None, depth: 0 }]] { + for pins in [ + vec![], + vec![ + PuppetPin { src: [28.0, 20.0], dst: [28.0, 20.0], rotate: None, depth: 0 }, + PuppetPin { src: [60.0, 62.0], dst: [60.0, 62.0], rotate: None, depth: 0 }, + ], + ] { let w = PuppetWarp { pins, mode: PuppetMode::Normal, density: PuppetDensity::Normal, expansion: 2.0 }; let out = puppet_warp(&s, b, &w, Interp::Bicubic); let worst = s.read_region(b).iter().zip(out.read_region(b)).map(|(a, c)| (a - c).abs()).fold(0.0f32, f32::max); @@ -812,7 +836,12 @@ mod tests { fn a_single_pin_translates_rigidly() { let s = blob(SampleType::U8); let b = s.content_bounds(); - let w = PuppetWarp { pins: vec![PuppetPin { src: [28.0, 30.0], dst: [38.0, 25.0], rotate: None, depth: 0 }], mode: PuppetMode::Rigid, density: PuppetDensity::Normal, expansion: 2.0 }; + let w = PuppetWarp { + pins: vec![PuppetPin { src: [28.0, 30.0], dst: [38.0, 25.0], rotate: None, depth: 0 }], + mode: PuppetMode::Rigid, + density: PuppetDensity::Normal, + expansion: 2.0, + }; let (solver, v, _) = deform(&s, b, &w, ITERATIONS); for (d, x) in v.iter().zip(&solver.mesh.verts) { assert!((d[0] - x[0] - 10.0).abs() < 1e-3 && (d[1] - x[1] + 5.0).abs() < 1e-3, "{d:?} {x:?}"); diff --git a/crates/algo/src/quantize.rs b/crates/algo/src/quantize.rs index 01b44b24f5..7c4bfbbe3f 100644 --- a/crates/algo/src/quantize.rs +++ b/crates/algo/src/quantize.rs @@ -158,16 +158,7 @@ fn forced_colors(f: Forced) -> Vec { match f { Forced::None => Vec::new(), Forced::BlackWhite => vec![[0, 0, 0], [255, 255, 255]], - Forced::Primaries => vec![ - [0, 0, 0], - [255, 255, 255], - [255, 0, 0], - [0, 255, 0], - [0, 0, 255], - [0, 255, 255], - [255, 0, 255], - [255, 255, 0], - ], + Forced::Primaries => vec![[0, 0, 0], [255, 255, 255], [255, 0, 0], [0, 255, 0], [0, 0, 255], [0, 255, 255], [255, 0, 255], [255, 255, 0]], Forced::Web => web_palette(), } } @@ -198,9 +189,7 @@ fn median_cut(pts: &[([f32; 3], u32)], n: usize) -> Vec<[f32; 3]> { .map(|(i, b)| { let mut best = (0usize, -1.0f32); for a in 0..3 { - let (lo, hi) = b.iter().fold((f32::MAX, f32::MIN), |(lo, hi), p| { - (lo.min(p.0[a]), hi.max(p.0[a])) - }); + let (lo, hi) = b.iter().fold((f32::MAX, f32::MIN), |(lo, hi), p| (lo.min(p.0[a]), hi.max(p.0[a]))); if hi - lo > best.1 { best = (a, hi - lo); } @@ -208,10 +197,7 @@ fn median_cut(pts: &[([f32; 3], u32)], n: usize) -> Vec<[f32; 3]> { let w: f32 = b.iter().map(|p| p.1 as f32).sum(); (i, best.0, best.1 * w.sqrt()) }) - .fold( - (usize::MAX, 0, -1.0f32), - |acc, x| if x.2 > acc.2 { x } else { acc }, - ); + .fold((usize::MAX, 0, -1.0f32), |acc, x| if x.2 > acc.2 { x } else { acc }); if bi == usize::MAX || score <= 0.0 { break; } @@ -235,12 +221,7 @@ fn median_cut(pts: &[([f32; 3], u32)], n: usize) -> Vec<[f32; 3]> { .iter() .map(|b| { let w: f64 = b.iter().map(|p| f64::from(p.1)).sum(); - std::array::from_fn(|a| { - (b.iter() - .map(|p| f64::from(p.0[a]) * f64::from(p.1)) - .sum::() - / w) as f32 - }) + std::array::from_fn(|a| (b.iter().map(|p| f64::from(p.0[a]) * f64::from(p.1)).sum::() / w) as f32) }) .collect() } @@ -286,12 +267,7 @@ fn lab_to_rgb8(lab: [f32; 3]) -> Rgb8 { /// Builds the palette for `px`. `colors` is the requested count (2..=256) for the computed /// kinds; forced colours are included first and count toward it. -pub fn build_palette( - px: &[[f32; 4]], - kind: PaletteKind, - colors: usize, - forced: Forced, -) -> Result, String> { +pub fn build_palette(px: &[[f32; 4]], kind: PaletteKind, colors: usize, forced: Forced) -> Result, String> { let colors = colors.clamp(2, 256); let mut pal = forced_colors(forced); let room = colors.saturating_sub(pal.len()); @@ -299,10 +275,7 @@ pub fn build_palette( let computed: Vec = match kind { PaletteKind::Exact => { if hist.len() > 256 { - return Err(format!( - "the image has {} colours; Exact needs 256 or fewer", - hist.len() - )); + return Err(format!("the image has {} colours; Exact needs 256 or fewer", hist.len())); } hist.iter().map(|h| h.0).collect() } @@ -314,24 +287,17 @@ pub fn build_palette( if hist.len() <= room { hist.iter().map(|h| h.0).collect() } else { - let pts: Vec<([f32; 3], u32)> = - hist.iter().map(|(c, n)| (c.map(f32::from), *n)).collect(); + let pts: Vec<([f32; 3], u32)> = hist.iter().map(|(c, n)| (c.map(f32::from), *n)).collect(); let mut centers = median_cut(&pts, room); kmeans(&pts, &mut centers, 2); - centers - .iter() - .map(|c| c.map(|v| v.round().clamp(0.0, 255.0) as u8)) - .collect() + centers.iter().map(|c| c.map(|v| v.round().clamp(0.0, 255.0) as u8)).collect() } } PaletteKind::Perceptual | PaletteKind::Selective => { if hist.len() <= room { hist.iter().map(|h| h.0).collect() } else { - let pts: Vec<([f32; 3], u32)> = hist - .iter() - .map(|(c, n)| (srgb_to_lab(c.map(|v| f32::from(v) / 255.0)), *n)) - .collect(); + let pts: Vec<([f32; 3], u32)> = hist.iter().map(|(c, n)| (srgb_to_lab(c.map(|v| f32::from(v) / 255.0)), *n)).collect(); let mut centers = median_cut(&pts, room); kmeans(&pts, &mut centers, 4); let mut v: Vec = centers.iter().map(|c| lab_to_rgb8(*c)).collect(); @@ -356,13 +322,7 @@ pub fn build_palette( pal.push(c); } } - if matches!( - kind, - PaletteKind::Adaptive - | PaletteKind::Perceptual - | PaletteKind::Selective - | PaletteKind::Uniform - ) { + if matches!(kind, PaletteKind::Adaptive | PaletteKind::Perceptual | PaletteKind::Selective | PaletteKind::Uniform) { pal.truncate(colors.max(forced_colors(forced).len())); } if pal.is_empty() { @@ -374,9 +334,7 @@ pub fn build_palette( fn nearest(pal: &[Rgb8], c: [f32; 3]) -> usize { let mut best = (f32::MAX, 0); for (i, e) in pal.iter().enumerate() { - let d: f32 = (0..3) - .map(|k| (c[k] * 255.0 - f32::from(e[k])).powi(2)) - .sum(); + let d: f32 = (0..3).map(|k| (c[k] * 255.0 - f32::from(e[k])).powi(2)).sum(); if d < best.0 { best = (d, i); } @@ -387,17 +345,14 @@ fn nearest(pal: &[Rgb8], c: [f32; 3]) -> usize { /// 8×8 Bayer threshold in -0.5..0.5. pub fn bayer8(x: usize, y: usize) -> f32 { const M: [u8; 64] = [ - 0, 32, 8, 40, 2, 34, 10, 42, 48, 16, 56, 24, 50, 18, 58, 26, 12, 44, 4, 36, 14, 46, 6, 38, - 60, 28, 52, 20, 62, 30, 54, 22, 3, 35, 11, 43, 1, 33, 9, 41, 51, 19, 59, 27, 49, 17, 57, - 25, 15, 47, 7, 39, 13, 45, 5, 37, 63, 31, 55, 23, 61, 29, 53, 21, + 0, 32, 8, 40, 2, 34, 10, 42, 48, 16, 56, 24, 50, 18, 58, 26, 12, 44, 4, 36, 14, 46, 6, 38, 60, 28, 52, 20, 62, 30, 54, 22, 3, 35, 11, 43, 1, 33, 9, 41, + 51, 19, 59, 27, 49, 17, 57, 25, 15, 47, 7, 39, 13, 45, 5, 37, 63, 31, 55, 23, 61, 29, 53, 21, ]; (f32::from(M[(y % 8) * 8 + x % 8]) + 0.5) / 64.0 - 0.5 } fn hash01(x: usize, y: usize, seed: u32) -> f32 { - let mut h = (x as u32).wrapping_mul(0x8da6_b343) - ^ (y as u32).wrapping_mul(0xd816_3841) - ^ seed.wrapping_mul(0xcb1a_b31f); + let mut h = (x as u32).wrapping_mul(0x8da6_b343) ^ (y as u32).wrapping_mul(0xd816_3841) ^ seed.wrapping_mul(0xcb1a_b31f); h ^= h >> 13; h = h.wrapping_mul(0x5bd1_e995); h ^= h >> 15; @@ -407,14 +362,7 @@ fn hash01(x: usize, y: usize, seed: u32) -> f32 { /// Maps every opaque pixel to its palette entry (in place) with the chosen dither; returns the /// index per pixel. Pixels with alpha < ½ become index `transparent` (and alpha 0) when given; /// otherwise alpha is kept. `amount` (0..=1) scales diffusion / pattern / noise strength. -pub fn quantize( - px: &mut [[f32; 4]], - w: usize, - pal: &[Rgb8], - dither: Dither, - amount: f32, - transparent: Option, -) -> Vec { +pub fn quantize(px: &mut [[f32; 4]], w: usize, pal: &[Rgb8], dither: Dither, amount: f32, transparent: Option) -> Vec { let h = px.len().checked_div(w).unwrap_or(0); let mut idx = vec![0u8; px.len()]; // Typical palette spacing sets the ordered/noise dither amplitude. @@ -503,12 +451,7 @@ pub fn quantize( next[x + 2][q] += d[q] / 16.0; } } - px[i] = [ - e[0], - e[1], - e[2], - if transparent.is_some() { 1.0 } else { p[3] }, - ]; + px[i] = [e[0], e[1], e[2], if transparent.is_some() { 1.0 } else { p[3] }]; } } idx @@ -568,16 +511,10 @@ fn spot(shape: HalftoneShape, u: f32, v: f32) -> f32 { pub fn to_bitmap(gray: &mut [f32], w: usize, method: BitmapMethod) { let h = gray.len().checked_div(w).unwrap_or(0); match method { - BitmapMethod::Threshold => gray - .iter_mut() - .for_each(|v| *v = if *v >= 0.5 { 1.0 } else { 0.0 }), + BitmapMethod::Threshold => gray.iter_mut().for_each(|v| *v = if *v >= 0.5 { 1.0 } else { 0.0 }), BitmapMethod::Pattern => { for (i, v) in gray.iter_mut().enumerate() { - *v = if *v > bayer8(i % w, i / w) + 0.5 { - 1.0 - } else { - 0.0 - }; + *v = if *v > bayer8(i % w, i / w) + 0.5 { 1.0 } else { 0.0 }; } } BitmapMethod::Diffusion => { @@ -608,15 +545,7 @@ pub fn to_bitmap(gray: &mut [f32], w: usize, method: BitmapMethod) { let cell = cell.max(2.0); let (s, c) = angle.to_radians().sin_cos(); // Rank the spot function over the cell so coverage is proportional to the tone. - let mut table: Vec = (0..32 * 32) - .map(|k| { - spot( - shape, - ((k % 32) as f32 + 0.5) / 16.0 - 1.0, - ((k / 32) as f32 + 0.5) / 16.0 - 1.0, - ) - }) - .collect(); + let mut table: Vec = (0..32 * 32).map(|k| spot(shape, ((k % 32) as f32 + 0.5) / 16.0 - 1.0, ((k / 32) as f32 + 0.5) / 16.0 - 1.0)).collect(); table.sort_by(f32::total_cmp); let cdf = |x: f32| table.partition_point(|t| *t < x) as f32 / table.len() as f32; for (i, v) in gray.iter_mut().enumerate() { @@ -625,11 +554,7 @@ pub fn to_bitmap(gray: &mut [f32], w: usize, method: BitmapMethod) { let u = (rx / cell).rem_euclid(1.0) * 2.0 - 1.0; let vv = (ry / cell).rem_euclid(1.0) * 2.0 - 1.0; // Ink (black) covers where the spot function exceeds the gray level. - *v = if cdf(spot(shape, u, vv)) >= *v { - 0.0 - } else { - 1.0 - }; + *v = if cdf(spot(shape, u, vv)) >= *v { 0.0 } else { 1.0 }; } } } @@ -640,16 +565,7 @@ mod tests { use super::*; fn gradient(w: usize, h: usize) -> Vec<[f32; 4]> { - (0..w * h) - .map(|i| { - [ - (i % w) as f32 / (w - 1) as f32, - (i / w) as f32 / (h - 1) as f32, - 0.3, - 1.0, - ] - }) - .collect() + (0..w * h).map(|i| [(i % w) as f32 / (w - 1) as f32, (i / w) as f32 / (h - 1) as f32, 0.3, 1.0]).collect() } #[test] @@ -666,28 +582,15 @@ mod tests { #[test] fn exact_palette_and_failure() { - let px = vec![ - [1.0, 0.0, 0.0, 1.0], - [0.0, 0.0, 1.0, 1.0], - [1.0, 0.0, 0.0, 1.0], - ]; - assert_eq!( - build_palette(&px, PaletteKind::Exact, 256, Forced::None) - .unwrap() - .len(), - 2 - ); + let px = vec![[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0], [1.0, 0.0, 0.0, 1.0]]; + assert_eq!(build_palette(&px, PaletteKind::Exact, 256, Forced::None).unwrap().len(), 2); assert!(build_palette(&gradient(64, 64), PaletteKind::Exact, 256, Forced::None).is_err()); } #[test] fn adaptive_and_perceptual_respect_count_and_forced() { let px = gradient(40, 40); - for kind in [ - PaletteKind::Adaptive, - PaletteKind::Perceptual, - PaletteKind::Selective, - ] { + for kind in [PaletteKind::Adaptive, PaletteKind::Perceptual, PaletteKind::Selective] { let pal = build_palette(&px, kind, 16, Forced::BlackWhite).unwrap(); assert!(pal.len() <= 16 && pal.len() >= 8, "{kind:?} {}", pal.len()); assert_eq!(&pal[..2], &[[0, 0, 0], [255, 255, 255]]); @@ -700,11 +603,7 @@ mod tests { // computed entries; the median cut used to return no centres and // k-means indexed into the empty list. let px = vec![[1.0, 1.0, 1.0, 1.0]; 16]; - for kind in [ - PaletteKind::Adaptive, - PaletteKind::Perceptual, - PaletteKind::Selective, - ] { + for kind in [PaletteKind::Adaptive, PaletteKind::Perceptual, PaletteKind::Selective] { let pal = build_palette(&px, kind, 2, Forced::BlackWhite).unwrap(); assert_eq!(pal, vec![[0, 0, 0], [255, 255, 255]], "{kind:?}"); let pal = build_palette(&gradient(8, 8), kind, 8, Forced::Primaries).unwrap(); @@ -715,12 +614,7 @@ mod tests { #[test] fn quantize_maps_to_palette_with_each_dither() { let pal = vec![[0, 0, 0], [255, 255, 255]]; - for d in [ - Dither::None, - Dither::Diffusion, - Dither::Pattern, - Dither::Noise, - ] { + for d in [Dither::None, Dither::Diffusion, Dither::Pattern, Dither::Noise] { let mut px: Vec<[f32; 4]> = (0..64) .map(|i| [i as f32 / 63.0; 4]) .map(|mut p| { @@ -748,11 +642,7 @@ mod tests { BitmapMethod::Threshold, BitmapMethod::Pattern, BitmapMethod::Diffusion, - BitmapMethod::Halftone { - cell: 6.0, - angle: 45.0, - shape: HalftoneShape::Round, - }, + BitmapMethod::Halftone { cell: 6.0, angle: 45.0, shape: HalftoneShape::Round }, ] { let mut g = vec![0.25f32; 32 * 32]; to_bitmap(&mut g, 32, m); diff --git a/crates/algo/src/render2.rs b/crates/algo/src/render2.rs index 65371f0946..a99aec50fa 100644 --- a/crates/algo/src/render2.rs +++ b/crates/algo/src/render2.rs @@ -487,7 +487,8 @@ fn flame_colour(t: f32, custom: Option<[f32; 4]>) -> [f32; 4] { [rgb[0], rgb[1], rgb[2], a * c[3]] } None => { - const STOPS: [(f32, [f32; 3]); 5] = [(0.0, [1.0, 0.98, 0.85]), (0.2, [1.0, 0.86, 0.35]), (0.45, [1.0, 0.55, 0.08]), (0.75, [0.9, 0.25, 0.02]), (1.0, [0.6, 0.08, 0.0])]; + const STOPS: [(f32, [f32; 3]); 5] = + [(0.0, [1.0, 0.98, 0.85]), (0.2, [1.0, 0.86, 0.35]), (0.45, [1.0, 0.55, 0.08]), (0.75, [0.9, 0.25, 0.02]), (1.0, [0.6, 0.08, 0.0])]; let k = t.clamp(0.0, 1.0); let mut rgb = STOPS[4].1; for w in STOPS.windows(2) { @@ -539,7 +540,11 @@ fn draw_flame(out: &mut Vec, spine: &Spine, width: f32, spec: &FlameSpec, let o = sway(u); let c = flame_colour(u, spec.color); let r = half * (1.25 - u) + 2.0; - out.push(Prim::ellipse((px - dy * o, py + dx * o), r, r * 1.2, dy.atan2(dx), [c[0], c[1], c[2], 0.16 * opacity], [c[0], c[1] * 0.6, c[2] * 0.3, 0.0]).with_soft(r.max(2.0)).additive()); + out.push( + Prim::ellipse((px - dy * o, py + dx * o), r, r * 1.2, dy.atan2(dx), [c[0], c[1], c[2], 0.16 * opacity], [c[0], c[1] * 0.6, c[2] * 0.3, 0.0]) + .with_soft(r.max(2.0)) + .additive(), + ); } let line_r = (half / lines as f32 * 2.2).clamp(0.8, 10.0); for li in 0..lines { @@ -572,7 +577,11 @@ fn draw_flame(out: &mut Vec, spine: &Spine, width: f32, spec: &FlameSpec, if let Some((q, rq, uq)) = prev { // Fade in over the first tenth (a soft base) and out towards the line's tip. let f = |c: [f32; 4], uu: f32, t: f32| [c[0], c[1], c[2], c[3] * opacity * (1.0 - t.powi(3)) * ((uu - start) / 0.08).clamp(0.0, 1.0).sqrt()]; - out.push(Prim::capsule(q, p, rq, r, f(flame_colour(uq, spec.color), uq, fade_t), f(flame_colour(u, spec.color), u, fade_t)).with_soft(1.0 + lr * 0.6).lighten()); + out.push( + Prim::capsule(q, p, rq, r, f(flame_colour(uq, spec.color), uq, fade_t), f(flame_colour(u, spec.color), u, fade_t)) + .with_soft(1.0 + lr * 0.6) + .lighten(), + ); } prev = Some((p, r, u)); } @@ -586,7 +595,8 @@ pub fn flame(spec: &FlameSpec, paths: &[Vec<(f32, f32)>]) -> Vec { let length = spec.length.clamp(1.0, 1000.0); let width = spec.width.clamp(1.0, 1000.0); let interval = spec.interval.clamp(1.0, 1000.0); - let pick_len = |rng: &mut Rng, var: f32| if spec.randomize_length || var > 0.0 { length * rng.range(1.0 - var.max(0.4), 1.0 + var.max(0.4) * 0.5) } else { length }; + let pick_len = + |rng: &mut Rng, var: f32| if spec.randomize_length || var > 0.0 { length * rng.range(1.0 - var.max(0.4), 1.0 + var.max(0.4) * 0.5) } else { length }; for path in paths.iter().filter(|p| !p.is_empty()) { let spine = Spine::new(path.clone()); let total = spine.len(); @@ -600,14 +610,29 @@ pub fn flame(spec: &FlameSpec, paths: &[Vec<(f32, f32)>]) -> Vec { let c = FlameSpec { flame_shape: FlameShape::Oval, turbulent: spec.turbulent * 0.3, jag: spec.jag * 0.2, ..spec.clone() }; draw_flame(&mut out, &candle, (width * 0.6).max(4.0), &c, &mut rng); let core = flame_colour(0.05, spec.color); - out.push(Prim::ellipse((base.0, base.1 - h * 0.28), (width * 0.12).max(1.5), h * 0.22, 0.0, [core[0], core[1], core[2], 0.9 * spec.opacity.clamp(0.0, 100.0) / 100.0], [core[0], core[1], core[2], 0.0]).with_soft(2.0).additive()); + out.push( + Prim::ellipse( + (base.0, base.1 - h * 0.28), + (width * 0.12).max(1.5), + h * 0.22, + 0.0, + [core[0], core[1], core[2], 0.9 * spec.opacity.clamp(0.0, 100.0) / 100.0], + [core[0], core[1], core[2], 0.0], + ) + .with_soft(2.0) + .additive(), + ); } _ => { let closed = path.len() > 2 && { let (a, b) = (path[0], path[path.len() - 1]); (a.0 - b.0).abs() < 1.0 && (a.1 - b.1).abs() < 1.0 }; - let count = if spec.adjust_interval_for_loops && closed { (total / interval).round().max(1.0) as usize } else { (total / interval).floor() as usize + 1 }; + let count = if spec.adjust_interval_for_loops && closed { + (total / interval).round().max(1.0) as usize + } else { + (total / interval).floor() as usize + 1 + }; let step = if spec.adjust_interval_for_loops && closed { total / count as f32 } else { interval }; for k in 0..count { let s = k as f32 * step; @@ -699,7 +724,22 @@ struct TreeKind { } #[allow(clippy::too_many_arguments)] -const fn tk(name: &'static str, habit: Habit, levels: u32, children: u32, spread: f32, ratio: f32, trunk: f32, droop: f32, wiggle: f32, thick: f32, leaf: Leaf, leaf_col: [f32; 3], bark: [f32; 3], accent: Option<[f32; 3]>) -> TreeKind { +const fn tk( + name: &'static str, + habit: Habit, + levels: u32, + children: u32, + spread: f32, + ratio: f32, + trunk: f32, + droop: f32, + wiggle: f32, + thick: f32, + leaf: Leaf, + leaf_col: [f32; 3], + bark: [f32; 3], + accent: Option<[f32; 3]>, +) -> TreeKind { TreeKind { name, habit, levels, children, spread, ratio, trunk, droop, wiggle, thick, leaf, leaf_col, bark, accent } } @@ -1023,7 +1063,8 @@ pub fn tree(spec: &TreeSpec, canvas: Rect) -> Vec { ang += 0.06 * d.sin().atan2(d.cos()); let nq = (q.0 + ang.cos() * flen / segs as f32, q.1 + ang.sin() * flen / segs as f32); let k2 = t.light_at(nq); - t.leaves.push((k2 - 0.1, Prim::capsule(q, nq, (r0 * 0.15).max(0.6), (r0 * 0.1).max(0.4), shade(t.leaf, k2 * 0.8), shade(t.leaf, k2 * 0.8)))); + t.leaves + .push((k2 - 0.1, Prim::capsule(q, nq, (r0 * 0.15).max(0.6), (r0 * 0.1).max(0.4), shade(t.leaf, k2 * 0.8), shade(t.leaf, k2 * 0.8)))); for j in 0..leaflets { let along = j as f32 / leaflets as f32; let at = (q.0 + (nq.0 - q.0) * along, q.1 + (nq.1 - q.1) * along); @@ -1201,7 +1242,12 @@ pub fn picture_frame(spec: &FrameSpec, canvas: Rect) -> Vec { let thick = (unit * 0.04 + short * 0.00012 * spec.thickness.clamp(1.0, 100.0)).max(0.6); let spacing = unit * (4.0 - 3.2 * spec.arrangement.clamp(1.0, 100.0) / 100.0); let inset = margin + unit; - let b = Border { x0: canvas.x0 as f32 + inset, y0: canvas.y0 as f32 + inset, w: (canvas.width() as f32 - 2.0 * inset).max(1.0), h: (canvas.height() as f32 - 2.0 * inset).max(1.0) }; + let b = Border { + x0: canvas.x0 as f32 + inset, + y0: canvas.y0 as f32 + inset, + w: (canvas.width() as f32 - 2.0 * inset).max(1.0), + h: (canvas.height() as f32 - 2.0 * inset).max(1.0), + }; let total = b.len(); let count = (total / spacing).round().max(4.0) as usize; let step = total / count as f32; @@ -1333,7 +1379,14 @@ pub fn picture_frame(spec: &FrameSpec, canvas: Rect) -> Vec { if style == "rope" { if k % 2 == 0 { let (_, t, _) = b.at(s); - out.push(Prim::ellipse(q, unit * 0.45, unit * 0.2, t.1.atan2(t.0) + 0.7, shade([vine[0], vine[1], vine[2]], 1.1), shade([vine[0], vine[1], vine[2]], 0.65))); + out.push(Prim::ellipse( + q, + unit * 0.45, + unit * 0.2, + t.1.atan2(t.0) + 0.7, + shade([vine[0], vine[1], vine[2]], 1.1), + shade([vine[0], vine[1], vine[2]], 0.65), + )); } } else if let Some(pp) = prev { out.push(Prim::capsule(pp, q, thick, thick, vine, vine)); @@ -1342,7 +1395,12 @@ pub fn picture_frame(spec: &FrameSpec, canvas: Rect) -> Vec { } if style == "doubleLine" { // A second rule inside each line and corner squares. - let inner = Border { x0: b.x0 + unit * 0.6 + off, y0: b.y0 + unit * 0.6 + off, w: b.w - 2.0 * (unit * 0.6 + off), h: b.h - 2.0 * (unit * 0.6 + off) }; + let inner = Border { + x0: b.x0 + unit * 0.6 + off, + y0: b.y0 + unit * 0.6 + off, + w: b.w - 2.0 * (unit * 0.6 + off), + h: b.h - 2.0 * (unit * 0.6 + off), + }; for e in 0..4 { let (p, _, _) = inner.at([0.0, inner.w, inner.w + inner.h, 2.0 * inner.w + inner.h][e]); let (q, _, _) = inner.at([inner.w, inner.w + inner.h, 2.0 * inner.w + inner.h, inner.len() - 0.001][e]); @@ -1458,7 +1516,14 @@ mod tests { assert!(!picture_frame(&FrameSpec { frame: k, ..Default::default() }, canvas).is_empty(), "frame {k}"); } let path = default_flame_path(canvas); - for t in [FlameType::OneFlameAlongPath, FlameType::MultipleFlamesAlongPath, FlameType::MultipleFlamesPathDirections, FlameType::MultipleFlamesVariousLength, FlameType::CandleLight, FlameType::MultipleFlamesOneDirection] { + for t in [ + FlameType::OneFlameAlongPath, + FlameType::MultipleFlamesAlongPath, + FlameType::MultipleFlamesPathDirections, + FlameType::MultipleFlamesVariousLength, + FlameType::CandleLight, + FlameType::MultipleFlamesOneDirection, + ] { assert!(!flame(&FlameSpec { flame_type: t, ..Default::default() }, std::slice::from_ref(&path)).is_empty(), "{t:?}"); } } diff --git a/crates/algo/src/retouch.rs b/crates/algo/src/retouch.rs index 6e6b8f1097..c719bca2bc 100644 --- a/crates/algo/src/retouch.rs +++ b/crates/algo/src/retouch.rs @@ -178,7 +178,16 @@ pub fn local_blur(data: &[f32], w: usize, h: usize, ch: usize, alpha: Option, win: (usize, usize, usize, usize), amount: f32, protect_detail: bool) -> Vec { +pub fn local_sharpen( + data: &[f32], + w: usize, + h: usize, + ch: usize, + alpha: Option, + win: (usize, usize, usize, usize), + amount: f32, + protect_detail: bool, +) -> Vec { let (x0, y0, x1, y1) = win; let ow = x1 - x0; let blur = local_blur(data, w, h, ch, alpha, win, 1.0); diff --git a/crates/algo/src/segment/focus.rs b/crates/algo/src/segment/focus.rs index 17973bdf5d..28227cd709 100644 --- a/crates/algo/src/segment/focus.rs +++ b/crates/algo/src/segment/focus.rs @@ -24,7 +24,8 @@ pub fn sharpness(img: &super::RgbImage, noise: f32) -> Vec { for yy in 0..h { for x in 0..w { let c = at(x, yy); - let l = (2.0 * c - at(x.saturating_sub(1), yy) - at((x + 1).min(w - 1), yy)).abs() + (2.0 * c - at(x, yy.saturating_sub(1)) - at(x, (yy + 1).min(h - 1))).abs(); + let l = (2.0 * c - at(x.saturating_sub(1), yy) - at((x + 1).min(w - 1), yy)).abs() + + (2.0 * c - at(x, yy.saturating_sub(1)) - at(x, (yy + 1).min(h - 1))).abs(); lap[yy * w + x] = l; } } diff --git a/crates/algo/src/segment/gmm.rs b/crates/algo/src/segment/gmm.rs index 8a35a32b81..370d9da4a7 100644 --- a/crates/algo/src/segment/gmm.rs +++ b/crates/algo/src/segment/gmm.rs @@ -24,7 +24,8 @@ impl Component { for (i, row) in c.iter_mut().enumerate() { row[i] += reg; } - let det = c[0][0] * (c[1][1] * c[2][2] - c[1][2] * c[2][1]) - c[0][1] * (c[1][0] * c[2][2] - c[1][2] * c[2][0]) + c[0][2] * (c[1][0] * c[2][1] - c[1][1] * c[2][0]); + let det = c[0][0] * (c[1][1] * c[2][2] - c[1][2] * c[2][1]) - c[0][1] * (c[1][0] * c[2][2] - c[1][2] * c[2][0]) + + c[0][2] * (c[1][0] * c[2][1] - c[1][1] * c[2][0]); if det <= 1e-30 || weight <= 0.0 { return None; } @@ -34,13 +35,7 @@ impl Component { [(c[1][0] * c[2][1] - c[1][1] * c[2][0]) / det, (c[0][1] * c[2][0] - c[0][0] * c[2][1]) / det, (c[0][0] * c[1][1] - c[0][1] * c[1][0]) / det], ]; let log_norm = weight.ln() - 0.5 * det.ln() - 1.5 * (2.0 * std::f64::consts::PI).ln(); - Some(Component { - weight: weight as f32, - mean: mean.map(|v| v as f32), - cov: c.map(|r| r.map(|v| v as f32)), - inv, - log_norm, - }) + Some(Component { weight: weight as f32, mean: mean.map(|v| v as f32), cov: c.map(|r| r.map(|v| v as f32)), inv, log_norm }) } /// `ln(weight · N(z; μ, Σ))`. @@ -48,7 +43,9 @@ impl Component { pub fn log_weighted(&self, z: [f32; 3]) -> f64 { let d = [(z[0] - self.mean[0]) as f64, (z[1] - self.mean[1]) as f64, (z[2] - self.mean[2]) as f64]; let m = &self.inv; - let q = d[0] * (m[0][0] * d[0] + m[0][1] * d[1] + m[0][2] * d[2]) + d[1] * (m[1][0] * d[0] + m[1][1] * d[1] + m[1][2] * d[2]) + d[2] * (m[2][0] * d[0] + m[2][1] * d[1] + m[2][2] * d[2]); + let q = d[0] * (m[0][0] * d[0] + m[0][1] * d[1] + m[0][2] * d[2]) + + d[1] * (m[1][0] * d[0] + m[1][1] * d[1] + m[1][2] * d[2]) + + d[2] * (m[2][0] * d[0] + m[2][1] * d[1] + m[2][2] * d[2]); self.log_norm - 0.5 * q } } diff --git a/crates/algo/src/segment/grabcut.rs b/crates/algo/src/segment/grabcut.rs index 8cceee058c..5e80791ff1 100644 --- a/crates/algo/src/segment/grabcut.rs +++ b/crates/algo/src/segment/grabcut.rs @@ -9,7 +9,7 @@ use photocraft_geom::Rect; use super::gmm::{DEFAULT_REG, Gmm}; -use super::{HARD_BG, HARD_FG, FREE, Region, RgbImage, Sampler, contrast_beta, data_costs, grid_cut, subsample}; +use super::{FREE, HARD_BG, HARD_FG, Region, RgbImage, Sampler, contrast_beta, data_costs, grid_cut, subsample}; /// Definite background. pub const BG: u8 = 0; diff --git a/crates/algo/src/segment/mod.rs b/crates/algo/src/segment/mod.rs index dd50bc7a90..5c6da253e5 100644 --- a/crates/algo/src/segment/mod.rs +++ b/crates/algo/src/segment/mod.rs @@ -30,8 +30,8 @@ pub mod gmm; pub mod grabcut; pub mod maxflow; pub mod quick; -pub mod slic; pub mod sky; +pub mod slic; pub mod subject; use photocraft_geom::Rect; diff --git a/crates/algo/src/segment/quick.rs b/crates/algo/src/segment/quick.rs index 612898f11c..2521b41565 100644 --- a/crates/algo/src/segment/quick.rs +++ b/crates/algo/src/segment/quick.rs @@ -20,7 +20,7 @@ use photocraft_geom::Rect; use super::gmm::Gmm; -use super::{HARD_FG, FREE, Region, RgbImage, Sampler, contrast_beta, grid_cut, keep_seeded, subsample}; +use super::{FREE, HARD_FG, Region, RgbImage, Sampler, contrast_beta, grid_cut, keep_seeded, subsample}; /// Covariance ridge for the (often tiny) stroke sample: ~6 levels of standard deviation. const QREG: f32 = 6e-4; diff --git a/crates/algo/src/selection.rs b/crates/algo/src/selection.rs index 8ae5a4c43f..9481d9464f 100644 --- a/crates/algo/src/selection.rs +++ b/crates/algo/src/selection.rs @@ -319,11 +319,8 @@ pub fn combine_region(old: Option<&Surface>, new: Option<&Region>, mode: Selecti return nonempty(o.clone()); } let p = prev_u8(o); - let out: Vec = p - .iter() - .zip(&new.mask) - .map(|(a, n)| if mode == SelectionMode::Add { *a.max(n) } else { a.saturating_sub(*n) } as f32 / 255.0) - .collect(); + let out: Vec = + p.iter().zip(&new.mask).map(|(a, n)| if mode == SelectionMode::Add { *a.max(n) } else { a.saturating_sub(*n) } as f32 / 255.0).collect(); let mut s = o.clone(); s.write_region(b, &out); s.prune(); @@ -557,7 +554,9 @@ pub fn polygon(points: &[(f32, f32)], area: Rect, anti_alias: bool) -> Vec } let (c0, c1) = (x0.floor() as usize, (x1.ceil() as usize).min(w)); for cx in c0..c1 { - let cov = if anti_alias { ((cx + 1) as f32).min(x1) - (cx as f32).max(x0) } else { + let cov = if anti_alias { + ((cx + 1) as f32).min(x1) - (cx as f32).max(x0) + } else { let c = cx as f32 + 0.5; if c >= x0 && c < x1 { 1.0 } else { 0.0 } }; @@ -689,7 +688,13 @@ mod tests { let area = Rect::new(3, -2, 3 + w, -2 + h); let img = blobs(w, h); let f: Vec<[f32; 4]> = img.iter().map(|p| p.map(|v| v as f32 / 255.0)).collect(); - for (seed, tol, contiguous, aa) in [((10, 5), 10.0, true, true), ((10, 5), 10.0, true, false), ((20, 20), 40.0, false, true), ((4, -2), 0.0, true, true), ((30, 10), 255.0, true, true)] { + for (seed, tol, contiguous, aa) in [ + ((10, 5), 10.0, true, true), + ((10, 5), 10.0, true, false), + ((20, 20), 40.0, false, true), + ((4, -2), 0.0, true, true), + ((30, 10), 255.0, true, true), + ] { let reference = magic_wand(&f, area, seed, tol, contiguous, aa); let r = wand_region(&img, area, seed, tol, contiguous, aa).unwrap(); for y in area.y0..area.y1 { diff --git a/crates/algo/src/stack.rs b/crates/algo/src/stack.rs index 214240432e..22b5471450 100644 --- a/crates/algo/src/stack.rs +++ b/crates/algo/src/stack.rs @@ -35,37 +35,23 @@ fn stat(v: &mut [f32], s: Stat) -> f32 { Stat::Summation => v.iter().sum::(), Stat::Minimum => v.iter().copied().fold(f32::MAX, f32::min), Stat::Maximum => v.iter().copied().fold(f32::MIN, f32::max), - Stat::Range => { - v.iter().copied().fold(f32::MIN, f32::max) - v.iter().copied().fold(f32::MAX, f32::min) - } + Stat::Range => v.iter().copied().fold(f32::MIN, f32::max) - v.iter().copied().fold(f32::MAX, f32::min), Stat::Median => { v.sort_by(f32::total_cmp); let k = v.len(); - if k % 2 == 1 { - v[k / 2] - } else { - (v[k / 2 - 1] + v[k / 2]) / 2.0 - } + if k % 2 == 1 { v[k / 2] } else { (v[k / 2 - 1] + v[k / 2]) / 2.0 } } Stat::Variance => m(2), Stat::StandardDeviation => m(2).sqrt(), Stat::Skewness => { let sd = m(2).sqrt(); // Map −2..2 to 0..1 (0.5 = symmetric). - if sd < 1e-6 { - 0.5 - } else { - (m(3) / sd.powi(3) / 4.0 + 0.5).clamp(0.0, 1.0) - } + if sd < 1e-6 { 0.5 } else { (m(3) / sd.powi(3) / 4.0 + 0.5).clamp(0.0, 1.0) } } Stat::Kurtosis => { let var = m(2); // 0..10 → 0..1. - if var < 1e-12 { - 0.0 - } else { - (m(4) / (var * var) / 10.0).clamp(0.0, 1.0) - } + if var < 1e-12 { 0.0 } else { (m(4) / (var * var) / 10.0).clamp(0.0, 1.0) } } Stat::Entropy => { if v.len() < 2 { @@ -73,9 +59,7 @@ fn stat(v: &mut [f32], s: Stat) -> f32 { } let mut counts: std::collections::HashMap = std::collections::HashMap::new(); for x in v.iter() { - *counts - .entry((x.clamp(0.0, 1.0) * 255.0).round() as u8) - .or_default() += 1; + *counts.entry((x.clamp(0.0, 1.0) * 255.0).round() as u8).or_default() += 1; } let h: f32 = counts .values() diff --git a/crates/algo/src/stylize2.rs b/crates/algo/src/stylize2.rs index af1d58895c..f783e99d57 100644 --- a/crates/algo/src/stylize2.rs +++ b/crates/algo/src/stylize2.rs @@ -288,7 +288,8 @@ pub(crate) fn tiles(src: &Image, out: Rect, ctx: &Ctx, count: u32, max_offset: f let (cx, cy) = (gx + dx, gy + dy); let (ox, oy) = offset(cx, cy); let (u, v) = (fx - ox - cx as f32 * ts, fy - oy - cy as f32 * ts); - if u >= 0.0 && v >= 0.0 && u < ts && v < ts && (cx as f32 * ts) < b.width() as f32 && (cy as f32 * ts) < b.height() as f32 && cx >= 0 && cy >= 0 { + if u >= 0.0 && v >= 0.0 && u < ts && v < ts && (cx as f32 * ts) < b.width() as f32 && (cy as f32 * ts) < b.height() as f32 && cx >= 0 && cy >= 0 + { let order = (cy, cx); if hit.is_none_or(|(o, _, _)| order > o) { hit = Some((order, ox, oy)); diff --git a/crates/algo/src/tests.rs b/crates/algo/src/tests.rs index af4ff1c7bc..c1d5bb9f55 100644 --- a/crates/algo/src/tests.rs +++ b/crates/algo/src/tests.rs @@ -240,35 +240,15 @@ fn maximum_roundness_grows_a_point_into_a_disc() { let mut s = flat(depth, R, [0.0, 0.0, 0.0, 1.0]); s.write_pixel(20, 15, &[1.0, 1.0, 1.0, 1.0]); for radius in [1.0f32, 2.0, 3.5, 6.0] { - let round = run( - &s, - &FilterParams::Maximum { - radius, - preserve: Preserve::Roundness, - }, - ); - let square = run( - &s, - &FilterParams::Maximum { - radius, - preserve: Preserve::Squareness, - }, - ); + let round = run(&s, &FilterParams::Maximum { radius, preserve: Preserve::Roundness }); + let square = run(&s, &FilterParams::Maximum { radius, preserve: Preserve::Squareness }); let rs = radius.round() as i32; for y in R.y0..R.y1 { for x in R.x0..R.x1 { let (dx, dy) = (x - 20, y - 15); let in_disc = (dx * dx + dy * dy) as f32 <= radius * radius; - assert_eq!( - round.pixel(x, y)[0] > 0.5, - in_disc, - "{depth:?} r {radius} at {dx},{dy}" - ); - assert_eq!( - square.pixel(x, y)[0] > 0.5, - dx.abs() <= rs && dy.abs() <= rs, - "{depth:?} r {radius} at {dx},{dy}" - ); + assert_eq!(round.pixel(x, y)[0] > 0.5, in_disc, "{depth:?} r {radius} at {dx},{dy}"); + assert_eq!(square.pixel(x, y)[0] > 0.5, dx.abs() <= rs && dy.abs() <= rs, "{depth:?} r {radius} at {dx},{dy}"); } } } @@ -276,13 +256,7 @@ fn maximum_roundness_grows_a_point_into_a_disc() { // Radius 1: the four neighbours, not the diagonals (a 3×3 square would take them). let mut s = flat(SampleType::U8, R, [0.0, 0.0, 0.0, 1.0]); s.write_pixel(20, 15, &[1.0, 1.0, 1.0, 1.0]); - let r1 = run( - &s, - &FilterParams::Maximum { - radius: 1.0, - preserve: Preserve::Roundness, - }, - ); + let r1 = run(&s, &FilterParams::Maximum { radius: 1.0, preserve: Preserve::Roundness }); assert_eq!(r1.pixel(21, 15)[0], 1.0); assert_eq!(r1.pixel(21, 16)[0], 0.0); } @@ -292,23 +266,14 @@ fn minimum_maximum_roundness_match_brute_force() { // Exhaustive disc search on a textured image, both operators, a few radii (incl. fractional). let s = pattern(SampleType::F32, R); let img = s.read_region(R); - let at = |x: i32, y: i32, c: usize| -> Option { - R.contains(x, y) - .then(|| img[((y * 40 + x) * 4) as usize + c]) - }; + let at = |x: i32, y: i32, c: usize| -> Option { R.contains(x, y).then(|| img[((y * 40 + x) * 4) as usize + c]) }; for radius in [1.0f32, 1.5, 2.9, 4.0] { let ri = radius.floor() as i32; for max in [false, true] { let p = if max { - FilterParams::Maximum { - radius, - preserve: Preserve::Roundness, - } + FilterParams::Maximum { radius, preserve: Preserve::Roundness } } else { - FilterParams::Minimum { - radius, - preserve: Preserve::Roundness, - } + FilterParams::Minimum { radius, preserve: Preserve::Roundness } }; let got = run(&s, &p); for y in R.y0 + ri..R.y1 - ri { @@ -324,10 +289,7 @@ fn minimum_maximum_roundness_match_brute_force() { } } } - assert!( - (got.pixel(x, y)[c] - want).abs() < 1e-6, - "r {radius} max {max} at {x},{y} c{c}" - ); + assert!((got.pixel(x, y)[c] - want).abs() < 1e-6, "r {radius} max {max} at {x},{y} c{c}"); } } } @@ -342,16 +304,8 @@ fn running_extreme_windows() { for k in 1..=v.len() { for max in [false, true] { crate::other::running_extreme(&v, k, max, &mut out); - let want: Vec = v - .windows(k) - .map(|w| { - w.iter() - .copied() - .fold(if max { f32::MIN } else { f32::MAX }, |a, b| { - if max { a.max(b) } else { a.min(b) } - }) - }) - .collect(); + let want: Vec = + v.windows(k).map(|w| w.iter().copied().fold(if max { f32::MIN } else { f32::MAX }, |a, b| if max { a.max(b) } else { a.min(b) })).collect(); assert_eq!(out, want, "k {k} max {max}"); } } @@ -360,22 +314,9 @@ fn running_extreme_windows() { #[test] fn minimum_maximum_old_params_default_to_squareness() { let p: FilterParams = serde_json::from_str(r#"{"filter":"minimum","radius":3}"#).unwrap(); - assert_eq!( - p, - FilterParams::Minimum { - radius: 3.0, - preserve: Preserve::Squareness - } - ); - let p: FilterParams = - serde_json::from_str(r#"{"filter":"maximum","radius":2,"preserve":"roundness"}"#).unwrap(); - assert_eq!( - p, - FilterParams::Maximum { - radius: 2.0, - preserve: Preserve::Roundness - } - ); + assert_eq!(p, FilterParams::Minimum { radius: 3.0, preserve: Preserve::Squareness }); + let p: FilterParams = serde_json::from_str(r#"{"filter":"maximum","radius":2,"preserve":"roundness"}"#).unwrap(); + assert_eq!(p, FilterParams::Maximum { radius: 2.0, preserve: Preserve::Roundness }); } #[test] @@ -475,7 +416,12 @@ fn other_colour_modes_work() { let mut s = Surface::new(f); let px: Vec = vec![0.4; f.channels() - 1].into_iter().chain([1.0]).collect(); s.fill_rect(R, &px); - for p in [FilterParams::GaussianBlur { radius: 1.0 }, FilterParams::Desaturate, FilterParams::Emboss { angle: 0.0, height: 1.0, amount: 100.0 }, FilterParams::Twirl { angle: 50.0 }] { + for p in [ + FilterParams::GaussianBlur { radius: 1.0 }, + FilterParams::Desaturate, + FilterParams::Emboss { angle: 0.0, height: 1.0, amount: 100.0 }, + FilterParams::Twirl { angle: 50.0 }, + ] { let out = run(&s, &p); assert_eq!(out.format(), f); } diff --git a/crates/algo/src/tests_ext.rs b/crates/algo/src/tests_ext.rs index 20ecc5fbf5..fc30e0874c 100644 --- a/crates/algo/src/tests_ext.rs +++ b/crates/algo/src/tests_ext.rs @@ -64,7 +64,16 @@ fn new_filters() -> Vec { FilterParams::ZigZag { amount: 50.0, ridges: 4.0, style: ZigZagStyle::PondRipples }, FilterParams::Fibers { variance: 16.0, strength: 4.0, seed: 8, foreground: BLACK, background: WHITE }, FilterParams::LensFlare { brightness: 100.0, center_x: 0.3, center_y: 0.3, lens: LensType::Zoom }, - FilterParams::LightingEffects { lights: vec![Light::default()], gloss: 0.0, metallic: 0.0, exposure: 0.0, ambience: 10.0, texture: TextureChannel::Luminance, height: 50.0, white_is_high: true }, + FilterParams::LightingEffects { + lights: vec![Light::default()], + gloss: 0.0, + metallic: 0.0, + exposure: 0.0, + ambience: 10.0, + texture: TextureChannel::Luminance, + height: 50.0, + white_is_high: true, + }, FilterParams::ReduceNoise { strength: 6.0, preserve_details: 60.0, reduce_color_noise: 45.0, sharpen_details: 25.0, remove_jpeg_artifact: true }, FilterParams::SmartBlur { radius: 3.0, threshold: 40.0, quality: BlurQuality::High, mode: SmartBlurMode::Normal }, FilterParams::SmartBlur { radius: 3.0, threshold: 25.0, quality: BlurQuality::Low, mode: SmartBlurMode::OverlayEdge }, @@ -90,7 +99,11 @@ fn new_filters() -> Vec { FilterParams::FieldBlur { pins: vec![FieldPin { x: 0.2, y: 0.2, blur: 0.0 }, FieldPin { x: 0.8, y: 0.8, blur: 6.0 }] }, FilterParams::SpinBlur { pins: vec![SpinPin { blur_angle: 30.0, ..SpinPin::default() }] }, FilterParams::PathBlur { paths: vec![BlurPath { points: vec![[0.1, 0.2], [0.9, 0.8]], speed: 8.0, taper: 30.0 }] }, - FilterParams::Custom { kernel: vec![0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -1.0, 0.0, 0.0, 0.0, -1.0, 5.0, -1.0, 0.0, 0.0, 0.0, -1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], scale: 1.0, offset: 0.0 }, + FilterParams::Custom { + kernel: vec![0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -1.0, 0.0, 0.0, 0.0, -1.0, 5.0, -1.0, 0.0, 0.0, 0.0, -1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + scale: 1.0, + offset: 0.0, + }, FilterParams::HsbHsl { input: HsbModel::Rgb, output: HsbModel::Hsl }, FilterParams::DeInterlace { eliminate_even: false, interpolate: true }, FilterParams::NtscColors, @@ -136,8 +149,17 @@ fn new_filters_change_pixels_and_respect_empty_selection() { fn new_filters_agree_across_bit_depths() { // Threshold-type filters flip where quantized inputs straddle a threshold; compare them on average only. let thresholdy = |p: &FilterParams| { - matches!(p, FilterParams::Facet | FilterParams::OilPaint { .. } | FilterParams::Mezzotint { .. } | FilterParams::TraceContour { .. } | FilterParams::SmartBlur { .. } | FilterParams::ColorHalftone { .. } | FilterParams::Diffuse { .. } | FilterParams::Wind { .. }) - || matches!(p, FilterParams::Extrude { level_based: true, .. }) + matches!( + p, + FilterParams::Facet + | FilterParams::OilPaint { .. } + | FilterParams::Mezzotint { .. } + | FilterParams::TraceContour { .. } + | FilterParams::SmartBlur { .. } + | FilterParams::ColorHalftone { .. } + | FilterParams::Diffuse { .. } + | FilterParams::Wind { .. } + ) || matches!(p, FilterParams::Extrude { level_based: true, .. }) }; for p in new_filters() { let outs: Vec> = [SampleType::U8, SampleType::U16, SampleType::F32] @@ -186,7 +208,9 @@ fn new_params_serde_roundtrip() { let back: FilterParams = serde_json::from_str(&j).unwrap(); // Caller-supplied maps are not serialized. let strip = |p: &FilterParams| match p.clone() { - FilterParams::Displace { horizontal, vertical, stretch, undefined, .. } => FilterParams::Displace { horizontal, vertical, stretch, undefined, map: None }, + FilterParams::Displace { horizontal, vertical, stretch, undefined, .. } => { + FilterParams::Displace { horizontal, vertical, stretch, undefined, map: None } + } other => other, }; assert_eq!(back, strip(&p), "{j}"); @@ -345,7 +369,8 @@ fn trace_contour_draws_line_at_level_crossing() { #[test] fn tiles_leave_gaps_with_fill() { let s = flat(SampleType::F32, R, [0.0, 0.0, 1.0, 1.0]); - let out = run(&s, &FilterParams::Tiles { count: 3, max_offset: 40.0, fill: TileFill::Foreground, foreground: [0.0, 1.0, 0.0, 1.0], background: WHITE, seed: 2 }); + let out = + run(&s, &FilterParams::Tiles { count: 3, max_offset: 40.0, fill: TileFill::Foreground, foreground: [0.0, 1.0, 0.0, 1.0], background: WHITE, seed: 2 }); let v = out.read_region(R); assert!(v.chunks_exact(4).any(|p| p[1] > 0.99), "foreground gaps"); assert!(v.chunks_exact(4).filter(|p| p[2] > 0.99).count() > 600, "mostly tiles"); @@ -354,12 +379,26 @@ fn tiles_leave_gaps_with_fill() { #[test] fn extrude_blocks_and_pyramids_shade_cells() { let s = pattern(SampleType::F32, R); - let blocks = run(&s, &FilterParams::Extrude { kind: ExtrudeType::Blocks, size: 8.0, depth: 40.0, level_based: false, solid_front: true, mask_incomplete: false, seed: 1 }); + let blocks = run( + &s, + &FilterParams::Extrude { kind: ExtrudeType::Blocks, size: 8.0, depth: 40.0, level_based: false, solid_front: true, mask_incomplete: false, seed: 1 }, + ); // A solid front face is uniform: find two equal neighbours somewhere in the middle. let same = (5..35).any(|x| blocks.pixel(x, 12) == blocks.pixel(x + 1, 12)); assert!(same); let flat_s = flat(SampleType::F32, R, [0.5, 0.5, 0.5, 1.0]); - let pyr = run(&flat_s, &FilterParams::Extrude { kind: ExtrudeType::Pyramids, size: 10.0, depth: 100.0, level_based: false, solid_front: false, mask_incomplete: false, seed: 1 }); + let pyr = run( + &flat_s, + &FilterParams::Extrude { + kind: ExtrudeType::Pyramids, + size: 10.0, + depth: 100.0, + level_based: false, + solid_front: false, + mask_incomplete: false, + seed: 1, + }, + ); // Light from the top-left: the left face is brighter than the right face. assert!(pyr.pixel(11, 15)[0] > pyr.pixel(18, 15)[0], "{:?} {:?}", pyr.pixel(11, 15), pyr.pixel(18, 15)); } @@ -390,11 +429,35 @@ fn lens_flare_is_brightest_at_its_centre() { fn lighting_point_light_falls_off() { let s = flat(SampleType::F32, R, [0.8, 0.8, 0.8, 1.0]); let light = Light { kind: LightKind::Point, x: 0.1, y: 0.5, z: 0.2, radius: 1.0, intensity: 50.0, ..Light::default() }; - let out = run(&s, &FilterParams::LightingEffects { lights: vec![light], gloss: -100.0, metallic: 0.0, exposure: 0.0, ambience: 0.0, texture: TextureChannel::None, height: 0.0, white_is_high: true }); + let out = run( + &s, + &FilterParams::LightingEffects { + lights: vec![light], + gloss: -100.0, + metallic: 0.0, + exposure: 0.0, + ambience: 0.0, + texture: TextureChannel::None, + height: 0.0, + white_is_high: true, + }, + ); assert!(out.pixel(4, 15)[0] > out.pixel(36, 15)[0] + 0.1, "{:?} {:?}", out.pixel(4, 15), out.pixel(36, 15)); // A spot light does not reach outside its cone. let spot = Light { kind: LightKind::Spot, x: 0.5, y: 0.5, z: 0.3, target_x: 0.5, target_y: 0.5, cone: 20.0, ..Light::default() }; - let out = run(&s, &FilterParams::LightingEffects { lights: vec![spot], gloss: 0.0, metallic: 0.0, exposure: 0.0, ambience: 0.0, texture: TextureChannel::None, height: 0.0, white_is_high: true }); + let out = run( + &s, + &FilterParams::LightingEffects { + lights: vec![spot], + gloss: 0.0, + metallic: 0.0, + exposure: 0.0, + ambience: 0.0, + texture: TextureChannel::None, + height: 0.0, + white_is_high: true, + }, + ); assert!(out.pixel(20, 15)[0] > 0.3 && out.pixel(1, 1)[0] < 1e-4, "{:?} {:?}", out.pixel(20, 15), out.pixel(1, 1)); } @@ -413,7 +476,10 @@ fn noisy(st: SampleType) -> Surface { fn reduce_noise_lowers_noise() { let s = noisy(SampleType::F32); let inner = Rect::new(8, 8, 32, 22); - let out = run(&s, &FilterParams::ReduceNoise { strength: 10.0, preserve_details: 0.0, reduce_color_noise: 100.0, sharpen_details: 0.0, remove_jpeg_artifact: false }); + let out = run( + &s, + &FilterParams::ReduceNoise { strength: 10.0, preserve_details: 0.0, reduce_color_noise: 100.0, sharpen_details: 0.0, remove_jpeg_artifact: false }, + ); for c in 0..3 { assert!(variance(&out, inner, c) < variance(&s, inner, c) * 0.5, "channel {c}"); } @@ -572,7 +638,10 @@ fn ntsc_limits_saturated_colours_only() { fn displace_shifts_by_map_and_shear_by_curve() { let s = pattern(SampleType::F32, R); let v = 0.5 + 3.0 / 64.0; - let out = run(&s, &FilterParams::Displace { horizontal: 25.0, vertical: 0.0, stretch: false, undefined: UndefinedAreas::Wrap, map: Some(map_image([v, 0.5, 0.5, 1.0])) }); + let out = run( + &s, + &FilterParams::Displace { horizontal: 25.0, vertical: 0.0, stretch: false, undefined: UndefinedAreas::Wrap, map: Some(map_image([v, 0.5, 0.5, 1.0])) }, + ); for x in 0..30 { assert_eq!(out.pixel(x, 7), s.pixel(x + 3, 7), "x={x}"); } diff --git a/crates/algo/src/tone.rs b/crates/algo/src/tone.rs index d3cff92b25..664a464860 100644 --- a/crates/algo/src/tone.rs +++ b/crates/algo/src/tone.rs @@ -10,8 +10,8 @@ use photocraft_color::convert::{lab_to_srgb, srgb_to_lab}; use serde::{Deserialize, Serialize}; use crate::fxutil::gauss_blur_n; -use crate::photo_util::{par_map, par_rows}; use crate::other2::{hsl_to_rgb, rgb_to_hsl}; +use crate::photo_util::{par_map, par_rows}; fn luma(c: [f32; 3]) -> f32 { 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2] @@ -73,51 +73,44 @@ pub fn shadows_highlights(px: &mut [[f32; 4]], w: usize, h: usize, p: &ShadowsHi return; } let ls = blurred_luma(px, w, h, p.shadow_radius); - let lh = if (p.highlight_radius - p.shadow_radius).abs() < 1e-3 { - ls.clone() - } else { - blurred_luma(px, w, h, p.highlight_radius) - }; + let lh = if (p.highlight_radius - p.shadow_radius).abs() < 1e-3 { ls.clone() } else { blurred_luma(px, w, h, p.highlight_radius) }; let (sa, ha) = (p.shadow_amount / 100.0, p.highlight_amount / 100.0); - let (st, ht) = ( - (p.shadow_tone / 100.0).max(0.01), - (p.highlight_tone / 100.0).max(0.01), - ); + let (st, ht) = ((p.shadow_tone / 100.0).max(0.01), (p.highlight_tone / 100.0).max(0.01)); let color = p.color / 100.0; let mid = p.midtone / 100.0; par_rows(px, w, 1, |y, pxrow| { for (x, q) in pxrow.iter_mut().enumerate() { - let i = y * w + x; - if q[3] <= 0.0 { - continue; - } - let c = [q[0], q[1], q[2]]; - // Mask weights: 1 in deep shadows (resp. bright highlights), fading out at the tonal width. - let ms = 1.0 - smoothstep(0.0, st, ls[i]); - let mh = smoothstep(1.0 - ht, 1.0, lh[i]); - let mut out = c; - if sa > 0.0 && ms > 0.0 { - // Lift: a gamma-like curve on the pixel, stronger for darker neighbourhoods. - let g = 1.0 / (1.0 + 2.0 * sa * ms); - out = out.map(|v| v.max(0.0).powf(g)); - } - if ha > 0.0 && mh > 0.0 { - let g = 1.0 / (1.0 + 2.0 * ha * mh); - out = out.map(|v| 1.0 - (1.0 - v.min(1.0)).powf(g)); - } - if mid != 0.0 { - let k = 1.0 + mid * (1.0 - ms - mh).clamp(0.0, 1.0); - out = out.map(|v| (v - 0.5) * k + 0.5); - } - let changed = (ms * sa + mh * ha).min(1.0); - if color != 0.0 && changed > 0.0 { - let l1 = luma(out); - let s = 1.0 + color * changed; - out = out.map(|v| l1 + (v - l1) * s); - } - q[0] = out[0].clamp(0.0, 1.0); - q[1] = out[1].clamp(0.0, 1.0); - q[2] = out[2].clamp(0.0, 1.0); + let i = y * w + x; + if q[3] <= 0.0 { + continue; + } + let c = [q[0], q[1], q[2]]; + // Mask weights: 1 in deep shadows (resp. bright highlights), fading out at the tonal width. + let ms = 1.0 - smoothstep(0.0, st, ls[i]); + let mh = smoothstep(1.0 - ht, 1.0, lh[i]); + let mut out = c; + if sa > 0.0 && ms > 0.0 { + // Lift: a gamma-like curve on the pixel, stronger for darker neighbourhoods. + let g = 1.0 / (1.0 + 2.0 * sa * ms); + out = out.map(|v| v.max(0.0).powf(g)); + } + if ha > 0.0 && mh > 0.0 { + let g = 1.0 / (1.0 + 2.0 * ha * mh); + out = out.map(|v| 1.0 - (1.0 - v.min(1.0)).powf(g)); + } + if mid != 0.0 { + let k = 1.0 + mid * (1.0 - ms - mh).clamp(0.0, 1.0); + out = out.map(|v| (v - 0.5) * k + 0.5); + } + let changed = (ms * sa + mh * ha).min(1.0); + if color != 0.0 && changed > 0.0 { + let l1 = luma(out); + let s = 1.0 + color * changed; + out = out.map(|v| l1 + (v - l1) * s); + } + q[0] = out[0].clamp(0.0, 1.0); + q[1] = out[1].clamp(0.0, 1.0); + q[2] = out[2].clamp(0.0, 1.0); } }); clip_stretch(px, p.black_clip, p.white_clip); @@ -164,14 +157,7 @@ fn clip_stretch(px: &mut [[f32; 4]], black: f32, white: f32) { /// Image › Adjustments › Replace Color: Color Range-style selection (fuzziness in 0..=200 levels) /// around `color`, then a hue (±180°), saturation and lightness (±100) shift weighted by it. /// Returns the selection mask (what the dialog previews). -pub fn replace_color( - px: &mut [[f32; 4]], - color: [f32; 3], - fuzziness: f32, - hue: f32, - saturation: f32, - lightness: f32, -) -> Vec { +pub fn replace_color(px: &mut [[f32; 4]], color: [f32; 3], fuzziness: f32, hue: f32, saturation: f32, lightness: f32) -> Vec { let mask = crate::selection::color_range(px, color, fuzziness); for (q, &k) in px.iter_mut().zip(&mask) { if k <= 0.0 { @@ -179,21 +165,9 @@ pub fn replace_color( } let c = [q[0], q[1], q[2]]; let [hh, ss, ll] = rgb_to_hsl(c); - let s = if saturation >= 0.0 { - ss + (1.0 - ss) * saturation / 100.0 - } else { - ss * (1.0 + saturation / 100.0) - }; - let l = if lightness >= 0.0 { - ll + (1.0 - ll) * lightness / 100.0 - } else { - ll * (1.0 + lightness / 100.0) - }; - let o = hsl_to_rgb([ - (hh + hue / 360.0).rem_euclid(1.0), - s.clamp(0.0, 1.0), - l.clamp(0.0, 1.0), - ]); + let s = if saturation >= 0.0 { ss + (1.0 - ss) * saturation / 100.0 } else { ss * (1.0 + saturation / 100.0) }; + let l = if lightness >= 0.0 { ll + (1.0 - ll) * lightness / 100.0 } else { ll * (1.0 + lightness / 100.0) }; + let o = hsl_to_rgb([(hh + hue / 360.0).rem_euclid(1.0), s.clamp(0.0, 1.0), l.clamp(0.0, 1.0)]); for i in 0..3 { q[i] = (c[i] + (o[i] - c[i]) * k).clamp(0.0, 1.0); } @@ -229,8 +203,7 @@ pub fn lab_stats(px: &[[f32; 4]], mask: Option<&[f32]>) -> Option { return None; } let mean: [f32; 3] = std::array::from_fn(|k| (sum[k] / wsum) as f32); - let std = - std::array::from_fn(|k| ((sq[k] / wsum - (sum[k] / wsum).powi(2)).max(0.0).sqrt()) as f32); + let std = std::array::from_fn(|k| ((sq[k] / wsum - (sum[k] / wsum).powi(2)).max(0.0).sqrt()) as f32); Some(LabStats { mean, std }) } @@ -249,34 +222,20 @@ pub struct MatchColor { impl Default for MatchColor { fn default() -> Self { - MatchColor { - luminance: 100.0, - intensity: 100.0, - fade: 0.0, - neutralize: false, - } + MatchColor { luminance: 100.0, intensity: 100.0, fade: 0.0, neutralize: false } } } /// Reinhard-style colour transfer in Lab: shift and scale each channel of `px` (statistics /// `target`) toward `source`'s statistics; with no source only Neutralize / intensity / luminance /// act on the image's own statistics. -pub fn match_color( - px: &mut [[f32; 4]], - target: &LabStats, - source: Option<&LabStats>, - o: &MatchColor, -) { +pub fn match_color(px: &mut [[f32; 4]], target: &LabStats, source: Option<&LabStats>, o: &MatchColor) { let src = source.copied().unwrap_or(*target); let lum = o.luminance / 100.0; let inten = o.intensity / 100.0; let fade = (o.fade / 100.0).clamp(0.0, 1.0); let scale = |k: usize| { - if target.std[k] > 1e-4 { - src.std[k] / target.std[k] - } else { - 1.0 - } + if target.std[k] > 1e-4 { src.std[k] / target.std[k] } else { 1.0 } }; let mut dst_mean = src.mean; if o.neutralize { @@ -367,13 +326,7 @@ pub fn hdr_toning(px: &mut [[f32; 4]], w: usize, h: usize, p: &HdrToning) { let mut base = logl.clone(); gauss_blur_n(&mut base, w, h, 1, p.radius.max(1.0) / 2.0); let n = px.iter().filter(|q| q[3] > 0.0).count().max(1) as f32; - let mean = base - .iter() - .zip(px.iter()) - .filter(|(_, q)| q[3] > 0.0) - .map(|(b, _)| *b) - .sum::() - / n; + let mean = base.iter().zip(px.iter()).filter(|(_, q)| q[3] > 0.0).map(|(b, _)| *b).sum::() / n; let compress = 1.0 / (1.0 + p.strength.max(0.0)); let detail = 1.0 + p.detail / 100.0; let exposure = 2f32.powf(p.exposure); @@ -383,32 +336,29 @@ pub fn hdr_toning(px: &mut [[f32; 4]], w: usize, h: usize, p: &HdrToning) { // Per-pixel tone map: embarrassingly parallel (base/logl read by index). par_rows(px, w, 1, |y, row| { for (x, q) in row.iter_mut().enumerate() { - let i = y * w + x; - if q[3] <= 0.0 { - continue; - } - let c = [q[0], q[1], q[2]]; - let l0 = luma(c).max(0.0) + EPS; - let new_log = mean + (base[i] - mean) * compress + (logl[i] - base[i]) * detail; - let mut l1 = (new_log.exp() * exposure).max(0.0).powf(gamma); - l1 += p.shadow / 100.0 * (1.0 - l1).powi(3) * 0.5 - p.highlight / 100.0 * l1.powi(3) * 0.5; - l1 = curve_eval(&sorted, l1.clamp(0.0, 1.0)); - let ratio = l1 / l0; - let mut out = c.map(|v| v * ratio); - // Saturation then vibrance (less saturated colours move more), around the new luminance. - let ll = luma(out); - let sat_now = { - let (mx, mn) = ( - out[0].max(out[1]).max(out[2]), - out[0].min(out[1]).min(out[2]), - ); - if mx > 0.0 { (mx - mn) / mx } else { 0.0 } - }; - let s = 1.0 + p.saturation / 100.0 + p.vibrance / 100.0 * (1.0 - sat_now); - out = out.map(|v| ll + (v - ll) * s.max(0.0)); - q[0] = out[0].clamp(0.0, 1.0); - q[1] = out[1].clamp(0.0, 1.0); - q[2] = out[2].clamp(0.0, 1.0); + let i = y * w + x; + if q[3] <= 0.0 { + continue; + } + let c = [q[0], q[1], q[2]]; + let l0 = luma(c).max(0.0) + EPS; + let new_log = mean + (base[i] - mean) * compress + (logl[i] - base[i]) * detail; + let mut l1 = (new_log.exp() * exposure).max(0.0).powf(gamma); + l1 += p.shadow / 100.0 * (1.0 - l1).powi(3) * 0.5 - p.highlight / 100.0 * l1.powi(3) * 0.5; + l1 = curve_eval(&sorted, l1.clamp(0.0, 1.0)); + let ratio = l1 / l0; + let mut out = c.map(|v| v * ratio); + // Saturation then vibrance (less saturated colours move more), around the new luminance. + let ll = luma(out); + let sat_now = { + let (mx, mn) = (out[0].max(out[1]).max(out[2]), out[0].min(out[1]).min(out[2])); + if mx > 0.0 { (mx - mn) / mx } else { 0.0 } + }; + let s = 1.0 + p.saturation / 100.0 + p.vibrance / 100.0 * (1.0 - sat_now); + out = out.map(|v| ll + (v - ll) * s.max(0.0)); + q[0] = out[0].clamp(0.0, 1.0); + q[1] = out[1].clamp(0.0, 1.0); + q[2] = out[2].clamp(0.0, 1.0); } }); } @@ -430,31 +380,13 @@ mod tests { fn shadows_lift_dark_pixels_more_than_bright() { let mut px = ramp(32, 8); let orig = px.clone(); - shadows_highlights( - &mut px, - 32, - 8, - &ShadowsHighlights { - black_clip: 0.0, - white_clip: 0.0, - ..Default::default() - }, - ); - let gain = |i: usize| { - luma([px[i][0], px[i][1], px[i][2]]) - luma([orig[i][0], orig[i][1], orig[i][2]]) - }; + shadows_highlights(&mut px, 32, 8, &ShadowsHighlights { black_clip: 0.0, white_clip: 0.0, ..Default::default() }); + let gain = |i: usize| luma([px[i][0], px[i][1], px[i][2]]) - luma([orig[i][0], orig[i][1], orig[i][2]]); assert!(gain(3) > 0.02, "{}", gain(3)); assert!(gain(3) > gain(30), "{} {}", gain(3), gain(30)); // Zero amounts and no clipping: identity. let mut px2 = orig.clone(); - let id = ShadowsHighlights { - shadow_amount: 0.0, - highlight_amount: 0.0, - color: 0.0, - black_clip: 0.0, - white_clip: 0.0, - ..Default::default() - }; + let id = ShadowsHighlights { shadow_amount: 0.0, highlight_amount: 0.0, color: 0.0, black_clip: 0.0, white_clip: 0.0, ..Default::default() }; shadows_highlights(&mut px2, 32, 8, &id); assert_eq!(px2, orig); } @@ -463,13 +395,7 @@ mod tests { fn highlights_darken_bright_pixels() { let mut px = ramp(32, 8); let orig = px.clone(); - let p = ShadowsHighlights { - shadow_amount: 0.0, - highlight_amount: 80.0, - black_clip: 0.0, - white_clip: 0.0, - ..Default::default() - }; + let p = ShadowsHighlights { shadow_amount: 0.0, highlight_amount: 80.0, black_clip: 0.0, white_clip: 0.0, ..Default::default() }; shadows_highlights(&mut px, 32, 8, &p); assert!(px[30][0] < orig[30][0]); assert!((px[1][0] - orig[1][0]).abs() < 1e-6); @@ -486,36 +412,19 @@ mod tests { #[test] fn match_color_moves_statistics_toward_source() { - let src: Vec<[f32; 4]> = (0..64) - .map(|i| [0.8, 0.4 + (i % 8) as f32 * 0.02, 0.2, 1.0]) - .collect(); - let mut tgt: Vec<[f32; 4]> = (0..64) - .map(|i| [0.2, 0.3, 0.6 + (i % 8) as f32 * 0.03, 1.0]) - .collect(); + let src: Vec<[f32; 4]> = (0..64).map(|i| [0.8, 0.4 + (i % 8) as f32 * 0.02, 0.2, 1.0]).collect(); + let mut tgt: Vec<[f32; 4]> = (0..64).map(|i| [0.2, 0.3, 0.6 + (i % 8) as f32 * 0.03, 1.0]).collect(); let s = lab_stats(&src, None).unwrap(); let t = lab_stats(&tgt, None).unwrap(); match_color(&mut tgt, &t, Some(&s), &MatchColor::default()); let after = lab_stats(&tgt, None).unwrap(); for k in 0..3 { - assert!( - (after.mean[k] - s.mean[k]).abs() < (t.mean[k] - s.mean[k]).abs() * 0.2 + 1.0, - "{k}: {after:?} vs {s:?}" - ); + assert!((after.mean[k] - s.mean[k]).abs() < (t.mean[k] - s.mean[k]).abs() * 0.2 + 1.0, "{k}: {after:?} vs {s:?}"); } // Neutralize with no source: the mean colour goes gray. - let mut tint: Vec<[f32; 4]> = (0..16) - .map(|i| [0.7, 0.5, 0.3 + i as f32 * 0.01, 1.0]) - .collect(); + let mut tint: Vec<[f32; 4]> = (0..16).map(|i| [0.7, 0.5, 0.3 + i as f32 * 0.01, 1.0]).collect(); let t = lab_stats(&tint, None).unwrap(); - match_color( - &mut tint, - &t, - None, - &MatchColor { - neutralize: true, - ..Default::default() - }, - ); + match_color(&mut tint, &t, None, &MatchColor { neutralize: true, ..Default::default() }); let n = lab_stats(&tint, None).unwrap(); assert!(n.mean[1].abs() < 2.0 && n.mean[2].abs() < 2.0, "{n:?}"); } @@ -523,38 +432,14 @@ mod tests { #[test] fn hdr_toning_compresses_range_and_keeps_bounds() { let mut px = ramp(40, 10); - hdr_toning( - &mut px, - 40, - 10, - &HdrToning { - strength: 2.0, - detail: 0.0, - saturation: 0.0, - ..Default::default() - }, - ); + hdr_toning(&mut px, 40, 10, &HdrToning { strength: 2.0, detail: 0.0, saturation: 0.0, ..Default::default() }); let l0 = luma([px[2][0], px[2][1], px[2][2]]); let l1 = luma([px[38][0], px[38][1], px[38][2]]); assert!(l1 > l0); - assert!( - px.iter() - .all(|q| q[..3].iter().all(|v| (0.0..=1.0).contains(v))) - ); + assert!(px.iter().all(|q| q[..3].iter().all(|v| (0.0..=1.0).contains(v)))); // A toning curve that inverts luminance flips the order. let mut px = ramp(40, 10); - hdr_toning( - &mut px, - 40, - 10, - &HdrToning { - strength: 0.0, - detail: 0.0, - saturation: 0.0, - curve: vec![(0.0, 1.0), (1.0, 0.0)], - ..Default::default() - }, - ); + hdr_toning(&mut px, 40, 10, &HdrToning { strength: 0.0, detail: 0.0, saturation: 0.0, curve: vec![(0.0, 1.0), (1.0, 0.0)], ..Default::default() }); assert!(luma([px[2][0], px[2][1], px[2][2]]) > luma([px[38][0], px[38][1], px[38][2]])); } } diff --git a/crates/algo/src/transform.rs b/crates/algo/src/transform.rs index d3bb5c1c36..a9f3c74d24 100644 --- a/crates/algo/src/transform.rs +++ b/crates/algo/src/transform.rs @@ -120,12 +120,7 @@ impl Interp { fn catmull_rom(t: f64) -> [f64; 4] { let t2 = t * t; let t3 = t2 * t; - [ - -0.5 * t3 + t2 - 0.5 * t, - 1.5 * t3 - 2.5 * t2 + 1.0, - -1.5 * t3 + 2.0 * t2 + 0.5 * t, - 0.5 * t3 - 0.5 * t2, - ] + [-0.5 * t3 + t2 - 0.5 * t, 1.5 * t3 - 2.5 * t2 + 1.0, -1.5 * t3 + 2.0 * t2 + 0.5 * t, 0.5 * t3 - 0.5 * t2] } /// Warp `src` (its content inside `src_rect`) by `h` (source → destination document space). @@ -160,7 +155,8 @@ pub fn warp_surface(src: &Surface, src_rect: Rect, h: &Homography, interp: Inter } let Some(inv) = h.inverse() else { return out }; // Destination bounds: the warped corners (plus a pixel for filter support). - let corners = [(src_rect.x0, src_rect.y0), (src_rect.x1, src_rect.y0), (src_rect.x1, src_rect.y1), (src_rect.x0, src_rect.y1)].map(|(x, y)| h.apply(x as f64, y as f64)); + let corners = [(src_rect.x0, src_rect.y0), (src_rect.x1, src_rect.y0), (src_rect.x1, src_rect.y1), (src_rect.x0, src_rect.y1)] + .map(|(x, y)| h.apply(x as f64, y as f64)); if corners.iter().any(|c| !c.0.is_finite() || !c.1.is_finite()) { return out; } @@ -314,7 +310,12 @@ mod tests { let mut s = rgba(); s.fill_rect(Rect::new(0, 0, 40, 20), &[0.2, 0.4, 0.6, 1.0]); let r = s.content_bounds(); - let up = warp_surface(&s, r, &Homography::rect_to_quad([0.0, 0.0, 40.0, 20.0], [[0.0, 0.0], [80.0, 0.0], [80.0, 40.0], [0.0, 40.0]]).unwrap(), Interp::Bicubic); + let up = warp_surface( + &s, + r, + &Homography::rect_to_quad([0.0, 0.0, 40.0, 20.0], [[0.0, 0.0], [80.0, 0.0], [80.0, 40.0], [0.0, 40.0]]).unwrap(), + Interp::Bicubic, + ); let b = up.content_bounds(); assert!(b.width() >= 80 && b.width() <= 82 && b.height() >= 40 && b.height() <= 42, "{b:?}"); // 90° rotation about (20, 10): a 40×20 box becomes 20×40. diff --git a/crates/algo/src/vanishing.rs b/crates/algo/src/vanishing.rs index 02bc79dd8b..11a9b5b7cd 100644 --- a/crates/algo/src/vanishing.rs +++ b/crates/algo/src/vanishing.rs @@ -99,11 +99,7 @@ impl VpPlane { // Vanishing directions (or the edge directions when they're parallel in the image). let dir = |a: [f64; 2], b: [f64; 2], cc: [f64; 2], d: [f64; 2]| -> V3 { let v = meet(a, b, cc, d); - if v[2].abs() < 1e-9 * (v[0].abs() + v[1].abs()).max(1.0) { - norm([v[0], v[1], 0.0]) - } else { - norm(ray([v[0] / v[2], v[1] / v[2]])) - } + if v[2].abs() < 1e-9 * (v[0].abs() + v[1].abs()).max(1.0) { norm([v[0], v[1], 0.0]) } else { norm(ray([v[0] / v[2], v[1] / v[2]])) } }; let d1 = dir(q[0], q[1], q[3], q[2]); let d2 = dir(q[0], q[3], q[1], q[2]); diff --git a/crates/algo/src/video.rs b/crates/algo/src/video.rs index 8653d95332..bbd093b0a8 100644 --- a/crates/algo/src/video.rs +++ b/crates/algo/src/video.rs @@ -3,9 +3,9 @@ use photocraft_color::ColorMode; use photocraft_geom::Rect; +use crate::Ctx; use crate::fxutil::{MAXC, rgba, set_rgba, xy}; use crate::image::Image; -use crate::Ctx; /// De-Interlace: removes one field (odd = lines 1, 3, 5… counted from the /// bounds' top, i.e. even offsets) and rebuilds it from the neighbouring diff --git a/crates/algo/src/warp.rs b/crates/algo/src/warp.rs index a7def8c391..0740dc0da8 100644 --- a/crates/algo/src/warp.rs +++ b/crates/algo/src/warp.rs @@ -181,7 +181,11 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2 } let fw = foot.width() as usize; let at = |x: i32, y: i32, c: usize| -> f64 { - if x < foot.x0 || y < foot.y0 || x >= foot.x1 || y >= foot.y1 { 0.0 } else { f64::from(px[((y - foot.y0) as usize * fw + (x - foot.x0) as usize) * n + c]) } + if x < foot.x0 || y < foot.y0 || x >= foot.x1 || y >= foot.y1 { + 0.0 + } else { + f64::from(px[((y - foot.y0) as usize * fw + (x - foot.x0) as usize) * n + c]) + } }; let mut outp = vec![0.0f32; w * h * n]; let mut any = false; diff --git a/crates/algo/src/wideangle.rs b/crates/algo/src/wideangle.rs index 10e36da911..ba3fafcc71 100644 --- a/crates/algo/src/wideangle.rs +++ b/crates/algo/src/wideangle.rs @@ -409,7 +409,10 @@ mod tests { let p = WideAngle { model: WideModel::Fisheye, focal_length: 8.0, - constraints: vec![Constraint { a: [60.0, 70.0], b: [420.0, 70.0], orientation: Orientation::Horizontal }, Constraint { a: [80.0, 60.0], b: [80.0, 260.0], orientation: Orientation::Free }], + constraints: vec![ + Constraint { a: [60.0, 70.0], b: [420.0, 70.0], orientation: Orientation::Horizontal }, + Constraint { a: [80.0, 60.0], b: [80.0, 260.0], orientation: Orientation::Free }, + ], ..Default::default() }; let cam = Camera::new(&p, frame); @@ -453,11 +456,30 @@ mod tile_tests { let frame = Rect::new(0, 0, 772, 517); let mut s = Surface::new(PixelFormat::RGBA8); s.fill_rect(frame, &[0.2, 0.4, 0.6, 1.0]); - for p in [WideAngle::default(), WideAngle { model: WideModel::Fisheye, focal_length: 12.0, constraints: vec![Constraint { a: [20.0, 30.0], b: [700.0, 35.0], orientation: Orientation::Horizontal }], ..Default::default() }] { + for p in [ + WideAngle::default(), + WideAngle { + model: WideModel::Fisheye, + focal_length: 12.0, + constraints: vec![Constraint { a: [20.0, 30.0], b: [700.0, 35.0], orientation: Orientation::Horizontal }], + ..Default::default() + }, + ] { let out = apply(&s, frame, &p, Interp::Bilinear); let holes: Vec<(i32, i32)> = (80..440).flat_map(|y| (80..690).map(move |x| (x, y))).filter(|&(x, y)| out.rgba(x, y)[3] < 0.5).collect(); let m = solve(&p, frame); - assert!(holes.is_empty(), "{} holes, first {:?} last {:?}; mesh {}x{} v0 {:?} vlast {:?} bounds {:?}", holes.len(), holes.first(), holes.last(), m.nx, m.ny, m.verts[0], m.verts.last(), out.content_bounds()); + assert!( + holes.is_empty(), + "{} holes, first {:?} last {:?}; mesh {}x{} v0 {:?} vlast {:?} bounds {:?}", + holes.len(), + holes.first(), + holes.last(), + m.nx, + m.ny, + m.verts[0], + m.verts.last(), + out.content_bounds() + ); } } } diff --git a/crates/automation/src/bridge.rs b/crates/automation/src/bridge.rs index e11b137aa6..e6904ea1d7 100644 --- a/crates/automation/src/bridge.rs +++ b/crates/automation/src/bridge.rs @@ -12,10 +12,7 @@ use tokio::sync::Mutex; use crate::AutomationError; -type Conn = ( - BufReader, - tokio::net::tcp::OwnedWriteHalf, -); +type Conn = (BufReader, tokio::net::tcp::OwnedWriteHalf); pub struct BridgeClient { addr: String, @@ -31,16 +28,9 @@ impl BridgeClient { let addr = addr.into(); let host = addr.rsplit_once(':').map(|(h, _)| h).unwrap_or(&addr); if !matches!(host, "127.0.0.1" | "localhost" | "[::1]" | "::1") { - return Err(AutomationError::BadRequest(format!( - "bridge address must be loopback, got `{addr}`" - ))); + return Err(AutomationError::BadRequest(format!("bridge address must be loopback, got `{addr}`"))); } - Ok(BridgeClient { - addr, - conn: Mutex::new(None), - next_id: AtomicU64::new(1), - timeout: Duration::from_secs(60), - }) + Ok(BridgeClient { addr, conn: Mutex::new(None), next_id: AtomicU64::new(1), timeout: Duration::from_secs(60) }) } pub fn with_timeout(mut self, t: Duration) -> Self { @@ -61,12 +51,7 @@ impl BridgeClient { let s = tokio::time::timeout(Duration::from_secs(5), TcpStream::connect(&self.addr)) .await .map_err(|_| AutomationError::Bridge(format!("timed out connecting to {}", self.addr)))? - .map_err(|e| { - AutomationError::Bridge(format!( - "cannot connect to {} ({e}); start the app with `photocraft --control `", - self.addr - )) - })?; + .map_err(|e| AutomationError::Bridge(format!("cannot connect to {} ({e}); start the app with `photocraft --control `", self.addr)))?; let (r, w) = s.into_split(); *guard = Some((BufReader::new(r), w)); } @@ -84,10 +69,7 @@ impl BridgeClient { } Err(_) => { *guard = None; - return Err(AutomationError::Bridge(format!( - "`{method}` timed out after {:?}", - self.timeout - ))); + return Err(AutomationError::Bridge(format!("`{method}` timed out after {:?}", self.timeout))); } } } @@ -96,14 +78,8 @@ impl BridgeClient { } /// Outer `Err` = transport failure (retryable); inner = app-level result. -async fn exchange( - conn: &mut Conn, - id: u64, - method: &str, - params: &Value, -) -> Result, AutomationError> { - let mut line = serde_json::to_string(&json!({"id": id, "method": method, "params": params})) - .map_err(|e| AutomationError::Other(e.to_string()))?; +async fn exchange(conn: &mut Conn, id: u64, method: &str, params: &Value) -> Result, AutomationError> { + let mut line = serde_json::to_string(&json!({"id": id, "method": method, "params": params})).map_err(|e| AutomationError::Other(e.to_string()))?; line.push('\n'); let io = |e: std::io::Error| AutomationError::Bridge(e.to_string()); conn.1.write_all(line.as_bytes()).await.map_err(io)?; @@ -113,9 +89,7 @@ async fn exchange( buf.clear(); let n = conn.0.read_line(&mut buf).await.map_err(io)?; if n == 0 { - return Err(AutomationError::Bridge( - "connection closed by the app".into(), - )); + return Err(AutomationError::Bridge("connection closed by the app".into())); } let Ok(v) = serde_json::from_str::(buf.trim()) else { continue; @@ -126,12 +100,7 @@ async fn exchange( return Ok(if v.get("ok").and_then(Value::as_bool) == Some(true) { Ok(v.get("result").cloned().unwrap_or(Value::Null)) } else { - Err(AutomationError::App( - v.get("error") - .and_then(Value::as_str) - .unwrap_or("unknown error") - .to_owned(), - )) + Err(AutomationError::App(v.get("error").and_then(Value::as_str).unwrap_or("unknown error").to_owned())) }); } } diff --git a/crates/automation/src/files.rs b/crates/automation/src/files.rs index 75c83aacaf..979c616cd5 100644 --- a/crates/automation/src/files.rs +++ b/crates/automation/src/files.rs @@ -19,30 +19,17 @@ pub struct Opened { /// natively; everything else goes through `photocraft-io` (PSD, PNG, …). pub fn open(path: &Path) -> Result { if path.is_dir() { - return Ok(Opened { - document: photocraft_format::load_path(path)?, - warnings: Vec::new(), - }); + return Ok(Opened { document: photocraft_format::load_path(path)?, warnings: Vec::new() }); } - let bytes = - std::fs::read(path).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?; - let name = path - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_default(); + let bytes = std::fs::read(path).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?; + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); let r = photocraft_io::import(&name, &bytes)?; - Ok(Opened { - document: r.document, - warnings: r.warnings, - }) + Ok(Opened { document: r.document, warnings: r.warnings }) } /// Previews for a `.pcraft` bundle. pub fn previews(doc: &Document) -> SaveOptions { - SaveOptions { - thumbnail: Some(photocraft_compose::thumbnail(doc, 256)), - composite: Some(photocraft_compose::thumbnail(doc, 1024)), - } + SaveOptions { thumbnail: Some(photocraft_compose::thumbnail(doc, 256)), composite: Some(photocraft_compose::thumbnail(doc, 1024)) } } /// Save or export `doc` to `path`, choosing the format from the extension @@ -57,16 +44,8 @@ pub fn save( ) -> Result, AutomationError> { let ext = format_override .map(|f| f.trim_start_matches('.').to_ascii_lowercase()) - .or_else(|| { - path.extension() - .map(|e| e.to_string_lossy().to_ascii_lowercase()) - }) - .ok_or_else(|| { - AutomationError::BadRequest(format!( - "cannot tell the format of `{}`; pass a format", - path.display() - )) - })?; + .or_else(|| path.extension().map(|e| e.to_string_lossy().to_ascii_lowercase())) + .ok_or_else(|| AutomationError::BadRequest(format!("cannot tell the format of `{}`; pass a format", path.display())))?; if ext == photocraft_format::EXTENSION { let mut local = PcraftWriter::new(); let w = writer.unwrap_or(&mut local); @@ -77,26 +56,15 @@ pub fn save( if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) { std::fs::create_dir_all(parent).map_err(|e| AutomationError::Io(e.to_string()))?; } - std::fs::write(path, &r.bytes) - .map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?; + std::fs::write(path, &r.bytes).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?; Ok(r.warnings) } /// Flattened document as PNG, scaled to fit `max_side` (0 = full size). pub fn render_png(doc: &Document, max_side: u32) -> Result, AutomationError> { - let side = if max_side == 0 { - doc.size.width.max(doc.size.height) - } else { - max_side - }; + let side = if max_side == 0 { doc.size.width.max(doc.size.height) } else { max_side }; let img = photocraft_compose::thumbnail(doc, side.max(1)); - let image = photocraft_codecs::Image::from_u8( - img.width, - img.height, - photocraft_codecs::ChannelLayout::Rgba, - img.pixels, - ) - .map_err(|e| AutomationError::Other(e.to_string()))?; - photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()) - .map_err(|e| AutomationError::Other(e.to_string())) + let image = photocraft_codecs::Image::from_u8(img.width, img.height, photocraft_codecs::ChannelLayout::Rgba, img.pixels) + .map_err(|e| AutomationError::Other(e.to_string()))?; + photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).map_err(|e| AutomationError::Other(e.to_string())) } diff --git a/crates/automation/src/headless.rs b/crates/automation/src/headless.rs index 5572b3edf5..4f3b011259 100644 --- a/crates/automation/src/headless.rs +++ b/crates/automation/src/headless.rs @@ -27,22 +27,15 @@ impl Headless { fn doc_index(&self, index: Option) -> Result { match index { Some(i) if i < self.session.documents().len() => Ok(i), - Some(i) => Err(AutomationError::BadRequest(format!( - "no document at index {i}" - ))), - None => self - .session - .active_index() - .ok_or_else(|| AutomationError::BadRequest("no document open".into())), + Some(i) => Err(AutomationError::BadRequest(format!("no document at index {i}"))), + None => self.session.active_index().ok_or_else(|| AutomationError::BadRequest("no document open".into())), } } /// Open a file and make it the active document. pub fn open(&mut self, path: &Path) -> Result { let o = files::open(path)?; - let index = self - .session - .add_document(o.document, Some(path.to_string_lossy().into_owned())); + let index = self.session.add_document(o.document, Some(path.to_string_lossy().into_owned())); let d = &self.session.documents()[index]; Ok(json!({ "index": index, @@ -56,22 +49,14 @@ impl Headless { /// Save (`.pcraft` or any export format by extension). With no path, /// saves to the document's own path. - pub fn save( - &mut self, - index: Option, - path: Option<&Path>, - format: Option<&str>, - opts: &ExportOptions, - ) -> Result { + pub fn save(&mut self, index: Option, path: Option<&Path>, format: Option<&str>, opts: &ExportOptions) -> Result { let i = self.doc_index(index)?; let st = &self.session.documents()[i]; let target: PathBuf = match (path, &st.path) { (Some(p), _) => p.to_path_buf(), (None, Some(p)) => PathBuf::from(p), (None, None) => { - return Err(AutomationError::BadRequest( - "document has no path; pass `path`".into(), - )); + return Err(AutomationError::BadRequest("document has no path; pass `path`".into())); } }; let doc = st.doc.clone(); @@ -79,11 +64,7 @@ impl Headless { let warnings = files::save(&doc, &target, format, opts, Some(writer))?; let is_native = format .map(|f| f.trim_start_matches('.').eq_ignore_ascii_case("pcraft")) - .unwrap_or_else(|| { - target - .extension() - .is_some_and(|e| e.eq_ignore_ascii_case("pcraft")) - }); + .unwrap_or_else(|| target.extension().is_some_and(|e| e.eq_ignore_ascii_case("pcraft"))); if is_native { self.session.set_active(i); if let Some(st) = self.session.active_mut() { @@ -96,16 +77,10 @@ impl Headless { pub fn inspect(&self, index: Option) -> Result { let i = self.doc_index(index)?; - Ok(photocraft_engine::inspect::document( - &self.session.documents()[i], - )) + Ok(photocraft_engine::inspect::document(&self.session.documents()[i])) } - pub fn render_png( - &self, - index: Option, - max_side: u32, - ) -> Result, AutomationError> { + pub fn render_png(&self, index: Option, max_side: u32) -> Result, AutomationError> { let i = self.doc_index(index)?; files::render_png(&self.session.documents()[i].doc, max_side) } @@ -115,13 +90,7 @@ impl Headless { } pub fn select(&mut self, index: usize) -> Result { - if self.session.set_active(index) { - Ok(self.session_list()) - } else { - Err(AutomationError::BadRequest(format!( - "no document at index {index}" - ))) - } + if self.session.set_active(index) { Ok(self.session_list()) } else { Err(AutomationError::BadRequest(format!("no document at index {index}"))) } } pub fn close(&mut self, index: Option) -> Result { diff --git a/crates/automation/src/rpc.rs b/crates/automation/src/rpc.rs index a3ce06abdb..66a86db842 100644 --- a/crates/automation/src/rpc.rs +++ b/crates/automation/src/rpc.rs @@ -57,8 +57,7 @@ impl Headless { let p = if params.is_null() { json!({}) } else { params }; match method { "engine.execute" => { - let id = - str_of(&p, "command").ok_or_else(|| bad("engine.execute needs `command`"))?; + let id = str_of(&p, "command").ok_or_else(|| bad("engine.execute needs `command`"))?; self.command_run(id, p.get("params").cloned().unwrap_or(Value::Null)) } "engine.commands" => { @@ -70,11 +69,7 @@ impl Headless { .into_iter() .flatten() .filter(|c| { - let hay = format!( - "{} {}", - c["id"].as_str().unwrap_or(""), - c["label"].as_str().unwrap_or("") - ); + let hay = format!("{} {}", c["id"].as_str().unwrap_or(""), c["label"].as_str().unwrap_or("")); hay.to_lowercase().contains(&n) }) .cloned() @@ -102,8 +97,7 @@ impl Headless { let png = self.render_png(index_of(&p), max)?; match str_of(&p, "path") { Some(path) => { - std::fs::write(path, &png) - .map_err(|e| AutomationError::Io(e.to_string()))?; + std::fs::write(path, &png).map_err(|e| AutomationError::Io(e.to_string()))?; Ok(json!({"path": path, "bytes": png.len()})) } None => Ok(json!({ @@ -127,14 +121,8 @@ impl Headless { /// `{method, params?}` (any [`METHODS`] entry). Stops at the first error unless /// `stopOnError` is false; the reply lists every step's result. pub fn batch(&mut self, p: &Value) -> Result { - let steps = p - .get("steps") - .and_then(Value::as_array) - .ok_or_else(|| bad("batch needs `steps`"))?; - let stop = p - .get("stopOnError") - .and_then(Value::as_bool) - .unwrap_or(true); + let steps = p.get("steps").and_then(Value::as_array).ok_or_else(|| bad("batch needs `steps`"))?; + let stop = p.get("stopOnError").and_then(Value::as_bool).unwrap_or(true); let mut results = Vec::with_capacity(steps.len()); let mut failed = 0usize; for (i, s) in steps.iter().enumerate() { @@ -142,11 +130,7 @@ impl Headless { let r = if let Some(c) = str_of(s, "command") { self.command_run(c, params) } else if let Some(m) = str_of(s, "method") { - if m == "batch" { - Err(bad("nested batch")) - } else { - self.handle(m, params) - } + if m == "batch" { Err(bad("nested batch")) } else { self.handle(m, params) } } else { Err(bad(format!("step {i} needs `command` or `method`"))) }; @@ -202,16 +186,9 @@ pub fn serve_lines(h: &Mutex, r: impl BufRead, mut w: impl Write) -> s /// Serve on a loopback TCP address (one thread per connection, one shared session). /// Refuses non-loopback addresses. -pub fn serve_tcp( - addr: &str, - h: Arc>, - ready: impl FnOnce(std::net::SocketAddr), -) -> Result<(), AutomationError> { - let listener = - TcpListener::bind(addr).map_err(|e| AutomationError::Io(format!("bind {addr}: {e}")))?; - let local = listener - .local_addr() - .map_err(|e| AutomationError::Io(e.to_string()))?; +pub fn serve_tcp(addr: &str, h: Arc>, ready: impl FnOnce(std::net::SocketAddr)) -> Result<(), AutomationError> { + let listener = TcpListener::bind(addr).map_err(|e| AutomationError::Io(format!("bind {addr}: {e}")))?; + let local = listener.local_addr().map_err(|e| AutomationError::Io(e.to_string()))?; if !local.ip().is_loopback() { return Err(bad(format!("{addr} is not a loopback address"))); } @@ -252,22 +229,13 @@ mod tests { ); let mut out = Vec::new(); serve_lines(&h, input.as_bytes(), &mut out).unwrap(); - let replies: Vec = String::from_utf8(out) - .unwrap() - .lines() - .map(|l| serde_json::from_str(l).unwrap()) - .collect(); + let replies: Vec = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect(); assert_eq!(replies.len(), 5); assert!(replies[0]["ok"].as_bool().unwrap()); assert_eq!(replies[1]["id"], 2); assert!(replies[2]["result"].to_string().contains("Ink")); assert!(!replies[3]["ok"].as_bool().unwrap()); - assert!( - replies[3]["error"] - .as_str() - .unwrap() - .contains("unknown method") - ); + assert!(replies[3]["error"].as_str().unwrap().contains("unknown method")); assert_eq!(replies[4]["id"], Value::Null); } @@ -275,8 +243,7 @@ mod tests { fn batch_stops_on_error_unless_told_not_to() { let h = session(); let mut g = h.lock().unwrap(); - g.handle("doc.new", json!({"width": 16, "height": 16})) - .unwrap(); + g.handle("doc.new", json!({"width": 16, "height": 16})).unwrap(); let steps = json!([ {"command": "layer.new.layer", "params": {"name": "A"}}, {"command": "no.such.command"}, @@ -287,9 +254,7 @@ mod tests { assert_eq!(r["completed"], 1); assert_eq!(r["failed"], 1); assert_eq!(r["results"].as_array().unwrap().len(), 2); - let r = g - .handle("batch", json!({"steps": steps, "stopOnError": false})) - .unwrap(); + let r = g.handle("batch", json!({"steps": steps, "stopOnError": false})).unwrap(); assert_eq!(r["completed"], 3); let insp = &r["results"][3]["result"]; assert!(insp.to_string().contains("\"B\"")); @@ -299,31 +264,15 @@ mod tests { fn render_to_file_and_base64() { let h = session(); let mut g = h.lock().unwrap(); - g.handle( - "doc.new", - json!({"width": 40, "height": 20, "background": "black"}), - ) - .unwrap(); + g.handle("doc.new", json!({"width": 40, "height": 20, "background": "black"})).unwrap(); let b = g.handle("doc.render", json!({"maxSide": 10})).unwrap(); - let png = base64::engine::general_purpose::STANDARD - .decode(b["base64"].as_str().unwrap()) - .unwrap(); + let png = base64::engine::general_purpose::STANDARD.decode(b["base64"].as_str().unwrap()).unwrap(); let img = photocraft_codecs::decode(&png).unwrap(); assert_eq!(img.dimensions(), (10, 5)); let path = std::env::temp_dir().join(format!("pc-rpc-{}.png", std::process::id())); - let r = g - .handle( - "doc.render", - json!({"path": path.to_string_lossy(), "maxSide": 0}), - ) - .unwrap(); + let r = g.handle("doc.render", json!({"path": path.to_string_lossy(), "maxSide": 0})).unwrap(); assert!(r["bytes"].as_u64().unwrap() > 0); - assert_eq!( - photocraft_codecs::decode(&std::fs::read(&path).unwrap()) - .unwrap() - .dimensions(), - (40, 20) - ); + assert_eq!(photocraft_codecs::decode(&std::fs::read(&path).unwrap()).unwrap().dimensions(), (40, 20)); let _ = std::fs::remove_file(path); } @@ -339,9 +288,7 @@ mod tests { let mut s = std::net::TcpStream::connect(addr).unwrap(); writeln!(s, r#"{{"id":"a","method":"methods"}}"#).unwrap(); let mut line = String::new(); - BufReader::new(s.try_clone().unwrap()) - .read_line(&mut line) - .unwrap(); + BufReader::new(s.try_clone().unwrap()).read_line(&mut line).unwrap(); let v: Value = serde_json::from_str(&line).unwrap(); assert_eq!(v["id"], "a"); assert!(v["result"].as_array().unwrap().iter().any(|m| m == "batch")); diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index 45ab3e2035..ab0f721ed6 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -158,9 +158,7 @@ pub struct ControlParams { // --------------------------------------------------------------------------- fn ok_json(v: &Value) -> CallToolResult { - CallToolResult::success(vec![Content::text( - serde_json::to_string_pretty(v).unwrap_or_default(), - )]) + CallToolResult::success(vec![Content::text(serde_json::to_string_pretty(v).unwrap_or_default())]) } fn fail(e: impl std::fmt::Display) -> CallToolResult { @@ -192,28 +190,17 @@ impl PhotocraftMcp { } pub fn bridge(addr: &str) -> Result { - Ok(Self::with_backend(Backend::Bridge(Arc::new( - BridgeClient::new(addr)?, - )))) + Ok(Self::with_backend(Backend::Bridge(Arc::new(BridgeClient::new(addr)?)))) } pub fn with_backend(backend: Backend) -> Self { - PhotocraftMcp { - backend: Arc::new(backend), - tool_router: Self::tool_router(), - } + PhotocraftMcp { backend: Arc::new(backend), tool_router: Self::tool_router() } } /// Serve MCP over stdin/stdout until the client disconnects. pub async fn serve_stdio(self) -> Result<(), AutomationError> { - let running = self - .serve(rmcp::transport::stdio()) - .await - .map_err(|e| AutomationError::Other(format!("MCP init: {e}")))?; - running - .waiting() - .await - .map_err(|e| AutomationError::Other(e.to_string()))?; + let running = self.serve(rmcp::transport::stdio()).await.map_err(|e| AutomationError::Other(format!("MCP init: {e}")))?; + running.waiting().await.map_err(|e| AutomationError::Other(e.to_string()))?; Ok(()) } @@ -246,17 +233,9 @@ impl PhotocraftMcp { } } - async fn screenshot( - &self, - b: &BridgeClient, - max_side: Option, - ) -> Result { - let path = - std::env::temp_dir().join(format!("photocraft-mcp-shot-{}.png", std::process::id())); - if let Err(e) = b - .call("ui.screenshot", json!({"path": path.to_string_lossy()})) - .await - { + async fn screenshot(&self, b: &BridgeClient, max_side: Option) -> Result { + let path = std::env::temp_dir().join(format!("photocraft-mcp-shot-{}.png", std::process::id())); + if let Err(e) = b.call("ui.screenshot", json!({"path": path.to_string_lossy()})).await { return Ok(fail(e)); } let bytes = match std::fs::read(&path) { @@ -268,10 +247,7 @@ impl PhotocraftMcp { Some(m) if m > 0 => downscale_png(&bytes, m).unwrap_or(bytes), _ => bytes, }; - Ok(png_result( - &bytes, - "screenshot of the live app window".into(), - )) + Ok(png_result(&bytes, "screenshot of the live app window".into())) } } @@ -282,10 +258,7 @@ fn downscale_png(png: &[u8], max_side: u32) -> Option> { return None; } let s = max_side as f32 / w.max(h) as f32; - let (nw, nh) = ( - ((w as f32 * s) as u32).max(1), - ((h as f32 * s) as u32).max(1), - ); + let (nw, nh) = (((w as f32 * s) as u32).max(1), ((h as f32 * s) as u32).max(1)); let src = img.to_rgba8(); let mut out = vec![0u8; (nw * nh * 4) as usize]; for y in 0..nh { @@ -297,9 +270,7 @@ fn downscale_png(png: &[u8], max_side: u32) -> Option> { out[di..di + 4].copy_from_slice(&src[si..si + 4]); } } - let small = - photocraft_codecs::Image::from_u8(nw, nh, photocraft_codecs::ChannelLayout::Rgba, out) - .ok()?; + let small = photocraft_codecs::Image::from_u8(nw, nh, photocraft_codecs::ChannelLayout::Rgba, out).ok()?; photocraft_codecs::encode(&small, photocraft_codecs::Format::Png, &Default::default()).ok() } @@ -318,13 +289,8 @@ impl PhotocraftMcp { to_result(b.call("ui.inspect", json!({})).await) } - #[tool( - description = "Open an image or document file and make it active. Returns index, size and import warnings." - )] - async fn doc_open( - &self, - Parameters(p): Parameters, - ) -> Result { + #[tool(description = "Open an image or document file and make it active. Returns index, size and import warnings.")] + async fn doc_open(&self, Parameters(p): Parameters) -> Result { let path = PathBuf::from(&p.path); if let Some(r) = self.headless_op(move |h| h.open(&path)).await { return to_result(r); @@ -334,10 +300,7 @@ impl PhotocraftMcp { } #[tool(description = "Create a new document (defaults: 1920x1080 RGB 8-bit white).")] - async fn doc_new( - &self, - Parameters(p): Parameters, - ) -> Result { + async fn doc_new(&self, Parameters(p): Parameters) -> Result { let mut params = serde_json::Map::new(); if let Some(v) = p.width { params.insert("width".into(), v.into()); @@ -357,61 +320,34 @@ impl PhotocraftMcp { if let Some(v) = p.name { params.insert("name".into(), v.into()); } - self.run_command("file.new".into(), Value::Object(params)) - .await + self.run_command("file.new".into(), Value::Object(params)).await } - #[tool( - description = "Save the document. `.pcraft` is the lossless native format (incremental); other extensions \ - (psd, png, jpg, tif, webp, exr, …) export. Returns warnings about anything the format cannot hold." - )] - async fn doc_save( - &self, - Parameters(p): Parameters, - ) -> Result { + #[tool(description = "Save the document. `.pcraft` is the lossless native format (incremental); other extensions \ + (psd, png, jpg, tif, webp, exr, …) export. Returns warnings about anything the format cannot hold.")] + async fn doc_save(&self, Parameters(p): Parameters) -> Result { self.save_impl(p).await } - #[tool( - description = "Export the document to another format (same as doc_save with an explicit path/format)." - )] - async fn doc_export( - &self, - Parameters(p): Parameters, - ) -> Result { + #[tool(description = "Export the document to another format (same as doc_save with an explicit path/format).")] + async fn doc_export(&self, Parameters(p): Parameters) -> Result { if p.path.is_none() { return Ok(fail("doc_export needs `path`")); } self.save_impl(p).await } - #[tool( - description = "Document state as JSON: layer tree (top to bottom), history, selection, active layer." - )] - async fn doc_inspect( - &self, - Parameters(p): Parameters, - ) -> Result { + #[tool(description = "Document state as JSON: layer tree (top to bottom), history, selection, active layer.")] + async fn doc_inspect(&self, Parameters(p): Parameters) -> Result { if let Some(r) = self.headless_op(move |h| h.inspect(p.index)).await { return to_result(r); } let b = self.bridge_client().expect("bridge"); - to_result( - b.call( - "engine.execute", - json!({"command": "document.inspect", "params": {}}), - ) - .await, - ) + to_result(b.call("engine.execute", json!({"command": "document.inspect", "params": {}})).await) } - #[tool( - description = "Render the flattened document and return it as a PNG image (bridge mode: window screenshot)." - )] - async fn doc_render_preview( - &self, - Parameters(p): Parameters, - ) -> Result { + #[tool(description = "Render the flattened document and return it as a PNG image (bridge mode: window screenshot).")] + async fn doc_render_preview(&self, Parameters(p): Parameters) -> Result { let max = p.max_side.unwrap_or(1024); if let Some(r) = self.headless_op(move |h| h.render_png(p.index, max)).await { return Ok(match r { @@ -424,10 +360,7 @@ impl PhotocraftMcp { } #[tool(description = "Make the document at `index` active.")] - async fn doc_select( - &self, - Parameters(p): Parameters, - ) -> Result { + async fn doc_select(&self, Parameters(p): Parameters) -> Result { match self.headless_op(move |h| h.select(p.index)).await { Some(r) => to_result(r), None => bridge_only_headless("doc_select"), @@ -435,23 +368,15 @@ impl PhotocraftMcp { } #[tool(description = "Close a document (default: the active one) without saving.")] - async fn doc_close( - &self, - Parameters(p): Parameters, - ) -> Result { + async fn doc_close(&self, Parameters(p): Parameters) -> Result { match self.headless_op(move |h| h.close(p.index)).await { Some(r) => to_result(r), None => bridge_only_headless("doc_close"), } } - #[tool( - description = "List engine commands: id, label, menu path, shortcut, parameter doc, enabled now." - )] - async fn command_list( - &self, - Parameters(p): Parameters, - ) -> Result { + #[tool(description = "List engine commands: id, label, menu path, shortcut, parameter doc, enabled now.")] + async fn command_list(&self, Parameters(p): Parameters) -> Result { let all = if let Some(r) = self.headless_op(|h| Ok(h.command_list())).await { r } else { @@ -467,52 +392,27 @@ impl PhotocraftMcp { let items: Vec = all .as_array() .cloned() - .unwrap_or_else(|| { - all.get("commands") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default() - }) + .unwrap_or_else(|| all.get("commands").and_then(Value::as_array).cloned().unwrap_or_default()) .into_iter() .filter(|c| { - let hay = format!( - "{} {}", - c.get("id").and_then(Value::as_str).unwrap_or(""), - c.get("label").and_then(Value::as_str).unwrap_or("") - ) - .to_lowercase(); - needle.as_ref().is_none_or(|n| hay.contains(n)) - && (!enabled_only || c.get("enabled").and_then(Value::as_bool).unwrap_or(true)) + let hay = + format!("{} {}", c.get("id").and_then(Value::as_str).unwrap_or(""), c.get("label").and_then(Value::as_str).unwrap_or("")).to_lowercase(); + needle.as_ref().is_none_or(|n| hay.contains(n)) && (!enabled_only || c.get("enabled").and_then(Value::as_bool).unwrap_or(true)) }) .collect(); Ok(ok_json(&Value::Array(items))) } - #[tool( - description = "Run an engine command by id with JSON params (see command_list). Returns the command's JSON result." - )] - async fn command_run( - &self, - Parameters(p): Parameters, - ) -> Result { - self.run_command(p.id, p.params.unwrap_or_else(|| json!({}))) - .await + #[tool(description = "Run an engine command by id with JSON params (see command_list). Returns the command's JSON result.")] + async fn command_run(&self, Parameters(p): Parameters) -> Result { + self.run_command(p.id, p.params.unwrap_or_else(|| json!({}))).await } - #[tool( - description = "Run several engine commands in one call (fewer round trips). Returns {completed, failed, \ - results:[{ok, result|error}]}; stops at the first error unless stop_on_error is false." - )] - async fn command_batch( - &self, - Parameters(p): Parameters, - ) -> Result { + #[tool(description = "Run several engine commands in one call (fewer round trips). Returns {completed, failed, \ + results:[{ok, result|error}]}; stops at the first error unless stop_on_error is false.")] + async fn command_batch(&self, Parameters(p): Parameters) -> Result { let stop = p.stop_on_error.unwrap_or(true); - let steps: Vec = p - .steps - .into_iter() - .map(|s| json!({"command": s.id, "params": s.params.unwrap_or_else(|| json!({}))})) - .collect(); + let steps: Vec = p.steps.into_iter().map(|s| json!({"command": s.id, "params": s.params.unwrap_or_else(|| json!({}))})).collect(); let args = json!({"steps": steps, "stopOnError": stop}); if let Some(r) = self.headless_op(move |h| h.batch(&args)).await { return to_result(r); @@ -532,16 +432,12 @@ impl PhotocraftMcp { } } } - Ok(ok_json( - &json!({"completed": results.len() - failed, "failed": failed, "results": results}), - )) + Ok(ok_json(&json!({"completed": results.len() - failed, "failed": failed, "results": results}))) } // ----- live-GUI tools (bridge mode) ----- - #[tool( - description = "Bridge mode: full UI state of the live app (tool, panels, views, dialogs, menu tree)." - )] + #[tool(description = "Bridge mode: full UI state of the live app (tool, panels, views, dialogs, menu tree).")] async fn ui_inspect(&self) -> Result { match self.bridge_client() { Some(b) => to_result(b.call("ui.inspect", json!({})).await), @@ -550,23 +446,15 @@ impl PhotocraftMcp { } #[tool(description = "Bridge mode: screenshot of the live app window as PNG.")] - async fn ui_screenshot( - &self, - Parameters(p): Parameters, - ) -> Result { + async fn ui_screenshot(&self, Parameters(p): Parameters) -> Result { match self.bridge_client() { Some(b) => self.screenshot(b, p.max_side).await, None => bridge_only("ui_screenshot"), } } - #[tool( - description = "Bridge mode: send pointer events (document coordinates) to the active tool." - )] - async fn ui_pointer( - &self, - Parameters(p): Parameters, - ) -> Result { + #[tool(description = "Bridge mode: send pointer events (document coordinates) to the active tool.")] + async fn ui_pointer(&self, Parameters(p): Parameters) -> Result { match self.bridge_client() { Some(b) => { let mut params = json!({"events": p.events}); @@ -580,10 +468,7 @@ impl PhotocraftMcp { } #[tool(description = "Bridge mode: activate a menu item by id.")] - async fn ui_menu_invoke( - &self, - Parameters(p): Parameters, - ) -> Result { + async fn ui_menu_invoke(&self, Parameters(p): Parameters) -> Result { match self.bridge_client() { Some(b) => to_result(b.call("ui.menu.invoke", json!({"id": p.id})).await), None => bridge_only("ui_menu_invoke"), @@ -591,53 +476,34 @@ impl PhotocraftMcp { } #[tool(description = "Bridge mode: change UI state (tool, panels, zoom, center, dark).")] - async fn ui_set( - &self, - Parameters(p): Parameters, - ) -> Result { + async fn ui_set(&self, Parameters(p): Parameters) -> Result { match self.bridge_client() { Some(b) => to_result(b.call("ui.set", p.fields).await), None => bridge_only("ui_set"), } } - #[tool( - description = "Bridge mode: call any control-protocol method (docs/control-protocol.md) with raw params." - )] - async fn control_call( - &self, - Parameters(p): Parameters, - ) -> Result { + #[tool(description = "Bridge mode: call any control-protocol method (docs/control-protocol.md) with raw params.")] + async fn control_call(&self, Parameters(p): Parameters) -> Result { match self.bridge_client() { - Some(b) => to_result( - b.call(&p.method, p.params.unwrap_or_else(|| json!({}))) - .await, - ), + Some(b) => to_result(b.call(&p.method, p.params.unwrap_or_else(|| json!({}))).await), None => bridge_only("control_call"), } } } fn bridge_only_headless(name: &str) -> Result { - Ok(fail(format!( - "`{name}` is only available in headless mode (in the GUI use the window/tab UI or control_call)" - ))) + Ok(fail(format!("`{name}` is only available in headless mode (in the GUI use the window/tab UI or control_call)"))) } impl PhotocraftMcp { async fn run_command(&self, id: String, params: Value) -> Result { let (id2, params2) = (id.clone(), params.clone()); - if let Some(r) = self - .headless_op(move |h| h.command_run(&id2, params2)) - .await - { + if let Some(r) = self.headless_op(move |h| h.command_run(&id2, params2)).await { return to_result(r); } let b = self.bridge_client().expect("bridge"); - to_result( - b.call("engine.execute", json!({"command": id, "params": params})) - .await, - ) + to_result(b.call("engine.execute", json!({"command": id, "params": params})).await) } async fn save_impl(&self, p: SaveParams) -> Result { @@ -667,10 +533,7 @@ impl PhotocraftMcp { impl ServerHandler for PhotocraftMcp {} /// Used by the render helper in tests and the CLI. -pub fn render_document_png( - doc: &photocraft_doc::Document, - max_side: u32, -) -> Result, AutomationError> { +pub fn render_document_png(doc: &photocraft_doc::Document, max_side: u32) -> Result, AutomationError> { files::render_png(doc, max_side) } @@ -681,15 +544,9 @@ mod tests { #[tokio::test(flavor = "multi_thread")] async fn a_panicking_command_does_not_wedge_the_session() { let mcp = PhotocraftMcp::headless(); - let r = mcp - .headless_op(|_| -> Result<(), AutomationError> { panic!("boom") }) - .await - .unwrap(); + let r = mcp.headless_op(|_| -> Result<(), AutomationError> { panic!("boom") }).await.unwrap(); assert!(r.is_err()); - let r = mcp - .headless_op(|h| h.command_run("file.new", json!({"width": 8, "height": 8}))) - .await - .unwrap(); + let r = mcp.headless_op(|h| h.command_run("file.new", json!({"width": 8, "height": 8}))).await.unwrap(); assert!(r.is_ok(), "session still usable after a panic: {r:?}"); } } diff --git a/crates/automation/tests/agent_tasks.rs b/crates/automation/tests/agent_tasks.rs index ed55ea0f60..65f2847b7f 100644 --- a/crates/automation/tests/agent_tasks.rs +++ b/crates/automation/tests/agent_tasks.rs @@ -32,12 +32,7 @@ async fn connect() -> Conn { } fn text(r: &CallToolResult) -> String { - r.content - .iter() - .filter_map(|c| c.as_text()) - .map(|t| t.text.clone()) - .collect::>() - .join("\n") + r.content.iter().filter_map(|c| c.as_text()).map(|t| t.text.clone()).collect::>().join("\n") } /// Call a tool and parse its JSON result, failing the test on a tool error. @@ -62,16 +57,8 @@ async fn inspect(c: &Conn) -> Value { /// Composite RGBA at (x, y), whatever envelope `command_run` wraps the result in. async fn pixel(c: &Conn, x: i32, y: i32) -> Vec { let v = run(c, "document.pixel", json!({"x": x, "y": y})).await; - let arr = if v.is_array() { - v - } else { - v.get("result").cloned().unwrap_or(v) - }; - arr.as_array() - .unwrap_or_else(|| panic!("pixel: {arr}")) - .iter() - .map(|x| x.as_f64().unwrap()) - .collect() + let arr = if v.is_array() { v } else { v.get("result").cloned().unwrap_or(v) }; + arr.as_array().unwrap_or_else(|| panic!("pixel: {arr}")).iter().map(|x| x.as_f64().unwrap()).collect() } fn top_layer(doc: &Value) -> &Value { @@ -94,16 +81,9 @@ fn gradient_png(dir: &std::path::Path) -> String { px.extend_from_slice(&[(x * 4) as u8, (y * 5) as u8, 128]); } } - let img = - photocraft_codecs::Image::from_u8(w, h, photocraft_codecs::ChannelLayout::Rgb, px).unwrap(); + let img = photocraft_codecs::Image::from_u8(w, h, photocraft_codecs::ChannelLayout::Rgb, px).unwrap(); let path = dir.join("gradient.png"); - std::fs::File::create(&path) - .unwrap() - .write_all( - &photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()) - .unwrap(), - ) - .unwrap(); + std::fs::File::create(&path).unwrap().write_all(&photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap(); path.to_string_lossy().into_owned() } @@ -118,32 +98,14 @@ async fn agent_completes_ten_scripted_tasks() { let c = connect().await; // 1. Make a title card: type layer with a drop shadow, exported as PNG. - tool( - &c, - "doc_new", - json!({"width": 200, "height": 120, "background": "white"}), - ) - .await; - run( - &c, - "type.create", - json!({"x": 20, "y": 70, "text": "Hello", "size": 36}), - ) - .await; - run( - &c, - "layer.layerStyle.dropShadow", - json!({"distance": 4, "size": 3}), - ) - .await; + tool(&c, "doc_new", json!({"width": 200, "height": 120, "background": "white"})).await; + run(&c, "type.create", json!({"x": 20, "y": 70, "text": "Hello", "size": 36})).await; + run(&c, "layer.layerStyle.dropShadow", json!({"distance": 4, "size": 3})).await; let doc = inspect(&c).await; let t = top_layer(&doc); assert_eq!(t["kind"], "Type", "task 1: {t}"); assert_eq!(t["text"]["text"], "Hello", "task 1: {t}"); - assert_eq!( - t["effects"]["items"][0]["kind"], "Drop Shadow", - "task 1: {t}" - ); + assert_eq!(t["effects"]["items"][0]["kind"], "Drop Shadow", "task 1: {t}"); let out = dir.join("card.png"); tool(&c, "doc_export", json!({"path": out.to_string_lossy()})).await; let card = photocraft_codecs::decode(&std::fs::read(&out).unwrap()).unwrap(); @@ -153,132 +115,63 @@ async fn agent_completes_ten_scripted_tasks() { // 2. Colour grade a photo non-destructively: Curves brightens the midtones, Vibrance on top. tool(&c, "doc_open", json!({"path": png})).await; let before = pixel(&c, 16, 24).await; - run( - &c, - "layer.newAdjustmentLayer.curves", - json!({"points": [[0, 0], [128, 200], [255, 255]]}), - ) - .await; - run( - &c, - "layer.newAdjustmentLayer.vibrance", - json!({"vibrance": 40, "saturation": 0}), - ) - .await; + run(&c, "layer.newAdjustmentLayer.curves", json!({"points": [[0, 0], [128, 200], [255, 255]]})).await; + run(&c, "layer.newAdjustmentLayer.vibrance", json!({"vibrance": 40, "saturation": 0})).await; let after = pixel(&c, 16, 24).await; - assert!( - after[1] > before[1] + 0.05, - "task 2: curves should brighten green {before:?} -> {after:?}" - ); + assert!(after[1] > before[1] + 0.05, "task 2: curves should brighten green {before:?} -> {after:?}"); let doc = inspect(&c).await; - let kinds: Vec<&str> = doc["layers"] - .as_array() - .unwrap() - .iter() - .filter_map(|l| l["kind"].as_str()) - .collect(); + let kinds: Vec<&str> = doc["layers"].as_array().unwrap().iter().filter_map(|l| l["kind"].as_str()).collect(); assert_eq!(kinds, ["Adjustment", "Adjustment", "Pixel"], "task 2"); // 3. Undo/redo: remove both adjustments, restore one. run(&c, "edit.undo", json!({})).await; run(&c, "edit.undo", json!({})).await; - assert!( - close(&pixel(&c, 16, 24).await, &before, 1e-6), - "task 3: undo should restore the original" - ); + assert!(close(&pixel(&c, 16, 24).await, &before, 1e-6), "task 3: undo should restore the original"); run(&c, "edit.redo", json!({})).await; - assert_eq!( - inspect(&c).await["layers"].as_array().unwrap().len(), - 2, - "task 3: redo restores Curves" - ); + assert_eq!(inspect(&c).await["layers"].as_array().unwrap().len(), 2, "task 3: redo restores Curves"); run(&c, "edit.undo", json!({})).await; // 4. Editable blur: smart object + Gaussian Blur, retune the radius, then hide the filter. run(&c, "layer.smartObjects.convertToSmartObject", json!({})).await; run(&c, "filter.blur.gaussianBlur", json!({"radius": 2})).await; - run( - &c, - "layer.smartFilter.setParams", - json!({"index": 0, "params": {"radius": 6}}), - ) - .await; + run(&c, "layer.smartFilter.setParams", json!({"index": 0, "params": {"radius": 6}})).await; let t = inspect(&c).await; let sf = &top_layer(&t)["smartFilters"][0]; assert_eq!(sf["command"], "filter.blur.gaussianBlur", "task 4: {t}"); assert_eq!(sf["params"]["radius"].as_f64(), Some(6.0), "task 4: {sf}"); - run( - &c, - "layer.smartFilter.setVisible", - json!({"index": 0, "visible": false}), - ) - .await; - assert!( - close(&pixel(&c, 16, 24).await, &before, 1.0 / 255.0), - "task 4: hiding the filter restores the pixels" - ); + run(&c, "layer.smartFilter.setVisible", json!({"index": 0, "visible": false})).await; + assert!(close(&pixel(&c, 16, 24).await, &before, 1.0 / 255.0), "task 4: hiding the filter restores the pixels"); // 5. Selection → layer mask: an elliptical reveal on a red layer. run(&c, "layer.new.layer", json!({"name": "Red"})).await; run(&c, "select.all", json!({})).await; run(&c, "edit.fill", json!({"color": "#ff0000"})).await; - run( - &c, - "select.rect", - json!({"x": 16, "y": 12, "width": 32, "height": 24, "ellipse": true}), - ) - .await; + run(&c, "select.rect", json!({"x": 16, "y": 12, "width": 32, "height": 24, "ellipse": true})).await; run(&c, "layer.layerMask.revealSelection", json!({})).await; assert_eq!(top_layer(&inspect(&c).await)["hasMask"], true, "task 5"); let inside = pixel(&c, 32, 24).await; let outside = pixel(&c, 1, 1).await; - assert!( - inside[0] > 0.99 && inside[1] < 0.01, - "task 5: centre is red {inside:?}" - ); - assert!( - outside[0] < 0.1, - "task 5: corner shows the photo {outside:?}" - ); + assert!(inside[0] > 0.99 && inside[1] < 0.01, "task 5: centre is red {inside:?}"); + assert!(outside[0] < 0.1, "task 5: corner shows the photo {outside:?}"); run(&c, "select.deselect", json!({})).await; // 6. Save a selection to a channel and load it back. - run( - &c, - "select.rect", - json!({"x": 4, "y": 6, "width": 20, "height": 10}), - ) - .await; + run(&c, "select.rect", json!({"x": 4, "y": 6, "width": 20, "height": 10})).await; run(&c, "select.saveSelection", json!({"name": "Keep"})).await; run(&c, "select.deselect", json!({})).await; assert_eq!(inspect(&c).await["hasSelection"], false, "task 6"); run(&c, "select.loadSelection", json!({"channel": "Keep"})).await; let doc = inspect(&c).await; - assert_eq!( - doc["selectionBounds"], - json!([4, 6, 20, 10]), - "task 6: {}", - doc["selectionBounds"] - ); + assert_eq!(doc["selectionBounds"], json!([4, 6, 20, 10]), "task 6: {}", doc["selectionBounds"]); assert_eq!(doc["channels"]["alpha"][0]["name"], "Keep", "task 6"); run(&c, "select.deselect", json!({})).await; tool(&c, "doc_close", json!({})).await; // 7. Lay out three swatches and align their left edges. - tool( - &c, - "doc_new", - json!({"width": 120, "height": 90, "background": "white"}), - ) - .await; + tool(&c, "doc_new", json!({"width": 120, "height": 90, "background": "white"})).await; for (i, x) in [10, 40, 70].into_iter().enumerate() { run(&c, "layer.new.layer", json!({"name": format!("S{i}")})).await; - run( - &c, - "select.rect", - json!({"x": x, "y": 10 + 25 * i as i32, "width": 20, "height": 15}), - ) - .await; + run(&c, "select.rect", json!({"x": x, "y": 10 + 25 * i as i32, "width": 20, "height": 15})).await; run(&c, "edit.fill", json!({"color": "#3366cc"})).await; } run(&c, "select.deselect", json!({})).await; @@ -297,35 +190,15 @@ async fn agent_completes_ten_scripted_tasks() { // 8. Export every layer to its own file. let layers_dir = dir.join("layers"); std::fs::create_dir_all(&layers_dir).unwrap(); - run( - &c, - "file.export.layersToFiles", - json!({"dir": layers_dir.to_string_lossy(), "format": "png", "prefix": "swatch"}), - ) - .await; - let n = std::fs::read_dir(&layers_dir) - .unwrap() - .filter(|e| { - e.as_ref() - .is_ok_and(|e| e.path().extension().is_some_and(|x| x == "png")) - }) - .count(); + run(&c, "file.export.layersToFiles", json!({"dir": layers_dir.to_string_lossy(), "format": "png", "prefix": "swatch"})).await; + let n = std::fs::read_dir(&layers_dir).unwrap().filter(|e| e.as_ref().is_ok_and(|e| e.path().extension().is_some_and(|x| x == "png"))).count(); assert_eq!(n, 4, "task 8: three swatches plus the background"); // 9. Resize, then crop to a square. run(&c, "image.imageSize", json!({"width": 60, "height": 45})).await; - run( - &c, - "image.crop", - json!({"x": 5, "y": 0, "width": 45, "height": 45}), - ) - .await; + run(&c, "image.crop", json!({"x": 5, "y": 0, "width": 45, "height": 45})).await; let doc = inspect(&c).await; - assert_eq!( - (doc["width"].as_u64(), doc["height"].as_u64()), - (Some(45), Some(45)), - "task 9" - ); + assert_eq!((doc["width"].as_u64(), doc["height"].as_u64()), (Some(45), Some(45)), "task 9"); // 10. Prepare for print: convert to CMYK and save natively, then reopen. run(&c, "image.mode.cmyk", json!({"intent": "perceptual"})).await; @@ -335,11 +208,7 @@ async fn agent_completes_ten_scripted_tasks() { tool(&c, "doc_close", json!({})).await; tool(&c, "doc_open", json!({"path": saved.to_string_lossy()})).await; let doc = inspect(&c).await; - assert_eq!( - (doc["mode"].as_str(), doc["width"].as_u64()), - (Some("Cmyk"), Some(45)), - "task 10" - ); + assert_eq!((doc["mode"].as_str(), doc["width"].as_u64()), (Some("Cmyk"), Some(45)), "task 10"); assert_eq!(doc["layers"].as_array().unwrap().len(), 4, "task 10"); c.cancel().await.unwrap(); diff --git a/crates/automation/tests/mcp.rs b/crates/automation/tests/mcp.rs index 7841d44cf5..24ed25be7a 100644 --- a/crates/automation/tests/mcp.rs +++ b/crates/automation/tests/mcp.rs @@ -28,11 +28,7 @@ async fn connect(server: PhotocraftMcp) -> RunningService { Client.serve(c).await.expect("client init") } -async fn call( - client: &RunningService, - name: &str, - args: Value, -) -> CallToolResult { +async fn call(client: &RunningService, name: &str, args: Value) -> CallToolResult { let mut p = CallToolRequestParams::new(name.to_owned()); if let Value::Object(m) = args { p = p.with_arguments(m); @@ -41,12 +37,7 @@ async fn call( } fn text(r: &CallToolResult) -> String { - r.content - .iter() - .filter_map(|c| c.as_text()) - .map(|t| t.text.clone()) - .collect::>() - .join("\n") + r.content.iter().filter_map(|c| c.as_text()).map(|t| t.text.clone()).collect::>().join("\n") } fn json_of(r: &CallToolResult) -> Value { @@ -86,28 +77,15 @@ async fn lists_expected_tools() { "ui_set", "control_call", ] { - assert!( - names.contains(&n.to_string()), - "missing tool {n}: {names:?}" - ); + assert!(names.contains(&n.to_string()), "missing tool {n}: {names:?}"); } for t in &tools { - assert!( - t.name - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '_'), - "tool name `{}`", - t.name - ); + assert!(t.name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'), "tool name `{}`", t.name); assert!(t.description.as_ref().is_some_and(|d| !d.is_empty())); } let info = client.peer_info().expect("server info"); assert_eq!(info.server_info.as_ref().unwrap().name, "photocraft"); - assert!( - info.instructions - .as_ref() - .is_some_and(|i| i.contains("command_list")) - ); + assert!(info.instructions.as_ref().is_some_and(|i| i.contains("command_list"))); client.cancel().await.unwrap(); } @@ -116,48 +94,23 @@ async fn headless_edit_render_save_roundtrip() { let dir = tmp("edit"); let client = connect(PhotocraftMcp::headless()).await; - let r = call( - &client, - "doc_new", - json!({"width": 64, "height": 48, "background": "white", "name": "Agent"}), - ) - .await; + let r = call(&client, "doc_new", json!({"width": 64, "height": 48, "background": "white", "name": "Agent"})).await; assert_ne!(r.is_error, Some(true), "{}", text(&r)); - let r = call( - &client, - "command_run", - json!({"id": "layer.new.layer", "params": {"name": "Ink"}}), - ) - .await; + let r = call(&client, "command_run", json!({"id": "layer.new.layer", "params": {"name": "Ink"}})).await; assert_ne!(r.is_error, Some(true), "{}", text(&r)); - let r = call( - &client, - "command_run", - json!({"id": "paint.stroke", "params": {"points": [[5, 5, 1.0], [50, 40, 1.0]], "size": 6, "color": "#ff0000"}}), - ) - .await; + let r = + call(&client, "command_run", json!({"id": "paint.stroke", "params": {"points": [[5, 5, 1.0], [50, 40, 1.0]], "size": 6, "color": "#ff0000"}})).await; assert_ne!(r.is_error, Some(true), "{}", text(&r)); let doc = json_of(&call(&client, "doc_inspect", json!({})).await); assert_eq!(doc["width"], 64); - let names: Vec<&str> = doc["layers"] - .as_array() - .unwrap() - .iter() - .filter_map(|l| l["name"].as_str()) - .collect(); + let names: Vec<&str> = doc["layers"].as_array().unwrap().iter().filter_map(|l| l["name"].as_str()).collect(); assert_eq!(names, ["Ink", "Background"]); let r = call(&client, "doc_render_preview", json!({"max_side": 32})).await; - let img = r - .content - .iter() - .find_map(|c| c.as_image()) - .expect("image content"); + let img = r.content.iter().find_map(|c| c.as_image()).expect("image content"); assert_eq!(img.mime_type, "image/png"); - let png = base64::engine::general_purpose::STANDARD - .decode(&img.data) - .unwrap(); + let png = base64::engine::general_purpose::STANDARD.decode(&img.data).unwrap(); let decoded = photocraft_codecs::decode(&png).unwrap(); assert_eq!(decoded.dimensions(), (32, 24)); @@ -165,23 +118,9 @@ async fn headless_edit_render_save_roundtrip() { let r = json_of(&call(&client, "doc_save", json!({"path": pc.to_string_lossy()})).await); assert_eq!(r["path"], pc.to_string_lossy().as_ref()); let png_path = dir.join("agent.png"); - json_of( - &call( - &client, - "doc_export", - json!({"path": png_path.to_string_lossy()}), - ) - .await, - ); + json_of(&call(&client, "doc_export", json!({"path": png_path.to_string_lossy()})).await); let jpg_path = dir.join("agent.jpg"); - json_of( - &call( - &client, - "doc_export", - json!({"path": jpg_path.to_string_lossy(), "quality": 70}), - ) - .await, - ); + json_of(&call(&client, "doc_export", json!({"path": jpg_path.to_string_lossy(), "quality": 70})).await); assert!(photocraft_codecs::decode(&std::fs::read(&png_path).unwrap()).is_ok()); assert!(std::fs::metadata(&jpg_path).unwrap().len() > 100); @@ -206,28 +145,10 @@ async fn command_list_filters() { assert!(n_all > 20); let blur = json_of(&call(&client, "command_list", json!({"filter": "blur"})).await); assert!(!blur.as_array().unwrap().is_empty() && blur.as_array().unwrap().len() < n_all); - assert!( - blur.as_array() - .unwrap() - .iter() - .all(|c| c["params"].is_string()) - ); + assert!(blur.as_array().unwrap().iter().all(|c| c["params"].is_string())); let enabled = json_of(&call(&client, "command_list", json!({"enabled_only": true})).await); - assert!( - enabled - .as_array() - .unwrap() - .iter() - .any(|c| c["id"] == "file.new") - ); - assert!( - !enabled - .as_array() - .unwrap() - .iter() - .any(|c| c["id"] == "layer.new.layer"), - "needs a document" - ); + assert!(enabled.as_array().unwrap().iter().any(|c| c["id"] == "file.new")); + assert!(!enabled.as_array().unwrap().iter().any(|c| c["id"] == "layer.new.layer"), "needs a document"); client.cancel().await.unwrap(); } @@ -239,12 +160,7 @@ async fn errors_are_tool_errors_not_crashes() { assert!(text(&r).contains("unknown command")); let r = call(&client, "doc_inspect", json!({})).await; assert_eq!(r.is_error, Some(true)); - let r = call( - &client, - "doc_open", - json!({"path": "/definitely/missing.png"}), - ) - .await; + let r = call(&client, "doc_open", json!({"path": "/definitely/missing.png"})).await; assert_eq!(r.is_error, Some(true)); let r = call(&client, "ui_inspect", json!({})).await; assert_eq!(r.is_error, Some(true)); @@ -259,35 +175,13 @@ async fn errors_are_tool_errors_not_crashes() { #[tokio::test(flavor = "multi_thread")] async fn open_png_and_inspect() { let dir = tmp("open"); - let img = photocraft_codecs::Image::from_u8( - 8, - 4, - photocraft_codecs::ChannelLayout::Rgb, - vec![200; 96], - ) - .unwrap(); + let img = photocraft_codecs::Image::from_u8(8, 4, photocraft_codecs::ChannelLayout::Rgb, vec![200; 96]).unwrap(); let path = dir.join("in.png"); - std::fs::File::create(&path) - .unwrap() - .write_all( - &photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()) - .unwrap(), - ) - .unwrap(); + std::fs::File::create(&path).unwrap().write_all(&photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap(); let client = connect(PhotocraftMcp::headless()).await; let o = json_of(&call(&client, "doc_open", json!({"path": path.to_string_lossy()})).await); - assert_eq!( - (o["width"].as_u64(), o["height"].as_u64()), - (Some(8), Some(4)) - ); - let px = json_of( - &call( - &client, - "command_run", - json!({"id": "document.pixel", "params": {"x": 1, "y": 1}}), - ) - .await, - ); + assert_eq!((o["width"].as_u64(), o["height"].as_u64()), (Some(8), Some(4))); + let px = json_of(&call(&client, "command_run", json!({"id": "document.pixel", "params": {"x": 1, "y": 1}})).await); assert!(px.to_string().contains("0.78"), "{px}"); client.cancel().await.unwrap(); std::fs::remove_dir_all(dir).unwrap(); @@ -321,31 +215,14 @@ async fn fake_app() -> (String, tokio::task::JoinHandle>) { } "ui.screenshot" => { let path = req["params"]["path"].as_str().unwrap().to_owned(); - let img = photocraft_codecs::Image::from_u8( - 40, - 20, - photocraft_codecs::ChannelLayout::Rgba, - vec![9; 3200], - ) - .unwrap(); - std::fs::write( - &path, - photocraft_codecs::encode( - &img, - photocraft_codecs::Format::Png, - &Default::default(), - ) - .unwrap(), - ) - .unwrap(); + let img = photocraft_codecs::Image::from_u8(40, 20, photocraft_codecs::ChannelLayout::Rgba, vec![9; 3200]).unwrap(); + std::fs::write(&path, photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap(); json!({"id": id, "ok": true, "result": {"path": path}}) } _ => json!({"id": id, "ok": false, "error": format!("unknown tool `{method}`")}), }; // A stale line first, to check id matching. - w.write_all(b"{\"id\":999999,\"ok\":true,\"result\":null}\n") - .await - .unwrap(); + w.write_all(b"{\"id\":999999,\"ok\":true,\"result\":null}\n").await.unwrap(); w.write_all(format!("{reply}\n").as_bytes()).await.unwrap(); seen.push(req); } @@ -361,30 +238,14 @@ async fn bridge_forwards_to_control_protocol() { let ui = json_of(&call(&client, "ui_inspect", json!({})).await); assert_eq!(ui["tool"], "brush"); - let r = json_of( - &call( - &client, - "command_run", - json!({"id": "layer.new.layer", "params": {"name": "X"}}), - ) - .await, - ); + let r = json_of(&call(&client, "command_run", json!({"id": "layer.new.layer", "params": {"name": "X"}})).await); assert_eq!(r["ran"], "layer.new.layer"); let l = json_of(&call(&client, "command_list", json!({})).await); assert_eq!(l[0]["id"], "file.new"); let shot = call(&client, "ui_screenshot", json!({"max_side": 20})).await; - let img = shot - .content - .iter() - .find_map(|c| c.as_image()) - .expect("image"); - let png = base64::engine::general_purpose::STANDARD - .decode(&img.data) - .unwrap(); - assert_eq!( - photocraft_codecs::decode(&png).unwrap().dimensions(), - (20, 10) - ); + let img = shot.content.iter().find_map(|c| c.as_image()).expect("image"); + let png = base64::engine::general_purpose::STANDARD.decode(&img.data).unwrap(); + assert_eq!(photocraft_codecs::decode(&png).unwrap().dimensions(), (20, 10)); let e = call(&client, "control_call", json!({"method": "bogus.method"})).await; assert_eq!(e.is_error, Some(true)); assert!(text(&e).contains("unknown tool")); diff --git a/crates/cms/src/builtin.rs b/crates/cms/src/builtin.rs index 9d3f6ee372..59af62f248 100644 --- a/crates/cms/src/builtin.rs +++ b/crates/cms/src/builtin.rs @@ -4,11 +4,11 @@ use std::sync::OnceLock; +use crate::Intent; use crate::curve::{Curve, srgb_trc}; use crate::math::{self, Mat3}; use crate::pipeline::Stage; use crate::profile::{ColorSpace, Lut, LutKind, Pcs, Profile, ProfileClass}; -use crate::Intent; /// Copyright tag text of every built-in profile. pub const COPYRIGHT: &str = "No copyright: CC0-1.0 public domain dedication. Generated by photocraft-cms."; diff --git a/crates/cms/src/curve.rs b/crates/cms/src/curve.rs index c974563e61..fe23e6e059 100644 --- a/crates/cms/src/curve.rs +++ b/crates/cms/src/curve.rs @@ -12,7 +12,10 @@ pub enum Curve { Gamma(f64), /// ICC `parametricCurveType` function `kind` (0–4) with parameters `[g, a, b, c, d, e, f]` /// (unused ones are 0). - Parametric { kind: u16, p: [f64; 7] }, + Parametric { + kind: u16, + p: [f64; 7], + }, /// Samples at evenly spaced inputs over `[0, 1]`, normalised outputs. Table(Vec), } diff --git a/crates/cms/src/lutfile.rs b/crates/cms/src/lutfile.rs index 4e81cafaed..74495151a7 100644 --- a/crates/cms/src/lutfile.rs +++ b/crates/cms/src/lutfile.rs @@ -52,11 +52,7 @@ impl LutFile { } } } - LutFile { - title: title.to_string(), - size, - data, - } + LutFile { title: title.to_string(), size, data } } fn check(self) -> Result { @@ -64,11 +60,7 @@ impl LutFile { return err(format!("unsupported LUT size {}", self.size)); } if self.data.len() != self.size.pow(3) * 3 { - return err(format!( - "expected {} entries, found {}", - self.size.pow(3), - self.data.len() / 3 - )); + return err(format!("expected {} entries, found {}", self.size.pow(3), self.data.len() / 3)); } if self.data.iter().any(|v| !v.is_finite()) { return err("non-finite LUT value"); @@ -105,26 +97,12 @@ pub fn parse_cube(text: &str) -> Result { let key = it.next().unwrap_or(""); let nums = |it: std::str::SplitWhitespace<'_>| -> Result<[f32; 3], LutError> { let v: Vec = it.filter_map(|s| s.parse().ok()).collect(); - if v.len() == 3 { - Ok([v[0], v[1], v[2]]) - } else { - err(format!("bad line `{line}`")) - } + if v.len() == 3 { Ok([v[0], v[1], v[2]]) } else { err(format!("bad line `{line}`")) } }; match key { "TITLE" => title = line[5..].trim().trim_matches('"').to_string(), - "LUT_3D_SIZE" => { - size3 = it - .next() - .and_then(|s| s.parse().ok()) - .ok_or_else(|| LutError("bad LUT_3D_SIZE".into()))? - } - "LUT_1D_SIZE" => { - size1 = it - .next() - .and_then(|s| s.parse().ok()) - .ok_or_else(|| LutError("bad LUT_1D_SIZE".into()))? - } + "LUT_3D_SIZE" => size3 = it.next().and_then(|s| s.parse().ok()).ok_or_else(|| LutError("bad LUT_3D_SIZE".into()))?, + "LUT_1D_SIZE" => size1 = it.next().and_then(|s| s.parse().ok()).ok_or_else(|| LutError("bad LUT_1D_SIZE".into()))?, "DOMAIN_MIN" => dmin = nums(it)?, "DOMAIN_MAX" => dmax = nums(it)?, "LUT_3D_INPUT_RANGE" | "LUT_1D_INPUT_RANGE" => { @@ -134,13 +112,8 @@ pub fn parse_cube(text: &str) -> Result { dmax = [v[1]; 3]; } } - k if k - .starts_with(|c: char| c.is_ascii_digit() || c == '-' || c == '+' || c == '.') => - { - let mut v = vec![ - k.parse::() - .map_err(|_| LutError(format!("bad number in `{line}`")))?, - ]; + k if k.starts_with(|c: char| c.is_ascii_digit() || c == '-' || c == '+' || c == '.') => { + let mut v = vec![k.parse::().map_err(|_| LutError(format!("bad number in `{line}`")))?]; v.extend(it.filter_map(|s| s.parse::().ok())); if v.len() != 3 { return err(format!("bad data line `{line}`")); @@ -153,12 +126,7 @@ pub fn parse_cube(text: &str) -> Result { } let _ = (dmin, dmax); // Domain other than 0..1 only shifts input sampling; we assume 0..1 inputs. if size3 > 0 { - return LutFile { - title, - size: size3, - data: rows, - } - .check(); + return LutFile { title, size: size3, data: rows }.check(); } if size1 >= 2 && rows.len() == size1 * 3 { let curve = |ch: usize, v: f32| { @@ -167,9 +135,7 @@ pub fn parse_cube(text: &str) -> Result { let f = x - i as f32; rows[i * 3 + ch] * (1.0 - f) + rows[(i + 1) * 3 + ch] * f }; - let mut l = LutFile::from_fn(&title, 33, |c| { - [curve(0, c[0]), curve(1, c[1]), curve(2, c[2])] - }); + let mut l = LutFile::from_fn(&title, 33, |c| [curve(0, c[0]), curve(1, c[1]), curve(2, c[2])]); l.title = title; return l.check(); } @@ -185,10 +151,7 @@ pub fn parse_3dl(text: &str) -> Result { if line.is_empty() || line.starts_with(|c: char| c.is_ascii_alphabetic()) { continue; } - let v: Vec = line - .split_whitespace() - .filter_map(|s| s.parse().ok()) - .collect(); + let v: Vec = line.split_whitespace().filter_map(|s| s.parse().ok()).collect(); if v.len() == 3 { rows.push([v[0], v[1], v[2]]); } @@ -199,14 +162,7 @@ pub fn parse_3dl(text: &str) -> Result { return err(format!("{} rows is not a cube", rows.len())); } let max = rows.iter().flatten().fold(0.0f64, |a, &b| a.max(b)); - let scale = if max <= 1.0 { - 1.0 - } else { - [1023.0, 4095.0, 16383.0, 65535.0] - .into_iter() - .find(|&s| max <= s) - .unwrap_or(max) - }; + let scale = if max <= 1.0 { 1.0 } else { [1023.0, 4095.0, 16383.0, 65535.0].into_iter().find(|&s| max <= s).unwrap_or(max) }; let mut data = vec![0.0f32; n * n * n * 3]; for (i, row) in rows.iter().enumerate() { let (r, g, b) = (i / (n * n), (i / n) % n, i % n); @@ -215,12 +171,7 @@ pub fn parse_3dl(text: &str) -> Result { data[at + k] = (row[k] / scale) as f32; } } - LutFile { - title: String::new(), - size: n, - data, - } - .check() + LutFile { title: String::new(), size: n, data }.check() } /// SpeedGrade `.look`: `` and a hex `` string of little-endian float32 RGB triplets @@ -232,38 +183,14 @@ pub fn parse_look(text: &str) -> Result { let end = text[start..].find(&format!(""))? + start; Some(text[start..end].trim().trim_matches('"').trim().to_string()) }; - let size: usize = tag("size") - .and_then(|s| s.parse().ok()) - .ok_or_else(|| LutError("no in .look".into()))?; - let hex: Vec = tag("data") - .ok_or_else(|| LutError("no in .look".into()))? - .bytes() - .filter(u8::is_ascii_hexdigit) - .collect(); + let size: usize = tag("size").and_then(|s| s.parse().ok()).ok_or_else(|| LutError("no in .look".into()))?; + let hex: Vec = tag("data").ok_or_else(|| LutError("no in .look".into()))?.bytes().filter(u8::is_ascii_hexdigit).collect(); let nib = |c: u8| (c as char).to_digit(16).unwrap_or(0) as u8; - let bytes: Vec = hex - .chunks_exact(2) - .map(|p| nib(p[0]) << 4 | nib(p[1])) - .collect(); - let floats: Vec = bytes - .chunks_exact(4) - .map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])) - .collect(); + let bytes: Vec = hex.chunks_exact(2).map(|p| nib(p[0]) << 4 | nib(p[1])).collect(); + let floats: Vec = bytes.chunks_exact(4).map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])).collect(); let n3 = size.pow(3); - let data = if floats.len() == n3 * 4 { - floats - .chunks_exact(4) - .flat_map(|c| [c[0], c[1], c[2]]) - .collect() - } else { - floats - }; - LutFile { - title: tag("title").unwrap_or_default(), - size, - data, - } - .check() + let data = if floats.len() == n3 * 4 { floats.chunks_exact(4).flat_map(|c| [c[0], c[1], c[2]]).collect() } else { floats }; + LutFile { title: tag("title").unwrap_or_default(), size, data }.check() } /// Writes a `.cube` file (what Photoshop embeds in a Color Lookup layer). @@ -347,10 +274,7 @@ mod tests { #[test] fn cube_roundtrip_and_identity() { let id = LutFile::identity(5); - let text = write_cube(&LutFile { - title: "Id".into(), - ..id.clone() - }); + let text = write_cube(&LutFile { title: "Id".into(), ..id.clone() }); let back = parse_cube(&text).unwrap(); assert_eq!(back.size, 5); assert_eq!(back.title, "Id"); @@ -388,15 +312,8 @@ mod tests { #[test] fn look_hex_floats() { let id = LutFile::identity(2); - let hex: String = id - .data - .iter() - .flat_map(|v| v.to_le_bytes()) - .map(|b| format!("{b:02X}")) - .collect(); - let xml = format!( - "\"2\"\"{hex}\"" - ); + let hex: String = id.data.iter().flat_map(|v| v.to_le_bytes()).map(|b| format!("{b:02X}")).collect(); + let xml = format!("\"2\"\"{hex}\""); assert_eq!(parse("a.look", xml.as_bytes()).unwrap().data, id.data); } diff --git a/crates/cms/src/math.rs b/crates/cms/src/math.rs index e9e6393914..170898fc7f 100644 --- a/crates/cms/src/math.rs +++ b/crates/cms/src/math.rs @@ -27,16 +27,11 @@ pub fn mul(a: &Mat3, b: &Mat3) -> Mat3 { } pub fn apply(m: &Mat3, v: [f64; 3]) -> [f64; 3] { - [ - m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2], - m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2], - m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2], - ] + [m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2], m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2], m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2]] } pub fn det(m: &Mat3) -> f64 { - m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0]) - + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]) + m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0]) + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]) } /// Inverse, or `None` when (nearly) singular. @@ -47,21 +42,9 @@ pub fn invert(m: &Mat3) -> Option { } let inv = 1.0 / d; Some([ - [ - (m[1][1] * m[2][2] - m[1][2] * m[2][1]) * inv, - (m[0][2] * m[2][1] - m[0][1] * m[2][2]) * inv, - (m[0][1] * m[1][2] - m[0][2] * m[1][1]) * inv, - ], - [ - (m[1][2] * m[2][0] - m[1][0] * m[2][2]) * inv, - (m[0][0] * m[2][2] - m[0][2] * m[2][0]) * inv, - (m[0][2] * m[1][0] - m[0][0] * m[1][2]) * inv, - ], - [ - (m[1][0] * m[2][1] - m[1][1] * m[2][0]) * inv, - (m[0][1] * m[2][0] - m[0][0] * m[2][1]) * inv, - (m[0][0] * m[1][1] - m[0][1] * m[1][0]) * inv, - ], + [(m[1][1] * m[2][2] - m[1][2] * m[2][1]) * inv, (m[0][2] * m[2][1] - m[0][1] * m[2][2]) * inv, (m[0][1] * m[1][2] - m[0][2] * m[1][1]) * inv], + [(m[1][2] * m[2][0] - m[1][0] * m[2][2]) * inv, (m[0][0] * m[2][2] - m[0][2] * m[2][0]) * inv, (m[0][2] * m[1][0] - m[0][0] * m[1][2]) * inv], + [(m[1][0] * m[2][1] - m[1][1] * m[2][0]) * inv, (m[0][1] * m[2][0] - m[0][0] * m[2][1]) * inv, (m[0][0] * m[1][1] - m[0][1] * m[1][0]) * inv], ]) } @@ -88,11 +71,7 @@ pub fn rgb_to_xyz_matrix(r: [f64; 2], g: [f64; 2], b: [f64; 2], white: [f64; 3]) let (xr, xg, xb) = (xy_to_xyz(r), xy_to_xyz(g), xy_to_xyz(b)); let m = [[xr[0], xg[0], xb[0]], [xr[1], xg[1], xb[1]], [xr[2], xg[2], xb[2]]]; let s = apply(&invert(&m).expect("primaries are independent"), white); - [ - [m[0][0] * s[0], m[0][1] * s[1], m[0][2] * s[2]], - [m[1][0] * s[0], m[1][1] * s[1], m[1][2] * s[2]], - [m[2][0] * s[0], m[2][1] * s[1], m[2][2] * s[2]], - ] + [[m[0][0] * s[0], m[0][1] * s[1], m[0][2] * s[2]], [m[1][0] * s[0], m[1][1] * s[1], m[1][2] * s[2]], [m[2][0] * s[0], m[2][1] * s[1], m[2][2] * s[2]]] } const EPS: f64 = 216.0 / 24389.0; // (6/29)^3 diff --git a/crates/cms/src/pipeline.rs b/crates/cms/src/pipeline.rs index d425f7d248..1c095756ea 100644 --- a/crates/cms/src/pipeline.rs +++ b/crates/cms/src/pipeline.rs @@ -17,7 +17,12 @@ pub enum Stage { /// Per-channel inverse curves. InvCurves(Vec), /// `out[r] = Σ m[r][c]·in[c] + offset[r]`, `rows × cols`. - Matrix { rows: usize, cols: usize, m: Vec, offset: Vec }, + Matrix { + rows: usize, + cols: usize, + m: Vec, + offset: Vec, + }, Clut(Clut), /// Float CIE Lab (L 0–100) → XYZ (D50, Y = 1). LabToXyz, @@ -185,8 +190,7 @@ impl Pipeline { let noop = match &s { Stage::Curves(c) | Stage::InvCurves(c) => c.iter().all(Curve::is_identity), Stage::Matrix { rows: 3, cols: 3, m, offset } => { - offset.iter().all(|o| o.abs() < 1e-12) - && m.iter().enumerate().all(|(i, v)| (v - if i % 4 == 0 { 1.0 } else { 0.0 }).abs() < 1e-12) + offset.iter().all(|o| o.abs() < 1e-12) && m.iter().enumerate().all(|(i, v)| (v - if i % 4 == 0 { 1.0 } else { 0.0 }).abs() < 1e-12) } _ => false, }; diff --git a/crates/cms/src/profile.rs b/crates/cms/src/profile.rs index d509805d6e..681129b33e 100644 --- a/crates/cms/src/profile.rs +++ b/crates/cms/src/profile.rs @@ -585,8 +585,11 @@ fn parse_lut(d: &[u8], a2b: bool) -> Result { let mut v = Vec::with_capacity(count); for _ in 0..count { let s = r.slice(*off, len * bps)?; - let t: Vec = - if wide { s.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0).collect() } else { s.iter().map(|b| *b as f32 / 255.0).collect() }; + let t: Vec = if wide { + s.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0).collect() + } else { + s.iter().map(|b| *b as f32 / 255.0).collect() + }; *off += len * bps; v.push(Curve::Table(t)); } @@ -599,8 +602,11 @@ fn parse_lut(d: &[u8], a2b: bool) -> Result { } let total = nodes as usize * o; let s = r.slice(off, total * bps)?; - let data: Vec = - if wide { s.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0).collect() } else { s.iter().map(|b| *b as f32 / 255.0).collect() }; + let data: Vec = if wide { + s.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0).collect() + } else { + s.iter().map(|b| *b as f32 / 255.0).collect() + }; off += total * bps; let out_curves = read_tables(&mut off, o, n_out)?; let mut stages = Vec::new(); diff --git a/crates/cms/src/synth.rs b/crates/cms/src/synth.rs index f96f28d2b0..657c829f31 100644 --- a/crates/cms/src/synth.rs +++ b/crates/cms/src/synth.rs @@ -22,12 +22,12 @@ //! The model is plausible for coated offset, not a measured characterisation: use a real //! profile from your print provider for production print. +use crate::Intent; use crate::clut::Clut; use crate::curve::Curve; use crate::math; use crate::pipeline::Stage; use crate::profile::{ColorSpace, Lut, LutKind, Pcs, Profile, ProfileClass}; -use crate::Intent; pub const DESCRIPTION: &str = "Photocraft Coated CMYK (synthetic, 300% TAC, medium GCR)"; @@ -84,14 +84,14 @@ impl CmykModel { // ISO 12647-2 (coated, paper type 1/2) Lab aims, D50/2°; CMY overprint estimated. let w = lab(95.0, 0.0, -2.0); let base: [[f64; 3]; 8] = [ - w, // paper - lab(55.0, -37.0, -50.0), // C - lab(48.0, 74.0, -3.0), // M - lab(24.0, 22.0, -46.0), // C+M (blue) - lab(89.0, -5.0, 93.0), // Y - lab(50.0, -65.0, 27.0), // C+Y (green) - lab(47.0, 68.0, 48.0), // M+Y (red) - lab(23.0, 0.0, 0.0), // C+M+Y + w, // paper + lab(55.0, -37.0, -50.0), // C + lab(48.0, 74.0, -3.0), // M + lab(24.0, 22.0, -46.0), // C+M (blue) + lab(89.0, -5.0, 93.0), // Y + lab(50.0, -65.0, 27.0), // C+Y (green) + lab(47.0, 68.0, 48.0), // M+Y (red) + lab(23.0, 0.0, 0.0), // C+M+Y ]; let k = lab(16.0, 0.0, 0.0); // Overprints with black: multiplicative in reflectance above a first-surface floor. diff --git a/crates/cms/src/transform.rs b/crates/cms/src/transform.rs index b11063e3d2..b8b9579854 100644 --- a/crates/cms/src/transform.rs +++ b/crates/cms/src/transform.rs @@ -326,12 +326,7 @@ impl Transform { let s0 = s1 * c.grid[1]; for (sp, dp) in s.chunks_exact(ss).zip(d.chunks_exact_mut(ds)) { let (a, b, cc) = (g0[sp[0] as usize], g1[sp[1] as usize], g2[sp[2] as usize]); - c.tetra_pub( - (a.0 + b.0 + cc.0) as usize, - [s0, s1, c.outputs], - [a.1, b.1, cc.1], - &mut mid, - ); + c.tetra_pub((a.0 + b.0 + cc.0) as usize, [s0, s1, c.outputs], [a.1, b.1, cc.1], &mut mid); self.finish(&mid, &mut out); for k in 0..self.outputs { dp[k] = q(out[k]); @@ -566,12 +561,7 @@ pub(crate) fn link_stages(src: &Profile, dst: &Profile, intent: Intent, bpc: boo if s_pcs == Pcs::Lab { stages.push(Stage::LabToXyz); } - stages.push(Stage::Matrix { - rows: 3, - cols: 3, - m: vec![scale[0], 0.0, 0.0, 0.0, scale[1], 0.0, 0.0, 0.0, scale[2]], - offset: offset.to_vec(), - }); + stages.push(Stage::Matrix { rows: 3, cols: 3, m: vec![scale[0], 0.0, 0.0, 0.0, scale[1], 0.0, 0.0, 0.0, scale[2]], offset: offset.to_vec() }); if d_pcs == Pcs::Lab { stages.push(Stage::XyzToLab); } diff --git a/crates/cms/tests/cms.rs b/crates/cms/tests/cms.rs index c2ba6d3a75..511a2415e8 100644 --- a/crates/cms/tests/cms.rs +++ b/crates/cms/tests/cms.rs @@ -353,7 +353,9 @@ fn gray_profiles() { #[test] fn hdr_floats_survive_matrix_transforms() { let lin = Builtin::LinearSrgb.profile(); - let t = Transform::with_options(lin, Builtin::Rec2020.profile(), TransformOptions { intent: Intent::RelativeColorimetric, bpc: false, precise_float: true }).unwrap(); + let t = + Transform::with_options(lin, Builtin::Rec2020.profile(), TransformOptions { intent: Intent::RelativeColorimetric, bpc: false, precise_float: true }) + .unwrap(); let back = Transform::new(Builtin::Rec2020.profile(), lin, Intent::RelativeColorimetric, false).unwrap(); let src = [4.0f32, 2.0, 0.5]; let mut mid = [0.0f32; 3]; @@ -410,7 +412,13 @@ fn system_profiles() { Err(e) => panic!("{}: {e}", path.display()), }; seen += 1; - if !matches!(p.class, photocraft_cms::ProfileClass::Input | photocraft_cms::ProfileClass::Display | photocraft_cms::ProfileClass::Output | photocraft_cms::ProfileClass::ColorSpace) { + if !matches!( + p.class, + photocraft_cms::ProfileClass::Input + | photocraft_cms::ProfileClass::Display + | photocraft_cms::ProfileClass::Output + | photocraft_cms::ProfileClass::ColorSpace + ) { continue; } assert!(!p.description.is_empty(), "{}", path.display()); @@ -499,4 +507,3 @@ fn oracle_moxcms_system_cmyk() { let p99 = sorted[sorted.len() * 99 / 100]; assert!(mean < 1.5 && p99 <= 6, "max {worst} p99 {p99} mean {mean}"); } - diff --git a/crates/codecs/src/codecs/exr.rs b/crates/codecs/src/codecs/exr.rs index d3281022a8..07ecdcfb7d 100644 --- a/crates/codecs/src/codecs/exr.rs +++ b/crates/codecs/src/codecs/exr.rs @@ -5,8 +5,7 @@ use std::io::Cursor; use exr::prelude::{ - AnyChannel, AnyChannels, Blocks, Compression, Encoding, FlatSamples, Layer, LayerAttributes, - LineOrder, ReadChannels, ReadLayers, WritableImage, read, + AnyChannel, AnyChannels, Blocks, Compression, Encoding, FlatSamples, Layer, LayerAttributes, LineOrder, ReadChannels, ReadLayers, WritableImage, read, }; use half::f16; @@ -32,57 +31,35 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result let meta = exr::meta::MetaData::read_from_buffered(Cursor::new(bytes), false).map_err(err)?; for header in meta.headers.iter() { let size = header.layer_size; - let (w, h) = ( - u32::try_from(size.0).unwrap_or(u32::MAX), - u32::try_from(size.1).unwrap_or(u32::MAX), - ); + let (w, h) = (u32::try_from(size.0).unwrap_or(u32::MAX), u32::try_from(size.1).unwrap_or(u32::MAX)); let bpp = header.channels.list.len().max(1) as u64 * 4; limits.check_bytes(w, h, bpp)?; } - let image = read() - .no_deep_data() - .largest_resolution_level() - .all_channels() - .first_valid_layer() - .all_attributes() - .from_buffered(Cursor::new(bytes)) - .map_err(err)?; + let image = + read().no_deep_data().largest_resolution_level().all_channels().first_valid_layer().all_attributes().from_buffered(Cursor::new(bytes)).map_err(err)?; let layer = &image.layer_data; let (w, h) = (layer.size.0, layer.size.1); let channels = &layer.channel_data.list; - let find = |n: &str| { - channels - .iter() - .find(|c| base_name(c.name.to_string().as_str()) == n) - }; - let (color, layout): (Vec<_>, ChannelLayout) = - if let (Some(r), Some(g), Some(b)) = (find("R"), find("G"), find("B")) { - match find("A") { - Some(a) => (vec![r, g, b, a], ChannelLayout::Rgba), - None => (vec![r, g, b], ChannelLayout::Rgb), - } - } else if let Some(y) = find("Y").or_else(|| (channels.len() == 1).then(|| &channels[0])) { - match find("A") { - Some(a) => (vec![y, a], ChannelLayout::GrayA), - None => (vec![y], ChannelLayout::Gray), - } - } else { - return Err(CodecError::unsupported(F, "no R/G/B or Y channels")); - }; - let all_f16 = color - .iter() - .all(|c| matches!(c.sample_data, FlatSamples::F16(_))); - let sample = if all_f16 { - SampleType::F16 + let find = |n: &str| channels.iter().find(|c| base_name(c.name.to_string().as_str()) == n); + let (color, layout): (Vec<_>, ChannelLayout) = if let (Some(r), Some(g), Some(b)) = (find("R"), find("G"), find("B")) { + match find("A") { + Some(a) => (vec![r, g, b, a], ChannelLayout::Rgba), + None => (vec![r, g, b], ChannelLayout::Rgb), + } + } else if let Some(y) = find("Y").or_else(|| (channels.len() == 1).then(|| &channels[0])) { + match find("A") { + Some(a) => (vec![y, a], ChannelLayout::GrayA), + None => (vec![y], ChannelLayout::Gray), + } } else { - SampleType::F32 + return Err(CodecError::unsupported(F, "no R/G/B or Y channels")); }; + let all_f16 = color.iter().all(|c| matches!(c.sample_data, FlatSamples::F16(_))); + let sample = if all_f16 { SampleType::F16 } else { SampleType::F32 }; let npx = w * h; if color.iter().any(|c| c.sample_data.len() != npx) { - return Err(err( - "channel sample count mismatch (subsampled channels are unsupported)", - )); + return Err(err("channel sample count mismatch (subsampled channels are unsupported)")); } let nc = color.len(); let (w32, h32) = (w as u32, h as u32); @@ -146,18 +123,11 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result Compression::ZIP16, ExrCompression::Piz => Compression::PIZ, }; - let encoding = Encoding { - compression, - blocks: Blocks::ScanLines, - line_order: LineOrder::Increasing, - }; + let encoding = Encoding { compression, blocks: Blocks::ScanLines, line_order: LineOrder::Increasing }; let channels = AnyChannels::sort(list.into_iter().collect()); let layer = Layer::new((w, h), LayerAttributes::default(), encoding, channels); let image = exr::image::Image::from_layer(layer); let mut cursor = Cursor::new(Vec::new()); - image - .write() - .to_buffered(&mut cursor) - .map_err(|e| CodecError::encode(F, e))?; + image.write().to_buffered(&mut cursor).map_err(|e| CodecError::encode(F, e))?; Ok(cursor.into_inner()) } diff --git a/crates/codecs/src/codecs/jpeg.rs b/crates/codecs/src/codecs/jpeg.rs index 5e3783547c..ae1a217283 100644 --- a/crates/codecs/src/codecs/jpeg.rs +++ b/crates/codecs/src/codecs/jpeg.rs @@ -72,16 +72,12 @@ pub(crate) fn scan_metadata(b: &[u8]) -> JpegMeta { m.exif = Some(seg[EXIF_HEADER.len()..].to_vec()); } 0xE1 if seg.starts_with(XMP_HEADER) && m.xmp.is_none() => { - m.xmp = std::str::from_utf8(&seg[XMP_HEADER.len()..]) - .ok() - .map(|s| s.trim_end_matches('\0').to_owned()); + m.xmp = std::str::from_utf8(&seg[XMP_HEADER.len()..]).ok().map(|s| s.trim_end_matches('\0').to_owned()); } 0xE2 if seg.len() >= 14 && seg.starts_with(ICC_HEADER) => { icc_parts.push((seg[12], seg[13], &seg[14..])); } - 0xC0..=0xCF - if !matches!(marker, 0xC4 | 0xC8 | 0xCC) && seg.len() >= 6 && m.frame.is_none() => - { + 0xC0..=0xCF if !matches!(marker, 0xC4 | 0xC8 | 0xCC) && seg.len() >= 6 && m.frame.is_none() => { let h = u16::from_be_bytes([seg[1], seg[2]]) as u32; let w = u16::from_be_bytes([seg[3], seg[4]]) as u32; m.frame = Some((w, h, seg[5])); @@ -96,11 +92,7 @@ pub(crate) fn scan_metadata(b: &[u8]) -> JpegMeta { if !icc_parts.is_empty() { icc_parts.sort_by_key(|p| p.0); let total = icc_parts[0].1 as usize; - let seqs_ok = icc_parts.len() == total - && icc_parts - .iter() - .enumerate() - .all(|(k, p)| p.0 as usize == k + 1 && p.1 as usize == total); + let seqs_ok = icc_parts.len() == total && icc_parts.iter().enumerate().all(|(k, p)| p.0 as usize == k + 1 && p.1 as usize == total); if seqs_ok || icc_parts.len() == 1 { m.icc = Some(icc_parts.iter().flat_map(|p| p.2.iter().copied()).collect()); } @@ -117,10 +109,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result if let Some((w, h, nc)) = meta.frame { limits.check_bytes(w, h, u64::from(nc.max(1)))?; } - let options = DecoderOptions::default() - .set_strict_mode(false) - .set_max_width(65535) - .set_max_height(65535); + let options = DecoderOptions::default().set_strict_mode(false).set_max_width(65535).set_max_height(65535); let mut dec = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(bytes), options); dec.decode_headers().map_err(err)?; let (w, h) = dec.dimensions().ok_or_else(|| err("no dimensions"))?; @@ -159,12 +148,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result } let mut img = Image::from_raw(w, h, layout, SampleType::U8, px)?; img.icc = meta.icc; - img.meta = Metadata { - exif: meta.exif, - xmp: meta.xmp, - dpi: meta.dpi, - text: Vec::new(), - }; + img.meta = Metadata { exif: meta.exif, xmp: meta.xmp, dpi: meta.dpi, text: Vec::new() }; Ok(img) } @@ -174,10 +158,7 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result (a, b), _ => { - return Err(CodecError::encode( - F, - "JPEG dimensions are limited to 65535", - )); + return Err(CodecError::encode(F, "JPEG dimensions are limited to 65535")); } }; let ct = match img.layout() { @@ -202,10 +183,7 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result= 1.0 && y >= 1.0 { - enc.set_density(jpeg_encoder::Density::Inch { - x: x.round().min(65535.0) as u16, - y: y.round().min(65535.0) as u16, - }); + enc.set_density(jpeg_encoder::Density::Inch { x: x.round().min(65535.0) as u16, y: y.round().min(65535.0) as u16 }); } if let Some(exif) = &img.meta.exif { let mut seg = EXIF_HEADER.to_vec(); diff --git a/crates/codecs/src/codecs/png.rs b/crates/codecs/src/codecs/png.rs index 310dd03914..f406d675f6 100644 --- a/crates/codecs/src/codecs/png.rs +++ b/crates/codecs/src/codecs/png.rs @@ -18,12 +18,7 @@ fn err(e: impl std::fmt::Display) -> CodecError { } pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result { - let mut decoder = png::Decoder::new_with_limits( - Cursor::new(bytes), - png::Limits { - bytes: limits.alloc_usize(), - }, - ); + let mut decoder = png::Decoder::new_with_limits(Cursor::new(bytes), png::Limits { bytes: limits.alloc_usize() }); decoder.set_transformations(png::Transformations::EXPAND); { let info = decoder.read_header_info().map_err(map_png_err)?; @@ -45,9 +40,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result png::BitDepth::Sixteen => SampleType::U16, d => return Err(err(format!("unexpected output bit depth {d:?}"))), }; - let size = reader - .output_buffer_size() - .ok_or_else(|| err("image too large"))?; + let size = reader.output_buffer_size().ok_or_else(|| err("image too large"))?; let mut buf = vec![0u8; size]; let out = reader.next_frame(&mut buf).map_err(map_png_err)?; buf.truncate(out.buffer_size()); @@ -63,19 +56,13 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result let info = reader.info(); let mut img = Image::from_raw(out.width, out.height, layout, sample, buf)?; img.icc = info.icc_profile.as_ref().map(|c| c.to_vec()); - let mut meta = Metadata { - exif: info.exif_metadata.as_ref().map(|c| c.to_vec()), - ..Default::default() - }; + let mut meta = Metadata { exif: info.exif_metadata.as_ref().map(|c| c.to_vec()), ..Default::default() }; if let Some(d) = info.pixel_dims && d.unit == png::Unit::Meter && d.xppu > 0 && d.yppu > 0 { - meta.dpi = Some(( - d.xppu as f32 * METERS_PER_INCH, - d.yppu as f32 * METERS_PER_INCH, - )); + meta.dpi = Some((d.xppu as f32 * METERS_PER_INCH, d.yppu as f32 * METERS_PER_INCH)); } for t in &info.uncompressed_latin1_text { meta.text.push((t.keyword.clone(), t.text.clone())); @@ -100,18 +87,13 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result fn map_png_err(e: png::DecodingError) -> CodecError { match e { - png::DecodingError::LimitsExceeded => { - CodecError::LimitExceeded("PNG decoder memory limit".into()) - } + png::DecodingError::LimitsExceeded => CodecError::LimitExceeded("PNG decoder memory limit".into()), e => err(e), } } fn is_latin1_keyword(k: &str) -> bool { - !k.is_empty() - && k.len() <= 79 - && k.chars() - .all(|c| (' '..='~').contains(&c) || ('\u{a1}'..='\u{ff}').contains(&c)) + !k.is_empty() && k.len() <= 79 && k.chars().all(|c| (' '..='~').contains(&c) || ('\u{a1}'..='\u{ff}').contains(&c)) } pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result, CodecError> { @@ -155,18 +137,14 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result 0.0 && y > 0.0 { - info.pixel_dims = Some(png::PixelDimensions { - xppu: (x / METERS_PER_INCH).round() as u32, - yppu: (y / METERS_PER_INCH).round() as u32, - unit: png::Unit::Meter, - }); + info.pixel_dims = + Some(png::PixelDimensions { xppu: (x / METERS_PER_INCH).round() as u32, yppu: (y / METERS_PER_INCH).round() as u32, unit: png::Unit::Meter }); } } let mut out = Vec::new(); { - let mut encoder = - png::Encoder::with_info(&mut out, info).map_err(|e| CodecError::encode(F, e))?; + let mut encoder = png::Encoder::with_info(&mut out, info).map_err(|e| CodecError::encode(F, e))?; encoder.set_compression(match opts.png_compression { PngCompression::None => png::Compression::NoCompression, PngCompression::Fast => png::Compression::Fast, @@ -186,28 +164,18 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result Result Result, CodecError> { +fn adam7_idat(data: &[u8], w: usize, h: usize, bpp: usize, level: PngCompression) -> Result, CodecError> { // (x0, y0, dx, dy) per pass. - const PASSES: [(usize, usize, usize, usize); 7] = [ - (0, 0, 8, 8), - (4, 0, 8, 8), - (0, 4, 4, 8), - (2, 0, 4, 4), - (0, 2, 2, 4), - (1, 0, 2, 2), - (0, 1, 1, 2), - ]; + const PASSES: [(usize, usize, usize, usize); 7] = [(0, 0, 8, 8), (4, 0, 8, 8), (0, 4, 4, 8), (2, 0, 4, 4), (0, 2, 2, 4), (1, 0, 2, 2), (0, 1, 1, 2)]; let mut raw = Vec::with_capacity(data.len() + h * 7); for (x0, y0, dx, dy) in PASSES { if x0 >= w || y0 >= h { @@ -357,7 +311,13 @@ fn filter_row(row: &[u8], prev: Option<&[u8]>, bpp: usize, scratch: &mut [u8], o let paeth = |a: u8, b: u8, c: u8| { let p = i16::from(a) + i16::from(b) - i16::from(c); let (pa, pb, pc) = ((p - i16::from(a)).abs(), (p - i16::from(b)).abs(), (p - i16::from(c)).abs()); - if pa <= pb && pa <= pc { a } else if pb <= pc { b } else { c } + if pa <= pb && pa <= pc { + a + } else if pb <= pc { + b + } else { + c + } }; let cost = |v: &[u8]| v.iter().map(|&b| u64::from((b as i8).unsigned_abs())).sum::(); let mut best = (cost(row), 0u8); diff --git a/crates/codecs/src/codecs/pnm.rs b/crates/codecs/src/codecs/pnm.rs index 94d99a54b6..07b16a26e1 100644 --- a/crates/codecs/src/codecs/pnm.rs +++ b/crates/codecs/src/codecs/pnm.rs @@ -27,10 +27,7 @@ impl<'a> Reader<'a> { while self.pos < self.b.len() { let c = self.b[self.pos]; if c == b'#' { - while self.pos < self.b.len() - && self.b[self.pos] != b'\n' - && self.b[self.pos] != b'\r' - { + while self.pos < self.b.len() && self.b[self.pos] != b'\n' && self.b[self.pos] != b'\r' { self.pos += 1; } } else if c.is_ascii_whitespace() { @@ -44,10 +41,7 @@ impl<'a> Reader<'a> { fn token(&mut self) -> Result<&'a [u8], CodecError> { self.skip_ws_and_comments(); let start = self.pos; - while self.pos < self.b.len() - && !self.b[self.pos].is_ascii_whitespace() - && self.b[self.pos] != b'#' - { + while self.pos < self.b.len() && !self.b[self.pos].is_ascii_whitespace() && self.b[self.pos] != b'#' { self.pos += 1; } if start == self.pos { @@ -58,10 +52,7 @@ impl<'a> Reader<'a> { fn uint(&mut self) -> Result { let t = self.token()?; - std::str::from_utf8(t) - .ok() - .and_then(|s| s.parse::().ok()) - .ok_or_else(|| err("invalid integer in header")) + std::str::from_utf8(t).ok().and_then(|s| s.parse::().ok()).ok_or_else(|| err("invalid integer in header")) } /// Consume the single whitespace byte that separates header and raster. @@ -89,45 +80,16 @@ fn sample_for_maxval(maxval: u32) -> Result { } /// Build an image from raw integer values in `[0, maxval]`. -fn from_values( - w: u32, - h: u32, - layout: ChannelLayout, - maxval: u32, - values: Vec, -) -> Result { +fn from_values(w: u32, h: u32, layout: ChannelLayout, maxval: u32, values: Vec) -> Result { let sample = sample_for_maxval(maxval)?; if values.iter().any(|&v| v > maxval) { return Err(err("sample exceeds maxval")); } match (sample, maxval) { - (SampleType::U8, 255) => { - Image::from_u8(w, h, layout, values.into_iter().map(|v| v as u8).collect()) - } - (SampleType::U16, 65535) => Image::from_u16( - w, - h, - layout, - &values.into_iter().map(|v| v as u16).collect::>(), - ), - (SampleType::U8, m) => Image::from_u8( - w, - h, - layout, - values - .into_iter() - .map(|v| ((v * 255 + m / 2) / m) as u8) - .collect(), - ), - (_, m) => Image::from_u16( - w, - h, - layout, - &values - .into_iter() - .map(|v| ((v as u64 * 65535 + m as u64 / 2) / m as u64) as u16) - .collect::>(), - ), + (SampleType::U8, 255) => Image::from_u8(w, h, layout, values.into_iter().map(|v| v as u8).collect()), + (SampleType::U16, 65535) => Image::from_u16(w, h, layout, &values.into_iter().map(|v| v as u16).collect::>()), + (SampleType::U8, m) => Image::from_u8(w, h, layout, values.into_iter().map(|v| ((v * 255 + m / 2) / m) as u8).collect()), + (_, m) => Image::from_u16(w, h, layout, &values.into_iter().map(|v| ((v as u64 * 65535 + m as u64 / 2) / m as u64) as u16).collect::>()), } } @@ -141,10 +103,7 @@ fn read_binary(data: &[u8], n: usize, maxval: u32) -> Result, CodecErro if data.len() < n * 2 { return Err(err("truncated raster")); } - Ok(data[..n * 2] - .chunks_exact(2) - .map(|c| u16::from_be_bytes([c[0], c[1]]) as u32) - .collect()) + Ok(data[..n * 2].chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]]) as u32).collect()) } } @@ -189,11 +148,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result } b'2' | b'3' | b'5' | b'6' => { let (w, h, maxval) = (r.uint()?, r.uint()?, r.uint()?); - let layout = if matches!(kind, b'2' | b'5') { - ChannelLayout::Gray - } else { - ChannelLayout::Rgb - }; + let layout = if matches!(kind, b'2' | b'5') { ChannelLayout::Gray } else { ChannelLayout::Rgb }; limits.check(w, h, layout, sample_for_maxval(maxval)?)?; let n = w as usize * h as usize * layout.channels(); let values = if matches!(kind, b'5' | b'6') { @@ -210,11 +165,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result } b'7' => decode_pam(&mut r, limits), b'F' | b'f' => { - let layout = if kind == b'F' { - ChannelLayout::Rgb - } else { - ChannelLayout::Gray - }; + let layout = if kind == b'F' { ChannelLayout::Rgb } else { ChannelLayout::Gray }; let (w, h) = (r.uint()?, r.uint()?); let scale: f32 = std::str::from_utf8(r.token()?) .ok() @@ -237,11 +188,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result for i in 0..row { let o = (y * row + i) * 4; let b4 = [data[o], data[o + 1], data[o + 2], data[o + 3]]; - out[dst + i] = if le { - f32::from_le_bytes(b4) - } else { - f32::from_be_bytes(b4) - }; + out[dst + i] = if le { f32::from_le_bytes(b4) } else { f32::from_be_bytes(b4) }; } } Image::from_f32(w, h, layout, &out) @@ -284,10 +231,7 @@ fn decode_pam(r: &mut Reader<'_>, limits: &Limits) -> Result ("CMYK", 4) => ChannelLayout::Cmyk, ("CMYK_ALPHA", 5) => ChannelLayout::CmykA, (t, d) => { - return Err(CodecError::unsupported( - F, - format!("PAM tuple type {t:?} with depth {d}"), - )); + return Err(CodecError::unsupported(F, format!("PAM tuple type {t:?} with depth {d}"))); } }; limits.check(w, h, layout, sample_for_maxval(maxval)?)?; @@ -296,11 +240,7 @@ fn decode_pam(r: &mut Reader<'_>, limits: &Limits) -> Result from_values(w, h, layout, maxval, values) } -pub(crate) fn encode( - src: &Image, - plan: Plan, - _opts: &EncodeOptions, -) -> Result, CodecError> { +pub(crate) fn encode(src: &Image, plan: Plan, _opts: &EncodeOptions) -> Result, CodecError> { let img = src.convert(plan.layout, plan.sample); let (w, h) = img.dimensions(); let layout = img.layout(); @@ -324,12 +264,8 @@ pub(crate) fn encode( sample @ (SampleType::U8 | SampleType::U16) => { let maxval = if sample == SampleType::U8 { 255 } else { 65535 }; match layout { - ChannelLayout::Gray => { - out.extend_from_slice(format!("P5\n{w} {h}\n{maxval}\n").as_bytes()) - } - ChannelLayout::Rgb => { - out.extend_from_slice(format!("P6\n{w} {h}\n{maxval}\n").as_bytes()) - } + ChannelLayout::Gray => out.extend_from_slice(format!("P5\n{w} {h}\n{maxval}\n").as_bytes()), + ChannelLayout::Rgb => out.extend_from_slice(format!("P6\n{w} {h}\n{maxval}\n").as_bytes()), l => { let t = match l { ChannelLayout::GrayA => "GRAYSCALE_ALPHA", @@ -337,13 +273,7 @@ pub(crate) fn encode( ChannelLayout::Cmyk => "CMYK", _ => "CMYK_ALPHA", }; - out.extend_from_slice( - format!( - "P7\nWIDTH {w}\nHEIGHT {h}\nDEPTH {}\nMAXVAL {maxval}\nTUPLTYPE {t}\nENDHDR\n", - l.channels() - ) - .as_bytes(), - ); + out.extend_from_slice(format!("P7\nWIDTH {w}\nHEIGHT {h}\nDEPTH {}\nMAXVAL {maxval}\nTUPLTYPE {t}\nENDHDR\n", l.channels()).as_bytes()); } } if sample == SampleType::U8 { diff --git a/crates/codecs/src/codecs/tiff.rs b/crates/codecs/src/codecs/tiff.rs index 44d97a9191..b60139a892 100644 --- a/crates/codecs/src/codecs/tiff.rs +++ b/crates/codecs/src/codecs/tiff.rs @@ -79,19 +79,11 @@ fn camera_raw(b: &[u8]) -> bool { }; let u16_at = |o: usize| { let s = b.get(o..o.checked_add(2)?)?; - Some(if le { - u16::from_le_bytes([s[0], s[1]]) - } else { - u16::from_be_bytes([s[0], s[1]]) - }) + Some(if le { u16::from_le_bytes([s[0], s[1]]) } else { u16::from_be_bytes([s[0], s[1]]) }) }; let u32_at = |o: usize| { let s: [u8; 4] = b.get(o..o.checked_add(4)?)?.try_into().ok()?; - Some(if le { - u32::from_le_bytes(s) - } else { - u32::from_be_bytes(s) - }) + Some(if le { u32::from_le_bytes(s) } else { u32::from_be_bytes(s) }) }; // CR2: "CR" and major version 2 right after the TIFF header. if b.get(8..11) == Some(b"CR\x02") { @@ -120,9 +112,7 @@ fn camera_raw(b: &[u8]) -> bool { }; match tag { TAG_DNG_VERSION => return true, - TAG_PHOTOMETRIC - if matches!(value(e), Some(PHOTOMETRIC_CFA | PHOTOMETRIC_LINEAR_RAW)) => - { + TAG_PHOTOMETRIC if matches!(value(e), Some(PHOTOMETRIC_CFA | PHOTOMETRIC_LINEAR_RAW)) => { return true; } TAG_SUB_IFDS if count == 1 => queue.extend(value(e)), @@ -136,8 +126,7 @@ fn camera_raw(b: &[u8]) -> bool { _ => {} } } - if let Some(next) = u32_at(ifd.saturating_add(2 + 12 * usize::from(n))).filter(|&o| o != 0) - { + if let Some(next) = u32_at(ifd.saturating_add(2 + 12 * usize::from(n))).filter(|&o| o != 0) { queue.push(next); } } @@ -146,10 +135,7 @@ fn camera_raw(b: &[u8]) -> bool { pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result { if camera_raw(bytes) { - return Err(CodecError::unsupported( - F, - "camera raw files (such as CR2, NEF, ARW or DNG) are not supported", - )); + return Err(CodecError::unsupported(F, "camera raw files (such as CR2, NEF, ARW or DNG) are not supported")); } let tl = { let mut l = tiff::decoder::Limits::default(); @@ -157,9 +143,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result l.intermediate_buffer_size = limits.alloc_usize().min(1 << 30); l }; - let mut dec = Decoder::new(Cursor::new(bytes)) - .map_err(map_err)? - .with_limits(tl); + let mut dec = Decoder::new(Cursor::new(bytes)).map_err(map_err)?.with_limits(tl); let (w, h) = dec.dimensions().map_err(map_err)?; let ct = dec.colortype().map_err(map_err)?; let (layout, bits) = match ct { @@ -170,27 +154,18 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result tiff::ColorType::CMYK(b) => (ChannelLayout::Cmyk, b), tiff::ColorType::CMYKA(b) => (ChannelLayout::CmykA, b), // The decoder reports gray+alpha as multiband (it ignores ExtraSamples). - tiff::ColorType::Multiband { - bit_depth, - num_samples: 2, - } => (ChannelLayout::GrayA, bit_depth), + tiff::ColorType::Multiband { bit_depth, num_samples: 2 } => (ChannelLayout::GrayA, bit_depth), other => return Err(CodecError::unsupported(F, format!("colour type {other:?}"))), }; let bytes_per_sample = u64::from(bits.max(8) / 8); limits.check_bytes(w, h, layout.channels() as u64 * bytes_per_sample.max(1))?; - let white_is_zero = dec - .find_tag_unsigned::(Tag::PhotometricInterpretation) - .ok() - .flatten() - == Some(PhotometricInterpretation::WhiteIsZero.to_u16()); + let white_is_zero = dec.find_tag_unsigned::(Tag::PhotometricInterpretation).ok().flatten() == Some(PhotometricInterpretation::WhiteIsZero.to_u16()); let result = dec.read_image().map_err(map_err)?; let n = w as usize * h as usize * layout.channels(); let mut img = match result { DecodingResult::U8(v) if bits == 8 => Image::from_u8(w, h, layout, v)?, - DecodingResult::U8(v) if bits < 8 && layout == ChannelLayout::Gray => { - Image::from_u8(w, h, layout, unpack_bits(&v, w as usize, h as usize, bits)?)? - } + DecodingResult::U8(v) if bits < 8 && layout == ChannelLayout::Gray => Image::from_u8(w, h, layout, unpack_bits(&v, w as usize, h as usize, bits)?)?, DecodingResult::U16(v) => Image::from_u16(w, h, layout, &v)?, DecodingResult::U32(v) => { let v: Vec = v.iter().map(|&x| (x >> 16) as u16).collect(); @@ -203,10 +178,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result Image::from_f32(w, h, layout, &v)? } _ => { - return Err(CodecError::unsupported( - F, - format!("sample format for {ct:?}"), - )); + return Err(CodecError::unsupported(F, format!("sample format for {ct:?}"))); } }; if img.sample_count() != n { @@ -225,11 +197,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result img = Image::from_normalized(w, h, layout, img.sample_type(), &v)?; } - img.icc = dec - .find_tag(Tag::IccProfile) - .ok() - .flatten() - .and_then(value_bytes); + img.icc = dec.find_tag(Tag::IccProfile).ok().flatten().and_then(value_bytes); let mut meta = Metadata { xmp: dec .find_tag(Tag::Unknown(TAG_XMP)) @@ -240,21 +208,9 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result .map(|s| s.trim_end_matches('\0').to_owned()), ..Default::default() }; - let unit = dec - .find_tag_unsigned::(Tag::ResolutionUnit) - .ok() - .flatten() - .unwrap_or(2); - let xr = dec - .find_tag(Tag::XResolution) - .ok() - .flatten() - .and_then(rational_f64); - let yr = dec - .find_tag(Tag::YResolution) - .ok() - .flatten() - .and_then(rational_f64); + let unit = dec.find_tag_unsigned::(Tag::ResolutionUnit).ok().flatten().unwrap_or(2); + let xr = dec.find_tag(Tag::XResolution).ok().flatten().and_then(rational_f64); + let yr = dec.find_tag(Tag::YResolution).ok().flatten().and_then(rational_f64); if let (Some(x), Some(y)) = (xr, yr) && x > 0.0 && y > 0.0 @@ -266,12 +222,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result }; } for (tag, key) in TEXT_TAGS { - if let Some(s) = dec - .find_tag(*tag) - .ok() - .flatten() - .and_then(|v| v.into_string().ok()) - { + if let Some(s) = dec.find_tag(*tag).ok().flatten().and_then(|v| v.into_string().ok()) { let s = s.trim_end_matches('\0').to_owned(); if !s.is_empty() { meta.text.push(((*key).to_owned(), s)); @@ -367,46 +318,11 @@ macro_rules! custom_colortype { } use PhotometricInterpretation as PI; -custom_colortype!( - GrayA8, - u8, - PI::BlackIsZero, - &[8, 8], - &[SampleFormat::Uint; 2], - int -); -custom_colortype!( - GrayA16, - u16, - PI::BlackIsZero, - &[16, 16], - &[SampleFormat::Uint; 2], - int -); -custom_colortype!( - GrayA32F, - f32, - PI::BlackIsZero, - &[32, 32], - &[SampleFormat::IEEEFP; 2], - float -); -custom_colortype!( - CmykA16, - u16, - PI::CMYK, - &[16; 5], - &[SampleFormat::Uint; 5], - int -); -custom_colortype!( - CmykA32F, - f32, - PI::CMYK, - &[32; 5], - &[SampleFormat::IEEEFP; 5], - float -); +custom_colortype!(GrayA8, u8, PI::BlackIsZero, &[8, 8], &[SampleFormat::Uint; 2], int); +custom_colortype!(GrayA16, u16, PI::BlackIsZero, &[16, 16], &[SampleFormat::Uint; 2], int); +custom_colortype!(GrayA32F, f32, PI::BlackIsZero, &[32, 32], &[SampleFormat::IEEEFP; 2], float); +custom_colortype!(CmykA16, u16, PI::CMYK, &[16; 5], &[SampleFormat::Uint; 5], int); +custom_colortype!(CmykA32F, f32, PI::CMYK, &[32; 5], &[SampleFormat::IEEEFP; 5], float); struct TagSet<'a> { icc: Option<&'a [u8]>, @@ -416,13 +332,7 @@ struct TagSet<'a> { alpha: bool, } -fn write_one( - enc: &mut TiffEncoder<&mut Cursor>>, - w: u32, - h: u32, - data: &[C::Inner], - tags: &TagSet<'_>, -) -> tiff::TiffResult<()> +fn write_one(enc: &mut TiffEncoder<&mut Cursor>>, w: u32, h: u32, data: &[C::Inner], tags: &TagSet<'_>) -> tiff::TiffResult<()> where C: TiffColorType, [C::Inner]: TiffValue, @@ -430,14 +340,8 @@ where let mut im = enc.new_image::(w, h)?; if let Some((x, y)) = tags.dpi { im.resolution_unit(ResolutionUnit::Inch); - im.x_resolution(Rational { - n: (x * 1000.0).round() as u32, - d: 1000, - }); - im.y_resolution(Rational { - n: (y * 1000.0).round() as u32, - d: 1000, - }); + im.x_resolution(Rational { n: (x * 1000.0).round() as u32, d: 1000 }); + im.y_resolution(Rational { n: (y * 1000.0).round() as u32, d: 1000 }); } let d = im.encoder(); if tags.alpha { @@ -462,16 +366,10 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result tiff::encoder::Compression::Uncompressed, TiffCompression::Lzw => tiff::encoder::Compression::Lzw, - TiffCompression::Deflate => { - tiff::encoder::Compression::Deflate(tiff::encoder::DeflateLevel::Balanced) - } + TiffCompression::Deflate => tiff::encoder::Compression::Deflate(tiff::encoder::DeflateLevel::Balanced), TiffCompression::PackBits => tiff::encoder::Compression::Packbits, }; - let predictor = if !img.sample_type().is_float() - && matches!( - opts.tiff_compression, - TiffCompression::Lzw | TiffCompression::Deflate - ) { + let predictor = if !img.sample_type().is_float() && matches!(opts.tiff_compression, TiffCompression::Lzw | TiffCompression::Deflate) { tiff::encoder::Predictor::Horizontal } else { tiff::encoder::Predictor::None @@ -480,43 +378,23 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result 0.0 && d.1 > 0.0) - } else { - None - }, + icc: if opts.embed_icc { img.icc.as_deref() } else { None }, + xmp: if opts.embed_metadata { img.meta.xmp.as_deref().map(str::as_bytes) } else { None }, + dpi: if opts.embed_metadata { img.meta.dpi.filter(|d| d.0 > 0.0 && d.1 > 0.0) } else { None }, text, alpha: img.layout().has_alpha(), }; let mut cursor = Cursor::new(Vec::new()); { - let mut enc = TiffEncoder::new(&mut cursor) - .map_err(|e| CodecError::encode(F, e))? - .with_compression(compression) - .with_predictor(predictor); + let mut enc = TiffEncoder::new(&mut cursor).map_err(|e| CodecError::encode(F, e))?.with_compression(compression).with_predictor(predictor); use ChannelLayout as L; use SampleType as S; let u16s = || img.to_u16_samples().unwrap_or_default(); @@ -525,9 +403,7 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result write_one::(&mut enc, w, h, d8, &tags), (L::Gray, S::U16) => write_one::(&mut enc, w, h, &u16s(), &tags), - (L::Gray, S::F32) => { - write_one::(&mut enc, w, h, &f32s(), &tags) - } + (L::Gray, S::F32) => write_one::(&mut enc, w, h, &f32s(), &tags), (L::GrayA, S::U8) => write_one::(&mut enc, w, h, d8, &tags), (L::GrayA, S::U16) => write_one::(&mut enc, w, h, &u16s(), &tags), (L::GrayA, S::F32) => write_one::(&mut enc, w, h, &f32s(), &tags), @@ -536,14 +412,10 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result write_one::(&mut enc, w, h, &f32s(), &tags), (L::Rgba, S::U8) => write_one::(&mut enc, w, h, d8, &tags), (L::Rgba, S::U16) => write_one::(&mut enc, w, h, &u16s(), &tags), - (L::Rgba, S::F32) => { - write_one::(&mut enc, w, h, &f32s(), &tags) - } + (L::Rgba, S::F32) => write_one::(&mut enc, w, h, &f32s(), &tags), (L::Cmyk, S::U8) => write_one::(&mut enc, w, h, d8, &tags), (L::Cmyk, S::U16) => write_one::(&mut enc, w, h, &u16s(), &tags), - (L::Cmyk, S::F32) => { - write_one::(&mut enc, w, h, &f32s(), &tags) - } + (L::Cmyk, S::F32) => write_one::(&mut enc, w, h, &f32s(), &tags), (L::CmykA, S::U8) => write_one::(&mut enc, w, h, d8, &tags), (L::CmykA, S::U16) => write_one::(&mut enc, w, h, &u16s(), &tags), (L::CmykA, S::F32) => write_one::(&mut enc, w, h, &f32s(), &tags), diff --git a/crates/codecs/src/codecs/via_image.rs b/crates/codecs/src/codecs/via_image.rs index 318a757693..89bfb39b0f 100644 --- a/crates/codecs/src/codecs/via_image.rs +++ b/crates/codecs/src/codecs/via_image.rs @@ -33,8 +33,7 @@ fn map_err(f: Format, e: image::ImageError) -> CodecError { } pub(crate) fn decode(f: Format, bytes: &[u8], limits: &Limits) -> Result { - let fmt = image_format(f) - .ok_or_else(|| CodecError::unsupported(f, "not handled by image backend"))?; + let fmt = image_format(f).ok_or_else(|| CodecError::unsupported(f, "not handled by image backend"))?; let mut reader = image::ImageReader::with_format(Cursor::new(bytes), fmt); let mut il = image::Limits::default(); il.max_image_width = Some(limits.max_width); @@ -61,17 +60,14 @@ pub(crate) fn from_dynamic(f: Format, d: DynamicImage) -> Result Image::from_u8(w, h, ChannelLayout::Rgb, b.into_raw())?, DynamicImage::ImageRgba8(b) => Image::from_u8(w, h, ChannelLayout::Rgba, b.into_raw())?, DynamicImage::ImageLuma16(b) => Image::from_u16(w, h, ChannelLayout::Gray, &b.into_raw())?, - DynamicImage::ImageLumaA16(b) => { - Image::from_u16(w, h, ChannelLayout::GrayA, &b.into_raw())? - } + DynamicImage::ImageLumaA16(b) => Image::from_u16(w, h, ChannelLayout::GrayA, &b.into_raw())?, DynamicImage::ImageRgb16(b) => Image::from_u16(w, h, ChannelLayout::Rgb, &b.into_raw())?, DynamicImage::ImageRgba16(b) => Image::from_u16(w, h, ChannelLayout::Rgba, &b.into_raw())?, DynamicImage::ImageRgb32F(b) => Image::from_f32(w, h, ChannelLayout::Rgb, &b.into_raw())?, DynamicImage::ImageRgba32F(b) => Image::from_f32(w, h, ChannelLayout::Rgba, &b.into_raw())?, other => { let b = other.into_rgba32f(); - Image::from_f32(w, h, ChannelLayout::Rgba, &b.into_raw()) - .map_err(|e| CodecError::malformed(f, e))? + Image::from_f32(w, h, ChannelLayout::Rgba, &b.into_raw()).map_err(|e| CodecError::malformed(f, e))? } }) } @@ -80,49 +76,28 @@ fn to_dynamic(img: &Image) -> Result { let (w, h) = img.dimensions(); let bad = || CodecError::InvalidImage("buffer size mismatch".into()); Ok(match (img.layout(), img.sample_type()) { - (ChannelLayout::Gray, SampleType::U8) => DynamicImage::ImageLuma8( - image::GrayImage::from_raw(w, h, img.data().to_vec()).ok_or_else(bad)?, - ), - (ChannelLayout::GrayA, SampleType::U8) => DynamicImage::ImageLumaA8( - image::GrayAlphaImage::from_raw(w, h, img.data().to_vec()).ok_or_else(bad)?, - ), - (ChannelLayout::Rgb, SampleType::U8) => DynamicImage::ImageRgb8( - image::RgbImage::from_raw(w, h, img.data().to_vec()).ok_or_else(bad)?, - ), - (ChannelLayout::Rgba, SampleType::U8) => DynamicImage::ImageRgba8( - image::RgbaImage::from_raw(w, h, img.data().to_vec()).ok_or_else(bad)?, - ), - (ChannelLayout::Rgb, SampleType::F32) => DynamicImage::ImageRgb32F( - image::Rgb32FImage::from_raw(w, h, img.to_f32_samples().unwrap_or_default()) - .ok_or_else(bad)?, - ), - (ChannelLayout::Rgba, SampleType::F32) => DynamicImage::ImageRgba32F( - image::Rgba32FImage::from_raw(w, h, img.to_f32_samples().unwrap_or_default()) - .ok_or_else(bad)?, - ), + (ChannelLayout::Gray, SampleType::U8) => DynamicImage::ImageLuma8(image::GrayImage::from_raw(w, h, img.data().to_vec()).ok_or_else(bad)?), + (ChannelLayout::GrayA, SampleType::U8) => DynamicImage::ImageLumaA8(image::GrayAlphaImage::from_raw(w, h, img.data().to_vec()).ok_or_else(bad)?), + (ChannelLayout::Rgb, SampleType::U8) => DynamicImage::ImageRgb8(image::RgbImage::from_raw(w, h, img.data().to_vec()).ok_or_else(bad)?), + (ChannelLayout::Rgba, SampleType::U8) => DynamicImage::ImageRgba8(image::RgbaImage::from_raw(w, h, img.data().to_vec()).ok_or_else(bad)?), + (ChannelLayout::Rgb, SampleType::F32) => { + DynamicImage::ImageRgb32F(image::Rgb32FImage::from_raw(w, h, img.to_f32_samples().unwrap_or_default()).ok_or_else(bad)?) + } + (ChannelLayout::Rgba, SampleType::F32) => { + DynamicImage::ImageRgba32F(image::Rgba32FImage::from_raw(w, h, img.to_f32_samples().unwrap_or_default()).ok_or_else(bad)?) + } (l, s) => { - return Err(CodecError::InvalidImage(format!( - "no image-crate mapping for {l:?} {s:?}" - ))); + return Err(CodecError::InvalidImage(format!("no image-crate mapping for {l:?} {s:?}"))); } }) } -pub(crate) fn encode( - f: Format, - src: &Image, - plan: Plan, - opts: &EncodeOptions, -) -> Result, CodecError> { - let fmt = image_format(f) - .ok_or_else(|| CodecError::unsupported(f, "not handled by image backend"))?; +pub(crate) fn encode(f: Format, src: &Image, plan: Plan, opts: &EncodeOptions) -> Result, CodecError> { + let fmt = image_format(f).ok_or_else(|| CodecError::unsupported(f, "not handled by image backend"))?; if let Some((mw, mh)) = f.max_dimensions() && (src.width() > mw || src.height() > mh) { - return Err(CodecError::encode( - f, - format!("dimensions exceed {mw}x{mh}"), - )); + return Err(CodecError::encode(f, format!("dimensions exceed {mw}x{mh}"))); } let img = src.convert(plan.layout, plan.sample); let dynimg = to_dynamic(&img)?; @@ -130,20 +105,12 @@ pub(crate) fn encode( match f { #[cfg(feature = "avif")] Format::Avif => { - let enc = image::codecs::avif::AvifEncoder::new_with_speed_quality( - &mut cursor, - 8, - opts.jpeg_quality.clamp(1, 100), - ); - dynimg - .write_with_encoder(enc) - .map_err(|e| CodecError::encode(f, e))?; + let enc = image::codecs::avif::AvifEncoder::new_with_speed_quality(&mut cursor, 8, opts.jpeg_quality.clamp(1, 100)); + dynimg.write_with_encoder(enc).map_err(|e| CodecError::encode(f, e))?; } _ => { let _ = opts; - dynimg - .write_to(&mut cursor, fmt) - .map_err(|e| CodecError::encode(f, e))?; + dynimg.write_to(&mut cursor, fmt).map_err(|e| CodecError::encode(f, e))?; } } Ok(cursor.into_inner()) diff --git a/crates/codecs/src/codecs/webp.rs b/crates/codecs/src/codecs/webp.rs index c62daccc03..74600e3add 100644 --- a/crates/codecs/src/codecs/webp.rs +++ b/crates/codecs/src/codecs/webp.rs @@ -19,40 +19,23 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result let mut dec = image_webp::WebPDecoder::new(Cursor::new(bytes)).map_err(err)?; dec.set_memory_limit(limits.alloc_usize()); let (w, h) = dec.dimensions(); - let layout = if dec.has_alpha() { - ChannelLayout::Rgba - } else { - ChannelLayout::Rgb - }; + let layout = if dec.has_alpha() { ChannelLayout::Rgba } else { ChannelLayout::Rgb }; limits.check(w, h, layout, SampleType::U8)?; - let size = dec - .output_buffer_size() - .ok_or_else(|| err("image too large"))?; + let size = dec.output_buffer_size().ok_or_else(|| err("image too large"))?; let mut buf = vec![0u8; size]; dec.read_image(&mut buf).map_err(err)?; let icc = dec.icc_profile().ok().flatten(); let exif = dec.exif_metadata().ok().flatten(); - let xmp = dec - .xmp_metadata() - .ok() - .flatten() - .and_then(|b| String::from_utf8(b).ok()); + let xmp = dec.xmp_metadata().ok().flatten().and_then(|b| String::from_utf8(b).ok()); let mut img = Image::from_u8(w, h, layout, buf)?; img.icc = icc; - img.meta = Metadata { - exif, - xmp, - ..Default::default() - }; + img.meta = Metadata { exif, xmp, ..Default::default() }; Ok(img) } pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result, CodecError> { if !opts.webp_lossless { - return Err(CodecError::unsupported( - F, - "lossy WebP encoding needs libwebp (C); only lossless is available", - )); + return Err(CodecError::unsupported(F, "lossy WebP encoding needs libwebp (C); only lossless is available")); } let img = src.convert(plan.layout, plan.sample); let ct = match img.layout() { @@ -77,7 +60,6 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result Self { - CodecError::Malformed { - format, - message: e.to_string(), - } + CodecError::Malformed { format, message: e.to_string() } } pub(crate) fn encode(format: Format, e: impl std::fmt::Display) -> Self { - CodecError::Encode { - format, - message: e.to_string(), - } + CodecError::Encode { format, message: e.to_string() } } pub(crate) fn unsupported(format: Format, reason: impl Into) -> Self { - CodecError::Unsupported { - format, - reason: reason.into(), - } + CodecError::Unsupported { format, reason: reason.into() } } } diff --git a/crates/codecs/src/fidelity.rs b/crates/codecs/src/fidelity.rs index 0e81962848..e49bd33774 100644 --- a/crates/codecs/src/fidelity.rs +++ b/crates/codecs/src/fidelity.rs @@ -51,10 +51,7 @@ pub enum FidelityWarning { impl FidelityWarning { /// `true` for warnings that make encoding fail outright. pub fn is_fatal(&self) -> bool { - matches!( - self, - FidelityWarning::WriteUnsupported { .. } | FidelityWarning::DimensionsExceeded { .. } - ) + matches!(self, FidelityWarning::WriteUnsupported { .. } | FidelityWarning::DimensionsExceeded { .. }) } } @@ -74,12 +71,7 @@ impl fmt::Display for FidelityWarning { WriteUnsupported { format } => { write!(f, "{} cannot be written in this build", format.name()) } - DepthReduced { from, to } => write!( - f, - "{} will be reduced to {}", - depth_name(*from), - depth_name(*to) - ), + DepthReduced { from, to } => write!(f, "{} will be reduced to {}", depth_name(*from), depth_name(*to)), RangeClipped => write!(f, "HDR values outside 0..1 will be clipped"), AlphaDiscarded => write!(f, "alpha will be discarded"), AlphaBinarized => write!(f, "alpha will be reduced to on/off transparency"), @@ -94,14 +86,8 @@ impl fmt::Display for FidelityWarning { XmpDropped => write!(f, "XMP not supported; it will be dropped"), DpiDropped => write!(f, "resolution (DPI) not supported; it will be dropped"), TextDropped => write!(f, "text metadata not supported; it will be dropped"), - DimensionsExceeded { - max_width, - max_height, - } => { - write!( - f, - "image exceeds the format maximum of {max_width}x{max_height}" - ) + DimensionsExceeded { max_width, max_height } => { + write!(f, "image exceeds the format maximum of {max_width}x{max_height}") } } } @@ -125,10 +111,7 @@ fn pick_sample(src: SampleType, depths: &[SampleType]) -> SampleType { SampleType::F16 => &[SampleType::F32, SampleType::U16, SampleType::U8], SampleType::F32 => &[SampleType::F16, SampleType::U16, SampleType::U8], }; - pref.iter() - .copied() - .find(|s| depths.contains(s)) - .unwrap_or(depths[0]) + pref.iter().copied().find(|s| depths.contains(s)).unwrap_or(depths[0]) } fn pick_layout(src: ChannelLayout, layouts: &[ChannelLayout], alpha: bool) -> ChannelLayout { @@ -141,11 +124,7 @@ fn pick_layout(src: ChannelLayout, layouts: &[ChannelLayout], alpha: bool) -> Ch } // CMYK falls back to RGB; gray expands to RGB. if cand.is_cmyk() || cand.is_gray() { - cand = if cand.has_alpha() { - ChannelLayout::Rgba - } else { - ChannelLayout::Rgb - }; + cand = if cand.has_alpha() { ChannelLayout::Rgba } else { ChannelLayout::Rgb }; } if layouts.contains(&cand) { return cand; @@ -165,14 +144,8 @@ pub(crate) fn plan(image: &Image, format: Format, _opts: &EncodeOptions) -> Plan match format { // PNM: integer data can use any layout (PAM); float goes to PFM // which only has gray and RGB. - Format::Pnm if sample.is_float() => Plan { - layout: pick_layout(layout, &[ChannelLayout::Gray, ChannelLayout::Rgb], false), - sample: SampleType::F32, - }, - _ => Plan { - layout: pick_layout(layout, c.layouts, c.alpha), - sample: pick_sample(sample, c.depths), - }, + Format::Pnm if sample.is_float() => Plan { layout: pick_layout(layout, &[ChannelLayout::Gray, ChannelLayout::Rgb], false), sample: SampleType::F32 }, + _ => Plan { layout: pick_layout(layout, c.layouts, c.alpha), sample: pick_sample(sample, c.depths) }, } } @@ -182,11 +155,7 @@ pub fn fidelity_warnings(image: &Image, format: Format) -> Vec } /// Warnings for writing `image` as `format` with the given options. -pub fn fidelity_warnings_with( - image: &Image, - format: Format, - opts: &EncodeOptions, -) -> Vec { +pub fn fidelity_warnings_with(image: &Image, format: Format, opts: &EncodeOptions) -> Vec { use FidelityWarning as W; let c = caps(format); let mut w = Vec::new(); @@ -197,10 +166,7 @@ pub fn fidelity_warnings_with( if let Some((mw, mh)) = format.max_dimensions() && (image.width() > mw || image.height() > mh) { - w.push(W::DimensionsExceeded { - max_width: mw, - max_height: mh, - }); + w.push(W::DimensionsExceeded { max_width: mw, max_height: mh }); } let p = plan(image, format, opts); let (sl, ss) = (image.layout(), image.sample_type()); @@ -214,10 +180,7 @@ pub fn fidelity_warnings_with( _ => true, }; if lossy_depth { - w.push(W::DepthReduced { - from: ss, - to: p.sample, - }); + w.push(W::DepthReduced { from: ss, to: p.sample }); } if ss.is_float() && !p.sample.is_float() && image.has_out_of_range() { w.push(W::RangeClipped); diff --git a/crates/codecs/src/format.rs b/crates/codecs/src/format.rs index b2f99a5c73..37b01fcb25 100644 --- a/crates/codecs/src/format.rs +++ b/crates/codecs/src/format.rs @@ -175,82 +175,21 @@ pub fn caps(format: Format) -> FormatCaps { lossy: false, }; match format { - Format::Png => FormatCaps { - depths: &[S::U8, S::U16], - layouts: RGB_GRAY, - icc: true, - exif: true, - xmp: true, - dpi: true, - text: true, - animation: true, - ..base - }, - Format::Jpeg => FormatCaps { - layouts: &[L::Gray, L::Rgb, L::Cmyk], - alpha: false, - icc: true, - exif: true, - xmp: true, - dpi: true, - lossy: true, - ..base - }, - Format::Tiff => FormatCaps { - depths: &[S::U8, S::U16, S::F32], - layouts: ALL_LAYOUTS, - icc: true, - xmp: true, - dpi: true, - text: true, - ..base - }, - Format::WebP => FormatCaps { - icc: true, - exif: true, - xmp: true, - animation: true, - ..base - }, - Format::Gif => FormatCaps { - layouts: &[L::Rgba], - animation: true, - lossy: true, - ..base - }, + Format::Png => { + FormatCaps { depths: &[S::U8, S::U16], layouts: RGB_GRAY, icc: true, exif: true, xmp: true, dpi: true, text: true, animation: true, ..base } + } + Format::Jpeg => FormatCaps { layouts: &[L::Gray, L::Rgb, L::Cmyk], alpha: false, icc: true, exif: true, xmp: true, dpi: true, lossy: true, ..base }, + Format::Tiff => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: ALL_LAYOUTS, icc: true, xmp: true, dpi: true, text: true, ..base }, + Format::WebP => FormatCaps { icc: true, exif: true, xmp: true, animation: true, ..base }, + Format::Gif => FormatCaps { layouts: &[L::Rgba], animation: true, lossy: true, ..base }, Format::Bmp => base, - Format::Tga => FormatCaps { - layouts: RGB_GRAY, - ..base - }, - Format::Ico => FormatCaps { - layouts: &[L::Rgba], - ..base - }, - Format::Pnm => FormatCaps { - depths: &[S::U8, S::U16, S::F32], - layouts: ALL_LAYOUTS, - ..base - }, + Format::Tga => FormatCaps { layouts: RGB_GRAY, ..base }, + Format::Ico => FormatCaps { layouts: &[L::Rgba], ..base }, + Format::Pnm => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: ALL_LAYOUTS, ..base }, Format::Qoi => base, - Format::OpenExr => FormatCaps { - depths: &[S::F16, S::F32], - layouts: RGB_GRAY, - ..base - }, - Format::Hdr => FormatCaps { - depths: &[S::F32], - layouts: &[L::Rgb], - alpha: false, - lossy: true, - ..base - }, - Format::Avif => FormatCaps { - read: false, - write: cfg!(feature = "avif"), - lossy: true, - ..base - }, + Format::OpenExr => FormatCaps { depths: &[S::F16, S::F32], layouts: RGB_GRAY, ..base }, + Format::Hdr => FormatCaps { depths: &[S::F32], layouts: &[L::Rgb], alpha: false, lossy: true, ..base }, + Format::Avif => FormatCaps { read: false, write: cfg!(feature = "avif"), lossy: true, ..base }, } } @@ -264,11 +203,7 @@ pub fn detect(bytes: &[u8]) -> Option { if b.starts_with(&[0xFF, 0xD8, 0xFF]) { return Some(Format::Jpeg); } - if b.starts_with(b"II*\0") - || b.starts_with(b"MM\0*") - || b.starts_with(b"II+\0") - || b.starts_with(b"MM\0+") - { + if b.starts_with(b"II*\0") || b.starts_with(b"MM\0*") || b.starts_with(b"II+\0") || b.starts_with(b"MM\0+") { return Some(Format::Tiff); } if b.len() >= 12 && &b[0..4] == b"RIFF" && &b[8..12] == b"WEBP" { @@ -295,11 +230,7 @@ pub fn detect(bytes: &[u8]) -> Option { if b.len() >= 6 && b[0..4] == [0, 0, 1, 0] && (b[4] != 0 || b[5] != 0) { return Some(Format::Ico); } - if b.len() >= 3 - && b[0] == b'P' - && matches!(b[1], b'1'..=b'7' | b'F' | b'f') - && b[2].is_ascii_whitespace() - { + if b.len() >= 3 && b[0] == b'P' && matches!(b[1], b'1'..=b'7' | b'F' | b'f') && b[2].is_ascii_whitespace() { return Some(Format::Pnm); } if looks_like_tga(b) { @@ -312,10 +243,7 @@ fn is_avif_ftyp(b: &[u8]) -> bool { let size = u32::from_be_bytes([b[0], b[1], b[2], b[3]]) as usize; let end = size.clamp(12, b.len().min(64)); let brands = &b[8..end]; - brands - .chunks_exact(4) - .enumerate() - .any(|(i, c)| i != 1 && (c == b"avif" || c == b"avis")) + brands.chunks_exact(4).enumerate().any(|(i, c)| i != 1 && (c == b"avif" || c == b"avis")) } fn looks_like_tga(b: &[u8]) -> bool { @@ -350,12 +278,6 @@ fn looks_like_tga(b: &[u8]) -> bool { /// Look up a format by file extension (case-insensitive, with or without a /// leading dot, or a full file name/path). pub fn from_extension(ext: &str) -> Option { - let ext = ext - .rsplit(['.', '/', '\\']) - .next() - .unwrap_or(ext) - .to_ascii_lowercase(); - Format::ALL - .into_iter() - .find(|f| f.extensions().contains(&ext.as_str())) + let ext = ext.rsplit(['.', '/', '\\']).next().unwrap_or(ext).to_ascii_lowercase(); + Format::ALL.into_iter().find(|f| f.extensions().contains(&ext.as_str())) } diff --git a/crates/codecs/src/image.rs b/crates/codecs/src/image.rs index 09a59a9cc9..415de80616 100644 --- a/crates/codecs/src/image.rs +++ b/crates/codecs/src/image.rs @@ -21,14 +21,8 @@ pub enum ChannelLayout { } impl ChannelLayout { - pub const ALL: [ChannelLayout; 6] = [ - ChannelLayout::Gray, - ChannelLayout::GrayA, - ChannelLayout::Rgb, - ChannelLayout::Rgba, - ChannelLayout::Cmyk, - ChannelLayout::CmykA, - ]; + pub const ALL: [ChannelLayout; 6] = + [ChannelLayout::Gray, ChannelLayout::GrayA, ChannelLayout::Rgb, ChannelLayout::Rgba, ChannelLayout::Cmyk, ChannelLayout::CmykA]; /// Total number of channels including alpha. pub const fn channels(self) -> usize { @@ -47,10 +41,7 @@ impl ChannelLayout { } pub const fn has_alpha(self) -> bool { - matches!( - self, - ChannelLayout::GrayA | ChannelLayout::Rgba | ChannelLayout::CmykA - ) + matches!(self, ChannelLayout::GrayA | ChannelLayout::Rgba | ChannelLayout::CmykA) } pub const fn is_gray(self) -> bool { @@ -85,9 +76,7 @@ impl ChannelLayout { /// `true` if both layouts share a colour model (ignoring alpha). pub const fn same_model(self, other: Self) -> bool { - (self.is_gray() && other.is_gray()) - || (self.is_rgb() && other.is_rgb()) - || (self.is_cmyk() && other.is_cmyk()) + (self.is_gray() && other.is_gray()) || (self.is_rgb() && other.is_rgb()) || (self.is_cmyk() && other.is_cmyk()) } } @@ -102,12 +91,7 @@ pub enum SampleType { } impl SampleType { - pub const ALL: [SampleType; 4] = [ - SampleType::U8, - SampleType::U16, - SampleType::F16, - SampleType::F32, - ]; + pub const ALL: [SampleType; 4] = [SampleType::U8, SampleType::U16, SampleType::F16, SampleType::F32]; pub const fn bytes(self) -> usize { match self { @@ -156,10 +140,7 @@ pub struct Image { } fn byte_len(width: u32, height: u32, layout: ChannelLayout, sample: SampleType) -> Option { - (width as usize) - .checked_mul(height as usize)? - .checked_mul(layout.channels())? - .checked_mul(sample.bytes()) + (width as usize).checked_mul(height as usize)?.checked_mul(layout.channels())?.checked_mul(sample.bytes()) } impl Image { @@ -169,95 +150,40 @@ impl Image { /// If the byte size overflows `usize`. pub fn new(width: u32, height: u32, layout: ChannelLayout, sample: SampleType) -> Self { let len = byte_len(width, height, layout, sample).expect("image size overflows usize"); - Image { - width, - height, - layout, - sample, - data: vec![0; len], - icc: None, - meta: Metadata::default(), - } + Image { width, height, layout, sample, data: vec![0; len], icc: None, meta: Metadata::default() } } /// Wrap raw interleaved, native-endian bytes. - pub fn from_raw( - width: u32, - height: u32, - layout: ChannelLayout, - sample: SampleType, - data: Vec, - ) -> Result { - let expected = byte_len(width, height, layout, sample) - .ok_or_else(|| CodecError::InvalidImage("image size overflows usize".into()))?; + pub fn from_raw(width: u32, height: u32, layout: ChannelLayout, sample: SampleType, data: Vec) -> Result { + let expected = byte_len(width, height, layout, sample).ok_or_else(|| CodecError::InvalidImage("image size overflows usize".into()))?; if data.len() != expected { - return Err(CodecError::InvalidImage(format!( - "buffer has {} bytes, expected {expected} for {width}x{height} {layout:?} {sample:?}", - data.len() - ))); + return Err(CodecError::InvalidImage(format!("buffer has {} bytes, expected {expected} for {width}x{height} {layout:?} {sample:?}", data.len()))); } - Ok(Image { - width, - height, - layout, - sample, - data, - icc: None, - meta: Metadata::default(), - }) + Ok(Image { width, height, layout, sample, data, icc: None, meta: Metadata::default() }) } - pub fn from_u8( - width: u32, - height: u32, - layout: ChannelLayout, - data: Vec, - ) -> Result { + pub fn from_u8(width: u32, height: u32, layout: ChannelLayout, data: Vec) -> Result { Self::from_raw(width, height, layout, SampleType::U8, data) } - pub fn from_u16( - width: u32, - height: u32, - layout: ChannelLayout, - data: &[u16], - ) -> Result { + pub fn from_u16(width: u32, height: u32, layout: ChannelLayout, data: &[u16]) -> Result { let bytes = data.iter().flat_map(|v| v.to_ne_bytes()).collect(); Self::from_raw(width, height, layout, SampleType::U16, bytes) } - pub fn from_f16( - width: u32, - height: u32, - layout: ChannelLayout, - data: &[f16], - ) -> Result { - let bytes = data - .iter() - .flat_map(|v| v.to_bits().to_ne_bytes()) - .collect(); + pub fn from_f16(width: u32, height: u32, layout: ChannelLayout, data: &[f16]) -> Result { + let bytes = data.iter().flat_map(|v| v.to_bits().to_ne_bytes()).collect(); Self::from_raw(width, height, layout, SampleType::F16, bytes) } - pub fn from_f32( - width: u32, - height: u32, - layout: ChannelLayout, - data: &[f32], - ) -> Result { + pub fn from_f32(width: u32, height: u32, layout: ChannelLayout, data: &[f32]) -> Result { let bytes = data.iter().flat_map(|v| v.to_ne_bytes()).collect(); Self::from_raw(width, height, layout, SampleType::F32, bytes) } /// Build an image from normalized f32 samples (`[0,1]` for integer /// targets; values are clamped and rounded when quantizing). - pub fn from_normalized( - width: u32, - height: u32, - layout: ChannelLayout, - sample: SampleType, - values: &[f32], - ) -> Result { + pub fn from_normalized(width: u32, height: u32, layout: ChannelLayout, sample: SampleType, values: &[f32]) -> Result { let data = quantize(values, sample); Self::from_raw(width, height, layout, sample, data) } @@ -312,32 +238,17 @@ impl Image { /// Copy samples out as `u16` (only when the sample type is `U16`). pub fn to_u16_samples(&self) -> Option> { - (self.sample == SampleType::U16).then(|| { - self.data - .chunks_exact(2) - .map(|c| u16::from_ne_bytes([c[0], c[1]])) - .collect() - }) + (self.sample == SampleType::U16).then(|| self.data.chunks_exact(2).map(|c| u16::from_ne_bytes([c[0], c[1]])).collect()) } /// Copy samples out as `f16` (only when the sample type is `F16`). pub fn to_f16_samples(&self) -> Option> { - (self.sample == SampleType::F16).then(|| { - self.data - .chunks_exact(2) - .map(|c| f16::from_bits(u16::from_ne_bytes([c[0], c[1]]))) - .collect() - }) + (self.sample == SampleType::F16).then(|| self.data.chunks_exact(2).map(|c| f16::from_bits(u16::from_ne_bytes([c[0], c[1]]))).collect()) } /// Copy samples out as `f32` (only when the sample type is `F32`). pub fn to_f32_samples(&self) -> Option> { - (self.sample == SampleType::F32).then(|| { - self.data - .chunks_exact(4) - .map(|c| f32::from_ne_bytes([c[0], c[1], c[2], c[3]])) - .collect() - }) + (self.sample == SampleType::F32).then(|| self.data.chunks_exact(4).map(|c| f32::from_ne_bytes([c[0], c[1], c[2], c[3]])).collect()) } /// Sample `index` (in samples, not bytes) as a normalized f32. @@ -414,11 +325,7 @@ impl Image { layout, sample, data, - icc: if layout.same_model(self.layout) { - self.icc.clone() - } else { - None - }, + icc: if layout.same_model(self.layout) { self.icc.clone() } else { None }, meta: self.meta.clone(), } } @@ -430,9 +337,7 @@ impl Image { /// Interleaved RGBA, 16 bits per sample. pub fn to_rgba16(&self) -> Vec { - self.convert(ChannelLayout::Rgba, SampleType::U16) - .to_u16_samples() - .unwrap_or_default() + self.convert(ChannelLayout::Rgba, SampleType::U16).to_u16_samples().unwrap_or_default() } /// Interleaved RGBA as f32 (integer inputs normalized to `[0,1]`). @@ -450,33 +355,17 @@ pub(crate) fn read_normalized(data: &[u8], sample: SampleType, i: usize) -> f32 match sample { SampleType::U8 => data[i] as f32 / 255.0, SampleType::U16 => u16::from_ne_bytes([data[2 * i], data[2 * i + 1]]) as f32 / 65535.0, - SampleType::F16 => { - f16::from_bits(u16::from_ne_bytes([data[2 * i], data[2 * i + 1]])).to_f32() - } - SampleType::F32 => f32::from_ne_bytes([ - data[4 * i], - data[4 * i + 1], - data[4 * i + 2], - data[4 * i + 3], - ]), + SampleType::F16 => f16::from_bits(u16::from_ne_bytes([data[2 * i], data[2 * i + 1]])).to_f32(), + SampleType::F32 => f32::from_ne_bytes([data[4 * i], data[4 * i + 1], data[4 * i + 2], data[4 * i + 3]]), } } pub(crate) fn to_normalized(data: &[u8], sample: SampleType) -> Vec { match sample { SampleType::U8 => data.iter().map(|&v| v as f32 / 255.0).collect(), - SampleType::U16 => data - .chunks_exact(2) - .map(|c| u16::from_ne_bytes([c[0], c[1]]) as f32 / 65535.0) - .collect(), - SampleType::F16 => data - .chunks_exact(2) - .map(|c| f16::from_bits(u16::from_ne_bytes([c[0], c[1]])).to_f32()) - .collect(), - SampleType::F32 => data - .chunks_exact(4) - .map(|c| f32::from_ne_bytes([c[0], c[1], c[2], c[3]])) - .collect(), + SampleType::U16 => data.chunks_exact(2).map(|c| u16::from_ne_bytes([c[0], c[1]]) as f32 / 65535.0).collect(), + SampleType::F16 => data.chunks_exact(2).map(|c| f16::from_bits(u16::from_ne_bytes([c[0], c[1]])).to_f32()).collect(), + SampleType::F32 => data.chunks_exact(4).map(|c| f32::from_ne_bytes([c[0], c[1], c[2], c[3]])).collect(), } } @@ -487,18 +376,9 @@ fn clamp01(v: f32) -> f32 { pub(crate) fn quantize(values: &[f32], sample: SampleType) -> Vec { match sample { - SampleType::U8 => values - .iter() - .map(|&v| (clamp01(v) * 255.0).round() as u8) - .collect(), - SampleType::U16 => values - .iter() - .flat_map(|&v| ((clamp01(v) * 65535.0).round() as u16).to_ne_bytes()) - .collect(), - SampleType::F16 => values - .iter() - .flat_map(|&v| f16::from_f32(v).to_bits().to_ne_bytes()) - .collect(), + SampleType::U8 => values.iter().map(|&v| (clamp01(v) * 255.0).round() as u8).collect(), + SampleType::U16 => values.iter().flat_map(|&v| ((clamp01(v) * 65535.0).round() as u16).to_ne_bytes()).collect(), + SampleType::F16 => values.iter().flat_map(|&v| f16::from_f32(v).to_bits().to_ne_bytes()).collect(), SampleType::F32 => values.iter().flat_map(|&v| v.to_ne_bytes()).collect(), } } @@ -507,11 +387,7 @@ pub(crate) fn quantize(values: &[f32], sample: SampleType) -> Vec { const LUMA: [f32; 3] = [0.2126, 0.7152, 0.0722]; fn cmyk_to_rgb(c: f32, m: f32, y: f32, k: f32) -> [f32; 3] { - [ - (1.0 - c) * (1.0 - k), - (1.0 - m) * (1.0 - k), - (1.0 - y) * (1.0 - k), - ] + [(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)] } fn rgb_to_cmyk(r: f32, g: f32, b: f32) -> [f32; 4] { @@ -543,18 +419,14 @@ pub(crate) fn convert_layout(src: &[f32], from: ChannelLayout, to: ChannelLayout let rgb = match from { ChannelLayout::Gray | ChannelLayout::GrayA => [color[0]; 3], ChannelLayout::Rgb | ChannelLayout::Rgba => [color[0], color[1], color[2]], - ChannelLayout::Cmyk | ChannelLayout::CmykA => { - cmyk_to_rgb(color[0], color[1], color[2], color[3]) - } + ChannelLayout::Cmyk | ChannelLayout::CmykA => cmyk_to_rgb(color[0], color[1], color[2], color[3]), }; match to { ChannelLayout::Gray | ChannelLayout::GrayA => { out.push(rgb[0] * LUMA[0] + rgb[1] * LUMA[1] + rgb[2] * LUMA[2]); } ChannelLayout::Rgb | ChannelLayout::Rgba => out.extend_from_slice(&rgb), - ChannelLayout::Cmyk | ChannelLayout::CmykA => { - out.extend_from_slice(&rgb_to_cmyk(rgb[0], rgb[1], rgb[2])) - } + ChannelLayout::Cmyk | ChannelLayout::CmykA => out.extend_from_slice(&rgb_to_cmyk(rgb[0], rgb[1], rgb[2])), } } if to.has_alpha() { @@ -586,9 +458,7 @@ mod tests { fn u8_u16_roundtrip_exact() { let v: Vec = (0..=255).collect(); let img = Image::from_u8(256, 1, ChannelLayout::Gray, v.clone()).unwrap(); - let back = img - .convert(ChannelLayout::Gray, SampleType::U16) - .convert(ChannelLayout::Gray, SampleType::U8); + let back = img.convert(ChannelLayout::Gray, SampleType::U16).convert(ChannelLayout::Gray, SampleType::U8); assert_eq!(back.data(), &v[..]); } @@ -596,26 +466,15 @@ mod tests { fn u16_f32_roundtrip_exact() { let v: Vec = (0..=65535u32).step_by(7).map(|x| x as u16).collect(); let img = Image::from_u16(v.len() as u32, 1, ChannelLayout::Gray, &v).unwrap(); - let back = img - .convert(ChannelLayout::Gray, SampleType::F32) - .convert(ChannelLayout::Gray, SampleType::U16); + let back = img.convert(ChannelLayout::Gray, SampleType::F32).convert(ChannelLayout::Gray, SampleType::U16); assert_eq!(back.to_u16_samples().unwrap(), v); } #[test] fn icc_dropped_on_model_change() { - let img = - Image::new(2, 2, ChannelLayout::Cmyk, SampleType::U8).with_icc(Some(vec![1, 2, 3])); - assert!( - img.convert(ChannelLayout::Rgb, SampleType::U8) - .icc - .is_none() - ); - assert!( - img.convert(ChannelLayout::CmykA, SampleType::U16) - .icc - .is_some() - ); + let img = Image::new(2, 2, ChannelLayout::Cmyk, SampleType::U8).with_icc(Some(vec![1, 2, 3])); + assert!(img.convert(ChannelLayout::Rgb, SampleType::U8).icc.is_none()); + assert!(img.convert(ChannelLayout::CmykA, SampleType::U16).icc.is_some()); } #[test] diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index d1c08e051f..b88b156781 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -20,15 +20,13 @@ mod image; mod options; pub mod web; +pub use crate::codecs::png::encode_indexed as encode_png_indexed; pub use crate::error::CodecError; pub use crate::fidelity::{FidelityWarning, fidelity_warnings, fidelity_warnings_with}; pub use crate::format::{ASYMMETRIC_EXCEPTIONS, Format, FormatCaps, caps, detect, from_extension}; pub use crate::image::{ChannelLayout, Image, Metadata, SampleType}; -pub use crate::options::{ - DecodeOptions, EncodeOptions, ExrCompression, Limits, PngCompression, TiffCompression, -}; +pub use crate::options::{DecodeOptions, EncodeOptions, ExrCompression, Limits, PngCompression, TiffCompression}; pub use half::f16; -pub use crate::codecs::png::encode_indexed as encode_png_indexed; use crate::codecs::{exr, jpeg, png, pnm, tiff, via_image, webp}; @@ -49,16 +47,9 @@ pub fn decode_as(format: Format, bytes: &[u8]) -> Result { } /// Decode as a specific format. -pub fn decode_as_with( - format: Format, - bytes: &[u8], - opts: &DecodeOptions, -) -> Result { +pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Result { if !caps(format).read { - return Err(CodecError::unsupported( - format, - "decoding is not available for this format", - )); + return Err(CodecError::unsupported(format, "decoding is not available for this format")); } let l = &opts.limits; let img = match format { @@ -68,13 +59,7 @@ pub fn decode_as_with( Format::WebP => webp::decode(bytes, l), Format::Pnm => pnm::decode(bytes, l), Format::OpenExr => exr::decode(bytes, l), - Format::Gif - | Format::Bmp - | Format::Tga - | Format::Ico - | Format::Qoi - | Format::Hdr - | Format::Avif => via_image::decode(format, bytes, l), + Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::decode(format, bytes, l), }?; // Final guard for decoders whose header we could not pre-inspect. l.check(img.width(), img.height(), img.layout(), img.sample_type())?; @@ -86,23 +71,15 @@ pub fn decode_as_with( /// format, oversize image) return an error. pub fn encode(image: &Image, format: Format, opts: &EncodeOptions) -> Result, CodecError> { if !caps(format).write { - return Err(CodecError::unsupported( - format, - "encoding is not available in this build", - )); + return Err(CodecError::unsupported(format, "encoding is not available in this build")); } if image.width() == 0 || image.height() == 0 { - return Err(CodecError::InvalidImage( - "cannot encode an empty image".into(), - )); + return Err(CodecError::InvalidImage("cannot encode an empty image".into())); } if let Some((mw, mh)) = format.max_dimensions() && (image.width() > mw || image.height() > mh) { - return Err(CodecError::encode( - format, - format!("dimensions exceed {mw}x{mh}"), - )); + return Err(CodecError::encode(format, format!("dimensions exceed {mw}x{mh}"))); } let plan = fidelity::plan(image, format, opts); match format { @@ -112,12 +89,6 @@ pub fn encode(image: &Image, format: Format, opts: &EncodeOptions) -> Result webp::encode(image, plan, opts), Format::Pnm => pnm::encode(image, plan, opts), Format::OpenExr => exr::encode(image, plan, opts), - Format::Gif - | Format::Bmp - | Format::Tga - | Format::Ico - | Format::Qoi - | Format::Hdr - | Format::Avif => via_image::encode(format, image, plan, opts), + Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::encode(format, image, plan, opts), } } diff --git a/crates/codecs/src/options.rs b/crates/codecs/src/options.rs index 698b9edc1e..f2c8f6a970 100644 --- a/crates/codecs/src/options.rs +++ b/crates/codecs/src/options.rs @@ -18,73 +18,39 @@ pub struct Limits { impl Default for Limits { fn default() -> Self { - Limits { - max_width: 1 << 18, - max_height: 1 << 18, - max_pixels: 1 << 28, - max_alloc: 2 << 30, - } + Limits { max_width: 1 << 18, max_height: 1 << 18, max_pixels: 1 << 28, max_alloc: 2 << 30 } } } impl Limits { /// Effectively unlimited (still bounded by `usize` overflow checks). pub fn none() -> Self { - Limits { - max_width: u32::MAX, - max_height: u32::MAX, - max_pixels: u64::MAX, - max_alloc: u64::MAX, - } + Limits { max_width: u32::MAX, max_height: u32::MAX, max_pixels: u64::MAX, max_alloc: u64::MAX } } /// Validate declared dimensions for an output buffer of the given layout /// and sample type. - pub fn check( - &self, - width: u32, - height: u32, - layout: ChannelLayout, - sample: SampleType, - ) -> Result<(), CodecError> { + pub fn check(&self, width: u32, height: u32, layout: ChannelLayout, sample: SampleType) -> Result<(), CodecError> { self.check_bytes(width, height, (layout.channels() * sample.bytes()) as u64) } - pub(crate) fn check_bytes( - &self, - width: u32, - height: u32, - bytes_per_pixel: u64, - ) -> Result<(), CodecError> { + pub(crate) fn check_bytes(&self, width: u32, height: u32, bytes_per_pixel: u64) -> Result<(), CodecError> { if width == 0 || height == 0 { - return Err(CodecError::InvalidImage(format!( - "zero-sized image {width}x{height}" - ))); + return Err(CodecError::InvalidImage(format!("zero-sized image {width}x{height}"))); } if width > self.max_width || height > self.max_height { - return Err(CodecError::LimitExceeded(format!( - "dimensions {width}x{height} exceed max {}x{}", - self.max_width, self.max_height - ))); + return Err(CodecError::LimitExceeded(format!("dimensions {width}x{height} exceed max {}x{}", self.max_width, self.max_height))); } let pixels = width as u64 * height as u64; if pixels > self.max_pixels { - return Err(CodecError::LimitExceeded(format!( - "{pixels} pixels exceed max {}", - self.max_pixels - ))); + return Err(CodecError::LimitExceeded(format!("{pixels} pixels exceed max {}", self.max_pixels))); } let bytes = pixels.saturating_mul(bytes_per_pixel); if bytes > self.max_alloc { - return Err(CodecError::LimitExceeded(format!( - "{bytes} bytes exceed max_alloc {}", - self.max_alloc - ))); + return Err(CodecError::LimitExceeded(format!("{bytes} bytes exceed max_alloc {}", self.max_alloc))); } if usize::try_from(bytes).is_err() { - return Err(CodecError::LimitExceeded(format!( - "{bytes} bytes do not fit in memory" - ))); + return Err(CodecError::LimitExceeded(format!("{bytes} bytes do not fit in memory"))); } Ok(()) } diff --git a/crates/codecs/src/web.rs b/crates/codecs/src/web.rs index 297813afe4..48d1cf1926 100644 --- a/crates/codecs/src/web.rs +++ b/crates/codecs/src/web.rs @@ -96,7 +96,14 @@ fn interlaced_rows(h: usize) -> Vec { /// An indexed GIF89a: `palette` (1..=256 entries), one index per pixel, optional transparent /// index (Graphic Control Extension) and interlacing. -pub fn encode_gif_indexed(width: u32, height: u32, indices: &[u8], palette: &[[u8; 3]], transparent: Option, interlaced: bool) -> Result, CodecError> { +pub fn encode_gif_indexed( + width: u32, + height: u32, + indices: &[u8], + palette: &[[u8; 3]], + transparent: Option, + interlaced: bool, +) -> Result, CodecError> { let (w, h) = (width as usize, height as usize); if width == 0 || height == 0 || width > 65535 || height > 65535 || indices.len() != w * h { return Err(gif_err("GIF size must be 1..=65535 and match the index data")); @@ -278,7 +285,17 @@ pub fn decode_wbmp(bytes: &[u8]) -> Result<(u32, u32, Vec), CodecError> { /// JPEG from 8-bit RGB (`rgb.len() == 3·w·h`): quality 1..=100, optional progressive scans and /// optimised Huffman tables (Save for Web's "Optimized"), optional ICC profile, density and XMP. #[allow(clippy::too_many_arguments)] -pub fn encode_jpeg_rgb8(width: u32, height: u32, rgb: &[u8], quality: u8, progressive: bool, optimized: bool, icc: Option<&[u8]>, dpi: Option, xmp: Option<&str>) -> Result, CodecError> { +pub fn encode_jpeg_rgb8( + width: u32, + height: u32, + rgb: &[u8], + quality: u8, + progressive: bool, + optimized: bool, + icc: Option<&[u8]>, + dpi: Option, + xmp: Option<&str>, +) -> Result, CodecError> { let e = |e: jpeg_encoder::EncodingError| CodecError::encode(Format::Jpeg, e); let (Ok(w16), Ok(h16)) = (u16::try_from(width), u16::try_from(height)) else { return Err(CodecError::encode(Format::Jpeg, "JPEG dimensions are limited to 65535")); @@ -425,12 +442,7 @@ mod anim_tests { #[test] fn animated_gif_has_header_and_frames() { let frames: Vec = (0..3) - .map(|i| GifFrame { - indices: vec![i as u8; 4], - palette: vec![[0, 0, 0], [255, 0, 0], [0, 255, 0]], - transparent: None, - delay_cs: 4, - }) + .map(|i| GifFrame { indices: vec![i as u8; 4], palette: vec![[0, 0, 0], [255, 0, 0], [0, 255, 0]], transparent: None, delay_cs: 4 }) .collect(); let g = encode_gif_animated(2, 2, &frames, true).unwrap(); assert_eq!(&g[..6], b"GIF89a"); diff --git a/crates/codecs/tests/camera_raw.rs b/crates/codecs/tests/camera_raw.rs index cefeceebb6..26d9493bb5 100644 --- a/crates/codecs/tests/camera_raw.rs +++ b/crates/codecs/tests/camera_raw.rs @@ -25,10 +25,7 @@ fn tiff(ifds: &[Vec<(u16, u16, u32)>], header8: Option<[u8; 4]>) -> Vec { b.extend_from_slice(&0u32.to_le_bytes()); } // Layout: IFDs back to back, then one shared 12-byte pixel strip. - let sizes: Vec = ifds - .iter() - .map(|e| 2 + 12 * (e.len() as u32 + 1) + 4) - .collect(); + let sizes: Vec = ifds.iter().map(|e| 2 + 12 * (e.len() as u32 + 1) + 4).collect(); let strip = first + sizes.iter().sum::(); let mut at = first; for (i, entries) in ifds.iter().enumerate() { @@ -77,10 +74,7 @@ fn assert_camera_raw(bytes: &[u8]) { assert!(reason.contains("camera raw"), "unexpected reason: {reason}"); } Err(e) => panic!("expected a camera raw error, got: {e}"), - Ok(img) => panic!( - "expected a camera raw error, decoded {:?}", - img.dimensions() - ), + Ok(img) => panic!("expected a camera raw error, decoded {:?}", img.dimensions()), } } diff --git a/crates/codecs/tests/common/mod.rs b/crates/codecs/tests/common/mod.rs index 6473c3ba7a..7d8d847e76 100644 --- a/crates/codecs/tests/common/mod.rs +++ b/crates/codecs/tests/common/mod.rs @@ -30,14 +30,7 @@ impl Rng { /// Gradient + noise image with values in [0, 1]. `noise` is the amplitude /// of uniform noise added to the gradient. Alpha is a separate gradient /// that includes fully transparent and fully opaque regions. -pub fn synth( - w: u32, - h: u32, - layout: ChannelLayout, - sample: SampleType, - seed: u64, - noise: f32, -) -> Image { +pub fn synth(w: u32, h: u32, layout: ChannelLayout, sample: SampleType, seed: u64, noise: f32) -> Image { let mut rng = Rng::new(seed); let nc = layout.channels(); let mut v = Vec::with_capacity((w * h) as usize * nc); @@ -84,19 +77,11 @@ pub fn psnr(a: &Image, b: &Image) -> f64 { }) .sum::() / av.len() as f64; - if mse == 0.0 { - f64::INFINITY - } else { - 10.0 * (1.0 / mse).log10() - } + if mse == 0.0 { f64::INFINITY } else { 10.0 * (1.0 / mse).log10() } } pub fn max_abs_diff(a: &Image, b: &Image) -> f32 { - a.to_normalized() - .iter() - .zip(b.to_normalized()) - .map(|(x, y)| (x - y).abs()) - .fold(0.0, f32::max) + a.to_normalized().iter().zip(b.to_normalized()).map(|(x, y)| (x - y).abs()).fold(0.0, f32::max) } /// Formats that can be written in this build. @@ -106,10 +91,7 @@ pub fn writable_formats() -> Vec { /// Formats that can be written *and* read in this build. pub fn rw_formats() -> Vec { - Format::ALL - .into_iter() - .filter(|f| caps(*f).write && caps(*f).read) - .collect() + Format::ALL.into_iter().filter(|f| caps(*f).write && caps(*f).read).collect() } /// A minimal valid little-endian TIFF-structured EXIF blob (one IFD with diff --git a/crates/codecs/tests/detect_caps.rs b/crates/codecs/tests/detect_caps.rs index 4d06d58172..8395e19646 100644 --- a/crates/codecs/tests/detect_caps.rs +++ b/crates/codecs/tests/detect_caps.rs @@ -20,12 +20,7 @@ fn every_enabled_format_is_symmetric_or_whitelisted() { continue; } let exc = ASYMMETRIC_EXCEPTIONS.iter().find(|(g, _)| *g == f); - let (_, reason) = exc.unwrap_or_else(|| { - panic!( - "{f:?} is enabled but not symmetric (read={}, write={})", - c.read, c.write - ) - }); + let (_, reason) = exc.unwrap_or_else(|| panic!("{f:?} is enabled but not symmetric (read={}, write={})", c.read, c.write)); assert!(reason.len() > 20, "{f:?} exception needs a real reason"); } } @@ -73,10 +68,7 @@ fn default_build_formats_all_symmetric() { fn avif_never_readable() { assert!(!caps(Format::Avif).read); assert_eq!(caps(Format::Avif).write, cfg!(feature = "avif")); - assert!(matches!( - decode_as(Format::Avif, b"\0\0\0\x1cftypavif"), - Err(CodecError::Unsupported { .. }) - )); + assert!(matches!(decode_as(Format::Avif, b"\0\0\0\x1cftypavif"), Err(CodecError::Unsupported { .. }))); } #[test] @@ -84,16 +76,8 @@ fn caps_are_internally_consistent() { for f in Format::ALL { let c = caps(f); assert!(!c.depths.is_empty() && !c.layouts.is_empty(), "{f:?}"); - assert_eq!( - c.alpha, - c.layouts.iter().any(|l| l.has_alpha()), - "{f:?} alpha flag vs layouts" - ); - assert!( - !f.extensions().is_empty() - && !f.name().is_empty() - && f.mime_type().starts_with("image/") - ); + assert_eq!(c.alpha, c.layouts.iter().any(|l| l.has_alpha()), "{f:?} alpha flag vs layouts"); + assert!(!f.extensions().is_empty() && !f.name().is_empty() && f.mime_type().starts_with("image/")); } } @@ -158,17 +142,7 @@ fn detect_magic_ico() { } #[test] fn detect_magic_pnm_family() { - for m in [ - &b"P1\n"[..], - b"P2 ", - b"P3\t", - b"P4\n", - b"P5\r", - b"P6\n", - b"P7\n", - b"PF\n", - b"Pf\n", - ] { + for m in [&b"P1\n"[..], b"P2 ", b"P3\t", b"P4\n", b"P5\r", b"P6\n", b"P7\n", b"PF\n", b"Pf\n"] { assert_eq!(detect(m), Some(Format::Pnm), "{:?}", std::str::from_utf8(m)); } assert_eq!(detect(b"P8\n"), None); @@ -180,10 +154,7 @@ fn detect_magic_qoi() { } #[test] fn detect_magic_exr() { - assert_eq!( - detect(&[0x76, 0x2F, 0x31, 0x01, 2, 0, 0, 0]), - Some(Format::OpenExr) - ); + assert_eq!(detect(&[0x76, 0x2F, 0x31, 0x01, 2, 0, 0, 0]), Some(Format::OpenExr)); } #[test] fn detect_magic_hdr() { @@ -192,24 +163,10 @@ fn detect_magic_hdr() { } #[test] fn detect_magic_avif() { - assert_eq!( - detect(b"\0\0\0\x1cftypavif\0\0\0\0avifmif1miaf"), - Some(Format::Avif) - ); - assert_eq!( - detect(b"\0\0\0\x1cftypavis\0\0\0\0avismsf1miaf"), - Some(Format::Avif) - ); - assert_eq!( - detect(b"\0\0\0\x18ftypmif1\0\0\0\0mif1avif"), - Some(Format::Avif), - "compatible brand" - ); - assert_eq!( - detect(b"\0\0\0\x18ftypisom\0\0\0\0isommp41"), - None, - "mp4 is not avif" - ); + assert_eq!(detect(b"\0\0\0\x1cftypavif\0\0\0\0avifmif1miaf"), Some(Format::Avif)); + assert_eq!(detect(b"\0\0\0\x1cftypavis\0\0\0\0avismsf1miaf"), Some(Format::Avif)); + assert_eq!(detect(b"\0\0\0\x18ftypmif1\0\0\0\0mif1avif"), Some(Format::Avif), "compatible brand"); + assert_eq!(detect(b"\0\0\0\x18ftypisom\0\0\0\0isommp41"), None, "mp4 is not avif"); } #[test] fn detect_tga_heuristic() { @@ -236,17 +193,12 @@ fn detect_negatives() { #[test] fn detect_random_bytes_rarely_matches_tga() { let mut rng = Rng::new(5); - let hits = (0..2000) - .filter(|_| detect(&rng.bytes(64)) == Some(Format::Tga)) - .count(); + let hits = (0..2000).filter(|_| detect(&rng.bytes(64)) == Some(Format::Tga)).count(); assert!(hits < 10, "{hits} false TGA positives"); } #[test] fn decode_unknown_is_error() { - assert!(matches!( - decode(b"garbage garbage"), - Err(CodecError::UnknownFormat) - )); + assert!(matches!(decode(b"garbage garbage"), Err(CodecError::UnknownFormat))); } // --------------------------------------------------------------------------- @@ -277,10 +229,7 @@ fn extension_specific_cases() { } #[test] fn extensions_unique_across_formats() { - let mut all: Vec<&str> = Format::ALL - .iter() - .flat_map(|f| f.extensions().iter().copied()) - .collect(); + let mut all: Vec<&str> = Format::ALL.iter().flat_map(|f| f.extensions().iter().copied()).collect(); let n = all.len(); all.sort(); all.dedup(); diff --git a/crates/codecs/tests/fidelity.rs b/crates/codecs/tests/fidelity.rs index baaf1b7aae..0b89f5147e 100644 --- a/crates/codecs/tests/fidelity.rs +++ b/crates/codecs/tests/fidelity.rs @@ -15,56 +15,30 @@ fn png_u8_rgb_is_clean() { } #[test] fn png_u16_rgba_is_clean() { - assert!( - warns( - &test_image(ChannelLayout::Rgba, SampleType::U16), - Format::Png - ) - .is_empty() - ); + assert!(warns(&test_image(ChannelLayout::Rgba, SampleType::U16), Format::Png).is_empty()); } #[test] fn tiff_is_clean_for_all_integer_and_f32_layouts() { for l in ChannelLayout::ALL { - for s in [ - SampleType::U8, - SampleType::U16, - SampleType::F32, - SampleType::F16, - ] { - assert!( - warns(&test_image(l, s), Format::Tiff).is_empty(), - "{l:?} {s:?}" - ); + for s in [SampleType::U8, SampleType::U16, SampleType::F32, SampleType::F16] { + assert!(warns(&test_image(l, s), Format::Tiff).is_empty(), "{l:?} {s:?}"); } } } #[test] fn sixteen_bit_to_jpeg_reduces() { - let w = warns( - &test_image(ChannelLayout::Rgb, SampleType::U16), - Format::Jpeg, - ); - assert!(w.contains(&W::DepthReduced { - from: SampleType::U16, - to: SampleType::U8 - })); + let w = warns(&test_image(ChannelLayout::Rgb, SampleType::U16), Format::Jpeg); + assert!(w.contains(&W::DepthReduced { from: SampleType::U16, to: SampleType::U8 })); assert!(w.contains(&W::LossyCompression)); } #[test] fn sixteen_bit_message_text() { - let w = W::DepthReduced { - from: SampleType::U16, - to: SampleType::U8, - }; + let w = W::DepthReduced { from: SampleType::U16, to: SampleType::U8 }; assert_eq!(w.to_string(), "16-bit will be reduced to 8-bit"); } #[test] fn alpha_to_jpeg_discarded() { - let w = warns( - &test_image(ChannelLayout::Rgba, SampleType::U8), - Format::Jpeg, - ); + let w = warns(&test_image(ChannelLayout::Rgba, SampleType::U8), Format::Jpeg); assert!(w.contains(&W::AlphaDiscarded)); } #[test] @@ -77,43 +51,24 @@ fn opaque_alpha_not_reported() { } #[test] fn alpha_to_hdr_discarded() { - assert!( - warns( - &test_image(ChannelLayout::Rgba, SampleType::F32), - Format::Hdr - ) - .contains(&W::AlphaDiscarded) - ); + assert!(warns(&test_image(ChannelLayout::Rgba, SampleType::F32), Format::Hdr).contains(&W::AlphaDiscarded)); } #[test] fn cmyk_to_png_converted() { - let w = warns( - &test_image(ChannelLayout::Cmyk, SampleType::U8), - Format::Png, - ); - assert!(w.contains(&W::CmykConverted { - to: ChannelLayout::Rgb - })); + let w = warns(&test_image(ChannelLayout::Cmyk, SampleType::U8), Format::Png); + assert!(w.contains(&W::CmykConverted { to: ChannelLayout::Rgb })); } #[test] fn cmyka_to_webp_converted_keeps_alpha() { - let w = warns( - &test_image(ChannelLayout::CmykA, SampleType::U8), - Format::WebP, - ); - assert!(w.contains(&W::CmykConverted { - to: ChannelLayout::Rgba - })); + let w = warns(&test_image(ChannelLayout::CmykA, SampleType::U8), Format::WebP); + assert!(w.contains(&W::CmykConverted { to: ChannelLayout::Rgba })); assert!(!w.contains(&W::AlphaDiscarded)); } #[test] fn cmyk_to_jpeg_and_tiff_not_converted() { for f in [Format::Jpeg, Format::Tiff, Format::Pnm] { let w = warns(&test_image(ChannelLayout::Cmyk, SampleType::U8), f); - assert!( - !w.iter().any(|w| matches!(w, W::CmykConverted { .. })), - "{f:?}" - ); + assert!(!w.iter().any(|w| matches!(w, W::CmykConverted { .. })), "{f:?}"); } } #[test] @@ -125,21 +80,13 @@ fn icc_dropped_for_bmp() { #[test] fn icc_warning_suppressed_when_not_embedding() { let img = test_image(ChannelLayout::Rgb, SampleType::U8).with_icc(Some(sample_icc(100))); - let opts = EncodeOptions { - embed_icc: false, - ..Default::default() - }; + let opts = EncodeOptions { embed_icc: false, ..Default::default() }; assert!(!fidelity_warnings_with(&img, Format::Bmp, &opts).contains(&W::IccDropped)); } #[test] fn exif_xmp_dpi_text_dropped_for_qoi() { let mut img = test_image(ChannelLayout::Rgb, SampleType::U8); - img.meta = Metadata { - exif: Some(sample_exif()), - xmp: Some("x".into()), - dpi: Some((72.0, 72.0)), - text: vec![("a".into(), "b".into())], - }; + img.meta = Metadata { exif: Some(sample_exif()), xmp: Some("x".into()), dpi: Some((72.0, 72.0)), text: vec![("a".into(), "b".into())] }; let w = warns(&img, Format::Qoi); for x in [W::ExifDropped, W::XmpDropped, W::DpiDropped, W::TextDropped] { assert!(w.contains(&x), "{x:?}"); @@ -148,12 +95,7 @@ fn exif_xmp_dpi_text_dropped_for_qoi() { #[test] fn webp_drops_dpi_and_text_only() { let mut img = test_image(ChannelLayout::Rgb, SampleType::U8); - img.meta = Metadata { - exif: Some(sample_exif()), - xmp: Some("x".into()), - dpi: Some((72.0, 72.0)), - text: vec![("a".into(), "b".into())], - }; + img.meta = Metadata { exif: Some(sample_exif()), xmp: Some("x".into()), dpi: Some((72.0, 72.0)), text: vec![("a".into(), "b".into())] }; let w = warns(&img, Format::WebP); assert_eq!(w, vec![W::DpiDropped, W::TextDropped]); } @@ -162,120 +104,72 @@ fn float_to_png_reduces_and_clips() { let vals: Vec = (0..12).map(|i| i as f32 * 0.3 - 0.5).collect(); let img = Image::from_f32(2, 2, ChannelLayout::Rgb, &vals).unwrap(); let w = warns(&img, Format::Png); - assert!(w.contains(&W::DepthReduced { - from: SampleType::F32, - to: SampleType::U16 - })); + assert!(w.contains(&W::DepthReduced { from: SampleType::F32, to: SampleType::U16 })); assert!(w.contains(&W::RangeClipped)); } #[test] fn in_range_float_not_clipped() { - let w = warns( - &test_image(ChannelLayout::Rgb, SampleType::F32), - Format::Png, - ); + let w = warns(&test_image(ChannelLayout::Rgb, SampleType::F32), Format::Png); assert!(!w.contains(&W::RangeClipped)); } #[test] fn float_to_exr_and_tiff_is_clean() { for f in [Format::OpenExr, Format::Tiff, Format::Pnm] { - assert!( - warns(&test_image(ChannelLayout::Rgb, SampleType::F32), f).is_empty(), - "{f:?}" - ); + assert!(warns(&test_image(ChannelLayout::Rgb, SampleType::F32), f).is_empty(), "{f:?}"); } } #[test] fn f16_to_exr_is_clean() { - assert!( - warns( - &test_image(ChannelLayout::Rgba, SampleType::F16), - Format::OpenExr - ) - .is_empty() - ); + assert!(warns(&test_image(ChannelLayout::Rgba, SampleType::F16), Format::OpenExr).is_empty()); } #[test] fn integer_to_exr_is_lossless() { for s in [SampleType::U8, SampleType::U16] { - assert!( - warns(&test_image(ChannelLayout::Rgb, s), Format::OpenExr).is_empty(), - "{s:?}" - ); + assert!(warns(&test_image(ChannelLayout::Rgb, s), Format::OpenExr).is_empty(), "{s:?}"); } } #[test] fn float_pnm_drops_alpha() { - let w = warns( - &test_image(ChannelLayout::Rgba, SampleType::F32), - Format::Pnm, - ); + let w = warns(&test_image(ChannelLayout::Rgba, SampleType::F32), Format::Pnm); assert!(w.contains(&W::AlphaDiscarded)); } #[test] fn gif_quantizes_and_binarizes() { - let w = warns( - &test_image(ChannelLayout::Rgba, SampleType::U8), - Format::Gif, - ); + let w = warns(&test_image(ChannelLayout::Rgba, SampleType::U8), Format::Gif); assert!(w.contains(&W::PaletteQuantized)); assert!(w.contains(&W::AlphaBinarized)); assert!(!w.contains(&W::LossyCompression)); } #[test] fn hdr_is_lossy() { - assert!( - warns( - &test_image(ChannelLayout::Rgb, SampleType::F32), - Format::Hdr - ) - .contains(&W::LossyCompression) - ); + assert!(warns(&test_image(ChannelLayout::Rgb, SampleType::F32), Format::Hdr).contains(&W::LossyCompression)); } #[test] fn ico_oversize_is_fatal() { let img = Image::new(512, 16, ChannelLayout::Rgba, SampleType::U8); let w = warns(&img, Format::Ico); assert!(w.iter().any(|w| w.is_fatal())); - assert!(w.contains(&W::DimensionsExceeded { - max_width: 256, - max_height: 256 - })); + assert!(w.contains(&W::DimensionsExceeded { max_width: 256, max_height: 256 })); } #[test] fn avif_default_build_write_unsupported() { - let w = warns( - &test_image(ChannelLayout::Rgb, SampleType::U8), - Format::Avif, - ); + let w = warns(&test_image(ChannelLayout::Rgb, SampleType::U8), Format::Avif); if cfg!(feature = "avif") { assert!(w.contains(&W::LossyCompression)); } else { - assert_eq!( - w, - vec![W::WriteUnsupported { - format: Format::Avif - }] - ); + assert_eq!(w, vec![W::WriteUnsupported { format: Format::Avif }]); assert!(w[0].is_fatal()); } } #[test] fn every_warning_has_a_message() { let all = [ - W::WriteUnsupported { - format: Format::Avif, - }, - W::DepthReduced { - from: SampleType::F32, - to: SampleType::F16, - }, + W::WriteUnsupported { format: Format::Avif }, + W::DepthReduced { from: SampleType::F32, to: SampleType::F16 }, W::RangeClipped, W::AlphaDiscarded, W::AlphaBinarized, - W::CmykConverted { - to: ChannelLayout::Rgb, - }, + W::CmykConverted { to: ChannelLayout::Rgb }, W::ColorToGray, W::PaletteQuantized, W::LossyCompression, @@ -284,10 +178,7 @@ fn every_warning_has_a_message() { W::XmpDropped, W::DpiDropped, W::TextDropped, - W::DimensionsExceeded { - max_width: 1, - max_height: 1, - }, + W::DimensionsExceeded { max_width: 1, max_height: 1 }, ]; for w in all { assert!(w.to_string().len() > 5); @@ -304,27 +195,12 @@ fn warnings_match_actual_roundtrip_behaviour() { let w = warns(&img, f); let back = decode(&encode(&img, f, &EncodeOptions::default()).unwrap()).unwrap(); let depth_reduced = w.iter().any(|w| matches!(w, W::DepthReduced { .. })); - let lost_depth = back.sample_type() != s - && back.sample_type() != SampleType::F32 - && s != SampleType::U8; - assert_eq!( - depth_reduced, - lost_depth, - "{f:?} {l:?} {s:?} {w:?} -> {:?}", - back.sample_type() - ); + let lost_depth = back.sample_type() != s && back.sample_type() != SampleType::F32 && s != SampleType::U8; + assert_eq!(depth_reduced, lost_depth, "{f:?} {l:?} {s:?} {w:?} -> {:?}", back.sample_type()); let alpha_lost = l.has_alpha() && !back.layout().has_alpha(); - assert_eq!( - w.contains(&W::AlphaDiscarded), - alpha_lost, - "{f:?} {l:?} {s:?}" - ); + assert_eq!(w.contains(&W::AlphaDiscarded), alpha_lost, "{f:?} {l:?} {s:?}"); let cmyk_lost = l.is_cmyk() && !back.layout().is_cmyk(); - assert_eq!( - w.iter().any(|w| matches!(w, W::CmykConverted { .. })), - cmyk_lost, - "{f:?} {l:?} {s:?}" - ); + assert_eq!(w.iter().any(|w| matches!(w, W::CmykConverted { .. })), cmyk_lost, "{f:?} {l:?} {s:?}"); } } } diff --git a/crates/codecs/tests/limits.rs b/crates/codecs/tests/limits.rs index 3163d43993..22a7a82e4e 100644 --- a/crates/codecs/tests/limits.rs +++ b/crates/codecs/tests/limits.rs @@ -21,29 +21,17 @@ fn encoded(f: Format) -> Vec { fn max_pixels_enforced_for_every_format() { for f in rw_formats() { let b = encoded(f); - let l = Limits { - max_pixels: 1000, - ..Limits::default() - }; + let l = Limits { max_pixels: 1000, ..Limits::default() }; assert!(is_limit(decode_with(&b, &opts(l))), "{f:?}"); - let l = Limits { - max_pixels: 64 * 48, - ..Limits::default() - }; - assert!( - decode_with(&b, &opts(l)).is_ok(), - "{f:?} exact limit should pass" - ); + let l = Limits { max_pixels: 64 * 48, ..Limits::default() }; + assert!(decode_with(&b, &opts(l)).is_ok(), "{f:?} exact limit should pass"); } } #[test] fn max_width_enforced_for_every_format() { for f in rw_formats() { - let l = Limits { - max_width: 63, - ..Limits::default() - }; + let l = Limits { max_width: 63, ..Limits::default() }; assert!(is_limit(decode_with(&encoded(f), &opts(l))), "{f:?}"); } } @@ -51,10 +39,7 @@ fn max_width_enforced_for_every_format() { #[test] fn max_height_enforced_for_every_format() { for f in rw_formats() { - let l = Limits { - max_height: 47, - ..Limits::default() - }; + let l = Limits { max_height: 47, ..Limits::default() }; assert!(is_limit(decode_with(&encoded(f), &opts(l))), "{f:?}"); } } @@ -62,10 +47,7 @@ fn max_height_enforced_for_every_format() { #[test] fn max_alloc_enforced_for_every_format() { for f in rw_formats() { - let l = Limits { - max_alloc: 64 * 48, - ..Limits::default() - }; + let l = Limits { max_alloc: 64 * 48, ..Limits::default() }; assert!(decode_with(&encoded(f), &opts(l)).is_err(), "{f:?}"); } } @@ -73,10 +55,7 @@ fn max_alloc_enforced_for_every_format() { #[test] fn limits_none_accepts() { for f in rw_formats() { - assert!( - decode_with(&encoded(f), &opts(Limits::none())).is_ok(), - "{f:?}" - ); + assert!(decode_with(&encoded(f), &opts(Limits::none())).is_ok(), "{f:?}"); } } @@ -95,11 +74,7 @@ fn crc32(d: &[u8]) -> u32 { for &b in d { c ^= b as u32; for _ in 0..8 { - c = if c & 1 != 0 { - 0xEDB8_8320 ^ (c >> 1) - } else { - c >> 1 - }; + c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 }; } } !c @@ -113,10 +88,7 @@ fn png_bomb_header_rejected_before_alloc() { ihdr.extend_from_slice(&[16, 6, 0, 0, 0]); let mut b = b"\x89PNG\r\n\x1a\n".to_vec(); b.extend(png_chunk(b"IHDR", &ihdr)); - b.extend(png_chunk( - b"IDAT", - &[0x78, 0x9C, 0x03, 0x00, 0x00, 0x00, 0x00, 0x01], - )); + b.extend(png_chunk(b"IDAT", &[0x78, 0x9C, 0x03, 0x00, 0x00, 0x00, 0x00, 0x01])); b.extend(png_chunk(b"IEND", &[])); assert!(is_limit(decode(&b))); } @@ -124,23 +96,15 @@ fn png_bomb_header_rejected_before_alloc() { #[test] fn pnm_bomb_header_rejected() { assert!(is_limit(decode(b"P6\n200000 200000\n255\n\0\0\0"))); - assert!(is_limit(decode( - b"P7\nWIDTH 99999\nHEIGHT 99999\nDEPTH 4\nMAXVAL 65535\nTUPLTYPE RGB_ALPHA\nENDHDR\n" - ))); + assert!(is_limit(decode(b"P7\nWIDTH 99999\nHEIGHT 99999\nDEPTH 4\nMAXVAL 65535\nTUPLTYPE RGB_ALPHA\nENDHDR\n"))); assert!(is_limit(decode(b"PF\n100000 100000\n-1.0\n"))); } #[test] fn jpeg_bomb_header_rejected() { // SOI + SOF0 claiming 65535x65535x3 with no scan data. - let b = [ - 0xFF, 0xD8, 0xFF, 0xC0, 0x00, 0x11, 8, 0xFF, 0xFF, 0xFF, 0xFF, 3, 1, 0x11, 0, 2, 0x11, 0, - 3, 0x11, 0, 0xFF, 0xD9, - ]; - let l = Limits { - max_pixels: 1 << 24, - ..Limits::default() - }; + let b = [0xFF, 0xD8, 0xFF, 0xC0, 0x00, 0x11, 8, 0xFF, 0xFF, 0xFF, 0xFF, 3, 1, 0x11, 0, 2, 0x11, 0, 3, 0x11, 0, 0xFF, 0xD9]; + let l = Limits { max_pixels: 1 << 24, ..Limits::default() }; assert!(is_limit(decode_with(&b, &opts(l)))); } @@ -148,14 +112,7 @@ fn jpeg_bomb_header_rejected() { fn tiff_bomb_header_rejected() { // Minimal little-endian TIFF claiming 1_000_000 x 1_000_000 8-bit gray. let mut b = b"II*\0\x08\0\0\0".to_vec(); - let entries: [(u16, u16, u32, u32); 6] = [ - (256, 4, 1, 1_000_000), - (257, 4, 1, 1_000_000), - (258, 3, 1, 8), - (262, 3, 1, 1), - (273, 4, 1, 0), - (279, 4, 1, 0), - ]; + let entries: [(u16, u16, u32, u32); 6] = [(256, 4, 1, 1_000_000), (257, 4, 1, 1_000_000), (258, 3, 1, 8), (262, 3, 1, 1), (273, 4, 1, 0), (279, 4, 1, 0)]; b.extend_from_slice(&(entries.len() as u16).to_le_bytes()); for (tag, ty, count, val) in entries { b.extend_from_slice(&tag.to_le_bytes()); @@ -170,25 +127,10 @@ fn tiff_bomb_header_rejected() { #[test] fn limits_check_api() { let l = Limits::default(); - assert!( - l.check(100, 100, ChannelLayout::Rgba, SampleType::F32) - .is_ok() - ); - assert!(matches!( - l.check(0, 1, ChannelLayout::Gray, SampleType::U8), - Err(CodecError::InvalidImage(_)) - )); - assert!(matches!( - l.check(u32::MAX, 1, ChannelLayout::Gray, SampleType::U8), - Err(CodecError::LimitExceeded(_)) - )); - let l = Limits { - max_alloc: 399, - ..Limits::default() - }; - assert!( - l.check(10, 10, ChannelLayout::Rgba, SampleType::U8) - .is_err() - ); + assert!(l.check(100, 100, ChannelLayout::Rgba, SampleType::F32).is_ok()); + assert!(matches!(l.check(0, 1, ChannelLayout::Gray, SampleType::U8), Err(CodecError::InvalidImage(_)))); + assert!(matches!(l.check(u32::MAX, 1, ChannelLayout::Gray, SampleType::U8), Err(CodecError::LimitExceeded(_)))); + let l = Limits { max_alloc: 399, ..Limits::default() }; + assert!(l.check(10, 10, ChannelLayout::Rgba, SampleType::U8).is_err()); assert!(l.check(10, 10, ChannelLayout::Rgb, SampleType::U8).is_ok()); } diff --git a/crates/codecs/tests/malformed.rs b/crates/codecs/tests/malformed.rs index 911d9eb2ca..a3dfb591bc 100644 --- a/crates/codecs/tests/malformed.rs +++ b/crates/codecs/tests/malformed.rs @@ -6,24 +6,13 @@ use photocraft_codecs::*; use proptest::prelude::*; fn tight() -> DecodeOptions { - DecodeOptions { - limits: Limits { - max_width: 4096, - max_height: 4096, - max_pixels: 1 << 22, - max_alloc: 64 << 20, - }, - } + DecodeOptions { limits: Limits { max_width: 4096, max_height: 4096, max_pixels: 1 << 22, max_alloc: 64 << 20 } } } fn samples() -> Vec<(Format, Vec)> { let mut v = Vec::new(); for f in rw_formats() { - for (l, s) in [ - (ChannelLayout::Rgba, SampleType::U8), - (ChannelLayout::Gray, SampleType::U16), - (ChannelLayout::Rgb, SampleType::F32), - ] { + for (l, s) in [(ChannelLayout::Rgba, SampleType::U8), (ChannelLayout::Gray, SampleType::U16), (ChannelLayout::Rgb, SampleType::F32)] { let mut img = synth(19, 13, l, s, 11, 0.3); img.icc = Some(sample_icc(300)); img.meta.exif = Some(sample_exif()); @@ -33,31 +22,9 @@ fn samples() -> Vec<(Format, Vec)> { } // Extra variants: interlaced PNG, ASCII PNM, CMYK JPEG. let img = synth(19, 13, ChannelLayout::Rgb, SampleType::U8, 1, 0.3); - v.push(( - Format::Png, - encode( - &img, - Format::Png, - &EncodeOptions { - png_interlaced: true, - ..Default::default() - }, - ) - .unwrap(), - )); - v.push(( - Format::Pnm, - b"P3\n2 2\n255\n0 1 2 3 4 5 6 7 8 9 10 11\n".to_vec(), - )); - v.push(( - Format::Jpeg, - encode( - &synth(19, 13, ChannelLayout::Cmyk, SampleType::U8, 1, 0.3), - Format::Jpeg, - &EncodeOptions::default(), - ) - .unwrap(), - )); + v.push((Format::Png, encode(&img, Format::Png, &EncodeOptions { png_interlaced: true, ..Default::default() }).unwrap())); + v.push((Format::Pnm, b"P3\n2 2\n255\n0 1 2 3 4 5 6 7 8 9 10 11\n".to_vec())); + v.push((Format::Jpeg, encode(&synth(19, 13, ChannelLayout::Cmyk, SampleType::U8, 1, 0.3), Format::Jpeg, &EncodeOptions::default()).unwrap())); v } @@ -85,10 +52,7 @@ fn truncated_files_mostly_error() { // Chopping off the second half must not silently succeed for formats // with length-checked rasters. for (f, bytes) in samples() { - if matches!( - f, - Format::Png | Format::Pnm | Format::Qoi | Format::OpenExr | Format::Tiff | Format::Bmp - ) { + if matches!(f, Format::Png | Format::Pnm | Format::Qoi | Format::OpenExr | Format::Tiff | Format::Bmp) { let r = decode_as_with(f, &bytes[..bytes.len() / 2], &tight()); assert!(r.is_err(), "{f:?} decoded half a file"); } @@ -172,27 +136,15 @@ fn pnm_valid_variants() { assert_eq!(img.sample_type(), SampleType::U16); assert_eq!(img.to_u16_samples().unwrap(), vec![0, 65535]); let img = decode(b"P3\n1 1\n255\n1 2 3\n").unwrap(); - assert_eq!( - (img.layout(), img.data()), - (ChannelLayout::Rgb, &[1u8, 2, 3][..]) - ); - let img = decode( - b"P7\nWIDTH 1\nHEIGHT 1\nDEPTH 2\nMAXVAL 255\nTUPLTYPE GRAYSCALE_ALPHA\nENDHDR\n\x10\x20", - ) - .unwrap(); - assert_eq!( - (img.layout(), img.data()), - (ChannelLayout::GrayA, &[0x10u8, 0x20][..]) - ); + assert_eq!((img.layout(), img.data()), (ChannelLayout::Rgb, &[1u8, 2, 3][..])); + let img = decode(b"P7\nWIDTH 1\nHEIGHT 1\nDEPTH 2\nMAXVAL 255\nTUPLTYPE GRAYSCALE_ALPHA\nENDHDR\n\x10\x20").unwrap(); + assert_eq!((img.layout(), img.data()), (ChannelLayout::GrayA, &[0x10u8, 0x20][..])); let mut pfm = b"PF\n1 2\n1.0\n".to_vec(); // big-endian, bottom-up for v in [1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0] { pfm.extend_from_slice(&v.to_be_bytes()); } let img = decode(&pfm).unwrap(); - assert_eq!( - img.to_f32_samples().unwrap(), - vec![4.0, 5.0, 6.0, 1.0, 2.0, 3.0] - ); + assert_eq!(img.to_f32_samples().unwrap(), vec![4.0, 5.0, 6.0, 1.0, 2.0, 3.0]); } #[test] @@ -201,13 +153,8 @@ fn jpeg_marker_garbage() { &[0xFF, 0xD8, 0xFF], &[0xFF, 0xD8, 0xFF, 0xE0, 0x00], &[0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x01], - &[ - 0xFF, 0xD8, 0xFF, 0xE2, 0x00, 0x10, b'I', b'C', b'C', b'_', b'P', b'R', b'O', b'F', - b'I', b'L', b'E', 0, 1, 1, - ], - &[ - 0xFF, 0xD8, 0xFF, 0xC0, 0x00, 0x0B, 8, 0xFF, 0xFF, 0xFF, 0xFF, 3, 0, 0, 0, - ], + &[0xFF, 0xD8, 0xFF, 0xE2, 0x00, 0x10, b'I', b'C', b'C', b'_', b'P', b'R', b'O', b'F', b'I', b'L', b'E', 0, 1, 1], + &[0xFF, 0xD8, 0xFF, 0xC0, 0x00, 0x0B, 8, 0xFF, 0xFF, 0xFF, 0xFF, 3, 0, 0, 0], &[0xFF, 0xD8, 0xFF, 0xD9], ]; for c in cases { diff --git a/crates/codecs/tests/metadata.rs b/crates/codecs/tests/metadata.rs index 246cc21e01..22a5347f5c 100644 --- a/crates/codecs/tests/metadata.rs +++ b/crates/codecs/tests/metadata.rs @@ -11,10 +11,7 @@ fn rich_image(layout: ChannelLayout) -> Image { exif: Some(sample_exif()), xmp: Some(SAMPLE_XMP.to_owned()), dpi: Some((300.0, 150.0)), - text: vec![ - ("Description".into(), "synthetic test image".into()), - ("Software".into(), "photocraft".into()), - ], + text: vec![("Description".into(), "synthetic test image".into()), ("Software".into(), "photocraft".into())], }; img } @@ -24,11 +21,7 @@ fn check_format(format: Format) { if !(c.read && c.write) { return; } - let layout = if c.layouts.contains(&ChannelLayout::Rgb) { - ChannelLayout::Rgb - } else { - c.layouts[0] - }; + let layout = if c.layouts.contains(&ChannelLayout::Rgb) { ChannelLayout::Rgb } else { c.layouts[0] }; let img = rich_image(layout); let bytes = encode(&img, format, &EncodeOptions::default()).unwrap(); let back = decode(&bytes).unwrap(); @@ -38,57 +31,32 @@ fn check_format(format: Format) { if c.icc { assert_eq!(back.icc, img.icc, "{format:?} icc bytes"); } else { - assert!( - warnings.contains(&FidelityWarning::IccDropped), - "{format:?}" - ); + assert!(warnings.contains(&FidelityWarning::IccDropped), "{format:?}"); } if c.exif { assert_eq!(back.meta.exif, img.meta.exif, "{format:?} exif"); } else { - assert!( - warnings.contains(&FidelityWarning::ExifDropped), - "{format:?}" - ); + assert!(warnings.contains(&FidelityWarning::ExifDropped), "{format:?}"); } if c.xmp { assert_eq!(back.meta.xmp, img.meta.xmp, "{format:?} xmp"); } else { assert!(back.meta.xmp.is_none()); - assert!( - warnings.contains(&FidelityWarning::XmpDropped), - "{format:?}" - ); + assert!(warnings.contains(&FidelityWarning::XmpDropped), "{format:?}"); } if c.dpi { - let (x, y) = back - .meta - .dpi - .unwrap_or_else(|| panic!("{format:?} lost dpi")); - assert!( - (x - 300.0).abs() < 0.05 && (y - 150.0).abs() < 0.05, - "{format:?} dpi {x},{y}" - ); + let (x, y) = back.meta.dpi.unwrap_or_else(|| panic!("{format:?} lost dpi")); + assert!((x - 300.0).abs() < 0.05 && (y - 150.0).abs() < 0.05, "{format:?} dpi {x},{y}"); } else { - assert!( - warnings.contains(&FidelityWarning::DpiDropped), - "{format:?}" - ); + assert!(warnings.contains(&FidelityWarning::DpiDropped), "{format:?}"); } if c.text { for kv in &img.meta.text { - assert!( - back.meta.text.contains(kv), - "{format:?} lost text {kv:?}: {:?}", - back.meta.text - ); + assert!(back.meta.text.contains(kv), "{format:?} lost text {kv:?}: {:?}", back.meta.text); } } else { assert!(back.meta.text.is_empty()); - assert!( - warnings.contains(&FidelityWarning::TextDropped), - "{format:?}" - ); + assert!(warnings.contains(&FidelityWarning::TextDropped), "{format:?}"); } } @@ -167,10 +135,7 @@ fn icc_on_cmyk_tiff_and_jpeg() { fn cmyk_icc_not_written_to_rgb_format() { let img = test_image(ChannelLayout::Cmyk, SampleType::U8).with_icc(Some(sample_icc(900))); let back = decode(&encode(&img, Format::Png, &EncodeOptions::default()).unwrap()).unwrap(); - assert!( - back.icc.is_none(), - "a CMYK profile must not be attached to converted RGB data" - ); + assert!(back.icc.is_none(), "a CMYK profile must not be attached to converted RGB data"); assert!(fidelity_warnings(&img, Format::Png).contains(&FidelityWarning::IccDropped)); } @@ -180,10 +145,7 @@ fn jpeg_multi_segment_icc() { for len in [65519, 65520, 150_000, 300_001] { let img = test_image(ChannelLayout::Rgb, SampleType::U8).with_icc(Some(sample_icc(len))); let bytes = encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap(); - let app2 = bytes - .windows(14) - .filter(|w| w[0] == 0xFF && w[1] == 0xE2 && &w[4..14] == b"ICC_PROFIL") - .count(); + let app2 = bytes.windows(14).filter(|w| w[0] == 0xFF && w[1] == 0xE2 && &w[4..14] == b"ICC_PROFIL").count(); assert_eq!(app2, len.div_ceil(65519), "segments for {len}"); assert_eq!(decode(&bytes).unwrap().icc.map(|v| v.len()), Some(len)); // Oracle: the image crate reads the same profile. @@ -196,34 +158,20 @@ fn jpeg_multi_segment_icc() { #[test] fn embed_flags_disable_metadata() { let img = rich_image(ChannelLayout::Rgb); - let opts = EncodeOptions { - embed_icc: false, - embed_metadata: false, - ..Default::default() - }; + let opts = EncodeOptions { embed_icc: false, embed_metadata: false, ..Default::default() }; for f in [Format::Png, Format::Jpeg, Format::Tiff, Format::WebP] { let back = decode(&encode(&img, f, &opts).unwrap()).unwrap(); assert!(back.icc.is_none(), "{f:?}"); assert!(back.meta.exif.is_none() && back.meta.xmp.is_none(), "{f:?}"); assert!(back.meta.text.is_empty(), "{f:?}"); - assert!( - fidelity_warnings_with(&img, Format::Bmp, &opts) - .iter() - .all(|w| !matches!( - w, - FidelityWarning::IccDropped | FidelityWarning::ExifDropped - )) - ); + assert!(fidelity_warnings_with(&img, Format::Bmp, &opts).iter().all(|w| !matches!(w, FidelityWarning::IccDropped | FidelityWarning::ExifDropped))); } } #[test] fn png_unicode_text_uses_itxt() { let mut img = test_image(ChannelLayout::Rgb, SampleType::U8); - img.meta.text = vec![ - ("Title".into(), "Ünïcödé ☃ 雪".into()), - ("Author".into(), "latin1 ok é".into()), - ]; + img.meta.text = vec![("Title".into(), "Ünïcödé ☃ 雪".into()), ("Author".into(), "latin1 ok é".into())]; let bytes = encode(&img, Format::Png, &EncodeOptions::default()).unwrap(); assert!(bytes.windows(4).any(|w| w == b"iTXt")); assert!(bytes.windows(4).any(|w| w == b"tEXt")); diff --git a/crates/codecs/tests/png_jpeg.rs b/crates/codecs/tests/png_jpeg.rs index 945d2e73da..b5d9bc736d 100644 --- a/crates/codecs/tests/png_jpeg.rs +++ b/crates/codecs/tests/png_jpeg.rs @@ -10,15 +10,7 @@ use photocraft_codecs::*; // --------------------------------------------------------------------------- fn interlaced(img: &Image) -> Vec { - encode( - img, - Format::Png, - &EncodeOptions { - png_interlaced: true, - ..Default::default() - }, - ) - .unwrap() + encode(img, Format::Png, &EncodeOptions { png_interlaced: true, ..Default::default() }).unwrap() } fn check_adam7(w: u32, h: u32, layout: ChannelLayout, sample: SampleType) { @@ -29,39 +21,18 @@ fn check_adam7(w: u32, h: u32, layout: ChannelLayout, sample: SampleType) { assert_eq!(b[28], 0); let da = decode(&a).unwrap(); let db = decode(&b).unwrap(); - assert_eq!( - da, db, - "{w}x{h} {layout:?} {sample:?}: interlaced != progressive" - ); + assert_eq!(da, db, "{w}x{h} {layout:?} {sample:?}: interlaced != progressive"); assert_eq!(da.data(), img.data()); // Oracle: the image crate agrees. let o = image::load_from_memory_with_format(&a, image::ImageFormat::Png).unwrap(); - let ours = da - .convert(ChannelLayout::Rgba, SampleType::U16) - .to_u16_samples() - .unwrap(); + let ours = da.convert(ChannelLayout::Rgba, SampleType::U16).to_u16_samples().unwrap(); assert_eq!(o.into_rgba16().into_raw(), ours); } #[test] fn adam7_small_sizes_all_layouts() { - for (w, h) in [ - (1, 1), - (2, 2), - (3, 3), - (5, 1), - (1, 9), - (7, 5), - (8, 8), - (9, 17), - (33, 31), - ] { - for l in [ - ChannelLayout::Gray, - ChannelLayout::GrayA, - ChannelLayout::Rgb, - ChannelLayout::Rgba, - ] { + for (w, h) in [(1, 1), (2, 2), (3, 3), (5, 1), (1, 9), (7, 5), (8, 8), (9, 17), (33, 31)] { + for l in [ChannelLayout::Gray, ChannelLayout::GrayA, ChannelLayout::Rgb, ChannelLayout::Rgba] { check_adam7(w, h, l, SampleType::U8); } } @@ -88,21 +59,8 @@ fn adam7_with_metadata() { #[test] fn adam7_compressions() { let img = test_image(ChannelLayout::Rgba, SampleType::U8); - for c in [ - PngCompression::None, - PngCompression::Fast, - PngCompression::Best, - ] { - let b = encode( - &img, - Format::Png, - &EncodeOptions { - png_interlaced: true, - png_compression: c, - ..Default::default() - }, - ) - .unwrap(); + for c in [PngCompression::None, PngCompression::Fast, PngCompression::Best] { + let b = encode(&img, Format::Png, &EncodeOptions { png_interlaced: true, png_compression: c, ..Default::default() }).unwrap(); assert_eq!(decode(&b).unwrap().data(), img.data()); } } @@ -119,10 +77,7 @@ fn png_palette_and_low_bit_depth_expand() { w.write_image_data(&[0b00_01_10_11]).unwrap(); } let img = decode(&out).unwrap(); - assert_eq!( - (img.layout(), img.data()), - (ChannelLayout::Gray, &[0u8, 85, 170, 255][..]) - ); + assert_eq!((img.layout(), img.data()), (ChannelLayout::Gray, &[0u8, 85, 170, 255][..])); let mut out = Vec::new(); { @@ -135,10 +90,7 @@ fn png_palette_and_low_bit_depth_expand() { w.write_image_data(&[0, 1]).unwrap(); } let img = decode(&out).unwrap(); - assert_eq!( - (img.layout(), img.data()), - (ChannelLayout::Rgba, &[255u8, 0, 0, 128, 0, 0, 255, 255][..]) - ); + assert_eq!((img.layout(), img.data()), (ChannelLayout::Rgba, &[255u8, 0, 0, 128, 0, 0, 255, 255][..])); } // --------------------------------------------------------------------------- @@ -156,19 +108,8 @@ fn jpeg_grayscale_decodes_as_gray() { #[test] fn jpeg_cmyk_roundtrip_with_adobe_marker() { let img = synth(40, 30, ChannelLayout::Cmyk, SampleType::U8, 3, 0.02); - let bytes = encode( - &img, - Format::Jpeg, - &EncodeOptions { - jpeg_quality: 95, - ..Default::default() - }, - ) - .unwrap(); - assert!( - bytes.windows(5).any(|w| w == b"Adobe"), - "APP14 Adobe marker" - ); + let bytes = encode(&img, Format::Jpeg, &EncodeOptions { jpeg_quality: 95, ..Default::default() }).unwrap(); + assert!(bytes.windows(5).any(|w| w == b"Adobe"), "APP14 Adobe marker"); let back = decode(&bytes).unwrap(); assert_eq!(back.layout(), ChannelLayout::Cmyk); assert!(psnr(&img, &back) > 35.0, "{}", psnr(&img, &back)); @@ -180,8 +121,7 @@ fn jpeg_ycck_decodes_to_cmyk() { let mut bytes = Vec::new(); let mut enc = jpeg_encoder::Encoder::new(&mut bytes, 95); enc.set_sampling_factor(jpeg_encoder::SamplingFactor::R_4_4_4); - enc.encode(img.data(), 40, 32, jpeg_encoder::ColorType::CmykAsYcck) - .unwrap(); + enc.encode(img.data(), 40, 32, jpeg_encoder::ColorType::CmykAsYcck).unwrap(); let back = decode(&bytes).unwrap(); assert_eq!(back.layout(), ChannelLayout::Cmyk); assert!(psnr(&img, &back) > 32.0, "{}", psnr(&img, &back)); @@ -189,13 +129,7 @@ fn jpeg_ycck_decodes_to_cmyk() { #[test] fn jpeg_cmyk_flat_colour_exact_ish() { - let img = Image::from_u8( - 16, - 16, - ChannelLayout::Cmyk, - [10u8, 100, 200, 50].repeat(256), - ) - .unwrap(); + let img = Image::from_u8(16, 16, ChannelLayout::Cmyk, [10u8, 100, 200, 50].repeat(256)).unwrap(); let back = decode(&encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap()).unwrap(); for px in back.data().chunks(4) { for (a, b) in px.iter().zip([10u8, 100, 200, 50]) { @@ -209,16 +143,8 @@ fn jpeg_rgb_matches_oracle_decoder() { let img = test_image(ChannelLayout::Rgb, SampleType::U8); let bytes = encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap(); let ours = decode(&bytes).unwrap(); - let theirs = image::load_from_memory_with_format(&bytes, image::ImageFormat::Jpeg) - .unwrap() - .into_rgb8(); - let max = ours - .data() - .iter() - .zip(theirs.as_raw()) - .map(|(a, b)| (*a as i32 - *b as i32).abs()) - .max() - .unwrap(); + let theirs = image::load_from_memory_with_format(&bytes, image::ImageFormat::Jpeg).unwrap().into_rgb8(); + let max = ours.data().iter().zip(theirs.as_raw()).map(|(a, b)| (*a as i32 - *b as i32).abs()).max().unwrap(); assert!(max <= 1, "max diff {max}"); } @@ -228,13 +154,7 @@ fn jpeg_progressive_from_other_encoder_decodes() { let mut bytes = Vec::new(); let mut enc = jpeg_encoder::Encoder::new(&mut bytes, 90); enc.set_progressive(true); - enc.encode( - img.data(), - img.width() as u16, - img.height() as u16, - jpeg_encoder::ColorType::Rgb, - ) - .unwrap(); + enc.encode(img.data(), img.width() as u16, img.height() as u16, jpeg_encoder::ColorType::Rgb).unwrap(); let back = decode(&bytes).unwrap(); assert!(psnr(&img, &back) > 25.0); } @@ -245,19 +165,11 @@ fn jpeg_progressive_from_other_encoder_decodes() { #[test] fn png_output_matches_oracle_all_layouts() { - for l in [ - ChannelLayout::Gray, - ChannelLayout::GrayA, - ChannelLayout::Rgb, - ChannelLayout::Rgba, - ] { + for l in [ChannelLayout::Gray, ChannelLayout::GrayA, ChannelLayout::Rgb, ChannelLayout::Rgba] { for s in [SampleType::U8, SampleType::U16] { let img = test_image(l, s); let b = encode(&img, Format::Png, &EncodeOptions::default()).unwrap(); - let o = image::load_from_memory(&b) - .unwrap() - .into_rgba16() - .into_raw(); + let o = image::load_from_memory(&b).unwrap().into_rgba16().into_raw(); assert_eq!(o, img.to_rgba16(), "{l:?} {s:?}"); } } @@ -269,10 +181,7 @@ fn tiff_output_matches_oracle() { for s in [SampleType::U8, SampleType::U16] { let img = test_image(l, s); let b = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap(); - let o = image::load_from_memory(&b) - .unwrap() - .into_rgba16() - .into_raw(); + let o = image::load_from_memory(&b).unwrap().into_rgba16().into_raw(); assert_eq!(o, img.to_rgba16(), "{l:?} {s:?}"); } } @@ -281,19 +190,8 @@ fn tiff_output_matches_oracle() { #[test] fn tiff_f32_output_matches_oracle() { let img = test_image(ChannelLayout::Rgba, SampleType::F32); - let b = encode( - &img, - Format::Tiff, - &EncodeOptions { - tiff_compression: TiffCompression::None, - ..Default::default() - }, - ) - .unwrap(); - let o = image::load_from_memory(&b) - .unwrap() - .into_rgba32f() - .into_raw(); + let b = encode(&img, Format::Tiff, &EncodeOptions { tiff_compression: TiffCompression::None, ..Default::default() }).unwrap(); + let o = image::load_from_memory(&b).unwrap().into_rgba32f().into_raw(); assert_eq!(o, img.to_f32_samples().unwrap()); } diff --git a/crates/codecs/tests/roundtrip.rs b/crates/codecs/tests/roundtrip.rs index fc78810991..b3fd97469c 100644 --- a/crates/codecs/tests/roundtrip.rs +++ b/crates/codecs/tests/roundtrip.rs @@ -31,39 +31,26 @@ fn check_roundtrip(format: Format, layout: ChannelLayout, sample: SampleType) { test_image(layout, sample) }; let opts = EncodeOptions::default(); - let bytes = encode(&img, format, &opts) - .unwrap_or_else(|e| panic!("{format:?} {layout:?} {sample:?}: {e}")); + let bytes = encode(&img, format, &opts).unwrap_or_else(|e| panic!("{format:?} {layout:?} {sample:?}: {e}")); assert_eq!(detect(&bytes), Some(format), "detect after encode"); if !c.read { return; } - let back = - decode(&bytes).unwrap_or_else(|e| panic!("{format:?} {layout:?} {sample:?} decode: {e}")); + let back = decode(&bytes).unwrap_or_else(|e| panic!("{format:?} {layout:?} {sample:?} decode: {e}")); assert_eq!(back.dimensions(), img.dimensions()); // Native storage must be preserved when the format declares support. let float_pnm_special = format == Format::Pnm && sample.is_float(); if c.layouts.contains(&layout) && c.depths.contains(&sample) && !float_pnm_special { assert_eq!(back.layout(), layout, "{format:?} must keep layout"); - assert_eq!( - back.sample_type(), - sample, - "{format:?} must keep sample type" - ); + assert_eq!(back.sample_type(), sample, "{format:?} must keep sample type"); } if c.depths.contains(&sample) && !float_pnm_special { - assert_eq!( - back.sample_type(), - sample, - "{format:?} must keep supported depth" - ); + assert_eq!(back.sample_type(), sample, "{format:?} must keep supported depth"); } // Integer sources never lose precision in formats offering that depth. if sample == SampleType::U16 && c.depths.contains(&SampleType::U16) { - assert!(matches!( - back.sample_type(), - SampleType::U16 | SampleType::F32 - )); + assert!(matches!(back.sample_type(), SampleType::U16 | SampleType::F32)); } let expected = img.convert(back.layout(), back.sample_type()); @@ -79,22 +66,12 @@ fn check_roundtrip(format: Format, layout: ChannelLayout, sample: SampleType) { } pe[ch - 1] = pb[ch - 1]; } - expected_cmp = Image::from_normalized( - img.width(), - img.height(), - back.layout(), - back.sample_type(), - &e, - ) - .unwrap(); + expected_cmp = Image::from_normalized(img.width(), img.height(), back.layout(), back.sample_type(), &e).unwrap(); } if c.lossy { let p = psnr(&expected_cmp, &back); - assert!( - p >= psnr_threshold(format), - "{format:?} {layout:?} {sample:?}: PSNR {p:.2} dB" - ); + assert!(p >= psnr_threshold(format), "{format:?} {layout:?} {sample:?}: PSNR {p:.2} dB"); } else { assert_eq!( expected_cmp.data(), @@ -196,21 +173,10 @@ fn hdr_format_keeps_bright_values_approximately() { #[test] fn f16_exr_is_bit_exact() { - let vals: Vec = (0..20 * 10 * 4) - .map(|i| f16::from_f32(i as f32 * 0.013 - 1.0)) - .collect(); + let vals: Vec = (0..20 * 10 * 4).map(|i| f16::from_f32(i as f32 * 0.013 - 1.0)).collect(); let img = Image::from_f16(20, 10, ChannelLayout::Rgba, &vals).unwrap(); - for comp in [ - ExrCompression::None, - ExrCompression::Rle, - ExrCompression::Zip1, - ExrCompression::Zip16, - ExrCompression::Piz, - ] { - let opts = EncodeOptions { - exr_compression: comp, - ..Default::default() - }; + for comp in [ExrCompression::None, ExrCompression::Rle, ExrCompression::Zip1, ExrCompression::Zip16, ExrCompression::Piz] { + let opts = EncodeOptions { exr_compression: comp, ..Default::default() }; let back = decode(&encode(&img, Format::OpenExr, &opts).unwrap()).unwrap(); assert_eq!(back.to_f16_samples().unwrap(), vals, "{comp:?}"); } @@ -221,22 +187,10 @@ fn tiff_all_compressions_lossless() { for layout in ChannelLayout::ALL { for sample in [SampleType::U8, SampleType::U16, SampleType::F32] { let img = test_image(layout, sample); - for comp in [ - TiffCompression::None, - TiffCompression::Lzw, - TiffCompression::Deflate, - TiffCompression::PackBits, - ] { - let opts = EncodeOptions { - tiff_compression: comp, - ..Default::default() - }; + for comp in [TiffCompression::None, TiffCompression::Lzw, TiffCompression::Deflate, TiffCompression::PackBits] { + let opts = EncodeOptions { tiff_compression: comp, ..Default::default() }; let back = decode(&encode(&img, Format::Tiff, &opts).unwrap()).unwrap(); - assert_eq!( - back, - img.clone().with_meta(back.meta.clone()), - "{layout:?} {sample:?} {comp:?}" - ); + assert_eq!(back, img.clone().with_meta(back.meta.clone()), "{layout:?} {sample:?} {comp:?}"); } } } @@ -245,16 +199,8 @@ fn tiff_all_compressions_lossless() { #[test] fn png_all_compressions_lossless() { let img = test_image(ChannelLayout::Rgba, SampleType::U16); - for comp in [ - PngCompression::None, - PngCompression::Fast, - PngCompression::Default, - PngCompression::Best, - ] { - let opts = EncodeOptions { - png_compression: comp, - ..Default::default() - }; + for comp in [PngCompression::None, PngCompression::Fast, PngCompression::Default, PngCompression::Best] { + let opts = EncodeOptions { png_compression: comp, ..Default::default() }; let back = decode(&encode(&img, Format::Png, &opts).unwrap()).unwrap(); assert_eq!(back.data(), img.data(), "{comp:?}"); } @@ -263,24 +209,8 @@ fn png_all_compressions_lossless() { #[test] fn jpeg_quality_affects_size_and_error() { let img = test_image(ChannelLayout::Rgb, SampleType::U8); - let lo = encode( - &img, - Format::Jpeg, - &EncodeOptions { - jpeg_quality: 20, - ..Default::default() - }, - ) - .unwrap(); - let hi = encode( - &img, - Format::Jpeg, - &EncodeOptions { - jpeg_quality: 98, - ..Default::default() - }, - ) - .unwrap(); + let lo = encode(&img, Format::Jpeg, &EncodeOptions { jpeg_quality: 20, ..Default::default() }).unwrap(); + let hi = encode(&img, Format::Jpeg, &EncodeOptions { jpeg_quality: 98, ..Default::default() }).unwrap(); assert!(lo.len() < hi.len()); let plo = psnr(&img, &decode(&lo).unwrap()); let phi = psnr(&img, &decode(&hi).unwrap()); @@ -290,48 +220,16 @@ fn jpeg_quality_affects_size_and_error() { #[test] fn jpeg_444_beats_420_on_chroma_detail() { let img = synth(64, 64, ChannelLayout::Rgb, SampleType::U8, 3, 0.5); - let a = encode( - &img, - Format::Jpeg, - &EncodeOptions { - jpeg_chroma_subsampling: false, - jpeg_quality: 95, - ..Default::default() - }, - ) - .unwrap(); - let b = encode( - &img, - Format::Jpeg, - &EncodeOptions { - jpeg_chroma_subsampling: true, - jpeg_quality: 95, - ..Default::default() - }, - ) - .unwrap(); + let a = encode(&img, Format::Jpeg, &EncodeOptions { jpeg_chroma_subsampling: false, jpeg_quality: 95, ..Default::default() }).unwrap(); + let b = encode(&img, Format::Jpeg, &EncodeOptions { jpeg_chroma_subsampling: true, jpeg_quality: 95, ..Default::default() }).unwrap(); assert!(psnr(&img, &decode(&a).unwrap()) > psnr(&img, &decode(&b).unwrap())); } #[test] fn webp_lossy_request_is_honest_error() { let img = test_image(ChannelLayout::Rgb, SampleType::U8); - let e = encode( - &img, - Format::WebP, - &EncodeOptions { - webp_lossless: false, - ..Default::default() - }, - ) - .unwrap_err(); - assert!(matches!( - e, - CodecError::Unsupported { - format: Format::WebP, - .. - } - )); + let e = encode(&img, Format::WebP, &EncodeOptions { webp_lossless: false, ..Default::default() }).unwrap_err(); + assert!(matches!(e, CodecError::Unsupported { format: Format::WebP, .. })); } #[test] @@ -346,10 +244,7 @@ fn one_pixel_images_roundtrip() { #[test] fn ico_rejects_oversize() { let img = Image::new(300, 10, ChannelLayout::Rgba, SampleType::U8); - assert!(matches!( - encode(&img, Format::Ico, &EncodeOptions::default()), - Err(CodecError::Encode { .. }) - )); + assert!(matches!(encode(&img, Format::Ico, &EncodeOptions::default()), Err(CodecError::Encode { .. }))); let img = Image::new(256, 256, ChannelLayout::Rgba, SampleType::U8); assert!(encode(&img, Format::Ico, &EncodeOptions::default()).is_ok()); } diff --git a/crates/color/src/blend.rs b/crates/color/src/blend.rs index 06bb8ab234..6450a7d4e7 100644 --- a/crates/color/src/blend.rs +++ b/crates/color/src/blend.rs @@ -140,16 +140,11 @@ impl BlendMode { } pub fn from_psd_key(key: [u8; 4]) -> Option { - std::iter::once(BlendMode::PassThrough) - .chain(Self::LAYER_MODES) - .find(|m| m.psd_key() == key) + std::iter::once(BlendMode::PassThrough).chain(Self::LAYER_MODES).find(|m| m.psd_key() == key) } pub fn is_separable(self) -> bool { - !matches!( - self, - BlendMode::Hue | BlendMode::Saturation | BlendMode::Color | BlendMode::Luminosity | BlendMode::DarkerColor | BlendMode::LighterColor - ) + !matches!(self, BlendMode::Hue | BlendMode::Saturation | BlendMode::Color | BlendMode::Luminosity | BlendMode::DarkerColor | BlendMode::LighterColor) } } @@ -241,11 +236,7 @@ fn hard_light(cb: f32, cs: f32) -> f32 { /// Photoshop's Soft Light (differs from the W3C/PDF definition in the upper half). #[inline] fn soft_light_ps(cb: f32, cs: f32) -> f32 { - if cs <= 0.5 { - 2.0 * cb * cs + cb * cb * (1.0 - 2.0 * cs) - } else { - 2.0 * cb * (1.0 - cs) + cb.max(0.0).sqrt() * (2.0 * cs - 1.0) - } + if cs <= 0.5 { 2.0 * cb * cs + cb * cb * (1.0 - 2.0 * cs) } else { 2.0 * cb * (1.0 - cs) + cb.max(0.0).sqrt() * (2.0 * cs - 1.0) } } // ---- non-separable helpers (W3C Compositing Level 1) ---- @@ -305,10 +296,18 @@ pub fn blend_rgb(mode: BlendMode, cb: [f32; 3], cs: [f32; 3]) -> [f32; 3] { BlendMode::Color => set_lum(cs, lum(cb)), BlendMode::Luminosity => set_lum(cb, lum(cs)), BlendMode::DarkerColor => { - if lum(cs) < lum(cb) { cs } else { cb } + if lum(cs) < lum(cb) { + cs + } else { + cb + } } BlendMode::LighterColor => { - if lum(cs) > lum(cb) { cs } else { cb } + if lum(cs) > lum(cb) { + cs + } else { + cb + } } m => [blend_channel(m, cb[0], cs[0]), blend_channel(m, cb[1], cs[1]), blend_channel(m, cb[2], cs[2])], } diff --git a/crates/color/src/convert.rs b/crates/color/src/convert.rs index 2c24daac9c..4b3b130381 100644 --- a/crates/color/src/convert.rs +++ b/crates/color/src/convert.rs @@ -13,12 +13,16 @@ use photocraft_cms::{Builtin, Intent, Transform}; fn cmyk_to_srgb_transform() -> &'static Transform { static T: OnceLock = OnceLock::new(); - T.get_or_init(|| Transform::new(Builtin::CoatedCmyk.profile(), Builtin::Srgb.profile(), Intent::RelativeColorimetric, true).expect("built-in profiles link")) + T.get_or_init(|| { + Transform::new(Builtin::CoatedCmyk.profile(), Builtin::Srgb.profile(), Intent::RelativeColorimetric, true).expect("built-in profiles link") + }) } fn srgb_to_cmyk_transform() -> &'static Transform { static T: OnceLock = OnceLock::new(); - T.get_or_init(|| Transform::new(Builtin::Srgb.profile(), Builtin::CoatedCmyk.profile(), Intent::RelativeColorimetric, true).expect("built-in profiles link")) + T.get_or_init(|| { + Transform::new(Builtin::Srgb.profile(), Builtin::CoatedCmyk.profile(), Intent::RelativeColorimetric, true).expect("built-in profiles link") + }) } /// Colour-managed CMYK → sRGB (built-in coated CMYK profile, relative colorimetric + BPC). @@ -76,11 +80,7 @@ pub fn lab_to_srgb(lab: [f32; 3]) -> [f32; 3] { let finv = |t: f32| if t > 6.0 / 29.0 { t * t * t } else { 3.0 * (6.0f32 / 29.0).powi(2) * (t - 4.0 / 29.0) }; let xyz = [finv(fx) * D50[0], finv(fy) * D50[1], finv(fz) * D50[2]]; // D50 XYZ → linear sRGB (Bradford-adapted matrix) - let m = [ - [3.133_856, -1.616_867, -0.490_615], - [-0.978_768, 1.916_142, 0.033_454], - [0.071_945, -0.228_991, 1.405_243], - ]; + let m = [[3.133_856, -1.616_867, -0.490_615], [-0.978_768, 1.916_142, 0.033_454], [0.071_945, -0.228_991, 1.405_243]]; let mut out = [0.0f32; 3]; for i in 0..3 { let lin = m[i][0] * xyz[0] + m[i][1] * xyz[1] + m[i][2] * xyz[2]; @@ -92,11 +92,7 @@ pub fn lab_to_srgb(lab: [f32; 3]) -> [f32; 3] { /// Encoded sRGB → CIE L*a*b* (D50). pub fn srgb_to_lab(rgb: [f32; 3]) -> [f32; 3] { let lin = [srgb_to_linear(rgb[0]), srgb_to_linear(rgb[1]), srgb_to_linear(rgb[2])]; - let m = [ - [0.436_074, 0.385_064, 0.143_080], - [0.222_504, 0.716_878, 0.060_618], - [0.013_932, 0.097_104, 0.714_173], - ]; + let m = [[0.436_074, 0.385_064, 0.143_080], [0.222_504, 0.716_878, 0.060_618], [0.013_932, 0.097_104, 0.714_173]]; let mut xyz = [0.0f32; 3]; for i in 0..3 { xyz[i] = (m[i][0] * lin[0] + m[i][1] * lin[1] + m[i][2] * lin[2]) / D50[i]; diff --git a/crates/compose/src/adjust.rs b/crates/compose/src/adjust.rs index caf283541d..7fa02d410e 100644 --- a/crates/compose/src/adjust.rs +++ b/crates/compose/src/adjust.rs @@ -26,9 +26,7 @@ impl Transfer { /// Default transfer for a document colour mode. pub fn for_mode(mode: photocraft_color::ColorMode) -> Self { match mode { - photocraft_color::ColorMode::Grayscale | photocraft_color::ColorMode::Duotone | photocraft_color::ColorMode::Bitmap => { - Transfer::Gamma(1.732) - } + photocraft_color::ColorMode::Grayscale | photocraft_color::ColorMode::Duotone | photocraft_color::ColorMode::Bitmap => Transfer::Gamma(1.732), _ => Transfer::Srgb, } } @@ -99,9 +97,8 @@ pub fn apply_with(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer) { }) } Adjustment::Levels { master, per_channel } => { - let luts: [Vec; 3] = std::array::from_fn(|i| { - (0..LUT_SIZE).map(|k| levels(&per_channel[i], levels(master, k as f32 / (LUT_SIZE - 1) as f32))).collect() - }); + let luts: [Vec; 3] = + std::array::from_fn(|i| (0..LUT_SIZE).map(|k| levels(&per_channel[i], levels(master, k as f32 / (LUT_SIZE - 1) as f32))).collect()); map_rgb(buf, |c| std::array::from_fn(|i| lut(&luts[i], c[i]))) } Adjustment::Curves { master, per_channel } => { @@ -145,11 +142,7 @@ pub fn apply_with(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer) { } Adjustment::ChannelMixer { matrix, monochrome } => map_rgb(buf, |c| { let mix = |row: &[f32; 4]| (row[0] * c[0] + row[1] * c[1] + row[2] * c[2] + row[3]).clamp(0.0, 1.0); - if *monochrome { - [mix(&matrix[0]); 3] - } else { - [mix(&matrix[0]), mix(&matrix[1]), mix(&matrix[2])] - } + if *monochrome { [mix(&matrix[0]); 3] } else { [mix(&matrix[0]), mix(&matrix[1]), mix(&matrix[2])] } }), Adjustment::PhotoFilter { color, density, preserve_luminosity } => map_rgb(buf, |c| { let filtered: [f32; 3] = std::array::from_fn(|i| c[i] * (1.0 - density) + c[i] * color[i] * density); @@ -337,11 +330,7 @@ pub fn modern_brightness(v: f32, brightness: f32) -> f32 { } let s = 1.375f32.powf(brightness / 50.0); // Exponent of the white-anchor term grows with |b|; shapes the roll-off toward (1,1). - let p = if brightness >= 0.0 { - (4.5 - 0.013 * brightness).max(2.0) - } else { - 5.0 - 0.072 * brightness - }; + let p = if brightness >= 0.0 { (4.5 - 0.013 * brightness).max(2.0) } else { 5.0 - 0.072 * brightness }; let v = v.clamp(0.0, 1.0); (s * v + (1.0 - s) * v.powf(p)).clamp(0.0, 1.0) } diff --git a/crates/compose/src/bounds.rs b/crates/compose/src/bounds.rs index c3beb01fb0..1055487f13 100644 --- a/crates/compose/src/bounds.rs +++ b/crates/compose/src/bounds.rs @@ -68,4 +68,3 @@ pub fn content_bounds(s: &Surface) -> Rect { } out } - diff --git a/crates/compose/src/effects.rs b/crates/compose/src/effects.rs index 9380dfb012..44866490ff 100644 --- a/crates/compose/src/effects.rs +++ b/crates/compose/src/effects.rs @@ -18,11 +18,11 @@ //! use an exact Euclidean distance transform. Soft falloffs are Gaussian blurs. use photocraft_color::blend::BlendMode; +use photocraft_doc::Pattern; use photocraft_doc::{ Bevel, BevelStyle, BevelTechnique, Contour, Effect, FxPaint, GlobalLight, Glow, GlowSource, GlowTechnique, Gradient, GradientStyle, Layer, Shadow, StrokePosition, }; -use photocraft_doc::Pattern; use photocraft_geom::Rect; use crate::pattern::{Placement, Tile}; @@ -271,20 +271,19 @@ fn dist_inside_by(s: &Map, m: Metric) -> Vec { // Partly covered edge pixels (alpha below their neighbourhood's) are seeds starting at // their coverage, mirroring `dist_outside`: a 25 % edge column puts the edge 0.25 px in. let cov = local_coverage(s); - let start = s - .v - .iter() - .zip(&cov.v) - .map(|(&a, &c)| { - if a <= INSIDE_EPS { - 0.0 - } else if c < 1.0 - INSIDE_EPS { - c - } else { - CHAMFER_INF - } - }) - .collect(); + let start = + s.v.iter() + .zip(&cov.v) + .map(|(&a, &c)| { + if a <= INSIDE_EPS { + 0.0 + } else if c < 1.0 - INSIDE_EPS { + c + } else { + CHAMFER_INF + } + }) + .collect(); let (d, _) = chamfer_from(start, s.w, s.h); return d.iter().zip(&s.v).map(|(d, &a)| if a <= INSIDE_EPS { -0.5 } else { d - 0.5 }).collect(); } @@ -372,10 +371,14 @@ pub fn ranged_lut(contour: &Contour, range: f32) -> Option> { return base; } let n = 4096; - Some((0..n).map(|k| { - let v = (k as f32 / (n - 1) as f32 / range).min(1.0); - base.as_ref().map_or(v, |l| lut_at(l, v)) - }).collect()) + Some( + (0..n) + .map(|k| { + let v = (k as f32 / (n - 1) as f32 / range).min(1.0); + base.as_ref().map_or(v, |l| lut_at(l, v)) + }) + .collect(), + ) } fn apply_lut(m: Map, l: Option>) -> Map { @@ -444,11 +447,7 @@ pub fn gradient_t(style: GradientStyle, angle: f32, scale: f32, reverse: bool, o /// Samples colour and opacity stops at `t`. pub fn sample_gradient(g: &Gradient, t: f32) -> [f32; 4] { let color = sample_stops(&g.stops.iter().map(|(p, c)| (*p, rgb(c))).collect::>(), t); - let alpha = if g.opacity_stops.is_empty() { - 1.0 - } else { - sample_stops(&g.opacity_stops.iter().map(|(p, a)| (*p, [*a; 3])).collect::>(), t)[0] - }; + let alpha = if g.opacity_stops.is_empty() { 1.0 } else { sample_stops(&g.opacity_stops.iter().map(|(p, a)| (*p, [*a; 3])).collect::>(), t)[0] }; [color[0], color[1], color[2], alpha] } @@ -674,7 +673,8 @@ fn rows_par(src: &[f32], dst: &mut [f32], w: usize, f: impl Fn(&[f32], &mut [f32 if w == 0 { return; } - let threads = if cfg!(target_arch = "wasm32") || src.len() < 1 << 16 { 1 } else { std::thread::available_parallelism().map_or(1, |n| n.get()).clamp(1, 16) }; + let threads = + if cfg!(target_arch = "wasm32") || src.len() < 1 << 16 { 1 } else { std::thread::available_parallelism().map_or(1, |n| n.get()).clamp(1, 16) }; if threads == 1 { dst.chunks_mut(w).zip(src.chunks(w)).for_each(|(d, s)| f(s, d)); return; @@ -1166,11 +1166,7 @@ pub fn composite_with_effects(layer: &Layer, content: &Buffer, backdrop: &mut Bu let d = vdout.as_ref().unwrap_or(&dout); let mut share = vec![0.0f32; w * h]; for (i, sh) in share.iter_mut().enumerate() { - let k = if inside(shape.v[i]) { - if vector_shape { 0.0 } else { 1.0 } - } else { - (out_w + 0.5 - d[i]).clamp(0.0, 1.0) - }; + let k = if inside(shape.v[i]) { if vector_shape { 0.0 } else { 1.0 } } else { (out_w + 0.5 - d[i]).clamp(0.0, 1.0) }; *sh = k * st.common.opacity * (1.0 - cover[i]); cover[i] += *sh; } @@ -1223,11 +1219,7 @@ pub fn composite_with_effects(layer: &Layer, content: &Buffer, backdrop: &mut Bu for x in rect.x0..rect.x1 { let i = ((y - rect.y0) as usize) * rect.width() as usize + (x - rect.x0) as usize; let a = before.px[i]; - let b = if x >= big.x0 && x < big.x1 && y >= big.y0 && y < big.y1 { - work.px[((y - big.y0) as usize) * w + (x - big.x0) as usize] - } else { - a - }; + let b = if x >= big.x0 && x < big.x1 && y >= big.y0 && y < big.y1 { work.px[((y - big.y0) as usize) * w + (x - big.x0) as usize] } else { a }; backdrop.px[i] = mix_premul(a, b, op); } } @@ -1459,7 +1451,11 @@ mod tests { let (a, _) = bevel_maps(&shape, &plain, &l, &no_tex()); // A contour reshapes the profile (here a ramp folded at its middle). let mut c = plain.clone(); - let fold = vec![photocraft_doc::adjust::CurvePoint { input: 0.0, output: 0.0 }, photocraft_doc::adjust::CurvePoint { input: 0.5, output: 1.0 }, photocraft_doc::adjust::CurvePoint { input: 1.0, output: 0.0 }]; + let fold = vec![ + photocraft_doc::adjust::CurvePoint { input: 0.0, output: 0.0 }, + photocraft_doc::adjust::CurvePoint { input: 0.5, output: 1.0 }, + photocraft_doc::adjust::CurvePoint { input: 1.0, output: 0.0 }, + ]; c.contour = Some(photocraft_doc::BevelContour { contour: Contour::Custom { name: "fold".into(), points: fold }, range: 1.0, anti_alias: false }); let (b, _) = bevel_maps(&shape, &c, &l, &no_tex()); let diff: f32 = a[1].v.iter().zip(&b[1].v).map(|(x, y)| (x - y).abs()).sum(); @@ -1470,7 +1466,15 @@ mod tests { sf.fill_rect(Rect::new(0, 2, 4, 4), &[1.0, 1.0, 1.0, 1.0]); let pats = [Pattern::new("stripes", sf, 4, 4)]; let mut t = plain.clone(); - t.texture = Some(photocraft_doc::BevelTexture { name: "stripes".into(), id: String::new(), scale: 1.0, depth: 1.0, invert: false, link: true, phase: (0.0, 0.0) }); + t.texture = Some(photocraft_doc::BevelTexture { + name: "stripes".into(), + id: String::new(), + scale: 1.0, + depth: 1.0, + invert: false, + link: true, + phase: (0.0, 0.0), + }); let tex = TextureCtx { rect: Rect::new(0, 0, 40, 40), patterns: &pats, anchor: (0.0, 0.0) }; let (m, _) = bevel_maps(&shape, &t, &l, &tex); let mid: f32 = (16..24).map(|y| m[0].v[y * 40 + 20] + m[1].v[y * 40 + 20]).sum(); diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 60c190b4c6..d81e868438 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -23,10 +23,10 @@ pub mod psblend; pub mod shape_split; use photocraft_color::blend::BlendMode; -use psblend as blend; use photocraft_doc::{Document, Fill, Layer, LayerContent, Pattern}; use photocraft_geom::Rect; use photocraft_raster::{Rgba8Image, Surface}; +use psblend as blend; /// Straight-alpha RGBA float buffer covering a rectangle. #[derive(Clone, Debug, PartialEq)] @@ -80,7 +80,14 @@ pub fn render(doc: &Document, rect: Rect) -> Buffer { /// [`render`] with an explicit tile size (tests check tile independence). pub fn render_tiled(doc: &Document, rect: Rect, tile: i32) -> Buffer { - let cx = Ctx { canvas: doc.bounds(), transfer: adjust::Transfer::for_mode(doc.mode), light: doc.global_light, patterns: &doc.patterns, mode: doc.mode, depth: doc.depth }; + let cx = Ctx { + canvas: doc.bounds(), + transfer: adjust::Transfer::for_mode(doc.mode), + light: doc.global_light, + patterns: &doc.patterns, + mode: doc.mode, + depth: doc.depth, + }; // Lab documents mix Normal blending in CIELAB, as Photoshop does (psblend::LAB_MIX). let lab = doc.mode == photocraft_color::ColorMode::Lab; if rect.width() as i32 <= tile && rect.height() as i32 <= tile { @@ -134,7 +141,18 @@ pub fn flatten(doc: &Document) -> Buffer { /// Render an arbitrary subset: a single layer (e.g. for thumbnails), isolated. pub fn render_layer(layer: &Layer, rect: Rect) -> Buffer { let mut buf = Buffer::transparent(rect); - composite_stack(std::slice::from_ref(layer), &mut buf, &Ctx { canvas: rect, transfer: adjust::Transfer::Srgb, light: photocraft_doc::GlobalLight::default(), patterns: &[], mode: photocraft_color::ColorMode::Rgb, depth: photocraft_color::SampleType::F32 }); + composite_stack( + std::slice::from_ref(layer), + &mut buf, + &Ctx { + canvas: rect, + transfer: adjust::Transfer::Srgb, + light: photocraft_doc::GlobalLight::default(), + patterns: &[], + mode: photocraft_color::ColorMode::Rgb, + depth: photocraft_color::SampleType::F32, + }, + ); buf } @@ -251,7 +269,13 @@ pub fn layer_bounds(layer: &Layer, canvas: Rect) -> Rect { LayerContent::Group(g) if g.artboard.is_some() => g.artboard.as_ref().map_or(Rect::EMPTY, |a| a.rect), LayerContent::Group(g) => g.children.iter().filter(|c| c.visible).fold(Rect::EMPTY, |acc, c| { let b = layer_bounds(c, canvas); - if b.is_empty() { acc } else if acc.is_empty() { b } else { acc.union(&b) } + if b.is_empty() { + acc + } else if acc.is_empty() { + b + } else { + acc.union(&b) + } }), LayerContent::Fill(_) => canvas, _ => layer.surface().map_or(Rect::EMPTY, bounds::content_bounds), @@ -320,7 +344,14 @@ fn render_content(layer: &Layer, rect: Rect, cx: &Ctx) -> Option { /// The alpha of `layer`'s own content over `rect` (masks applied, row-major): the shape its /// effect maps are built from (for the GPU compositor). Zero for adjustment layers. pub fn layer_shape(doc: &Document, layer: &Layer, rect: Rect) -> Vec { - let cx = Ctx { canvas: doc.bounds(), transfer: adjust::Transfer::for_mode(doc.mode), light: doc.global_light, patterns: &doc.patterns, mode: doc.mode, depth: doc.depth }; + let cx = Ctx { + canvas: doc.bounds(), + transfer: adjust::Transfer::for_mode(doc.mode), + light: doc.global_light, + patterns: &doc.patterns, + mode: doc.mode, + depth: doc.depth, + }; render_content(layer, rect, &cx).map(|b| b.px.iter().map(|p| p[3]).collect()).unwrap_or_else(|| vec![0.0; rect.width() as usize * rect.height() as usize]) } @@ -482,12 +513,7 @@ pub fn blend_if_active(layer: &Layer, mode: photocraft_color::ColorMode) -> bool /// How much of a pixel shows through `layer`'s Blend If ranges, given the layer's own colour /// (`this`, `None` where the layer has no content of its own there) and the colour beneath it /// (`under`, `None` where nothing is beneath). Every range multiplies in. -fn blend_if_weight( - layer: &Layer, - mode: photocraft_color::ColorMode, - this: Option<[f32; 4]>, - under: Option<[f32; 4]>, -) -> f32 { +fn blend_if_weight(layer: &Layer, mode: photocraft_color::ColorMode, this: Option<[f32; 4]>, under: Option<[f32; 4]>) -> f32 { use photocraft_color::ColorMode as M; let bi = &layer.blend_if; let mut k = 1.0; @@ -521,11 +547,7 @@ fn blend_if_weight( fn apply_blend_if(layer: &Layer, before: &Buffer, out: &mut Buffer, cx: &Ctx) { // "This Layer" is the layer's own colour; adjustment layers (no content of their own) are // judged by their result. - let own = if matches!(layer.content, LayerContent::Adjustment(_)) { - None - } else { - render_content(layer, out.rect, cx) - }; + let own = if matches!(layer.content, LayerContent::Adjustment(_)) { None } else { render_content(layer, out.rect, cx) }; for (i, (p, b)) in out.px.iter_mut().zip(&before.px).enumerate() { let this = match &own { Some(o) => Some(o.px[i]).filter(|q| q[3] > 0.0), @@ -538,8 +560,7 @@ fn apply_blend_if(layer: &Layer, before: &Buffer, out: &mut Buffer, cx: &Ctx) { } let a = b[3] + (p[3] - b[3]) * k; *p = if a > 0.0 { - let c = - |c: usize| ((b[c] * b[3] + (p[c] * p[3] - b[c] * b[3]) * k) / a).clamp(0.0, 1.0); + let c = |c: usize| ((b[c] * b[3] + (p[c] * p[3] - b[c] * b[3]) * k) / a).clamp(0.0, 1.0); [c(0), c(1), c(2), a] } else { [0.0; 4] @@ -674,12 +695,7 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer let b = before.px[i]; let a = adjusted.px[i]; let blended = blend::blend_rgb(layer.blend, [b[0], b[1], b[2]], [a[0], a[1], a[2]]); - backdrop.px[i] = [ - b[0] + (blended[0] - b[0]) * k, - b[1] + (blended[1] - b[1]) * k, - b[2] + (blended[2] - b[2]) * k, - b[3], - ]; + backdrop.px[i] = [b[0] + (blended[0] - b[0]) * k, b[1] + (blended[1] - b[1]) * k, b[2] + (blended[2] - b[2]) * k, b[3]]; } } return; @@ -859,7 +875,9 @@ fn effect_maps(layer: &Layer, cx: &Ctx) -> std::sync::Arc { let shape: Vec = if region.is_empty() { Vec::new() } else { - render_content(layer, region, cx).map(|b| b.px.iter().map(|p| p[3]).collect()).unwrap_or_else(|| vec![0.0; region.width() as usize * region.height() as usize]) + render_content(layer, region, cx) + .map(|b| b.px.iter().map(|p| p[3]).collect()) + .unwrap_or_else(|| vec![0.0; region.width() as usize * region.height() as usize]) }; let maps = effects::build_maps(layer, shape, region, &cx.light, &texture_ctx(layer, region, cx)); let bytes = maps.bytes(); @@ -929,7 +947,14 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) { let Some(content) = render_content(layer, big, cx) else { return }; let mut opaque = Buffer { rect, px: base.px.iter().map(|p| [p[0], p[1], p[2], 1.0]).collect() }; let maps = effect_maps(layer, cx); - effects::composite_with_effects(layer, &content, &mut opaque, &maps, paint_bounds(layer).unwrap_or_else(|| layer_bounds(layer, cx.canvas)), cx.patterns); + effects::composite_with_effects( + layer, + &content, + &mut opaque, + &maps, + paint_bounds(layer).unwrap_or_else(|| layer_bounds(layer, cx.canvas)), + cx.patterns, + ); for (p, o) in base.px.iter_mut().zip(&opaque.px) { if p[3] > 0.0 { *p = [o[0], o[1], o[2], p[3]]; diff --git a/crates/compose/src/masks.rs b/crates/compose/src/masks.rs index ea1e1ef963..97769e5455 100644 --- a/crates/compose/src/masks.rs +++ b/crates/compose/src/masks.rs @@ -76,7 +76,13 @@ pub fn combined_mask(layer: &Layer, canvas: Rect) -> Option { && v_out > 0.0 { let b = m.surface.content_bounds(); - area = if area.is_empty() { b } else if b.is_empty() { area } else { area.union(&b) }; + area = if area.is_empty() { + b + } else if b.is_empty() { + area + } else { + area.union(&b) + }; } let area = area.intersect(&canvas); let mut s = Surface::with_default(FORMAT, &[p_def * v_out]); diff --git a/crates/compose/src/psblend.rs b/crates/compose/src/psblend.rs index 50c20d92ca..3b8448af42 100644 --- a/crates/compose/src/psblend.rs +++ b/crates/compose/src/psblend.rs @@ -114,12 +114,7 @@ pub fn composite(mode: BlendMode, backdrop: [f32; 4], source: [f32; 4], opacity: } let kb = ab * (1.0 - as_); let inv = 1.0 / ao; - return [ - (kb * backdrop[0] + as_ * source[0]) * inv, - (kb * backdrop[1] + as_ * source[1]) * inv, - (kb * backdrop[2] + as_ * source[2]) * inv, - ao, - ]; + return [(kb * backdrop[0] + as_ * source[0]) * inv, (kb * backdrop[1] + as_ * source[1]) * inv, (kb * backdrop[2] + as_ * source[2]) * inv, ao]; } let cb = [backdrop[0], backdrop[1], backdrop[2]]; let cs = [source[0], source[1], source[2]]; @@ -191,7 +186,10 @@ mod tests { // 69 % blue (0, 51, 153) over grey 129: Photoshop shows (57, 80, 146). let r = composite_gamma(BlendMode::Normal, [129.0 / 255.0, 129.0 / 255.0, 129.0 / 255.0, 1.0], [0.0, 0.2, 0.6, 0.686], 1.0, TEXT_GAMMA); assert!((r[0] * 255.0 - 57.0).abs() < 2.0 && (r[1] * 255.0 - 80.0).abs() < 2.0 && (r[2] * 255.0 - 146.0).abs() < 2.0, "{r:?}"); - assert_eq!(composite_gamma(BlendMode::Multiply, [0.2, 0.4, 0.6, 0.7], [0.5, 0.1, 0.9, 0.5], 0.8, 1.0), composite(BlendMode::Multiply, [0.2, 0.4, 0.6, 0.7], [0.5, 0.1, 0.9, 0.5], 0.8)); + assert_eq!( + composite_gamma(BlendMode::Multiply, [0.2, 0.4, 0.6, 0.7], [0.5, 0.1, 0.9, 0.5], 0.8, 1.0), + composite(BlendMode::Multiply, [0.2, 0.4, 0.6, 0.7], [0.5, 0.1, 0.9, 0.5], 0.8) + ); } use super::*; @@ -228,7 +226,8 @@ mod tests { } let (cb, cs) = ([0.2, 0.5, 0.9], [0.7, 0.1, 0.4]); assert_eq!(blend_rgb(m, cb, cs), generic::blend_rgb(m, cb, cs)); - let (a, b) = (composite(m, [0.2, 0.5, 0.9, 0.7], [0.7, 0.1, 0.4, 0.6], 0.8), generic::composite(m, [0.2, 0.5, 0.9, 0.7], [0.7, 0.1, 0.4, 0.6], 0.8)); + let (a, b) = + (composite(m, [0.2, 0.5, 0.9, 0.7], [0.7, 0.1, 0.4, 0.6], 0.8), generic::composite(m, [0.2, 0.5, 0.9, 0.7], [0.7, 0.1, 0.4, 0.6], 0.8)); for c in 0..4 { assert!((a[c] - b[c]).abs() < 1e-6, "{m:?} {a:?} {b:?}"); } diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 1614cac417..7bf114635b 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -260,7 +260,13 @@ fn solid_and_gradient_fill_layers() { d.layers.push(Layer::new("fill", LayerContent::Fill(Fill::Solid(Color::rgb(0.0, 0.0, 1.0))))); assert!(close4(px(&d, 5, 0), [0.0, 0.0, 1.0, 1.0])); - let g = Fill::Gradient { stops: vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], angle: 0.0, scale: 1.0, style: photocraft_doc::GradientStyle::Linear, reverse: false }; + let g = Fill::Gradient { + stops: vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], + angle: 0.0, + scale: 1.0, + style: photocraft_doc::GradientStyle::Linear, + reverse: false, + }; let buf = render_fill(&g, Rect::new(0, 0, 10, 1), Rect::new(0, 0, 10, 1), &[]); // tile independence: a 1px render of the right edge equals the full render let one = render_fill(&g, Rect::new(9, 0, 10, 1), Rect::new(0, 0, 10, 1), &[]); @@ -353,7 +359,12 @@ fn fx_doc(effects: Vec) -> Document { } fn stroke(size: f32, position: StrokePosition) -> Effect { - Effect::Stroke(StrokeFx { common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), size, position, paint: FxPaint::Color(Color::rgb(0.0, 0.0, 1.0)) }) + Effect::Stroke(StrokeFx { + common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), + size, + position, + paint: FxPaint::Color(Color::rgb(0.0, 0.0, 1.0)), + }) } #[test] @@ -516,25 +527,28 @@ fn satin_and_bevel_stay_inside_shape() { anti_alias: true, invert: false, }); - let d = fx_doc(vec![satin, Effect::BevelEmboss(photocraft_doc::Bevel { - enabled: true, - style: photocraft_doc::BevelStyle::InnerBevel, - technique: photocraft_doc::BevelTechnique::Smooth, - depth: 1.0, - up: true, - size: 4.0, - soften: 0.0, - angle: 90.0, - altitude: 30.0, - use_global_light: false, - gloss_contour: photocraft_doc::Contour::Linear, - highlight: FxCommon::new(photocraft_color::BlendMode::Screen, 0.75), - highlight_color: Color::WHITE, - shadow: FxCommon::new(photocraft_color::BlendMode::Multiply, 0.75), - shadow_color: Color::BLACK, - contour: None, - texture: None, - })]); + let d = fx_doc(vec![ + satin, + Effect::BevelEmboss(photocraft_doc::Bevel { + enabled: true, + style: photocraft_doc::BevelStyle::InnerBevel, + technique: photocraft_doc::BevelTechnique::Smooth, + depth: 1.0, + up: true, + size: 4.0, + soften: 0.0, + angle: 90.0, + altitude: 30.0, + use_global_light: false, + gloss_contour: photocraft_doc::Contour::Linear, + highlight: FxCommon::new(photocraft_color::BlendMode::Screen, 0.75), + highlight_color: Color::WHITE, + shadow: FxCommon::new(photocraft_color::BlendMode::Multiply, 0.75), + shadow_color: Color::BLACK, + contour: None, + texture: None, + }), + ]); for (x, y) in [(5, 5), (35, 20), (20, 35)] { assert!(close4(px(&d, x, y), [1.0; 4]), "({x},{y}) {:?}", px(&d, x, y)); } @@ -558,7 +572,10 @@ fn clipped_layer_effects_are_clipped_to_base() { #[test] fn group_effects_apply_to_group_shape() { let mut d = doc_white(40, 40); - let mut g = Layer::group("g", vec![solid_layer("a", Rect::new(10, 10, 20, 30), [1.0, 0.0, 0.0, 1.0]), solid_layer("b", Rect::new(20, 10, 30, 30), [1.0, 0.0, 0.0, 1.0])]); + let mut g = Layer::group( + "g", + vec![solid_layer("a", Rect::new(10, 10, 20, 30), [1.0, 0.0, 0.0, 1.0]), solid_layer("b", Rect::new(20, 10, 30, 30), [1.0, 0.0, 0.0, 1.0])], + ); g.effects.items.push(stroke(2.0, StrokePosition::Outside)); d.layers.push(g); assert!(close4(px(&d, 9, 20), [0.0, 0.0, 1.0, 1.0])); @@ -787,28 +804,16 @@ const FULL: photocraft_doc::BlendRange = photocraft_doc::BlendRange::FULL; fn blend_if_this_layer_hides_by_the_layers_own_value() { // Mid-grey backdrop; the layer is black on the left, white on the right. let mut d = doc_white(8, 8); - d.layers.push(solid_layer( - "grey", - Rect::new(0, 0, 8, 8), - [0.5, 0.5, 0.5, 1.0], - )); + d.layers.push(solid_layer("grey", Rect::new(0, 0, 8, 8), [0.5, 0.5, 0.5, 1.0])); let mut l = solid_layer("bw", Rect::new(0, 0, 4, 8), [0.0, 0.0, 0.0, 1.0]); - l.surface_mut() - .unwrap() - .fill_rect(Rect::new(4, 0, 8, 8), &[1.0, 1.0, 1.0, 1.0]); + l.surface_mut().unwrap().fill_rect(Rect::new(4, 0, 8, 8), &[1.0, 1.0, 1.0, 1.0]); // Gray › This Layer: black point at 50 hides the blacks. l.blend_if.set(0, [range([50, 50], [255, 255]), FULL]); d.layers.push(l); - assert!( - close4(px(&d, 1, 1), [0.5, 0.5, 0.5, 1.0]), - "{:?}", - px(&d, 1, 1) - ); + assert!(close4(px(&d, 1, 1), [0.5, 0.5, 0.5, 1.0]), "{:?}", px(&d, 1, 1)); assert!(close4(px(&d, 6, 1), [1.0, 1.0, 1.0, 1.0])); // White point at 200 hides the whites as well. - d.layers[2] - .blend_if - .set(0, [range([50, 50], [200, 200]), FULL]); + d.layers[2].blend_if.set(0, [range([50, 50], [200, 200]), FULL]); assert!(close4(px(&d, 6, 1), [0.5, 0.5, 0.5, 1.0])); // Back to the defaults: everything shows again. d.layers[2].blend_if.set(0, [FULL, FULL]); @@ -820,11 +825,7 @@ fn blend_if_this_layer_hides_by_the_layers_own_value() { fn blend_if_underlying_layer_hides_by_the_backdrop_value() { // Backdrop black on the left, white on the right; a red layer over all of it. let mut d = doc_white(8, 8); - d.layers.push(solid_layer( - "black", - Rect::new(0, 0, 4, 8), - [0.0, 0.0, 0.0, 1.0], - )); + d.layers.push(solid_layer("black", Rect::new(0, 0, 4, 8), [0.0, 0.0, 0.0, 1.0])); let mut l = solid_layer("red", Rect::new(0, 0, 8, 8), [1.0, 0.0, 0.0, 1.0]); // Gray › Underlying Layer: white point at 128 = only over the darks (sky-replacement style). l.blend_if.set(0, [FULL, range([0, 0], [128, 128])]); @@ -839,10 +840,7 @@ fn blend_if_split_point_fades_like_opacity() { // is exactly the layer at 50% opacity, also over a semi-transparent backdrop. let v = 100.0 / 255.0; let mut d = doc_white(4, 4); - d.layers[0] - .surface_mut() - .unwrap() - .fill_rect(Rect::new(0, 0, 4, 4), &[0.0, 0.2, 1.0, 0.5]); + d.layers[0].surface_mut().unwrap().fill_rect(Rect::new(0, 0, 4, 4), &[0.0, 0.2, 1.0, 0.5]); let mut l = solid_layer("v", Rect::new(0, 0, 4, 4), [v, v, v, 1.0]); l.blend = BlendMode::Multiply; let mut half = l.clone(); @@ -852,10 +850,7 @@ fn blend_if_split_point_fades_like_opacity() { d.layers.push(l); d2.layers.push(half); let (a, b) = (px(&d, 1, 1), px(&d2, 1, 1)); - assert!( - a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-4), - "{a:?} vs {b:?}" - ); + assert!(a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-4), "{a:?} vs {b:?}"); } #[test] @@ -863,29 +858,21 @@ fn blend_if_per_channel_ranges() { // Blue › This Layer: hide pixels whose blue is above 100. let mut d = doc_white(8, 8); let mut l = solid_layer("c", Rect::new(0, 0, 4, 8), [1.0, 0.0, 0.0, 1.0]); - l.surface_mut() - .unwrap() - .fill_rect(Rect::new(4, 0, 8, 8), &[0.0, 0.0, 1.0, 1.0]); + l.surface_mut().unwrap().fill_rect(Rect::new(4, 0, 8, 8), &[0.0, 0.0, 1.0, 1.0]); l.blend_if.set(3, [range([0, 0], [100, 100]), FULL]); d.layers.push(l); assert!(close4(px(&d, 1, 1), [1.0, 0.0, 0.0, 1.0])); assert!(close4(px(&d, 6, 1), [1.0, 1.0, 1.0, 1.0])); // Red › Underlying Layer: the backdrop's red (255) is above 254 → hidden everywhere. d.layers[1].blend_if = Default::default(); - d.layers[1] - .blend_if - .set(1, [FULL, range([0, 0], [254, 254])]); + d.layers[1].blend_if.set(1, [FULL, range([0, 0], [254, 254])]); assert!(close4(px(&d, 1, 1), [1.0; 4])); } #[test] fn blend_if_on_adjustment_and_clipped_layers() { let mut d = doc_white(8, 8); - d.layers.push(solid_layer( - "dark", - Rect::new(0, 0, 4, 8), - [0.1, 0.1, 0.1, 1.0], - )); + d.layers.push(solid_layer("dark", Rect::new(0, 0, 4, 8), [0.1, 0.1, 0.1, 1.0])); // Invert, but only where the backdrop is dark (Underlying white point at 128). let mut inv = Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert)); inv.blend_if.set(0, [FULL, range([0, 0], [128, 128])]); @@ -895,9 +882,7 @@ fn blend_if_on_adjustment_and_clipped_layers() { // A clipped layer judges "underlying" by its clipping base. let mut d = doc_white(8, 8); let mut base = solid_layer("base", Rect::new(0, 0, 8, 8), [0.0, 0.0, 0.0, 1.0]); - base.surface_mut() - .unwrap() - .fill_rect(Rect::new(4, 0, 8, 8), &[0.9, 0.9, 0.9, 1.0]); + base.surface_mut().unwrap().fill_rect(Rect::new(4, 0, 8, 8), &[0.9, 0.9, 0.9, 1.0]); let mut clip = solid_layer("clip", Rect::new(0, 0, 8, 8), [0.0, 1.0, 0.0, 1.0]); clip.clipped = true; clip.blend_if.set(0, [FULL, range([128, 128], [255, 255])]); @@ -910,17 +895,9 @@ fn blend_if_on_adjustment_and_clipped_layers() { #[test] fn blend_if_modes() { // Grayscale documents: the single channel's entry (or the gray entry) applies. - let mut d = Document::with_background( - "g", - Size::new(4, 4), - ColorMode::Grayscale, - SampleType::U8, - Color::WHITE, - ); + let mut d = Document::with_background("g", Size::new(4, 4), ColorMode::Grayscale, SampleType::U8, Color::WHITE); let mut l = Layer::raster("k", d.pixel_format()); - l.surface_mut() - .unwrap() - .fill_rect(Rect::new(0, 0, 4, 4), &[0.0, 1.0]); + l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 4, 4), &[0.0, 1.0]); l.blend_if.set(1, [range([10, 10], [255, 255]), FULL]); d.layers.push(l); assert!(close4(px(&d, 1, 1), [1.0; 4]), "{:?}", px(&d, 1, 1)); @@ -984,10 +961,7 @@ fn modern_contrast_matches_photoshop() { #[test] fn modern_brightness_matches_photoshop() { - let cases: [(f32, [u8; 10]); 2] = [ - (50.0, [0, 22, 44, 88, 132, 171, 203, 228, 246, 255]), - (-50.0, [0, 12, 23, 47, 70, 93, 118, 148, 186, 255]), - ]; + let cases: [(f32, [u8; 10]); 2] = [(50.0, [0, 22, 44, 88, 132, 171, 203, 228, 246, 255]), (-50.0, [0, 12, 23, 47, 70, 93, 118, 148, 186, 255])]; let xs = [0usize, 16, 32, 64, 96, 128, 160, 192, 224, 255]; for (b, out) in cases { for (i, &x) in xs.iter().enumerate() { diff --git a/crates/doc/src/adjust.rs b/crates/doc/src/adjust.rs index cd8cc9b4e6..f3e6c45feb 100644 --- a/crates/doc/src/adjust.rs +++ b/crates/doc/src/adjust.rs @@ -27,18 +27,55 @@ impl Default for LevelsChannel { /// Photoshop adjustment layers (Layer → New Adjustment Layer). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub enum Adjustment { - BrightnessContrast { brightness: f32, contrast: f32, legacy: bool }, + BrightnessContrast { + brightness: f32, + contrast: f32, + legacy: bool, + }, /// Composite channel first, then R, G, B. - Levels { master: LevelsChannel, per_channel: [LevelsChannel; 3] }, + Levels { + master: LevelsChannel, + per_channel: [LevelsChannel; 3], + }, /// Master curve then R, G, B curves. - Curves { master: Vec, per_channel: [Vec; 3] }, - Exposure { exposure: f32, offset: f32, gamma: f32 }, - Vibrance { vibrance: f32, saturation: f32 }, - HueSaturation { hue: f32, saturation: f32, lightness: f32, colorize: bool }, - ColorBalance { shadows: [f32; 3], midtones: [f32; 3], highlights: [f32; 3], preserve_luminosity: bool }, - BlackWhite { weights: [f32; 6], tint: Option<[f32; 3]> }, - PhotoFilter { color: [f32; 3], density: f32, preserve_luminosity: bool }, - ChannelMixer { matrix: [[f32; 4]; 3], monochrome: bool }, + Curves { + master: Vec, + per_channel: [Vec; 3], + }, + Exposure { + exposure: f32, + offset: f32, + gamma: f32, + }, + Vibrance { + vibrance: f32, + saturation: f32, + }, + HueSaturation { + hue: f32, + saturation: f32, + lightness: f32, + colorize: bool, + }, + ColorBalance { + shadows: [f32; 3], + midtones: [f32; 3], + highlights: [f32; 3], + preserve_luminosity: bool, + }, + BlackWhite { + weights: [f32; 6], + tint: Option<[f32; 3]>, + }, + PhotoFilter { + color: [f32; 3], + density: f32, + preserve_luminosity: bool, + }, + ChannelMixer { + matrix: [[f32; 4]; 3], + monochrome: bool, + }, /// A 3D LUT: `size`³ RGB triplets, red varying fastest (`((b·size + g)·size + r)·3`), in 0..=1. /// `lut` is None for a lookup Photoshop stores as an ICC profile (identity here). ColorLookup { @@ -53,14 +90,27 @@ pub enum Adjustment { dither: bool, }, Invert, - Posterize { levels: u32 }, - Threshold { level: f32 }, - GradientMap { stops: Vec<(f32, [f32; 3])>, reverse: bool }, + Posterize { + levels: u32, + }, + Threshold { + level: f32, + }, + GradientMap { + stops: Vec<(f32, [f32; 3])>, + reverse: bool, + }, /// Per range (reds, yellows, greens, cyans, blues, magentas, whites, neutrals, blacks) the /// cyan, magenta, yellow and black change in percent (-100..=100), as in Photoshop. - SelectiveColor { relative: bool, adjustments: [[f32; 4]; 9] }, + SelectiveColor { + relative: bool, + adjustments: [[f32; 4]; 9], + }, /// A PSD adjustment we can't evaluate yet; preserved raw for round-trip. - Unsupported { psd_key: String, raw: Vec }, + Unsupported { + psd_key: String, + raw: Vec, + }, } impl Adjustment { diff --git a/crates/doc/src/analysis.rs b/crates/doc/src/analysis.rs index 51845a6a71..840bf54990 100644 --- a/crates/doc/src/analysis.rs +++ b/crates/doc/src/analysis.rs @@ -108,7 +108,15 @@ pub struct Note { impl Default for Note { fn default() -> Self { // Photoshop's default note colour (pale yellow). - Self { author: String::new(), color: Color::rgb(1.0, 1.0, 0.51), text: String::new(), position: [0.0, 0.0], popup: [0.0, 0.0, 0.0, 0.0], open: false, modified: String::new() } + Self { + author: String::new(), + color: Color::rgb(1.0, 1.0, 0.51), + text: String::new(), + position: [0.0, 0.0], + popup: [0.0, 0.0, 0.0, 0.0], + open: false, + modified: String::new(), + } } } diff --git a/crates/doc/src/blend_if.rs b/crates/doc/src/blend_if.rs index 245b2aacb0..0c6ee40ba9 100644 --- a/crates/doc/src/blend_if.rs +++ b/crates/doc/src/blend_if.rs @@ -23,10 +23,7 @@ impl Default for BlendRange { impl BlendRange { /// Every value blends (the sliders at the ends). - pub const FULL: BlendRange = BlendRange { - black: [0, 0], - white: [255, 255], - }; + pub const FULL: BlendRange = BlendRange { black: [0, 0], white: [255, 255] }; /// Whether this range hides nothing. pub fn is_full(&self) -> bool { @@ -35,10 +32,7 @@ impl BlendRange { /// From PSD's byte order: black low, black high, white low, white high. pub fn from_bytes(b: [u8; 4]) -> Self { - BlendRange { - black: [b[0], b[1]], - white: [b[2], b[3]], - } + BlendRange { black: [b[0], b[1]], white: [b[2], b[3]] } } /// PSD's byte order (see [`BlendRange::from_bytes`]). @@ -54,11 +48,7 @@ impl BlendRange { let ramp = |lo: u8, hi: u8, x: f32| -> f32 { // 0 at or below `lo`, 1 at or above `hi`. let (lo, hi) = (f32::from(lo), f32::from(hi)); - if hi <= lo { - if x >= lo { 1.0 } else { 0.0 } - } else { - ((x - lo) / (hi - lo)).clamp(0.0, 1.0) - } + if hi <= lo { if x >= lo { 1.0 } else { 0.0 } } else { ((x - lo) / (hi - lo)).clamp(0.0, 1.0) } }; let lo = ramp(self.black[0], self.black[1], v); // Mirror for the white point: 1 at or below its low half, 0 above its high half. @@ -93,11 +83,7 @@ impl BlendIf { self.ranges.resize(i + 1, [BlendRange::FULL; 2]); } self.ranges[i] = pair; - while self - .ranges - .last() - .is_some_and(|p| p.iter().all(BlendRange::is_full)) - { + while self.ranges.last().is_some_and(|p| p.iter().all(BlendRange::is_full)) { self.ranges.pop(); } } @@ -118,10 +104,7 @@ mod tests { #[test] fn unsplit_points_are_hard_thresholds() { - let r = BlendRange { - black: [50, 50], - white: [200, 200], - }; + let r = BlendRange { black: [50, 50], white: [200, 200] }; assert_eq!(r.weight(49.0), 0.0); assert_eq!(r.weight(49.9), 0.0); assert_eq!(r.weight(50.0), 1.0); @@ -132,10 +115,7 @@ mod tests { #[test] fn split_points_ramp_linearly() { - let r = BlendRange { - black: [0, 100], - white: [155, 255], - }; + let r = BlendRange { black: [0, 100], white: [155, 255] }; // A split black point fades from its low half (hidden) to its high half (shown). assert_eq!(r.weight(0.0), 0.0); assert!((r.weight(50.0) - 0.5).abs() < 1e-6); @@ -148,10 +128,7 @@ mod tests { #[test] fn bytes_round_trip_in_psd_order() { - let r = BlendRange { - black: [10, 20], - white: [230, 240], - }; + let r = BlendRange { black: [10, 20], white: [230, 240] }; assert_eq!(r.to_bytes(), [10, 20, 230, 240]); assert_eq!(BlendRange::from_bytes(r.to_bytes()), r); } @@ -160,10 +137,7 @@ mod tests { fn set_trims_trailing_full_entries() { let mut b = BlendIf::default(); assert!(b.is_default()); - let r = BlendRange { - black: [30, 30], - white: [255, 255], - }; + let r = BlendRange { black: [30, 30], white: [255, 255] }; b.set(2, [BlendRange::FULL, r]); assert_eq!(b.ranges.len(), 3); assert_eq!(b.get(2), [BlendRange::FULL, r]); diff --git a/crates/doc/src/comps.rs b/crates/doc/src/comps.rs index 347139c389..796baa7d43 100644 --- a/crates/doc/src/comps.rs +++ b/crates/doc/src/comps.rs @@ -232,7 +232,15 @@ mod tests { #[test] fn captured_info_bits() { - let mut c = LayerComp { id: 1, name: "c".into(), comment: String::new(), apply_visibility: false, apply_position: false, apply_appearance: false, states: vec![] }; + let mut c = LayerComp { + id: 1, + name: "c".into(), + comment: String::new(), + apply_visibility: false, + apply_position: false, + apply_appearance: false, + states: vec![], + }; c.set_captured_info(5); assert!(c.apply_visibility && !c.apply_position && c.apply_appearance); assert_eq!(c.captured_info(), 5); @@ -242,7 +250,15 @@ mod tests { fn missing_layers_and_ids() { let mut d = doc(); let states = capture_states(&d); - d.layer_comps.push(LayerComp { id: next_comp_id(&d), name: "c".into(), comment: String::new(), apply_visibility: true, apply_position: true, apply_appearance: true, states }); + d.layer_comps.push(LayerComp { + id: next_comp_id(&d), + name: "c".into(), + comment: String::new(), + apply_visibility: true, + apply_position: true, + apply_appearance: true, + states, + }); assert_eq!(d.layer_comps[0].id, 1); assert_eq!(next_comp_id(&d), 2); let gone = d.layers[1].id; diff --git a/crates/doc/src/effects.rs b/crates/doc/src/effects.rs index 73c4d97ded..9fa3302f4e 100644 --- a/crates/doc/src/effects.rs +++ b/crates/doc/src/effects.rs @@ -67,7 +67,11 @@ pub enum FxPaint { Color(Color), Gradient(Gradient), /// Pattern by name/id (rendered once pattern data is available). - Pattern { name: String, id: String, scale: f32 }, + Pattern { + name: String, + id: String, + scale: f32, + }, } /// Blend mode + opacity + enabled, shared by every effect. @@ -243,8 +247,15 @@ pub enum Effect { OuterGlow(Glow), InnerGlow(Glow), Stroke(StrokeFx), - ColorOverlay { common: FxCommon, color: Color }, - GradientOverlay { common: FxCommon, gradient: Gradient, dither: bool }, + ColorOverlay { + common: FxCommon, + color: Color, + }, + GradientOverlay { + common: FxCommon, + gradient: Gradient, + dither: bool, + }, PatternOverlay { common: FxCommon, name: String, @@ -271,9 +282,7 @@ impl Effect { Effect::DropShadow(s) | Effect::InnerShadow(s) => s.common.enabled, Effect::OuterGlow(g) | Effect::InnerGlow(g) => g.common.enabled, Effect::Stroke(s) => s.common.enabled, - Effect::ColorOverlay { common, .. } | Effect::GradientOverlay { common, .. } | Effect::PatternOverlay { common, .. } => { - common.enabled - } + Effect::ColorOverlay { common, .. } | Effect::GradientOverlay { common, .. } | Effect::PatternOverlay { common, .. } => common.enabled, Effect::Satin(s) => s.common.enabled, Effect::BevelEmboss(b) => b.enabled, } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 8fea839b79..875238d4e6 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -15,11 +15,11 @@ pub mod effects; pub mod mode; pub mod pattern; pub mod slices; -pub mod variables; -pub mod video; pub mod text; pub mod text_styles; +pub mod variables; pub mod vector; +pub mod video; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; @@ -32,20 +32,19 @@ pub use effects::{ Bevel, BevelContour, BevelStyle, BevelTechnique, BevelTexture, Contour, Effect, FxCommon, FxPaint, GlobalLight, Glow, GlowSource, GlowTechnique, Gradient, GradientStyle, Satin, Shadow, StrokeFx, StrokePosition, }; +pub use mode::{ColorTable, Duotone, DuotoneInk, StackMode}; +pub use pattern::Pattern; pub use photocraft_color::{BlendMode, Color, ColorMode, PixelFormat, SampleType}; pub use photocraft_geom::{Affine, Rect, Size}; pub use photocraft_raster::Surface; -pub use mode::{ColorTable, Duotone, DuotoneInk, StackMode}; -pub use pattern::Pattern; -pub use video::{Timeline, VideoData, VideoSource}; -pub use variables::{DataSet, DataValue, PixelAlign, PixelMethod, VarKind, VariableDef, Variables}; +use serde::{Deserialize, Serialize}; pub use slices::{Slice, SliceKind, SliceOrigin, Slices}; pub use text_styles::TextStyles; +pub use variables::{DataSet, DataValue, PixelAlign, PixelMethod, VarKind, VariableDef, Variables}; pub use vector::{ - ClippingPath, FillRule, Knot, LineCap, LineJoin, LiveShape, NamedPath, Path, PathOp, ShapeLayer, ShapeStroke, StrokeAlign, Subpath, - VectorMask, + ClippingPath, FillRule, Knot, LineCap, LineJoin, LiveShape, NamedPath, Path, PathOp, ShapeLayer, ShapeStroke, StrokeAlign, Subpath, VectorMask, }; -use serde::{Deserialize, Serialize}; +pub use video::{Timeline, VideoData, VideoSource}; static NEXT_ID: AtomicU64 = AtomicU64::new(1); @@ -175,7 +174,13 @@ pub struct Group { pub enum Fill { Solid(Color), /// Gradient between stops at `angle` degrees (`style` geometry, optionally reversed). - Gradient { stops: Vec<(f32, Color)>, angle: f32, scale: f32, style: GradientStyle, reverse: bool }, + Gradient { + stops: Vec<(f32, Color)>, + angle: f32, + scale: f32, + style: GradientStyle, + reverse: bool, + }, /// A pattern from [`Document::patterns`] (looked up by `id`, then `name`). Pattern { name: String, diff --git a/crates/doc/src/mode.rs b/crates/doc/src/mode.rs index 56b1064960..571fd8eaf2 100644 --- a/crates/doc/src/mode.rs +++ b/crates/doc/src/mode.rs @@ -22,9 +22,7 @@ impl ColorTable { pub fn nearest(&self, c: [f32; 3]) -> usize { let mut best = (f32::MAX, 0); for (i, e) in self.colors.iter().enumerate() { - let d: f32 = (0..3) - .map(|k| (c[k] - f32::from(e[k]) / 255.0).powi(2)) - .sum(); + let d: f32 = (0..3).map(|k| (c[k] - f32::from(e[k]) / 255.0).powi(2)).sum(); if d < best.0 { best = (d, i); } @@ -34,9 +32,7 @@ impl ColorTable { /// Entry `i` as RGB in 0..=1 (black past the end). pub fn rgb(&self, i: usize) -> [f32; 3] { - self.colors - .get(i) - .map_or([0.0; 3], |e| e.map(|v| f32::from(v) / 255.0)) + self.colors.get(i).map_or([0.0; 3], |e| e.map(|v| f32::from(v) / 255.0)) } } @@ -52,20 +48,7 @@ pub struct DuotoneInk { impl DuotoneInk { pub fn new(name: impl Into, color: [f32; 3]) -> Self { - DuotoneInk { - name: name.into(), - color, - curve: vec![ - CurvePoint { - input: 0.0, - output: 0.0, - }, - CurvePoint { - input: 1.0, - output: 1.0, - }, - ], - } + DuotoneInk { name: name.into(), color, curve: vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 1.0 }] } } } @@ -109,10 +92,7 @@ impl Duotone { let t = i as f32 / 32.0; (t, self.render(t)) }); - crate::Adjustment::GradientMap { - stops: stops.collect(), - reverse: false, - } + crate::Adjustment::GradientMap { stops: stops.collect(), reverse: false } } } @@ -211,10 +191,7 @@ mod tests { #[test] fn color_table_nearest() { - let t = ColorTable { - colors: vec![[0, 0, 0], [255, 255, 255], [255, 0, 0]], - transparent: None, - }; + let t = ColorTable { colors: vec![[0, 0, 0], [255, 255, 255], [255, 0, 0]], transparent: None }; assert_eq!(t.nearest([0.9, 0.1, 0.1]), 2); assert_eq!(t.nearest([0.6, 0.6, 0.6]), 1); assert_eq!(t.rgb(2), [1.0, 0.0, 0.0]); @@ -222,21 +199,12 @@ mod tests { #[test] fn duotone_render_black_ink_is_gray() { - let d = Duotone { - inks: vec![DuotoneInk::new("Black", [0.0; 3])], - psd_raw: None, - }; + let d = Duotone { inks: vec![DuotoneInk::new("Black", [0.0; 3])], psd_raw: None }; for g in [0.0, 0.25, 1.0] { let c = d.render(g); assert!((c[0] - g).abs() < 1e-6 && c[0] == c[2]); } - let d = Duotone { - inks: vec![ - DuotoneInk::new("Black", [0.0; 3]), - DuotoneInk::new("Orange", [1.0, 0.5, 0.0]), - ], - psd_raw: None, - }; + let d = Duotone { inks: vec![DuotoneInk::new("Black", [0.0; 3]), DuotoneInk::new("Orange", [1.0, 0.5, 0.0])], psd_raw: None }; let c = d.render(0.5); assert!(c[0] > c[2], "{c:?}"); } diff --git a/crates/doc/src/pattern.rs b/crates/doc/src/pattern.rs index 731dcb435c..47279e2c86 100644 --- a/crates/doc/src/pattern.rs +++ b/crates/doc/src/pattern.rs @@ -38,14 +38,7 @@ impl Pattern { h1 = fnv(h1, &v.to_bits().to_le_bytes()); } let h2 = fnv(h1 ^ 0x9e37_79b9_7f4a_7c15, format!("{:?}", surface.format()).as_bytes()); - let id = format!( - "{:08x}-{:04x}-{:04x}-{:04x}-{:012x}", - (h1 >> 32) as u32, - (h1 >> 16) as u16, - h1 as u16, - (h2 >> 48) as u16, - h2 & 0xffff_ffff_ffff - ); + let id = format!("{:08x}-{:04x}-{:04x}-{:04x}-{:012x}", (h1 >> 32) as u32, (h1 >> 16) as u16, h1 as u16, (h2 >> 48) as u16, h2 & 0xffff_ffff_ffff); Pattern { id, name, width, height, surface } } @@ -85,10 +78,7 @@ pub fn find<'a>(patterns: &'a [Pattern], id: &str, name: &str) -> Option<&'a Pat if name.is_empty() { return None; } - patterns - .iter() - .find(|p| p.name == name) - .or_else(|| patterns.iter().find(|p| p.display_name().eq_ignore_ascii_case(display_name(name)))) + patterns.iter().find(|p| p.name == name).or_else(|| patterns.iter().find(|p| p.display_name().eq_ignore_ascii_case(display_name(name)))) } #[cfg(test)] diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index b0c1b90987..8e22db6ba2 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -147,10 +147,7 @@ pub enum TextAlign { impl TextAlign { pub fn is_justified(self) -> bool { - matches!( - self, - Self::JustifyLeft | Self::JustifyCenter | Self::JustifyRight | Self::JustifyAll - ) + matches!(self, Self::JustifyLeft | Self::JustifyCenter | Self::JustifyRight | Self::JustifyAll) } } @@ -213,12 +210,7 @@ pub enum TextShape { #[default] Point, /// Text-space pixels, before the layer transform. - Box { - x: f32, - y: f32, - width: f32, - height: f32, - }, + Box { x: f32, y: f32, width: f32, height: f32 }, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -264,47 +256,27 @@ mod tests { use crate::TextLayer; fn run(len: usize, size: f32) -> TextRun { - TextRun { - len, - style: CharStyle { - size_pt: size, - ..Default::default() - }, - } + TextRun { len, style: CharStyle { size_pt: size, ..Default::default() } } } #[test] fn runs_are_normalized_to_the_text() { - let base = TextLayer { - text: "héllo".into(), - size_pt: 9.0, - ..Default::default() - }; + let base = TextLayer { text: "héllo".into(), size_pt: 9.0, ..Default::default() }; // No runs: one run from the summary fields. let r = base.char_runs(); assert_eq!(r.len(), 1); assert_eq!((r[0].len, r[0].style.size_pt), (6, 9.0)); // Too short: last run stretched. Split inside 'é' snaps to the char boundary. - let t = TextLayer { - runs: vec![run(2, 10.0), run(1, 20.0)], - ..base.clone() - }; + let t = TextLayer { runs: vec![run(2, 10.0), run(1, 20.0)], ..base.clone() }; let r = t.char_runs(); assert_eq!(r.iter().map(|r| r.len).collect::>(), vec![3, 3]); // Too long: truncated; empty runs dropped. - let t = TextLayer { - runs: vec![run(0, 1.0), run(100, 10.0), run(5, 20.0)], - ..base.clone() - }; + let t = TextLayer { runs: vec![run(0, 1.0), run(100, 10.0), run(5, 20.0)], ..base.clone() }; let r = t.char_runs(); assert_eq!(r.len(), 1); assert_eq!((r[0].len, r[0].style.size_pt), (6, 10.0)); // Empty text keeps the first run's style. - let t = TextLayer { - text: String::new(), - runs: vec![run(0, 33.0)], - ..base.clone() - }; + let t = TextLayer { text: String::new(), runs: vec![run(0, 33.0)], ..base.clone() }; assert_eq!(t.char_runs()[0].style.size_pt, 33.0); assert_eq!(t.paragraph_runs().len(), 1); } @@ -313,14 +285,7 @@ mod tests { fn summary_follows_first_run() { let mut t = TextLayer { text: "ab".into(), - runs: vec![TextRun { - len: 2, - style: CharStyle { - font_family: "X".into(), - size_pt: 7.0, - ..Default::default() - }, - }], + runs: vec![TextRun { len: 2, style: CharStyle { font_family: "X".into(), size_pt: 7.0, ..Default::default() } }], ..Default::default() }; t.sync_summary(); @@ -329,13 +294,7 @@ mod tests { #[test] fn styles_serialize() { - let s = CharStyle { - features: vec![FontFeature { - tag: "ss01".into(), - value: 1, - }], - ..Default::default() - }; + let s = CharStyle { features: vec![FontFeature { tag: "ss01".into(), value: 1 }], ..Default::default() }; let v = serde_json::to_value(&s).unwrap(); let back: CharStyle = serde_json::from_value(v).unwrap(); assert_eq!(back, s); diff --git a/crates/doc/src/text_styles.rs b/crates/doc/src/text_styles.rs index 105b65a474..debfdeb202 100644 --- a/crates/doc/src/text_styles.rs +++ b/crates/doc/src/text_styles.rs @@ -218,7 +218,15 @@ impl TextStyles { /// Re-resolves a run against (possibly changed) styles, keeping its overrides: `old` gives /// the styles as they were when the overrides were made. - pub fn restyle_char(&self, old: &TextStyles, run: &CharStyle, old_para: Option, new_para: Option, new_chr: Option, family: &str) -> CharStyle { + pub fn restyle_char( + &self, + old: &TextStyles, + run: &CharStyle, + old_para: Option, + new_para: Option, + new_chr: Option, + family: &str, + ) -> CharStyle { let overrides = old.char_overrides(run, old_para, family); let base = self.resolve_char(new_para, new_chr, family); apply_attrs(&base, &overrides).unwrap_or(base) @@ -247,7 +255,12 @@ mod tests { fn styles() -> TextStyles { let mut s = TextStyles::default(); s.character.push(CharacterStyleDef { id: 1, name: "Big".into(), attrs: attrs(json!({"size_pt": 40.0, "underline": true})) }); - s.paragraph.push(ParagraphStyleDef { id: 1, name: "Head".into(), para_attrs: attrs(json!({"align": "Center"})), char_attrs: attrs(json!({"size_pt": 20.0, "weight": 700})) }); + s.paragraph.push(ParagraphStyleDef { + id: 1, + name: "Head".into(), + para_attrs: attrs(json!({"align": "Center"})), + char_attrs: attrs(json!({"size_pt": 20.0, "weight": 700})), + }); s } diff --git a/crates/doc/src/vector.rs b/crates/doc/src/vector.rs index e3e17e7da6..5cc890399a 100644 --- a/crates/doc/src/vector.rs +++ b/crates/doc/src/vector.rs @@ -250,12 +250,25 @@ impl Default for ShapeStroke { #[serde(tag = "kind", rename_all = "camelCase")] pub enum LiveShape { /// Rectangle `[x, y, w, h]` with corner radii `[tl, tr, br, bl]` (all zero = sharp). - Rect { rect: [f64; 4], radii: [f64; 4] }, - Ellipse { rect: [f64; 4] }, + Rect { + rect: [f64; 4], + radii: [f64; 4], + }, + Ellipse { + rect: [f64; 4], + }, /// Regular polygon or star inscribed in `rect`; `star_ratio` = inner/outer radius (1 = polygon). - Polygon { rect: [f64; 4], sides: u32, star_ratio: f64 }, + Polygon { + rect: [f64; 4], + sides: u32, + star_ratio: f64, + }, /// Line from `from` to `to` drawn as a filled bar `weight` px thick. - Line { from: [f64; 2], to: [f64; 2], weight: f64 }, + Line { + from: [f64; 2], + to: [f64; 2], + weight: f64, + }, } /// A shape layer: a filled and/or stroked path. diff --git a/crates/engine/examples/bench_web.rs b/crates/engine/examples/bench_web.rs index bcd4e0e8e4..df6477f598 100644 --- a/crates/engine/examples/bench_web.rs +++ b/crates/engine/examples/bench_web.rs @@ -20,7 +20,10 @@ fn main() { for y in 0..h { for x in 0..w { let n = ((x.wrapping_mul(73_856_093) ^ y.wrapping_mul(19_349_663)) as u32 % 1000) as f32 / 1000.0 * 0.06; - data.extend(photocraft_raster::from_rgba(&fmt, [x as f32 / w as f32 + n, 0.5 + ((x + y) as f32 * 0.002).sin() * 0.3, y as f32 / h as f32, 1.0])); + data.extend(photocraft_raster::from_rgba( + &fmt, + [x as f32 / w as f32 + n, 0.5 + ((x + y) as f32 * 0.002).sin() * 0.3, y as f32 / h as f32, 1.0], + )); } } surf.write_region(Rect::new(0, 0, w, h), &data); diff --git a/crates/engine/examples/retouch_bench.rs b/crates/engine/examples/retouch_bench.rs index c260f8aad5..3a19f4216c 100644 --- a/crates/engine/examples/retouch_bench.rs +++ b/crates/engine/examples/retouch_bench.rs @@ -24,7 +24,8 @@ fn main() { for x in area.x0..area.x1 { let n = ((x.wrapping_mul(73_856_093) ^ y.wrapping_mul(19_349_663)) as u32 % 1000) as f32 / 1000.0 * 0.08 - 0.04; let blemish = (x % 400 - 200).abs() < 8 && (y % 400 - 200).abs() < 8; - let v = if blemish { [0.05, 0.05, 0.05, 1.0] } else { [0.3 + 0.0002 * x as f32 + n, 0.4 + ((x + y) as f32 * 0.01).sin() * 0.1 + n, 0.5 + n, 1.0] }; + let v = + if blemish { [0.05, 0.05, 0.05, 1.0] } else { [0.3 + 0.0002 * x as f32 + n, 0.4 + ((x + y) as f32 * 0.01).sin() * 0.1 + n, 0.5 + n, 1.0] }; data.extend(photocraft_raster::from_rgba(&fmt, v)); } } diff --git a/crates/engine/src/adjust_cmds.rs b/crates/engine/src/adjust_cmds.rs index 5e4188cc0d..03d37316dd 100644 --- a/crates/engine/src/adjust_cmds.rs +++ b/crates/engine/src/adjust_cmds.rs @@ -21,21 +21,14 @@ use crate::{EngineError, Result, Session}; pub use photocraft_cms::lutfile::BUILTIN as LOOKS; /// Selective Color range keys in storage order. -pub const RANGES: [&str; 9] = [ - "reds", "yellows", "greens", "cyans", "blues", "magentas", "whites", "neutrals", "blacks", -]; +pub const RANGES: [&str; 9] = ["reds", "yellows", "greens", "cyans", "blues", "magentas", "whites", "neutrals", "blacks"]; fn bad(cmd: &str, msg: impl Into) -> EngineError { - EngineError::BadParams { - cmd: cmd.into(), - msg: msg.into(), - } + EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } } fn num(p: &Value, key: &str, default: f32) -> f32 { - p.get(key) - .and_then(Value::as_f64) - .map_or(default, |v| v as f32) + p.get(key).and_then(Value::as_f64).map_or(default, |v| v as f32) } /// Selective Color from params. Accepts per-range arrays (`"reds":[c,m,y,k]` in percent) and the @@ -43,10 +36,7 @@ fn num(p: &Value, key: &str, default: f32) -> f32 { /// `"method":"relative|absolute"`. Unspecified ranges keep `base`'s values. pub fn selective_from_params(p: &Value, base: Option<&Adjustment>) -> Adjustment { let (mut relative, mut adj) = match base { - Some(Adjustment::SelectiveColor { - relative, - adjustments, - }) => (*relative, *adjustments), + Some(Adjustment::SelectiveColor { relative, adjustments }) => (*relative, *adjustments), _ => (true, [[0.0; 4]; 9]), }; if let Some(m) = p.get("method").and_then(Value::as_str) { @@ -62,10 +52,7 @@ pub fn selective_from_params(p: &Value, base: Option<&Adjustment>) -> Adjustment } } } - let range = p - .get("colors") - .and_then(Value::as_str) - .and_then(|c| RANGES.iter().position(|r| *r == c)); + let range = p.get("colors").and_then(Value::as_str).and_then(|c| RANGES.iter().position(|r| *r == c)); if let Some(i) = range { for (k, key) in ["cyan", "magenta", "yellow", "black"].iter().enumerate() { if let Some(v) = p.get(*key).and_then(Value::as_f64) { @@ -73,10 +60,7 @@ pub fn selective_from_params(p: &Value, base: Option<&Adjustment>) -> Adjustment } } } - Adjustment::SelectiveColor { - relative, - adjustments: adj, - } + Adjustment::SelectiveColor { relative, adjustments: adj } } /// Color Lookup from params: `"lut"` (a built-in look id or `"none"`), `"file"` (a .cube / .3dl / @@ -86,13 +70,7 @@ pub fn selective_from_params(p: &Value, base: Option<&Adjustment>) -> Adjustment pub fn lookup_from_params(p: &Value, base: Option<&Adjustment>) -> Result { const CMD: &str = "colorLookup"; let (mut name, mut lut, mut size, mut tetrahedral, mut dither) = match base { - Some(Adjustment::ColorLookup { - name, - lut, - size, - tetrahedral, - dither, - }) => (name.clone(), lut.clone(), *size, *tetrahedral, *dither), + Some(Adjustment::ColorLookup { name, lut, size, tetrahedral, dither }) => (name.clone(), lut.clone(), *size, *tetrahedral, *dither), _ => (String::new(), None, 0, false, false), }; let mut loaded: Option<(photocraft_cms::lutfile::LutFile, String)> = None; @@ -102,33 +80,19 @@ pub fn lookup_from_params(p: &Value, base: Option<&Adjustment>) -> Result) -> Result String { - photocraft_cms::lutfile::BUILTIN - .iter() - .map(|b| b.0) - .collect::>() - .join(", ") + photocraft_cms::lutfile::BUILTIN.iter().map(|b| b.0).collect::>().join(", ") } fn base_name(path: &str) -> String { @@ -178,14 +132,9 @@ fn read_file(_: &str) -> Option> { /// `layer.setAdjustment` for the kinds edited one range / option at a time: params merge into the /// current values; a call with no parameters resets to defaults. pub fn update_adjustment(existing: &Adjustment, p: &Value) -> Option> { - let empty = p.as_object().is_none_or(|o| { - o.keys() - .all(|k| matches!(k.as_str(), "layer" | "coalesce" | "__kind")) - }); + let empty = p.as_object().is_none_or(|o| o.keys().all(|k| matches!(k.as_str(), "layer" | "coalesce" | "__kind"))); match existing { - Adjustment::SelectiveColor { .. } => { - Some(Ok(selective_from_params(p, (!empty).then_some(existing)))) - } + Adjustment::SelectiveColor { .. } => Some(Ok(selective_from_params(p, (!empty).then_some(existing)))), Adjustment::ColorLookup { .. } => Some(lookup_from_params(p, (!empty).then_some(existing))), _ => None, } @@ -195,11 +144,7 @@ pub fn update_adjustment(existing: &Adjustment, p: &Value) -> Option, - f: &mut dyn FnMut(&mut Vec<[f32; 4]>, Rect), -) { +fn process_surface(surf: &mut Surface, selection: Option<&Surface>, f: &mut dyn FnMut(&mut Vec<[f32; 4]>, Rect)) { let r = surf.content_bounds(); if r.is_empty() { return; @@ -213,9 +158,7 @@ fn process_surface( f(&mut px, r); let mut out = Vec::with_capacity(raw.len()); for (i, (a, o)) in px.iter().zip(&orig).enumerate() { - let k = selection.map_or(1.0, |sel| { - sel.sample_channel(r.x0 + (i % w) as i32, r.y0 + (i / w) as i32, 0) - }); + let k = selection.map_or(1.0, |sel| sel.sample_channel(r.x0 + (i % w) as i32, r.y0 + (i / w) as i32, 0)); let mixed: [f32; 4] = std::array::from_fn(|c| o[c] + (a[c] - o[c]) * k); let mut enc = [0.0f32; 8]; let m = from_rgba_into(&fmt, mixed, &mut enc); @@ -225,12 +168,7 @@ fn process_surface( } /// One history step running `f` on the active pixel layer (or targeted channel). -fn rgba_edit( - s: &mut Session, - label: &str, - p: &Value, - mut f: impl FnMut(&mut Vec<[f32; 4]>, Rect), -) -> Result { +fn rgba_edit(s: &mut Session, label: &str, p: &Value, mut f: impl FnMut(&mut Vec<[f32; 4]>, Rect)) -> Result { if crate::channel_cmds::is_channel_target(p) { return s.edit(label, |doc, _| { let sel = doc.selection.clone(); @@ -256,24 +194,12 @@ fn has_pixels(s: &Session) -> std::result::Result<(), String> { return Ok(()); } let d = s.active().ok_or("no document open")?; - let l = d - .active_layer - .and_then(|id| d.doc.layer(id)) - .ok_or("no active layer")?; - if matches!(l.content, LayerContent::Raster(_)) { - Ok(()) - } else { - Err(format!( - "active layer is a {} layer, not a pixel layer", - l.content.kind_name() - )) - } + let l = d.active_layer.and_then(|id| d.doc.layer(id)).ok_or("no active layer")?; + if matches!(l.content, LayerContent::Raster(_)) { Ok(()) } else { Err(format!("active layer is a {} layer, not a pixel layer", l.content.kind_name())) } } fn has_doc(s: &Session) -> std::result::Result<(), String> { - s.active() - .map(|_| ()) - .ok_or_else(|| "no document open".into()) + s.active().map(|_| ()).ok_or_else(|| "no document open".into()) } // ---------- commands ---------- @@ -292,19 +218,13 @@ fn shadows_highlights(s: &mut Session, p: &Value) -> Result { black_clip: num(p, "blackClip", d.black_clip).clamp(0.0, 50.0), white_clip: num(p, "whiteClip", d.white_clip).clamp(0.0, 50.0), }; - rgba_edit(s, "Shadows/Highlights", p, |px, r| { - tone::shadows_highlights(px, r.width() as usize, r.height() as usize, &sh) - }) + rgba_edit(s, "Shadows/Highlights", p, |px, r| tone::shadows_highlights(px, r.width() as usize, r.height() as usize, &sh)) } fn replace_color(s: &mut Session, p: &Value) -> Result { let c = crate::commands::color_param(p, "color", s.tools.foreground); let fuzz = num(p, "fuzziness", 40.0).clamp(0.0, 200.0); - let (h, sat, l) = ( - num(p, "hue", 0.0).clamp(-180.0, 180.0), - num(p, "saturation", 0.0).clamp(-100.0, 100.0), - num(p, "lightness", 0.0).clamp(-100.0, 100.0), - ); + let (h, sat, l) = (num(p, "hue", 0.0).clamp(-180.0, 180.0), num(p, "saturation", 0.0).clamp(-100.0, 100.0), num(p, "lightness", 0.0).clamp(-100.0, 100.0)); rgba_edit(s, "Replace Color", p, |px, _| { tone::replace_color(px, [c[0], c[1], c[2]], fuzz, h, sat, l); }) @@ -318,18 +238,12 @@ fn stats_pixels(doc: &Document, layer: Option, use_selection: bool) -> Resu let area = doc.bounds(); let px: Vec<[f32; 4]> = match layer { Some(id) => { - let l = doc - .layer(photocraft_doc::LayerId(id)) - .ok_or(EngineError::NoLayer(photocraft_doc::LayerId(id)))?; + let l = doc.layer(photocraft_doc::LayerId(id)).ok_or(EngineError::NoLayer(photocraft_doc::LayerId(id)))?; photocraft_compose::render_layer(l, area).px } None => photocraft_compose::flatten(doc).px, }; - let mask = doc - .selection - .as_ref() - .filter(|_| use_selection) - .map(|sel| photocraft_algo::selection::mask_from_surface(Some(sel), area)); + let mask = doc.selection.as_ref().filter(|_| use_selection).map(|sel| photocraft_algo::selection::mask_from_surface(Some(sel), area)); Ok((px, mask)) } @@ -339,45 +253,24 @@ fn match_color(s: &mut Session, p: &Value) -> Result { luminance: num(p, "luminance", 100.0).clamp(1.0, 200.0), intensity: num(p, "intensity", 100.0).clamp(1.0, 200.0), fade: num(p, "fade", 0.0).clamp(0.0, 100.0), - neutralize: p - .get("neutralize") - .and_then(Value::as_bool) - .unwrap_or(false), + neutralize: p.get("neutralize").and_then(Value::as_bool).unwrap_or(false), }; let source = match p.get("source").and_then(Value::as_i64) { Some(i) if i >= 0 => { - let d = s - .documents() - .get(i as usize) - .ok_or_else(|| bad(CMD, format!("no document {i}")))?; + let d = s.documents().get(i as usize).ok_or_else(|| bad(CMD, format!("no document {i}")))?; let layer = p.get("sourceLayer").and_then(Value::as_u64); - let (px, mask) = stats_pixels( - &d.doc, - layer, - p.get("useSelectionInSource") - .and_then(Value::as_bool) - .unwrap_or(false), - )?; - Some( - tone::lab_stats(&px, mask.as_deref()) - .ok_or_else(|| bad(CMD, "the source has no opaque pixels"))?, - ) + let (px, mask) = stats_pixels(&d.doc, layer, p.get("useSelectionInSource").and_then(Value::as_bool).unwrap_or(false))?; + Some(tone::lab_stats(&px, mask.as_deref()).ok_or_else(|| bad(CMD, "the source has no opaque pixels"))?) } _ => None, }; // Source "None" with default options matches the image to its own statistics: a no-op, as in // Photoshop (Neutralize / Luminance / Color Intensity still act). // Target statistics: the layer itself, restricted to the selection when asked. - let target_sel = p - .get("useSelectionInTarget") - .and_then(Value::as_bool) - .unwrap_or(false); + let target_sel = p.get("useSelectionInTarget").and_then(Value::as_bool).unwrap_or(false); let sel = s.active().and_then(|d| d.doc.selection.clone()); rgba_edit(s, "Match Color", p, |px, r| { - let mask: Option> = sel - .as_ref() - .filter(|_| target_sel) - .map(|sel| photocraft_algo::selection::mask_from_surface(Some(sel), r)); + let mask: Option> = sel.as_ref().filter(|_| target_sel).map(|sel| photocraft_algo::selection::mask_from_surface(Some(sel), r)); let Some(target) = tone::lab_stats(px, mask.as_deref()) else { return; }; @@ -390,16 +283,7 @@ fn hdr_toning(s: &mut Session, p: &Value) -> Result { let curve: Vec<(f32, f32)> = p .get("curve") .and_then(Value::as_array) - .map(|a| { - a.iter() - .filter_map(|v| { - Some(( - v.get(0)?.as_f64()? as f32 / 255.0, - v.get(1)?.as_f64()? as f32 / 255.0, - )) - }) - .collect() - }) + .map(|a| a.iter().filter_map(|v| Some((v.get(0)?.as_f64()? as f32 / 255.0, v.get(1)?.as_f64()? as f32 / 255.0))).collect()) .unwrap_or_default(); let h = HdrToning { radius: num(p, "radius", d.radius).clamp(1.0, 500.0), @@ -420,15 +304,10 @@ fn hdr_toning(s: &mut Session, p: &Value) -> Result { let mut px = buf.px; tone::hdr_toning(&mut px, w, hgt, &h); let fmt = doc.pixel_format(); - let data: Vec = px - .iter() - .flat_map(|q| photocraft_raster::from_rgba(&fmt, *q)) - .collect(); + let data: Vec = px.iter().flat_map(|q| photocraft_raster::from_rgba(&fmt, *q)).collect(); let mut bg = Layer::raster("Background", fmt); bg.locks.transparency = true; - bg.surface_mut() - .expect("raster") - .write_region(doc.bounds(), &data); + bg.surface_mut().expect("raster").write_region(doc.bounds(), &data); *active = Some(bg.id); doc.layers = vec![bg]; doc.selection = None; @@ -439,12 +318,7 @@ fn hdr_toning(s: &mut Session, p: &Value) -> Result { /// The Color Lookup built-ins (id, label) for UIs and agents. fn list_looks(_: &mut Session, _: &Value) -> Result { - Ok(json!( - photocraft_cms::lutfile::BUILTIN - .iter() - .map(|(id, label)| json!({"id": id, "label": label})) - .collect::>() - )) + Ok(json!(photocraft_cms::lutfile::BUILTIN.iter().map(|(id, label)| json!({"id": id, "label": label})).collect::>())) } macro_rules! spec { diff --git a/crates/engine/src/adjust_cmds/tests.rs b/crates/engine/src/adjust_cmds/tests.rs index 2a431db7bf..ef91e066b3 100644 --- a/crates/engine/src/adjust_cmds/tests.rs +++ b/crates/engine/src/adjust_cmds/tests.rs @@ -5,30 +5,15 @@ const MODES: [&str; 4] = ["rgb", "gray", "cmyk", "lab"]; fn session(depth: u64, mode: &str) -> Session { let mut s = Session::new(); - s.execute( - "file.new", - json!({"width": 48, "height": 32, "depth": depth, "mode": mode}), - ) - .unwrap(); - paint(&mut s, |x, y| { - [ - 0.1 + x as f32 / 60.0, - 0.2 + y as f32 / 50.0, - 0.6 - x as f32 / 120.0, - 1.0, - ] - }); + s.execute("file.new", json!({"width": 48, "height": 32, "depth": depth, "mode": mode})).unwrap(); + paint(&mut s, |x, y| [0.1 + x as f32 / 60.0, 0.2 + y as f32 / 50.0, 0.6 - x as f32 / 120.0, 1.0]); s } fn paint(s: &mut Session, f: impl Fn(i32, i32) -> [f32; 4]) { s.edit("setup", |doc, active| { let b = doc.bounds(); - let surf = doc - .layer_mut(active.unwrap()) - .unwrap() - .surface_mut() - .unwrap(); + let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); let fmt = surf.format(); let mut data = Vec::new(); for y in b.y0..b.y1 { @@ -44,12 +29,7 @@ fn paint(s: &mut Session, f: impl Fn(i32, i32) -> [f32; 4]) { fn rgba(s: &Session, x: i32, y: i32) -> [f32; 4] { let d = s.active().unwrap(); - d.doc - .layer(d.active_layer.unwrap()) - .unwrap() - .surface() - .unwrap() - .rgba(x, y) + d.doc.layer(d.active_layer.unwrap()).unwrap().surface().unwrap().rgba(x, y) } fn changed(a: [f32; 4], b: [f32; 4]) -> f32 { @@ -63,22 +43,10 @@ fn doc(s: &Session) -> &Document { #[test] fn destructive_adjustments_all_depths_and_modes_one_undo_step() { let cases: [(&str, Value); 4] = [ - ( - "image.adjustments.shadowsHighlights", - json!({"shadowAmount": 80, "blackClip": 0, "whiteClip": 0}), - ), - ( - "image.adjustments.replaceColor", - json!({"color": "#4d66cc", "fuzziness": 200, "hue": 90, "saturation": 30}), - ), - ( - "image.adjustments.matchColor", - json!({"neutralize": true, "intensity": 50}), - ), - ( - "image.adjustments.hdrToning", - json!({"strength": 2, "exposure": 0.5}), - ), + ("image.adjustments.shadowsHighlights", json!({"shadowAmount": 80, "blackClip": 0, "whiteClip": 0})), + ("image.adjustments.replaceColor", json!({"color": "#4d66cc", "fuzziness": 200, "hue": 90, "saturation": 30})), + ("image.adjustments.matchColor", json!({"neutralize": true, "intensity": 50})), + ("image.adjustments.hdrToning", json!({"strength": 2, "exposure": 0.5})), ]; for (id, params) in &cases { for depth in DEPTHS { @@ -86,22 +54,13 @@ fn destructive_adjustments_all_depths_and_modes_one_undo_step() { let mut s = session(depth, mode); let before = rgba(&s, 3, 3); let steps = s.active().unwrap().history.past_len(); - s.execute(id, params.clone()) - .unwrap_or_else(|e| panic!("{id} {depth} {mode}: {e}")); + s.execute(id, params.clone()).unwrap_or_else(|e| panic!("{id} {depth} {mode}: {e}")); let after = rgba(&s, 3, 3); // Gray documents can't show hue changes from Replace Color at a gray pixel. - if !(mode == "gray" && (id.ends_with("replaceColor") || id.ends_with("matchColor"))) - { - assert!( - changed(before, after) > 1e-3, - "{id} {depth} {mode}: {before:?} → {after:?}" - ); + if !(mode == "gray" && (id.ends_with("replaceColor") || id.ends_with("matchColor"))) { + assert!(changed(before, after) > 1e-3, "{id} {depth} {mode}: {before:?} → {after:?}"); } - assert_eq!( - s.active().unwrap().history.past_len(), - steps + 1, - "{id}: one history step" - ); + assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "{id}: one history step"); s.execute("edit.undo", json!({})).unwrap(); assert!(changed(before, rgba(&s, 3, 3)) < 1e-6, "{id} undo"); } @@ -122,11 +81,7 @@ fn destructive_adjustments_respect_selection() { .unwrap(); let outside = rgba(&s, 30, 5); let inside = rgba(&s, 5, 5); - s.execute( - "image.adjustments.shadowsHighlights", - json!({"shadowAmount": 100, "blackClip": 0, "whiteClip": 0}), - ) - .unwrap(); + s.execute("image.adjustments.shadowsHighlights", json!({"shadowAmount": 100, "blackClip": 0, "whiteClip": 0})).unwrap(); assert!(changed(inside, rgba(&s, 5, 5)) > 1e-3); assert_eq!(outside, rgba(&s, 30, 5)); } @@ -134,38 +89,21 @@ fn destructive_adjustments_respect_selection() { #[test] fn match_color_from_another_document() { let mut s = session(8, "rgb"); - s.execute( - "file.new", - json!({"width": 16, "height": 16, "background": "#e05010"}), - ) - .unwrap(); + s.execute("file.new", json!({"width": 16, "height": 16, "background": "#e05010"})).unwrap(); // The target is document 0. - s.execute("document.activate", json!({"document": 0})) - .unwrap(); + s.execute("document.activate", json!({"document": 0})).unwrap(); let before = rgba(&s, 10, 10); - s.execute("image.adjustments.matchColor", json!({"source": 1})) - .unwrap(); + s.execute("image.adjustments.matchColor", json!({"source": 1})).unwrap(); let after = rgba(&s, 10, 10); - assert!( - after[0] > before[0] && after[2] < before[2], - "{before:?} → {after:?}" - ); + assert!(after[0] > before[0] && after[2] < before[2], "{before:?} → {after:?}"); // Fade 100 is a no-op; a bad source index is an error. s.execute("edit.undo", json!({})).unwrap(); - s.execute( - "image.adjustments.matchColor", - json!({"source": 1, "fade": 100}), - ) - .unwrap(); + s.execute("image.adjustments.matchColor", json!({"source": 1, "fade": 100})).unwrap(); assert!(changed(before, rgba(&s, 10, 10)) < 2.0 / 255.0); - assert!( - s.execute("image.adjustments.matchColor", json!({"source": 7})) - .is_err() - ); + assert!(s.execute("image.adjustments.matchColor", json!({"source": 7})).is_err()); // Source "None" with default options is a no-op. let now = rgba(&s, 10, 10); - s.execute("image.adjustments.matchColor", json!({})) - .unwrap(); + s.execute("image.adjustments.matchColor", json!({})).unwrap(); assert!(changed(now, rgba(&s, 10, 10)) < 2.0 / 255.0); } @@ -182,13 +120,8 @@ fn hdr_toning_flattens() { #[test] fn disabled_without_pixels() { let mut s = session(8, "rgb"); - s.execute("layer.newAdjustmentLayer.invert", json!({})) - .unwrap(); - for id in [ - "image.adjustments.shadowsHighlights", - "image.adjustments.replaceColor", - "image.adjustments.matchColor", - ] { + s.execute("layer.newAdjustmentLayer.invert", json!({})).unwrap(); + for id in ["image.adjustments.shadowsHighlights", "image.adjustments.replaceColor", "image.adjustments.matchColor"] { assert!(!s.is_enabled(id), "{id}"); } assert!(s.is_enabled("image.adjustments.hdrToning")); @@ -203,76 +136,43 @@ fn selective_color_layer_and_destructive() { paint(&mut s, |_, _| [0.9, 0.1, 0.1, 1.0]); let before = rgba(&s, 1, 1); // Reds: −100 % cyan (absolute) makes red redder; +100 % black darkens. - s.execute( - "image.adjustments.selectiveColor", - json!({"method": "absolute", "reds": [0, 0, 0, 60]}), - ) - .unwrap(); + s.execute("image.adjustments.selectiveColor", json!({"method": "absolute", "reds": [0, 0, 0, 60]})).unwrap(); let after = rgba(&s, 1, 1); - assert!( - after[0] < before[0] - 0.05, - "{depth} {mode}: {before:?} → {after:?}" - ); + assert!(after[0] < before[0] - 0.05, "{depth} {mode}: {before:?} → {after:?}"); } } let mut s = session(8, "rgb"); - let r = s - .execute( - "layer.newAdjustmentLayer.selectiveColor", - json!({"colors": "blues", "yellow": 40}), - ) - .unwrap(); + let r = s.execute("layer.newAdjustmentLayer.selectiveColor", json!({"colors": "blues", "yellow": 40})).unwrap(); let id = photocraft_doc::LayerId(r["layer"].as_u64().unwrap()); let get = |s: &Session| match &doc(s).layer(id).unwrap().content { - LayerContent::Adjustment(Adjustment::SelectiveColor { - relative, - adjustments, - }) => (*relative, *adjustments), + LayerContent::Adjustment(Adjustment::SelectiveColor { relative, adjustments }) => (*relative, *adjustments), other => panic!("{other:?}"), }; assert_eq!(get(&s).1[4], [0.0, 0.0, 40.0, 0.0]); // Properties edits merge one range at a time. - s.execute( - "layer.setAdjustment", - json!({"layer": id.0, "colors": "neutrals", "black": -20, "method": "absolute"}), - ) - .unwrap(); + s.execute("layer.setAdjustment", json!({"layer": id.0, "colors": "neutrals", "black": -20, "method": "absolute"})).unwrap(); let (rel, a) = get(&s); assert!(!rel); assert_eq!(a[4][2], 40.0); assert_eq!(a[7][3], -20.0); // No parameters: reset. - s.execute("layer.setAdjustment", json!({"layer": id.0})) - .unwrap(); + s.execute("layer.setAdjustment", json!({"layer": id.0})).unwrap(); assert_eq!(get(&s), (true, [[0.0; 4]; 9])); } #[test] fn color_lookup_builtin_data_and_errors() { let mut s = session(8, "rgb"); - let r = s - .execute( - "layer.newAdjustmentLayer.colorLookup", - json!({"lut": "dayForNight"}), - ) - .unwrap(); + let r = s.execute("layer.newAdjustmentLayer.colorLookup", json!({"lut": "dayForNight"})).unwrap(); let id = photocraft_doc::LayerId(r["layer"].as_u64().unwrap()); let px = photocraft_compose::render(doc(&s), Rect::from_xywh(40, 30, 1, 1)).px[0]; assert!(px[2] > px[0], "night is blue: {px:?}"); // Switching interpolation keeps the table. - s.execute( - "layer.setAdjustment", - json!({"layer": id.0, "interpolation": "tetrahedral", "dither": true}), - ) - .unwrap(); + s.execute("layer.setAdjustment", json!({"layer": id.0, "interpolation": "tetrahedral", "dither": true})).unwrap(); match &doc(&s).layer(id).unwrap().content { - LayerContent::Adjustment(Adjustment::ColorLookup { - lut: Some(_), - size: 33, - tetrahedral: true, - dither: true, - name, - }) => assert_eq!(name, "Day for Night"), + LayerContent::Adjustment(Adjustment::ColorLookup { lut: Some(_), size: 33, tetrahedral: true, dither: true, name }) => { + assert_eq!(name, "Day for Night") + } other => panic!("{other:?}"), } // Embedded .cube text (an inverting LUT) applied destructively, at every depth. @@ -287,63 +187,26 @@ fn color_lookup_builtin_data_and_errors() { for depth in DEPTHS { let mut s = session(depth, "rgb"); let before = rgba(&s, 5, 5); - s.execute( - "image.adjustments.colorLookup", - json!({"data": cube, "fileName": "invert.cube"}), - ) - .unwrap(); + s.execute("image.adjustments.colorLookup", json!({"data": cube, "fileName": "invert.cube"})).unwrap(); let after = rgba(&s, 5, 5); for c in 0..3 { - assert!( - (after[c] - (1.0 - before[c])).abs() < 2.0 / 255.0, - "{depth}: {before:?} {after:?}" - ); + assert!((after[c] - (1.0 - before[c])).abs() < 2.0 / 255.0, "{depth}: {before:?} {after:?}"); } } - assert!( - s.execute("image.adjustments.colorLookup", json!({"lut": "nope"})) - .is_err() - ); - assert!( - s.execute( - "image.adjustments.colorLookup", - json!({"data": "LUT_3D_SIZE 3\n0 0 0\n"}) - ) - .is_err() - ); - assert!( - s.execute( - "layer.newAdjustmentLayer.colorLookup", - json!({"file": "/nonexistent/x.cube"}) - ) - .is_err() - ); - let looks = s - .execute("image.adjustments.colorLookup.list", json!({})) - .unwrap(); - assert_eq!( - looks.as_array().unwrap().len(), - photocraft_cms::lutfile::BUILTIN.len() - ); + assert!(s.execute("image.adjustments.colorLookup", json!({"lut": "nope"})).is_err()); + assert!(s.execute("image.adjustments.colorLookup", json!({"data": "LUT_3D_SIZE 3\n0 0 0\n"})).is_err()); + assert!(s.execute("layer.newAdjustmentLayer.colorLookup", json!({"file": "/nonexistent/x.cube"})).is_err()); + let looks = s.execute("image.adjustments.colorLookup.list", json!({})).unwrap(); + assert_eq!(looks.as_array().unwrap().len(), photocraft_cms::lutfile::BUILTIN.len()); } #[test] fn new_kinds_round_trip_through_psd_and_pcraft() { let mut s = session(8, "rgb"); - s.execute( - "layer.newAdjustmentLayer.selectiveColor", - json!({"reds": [10, -20, 30, -40], "method": "absolute"}), - ) - .unwrap(); - s.execute( - "layer.newAdjustmentLayer.colorLookup", - json!({"lut": "warm", "dither": true}), - ) - .unwrap(); + s.execute("layer.newAdjustmentLayer.selectiveColor", json!({"reds": [10, -20, 30, -40], "method": "absolute"})).unwrap(); + s.execute("layer.newAdjustmentLayer.colorLookup", json!({"lut": "warm", "dither": true})).unwrap(); let d = doc(&s).clone(); - let bytes = photocraft_io::export(&d, "x.psd", &Default::default()) - .unwrap() - .bytes; + let bytes = photocraft_io::export(&d, "x.psd", &Default::default()).unwrap().bytes; let back = photocraft_io::import("x.psd", &bytes).unwrap().document; let kinds: Vec<&str> = back .layers diff --git a/crates/engine/src/align_cmds.rs b/crates/engine/src/align_cmds.rs index afdcbce4a9..fc3d0c430e 100644 --- a/crates/engine/src/align_cmds.rs +++ b/crates/engine/src/align_cmds.rs @@ -47,7 +47,8 @@ fn auto_align(s: &mut Session, p: &Value) -> Result { return Err(bad(cmd, "select two or more pixel layers")); } let projection = p.get("projection").and_then(Value::as_str).unwrap_or("auto"); - let layout = Layout::parse(projection).ok_or_else(|| bad(cmd, format!("unknown projection `{projection}` (auto|perspective|cylindrical|spherical|collage|reposition)")))?; + let layout = Layout::parse(projection) + .ok_or_else(|| bad(cmd, format!("unknown projection `{projection}` (auto|perspective|cylindrical|spherical|collage|reposition)")))?; let st = s.active().ok_or(EngineError::NoDocument)?; let doc = st.doc.clone(); let reference = match p.get("reference").and_then(Value::as_u64) { @@ -61,7 +62,8 @@ fn auto_align(s: &mut Session, p: &Value) -> Result { let images: Vec<(&Surface, Rect)> = surfs.iter().map(|s| (*s, area)).collect(); let focal35 = doc.metadata.exif.as_ref().and_then(|e| photocraft_algo::exif::read(e).focal_length_35mm); let geometric = p.get("geometricCorrection").and_then(Value::as_bool).unwrap_or(false); - let al = register(&images, layout, Some(ref_idx), geometric, focal35).ok_or_else(|| EngineError::Other("Auto-Align couldn't find enough matching detail between the layers".into()))?; + let al = register(&images, layout, Some(ref_idx), geometric, focal35) + .ok_or_else(|| EngineError::Other("Auto-Align couldn't find enough matching detail between the layers".into()))?; let interp = Interp::parse(p.get("interpolation").and_then(Value::as_str).unwrap_or("bicubic")); let planar = matches!(al.layout, Layout::Perspective | Layout::Collage | Layout::Reposition); let mut inliers = vec![0usize; ids.len()]; @@ -181,7 +183,8 @@ fn auto_blend(s: &mut Session, p: &Value) -> Result { (weights, blended) } else { // Panorama: seams routed through agreement, multi-band blended (photo_cmds). - let surfs: Vec = ids.iter().map(|id| doc.layer(*id).and_then(|l| l.surface()).cloned().ok_or(EngineError::NoLayer(*id))).collect::>()?; + let surfs: Vec = + ids.iter().map(|id| doc.layer(*id).and_then(|l| l.surface()).cloned().ok_or(EngineError::NoLayer(*id))).collect::>()?; let order = seam_order(&surfs, 0); let b = seam_blend(&surfs, area, &order, seamless, false, None); let weights: Vec> = b.layers.iter().map(|(_, m)| m.read_region(area)).collect(); @@ -219,7 +222,12 @@ macro_rules! spec { pub fn specs() -> Vec { vec![ - spec!("edit.autoAlignLayers", "Auto-Align Layers…", r##"{"projection":"auto|perspective|cylindrical|spherical|collage|reposition","reference":layer id?=bottom selected layer,"geometricCorrection":bool=false,"interpolation":"bicubic|bilinear|nearest"}"##, auto_align), + spec!( + "edit.autoAlignLayers", + "Auto-Align Layers…", + r##"{"projection":"auto|perspective|cylindrical|spherical|collage|reposition","reference":layer id?=bottom selected layer,"geometricCorrection":bool=false,"interpolation":"bicubic|bilinear|nearest"}"##, + auto_align + ), spec!("edit.autoBlendLayers", "Auto-Blend Layers…", r##"{"method":"panorama|stack","seamlessTones":bool=true}"##, auto_blend), ] } diff --git a/crates/engine/src/analysis_cmds.rs b/crates/engine/src/analysis_cmds.rs index 56d8ecb9fd..cdfe3c5b70 100644 --- a/crates/engine/src/analysis_cmds.rs +++ b/crates/engine/src/analysis_cmds.rs @@ -54,7 +54,8 @@ pub const COLUMNS: &[(&str, &str)] = &[ ]; const COMMON: &[&str] = &["label", "dateTime", "document", "source", "scale", "scaleUnits", "scaleFactor"]; -const SELECTION_ONLY: &[&str] = &["count", "area", "perimeter", "circularity", "height", "width", "grayMin", "grayMax", "grayMean", "grayMedian", "integratedDensity", "histogram"]; +const SELECTION_ONLY: &[&str] = + &["count", "area", "perimeter", "circularity", "height", "width", "grayMin", "grayMax", "grayMean", "grayMedian", "integratedDensity", "histogram"]; const RULER_ONLY: &[&str] = &["length", "angle"]; const COUNT_ONLY: &[&str] = &["count"]; @@ -298,7 +299,9 @@ fn select_data_points(s: &mut Session, p: &Value) -> Result { list.sort_by_key(|k| COLUMNS.iter().position(|c| c.0 == k)); } s.analysis.data_points = next; - Ok(json!({"dataPoints": s.analysis.data_points, "available": {"selection": available("selection"), "ruler": available("ruler"), "count": available("count")}, "columns": COLUMNS.iter().map(|c| json!({"key": c.0, "name": c.1})).collect::>()})) + Ok( + json!({"dataPoints": s.analysis.data_points, "available": {"selection": available("selection"), "ruler": available("ruler"), "count": available("count")}, "columns": COLUMNS.iter().map(|c| json!({"key": c.0, "name": c.1})).collect::>()}), + ) } // ------------------------------------------------------------------ selection measurements @@ -441,7 +444,8 @@ pub fn measure_features(mask: &[bool], gray: &[f32], w: usize, h: usize, origin: }, ); // Area, bounds and gray values. - let mut feats: Vec = (0..n).map(|_| Feature { bounds: Rect::EMPTY, gray_min: f64::MAX, gray_max: f64::MIN, histogram: vec![0; 256], ..Default::default() }).collect(); + let mut feats: Vec = + (0..n).map(|_| Feature { bounds: Rect::EMPTY, gray_min: f64::MAX, gray_max: f64::MIN, histogram: vec![0; 256], ..Default::default() }).collect(); let mut sums = vec![0.0f64; n]; let mut values: Vec> = vec![Vec::new(); n]; for y in 0..h { @@ -849,11 +853,14 @@ fn base_row(s: &mut Session, d: &Document, source: &str, label: &str, now: &str) m.insert("label".into(), json!(label)); m.insert("dateTime".into(), json!(now)); m.insert("document".into(), json!(d.name)); - m.insert("source".into(), json!(match source { - "selection" => "Selection", - "ruler" => "Ruler Tool", - _ => "Count Tool", - })); + m.insert( + "source".into(), + json!(match source { + "selection" => "Selection", + "ruler" => "Ruler Tool", + _ => "Count Tool", + }), + ); m.insert("scale".into(), json!(sc.describe())); m.insert("scaleUnits".into(), json!(sc.units)); m.insert("scaleFactor".into(), json!(sc.factor())); @@ -1094,13 +1101,69 @@ macro_rules! spec { pub fn specs() -> Vec { vec![ - spec!("image.analysis.setMeasurementScale", "Set Measurement Scale…", ["Image", "Analysis"], r##"{"preset":"default|custom"="custom","pixelLength":px,"logicalLength":number,"units":str (e.g. "mm")} (no params: read the scale)"##, has_doc, set_scale, true), - spec!("image.analysis.selectDataPoints", "Select Data Points…", ["Image", "Analysis"], r##"{"selection":[keys]|{key:bool}?,"ruler":[keys]|{key:bool}?,"count":[keys]|{key:bool}?,"reset":bool=false} (keys: label,dateTime,document,source,scale,scaleUnits,scaleFactor,count,area,perimeter,circularity,height,width,grayMin,grayMax,grayMean,grayMedian,integratedDensity,histogram,length,angle)"##, always, select_data_points, true), - spec!("image.analysis.recordMeasurements", "Record Measurements", ["Image", "Analysis"], r##"{"source":"auto|selection|ruler|count"="auto"} → appended Measurement Log rows (selection: summary + one row per feature)"##, has_doc, record, true), - spec!("image.analysis.rulerTool", "Ruler Tool", ["Image", "Analysis"], r##"{"start":[x,y],"end":[x,y],"protractor":[x,y]|null?,"clear":bool=false} (no params: read) → X/Y/W/H/angle/L1/L2"##, has_doc, ruler_tool, true), - spec!("image.analysis.countTool", "Count Tool", ["Image", "Analysis"], r##"{} → count groups and markers (edit with count.*)"##, has_doc, count_tool, false), - spec!("image.analysis.placeScaleMarker", "Place Scale Marker…", ["Image", "Analysis"], r##"{"length":logical units=nice ≈ width/5,"font":str?,"fontSize":pt=12,"displayText":bool=true,"textPosition":"top|bottom"="bottom","color":"black|white"="black"}"##, has_doc, place_scale_marker, true), - spec!("image.analysis.straightenLayer", "Straighten Layer", [], r##"{"crop":bool=(active layer is the Background)} (rotates so the ruler line is level; crop = rotate the canvas and crop to the image)"##, has_ruler, straighten, true), + spec!( + "image.analysis.setMeasurementScale", + "Set Measurement Scale…", + ["Image", "Analysis"], + r##"{"preset":"default|custom"="custom","pixelLength":px,"logicalLength":number,"units":str (e.g. "mm")} (no params: read the scale)"##, + has_doc, + set_scale, + true + ), + spec!( + "image.analysis.selectDataPoints", + "Select Data Points…", + ["Image", "Analysis"], + r##"{"selection":[keys]|{key:bool}?,"ruler":[keys]|{key:bool}?,"count":[keys]|{key:bool}?,"reset":bool=false} (keys: label,dateTime,document,source,scale,scaleUnits,scaleFactor,count,area,perimeter,circularity,height,width,grayMin,grayMax,grayMean,grayMedian,integratedDensity,histogram,length,angle)"##, + always, + select_data_points, + true + ), + spec!( + "image.analysis.recordMeasurements", + "Record Measurements", + ["Image", "Analysis"], + r##"{"source":"auto|selection|ruler|count"="auto"} → appended Measurement Log rows (selection: summary + one row per feature)"##, + has_doc, + record, + true + ), + spec!( + "image.analysis.rulerTool", + "Ruler Tool", + ["Image", "Analysis"], + r##"{"start":[x,y],"end":[x,y],"protractor":[x,y]|null?,"clear":bool=false} (no params: read) → X/Y/W/H/angle/L1/L2"##, + has_doc, + ruler_tool, + true + ), + spec!( + "image.analysis.countTool", + "Count Tool", + ["Image", "Analysis"], + r##"{} → count groups and markers (edit with count.*)"##, + has_doc, + count_tool, + false + ), + spec!( + "image.analysis.placeScaleMarker", + "Place Scale Marker…", + ["Image", "Analysis"], + r##"{"length":logical units=nice ≈ width/5,"font":str?,"fontSize":pt=12,"displayText":bool=true,"textPosition":"top|bottom"="bottom","color":"black|white"="black"}"##, + has_doc, + place_scale_marker, + true + ), + spec!( + "image.analysis.straightenLayer", + "Straighten Layer", + [], + r##"{"crop":bool=(active layer is the Background)} (rotates so the ruler line is level; crop = rotate the canvas and crop to the image)"##, + has_ruler, + straighten, + true + ), spec!("image.analysis.info", "Analysis Info", [], r##"{} → scale, ruler readout, count groups, note and log counts"##, has_doc, analysis_info, false), spec!("count.add", "Add Count", [], r##"{"x":px,"y":px,"group":index?}"##, has_doc, count_add, true), spec!("count.remove", "Remove Count", [], r##"{"index":n,"group":index?} | {"x":px,"y":px,"radius":px=6}"##, has_doc, count_remove, true), @@ -1108,10 +1171,26 @@ pub fn specs() -> Vec { spec!("count.clear", "Clear Count", [], r##"{"group":index|"all"=active}"##, has_doc, count_clear, true), spec!("count.newGroup", "New Count Group", [], r##"{"name":str?,"color":"#rrggbb"|[r,g,b]?}"##, has_doc, count_new_group, true), spec!("count.deleteGroup", "Delete Count Group", [], r##"{"group":index=active}"##, has_doc, count_delete_group, true), - spec!("count.setGroup", "Count Group Options", [], r##"{"group":index=active,"name":str?,"color":"#rrggbb"|[r,g,b]?,"markerSize":1..10?,"labelSize":8..72?,"visible":bool?,"active":bool?}"##, has_doc, count_set_group, true), + spec!( + "count.setGroup", + "Count Group Options", + [], + r##"{"group":index=active,"name":str?,"color":"#rrggbb"|[r,g,b]?,"markerSize":1..10?,"labelSize":8..72?,"visible":bool?,"active":bool?}"##, + has_doc, + count_set_group, + true + ), spec!("measurementLog.list", "Measurement Log", [], r##"{} → rows and columns"##, always, log_list, false), spec!("measurementLog.delete", "Delete Measurements", [], r##"{"rows":[ids]}|{"all":true}"##, has_log, log_delete, true), - spec!("measurementLog.export", "Export Measurements…", [], r##"{"path":str? (CSV file; omitted → returns the CSV text),"rows":[ids]?}"##, always, log_export, true), + spec!( + "measurementLog.export", + "Export Measurements…", + [], + r##"{"path":str? (CSV file; omitted → returns the CSV text),"rows":[ids]?}"##, + always, + log_export, + true + ), ] } diff --git a/crates/engine/src/artboard_cmds.rs b/crates/engine/src/artboard_cmds.rs index 3306f8c4fd..d78ff68583 100644 --- a/crates/engine/src/artboard_cmds.rs +++ b/crates/engine/src/artboard_cmds.rs @@ -165,7 +165,11 @@ fn new_artboard(s: &mut Session, p: &Value) -> Result { None => Rect::from_xywh(0, 0, pw, ph), }; // A preset decides the size (dialogs send the canvas size alongside it). - let rect = if preset.is_empty() || p.get("rect").is_some() { rect_param(p, base, cmd)? } else { rect_param(&json!({"x": p.get("x"), "y": p.get("y")}), base, cmd)? }; + let rect = if preset.is_empty() || p.get("rect").is_some() { + rect_param(p, base, cmd)? + } else { + rect_param(&json!({"x": p.get("x"), "y": p.get("y")}), base, cmd)? + }; let background = background_param(p, cmd)?.unwrap_or_default(); let name = p.get("name").and_then(Value::as_str).filter(|n| !n.is_empty()).map(str::to_string); let id = s.edit("New Artboard", |doc, active| { @@ -370,10 +374,20 @@ pub fn raster_pdf(pages: &[(u32, u32, f32, Vec)]) -> Vec { let k = 72.0 / f64::from(dpi.max(1.0)); let (pw, ph) = (f64::from(*w) * k, f64::from(*h) * k); let (contents, image) = (4 + i * 3, 5 + i * 3); - obj(&mut out, format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {pw:.3} {ph:.3}] /Resources << /XObject << /Im0 {image} 0 R >> >> /Contents {contents} 0 R >>").as_bytes()); + obj( + &mut out, + format!( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {pw:.3} {ph:.3}] /Resources << /XObject << /Im0 {image} 0 R >> >> /Contents {contents} 0 R >>" + ) + .as_bytes(), + ); let stream = format!("q {pw:.3} 0 0 {ph:.3} 0 0 cm /Im0 Do Q"); obj(&mut out, format!("<< /Length {} >>\nstream\n{stream}\nendstream", stream.len()).as_bytes()); - let mut img = format!("<< /Type /XObject /Subtype /Image /Width {w} /Height {h} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length {} >>\nstream\n", jpeg.len()).into_bytes(); + let mut img = format!( + "<< /Type /XObject /Subtype /Image /Width {w} /Height {h} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length {} >>\nstream\n", + jpeg.len() + ) + .into_bytes(); img.extend_from_slice(jpeg); img.extend_from_slice(b"\nendstream"); obj(&mut out, &img); @@ -421,13 +435,62 @@ pub fn specs() -> Vec { }; } vec![ - spec!("layer.new.artboard", "Artboard…", &["Layer", "New"], r##"{"rect":[x,y,w,h]? | "x","y","width","height"? (default: canvas size, right of the last board),"preset":"iPhone 14|Web 1920|A4|…"?,"name":str?,"background":"white|black|transparent|custom"="white","color":[r,g,b]|"#rrggbb"? (custom)} → {layer, rect}"##, has_doc, new_artboard), - spec!("layer.new.artboardFromGroup", "Artboard from Group…", &["Layer", "New"], r##"{"layer":id? (a top-level group; default active),"background":…?} → {layer, rect}"##, has_plain_group, artboard_from_group), - spec!("layer.new.artboardFromLayers", "Artboard from Layers…", &["Layer", "New"], r##"{"name":str?,"background":…?} (the selected layers) → {layer, rect}"##, has_free_layers, artboard_from_layers), - spec!("layer.artboard.set", "Edit Artboard", &[], r##"{"layer":id? (default: the active artboard),"x","y","width","height"?|"rect":[x,y,w,h]?,"preset":str?,"background":"white|black|transparent|custom"?,"color":…?,"name":str?,"moveContents":bool=true} → {layer, rect}"##, has_active_artboard, set_props), - spec!("view.clearSelectedArtboardGuides", "Clear Selected Artboard Guides", &["View"], "{} (guides inside the active artboard)", has_active_artboard, |s, _| clear_artboard_guides(s)), - spec!("file.export.artboardsToFiles", "Artboards to Files…", &["File", "Export"], r##"{"dir":folder,"format":"png|jpg|psd|tiff|…"="png","prefix":str=document name ("" = none),"artboards":[id]? (default all),"quality":0..12?} → {files}"##, export_artboards, artboards_to_files), - spec!("file.export.artboardsToPdf", "Artboards to PDF…", &["File", "Export"], r##"{"path":str (.pdf),"artboards":[id]?,"quality":0..12=10} → {path, pages} (one raster page per board)"##, export_artboards, artboards_to_pdf), + spec!( + "layer.new.artboard", + "Artboard…", + &["Layer", "New"], + r##"{"rect":[x,y,w,h]? | "x","y","width","height"? (default: canvas size, right of the last board),"preset":"iPhone 14|Web 1920|A4|…"?,"name":str?,"background":"white|black|transparent|custom"="white","color":[r,g,b]|"#rrggbb"? (custom)} → {layer, rect}"##, + has_doc, + new_artboard + ), + spec!( + "layer.new.artboardFromGroup", + "Artboard from Group…", + &["Layer", "New"], + r##"{"layer":id? (a top-level group; default active),"background":…?} → {layer, rect}"##, + has_plain_group, + artboard_from_group + ), + spec!( + "layer.new.artboardFromLayers", + "Artboard from Layers…", + &["Layer", "New"], + r##"{"name":str?,"background":…?} (the selected layers) → {layer, rect}"##, + has_free_layers, + artboard_from_layers + ), + spec!( + "layer.artboard.set", + "Edit Artboard", + &[], + r##"{"layer":id? (default: the active artboard),"x","y","width","height"?|"rect":[x,y,w,h]?,"preset":str?,"background":"white|black|transparent|custom"?,"color":…?,"name":str?,"moveContents":bool=true} → {layer, rect}"##, + has_active_artboard, + set_props + ), + spec!( + "view.clearSelectedArtboardGuides", + "Clear Selected Artboard Guides", + &["View"], + "{} (guides inside the active artboard)", + has_active_artboard, + |s, _| clear_artboard_guides(s) + ), + spec!( + "file.export.artboardsToFiles", + "Artboards to Files…", + &["File", "Export"], + r##"{"dir":folder,"format":"png|jpg|psd|tiff|…"="png","prefix":str=document name ("" = none),"artboards":[id]? (default all),"quality":0..12?} → {files}"##, + export_artboards, + artboards_to_files + ), + spec!( + "file.export.artboardsToPdf", + "Artboards to PDF…", + &["File", "Export"], + r##"{"path":str (.pdf),"artboards":[id]?,"quality":0..12=10} → {path, pages} (one raster page per board)"##, + export_artboards, + artboards_to_pdf + ), ] } diff --git a/crates/engine/src/artboard_cmds/tests.rs b/crates/engine/src/artboard_cmds/tests.rs index c38f79b7f3..93ac762b52 100644 --- a/crates/engine/src/artboard_cmds/tests.rs +++ b/crates/engine/src/artboard_cmds/tests.rs @@ -158,7 +158,9 @@ fn raster_pdf_xref_offsets_point_at_objects() { #[test] fn disabled_without_artboards() { let mut s = session(8); - for id in ["layer.artboard.set", "view.clearSelectedArtboardGuides", "file.export.artboardsToFiles", "file.export.artboardsToPdf", "layer.new.artboardFromGroup"] { + for id in + ["layer.artboard.set", "view.clearSelectedArtboardGuides", "file.export.artboardsToFiles", "file.export.artboardsToPdf", "layer.new.artboardFromGroup"] + { assert!(!s.is_enabled(id), "{id}"); } assert!(s.is_enabled("layer.new.artboard")); diff --git a/crates/engine/src/automate_cmds.rs b/crates/engine/src/automate_cmds.rs index 00af2646c8..bf370bc3ad 100644 --- a/crates/engine/src/automate_cmds.rs +++ b/crates/engine/src/automate_cmds.rs @@ -52,10 +52,9 @@ pub fn parse_script(text: &str) -> Result> { let v: Value = serde_json::from_str(t).map_err(|e| bad(cmd, format!("script JSON: {e}")))?; let steps = match &v { Value::Array(_) => &v, - Value::Object(o) => o - .get("steps") - .or_else(|| o.get("action").and_then(|a| a.get("steps").or(Some(a)))) - .ok_or_else(|| bad(cmd, "script object needs \"steps\""))?, + Value::Object(o) => { + o.get("steps").or_else(|| o.get("action").and_then(|a| a.get("steps").or(Some(a)))).ok_or_else(|| bad(cmd, "script object needs \"steps\""))? + } _ => unreachable!(), }; return parse_steps(steps, cmd); @@ -77,7 +76,9 @@ fn parse_steps(v: &Value, cmd: &str) -> Result> { let arr = v.as_array().ok_or_else(|| bad(cmd, "steps must be an array"))?; arr.iter() .map(|st| match st { - Value::Array(a) if !a.is_empty() => Ok((a[0].as_str().ok_or_else(|| bad(cmd, "step id must be a string"))?.to_string(), a.get(1).cloned().unwrap_or(json!({})))), + Value::Array(a) if !a.is_empty() => { + Ok((a[0].as_str().ok_or_else(|| bad(cmd, "step id must be a string"))?.to_string(), a.get(1).cloned().unwrap_or(json!({})))) + } Value::Object(o) => { let id = o.get("command").or_else(|| o.get("id")).and_then(Value::as_str).ok_or_else(|| bad(cmd, "step needs \"command\""))?; Ok((id.to_string(), o.get("params").cloned().unwrap_or(json!({})))) @@ -176,7 +177,11 @@ fn script_events(s: &mut Session, p: &Value) -> Result { ev.bindings.remove(i as usize); } // The dialog's flat form: "event" + "script" at the top level means add. - let flat = p.get("event").and_then(Value::as_str).filter(|_| p.get("script").and_then(Value::as_str).is_some_and(|v| !v.is_empty())).map(|e| json!({"event": e, "script": p["script"], "name": p.get("name").cloned().unwrap_or(Value::Null)})); + let flat = p + .get("event") + .and_then(Value::as_str) + .filter(|_| p.get("script").and_then(Value::as_str).is_some_and(|v| !v.is_empty())) + .map(|e| json!({"event": e, "script": p["script"], "name": p.get("name").cloned().unwrap_or(Value::Null)})); if let Some(a) = p.get("add").cloned().or(flat).as_ref() { let event = a.get("event").and_then(Value::as_str).ok_or_else(|| bad(cmd, "add needs \"event\""))?; if !EVENTS.iter().any(|(id, _)| *id == event) { @@ -190,7 +195,12 @@ fn script_events(s: &mut Session, p: &Value) -> Result { if let Some(st) = &steps { check_known(&parse_steps(st, cmd)?, cmd)?; } - let name = a.get("name").and_then(Value::as_str).filter(|n| !n.is_empty()).map(str::to_string).unwrap_or_else(|| script.as_deref().map(file_name).unwrap_or_else(|| "Action".into())); + let name = a + .get("name") + .and_then(Value::as_str) + .filter(|n| !n.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| script.as_deref().map(file_name).unwrap_or_else(|| "Action".into())); ev.bindings.push(ScriptBinding { event: event.into(), script, steps, name }); if a.get("enable").and_then(Value::as_bool).unwrap_or(true) { ev.enabled = true; @@ -293,7 +303,10 @@ fn write_shim(droplet: &str) -> Result { let abs = std::fs::canonicalize(droplet).map(|p| p.to_string_lossy().into_owned()).unwrap_or_else(|_| droplet.to_string()); let base = abs.trim_end_matches(".pcdroplet").trim_end_matches(".json"); let shim = format!("{base}.command"); - let body = format!("#!/bin/sh\n# PhotoCraft droplet: runs the action on the files given (or dropped).\nexec \"${{PHOTOCRAFT_CLI:-photocraft-cli}}\" droplet \"{}\" \"$@\"\n", abs.replace('"', "\\\"")); + let body = format!( + "#!/bin/sh\n# PhotoCraft droplet: runs the action on the files given (or dropped).\nexec \"${{PHOTOCRAFT_CLI:-photocraft-cli}}\" droplet \"{}\" \"$@\"\n", + abs.replace('"', "\\\"") + ); std::fs::write(&shim, body).map_err(|e| EngineError::Other(format!("{shim}: {e}")))?; #[cfg(unix)] { @@ -346,7 +359,10 @@ fn run_droplet(s: &mut Session, p: &Value) -> Result { fn statistics(s: &mut Session, p: &Value) -> Result { let cmd = "file.scripts.statistics"; let mode = p.get("mode").or_else(|| p.get("stackMode")).and_then(Value::as_str).unwrap_or("median"); - let sm = photocraft_doc::StackMode::ALL.into_iter().find(|m| m.id().eq_ignore_ascii_case(mode)).ok_or_else(|| bad(cmd, format!("unknown stack mode `{mode}`")))?; + let sm = photocraft_doc::StackMode::ALL + .into_iter() + .find(|m| m.id().eq_ignore_ascii_case(mode)) + .ok_or_else(|| bad(cmd, format!("unknown stack mode `{mode}`")))?; let input = p.get("input").or_else(|| p.get("paths")).cloned().ok_or_else(|| bad(cmd, "missing \"input\" (files or a folder)"))?; let align = p.get("align").and_then(Value::as_bool).unwrap_or(false); let r = s.execute("file.scripts.loadFilesIntoStack", json!({"paths": input, "createSmartObject": !align}))?; @@ -498,12 +514,54 @@ pub fn specs() -> Vec { } let native = crate::file_cmds::native; vec![ - spec!("file.automate.contactSheetII", "Contact Sheet II…", &["File", "Automate"], r##"{"input":folder|[paths],"units":"inches|cm|mm|pixels"="inches","width":8,"height":10,"resolution":ppi=300,"mode":"rgb|gray|cmyk|lab"="rgb","depth":8|16=8,"columns":5,"rows":6,"placeAcrossFirst":bool=true,"autoSpacing":bool=true,"horizontal":units?,"vertical":units?,"rotateForBestFit":bool=false,"caption":bool=true (file name as caption),"font":family?,"fontSize":pt=12,"flatten":bool=false} → {documents, pages, images}"##, native, contact_sheet), - spec!("file.automate.createDroplet", "Create Droplet…", &["File", "Automate"], r##"{"path":str (.pcdroplet),"steps":[[id,params]…] (the action),"name":str?,"output":folder?,"format":"same|png|jpg|…"?,"quality":0..12?,"shim":bool=true on macOS/Linux (writes .command calling `photocraft-cli droplet`)} → {path, shim}"##, native, create_droplet), - spec!("file.automate.runDroplet", "Run Droplet", &[], r##"{"droplet":path,"input":[files or folders],"output":folder? (default: droplet's, else /droplet-output)} → {files, errors}"##, native, run_droplet), - spec!("file.scripts.statistics", "Statistics…", &["File", "Scripts"], r##"{"mode":"mean|median|maximum|minimum|range|summation|variance|standardDeviation|skewness|kurtosis|entropy"="median","input":folder|[paths],"align":bool=false (Auto-Align first)} → new document with one stack-mode smart object"##, native, statistics), - spec!("file.scripts.browse", "Browse…", &["File", "Scripts"], r##"{"path":script file | "script":text | "steps":[[id,params]…]} (JSON action, or one `command.id {json}` per line) → {steps, ok, results}"##, |_| Ok(()), browse), - spec!("file.scripts.scriptEventsManager", "Script Events Manager…", &["File", "Scripts"], r##"{"enabled":bool?,"add":{"event":"startApplication|newDocument|openDocument|saveDocument|closeDocument|print|export|everything","script":path?|"steps":[…]?,"name":str?}?,"remove":index?,"removeAll":bool?} → {enabled, bindings, events}"##, |_| Ok(()), script_events), + spec!( + "file.automate.contactSheetII", + "Contact Sheet II…", + &["File", "Automate"], + r##"{"input":folder|[paths],"units":"inches|cm|mm|pixels"="inches","width":8,"height":10,"resolution":ppi=300,"mode":"rgb|gray|cmyk|lab"="rgb","depth":8|16=8,"columns":5,"rows":6,"placeAcrossFirst":bool=true,"autoSpacing":bool=true,"horizontal":units?,"vertical":units?,"rotateForBestFit":bool=false,"caption":bool=true (file name as caption),"font":family?,"fontSize":pt=12,"flatten":bool=false} → {documents, pages, images}"##, + native, + contact_sheet + ), + spec!( + "file.automate.createDroplet", + "Create Droplet…", + &["File", "Automate"], + r##"{"path":str (.pcdroplet),"steps":[[id,params]…] (the action),"name":str?,"output":folder?,"format":"same|png|jpg|…"?,"quality":0..12?,"shim":bool=true on macOS/Linux (writes .command calling `photocraft-cli droplet`)} → {path, shim}"##, + native, + create_droplet + ), + spec!( + "file.automate.runDroplet", + "Run Droplet", + &[], + r##"{"droplet":path,"input":[files or folders],"output":folder? (default: droplet's, else /droplet-output)} → {files, errors}"##, + native, + run_droplet + ), + spec!( + "file.scripts.statistics", + "Statistics…", + &["File", "Scripts"], + r##"{"mode":"mean|median|maximum|minimum|range|summation|variance|standardDeviation|skewness|kurtosis|entropy"="median","input":folder|[paths],"align":bool=false (Auto-Align first)} → new document with one stack-mode smart object"##, + native, + statistics + ), + spec!( + "file.scripts.browse", + "Browse…", + &["File", "Scripts"], + r##"{"path":script file | "script":text | "steps":[[id,params]…]} (JSON action, or one `command.id {json}` per line) → {steps, ok, results}"##, + |_| Ok(()), + browse + ), + spec!( + "file.scripts.scriptEventsManager", + "Script Events Manager…", + &["File", "Scripts"], + r##"{"enabled":bool?,"add":{"event":"startApplication|newDocument|openDocument|saveDocument|closeDocument|print|export|everything","script":path?|"steps":[…]?,"name":str?}?,"remove":index?,"removeAll":bool?} → {enabled, bindings, events}"##, + |_| Ok(()), + script_events + ), ] } diff --git a/crates/engine/src/automate_cmds/tests.rs b/crates/engine/src/automate_cmds/tests.rs index 2f862bda9f..9a51f68082 100644 --- a/crates/engine/src/automate_cmds/tests.rs +++ b/crates/engine/src/automate_cmds/tests.rs @@ -13,7 +13,11 @@ fn images(dir: &str, n: usize, w: u32, h: u32) -> Vec { .map(|i| { let mut s = Session::new(); let v = (i as f32 * 40.0 + 20.0) / 255.0; - s.execute("file.new", json!({"width": w, "height": h, "background": format!("#{:02x}{:02x}{:02x}", (v * 255.0) as u8, (v * 255.0) as u8, (v * 255.0) as u8)})).unwrap(); + s.execute( + "file.new", + json!({"width": w, "height": h, "background": format!("#{:02x}{:02x}{:02x}", (v * 255.0) as u8, (v * 255.0) as u8, (v * 255.0) as u8)}), + ) + .unwrap(); let path = format!("{dir}/img{i}.png"); crate::file_cmds::save_doc(&s.active().unwrap().doc, &path, None).unwrap(); path @@ -53,7 +57,9 @@ fn browse_runs_a_script_file() { #[test] fn script_events_fire_on_new_and_close() { let mut s = Session::new(); - let r = s.execute("file.scripts.scriptEventsManager", json!({"add": {"event": "newDocument", "steps": [["image.imageRotation.90cw", {}]], "name": "Rotate"}})).unwrap(); + let r = s + .execute("file.scripts.scriptEventsManager", json!({"add": {"event": "newDocument", "steps": [["image.imageRotation.90cw", {}]], "name": "Rotate"}})) + .unwrap(); assert_eq!(r["enabled"], true); assert_eq!(r["bindings"].as_array().unwrap().len(), 1); assert!(s.execute("file.scripts.scriptEventsManager", json!({"add": {"event": "nope", "steps": []}})).is_err()); @@ -82,7 +88,12 @@ fn droplets_are_written_and_run() { let inputs = images(&dir, 2, 40, 20); let mut s = Session::new(); let path = format!("{dir}/Rotate"); - let r = s.execute("file.automate.createDroplet", json!({"path": path, "steps": [["image.imageRotation.90cw", {}]], "format": "png", "output": format!("{dir}/out")})).unwrap(); + let r = s + .execute( + "file.automate.createDroplet", + json!({"path": path, "steps": [["image.imageRotation.90cw", {}]], "format": "png", "output": format!("{dir}/out")}), + ) + .unwrap(); let dp = r["path"].as_str().unwrap().to_string(); assert!(dp.ends_with("Rotate.pcdroplet")); let v: Value = serde_json::from_slice(&std::fs::read(&dp).unwrap()).unwrap(); @@ -141,7 +152,12 @@ fn contact_sheet_places_thumbnails_with_captions() { let b = thumb.surface().unwrap().content_bounds(); assert!(b.width() <= 200 && b.height() < 150, "fits its cell: {b:?}"); // Flattened variant. - let r = s.execute("file.automate.contactSheetII", json!({"input": dir, "units": "pixels", "width": 200, "height": 200, "columns": 3, "rows": 2, "caption": false, "flatten": true})).unwrap(); + let r = s + .execute( + "file.automate.contactSheetII", + json!({"input": dir, "units": "pixels", "width": 200, "height": 200, "columns": 3, "rows": 2, "caption": false, "flatten": true}), + ) + .unwrap(); let d = &s.documents()[r["documents"][0].as_u64().unwrap() as usize].doc; assert_eq!(d.layers.len(), 1); } diff --git a/crates/engine/src/brush_cmds.rs b/crates/engine/src/brush_cmds.rs index 1fa109e83e..9c81807752 100644 --- a/crates/engine/src/brush_cmds.rs +++ b/crates/engine/src/brush_cmds.rs @@ -440,9 +440,30 @@ macro_rules! spec { /// Brush command specs. pub fn specs() -> Vec { vec![ - spec!("paint.pencil", "Pencil", r##"{"points":[[x,y,pressure?,tiltX?,tiltY?,rotation?,timeMs?,wheel?],…],"brush":{…}?,"preset":name?,"size":px?,"opacity":0..1?,"color":"#rrggbb"?=foreground,"mode":"normal|multiply|screen|…"="normal","erase":bool?,"autoErase":bool=false,"seed":u64?,"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target}"##, has_paintable, pencil, true), - spec!("paint.mixerBrush", "Mixer Brush", r##"{"points":[…],"brush":{…}?,"preset":name?,"size":px?,"wet":0..100=50,"load":0..100=50,"mix":0..100=50,"flow":0..100=100,"color":"#rrggbb"?=foreground,"sampleAllLayers":bool=false,"cleanAfterStroke":bool=true,"loadAfterStroke":bool=true,"seed":u64?}"##, has_paintable, mixer_brush, true), - spec!("paint.colorReplacement", "Color Replacement", r##"{"points":[…],"brush":{…}?,"size":px?,"mode":"hue|saturation|color|luminosity"="color","sampling":"continuous|once|backgroundSwatch"="continuous","limits":"contiguous|discontiguous|findEdges"="contiguous","tolerance":0..100=30,"antiAlias":bool=true,"color":"#rrggbb"?=foreground,"seed":u64?}"##, crate::commands::has_paintable, color_replacement, true), + spec!( + "paint.pencil", + "Pencil", + r##"{"points":[[x,y,pressure?,tiltX?,tiltY?,rotation?,timeMs?,wheel?],…],"brush":{…}?,"preset":name?,"size":px?,"opacity":0..1?,"color":"#rrggbb"?=foreground,"mode":"normal|multiply|screen|…"="normal","erase":bool?,"autoErase":bool=false,"seed":u64?,"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target}"##, + has_paintable, + pencil, + true + ), + spec!( + "paint.mixerBrush", + "Mixer Brush", + r##"{"points":[…],"brush":{…}?,"preset":name?,"size":px?,"wet":0..100=50,"load":0..100=50,"mix":0..100=50,"flow":0..100=100,"color":"#rrggbb"?=foreground,"sampleAllLayers":bool=false,"cleanAfterStroke":bool=true,"loadAfterStroke":bool=true,"seed":u64?}"##, + has_paintable, + mixer_brush, + true + ), + spec!( + "paint.colorReplacement", + "Color Replacement", + r##"{"points":[…],"brush":{…}?,"size":px?,"mode":"hue|saturation|color|luminosity"="color","sampling":"continuous|once|backgroundSwatch"="continuous","limits":"contiguous|discontiguous|findEdges"="contiguous","tolerance":0..100=30,"antiAlias":bool=true,"color":"#rrggbb"?=foreground,"seed":u64?}"##, + crate::commands::has_paintable, + color_replacement, + true + ), spec!("brush.presets.list", "List Brush Presets", r##"{"full":bool=false}"##, always, presets_list, false), spec!("brush.presets.save", "Save Brush Preset", r##"{"name":string,"brush":{…BrushSettings}?=current brush}"##, always, presets_save, true), spec!("brush.presets.delete", "Delete Brush Preset", r##"{"name":string}"##, always, presets_delete, true), diff --git a/crates/engine/src/brush_cmds/tests.rs b/crates/engine/src/brush_cmds/tests.rs index c09d1f8f9b..d5e839db91 100644 --- a/crates/engine/src/brush_cmds/tests.rs +++ b/crates/engine/src/brush_cmds/tests.rs @@ -41,7 +41,8 @@ fn stroke_accepts_full_points_brush_and_preset() { // Tilt up = 90°: the flat tip stands vertically. assert!(rgba(&s, 50, 40)[3] > 0.9 && rgba(&s, 50, 60)[3] > 0.9 && rgba(&s, 60, 50)[3] < 0.01); // Object points. - s.execute("paint.stroke", json!({"points": [{"x": 150, "y": 20}, {"x": 180, "y": 20, "pressure": 1.0}], "size": 4, "brush": {"pressureSize": false}})).unwrap(); + s.execute("paint.stroke", json!({"points": [{"x": 150, "y": 20}, {"x": 180, "y": 20, "pressure": 1.0}], "size": 4, "brush": {"pressureSize": false}})) + .unwrap(); assert!(rgba(&s, 165, 20)[3] > 0.9); // Presets by name. s.execute("paint.stroke", json!({"points": [[20, 80], [120, 80]], "preset": "Hard Round"})).unwrap(); @@ -103,7 +104,11 @@ fn pencil_is_aliased_and_auto_erases() { fn mixer_brush_reservoir_and_cleaning() { let mut s = session(200, 30); s.execute("tools.setColors", json!({"foreground": "#ff0000"})).unwrap(); - s.execute("paint.mixerBrush", json!({"points": [[5, 15], [195, 15]], "size": 8, "wet": 0, "load": 0, "mix": 0, "brush": {"pressureSize": false, "spacing": 0.25}})).unwrap(); + s.execute( + "paint.mixerBrush", + json!({"points": [[5, 15], [195, 15]], "size": 8, "wet": 0, "load": 0, "mix": 0, "brush": {"pressureSize": false, "spacing": 0.25}}), + ) + .unwrap(); assert!(rgba(&s, 10, 15)[3] > 0.95 && rgba(&s, 190, 15)[3] < 0.5, "low load dries out"); assert!(s.tools.mixer.pickup.is_none(), "cleaned after stroke"); // Wet, not cleaned: the pickup persists into the session. @@ -124,7 +129,8 @@ fn color_replacement_modes() { let lum = |c: [f32; 4]| photocraft_color::blend::lum([c[0], c[1], c[2]]); assert!(c[0] > c[1], "{c:?}"); assert!((lum(c) - lum(before)).abs() < 0.02); - s.execute("paint.colorReplacement", json!({"points": [[10, 5], [50, 5]], "size": 6, "mode": "luminosity", "sampling": "once", "limits": "discontiguous"})).unwrap(); + s.execute("paint.colorReplacement", json!({"points": [[10, 5], [50, 5]], "size": 6, "mode": "luminosity", "sampling": "once", "limits": "discontiguous"})) + .unwrap(); assert!((lum(rgba(&s, 30, 5)) - 0.3).abs() < 0.02); assert!(s.execute("paint.colorReplacement", json!({"points": [[1, 1]], "mode": "bogus"})).is_err()); } @@ -203,7 +209,6 @@ fn works_on_cmyk_and_16_bit() { } } - #[test] fn brush_blend_mode_multiply() { // Painting with a blend mode (options-bar "Mode"): blue × yellow = black (Multiply). diff --git a/crates/engine/src/channel_cmds.rs b/crates/engine/src/channel_cmds.rs index 524f1f5731..30491ca8aa 100644 --- a/crates/engine/src/channel_cmds.rs +++ b/crates/engine/src/channel_cmds.rs @@ -80,9 +80,7 @@ impl ChannelView { /// True when the canvas shows just the normal composite (nothing extra to draw). pub fn is_plain(&self, doc: &Document) -> bool { let colors = color_count(doc); - self.visible_colors(colors) == colors - && !(0..doc.channels.len()).any(|i| self.alpha_shown(i)) - && (doc.quick_mask.is_none() || self.quick_mask_hidden) + self.visible_colors(colors) == colors && !(0..doc.channels.len()).any(|i| self.alpha_shown(i)) && (doc.quick_mask.is_none() || self.quick_mask_hidden) } } @@ -512,7 +510,9 @@ pub(crate) fn channel_surface_for_filter<'a>(doc: &'a mut Document, p: &Value) - /// Commands whose target follows the Channels panel. fn routed(id: &str) -> bool { - id.starts_with("filter.") || id.starts_with("image.adjustments.") || matches!(id, "paint.stroke" | "paint.pencil" | "paint.bucket" | "paint.gradient" | "paint.mixerBrush" | "edit.fill" | "image.applyImage") + id.starts_with("filter.") + || id.starts_with("image.adjustments.") + || matches!(id, "paint.stroke" | "paint.pencil" | "paint.bucket" | "paint.gradient" | "paint.mixerBrush" | "edit.fill" | "image.applyImage") } /// Fill in `"target"` from the targeted channel (or Quick Mask mode) when the caller gave none. @@ -576,7 +576,10 @@ pub(crate) fn restrict_to_color(before: &Document, after: &mut Document, id: Lay /// The active document targets an alpha channel or is in Quick Mask mode, so pixel commands /// work without a pixel layer. pub(crate) fn edits_channel(s: &Session) -> bool { - s.active().is_some_and(|st| st.doc.quick_mask.is_some() && st.channel_view.target == ChannelTarget::Composite || matches!(st.channel_view.target, ChannelTarget::Alpha(i) if i < st.doc.channels.len())) + s.active().is_some_and(|st| { + st.doc.quick_mask.is_some() && st.channel_view.target == ChannelTarget::Composite + || matches!(st.channel_view.target, ChannelTarget::Alpha(i) if i < st.doc.channels.len()) + }) } // --------------------------------------------------------------------------------------------- @@ -815,7 +818,11 @@ fn new_spot(s: &mut Session, p: &Value) -> Result { let i = s.edit("New Spot Channel", |doc, _| { let area = doc.bounds(); let fmt = channel_format(doc); - let surface = if from_sel && doc.selection.is_some() { plane_surface(&sel::mask_from_surface(doc.selection.as_ref(), area), area, fmt) } else { Surface::new(fmt) }; + let surface = if from_sel && doc.selection.is_some() { + plane_surface(&sel::mask_from_surface(doc.selection.as_ref(), area), area, fmt) + } else { + Surface::new(fmt) + }; let name = name.clone().unwrap_or_else(|| next_alpha_name(doc, "Spot Color")); doc.channels.push(AlphaChannel { spot: Some((color, solidity)), ..AlphaChannel::new(name, surface) }); Ok(doc.channels.len() - 1) @@ -966,7 +973,17 @@ fn move_channel(s: &mut Session, p: &Value) -> Result { v.alpha_visible.resize(n, false); let vis = v.alpha_visible.remove(i); v.alpha_visible.insert(to, vis); - let remap = |t: usize| if t == i { to } else if i < t && t <= to { t - 1 } else if to <= t && t < i { t + 1 } else { t }; + let remap = |t: usize| { + if t == i { + to + } else if i < t && t <= to { + t - 1 + } else if to <= t && t < i { + t + 1 + } else { + t + } + }; if let ChannelTarget::Alpha(t) = v.target { v.target = ChannelTarget::Alpha(remap(t)); } @@ -1100,7 +1117,12 @@ fn options(s: &mut Session, p: &Value) -> Result { } ChanRef::QuickMask => { // Quick Mask Options persist for the session; the live Quick Mask follows them. - let mut probe = AlphaChannel { color: s.quick_mask_options.color, opacity: s.quick_mask_options.opacity, indicates: s.quick_mask_options.indicates, ..AlphaChannel::new("Quick Mask", Surface::new(PixelFormat::GRAY8)) }; + let mut probe = AlphaChannel { + color: s.quick_mask_options.color, + opacity: s.quick_mask_options.opacity, + indicates: s.quick_mask_options.indicates, + ..AlphaChannel::new("Quick Mask", Surface::new(PixelFormat::GRAY8)) + }; apply(&mut probe)?; if probe.spot.is_some() { return Err(bad(cmd, "Quick Mask can't be a spot channel")); @@ -1218,7 +1240,10 @@ fn merge(s: &mut Session, p: &Value) -> Result { other => return Err(bad(cmd, format!("unknown mode `{other}`"))), }; let srcs: Vec = match p.get("documents").and_then(Value::as_array) { - Some(a) => a.iter().map(|v| v.as_u64().map(|i| i as usize).filter(|i| *i < s.documents().len()).ok_or_else(|| bad(cmd, "bad document index"))).collect::>()?, + Some(a) => a + .iter() + .map(|v| v.as_u64().map(|i| i as usize).filter(|i| *i < s.documents().len()).ok_or_else(|| bad(cmd, "bad document index"))) + .collect::>()?, None => mergeable(s).into_iter().take(mode.color_channels()).collect(), }; if srcs.len() < mode.color_channels() { @@ -1285,7 +1310,8 @@ fn apply_image(s: &mut Session, p: &Value) -> Result { let weight = |i: usize| opacity * mask.as_ref().map_or(1.0, |m| m[i]) * selection.as_ref().map_or(1.0, |m| m[i]); if is_channel_target(p) { // A grayscale target gets the luminosity of a colour source (one plane). - let srcp = if src.len() == 1 { src[0].clone() } else { (0..src[0].len()).map(|i| src.iter().map(|pl| pl[i]).sum::() / src.len() as f32).collect() }; + let srcp = + if src.len() == 1 { src[0].clone() } else { (0..src[0].len()).map(|i| src.iter().map(|pl| pl[i]).sum::() / src.len() as f32).collect() }; let (surf, _) = target_surface(doc, None, p)?; let base = read_plane(surf, area); let out: Vec = base.iter().enumerate().map(|(i, &b)| b + (blending.apply(b, srcp[i]) - b) * weight(i)).collect(); @@ -1335,7 +1361,8 @@ fn calculations(s: &mut Session, p: &Value) -> Result { let s2 = source(s, &source_spec(p, "source2", "source2").unwrap_or_else(|| json!({})), cmd, true)?.remove(0); let mask = mask_plane(s, p, cmd)?; // Source 1 is blended onto Source 2. - let out: Vec = s2.iter().zip(&s1).enumerate().map(|(i, (&b, &t))| b + (blending.apply(b, t) - b) * opacity * mask.as_ref().map_or(1.0, |m| m[i])).collect(); + let out: Vec = + s2.iter().zip(&s1).enumerate().map(|(i, (&b, &t))| b + (blending.apply(b, t) - b) * opacity * mask.as_ref().map_or(1.0, |m| m[i])).collect(); let name = p.get("name").and_then(Value::as_str).map(str::to_string); match p.get("result").and_then(Value::as_str).unwrap_or("newChannel") { "newChannel" => { @@ -1407,24 +1434,113 @@ macro_rules! spec { pub fn specs() -> Vec { vec![ - spec!("select.saveSelection", "Save Selection…", ["Select"], None, r##"{"name":str?,"channel":"new"|index|name="new","document":index?,"operation":"new|replace|add|subtract|intersect"="new"}"##, has_selection, save_selection), - spec!("select.loadSelection", "Load Selection…", ["Select"], None, r##"{"channel":index|name|"composite"|"red|green|blue|…"|"transparency"|"mask"|"quickMask"|"selection","layer":id?,"document":index?,"invert":bool=false,"operation":"new|add|subtract|intersect"="new"}"##, has_doc, load_selection), + spec!( + "select.saveSelection", + "Save Selection…", + ["Select"], + None, + r##"{"name":str?,"channel":"new"|index|name="new","document":index?,"operation":"new|replace|add|subtract|intersect"="new"}"##, + has_selection, + save_selection + ), + spec!( + "select.loadSelection", + "Load Selection…", + ["Select"], + None, + r##"{"channel":index|name|"composite"|"red|green|blue|…"|"transparency"|"mask"|"quickMask"|"selection","layer":id?,"document":index?,"invert":bool=false,"operation":"new|add|subtract|intersect"="new"}"##, + has_doc, + load_selection + ), spec!("select.editInQuickMaskMode", "Edit in Quick Mask Mode", ["Select"], Some("Q"), r##"{"on":bool?=toggle}"##, has_doc, quick_mask), - spec!("image.applyImage", "Apply Image…", ["Image"], None, r##"{"source":{"document":index?,"layer":id|"merged"="merged","channel":"composite"|"red|…"|index|"selection"|"transparency"|"mask"="composite","invert":bool=false},"blending":"normal|multiply|screen|overlay|softLight|hardLight|colorDodge|colorBurn|darken|lighten|difference|exclusion|linearBurn|linearDodge|add|subtract|…"="multiply","opacity":0..100=100,"scale":1..2=1,"offset":-255..255=0,"preserveTransparency":bool=false,"mask":{"document","layer","channel","invert"}?,"sourceChannel|sourceDocument|sourceLayer|sourceInvert|maskChannel|…":flat form of source/mask?}"##, has_apply_target, apply_image), - spec!("image.calculations", "Calculations…", ["Image"], None, r##"{"source1":{"document","layer","channel","invert"},"source2":{…},"blending":"multiply|…"="multiply","opacity":0..100=100,"scale":1..2=1,"offset":-255..255=0,"mask":{…}?,"result":"newChannel|newDocument|selection"="newChannel","name":str?}"##, has_doc, calculations), - spec!("channel.new", "New Channel…", [], None, r##"{"name":str?,"fill":"black|white|selection"="black","color":"#rrggbb"="#ff0000","opacity":0..100=50,"indicates":"masked|selected"="masked"}"##, has_doc, new_channel), - spec!("channel.newSpot", "New Spot Channel…", [], None, r##"{"name":str?,"color":"#rrggbb"="#ff0000","solidity":0..100=0,"fromSelection":bool=true}"##, has_doc, new_spot), - spec!("channel.duplicate", "Duplicate Channel…", [], None, r##"{"channel":index|name|"red|…"|"composite"|"quickMask","name":str?,"document":index|"new"?=active,"invert":bool=false}"##, has_doc, duplicate_channel), + spec!( + "image.applyImage", + "Apply Image…", + ["Image"], + None, + r##"{"source":{"document":index?,"layer":id|"merged"="merged","channel":"composite"|"red|…"|index|"selection"|"transparency"|"mask"="composite","invert":bool=false},"blending":"normal|multiply|screen|overlay|softLight|hardLight|colorDodge|colorBurn|darken|lighten|difference|exclusion|linearBurn|linearDodge|add|subtract|…"="multiply","opacity":0..100=100,"scale":1..2=1,"offset":-255..255=0,"preserveTransparency":bool=false,"mask":{"document","layer","channel","invert"}?,"sourceChannel|sourceDocument|sourceLayer|sourceInvert|maskChannel|…":flat form of source/mask?}"##, + has_apply_target, + apply_image + ), + spec!( + "image.calculations", + "Calculations…", + ["Image"], + None, + r##"{"source1":{"document","layer","channel","invert"},"source2":{…},"blending":"multiply|…"="multiply","opacity":0..100=100,"scale":1..2=1,"offset":-255..255=0,"mask":{…}?,"result":"newChannel|newDocument|selection"="newChannel","name":str?}"##, + has_doc, + calculations + ), + spec!( + "channel.new", + "New Channel…", + [], + None, + r##"{"name":str?,"fill":"black|white|selection"="black","color":"#rrggbb"="#ff0000","opacity":0..100=50,"indicates":"masked|selected"="masked"}"##, + has_doc, + new_channel + ), + spec!( + "channel.newSpot", + "New Spot Channel…", + [], + None, + r##"{"name":str?,"color":"#rrggbb"="#ff0000","solidity":0..100=0,"fromSelection":bool=true}"##, + has_doc, + new_spot + ), + spec!( + "channel.duplicate", + "Duplicate Channel…", + [], + None, + r##"{"channel":index|name|"red|…"|"composite"|"quickMask","name":str?,"document":index|"new"?=active,"invert":bool=false}"##, + has_doc, + duplicate_channel + ), spec!("channel.delete", "Delete Channel", [], None, r##"{"channel":index|name?=targeted}"##, has_channels, delete_channel), spec!("channel.rename", "Rename Channel", [], None, r##"{"channel":index|name,"name":str}"##, has_channels, rename_channel), spec!("channel.move", "Reorder Channel", [], None, r##"{"channel":index|name,"to":index}"##, has_channels, move_channel), spec!("channel.target", "Target Channel", [], None, r##"{"channel":"composite"|"red|green|blue|…"|index|name="composite"}"##, has_doc, target_cmd), - spec!("channel.setVisible", "Channel Visibility", [], None, r##"{"channel":"composite"|"red|…"|index|name|"quickMask","visible":bool?=toggle}"##, has_doc, set_visible), - spec!("channel.options", "Channel Options…", [], None, r##"{"channel":index|name|"quickMask","name":str?,"indicates":"masked|selected|spot"?,"color":"#rrggbb"?,"opacity":0..100?,"solidity":0..100?}"##, has_doc, options), + spec!( + "channel.setVisible", + "Channel Visibility", + [], + None, + r##"{"channel":"composite"|"red|…"|index|name|"quickMask","visible":bool?=toggle}"##, + has_doc, + set_visible + ), + spec!( + "channel.options", + "Channel Options…", + [], + None, + r##"{"channel":index|name|"quickMask","name":str?,"indicates":"masked|selected|spot"?,"color":"#rrggbb"?,"opacity":0..100?,"solidity":0..100?}"##, + has_doc, + options + ), spec!("channel.mergeSpot", "Merge Spot Channel", [], None, r##"{"channel":index|name?=targeted or first spot}"##, has_spot, merge_spot), spec!("channel.split", "Split Channels", [], None, r##"{"closeOriginal":bool=true}"##, can_split, split), - spec!("channel.merge", "Merge Channels…", [], None, r##"{"mode":"rgb|cmyk|lab"="rgb","documents":[index,…]?=open grayscale docs of the active size,"name":str?}"##, can_merge, merge), - CommandSpec { id: "channel.list", label: "Channels", menu: &[], shortcut: None, params: "{}", enabled: has_doc, run: |s, _| Ok(list(s)), journal: false }, + spec!( + "channel.merge", + "Merge Channels…", + [], + None, + r##"{"mode":"rgb|cmyk|lab"="rgb","documents":[index,…]?=open grayscale docs of the active size,"name":str?}"##, + can_merge, + merge + ), + CommandSpec { + id: "channel.list", + label: "Channels", + menu: &[], + shortcut: None, + params: "{}", + enabled: has_doc, + run: |s, _| Ok(list(s)), + journal: false, + }, spec!("channel.target.composite", "Target Composite Channel", [], Some("Cmd+2"), "{}", has_doc, |s, _| target_slot(s, 2)), spec!("channel.target.slot3", "Target Channel 3", [], Some("Cmd+3"), "{}", slot_ok::<3>, |s, _| target_slot(s, 3)), spec!("channel.target.slot4", "Target Channel 4", [], Some("Cmd+4"), "{}", slot_ok::<4>, |s, _| target_slot(s, 4)), @@ -1450,5 +1566,9 @@ fn has_apply_target(s: &Session) -> std::result::Result<(), String> { has_layer(s)?; let d = s.active().expect("checked"); let l = d.doc.layer(d.active_layer.expect("checked")).expect("checked"); - if matches!(l.content, LayerContent::Raster(_)) { Ok(()) } else { Err(format!("Apply Image needs a pixel layer (active layer is a {} layer)", l.content.kind_name())) } + if matches!(l.content, LayerContent::Raster(_)) { + Ok(()) + } else { + Err(format!("Apply Image needs a pixel layer (active layer is a {} layer)", l.content.kind_name())) + } } diff --git a/crates/engine/src/channel_cmds/tests.rs b/crates/engine/src/channel_cmds/tests.rs index 399675b9a8..b338d94ed8 100644 --- a/crates/engine/src/channel_cmds/tests.rs +++ b/crates/engine/src/channel_cmds/tests.rs @@ -342,7 +342,8 @@ fn apply_image_hand_computed() { s.execute("channel.new", json!({"fill": "white"})).unwrap(); s.execute("channel.target", json!({"channel": "composite"})).unwrap(); rect(&mut s, 0, 0, 20, 20, "replace"); - s.execute("image.applyImage", json!({"source": {"document": 1, "channel": "red"}, "blending": "add", "scale": 2, "offset": -10, "mask": {"channel": 0}})).unwrap(); + s.execute("image.applyImage", json!({"source": {"document": 1, "channel": "red"}, "blending": "add", "scale": 2, "offset": -10, "mask": {"channel": 0}})) + .unwrap(); let want = ((g + 1.0) / 2.0 - 10.0 / 255.0).clamp(0.0, 1.0); assert!((px(&s, 3, 3)[1] - want).abs() < 0.003); assert!((px(&s, 30, 3)[1] - g).abs() < 0.003, "outside the selection"); @@ -377,10 +378,19 @@ fn calculations_results() { assert_eq!(doc(&s).channels[i].name, "X"); assert_eq!((chan(&s, i, 5, 5), chan(&s, i, 15, 5), chan(&s, i, 25, 5)), (0.0, 1.0, 0.0)); // Screen = union, at 50% opacity; source 1 inverted. - s.execute("image.calculations", json!({"source1": {"channel": 0, "invert": true}, "source2": {"channel": 1}, "blending": "difference", "result": "selection"})).unwrap(); + s.execute( + "image.calculations", + json!({"source1": {"channel": 0, "invert": true}, "source2": {"channel": 1}, "blending": "difference", "result": "selection"}), + ) + .unwrap(); // |s2 - (1 - s1)|: x=5: |0-0|=0, x=15: |1-0|=1, x=35: |0-1|=1 assert_eq!((sel_at(&s, 5, 5), sel_at(&s, 15, 5), sel_at(&s, 35, 5)), (0.0, 1.0, 1.0)); - let r = s.execute("image.calculations", json!({"source1": {"channel": "composite"}, "source2": {"channel": 1}, "blending": "normal", "opacity": 50, "result": "newDocument"})).unwrap(); + let r = s + .execute( + "image.calculations", + json!({"source1": {"channel": "composite"}, "source2": {"channel": 1}, "blending": "normal", "opacity": 50, "result": "newDocument"}), + ) + .unwrap(); assert_eq!(r["document"], 1); let d = doc(&s); assert_eq!(d.mode, ColorMode::Grayscale); diff --git a/crates/engine/src/color_cmds.rs b/crates/engine/src/color_cmds.rs index 87fd627094..2a80af716e 100644 --- a/crates/engine/src/color_cmds.rs +++ b/crates/engine/src/color_cmds.rs @@ -326,7 +326,8 @@ impl ColorState { } let lut = self.display_lut(doc, size)?; let mut bytes = lut.to_rgba8(); - let check = if pv.gamut_warning { Some(GamutCheck::new(&composite_profile(doc), &pv.setup.profile, pv.gamut_threshold).map_err(cms_err)?) } else { None }; + let check = + if pv.gamut_warning { Some(GamutCheck::new(&composite_profile(doc), &pv.setup.profile, pv.gamut_threshold).map_err(cms_err)?) } else { None }; let s = (size - 1) as f32; for (i, px) in bytes.chunks_exact_mut(4).enumerate() { let rgb = [(i % size) as f32 / s, ((i / size) % size) as f32 / s, (i / (size * size)) as f32 / s]; @@ -618,11 +619,13 @@ pub fn convert_mode(s: &mut Session, mode: ColorMode, p: &Value) -> Result s.color.resolve(spec, Some(doc), Some(mode))?, None => s.color.working(mode), }; - if space_mode(dst.color_space) != Some(match mode { - ColorMode::Bitmap | ColorMode::Duotone => ColorMode::Grayscale, - ColorMode::Indexed | ColorMode::Multichannel => ColorMode::Rgb, - m => m, - }) { + if space_mode(dst.color_space) + != Some(match mode { + ColorMode::Bitmap | ColorMode::Duotone => ColorMode::Grayscale, + ColorMode::Indexed | ColorMode::Multichannel => ColorMode::Rgb, + m => m, + }) + { return Err(EngineError::Other(format!("profile `{}` is {:?}, not {mode:?}", dst.description, dst.color_space))); } let intent = intent_or(p, s.color.settings.intent())?; @@ -665,7 +668,8 @@ fn always(_: &Session) -> std::result::Result<(), String> { } fn assign_profile(s: &mut Session, p: &Value) -> Result { - let spec = p.get("profile").and_then(Value::as_str).ok_or_else(|| EngineError::BadParams { cmd: "edit.assignProfile".into(), msg: "missing `profile`".into() })?; + let spec = + p.get("profile").and_then(Value::as_str).ok_or_else(|| EngineError::BadParams { cmd: "edit.assignProfile".into(), msg: "missing `profile`".into() })?; let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; let bytes = if spec == "none" { None @@ -688,7 +692,10 @@ fn assign_profile(s: &mut Session, p: &Value) -> Result { } fn convert_to_profile(s: &mut Session, p: &Value) -> Result { - let spec = p.get("profile").and_then(Value::as_str).ok_or_else(|| EngineError::BadParams { cmd: "edit.convertToProfile".into(), msg: "missing `profile`".into() })?; + let spec = p + .get("profile") + .and_then(Value::as_str) + .ok_or_else(|| EngineError::BadParams { cmd: "edit.convertToProfile".into(), msg: "missing `profile`".into() })?; let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; let dst = s.color.resolve(spec, Some(doc), None)?; let intent = intent_or(p, s.color.settings.intent())?; @@ -699,10 +706,8 @@ fn convert_to_profile(s: &mut Session, p: &Value) -> Result { } fn profile_info(s: &mut Session, p: &Value) -> Result { - let builtins: Vec = Builtin::ALL - .iter() - .map(|b| json!({ "id": b.id(), "description": b.description(), "colorSpace": format!("{:?}", b.profile().color_space) })) - .collect(); + let builtins: Vec = + Builtin::ALL.iter().map(|b| json!({ "id": b.id(), "description": b.description(), "colorSpace": format!("{:?}", b.profile().color_space) })).collect(); let doc = s.active().map(|d| d.doc.clone()); let info = match p.get("profile").and_then(Value::as_str) { Some(spec) => { @@ -724,10 +729,19 @@ fn proof_setup(s: &mut Session, p: &Value) -> Result { let name = p.get("profile").and_then(Value::as_str).unwrap_or("working-cmyk").to_string(); let profile = s.color.resolve(&name, Some(&doc), Some(ColorMode::Cmyk))?; let intent = intent_param(p)?; - let setup = ProofSetup { name, profile, intent, bpc: bool_param(p, "bpc", true), simulate_paper: bool_param(p, "simulatePaper", false), kind: crate::proof_sim::ProofKind::Profile }; + let setup = ProofSetup { + name, + profile, + intent, + bpc: bool_param(p, "bpc", true), + simulate_paper: bool_param(p, "simulatePaper", false), + kind: crate::proof_sim::ProofKind::Profile, + }; let pv = s.color.proof_mut(id); pv.setup = setup.clone(); - Ok(json!({ "profile": setup.profile.description, "intent": setup.intent.id(), "bpc": setup.bpc, "simulatePaper": setup.simulate_paper, "proofColors": pv.enabled })) + Ok( + json!({ "profile": setup.profile.description, "intent": setup.intent.id(), "bpc": setup.bpc, "simulatePaper": setup.simulate_paper, "proofColors": pv.enabled }), + ) } fn proof_colors(s: &mut Session, p: &Value) -> Result { @@ -777,7 +791,13 @@ fn color_settings(s: &mut Session, p: &Value) -> Result { *slot = Policy::parse(v).ok_or_else(|| EngineError::BadParams { cmd: cmd.into(), msg: format!("`{k}` must be preserve|convert|off") })?; } } - for (k, slot) in [("askOnMismatch", &mut next.ask_on_mismatch), ("askOnPaste", &mut next.ask_on_paste), ("askOnMissing", &mut next.ask_on_missing), ("bpc", &mut next.bpc), ("dither", &mut next.dither)] { + for (k, slot) in [ + ("askOnMismatch", &mut next.ask_on_mismatch), + ("askOnPaste", &mut next.ask_on_paste), + ("askOnMissing", &mut next.ask_on_missing), + ("bpc", &mut next.bpc), + ("dither", &mut next.dither), + ] { if let Some(v) = p.get(k).and_then(Value::as_bool) { *slot = v; } @@ -789,7 +809,10 @@ fn color_settings(s: &mut Session, p: &Value) -> Result { Some(Value::Bool(false)) => next.blend_text_gamma = 1.0, Some(Value::Bool(true)) => next.blend_text_gamma = photocraft_compose::psblend::TEXT_GAMMA, Some(v) => { - let g = v.as_f64().filter(|g| (1.0..=2.2).contains(g)).ok_or_else(|| EngineError::BadParams { cmd: cmd.into(), msg: "`blendTextGamma` must be 1.0..2.2 or a bool".into() })?; + let g = v + .as_f64() + .filter(|g| (1.0..=2.2).contains(g)) + .ok_or_else(|| EngineError::BadParams { cmd: cmd.into(), msg: "`blendTextGamma` must be 1.0..2.2 or a bool".into() })?; next.blend_text_gamma = g as f32; } None => {} @@ -837,7 +860,10 @@ fn profile_mismatch(s: &mut Session, p: &Value) -> Result { })?; Ok(json!({"action": "assigned", "profile": w.description})) } - other => Err(EngineError::BadParams { cmd: "color.profileMismatch".into(), msg: format!("unknown action `{other}` (preserve|convert|discard|assignWorking)") }), + other => Err(EngineError::BadParams { + cmd: "color.profileMismatch".into(), + msg: format!("unknown action `{other}` (preserve|convert|discard|assignWorking)"), + }), } } @@ -863,8 +889,24 @@ macro_rules! spec { /// Colour management command specs. pub fn specs() -> Vec { vec![ - spec!("edit.assignProfile", "Assign Profile…", ["Edit"], r##"{"profile":"working|srgb|display-p3|adobe-rgb-compat|prophoto-compat|linear-srgb|rec2020|gray-gamma-2.2|sgray|lab-d50|coated-cmyk|none" (or a path to an .icc file)}"##, has_doc, assign_profile, true), - spec!("edit.convertToProfile", "Convert to Profile…", ["Edit"], r##"{"profile":"srgb|display-p3|adobe-rgb-compat|prophoto-compat|linear-srgb|rec2020|gray-gamma-2.2|sgray|lab-d50|coated-cmyk|working" (or a path to an .icc file),"intent":"perceptual|relative|saturation|absolute"="relative","bpc":bool=true}"##, has_doc, convert_to_profile, true), + spec!( + "edit.assignProfile", + "Assign Profile…", + ["Edit"], + r##"{"profile":"working|srgb|display-p3|adobe-rgb-compat|prophoto-compat|linear-srgb|rec2020|gray-gamma-2.2|sgray|lab-d50|coated-cmyk|none" (or a path to an .icc file)}"##, + has_doc, + assign_profile, + true + ), + spec!( + "edit.convertToProfile", + "Convert to Profile…", + ["Edit"], + r##"{"profile":"srgb|display-p3|adobe-rgb-compat|prophoto-compat|linear-srgb|rec2020|gray-gamma-2.2|sgray|lab-d50|coated-cmyk|working" (or a path to an .icc file),"intent":"perceptual|relative|saturation|absolute"="relative","bpc":bool=true}"##, + has_doc, + convert_to_profile, + true + ), spec!( "edit.colorSettings", "Color Settings…", @@ -875,11 +917,36 @@ pub fn specs() -> Vec { true, "Cmd+Shift+K" ), - spec!("color.profileMismatch", "Embedded Profile Mismatch", [], r##"{"action":"preserve|convert|discard|assignWorking"}"##, has_doc, profile_mismatch, true), + spec!( + "color.profileMismatch", + "Embedded Profile Mismatch", + [], + r##"{"action":"preserve|convert|discard|assignWorking"}"##, + has_doc, + profile_mismatch, + true + ), spec!("edit.profileInfo", "Profile Info", [], r##"{"profile":"|document|/path/to/profile.icc"=document}"##, always, profile_info, false), - spec!("view.proofSetup", "Proof Setup…", [], r##"{"profile":"working-cmyk|srgb|display-p3|adobe-rgb-compat|prophoto-compat|linear-srgb|rec2020|gray-gamma-2.2|sgray|lab-d50|coated-cmyk" (or a path to an .icc file)="working-cmyk","intent":"perceptual|relative|saturation|absolute"="relative","bpc":bool=true,"simulatePaper":bool=false}"##, has_doc, proof_setup, false), + spec!( + "view.proofSetup", + "Proof Setup…", + [], + r##"{"profile":"working-cmyk|srgb|display-p3|adobe-rgb-compat|prophoto-compat|linear-srgb|rec2020|gray-gamma-2.2|sgray|lab-d50|coated-cmyk" (or a path to an .icc file)="working-cmyk","intent":"perceptual|relative|saturation|absolute"="relative","bpc":bool=true,"simulatePaper":bool=false}"##, + has_doc, + proof_setup, + false + ), spec!("view.proofColors", "Proof Colors", ["View"], r##"{"on":bool=toggle}"##, has_doc, proof_colors, false, "Cmd+Y"), - spec!("view.gamutWarning", "Gamut Warning", ["View"], r##"{"on":bool=toggle,"threshold":deltaE=4,"profile":""}"##, has_doc, gamut_warning, false, "Cmd+Shift+Y"), + spec!( + "view.gamutWarning", + "Gamut Warning", + ["View"], + r##"{"on":bool=toggle,"threshold":deltaE=4,"profile":""}"##, + has_doc, + gamut_warning, + false, + "Cmd+Shift+Y" + ), ] } diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 8e3916b006..0bb47224af 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -59,7 +59,11 @@ pub(crate) fn has_paintable(s: &Session) -> std::result::Result<(), String> { has_layer(s)?; let d = s.active().unwrap(); let l = d.doc.layer(d.active_layer.unwrap()).unwrap(); - if matches!(l.content, LayerContent::Raster(_)) || l.mask.is_some() { Ok(()) } else { Err(format!("active layer is a {} layer without a mask", l.content.kind_name())) } + if matches!(l.content, LayerContent::Raster(_)) || l.mask.is_some() { + Ok(()) + } else { + Err(format!("active layer is a {} layer without a mask", l.content.kind_name())) + } } /// Surface a paint command writes to: the layer's pixels, or its mask with `"target":"mask"`. @@ -191,7 +195,9 @@ pub fn adjustment_from_params(kind: &str, p: &Value) -> Adjustment { lightness: f32_or(p, "lightness", 0.0), colorize: p.get("colorize").and_then(Value::as_bool).unwrap_or(false), }, - "brightnessContrast" => Adjustment::BrightnessContrast { brightness: f32_or(p, "brightness", 0.0), contrast: f32_or(p, "contrast", 0.0), legacy: false }, + "brightnessContrast" => { + Adjustment::BrightnessContrast { brightness: f32_or(p, "brightness", 0.0), contrast: f32_or(p, "contrast", 0.0), legacy: false } + } "threshold" => Adjustment::Threshold { level: f32_or(p, "level", 128.0) / 255.0 }, "posterize" => Adjustment::Posterize { levels: f32_or(p, "levels", 4.0) as u32 }, "exposure" => Adjustment::Exposure { exposure: f32_or(p, "exposure", 0.0), offset: f32_or(p, "offset", 0.0), gamma: f32_or(p, "gamma", 1.0) }, @@ -241,7 +247,13 @@ pub fn adjustment_from_params(kind: &str, p: &Value) -> Adjustment { "gradientMap" => Adjustment::GradientMap { stops: vec![(0.0, [0.0; 3]), (1.0, [1.0; 3])], reverse: false }, "selectiveColor" => crate::adjust_cmds::selective_from_params(p, None), // Best effort here (a bad LUT file gives an empty lookup); commands use the checked path. - "colorLookup" => crate::adjust_cmds::lookup_from_params(p, None).unwrap_or(Adjustment::ColorLookup { name: String::new(), lut: None, size: 0, tetrahedral: false, dither: false }), + "colorLookup" => crate::adjust_cmds::lookup_from_params(p, None).unwrap_or(Adjustment::ColorLookup { + name: String::new(), + lut: None, + size: 0, + tetrahedral: false, + dither: false, + }), _ => Adjustment::Invert, } } @@ -252,40 +264,48 @@ pub fn adjustment_from_params(kind: &str, p: &Value) -> Adjustment { fn build() -> Vec { let mut v = vec![ // File - cmd!("file.new", "New…", ["File"], Some("Cmd+N"), r##"{"width":u32=1920,"height":u32=1080,"mode":"rgb|gray|cmyk|lab"="rgb","depth":8|16|32=8,"background":"white|black|backgroundColor|transparent|#rrggbb"="white","resolution":ppi=72,"name":str}"##, always, |s, p| { - let w = p.get("width").and_then(Value::as_u64).unwrap_or(1920).clamp(1, 300_000) as u32; - let h = p.get("height").and_then(Value::as_u64).unwrap_or(1080).clamp(1, 300_000) as u32; - let mode = match p.get("mode").and_then(Value::as_str).unwrap_or("rgb") { - "gray" | "grayscale" => ColorMode::Grayscale, - "cmyk" => ColorMode::Cmyk, - "lab" => ColorMode::Lab, - _ => ColorMode::Rgb, - }; - let depth = match p.get("depth").and_then(Value::as_u64).unwrap_or(8) { - 16 => SampleType::U16, - 32 => SampleType::F32, - _ => SampleType::U8, - }; - let name = p.get("name").and_then(Value::as_str).unwrap_or("Untitled").to_string(); - let res = p.get("resolution").and_then(Value::as_f64).unwrap_or(72.0).clamp(1.0, 30_000.0) as f32; - let bgc = s.tools.background; - let mut doc = match p.get("background").and_then(Value::as_str).unwrap_or("white") { - "backgroundColor" => Document::with_background(name, Size::new(w, h), mode, depth, Color::rgba(bgc[0], bgc[1], bgc[2], 1.0)), - "transparent" => { - let mut d = Document::new(name, Size::new(w, h), mode, depth); - d.layers.push(Layer::raster("Layer 1", d.pixel_format())); - d - } - "black" => Document::with_background(name, Size::new(w, h), mode, depth, Color::BLACK), - other => { - let c = parse_hex(other).unwrap_or([1.0; 4]); - Document::with_background(name, Size::new(w, h), mode, depth, Color::rgba(c[0], c[1], c[2], c[3])) - } - }; - doc.resolution_dpi = res; - let i = s.add_document(doc, None); - Ok(json!({ "document": i })) - }), + cmd!( + "file.new", + "New…", + ["File"], + Some("Cmd+N"), + r##"{"width":u32=1920,"height":u32=1080,"mode":"rgb|gray|cmyk|lab"="rgb","depth":8|16|32=8,"background":"white|black|backgroundColor|transparent|#rrggbb"="white","resolution":ppi=72,"name":str}"##, + always, + |s, p| { + let w = p.get("width").and_then(Value::as_u64).unwrap_or(1920).clamp(1, 300_000) as u32; + let h = p.get("height").and_then(Value::as_u64).unwrap_or(1080).clamp(1, 300_000) as u32; + let mode = match p.get("mode").and_then(Value::as_str).unwrap_or("rgb") { + "gray" | "grayscale" => ColorMode::Grayscale, + "cmyk" => ColorMode::Cmyk, + "lab" => ColorMode::Lab, + _ => ColorMode::Rgb, + }; + let depth = match p.get("depth").and_then(Value::as_u64).unwrap_or(8) { + 16 => SampleType::U16, + 32 => SampleType::F32, + _ => SampleType::U8, + }; + let name = p.get("name").and_then(Value::as_str).unwrap_or("Untitled").to_string(); + let res = p.get("resolution").and_then(Value::as_f64).unwrap_or(72.0).clamp(1.0, 30_000.0) as f32; + let bgc = s.tools.background; + let mut doc = match p.get("background").and_then(Value::as_str).unwrap_or("white") { + "backgroundColor" => Document::with_background(name, Size::new(w, h), mode, depth, Color::rgba(bgc[0], bgc[1], bgc[2], 1.0)), + "transparent" => { + let mut d = Document::new(name, Size::new(w, h), mode, depth); + d.layers.push(Layer::raster("Layer 1", d.pixel_format())); + d + } + "black" => Document::with_background(name, Size::new(w, h), mode, depth, Color::BLACK), + other => { + let c = parse_hex(other).unwrap_or([1.0; 4]); + Document::with_background(name, Size::new(w, h), mode, depth, Color::rgba(c[0], c[1], c[2], c[3])) + } + }; + doc.resolution_dpi = res; + let i = s.add_document(doc, None); + Ok(json!({ "document": i })) + } + ), cmd!("file.close", "Close", ["File"], Some("Cmd+W"), r##"{"document":index?}"##, has_doc, |s, p| { let i = p.get("document").and_then(Value::as_u64).map(|v| v as usize).or(s.active_index()).ok_or(EngineError::NoDocument)?; s.close(i).ok_or(EngineError::NoDocument)?; @@ -294,21 +314,29 @@ fn build() -> Vec { // Edit cmd!("edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, |s, _| Ok(json!(s.undo()))), cmd!("edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, |s, _| Ok(json!(s.redo()))), - cmd!("edit.fill", "Fill…", ["Edit"], Some("Shift+F5"), r##"{"contents":"color|pattern"="color","color":"#rrggbb|[r,g,b,a]"=foreground,"pattern":id|name (contents=pattern),"scale":%=100,"angle":deg,"opacity":%=100,"target":"pixels"|{"channel":i}|"quickMask"?}"##, has_pixel_or_channel, |s, p| { - if p.get("contents").and_then(Value::as_str) == Some("pattern") { - return crate::pattern_cmds::fill_with_pattern(s, p); + cmd!( + "edit.fill", + "Fill…", + ["Edit"], + Some("Shift+F5"), + r##"{"contents":"color|pattern"="color","color":"#rrggbb|[r,g,b,a]"=foreground,"pattern":id|name (contents=pattern),"scale":%=100,"angle":deg,"opacity":%=100,"target":"pixels"|{"channel":i}|"quickMask"?}"##, + has_pixel_or_channel, + |s, p| { + if p.get("contents").and_then(Value::as_str) == Some("pattern") { + return crate::pattern_cmds::fill_with_pattern(s, p); + } + let color = color_param(p, "color", s.tools.foreground); + let id = if crate::channel_cmds::is_channel_target(p) { None } else { Some(layer_param(s, p)?) }; + s.edit("Fill", |doc, _| { + let sel = doc.selection.clone(); + let area = sel.as_ref().map(|m| m.content_bounds()).unwrap_or(doc.bounds()); + let (surf, lock) = crate::channel_cmds::target_surface(doc, id, p)?; + pixels::fill_surface(surf, area, color, sel.as_ref(), lock); + Ok(()) + })?; + Ok(Value::Null) } - let color = color_param(p, "color", s.tools.foreground); - let id = if crate::channel_cmds::is_channel_target(p) { None } else { Some(layer_param(s, p)?) }; - s.edit("Fill", |doc, _| { - let sel = doc.selection.clone(); - let area = sel.as_ref().map(|m| m.content_bounds()).unwrap_or(doc.bounds()); - let (surf, lock) = crate::channel_cmds::target_surface(doc, id, p)?; - pixels::fill_surface(surf, area, color, sel.as_ref(), lock); - Ok(()) - })?; - Ok(Value::Null) - }), + ), cmd!("edit.clear", "Clear", ["Edit"], Some("Delete"), "{}", has_pixel_layer, |s, p| { let id = layer_param(s, p)?; s.edit("Clear", |doc, _| { @@ -350,62 +378,70 @@ fn build() -> Vec { })?; Ok(Value::Null) }), - cmd!("select.rect", "Rectangular Selection", [], None, r##"{"x":i32,"y":i32,"width":u32,"height":u32,"mode":"replace|add|subtract|intersect"="replace","ellipse":bool=false,"antiAlias":bool=true,"feather":px=0}"##, has_doc, |s, p| { - let get = |k: &str| int(p, k).ok_or_else(|| bad("select.rect", format!("missing `{k}`"))); - let r = Rect::from_xywh(get("x")? as i32, get("y")? as i32, get("width")?.max(0) as u32, get("height")?.max(0) as u32); - let mode = p.get("mode").and_then(Value::as_str).unwrap_or("replace").to_string(); - let ellipse = p.get("ellipse").and_then(Value::as_bool).unwrap_or(false); - // Options bar: anti-aliased ellipse edges (4x4 supersampled) and Feather (applied to the new shape only). - let aa = p.get("antiAlias").and_then(Value::as_bool).unwrap_or(true); - let feather = p.get("feather").and_then(Value::as_f64).unwrap_or(0.0).clamp(0.0, 1000.0) as f32; - s.edit("Rectangular Marquee", |doc, _| { - let mut shape = Surface::new(photocraft_color::PixelFormat::GRAY8); - if ellipse { - let (cx, cy) = ((r.x0 + r.x1) as f32 / 2.0, (r.y0 + r.y1) as f32 / 2.0); - let (rx, ry) = (r.width() as f32 / 2.0, r.height() as f32 / 2.0); - let inside = |x: f32, y: f32| { - let (dx, dy) = ((x - cx) / rx, (y - cy) / ry); - dx * dx + dy * dy <= 1.0 - }; - for y in r.y0..r.y1 { - for x in r.x0..r.x1 { - let (fx, fy) = (x as f32, y as f32); - let corners = [(fx, fy), (fx + 1.0, fy), (fx, fy + 1.0), (fx + 1.0, fy + 1.0)].iter().filter(|(a, b)| inside(*a, *b)).count(); - let cov = if !aa { - if inside(fx + 0.5, fy + 0.5) { 1.0 } else { 0.0 } - } else if corners == 4 { - 1.0 - } else { - let n = (0..16).filter(|i| inside(fx + ((i % 4) as f32 + 0.5) / 4.0, fy + ((i / 4) as f32 + 0.5) / 4.0)).count(); - n as f32 / 16.0 - }; - if cov > 0.0 { - shape.write_pixel(x, y, &[cov]); + cmd!( + "select.rect", + "Rectangular Selection", + [], + None, + r##"{"x":i32,"y":i32,"width":u32,"height":u32,"mode":"replace|add|subtract|intersect"="replace","ellipse":bool=false,"antiAlias":bool=true,"feather":px=0}"##, + has_doc, + |s, p| { + let get = |k: &str| int(p, k).ok_or_else(|| bad("select.rect", format!("missing `{k}`"))); + let r = Rect::from_xywh(get("x")? as i32, get("y")? as i32, get("width")?.max(0) as u32, get("height")?.max(0) as u32); + let mode = p.get("mode").and_then(Value::as_str).unwrap_or("replace").to_string(); + let ellipse = p.get("ellipse").and_then(Value::as_bool).unwrap_or(false); + // Options bar: anti-aliased ellipse edges (4x4 supersampled) and Feather (applied to the new shape only). + let aa = p.get("antiAlias").and_then(Value::as_bool).unwrap_or(true); + let feather = p.get("feather").and_then(Value::as_f64).unwrap_or(0.0).clamp(0.0, 1000.0) as f32; + s.edit("Rectangular Marquee", |doc, _| { + let mut shape = Surface::new(photocraft_color::PixelFormat::GRAY8); + if ellipse { + let (cx, cy) = ((r.x0 + r.x1) as f32 / 2.0, (r.y0 + r.y1) as f32 / 2.0); + let (rx, ry) = (r.width() as f32 / 2.0, r.height() as f32 / 2.0); + let inside = |x: f32, y: f32| { + let (dx, dy) = ((x - cx) / rx, (y - cy) / ry); + dx * dx + dy * dy <= 1.0 + }; + for y in r.y0..r.y1 { + for x in r.x0..r.x1 { + let (fx, fy) = (x as f32, y as f32); + let corners = [(fx, fy), (fx + 1.0, fy), (fx, fy + 1.0), (fx + 1.0, fy + 1.0)].iter().filter(|(a, b)| inside(*a, *b)).count(); + let cov = if !aa { + if inside(fx + 0.5, fy + 0.5) { 1.0 } else { 0.0 } + } else if corners == 4 { + 1.0 + } else { + let n = (0..16).filter(|i| inside(fx + ((i % 4) as f32 + 0.5) / 4.0, fy + ((i / 4) as f32 + 0.5) / 4.0)).count(); + n as f32 / 16.0 + }; + if cov > 0.0 { + shape.write_pixel(x, y, &[cov]); + } } } + } else { + shape.fill_rect(r, &[1.0]); } - } else { - shape.fill_rect(r, &[1.0]); - } - let area = doc.bounds(); - if feather > 0.0 { - use photocraft_algo::selection as sel; - let m = sel::feather(&sel::mask_from_surface(Some(&shape), area), area.width() as usize, area.height() as usize, feather); - doc.selection = sel::combine(doc.selection.as_ref(), &m, area, sel::SelectionMode::parse(&mode)); - return Ok(()); - } - let old = doc.selection.take(); - let combined = match (mode.as_str(), old) { - ("add", Some(o)) => combine(&o, &shape, area, |a, b| a.max(b)), - ("subtract", Some(o)) => combine(&o, &shape, area, |a, b| a * (1.0 - b)), - ("intersect", Some(o)) => combine(&o, &shape, area, |a, b| a.min(b)), - _ => shape, - }; - doc.selection = Some(combined); - Ok(()) - })?; - Ok(Value::Null) - }), + let area = doc.bounds(); + if feather > 0.0 { + use photocraft_algo::selection as sel; + let m = sel::feather(&sel::mask_from_surface(Some(&shape), area), area.width() as usize, area.height() as usize, feather); + doc.selection = sel::combine(doc.selection.as_ref(), &m, area, sel::SelectionMode::parse(&mode)); + return Ok(()); + } + let old = doc.selection.take(); + let combined = match (mode.as_str(), old) { + ("add", Some(o)) => combine(&o, &shape, area, |a, b| a.max(b)), + ("subtract", Some(o)) => combine(&o, &shape, area, |a, b| a * (1.0 - b)), + ("intersect", Some(o)) => combine(&o, &shape, area, |a, b| a.min(b)), + _ => shape, + }; + doc.selection = Some(combined); + Ok(()) + })?; + Ok(Value::Null) + } + ), // Layer cmd!("layer.new.layer", "Layer…", ["Layer", "New"], Some("Cmd+Shift+N"), r##"{"name":str?}"##, has_doc, |s, p| { let id = s.edit("New Layer", |doc, active| { @@ -425,7 +461,15 @@ fn build() -> Vec { })?; Ok(json!({ "layer": id.0 })) }), - cmd!("layer.groupLayers", "Group Layers", ["Layer"], Some("Cmd+G"), r##"{"layer":id?,"name":str?} (no layer: every selected layer)"##, has_layer, crate::layer_multi_cmds::group_layers), + cmd!( + "layer.groupLayers", + "Group Layers", + ["Layer"], + Some("Cmd+G"), + r##"{"layer":id?,"name":str?} (no layer: every selected layer)"##, + has_layer, + crate::layer_multi_cmds::group_layers + ), cmd!("layer.duplicate", "Duplicate Layer…", ["Layer"], None, r##"{"layer":id?} (no layer: every selected layer)"##, has_layer, |s, p| { if crate::layer_multi_cmds::multi(s, p) { return crate::layer_multi_cmds::duplicate_selected(s); @@ -460,58 +504,82 @@ fn build() -> Vec { })?; Ok(Value::Null) }), - cmd!("layer.select", "Select Layer", [], None, r##"{"layer":id,"mode":"replace|toggle|range|add"="replace"} (toggle = ⌘-click, range = ⇧-click)"##, has_doc, crate::layer_multi_cmds::select), - cmd!("layer.setProps", "Layer Properties", [], None, r##"{"layer":id?,"name":str?,"visible":bool?,"opacity":0..1?,"fill":0..1?,"blend":"Multiply|…"?,"clipped":bool?,"locked":bool?,"locks":{"transparency","pixels","position","artboard","all":bool}?,"channels":[bool,…]? (Advanced Blending: which colour channels blend, R G B / C M Y K / L a b)}"##, has_layer, |s, p| { - let id = layer_param(s, p)?; - let label = if p.get("visible").is_some() && p.as_object().is_some_and(|o| o.len() <= 2) { "Layer Visibility" } else { "Layer Properties" }; - s.edit(label, |doc, _| { - let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; - if let Some(v) = p.get("name").and_then(Value::as_str) { - l.name = v.to_string(); - } - if let Some(v) = p.get("visible").and_then(Value::as_bool) { - l.visible = v; - } - if let Some(v) = p.get("opacity").and_then(Value::as_f64) { - l.opacity = (v as f32).clamp(0.0, 1.0); - } - if let Some(v) = p.get("fill").and_then(Value::as_f64) { - l.fill_opacity = (v as f32).clamp(0.0, 1.0); - } - if let Some(v) = p.get("blend").and_then(Value::as_str) { - l.blend = blend_from_str(v).ok_or_else(|| bad("layer.setProps", format!("unknown blend mode `{v}`")))?; - } - if let Some(v) = p.get("clipped").and_then(Value::as_bool) { - l.clipped = v; - } - if let Some(v) = p.get("locked").and_then(Value::as_bool) { - l.locks.all = v; - } - if let Some(Value::Array(ch)) = p.get("channels") { - // Blending Options › Advanced Blending › Channels: unchecked = left out. - l.excluded_channels = ch.iter().take(32).enumerate().filter(|(_, v)| v.as_bool() == Some(false)).fold(0, |m, (i, _)| m | 1 << i); - } - if let Some(Value::Object(m)) = p.get("locks") { - for (k, v) in m { - let v = v.as_bool().unwrap_or(false); - match k.as_str() { - "transparency" => l.locks.transparency = v, - "pixels" => l.locks.pixels = v, - "position" => l.locks.position = v, - "artboard" => l.locks.artboard = v, - "all" => l.locks.all = v, - other => return Err(bad("layer.setProps", format!("unknown lock `{other}`"))), + cmd!( + "layer.select", + "Select Layer", + [], + None, + r##"{"layer":id,"mode":"replace|toggle|range|add"="replace"} (toggle = ⌘-click, range = ⇧-click)"##, + has_doc, + crate::layer_multi_cmds::select + ), + cmd!( + "layer.setProps", + "Layer Properties", + [], + None, + r##"{"layer":id?,"name":str?,"visible":bool?,"opacity":0..1?,"fill":0..1?,"blend":"Multiply|…"?,"clipped":bool?,"locked":bool?,"locks":{"transparency","pixels","position","artboard","all":bool}?,"channels":[bool,…]? (Advanced Blending: which colour channels blend, R G B / C M Y K / L a b)}"##, + has_layer, + |s, p| { + let id = layer_param(s, p)?; + let label = if p.get("visible").is_some() && p.as_object().is_some_and(|o| o.len() <= 2) { "Layer Visibility" } else { "Layer Properties" }; + s.edit(label, |doc, _| { + let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + if let Some(v) = p.get("name").and_then(Value::as_str) { + l.name = v.to_string(); + } + if let Some(v) = p.get("visible").and_then(Value::as_bool) { + l.visible = v; + } + if let Some(v) = p.get("opacity").and_then(Value::as_f64) { + l.opacity = (v as f32).clamp(0.0, 1.0); + } + if let Some(v) = p.get("fill").and_then(Value::as_f64) { + l.fill_opacity = (v as f32).clamp(0.0, 1.0); + } + if let Some(v) = p.get("blend").and_then(Value::as_str) { + l.blend = blend_from_str(v).ok_or_else(|| bad("layer.setProps", format!("unknown blend mode `{v}`")))?; + } + if let Some(v) = p.get("clipped").and_then(Value::as_bool) { + l.clipped = v; + } + if let Some(v) = p.get("locked").and_then(Value::as_bool) { + l.locks.all = v; + } + if let Some(Value::Array(ch)) = p.get("channels") { + // Blending Options › Advanced Blending › Channels: unchecked = left out. + l.excluded_channels = ch.iter().take(32).enumerate().filter(|(_, v)| v.as_bool() == Some(false)).fold(0, |m, (i, _)| m | 1 << i); + } + if let Some(Value::Object(m)) = p.get("locks") { + for (k, v) in m { + let v = v.as_bool().unwrap_or(false); + match k.as_str() { + "transparency" => l.locks.transparency = v, + "pixels" => l.locks.pixels = v, + "position" => l.locks.position = v, + "artboard" => l.locks.artboard = v, + "all" => l.locks.all = v, + other => return Err(bad("layer.setProps", format!("unknown lock `{other}`"))), + } } } - } - Ok(()) - })?; - Ok(Value::Null) - }), + Ok(()) + })?; + Ok(Value::Null) + } + ), cmd!("layer.arrange.bringForward", "Bring Forward", ["Layer", "Arrange"], Some("Cmd+]"), r##"{"layer":id?}"##, has_layer, |s, p| arrange(s, p, 1)), cmd!("layer.arrange.sendBackward", "Send Backward", ["Layer", "Arrange"], Some("Cmd+["), r##"{"layer":id?}"##, has_layer, |s, p| arrange(s, p, -1)), - cmd!("layer.arrange.bringToFront", "Bring to Front", ["Layer", "Arrange"], Some("Cmd+Shift+]"), r##"{"layer":id?}"##, has_layer, |s, p| arrange(s, p, i32::MAX)), - cmd!("layer.arrange.sendToBack", "Send to Back", ["Layer", "Arrange"], Some("Cmd+Shift+["), r##"{"layer":id?}"##, has_layer, |s, p| arrange(s, p, i32::MIN)), + cmd!("layer.arrange.bringToFront", "Bring to Front", ["Layer", "Arrange"], Some("Cmd+Shift+]"), r##"{"layer":id?}"##, has_layer, |s, p| arrange( + s, + p, + i32::MAX + )), + cmd!("layer.arrange.sendToBack", "Send to Back", ["Layer", "Arrange"], Some("Cmd+Shift+["), r##"{"layer":id?}"##, has_layer, |s, p| arrange( + s, + p, + i32::MIN + )), cmd!("layer.createClippingMask", "Create Clipping Mask", ["Layer"], Some("Cmd+Alt+G"), r##"{"layer":id?}"##, has_layer, |s, p| { let id = layer_param(s, p)?; s.edit("Create Clipping Mask", |doc, _| { @@ -528,14 +596,29 @@ fn build() -> Vec { })?; Ok(Value::Null) }), - cmd!("layer.layerMask.revealAll", "Reveal All", ["Layer", "Layer Mask"], None, r##"{"layer":id?}"##, has_layer, |s, p| set_mask(s, p, "Add Layer Mask", Some(LayerMask::reveal_all()))), - cmd!("layer.layerMask.hideAll", "Hide All", ["Layer", "Layer Mask"], None, r##"{"layer":id?}"##, has_layer, |s, p| set_mask(s, p, "Add Layer Mask", Some(LayerMask::hide_all()))), + cmd!("layer.layerMask.revealAll", "Reveal All", ["Layer", "Layer Mask"], None, r##"{"layer":id?}"##, has_layer, |s, p| set_mask( + s, + p, + "Add Layer Mask", + Some(LayerMask::reveal_all()) + )), + cmd!("layer.layerMask.hideAll", "Hide All", ["Layer", "Layer Mask"], None, r##"{"layer":id?}"##, has_layer, |s, p| set_mask( + s, + p, + "Add Layer Mask", + Some(LayerMask::hide_all()) + )), cmd!("layer.layerMask.revealSelection", "Reveal Selection", ["Layer", "Layer Mask"], None, r##"{"layer":id?}"##, has_selection, |s, p| { let sel = s.active().and_then(|d| d.doc.selection.clone()).ok_or(EngineError::Other("no selection".into()))?; let mask = LayerMask { surface: sel, ..LayerMask::reveal_all() }; set_mask(s, p, "Add Layer Mask", Some(mask)) }), - cmd!("layer.layerMask.delete", "Delete", ["Layer", "Layer Mask"], None, r##"{"layer":id?}"##, has_layer, |s, p| set_mask(s, p, "Delete Layer Mask", None)), + cmd!("layer.layerMask.delete", "Delete", ["Layer", "Layer Mask"], None, r##"{"layer":id?}"##, has_layer, |s, p| set_mask( + s, + p, + "Delete Layer Mask", + None + )), cmd!("layer.mergeDown", "Merge Down", ["Layer"], None, r##"{"layer":id?}"##, has_layer, |s, p| { let id = layer_param(s, p)?; s.edit("Merge Down", |doc, active| { @@ -580,20 +663,34 @@ fn build() -> Vec { })?; Ok(json!({ "layer": id.0 })) }), - cmd!("layer.newFillLayer.gradient", "Gradient…", ["Layer", "New Fill Layer"], None, r##"{"from":"#rrggbb","to":"#rrggbb","angle":deg=90,"style":"linear|radial|angle|reflected|diamond","reverse":bool}"##, has_doc, |s, p| { - let a = color_param(p, "from", s.tools.foreground); - let b = color_param(p, "to", s.tools.background); - let angle = f32_or(p, "angle", 90.0); - let style = crate::layer_style::gradient_style(p.get("style").and_then(Value::as_str).unwrap_or("linear")); - let reverse = p.get("reverse").and_then(Value::as_bool).unwrap_or(false); - let id = s.edit("New Gradient Fill Layer", |doc, active| { - let fill = Fill::Gradient { stops: vec![(0.0, Color::rgba(a[0], a[1], a[2], a[3])), (1.0, Color::rgba(b[0], b[1], b[2], b[3]))], angle, scale: 1.0, style, reverse }; - let id = doc.insert_above(*active, Layer::new(doc.next_layer_name("Gradient Fill"), LayerContent::Fill(fill))); - *active = Some(id); - Ok(id) - })?; - Ok(json!({ "layer": id.0 })) - }), + cmd!( + "layer.newFillLayer.gradient", + "Gradient…", + ["Layer", "New Fill Layer"], + None, + r##"{"from":"#rrggbb","to":"#rrggbb","angle":deg=90,"style":"linear|radial|angle|reflected|diamond","reverse":bool}"##, + has_doc, + |s, p| { + let a = color_param(p, "from", s.tools.foreground); + let b = color_param(p, "to", s.tools.background); + let angle = f32_or(p, "angle", 90.0); + let style = crate::layer_style::gradient_style(p.get("style").and_then(Value::as_str).unwrap_or("linear")); + let reverse = p.get("reverse").and_then(Value::as_bool).unwrap_or(false); + let id = s.edit("New Gradient Fill Layer", |doc, active| { + let fill = Fill::Gradient { + stops: vec![(0.0, Color::rgba(a[0], a[1], a[2], a[3])), (1.0, Color::rgba(b[0], b[1], b[2], b[3]))], + angle, + scale: 1.0, + style, + reverse, + }; + let id = doc.insert_above(*active, Layer::new(doc.next_layer_name("Gradient Fill"), LayerContent::Fill(fill))); + *active = Some(id); + Ok(id) + })?; + Ok(json!({ "layer": id.0 })) + } + ), cmd!("layer.setAdjustment", "Adjustment Properties", [], None, r##"{"layer":id?, …params of that adjustment kind}"##, has_layer, |s, p| { let id = layer_param(s, p)?; s.edit("Modify Adjustment", |doc, _| { @@ -629,7 +726,11 @@ fn build() -> Vec { } other => { let (&last, parent) = path.split_last().expect("non-empty path"); - let sib = if parent.is_empty() { &mut doc.layers } else { doc.layer_at_mut(parent).and_then(|t| t.children_mut()).expect("parent is a group") }; + let sib = if parent.is_empty() { + &mut doc.layers + } else { + doc.layer_at_mut(parent).and_then(|t| t.children_mut()).expect("parent is a group") + }; let at = if other == "below" { last } else { last + 1 }; sib.insert(at.min(sib.len()), layer); } @@ -639,7 +740,15 @@ fn build() -> Vec { })?; Ok(Value::Null) }), - cmd!("layer.translate", "Move Layer", [], None, r##"{"layer":id?,"dx":i32,"dy":i32} (no layer: every selected layer; linked layers follow)"##, has_layer, crate::layer_multi_cmds::translate), + cmd!( + "layer.translate", + "Move Layer", + [], + None, + r##"{"layer":id?,"dx":i32,"dy":i32} (no layer: every selected layer; linked layers follow)"##, + has_layer, + crate::layer_multi_cmds::translate + ), // Image cmd!("image.imageRotation.flipCanvasHorizontal", "Flip Canvas Horizontal", ["Image", "Image Rotation"], None, "{}", has_doc, |s, _| { s.edit("Flip Canvas Horizontal", |doc, _| { @@ -687,7 +796,15 @@ fn build() -> Vec { destructive_adjust(s, "Desaturate", Adjustment::HueSaturation { hue: 0.0, saturation: -100.0, lightness: 0.0, colorize: false }, &Value::Null) }), // Paint - cmd!("paint.stroke", "Brush Stroke", [], None, r##"{"points":[[x,y,pressure?,tiltX?,tiltY?,rotation?,timeMs?,wheel?],…],"brush":{…BrushSettings}?,"preset":name?,"size":px?,"hardness":0..1?,"opacity":0..1?,"flow":0..1?,"spacing":0..10?,"color":"#rrggbb"?=foreground,"mode":"normal|multiply|screen|…"="normal","erase":bool?,"smoothing":0..1?,"zoom":number=1,"seed":u64?,"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target}"##, has_paintable, crate::brush_cmds::paint_stroke), + cmd!( + "paint.stroke", + "Brush Stroke", + [], + None, + r##"{"points":[[x,y,pressure?,tiltX?,tiltY?,rotation?,timeMs?,wheel?],…],"brush":{…BrushSettings}?,"preset":name?,"size":px?,"hardness":0..1?,"opacity":0..1?,"flow":0..1?,"spacing":0..10?,"color":"#rrggbb"?=foreground,"mode":"normal|multiply|screen|…"="normal","erase":bool?,"smoothing":0..1?,"zoom":number=1,"seed":u64?,"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target}"##, + has_paintable, + crate::brush_cmds::paint_stroke + ), cmd!("tools.setColors", "Set Colors", [], None, r##"{"foreground":"#rrggbb"?,"background":"#rrggbb"?}"##, always, |s, p| { s.tools.foreground = color_param(p, "foreground", s.tools.foreground); s.tools.background = color_param(p, "background", s.tools.background); @@ -703,28 +820,80 @@ fn build() -> Vec { Ok(Value::Null) }), // Queries (not journaled) - CommandSpec { id: "session.inspect", label: "Inspect Session", menu: &[], shortcut: None, params: "{}", enabled: always, run: |s, _| Ok(inspect::session(s)), journal: false }, - CommandSpec { id: "document.inspect", label: "Inspect Document", menu: &[], shortcut: None, params: r##"{"document":index?}"##, enabled: has_doc, run: |s, p| { - let i = p.get("document").and_then(Value::as_u64).map(|v| v as usize).or(s.active_index()).ok_or(EngineError::NoDocument)?; - let d = s.documents().get(i).ok_or(EngineError::NoDocument)?; - Ok(inspect::document(d)) - }, journal: false }, - CommandSpec { id: "document.activate", label: "Activate Document", menu: &[], shortcut: None, params: r##"{"document":index}"##, enabled: has_doc, run: |s, p| { - let i = p.get("document").and_then(Value::as_u64).ok_or_else(|| bad("document.activate", "missing `document`"))?; - if s.set_active(i as usize) { Ok(Value::Null) } else { Err(EngineError::NoDocument) } - }, journal: false }, - CommandSpec { id: "command.list", label: "List Commands", menu: &[], shortcut: None, params: "{}", enabled: always, run: |s, _| { - Ok(Value::Array(command_specs().iter().map(|c| json!({ - "id": c.id, "label": c.label, "menu": c.menu, "shortcut": c.shortcut, "params": c.params, "enabled": (c.enabled)(s).is_ok(), - })).collect())) - }, journal: false }, - CommandSpec { id: "document.pixel", label: "Read Composite Pixel", menu: &[], shortcut: None, params: r##"{"x":i32,"y":i32}"##, enabled: has_doc, run: |s, p| { - let x = int(p, "x").unwrap_or(0) as i32; - let y = int(p, "y").unwrap_or(0) as i32; - let d = s.active().ok_or(EngineError::NoDocument)?; - let px = photocraft_compose::render(&d.doc, Rect::from_xywh(x, y, 1, 1)).px[0]; - Ok(json!(px)) - }, journal: false }, + CommandSpec { + id: "session.inspect", + label: "Inspect Session", + menu: &[], + shortcut: None, + params: "{}", + enabled: always, + run: |s, _| Ok(inspect::session(s)), + journal: false, + }, + CommandSpec { + id: "document.inspect", + label: "Inspect Document", + menu: &[], + shortcut: None, + params: r##"{"document":index?}"##, + enabled: has_doc, + run: |s, p| { + let i = p.get("document").and_then(Value::as_u64).map(|v| v as usize).or(s.active_index()).ok_or(EngineError::NoDocument)?; + let d = s.documents().get(i).ok_or(EngineError::NoDocument)?; + Ok(inspect::document(d)) + }, + journal: false, + }, + CommandSpec { + id: "document.activate", + label: "Activate Document", + menu: &[], + shortcut: None, + params: r##"{"document":index}"##, + enabled: has_doc, + run: |s, p| { + let i = p.get("document").and_then(Value::as_u64).ok_or_else(|| bad("document.activate", "missing `document`"))?; + if s.set_active(i as usize) { Ok(Value::Null) } else { Err(EngineError::NoDocument) } + }, + journal: false, + }, + CommandSpec { + id: "command.list", + label: "List Commands", + menu: &[], + shortcut: None, + params: "{}", + enabled: always, + run: |s, _| { + Ok(Value::Array( + command_specs() + .iter() + .map(|c| { + json!({ + "id": c.id, "label": c.label, "menu": c.menu, "shortcut": c.shortcut, "params": c.params, "enabled": (c.enabled)(s).is_ok(), + }) + }) + .collect(), + )) + }, + journal: false, + }, + CommandSpec { + id: "document.pixel", + label: "Read Composite Pixel", + menu: &[], + shortcut: None, + params: r##"{"x":i32,"y":i32}"##, + enabled: has_doc, + run: |s, p| { + let x = int(p, "x").unwrap_or(0) as i32; + let y = int(p, "y").unwrap_or(0) as i32; + let d = s.active().ok_or(EngineError::NoDocument)?; + let px = photocraft_compose::render(&d.doc, Rect::from_xywh(x, y, 1, 1)).px[0]; + Ok(json!(px)) + }, + journal: false, + }, ]; // Adjustment layers + destructive adjustments, generated from one list. @@ -743,8 +912,16 @@ fn build() -> Vec { ("posterize", "Posterize…", r##"{"levels":2..255}"##), ("threshold", "Threshold…", r##"{"level":1..255}"##), ("gradientMap", "Gradient Map…", "{}"), - ("selectiveColor", "Selective Color…", r##"{"method":"relative|absolute"="relative","colors":"reds|yellows|greens|cyans|blues|magentas|whites|neutrals|blacks"="reds","cyan":-100..100=0,"magenta":-100..100=0,"yellow":-100..100=0,"black":-100..100=0,"reds":json} (per-range [c,m,y,k] arrays: reds yellows greens cyans blues magentas whites neutrals blacks)"##), - ("colorLookup", "Color Lookup…", r##"{"lut":"none|warm|cool|tealOrange|bleachBypass|fadedFilm|dayForNight|monoContrast|crossProcess"="none","file":text,"interpolation":"trilinear|tetrahedral"="trilinear","dither":bool=false,"data":json} (file: .cube/.3dl/.look path; data: file text + "fileName")"##), + ( + "selectiveColor", + "Selective Color…", + r##"{"method":"relative|absolute"="relative","colors":"reds|yellows|greens|cyans|blues|magentas|whites|neutrals|blacks"="reds","cyan":-100..100=0,"magenta":-100..100=0,"yellow":-100..100=0,"black":-100..100=0,"reds":json} (per-range [c,m,y,k] arrays: reds yellows greens cyans blues magentas whites neutrals blacks)"##, + ), + ( + "colorLookup", + "Color Lookup…", + r##"{"lut":"none|warm|cool|tealOrange|bleachBypass|fadedFilm|dayForNight|monoContrast|crossProcess"="none","file":text,"interpolation":"trilinear|tetrahedral"="trilinear","dither":bool=false,"data":json} (file: .cube/.3dl/.look path; data: file text + "fileName")"##, + ), ]; for &(kind, label, params) in ADJ { let layer_id: &'static str = Box::leak(format!("layer.newAdjustmentLayer.{kind}").into_boxed_str()); diff --git a/crates/engine/src/comps_cmds.rs b/crates/engine/src/comps_cmds.rs index 81026aaaa5..d2e2cf8bb9 100644 --- a/crates/engine/src/comps_cmds.rs +++ b/crates/engine/src/comps_cmds.rs @@ -44,7 +44,9 @@ fn comp_param(doc: &Document, p: &Value, cmd: &str) -> Result { let id = n.as_u64().ok_or_else(|| bad(cmd, "\"comp\" must be a comp id or name"))? as u32; doc.comp(id).map(|c| c.id).ok_or_else(|| bad(cmd, format!("no layer comp with id {id}"))) } - Some(Value::String(name)) => doc.layer_comps.iter().find(|c| c.name == *name).map(|c| c.id).ok_or_else(|| bad(cmd, format!("no layer comp named \"{name}\""))), + Some(Value::String(name)) => { + doc.layer_comps.iter().find(|c| c.name == *name).map(|c| c.id).ok_or_else(|| bad(cmd, format!("no layer comp named \"{name}\""))) + } Some(_) => Err(bad(cmd, "\"comp\" must be a comp id or name")), None => doc .last_applied_comp @@ -374,8 +376,22 @@ pub fn specs() -> Vec { }; } vec![ - spec!("layerComp.new", "New Layer Comp…", &[], r##"{"name":str="Layer Comp N","comment":str="","visibility":bool=true,"position":bool=true,"appearance":bool=true} → {comp}"##, has_doc, new_comp), - spec!("layerComp.update", "Update Layer Comp", &[], r##"{"comp":id|name|"*"? (default: last applied; "*" = all),"what":"all|visibility|position|appearance"="all"}"##, has_comps, update_comp), + spec!( + "layerComp.new", + "New Layer Comp…", + &[], + r##"{"name":str="Layer Comp N","comment":str="","visibility":bool=true,"position":bool=true,"appearance":bool=true} → {comp}"##, + has_doc, + new_comp + ), + spec!( + "layerComp.update", + "Update Layer Comp", + &[], + r##"{"comp":id|name|"*"? (default: last applied; "*" = all),"what":"all|visibility|position|appearance"="all"}"##, + has_comps, + update_comp + ), spec!("layerComp.apply", "Apply Layer Comp", &[], r##"{"comp":id|name? (default: last applied)} → {comp, missingLayers}"##, has_comps, apply_cmd), spec!("layerComp.previous", "Apply Previous Layer Comp", &[], "{}", has_comps, |s, _| step(s, -1)), spec!("layerComp.next", "Apply Next Layer Comp", &[], "{}", has_comps, |s, _| step(s, 1)), @@ -383,11 +399,42 @@ pub fn specs() -> Vec { spec!("layerComp.duplicate", "Duplicate Layer Comp", &[], r##"{"comp":id|name?} → {comp}"##, has_comps, duplicate), spec!("layerComp.rename", "Rename Layer Comp", &[], r##"{"comp":id|name?,"name":str}"##, has_comps, rename), spec!("layerComp.setComment", "Layer Comp Comment", &[], r##"{"comp":id|name?,"comment":str}"##, has_comps, set_comment), - spec!("layerComp.setOptions", "Layer Comp Options…", &[], r##"{"comp":id|name?,"visibility":bool?,"position":bool?,"appearance":bool?,"name":str?,"comment":str?}"##, has_comps, set_options), + spec!( + "layerComp.setOptions", + "Layer Comp Options…", + &[], + r##"{"comp":id|name?,"visibility":bool?,"position":bool?,"appearance":bool?,"name":str?,"comment":str?}"##, + has_comps, + set_options + ), spec!("layerComp.restoreLastDocumentState", "Restore Last Document State", &[], "{}", has_last_state, |s, _| restore_last(s)), - spec!("layerComp.updateWarnings", "Layer Comp Warnings", &[], r##"{"clear":bool=false (drop states of deleted layers)} → {warnings:[{comp,name,missingLayers}],count}"##, has_doc, update_warnings), - CommandSpec { journal: false, ..spec!("layerComp.list", "List Layer Comps", &[], "{} → {comps:[{id,name,comment,visibility,position,appearance,layers,missingLayers}],lastApplied,hasLastDocumentState}", has_doc, |s, _| list(s)) }, - spec!("file.export.layerCompsToFiles", "Layer Comps to Files…", &["File", "Export"], r##"{"dir":folder,"format":"png|jpg|psd|tiff|…"="png","prefix":str=document name,"selectedOnly":bool=false (only the last applied comp),"comps":[id]?,"quality":0..12?} → {files}"##, export_comps, comps_to_files), + spec!( + "layerComp.updateWarnings", + "Layer Comp Warnings", + &[], + r##"{"clear":bool=false (drop states of deleted layers)} → {warnings:[{comp,name,missingLayers}],count}"##, + has_doc, + update_warnings + ), + CommandSpec { + journal: false, + ..spec!( + "layerComp.list", + "List Layer Comps", + &[], + "{} → {comps:[{id,name,comment,visibility,position,appearance,layers,missingLayers}],lastApplied,hasLastDocumentState}", + has_doc, + |s, _| list(s) + ) + }, + spec!( + "file.export.layerCompsToFiles", + "Layer Comps to Files…", + &["File", "Export"], + r##"{"dir":folder,"format":"png|jpg|psd|tiff|…"="png","prefix":str=document name,"selectedOnly":bool=false (only the last applied comp),"comps":[id]?,"quality":0..12?} → {files}"##, + export_comps, + comps_to_files + ), ] } diff --git a/crates/engine/src/comps_cmds/tests.rs b/crates/engine/src/comps_cmds/tests.rs index e73976df5d..1f578859fe 100644 --- a/crates/engine/src/comps_cmds/tests.rs +++ b/crates/engine/src/comps_cmds/tests.rs @@ -164,7 +164,9 @@ fn disabled_states_and_bad_params() { assert!(!s.is_enabled("layerComp.new")); s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); assert!(s.is_enabled("layerComp.new")); - for id in ["layerComp.apply", "layerComp.update", "layerComp.next", "layerComp.delete", "file.export.layerCompsToFiles", "layerComp.restoreLastDocumentState"] { + for id in + ["layerComp.apply", "layerComp.update", "layerComp.next", "layerComp.delete", "file.export.layerCompsToFiles", "layerComp.restoreLastDocumentState"] + { assert!(!s.is_enabled(id), "{id}"); } s.execute("layerComp.new", json!({})).unwrap(); diff --git a/crates/engine/src/distort_cmds.rs b/crates/engine/src/distort_cmds.rs index 759cf52350..c88f040fd3 100644 --- a/crates/engine/src/distort_cmds.rs +++ b/crates/engine/src/distort_cmds.rs @@ -61,7 +61,8 @@ pub fn liquify_params(cmd: &str, p: &Value, canvas: Rect) -> Result<(Vec Result { let pins: Vec = match p.get("pins") { - Some(v) => serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("bad `pins` (each {{\"src\":[x,y],\"dst\":[x,y],\"rotate\":deg?,\"depth\":n?}}): {e}")))?, + Some(v) => serde_json::from_value(v.clone()) + .map_err(|e| bad(cmd, format!("bad `pins` (each {{\"src\":[x,y],\"dst\":[x,y],\"rotate\":deg?,\"depth\":n?}}): {e}")))?, None => Vec::new(), }; if pins.iter().any(|q| q.src.iter().chain(&q.dst).any(|v| !v.is_finite()) || q.rotate.is_some_and(|r| !r.is_finite())) { @@ -229,7 +230,15 @@ fn check_locks(l: &Layer, position: bool) -> Result<()> { /// place by `f(surface, selection)`. type MaskFn<'a> = Option<&'a dyn Fn(&Surface) -> Surface>; -fn run_on_layer(s: &mut Session, cmd: &str, label: &str, p: &Value, moves: bool, f: &dyn Fn(&Surface, Option<&Surface>, Rect) -> Surface, mask: MaskFn) -> Result { +fn run_on_layer( + s: &mut Session, + cmd: &str, + label: &str, + p: &Value, + moves: bool, + f: &dyn Fn(&Surface, Option<&Surface>, Rect) -> Surface, + mask: MaskFn, +) -> Result { let id = target(s, p)?; let mut params = p.clone(); if let Value::Object(m) = &mut params { @@ -275,13 +284,21 @@ fn liquify(s: &mut Session, p: &Value) -> Result { if field.is_identity() && !smart { return Ok(json!({"layer": id.0, "changed": false})); } - let r = run_on_layer(s, LIQUIFY, "Liquify", p, false, &|surf, sel, _| { - let out = apply_liquify(surf, &field); - match sel { - Some(sel) => mix_by_selection(surf, &out, sel), - None => out, - } - }, None)?; + let r = run_on_layer( + s, + LIQUIFY, + "Liquify", + p, + false, + &|surf, sel, _| { + let out = apply_liquify(surf, &field); + match sel { + Some(sel) => mix_by_selection(surf, &out, sel), + None => out, + } + }, + None, + )?; let mut r = r; r["ms"] = json!(t0.elapsed().as_secs_f64() * 1000.0); r["maxDisplacement"] = json!(field.max_displacement()); @@ -331,7 +348,15 @@ fn perspective(s: &mut Session, p: &Value, cmd: &str) -> Result { Some(map) => warp_mesh_gray(m, &|x, y| map.map(x, y), Interp::Bilinear), None => m.clone(), }; - let mut r = run_on_layer(s, cmd, "Perspective Warp", p, true, &|surf, sel, _| deform_selected(surf, sel, &|x| perspective_surface(x, &planes, it)), Some(&mask_fn))?; + let mut r = run_on_layer( + s, + cmd, + "Perspective Warp", + p, + true, + &|surf, sel, _| deform_selected(surf, sel, &|x| perspective_surface(x, &planes, it)), + Some(&mask_fn), + )?; r["planes"] = json!(planes); Ok(r) } @@ -341,11 +366,56 @@ pub fn specs() -> Vec { const P: &str = r##"{"pins":[{"src":[x,y],"dst":[x,y],"rotate":deg?,"depth":n?}…],"mode":"rigid|normal|distort"="normal","density":"fewer|normal|more"="normal","expansion":px=2,"interpolation":"bicubic|bilinear|nearest","layer":id?} — mesh over the opaque region, as-rigid-as-possible; on a smart object it becomes a smart filter"##; const Q: &str = r##"{"planes":[{"src":[[x,y]×4],"dst":[[x,y]×4]}…] (corners clockwise from top-left; corners that coincide in src are linked),"straighten":"horizontal|vertical|auto"?,"interpolation":"bicubic|bilinear|nearest","layer":id?}"##; vec![ - CommandSpec { id: LIQUIFY, label: "Liquify…", menu: &["Filter"], shortcut: Some("Cmd+Shift+X"), params: L, enabled: crate::filters::has_filterable_layer, journal: true, run: liquify }, - CommandSpec { id: PUPPET, label: "Puppet Warp", menu: &["Edit"], shortcut: None, params: P, enabled: pixel_layer, journal: true, run: |s, p| puppet(s, p, PUPPET) }, - CommandSpec { id: PUPPET_SMART, label: "Puppet Warp", menu: &["Layer", "Smart Objects"], shortcut: None, params: P, enabled: smart_layer, journal: true, run: |s, p| puppet(s, p, PUPPET_SMART) }, - CommandSpec { id: PERSPECTIVE, label: "Perspective Warp", menu: &["Edit"], shortcut: None, params: Q, enabled: pixel_layer, journal: true, run: |s, p| perspective(s, p, PERSPECTIVE) }, - CommandSpec { id: PERSPECTIVE_SMART, label: "Perspective Warp", menu: &["Layer", "Smart Objects"], shortcut: None, params: Q, enabled: smart_layer, journal: true, run: |s, p| perspective(s, p, PERSPECTIVE_SMART) }, + CommandSpec { + id: LIQUIFY, + label: "Liquify…", + menu: &["Filter"], + shortcut: Some("Cmd+Shift+X"), + params: L, + enabled: crate::filters::has_filterable_layer, + journal: true, + run: liquify, + }, + CommandSpec { + id: PUPPET, + label: "Puppet Warp", + menu: &["Edit"], + shortcut: None, + params: P, + enabled: pixel_layer, + journal: true, + run: |s, p| puppet(s, p, PUPPET), + }, + CommandSpec { + id: PUPPET_SMART, + label: "Puppet Warp", + menu: &["Layer", "Smart Objects"], + shortcut: None, + params: P, + enabled: smart_layer, + journal: true, + run: |s, p| puppet(s, p, PUPPET_SMART), + }, + CommandSpec { + id: PERSPECTIVE, + label: "Perspective Warp", + menu: &["Edit"], + shortcut: None, + params: Q, + enabled: pixel_layer, + journal: true, + run: |s, p| perspective(s, p, PERSPECTIVE), + }, + CommandSpec { + id: PERSPECTIVE_SMART, + label: "Perspective Warp", + menu: &["Layer", "Smart Objects"], + shortcut: None, + params: Q, + enabled: smart_layer, + journal: true, + run: |s, p| perspective(s, p, PERSPECTIVE_SMART), + }, ] } diff --git a/crates/engine/src/distort_cmds/tests.rs b/crates/engine/src/distort_cmds/tests.rs index 821b1e2630..fca77926c6 100644 --- a/crates/engine/src/distort_cmds/tests.rs +++ b/crates/engine/src/distort_cmds/tests.rs @@ -101,7 +101,10 @@ fn puppet_identity_translation_and_undo() { s.execute(PUPPET, json!({"pins": [{"src": [30, 25], "dst": [36, 29]}], "mode": "rigid", "interpolation": "bilinear"})).unwrap(); let after = active_surface(&s); assert_eq!(after.content_bounds(), Rect::new(16, 14, 66, 44), "{depth}"); - assert!(worst(&after.convert(before.format()), &photocraft_algo::resample::translate_surface(&before, 6, 4), Rect::new(16, 14, 66, 44)) <= 1.0 / 255.0, "{depth}"); + assert!( + worst(&after.convert(before.format()), &photocraft_algo::resample::translate_surface(&before, 6, 4), Rect::new(16, 14, 66, 44)) <= 1.0 / 255.0, + "{depth}" + ); s.undo(); assert_eq!(active_surface(&s), before); assert!(s.execute(PUPPET, json!({"pins": [{"src": [1, 1]}]})).is_err()); @@ -133,7 +136,10 @@ fn perspective_identity_known_homography_and_mask() { s.execute(PERSPECTIVE, json!({"planes": [{"src": q, "dst": d}], "interpolation": "bilinear"})).unwrap(); let after = active_surface(&s); assert_eq!(after.content_bounds(), Rect::new(15, 13, 65, 43)); - assert!(worst(&after.convert(before.format()), &photocraft_algo::resample::translate_surface(&before, 5, 3), Rect::new(15, 13, 65, 43)) <= 1.0 / 255.0, "{depth}"); + assert!( + worst(&after.convert(before.format()), &photocraft_algo::resample::translate_surface(&before, 5, 3), Rect::new(15, 13, 65, 43)) <= 1.0 / 255.0, + "{depth}" + ); s.undo(); assert_eq!(active_surface(&s), before); } diff --git a/crates/engine/src/edit_cmds.rs b/crates/engine/src/edit_cmds.rs index b1fcb6b80b..05eb963409 100644 --- a/crates/engine/src/edit_cmds.rs +++ b/crates/engine/src/edit_cmds.rs @@ -321,7 +321,8 @@ fn toggle_last_state(s: &mut Session) -> Result { /// Edit › Transform › Again: replay the last `edit.transform` on the active layer. fn transform_again(s: &mut Session) -> Result { - let (_, last) = s.journal.iter().rev().find(|(id, _)| id == "edit.transform").cloned().ok_or(EngineError::Other("there is no transform to repeat".into()))?; + let (_, last) = + s.journal.iter().rev().find(|(id, _)| id == "edit.transform").cloned().ok_or(EngineError::Other("there is no transform to repeat".into()))?; let (rect, quad) = (last.get("rect").cloned(), last.get("quad").cloned()); let mut p = json!({"interpolation": last.get("interpolation").cloned().unwrap_or(json!("bicubic"))}); match (rect, quad, last.get("matrix").cloned()) { @@ -332,8 +333,11 @@ fn transform_again(s: &mut Session) -> Result { if r.len() != 4 || q.len() != 4 || q.iter().any(|row| row.len() < 2) { return Err(EngineError::Other("the last transform can't be repeated".into())); } - let h = photocraft_algo::transform::Homography::rect_to_quad([r[0], r[1], r[2], r[3]], [[q[0][0], q[0][1]], [q[1][0], q[1][1]], [q[2][0], q[2][1]], [q[3][0], q[3][1]]]) - .ok_or(EngineError::Other("the last transform can't be repeated".into()))?; + let h = photocraft_algo::transform::Homography::rect_to_quad( + [r[0], r[1], r[2], r[3]], + [[q[0][0], q[0][1]], [q[1][0], q[1][1]], [q[2][0], q[2][1]], [q[3][0], q[3][1]]], + ) + .ok_or(EngineError::Other("the last transform can't be repeated".into()))?; let d = s.active().ok_or(EngineError::NoDocument)?; let l = d.doc.layer(active_id(s)?).ok_or(EngineError::Other("no active layer".into()))?; let b = crate::transform_cmds::transform_bounds(&d.doc, l); @@ -402,7 +406,15 @@ pub fn specs() -> Vec { }), spec!("edit.copy", "Copy", &["Edit"], Some("Cmd+C"), "{}", has_pixels, |s, _| copy(s, false)), spec!("edit.copyMerged", "Copy Merged", &["Edit"], Some("Cmd+Shift+C"), "{}", has_doc, |s, _| copy(s, true)), - spec!("edit.paste", "Paste", &["Edit"], Some("Cmd+V"), r##"{"center":[x,y]? (view centre; default keeps the position when it overlaps the canvas)}"##, has_clip, |s, p| paste(s, p, false)), + spec!( + "edit.paste", + "Paste", + &["Edit"], + Some("Cmd+V"), + r##"{"center":[x,y]? (view centre; default keeps the position when it overlaps the canvas)}"##, + has_clip, + |s, p| paste(s, p, false) + ), spec!("edit.pasteSpecial.pasteInPlace", "Paste in Place", &["Edit", "Paste Special"], Some("Cmd+Shift+V"), "{}", has_clip, |s, p| paste(s, p, true)), spec!("layer.new.layerViaCopy", "Layer via Copy", &["Layer", "New"], Some("Cmd+J"), "{}", has_doc, |s, _| layer_via(s, false)), spec!("layer.new.layerViaCut", "Layer via Cut", &["Layer", "New"], Some("Cmd+Shift+J"), "{}", has_pixels, |s, _| layer_via(s, true)), @@ -412,8 +424,12 @@ pub fn specs() -> Vec { spec!("image.autoColor", "Auto Color", &["Image"], Some("Cmd+Shift+B"), "{}", has_pixels, |s, _| auto_adjust(s, "color")), spec!("edit.toggleLastState", "Toggle Last State", &["Edit"], Some("Cmd+Alt+Z"), "{}", has_doc, |s, _| toggle_last_state(s)), spec!("edit.transform.again", "Again", &["Edit", "Transform"], Some("Cmd+Shift+T"), "{}", has_doc, |s, _| transform_again(s)), - spec!("view.newGuide", "New Guide…", &["View"], None, r##"{"orientation":"horizontal|vertical","position":px}"##, has_doc, |s, p| guide_cmd(s, p, "new")), - spec!("view.moveGuide", "Move Guide", &[], None, r##"{"orientation":"horizontal|vertical","index":n,"position":px}"##, has_doc, |s, p| guide_cmd(s, p, "move")), + spec!("view.newGuide", "New Guide…", &["View"], None, r##"{"orientation":"horizontal|vertical","position":px}"##, has_doc, |s, p| guide_cmd( + s, p, "new" + )), + spec!("view.moveGuide", "Move Guide", &[], None, r##"{"orientation":"horizontal|vertical","index":n,"position":px}"##, has_doc, |s, p| guide_cmd( + s, p, "move" + )), spec!("view.deleteGuide", "Delete Guide", &[], None, r##"{"orientation":"horizontal|vertical","index":n}"##, has_doc, |s, p| guide_cmd(s, p, "delete")), spec!("view.clearGuides", "Clear Guides", &["View"], None, "{}", has_doc, |s, p| guide_cmd(s, p, "clear")), ] diff --git a/crates/engine/src/edit_menu_cmds.rs b/crates/engine/src/edit_menu_cmds.rs index 6230ef6785..91093273d9 100644 --- a/crates/engine/src/edit_menu_cmds.rs +++ b/crates/engine/src/edit_menu_cmds.rs @@ -48,7 +48,10 @@ pub struct EditState { /// Commands whose result Edit › Fade can blend back against the previous state. fn fadeable(id: &str) -> bool { - id.starts_with("filter.") || id.starts_with("image.adjustments.") || id.starts_with("paint.") || matches!(id, "edit.fill" | "edit.stroke" | "edit.contentAwareFill" | "image.autoTone" | "image.autoContrast" | "image.autoColor") + id.starts_with("filter.") + || id.starts_with("image.adjustments.") + || id.starts_with("paint.") + || matches!(id, "edit.fill" | "edit.stroke" | "edit.contentAwareFill" | "image.autoTone" | "image.autoContrast" | "image.autoColor") } /// Called after every successful command: remember a fadeable result. @@ -59,23 +62,27 @@ pub(crate) fn after_command(s: &mut Session, id: &str) { let Some(st) = s.active() else { return }; let Some(layer) = st.active_layer else { return }; let label = st.history.undo_label().unwrap_or(id).to_string(); - let mask = st.doc.layer(layer).is_some_and(|l| l.mask.is_some()) && st.history.state(st.history.past_len().wrapping_sub(1)).is_some_and(|prev| { - // The mask changed and the pixels didn't: the command painted the mask. - let now = st.doc.layer(layer); - let was = prev.layer(layer); - let surf_eq = |a: Option<&Surface>, b: Option<&Surface>| match (a, b) { - (Some(a), Some(b)) => crate_fingerprint(a) == crate_fingerprint(b), - (None, None) => true, - _ => false, - }; - surf_eq(now.and_then(|l| l.surface()), was.and_then(|l| l.surface())) && !surf_eq(now.and_then(|l| l.mask.as_ref().map(|m| &m.surface)), was.and_then(|l| l.mask.as_ref().map(|m| &m.surface))) - }); + let mask = st.doc.layer(layer).is_some_and(|l| l.mask.is_some()) + && st.history.state(st.history.past_len().wrapping_sub(1)).is_some_and(|prev| { + // The mask changed and the pixels didn't: the command painted the mask. + let now = st.doc.layer(layer); + let was = prev.layer(layer); + let surf_eq = |a: Option<&Surface>, b: Option<&Surface>| match (a, b) { + (Some(a), Some(b)) => crate_fingerprint(a) == crate_fingerprint(b), + (None, None) => true, + _ => false, + }; + surf_eq(now.and_then(|l| l.surface()), was.and_then(|l| l.surface())) + && !surf_eq(now.and_then(|l| l.mask.as_ref().map(|m| &m.surface)), was.and_then(|l| l.mask.as_ref().map(|m| &m.surface))) + }); s.edit_state.fade = Some(FadeSource { doc: st.doc.id, revision: st.revision, layer, mask, label }); } /// Tile identity of a surface (pointer equality of its COW tiles). fn crate_fingerprint(s: &Surface) -> u64 { - s.tiles().fold(s.tile_count() as u64, |h, (c, t)| (h ^ std::sync::Arc::as_ptr(t) as usize as u64 ^ ((c.tx as u64) << 32 | c.ty as u32 as u64)).wrapping_mul(0x100_0000_01b3)) + s.tiles().fold(s.tile_count() as u64, |h, (c, t)| { + (h ^ std::sync::Arc::as_ptr(t) as usize as u64 ^ ((c.tx as u64) << 32 | c.ty as u32 as u64)).wrapping_mul(0x100_0000_01b3) + }) } // ------------------------------------------------------------------ helpers @@ -271,7 +278,9 @@ fn can_purge_histories(s: &Session) -> std::result::Result<(), String> { if s.documents().iter().any(|d| d.history.can_undo() || d.history.can_redo()) { Ok(()) } else { Err("no history to purge".into()) } } fn can_purge_all(s: &Session) -> std::result::Result<(), String> { - can_purge_histories(s).or_else(|_| can_purge_clipboard(s)).or_else(|_| if photocraft_compose::effect_cache_bytes() > 0 { Ok(()) } else { Err("nothing to purge".into()) }) + can_purge_histories(s) + .or_else(|_| can_purge_clipboard(s)) + .or_else(|_| if photocraft_compose::effect_cache_bytes() > 0 { Ok(()) } else { Err("nothing to purge".into()) }) } // ------------------------------------------------------------------ Content-Aware Fill @@ -527,7 +536,13 @@ fn can_define_shape(s: &Session) -> std::result::Result<(), String> { fn define_custom_shape(s: &mut Session, p: &Value) -> Result { let st = s.active().ok_or(EngineError::NoDocument)?; let path = current_path(&st.doc, st.active_layer, p.get("path").and_then(Value::as_str)).ok_or_else(|| bad("edit.defineCustomShape", "no such path"))?; - let name = p.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()).map(str::to_string).unwrap_or_else(|| format!("Shape {}", s.edit_state.custom_shapes.len() + 1)); + let name = p + .get("name") + .and_then(Value::as_str) + .map(str::trim) + .filter(|n| !n.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("Shape {}", s.edit_state.custom_shapes.len() + 1)); let shapes = &mut s.edit_state.custom_shapes; match shapes.iter_mut().find(|c| c.name == name) { Some(c) => c.path = path, @@ -575,7 +590,8 @@ pub fn find_matches(text: &str, find: &str, case: bool, whole: bool) -> Vec<(usi fn type_layers(doc: &Document, forward: bool) -> Vec { // Layers panel order: top first. - let mut ids: Vec = doc.walk().into_iter().rev().filter(|(_, _, l)| matches!(l.content, LayerContent::Text(_)) && !l.locks.all).map(|(_, _, l)| l.id).collect(); + let mut ids: Vec = + doc.walk().into_iter().rev().filter(|(_, _, l)| matches!(l.content, LayerContent::Text(_)) && !l.locks.all).map(|(_, _, l)| l.id).collect(); if !forward { ids.reverse(); } @@ -591,7 +607,8 @@ fn find_replace(s: &mut Session, p: &Value) -> Result { let cmd = "edit.findAndReplaceText"; let find = p.get("find").and_then(Value::as_str).filter(|f| !f.is_empty()).ok_or_else(|| bad(cmd, "missing `find`"))?.to_string(); let replace = str_or(p, "replace", "").to_string(); - let (case, whole, forward, all_layers) = (bool_or(p, "caseSensitive", false), bool_or(p, "wholeWord", false), bool_or(p, "forward", true), bool_or(p, "allLayers", true)); + let (case, whole, forward, all_layers) = + (bool_or(p, "caseSensitive", false), bool_or(p, "wholeWord", false), bool_or(p, "forward", true), bool_or(p, "allLayers", true)); let action = str_or(p, "action", "changeAll").to_string(); let st = s.active().ok_or(EngineError::NoDocument)?; let mut layers = type_layers(&st.doc, forward); @@ -608,7 +625,8 @@ fn find_replace(s: &mut Session, p: &Value) -> Result { match action.as_str() { "changeAll" => { let doc = st.doc.clone(); - let plan: Vec<(LayerId, Vec<(usize, usize)>)> = layers.iter().filter_map(|id| Some((*id, find_matches(&text_of(&doc, *id)?, &find, case, whole)))).filter(|(_, m)| !m.is_empty()).collect(); + let plan: Vec<(LayerId, Vec<(usize, usize)>)> = + layers.iter().filter_map(|id| Some((*id, find_matches(&text_of(&doc, *id)?, &find, case, whole)))).filter(|(_, m)| !m.is_empty()).collect(); let count: usize = plan.iter().map(|(_, m)| m.len()).sum(); if count == 0 { return Ok(json!({"count": 0, "layers": 0})); @@ -678,7 +696,9 @@ fn find_replace(s: &mut Session, p: &Value) -> Result { if let Some((a, b)) = hit { s.edit_state.find_cursor = Some((id, a, b)); let _ = s.select_layer(id); - return Ok(json!({"found": {"layer": id.0, "start": a, "end": b, "text": &text[a..b]}, "changed": changed.map(|(l, a, b)| json!({"layer": l.0, "start": a, "end": b}))})); + return Ok( + json!({"found": {"layer": id.0, "start": a, "end": b, "text": &text[a..b]}, "changed": changed.map(|(l, a, b)| json!({"layer": l.0, "start": a, "end": b}))}), + ); } } s.edit_state.find_cursor = None; @@ -736,7 +756,8 @@ fn preset_manager(s: &mut Session, p: &Value) -> Result { let i = preset_index(s, &kind, p)?; match action { "rename" => { - let new = p.get("newName").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(cmd, "missing `newName`"))?.to_string(); + let new = + p.get("newName").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(cmd, "missing `newName`"))?.to_string(); match kind.as_str() { "brushes" => s.tools.presets[i].name = new, "patterns" => s.patterns.items[i].name = new, @@ -775,14 +796,22 @@ pub const PRESET_FORMAT: &str = "photocraft-presets"; fn export_import(s: &mut Session, p: &Value) -> Result { let cmd = "edit.presets.exportImportPresets"; - let kinds: Vec = p.get("kinds").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_else(|| ["brushes", "customShapes"].iter().map(|k| k.to_string()).collect()); + let kinds: Vec = p + .get("kinds") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()) + .unwrap_or_else(|| ["brushes", "customShapes"].iter().map(|k| k.to_string()).collect()); let want = |k: &str| kinds.iter().any(|x| x == k); match str_or(p, "action", "export") { "export" => { let file = PresetFile { format: PRESET_FORMAT.into(), version: 1, - brushes: if want("brushes") { s.tools.presets.iter().filter(|b| !b.builtin || bool_or(p, "includeBuiltins", false)).cloned().collect() } else { Vec::new() }, + brushes: if want("brushes") { + s.tools.presets.iter().filter(|b| !b.builtin || bool_or(p, "includeBuiltins", false)).cloned().collect() + } else { + Vec::new() + }, custom_shapes: if want("customShapes") { s.edit_state.custom_shapes.clone() } else { Vec::new() }, }; Ok(json!({"data": file, "brushes": file.brushes.len(), "customShapes": file.custom_shapes.len()})) @@ -832,11 +861,21 @@ macro_rules! spec { pub fn specs() -> Vec { vec![ - spec!("edit.fade", "Fade…", ["Edit"], Some("Cmd+Shift+F"), r##"{"opacity":0..100=100,"mode":"normal|multiply|screen|overlay|softLight|hardLight|darken|lighten|difference|color|luminosity"}"##, can_fade, fade), + spec!( + "edit.fade", + "Fade…", + ["Edit"], + Some("Cmd+Shift+F"), + r##"{"opacity":0..100=100,"mode":"normal|multiply|screen|overlay|softLight|hardLight|darken|lighten|difference|color|luminosity"}"##, + can_fade, + fade + ), spec!("edit.purge.undo", "Undo", ["Edit", "Purge"], None, "{}", can_purge_undo, |s, _| purge(s, "undo")), spec!("edit.purge.clipboard", "Clipboard", ["Edit", "Purge"], None, "{}", can_purge_clipboard, |s, _| purge(s, "clipboard")), spec!("edit.purge.histories", "Histories", ["Edit", "Purge"], None, "{}", can_purge_histories, |s, _| purge(s, "histories")), - spec!("edit.purge.videoCache", "Video Cache", ["Edit", "Purge"], None, "{}", always, |_, _| Ok(json!({"purged": [], "bytes": 0, "message": "nothing to purge (no video layers)"}))), + spec!("edit.purge.videoCache", "Video Cache", ["Edit", "Purge"], None, "{}", always, |_, _| Ok( + json!({"purged": [], "bytes": 0, "message": "nothing to purge (no video layers)"}) + )), spec!("edit.purge.all", "All", ["Edit", "Purge"], None, "{}", can_purge_all, |s, _| purge(s, "all")), spec!( "edit.contentAwareFill", @@ -856,8 +895,24 @@ pub fn specs() -> Vec { |s| pixel_layer(s).map(|_| ()), content_aware_scale ), - spec!("edit.defineBrushPreset", "Define Brush Preset…", ["Edit"], None, r##"{"name":text}"##, |s| if s.is_enabled("brush.defineFromSelection") { Ok(()) } else { Err("select pixels to define a brush from".into()) }, define_brush_preset), - spec!("edit.defineCustomShape", "Define Custom Shape…", ["Edit"], None, r##"{"name":text,"path":"work|"?}"##, can_define_shape, define_custom_shape), + spec!( + "edit.defineBrushPreset", + "Define Brush Preset…", + ["Edit"], + None, + r##"{"name":text}"##, + |s| if s.is_enabled("brush.defineFromSelection") { Ok(()) } else { Err("select pixels to define a brush from".into()) }, + define_brush_preset + ), + spec!( + "edit.defineCustomShape", + "Define Custom Shape…", + ["Edit"], + None, + r##"{"name":text,"path":"work|"?}"##, + can_define_shape, + define_custom_shape + ), spec!( "edit.findAndReplaceText", "Find and Replace Text…", @@ -867,8 +922,24 @@ pub fn specs() -> Vec { has_type_layer, find_replace ), - spec!("edit.presets.presetManager", "Preset Manager…", ["Edit", "Presets"], None, r##"{"action":"list|rename|delete|move","kind":"brushes|customShapes|patterns","index":n?,"name":str?,"newName":str?,"to":n?}"##, always, preset_manager), - spec!("edit.presets.exportImportPresets", "Export/Import Presets…", ["Edit", "Presets"], None, r##"{"action":"export|import","kinds":["brushes","customShapes"]?,"data":json (import),"includeBuiltins":bool=false}"##, always, export_import), + spec!( + "edit.presets.presetManager", + "Preset Manager…", + ["Edit", "Presets"], + None, + r##"{"action":"list|rename|delete|move","kind":"brushes|customShapes|patterns","index":n?,"name":str?,"newName":str?,"to":n?}"##, + always, + preset_manager + ), + spec!( + "edit.presets.exportImportPresets", + "Export/Import Presets…", + ["Edit", "Presets"], + None, + r##"{"action":"export|import","kinds":["brushes","customShapes"]?,"data":json (import),"includeBuiltins":bool=false}"##, + always, + export_import + ), ] } diff --git a/crates/engine/src/edit_menu_cmds/tests.rs b/crates/engine/src/edit_menu_cmds/tests.rs index 3b402dee92..4e5cbb07c3 100644 --- a/crates/engine/src/edit_menu_cmds/tests.rs +++ b/crates/engine/src/edit_menu_cmds/tests.rs @@ -255,10 +255,17 @@ fn find_and_replace_across_type_layers() { s.execute("type.create", json!({"text": "world peace, World", "x": 4, "y": 40, "size": 12})).unwrap(); let r = s.execute("edit.findAndReplaceText", json!({"find": "world", "replace": "planet"})).unwrap(); assert_eq!((r["count"].as_u64(), r["layers"].as_u64()), (Some(3), Some(2))); - let texts: Vec = s.active().unwrap().doc.walk().into_iter().filter_map(|(_, _, l)| match &l.content { - LayerContent::Text(t) => Some(t.text.clone()), - _ => None, - }).collect(); + let texts: Vec = s + .active() + .unwrap() + .doc + .walk() + .into_iter() + .filter_map(|(_, _, l)| match &l.content { + LayerContent::Text(t) => Some(t.text.clone()), + _ => None, + }) + .collect(); assert!(texts.contains(&"Hello planet".to_string()), "{texts:?}"); assert!(texts.contains(&"planet peace, planet".to_string()), "{texts:?}"); s.undo(); diff --git a/crates/engine/src/extra_cmds.rs b/crates/engine/src/extra_cmds.rs index d744bb3108..1a7fefc999 100644 --- a/crates/engine/src/extra_cmds.rs +++ b/crates/engine/src/extra_cmds.rs @@ -540,14 +540,35 @@ pub fn specs() -> Vec { }; } vec![ - spec!("edit.stroke", "Stroke…", &["Edit"], None, r##"{"width":1..250=1,"color":"#rrggbb|[r,g,b,a]"=foreground,"location":"inside|center|outside"="center","opacity":0..100=100}"##, has_pixels, stroke), + spec!( + "edit.stroke", + "Stroke…", + &["Edit"], + None, + r##"{"width":1..250=1,"color":"#rrggbb|[r,g,b,a]"=foreground,"location":"inside|center|outside"="center","opacity":0..100=100}"##, + has_pixels, + stroke + ), spec!("edit.transform.rotate180", "Rotate 180°", &["Edit", "Transform"], None, "{}", has_layer, |s, _| transform_preset(s, "rotate180")), spec!("edit.transform.rotate90Cw", "Rotate 90° Clockwise", &["Edit", "Transform"], None, "{}", has_layer, |s, _| transform_preset(s, "rotate90Cw")), - spec!("edit.transform.rotate90Ccw", "Rotate 90° Counter Clockwise", &["Edit", "Transform"], None, "{}", has_layer, |s, _| transform_preset(s, "rotate90Ccw")), + spec!("edit.transform.rotate90Ccw", "Rotate 90° Counter Clockwise", &["Edit", "Transform"], None, "{}", has_layer, |s, _| transform_preset( + s, + "rotate90Ccw" + )), spec!("edit.transform.flipHorizontal", "Flip Horizontal", &["Edit", "Transform"], None, "{}", has_layer, |s, _| transform_preset(s, "flipHorizontal")), spec!("edit.transform.flipVertical", "Flip Vertical", &["Edit", "Transform"], None, "{}", has_layer, |s, _| transform_preset(s, "flipVertical")), - spec!("edit.pasteSpecial.pasteInto", "Paste Into", &["Edit", "Paste Special"], Some("Cmd+Alt+Shift+V"), r##"{"center":[x,y]?}"##, has_clip_and_selection, |s, p| paste_into(s, p, false)), - spec!("edit.pasteSpecial.pasteOutside", "Paste Outside", &["Edit", "Paste Special"], None, r##"{"center":[x,y]?}"##, has_clip_and_selection, |s, p| paste_into(s, p, true)), + spec!( + "edit.pasteSpecial.pasteInto", + "Paste Into", + &["Edit", "Paste Special"], + Some("Cmd+Alt+Shift+V"), + r##"{"center":[x,y]?}"##, + has_clip_and_selection, + |s, p| paste_into(s, p, false) + ), + spec!("edit.pasteSpecial.pasteOutside", "Paste Outside", &["Edit", "Paste Special"], None, r##"{"center":[x,y]?}"##, has_clip_and_selection, |s, p| { + paste_into(s, p, true) + }), spec!("select.reselect", "Reselect", &["Select"], Some("Cmd+Shift+D"), "{}", can_reselect, |s, _| { let m = reselect_target(s).ok_or(EngineError::Other("there is no selection to restore".into()))?; s.edit("Reselect", |doc, _| { @@ -575,36 +596,81 @@ pub fn specs() -> Vec { s.style_clipboard = Some(style); Ok(Value::Null) }), - spec!("layer.layerStyle.pasteLayerStyle", "Paste Layer Style", &["Layer", "Layer Style"], None, r##"{"layer":id?}"##, |s| { - has_layer(s)?; - s.style_clipboard.as_ref().map(|_| ()).ok_or_else(|| "no layer style has been copied".into()) - }, |s, p| { - let id = layer_param(s, p)?; - let (fx, blend, fill) = s.style_clipboard.clone().ok_or(EngineError::Other("no layer style has been copied".into()))?; - s.edit("Paste Layer Style", |doc, _| { - let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; - l.effects = fx; - l.blend = blend; - l.fill_opacity = fill; - Ok(()) - })?; - Ok(Value::Null) - }), + spec!( + "layer.layerStyle.pasteLayerStyle", + "Paste Layer Style", + &["Layer", "Layer Style"], + None, + r##"{"layer":id?}"##, + |s| { + has_layer(s)?; + s.style_clipboard.as_ref().map(|_| ()).ok_or_else(|| "no layer style has been copied".into()) + }, + |s, p| { + let id = layer_param(s, p)?; + let (fx, blend, fill) = s.style_clipboard.clone().ok_or(EngineError::Other("no layer style has been copied".into()))?; + s.edit("Paste Layer Style", |doc, _| { + let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + l.effects = fx; + l.blend = blend; + l.fill_opacity = fill; + Ok(()) + })?; + Ok(Value::Null) + } + ), spec!("layer.layerStyle.hideAllEffects", "Hide All Effects", &["Layer", "Layer Style"], None, "{}", has_doc, |s, _| set_all_effects(s, false)), spec!("layer.layerStyle.showAllEffects", "Show All Effects", &[], None, "{}", has_doc, |s, _| set_all_effects(s, true)), - spec!("layer.layerMask.enabled", "Disable Layer Mask", &["Layer", "Layer Mask"], None, r##"{"layer":id?,"enabled":bool? (default: toggle)}"##, has_mask, |s, p| toggle_mask(s, p, "enabled")), - spec!("layer.layerMask.linked", "Unlink Layer Mask", &["Layer", "Layer Mask"], None, r##"{"layer":id?,"linked":bool? (default: toggle)}"##, has_mask, |s, p| toggle_mask(s, p, "linked")), + spec!( + "layer.layerMask.enabled", + "Disable Layer Mask", + &["Layer", "Layer Mask"], + None, + r##"{"layer":id?,"enabled":bool? (default: toggle)}"##, + has_mask, + |s, p| toggle_mask(s, p, "enabled") + ), + spec!( + "layer.layerMask.linked", + "Unlink Layer Mask", + &["Layer", "Layer Mask"], + None, + r##"{"layer":id?,"linked":bool? (default: toggle)}"##, + has_mask, + |s, p| toggle_mask(s, p, "linked") + ), spec!("layer.rasterize.layer", "Layer", &["Layer", "Rasterize"], None, r##"{"layer":id?}"##, has_layer, |s, p| rasterize(s, p, None)), spec!("layer.rasterize.allLayers", "All Layers", &["Layer", "Rasterize"], None, "{}", has_doc, |s, _| rasterize_all(s)), spec!("layer.rasterize.type", "Type", &["Layer", "Rasterize"], None, r##"{"layer":id?}"##, has_layer, |s, p| rasterize(s, p, Some("type"))), - spec!("layer.rasterize.fillContent", "Fill Content", &["Layer", "Rasterize"], None, r##"{"layer":id?}"##, has_layer, |s, p| rasterize(s, p, Some("fill"))), - spec!("layer.rasterize.smartObject", "Smart Object", &["Layer", "Rasterize"], None, r##"{"layer":id?}"##, has_layer, |s, p| rasterize(s, p, Some("smart"))), - spec!("layer.delete.hiddenLayers", "Hidden Layers", &["Layer", "Delete"], None, "{}", has_doc, |s, _| delete_where(s, "Delete Hidden Layers", |l| !l.visible)), - spec!("file.scripts.deleteAllEmptyLayers", "Delete All Empty Layers", &["File", "Scripts"], None, "{}", has_doc, |s, _| delete_where(s, "Delete All Empty Layers", is_empty_layer)), - spec!("layer.ungroupLayers", "Ungroup Layers", &["Layer"], Some("Cmd+Shift+G"), r##"{"layer":id?}"##, |s| match active_layer(s)?.is_group() { - true => Ok(()), - false => Err("the active layer is not a group".into()), - }, ungroup), + spec!("layer.rasterize.fillContent", "Fill Content", &["Layer", "Rasterize"], None, r##"{"layer":id?}"##, has_layer, |s, p| rasterize( + s, + p, + Some("fill") + )), + spec!("layer.rasterize.smartObject", "Smart Object", &["Layer", "Rasterize"], None, r##"{"layer":id?}"##, has_layer, |s, p| rasterize( + s, + p, + Some("smart") + )), + spec!("layer.delete.hiddenLayers", "Hidden Layers", &["Layer", "Delete"], None, "{}", has_doc, |s, _| delete_where(s, "Delete Hidden Layers", |l| !l + .visible)), + spec!("file.scripts.deleteAllEmptyLayers", "Delete All Empty Layers", &["File", "Scripts"], None, "{}", has_doc, |s, _| delete_where( + s, + "Delete All Empty Layers", + is_empty_layer + )), + spec!( + "layer.ungroupLayers", + "Ungroup Layers", + &["Layer"], + Some("Cmd+Shift+G"), + r##"{"layer":id?}"##, + |s| match active_layer(s)?.is_group() { + true => Ok(()), + false => Err("the active layer is not a group".into()), + }, + ungroup + ), spec!("layer.hideLayers", "Hide Layers", &["Layer"], Some("Cmd+,"), r##"{"layer":id?}"##, has_layer, |s, p| set_visible(s, p, false)), spec!("layer.showLayers", "Show Layers", &[], None, r##"{"layer":id?}"##, has_layer, |s, p| set_visible(s, p, true)), spec!("filter.blur.average", "Average", &["Filter", "Blur"], None, "{}", has_pixels, |s, _| average(s)), diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index 5a001cb247..52ac78b9b2 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -114,7 +114,8 @@ pub(crate) fn list_images(dir: &str) -> Result> { } /// Extensions the batch commands pick up from a folder. -const OPENABLE: &[&str] = &["psd", "psb", "pcraft", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "exr", "hdr", "qoi", "ico", "pnm", "ppm", "pgm"]; +const OPENABLE: &[&str] = + &["psd", "psb", "pcraft", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "exr", "hdr", "qoi", "ico", "pnm", "ppm", "pgm"]; pub(crate) fn file_name(path: &str) -> String { path.rsplit(['/', '\\']).next().unwrap_or(path).to_string() @@ -399,10 +400,17 @@ pub fn read_file_info(xmp: Option<&str>) -> Value { Some((_, _, inner)) => li_values(inner), None => find_attr(x, prop).map(|(_, _, v)| vec![xml_unescape(v)]).unwrap_or_default(), }; - let v = if kind == "Bag" { json!(vals) } else if kind == "Seq" { json!(vals.join("; ")) } else { json!(vals.into_iter().next().unwrap_or_default()) }; + let v = if kind == "Bag" { + json!(vals) + } else if kind == "Seq" { + json!(vals.join("; ")) + } else { + json!(vals.into_iter().next().unwrap_or_default()) + }; m.insert(key.into(), v); } - let marked = find_element(x, "xmpRights:Marked").map(|(_, _, v)| v.trim().to_string()).or_else(|| find_attr(x, "xmpRights:Marked").map(|(_, _, v)| v.to_string())); + let marked = + find_element(x, "xmpRights:Marked").map(|(_, _, v)| v.trim().to_string()).or_else(|| find_attr(x, "xmpRights:Marked").map(|(_, _, v)| v.to_string())); m.insert( "copyrightStatus".into(), json!(match marked.as_deref() { @@ -411,7 +419,9 @@ pub fn read_file_info(xmp: Option<&str>) -> Value { _ => "unknown", }), ); - let url = find_element(x, "xmpRights:WebStatement").map(|(_, _, v)| xml_unescape(v.trim())).or_else(|| find_attr(x, "xmpRights:WebStatement").map(|(_, _, v)| xml_unescape(v))); + let url = find_element(x, "xmpRights:WebStatement") + .map(|(_, _, v)| xml_unescape(v.trim())) + .or_else(|| find_attr(x, "xmpRights:WebStatement").map(|(_, _, v)| xml_unescape(v))); m.insert("copyrightUrl".into(), json!(url.unwrap_or_default())); Value::Object(m) } @@ -454,7 +464,9 @@ pub fn write_file_info(xmp: Option<&str>, info: &Value) -> String { } let items: String = vals .iter() - .map(|v| if kind == "Alt" { format!("{}", xml_escape(v)) } else { format!("{}", xml_escape(v)) }) + .map(|v| { + if kind == "Alt" { format!("{}", xml_escape(v)) } else { format!("{}", xml_escape(v)) } + }) .collect(); if kind.is_empty() { body.push_str(&format!(" <{prop}>{}\n", xml_escape(&vals[0]))); @@ -537,7 +549,9 @@ fn conditional_mode_change(s: &mut Session, p: &Value) -> Result { }; let cur = mode_name(s.active().ok_or(EngineError::NoDocument)?.doc.mode); let from: Vec = match p.get("from") { - Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).map(|v| v.to_ascii_lowercase().replace("gray", "grayscale").replace("grayscalescale", "grayscale")).collect(), + Some(Value::Array(a)) => { + a.iter().filter_map(Value::as_str).map(|v| v.to_ascii_lowercase().replace("gray", "grayscale").replace("grayscalescale", "grayscale")).collect() + } Some(Value::String(v)) if v != "any" => vec![v.to_ascii_lowercase()], _ => vec!["any".into()], }; @@ -554,7 +568,9 @@ fn parse_steps(v: &Value) -> Result> { let arr = v.as_array().ok_or_else(|| bad("\"steps\" must be an array"))?; arr.iter() .map(|st| match st { - Value::Array(a) if !a.is_empty() => Ok((a[0].as_str().ok_or_else(|| bad("step id must be a string"))?.to_string(), a.get(1).cloned().unwrap_or(json!({})))), + Value::Array(a) if !a.is_empty() => { + Ok((a[0].as_str().ok_or_else(|| bad("step id must be a string"))?.to_string(), a.get(1).cloned().unwrap_or(json!({})))) + } Value::Object(o) => { let id = o.get("command").or_else(|| o.get("id")).and_then(Value::as_str).ok_or_else(|| bad("step needs \"command\""))?; Ok((id.to_string(), o.get("params").cloned().unwrap_or(json!({})))) @@ -576,7 +592,14 @@ pub(crate) fn batch_inputs(p: &Value, cmd: &str) -> Result> { /// Opens each input in a scratch session, runs `f` on it and saves it to `output` as `format` /// (`"same"` keeps the input's extension). Errors per file are collected, not fatal. -pub(crate) fn process_files(inputs: &[String], output: &str, format: &str, quality: Option, suffix: &str, f: &dyn Fn(&mut Session) -> Result<()>) -> Value { +pub(crate) fn process_files( + inputs: &[String], + output: &str, + format: &str, + quality: Option, + suffix: &str, + f: &dyn Fn(&mut Session) -> Result<()>, +) -> Value { let mut files = Vec::new(); let mut errors = Vec::new(); for path in inputs { @@ -586,7 +609,11 @@ pub(crate) fn process_files(inputs: &[String], output: &str, format: &str, quali let doc = import(&file_name(path), &bytes)?; scratch.add_document(doc, Some(path.clone())); f(&mut scratch)?; - let ext = if format == "same" { path.rsplit('.').next().unwrap_or("png").to_ascii_lowercase() } else { format.trim_start_matches('.').to_ascii_lowercase() }; + let ext = if format == "same" { + path.rsplit('.').next().unwrap_or("png").to_ascii_lowercase() + } else { + format.trim_start_matches('.').to_ascii_lowercase() + }; let out = join(output, &format!("{}{suffix}.{ext}", stem(path))); let d = scratch.active().ok_or(EngineError::NoDocument)?; save_doc(&d.doc, &out, quality)?; @@ -602,7 +629,8 @@ pub(crate) fn process_files(inputs: &[String], output: &str, format: &str, quali fn batch(_s: &mut Session, p: &Value) -> Result { let cmd = "file.automate.batch"; - let steps = parse_steps(p.get("steps").or_else(|| p.get("action")).ok_or_else(|| EngineError::BadParams { cmd: cmd.into(), msg: "missing \"steps\"".into() })?)?; + let steps = + parse_steps(p.get("steps").or_else(|| p.get("action")).ok_or_else(|| EngineError::BadParams { cmd: cmd.into(), msg: "missing \"steps\"".into() })?)?; if let Some((id, _)) = steps.iter().find(|(id, _)| crate::commands::find(id).is_none()) { return Err(EngineError::BadParams { cmd: cmd.into(), msg: format!("unknown command `{id}` in the action") }); } @@ -968,23 +996,137 @@ pub fn specs() -> Vec { } vec![ spec!("file.closeAll", "Close All", &["File"], Some("Cmd+Alt+W"), "{}", has_doc, |s, _| close_all(s)), - spec!("file.closeOthers", "Close Others", &["File"], Some("Cmd+Alt+P"), r##"{"document":index? (the one to keep; default active)}"##, has_doc, close_others), + spec!( + "file.closeOthers", + "Close Others", + &["File"], + Some("Cmd+Alt+P"), + r##"{"document":index? (the one to keep; default active)}"##, + has_doc, + close_others + ), spec!("file.revert", "Revert", &["File"], Some("F12"), "{} (reloads the saved file as one undoable step)", can_revert, |s, _| revert(s)), - spec!("file.saveACopy", "Save a Copy…", &["File"], Some("Cmd+Alt+S"), r##"{"path":str (format from the extension),"quality":0..12? (JPEG),"layers":bool=true}"##, native_doc, save_a_copy), - spec!("file.openAs", "Open As…", &["File"], Some("Cmd+Alt+Shift+O"), r##"{"path":str,"as":"psd|png|jpg|tiff|…"? (decode as this format)}"##, native, open_as), - spec!("file.placeEmbedded", "Place Embedded…", &["File"], None, r##"{"path":str,"scale":%? (default: fit when larger than the canvas),"fit":bool=true,"center":[x,y]?}"##, native_doc, |s, p| place(s, p, false)), - spec!("file.placeLinked", "Place Linked…", &["File"], None, r##"{"path":str,"scale":%?,"fit":bool=true,"center":[x,y]?}"##, native_doc, |s, p| place(s, p, true)), - spec!("file.fileInfo", "File Info…", &["File"], Some("Cmd+Alt+Shift+I"), r##"{"title":str?,"author":str?,"authorTitle":str?,"description":str?,"keywords":[str]|"a; b"?,"copyright":str?,"copyrightStatus":"unknown|copyrighted|publicDomain"?,"copyrightUrl":str?} (no keys: read)"##, has_doc, file_info), - spec!("file.automate.fitImage", "Fit Image…", &["File", "Automate"], None, r##"{"width":px,"height":px,"dontEnlarge":bool=false,"resample":"bicubic|bilinear|nearest|lanczos|preserveDetails"="bicubic"}"##, has_doc, fit_image), - spec!("file.automate.conditionalModeChange", "Conditional Mode Change…", &["File", "Automate"], None, r##"{"from":["rgb","grayscale","cmyk","lab","indexed","bitmap",…]|"any"="any","to":"rgb|grayscale|cmyk|lab"}"##, has_doc, conditional_mode_change), - spec!("file.automate.batch", "Batch…", &["File", "Automate"], None, r##"{"steps":[[commandId,params]|{"command":id,"params":{}}…] (a recorded action),"input":folder|[paths],"output":folder,"format":"same|png|jpg|psd|tiff|…"="same","quality":0..12?} → {files, errors}"##, native, batch), - spec!("file.scripts.imageProcessor", "Image Processor…", &["File", "Scripts"], None, r##"{"input":folder|[paths],"output":folder,"format":"jpg|png|psd|tiff|…"="jpg","quality":0..12=8,"width":px?,"height":px? (fit, never enlarge),"convertToSrgb":bool=false} → {files, errors}"##, native, image_processor), - spec!("file.scripts.loadFilesIntoStack", "Load Files into Stack…", &["File", "Scripts"], None, r##"{"paths":[str]|folder,"createSmartObject":bool=false} → new document with one layer per file (inside one smart object with createSmartObject)"##, native, load_files_into_stack), + spec!( + "file.saveACopy", + "Save a Copy…", + &["File"], + Some("Cmd+Alt+S"), + r##"{"path":str (format from the extension),"quality":0..12? (JPEG),"layers":bool=true}"##, + native_doc, + save_a_copy + ), + spec!( + "file.openAs", + "Open As…", + &["File"], + Some("Cmd+Alt+Shift+O"), + r##"{"path":str,"as":"psd|png|jpg|tiff|…"? (decode as this format)}"##, + native, + open_as + ), + spec!( + "file.placeEmbedded", + "Place Embedded…", + &["File"], + None, + r##"{"path":str,"scale":%? (default: fit when larger than the canvas),"fit":bool=true,"center":[x,y]?}"##, + native_doc, + |s, p| place(s, p, false) + ), + spec!("file.placeLinked", "Place Linked…", &["File"], None, r##"{"path":str,"scale":%?,"fit":bool=true,"center":[x,y]?}"##, native_doc, |s, p| place( + s, p, true + )), + spec!( + "file.fileInfo", + "File Info…", + &["File"], + Some("Cmd+Alt+Shift+I"), + r##"{"title":str?,"author":str?,"authorTitle":str?,"description":str?,"keywords":[str]|"a; b"?,"copyright":str?,"copyrightStatus":"unknown|copyrighted|publicDomain"?,"copyrightUrl":str?} (no keys: read)"##, + has_doc, + file_info + ), + spec!( + "file.automate.fitImage", + "Fit Image…", + &["File", "Automate"], + None, + r##"{"width":px,"height":px,"dontEnlarge":bool=false,"resample":"bicubic|bilinear|nearest|lanczos|preserveDetails"="bicubic"}"##, + has_doc, + fit_image + ), + spec!( + "file.automate.conditionalModeChange", + "Conditional Mode Change…", + &["File", "Automate"], + None, + r##"{"from":["rgb","grayscale","cmyk","lab","indexed","bitmap",…]|"any"="any","to":"rgb|grayscale|cmyk|lab"}"##, + has_doc, + conditional_mode_change + ), + spec!( + "file.automate.batch", + "Batch…", + &["File", "Automate"], + None, + r##"{"steps":[[commandId,params]|{"command":id,"params":{}}…] (a recorded action),"input":folder|[paths],"output":folder,"format":"same|png|jpg|psd|tiff|…"="same","quality":0..12?} → {files, errors}"##, + native, + batch + ), + spec!( + "file.scripts.imageProcessor", + "Image Processor…", + &["File", "Scripts"], + None, + r##"{"input":folder|[paths],"output":folder,"format":"jpg|png|psd|tiff|…"="jpg","quality":0..12=8,"width":px?,"height":px? (fit, never enlarge),"convertToSrgb":bool=false} → {files, errors}"##, + native, + image_processor + ), + spec!( + "file.scripts.loadFilesIntoStack", + "Load Files into Stack…", + &["File", "Scripts"], + None, + r##"{"paths":[str]|folder,"createSmartObject":bool=false} → new document with one layer per file (inside one smart object with createSmartObject)"##, + native, + load_files_into_stack + ), spec!("file.scripts.flattenAllLayerEffects", "Flatten All Layer Effects", &["File", "Scripts"], None, "{}", has_doc, |s, _| flatten_all_effects(s)), - spec!("file.scripts.flattenAllMasks", "Flatten All Masks", &["File", "Scripts"], None, "{} (pixel layers; masks on other layer kinds are left)", has_doc, |s, _| flatten_all_masks(s)), - spec!("file.export.layersToFiles", "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?} → {files}"##, native_doc, layers_to_files), - spec!("file.export.colorLookupTables", "Color Lookup Tables…", &["File", "Export"], None, r##"{"path":str? (.cube; omit to return the text),"size":2..256=33,"title":str?}"##, has_adjustments, color_lookup_tables), - spec!("view.newGuideLayout", "New Guide Layout…", &["View"], None, r##"{"columns":n=0,"width":px?,"gutter":px=0,"rows":n=0,"height":px?,"rowGutter":px=gutter,"margin":px|[top,left,bottom,right]=0,"centerColumns":bool=false,"clearExisting":bool=false}"##, has_doc, new_guide_layout), + spec!( + "file.scripts.flattenAllMasks", + "Flatten All Masks", + &["File", "Scripts"], + None, + "{} (pixel layers; masks on other layer kinds are left)", + has_doc, + |s, _| flatten_all_masks(s) + ), + spec!( + "file.export.layersToFiles", + "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?} → {files}"##, + native_doc, + layers_to_files + ), + spec!( + "file.export.colorLookupTables", + "Color Lookup Tables…", + &["File", "Export"], + None, + r##"{"path":str? (.cube; omit to return the text),"size":2..256=33,"title":str?}"##, + has_adjustments, + color_lookup_tables + ), + spec!( + "view.newGuideLayout", + "New Guide Layout…", + &["View"], + None, + r##"{"columns":n=0,"width":px?,"gutter":px=0,"rows":n=0,"height":px?,"rowGutter":px=gutter,"margin":px|[top,left,bottom,right]=0,"centerColumns":bool=false,"clearExisting":bool=false}"##, + has_doc, + new_guide_layout + ), spec!("view.newGuidesFromShape", "New Guides From Shape", &["View"], None, r##"{"layer":id?}"##, has_shape, guides_from_shape), spec!("view.clearCanvasGuides", "Clear Canvas Guides", &["View"], None, "{}", has_doc, |s, _| clear_canvas_guides(s)), ] diff --git a/crates/engine/src/file_cmds/tests.rs b/crates/engine/src/file_cmds/tests.rs index c28cb2b6f7..8b69c6f3c5 100644 --- a/crates/engine/src/file_cmds/tests.rs +++ b/crates/engine/src/file_cmds/tests.rs @@ -203,7 +203,10 @@ fn flatten_all_layer_effects_and_masks() { s.edit("fx", |doc, _| { let l = doc.layer_mut(id).unwrap(); l.effects.enabled = true; - l.effects.items.push(photocraft_doc::Effect::ColorOverlay { common: photocraft_doc::effects::FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), color: photocraft_color::Color::rgb(0.0, 1.0, 0.0) }); + l.effects.items.push(photocraft_doc::Effect::ColorOverlay { + common: photocraft_doc::effects::FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), + color: photocraft_color::Color::rgb(0.0, 1.0, 0.0), + }); Ok(()) }) .unwrap(); @@ -287,7 +290,8 @@ fn color_lookup_table_bakes_the_adjustment_stack() { let r = s.execute("file.export.colorLookupTables", json!({"size": 3})).unwrap(); let cube = r["cube"].as_str().unwrap(); assert!(cube.contains("LUT_3D_SIZE 3")); - let rows: Vec> = cube.lines().filter(|l| l.chars().next().is_some_and(|c| c.is_ascii_digit())).map(|l| l.split(' ').map(|v| v.parse().unwrap()).collect()).collect(); + let rows: Vec> = + cube.lines().filter(|l| l.chars().next().is_some_and(|c| c.is_ascii_digit())).map(|l| l.split(' ').map(|v| v.parse().unwrap()).collect()).collect(); assert_eq!(rows.len(), 27); // First entry (0,0,0) → white; second (r = 0.5) → 0.5; last (1,1,1) → black. assert!(rows[0].iter().all(|v| (v - 1.0).abs() < 1e-3), "{:?}", rows[0]); diff --git a/crates/engine/src/filters.rs b/crates/engine/src/filters.rs index f2f3b06d63..bd77a562f5 100644 --- a/crates/engine/src/filters.rs +++ b/crates/engine/src/filters.rs @@ -33,11 +33,7 @@ fn undefined(p: &Value) -> UndefinedAreas { /// Minimum / Maximum › Preserve (Photoshop's default is Squareness). fn preserve(p: &Value) -> Preserve { - if s(p, "preserve", "squareness") == "roundness" { - Preserve::Roundness - } else { - Preserve::Squareness - } + if s(p, "preserve", "squareness") == "roundness" { Preserve::Roundness } else { Preserve::Squareness } } /// Builds the algorithm parameters for a filter command id from JSON params @@ -60,7 +56,9 @@ pub fn params_for(id: &str, p: &Value) -> Option { center_x: f(p, "centerX", 0.5), center_y: f(p, "centerY", 0.5), }, - "filter.blur.surfaceBlur" => FilterParams::SurfaceBlur { radius: f(p, "radius", 5.0).clamp(1.0, 100.0), threshold: f(p, "threshold", 15.0).clamp(2.0, 255.0) }, + "filter.blur.surfaceBlur" => { + FilterParams::SurfaceBlur { radius: f(p, "radius", 5.0).clamp(1.0, 100.0), threshold: f(p, "threshold", 15.0).clamp(2.0, 255.0) } + } "filter.sharpen.unsharpMask" => FilterParams::UnsharpMask { amount: f(p, "amount", 50.0).clamp(1.0, 500.0), radius: f(p, "radius", 1.0).clamp(0.1, 1000.0), @@ -79,12 +77,16 @@ pub fn params_for(id: &str, p: &Value) -> Option { seed: i(p, "seed", 0) as u32, }, "filter.noise.median" => FilterParams::Median { radius: f(p, "radius", 1.0).clamp(1.0, 500.0) }, - "filter.noise.dustAndScratches" => FilterParams::DustAndScratches { radius: f(p, "radius", 1.0).clamp(1.0, 500.0), threshold: f(p, "threshold", 0.0).clamp(0.0, 255.0) }, + "filter.noise.dustAndScratches" => { + FilterParams::DustAndScratches { radius: f(p, "radius", 1.0).clamp(1.0, 500.0), threshold: f(p, "threshold", 0.0).clamp(0.0, 255.0) } + } "filter.other.minimum" => FilterParams::Minimum { radius: f(p, "radius", 1.0).clamp(0.2, 500.0), preserve: preserve(p) }, "filter.other.maximum" => FilterParams::Maximum { radius: f(p, "radius", 1.0).clamp(0.2, 500.0), preserve: preserve(p) }, "filter.other.offset" => FilterParams::Offset { horizontal: i(p, "horizontal", 0), vertical: i(p, "vertical", 0), undefined: undefined(p) }, "filter.pixelate.mosaic" => FilterParams::Mosaic { cell_size: f(p, "cellSize", 10.0).clamp(2.0, 200.0) }, - "filter.stylize.emboss" => FilterParams::Emboss { angle: f(p, "angle", 135.0), height: f(p, "height", 3.0).clamp(1.0, 100.0), amount: f(p, "amount", 100.0).clamp(1.0, 500.0) }, + "filter.stylize.emboss" => { + FilterParams::Emboss { angle: f(p, "angle", 135.0), height: f(p, "height", 3.0).clamp(1.0, 100.0), amount: f(p, "amount", 100.0).clamp(1.0, 500.0) } + } "filter.stylize.findEdges" => FilterParams::FindEdges, "filter.stylize.solarize" => FilterParams::Solarize, "filter.distort.twirl" => FilterParams::Twirl { angle: f(p, "angle", 50.0).clamp(-999.0, 999.0) }, @@ -204,7 +206,8 @@ pub(crate) fn run_filter(s: &mut Session, id: &str, p: &Value) -> Result LayerContent::Raster(surf) => surf, LayerContent::Smart(_) => { // Non-destructive: record the filter and re-render the smart object from its source. - let sf = SmartFilter { command: id.to_string(), params: params.clone(), blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true }; + let sf = + SmartFilter { command: id.to_string(), params: params.clone(), blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true }; return crate::smart_cmds::add_smart_filter(doc, layer, sf, selection.as_ref()); } _ => return Err(EngineError::Other("not a pixel layer".into())), @@ -244,11 +247,31 @@ pub fn specs() -> Vec { filter_cmd!("filter.noise.despeckle", "Despeckle", ["Filter", "Noise"], "{}"), filter_cmd!("filter.blur.boxBlur", "Box Blur…", ["Filter", "Blur"], r##"{"radius":1..2000=1}"##), filter_cmd!("filter.blur.motionBlur", "Motion Blur…", ["Filter", "Blur"], r##"{"angle":-360..360=0,"distance":1..2000=10}"##), - filter_cmd!("filter.blur.radialBlur", "Radial Blur…", ["Filter", "Blur"], r##"{"amount":1..100=10,"method":"spin|zoom","centerX":0..1=0.5,"centerY":0..1=0.5}"##), + filter_cmd!( + "filter.blur.radialBlur", + "Radial Blur…", + ["Filter", "Blur"], + r##"{"amount":1..100=10,"method":"spin|zoom","centerX":0..1=0.5,"centerY":0..1=0.5}"## + ), filter_cmd!("filter.blur.surfaceBlur", "Surface Blur…", ["Filter", "Blur"], r##"{"radius":1..100=5,"threshold":2..255=15}"##), - filter_cmd!("filter.sharpen.unsharpMask", "Unsharp Mask…", ["Filter", "Sharpen"], r##"{"amount":1..500=50,"radius":0.1..1000=1,"threshold":0..255=0}"##), - filter_cmd!("filter.sharpen.smartSharpen", "Smart Sharpen…", ["Filter", "Sharpen"], r##"{"amount":1..500=100,"radius":0.1..64=1,"reduceNoise":0..100=10}"##), - filter_cmd!("filter.noise.addNoise", "Add Noise…", ["Filter", "Noise"], r##"{"amount":0.1..400=12.5,"distribution":"uniform|gaussian","monochromatic":bool,"seed":u32=0}"##), + filter_cmd!( + "filter.sharpen.unsharpMask", + "Unsharp Mask…", + ["Filter", "Sharpen"], + r##"{"amount":1..500=50,"radius":0.1..1000=1,"threshold":0..255=0}"## + ), + filter_cmd!( + "filter.sharpen.smartSharpen", + "Smart Sharpen…", + ["Filter", "Sharpen"], + r##"{"amount":1..500=100,"radius":0.1..64=1,"reduceNoise":0..100=10}"## + ), + filter_cmd!( + "filter.noise.addNoise", + "Add Noise…", + ["Filter", "Noise"], + r##"{"amount":0.1..400=12.5,"distribution":"uniform|gaussian","monochromatic":bool,"seed":u32=0}"## + ), filter_cmd!("filter.noise.median", "Median…", ["Filter", "Noise"], r##"{"radius":1..500=1}"##), filter_cmd!("filter.noise.dustAndScratches", "Dust & Scratches…", ["Filter", "Noise"], r##"{"radius":1..500=1,"threshold":0..255=0}"##), filter_cmd!("filter.pixelate.mosaic", "Mosaic…", ["Filter", "Pixelate"], r##"{"cellSize":2..200=10}"##), @@ -258,13 +281,23 @@ pub fn specs() -> Vec { filter_cmd!("filter.distort.twirl", "Twirl…", ["Filter", "Distort"], r##"{"angle":-999..999=50}"##), filter_cmd!("filter.distort.pinch", "Pinch…", ["Filter", "Distort"], r##"{"amount":-100..100=50}"##), filter_cmd!("filter.distort.spherize", "Spherize…", ["Filter", "Distort"], r##"{"amount":-100..100=100,"mode":"normal|horizontalOnly|verticalOnly"}"##), - filter_cmd!("filter.distort.wave", "Wave…", ["Filter", "Distort"], r##"{"generators":1..999=5,"wavelengthMin":1..998=10,"wavelengthMax":2..999=120,"amplitudeMin":1..998=5,"amplitudeMax":1..999=35,"type":"sine|triangle|square","undefinedAreas":"wrap|repeat","seed":u32=0}"##), + filter_cmd!( + "filter.distort.wave", + "Wave…", + ["Filter", "Distort"], + r##"{"generators":1..999=5,"wavelengthMin":1..998=10,"wavelengthMax":2..999=120,"amplitudeMin":1..998=5,"amplitudeMax":1..999=35,"type":"sine|triangle|square","undefinedAreas":"wrap|repeat","seed":u32=0}"## + ), filter_cmd!("filter.distort.ripple", "Ripple…", ["Filter", "Distort"], r##"{"amount":-999..999=100,"size":"small|medium|large"}"##), filter_cmd!("filter.distort.polarCoordinates", "Polar Coordinates…", ["Filter", "Distort"], r##"{"mode":"rectangularToPolar|polarToRectangular"}"##), filter_cmd!("filter.other.highPass", "High Pass…", ["Filter", "Other"], r##"{"radius":0.1..1000=10}"##), filter_cmd!("filter.other.minimum", "Minimum…", ["Filter", "Other"], r##"{"radius":0.2..500=1,"preserve":"squareness|roundness"}"##), filter_cmd!("filter.other.maximum", "Maximum…", ["Filter", "Other"], r##"{"radius":0.2..500=1,"preserve":"squareness|roundness"}"##), - filter_cmd!("filter.other.offset", "Offset…", ["Filter", "Other"], r##"{"horizontal":px=0,"vertical":px=0,"undefinedAreas":"wrap|repeat|transparent"}"##), + filter_cmd!( + "filter.other.offset", + "Offset…", + ["Filter", "Other"], + r##"{"horizontal":px=0,"vertical":px=0,"undefinedAreas":"wrap|repeat|transparent"}"## + ), CommandSpec { id: "filter.lastFilter", label: "Last Filter", @@ -439,68 +472,36 @@ mod tests { fn minimum_maximum_preserve_roundness() { for depth in [8, 16, 32] { let mut s = Session::new(); - s.execute( - "file.new", - json!({"width": 48, "height": 32, "depth": depth}), - ) - .unwrap(); + s.execute("file.new", json!({"width": 48, "height": 32, "depth": depth})).unwrap(); s.execute("layer.new.layer", json!({})).unwrap(); // A white dot on black. s.edit("dot", |doc, active| { - let surf = doc - .layer_mut(active.unwrap()) - .unwrap() - .surface_mut() - .unwrap(); - surf.fill_rect( - photocraft_geom::Rect::new(0, 0, 48, 32), - &[0.0, 0.0, 0.0, 1.0], - ); + let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + surf.fill_rect(photocraft_geom::Rect::new(0, 0, 48, 32), &[0.0, 0.0, 0.0, 1.0]); surf.write_pixel(24, 16, &[1.0, 1.0, 1.0, 1.0]); Ok(()) }) .unwrap(); let before = active_pixels(&s); let lit = |s: &Session, x: usize, y: usize| active_pixels(s)[(y * 48 + x) * 4] > 0.5; - s.execute( - "filter.other.maximum", - json!({"radius": 4, "preserve": "roundness"}), - ) - .unwrap(); + s.execute("filter.other.maximum", json!({"radius": 4, "preserve": "roundness"})).unwrap(); // Grown into a disc of radius 4: (4, 0) and (2, 3) are in, the square's corner isn't. assert!(lit(&s, 28, 16) && lit(&s, 26, 19), "depth {depth}"); assert!(!lit(&s, 28, 20) && !lit(&s, 27, 19), "depth {depth}"); s.execute("edit.undo", json!({})).unwrap(); assert_eq!(active_pixels(&s), before); // The default is Squareness: the corner is taken. - s.execute("filter.other.maximum", json!({"radius": 4})) - .unwrap(); + s.execute("filter.other.maximum", json!({"radius": 4})).unwrap(); assert!(lit(&s, 28, 20), "depth {depth}"); // Minimum with roundness: only the centre of that 9×9 square holds a whole disc. - s.execute( - "filter.other.minimum", - json!({"radius": 4, "preserve": "roundness"}), - ) - .unwrap(); + s.execute("filter.other.minimum", json!({"radius": 4, "preserve": "roundness"})).unwrap(); assert!(!lit(&s, 20, 12) && lit(&s, 24, 16), "depth {depth}"); } assert_eq!( - params_for( - "filter.other.minimum", - &json!({"radius": 2, "preserve": "roundness"}) - ), - Some(FilterParams::Minimum { - radius: 2.0, - preserve: Preserve::Roundness - }) - ); - assert_eq!( - params_for("filter.other.maximum", &json!({})), - Some(FilterParams::Maximum { - radius: 1.0, - preserve: Preserve::Squareness - }) + params_for("filter.other.minimum", &json!({"radius": 2, "preserve": "roundness"})), + Some(FilterParams::Minimum { radius: 2.0, preserve: Preserve::Roundness }) ); + assert_eq!(params_for("filter.other.maximum", &json!({})), Some(FilterParams::Maximum { radius: 1.0, preserve: Preserve::Squareness })); } #[test] diff --git a/crates/engine/src/filters_ext.rs b/crates/engine/src/filters_ext.rs index 0eab06b222..4fafacf96c 100644 --- a/crates/engine/src/filters_ext.rs +++ b/crates/engine/src/filters_ext.rs @@ -10,8 +10,8 @@ use std::sync::Arc; use photocraft_algo::{ - self as algo, BlurPath, BlurQuality, BlurShape, DepthSource, DiffuseMode, Distribution, ExtrudeType, FieldPin, FilterParams, HsbModel, IrisPin, LensType, Light, LightKind, MezzotintType, SmartBlurMode, SpinPin, - TextureChannel, TileFill, UndefinedAreas, WindMethod, ZigZagStyle, + self as algo, BlurPath, BlurQuality, BlurShape, DepthSource, DiffuseMode, Distribution, ExtrudeType, FieldPin, FilterParams, HsbModel, IrisPin, LensType, + Light, LightKind, MezzotintType, SmartBlurMode, SpinPin, TextureChannel, TileFill, UndefinedAreas, WindMethod, ZigZagStyle, }; use photocraft_doc::{Document, Layer}; use photocraft_geom::Rect; @@ -93,7 +93,9 @@ pub fn params_for(id: &str, p: &Value) -> Option { }, seed: seed(p), }, - "filter.pixelate.pointillize" => FilterParams::Pointillize { cell_size: f(p, "cellSize", 5.0).clamp(3.0, 300.0), seed: seed(p), background: colour(p, "background", WHITE) }, + "filter.pixelate.pointillize" => { + FilterParams::Pointillize { cell_size: f(p, "cellSize", 5.0).clamp(3.0, 300.0), seed: seed(p), background: colour(p, "background", WHITE) } + } // ---- Stylize ---- "filter.stylize.diffuse" => FilterParams::Diffuse { mode: match s(p, "mode", "normal") { @@ -203,7 +205,10 @@ pub fn params_for(id: &str, p: &Value) -> Option { target_y: f(p, "targetY", 0.55), angle: f(p, "angle", 135.0), elevation: f(p, "elevation", 45.0).clamp(0.0, 90.0), - color: { let c = colour(p, "color", WHITE); [c[0], c[1], c[2]] }, + color: { + let c = colour(p, "color", WHITE); + [c[0], c[1], c[2]] + }, intensity: f(p, "intensity", 75.0).clamp(-100.0, 100.0), cone: f(p, "cone", 45.0).clamp(1.0, 89.0), hotspot: f(p, "hotspot", 50.0).clamp(0.0, 100.0), @@ -355,7 +360,10 @@ pub fn params_for(id: &str, p: &Value) -> Option { FilterParams::HsbHsl { input: m("inputMode", "rgb"), output: m("rowOrder", "hsb") } } // ---- Video ---- - "filter.video.deInterlace" => FilterParams::DeInterlace { eliminate_even: s(p, "eliminate", "oddFields") == "evenFields", interpolate: s(p, "createBy", "interpolation") != "duplication" }, + "filter.video.deInterlace" => FilterParams::DeInterlace { + eliminate_even: s(p, "eliminate", "oddFields") == "evenFields", + interpolate: s(p, "createBy", "interpolation") != "duplication", + }, "filter.video.ntscColors" => FilterParams::NtscColors, _ => return crate::gallery_cmds::params_for(id, p), }) @@ -447,26 +455,71 @@ macro_rules! cmd { pub fn specs() -> Vec { vec![ // Pixelate - cmd!("filter.pixelate.colorHalftone", "Color Halftone…", ["Filter", "Pixelate"], r##"{"maxRadius":4..127=8,"channel1":-360..360=108,"channel2":-360..360=162,"channel3":-360..360=90,"channel4":-360..360=45}"##), + cmd!( + "filter.pixelate.colorHalftone", + "Color Halftone…", + ["Filter", "Pixelate"], + r##"{"maxRadius":4..127=8,"channel1":-360..360=108,"channel2":-360..360=162,"channel3":-360..360=90,"channel4":-360..360=45}"## + ), cmd!("filter.pixelate.crystallize", "Crystallize…", ["Filter", "Pixelate"], r##"{"cellSize":3..300=10,"seed":u32=0}"##), cmd!("filter.pixelate.facet", "Facet", ["Filter", "Pixelate"], "{}"), cmd!("filter.pixelate.fragment", "Fragment", ["Filter", "Pixelate"], "{}"), - cmd!("filter.pixelate.mezzotint", "Mezzotint…", ["Filter", "Pixelate"], r##"{"type":"fineDots|mediumDots|grainyDots|coarseDots|shortLines|mediumLines|longLines|shortStrokes|mediumStrokes|longStrokes","seed":u32=0}"##), + cmd!( + "filter.pixelate.mezzotint", + "Mezzotint…", + ["Filter", "Pixelate"], + r##"{"type":"fineDots|mediumDots|grainyDots|coarseDots|shortLines|mediumLines|longLines|shortStrokes|mediumStrokes|longStrokes","seed":u32=0}"## + ), cmd!("filter.pixelate.pointillize", "Pointillize…", ["Filter", "Pixelate"], r##"{"cellSize":3..300=5,"seed":u32=0,"background":json}"##), // Stylize cmd!("filter.stylize.diffuse", "Diffuse…", ["Filter", "Stylize"], r##"{"mode":"normal|darkenOnly|lightenOnly|anisotropic","seed":u32=0}"##), - cmd!("filter.stylize.extrude", "Extrude…", ["Filter", "Stylize"], r##"{"type":"blocks|pyramids","size":2..255=30,"depth":1..255=30,"depthMode":"random|levelBased","solidFrontFaces":bool,"maskIncompleteBlocks":bool,"seed":u32=0}"##), - cmd!("filter.stylize.oilPaint", "Oil Paint…", ["Filter", "Stylize"], r##"{"stylization":0.1..10=4,"cleanliness":0..10=5,"scale":0.1..10=1,"bristleDetail":0..10=5,"lighting":bool=true,"angle":-180..180=-60,"shine":0..10=1}"##), - cmd!("filter.stylize.tiles", "Tiles…", ["Filter", "Stylize"], r##"{"count":1..99=10,"maxOffset":1..99=10,"fill":"background|foreground|inverse|unaltered","seed":u32=0,"foreground":json,"background":json}"##), + cmd!( + "filter.stylize.extrude", + "Extrude…", + ["Filter", "Stylize"], + r##"{"type":"blocks|pyramids","size":2..255=30,"depth":1..255=30,"depthMode":"random|levelBased","solidFrontFaces":bool,"maskIncompleteBlocks":bool,"seed":u32=0}"## + ), + cmd!( + "filter.stylize.oilPaint", + "Oil Paint…", + ["Filter", "Stylize"], + r##"{"stylization":0.1..10=4,"cleanliness":0..10=5,"scale":0.1..10=1,"bristleDetail":0..10=5,"lighting":bool=true,"angle":-180..180=-60,"shine":0..10=1}"## + ), + cmd!( + "filter.stylize.tiles", + "Tiles…", + ["Filter", "Stylize"], + r##"{"count":1..99=10,"maxOffset":1..99=10,"fill":"background|foreground|inverse|unaltered","seed":u32=0,"foreground":json,"background":json}"## + ), cmd!("filter.stylize.traceContour", "Trace Contour…", ["Filter", "Stylize"], r##"{"level":0..255=128,"edge":"lower|upper"}"##), cmd!("filter.stylize.wind", "Wind…", ["Filter", "Stylize"], r##"{"method":"wind|blast|stagger","direction":"fromRight|fromLeft","seed":u32=0}"##), // Distort - cmd!("filter.distort.displace", "Displace…", ["Filter", "Distort"], r##"{"horizontal":-999..999=10,"vertical":-999..999=10,"fit":"stretch|tile","undefinedAreas":"repeat|wrap","mapDocument":doc,"mapPath":text,"mapLayer":json}"##), + cmd!( + "filter.distort.displace", + "Displace…", + ["Filter", "Distort"], + r##"{"horizontal":-999..999=10,"vertical":-999..999=10,"fit":"stretch|tile","undefinedAreas":"repeat|wrap","mapDocument":doc,"mapPath":text,"mapLayer":json}"## + ), cmd!("filter.distort.shear", "Shear…", ["Filter", "Distort"], r##"{"amount":-100..100=0,"undefinedAreas":"wrap|repeat","points":json}"##), - cmd!("filter.distort.zigZag", "ZigZag…", ["Filter", "Distort"], r##"{"amount":-100..100=10,"ridges":0..20=5,"style":"pondRipples|outFromCenter|aroundCenter"}"##), + cmd!( + "filter.distort.zigZag", + "ZigZag…", + ["Filter", "Distort"], + r##"{"amount":-100..100=10,"ridges":0..20=5,"style":"pondRipples|outFromCenter|aroundCenter"}"## + ), // Render - cmd!("filter.render.fibers", "Fibers…", ["Filter", "Render"], r##"{"variance":1..64=16,"strength":1..64=4,"seed":u32=0,"foreground":json,"background":json}"##), - cmd!("filter.render.lensFlare", "Lens Flare…", ["Filter", "Render"], r##"{"brightness":10..300=100,"centerX":0..1=0.5,"centerY":0..1=0.5,"lens":"zoom|prime35|prime105|moviePrime"}"##), + cmd!( + "filter.render.fibers", + "Fibers…", + ["Filter", "Render"], + r##"{"variance":1..64=16,"strength":1..64=4,"seed":u32=0,"foreground":json,"background":json}"## + ), + cmd!( + "filter.render.lensFlare", + "Lens Flare…", + ["Filter", "Render"], + r##"{"brightness":10..300=100,"centerX":0..1=0.5,"centerY":0..1=0.5,"lens":"zoom|prime35|prime105|moviePrime"}"## + ), cmd!( "filter.render.lightingEffects", "Lighting Effects…", @@ -474,27 +527,72 @@ pub fn specs() -> Vec { r##"{"lightType":"spot|point|infinite","intensity":-100..100=75,"lightX":0..1=0.25,"lightY":0..1=0.2,"lightZ":0..2=0.6,"targetX":0..1=0.5,"targetY":0..1=0.55,"cone":1..89=45,"hotspot":0..100=50,"angle":-180..180=135,"elevation":0..90=45,"gloss":-100..100=0,"metallic":-100..100=0,"exposure":-100..100=0,"ambience":-100..100=8,"texture":"none|red|green|blue|alpha|luminance","height":0..100=50,"whiteIsHigh":bool=true,"lights":json}"## ), // Noise - cmd!("filter.noise.reduceNoise", "Reduce Noise…", ["Filter", "Noise"], r##"{"strength":0..10=6,"preserveDetails":0..100=60,"reduceColorNoise":0..100=45,"sharpenDetails":0..100=25,"removeJpegArtifact":bool}"##), + cmd!( + "filter.noise.reduceNoise", + "Reduce Noise…", + ["Filter", "Noise"], + r##"{"strength":0..10=6,"preserveDetails":0..100=60,"reduceColorNoise":0..100=45,"sharpenDetails":0..100=25,"removeJpegArtifact":bool}"## + ), // Blur - cmd!("filter.blur.smartBlur", "Smart Blur…", ["Filter", "Blur"], r##"{"radius":0.1..100=3,"threshold":0.1..100=25,"quality":"high|medium|low","mode":"normal|edgeOnly|overlayEdge"}"##), + cmd!( + "filter.blur.smartBlur", + "Smart Blur…", + ["Filter", "Blur"], + r##"{"radius":0.1..100=3,"threshold":0.1..100=25,"quality":"high|medium|low","mode":"normal|edgeOnly|overlayEdge"}"## + ), cmd!( "filter.blur.lensBlur", "Lens Blur…", ["Filter", "Blur"], r##"{"radius":0..100=15,"shape":"hexagon|triangle|square|pentagon|heptagon|octagon","bladeCurvature":0..100=0,"rotation":0..360=0,"depthMap":"none|transparency|layerMask","focalDistance":0..255=0,"invert":bool,"brightness":0..100=0,"threshold":0..255=255,"noise":0..100=0,"distribution":"uniform|gaussian","monochromatic":bool,"seed":u32=0}"## ), - cmd!("filter.blur.shapeBlur", "Shape Blur…", ["Filter", "Blur"], r##"{"radius":5..1000=10,"shape":"circle|ring|square|diamond|triangle|hexagon|star|heart|cross"}"##), + cmd!( + "filter.blur.shapeBlur", + "Shape Blur…", + ["Filter", "Blur"], + r##"{"radius":5..1000=10,"shape":"circle|ring|square|diamond|triangle|hexagon|star|heart|cross"}"## + ), // Blur Gallery - cmd!("filter.blurGallery.tiltShift", "Tilt-Shift…", ["Filter", "Blur Gallery"], r##"{"blur":0..500=15,"centerX":0..1=0.5,"centerY":0..1=0.5,"angle":-90..90=0,"focus":0..1=0.1,"transition":0.01..1=0.15}"##), - cmd!("filter.blurGallery.irisBlur", "Iris Blur…", ["Filter", "Blur Gallery"], r##"{"blur":0..500=15,"centerX":0..1=0.5,"centerY":0..1=0.5,"radiusX":0.01..1=0.35,"radiusY":0.01..1=0.25,"angle":-180..180=0,"roundness":0..100=0,"feather":0..0.99=0.5,"pins":json}"##), - cmd!("filter.blurGallery.fieldBlur", "Field Blur…", ["Filter", "Blur Gallery"], r##"{"blur":0..500=15,"centerX":0..1=0.5,"centerY":0..1=0.5,"pins":json}"##), - cmd!("filter.blurGallery.spinBlur", "Spin Blur…", ["Filter", "Blur Gallery"], r##"{"blurAngle":0..360=15,"centerX":0..1=0.5,"centerY":0..1=0.5,"radiusX":0.01..1=0.3,"radiusY":0.01..1=0.3,"angle":-180..180=0,"pins":json}"##), - cmd!("filter.blurGallery.pathBlur", "Path Blur…", ["Filter", "Blur Gallery"], r##"{"speed":0..500=50,"taper":0..100=0,"startX":0..1=0.2,"startY":0..1=0.5,"endX":0..1=0.8,"endY":0..1=0.5,"paths":json}"##), + cmd!( + "filter.blurGallery.tiltShift", + "Tilt-Shift…", + ["Filter", "Blur Gallery"], + r##"{"blur":0..500=15,"centerX":0..1=0.5,"centerY":0..1=0.5,"angle":-90..90=0,"focus":0..1=0.1,"transition":0.01..1=0.15}"## + ), + cmd!( + "filter.blurGallery.irisBlur", + "Iris Blur…", + ["Filter", "Blur Gallery"], + r##"{"blur":0..500=15,"centerX":0..1=0.5,"centerY":0..1=0.5,"radiusX":0.01..1=0.35,"radiusY":0.01..1=0.25,"angle":-180..180=0,"roundness":0..100=0,"feather":0..0.99=0.5,"pins":json}"## + ), + cmd!( + "filter.blurGallery.fieldBlur", + "Field Blur…", + ["Filter", "Blur Gallery"], + r##"{"blur":0..500=15,"centerX":0..1=0.5,"centerY":0..1=0.5,"pins":json}"## + ), + cmd!( + "filter.blurGallery.spinBlur", + "Spin Blur…", + ["Filter", "Blur Gallery"], + r##"{"blurAngle":0..360=15,"centerX":0..1=0.5,"centerY":0..1=0.5,"radiusX":0.01..1=0.3,"radiusY":0.01..1=0.3,"angle":-180..180=0,"pins":json}"## + ), + cmd!( + "filter.blurGallery.pathBlur", + "Path Blur…", + ["Filter", "Blur Gallery"], + r##"{"speed":0..500=50,"taper":0..100=0,"startX":0..1=0.2,"startY":0..1=0.5,"endX":0..1=0.8,"endY":0..1=0.5,"paths":json}"## + ), // Other cmd!("filter.other.custom", "Custom…", ["Filter", "Other"], r##"{"kernel":int[25],"scale":1..9999=1,"offset":-9999..9999=0}"##), cmd!("filter.other.hsbHsl", "HSB/HSL", ["Filter", "Other"], r##"{"inputMode":"rgb|hsb|hsl","rowOrder":"hsb|hsl|rgb"}"##), // Video - cmd!("filter.video.deInterlace", "De-Interlace…", ["Filter", "Video"], r##"{"eliminate":"oddFields|evenFields","createBy":"interpolation|duplication"}"##), + cmd!( + "filter.video.deInterlace", + "De-Interlace…", + ["Filter", "Video"], + r##"{"eliminate":"oddFields|evenFields","createBy":"interpolation|duplication"}"## + ), cmd!("filter.video.ntscColors", "NTSC Colors", ["Filter", "Video"], "{}"), ] } diff --git a/crates/engine/src/filters_ext/tests.rs b/crates/engine/src/filters_ext/tests.rs index 19c0839fd3..ecbd4a2eb5 100644 --- a/crates/engine/src/filters_ext/tests.rs +++ b/crates/engine/src/filters_ext/tests.rs @@ -201,8 +201,13 @@ fn lens_blur_uses_the_layer_mask_as_depth() { #[test] fn params_map_to_algorithm_units() { assert_eq!(params_for("filter.pixelate.crystallize", &json!({"cellSize": 1})), Some(FilterParams::Crystallize { cell_size: 3.0, seed: 0 })); - assert_eq!(params_for("filter.stylize.traceContour", &json!({"level": 300, "edge": "upper"})), Some(FilterParams::TraceContour { level: 255.0, upper: true })); - let Some(FilterParams::IrisBlur { pins }) = params_for("filter.blurGallery.irisBlur", &json!({"pins": [{"x": 0.2, "blur": 30}, {"x": 0.8}]})) else { panic!() }; + assert_eq!( + params_for("filter.stylize.traceContour", &json!({"level": 300, "edge": "upper"})), + Some(FilterParams::TraceContour { level: 255.0, upper: true }) + ); + let Some(FilterParams::IrisBlur { pins }) = params_for("filter.blurGallery.irisBlur", &json!({"pins": [{"x": 0.2, "blur": 30}, {"x": 0.8}]})) else { + panic!() + }; assert_eq!(pins.len(), 2); assert_eq!(pins[0].blur, 30.0); assert_eq!(pins[1].blur, IrisPin::default().blur); diff --git a/crates/engine/src/frame_cmds.rs b/crates/engine/src/frame_cmds.rs index d14cbab0bb..2b6606e829 100644 --- a/crates/engine/src/frame_cmds.rs +++ b/crates/engine/src/frame_cmds.rs @@ -39,17 +39,10 @@ fn frame_from_layers(s: &mut Session, p: &Value) -> Result { gp.insert("name".into(), name.clone()); } let grouped = s.execute("layer.new.groupFromLayers", Value::Object(gp))?; - let gid = LayerId( - grouped - .get("layer") - .and_then(Value::as_u64) - .ok_or_else(|| EngineError::Other("grouping did not return a layer".into()))?, - ); - let rect = crate::layer_multi_cmds::layer_bounds( - s.active().ok_or(EngineError::NoDocument)?.doc.layer(gid).ok_or(EngineError::NoLayer(gid))?, - ) - .filter(|r| !r.is_empty()) - .unwrap_or_else(|| s.active().unwrap().doc.bounds()); + let gid = LayerId(grouped.get("layer").and_then(Value::as_u64).ok_or_else(|| EngineError::Other("grouping did not return a layer".into()))?); + let rect = crate::layer_multi_cmds::layer_bounds(s.active().ok_or(EngineError::NoDocument)?.doc.layer(gid).ok_or(EngineError::NoLayer(gid))?) + .filter(|r| !r.is_empty()) + .unwrap_or_else(|| s.active().unwrap().doc.bounds()); s.edit("Frame from Layers", |doc, _| { let g = doc.layer_mut(gid).ok_or(EngineError::NoLayer(gid))?; diff --git a/crates/engine/src/gallery_cmds.rs b/crates/engine/src/gallery_cmds.rs index 4622f78a0b..484bd7a881 100644 --- a/crates/engine/src/gallery_cmds.rs +++ b/crates/engine/src/gallery_cmds.rs @@ -105,7 +105,9 @@ fn run_gallery(s: &mut Session, p: &Value) -> Result { } let effects = effects_from_json(p).map_err(EngineError::Other)?; if effects.is_empty() { - return Err(EngineError::Other("filter.filterGallery needs `effects`: [{\"filter\": \"\", \"params\": {...}}] (pass {\"list\": true} for the catalogue)".into())); + return Err(EngineError::Other( + "filter.filterGallery needs `effects`: [{\"filter\": \"\", \"params\": {...}}] (pass {\"list\": true} for the catalogue)".into(), + )); } // Normalize the recorded params (journal / smart filter) to the canonical form. let mut q: Map = p.as_object().cloned().unwrap_or_default(); @@ -143,12 +145,53 @@ pub fn specs() -> Vec { journal: true, }]; v.extend(gallery_specs![ - ColoredPencil, Cutout, DryBrush, FilmGrain, Fresco, NeonGlow, PaintDaubs, PaletteKnife, PlasticWrap, PosterEdges, RoughPastels, SmudgeStick, Sponge, Underpainting, Watercolor, - AccentedEdges, AngledStrokes, Crosshatch, DarkStrokes, InkOutlines, Spatter, SprayedStrokes, SumiE, - DiffuseGlow, Glass, OceanRipple, - BasRelief, ChalkCharcoal, Charcoal, Chrome, ConteCrayon, GraphicPen, HalftonePattern, NotePaper, Photocopy, Plaster, Reticulation, Stamp, TornEdges, WaterPaper, + ColoredPencil, + Cutout, + DryBrush, + FilmGrain, + Fresco, + NeonGlow, + PaintDaubs, + PaletteKnife, + PlasticWrap, + PosterEdges, + RoughPastels, + SmudgeStick, + Sponge, + Underpainting, + Watercolor, + AccentedEdges, + AngledStrokes, + Crosshatch, + DarkStrokes, + InkOutlines, + Spatter, + SprayedStrokes, + SumiE, + DiffuseGlow, + Glass, + OceanRipple, + BasRelief, + ChalkCharcoal, + Charcoal, + Chrome, + ConteCrayon, + GraphicPen, + HalftonePattern, + NotePaper, + Photocopy, + Plaster, + Reticulation, + Stamp, + TornEdges, + WaterPaper, GlowingEdges, - Craquelure, Grain, MosaicTiles, Patchwork, StainedGlass, Texturizer, + Craquelure, + Grain, + MosaicTiles, + Patchwork, + StainedGlass, + Texturizer, ]); v } diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index e5c8d71504..e14c4ba099 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -194,8 +194,20 @@ fn crop_doc(doc: &mut Document, r: Rect, delete_pixels: bool) { } fn anchor_factors(a: &str) -> (f64, f64) { - let x = if a.contains("Left") || a == "left" { 0.0 } else if a.contains("Right") || a == "right" { 1.0 } else { 0.5 }; - let y = if a.starts_with("top") { 0.0 } else if a.starts_with("bottom") { 1.0 } else { 0.5 }; + let x = if a.contains("Left") || a == "left" { + 0.0 + } else if a.contains("Right") || a == "right" { + 1.0 + } else { + 0.5 + }; + let y = if a.starts_with("top") { + 0.0 + } else if a.starts_with("bottom") { + 1.0 + } else { + 0.5 + }; (x, y) } @@ -261,7 +273,9 @@ fn crop(s: &mut Session, p: &Value) -> Result { _ => None, }; // An explicit rectangle (the Crop tool) may extend past the canvas; a selection crop is clamped. - let r = explicit.or_else(|| s.active().and_then(|d| d.doc.selection.as_ref().map(|sel| sel.content_bounds().intersect(&d.doc.bounds())))).unwrap_or(Rect::EMPTY); + let r = explicit + .or_else(|| s.active().and_then(|d| d.doc.selection.as_ref().map(|sel| sel.content_bounds().intersect(&d.doc.bounds())))) + .unwrap_or(Rect::EMPTY); if r.is_empty() { return Err(EngineError::Other("nothing to crop: pass x/y/width/height or make a selection".into())); } @@ -368,14 +382,63 @@ macro_rules! spec { /// Image menu command specs. pub fn specs() -> Vec { vec![ - spec!("image.imageSize", "Image Size…", ["Image"], r##"{"width":px,"height":px,"resolution":ppi,"resample":"bicubic|bilinear|nearest|lanczos|preserveDetails|none"="bicubic"}"##, has_doc, image_size), - spec!("image.canvasSize", "Canvas Size…", ["Image"], r##"{"width":px,"height":px,"relative":bool=false,"anchor":"topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight"="center","extensionColor":"background|foreground|white|black|transparent|#rrggbb"="background"}"##, has_doc, canvas_size), + spec!( + "image.imageSize", + "Image Size…", + ["Image"], + r##"{"width":px,"height":px,"resolution":ppi,"resample":"bicubic|bilinear|nearest|lanczos|preserveDetails|none"="bicubic"}"##, + has_doc, + image_size + ), + spec!( + "image.canvasSize", + "Canvas Size…", + ["Image"], + r##"{"width":px,"height":px,"relative":bool=false,"anchor":"topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight"="center","extensionColor":"background|foreground|white|black|transparent|#rrggbb"="background"}"##, + has_doc, + canvas_size + ), spec!("image.crop", "Crop", ["Image"], r##"{"x":px,"y":px,"width":px,"height":px,"deleteCroppedPixels":bool=true}"##, has_doc, crop), - spec!("image.trim", "Trim…", ["Image"], r##"{"basedOn":"transparent|topLeft|bottomRight"="transparent","top":bool=true,"bottom":bool=true,"left":bool=true,"right":bool=true}"##, has_doc, trim), - spec!("image.mode.rgb", "RGB Color", ["Image", "Mode"], r##"{"profile":"|working|/path/to/profile.icc"=working,"intent":"perceptual|relative|saturation|absolute"="relative","bpc":bool=true}"##, has_doc, |s, p| convert_mode(s, ColorMode::Rgb, p)), - spec!("image.mode.grayscale", "Grayscale", ["Image", "Mode"], r##"{"profile":"|working|/path/to/profile.icc"=working,"intent":"perceptual|relative|saturation|absolute"="relative","bpc":bool=true}"##, has_doc, |s, p| convert_mode(s, ColorMode::Grayscale, p)), - spec!("image.mode.cmyk", "CMYK Color", ["Image", "Mode"], r##"{"profile":"|working|/path/to/profile.icc"=working,"intent":"perceptual|relative|saturation|absolute"="relative","bpc":bool=true}"##, has_doc, |s, p| convert_mode(s, ColorMode::Cmyk, p)), - spec!("image.mode.lab", "Lab Color", ["Image", "Mode"], r##"{"profile":"|working|/path/to/profile.icc"=working,"intent":"perceptual|relative|saturation|absolute"="relative","bpc":bool=true}"##, has_doc, |s, p| convert_mode(s, ColorMode::Lab, p)), + spec!( + "image.trim", + "Trim…", + ["Image"], + r##"{"basedOn":"transparent|topLeft|bottomRight"="transparent","top":bool=true,"bottom":bool=true,"left":bool=true,"right":bool=true}"##, + has_doc, + trim + ), + spec!( + "image.mode.rgb", + "RGB Color", + ["Image", "Mode"], + r##"{"profile":"|working|/path/to/profile.icc"=working,"intent":"perceptual|relative|saturation|absolute"="relative","bpc":bool=true}"##, + has_doc, + |s, p| convert_mode(s, ColorMode::Rgb, p) + ), + spec!( + "image.mode.grayscale", + "Grayscale", + ["Image", "Mode"], + r##"{"profile":"|working|/path/to/profile.icc"=working,"intent":"perceptual|relative|saturation|absolute"="relative","bpc":bool=true}"##, + has_doc, + |s, p| convert_mode(s, ColorMode::Grayscale, p) + ), + spec!( + "image.mode.cmyk", + "CMYK Color", + ["Image", "Mode"], + r##"{"profile":"|working|/path/to/profile.icc"=working,"intent":"perceptual|relative|saturation|absolute"="relative","bpc":bool=true}"##, + has_doc, + |s, p| convert_mode(s, ColorMode::Cmyk, p) + ), + spec!( + "image.mode.lab", + "Lab Color", + ["Image", "Mode"], + r##"{"profile":"|working|/path/to/profile.icc"=working,"intent":"perceptual|relative|saturation|absolute"="relative","bpc":bool=true}"##, + has_doc, + |s, p| convert_mode(s, ColorMode::Lab, p) + ), spec!("image.mode.bits8", "8 Bits/Channel", ["Image", "Mode"], "{}", has_doc, |s, _| convert_depth(s, SampleType::U8)), spec!("image.mode.bits16", "16 Bits/Channel", ["Image", "Mode"], "{}", has_doc, |s, _| convert_depth(s, SampleType::U16)), spec!("image.mode.bits32", "32 Bits/Channel", ["Image", "Mode"], "{}", has_doc, |s, _| convert_depth(s, SampleType::F32)), diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index eaa86eb61e..340cce92ad 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -93,7 +93,12 @@ fn layer_sel(l: &Layer, selected: &[photocraft_doc::LayerId]) -> Value { v["adjustment"] = serde_json::to_value(a).unwrap_or(Value::Null); } LayerContent::Smart(sm) => { - v["smartFilters"] = Value::Array(sm.smart_filters.iter().map(|f| json!({"command": f.command, "params": f.params, "visible": f.visible, "opacity": f.opacity, "blend": f.blend.label()})).collect()); + v["smartFilters"] = Value::Array( + sm.smart_filters + .iter() + .map(|f| json!({"command": f.command, "params": f.params, "visible": f.visible, "opacity": f.opacity, "blend": f.blend.label()})) + .collect(), + ); v["smartFiltersEnabled"] = json!(sm.filters_enabled); } LayerContent::Text(t) => { diff --git a/crates/engine/src/layer_menu_cmds.rs b/crates/engine/src/layer_menu_cmds.rs index 9539f89935..5e604b7a35 100644 --- a/crates/engine/src/layer_menu_cmds.rs +++ b/crates/engine/src/layer_menu_cmds.rs @@ -5,10 +5,7 @@ use photocraft_algo::selection::Region; use photocraft_color::{BlendMode, ColorMode}; -use photocraft_doc::{ - BlendIf, BlendRange, Document, Effect, Layer, LayerContent, LayerId, LayerMask, SmartSource, - StackMode, -}; +use photocraft_doc::{BlendIf, BlendRange, Document, Effect, Layer, LayerContent, LayerId, LayerMask, SmartSource, StackMode}; use photocraft_geom::Rect; use photocraft_raster::{Surface, from_rgba_into, to_rgba}; use serde_json::{Value, json}; @@ -17,10 +14,7 @@ use crate::commands::{CommandSpec, layer_param}; use crate::{EngineError, Result, Session}; fn bad(cmd: &str, msg: impl Into) -> EngineError { - EngineError::BadParams { - cmd: cmd.into(), - msg: msg.into(), - } + EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } } fn other(msg: impl Into) -> EngineError { @@ -28,18 +22,14 @@ fn other(msg: impl Into) -> EngineError { } fn num(p: &Value, key: &str, default: f32) -> f32 { - p.get(key) - .and_then(Value::as_f64) - .map_or(default, |v| v as f32) + p.get(key).and_then(Value::as_f64).map_or(default, |v| v as f32) } type Enabled = std::result::Result<(), String>; fn active_layer(s: &Session) -> std::result::Result<&Layer, String> { let d = s.active().ok_or("no document open")?; - d.active_layer - .and_then(|id| d.doc.layer(id)) - .ok_or_else(|| "no active layer".into()) + d.active_layer.and_then(|id| d.doc.layer(id)).ok_or_else(|| "no active layer".into()) } fn has_layer(s: &Session) -> Enabled { @@ -48,46 +38,25 @@ fn has_layer(s: &Session) -> Enabled { fn has_raster(s: &Session) -> Enabled { let l = active_layer(s)?; - if matches!(l.content, LayerContent::Raster(_)) { - Ok(()) - } else { - Err(format!( - "active layer is a {} layer, not a pixel layer", - l.content.kind_name() - )) - } + if matches!(l.content, LayerContent::Raster(_)) { Ok(()) } else { Err(format!("active layer is a {} layer, not a pixel layer", l.content.kind_name())) } } fn has_raster_with_mask(s: &Session) -> Enabled { has_raster(s)?; - if active_layer(s)?.mask.is_some() { - Ok(()) - } else { - Err("the layer has no layer mask".into()) - } + if active_layer(s)?.mask.is_some() { Ok(()) } else { Err("the layer has no layer mask".into()) } } fn has_selection_layer(s: &Session) -> Enabled { has_layer(s)?; - if s.active().is_some_and(|d| d.doc.selection.is_some()) { - Ok(()) - } else { - Err("no selection".into()) - } + if s.active().is_some_and(|d| d.doc.selection.is_some()) { Ok(()) } else { Err("no selection".into()) } } fn has_doc(s: &Session) -> Enabled { - s.active() - .map(|_| ()) - .ok_or_else(|| "no document open".into()) + s.active().map(|_| ()).ok_or_else(|| "no document open".into()) } fn has_smart(s: &Session) -> Enabled { - if matches!(active_layer(s)?.content, LayerContent::Smart(_)) { - Ok(()) - } else { - Err("the active layer is not a smart object".into()) - } + if matches!(active_layer(s)?.content, LayerContent::Smart(_)) { Ok(()) } else { Err("the active layer is not a smart object".into()) } } fn has_linked_smart(s: &Session) -> Enabled { @@ -99,25 +68,14 @@ fn has_linked_smart(s: &Session) -> Enabled { fn has_effects(s: &Session) -> Enabled { let l = active_layer(s)?; - if l.effects.items.iter().any(Effect::enabled) { - Ok(()) - } else { - Err("the layer has no layer effects".into()) - } + if l.effects.items.iter().any(Effect::enabled) { Ok(()) } else { Err("the layer has no layer effects".into()) } } fn has_content_options(s: &Session) -> Enabled { let l = active_layer(s)?; match l.content { - LayerContent::Adjustment(_) - | LayerContent::Fill(_) - | LayerContent::Text(_) - | LayerContent::Smart(_) - | LayerContent::Shape(_) => Ok(()), - _ => Err(format!( - "{} layers have no content options", - l.content.kind_name() - )), + LayerContent::Adjustment(_) | LayerContent::Fill(_) | LayerContent::Text(_) | LayerContent::Smart(_) | LayerContent::Shape(_) => Ok(()), + _ => Err(format!("{} layers have no content options", l.content.kind_name())), } } @@ -127,10 +85,7 @@ fn has_content_options(s: &Session) -> Enabled { fn read_rgba(surf: &Surface, r: Rect) -> Vec<[f32; 4]> { let fmt = surf.format(); let n = fmt.channels(); - surf.read_region(r) - .chunks_exact(n) - .map(|q| to_rgba(&fmt, q)) - .collect() + surf.read_region(r).chunks_exact(n).map(|q| to_rgba(&fmt, q)).collect() } fn write_rgba(surf: &mut Surface, r: Rect, px: &[[f32; 4]]) { @@ -145,21 +100,13 @@ fn write_rgba(surf: &mut Surface, r: Rect, px: &[[f32; 4]]) { } /// One history step editing the active raster layer's RGBA (content bounds grown by `grow`). -fn edit_rgba( - s: &mut Session, - label: &str, - grow: i32, - f: impl FnOnce(&mut Vec<[f32; 4]>, Rect), -) -> Result { +fn edit_rgba(s: &mut Session, label: &str, grow: i32, f: impl FnOnce(&mut Vec<[f32; 4]>, Rect)) -> Result { let id = layer_param(s, &Value::Null)?; s.edit(label, |doc, _| { let bounds = doc.bounds(); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; let surf = crate::commands::paint_surface(l, &Value::Null)?; - let r = surf - .content_bounds() - .inflate(grow) - .intersect(&bounds.inflate(grow)); + let r = surf.content_bounds().inflate(grow).intersect(&bounds.inflate(grow)); if r.is_empty() { return Ok(Value::Null); } @@ -178,10 +125,7 @@ fn apply_mask(s: &mut Session, p: &Value) -> Result { let id = layer_param(s, p)?; s.edit("Apply Layer Mask", |doc, _| { let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; - let mask = l - .mask - .take() - .ok_or_else(|| other("the layer has no layer mask"))?; + let mask = l.mask.take().ok_or_else(|| other("the layer has no layer mask"))?; let surf = crate::commands::paint_surface(l, &Value::Null)?; let r = surf.content_bounds(); if !r.is_empty() { @@ -251,10 +195,7 @@ fn mask_all_objects(s: &mut Session, p: &Value) -> Result { let layer = doc.layer(id).ok_or(EngineError::NoLayer(id))?; let region: Option = match layer.surface() { Some(surf) if matches!(layer.content, LayerContent::Raster(_)) => { - photocraft_algo::segment::subject::select_subject( - &photocraft_algo::segment::SurfaceSampler(surf), - doc.bounds(), - ) + photocraft_algo::segment::subject::select_subject(&photocraft_algo::segment::SurfaceSampler(surf), doc.bounds()) } _ => { struct Composite<'a>(&'a Document); @@ -289,12 +230,8 @@ pub fn defringe(px: &mut [[f32; 4]], w: usize, h: usize, width: usize) { return; } let dist = photocraft_algo::selection::edt(&outside, w, h); - let mut known: Vec = (0..px.len()) - .map(|i| !outside[i] && dist[i] > width as f32) - .collect(); - let mut todo: Vec = (0..px.len()) - .filter(|&i| !outside[i] && !known[i]) - .collect(); + let mut known: Vec = (0..px.len()).map(|i| !outside[i] && dist[i] > width as f32).collect(); + let mut todo: Vec = (0..px.len()).filter(|&i| !outside[i] && !known[i]).collect(); while !todo.is_empty() { let mut next = Vec::new(); let mut updates = Vec::new(); @@ -302,16 +239,7 @@ pub fn defringe(px: &mut [[f32; 4]], w: usize, h: usize, width: usize) { let (x, y) = ((i % w) as i32, (i / w) as i32); let mut acc = [0.0f32; 3]; let mut n = 0.0; - for (dx, dy) in [ - (-1, 0), - (1, 0), - (0, -1), - (0, 1), - (-1, -1), - (1, -1), - (-1, 1), - (1, 1), - ] { + for (dx, dy) in [(-1, 0), (1, 0), (0, -1), (0, 1), (-1, -1), (1, -1), (-1, 1), (1, 1)] { let (nx, ny) = (x + dx, y + dy); if nx < 0 || ny < 0 || nx >= w as i32 || ny >= h as i32 { continue; @@ -343,30 +271,19 @@ pub fn defringe(px: &mut [[f32; 4]], w: usize, h: usize, width: usize) { fn matting_defringe(s: &mut Session, p: &Value) -> Result { let width = num(p, "width", 1.0).clamp(1.0, 200.0).round() as usize; - edit_rgba(s, "Defringe", 1, |px, r| { - defringe(px, r.width() as usize, r.height() as usize, width) - }) + edit_rgba(s, "Defringe", 1, |px, r| defringe(px, r.width() as usize, r.height() as usize, width)) } /// Remove Black / White Matte: un-premultiply colours that were composited over black or white. fn remove_matte(s: &mut Session, white: bool) -> Result { - edit_rgba( - s, - if white { - "Remove White Matte" - } else { - "Remove Black Matte" - }, - 0, - |px, _| { - for q in px.iter_mut().filter(|q| q[3] > 0.0 && q[3] < 1.0) { - let a = q[3]; - for v in &mut q[..3] { - *v = if white { (*v - (1.0 - a)) / a } else { *v / a }.clamp(0.0, 1.0); - } + edit_rgba(s, if white { "Remove White Matte" } else { "Remove Black Matte" }, 0, |px, _| { + for q in px.iter_mut().filter(|q| q[3] > 0.0 && q[3] < 1.0) { + let a = q[3]; + for v in &mut q[..3] { + *v = if white { (*v - (1.0 - a)) / a } else { *v / a }.clamp(0.0, 1.0); } - }, - ) + } + }) } /// Layer › Matting › Color Decontaminate: fringe colours move toward nearby opaque colours @@ -382,14 +299,8 @@ fn color_decontaminate(s: &mut Session, p: &Value) -> Result { if r.is_empty() || !surf.format().alpha { return Ok(()); } - let alpha: Vec = read_rgba(surf, r) - .iter() - .map(|q| (q[3].clamp(0.0, 1.0) * 255.0).round() as u8) - .collect(); - let region = Region { - bbox: r, - mask: alpha, - }; + let alpha: Vec = read_rgba(surf, r).iter().map(|q| (q[3].clamp(0.0, 1.0) * 255.0).round() as u8).collect(); + let region = Region { bbox: r, mask: alpha }; *surf = photocraft_algo::matting::decontaminate(surf, ®ion, radius, amount); Ok(()) })?; @@ -431,10 +342,7 @@ macro_rules! stack_spec { /// Native-only: show a linked smart object's file in the platform file manager. fn reveal_in_finder(s: &mut Session, p: &Value) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; - let l = d - .active_layer - .and_then(|id| d.doc.layer(id)) - .ok_or_else(|| other("no active layer"))?; + let l = d.active_layer.and_then(|id| d.doc.layer(id)).ok_or_else(|| other("no active layer"))?; let LayerContent::Smart(sm) = &l.content else { return Err(other("the active layer is not a smart object")); }; @@ -456,20 +364,14 @@ pub fn reveal_command(path: &str) -> (&'static str, Vec) { } else if cfg!(target_os = "windows") { ("explorer", vec![format!("/select,{path}")]) } else { - let parent = std::path::Path::new(path) - .parent() - .map_or_else(|| ".".to_string(), |p| p.to_string_lossy().into_owned()); + let parent = std::path::Path::new(path).parent().map_or_else(|| ".".to_string(), |p| p.to_string_lossy().into_owned()); ("xdg-open", vec![parent]) } } #[cfg(not(target_arch = "wasm32"))] fn spawn(program: &str, args: &[String]) -> Result<()> { - std::process::Command::new(program) - .args(args) - .spawn() - .map(|_| ()) - .map_err(|e| other(format!("{program}: {e}"))) + std::process::Command::new(program).args(args).spawn().map(|_| ()).map_err(|e| other(format!("{program}: {e}"))) } #[cfg(target_arch = "wasm32")] @@ -478,11 +380,7 @@ fn spawn(_: &str, _: &[String]) -> Result<()> { } fn native(_: &Session) -> Enabled { - if cfg!(target_arch = "wasm32") { - Err("not available in the browser".into()) - } else { - Ok(()) - } + if cfg!(target_arch = "wasm32") { Err("not available in the browser".into()) } else { Ok(()) } } // ---------- layer style ---------- @@ -491,20 +389,11 @@ fn blending_options(s: &mut Session, p: &Value) -> Result { const CMD: &str = "layer.layerStyle.blendingOptions"; let id = layer_param(s, p)?; let blend = match p.get("blend").and_then(Value::as_str) { - Some(b) => Some( - crate::commands::blend_from_str(b) - .ok_or_else(|| bad(CMD, format!("unknown blend mode `{b}`")))?, - ), + Some(b) => Some(crate::commands::blend_from_str(b).ok_or_else(|| bad(CMD, format!("unknown blend mode `{b}`")))?), None => None, }; - let opacity = p - .get("opacity") - .and_then(Value::as_f64) - .map(|v| (v as f32 / 100.0).clamp(0.0, 1.0)); - let fill = p - .get("fillOpacity") - .and_then(Value::as_f64) - .map(|v| (v as f32 / 100.0).clamp(0.0, 1.0)); + let opacity = p.get("opacity").and_then(Value::as_f64).map(|v| (v as f32 / 100.0).clamp(0.0, 1.0)); + let fill = p.get("fillOpacity").and_then(Value::as_f64).map(|v| (v as f32 / 100.0).clamp(0.0, 1.0)); let mode = s.active().ok_or(EngineError::NoDocument)?.doc.mode; let blend_if = match p.get("blendIf") { None => None, @@ -530,8 +419,7 @@ fn blending_options(s: &mut Session, p: &Value) -> Result { Some(Some(entries)) => { for (i, this, under) in entries { let [cur_this, cur_under] = l.blend_if.get(*i); - l.blend_if - .set(*i, [this.unwrap_or(cur_this), under.unwrap_or(cur_under)]); + l.blend_if.set(*i, [this.unwrap_or(cur_this), under.unwrap_or(cur_under)]); } } None => {} @@ -552,26 +440,21 @@ fn blend_if_entries(v: &Value, mode: ColorMode) -> Result a.iter().collect(), Value::Object(_) => vec![v], _ => { - return Err(bad( - CMD, - "blendIf: expected an object, a list of objects or null", - )); + return Err(bad(CMD, "blendIf: expected an object, a list of objects or null")); } }; let mut out = Vec::with_capacity(items.len()); for it in items { let ch = it.get("channel").unwrap_or(&Value::Null); - let i = blend_if_channel(ch, mode).ok_or_else(|| { - bad( - CMD, - format!("blendIf: unknown channel {ch} for a {mode:?} document"), - ) - })?; + let i = blend_if_channel(ch, mode).ok_or_else(|| bad(CMD, format!("blendIf: unknown channel {ch} for a {mode:?} document")))?; let range = |key: &str| -> Result> { match it.get(key) { None => Ok(None), Some(r) => blend_range(r).map(Some).ok_or_else(|| { - bad(CMD, format!("blendIf.{key}: expected [black, white] or [blackLow, blackHigh, whiteLow, whiteHigh], 0..255, in increasing order (got {r})")) + bad( + CMD, + format!("blendIf.{key}: expected [black, white] or [blackLow, blackHigh, whiteLow, whiteHigh], 0..255, in increasing order (got {r})"), + ) }), } }; @@ -605,22 +488,13 @@ fn blend_if_channel(v: &Value, mode: ColorMode) -> Option { /// in increasing order (the dialog's sliders can't cross). fn blend_range(v: &Value) -> Option { let a = v.as_array()?; - let n: Vec = a - .iter() - .map(|x| { - x.as_f64() - .filter(|f| (0.0..=255.0).contains(f)) - .map(|f| f.round() as u8) - }) - .collect::>()?; + let n: Vec = a.iter().map(|x| x.as_f64().filter(|f| (0.0..=255.0).contains(f)).map(|f| f.round() as u8)).collect::>()?; let q = match n.as_slice() { [b, w] => [*b, *b, *w, *w], [a, b, c, d] => [*a, *b, *c, *d], _ => return None, }; - q.windows(2) - .all(|w| w[0] <= w[1]) - .then(|| BlendRange::from_bytes(q)) + q.windows(2).all(|w| w[0] <= w[1]).then(|| BlendRange::from_bytes(q)) } fn global_light(s: &mut Session, p: &Value) -> Result { @@ -653,12 +527,9 @@ fn is_below(e: &Effect) -> bool { match e { Effect::DropShadow(_) | Effect::OuterGlow(_) => true, Effect::Stroke(st) => st.position == photocraft_doc::StrokePosition::Outside, - Effect::BevelEmboss(b) => matches!( - b.style, - photocraft_doc::BevelStyle::OuterBevel - | photocraft_doc::BevelStyle::Emboss - | photocraft_doc::BevelStyle::PillowEmboss - ), + Effect::BevelEmboss(b) => { + matches!(b.style, photocraft_doc::BevelStyle::OuterBevel | photocraft_doc::BevelStyle::Emboss | photocraft_doc::BevelStyle::PillowEmboss) + } _ => false, } } @@ -668,9 +539,7 @@ fn effect_common_mut(e: &mut Effect) -> Option<&mut photocraft_doc::FxCommon> { Effect::DropShadow(s) | Effect::InnerShadow(s) => Some(&mut s.common), Effect::OuterGlow(g) | Effect::InnerGlow(g) => Some(&mut g.common), Effect::Stroke(s) => Some(&mut s.common), - Effect::ColorOverlay { common, .. } - | Effect::GradientOverlay { common, .. } - | Effect::PatternOverlay { common, .. } => Some(common), + Effect::ColorOverlay { common, .. } | Effect::GradientOverlay { common, .. } | Effect::PatternOverlay { common, .. } => Some(common), Effect::Satin(s) => Some(&mut s.common), Effect::BevelEmboss(_) => None, } @@ -686,20 +555,11 @@ fn create_layer(s: &mut Session, p: &Value) -> Result { let canvas = doc.bounds(); let fmt = doc.pixel_format(); let l = doc.layer(id).ok_or(EngineError::NoLayer(id))?.clone(); - let effects: Vec = l - .effects - .items - .iter() - .filter(|e| e.enabled()) - .cloned() - .collect(); + let effects: Vec = l.effects.items.iter().filter(|e| e.enabled()).cloned().collect(); if effects.is_empty() || !l.effects.enabled { return Err(other("the layer has no layer effects")); } - let area = l - .surface() - .map_or(canvas, |s| s.content_bounds().union(&canvas)) - .inflate(256); + let area = l.surface().map_or(canvas, |s| s.content_bounds().union(&canvas)).inflate(256); let light = doc.global_light; let mut below = Vec::new(); let mut above = Vec::new(); @@ -730,17 +590,10 @@ fn create_layer(s: &mut Session, p: &Value) -> Result { tmp.layers = vec![alone]; let buf = photocraft_compose::render(&tmp, area); let mut surf = Surface::new(fmt); - let data: Vec = buf - .px - .iter() - .flat_map(|q| photocraft_raster::from_rgba(&fmt, *q)) - .collect(); + let data: Vec = buf.px.iter().flat_map(|q| photocraft_raster::from_rgba(&fmt, *q)).collect(); surf.write_region(area, &data); surf.prune(); - let mut nl = Layer::new( - format!("{}'s {}", l.name, e.label()), - LayerContent::Raster(surf), - ); + let mut nl = Layer::new(format!("{}'s {}", l.name, e.label()), LayerContent::Raster(surf)); nl.blend = blend; nl.opacity = opacity; if is_below(&e) { @@ -754,13 +607,8 @@ fn create_layer(s: &mut Session, p: &Value) -> Result { // Insert in order: below layers under the source, clipped layers above it. let path = doc.path_of(id).ok_or(EngineError::NoLayer(id))?; let (&at, parent) = path.split_last().expect("non-empty path"); - let sib = if parent.is_empty() { - &mut doc.layers - } else { - doc.layer_at_mut(parent) - .and_then(|t| t.children_mut()) - .ok_or_else(|| other("bad layer path"))? - }; + let sib = + if parent.is_empty() { &mut doc.layers } else { doc.layer_at_mut(parent).and_then(|t| t.children_mut()).ok_or_else(|| other("bad layer path"))? }; let src = &mut sib[at]; src.effects.items.clear(); src.effects.psd_raw = None; @@ -788,10 +636,7 @@ fn content_options(s: &mut Session, _: &Value) -> Result { LayerContent::Smart(_) => "smartObject".into(), LayerContent::Shape(_) => "shape".into(), other => { - return Err(EngineError::Other(format!( - "{} layers have no content options", - other.kind_name() - ))); + return Err(EngineError::Other(format!("{} layers have no content options", other.kind_name()))); } }; Ok(json!({"layer": l.id.0, "editor": editor})) @@ -805,11 +650,7 @@ pub fn layer_document(doc: &Document, id: LayerId) -> Result { let l = doc.layer(id).ok_or(EngineError::NoLayer(id))?; let mut alone = l.clone(); alone.visible = true; - alone.blend = if alone.is_group() { - BlendMode::PassThrough - } else { - BlendMode::Normal - }; + alone.blend = if alone.is_group() { BlendMode::PassThrough } else { BlendMode::Normal }; alone.clipped = false; let mut tmp = doc.clone(); tmp.layers = vec![alone]; @@ -828,12 +669,7 @@ pub fn layer_document(doc: &Document, id: LayerId) -> Result { return Err(other("the layer is empty")); } let fmt = doc.pixel_format(); - let mut out = Document::new( - l.name.clone(), - photocraft_doc::Size::new(b.width(), b.height()), - doc.mode, - doc.depth, - ); + let mut out = Document::new(l.name.clone(), photocraft_doc::Size::new(b.width(), b.height()), doc.mode, doc.depth); out.resolution_dpi = doc.resolution_dpi; out.icc_profile = doc.icc_profile.clone(); let mut surf = Surface::new(fmt); @@ -841,14 +677,11 @@ pub fn layer_document(doc: &Document, id: LayerId) -> Result { .flat_map(|y| { let row = (y - area.y0) as usize * w; let px = &buf.px; - (b.x0..b.x1).flat_map(move |x| { - photocraft_raster::from_rgba(&fmt, px[row + (x - area.x0) as usize]) - }) + (b.x0..b.x1).flat_map(move |x| photocraft_raster::from_rgba(&fmt, px[row + (x - area.x0) as usize])) }) .collect(); surf.write_region(Rect::from_xywh(0, 0, b.width(), b.height()), &rows); - out.layers - .push(Layer::new(l.name.clone(), LayerContent::Raster(surf))); + out.layers.push(Layer::new(l.name.clone(), LayerContent::Raster(surf))); Ok(out) } @@ -856,11 +689,7 @@ fn export_layer(s: &mut Session, p: &Value, cmd: &str, png_only: bool) -> Result let id = layer_param(s, p)?; let d = s.active().ok_or(EngineError::NoDocument)?; let mut ldoc = layer_document(&d.doc, id)?; - let path = p - .get("path") - .and_then(Value::as_str) - .filter(|s| !s.is_empty()) - .ok_or_else(|| bad(cmd, "missing `path` (UIs ask for one)"))?; + let path = p.get("path").and_then(Value::as_str).filter(|s| !s.is_empty()).ok_or_else(|| bad(cmd, "missing `path` (UIs ask for one)"))?; let lower = path.to_ascii_lowercase(); if png_only && !lower.ends_with(".png") { return Err(bad(cmd, "Quick Export as PNG writes .png files")); @@ -869,18 +698,13 @@ fn export_layer(s: &mut Session, p: &Value, cmd: &str, png_only: bool) -> Result if (scale - 100.0).abs() > 1e-3 { let mut tmp = Session::new(); tmp.add_document(ldoc, None); - let w = (tmp.active().map_or(1, |d| d.doc.size.width) as f32 * scale / 100.0) - .round() - .max(1.0); + let w = (tmp.active().map_or(1, |d| d.doc.size.width) as f32 * scale / 100.0).round().max(1.0); tmp.execute("image.imageSize", json!({"width": w}))?; ldoc = (*tmp.active().ok_or(EngineError::NoDocument)?.doc).clone(); } - let out = photocraft_io::export(&ldoc, path, &Default::default()) - .map_err(|e| other(e.to_string()))?; + let out = photocraft_io::export(&ldoc, path, &Default::default()).map_err(|e| other(e.to_string()))?; write_file(path, &out.bytes)?; - Ok( - json!({"path": path, "bytes": out.bytes.len(), "width": ldoc.size.width, "height": ldoc.size.height, "warnings": out.warnings}), - ) + Ok(json!({"path": path, "bytes": out.bytes.len(), "width": ldoc.size.width, "height": ldoc.size.height, "warnings": out.warnings})) } #[cfg(not(target_arch = "wasm32"))] @@ -901,62 +725,13 @@ macro_rules! spec { pub fn specs() -> Vec { vec![ - spec!( - "layer.layerMask.apply", - "Apply", - ["Layer", "Layer Mask"], - r##"{"layer":id?}"##, - has_raster_with_mask, - apply_mask - ), - spec!( - "layer.layerMask.fromTransparency", - "From Transparency", - ["Layer", "Layer Mask"], - r##"{"layer":id?}"##, - has_raster, - mask_from_transparency - ), - spec!( - "layer.layerMask.hideSelection", - "Hide Selection", - ["Layer", "Layer Mask"], - r##"{"layer":id?}"##, - has_selection_layer, - hide_selection - ), - spec!( - "layer.maskAllObjects", - "Mask All Objects", - ["Layer"], - r##"{"layer":id?}"##, - has_layer, - mask_all_objects - ), - spec!( - "layer.matting.defringe", - "Defringe…", - ["Layer", "Matting"], - r##"{"width":1..200=1}"##, - has_raster, - matting_defringe - ), - spec!( - "layer.matting.removeBlackMatte", - "Remove Black Matte", - ["Layer", "Matting"], - "{}", - has_raster, - |s, _| remove_matte(s, false) - ), - spec!( - "layer.matting.removeWhiteMatte", - "Remove White Matte", - ["Layer", "Matting"], - "{}", - has_raster, - |s, _| remove_matte(s, true) - ), + spec!("layer.layerMask.apply", "Apply", ["Layer", "Layer Mask"], r##"{"layer":id?}"##, has_raster_with_mask, apply_mask), + spec!("layer.layerMask.fromTransparency", "From Transparency", ["Layer", "Layer Mask"], r##"{"layer":id?}"##, has_raster, mask_from_transparency), + spec!("layer.layerMask.hideSelection", "Hide Selection", ["Layer", "Layer Mask"], r##"{"layer":id?}"##, has_selection_layer, hide_selection), + spec!("layer.maskAllObjects", "Mask All Objects", ["Layer"], r##"{"layer":id?}"##, has_layer, mask_all_objects), + spec!("layer.matting.defringe", "Defringe…", ["Layer", "Matting"], r##"{"width":1..200=1}"##, has_raster, matting_defringe), + spec!("layer.matting.removeBlackMatte", "Remove Black Matte", ["Layer", "Matting"], "{}", has_raster, |s, _| remove_matte(s, false)), + spec!("layer.matting.removeWhiteMatte", "Remove White Matte", ["Layer", "Matting"], "{}", has_raster, |s, _| remove_matte(s, true)), spec!( "layer.matting.colorDecontaminate", "Color Decontaminate…", @@ -973,11 +748,7 @@ pub fn specs() -> Vec { stack_spec!("minimum", "Minimum", Some(StackMode::Minimum)), stack_spec!("range", "Range", Some(StackMode::Range)), stack_spec!("skewness", "Skewness", Some(StackMode::Skewness)), - stack_spec!( - "standardDeviation", - "Standard Deviation", - Some(StackMode::StandardDeviation) - ), + stack_spec!("standardDeviation", "Standard Deviation", Some(StackMode::StandardDeviation)), stack_spec!("summation", "Summation", Some(StackMode::Summation)), stack_spec!("variance", "Variance", Some(StackMode::Variance)), stack_spec!("none", "None", None), @@ -1005,22 +776,8 @@ pub fn specs() -> Vec { has_doc, global_light ), - spec!( - "layer.layerStyle.createLayer", - "Create Layer", - ["Layer", "Layer Style"], - r##"{"layer":id?}"##, - has_effects, - create_layer - ), - spec!( - "layer.layerStyle.scaleEffects", - "Scale Effects…", - ["Layer", "Layer Style"], - r##"{"scale":1..1000=100}"##, - has_effects, - scale_effects - ), + spec!("layer.layerStyle.createLayer", "Create Layer", ["Layer", "Layer Style"], r##"{"layer":id?}"##, has_effects, create_layer), + spec!("layer.layerStyle.scaleEffects", "Scale Effects…", ["Layer", "Layer Style"], r##"{"scale":1..1000=100}"##, has_effects, scale_effects), CommandSpec { id: "layer.layerContentOptions", label: "Layer Content Options…", @@ -1031,14 +788,12 @@ pub fn specs() -> Vec { run: content_options, journal: false, }, - spec!( + spec!("layer.quickExportAsPng", "Quick Export as PNG", ["Layer"], r##"{"layer":id?,"path":text}"##, has_layer, |s, p| export_layer( + s, + p, "layer.quickExportAsPng", - "Quick Export as PNG", - ["Layer"], - r##"{"layer":id?,"path":text}"##, - has_layer, - |s, p| export_layer(s, p, "layer.quickExportAsPng", true) - ), + true + )), spec!( "layer.exportAs", "Export As…", diff --git a/crates/engine/src/layer_menu_cmds/tests.rs b/crates/engine/src/layer_menu_cmds/tests.rs index 6e543a3e04..b2ecc0498c 100644 --- a/crates/engine/src/layer_menu_cmds/tests.rs +++ b/crates/engine/src/layer_menu_cmds/tests.rs @@ -20,11 +20,7 @@ fn active(s: &Session) -> &Layer { fn paint(s: &mut Session, f: impl Fn(i32, i32) -> [f32; 4]) { s.edit("setup", |doc, active| { let b = doc.bounds(); - let surf = doc - .layer_mut(active.unwrap()) - .unwrap() - .surface_mut() - .unwrap(); + let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); let fmt = surf.format(); let mut data = Vec::new(); for y in b.y0..b.y1 { @@ -44,12 +40,7 @@ fn disc(x: i32, y: i32) -> [f32; 4] { let d = (((x - 20) * (x - 20) + (y - 15) * (y - 15)) as f32).sqrt(); let a = (10.0 - d).clamp(0.0, 1.0); // Edge pixels were matted on black: colour scaled by alpha. - [ - 0.9 * if a < 1.0 { a } else { 1.0 }, - 0.1 * a.min(1.0), - 0.1 * a.min(1.0), - a, - ] + [0.9 * if a < 1.0 { a } else { 1.0 }, 0.1 * a.min(1.0), 0.1 * a.min(1.0), a] } fn select_left_half(s: &mut Session) { @@ -69,8 +60,7 @@ fn mask_apply_from_transparency_hide_selection() { paint(&mut s, |_, _| [0.2, 0.4, 0.6, 1.0]); assert!(!s.is_enabled("layer.layerMask.apply")); select_left_half(&mut s); - s.execute("layer.layerMask.hideSelection", json!({})) - .unwrap(); + s.execute("layer.layerMask.hideSelection", json!({})).unwrap(); assert!(doc(&s).selection.is_none()); let m = active(&s).mask.as_ref().unwrap(); assert_eq!((m.value(5, 5), m.value(30, 5)), (0.0, 1.0)); @@ -80,8 +70,7 @@ fn mask_apply_from_transparency_hide_selection() { assert_eq!(surf.rgba(5, 5)[3], 0.0); assert_eq!(surf.rgba(30, 5)[3], 1.0); // From Transparency: alpha moves into the mask, pixels become opaque. - s.execute("layer.layerMask.fromTransparency", json!({})) - .unwrap(); + s.execute("layer.layerMask.fromTransparency", json!({})).unwrap(); let l = active(&s); assert_eq!(l.mask.as_ref().unwrap().value(5, 5), 0.0); assert_eq!(l.mask.as_ref().unwrap().value(30, 5), 1.0); @@ -105,43 +94,27 @@ fn matting_commands_fix_dark_fringes() { }); let ex = edge.expect("an antialiased edge pixel"); let before = active(&s).surface().unwrap().rgba(ex, 17); - s.execute("layer.matting.removeBlackMatte", json!({})) - .unwrap(); + s.execute("layer.matting.removeBlackMatte", json!({})).unwrap(); let after = active(&s).surface().unwrap().rgba(ex, 17); - assert!( - after[0] > before[0] + 0.05, - "{depth}: {before:?} → {after:?}" - ); + assert!(after[0] > before[0] + 0.05, "{depth}: {before:?} → {after:?}"); assert!((after[3] - before[3]).abs() < 1e-2, "alpha kept"); s.execute("edit.undo", json!({})).unwrap(); - s.execute("layer.matting.defringe", json!({"width": 2})) - .unwrap(); + s.execute("layer.matting.defringe", json!({"width": 2})).unwrap(); let d = active(&s).surface().unwrap().rgba(ex, 17); - assert!( - d[0] > 0.8, - "{depth}: defringed edge takes the core red: {d:?}" - ); + assert!(d[0] > 0.8, "{depth}: defringed edge takes the core red: {d:?}"); s.execute("edit.undo", json!({})).unwrap(); - s.execute("layer.matting.colorDecontaminate", json!({"amount": 100})) - .unwrap(); + s.execute("layer.matting.colorDecontaminate", json!({"amount": 100})).unwrap(); let c = active(&s).surface().unwrap().rgba(ex, 17); assert!(c[0] > before[0], "{depth}: {before:?} → {c:?}"); s.execute("edit.undo", json!({})).unwrap(); - s.execute("layer.matting.removeWhiteMatte", json!({})) - .unwrap(); + s.execute("layer.matting.removeWhiteMatte", json!({})).unwrap(); } } #[test] fn defringe_pure_function() { // 5×1: transparent, fringe (blue), core red ×3. - let mut px = vec![ - [0.0, 0.0, 0.0, 0.0], - [0.0, 0.0, 1.0, 0.5], - [1.0, 0.0, 0.0, 1.0], - [1.0, 0.0, 0.0, 1.0], - [1.0, 0.0, 0.0, 1.0], - ]; + let mut px = vec![[0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.5], [1.0, 0.0, 0.0, 1.0], [1.0, 0.0, 0.0, 1.0], [1.0, 0.0, 0.0, 1.0]]; defringe(&mut px, 5, 1, 1); assert_eq!(px[1], [1.0, 0.0, 0.0, 0.5]); assert_eq!(px[0][3], 0.0); @@ -151,44 +124,16 @@ fn defringe_pure_function() { fn global_light_blending_options_scale_effects() { let mut s = session(8, "rgb"); paint(&mut s, disc); - s.execute( - "layer.layerStyle.globalLight", - json!({"angle": 200, "altitude": 45}), - ) - .unwrap(); - assert_eq!( - (doc(&s).global_light.angle, doc(&s).global_light.altitude), - (-160.0, 45.0) - ); - s.execute( - "layer.layerStyle.blendingOptions", - json!({"blend": "Multiply", "opacity": 50, "fillOpacity": 25}), - ) - .unwrap(); + s.execute("layer.layerStyle.globalLight", json!({"angle": 200, "altitude": 45})).unwrap(); + assert_eq!((doc(&s).global_light.angle, doc(&s).global_light.altitude), (-160.0, 45.0)); + s.execute("layer.layerStyle.blendingOptions", json!({"blend": "Multiply", "opacity": 50, "fillOpacity": 25})).unwrap(); let l = active(&s); - assert_eq!( - (l.blend, l.opacity, l.fill_opacity), - (BlendMode::Multiply, 0.5, 0.25) - ); - assert!( - s.execute("layer.layerStyle.blendingOptions", json!({"blend": "nope"})) - .is_err() - ); - assert!( - s.execute( - "layer.layerStyle.blendingOptions", - json!({"blend": "Pass Through"}) - ) - .is_err() - ); + assert_eq!((l.blend, l.opacity, l.fill_opacity), (BlendMode::Multiply, 0.5, 0.25)); + assert!(s.execute("layer.layerStyle.blendingOptions", json!({"blend": "nope"})).is_err()); + assert!(s.execute("layer.layerStyle.blendingOptions", json!({"blend": "Pass Through"})).is_err()); assert!(!s.is_enabled("layer.layerStyle.scaleEffects")); - s.execute( - "layer.layerStyle.dropShadow", - json!({"distance": 10, "size": 4}), - ) - .unwrap(); - s.execute("layer.layerStyle.scaleEffects", json!({"scale": 200})) - .unwrap(); + s.execute("layer.layerStyle.dropShadow", json!({"distance": 10, "size": 4})).unwrap(); + s.execute("layer.layerStyle.scaleEffects", json!({"scale": 200})).unwrap(); match &active(&s).effects.items[0] { Effect::DropShadow(sh) => assert_eq!((sh.distance, sh.size), (20.0, 8.0)), e => panic!("{e:?}"), @@ -200,32 +145,13 @@ fn blending_options_blend_if() { for depth in DEPTHS { let mut s = session(depth, "rgb"); // Black on the left, white on the right. - paint(&mut s, |x, _| { - if x < 20 { - [0.0, 0.0, 0.0, 1.0] - } else { - [1.0; 4] - } - }); + paint(&mut s, |x, _| if x < 20 { [0.0, 0.0, 0.0, 1.0] } else { [1.0; 4] }); let at = |s: &Session, x: i32| photocraft_compose::flatten(doc(s)).get(x, 5); // Gray › This Layer: black point 50 hides the black half. - s.execute( - "layer.layerStyle.blendingOptions", - json!({"blendIf": {"channel": "gray", "thisLayer": [50, 255]}}), - ) - .unwrap(); + s.execute("layer.layerStyle.blendingOptions", json!({"blendIf": {"channel": "gray", "thisLayer": [50, 255]}})).unwrap(); assert_eq!(at(&s, 5)[3], 0.0, "depth {depth}"); assert_eq!(at(&s, 30), [1.0; 4], "depth {depth}"); - assert_eq!( - active(&s).blend_if.get(0), - [ - BlendRange { - black: [50, 50], - white: [255, 255] - }, - BlendRange::FULL - ] - ); + assert_eq!(active(&s).blend_if.get(0), [BlendRange { black: [50, 50], white: [255, 255] }, BlendRange::FULL]); // Setting the other slider pair keeps this one; split points are 4 values. s.execute( "layer.layerStyle.blendingOptions", @@ -234,29 +160,13 @@ fn blending_options_blend_if() { .unwrap(); let bi = &active(&s).blend_if; assert_eq!(bi.get(0)[0].black, [50, 50]); - assert_eq!( - bi.get(0)[1], - BlendRange { - black: [0, 10], - white: [245, 255] - } - ); - assert_eq!( - bi.get(3)[0], - BlendRange { - black: [0, 0], - white: [200, 200] - } - ); + assert_eq!(bi.get(0)[1], BlendRange { black: [0, 10], white: [245, 255] }); + assert_eq!(bi.get(3)[0], BlendRange { black: [0, 0], white: [200, 200] }); let ins = crate::inspect::layer(active(&s)); - assert_eq!( - ins["blendIf"][0], - json!({"channel": 0, "thisLayer": [50, 50, 255, 255], "underlying": [0, 10, 245, 255]}) - ); + assert_eq!(ins["blendIf"][0], json!({"channel": 0, "thisLayer": [50, 50, 255, 255], "underlying": [0, 10, 245, 255]})); assert_eq!(ins["blendIf"][1]["channel"], 3); // Blending options without `blendIf` (the dialog's blend/opacity/fill) leave it alone. - s.execute("layer.layerStyle.blendingOptions", json!({"opacity": 90})) - .unwrap(); + s.execute("layer.layerStyle.blendingOptions", json!({"opacity": 90})).unwrap(); assert!(!active(&s).blend_if.is_default()); // One undo step per call. s.execute("edit.undo", json!({})).unwrap(); @@ -264,8 +174,7 @@ fn blending_options_blend_if() { assert_eq!(active(&s).blend_if.get(0)[1], BlendRange::FULL); assert_eq!(active(&s).blend_if.get(0)[0].black, [50, 50]); // null resets everything. - s.execute("layer.layerStyle.blendingOptions", json!({"blendIf": null})) - .unwrap(); + s.execute("layer.layerStyle.blendingOptions", json!({"blendIf": null})).unwrap(); assert!(active(&s).blend_if.is_default()); assert!(crate::inspect::layer(active(&s)).get("blendIf").is_none()); assert_eq!(at(&s, 5), [0.0, 0.0, 0.0, 1.0]); @@ -287,11 +196,7 @@ fn blend_if_rejects_bad_params() { json!({"channel": "gray", "underlying": "dark"}), json!("gray"), ] { - assert!( - s.execute("layer.layerStyle.blendingOptions", json!({"blendIf": bad})) - .is_err(), - "{bad}" - ); + assert!(s.execute("layer.layerStyle.blendingOptions", json!({"blendIf": bad})).is_err(), "{bad}"); } assert!(active(&s).blend_if.is_default()); assert_eq!(s.active().unwrap().history.past_len(), steps); @@ -300,26 +205,12 @@ fn blend_if_rejects_bad_params() { #[test] fn blend_if_channel_names_follow_the_mode() { let mut s = session(8, "cmyk"); - s.execute( - "layer.layerStyle.blendingOptions", - json!({"blendIf": {"channel": "black", "underlying": [0, 128]}}), - ) - .unwrap(); + s.execute("layer.layerStyle.blendingOptions", json!({"blendIf": {"channel": "black", "underlying": [0, 128]}})).unwrap(); assert_eq!(active(&s).blend_if.get(4)[1].white, [128, 128]); - assert!( - s.execute( - "layer.layerStyle.blendingOptions", - json!({"blendIf": {"channel": "red", "underlying": [0, 128]}}) - ) - .is_err() - ); + assert!(s.execute("layer.layerStyle.blendingOptions", json!({"blendIf": {"channel": "red", "underlying": [0, 128]}})).is_err()); // A grayscale document's Gray is its one channel. let mut s = session(16, "grayscale"); - s.execute( - "layer.layerStyle.blendingOptions", - json!({"blendIf": {"channel": "gray", "thisLayer": [30, 255]}}), - ) - .unwrap(); + s.execute("layer.layerStyle.blendingOptions", json!({"blendIf": {"channel": "gray", "thisLayer": [30, 255]}})).unwrap(); assert_eq!(active(&s).blend_if.get(1)[0].black, [30, 30]); assert_eq!(active(&s).blend_if.get(0), [BlendRange::FULL; 2]); } @@ -328,29 +219,13 @@ fn blend_if_channel_names_follow_the_mode() { fn create_layer_splits_effects_and_keeps_the_look() { let mut s = session(8, "rgb"); s.execute("layer.new.layer", json!({})).unwrap(); - paint(&mut s, |x, y| { - if (10..30).contains(&x) && (8..22).contains(&y) { - [0.2, 0.5, 0.9, 1.0] - } else { - [0.0; 4] - } - }); - s.execute( - "layer.layerStyle.dropShadow", - json!({"blend": "normal", "distance": 4, "size": 2, "opacity": 60}), - ) - .unwrap(); - s.execute( - "layer.layerStyle.colorOverlay", - json!({"color": "#ff8000", "opacity": 50, "add": true}), - ) - .unwrap(); + paint(&mut s, |x, y| if (10..30).contains(&x) && (8..22).contains(&y) { [0.2, 0.5, 0.9, 1.0] } else { [0.0; 4] }); + s.execute("layer.layerStyle.dropShadow", json!({"blend": "normal", "distance": 4, "size": 2, "opacity": 60})).unwrap(); + s.execute("layer.layerStyle.colorOverlay", json!({"color": "#ff8000", "opacity": 50, "add": true})).unwrap(); let id = active(&s).id; let before = photocraft_compose::flatten(doc(&s)); let n0 = doc(&s).layers.len(); - let r = s - .execute("layer.layerStyle.createLayer", json!({})) - .unwrap(); + let r = s.execute("layer.layerStyle.createLayer", json!({})).unwrap(); assert_eq!(r["layers"], json!(2)); let d = doc(&s); assert_eq!(d.layers.len(), n0 + 2); @@ -359,17 +234,8 @@ fn create_layer_splits_effects_and_keeps_the_look() { assert!(d.layers[i + 1].name.ends_with("Color Overlay") && d.layers[i + 1].clipped); assert!(d.layers[i].effects.items.is_empty()); let after = photocraft_compose::flatten(d); - let worst = before - .px - .iter() - .zip(&after.px) - .map(|(a, b)| (0..4).map(|c| (a[c] - b[c]).abs()).fold(0.0, f32::max)) - .fold(0.0, f32::max); - assert!( - worst < 3.0 / 255.0, - "composite changed by {}", - worst * 255.0 - ); + let worst = before.px.iter().zip(&after.px).map(|(a, b)| (0..4).map(|c| (a[c] - b[c]).abs()).fold(0.0, f32::max)).fold(0.0, f32::max); + assert!(worst < 3.0 / 255.0, "composite changed by {}", worst * 255.0); assert!(!s.is_enabled("layer.layerStyle.createLayer")); s.execute("edit.undo", json!({})).unwrap(); assert_eq!(doc(&s).layers.len(), n0); @@ -379,69 +245,29 @@ fn create_layer_splits_effects_and_keeps_the_look() { fn content_options_reports_the_editor() { let mut s = session(8, "rgb"); assert!(!s.is_enabled("layer.layerContentOptions")); - s.execute("layer.newAdjustmentLayer.levels", json!({})) - .unwrap(); - assert_eq!( - s.execute("layer.layerContentOptions", json!({})).unwrap()["editor"], - json!("adjustment:levels") - ); - s.execute("layer.newFillLayer.solidColor", json!({})) - .unwrap(); - assert_eq!( - s.execute("layer.layerContentOptions", json!({})).unwrap()["editor"], - json!("fill") - ); + s.execute("layer.newAdjustmentLayer.levels", json!({})).unwrap(); + assert_eq!(s.execute("layer.layerContentOptions", json!({})).unwrap()["editor"], json!("adjustment:levels")); + s.execute("layer.newFillLayer.solidColor", json!({})).unwrap(); + assert_eq!(s.execute("layer.layerContentOptions", json!({})).unwrap()["editor"], json!("fill")); } #[test] fn export_layer_trims_to_its_pixels() { let mut s = session(16, "rgb"); - paint(&mut s, |x, y| { - if (5..15).contains(&x) && (4..10).contains(&y) { - [1.0, 0.0, 0.0, 1.0] - } else { - [0.0; 4] - } - }); + paint(&mut s, |x, y| if (5..15).contains(&x) && (4..10).contains(&y) { [1.0, 0.0, 0.0, 1.0] } else { [0.0; 4] }); let d = layer_document(doc(&s), active(&s).id).unwrap(); assert_eq!((d.size.width, d.size.height), (10, 6)); - assert_eq!( - d.layers[0].surface().unwrap().rgba(0, 0), - [1.0, 0.0, 0.0, 1.0] - ); + assert_eq!(d.layers[0].surface().unwrap().rgba(0, 0), [1.0, 0.0, 0.0, 1.0]); let dir = std::env::temp_dir().join(format!("pc-layer-export-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("layer.png"); - let r = s - .execute( - "layer.quickExportAsPng", - json!({"path": path.to_string_lossy()}), - ) - .unwrap(); - assert_eq!( - (r["width"].as_u64(), r["height"].as_u64()), - (Some(10), Some(6)) - ); - assert!( - std::fs::read(&path) - .unwrap() - .starts_with(&[0x89, b'P', b'N', b'G']) - ); + let r = s.execute("layer.quickExportAsPng", json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(10), Some(6))); + assert!(std::fs::read(&path).unwrap().starts_with(&[0x89, b'P', b'N', b'G'])); let jpg = dir.join("layer.jpg"); - let r = s - .execute( - "layer.exportAs", - json!({"path": jpg.to_string_lossy(), "scale": 200}), - ) - .unwrap(); + let r = s.execute("layer.exportAs", json!({"path": jpg.to_string_lossy(), "scale": 200})).unwrap(); assert_eq!(r["width"].as_u64(), Some(20)); - assert!( - s.execute( - "layer.quickExportAsPng", - json!({"path": jpg.to_string_lossy()}) - ) - .is_err() - ); + assert!(s.execute("layer.quickExportAsPng", json!({"path": jpg.to_string_lossy()})).is_err()); assert!(s.execute("layer.exportAs", json!({})).is_err()); let _ = std::fs::remove_dir_all(&dir); } @@ -457,24 +283,11 @@ fn stack_modes_render_from_the_nested_layers() { let mut l = Layer::raster(format!("f{k}"), fmt); let v = if k == 2 { 1.0 } else { 0.4 }; let surf = l.surface_mut().unwrap(); - surf.write_region( - Rect::new(0, 0, 40, 30), - &photocraft_raster::from_rgba(&fmt, [0.4, 0.4, 0.4, 1.0]).repeat(1200), - ); - surf.write_region( - Rect::new(3, 3, 4, 4), - &photocraft_raster::from_rgba(&fmt, [v, v, v, 1.0]), - ); + surf.write_region(Rect::new(0, 0, 40, 30), &photocraft_raster::from_rgba(&fmt, [0.4, 0.4, 0.4, 1.0]).repeat(1200)); + surf.write_region(Rect::new(3, 3, 4, 4), &photocraft_raster::from_rgba(&fmt, [v, v, v, 1.0])); kids.push(l); } - let g = Layer::new( - "stack", - LayerContent::Group(photocraft_doc::Group { - children: kids, - expanded: true, - artboard: None, - }), - ); + let g = Layer::new("stack", LayerContent::Group(photocraft_doc::Group { children: kids, expanded: true, artboard: None })); let smart = crate::smart_cmds::layer_to_smart(doc, &g)?; let id = smart.id; doc.layers = vec![smart]; @@ -487,39 +300,24 @@ fn stack_modes_render_from_the_nested_layers() { LayerContent::Smart(sm) => sm.cache.unwrap().rgba(x, y), _ => panic!(), }; - assert!( - at(&s, 3, 3)[0] > 0.9, - "normal composite shows the top frame" - ); - s.execute("layer.smartObjects.stackMode.median", json!({})) - .unwrap(); - assert!( - (at(&s, 3, 3)[0] - 0.4).abs() < 2.0 / 255.0, - "median removes the outlier" - ); - s.execute("layer.smartObjects.stackMode.maximum", json!({})) - .unwrap(); + assert!(at(&s, 3, 3)[0] > 0.9, "normal composite shows the top frame"); + s.execute("layer.smartObjects.stackMode.median", json!({})).unwrap(); + assert!((at(&s, 3, 3)[0] - 0.4).abs() < 2.0 / 255.0, "median removes the outlier"); + s.execute("layer.smartObjects.stackMode.maximum", json!({})).unwrap(); assert!(at(&s, 3, 3)[0] > 0.99); - s.execute("layer.smartObjects.stackMode.range", json!({})) - .unwrap(); + s.execute("layer.smartObjects.stackMode.range", json!({})).unwrap(); assert!(at(&s, 10, 10)[0] < 1.0 / 255.0); for m in StackMode::ALL { - s.execute( - &format!("layer.smartObjects.stackMode.{}", m.id()), - json!({}), - ) - .unwrap(); + s.execute(&format!("layer.smartObjects.stackMode.{}", m.id()), json!({})).unwrap(); match &active(&s).content { LayerContent::Smart(sm) => assert_eq!(sm.stack_mode, Some(m)), _ => panic!(), } } - s.execute("layer.smartObjects.stackMode.none", json!({})) - .unwrap(); + s.execute("layer.smartObjects.stackMode.none", json!({})).unwrap(); assert!(at(&s, 3, 3)[0] > 0.9); // The stack mode survives .pcraft. - s.execute("layer.smartObjects.stackMode.mean", json!({})) - .unwrap(); + s.execute("layer.smartObjects.stackMode.mean", json!({})).unwrap(); let bytes = photocraft_format::save_to_bytes(doc(&s), &Default::default()).unwrap(); let back = photocraft_format::load_from_bytes(&bytes).unwrap(); match &back.layers[0].content { @@ -537,9 +335,7 @@ fn reveal_in_finder_dry_run_and_enabled() { let mut l = Layer::new( "linked", LayerContent::Smart(photocraft_doc::SmartObject::new( - SmartSource::Linked { - path: "/tmp/pics/a.png".into(), - }, + SmartSource::Linked { path: "/tmp/pics/a.png".into() }, photocraft_geom::Affine::IDENTITY, None, )), @@ -550,9 +346,7 @@ fn reveal_in_finder_dry_run_and_enabled() { }) .unwrap(); assert!(s.is_enabled("layer.smartObjects.revealInFinder")); - let r = s - .execute("layer.smartObjects.revealInFinder", json!({"dryRun": true})) - .unwrap(); + let r = s.execute("layer.smartObjects.revealInFinder", json!({"dryRun": true})).unwrap(); let (prog, args) = reveal_command("/tmp/pics/a.png"); assert_eq!(r["program"], json!(prog)); assert_eq!(r["args"], json!(args)); @@ -563,19 +357,12 @@ fn mask_all_objects_masks_the_subject() { let mut s = session(8, "rgb"); paint(&mut s, |x, y| { let inside = (((x - 20) * (x - 20) + (y - 15) * (y - 15)) as f32).sqrt() < 8.0; - if inside { - [0.9, 0.2, 0.1, 1.0] - } else { - [0.95, 0.95, 0.95, 1.0] - } + if inside { [0.9, 0.2, 0.1, 1.0] } else { [0.95, 0.95, 0.95, 1.0] } }); match s.execute("layer.maskAllObjects", json!({})) { Ok(_) => { let m = active(&s).mask.as_ref().unwrap(); - assert!( - m.value(20, 15) > m.value(1, 1), - "subject revealed, background hidden" - ); + assert!(m.value(20, 15) > m.value(1, 1), "subject revealed, background hidden"); } // The segmentation may decline tiny images; then nothing changes. Err(_) => assert!(active(&s).mask.is_none()), @@ -587,17 +374,11 @@ fn layer_masks_in_gray_cmyk_lab() { for mode in ["gray", "cmyk", "lab"] { let mut s = session(16, mode); paint(&mut s, disc); - s.execute("layer.layerMask.fromTransparency", json!({})) - .unwrap(); + s.execute("layer.layerMask.fromTransparency", json!({})).unwrap(); s.execute("layer.layerMask.apply", json!({})).unwrap(); - s.execute("layer.matting.defringe", json!({"width": 1})) - .unwrap(); - s.execute("layer.matting.removeBlackMatte", json!({})) - .unwrap(); - assert!( - active(&s).surface().unwrap().rgba(20, 15)[3] > 0.99, - "{mode}" - ); + s.execute("layer.matting.defringe", json!({"width": 1})).unwrap(); + s.execute("layer.matting.removeBlackMatte", json!({})).unwrap(); + assert!(active(&s).surface().unwrap().rgba(20, 15)[3] > 0.99, "{mode}"); } } @@ -615,25 +396,14 @@ fn load_files_into_stack_as_smart_object_then_median() { photocraft_doc::Color::rgba(*v, *v, *v, 1.0), ); let p = dir.join(format!("f{k}.png")); - std::fs::write( - &p, - photocraft_io::export(&d, "f.png", &Default::default()) - .unwrap() - .bytes, - ) - .unwrap(); + std::fs::write(&p, photocraft_io::export(&d, "f.png", &Default::default()).unwrap().bytes).unwrap(); paths.push(p.to_string_lossy().into_owned()); } let mut s = Session::new(); - s.execute( - "file.scripts.loadFilesIntoStack", - json!({"paths": paths, "createSmartObject": true}), - ) - .unwrap(); + s.execute("file.scripts.loadFilesIntoStack", json!({"paths": paths, "createSmartObject": true})).unwrap(); assert_eq!(doc(&s).layers.len(), 1); assert!(matches!(doc(&s).layers[0].content, LayerContent::Smart(_))); - s.execute("layer.smartObjects.stackMode.median", json!({})) - .unwrap(); + s.execute("layer.smartObjects.stackMode.median", json!({})).unwrap(); let px = photocraft_compose::render(doc(&s), Rect::from_xywh(2, 2, 1, 1)).px[0]; assert!((px[0] - 0.2).abs() < 2.0 / 255.0, "{px:?}"); let _ = std::fs::remove_dir_all(&dir); diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index b307e7502f..a572c997e0 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -472,7 +472,8 @@ fn lock_layers(s: &mut Session, p: &Value) -> Result { } fn rename_layer(s: &mut Session, p: &Value) -> Result { - let name = p.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad("layer.renameLayer", "missing `name`"))?.to_string(); + let name = + p.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad("layer.renameLayer", "missing `name`"))?.to_string(); let id = crate::commands::layer_param(s, p)?; s.edit("Rename Layer", |doc, _| { doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?.name = name; @@ -657,18 +658,45 @@ pub fn specs() -> Vec { spec!("layer.align.horizontalCenters", "Horizontal Centers", &["Layer", "Align"], None, ALIGN, can_align, |s, p| align(s, p, "horizontalCenters")), spec!("layer.align.rightEdges", "Right Edges", &["Layer", "Align"], None, ALIGN, can_align, |s, p| align(s, p, "rightEdges")), spec!("layer.distribute.topEdges", "Top Edges", &["Layer", "Distribute"], None, "{}", can_distribute, |s, _| distribute(s, "topEdges")), - spec!("layer.distribute.verticalCenters", "Vertical Centers", &["Layer", "Distribute"], None, "{}", can_distribute, |s, _| distribute(s, "verticalCenters")), + spec!("layer.distribute.verticalCenters", "Vertical Centers", &["Layer", "Distribute"], None, "{}", can_distribute, |s, _| distribute( + s, + "verticalCenters" + )), spec!("layer.distribute.bottomEdges", "Bottom Edges", &["Layer", "Distribute"], None, "{}", can_distribute, |s, _| distribute(s, "bottomEdges")), spec!("layer.distribute.leftEdges", "Left Edges", &["Layer", "Distribute"], None, "{}", can_distribute, |s, _| distribute(s, "leftEdges")), - spec!("layer.distribute.horizontalCenters", "Horizontal Centers", &["Layer", "Distribute"], None, "{}", can_distribute, |s, _| distribute(s, "horizontalCenters")), + spec!("layer.distribute.horizontalCenters", "Horizontal Centers", &["Layer", "Distribute"], None, "{}", can_distribute, |s, _| distribute( + s, + "horizontalCenters" + )), spec!("layer.distribute.rightEdges", "Right Edges", &["Layer", "Distribute"], None, "{}", can_distribute, |s, _| distribute(s, "rightEdges")), - spec!("layer.distribute.horizontally", "Horizontally", &["Layer", "Distribute"], None, "{} (equal horizontal gaps)", can_distribute, |s, _| distribute(s, "horizontally")), - spec!("layer.distribute.vertically", "Vertically", &["Layer", "Distribute"], None, "{} (equal vertical gaps)", can_distribute, |s, _| distribute(s, "vertically")), - spec!("layer.linkLayers", "Link Layers", &["Layer"], None, "{} (toggles: unlinks when the selection is already one link group)", can_link, |s, _| link_layers(s)), + spec!( + "layer.distribute.horizontally", + "Horizontally", + &["Layer", "Distribute"], + None, + "{} (equal horizontal gaps)", + can_distribute, + |s, _| distribute(s, "horizontally") + ), + spec!("layer.distribute.vertically", "Vertically", &["Layer", "Distribute"], None, "{} (equal vertical gaps)", can_distribute, |s, _| distribute( + s, + "vertically" + )), + spec!("layer.linkLayers", "Link Layers", &["Layer"], None, "{} (toggles: unlinks when the selection is already one link group)", can_link, |s, _| { + link_layers(s) + }), spec!("layer.mergeLayers", "Merge Layers", &["Layer"], Some("Cmd+E"), "{} (one layer selected: Merge Down)", has_layer, |s, _| merge_layers(s)), spec!("layer.new.groupFromLayers", "Group from Layers…", &["Layer", "New"], None, r##"{"name":str?}"##, has_layer, group_layers), spec!("layer.arrange.reverse", "Reverse", &["Layer", "Arrange"], None, "{}", two_plus, |s, _| reverse(s)), - spec!("layer.lockLayers", "Lock Layers…", &["Layer"], None, r##"{"transparency":bool?,"pixels":bool?,"position":bool?,"artboard":bool?,"all":bool?} (none given: toggle lock all)"##, has_layer, lock_layers), + spec!( + "layer.lockLayers", + "Lock Layers…", + &["Layer"], + None, + r##"{"transparency":bool?,"pixels":bool?,"position":bool?,"artboard":bool?,"all":bool?} (none given: toggle lock all)"##, + has_layer, + lock_layers + ), spec!("layer.renameLayer", "Rename Layer", &["Layer"], None, r##"{"layer":id?,"name":str}"##, has_layer, rename_layer), ] } diff --git a/crates/engine/src/layer_style.rs b/crates/engine/src/layer_style.rs index c3f30590cb..c6251cc0ea 100644 --- a/crates/engine/src/layer_style.rs +++ b/crates/engine/src/layer_style.rs @@ -2,8 +2,8 @@ use photocraft_color::{BlendMode, Color}; use photocraft_doc::{ - Bevel, BevelStyle, BevelTechnique, Contour, Effect, FxCommon, FxPaint, Glow, GlowSource, GlowTechnique, Gradient, GradientStyle, - Satin, Shadow, StrokeFx, StrokePosition, + Bevel, BevelStyle, BevelTechnique, Contour, Effect, FxCommon, FxPaint, Glow, GlowSource, GlowTechnique, Gradient, GradientStyle, Satin, Shadow, StrokeFx, + StrokePosition, }; use serde_json::{Value, json}; @@ -149,7 +149,11 @@ pub fn effect_from_params(kind: &str, p: &Value) -> Option { highlight_color: Color::WHITE, shadow: FxCommon::new(BlendMode::Multiply, 0.75), shadow_color: Color::BLACK, - contour: b(p, "contour", false).then(|| photocraft_doc::BevelContour { contour: Contour::Linear, range: (f(p, "contourRange", 50.0) / 100.0).clamp(0.01, 1.0), anti_alias: false }), + contour: b(p, "contour", false).then(|| photocraft_doc::BevelContour { + contour: Contour::Linear, + range: (f(p, "contourRange", 50.0) / 100.0).clamp(0.01, 1.0), + anti_alias: false, + }), texture: p.get("texture").and_then(Value::as_str).filter(|t| !t.is_empty()).map(|t| photocraft_doc::BevelTexture { name: t.to_string(), id: t.to_string(), @@ -215,15 +219,47 @@ macro_rules! style_cmd { /// The `layer.layerStyle.*` command specs. pub fn specs() -> Vec { vec![ - style_cmd!("dropShadow", "Drop Shadow…", r##"{"color":"#rrggbb","opacity":0..100=75,"blend":str="multiply","angle":deg=120,"useGlobalLight":bool,"distance":px=5,"spread":0..100,"size":px=5,"knocksOut":bool,"add":bool,"layer":id}"##), - style_cmd!("innerShadow", "Inner Shadow…", r##"{"color":"#rrggbb","opacity":0..100=75,"blend":str,"angle":deg,"distance":px,"choke":0..100,"size":px,"add":bool}"##), - style_cmd!("outerGlow", "Outer Glow…", r##"{"color":"#rrggbb","opacity":0..100=75,"blend":str="screen","technique":"softer|precise","spread":0..100,"size":px,"range":0..100,"add":bool}"##), - style_cmd!("innerGlow", "Inner Glow…", r##"{"color":"#rrggbb","opacity":0..100=75,"blend":str="screen","technique":"softer|precise","source":"edge|center","choke":0..100,"size":px,"add":bool}"##), - style_cmd!("stroke", "Stroke…", r##"{"size":px=3,"position":"outside|inside|center","color":"#rrggbb","from":"#rrggbb","to":"#rrggbb","style":str,"angle":deg,"opacity":0..100,"blend":str,"add":bool}"##), + style_cmd!( + "dropShadow", + "Drop Shadow…", + r##"{"color":"#rrggbb","opacity":0..100=75,"blend":str="multiply","angle":deg=120,"useGlobalLight":bool,"distance":px=5,"spread":0..100,"size":px=5,"knocksOut":bool,"add":bool,"layer":id}"## + ), + style_cmd!( + "innerShadow", + "Inner Shadow…", + r##"{"color":"#rrggbb","opacity":0..100=75,"blend":str,"angle":deg,"distance":px,"choke":0..100,"size":px,"add":bool}"## + ), + style_cmd!( + "outerGlow", + "Outer Glow…", + r##"{"color":"#rrggbb","opacity":0..100=75,"blend":str="screen","technique":"softer|precise","spread":0..100,"size":px,"range":0..100,"add":bool}"## + ), + style_cmd!( + "innerGlow", + "Inner Glow…", + r##"{"color":"#rrggbb","opacity":0..100=75,"blend":str="screen","technique":"softer|precise","source":"edge|center","choke":0..100,"size":px,"add":bool}"## + ), + style_cmd!( + "stroke", + "Stroke…", + r##"{"size":px=3,"position":"outside|inside|center","color":"#rrggbb","from":"#rrggbb","to":"#rrggbb","style":str,"angle":deg,"opacity":0..100,"blend":str,"add":bool}"## + ), style_cmd!("colorOverlay", "Color Overlay…", r##"{"color":"#rrggbb","opacity":0..100=100,"blend":str,"add":bool}"##), - style_cmd!("gradientOverlay", "Gradient Overlay…", r##"{"from":"#rrggbb","to":"#rrggbb","style":"linear|radial|angle|reflected|diamond","angle":deg=90,"scale":10..150=100,"reverse":bool,"opacity":0..100,"blend":str,"add":bool}"##), - style_cmd!("patternOverlay", "Pattern Overlay…", r##"{"pattern":id|name?=first library pattern,"opacity":0..100=100,"blend":str,"scale":1..1000=100,"angle":deg=0,"link":bool=true,"phaseX":px,"phaseY":px,"add":bool}"##), - style_cmd!("bevelEmboss", "Bevel & Emboss…", r##"{"style":"inner|outer|emboss|pillow|stroke","technique":"smooth|chiselHard|chiselSoft","contour":bool,"contourRange":1..100=50,"texture":pattern id|name?,"textureScale":1..1000=100,"textureDepth":-1000..1000=100,"textureInvert":bool,"textureLink":bool=true,"depth":1..1000=100,"direction":"up|down","size":px=5,"soften":px,"angle":deg,"altitude":deg,"add":bool}"##), + style_cmd!( + "gradientOverlay", + "Gradient Overlay…", + r##"{"from":"#rrggbb","to":"#rrggbb","style":"linear|radial|angle|reflected|diamond","angle":deg=90,"scale":10..150=100,"reverse":bool,"opacity":0..100,"blend":str,"add":bool}"## + ), + style_cmd!( + "patternOverlay", + "Pattern Overlay…", + r##"{"pattern":id|name?=first library pattern,"opacity":0..100=100,"blend":str,"scale":1..1000=100,"angle":deg=0,"link":bool=true,"phaseX":px,"phaseY":px,"add":bool}"## + ), + style_cmd!( + "bevelEmboss", + "Bevel & Emboss…", + r##"{"style":"inner|outer|emboss|pillow|stroke","technique":"smooth|chiselHard|chiselSoft","contour":bool,"contourRange":1..100=50,"texture":pattern id|name?,"textureScale":1..1000=100,"textureDepth":-1000..1000=100,"textureInvert":bool,"textureLink":bool=true,"depth":1..1000=100,"direction":"up|down","size":px=5,"soften":px,"angle":deg,"altitude":deg,"add":bool}"## + ), style_cmd!("satin", "Satin…", r##"{"color":"#rrggbb","opacity":0..100=50,"blend":str,"angle":deg,"distance":px,"size":px,"invert":bool,"add":bool}"##), CommandSpec { id: "layer.layerStyle.clear", diff --git a/crates/engine/src/lens_cmds.rs b/crates/engine/src/lens_cmds.rs index 920e2be855..f9c2cdadfc 100644 --- a/crates/engine/src/lens_cmds.rs +++ b/crates/engine/src/lens_cmds.rs @@ -110,7 +110,8 @@ fn resolved_lens_params(p: &Value, lc: &LensCorrection) -> Value { /// Adaptive Wide Angle settings; a zero focal length is taken from EXIF when present. pub fn wide_params(cmd: &str, p: &Value, info: Option<&exif::CameraInfo>) -> Result { - let mut wa: WideAngle = serde_json::from_value(p.clone()).map_err(|e| bad(cmd, format!("bad params: {e} (constraints: [{{\"a\":[x,y],\"b\":[x,y],\"orientation\":\"free|horizontal|vertical\"}}])")))?; + let mut wa: WideAngle = serde_json::from_value(p.clone()) + .map_err(|e| bad(cmd, format!("bad params: {e} (constraints: [{{\"a\":[x,y],\"b\":[x,y],\"orientation\":\"free|horizontal|vertical\"}}])")))?; if wa.constraints.iter().any(|c| c.a.iter().chain(&c.b).any(|v| !v.is_finite())) { return Err(bad(cmd, "constraint points must be finite")); } @@ -296,7 +297,8 @@ fn lens_correction(s: &mut Session, p: &Value) -> Result { let lc = lens_params(LENS, p, frame, info.as_ref())?; let stored = resolved_lens_params(p, &lc); let t0 = Stopwatch::start(); - let id = run_filter(s, LENS, "Lens Correction", stored.clone(), matches!(lc.edge, EdgeMode::Transparency), &|surf, canvas| lens::correct(surf, canvas, &lc))?; + let id = + run_filter(s, LENS, "Lens Correction", stored.clone(), matches!(lc.edge, EdgeMode::Transparency), &|surf, canvas| lens::correct(surf, canvas, &lc))?; Ok(json!({"layer": id.0, "profile": lc.profile.as_ref().map(|p| p.name.clone()), "angle": lc.angle, "scale": lc.scale, "params": stored, "ms": t0.ms()})) } @@ -315,7 +317,11 @@ fn adaptive_wide_angle(s: &mut Session, p: &Value) -> Result { let interp = Interp::parse(p.get("interpolation").and_then(Value::as_str).unwrap_or("bicubic")); let id = run_filter(s, WIDE, "Adaptive Wide Angle", stored.clone(), true, &|surf, canvas| { let tris = mesh.triangles(); - if canvas == frame { photocraft_algo::warp::warp_triangles(surf, canvas, &mesh.verts, &tris, interp) } else { wideangle::apply(surf, canvas, &wa, interp) } + if canvas == frame { + photocraft_algo::warp::warp_triangles(surf, canvas, &mesh.verts, &tris, interp) + } else { + wideangle::apply(surf, canvas, &wa, interp) + } })?; let model = match cam.model { wideangle::WideModel::Fisheye => "fisheye", @@ -371,7 +377,16 @@ const LENS_DOC: &str = r##"{"profile":"none|auto|generic","focalLength":mm=0,"co pub fn specs() -> Vec { vec![ - CommandSpec { id: LENS, label: "Lens Correction…", menu: &["Filter"], shortcut: Some("Cmd+Shift+R"), params: LENS_DOC, enabled: filterable, run: lens_correction, journal: true }, + CommandSpec { + id: LENS, + label: "Lens Correction…", + menu: &["Filter"], + shortcut: Some("Cmd+Shift+R"), + params: LENS_DOC, + enabled: filterable, + run: lens_correction, + journal: true, + }, CommandSpec { id: WIDE, label: "Adaptive Wide Angle…", @@ -449,7 +464,8 @@ mod tests { // EXIF-driven auto profile. let mut s = session(8); s.edit("exif", |doc, _| { - doc.metadata.exif = Some(std::sync::Arc::new(exif::build(&exif::CameraInfo { focal_length: Some(16.0), focal_length_35mm: Some(24.0), ..Default::default() }))); + doc.metadata.exif = + Some(std::sync::Arc::new(exif::build(&exif::CameraInfo { focal_length: Some(16.0), focal_length_35mm: Some(24.0), ..Default::default() }))); Ok(()) }) .unwrap(); @@ -480,7 +496,9 @@ mod tests { #[test] fn adaptive_wide_angle_straightens() { let mut s = session(8); - let r = s.execute(WIDE, json!({"model": "fisheye", "focalLength": 10, "constraints": [{"a": [10, 15], "b": [110, 15], "orientation": "horizontal"}]})).unwrap(); + let r = s + .execute(WIDE, json!({"model": "fisheye", "focalLength": 10, "constraints": [{"a": [10, 15], "b": [110, 15], "orientation": "horizontal"}]})) + .unwrap(); assert_eq!(r["model"], "fisheye"); assert!(r["residual"].as_f64().unwrap() < 2.0, "{r}"); assert!(s.execute(WIDE, json!({"constraints": [{"a": [0, 0]}]})).is_err()); @@ -531,7 +549,12 @@ mod tests { let path = dir.join("in").join(format!("shot{k}.png")); crate::file_cmds::save_doc(&s.active().unwrap().doc, path.to_str().unwrap(), None).unwrap(); } - let r = s.execute(LENS_BATCH, json!({"input": dir.join("in").to_str().unwrap(), "output": dir.join("out").to_str().unwrap(), "vignetteAmount": 60, "edge": "edgeExtension"})).unwrap(); + let r = s + .execute( + LENS_BATCH, + json!({"input": dir.join("in").to_str().unwrap(), "output": dir.join("out").to_str().unwrap(), "vignetteAmount": 60, "edge": "edgeExtension"}), + ) + .unwrap(); assert_eq!(r["files"].as_array().unwrap().len(), 2, "{r}"); assert!(r["errors"].as_array().unwrap().is_empty()); assert!(dir.join("out").join("shot0.png").is_file()); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ace52efd08..c276427fb1 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -7,66 +7,66 @@ #![forbid(unsafe_code)] pub mod adjust_cmds; +pub mod align_cmds; pub mod analysis_cmds; -pub mod notes_cmds; -pub mod proof_sim; +pub mod artboard_cmds; +pub mod automate_cmds; +pub mod brush_cmds; pub mod build_info; -pub mod layer_menu_cmds; -pub mod mode_cmds; -pub mod multichannel_cmds; pub mod channel_cmds; +pub mod color_cmds; pub mod commands; -pub mod inspect; -pub mod layer_style; +pub mod comps_cmds; +pub mod distort_cmds; +pub mod edit_cmds; +pub mod edit_menu_cmds; +pub mod extra_cmds; +pub mod file_cmds; pub mod filters; pub mod filters_ext; +mod frame_cmds; pub mod gallery_cmds; -pub mod type_cmds; -pub mod transform_cmds; -pub mod vector_cmds; -pub mod smartselect_cmds; -pub mod edit_cmds; -pub mod retouch_cmds; pub mod image_cmds; +pub mod inspect; +pub mod layer_menu_cmds; +pub mod layer_multi_cmds; +pub mod layer_style; +pub mod lens_cmds; +mod migrate_cmds; +pub mod mode_cmds; +pub mod multichannel_cmds; +pub mod notes_cmds; pub mod paint_cmds; pub mod pattern_cmds; -pub mod warp_cmds; -pub mod distort_cmds; +pub mod photo_cmds; +pub mod pick_cmds; +mod pixels; +pub mod prefs; +pub mod presets; +pub mod print_cmds; +pub mod proof_sim; pub mod render_cmds; -pub mod selection_cmds; +pub mod retouch_cmds; pub mod select_extra_cmds; -pub mod brush_cmds; -pub mod extra_cmds; -pub mod color_cmds; -pub mod layer_multi_cmds; +pub mod selection_cmds; +pub mod slice_cmds; pub mod smart_cmds; -pub mod file_cmds; +pub mod smartselect_cmds; +pub mod snap; +mod timeline_cmds; +pub mod transform_cmds; +mod trap_cmds; +pub mod type_cmds; pub mod type_extra_cmds; pub mod type_spell_cmds; pub mod type_styles_cmds; -mod pixels; -pub mod prefs; -pub mod snap; -pub mod edit_menu_cmds; -pub mod align_cmds; -pub mod photo_cmds; -pub mod lens_cmds; -pub mod vp_cmds; -pub mod artboard_cmds; -pub mod comps_cmds; -pub mod automate_cmds; -mod frame_cmds; -mod migrate_cmds; -mod trap_cmds; -mod timeline_cmds; -mod video_cmds; -mod wia_cmds; mod variables_cmds; -pub mod pick_cmds; -pub mod print_cmds; -pub mod slice_cmds; +pub mod vector_cmds; +mod video_cmds; +pub mod vp_cmds; +pub mod warp_cmds; pub mod web_cmds; -pub mod presets; +mod wia_cmds; use std::sync::Arc; @@ -76,8 +76,8 @@ use serde_json::Value; pub use commands::{CommandSpec, command_specs}; pub use photocraft_doc as doc; -pub use photocraft_paint::BrushSettings; pub use photocraft_paint as paint; +pub use photocraft_paint::BrushSettings; #[derive(Debug, thiserror::Error)] pub enum EngineError { diff --git a/crates/engine/src/migrate_cmds.rs b/crates/engine/src/migrate_cmds.rs index 6dce81d0c0..200ab92062 100644 --- a/crates/engine/src/migrate_cmds.rs +++ b/crates/engine/src/migrate_cmds.rs @@ -25,10 +25,10 @@ fn groups(v: &Value, key: &str) -> Vec> } fn migrate(s: &mut Session, p: &Value) -> Result { - let path = p.get("path").and_then(Value::as_str).ok_or_else(|| EngineError::BadParams { - cmd: "edit.presets.migratePresets".into(), - msg: "need `path` to a presets or preferences file".into(), - })?; + let path = p + .get("path") + .and_then(Value::as_str) + .ok_or_else(|| EngineError::BadParams { cmd: "edit.presets.migratePresets".into(), msg: "need `path` to a presets or preferences file".into() })?; let text = std::fs::read_to_string(path).map_err(|e| EngineError::Other(format!("read `{path}`: {e}")))?; let root: Value = serde_json::from_str(&text).map_err(|e| EngineError::Other(format!("`{path}` is not JSON: {e}")))?; // Accept a full preferences file (has a `presets` section) or a bare presets object. diff --git a/crates/engine/src/mode_cmds.rs b/crates/engine/src/mode_cmds.rs index 2f5e6ddc9c..9c7ac2fe10 100644 --- a/crates/engine/src/mode_cmds.rs +++ b/crates/engine/src/mode_cmds.rs @@ -11,9 +11,7 @@ use photocraft_algo::quantize::{self, BitmapMethod, Dither, Forced, HalftoneShap use photocraft_algo::transform::{Homography, Interp}; use photocraft_color::{ColorMode, PixelFormat, SampleType}; use photocraft_doc::adjust::CurvePoint; -use photocraft_doc::{ - ColorTable, Document, Duotone, DuotoneInk, Layer, LayerContent, LayerId, Size, -}; +use photocraft_doc::{ColorTable, Document, Duotone, DuotoneInk, Layer, LayerContent, LayerId, Size}; use photocraft_geom::Affine; use photocraft_raster::Surface; use serde_json::{Value, json}; @@ -22,16 +20,11 @@ use crate::commands::CommandSpec; use crate::{EngineError, Result, Session}; fn bad(cmd: &str, msg: impl Into) -> EngineError { - EngineError::BadParams { - cmd: cmd.into(), - msg: msg.into(), - } + EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } } fn num(p: &Value, key: &str, default: f32) -> f32 { - p.get(key) - .and_then(Value::as_f64) - .map_or(default, |v| v as f32) + p.get(key).and_then(Value::as_f64).map_or(default, |v| v as f32) } fn str_or<'a>(p: &'a Value, key: &str, default: &'a str) -> &'a str { @@ -41,18 +34,12 @@ fn str_or<'a>(p: &'a Value, key: &str, default: &'a str) -> &'a str { type Enabled = std::result::Result<(), String>; fn has_doc(s: &Session) -> Enabled { - s.active() - .map(|_| ()) - .ok_or_else(|| "no document open".into()) + s.active().map(|_| ()).ok_or_else(|| "no document open".into()) } fn mode_is(s: &Session, modes: &[ColorMode], what: &str) -> Enabled { let d = s.active().ok_or("no document open")?; - if modes.contains(&d.doc.mode) { - Ok(()) - } else { - Err(format!("{what} (the document is {:?})", d.doc.mode)) - } + if modes.contains(&d.doc.mode) { Ok(()) } else { Err(format!("{what} (the document is {:?})", d.doc.mode)) } } // ---------- Image Rotation › Arbitrary ---------- @@ -78,9 +65,7 @@ fn rotate_arbitrary(s: &mut Session, p: &Value) -> Result { d => return Err(bad(CMD, format!("direction `{d}` (cw|ccw)"))), }; if deg.rem_euclid(360.0).abs() < 1e-9 { - return Ok( - json!({"width": s.active().map(|d| d.doc.size.width), "height": s.active().map(|d| d.doc.size.height)}), - ); + return Ok(json!({"width": s.active().map(|d| d.doc.size.width), "height": s.active().map(|d| d.doc.size.height)})); } let bg = s.tools.background; let size = s.edit("Rotate Canvas", |doc, _| { @@ -90,27 +75,14 @@ fn rotate_arbitrary(s: &mut Session, p: &Value) -> Result { let (cx, cy) = (f64::from(old.width) / 2.0, f64::from(old.height) / 2.0); let (nx, ny) = (f64::from(new.width) / 2.0, f64::from(new.height) / 2.0); // Clockwise on screen (y down): x' = c·x − s·y, y' = s·x + c·y about the centres. - let a = Affine { - m: [ - cs, - sn, - -sn, - cs, - nx - (cs * cx - sn * cy), - ny - (sn * cx + cs * cy), - ], - }; - let h = Homography([ - a.m[0], a.m[2], a.m[4], a.m[1], a.m[3], a.m[5], 0.0, 0.0, 1.0, - ]); + let a = Affine { m: [cs, sn, -sn, cs, nx - (cs * cx - sn * cy), ny - (sn * cx + cs * cy)] }; + let h = Homography([a.m[0], a.m[2], a.m[4], a.m[1], a.m[3], a.m[5], 0.0, 0.0, 1.0]); let interp = Interp::parse(str_or(p, "interpolation", "bicubic")); doc.size = new; let canvas = doc.bounds(); let fmt = doc.pixel_format(); for l in doc.layers.iter_mut() { - let background = l.name == "Background" - && l.locks.position - && matches!(l.content, LayerContent::Raster(_)); + let background = l.name == "Background" && l.locks.position && matches!(l.content, LayerContent::Raster(_)); if background { // The Background stays a Background: rotated pixels over the background colour. let surf = l.surface_mut().expect("raster"); @@ -118,10 +90,7 @@ fn rotate_arbitrary(s: &mut Session, p: &Value) -> Result { let rotated = photocraft_algo::transform::warp_surface(surf, src, &h, interp); let mut base = Surface::new(fmt); let fill = photocraft_raster::from_rgba(&fmt, bg); - base.write_region( - canvas, - &fill.repeat(canvas.width() as usize * canvas.height() as usize), - ); + base.write_region(canvas, &fill.repeat(canvas.width() as usize * canvas.height() as usize)); crate::transform_cmds::composite_over(&mut base, &rotated); base.prune(); *surf = base; @@ -141,16 +110,11 @@ fn rotate_arbitrary(s: &mut Session, p: &Value) -> Result { ch.surface = crate::transform_cmds::warp_gray(&ch.surface, &h, interp); } if let Some(sel) = &doc.selection { - doc.selection = Some(crate::transform_cmds::warp_gray(sel, &h, Interp::Bilinear)) - .filter(|s| !s.content_bounds().is_empty()); + doc.selection = Some(crate::transform_cmds::warp_gray(sel, &h, Interp::Bilinear)).filter(|s| !s.content_bounds().is_empty()); } // Type and smart objects re-render from their new transforms. - let ids: Vec = doc - .walk() - .iter() - .filter(|(_, _, l)| matches!(l.content, LayerContent::Text(_) | LayerContent::Smart(_))) - .map(|(_, _, l)| l.id) - .collect(); + let ids: Vec = + doc.walk().iter().filter(|(_, _, l)| matches!(l.content, LayerContent::Text(_) | LayerContent::Smart(_))).map(|(_, _, l)| l.id).collect(); for id in ids { let snapshot = doc.clone(); if let Some(l) = doc.layer_mut(id) { @@ -168,45 +132,26 @@ fn rotate_arbitrary(s: &mut Session, p: &Value) -> Result { /// The flattened image as straight RGBA (over white when `opaque`). fn composite(doc: &Document, opaque: bool) -> Vec<[f32; 4]> { let buf = photocraft_compose::flatten(doc); - if opaque { - buf.over_background([1.0, 1.0, 1.0]).px - } else { - buf.px - } + if opaque { buf.over_background([1.0, 1.0, 1.0]).px } else { buf.px } } /// Replace the document's layers by one 8-bit layer holding `px` in `mode`'s storage format. -fn single_layer( - doc: &mut Document, - active: &mut Option, - mode: ColorMode, - px: &[[f32; 4]], - name: &str, - background: bool, -) { +fn single_layer(doc: &mut Document, active: &mut Option, mode: ColorMode, px: &[[f32; 4]], name: &str, background: bool) { doc.mode = mode; doc.depth = SampleType::U8; let fmt = doc.pixel_format(); - let data: Vec = px - .iter() - .flat_map(|q| photocraft_raster::from_rgba(&fmt, *q)) - .collect(); + let data: Vec = px.iter().flat_map(|q| photocraft_raster::from_rgba(&fmt, *q)).collect(); let mut l = Layer::raster(name, fmt); if background { l.locks.transparency = true; l.locks.position = true; } - l.surface_mut() - .expect("raster") - .write_region(doc.bounds(), &data); + l.surface_mut().expect("raster").write_region(doc.bounds(), &data); *active = Some(l.id); doc.layers = vec![l]; for ch in doc.channels.iter_mut() { let f = PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false); - ch.surface = ch.surface.convert(PixelFormat { - alpha: ch.surface.format().alpha, - ..f - }); + ch.surface = ch.surface.convert(PixelFormat { alpha: ch.surface.format().alpha, ..f }); } doc.quick_mask = None; doc.color_table = None; @@ -237,36 +182,17 @@ fn parse_dither(s: &str) -> Option { fn indexed_color(s: &mut Session, p: &Value) -> Result { const CMD: &str = "image.mode.indexedColor"; - let kind = PaletteKind::from_id(str_or(p, "palette", "selective")).ok_or_else(|| { - bad( - CMD, - "palette: exact|systemMac|systemWindows|web|uniform|perceptual|selective|adaptive", - ) - })?; - let forced = parse_forced(str_or(p, "forced", "blackWhite")) - .ok_or_else(|| bad(CMD, "forced: none|blackWhite|primaries|web"))?; - let dither = parse_dither(str_or(p, "dither", "diffusion")) - .ok_or_else(|| bad(CMD, "dither: none|diffusion|pattern|noise"))?; + let kind = PaletteKind::from_id(str_or(p, "palette", "selective")) + .ok_or_else(|| bad(CMD, "palette: exact|systemMac|systemWindows|web|uniform|perceptual|selective|adaptive"))?; + let forced = parse_forced(str_or(p, "forced", "blackWhite")).ok_or_else(|| bad(CMD, "forced: none|blackWhite|primaries|web"))?; + let dither = parse_dither(str_or(p, "dither", "diffusion")).ok_or_else(|| bad(CMD, "dither: none|diffusion|pattern|noise"))?; let colors = num(p, "colors", 256.0).clamp(2.0, 256.0) as usize; let amount = num(p, "amount", 75.0).clamp(0.0, 100.0) / 100.0; - let transparency = p - .get("transparency") - .and_then(Value::as_bool) - .unwrap_or(true); + let transparency = p.get("transparency").and_then(Value::as_bool).unwrap_or(true); let (pal_len, transparent) = s.edit("Indexed Color", |doc, active| { let mut px = composite(doc, !transparency); let has_clear = transparency && px.iter().any(|q| q[3] < 0.5); - let mut pal = quantize::build_palette( - &px, - kind, - if has_clear { - colors.saturating_sub(1).max(2) - } else { - colors - }, - forced, - ) - .map_err(|e| bad(CMD, e))?; + let mut pal = quantize::build_palette(&px, kind, if has_clear { colors.saturating_sub(1).max(2) } else { colors }, forced).map_err(|e| bad(CMD, e))?; let transparent = if has_clear && pal.len() < 256 { pal.push([255, 255, 255]); Some(pal.len() - 1) @@ -275,22 +201,8 @@ fn indexed_color(s: &mut Session, p: &Value) -> Result { }; let w = doc.size.width as usize; quantize::quantize(&mut px, w, &pal, dither, amount, transparent); - single_layer( - doc, - active, - ColorMode::Indexed, - &px, - if transparent.is_some() { - "Index" - } else { - "Background" - }, - transparent.is_none(), - ); - doc.color_table = Some(ColorTable { - colors: pal.clone(), - transparent: transparent.map(|t| t as u8), - }); + single_layer(doc, active, ColorMode::Indexed, &px, if transparent.is_some() { "Index" } else { "Background" }, transparent.is_none()); + doc.color_table = Some(ColorTable { colors: pal.clone(), transparent: transparent.map(|t| t as u8) }); Ok((pal.len(), transparent)) })?; Ok(json!({"colors": pal_len, "transparentIndex": transparent})) @@ -317,21 +229,11 @@ fn parse_color(v: &Value) -> Option<[u8; 3]> { /// Built-in Color Table presets. pub fn preset_table(name: &str) -> Option> { - let ramp = |f: &dyn Fn(f32) -> [f32; 3]| { - (0..256) - .map(|i| f(i as f32 / 255.0).map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)) - .collect() - }; + let ramp = |f: &dyn Fn(f32) -> [f32; 3]| (0..256).map(|i| f(i as f32 / 255.0).map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)).collect(); Some(match name { "grayscale" => ramp(&|t| [t; 3]), // Black → red → yellow → white, the incandescence scale. - "blackBody" => ramp(&|t| { - [ - (t * 3.0).min(1.0), - (t * 3.0 - 1.0).clamp(0.0, 1.0), - (t * 3.0 - 2.0).clamp(0.0, 1.0), - ] - }), + "blackBody" => ramp(&|t| [(t * 3.0).min(1.0), (t * 3.0 - 1.0).clamp(0.0, 1.0), (t * 3.0 - 2.0).clamp(0.0, 1.0)]), "spectrum" => ramp(&|t| { let h = t * 300.0 / 60.0; let x = 1.0 - (h % 2.0 - 1.0).abs(); @@ -354,20 +256,11 @@ pub fn preset_table(name: &str) -> Option> { /// the table, as in Photoshop) and set the transparent entry. No changes: report the table. fn color_table(s: &mut Session, p: &Value) -> Result { const CMD: &str = "image.mode.colorTable"; - let cur = s - .active() - .ok_or(EngineError::NoDocument)? - .doc - .color_table - .clone() - .ok_or_else(|| EngineError::Other("the document has no color table".into()))?; + let cur = s.active().ok_or(EngineError::NoDocument)?.doc.color_table.clone().ok_or_else(|| EngineError::Other("the document has no color table".into()))?; let mut table = cur.clone(); match str_or(p, "table", "custom") { "custom" => {} - name => { - table.colors = - preset_table(name).ok_or_else(|| bad(CMD, format!("unknown table `{name}`")))? - } + name => table.colors = preset_table(name).ok_or_else(|| bad(CMD, format!("unknown table `{name}`")))?, } if let Some(a) = p.get("colors").and_then(Value::as_array) { let c: Option> = a.iter().map(parse_color).collect(); @@ -379,31 +272,17 @@ fn color_table(s: &mut Session, p: &Value) -> Result { } if let Some(e) = p.get("entries").and_then(Value::as_object) { for (k, v) in e { - let i: usize = k - .parse() - .map_err(|_| bad(CMD, format!("entry index `{k}`")))?; + let i: usize = k.parse().map_err(|_| bad(CMD, format!("entry index `{k}`")))?; let c = parse_color(v).ok_or_else(|| bad(CMD, format!("entry {i}: bad colour")))?; if i >= table.colors.len() { - return Err(bad( - CMD, - format!( - "entry {i} is past the table ({} entries)", - table.colors.len() - ), - )); + return Err(bad(CMD, format!("entry {i} is past the table ({} entries)", table.colors.len()))); } table.colors[i] = c; } } match p.get("transparent") { Some(Value::Null) => table.transparent = None, - Some(v) => { - table.transparent = v - .as_u64() - .filter(|&i| (i as usize) < table.colors.len()) - .map(|i| i as u8) - .or(table.transparent) - } + Some(v) => table.transparent = v.as_u64().filter(|&i| (i as usize) < table.colors.len()).map(|i| i as u8).or(table.transparent), None => {} } let report = |t: &ColorTable| json!({"colors": t.colors.iter().map(|c| hex(*c)).collect::>(), "transparent": t.transparent}); @@ -427,19 +306,10 @@ fn color_table(s: &mut Session, p: &Value) -> Result { continue; } let i = old.nearest([c[0], c[1], c[2]]); - let alpha = if table.transparent == Some(i as u8) { - 0.0 - } else { - c[3] - }; - let nc = if i < table.colors.len() { - table.rgb(i) - } else { - table.rgb(table.nearest([c[0], c[1], c[2]])) - }; + let alpha = if table.transparent == Some(i as u8) { 0.0 } else { c[3] }; + let nc = if i < table.colors.len() { table.rgb(i) } else { table.rgb(table.nearest([c[0], c[1], c[2]])) }; let mut enc = [0.0f32; 8]; - let m = - photocraft_raster::from_rgba_into(&fmt, [nc[0], nc[1], nc[2], alpha], &mut enc); + let m = photocraft_raster::from_rgba_into(&fmt, [nc[0], nc[1], nc[2], alpha], &mut enc); q.copy_from_slice(&enc[..m]); } surf.write_region(r, &data); @@ -455,37 +325,22 @@ fn color_table(s: &mut Session, p: &Value) -> Result { fn bitmap(s: &mut Session, p: &Value) -> Result { const CMD: &str = "image.mode.bitmap"; - let dpi = s - .active() - .ok_or(EngineError::NoDocument)? - .doc - .resolution_dpi - .max(1.0); + let dpi = s.active().ok_or(EngineError::NoDocument)?.doc.resolution_dpi.max(1.0); let method = match str_or(p, "method", "diffusion") { "threshold" | "threshold50" => BitmapMethod::Threshold, "pattern" => BitmapMethod::Pattern, "diffusion" => BitmapMethod::Diffusion, "halftone" => { let freq = num(p, "frequency", 53.0).clamp(1.0, 999.0); - BitmapMethod::Halftone { - cell: (dpi / freq).max(2.0), - angle: num(p, "angle", 45.0), - shape: HalftoneShape::from_id(str_or(p, "shape", "round")), - } + BitmapMethod::Halftone { cell: (dpi / freq).max(2.0), angle: num(p, "angle", 45.0), shape: HalftoneShape::from_id(str_or(p, "shape", "round")) } } m => { - return Err(bad( - CMD, - format!("method `{m}` (threshold|pattern|diffusion|halftone)"), - )); + return Err(bad(CMD, format!("method `{m}` (threshold|pattern|diffusion|halftone)"))); } }; s.edit("Bitmap", |doc, active| { let px = composite(doc, true); - let mut gray: Vec = px - .iter() - .map(|q| photocraft_color::convert::rgb_to_gray([q[0], q[1], q[2]])) - .collect(); + let mut gray: Vec = px.iter().map(|q| photocraft_color::convert::rgb_to_gray([q[0], q[1], q[2]])).collect(); quantize::to_bitmap(&mut gray, doc.size.width as usize, method); let out: Vec<[f32; 4]> = gray.iter().map(|g| [*g, *g, *g, 1.0]).collect(); single_layer(doc, active, ColorMode::Bitmap, &out, "Background", true); @@ -499,32 +354,16 @@ fn bitmap(s: &mut Session, p: &Value) -> Result { /// Default inks per type: black first, then warm brown, gold and slate blue (generic names; /// Photoshop ships Pantone presets, which we don't reproduce). fn default_inks(n: usize) -> Vec { - let all = [ - ("Black", [0.0, 0.0, 0.0]), - ("Warm Brown", [0.62, 0.38, 0.18]), - ("Gold", [0.86, 0.68, 0.18]), - ("Slate Blue", [0.27, 0.38, 0.58]), - ]; - all.iter() - .take(n) - .map(|(name, c)| DuotoneInk::new(*name, *c)) - .collect() + let all = [("Black", [0.0, 0.0, 0.0]), ("Warm Brown", [0.62, 0.38, 0.18]), ("Gold", [0.86, 0.68, 0.18]), ("Slate Blue", [0.27, 0.38, 0.58])]; + all.iter().take(n).map(|(name, c)| DuotoneInk::new(*name, *c)).collect() } fn parse_inks(cmd: &str, v: &Value, n: usize) -> Result> { - let a = v.as_array().ok_or_else(|| { - bad( - cmd, - "inks: [{\"name\",\"color\":\"#rrggbb\",\"curve\":[[in,out],…] (0..100)}, …]", - ) - })?; + let a = v.as_array().ok_or_else(|| bad(cmd, "inks: [{\"name\",\"color\":\"#rrggbb\",\"curve\":[[in,out],…] (0..100)}, …]"))?; let defaults = default_inks(n.max(a.len()).min(4)); let mut out = Vec::new(); for (i, ink) in a.iter().take(4).enumerate() { - let mut d = defaults - .get(i) - .cloned() - .unwrap_or_else(|| DuotoneInk::new(format!("Ink {}", i + 1), [0.0; 3])); + let mut d = defaults.get(i).cloned().unwrap_or_else(|| DuotoneInk::new(format!("Ink {}", i + 1), [0.0; 3])); if let Some(name) = ink.get("name").and_then(Value::as_str) { d.name = name.to_string(); } @@ -532,15 +371,8 @@ fn parse_inks(cmd: &str, v: &Value, n: usize) -> Result> { d.color = c.map(|v| f32::from(v) / 255.0); } if let Some(pts) = ink.get("curve").and_then(Value::as_array) { - let mut curve: Vec = pts - .iter() - .filter_map(|p| { - Some(CurvePoint { - input: p.get(0)?.as_f64()? as f32 / 100.0, - output: p.get(1)?.as_f64()? as f32 / 100.0, - }) - }) - .collect(); + let mut curve: Vec = + pts.iter().filter_map(|p| Some(CurvePoint { input: p.get(0)?.as_f64()? as f32 / 100.0, output: p.get(1)?.as_f64()? as f32 / 100.0 })).collect(); curve.sort_by(|a, b| a.input.total_cmp(&b.input)); if curve.len() >= 2 { d.curve = curve; @@ -559,10 +391,7 @@ fn duotone(s: &mut Session, p: &Value) -> Result { "tritone" => 3, "quadtone" => 4, t => { - return Err(bad( - CMD, - format!("type `{t}` (monotone|duotone|tritone|quadtone)"), - )); + return Err(bad(CMD, format!("type `{t}` (monotone|duotone|tritone|quadtone)"))); } }; let mut inks = match p.get("inks") { @@ -576,10 +405,7 @@ fn duotone(s: &mut Session, p: &Value) -> Result { let names: Vec = inks.iter().map(|i| i.name.clone()).collect(); s.edit("Duotone", |doc, _| { doc.mode = ColorMode::Duotone; - doc.duotone = Some(Duotone { - inks, - psd_raw: None, - }); + doc.duotone = Some(Duotone { inks, psd_raw: None }); doc.color_table = None; Ok(()) })?; @@ -609,14 +435,7 @@ pub fn specs() -> Vec { r##"{"palette":"selective|perceptual|adaptive|exact|systemMac|systemWindows|web|uniform"="selective","colors":2..256=256,"forced":"blackWhite|none|primaries|web"="blackWhite","transparency":bool=true,"dither":"diffusion|none|pattern|noise"="diffusion","amount":0..100=75} (flattens)"##, |s| mode_is( s, - &[ - ColorMode::Rgb, - ColorMode::Grayscale, - ColorMode::Indexed, - ColorMode::Duotone, - ColorMode::Cmyk, - ColorMode::Lab - ], + &[ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Indexed, ColorMode::Duotone, ColorMode::Cmyk, ColorMode::Lab], "Indexed Color needs an RGB or Grayscale image" ), indexed_color @@ -626,11 +445,7 @@ pub fn specs() -> Vec { "Color Table…", ["Image", "Mode"], r##"{"table":"custom|blackBody|grayscale|spectrum|systemMac|systemWindows|web"="custom","colors":json,"entries":json,"transparent":json} (colors: ["#rrggbb", …]; entries: {"index": "#rrggbb"}; transparent: index|null)"##, - |s| mode_is( - s, - &[ColorMode::Indexed], - "Color Table needs an Indexed Color image" - ), + |s| mode_is(s, &[ColorMode::Indexed], "Color Table needs an Indexed Color image"), color_table ), spec!( @@ -638,11 +453,7 @@ pub fn specs() -> Vec { "Bitmap…", ["Image", "Mode"], r##"{"method":"diffusion|threshold|pattern|halftone"="diffusion","frequency":1..999=53,"angle":-180..180=45,"shape":"round|ellipse|line|square|diamond|cross"="round"} (halftone frequency in lines/inch; flattens)"##, - |s| mode_is( - s, - &[ColorMode::Grayscale], - "Bitmap needs a Grayscale image (Image › Mode › Grayscale first)" - ), + |s| mode_is(s, &[ColorMode::Grayscale], "Bitmap needs a Grayscale image (Image › Mode › Grayscale first)"), bitmap ), spec!( @@ -650,11 +461,7 @@ pub fn specs() -> Vec { "Duotone…", ["Image", "Mode"], r##"{"type":"duotone|monotone|tritone|quadtone"="duotone","inks":json} (inks: [{"name","color":"#rrggbb","curve":[[in,out],…] in 0..100}, …])"##, - |s| mode_is( - s, - &[ColorMode::Grayscale, ColorMode::Duotone], - "Duotone needs a Grayscale image (Image › Mode › Grayscale first)" - ), + |s| mode_is(s, &[ColorMode::Grayscale, ColorMode::Duotone], "Duotone needs a Grayscale image (Image › Mode › Grayscale first)"), duotone ), ] @@ -668,10 +475,7 @@ pub fn duotone_display_layer(d: &Duotone) -> Layer { /// A copy of `doc` that displays like the printed result (Duotone inks), or None when the /// document displays as stored. pub fn display_document(doc: &Document) -> Option { - let d = doc - .duotone - .as_ref() - .filter(|_| doc.mode == ColorMode::Duotone)?; + let d = doc.duotone.as_ref().filter(|_| doc.mode == ColorMode::Duotone)?; let mut out = doc.clone(); out.layers.push(duotone_display_layer(d)); Some(out) diff --git a/crates/engine/src/mode_cmds/tests.rs b/crates/engine/src/mode_cmds/tests.rs index 5d4a44ed15..ac94c775d7 100644 --- a/crates/engine/src/mode_cmds/tests.rs +++ b/crates/engine/src/mode_cmds/tests.rs @@ -3,25 +3,15 @@ use photocraft_geom::Rect; fn session(w: u32, h: u32, depth: u64, mode: &str) -> Session { let mut s = Session::new(); - s.execute( - "file.new", - json!({"width": w, "height": h, "depth": depth, "mode": mode}), - ) - .unwrap(); - paint(&mut s, |x, y| { - [x as f32 / w as f32, y as f32 / h as f32, 0.5, 1.0] - }); + s.execute("file.new", json!({"width": w, "height": h, "depth": depth, "mode": mode})).unwrap(); + paint(&mut s, |x, y| [x as f32 / w as f32, y as f32 / h as f32, 0.5, 1.0]); s } fn paint(s: &mut Session, f: impl Fn(i32, i32) -> [f32; 4]) { s.edit("setup", |doc, active| { let b = doc.bounds(); - let surf = doc - .layer_mut(active.unwrap()) - .unwrap() - .surface_mut() - .unwrap(); + let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); let fmt = surf.format(); let mut data = Vec::new(); for y in b.y0..b.y1 { @@ -58,38 +48,16 @@ fn rotate_arbitrary_grows_canvas_and_keeps_background() { for depth in [8, 16, 32] { let mut s = session(40, 20, depth, "rgb"); s.execute("layer.new.layer", json!({})).unwrap(); - paint(&mut s, |x, y| { - if (15..25).contains(&x) && (5..15).contains(&y) { - [1.0, 0.0, 0.0, 1.0] - } else { - [0.0; 4] - } - }); - s.execute("tools.setColors", json!({"background": "#00ff00"})) - .unwrap(); - let r = s - .execute( - "image.rotation.arbitrary", - json!({"angle": 90, "direction": "cw"}), - ) - .unwrap(); - assert_eq!( - (r["width"].as_u64(), r["height"].as_u64()), - (Some(20), Some(40)) - ); + paint(&mut s, |x, y| if (15..25).contains(&x) && (5..15).contains(&y) { [1.0, 0.0, 0.0, 1.0] } else { [0.0; 4] }); + s.execute("tools.setColors", json!({"background": "#00ff00"})).unwrap(); + let r = s.execute("image.rotation.arbitrary", json!({"angle": 90, "direction": "cw"})).unwrap(); + assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(20), Some(40))); let d = doc(&s); assert_eq!(d.layers[0].name, "Background"); // The red square stays centred. - assert!( - d.layers[1].surface().unwrap().rgba(10, 20)[0] > 0.9, - "{depth}" - ); + assert!(d.layers[1].surface().unwrap().rgba(10, 20)[0] > 0.9, "{depth}"); s.execute("edit.undo", json!({})).unwrap(); - s.execute( - "image.rotation.arbitrary", - json!({"angle": 30, "direction": "ccw"}), - ) - .unwrap(); + s.execute("image.rotation.arbitrary", json!({"angle": 30, "direction": "ccw"})).unwrap(); let d = doc(&s); assert_eq!(d.size, rotated_size(Size::new(40, 20), 30.0)); assert!(d.size.width > 40 && d.size.height > 20); @@ -98,75 +66,35 @@ fn rotate_arbitrary_grows_canvas_and_keeps_background() { assert!(corner[1] > 0.9 && corner[0] < 0.1, "{corner:?}"); } let mut s = session(10, 10, 8, "rgb"); - assert!( - s.execute( - "image.rotation.arbitrary", - json!({"angle": 10, "direction": "up"}) - ) - .is_err() - ); + assert!(s.execute("image.rotation.arbitrary", json!({"angle": 10, "direction": "up"})).is_err()); let n = s.active().unwrap().history.past_len(); - s.execute("image.rotation.arbitrary", json!({"angle": 360})) - .unwrap(); - assert_eq!( - s.active().unwrap().history.past_len(), - n, - "a full turn changes nothing" - ); + s.execute("image.rotation.arbitrary", json!({"angle": 360})).unwrap(); + assert_eq!(s.active().unwrap().history.past_len(), n, "a full turn changes nothing"); } #[test] fn rotate_arbitrary_other_modes() { for mode in ["gray", "cmyk", "lab"] { let mut s = session(16, 12, 16, mode); - s.execute("image.rotation.arbitrary", json!({"angle": 45})) - .unwrap(); - assert_eq!( - doc(&s).size, - rotated_size(Size::new(16, 12), 45.0), - "{mode}" - ); + s.execute("image.rotation.arbitrary", json!({"angle": 45})).unwrap(); + assert_eq!(doc(&s).size, rotated_size(Size::new(16, 12), 45.0), "{mode}"); } } #[test] fn indexed_color_palettes_and_dithers() { - for palette in [ - "selective", - "perceptual", - "adaptive", - "web", - "uniform", - "systemMac", - "systemWindows", - ] { + for palette in ["selective", "perceptual", "adaptive", "web", "uniform", "systemMac", "systemWindows"] { for dither in ["none", "diffusion", "pattern", "noise"] { let mut s = session(24, 16, 16, "rgb"); - let r = s - .execute( - "image.mode.indexedColor", - json!({"palette": palette, "colors": 16, "dither": dither, "forced": "none"}), - ) - .unwrap(); + let r = s.execute("image.mode.indexedColor", json!({"palette": palette, "colors": 16, "dither": dither, "forced": "none"})).unwrap(); let d = doc(&s); - assert_eq!( - (d.mode, d.depth), - (ColorMode::Indexed, SampleType::U8), - "{palette} {dither}" - ); + assert_eq!((d.mode, d.depth), (ColorMode::Indexed, SampleType::U8), "{palette} {dither}"); let table = d.color_table.as_ref().unwrap(); assert_eq!(table.colors.len() as u64, r["colors"].as_u64().unwrap()); let used = colours(d); - assert!( - used.iter().all(|c| table.colors.contains(c)), - "{palette} {dither}: pixels off the table" - ); + assert!(used.iter().all(|c| table.colors.contains(c)), "{palette} {dither}: pixels off the table"); if !matches!(palette, "web" | "systemMac" | "systemWindows") { - assert!( - table.colors.len() <= 16, - "{palette}: {}", - table.colors.len() - ); + assert!(table.colors.len() <= 16, "{palette}: {}", table.colors.len()); } } } @@ -176,11 +104,7 @@ fn indexed_color_palettes_and_dithers() { fn indexed_exact_transparency_and_sources() { // Two colours + transparency: exact palette with a transparent entry. let mut s = Session::new(); - s.execute( - "file.new", - json!({"width": 8, "height": 8, "background": "transparent"}), - ) - .unwrap(); + s.execute("file.new", json!({"width": 8, "height": 8, "background": "transparent"})).unwrap(); paint(&mut s, |x, _| { if x < 3 { [1.0, 0.0, 0.0, 1.0] @@ -190,11 +114,7 @@ fn indexed_exact_transparency_and_sources() { [0.0; 4] } }); - s.execute( - "image.mode.indexedColor", - json!({"palette": "exact", "forced": "none"}), - ) - .unwrap(); + s.execute("image.mode.indexedColor", json!({"palette": "exact", "forced": "none"})).unwrap(); let d = doc(&s); let t = d.color_table.as_ref().unwrap(); assert_eq!(t.colors.len(), 3); @@ -203,20 +123,15 @@ fn indexed_exact_transparency_and_sources() { assert_eq!(d.layers[0].surface().unwrap().rgba(7, 0)[3], 0.0); // Exact fails on a gradient. let mut s = session(64, 64, 8, "rgb"); - assert!( - s.execute("image.mode.indexedColor", json!({"palette": "exact"})) - .is_err() - ); + assert!(s.execute("image.mode.indexedColor", json!({"palette": "exact"})).is_err()); // Gray, CMYK and Lab sources flatten through the composite. for mode in ["gray", "cmyk", "lab"] { let mut s = session(12, 12, 16, mode); - s.execute("image.mode.indexedColor", json!({"colors": 8})) - .unwrap(); + s.execute("image.mode.indexedColor", json!({"colors": 8})).unwrap(); assert_eq!(doc(&s).mode, ColorMode::Indexed, "{mode}"); } // Back to RGB drops the table. - s.execute("image.mode.indexedColor", json!({"colors": 8})) - .unwrap(); + s.execute("image.mode.indexedColor", json!({"colors": 8})).unwrap(); s.execute("image.mode.rgb", json!({})).unwrap(); assert!(doc(&s).color_table.is_none()); s.execute("edit.undo", json!({})).unwrap(); @@ -227,11 +142,7 @@ fn indexed_exact_transparency_and_sources() { fn color_table_presets_and_entries() { let mut s = session(16, 16, 8, "rgb"); assert!(!s.is_enabled("image.mode.colorTable")); - s.execute( - "image.mode.indexedColor", - json!({"palette": "uniform", "colors": 8, "dither": "none", "forced": "none"}), - ) - .unwrap(); + s.execute("image.mode.indexedColor", json!({"palette": "uniform", "colors": 8, "dither": "none", "forced": "none"})).unwrap(); let before = doc(&s).color_table.clone().unwrap(); // No changes: a report, no history step. let n = s.active().unwrap().history.past_len(); @@ -241,31 +152,13 @@ fn color_table_presets_and_entries() { // Replace entry 0: pixels of index 0 change colour. let px0 = doc(&s).layers[0].surface().unwrap().rgba(0, 0); let i0 = before.nearest([px0[0], px0[1], px0[2]]); - s.execute( - "image.mode.colorTable", - json!({"entries": {i0.to_string(): "#ff00ff"}}), - ) - .unwrap(); - assert_eq!( - doc(&s).layers[0].surface().unwrap().rgba(0, 0), - [1.0, 0.0, 1.0, 1.0] - ); - s.execute("image.mode.colorTable", json!({"table": "blackBody"})) - .unwrap(); + s.execute("image.mode.colorTable", json!({"entries": {i0.to_string(): "#ff00ff"}})).unwrap(); + assert_eq!(doc(&s).layers[0].surface().unwrap().rgba(0, 0), [1.0, 0.0, 1.0, 1.0]); + s.execute("image.mode.colorTable", json!({"table": "blackBody"})).unwrap(); assert_eq!(doc(&s).color_table.as_ref().unwrap().colors.len(), 256); - s.execute("image.mode.colorTable", json!({"transparent": 0})) - .unwrap(); - assert!( - s.execute("image.mode.colorTable", json!({"table": "nope"})) - .is_err() - ); - assert!( - s.execute( - "image.mode.colorTable", - json!({"entries": {"999": "#000000"}}) - ) - .is_err() - ); + s.execute("image.mode.colorTable", json!({"transparent": 0})).unwrap(); + assert!(s.execute("image.mode.colorTable", json!({"table": "nope"})).is_err()); + assert!(s.execute("image.mode.colorTable", json!({"entries": {"999": "#000000"}})).is_err()); } #[test] @@ -274,17 +167,9 @@ fn bitmap_methods_need_grayscale() { assert!(!s.is_enabled("image.mode.bitmap")); s.execute("image.mode.grayscale", json!({})).unwrap(); for method in ["threshold", "pattern", "diffusion", "halftone"] { - s.execute( - "image.mode.bitmap", - json!({"method": method, "frequency": 12, "shape": "diamond"}), - ) - .unwrap(); + s.execute("image.mode.bitmap", json!({"method": method, "frequency": 12, "shape": "diamond"})).unwrap(); let d = doc(&s); - assert_eq!( - (d.mode, d.depth), - (ColorMode::Bitmap, SampleType::U8), - "{method}" - ); + assert_eq!((d.mode, d.depth), (ColorMode::Bitmap, SampleType::U8), "{method}"); let surf = d.layers[0].surface().unwrap(); for y in 0..32 { for x in 0..32 { @@ -294,21 +179,20 @@ fn bitmap_methods_need_grayscale() { } s.execute("edit.undo", json!({})).unwrap(); } - assert!( - s.execute("image.mode.bitmap", json!({"method": "nope"})) - .is_err() - ); + assert!(s.execute("image.mode.bitmap", json!({"method": "nope"})).is_err()); } #[test] fn duotone_inks_and_display() { let mut s = session(16, 8, 8, "gray"); paint(&mut s, |x, _| [x as f32 / 15.0; 4].map(|v| v.min(1.0))); - assert!( - s.execute("image.mode.duotone", json!({"type": "quintone"})) - .is_err() - ); - let r = s.execute("image.mode.duotone", json!({"type": "duotone", "inks": [{"name": "Black"}, {"name": "Orange", "color": "#ff8000", "curve": [[0, 0], [100, 80]]}]})).unwrap(); + assert!(s.execute("image.mode.duotone", json!({"type": "quintone"})).is_err()); + let r = s + .execute( + "image.mode.duotone", + json!({"type": "duotone", "inks": [{"name": "Black"}, {"name": "Orange", "color": "#ff8000", "curve": [[0, 0], [100, 80]]}]}), + ) + .unwrap(); assert_eq!(r["inks"], json!(["Black", "Orange"])); let d = doc(&s); assert_eq!(d.mode, ColorMode::Duotone); @@ -318,18 +202,9 @@ fn duotone_inks_and_display() { let shown = display_document(d).unwrap(); let px = photocraft_compose::render(&shown, Rect::from_xywh(8, 4, 1, 1)).px[0]; assert!(px[0] > px[2] + 0.05, "{px:?}"); - assert!( - display_document(&Document::new( - "x", - Size::new(1, 1), - ColorMode::Grayscale, - SampleType::U8 - )) - .is_none() - ); + assert!(display_document(&Document::new("x", Size::new(1, 1), ColorMode::Grayscale, SampleType::U8)).is_none()); // Tritone defaults and leaving the mode. - s.execute("image.mode.duotone", json!({"type": "tritone"})) - .unwrap(); + s.execute("image.mode.duotone", json!({"type": "tritone"})).unwrap(); assert_eq!(doc(&s).duotone.as_ref().unwrap().inks.len(), 3); s.execute("image.mode.grayscale", json!({})).unwrap(); assert!(doc(&s).duotone.is_none()); @@ -340,8 +215,7 @@ fn duotone_inks_and_display() { #[test] fn modes_round_trip_through_pcraft() { let mut s = session(12, 12, 8, "rgb"); - s.execute("image.mode.indexedColor", json!({"colors": 6})) - .unwrap(); + s.execute("image.mode.indexedColor", json!({"colors": 6})).unwrap(); let bytes = photocraft_format::save_to_bytes(doc(&s), &Default::default()).unwrap(); let back = photocraft_format::load_from_bytes(&bytes).unwrap(); assert_eq!(back.color_table, doc(&s).color_table); diff --git a/crates/engine/src/multichannel_cmds.rs b/crates/engine/src/multichannel_cmds.rs index c8e1911084..006978f301 100644 --- a/crates/engine/src/multichannel_cmds.rs +++ b/crates/engine/src/multichannel_cmds.rs @@ -33,7 +33,11 @@ const SOURCES: [ColorMode; 5] = [ColorMode::Rgb, ColorMode::Cmyk, ColorMode::Lab fn enabled(s: &Session) -> std::result::Result<(), String> { let d = s.active().ok_or("no document open")?; - if SOURCES.contains(&d.doc.mode) { Ok(()) } else { Err(format!("Multichannel needs an RGB, CMYK, Lab, Grayscale or Duotone image (the document is {:?})", d.doc.mode)) } + if SOURCES.contains(&d.doc.mode) { + Ok(()) + } else { + Err(format!("Multichannel needs an RGB, CMYK, Lab, Grayscale or Duotone image (the document is {:?})", d.doc.mode)) + } } /// Native colour planes of the flattened image (opaque over white): one `Vec` per colour @@ -95,12 +99,21 @@ fn inks_of(doc: &Document) -> Vec<(String, Color, Vec)> { let planes = flattened_planes(doc); let inv = |p: &Vec| p.iter().map(|v| 1.0 - v).collect::>(); match doc.mode { - ColorMode::Rgb => [("Cyan", CYAN), ("Magenta", MAGENTA), ("Yellow", YELLOW)].into_iter().zip(&planes).map(|((n, c), p)| (n.to_string(), c, inv(p))).collect(), - ColorMode::Cmyk => [("Cyan", CYAN), ("Magenta", MAGENTA), ("Yellow", YELLOW), ("Black", BLACK)].into_iter().zip(planes).map(|((n, c), p)| (n.to_string(), c, p)).collect(), + ColorMode::Rgb => { + [("Cyan", CYAN), ("Magenta", MAGENTA), ("Yellow", YELLOW)].into_iter().zip(&planes).map(|((n, c), p)| (n.to_string(), c, inv(p))).collect() + } + ColorMode::Cmyk => [("Cyan", CYAN), ("Magenta", MAGENTA), ("Yellow", YELLOW), ("Black", BLACK)] + .into_iter() + .zip(planes) + .map(|((n, c), p)| (n.to_string(), c, p)) + .collect(), ColorMode::Lab => planes.iter().enumerate().map(|(k, p)| (format!("Alpha {}", k + 1), Color::BLACK, inv(p))).collect(), ColorMode::Duotone if doc.duotone.as_ref().is_some_and(|d| !d.inks.is_empty()) => { let d = doc.duotone.as_ref().expect("checked"); - d.inks.iter().map(|ink| (ink.name.clone(), Color::rgb(ink.color[0], ink.color[1], ink.color[2]), planes[0].iter().map(|g| ink.density(1.0 - g)).collect())).collect() + d.inks + .iter() + .map(|ink| (ink.name.clone(), Color::rgb(ink.color[0], ink.color[1], ink.color[2]), planes[0].iter().map(|g| ink.density(1.0 - g)).collect())) + .collect() } _ => vec![("Black".to_string(), Color::BLACK, inv(&planes[0]))], } @@ -109,7 +122,10 @@ fn inks_of(doc: &Document) -> Vec<(String, Color, Vec)> { fn to_multichannel(s: &mut Session, _p: &Value) -> Result { let names = s.edit("Multichannel", |doc, active| { let inks = inks_of(doc); - let mut channels: Vec = inks.iter().map(|(name, ink, vals)| AlphaChannel { spot: Some((*ink, 0.0)), ..AlphaChannel::new(name.clone(), channel_surface(doc, vals)) }).collect(); + let mut channels: Vec = inks + .iter() + .map(|(name, ink, vals)| AlphaChannel { spot: Some((*ink, 0.0)), ..AlphaChannel::new(name.clone(), channel_surface(doc, vals)) }) + .collect(); // Existing alpha / spot channels follow the inks, converted to the document depth. let fmt = PixelFormat::new(ColorMode::Grayscale, doc.depth, false); for mut ch in std::mem::take(&mut doc.channels) { diff --git a/crates/engine/src/notes_cmds.rs b/crates/engine/src/notes_cmds.rs index 6995341a43..896dafead2 100644 --- a/crates/engine/src/notes_cmds.rs +++ b/crates/engine/src/notes_cmds.rs @@ -190,11 +190,56 @@ pub fn import_notes_from(s: &mut Session, name: &str, bytes: &[u8]) -> Result Vec { vec![ - CommandSpec { id: "notes.add", label: "New Note", menu: &[], shortcut: None, params: r##"{"x":px,"y":px,"text":str="","author":str="","color":"#rrggbb"|[r,g,b]=pale yellow,"open":bool=true}"##, enabled: has_doc, run: add, journal: true }, - CommandSpec { id: "notes.set", label: "Edit Note", menu: &[], shortcut: None, params: r##"{"index":n,"text":str?,"author":str?,"color":"#rrggbb"|[r,g,b]?,"x":px?,"y":px?,"open":bool?}"##, enabled: has_notes, run: set, journal: true }, - CommandSpec { id: "notes.delete", label: "Delete Note", menu: &[], shortcut: None, params: r##"{"index":n}|{"all":true}"##, enabled: has_notes, run: delete, journal: true }, - CommandSpec { id: "notes.list", label: "Notes", menu: &[], shortcut: None, params: r##"{} → notes (index, author, text, colour, position, open, modified)"##, enabled: has_doc, run: list, journal: false }, - CommandSpec { id: "file.import.notes", label: "Notes…", menu: &["File", "Import"], shortcut: None, params: r##"{"path":".psd|.psb|.pcraft with notes"}"##, enabled: has_doc, run: import, journal: true }, + CommandSpec { + id: "notes.add", + label: "New Note", + menu: &[], + shortcut: None, + params: r##"{"x":px,"y":px,"text":str="","author":str="","color":"#rrggbb"|[r,g,b]=pale yellow,"open":bool=true}"##, + enabled: has_doc, + run: add, + journal: true, + }, + CommandSpec { + id: "notes.set", + label: "Edit Note", + menu: &[], + shortcut: None, + params: r##"{"index":n,"text":str?,"author":str?,"color":"#rrggbb"|[r,g,b]?,"x":px?,"y":px?,"open":bool?}"##, + enabled: has_notes, + run: set, + journal: true, + }, + CommandSpec { + id: "notes.delete", + label: "Delete Note", + menu: &[], + shortcut: None, + params: r##"{"index":n}|{"all":true}"##, + enabled: has_notes, + run: delete, + journal: true, + }, + CommandSpec { + id: "notes.list", + label: "Notes", + menu: &[], + shortcut: None, + params: r##"{} → notes (index, author, text, colour, position, open, modified)"##, + enabled: has_doc, + run: list, + journal: false, + }, + CommandSpec { + id: "file.import.notes", + label: "Notes…", + menu: &["File", "Import"], + shortcut: None, + params: r##"{"path":".psd|.psb|.pcraft with notes"}"##, + enabled: has_doc, + run: import, + journal: true, + }, ] } diff --git a/crates/engine/src/paint_cmds.rs b/crates/engine/src/paint_cmds.rs index 648c02285b..1e9f572585 100644 --- a/crates/engine/src/paint_cmds.rs +++ b/crates/engine/src/paint_cmds.rs @@ -43,7 +43,8 @@ fn bucket(s: &mut Session, p: &Value) -> Result { let source = p.get("contents").or_else(|| p.get("source")).and_then(Value::as_str).unwrap_or("foreground"); let pattern = if source == "pattern" { let pat = crate::pattern_cmds::resolve_param(s, "paint.bucket", p)?; - let tile = photocraft_compose::pattern::Tile::new(&pat).ok_or_else(|| EngineError::BadParams { cmd: "paint.bucket".into(), msg: "the pattern is empty".into() })?; + let tile = photocraft_compose::pattern::Tile::new(&pat) + .ok_or_else(|| EngineError::BadParams { cmd: "paint.bucket".into(), msg: "the pattern is empty".into() })?; let (scale, angle, _, phase) = crate::pattern_cmds::placement(p); Some((tile, scale, angle, phase)) } else { @@ -86,7 +87,8 @@ fn gradient(s: &mut Session, p: &Value) -> Result { let stops: Vec<(f32, [f32; 4])> = match crate::presets::gradients::tool_stops(s, p)? { Some(st) => st, None => { - let colors: Vec<[f32; 4]> = p.get("colors").and_then(Value::as_array).map(|a| a.iter().map(|v| color(Some(v), fg)).collect()).unwrap_or_else(|| vec![fg, bg]); + let colors: Vec<[f32; 4]> = + p.get("colors").and_then(Value::as_array).map(|a| a.iter().map(|v| color(Some(v), fg)).collect()).unwrap_or_else(|| vec![fg, bg]); let n = colors.len(); colors.into_iter().enumerate().map(|(i, c)| (if n > 1 { i as f32 / (n - 1) as f32 } else { 0.0 }, c)).collect() } @@ -174,7 +176,12 @@ mod tests { s.execute("paint.bucket", json!({"x": 2, "y": 2, "contents": "pattern", "pattern": "Diagonal Lines", "antiAlias": false, "tolerance": 0})).unwrap(); // The filled region (left of the wall) now carries the pattern's light/dark variation, // while the wall and the region past it are untouched. - let left: Vec<[f32; 4]> = (0..10).map(|x| { let p = px(&s, x, 0); [p[0], p[1], p[2], p[3]] }).collect(); + let left: Vec<[f32; 4]> = (0..10) + .map(|x| { + let p = px(&s, x, 0); + [p[0], p[1], p[2], p[3]] + }) + .collect(); assert!(left.iter().any(|p| p[0] < 0.3) && left.iter().any(|p| p[0] > 0.9), "pattern varies in the fill: {left:?}"); assert_eq!(px(&s, 15, 5), vec![1.0, 1.0, 1.0, 1.0], "region past the wall untouched"); } @@ -199,7 +206,8 @@ mod tests { // per-pixel noise with dither on (which breaks 8-bit banding). let run = |dither: bool| { let mut s = session(); - s.execute("paint.gradient", json!({"from": [0, 0], "to": [19, 0], "colors": [[0.5, 0.5, 0.5, 1.0], [0.5, 0.5, 0.5, 1.0]], "dither": dither})).unwrap(); + s.execute("paint.gradient", json!({"from": [0, 0], "to": [19, 0], "colors": [[0.5, 0.5, 0.5, 1.0], [0.5, 0.5, 0.5, 1.0]], "dither": dither})) + .unwrap(); (0..19).map(|x| px(&s, x, 5)[0]).collect::>() }; let smooth = run(false); diff --git a/crates/engine/src/pattern_cmds.rs b/crates/engine/src/pattern_cmds.rs index 84676d4793..1149889361 100644 --- a/crates/engine/src/pattern_cmds.rs +++ b/crates/engine/src/pattern_cmds.rs @@ -233,7 +233,8 @@ fn define(s: &mut Session, p: &Value) -> Result { fn library_index(s: &Session, cmd: &str, p: &Value) -> Result { let key = p.get("pattern").and_then(Value::as_str).ok_or_else(|| bad(cmd, "missing `pattern` (id or name)"))?; - let found = photocraft_doc::pattern::find(&s.patterns.items, key, key).map(|f| f.id.clone()).ok_or_else(|| bad(cmd, format!("no library pattern \"{key}\"")))?; + let found = + photocraft_doc::pattern::find(&s.patterns.items, key, key).map(|f| f.id.clone()).ok_or_else(|| bad(cmd, format!("no library pattern \"{key}\"")))?; Ok(s.patterns.items.iter().position(|q| q.id == found).unwrap_or(0)) } @@ -281,7 +282,9 @@ fn export(s: &mut Session, p: &Value) -> Result { let cmd = "pattern.export"; let path = p.get("path").and_then(Value::as_str).ok_or_else(|| bad(cmd, "missing `path`"))?; let pats: Vec = match p.get("patterns").and_then(Value::as_array) { - Some(keys) => keys.iter().filter_map(Value::as_str).map(|k| resolve(s, k).ok_or_else(|| bad(cmd, format!("no pattern \"{k}\"")))).collect::>()?, + Some(keys) => { + keys.iter().filter_map(Value::as_str).map(|k| resolve(s, k).ok_or_else(|| bad(cmd, format!("no pattern \"{k}\"")))).collect::>()? + } None => s.patterns.items.clone(), }; let bytes = photocraft_io::pattern_map::write_pat(&pats).map_err(EngineError::Other)?; @@ -291,9 +294,10 @@ fn export(s: &mut Session, p: &Value) -> Result { pub(crate) fn placement(p: &Value) -> (f32, f32, bool, (f32, f32)) { let num = |k: &str, d: f64| p.get(k).and_then(Value::as_f64).unwrap_or(d) as f32; - let phase = p.get("phase").and_then(Value::as_array).map_or((0.0, 0.0), |a| { - (a.first().and_then(Value::as_f64).unwrap_or(0.0) as f32, a.get(1).and_then(Value::as_f64).unwrap_or(0.0) as f32) - }); + let phase = p + .get("phase") + .and_then(Value::as_array) + .map_or((0.0, 0.0), |a| (a.first().and_then(Value::as_f64).unwrap_or(0.0) as f32, a.get(1).and_then(Value::as_f64).unwrap_or(0.0) as f32)); ((num("scale", 100.0) / 100.0).clamp(0.01, 10.0), num("angle", 0.0), p.get("link").and_then(Value::as_bool).unwrap_or(true), phase) } @@ -360,10 +364,13 @@ fn brush_texture(s: &mut Session, p: &Value) -> Result { // Brush textures are grey: luminance of the composite over white. let mut px = vec![[0.0f32; 4]; (pat.width * pat.height) as usize]; pat.surface.read_rgba_into(pat.rect(), &mut px); - let lum: Vec = px.iter().map(|q| { - let l = 0.299 * q[0] + 0.587 * q[1] + 0.114 * q[2]; - l * q[3] + (1.0 - q[3]) - }).collect(); + let lum: Vec = px + .iter() + .map(|q| { + let l = 0.299 * q[0] + 0.587 * q[1] + 0.114 * q[2]; + l * q[3] + (1.0 - q[3]) + }) + .collect(); let tile = photocraft_paint::GrayTile::from_f32(pat.width, pat.height, &lum); let t = &mut s.tools.brush.texture; t.pattern = photocraft_paint::Pattern::Tile(tile); @@ -395,11 +402,56 @@ pub fn specs() -> Vec { journal: true, run: define, }, - CommandSpec { id: "pattern.list", label: "Patterns", menu: &[], shortcut: None, params: "{} → [{id,name,width,height,mode,depth,inDocument,inLibrary}]", enabled: always, journal: false, run: list }, - CommandSpec { id: "pattern.rename", label: "Rename Pattern", menu: &[], shortcut: None, params: r##"{"pattern":id|name,"name":str}"##, enabled: has_pattern, journal: true, run: rename }, - CommandSpec { id: "pattern.delete", label: "Delete Pattern", menu: &[], shortcut: None, params: r##"{"pattern":id|name} (library only; documents keep their copy)"##, enabled: has_pattern, journal: true, run: delete }, - CommandSpec { id: "pattern.import", label: "Import Patterns…", menu: &[], shortcut: None, params: r##"{"path":".pat file"}"##, enabled: always, journal: true, run: import }, - CommandSpec { id: "pattern.export", label: "Export Patterns…", menu: &[], shortcut: None, params: r##"{"path":".pat file","patterns":[id|name]? (default: the whole library)}"##, enabled: has_pattern, journal: true, run: export }, + CommandSpec { + id: "pattern.list", + label: "Patterns", + menu: &[], + shortcut: None, + params: "{} → [{id,name,width,height,mode,depth,inDocument,inLibrary}]", + enabled: always, + journal: false, + run: list, + }, + CommandSpec { + id: "pattern.rename", + label: "Rename Pattern", + menu: &[], + shortcut: None, + params: r##"{"pattern":id|name,"name":str}"##, + enabled: has_pattern, + journal: true, + run: rename, + }, + CommandSpec { + id: "pattern.delete", + label: "Delete Pattern", + menu: &[], + shortcut: None, + params: r##"{"pattern":id|name} (library only; documents keep their copy)"##, + enabled: has_pattern, + journal: true, + run: delete, + }, + CommandSpec { + id: "pattern.import", + label: "Import Patterns…", + menu: &[], + shortcut: None, + params: r##"{"path":".pat file"}"##, + enabled: always, + journal: true, + run: import, + }, + CommandSpec { + id: "pattern.export", + label: "Export Patterns…", + menu: &[], + shortcut: None, + params: r##"{"path":".pat file","patterns":[id|name]? (default: the whole library)}"##, + enabled: has_pattern, + journal: true, + run: export, + }, CommandSpec { id: "layer.newFillLayer.pattern", label: "Pattern…", diff --git a/crates/engine/src/photo_cmds.rs b/crates/engine/src/photo_cmds.rs index d63f2e5550..6c77afdd67 100644 --- a/crates/engine/src/photo_cmds.rs +++ b/crates/engine/src/photo_cmds.rs @@ -394,7 +394,8 @@ fn photomerge(s: &mut Session, p: &Value) -> Result { let cmd = "file.automate.photomerge"; let t0 = Stopwatch::start(); let layout_s = p.get("layout").and_then(Value::as_str).unwrap_or("auto"); - let layout = Layout::parse(layout_s).ok_or_else(|| bad(cmd, format!("unknown layout `{layout_s}` (auto|perspective|cylindrical|spherical|collage|reposition)")))?; + let layout = + Layout::parse(layout_s).ok_or_else(|| bad(cmd, format!("unknown layout `{layout_s}` (auto|perspective|cylindrical|spherical|collage|reposition)")))?; let blend = p.get("blend").and_then(Value::as_bool).unwrap_or(true); let vignette = p.get("vignetteRemoval").and_then(Value::as_bool).unwrap_or(false); let geometric = p.get("geometricCorrection").and_then(Value::as_bool).unwrap_or(false); @@ -405,7 +406,8 @@ fn photomerge(s: &mut Session, p: &Value) -> Result { } let focal35 = p.get("focalLength").and_then(Value::as_f64).filter(|f| *f > 0.0).or_else(|| exif_focal(&srcs)); let images: Vec<(&Surface, Rect)> = srcs.iter().map(|s| (&s.surf, Rect::new(0, 0, s.w as i32, s.h as i32))).collect(); - let al = register(&images, layout, None, geometric, focal35).ok_or_else(|| EngineError::Other("Photomerge couldn't find enough matching detail between the images".into()))?; + let al = register(&images, layout, None, geometric, focal35) + .ok_or_else(|| EngineError::Other("Photomerge couldn't find enough matching detail between the images".into()))?; let t_reg = t0.ms() / 1000.0; let placed: Vec = (0..srcs.len()).filter(|&i| al.placements[i].is_some()).collect(); let failed: Vec = (0..srcs.len()).filter(|&i| al.placements[i].is_none()).map(|i| srcs[i].name.clone()).collect(); @@ -426,7 +428,18 @@ fn photomerge(s: &mut Session, p: &Value) -> Result { let offset = (-x0.floor(), -y0.floor()); let warped: Vec = { let jobs: Vec = placed.clone(); - jobs.iter().map(|&i| warp_placed(&srcs[i].surf, Rect::new(0, 0, srcs[i].w as i32, srcs[i].h as i32), (0.0, 0.0), al.placements[i].as_ref().expect("placed"), offset, Interp::Bicubic)).collect() + jobs.iter() + .map(|&i| { + warp_placed( + &srcs[i].surf, + Rect::new(0, 0, srcs[i].w as i32, srcs[i].h as i32), + (0.0, 0.0), + al.placements[i].as_ref().expect("placed"), + offset, + Interp::Bicubic, + ) + }) + .collect() }; let t_warp = t0.ms() / 1000.0; let mut doc = Document::new(format!("Untitled_Panorama{}", s.documents().len() + 1), Size::new(cw as u32, ch as u32), fmt.mode, fmt.sample); @@ -621,7 +634,17 @@ fn merge_to_hdr(s: &mut Session, p: &Value) -> Result { return Err(bad(cmd, "`ghostBase` is an image index")); } let refs: Vec<&[[f32; 4]]> = imgs.iter().map(Vec::as_slice).collect(); - let merged = hdr::merge(w, h, &refs, &MergeOptions { exposures: exposures.clone(), remove_ghosts: p.get("removeGhosts").and_then(Value::as_bool).unwrap_or(false), ghost_base, response: None }); + let merged = hdr::merge( + w, + h, + &refs, + &MergeOptions { + exposures: exposures.clone(), + remove_ghosts: p.get("removeGhosts").and_then(Value::as_bool).unwrap_or(false), + ghost_base, + response: None, + }, + ); let mode = p.get("mode").and_then(|v| v.as_str().map(str::to_string).or_else(|| v.as_u64().map(|n| n.to_string()))).unwrap_or_else(|| "32".into()); let (depth, mut px) = match mode.as_str() { "32" => (SampleType::F32, merged.px.clone()), @@ -791,7 +814,12 @@ mod tests { z = z.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); (z >> 33) as f64 / (1u64 << 31) as f64 }; - let mut px: Vec<[f32; 4]> = (0..w * h).map(|i| { let x = (i % w) as f32 / w as f32; [0.3 + 0.2 * x, 0.35, 0.4 - 0.1 * x, 1.0] }).collect(); + let mut px: Vec<[f32; 4]> = (0..w * h) + .map(|i| { + let x = (i % w) as f32 / w as f32; + [0.3 + 0.2 * x, 0.35, 0.4 - 0.1 * x, 1.0] + }) + .collect(); for _ in 0..(w * h / 700) { let (cx, cy, r) = (rnd() * w as f64, rnd() * h as f64, 3.0 + rnd() * 9.0); let col = [rnd() as f32, rnd() as f32, rnd() as f32, 1.0]; @@ -821,7 +849,12 @@ mod tests { } fn crop(px: &[[f32; 4]], sw: usize, x0: usize, y0: usize, w: usize, h: usize, gain: f32) -> Vec<[f32; 4]> { - (0..w * h).map(|i| { let q = px[(y0 + i / w) * sw + x0 + i % w]; [q[0] * gain, q[1] * gain, q[2] * gain, 1.0] }).collect() + (0..w * h) + .map(|i| { + let q = px[(y0 + i / w) * sw + x0 + i % w]; + [q[0] * gain, q[1] * gain, q[2] * gain, 1.0] + }) + .collect() } #[test] @@ -903,12 +936,31 @@ mod tests { #[test] fn hdr_merge_from_synthetic_exposures() { let (w, h) = (96usize, 64usize); - let radiance: Vec<[f32; 3]> = (0..w * h).map(|i| { let x = (i % w) as f32 / w as f32; let b = 0.003 * 2f32.powf(x * 11.0) * (1.0 + 0.2 * ((i / w) as f32 * 0.3).sin()); [b, b * 0.9, b * 0.7] }).collect(); - let shoot = |dt: f32| -> Vec<[f32; 4]> { radiance.iter().map(|r| { let f = |v: f32| photocraft_algo_srgb((v * dt).min(1.0)); [f(r[0]), f(r[1]), f(r[2]), 1.0] }).collect() }; + let radiance: Vec<[f32; 3]> = (0..w * h) + .map(|i| { + let x = (i % w) as f32 / w as f32; + let b = 0.003 * 2f32.powf(x * 11.0) * (1.0 + 0.2 * ((i / w) as f32 * 0.3).sin()); + [b, b * 0.9, b * 0.7] + }) + .collect(); + let shoot = |dt: f32| -> Vec<[f32; 4]> { + radiance + .iter() + .map(|r| { + let f = |v: f32| photocraft_algo_srgb((v * dt).min(1.0)); + [f(r[0]), f(r[1]), f(r[2]), 1.0] + }) + .collect() + }; let mut s = Session::new(); for (k, dt) in [0.25f32, 1.0, 4.0].into_iter().enumerate() { let mut d = doc_from(&format!("e{k}"), &shoot(dt), w, h, SampleType::U8); - d.metadata.exif = Some(std::sync::Arc::new(exif::build(&exif::CameraInfo { exposure_time: Some(dt as f64 / 100.0), f_number: Some(8.0), iso: Some(100.0), ..Default::default() }))); + d.metadata.exif = Some(std::sync::Arc::new(exif::build(&exif::CameraInfo { + exposure_time: Some(dt as f64 / 100.0), + f_number: Some(8.0), + iso: Some(100.0), + ..Default::default() + }))); s.add_document(d, None); } let r = s.execute("file.automate.mergeToHdrPro", json!({"useOpenDocuments": true})).unwrap(); @@ -927,7 +979,9 @@ mod tests { s.close(out); // 8-bit tone-mapped output with explicit EVs. for method in ["localAdaptation", "exposureGamma", "highlightCompression", "equalizeHistogram"] { - let r = s.execute("file.automate.mergeToHdrPro", json!({"paths": [], "useOpenDocuments": true, "exposures": [0, 2, 4], "mode": "8", "method": method})).unwrap(); + let r = s + .execute("file.automate.mergeToHdrPro", json!({"paths": [], "useOpenDocuments": true, "exposures": [0, 2, 4], "mode": "8", "method": method})) + .unwrap(); assert_eq!(r["exposureSource"], "params"); let d = &s.active().unwrap().doc; assert_eq!(d.depth, SampleType::U8); diff --git a/crates/engine/src/pixels.rs b/crates/engine/src/pixels.rs index 11331b56d3..d6206b7021 100644 --- a/crates/engine/src/pixels.rs +++ b/crates/engine/src/pixels.rs @@ -120,12 +120,9 @@ pub fn remap_document(doc: &mut Document, map: &dyn Fn(i32, i32) -> (i32, i32)) /// Merge `upper` onto `lower` producing a raster layer (Layer → Merge Down). pub fn merge_down(doc_bounds: Rect, lower: &Layer, upper: &Layer, format: photocraft_color::PixelFormat) -> Layer { - let area = [lower, upper] - .iter() - .map(|l| l.surface().map(|s| s.content_bounds()).unwrap_or(doc_bounds)) - .fold(Rect::EMPTY, |a, b| a.union(&b)); + let area = [lower, upper].iter().map(|l| l.surface().map(|s| s.content_bounds()).unwrap_or(doc_bounds)).fold(Rect::EMPTY, |a, b| a.union(&b)); let stack = vec![lower.clone(), upper.clone()]; - let mut tmp = Document::new("merge", doc_bounds.size(), format.mode, format.sample); + let mut tmp = Document::new("merge", doc_bounds.size(), format.mode, format.sample); tmp.layers = stack; let buf = photocraft_compose::render(&tmp, area); let mut s = Surface::new(format); diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index e1bf108549..a7fe3ebe9e 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -228,7 +228,15 @@ pub struct Workspace { impl Default for Workspace { fn default() -> Self { - Self { auto_collapse_icon_panels: false, auto_show_hidden_panels: true, open_documents_as_tabs: true, enable_floating_document_window_docking: true, large_tabs: false, enable_narrow_options_bar: false, remember_workspace_changes: true } + Self { + auto_collapse_icon_panels: false, + auto_show_hidden_panels: true, + open_documents_as_tabs: true, + enable_floating_document_window_docking: true, + large_tabs: false, + enable_narrow_options_bar: false, + remember_workspace_changes: true, + } } } @@ -330,7 +338,13 @@ pub struct Export { impl Default for Export { fn default() -> Self { - Self { quick_export_format: QuickExportFormat::Png, quick_export_location: ExportLocation::Ask, jpeg_quality: 85, metadata: ExportMetadata::Copyright, convert_to_srgb: true } + Self { + quick_export_format: QuickExportFormat::Png, + quick_export_location: ExportLocation::Ask, + jpeg_quality: 85, + metadata: ExportMetadata::Copyright, + convert_to_srgb: true, + } } } @@ -354,7 +368,15 @@ pub struct Performance { impl Default for Performance { fn default() -> Self { - Self { memory_usage_mb: 8192, history_states: 50, cache_levels: 4, cache_tile_size: 8192, use_gpu: true, effect_cache_mb: 768, legacy_compositing: false } + Self { + memory_usage_mb: 8192, + history_states: 50, + cache_levels: 4, + cache_tile_size: 8192, + use_gpu: true, + effect_cache_mb: 768, + legacy_compositing: false, + } } } @@ -391,7 +413,13 @@ pub struct Cursors { impl Default for Cursors { fn default() -> Self { - Self { painting: PaintingCursor::NormalTip, show_crosshair_in_brush_tip: false, show_only_crosshair_while_painting: false, other: OtherCursor::Standard, brush_preview_color: "#ff0000".into() } + Self { + painting: PaintingCursor::NormalTip, + show_crosshair_in_brush_tip: false, + show_only_crosshair_while_painting: false, + other: OtherCursor::Standard, + brush_preview_color: "#ff0000".into(), + } } } @@ -410,7 +438,14 @@ pub struct TransparencyAndGamut { impl Default for TransparencyAndGamut { fn default() -> Self { - Self { grid_size: CheckerSize::Medium, grid_colors: CheckerColors::Light, custom_light: "#ffffff".into(), custom_dark: "#cccccc".into(), gamut_warning_color: "#808080".into(), gamut_warning_opacity: 100 } + Self { + grid_size: CheckerSize::Medium, + grid_colors: CheckerColors::Light, + custom_light: "#ffffff".into(), + custom_dark: "#cccccc".into(), + gamut_warning_color: "#808080".into(), + gamut_warning_opacity: 100, + } } } @@ -454,7 +489,15 @@ pub struct UnitsAndRulers { impl Default for UnitsAndRulers { fn default() -> Self { - Self { rulers: Unit::Pixels, type_units: TypeUnit::Points, column_width: 180.0, gutter: 12.0, print_resolution: 300.0, screen_resolution: 72.0, point_size: PointSize::PostScript } + Self { + rulers: Unit::Pixels, + type_units: TypeUnit::Points, + column_width: 180.0, + gutter: 12.0, + print_resolution: 300.0, + screen_resolution: 72.0, + point_size: PointSize::PostScript, + } } } @@ -536,7 +579,16 @@ pub struct TypePrefs { impl Default for TypePrefs { fn default() -> Self { - Self { smart_quotes: true, missing_glyph_protection: true, show_font_names_in_english: true, use_esc_to_commit: true, text_engine: TextEngine::WorldReady, font_preview: FontPreview::Medium, fill_new_type_layers_with_placeholder: true, recent_fonts: 10 } + Self { + smart_quotes: true, + missing_glyph_protection: true, + show_font_names_in_english: true, + use_esc_to_commit: true, + text_engine: TextEngine::WorldReady, + font_preview: FontPreview::Medium, + fill_new_type_layers_with_placeholder: true, + recent_fonts: 10, + } } } @@ -568,7 +620,14 @@ pub struct RawDefaults { impl Default for RawDefaults { fn default() -> Self { - Self { color_space: RawColorSpace::AdobeRgb, bit_depth: RawDepth::Sixteen, resolution: 300.0, sharpen_for: RawSharpen::None, open_as_smart_object: false, apply_auto_tone: false } + Self { + color_space: RawColorSpace::AdobeRgb, + bit_depth: RawDepth::Sixteen, + resolution: 300.0, + sharpen_for: RawSharpen::None, + open_as_smart_object: false, + apply_auto_tone: false, + } } } @@ -725,7 +784,18 @@ pub fn is_color(path: &str) -> bool { return false; } let leaf = path.rsplit('.').next().unwrap_or(path); - matches!(leaf, "guideColor" | "smartGuideColor" | "gridColor" | "sliceColor" | "canvasCustomColor" | "brushPreviewColor" | "gamutWarningColor" | "customLight" | "customDark") + matches!( + leaf, + "guideColor" + | "smartGuideColor" + | "gridColor" + | "sliceColor" + | "canvasCustomColor" + | "brushPreviewColor" + | "gamutWarningColor" + | "customLight" + | "customDark" + ) } pub fn parse_hex(s: &str) -> Option<[u8; 3]> { @@ -930,7 +1000,11 @@ fn capitalize(s: &str) -> String { Some(f) if f.is_ascii_alphabetic() => { let rest: String = c.collect(); // Function keys stay upper-case (F1…F24); named keys are Title case. - if f.eq_ignore_ascii_case(&'f') && rest.chars().all(|c| c.is_ascii_digit()) && !rest.is_empty() { format!("F{rest}") } else { format!("{}{}", f.to_ascii_uppercase(), rest.to_ascii_lowercase()) } + if f.eq_ignore_ascii_case(&'f') && rest.chars().all(|c| c.is_ascii_digit()) && !rest.is_empty() { + format!("F{rest}") + } else { + format!("{}{}", f.to_ascii_uppercase(), rest.to_ascii_lowercase()) + } } _ => s.to_string(), } @@ -1195,7 +1269,9 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result { /// Edit › Menus: hide/show items and give them colours. fn menus(s: &mut Session, p: &Value) -> Result { - let ids = |k: &str| -> Vec { p.get(k).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default() }; + let ids = |k: &str| -> Vec { + p.get(k).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default() + }; const COLORS: [&str; 8] = ["none", "red", "orange", "yellow", "green", "blue", "violet", "gray"]; if let Some(m) = p.get("color").and_then(Value::as_object) { for v in m.values() { @@ -1267,8 +1343,24 @@ fn section_of(id: &str) -> &str { /// Preference command specs. pub fn specs() -> Vec { vec![ - spec!("prefs.get", "Get Preferences", [], None, r##"{"path":"section.key"?=everything (e.g. "performance.historyStates", "colorSettings.workingRgb")}"##, prefs_get, false), - spec!("prefs.set", "Set Preferences", [], None, r##"{"path":"section.key","value":json} or {"values":{"section.key":json,…}} (validated; all or nothing)"##, prefs_set, false), + spec!( + "prefs.get", + "Get Preferences", + [], + None, + r##"{"path":"section.key"?=everything (e.g. "performance.historyStates", "colorSettings.workingRgb")}"##, + prefs_get, + false + ), + spec!( + "prefs.set", + "Set Preferences", + [], + None, + r##"{"path":"section.key","value":json} or {"values":{"section.key":json,…}} (validated; all or nothing)"##, + prefs_set, + false + ), spec!("prefs.reset", "Reset Preferences", [], None, r##"{"path":"section|section.key"?=everything}"##, prefs_reset, false), section!("edit.preferences.general", "General…"), section!("edit.preferences.interface", "Interface…"), @@ -1297,7 +1389,15 @@ pub fn specs() -> Vec { keyboard_shortcuts, true ), - spec!("edit.menus", "Menus…", ["Edit"], Some("Cmd+Alt+Shift+M"), r##"{"hide":["",…]?,"show":["",…]?,"color":{"":"red|orange|yellow|green|blue|violet|gray|none"}?,"reset":bool=false}"##, menus, true), + spec!( + "edit.menus", + "Menus…", + ["Edit"], + Some("Cmd+Alt+Shift+M"), + r##"{"hide":["",…]?,"show":["",…]?,"color":{"":"red|orange|yellow|green|blue|violet|gray|none"}?,"reset":bool=false}"##, + menus, + true + ), spec!("edit.toolbar", "Toolbar…", ["Edit"], None, r##"{"hidden":["",…]?,"order":["",…]?,"reset":bool=false}"##, toolbar, true), ] } diff --git a/crates/engine/src/prefs/tests.rs b/crates/engine/src/prefs/tests.rs index 342c977a1e..71d9555414 100644 --- a/crates/engine/src/prefs/tests.rs +++ b/crates/engine/src/prefs/tests.rs @@ -80,7 +80,8 @@ fn whole_section_set_merges_keys() { #[test] fn json_round_trip_tolerates_unknown_and_missing_keys() { let mut s = session(); - s.execute("prefs.set", json!({"values": {"interface.theme": "studioLight", "fileHandling.autosaveMinutes": 5, "shortcuts.edit.fill": "Cmd+Shift+F"}})).unwrap(); + s.execute("prefs.set", json!({"values": {"interface.theme": "studioLight", "fileHandling.autosaveMinutes": 5, "shortcuts.edit.fill": "Cmd+Shift+F"}})) + .unwrap(); s.execute("edit.colorSettings", json!({"workingRgb": "display-p3", "policyRgb": "convert"})).unwrap(); let text = s.prefs_to_json(); let mut t = Session::new(); diff --git a/crates/engine/src/presets/clone_source.rs b/crates/engine/src/presets/clone_source.rs index 69cc3eaaeb..27f79c144f 100644 --- a/crates/engine/src/presets/clone_source.rs +++ b/crates/engine/src/presets/clone_source.rs @@ -43,7 +43,11 @@ impl Default for CloneSource { impl CloneSource { pub fn is_identity(&self) -> bool { - (self.scale[0] - 100.0).abs() < 1e-9 && (self.scale[1] - 100.0).abs() < 1e-9 && self.rotation.rem_euclid(360.0).abs() < 1e-9 && !self.flip_h && !self.flip_v + (self.scale[0] - 100.0).abs() < 1e-9 + && (self.scale[1] - 100.0).abs() < 1e-9 + && self.rotation.rem_euclid(360.0).abs() < 1e-9 + && !self.flip_h + && !self.flip_v } /// The panel's Offset X/Y: source − anchor (once a stroke paired them). pub fn offset(&self) -> Option<[f64; 2]> { @@ -149,7 +153,10 @@ pub fn mapping(s: &mut Session, p: &Value, first: (f64, f64), cmd: &str) -> Resu let slot = s.presets.clone.active().clone(); let explicit_tf = ["scale", "rotation", "flipH", "flipV"].iter().any(|k| p.get(*k).is_some()); let m = if explicit_tf { - let sc = p.get("scale").and_then(Value::as_array).map_or([100.0, 100.0], |a| [a.first().and_then(Value::as_f64).unwrap_or(100.0), a.get(1).and_then(Value::as_f64).unwrap_or(100.0)]); + let sc = p + .get("scale") + .and_then(Value::as_array) + .map_or([100.0, 100.0], |a| [a.first().and_then(Value::as_f64).unwrap_or(100.0), a.get(1).and_then(Value::as_f64).unwrap_or(100.0)]); let flag = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(false); transform_matrix(sc, p.get("rotation").and_then(Value::as_f64).unwrap_or(0.0), flag("flipH"), flag("flipV")) } else { @@ -236,7 +243,9 @@ fn index(s: &Session, p: &Value, cmd: &str) -> Result { fn list(s: &mut Session, _: &Value) -> Result { let c = &s.presets.clone; - Ok(json!({"active": c.active, "sources": c.slots.iter().enumerate().map(|(i, x)| x.to_json(i)).collect::>(), "overlay": serde_json::to_value(&c.overlay).unwrap_or(Value::Null)})) + Ok( + json!({"active": c.active, "sources": c.slots.iter().enumerate().map(|(i, x)| x.to_json(i)).collect::>(), "overlay": serde_json::to_value(&c.overlay).unwrap_or(Value::Null)}), + ) } fn select(s: &mut Session, p: &Value) -> Result { @@ -333,8 +342,26 @@ fn overlay(s: &mut Session, p: &Value) -> Result { pub fn specs() -> Vec { vec![ - CommandSpec { id: "cloneSource.list", label: "Clone Sources", menu: &[], shortcut: None, params: "{} → {active,sources:[{index,source,anchor,offset,layer,width,height,rotation,flipH,flipV}],overlay}", enabled: always, run: list, journal: false }, - CommandSpec { id: "cloneSource.select", label: "Select Clone Source", menu: &[], shortcut: None, params: r##"{"index":0..4}"##, enabled: always, run: select, journal: true }, + CommandSpec { + id: "cloneSource.list", + label: "Clone Sources", + menu: &[], + shortcut: None, + params: "{} → {active,sources:[{index,source,anchor,offset,layer,width,height,rotation,flipH,flipV}],overlay}", + enabled: always, + run: list, + journal: false, + }, + CommandSpec { + id: "cloneSource.select", + label: "Select Clone Source", + menu: &[], + shortcut: None, + params: r##"{"index":0..4}"##, + enabled: always, + run: select, + journal: true, + }, CommandSpec { id: "cloneSource.set", label: "Set Clone Source", @@ -345,7 +372,25 @@ pub fn specs() -> Vec { run: set, journal: true, }, - CommandSpec { id: "cloneSource.resetTransform", label: "Reset Transform", menu: &[], shortcut: None, params: r##"{"index":0..4?=active}"##, enabled: always, run: reset_transform, journal: true }, - CommandSpec { id: "cloneSource.overlay", label: "Clone Source Overlay", menu: &[], shortcut: None, params: r##"{"show":bool?,"opacity":0..100?,"clipped":bool?,"autoHide":bool?,"invert":bool?,"blend":"normal|darken|lighten|difference"?}"##, enabled: always, run: overlay, journal: true }, + CommandSpec { + id: "cloneSource.resetTransform", + label: "Reset Transform", + menu: &[], + shortcut: None, + params: r##"{"index":0..4?=active}"##, + enabled: always, + run: reset_transform, + journal: true, + }, + CommandSpec { + id: "cloneSource.overlay", + label: "Clone Source Overlay", + menu: &[], + shortcut: None, + params: r##"{"show":bool?,"opacity":0..100?,"clipped":bool?,"autoHide":bool?,"invert":bool?,"blend":"normal|darken|lighten|difference"?}"##, + enabled: always, + run: overlay, + journal: true, + }, ] } diff --git a/crates/engine/src/presets/gradients.rs b/crates/engine/src/presets/gradients.rs index 62553456ae..89bcea48c8 100644 --- a/crates/engine/src/presets/gradients.rs +++ b/crates/engine/src/presets/gradients.rs @@ -75,7 +75,11 @@ fn sample(stops: &[(f32, V)], t: f32, mix: impl Fn(V, V, f32) -> V) -> impl GradientPreset { pub fn new(name: &str, colors: &[&str]) -> Self { let n = colors.len().max(2) - 1; - GradientPreset { name: name.into(), stops: colors.iter().enumerate().map(|(i, c)| (i as f32 / n as f32, StopColor::Rgb(hex(c)))).collect(), opacity: Vec::new() } + GradientPreset { + name: name.into(), + stops: colors.iter().enumerate().map(|(i, c)| (i as f32 / n as f32, StopColor::Rgb(hex(c)))).collect(), + opacity: Vec::new(), + } } pub fn foreground_to_background() -> Self { GradientPreset { name: "Foreground to Background".into(), stops: vec![(0.0, StopColor::Foreground), (1.0, StopColor::Background)], opacity: Vec::new() } @@ -173,18 +177,128 @@ impl GradientPreset { /// Built-in groups, laid out like Photoshop's default Gradients panel (Basics, then folders by /// hue). The colours are our own. pub fn builtin() -> Vec> { - let fam = |prefix: &str, sets: &[&[&str]]| -> Vec { sets.iter().enumerate().map(|(i, c)| GradientPreset::new(&format!("{prefix} {:02}", i + 1), c)).collect() }; - let fg_transparent = GradientPreset { name: "Foreground to Transparent".into(), stops: vec![(0.0, StopColor::Foreground), (1.0, StopColor::Foreground)], opacity: vec![(0.0, 1.0), (1.0, 0.0)] }; + let fam = |prefix: &str, sets: &[&[&str]]| -> Vec { + sets.iter().enumerate().map(|(i, c)| GradientPreset::new(&format!("{prefix} {:02}", i + 1), c)).collect() + }; + let fg_transparent = GradientPreset { + name: "Foreground to Transparent".into(), + stops: vec![(0.0, StopColor::Foreground), (1.0, StopColor::Foreground)], + opacity: vec![(0.0, 1.0), (1.0, 0.0)], + }; vec![ Group::new("Basics", vec![GradientPreset::foreground_to_background(), fg_transparent, GradientPreset::new("Black, White", &["#000000", "#ffffff"])]), - Group::new("Blues", fam("Blue", &[&["#0b3d91", "#4fa3e0"], &["#1c2a5a", "#3f6fd8", "#a8d4ff"], &["#00b4d8", "#03045e"], &["#5ec8f2", "#e6f6ff"], &["#253b80", "#20b2aa"], &["#89c2ff", "#1d4ed8", "#0a1a40"]])), - Group::new("Purples", fam("Purple", &[&["#3c096c", "#c77dff"], &["#5a189a", "#e0aaff"], &["#240046", "#7b2cbf", "#f3d9ff"], &["#9d4edd", "#4361ee"], &["#6a0572", "#ab83a1"], &["#2d0b59", "#ff6fd8"]])), - Group::new("Pinks", fam("Pink", &[&["#ff4d8d", "#ffd1e1"], &["#c9184a", "#ff8fab"], &["#ff70a6", "#ffd670"], &["#8e2c5a", "#f7a1c4", "#fff0f5"], &["#ff5fa2", "#a855f7"], &["#ffc2d6", "#ff2e63"]])), - Group::new("Reds", fam("Red", &[&["#7f0000", "#ff3b30"], &["#d00000", "#ffba08"], &["#370617", "#9d0208", "#f48c06"], &["#e5383b", "#f5f3f4"], &["#a4161a", "#0b090a"], &["#ff595e", "#ffca3a"]])), - Group::new("Oranges", fam("Orange", &[&["#ff6d00", "#ffd60a"], &["#e85d04", "#ffba08"], &["#9c3d0b", "#ff9e40", "#fff1d6"], &["#ff7b00", "#ff0054"], &["#fb8500", "#023047"], &["#ffb703", "#fb5607"]])), - Group::new("Greens", fam("Green", &[&["#004b23", "#70e000"], &["#2d6a4f", "#b7e4c7"], &["#1b4332", "#40916c", "#d8f3dc"], &["#38b000", "#ccff33"], &["#0b6e4f", "#08a0a8"], &["#606c38", "#dda15e"]])), - Group::new("Browns", fam("Brown", &[&["#3e2412", "#a47148"], &["#6f4518", "#e6ccb2"], &["#2b1708", "#7f5539", "#ddb892"], &["#8b5e34", "#ffd8a8"], &["#582f0e", "#b6ad90"], &["#4a3728", "#c08552", "#f3e9dc"]])), - Group::new("Grays", fam("Gray", &[&["#212529", "#f8f9fa"], &["#495057", "#dee2e6"], &["#000000", "#6c757d", "#ffffff"], &["#343a40", "#adb5bd", "#343a40"], &["#8d99ae", "#2b2d42"], &["#e9ecef", "#ced4da", "#868e96"]])), + Group::new( + "Blues", + fam( + "Blue", + &[ + &["#0b3d91", "#4fa3e0"], + &["#1c2a5a", "#3f6fd8", "#a8d4ff"], + &["#00b4d8", "#03045e"], + &["#5ec8f2", "#e6f6ff"], + &["#253b80", "#20b2aa"], + &["#89c2ff", "#1d4ed8", "#0a1a40"], + ], + ), + ), + Group::new( + "Purples", + fam( + "Purple", + &[ + &["#3c096c", "#c77dff"], + &["#5a189a", "#e0aaff"], + &["#240046", "#7b2cbf", "#f3d9ff"], + &["#9d4edd", "#4361ee"], + &["#6a0572", "#ab83a1"], + &["#2d0b59", "#ff6fd8"], + ], + ), + ), + Group::new( + "Pinks", + fam( + "Pink", + &[ + &["#ff4d8d", "#ffd1e1"], + &["#c9184a", "#ff8fab"], + &["#ff70a6", "#ffd670"], + &["#8e2c5a", "#f7a1c4", "#fff0f5"], + &["#ff5fa2", "#a855f7"], + &["#ffc2d6", "#ff2e63"], + ], + ), + ), + Group::new( + "Reds", + fam( + "Red", + &[ + &["#7f0000", "#ff3b30"], + &["#d00000", "#ffba08"], + &["#370617", "#9d0208", "#f48c06"], + &["#e5383b", "#f5f3f4"], + &["#a4161a", "#0b090a"], + &["#ff595e", "#ffca3a"], + ], + ), + ), + Group::new( + "Oranges", + fam( + "Orange", + &[ + &["#ff6d00", "#ffd60a"], + &["#e85d04", "#ffba08"], + &["#9c3d0b", "#ff9e40", "#fff1d6"], + &["#ff7b00", "#ff0054"], + &["#fb8500", "#023047"], + &["#ffb703", "#fb5607"], + ], + ), + ), + Group::new( + "Greens", + fam( + "Green", + &[ + &["#004b23", "#70e000"], + &["#2d6a4f", "#b7e4c7"], + &["#1b4332", "#40916c", "#d8f3dc"], + &["#38b000", "#ccff33"], + &["#0b6e4f", "#08a0a8"], + &["#606c38", "#dda15e"], + ], + ), + ), + Group::new( + "Browns", + fam( + "Brown", + &[ + &["#3e2412", "#a47148"], + &["#6f4518", "#e6ccb2"], + &["#2b1708", "#7f5539", "#ddb892"], + &["#8b5e34", "#ffd8a8"], + &["#582f0e", "#b6ad90"], + &["#4a3728", "#c08552", "#f3e9dc"], + ], + ), + ), + Group::new( + "Grays", + fam( + "Gray", + &[ + &["#212529", "#f8f9fa"], + &["#495057", "#dee2e6"], + &["#000000", "#6c757d", "#ffffff"], + &["#343a40", "#adb5bd", "#343a40"], + &["#8d99ae", "#2b2d42"], + &["#e9ecef", "#ced4da", "#868e96"], + ], + ), + ), ] } @@ -216,7 +330,8 @@ pub fn tool_stops(s: &Session, p: &Value) -> Result> { /// The preset named by `"preset"` (else the current gradient), or explicit `"stops"`. fn preset_param(s: &Session, p: &Value, cmd: &str) -> Result { if let Some(name) = str_param(p, "preset") { - let (gi, ii) = find(&s.presets.gradients, name, str_param(p, "group")).ok_or_else(|| bad(cmd, format!("no gradient preset \"{name}\" (see gradient.presets.list)")))?; + let (gi, ii) = find(&s.presets.gradients, name, str_param(p, "group")) + .ok_or_else(|| bad(cmd, format!("no gradient preset \"{name}\" (see gradient.presets.list)")))?; return Ok(s.presets.gradients[gi].items[ii].clone()); } if p.get("stops").is_some() { @@ -226,7 +341,8 @@ fn preset_param(s: &Session, p: &Value, cmd: &str) -> Result { } fn list(s: &mut Session, _: &Value) -> Result { - let groups: Vec = s.presets.gradients.iter().map(|g| json!({"name": g.name, "presets": g.items.iter().map(GradientPreset::to_json).collect::>()})).collect(); + let groups: Vec = + s.presets.gradients.iter().map(|g| json!({"name": g.name, "presets": g.items.iter().map(GradientPreset::to_json).collect::>()})).collect(); Ok(json!({"groups": groups, "current": s.presets.gradient.to_json()})) } @@ -318,13 +434,24 @@ pub fn specs() -> Vec { let stops = r##""stops":[[t 0..1,"#rrggbb"|"foreground"|"background"],…],"transparency":[[t,opacity 0..100],…]?"##; let leak = |s: String| -> &'static str { Box::leak(s.into_boxed_str()) }; vec![ - CommandSpec { id: "gradient.presets.list", label: "Gradient Presets", menu: &[], shortcut: None, params: "{} → {groups:[{name,presets:[{name,stops,transparency}]}],current}", enabled: always, run: list, journal: false }, + CommandSpec { + id: "gradient.presets.list", + label: "Gradient Presets", + menu: &[], + shortcut: None, + params: "{} → {groups:[{name,presets:[{name,stops,transparency}]}],current}", + enabled: always, + run: list, + journal: false, + }, CommandSpec { id: CMD_SELECT, label: "Select Gradient", menu: &[], shortcut: None, - params: leak(format!(r##"{{"preset":name|{stops},"group":name?,"applyToLayer":bool=true (also recolours a selected Gradient Fill layer)}} → {{current,layer?}}. The Gradient tool paints with it."##)), + params: leak(format!( + r##"{{"preset":name|{stops},"group":name?,"applyToLayer":bool=true (also recolours a selected Gradient Fill layer)}} → {{current,layer?}}. The Gradient tool paints with it."## + )), enabled: always, run: select, journal: true, @@ -334,13 +461,42 @@ pub fn specs() -> Vec { label: "New Gradient Fill Layer from Preset", menu: &[], shortcut: None, - params: leak(format!(r##"{{"preset":name?=current|{stops},"angle":deg=90,"style":"linear|radial|angle|reflected|diamond"="linear","scale":10..150=100,"reverse":bool}} → {{layer}}"##)), + params: leak(format!( + r##"{{"preset":name?=current|{stops},"angle":deg=90,"style":"linear|radial|angle|reflected|diamond"="linear","scale":10..150=100,"reverse":bool}} → {{layer}}"## + )), enabled: super::has_doc, run: apply, journal: true, }, - CommandSpec { id: "gradient.presets.new", label: "New Gradient Preset", menu: &[], shortcut: None, params: leak(format!(r##"{{"name":str="Custom","group":name?=first,{stops} (default: the current gradient)}}"##)), enabled: always, run: new_preset, journal: true }, - CommandSpec { id: "gradient.presets.edit", label: "Edit Gradient Presets", menu: &[], shortcut: None, params: super::GROUP_EDIT_PARAMS, enabled: always, run: edit, journal: true }, - CommandSpec { id: "gradient.presets.reset", label: "Restore Default Gradients", menu: &[], shortcut: None, params: r##"{"append":bool=false}"##, enabled: always, run: reset, journal: true }, + CommandSpec { + id: "gradient.presets.new", + label: "New Gradient Preset", + menu: &[], + shortcut: None, + params: leak(format!(r##"{{"name":str="Custom","group":name?=first,{stops} (default: the current gradient)}}"##)), + enabled: always, + run: new_preset, + journal: true, + }, + CommandSpec { + id: "gradient.presets.edit", + label: "Edit Gradient Presets", + menu: &[], + shortcut: None, + params: super::GROUP_EDIT_PARAMS, + enabled: always, + run: edit, + journal: true, + }, + CommandSpec { + id: "gradient.presets.reset", + label: "Restore Default Gradients", + menu: &[], + shortcut: None, + params: r##"{"append":bool=false}"##, + enabled: always, + run: reset, + journal: true, + }, ] } diff --git a/crates/engine/src/presets/mod.rs b/crates/engine/src/presets/mod.rs index 90e2849ef0..a257866f4b 100644 --- a/crates/engine/src/presets/mod.rs +++ b/crates/engine/src/presets/mod.rs @@ -197,7 +197,11 @@ pub(crate) fn req_str<'a>(p: &'a Value, k: &str, cmd: &str) -> Result<&'a str> { /// Locate a preset by name (optionally within `group`): (group index, item index). pub(crate) fn find(groups: &[Group], name: &str, group: Option<&str>) -> Option<(usize, usize)> { - groups.iter().enumerate().filter(|(_, g)| group.is_none_or(|n| g.name == n)).find_map(|(gi, g)| g.items.iter().position(|i| i.name() == name).map(|ii| (gi, ii))) + groups + .iter() + .enumerate() + .filter(|(_, g)| group.is_none_or(|n| g.name == n)) + .find_map(|(gi, g)| g.items.iter().position(|i| i.name() == name).map(|ii| (gi, ii))) } /// `base`, or `base 2`, `base 3`… so names stay unique across all groups (Photoshop allows diff --git a/crates/engine/src/presets/patterns.rs b/crates/engine/src/presets/patterns.rs index 589fa09a31..f4ecb15c3c 100644 --- a/crates/engine/src/presets/patterns.rs +++ b/crates/engine/src/presets/patterns.rs @@ -47,7 +47,12 @@ fn list(s: &mut Session, _: &Value) -> Result { .pattern_groups .iter() .map(|g| { - let pats: Vec = g.items.iter().filter_map(|id| s.patterns.items.iter().find(|p| &p.id == id)).map(|p| json!({"id": p.id, "name": p.display_name(), "width": p.width, "height": p.height})).collect(); + let pats: Vec = g + .items + .iter() + .filter_map(|id| s.patterns.items.iter().find(|p| &p.id == id)) + .map(|p| json!({"id": p.id, "name": p.display_name(), "width": p.width, "height": p.height})) + .collect(); json!({"name": g.name, "patterns": pats}) }) .collect(); @@ -152,7 +157,16 @@ fn edit(s: &mut Session, p: &Value) -> Result { pub fn specs() -> Vec { vec![ - CommandSpec { id: "pattern.presets.list", label: "Pattern Presets", menu: &[], shortcut: None, params: "{} → {groups:[{name,patterns:[{id,name,width,height}]}],current}", enabled: always, run: list, journal: false }, + CommandSpec { + id: "pattern.presets.list", + label: "Pattern Presets", + menu: &[], + shortcut: None, + params: "{} → {groups:[{name,patterns:[{id,name,width,height}]}],current}", + enabled: always, + run: list, + journal: false, + }, CommandSpec { id: "pattern.presets.select", label: "Select Pattern", @@ -163,8 +177,35 @@ pub fn specs() -> Vec { run: select, journal: true, }, - CommandSpec { id: "pattern.presets.apply", label: "New Pattern Fill Layer from Preset", menu: &[], shortcut: None, params: r##"{"pattern":id|name?=selected,"scale":1..1000=100,"angle":deg=0} → {layer}"##, enabled: super::has_doc, run: apply, journal: true }, - CommandSpec { id: "pattern.presets.new", label: "New Pattern Preset", menu: &[], shortcut: None, params: r##"{"name":str?,"group":name?,"rect":[x0,y0,x1,y1]?} (Define Pattern from the selection/canvas into a group)"##, enabled: super::has_doc, run: new_preset, journal: true }, - CommandSpec { id: "pattern.presets.edit", label: "Edit Pattern Presets", menu: &[], shortcut: None, params: super::GROUP_EDIT_PARAMS, enabled: always, run: edit, journal: true }, + CommandSpec { + id: "pattern.presets.apply", + label: "New Pattern Fill Layer from Preset", + menu: &[], + shortcut: None, + params: r##"{"pattern":id|name?=selected,"scale":1..1000=100,"angle":deg=0} → {layer}"##, + enabled: super::has_doc, + run: apply, + journal: true, + }, + CommandSpec { + id: "pattern.presets.new", + label: "New Pattern Preset", + menu: &[], + shortcut: None, + params: r##"{"name":str?,"group":name?,"rect":[x0,y0,x1,y1]?} (Define Pattern from the selection/canvas into a group)"##, + enabled: super::has_doc, + run: new_preset, + journal: true, + }, + CommandSpec { + id: "pattern.presets.edit", + label: "Edit Pattern Presets", + menu: &[], + shortcut: None, + params: super::GROUP_EDIT_PARAMS, + enabled: always, + run: edit, + journal: true, + }, ] } diff --git a/crates/engine/src/presets/shapes.rs b/crates/engine/src/presets/shapes.rs index a84439e75d..a60f502e7a 100644 --- a/crates/engine/src/presets/shapes.rs +++ b/crates/engine/src/presets/shapes.rs @@ -107,7 +107,11 @@ pub fn parse(src: &str) -> std::result::Result { } else { let q = Point::new(num(&mut i)?, num(&mut i)?); let p = Point::new(num(&mut i)?, num(&mut i)?); - (Point::new(last.x + (q.x - last.x) * 2.0 / 3.0, last.y + (q.y - last.y) * 2.0 / 3.0), Point::new(p.x + (q.x - p.x) * 2.0 / 3.0, p.y + (q.y - p.y) * 2.0 / 3.0), p) + ( + Point::new(last.x + (q.x - last.x) * 2.0 / 3.0, last.y + (q.y - last.y) * 2.0 / 3.0), + Point::new(p.x + (q.x - p.x) * 2.0 / 3.0, p.y + (q.y - p.y) * 2.0 / 3.0), + p, + ) }; s.knots.last_mut().expect("non-empty").out_ctrl = c1; s.knots.push(Knot { anchor: p, in_ctrl: c2, out_ctrl: p, smooth: false }); @@ -176,7 +180,9 @@ fn shout() -> String { /// Built-in groups (our own drawings). pub fn builtin() -> Vec> { - let mk = |list: Vec<(&str, String)>| -> Vec { list.into_iter().filter_map(|(n, src)| parse(&src).ok().map(|path| ShapePreset { name: n.into(), path })).collect() }; + let mk = |list: Vec<(&str, String)>| -> Vec { + list.into_iter().filter_map(|(n, src)| parse(&src).ok().map(|path| ShapePreset { name: n.into(), path })).collect() + }; let s = |x: &str| x.to_string(); vec![ Group::new( @@ -209,7 +215,12 @@ pub fn builtin() -> Vec> { Group::new( "Speech Bubbles", mk(vec![ - ("Speech Bubble", s("M 15 5 L 85 5 C 93 5 100 12 100 20 L 100 60 C 100 68 93 75 85 75 L 45 75 L 20 98 L 25 75 L 15 75 C 7 75 0 68 0 60 L 0 20 C 0 12 7 5 15 5 Z")), + ( + "Speech Bubble", + s( + "M 15 5 L 85 5 C 93 5 100 12 100 20 L 100 60 C 100 68 93 75 85 75 L 45 75 L 20 98 L 25 75 L 15 75 C 7 75 0 68 0 60 L 0 20 C 0 12 7 5 15 5 Z", + ), + ), ("Round Bubble", s("M 50 5 C 78 5 100 22 100 42 C 100 62 78 79 50 79 C 44 79 38 78 33 77 L 12 95 L 18 72 C 7 65 0 54 0 42 C 0 22 22 5 50 5 Z")), ("Thought Bubble", s("M 50 5 C 78 5 100 20 100 38 C 100 56 78 71 50 71 C 22 71 0 56 0 38 C 0 20 22 5 50 5 Z O 22 82 8 O 9 95 5")), ("Shout Bubble", shout()), @@ -218,10 +229,18 @@ pub fn builtin() -> Vec> { Group::new( "Nature", mk(vec![ - ("Leaf", s("M 50 0 C 80 20 95 50 80 75 C 72 88 60 92 53 92 L 53 100 L 47 100 L 47 92 C 40 92 28 88 20 75 C 5 50 20 20 50 0 Z ! M 48.5 22 L 51.5 22 L 51.5 88 L 48.5 88 Z")), + ( + "Leaf", + s( + "M 50 0 C 80 20 95 50 80 75 C 72 88 60 92 53 92 L 53 100 L 47 100 L 47 92 C 40 92 28 88 20 75 C 5 50 20 20 50 0 Z ! M 48.5 22 L 51.5 22 L 51.5 88 L 48.5 88 Z", + ), + ), ("Sun", sun()), ("Crescent Moon", s("O 45 50 45 ! O 66 40 38")), - ("Cloud", s("M 25 80 C 10 80 0 70 0 58 C 0 46 10 37 22 37 C 24 20 38 10 54 10 C 70 10 82 20 85 34 C 95 35 100 45 100 57 C 100 70 90 80 78 80 Z")), + ( + "Cloud", + s("M 25 80 C 10 80 0 70 0 58 C 0 46 10 37 22 37 C 24 20 38 10 54 10 C 70 10 82 20 85 34 C 95 35 100 45 100 57 C 100 70 90 80 78 80 Z"), + ), ("Raindrop", s("M 50 0 C 60 25 85 45 85 68 C 85 87 69 100 50 100 C 31 100 15 87 15 68 C 15 45 40 25 50 0 Z")), ("Flower", flower()), ("Tree", s("M 50 0 L 85 45 L 68 45 L 92 75 L 56 75 L 56 100 L 44 100 L 44 75 L 8 75 L 32 45 L 15 45 Z")), @@ -233,8 +252,18 @@ pub fn builtin() -> Vec> { ("Fish", s("M 0 50 C 15 25 45 15 70 32 L 96 12 L 88 50 L 96 88 L 70 68 C 45 85 15 75 0 50 Z ! O 20 45 4")), ("Cat", s("M 15 10 L 35 30 C 45 27 55 27 65 30 L 85 10 L 88 50 C 92 75 72 95 50 95 C 28 95 8 75 12 50 Z ! O 36 58 6 ! O 64 58 6")), ("Bird", s("M 0 40 C 15 30 35 32 50 50 C 65 32 85 30 100 40 C 85 40 65 48 50 70 C 35 48 15 40 0 40 Z")), - ("Butterfly", s("M 50 30 C 40 5 5 0 5 25 C 5 45 30 50 45 50 C 25 55 10 75 25 90 C 38 100 48 80 50 65 C 52 80 62 100 75 90 C 90 75 75 55 55 50 C 70 50 95 45 95 25 C 95 0 60 5 50 30 Z")), - ("Paw Print", s("M 50 50 C 68 50 82 66 82 80 C 82 92 70 96 60 94 C 55 93 52 91 50 91 C 48 91 45 93 40 94 C 30 96 18 92 18 80 C 18 66 32 50 50 50 Z O 18 42 10 O 37 22 11 O 63 22 11 O 82 42 10")), + ( + "Butterfly", + s( + "M 50 30 C 40 5 5 0 5 25 C 5 45 30 50 45 50 C 25 55 10 75 25 90 C 38 100 48 80 50 65 C 52 80 62 100 75 90 C 90 75 75 55 55 50 C 70 50 95 45 95 25 C 95 0 60 5 50 30 Z", + ), + ), + ( + "Paw Print", + s( + "M 50 50 C 68 50 82 66 82 80 C 82 92 70 96 60 94 C 55 93 52 91 50 91 C 48 91 45 93 40 94 C 30 96 18 92 18 80 C 18 66 32 50 50 50 Z O 18 42 10 O 37 22 11 O 63 22 11 O 82 42 10", + ), + ), ("Rabbit", s("O 50 72 26 M 34 52 C 22 30 24 2 34 2 C 44 2 46 30 44 50 Z M 66 52 C 78 30 76 2 66 2 C 56 2 54 30 56 50 Z")), ]), ), @@ -334,7 +363,8 @@ fn new_preset(s: &mut Session, p: &Value) -> Result { Some(Value::String(src)) if src.trim_start().starts_with(['M', 'O', '!']) => parse(src).map_err(|e| bad(CMD, e))?, other => { let d = s.active().ok_or_else(|| bad(CMD, "no document (pass `path`)"))?; - crate::edit_menu_cmds::current_path(&d.doc, d.active_layer, other.and_then(Value::as_str)).ok_or_else(|| bad(CMD, "select a path or shape layer first"))? + crate::edit_menu_cmds::current_path(&d.doc, d.active_layer, other.and_then(Value::as_str)) + .ok_or_else(|| bad(CMD, "select a path or shape layer first"))? } }; if path.is_empty() { @@ -354,7 +384,11 @@ fn edit(s: &mut Session, p: &Value) -> Result { const CMD: &str = "shape.presets.edit"; let action = req_str(p, "action", CMD)?.to_string(); // Define Custom Shape results live in the edit state; rename/delete them there. - let custom = |s: &Session, n: &str| str_param(p, "group").is_none_or(|g| g == CUSTOM_GROUP) && find(&s.presets.shapes, n, None).is_none() && s.edit_state.custom_shapes.iter().any(|c| c.name == n); + let custom = |s: &Session, n: &str| { + str_param(p, "group").is_none_or(|g| g == CUSTOM_GROUP) + && find(&s.presets.shapes, n, None).is_none() + && s.edit_state.custom_shapes.iter().any(|c| c.name == n) + }; let r = match action.as_str() { "rename" if custom(s, req_str(p, "preset", CMD)?) => { let (n, to) = (req_str(p, "preset", CMD)?, req_str(p, "name", CMD)?); @@ -390,7 +424,16 @@ fn reset(s: &mut Session, p: &Value) -> Result { pub fn specs() -> Vec { vec![ - CommandSpec { id: "shape.presets.list", label: "Shape Presets", menu: &[], shortcut: None, params: "{} → {groups:[{name,shapes:[{name,subpaths}]}]}", enabled: always, run: list, journal: false }, + CommandSpec { + id: "shape.presets.list", + label: "Shape Presets", + menu: &[], + shortcut: None, + params: "{} → {groups:[{name,shapes:[{name,subpaths}]}]}", + enabled: always, + run: list, + journal: false, + }, CommandSpec { id: "shape.presets.place", label: "Place Custom Shape", @@ -411,7 +454,25 @@ pub fn specs() -> Vec { run: new_preset, journal: true, }, - CommandSpec { id: "shape.presets.edit", label: "Edit Shape Presets", menu: &[], shortcut: None, params: super::GROUP_EDIT_PARAMS, enabled: always, run: edit, journal: true }, - CommandSpec { id: "shape.presets.reset", label: "Restore Default Shapes", menu: &[], shortcut: None, params: r##"{"append":bool=false}"##, enabled: always, run: reset, journal: true }, + CommandSpec { + id: "shape.presets.edit", + label: "Edit Shape Presets", + menu: &[], + shortcut: None, + params: super::GROUP_EDIT_PARAMS, + enabled: always, + run: edit, + journal: true, + }, + CommandSpec { + id: "shape.presets.reset", + label: "Restore Default Shapes", + menu: &[], + shortcut: None, + params: r##"{"append":bool=false}"##, + enabled: always, + run: reset, + journal: true, + }, ] } diff --git a/crates/engine/src/presets/styles.rs b/crates/engine/src/presets/styles.rs index a392caf0f4..c028fcb040 100644 --- a/crates/engine/src/presets/styles.rs +++ b/crates/engine/src/presets/styles.rs @@ -39,7 +39,10 @@ impl Named for StylePreset { impl StylePreset { /// From `[[kind, params], …]` in `layer.layerStyle.` form. fn from_spec(name: &str, fx: Value, fill: Option) -> Self { - let effects = fx.as_array().map(|a| a.iter().filter_map(|e| effect_from_params(e.get(0)?.as_str()?, e.get(1).unwrap_or(&Value::Null))).collect()).unwrap_or_default(); + let effects = fx + .as_array() + .map(|a| a.iter().filter_map(|e| effect_from_params(e.get(0)?.as_str()?, e.get(1).unwrap_or(&Value::Null))).collect()) + .unwrap_or_default(); StylePreset { name: name.into(), effects, blend: None, fill_opacity: fill } } @@ -73,24 +76,68 @@ pub fn builtin() -> Vec> { Group::new( "Text Effects", vec![ - s("Neon Blue", json!([["colorOverlay", {"color": "#d8f4ff"}], ["innerGlow", {"color": "#3fb6ff", "size": 6, "opacity": 90, "blend": "normal"}], ["outerGlow", {"color": "#1e90ff", "size": 22, "opacity": 90}]]), None), - s("Neon Pink", json!([["colorOverlay", {"color": "#ffe1f3"}], ["innerGlow", {"color": "#ff4fb4", "size": 6, "opacity": 90, "blend": "normal"}], ["outerGlow", {"color": "#ff2d95", "size": 22, "opacity": 90}]]), None), - s("Chrome", json!([["gradientOverlay", {"from": "#3a3f47", "to": "#f4f6f8", "angle": 90}], ["bevelEmboss", {"style": "inner", "size": 6, "depth": 250, "soften": 1}], ["stroke", {"size": 1, "color": "#2a2d33", "position": "outside"}]]), None), - s("Gold", json!([["gradientOverlay", {"from": "#8a5a12", "to": "#ffe08a", "angle": 90}], ["bevelEmboss", {"style": "inner", "size": 5, "depth": 180}], ["dropShadow", {"distance": 3, "size": 5, "opacity": 50}]]), None), - s("Comic Outline", json!([["colorOverlay", {"color": "#ffd23f"}], ["stroke", {"size": 4, "color": "#111111", "position": "outside"}], ["dropShadow", {"distance": 6, "size": 0, "opacity": 100, "angle": 135}]]), None), - s("Letterpress", json!([["colorOverlay", {"color": "#5b6168"}], ["innerShadow", {"distance": 2, "size": 2, "opacity": 70}], ["dropShadow", {"distance": 1, "size": 0, "color": "#ffffff", "opacity": 60, "blend": "screen", "angle": 90}]]), None), + s( + "Neon Blue", + json!([["colorOverlay", {"color": "#d8f4ff"}], ["innerGlow", {"color": "#3fb6ff", "size": 6, "opacity": 90, "blend": "normal"}], ["outerGlow", {"color": "#1e90ff", "size": 22, "opacity": 90}]]), + None, + ), + s( + "Neon Pink", + json!([["colorOverlay", {"color": "#ffe1f3"}], ["innerGlow", {"color": "#ff4fb4", "size": 6, "opacity": 90, "blend": "normal"}], ["outerGlow", {"color": "#ff2d95", "size": 22, "opacity": 90}]]), + None, + ), + s( + "Chrome", + json!([["gradientOverlay", {"from": "#3a3f47", "to": "#f4f6f8", "angle": 90}], ["bevelEmboss", {"style": "inner", "size": 6, "depth": 250, "soften": 1}], ["stroke", {"size": 1, "color": "#2a2d33", "position": "outside"}]]), + None, + ), + s( + "Gold", + json!([["gradientOverlay", {"from": "#8a5a12", "to": "#ffe08a", "angle": 90}], ["bevelEmboss", {"style": "inner", "size": 5, "depth": 180}], ["dropShadow", {"distance": 3, "size": 5, "opacity": 50}]]), + None, + ), + s( + "Comic Outline", + json!([["colorOverlay", {"color": "#ffd23f"}], ["stroke", {"size": 4, "color": "#111111", "position": "outside"}], ["dropShadow", {"distance": 6, "size": 0, "opacity": 100, "angle": 135}]]), + None, + ), + s( + "Letterpress", + json!([["colorOverlay", {"color": "#5b6168"}], ["innerShadow", {"distance": 2, "size": 2, "opacity": 70}], ["dropShadow", {"distance": 1, "size": 0, "color": "#ffffff", "opacity": 60, "blend": "screen", "angle": 90}]]), + None, + ), s("Hollow", json!([["stroke", {"size": 2, "color": "#222222", "position": "inside"}]]), Some(0.0)), ], ), Group::new( "Buttons", vec![ - s("Glass", json!([["gradientOverlay", {"from": "#2b6cb0", "to": "#90cdf4", "angle": 90}], ["innerGlow", {"color": "#ffffff", "size": 6, "opacity": 45, "blend": "screen"}], ["bevelEmboss", {"style": "inner", "size": 8, "depth": 100, "soften": 4}], ["dropShadow", {"distance": 3, "size": 8, "opacity": 40}]]), None), - s("Gel Green", json!([["gradientOverlay", {"from": "#1f7a3a", "to": "#7be495", "angle": 90}], ["innerShadow", {"distance": 2, "size": 5, "opacity": 35}], ["bevelEmboss", {"style": "inner", "size": 10, "depth": 150, "soften": 6}]]), None), - s("Pressed", json!([["colorOverlay", {"color": "#d7dbe0"}], ["innerShadow", {"distance": 3, "size": 6, "opacity": 60}], ["stroke", {"size": 1, "color": "#9aa1a9", "position": "inside"}]]), None), - s("Pill Red", json!([["gradientOverlay", {"from": "#9b1c1c", "to": "#f87171", "angle": 90}], ["bevelEmboss", {"style": "pillow", "size": 6, "depth": 120}], ["dropShadow", {"distance": 2, "size": 4, "opacity": 45}]]), None), + s( + "Glass", + json!([["gradientOverlay", {"from": "#2b6cb0", "to": "#90cdf4", "angle": 90}], ["innerGlow", {"color": "#ffffff", "size": 6, "opacity": 45, "blend": "screen"}], ["bevelEmboss", {"style": "inner", "size": 8, "depth": 100, "soften": 4}], ["dropShadow", {"distance": 3, "size": 8, "opacity": 40}]]), + None, + ), + s( + "Gel Green", + json!([["gradientOverlay", {"from": "#1f7a3a", "to": "#7be495", "angle": 90}], ["innerShadow", {"distance": 2, "size": 5, "opacity": 35}], ["bevelEmboss", {"style": "inner", "size": 10, "depth": 150, "soften": 6}]]), + None, + ), + s( + "Pressed", + json!([["colorOverlay", {"color": "#d7dbe0"}], ["innerShadow", {"distance": 3, "size": 6, "opacity": 60}], ["stroke", {"size": 1, "color": "#9aa1a9", "position": "inside"}]]), + None, + ), + s( + "Pill Red", + json!([["gradientOverlay", {"from": "#9b1c1c", "to": "#f87171", "angle": 90}], ["bevelEmboss", {"style": "pillow", "size": 6, "depth": 120}], ["dropShadow", {"distance": 2, "size": 4, "opacity": 45}]]), + None, + ), s("Flat Shadow", json!([["colorOverlay", {"color": "#3b82f6"}], ["dropShadow", {"distance": 5, "size": 0, "opacity": 35, "angle": 90}]]), None), - s("Satin Plum", json!([["colorOverlay", {"color": "#7e3a8c"}], ["satin", {"color": "#1b0420", "opacity": 55, "size": 18, "distance": 14}], ["bevelEmboss", {"style": "inner", "size": 4, "depth": 80}]]), None), + s( + "Satin Plum", + json!([["colorOverlay", {"color": "#7e3a8c"}], ["satin", {"color": "#1b0420", "opacity": 55, "size": 18, "distance": 14}], ["bevelEmboss", {"style": "inner", "size": 4, "depth": 80}]]), + None, + ), ], ), ] @@ -103,7 +150,8 @@ fn find_style<'a>(s: &'a Session, p: &Value, cmd: &str) -> Result<&'a StylePrese } fn list(s: &mut Session, _: &Value) -> Result { - let groups: Vec = s.presets.styles.iter().map(|g| json!({"name": g.name, "presets": g.items.iter().map(StylePreset::to_json).collect::>()})).collect(); + let groups: Vec = + s.presets.styles.iter().map(|g| json!({"name": g.name, "presets": g.items.iter().map(StylePreset::to_json).collect::>()})).collect(); Ok(json!({"groups": groups})) } @@ -261,7 +309,16 @@ pub fn thumbnail(s: &Session, style: &StylePreset, size: u32) -> Vec { pub fn specs() -> Vec { vec![ - CommandSpec { id: "style.presets.list", label: "Style Presets", menu: &[], shortcut: None, params: "{} → {groups:[{name,presets:[{name,effects,blend,fillOpacity}]}]}", enabled: always, run: list, journal: false }, + CommandSpec { + id: "style.presets.list", + label: "Style Presets", + menu: &[], + shortcut: None, + params: "{} → {groups:[{name,presets:[{name,effects,blend,fillOpacity}]}]}", + enabled: always, + run: list, + journal: false, + }, CommandSpec { id: "style.presets.apply", label: "Apply Style", @@ -272,8 +329,35 @@ pub fn specs() -> Vec { run: apply, journal: true, }, - CommandSpec { id: "style.presets.new", label: "New Style…", menu: &[], shortcut: None, params: r##"{"name":str="Style","group":name?,"layer":id?,"includeEffects":bool=true,"includeBlending":bool=true} (from the layer's effects)"##, enabled: has_layer, run: new_preset, journal: true }, - CommandSpec { id: "style.presets.edit", label: "Edit Style Presets", menu: &[], shortcut: None, params: super::GROUP_EDIT_PARAMS, enabled: always, run: edit, journal: true }, - CommandSpec { id: "style.presets.reset", label: "Restore Default Styles", menu: &[], shortcut: None, params: r##"{"append":bool=false}"##, enabled: always, run: reset, journal: true }, + CommandSpec { + id: "style.presets.new", + label: "New Style…", + menu: &[], + shortcut: None, + params: r##"{"name":str="Style","group":name?,"layer":id?,"includeEffects":bool=true,"includeBlending":bool=true} (from the layer's effects)"##, + enabled: has_layer, + run: new_preset, + journal: true, + }, + CommandSpec { + id: "style.presets.edit", + label: "Edit Style Presets", + menu: &[], + shortcut: None, + params: super::GROUP_EDIT_PARAMS, + enabled: always, + run: edit, + journal: true, + }, + CommandSpec { + id: "style.presets.reset", + label: "Restore Default Styles", + menu: &[], + shortcut: None, + params: r##"{"append":bool=false}"##, + enabled: always, + run: reset, + journal: true, + }, ] } diff --git a/crates/engine/src/presets/tests.rs b/crates/engine/src/presets/tests.rs index 9e4bb1a28a..085b04ef7a 100644 --- a/crates/engine/src/presets/tests.rs +++ b/crates/engine/src/presets/tests.rs @@ -127,7 +127,12 @@ fn presets_persist_through_preferences_json() { t.load_prefs_json(&text).unwrap(); let names = |t: &mut Session, cmd: &str, key: &str| -> Vec { let v = t.execute(cmd, json!({})).unwrap(); - v["groups"].as_array().unwrap().iter().flat_map(|g| g[key].as_array().unwrap().iter().map(|i| i["name"].as_str().unwrap().to_string()).collect::>()).collect() + v["groups"] + .as_array() + .unwrap() + .iter() + .flat_map(|g| g[key].as_array().unwrap().iter().map(|i| i["name"].as_str().unwrap().to_string()).collect::>()) + .collect() }; assert!(names(&mut t, "gradient.presets.list", "presets").contains(&"Saved".to_string())); assert!(names(&mut t, "shape.presets.list", "shapes").contains(&"Tri".to_string())); diff --git a/crates/engine/src/presets/tools.rs b/crates/engine/src/presets/tools.rs index 25381d9f52..af728624c7 100644 --- a/crates/engine/src/presets/tools.rs +++ b/crates/engine/src/presets/tools.rs @@ -48,7 +48,13 @@ pub fn builtin() -> Vec { fn list(s: &mut Session, p: &Value) -> Result { let filter = str_param(p, "tool").map(norm); - let items: Vec = s.presets.tool_presets.iter().filter(|t| filter.as_ref().is_none_or(|f| norm(&t.tool) == *f)).map(|t| json!({"name": t.name, "tool": t.tool})).collect(); + let items: Vec = s + .presets + .tool_presets + .iter() + .filter(|t| filter.as_ref().is_none_or(|f| norm(&t.tool) == *f)) + .map(|t| json!({"name": t.name, "tool": t.tool})) + .collect(); Ok(json!({"presets": items})) } @@ -60,7 +66,23 @@ fn new_preset(s: &mut Session, p: &Value) -> Result { return Err(bad(CMD, "`options` must be an object")); } // Brush-based tools carry the current brush unless the caller sent one. - let brushy = ["brush", "pencil", "eraser", "clonestamp", "healing", "spothealing", "historybrush", "blur", "sharpen", "smudge", "dodge", "burn", "sponge", "mixerbrush", "colorreplacement"]; + let brushy = [ + "brush", + "pencil", + "eraser", + "clonestamp", + "healing", + "spothealing", + "historybrush", + "blur", + "sharpen", + "smudge", + "dodge", + "burn", + "sponge", + "mixerbrush", + "colorreplacement", + ]; if options.get("brush").is_none() && brushy.contains(&norm(&tool).as_str()) { options["brush"] = serde_json::to_value(&s.tools.brush).unwrap_or(Value::Null); } @@ -78,7 +100,13 @@ fn new_preset(s: &mut Session, p: &Value) -> Result { fn select(s: &mut Session, p: &Value) -> Result { const CMD: &str = "tool.presets.select"; let name = req_str(p, "preset", CMD)?; - let t = s.presets.tool_presets.iter().find(|t| t.name == name).cloned().ok_or_else(|| bad(CMD, format!("no tool preset \"{name}\" (see tool.presets.list)")))?; + let t = s + .presets + .tool_presets + .iter() + .find(|t| t.name == name) + .cloned() + .ok_or_else(|| bad(CMD, format!("no tool preset \"{name}\" (see tool.presets.list)")))?; if let Some(b) = t.options.get("brush").filter(|b| b.is_object()) { super::call(s, "tools.setBrush", json!({"brush": b}))?; } @@ -111,7 +139,16 @@ fn reset(s: &mut Session, _: &Value) -> Result { pub fn specs() -> Vec { vec![ - CommandSpec { id: "tool.presets.list", label: "Tool Presets", menu: &[], shortcut: None, params: r##"{"tool":name? (Current Tool Only)} → {presets:[{name,tool}]}"##, enabled: always, run: list, journal: false }, + CommandSpec { + id: "tool.presets.list", + label: "Tool Presets", + menu: &[], + shortcut: None, + params: r##"{"tool":name? (Current Tool Only)} → {presets:[{name,tool}]}"##, + enabled: always, + run: list, + journal: false, + }, CommandSpec { id: "tool.presets.new", label: "New Tool Preset…", @@ -122,8 +159,35 @@ pub fn specs() -> Vec { run: new_preset, journal: true, }, - CommandSpec { id: "tool.presets.select", label: "Select Tool Preset", menu: &[], shortcut: None, params: r##"{"preset":name} → {name,tool,options} (applies "brush" and "foreground"; the shell switches tool and options)"##, enabled: always, run: select, journal: true }, - CommandSpec { id: "tool.presets.edit", label: "Edit Tool Presets", menu: &[], shortcut: None, params: r##"{"action":"rename|delete","preset":name|[names],"name":str (rename)}"##, enabled: always, run: edit, journal: true }, - CommandSpec { id: "tool.presets.reset", label: "Reset Tool Presets", menu: &[], shortcut: None, params: "{}", enabled: always, run: reset, journal: true }, + CommandSpec { + id: "tool.presets.select", + label: "Select Tool Preset", + menu: &[], + shortcut: None, + params: r##"{"preset":name} → {name,tool,options} (applies "brush" and "foreground"; the shell switches tool and options)"##, + enabled: always, + run: select, + journal: true, + }, + CommandSpec { + id: "tool.presets.edit", + label: "Edit Tool Presets", + menu: &[], + shortcut: None, + params: r##"{"action":"rename|delete","preset":name|[names],"name":str (rename)}"##, + enabled: always, + run: edit, + journal: true, + }, + CommandSpec { + id: "tool.presets.reset", + label: "Reset Tool Presets", + menu: &[], + shortcut: None, + params: "{}", + enabled: always, + run: reset, + journal: true, + }, ] } diff --git a/crates/engine/src/print_cmds.rs b/crates/engine/src/print_cmds.rs index 2f0b731f21..96977a5657 100644 --- a/crates/engine/src/print_cmds.rs +++ b/crates/engine/src/print_cmds.rs @@ -31,7 +31,16 @@ fn other(e: impl std::fmt::Display) -> EngineError { // ---------- PDF ---------- /// Paper sizes in points (portrait). -pub const PAPERS: [(&str, f64, f64); 8] = [("letter", 612.0, 792.0), ("legal", 612.0, 1008.0), ("tabloid", 792.0, 1224.0), ("a3", 841.89, 1190.55), ("a4", 595.28, 841.89), ("a5", 419.53, 595.28), ("4x6", 288.0, 432.0), ("5x7", 360.0, 504.0)]; +pub const PAPERS: [(&str, f64, f64); 8] = [ + ("letter", 612.0, 792.0), + ("legal", 612.0, 1008.0), + ("tabloid", 792.0, 1224.0), + ("a3", 841.89, 1190.55), + ("a4", 595.28, 841.89), + ("a5", 419.53, 595.28), + ("4x6", 288.0, 432.0), + ("5x7", 360.0, 504.0), +]; /// The image placed on a print page. pub struct PrintImage { @@ -136,7 +145,13 @@ pub fn print_pdf(page: &PrintPage) -> Vec { // 1 catalog, 2 pages, 3 page, 4 contents, 5 image, 6 font, 7 ICC (optional). obj(&mut out, b"<< /Type /Catalog /Pages 2 0 R >>"); obj(&mut out, b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>"); - obj(&mut out, format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {pw:.2} {ph:.2}] /Resources << /XObject << /Im0 5 0 R >> /Font << /F1 6 0 R >> >> /Contents 4 0 R >>").as_bytes()); + obj( + &mut out, + format!( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {pw:.2} {ph:.2}] /Resources << /XObject << /Im0 5 0 R >> /Font << /F1 6 0 R >> >> /Contents 4 0 R >>" + ) + .as_bytes(), + ); // An explicit white page, so every viewer (and rasteriser) shows paper, not transparency. let mut content = format!("q 1 g 0 0 {pw:.2} {ph:.2} re f Q\nq {w:.3} 0 0 {h:.3} {x:.3} {y:.3} cm /Im0 Do Q\n"); content.push_str(&marks_ops(page.marks, page.rect)); @@ -160,7 +175,13 @@ pub fn print_pdf(page: &PrintPage) -> Vec { }; let cs = if img.icc.is_some() { "[/ICCBased 7 0 R]".to_string() } else { device.to_string() }; let z = deflate(&img.data); - let mut body = format!("<< /Type /XObject /Subtype /Image /Width {} /Height {} /ColorSpace {cs} /BitsPerComponent 8 /Filter /FlateDecode /Length {} >>\nstream\n", img.width, img.height, z.len()).into_bytes(); + let mut body = format!( + "<< /Type /XObject /Subtype /Image /Width {} /Height {} /ColorSpace {cs} /BitsPerComponent 8 /Filter /FlateDecode /Length {} >>\nstream\n", + img.width, + img.height, + z.len() + ) + .into_bytes(); body.extend_from_slice(&z); body.extend_from_slice(b"\nendstream"); obj(&mut out, &body); @@ -213,7 +234,8 @@ fn print_image(doc: &Document, p: &Value, cmd: &str) -> Result<(PrintImage, Valu let gray = d.mode == ColorMode::Grayscale; let buf = photocraft_compose::flatten(d).over_background([1.0, 1.0, 1.0]); let to8 = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; - let data: Vec = if gray { buf.px.iter().map(|p| to8(p[0])).collect() } else { buf.px.iter().flat_map(|p| [to8(p[0]), to8(p[1]), to8(p[2])]).collect() }; + let data: Vec = + if gray { buf.px.iter().map(|p| to8(p[0])).collect() } else { buf.px.iter().flat_map(|p| [to8(p[0]), to8(p[1]), to8(p[2])]).collect() }; return Ok((PrintImage { width: d.size.width, height: d.size.height, channels: if gray { 1 } else { 3 }, data, icc }, info)); } t.execute("layer.flattenImage", json!({}))?; @@ -235,7 +257,11 @@ fn print_image(doc: &Document, p: &Value, cmd: &str) -> Result<(PrintImage, Valu fn paper(p: &Value, cmd: &str) -> Result<(f64, f64)> { let (w, h) = match p.get("paper") { None => (612.0, 792.0), - Some(Value::String(name)) => PAPERS.iter().find(|(n, _, _)| n.eq_ignore_ascii_case(name)).map(|(_, w, h)| (*w, *h)).ok_or_else(|| bad(cmd, format!("unknown paper `{name}` ({})", PAPERS.map(|p| p.0).join("|"))))?, + Some(Value::String(name)) => PAPERS + .iter() + .find(|(n, _, _)| n.eq_ignore_ascii_case(name)) + .map(|(_, w, h)| (*w, *h)) + .ok_or_else(|| bad(cmd, format!("unknown paper `{name}` ({})", PAPERS.map(|p| p.0).join("|"))))?, Some(Value::Array(a)) if a.len() == 2 => (a[0].as_f64().unwrap_or(0.0), a[1].as_f64().unwrap_or(0.0)), _ => return Err(bad(cmd, "paper is a name or [width, height] in points")), }; @@ -269,7 +295,11 @@ pub fn layout(doc: &Document, p: &Value, cmd: &str) -> Result { }; let margin = if marks.corner_crop || marks.center_crop || marks.registration { 36.0 } else { 18.0 }; let fit = p.get("scaleToFit").and_then(Value::as_bool).unwrap_or(false); - let scale = if fit { ((pw - 2.0 * margin) / iw).min((ph - 2.0 * margin) / ih) } else { p.get("scale").and_then(Value::as_f64).filter(|v| *v > 0.0).unwrap_or(100.0) / 100.0 }; + let scale = if fit { + ((pw - 2.0 * margin) / iw).min((ph - 2.0 * margin) / ih) + } else { + p.get("scale").and_then(Value::as_f64).filter(|v| *v > 0.0).unwrap_or(100.0) / 100.0 + }; let (w, h) = (iw * scale, ih * scale); let center = p.get("center").and_then(Value::as_bool).unwrap_or(true); let (x, y) = if center { @@ -289,7 +319,11 @@ fn do_print(s: &mut Session, p: &Value, cmd: &str) -> Result { let doc = d.doc.clone(); let PrintLayout { paper: (pw, ph), rect: (x, y, w, h), scale, marks } = layout(&doc, p, cmd)?; let (img, color) = print_image(&doc, p, cmd)?; - let description = p.get("description").and_then(Value::as_bool).unwrap_or(false).then(|| crate::file_cmds::read_file_info(doc.metadata.xmp.as_deref())["description"].as_str().unwrap_or_default().to_string()); + let description = p + .get("description") + .and_then(Value::as_bool) + .unwrap_or(false) + .then(|| crate::file_cmds::read_file_info(doc.metadata.xmp.as_deref())["description"].as_str().unwrap_or_default().to_string()); let label = p.get("labels").and_then(Value::as_bool).unwrap_or(false).then(|| doc.name.clone()); let page = PrintPage { paper: (pw, ph), image: img, rect: (x, y, w, h), marks, description, label }; let pdf = print_pdf(&page); @@ -329,7 +363,9 @@ fn do_print(s: &mut Session, p: &Value, cmd: &str) -> Result { } s.file_menu.last_print = Some(remembered); crate::automate_cmds::fire_event(s, "print"); - Ok(json!({"pdf": pdf_path, "bytes": pdf.len(), "paper": [pw, ph], "imageRect": [x, y, w, h], "scale": scale * 100.0, "copies": copies, "command": if send { json!(argv) } else { Value::Null }, "sent": sent, "spooler": spool, "color": color})) + Ok( + json!({"pdf": pdf_path, "bytes": pdf.len(), "paper": [pw, ph], "imageRect": [x, y, w, h], "scale": scale * 100.0, "copies": copies, "command": if send { json!(argv) } else { Value::Null }, "sent": sent, "spooler": spool, "color": color}), + ) } #[cfg(not(target_arch = "wasm32"))] @@ -440,7 +476,11 @@ pub fn paths_to_ai(doc: &Document, which: &str) -> Result<(String, usize)> { let mut s = String::new(); s.push_str("%!PS-Adobe-2.0 EPSF-1.2\n%%Creator: PhotoCraft\n"); s.push_str(&format!("%%Title: ({})\n", doc.name.replace([')', '('], "_"))); - s.push_str(&format!("%%BoundingBox: 0 0 {} {}\n%%HiResBoundingBox: 0 0 {w:.4} {h:.4}\n%AI3_Cropmarks: 0 0 {w:.4} {h:.4}\n", w.ceil() as i64, h.ceil() as i64)); + s.push_str(&format!( + "%%BoundingBox: 0 0 {} {}\n%%HiResBoundingBox: 0 0 {w:.4} {h:.4}\n%AI3_Cropmarks: 0 0 {w:.4} {h:.4}\n", + w.ceil() as i64, + h.ceil() as i64 + )); s.push_str("%%DocumentProcessColors: Black\n%%EndComments\n%%EndProlog\n%%BeginSetup\n%%EndSetup\n"); let pt = |x: f64, y: f64| (x * k, h - y * k); let mut n = 0; @@ -512,9 +552,33 @@ pub fn specs() -> Vec { const PRINT_PARAMS: &str = r##"{"printer":name? (default printer),"copies":1..999=1,"paper":"letter|legal|tabloid|a3|a4|a5|4x6|5x7"|[w,h] pt="letter","orientation":"portrait|landscape"="portrait","center":bool=true,"top":in?,"left":in?,"scale":%=100,"scaleToFit":bool=false,"colorHandling":"printerManages|photocraftManages|noColorManagement"="printerManages","printerProfile":profile? (photocraftManages),"intent":"perceptual|relative|saturation|absolute"="relative","bpc":bool=true,"cornerCropMarks":bool,"centerCropMarks":bool,"registrationMarks":bool,"description":bool,"labels":bool,"output":pdf path? (print to PDF; then "send" defaults to false),"send":bool?,"dryRun":bool=false (render the PDF, report the lp command, don't spool)} → {pdf, imageRect, command, sent}"##; vec![ spec!("file.print", "Print…", &["File"], Some("Cmd+P"), PRINT_PARAMS, native_doc, print), - spec!("file.printOneCopy", "Print One Copy", &["File"], Some("Cmd+Alt+Shift+P"), "{} (the last Print settings, one copy; any Print key overrides)", native_doc, print_one_copy), - spec!("file.package", "Package…", &["File"], None, r##"{"dir":folder,"format":"pcraft|psd|psb"="pcraft"} → {folder, document, links, missing} (copies the document and its linked files into //, relinked to Links/)"##, native_doc, package), - spec!("file.export.pathsToIllustrator", "Paths to Illustrator…", &["File", "Export"], None, r##"{"path":str? (.ai; omit to return the text),"paths":"all|work|"="all"} → {path, paths}"##, has_paths, paths_to_illustrator), + spec!( + "file.printOneCopy", + "Print One Copy", + &["File"], + Some("Cmd+Alt+Shift+P"), + "{} (the last Print settings, one copy; any Print key overrides)", + native_doc, + print_one_copy + ), + spec!( + "file.package", + "Package…", + &["File"], + None, + r##"{"dir":folder,"format":"pcraft|psd|psb"="pcraft"} → {folder, document, links, missing} (copies the document and its linked files into //, relinked to Links/)"##, + native_doc, + package + ), + spec!( + "file.export.pathsToIllustrator", + "Paths to Illustrator…", + &["File", "Export"], + None, + r##"{"path":str? (.ai; omit to return the text),"paths":"all|work|"="all"} → {path, paths}"##, + has_paths, + paths_to_illustrator + ), ] } diff --git a/crates/engine/src/print_cmds/tests.rs b/crates/engine/src/print_cmds/tests.rs index 3bb3b4cca5..0adb00eb8e 100644 --- a/crates/engine/src/print_cmds/tests.rs +++ b/crates/engine/src/print_cmds/tests.rs @@ -37,7 +37,12 @@ fn print_dry_run_renders_a_pdf_and_reports_lp() { let dir = tmp("print"); for depth in [8, 16, 32] { let mut s = session("rgb", depth); - let r = s.execute("file.print", json!({"dryRun": true, "printer": "Office", "copies": 2, "paper": "a4", "cornerCropMarks": true, "registrationMarks": true, "labels": true})).unwrap(); + let r = s + .execute( + "file.print", + json!({"dryRun": true, "printer": "Office", "copies": 2, "paper": "a4", "cornerCropMarks": true, "registrationMarks": true, "labels": true}), + ) + .unwrap(); let cmd: Vec = serde_json::from_value(r["command"].clone()).unwrap(); assert_eq!(&cmd[..5], &["lp", "-d", "Office", "-n", "2"]); assert_eq!(r["sent"], false); @@ -70,7 +75,12 @@ fn print_dry_run_renders_a_pdf_and_reports_lp() { #[test] fn photocraft_manages_colors_converts_to_the_printer_profile() { let mut s = session("rgb", 8); - let r = s.execute("file.print", json!({"dryRun": true, "colorHandling": "photocraftManages", "printerProfile": "coated-cmyk", "intent": "perceptual", "bpc": false})).unwrap(); + let r = s + .execute( + "file.print", + json!({"dryRun": true, "colorHandling": "photocraftManages", "printerProfile": "coated-cmyk", "intent": "perceptual", "bpc": false}), + ) + .unwrap(); assert_eq!(r["color"]["intent"], "perceptual"); let pdf = std::fs::read(r["pdf"].as_str().unwrap()).unwrap(); let (_, _, cs, data) = pdf_image(&pdf); @@ -128,7 +138,11 @@ fn package_copies_links_and_relinks() { assert!(linked[0].ends_with("Links/art.png"), "{linked:?}"); // The open document still points at the original. let open = &s.active().unwrap().doc; - assert!(open.walk().iter().any(|(_, _, l)| matches!(&l.content, LayerContent::Smart(so) if matches!(&so.source, SmartSource::Linked { path } if *path == link)))); + assert!( + open.walk() + .iter() + .any(|(_, _, l)| matches!(&l.content, LayerContent::Smart(so) if matches!(&so.source, SmartSource::Linked { path } if *path == link))) + ); } #[test] diff --git a/crates/engine/src/proof_sim.rs b/crates/engine/src/proof_sim.rs index 9448d91448..4b2a9f0982 100644 --- a/crates/engine/src/proof_sim.rs +++ b/crates/engine/src/proof_sim.rs @@ -128,7 +128,8 @@ fn srgb_encode(v: f32) -> f32 { const RGB_LMS: [[f32; 3]; 3] = [[17.8824, 43.5161, 4.11935], [3.45565, 27.1554, 3.86714], [0.0299566, 0.184309, 1.46709]]; /// LMS → linear RGB (the paper's values, kept verbatim). #[allow(clippy::excessive_precision)] -const LMS_RGB: [[f32; 3]; 3] = [[0.080_944_45, -0.130_504_41, 0.116_721_07], [-0.010_248_533, 0.054_019_327, -0.113_614_71], [-0.000_365_297, -0.004_121_614_7, 0.693_511_4]]; +const LMS_RGB: [[f32; 3]; 3] = + [[0.080_944_45, -0.130_504_41, 0.116_721_07], [-0.010_248_533, 0.054_019_327, -0.113_614_71], [-0.000_365_297, -0.004_121_614_7, 0.693_511_4]]; fn mul(m: &[[f32; 3]; 3], v: [f32; 3]) -> [f32; 3] { [0, 1, 2].map(|i| m[i][0] * v[0] + m[i][1] * v[1] + m[i][2] * v[2]) @@ -278,7 +279,16 @@ fn preview_32(s: &mut Session, p: &Value) -> Result { macro_rules! kind_spec { ($id:literal, $label:literal) => { - CommandSpec { id: $id, label: $label, menu: &["View", "Proof Setup"], shortcut: None, params: "{} (sets the proof and turns Proof Colors on)", enabled: has_doc, run: |s, _| run_kind(s, $id), journal: false } + CommandSpec { + id: $id, + label: $label, + menu: &["View", "Proof Setup"], + shortcut: None, + params: "{} (sets the proof and turns Proof Colors on)", + enabled: has_doc, + run: |s, _| run_kind(s, $id), + journal: false, + } }; } @@ -344,7 +354,14 @@ mod tests { fn plates_and_simulations_build_luts() { for depth in [8, 16, 32] { let mut s = session("rgb", depth); - for id in ["view.proofSetup.workingCyanPlate", "view.proofSetup.workingBlackPlate", "view.proofSetup.workingCmyPlate", "view.proofSetup.legacyMacintoshRgb", "view.proofSetup.colorBlindnessProtanopia", "view.proofSetup.colorBlindnessDeuteranopia"] { + for id in [ + "view.proofSetup.workingCyanPlate", + "view.proofSetup.workingBlackPlate", + "view.proofSetup.workingCmyPlate", + "view.proofSetup.legacyMacintoshRgb", + "view.proofSetup.colorBlindnessProtanopia", + "view.proofSetup.colorBlindnessDeuteranopia", + ] { let r = s.execute(id, json!({})).unwrap(); assert_eq!(r["proofColors"], true); let d = s.active().unwrap().doc.clone(); diff --git a/crates/engine/src/render_cmds.rs b/crates/engine/src/render_cmds.rs index 58665ca652..892d146697 100644 --- a/crates/engine/src/render_cmds.rs +++ b/crates/engine/src/render_cmds.rs @@ -71,7 +71,9 @@ fn flame_paths(s: &Session, p: &Value) -> Option>> { .into_iter() .map(|pl| { let mut v: Vec<(f32, f32)> = pl.pts.iter().map(|&(x, y)| (x as f32, y as f32)).collect(); - if pl.closed && let Some(&f) = v.first() { + if pl.closed + && let Some(&f) = v.first() + { v.push(f); } v @@ -238,7 +240,11 @@ mod tests { #[test] fn each_renderer_draws_undoes_and_is_seeded_at_every_depth() { for depth in ["8", "16", "32"] { - for (id, p) in [("filter.render.flame", json!({})), ("filter.render.tree", json!({"baseTreeType": 7})), ("filter.render.pictureFrame", json!({"frame": "roses"}))] { + for (id, p) in [ + ("filter.render.flame", json!({})), + ("filter.render.tree", json!({"baseTreeType": 7})), + ("filter.render.pictureFrame", json!({"frame": "roses"})), + ] { let mut s = session(depth); let r = s.execute(id, p.clone()).unwrap_or_else(|e| panic!("{id}@{depth}: {e}")); assert!(r["primitives"].as_u64().unwrap() > 0, "{id}"); diff --git a/crates/engine/src/retouch_cmds.rs b/crates/engine/src/retouch_cmds.rs index 0b060a8228..307dd34618 100644 --- a/crates/engine/src/retouch_cmds.rs +++ b/crates/engine/src/retouch_cmds.rs @@ -80,12 +80,7 @@ fn parse_brush(s: &Session, p: &Value, cmd: &str) -> Result<(Stroke, LayerId)> { /// Run `f` on the target layer's surface inside one undoable step. `f` gets the pre-stroke document /// (a cheap copy-on-write snapshot), the surface, the selection and the transparency lock. -fn run_stroke( - s: &mut Session, - label: &str, - id: LayerId, - f: impl FnOnce(&Document, &mut Surface, Option<&Surface>, bool) -> Result, -) -> Result { +fn run_stroke(s: &mut Session, label: &str, id: LayerId, f: impl FnOnce(&Document, &mut Surface, Option<&Surface>, bool) -> Result) -> Result { let dmg = s.edit(label, |doc, _| { let pre = doc.clone(); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; @@ -387,7 +382,8 @@ fn spot_heal_surface(surf: &mut Surface, area: Rect, stroke: &Stroke, kind: Spot // membrane corrects (otherwise the Dirichlet data would be all zero and the blend a no-op). let domain = dilate(w, h, &hole, 2); let heal_mask = dilate(w, h, &hole, 1); - let content_aware = |img: &[f32]| inpaint::complete(w, h, ch, img, &domain, &CompleteParams::default()).unwrap_or_else(|| poisson::membrane_fill(w, h, ch, img, &domain)); + let content_aware = + |img: &[f32]| inpaint::complete(w, h, ch, img, &domain, &CompleteParams::default()).unwrap_or_else(|| poisson::membrane_fill(w, h, ch, img, &domain)); let filled = match kind { SpotType::ContentAware => content_aware(&img.data), SpotType::CreateTexture => inpaint::synthesize(w, h, ch, &img.data, &domain, (size / 4.0).clamp(6.0, 32.0) as usize, 0x5eed), @@ -584,7 +580,9 @@ pub fn specs() -> Vec { label: "Clone Stamp", menu: &[], shortcut: None, - params: brush_params!(r#","source":[sx,sy] (sampled under the first point) | "offset":[dx,dy],"aligned":bool=true,"sampleLayer":"current|currentAndBelow|all"="current","mode":"normal|multiply|…"="normal" → {"damage","offset","aligned","nextSource"}"#), + params: brush_params!( + r#","source":[sx,sy] (sampled under the first point) | "offset":[dx,dy],"aligned":bool=true,"sampleLayer":"current|currentAndBelow|all"="current","mode":"normal|multiply|…"="normal" → {"damage","offset","aligned","nextSource"}"# + ), enabled: has_pixel_layer, run: clone_stamp, journal: true, @@ -594,7 +592,9 @@ pub fn specs() -> Vec { label: "Healing Brush", menu: &[], shortcut: None, - params: brush_params!(r#","source":[sx,sy] | "offset":[dx,dy],"aligned":bool=true,"sampleLayer":"current|currentAndBelow|all"="current","mode":"normal|…"="normal" → {"damage","offset","aligned","nextSource"}"#), + params: brush_params!( + r#","source":[sx,sy] | "offset":[dx,dy],"aligned":bool=true,"sampleLayer":"current|currentAndBelow|all"="current","mode":"normal|…"="normal" → {"damage","offset","aligned","nextSource"}"# + ), enabled: has_pixel_layer, run: healing_brush, journal: true, diff --git a/crates/engine/src/retouch_cmds/tests.rs b/crates/engine/src/retouch_cmds/tests.rs index 4fb46978a3..d45bdb85d9 100644 --- a/crates/engine/src/retouch_cmds/tests.rs +++ b/crates/engine/src/retouch_cmds/tests.rs @@ -95,7 +95,8 @@ fn clone_stamp_sample_all_layers_onto_empty_layer_and_opacity() { let p = rgba(&s, 48, 16); assert!(p[0] > 0.99 && p[1] < 0.01 && p[3] > 0.99, "{p:?}"); // currentAndBelow on the bottom layer ignores layers above. - s.execute("paint.cloneStamp", json!({"points": [[40, 5]], "source": [8, 5], "size": 4, "hardness": 100, "opacity": 50, "sampleLayer": "currentAndBelow"})).unwrap(); + s.execute("paint.cloneStamp", json!({"points": [[40, 5]], "source": [8, 5], "size": 4, "hardness": 100, "opacity": 50, "sampleLayer": "currentAndBelow"})) + .unwrap(); let p = rgba(&s, 40, 5); assert!((p[3] - 0.5).abs() < 0.02, "opacity 50 → half alpha {p:?}"); assert!(s.execute("paint.cloneStamp", json!({"points": [[1, 1]], "source": [0, 0], "sampleLayer": "nope"})).is_err()); @@ -282,7 +283,18 @@ fn history_brush_errors_when_layer_is_new() { #[test] fn retouch_commands_are_registered_and_need_a_pixel_layer() { - let ids = ["paint.cloneStamp", "paint.healingBrush", "paint.spotHealing", "paint.dodge", "paint.burn", "paint.sponge", "paint.blur", "paint.sharpen", "paint.smudge", "paint.historyBrush"]; + let ids = [ + "paint.cloneStamp", + "paint.healingBrush", + "paint.spotHealing", + "paint.dodge", + "paint.burn", + "paint.sponge", + "paint.blur", + "paint.sharpen", + "paint.smudge", + "paint.historyBrush", + ]; for id in ids { let spec = crate::commands::find(id).unwrap_or_else(|| panic!("{id} missing")); assert!(spec.params.contains("\"points\"") && spec.params.contains("spacing"), "{id}"); @@ -293,4 +305,3 @@ fn retouch_commands_are_registered_and_need_a_pixel_layer() { assert!(s.execute("paint.dodge", json!({"points": [[1, 1]], "range": "bogus"})).is_err()); assert!(s.execute("paint.spotHealing", json!({"points": [[1, 1]], "type": "bogus"})).is_err()); } - diff --git a/crates/engine/src/selection_cmds.rs b/crates/engine/src/selection_cmds.rs index ccba3024b0..bd7a9049b7 100644 --- a/crates/engine/src/selection_cmds.rs +++ b/crates/engine/src/selection_cmds.rs @@ -95,7 +95,12 @@ fn parse_color(p: &Value) -> [f32; 3] { } fn color_range(s: &mut Session, p: &Value) -> Result { - let color = if p.get("color").is_some() { parse_color(p) } else { let f = s.tools.foreground; [f[0], f[1], f[2]] }; + let color = if p.get("color").is_some() { + parse_color(p) + } else { + let f = s.tools.foreground; + [f[0], f[1], f[2]] + }; let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true))?; let mask = sel::color_range(&px, color, f(p, "fuzziness", 40.0)); set_selection(s, "Color Range", area, mask, mode(p)) @@ -192,16 +197,39 @@ macro_rules! spec { /// Selection command specs. pub fn specs() -> Vec { vec![ - spec!("select.magicWand", "Magic Wand", [], r##"{"x":px,"y":px,"tolerance":0..255=32,"contiguous":bool=true,"antiAlias":bool=true,"sampleAllLayers":bool=false,"mode":"replace|add|subtract|intersect"="replace"}"##, has_doc, magic_wand), - spec!("select.colorRange", "Color Range…", ["Select"], r##"{"color":"#rrggbb"=foreground,"fuzziness":0..200=40,"sampleAllLayers":bool=true,"mode":"replace|add|subtract|intersect"="replace"}"##, has_doc, color_range), + spec!( + "select.magicWand", + "Magic Wand", + [], + r##"{"x":px,"y":px,"tolerance":0..255=32,"contiguous":bool=true,"antiAlias":bool=true,"sampleAllLayers":bool=false,"mode":"replace|add|subtract|intersect"="replace"}"##, + has_doc, + magic_wand + ), + spec!( + "select.colorRange", + "Color Range…", + ["Select"], + r##"{"color":"#rrggbb"=foreground,"fuzziness":0..200=40,"sampleAllLayers":bool=true,"mode":"replace|add|subtract|intersect"="replace"}"##, + has_doc, + color_range + ), spec!("select.modify.border", "Border…", ["Select", "Modify"], r##"{"radius":1..200=1}"##, has_selection, |s, p| modify(s, p, "border")), spec!("select.modify.smooth", "Smooth…", ["Select", "Modify"], r##"{"radius":1..500=1}"##, has_selection, |s, p| modify(s, p, "smooth")), spec!("select.modify.expand", "Expand…", ["Select", "Modify"], r##"{"radius":1..500=1}"##, has_selection, |s, p| modify(s, p, "expand")), spec!("select.modify.contract", "Contract…", ["Select", "Modify"], r##"{"radius":1..500=1}"##, has_selection, |s, p| modify(s, p, "contract")), spec!("select.modify.feather", "Feather…", ["Select", "Modify"], r##"{"radius":0.1..1000=1}"##, has_selection, |s, p| modify(s, p, "feather")), spec!("select.grow", "Grow", ["Select"], r##"{"tolerance":0..255=32,"sampleAllLayers":bool=false}"##, has_selection, |s, p| grow_similar(s, p, true)), - spec!("select.similar", "Similar", ["Select"], r##"{"tolerance":0..255=32,"sampleAllLayers":bool=false}"##, has_selection, |s, p| grow_similar(s, p, false)), - spec!("select.lasso", "Lasso", [], r##"{"points":[[x,y],…],"mode":"replace|add|subtract|intersect"="replace","antiAlias":bool=true,"feather":px=0}"##, has_doc, lasso), + spec!("select.similar", "Similar", ["Select"], r##"{"tolerance":0..255=32,"sampleAllLayers":bool=false}"##, has_selection, |s, p| grow_similar( + s, p, false + )), + spec!( + "select.lasso", + "Lasso", + [], + r##"{"points":[[x,y],…],"mode":"replace|add|subtract|intersect"="replace","antiAlias":bool=true,"feather":px=0}"##, + has_doc, + lasso + ), ] } diff --git a/crates/engine/src/slice_cmds.rs b/crates/engine/src/slice_cmds.rs index 52c879c8c7..67e2e5358e 100644 --- a/crates/engine/src/slice_cmds.rs +++ b/crates/engine/src/slice_cmds.rs @@ -62,7 +62,15 @@ pub fn layer_bounds(l: &Layer) -> Rect { let own = match &l.content { LayerContent::Group(g) => match &g.artboard { Some(a) => a.rect, - None => g.children.iter().filter(|c| c.visible).map(layer_bounds).fold(Rect::EMPTY, |a, b| if a.is_empty() { b } else if b.is_empty() { a } else { a.union(&b) }), + None => g.children.iter().filter(|c| c.visible).map(layer_bounds).fold(Rect::EMPTY, |a, b| { + if a.is_empty() { + b + } else if b.is_empty() { + a + } else { + a.union(&b) + } + }), }, _ => photocraft_doc::comps::position_bounds(l), }; @@ -403,7 +411,16 @@ fn layer_based(s: &mut Session, p: &Value) -> Result { let id = s.edit("New Layer Based Slice", |doc, _| { let id = doc.slices.next_id(); let text = |k: &str| p.get(k).and_then(Value::as_str).unwrap_or_default().to_string(); - doc.slices.list.push(Slice { id, origin: SliceOrigin::Layer, layer: Some(layer), rect, name: text("name"), url: text("url"), alt: text("alt"), ..Default::default() }); + doc.slices.list.push(Slice { + id, + origin: SliceOrigin::Layer, + layer: Some(layer), + rect, + name: text("name"), + url: text("url"), + alt: text("alt"), + ..Default::default() + }); Ok(id) })?; Ok(json!({"slice": id, "rect": [rect.x0, rect.y0, rect.width(), rect.height()]})) @@ -431,14 +448,53 @@ pub fn specs() -> Vec { }; } vec![ - spec!("slice.new", "Slice Tool", &[], r##"{"rect":[x,y,w,h] | "x","y","width","height", plus Slice Options ("name","kind":"image|noImage|table","url","target","message","alt","cellText","cellTextIsHtml","background":"none|#rrggbb")?} → {slice, number}"##, unlocked, new_slice), - spec!("slice.fromGuides", "Slices From Guides", &[], "{} (replaces every slice by the grid of the canvas guides) → {slices}", unlocked, |s, _| from_guides(s)), - spec!("slice.set", "Slice Options…", &[], r##"{"slice":id | "number":n (an auto slice is promoted), "name"?,"kind":"image|noImage|table"?,"url"?,"target"?,"message"?,"alt"?,"cellText"?,"cellTextIsHtml"?,"horizontalAlign":0..4?,"verticalAlign":0..4?,"background":"none|#rrggbb"?,"outsets":[t,l,b,r]? (layer slices),"rect":[x,y,w,h]? (move/resize; a layer slice becomes a user slice)} → the slice"##, unlocked, set_slice), + spec!( + "slice.new", + "Slice Tool", + &[], + r##"{"rect":[x,y,w,h] | "x","y","width","height", plus Slice Options ("name","kind":"image|noImage|table","url","target","message","alt","cellText","cellTextIsHtml","background":"none|#rrggbb")?} → {slice, number}"##, + unlocked, + new_slice + ), + spec!("slice.fromGuides", "Slices From Guides", &[], "{} (replaces every slice by the grid of the canvas guides) → {slices}", unlocked, |s, _| { + from_guides(s) + }), + spec!( + "slice.set", + "Slice Options…", + &[], + r##"{"slice":id | "number":n (an auto slice is promoted), "name"?,"kind":"image|noImage|table"?,"url"?,"target"?,"message"?,"alt"?,"cellText"?,"cellTextIsHtml"?,"horizontalAlign":0..4?,"verticalAlign":0..4?,"background":"none|#rrggbb"?,"outsets":[t,l,b,r]? (layer slices),"rect":[x,y,w,h]? (move/resize; a layer slice becomes a user slice)} → the slice"##, + unlocked, + set_slice + ), spec!("slice.promote", "Promote", &[], r##"{"slice":id | "number":n} (auto or layer-based → user slice) → {slice}"##, unlocked, promote), spec!("slice.delete", "Delete Slice", &[], r##"{"slice":id | "number":n | "slices":[id…]} → {deleted}"##, has_slices, delete), - spec!("slice.divide", "Divide Slice…", &[], r##"{"slice":id | "number":n,"horizontal":n=1 (slices down),"vertical":n=1 (slices across)} → {slices}"##, unlocked, divide), - CommandSpec { id: "slice.list", label: "List Slices", menu: &[], shortcut: None, params: "{} → {slices:[{number,id,origin:auto|layer|user,name,rect:[x,y,w,h],kind,layer,url,alt,…}], locked}", enabled: has_doc, journal: false, run: |s, _| list(s) }, - spec!("layer.newLayerBasedSlice", "New Layer Based Slice", &["Layer"], r##"{"layer":id?,"name":str?,"url":str?,"alt":str?} (follows the layer's bounds with effects) → {slice, rect}"##, has_layer_for_slice, layer_based), + spec!( + "slice.divide", + "Divide Slice…", + &[], + r##"{"slice":id | "number":n,"horizontal":n=1 (slices down),"vertical":n=1 (slices across)} → {slices}"##, + unlocked, + divide + ), + CommandSpec { + id: "slice.list", + label: "List Slices", + menu: &[], + shortcut: None, + params: "{} → {slices:[{number,id,origin:auto|layer|user,name,rect:[x,y,w,h],kind,layer,url,alt,…}], locked}", + enabled: has_doc, + journal: false, + run: |s, _| list(s), + }, + spec!( + "layer.newLayerBasedSlice", + "New Layer Based Slice", + &["Layer"], + r##"{"layer":id?,"name":str?,"url":str?,"alt":str?} (follows the layer's bounds with effects) → {slice, rect}"##, + has_layer_for_slice, + layer_based + ), spec!("view.lockSlices", "Lock Slices", &["View"], r##"{"on":bool? (default: toggle)} → {locked}"##, has_doc, lock), spec!("view.clearSlices", "Clear Slices", &["View"], "{} (deletes every user and layer-based slice) → {cleared}", has_slices, |s, _| clear(s)), ] diff --git a/crates/engine/src/smart_cmds.rs b/crates/engine/src/smart_cmds.rs index 1760aeb7b1..debcb61f40 100644 --- a/crates/engine/src/smart_cmds.rs +++ b/crates/engine/src/smart_cmds.rs @@ -179,12 +179,16 @@ pub fn stack_image(file_name: &str, bytes: &[u8], fmt: PixelFormat, mode: photoc all => all, }; let bounds = doc.bounds(); - let frames: Vec> = layers.iter().filter(|l| l.visible).map(|l| { - let mut one = l.clone(); - one.blend = BlendMode::Normal; - one.clipped = false; - photocraft_compose::render_layer(&one, bounds).px - }).collect(); + let frames: Vec> = layers + .iter() + .filter(|l| l.visible) + .map(|l| { + let mut one = l.clone(); + one.blend = BlendMode::Normal; + one.clipped = false; + photocraft_compose::render_layer(&one, bounds).px + }) + .collect(); let stat = match mode { photocraft_doc::StackMode::Entropy => Stat::Entropy, photocraft_doc::StackMode::Kurtosis => Stat::Kurtosis, @@ -382,7 +386,8 @@ pub(crate) fn add_smart_filter(doc: &mut Document, id: LayerId, sf: photocraft_d } let bounds = selection.map(Surface::content_bounds).filter(|b| !b.is_empty()).unwrap_or(canvas); let cache = sm.cache.as_ref().ok_or_else(|| other("smart object has no pixels"))?; - let out = crate::filters::apply_filter_to_surface(&sf.command, &sf.params, cache, bounds, selection, canvas).ok_or_else(|| other(format!("unknown filter {}", sf.command)))?; + let out = crate::filters::apply_filter_to_surface(&sf.command, &sf.params, cache, bounds, selection, canvas) + .ok_or_else(|| other(format!("unknown filter {}", sf.command)))?; sm.cache = Some(out); sm.smart_filters.push(sf); Ok(()) @@ -518,7 +523,10 @@ pub fn layer_to_smart(doc: &Document, l: &Layer) -> Result { cache_put(cache_key(&bytes, fmt), SourceImage { surface: Arc::new(translate_surface(&cache, -b.x0, -b.y0)), bounds: sub.bounds() }); let source = SmartSource::Embedded { file_name: sub.name.clone(), bytes: Arc::new(bytes) }; - let mut out = Layer::new(if background { "Layer 0".to_string() } else { l.name.clone() }, LayerContent::Smart(SmartObject::new(source, Affine::translate(b.x0 as f64, b.y0 as f64), Some(cache)))); + let mut out = Layer::new( + if background { "Layer 0".to_string() } else { l.name.clone() }, + LayerContent::Smart(SmartObject::new(source, Affine::translate(b.x0 as f64, b.y0 as f64), Some(cache))), + ); out.visible = l.visible; out.opacity = l.opacity; out.blend = if l.blend == BlendMode::PassThrough { BlendMode::Normal } else { l.blend }; @@ -818,7 +826,14 @@ pub fn specs() -> Vec { spec!("filter.convertForSmartFilters", "Convert for Smart Filters", &["Filter"], r##"{"layer":id?}"##, not_smart, convert), spec!("layer.smartObjects.newSmartObjectViaCopy", "New Smart Object via Copy", SO, r##"{"layer":id?}"##, has_smart, via_copy), spec!("layer.smartObjects.rasterize", "Rasterize", SO, r##"{"layer":id?}"##, has_smart, |s, p| s.execute("layer.rasterize.smartObject", p.clone())), - spec!("layer.smartObjects.editContents", "Edit Contents", SO, r##"{"layer":id?} → opens the contents as a new document; saving (layer.smartObjects.saveContents) or closing it updates the smart object"##, has_smart, edit_contents), + spec!( + "layer.smartObjects.editContents", + "Edit Contents", + SO, + r##"{"layer":id?} → opens the contents as a new document; saving (layer.smartObjects.saveContents) or closing it updates the smart object"##, + has_smart, + edit_contents + ), spec!("layer.smartObjects.saveContents", "Save Contents", &[], "{} (in an Edit Contents document)", is_smart_child, |s, _| { let i = s.active_index().ok_or(EngineError::NoDocument)?; Ok(json!({"updated": commit_child(s, i)?})) @@ -834,43 +849,78 @@ pub fn specs() -> Vec { s.edit("Update Modified Content", |doc, _| refresh_or_fail(doc, id))?; Ok(json!({"layer": id.0})) }), - spec!("layer.smartObjects.updateAllModifiedContent", "Update All Modified Content", SO, "{}", |s| s.active().map(|_| ()).ok_or_else(|| "no document open".into()), |s, _| update_all(s)), + spec!( + "layer.smartObjects.updateAllModifiedContent", + "Update All Modified Content", + SO, + "{}", + |s| s.active().map(|_| ()).ok_or_else(|| "no document open".into()), + |s, _| update_all(s) + ), spec!("layer.smartObjects.convertToEmbedded", "Convert to Embedded", SO, r##"{"layer":id?}"##, has_linked, |s, p| { set_source(s, p, "Convert to Embedded", true, |meta, src| { let (file_name, bytes) = source_bytes(meta, src).ok_or_else(|| other("the linked file can't be read"))?; Ok(SmartSource::Embedded { file_name, bytes }) }) }), - spec!("layer.smartObjects.convertToLinked", "Convert to Linked…", SO, r##"{"layer":id?,"path":str} (writes the contents there)"##, has_smart, |s, p| { - let path = path_param("layer.smartObjects.convertToLinked", p)?.to_string(); - set_source(s, p, "Convert to Linked", false, |meta, src| { - let (_, bytes) = source_bytes(meta, src).ok_or_else(|| other("the smart object's contents are unavailable"))?; - std::fs::write(&path, &*bytes).map_err(|e| other(format!("can't write {path}: {e}")))?; - Ok(SmartSource::Linked { path }) - }) - }), + spec!( + "layer.smartObjects.convertToLinked", + "Convert to Linked…", + SO, + r##"{"layer":id?,"path":str} (writes the contents there)"##, + has_smart, + |s, p| { + let path = path_param("layer.smartObjects.convertToLinked", p)?.to_string(); + set_source(s, p, "Convert to Linked", false, |meta, src| { + let (_, bytes) = source_bytes(meta, src).ok_or_else(|| other("the smart object's contents are unavailable"))?; + std::fs::write(&path, &*bytes).map_err(|e| other(format!("can't write {path}: {e}")))?; + Ok(SmartSource::Linked { path }) + }) + } + ), // Smart filters - spec!("layer.smartFilter.disableSmartFilters", "Disable Smart Filters", SF, r##"{"layer":id?,"enabled":bool? (default: toggle)}"##, has_smart_filters, |s, p| { - edit_filters(s, p, "Disable Smart Filters", |sm| { - sm.filters_enabled = p.get("enabled").and_then(Value::as_bool).unwrap_or(!sm.filters_enabled); - Ok(()) - }) - }), + spec!( + "layer.smartFilter.disableSmartFilters", + "Disable Smart Filters", + SF, + r##"{"layer":id?,"enabled":bool? (default: toggle)}"##, + has_smart_filters, + |s, p| { + edit_filters(s, p, "Disable Smart Filters", |sm| { + sm.filters_enabled = p.get("enabled").and_then(Value::as_bool).unwrap_or(!sm.filters_enabled); + Ok(()) + }) + } + ), spec!("layer.smartFilter.deleteFilterMask", "Delete Filter Mask", SF, r##"{"layer":id?}"##, has_filter_mask, |s, p| { edit_filters(s, p, "Delete Filter Mask", |sm| { sm.filter_mask = None; Ok(()) }) }), - spec!("layer.smartFilter.disableFilterMask", "Disable Filter Mask", SF, r##"{"layer":id?,"enabled":bool? (default: toggle)}"##, has_filter_mask, |s, p| { - edit_filters(s, p, "Disable Filter Mask", |sm| { - if let Some(m) = &mut sm.filter_mask { - m.enabled = p.get("enabled").and_then(Value::as_bool).unwrap_or(!m.enabled); - } - Ok(()) - }) - }), - spec!("layer.smartFilter.blendingOptions", "Blending Options…", SF, r##"{"layer":id?,"index":u32? (0 = bottom; default top),"blend":str?,"opacity":0..1?}"##, has_smart_filters, blending_options), + spec!( + "layer.smartFilter.disableFilterMask", + "Disable Filter Mask", + SF, + r##"{"layer":id?,"enabled":bool? (default: toggle)}"##, + has_filter_mask, + |s, p| { + edit_filters(s, p, "Disable Filter Mask", |sm| { + if let Some(m) = &mut sm.filter_mask { + m.enabled = p.get("enabled").and_then(Value::as_bool).unwrap_or(!m.enabled); + } + Ok(()) + }) + } + ), + spec!( + "layer.smartFilter.blendingOptions", + "Blending Options…", + SF, + r##"{"layer":id?,"index":u32? (0 = bottom; default top),"blend":str?,"opacity":0..1?}"##, + has_smart_filters, + blending_options + ), spec!("layer.smartFilter.clearSmartFilters", "Clear Smart Filters", SF, r##"{"layer":id?}"##, has_smart_filters, |s, p| { edit_filters(s, p, "Clear Smart Filters", |sm| { sm.smart_filters.clear(); @@ -878,8 +928,22 @@ pub fn specs() -> Vec { Ok(()) }) }), - spec!("layer.smartFilter.setVisible", "Show/Hide Smart Filter", &[], r##"{"layer":id?,"index":u32?,"visible":bool? (default: toggle)}"##, has_smart_filters, set_filter_visible), - spec!("layer.smartFilter.setParams", "Edit Smart Filter", &[], r##"{"layer":id?,"index":u32?,"params":{…} (merged)}"##, has_smart_filters, set_filter_params), + spec!( + "layer.smartFilter.setVisible", + "Show/Hide Smart Filter", + &[], + r##"{"layer":id?,"index":u32?,"visible":bool? (default: toggle)}"##, + has_smart_filters, + set_filter_visible + ), + spec!( + "layer.smartFilter.setParams", + "Edit Smart Filter", + &[], + r##"{"layer":id?,"index":u32?,"params":{…} (merged)}"##, + has_smart_filters, + set_filter_params + ), spec!("layer.smartFilter.delete", "Delete Smart Filter", &[], r##"{"layer":id?,"index":u32?}"##, has_smart_filters, delete_filter), spec!("layer.smartFilter.move", "Move Smart Filter", &[], r##"{"layer":id?,"index":u32?,"to":u32}"##, has_smart_filters, move_filter), ] diff --git a/crates/engine/src/smart_cmds/tests.rs b/crates/engine/src/smart_cmds/tests.rs index b5f83d03f2..313303bff1 100644 --- a/crates/engine/src/smart_cmds/tests.rs +++ b/crates/engine/src/smart_cmds/tests.rs @@ -358,7 +358,13 @@ fn pcraft_round_trip_keeps_live_smart_objects() { #[test] fn psd_placed_layer_renders_from_lnk2_data() { // A PNG embedded the way Photoshop stores placed files: a global lnk2 block keyed by uuid. - let mut inner = Document::with_background("in", Size::new(10, 8), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8, photocraft_color::Color::rgba(0.0, 1.0, 0.0, 1.0)); + let mut inner = Document::with_background( + "in", + Size::new(10, 8), + photocraft_color::ColorMode::Rgb, + photocraft_color::SampleType::U8, + photocraft_color::Color::rgba(0.0, 1.0, 0.0, 1.0), + ); inner.layers[0].surface_mut().unwrap().fill_rect(Rect::new(0, 0, 5, 8), &[1.0, 0.0, 0.0, 1.0]); let png = photocraft_io::export(&inner, "png", &Default::default()).unwrap().bytes; let lnk2 = photocraft_io::linked::encode_linked_file(&photocraft_io::linked::LinkedFile { uuid: "uuid-1".into(), file_name: "in.png".into(), bytes: png }); @@ -400,7 +406,13 @@ fn export_replace_and_linked_contents() { s.execute("layer.smartObjects.exportContents", json!({"path": out.to_str().unwrap()})).unwrap(); assert!(photocraft_format::is_pcraft(&std::fs::read(&out).unwrap())); // Replace with a flat PNG. - let png_doc = Document::with_background("p", Size::new(4, 4), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8, photocraft_color::Color::rgba(1.0, 1.0, 0.0, 1.0)); + let png_doc = Document::with_background( + "p", + Size::new(4, 4), + photocraft_color::ColorMode::Rgb, + photocraft_color::SampleType::U8, + photocraft_color::Color::rgba(1.0, 1.0, 0.0, 1.0), + ); let png = dir.join("yellow.png"); std::fs::write(&png, photocraft_io::export(&png_doc, "png", &Default::default()).unwrap().bytes).unwrap(); s.execute("layer.smartObjects.replaceContents", json!({"path": png.to_str().unwrap()})).unwrap(); diff --git a/crates/engine/src/smartselect_cmds.rs b/crates/engine/src/smartselect_cmds.rs index 1ad708b3f9..d3c2024e2d 100644 --- a/crates/engine/src/smartselect_cmds.rs +++ b/crates/engine/src/smartselect_cmds.rs @@ -173,7 +173,8 @@ fn refine_edge(s: &mut Session, p: &Value) -> Result { "layerMask" => { let id = active.ok_or_else(|| EngineError::Other("no active layer".into()))?; s.edit("Select and Mask", |doc, _| { - let surface = region.as_ref().map(matting::region_surface).unwrap_or_else(|| photocraft_raster::Surface::new(photocraft_color::PixelFormat::GRAY8)); + let surface = + region.as_ref().map(matting::region_surface).unwrap_or_else(|| photocraft_raster::Surface::new(photocraft_color::PixelFormat::GRAY8)); doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?.mask = Some(LayerMask { surface, ..LayerMask::reveal_all() }); doc.selection = None; Ok(()) @@ -183,7 +184,9 @@ fn refine_edge(s: &mut Session, p: &Value) -> Result { _ => { let id = active.ok_or_else(|| EngineError::Other("no active layer".into()))?; let src_layer = d.doc.layer(id).ok_or(EngineError::NoLayer(id))?; - let src = src_layer.surface().ok_or_else(|| EngineError::Other(format!("the active layer is a {} layer without pixels", src_layer.content.kind_name())))?; + let src = src_layer + .surface() + .ok_or_else(|| EngineError::Other(format!("the active layer is a {} layer without pixels", src_layer.content.kind_name())))?; let name = format!("{} copy", src_layer.name); let empty = Region { bbox: Rect::EMPTY, mask: Vec::new() }; let reg = region.as_ref().unwrap_or(&empty); @@ -219,9 +222,30 @@ macro_rules! spec { /// Smart selection command specs. pub fn specs() -> Vec { vec![ - spec!("select.quick", "Quick Selection", [], r##"{"points":[[x,y],…],"size":px=30,"mode":"add|subtract|replace"="add","sampleAllLayers":bool=false,"enhanceEdge":bool=false}"##, has_doc, quick_selection), - spec!("select.object", "Object Selection", [], r##"{"rect":[x,y,w,h],"mode":"replace|add|subtract|intersect"="replace","sampleAllLayers":bool=false}"##, has_doc, object_selection), - spec!("select.subject", "Subject", ["Select"], r##"{"sampleAllLayers":bool=true,"mode":"replace|add|subtract|intersect"="replace"}"##, has_doc, select_subject), + spec!( + "select.quick", + "Quick Selection", + [], + r##"{"points":[[x,y],…],"size":px=30,"mode":"add|subtract|replace"="add","sampleAllLayers":bool=false,"enhanceEdge":bool=false}"##, + has_doc, + quick_selection + ), + spec!( + "select.object", + "Object Selection", + [], + r##"{"rect":[x,y,w,h],"mode":"replace|add|subtract|intersect"="replace","sampleAllLayers":bool=false}"##, + has_doc, + object_selection + ), + spec!( + "select.subject", + "Subject", + ["Select"], + r##"{"sampleAllLayers":bool=true,"mode":"replace|add|subtract|intersect"="replace"}"##, + has_doc, + select_subject + ), spec!( "select.refineEdge", "Refine Edge", @@ -230,7 +254,14 @@ pub fn specs() -> Vec { has_selection, refine_edge ), - spec!("select.focusArea", "Focus Area…", ["Select"], r##"{"range":0..1=0.5,"noise":0..1=0,"sampleAllLayers":bool=true,"mode":"replace|add|subtract|intersect"="replace"}"##, has_doc, focus_area), + spec!( + "select.focusArea", + "Focus Area…", + ["Select"], + r##"{"range":0..1=0.5,"noise":0..1=0,"sampleAllLayers":bool=true,"mode":"replace|add|subtract|intersect"="replace"}"##, + has_doc, + focus_area + ), ] } @@ -390,11 +421,12 @@ mod tests { } fn soft_count(s: &photocraft_raster::Surface, y: i32) -> usize { - (0..80).filter(|x| { - let v = s.sample_channel(*x, y, 0); - v > 0.05 && v < 0.95 - }) - .count() + (0..80) + .filter(|x| { + let v = s.sample_channel(*x, y, 0); + v > 0.05 && v < 0.95 + }) + .count() } #[test] diff --git a/crates/engine/src/snap.rs b/crates/engine/src/snap.rs index 7d2c2e3687..036f5e35a3 100644 --- a/crates/engine/src/snap.rs +++ b/crates/engine/src/snap.rs @@ -151,7 +151,12 @@ impl SnapTargets { /// Only the targets for which `keep` holds (e.g. smart guides: layer alignments). pub fn filtered(&self, keep: impl Fn(SnapKind) -> bool) -> SnapTargets { - SnapTargets { x: self.x.iter().copied().filter(|t| keep(t.kind)).collect(), y: self.y.iter().copied().filter(|t| keep(t.kind)).collect(), grid: self.grid.filter(|_| keep(SnapKind::Grid)), extent: self.extent } + SnapTargets { + x: self.x.iter().copied().filter(|t| keep(t.kind)).collect(), + y: self.y.iter().copied().filter(|t| keep(t.kind)).collect(), + grid: self.grid.filter(|_| keep(SnapKind::Grid)), + extent: self.extent, + } } pub fn is_empty(&self) -> bool { @@ -225,7 +230,9 @@ impl SnapTargets { let moved = v + d; let list = if vertical { &self.x } else { &self.y }; let hit = list.iter().copied().filter(|t| (t.pos - moved).abs() < 1e-6).min_by_key(|t| u8::from(t.kind != SnapKind::Guide)); - let hit = hit.or_else(|| self.grid.filter(|s| ((moved / s).round() * s - moved).abs() < 1e-6).map(|_| Target { pos: moved, kind: SnapKind::Grid, span: other })); + let hit = hit.or_else(|| { + self.grid.filter(|s| ((moved / s).round() * s - moved).abs() < 1e-6).map(|_| Target { pos: moved, kind: SnapKind::Grid, span: other }) + }); if let Some(t) = hit { lines.push(line(vertical, t, other.0, other.1)); } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 5d824bec64..f0e2f746ac 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -91,7 +91,8 @@ fn adjustment_layers_change_composite() { #[test] fn every_adjustment_command_runs() { let mut s = session_with_doc(); - let ids: Vec<&str> = command_specs().iter().map(|c| c.id).filter(|id| id.starts_with("layer.newAdjustmentLayer.") || id.starts_with("image.adjustments.")).collect(); + let ids: Vec<&str> = + command_specs().iter().map(|c| c.id).filter(|id| id.starts_with("layer.newAdjustmentLayer.") || id.starts_with("image.adjustments.")).collect(); assert!(ids.len() >= 28); for id in ids { // re-select the background for destructive ones diff --git a/crates/engine/src/timeline_cmds.rs b/crates/engine/src/timeline_cmds.rs index ca7a318780..9f8f5a99eb 100644 --- a/crates/engine/src/timeline_cmds.rs +++ b/crates/engine/src/timeline_cmds.rs @@ -47,7 +47,8 @@ fn delete(s: &mut Session) -> Result { } fn set_frame(s: &mut Session, p: &Value) -> Result { - let f = p.get("frame").and_then(Value::as_u64).ok_or_else(|| EngineError::BadParams { cmd: "timeline.setFrame".into(), msg: "need `frame`".into() })? as usize; + let f = + p.get("frame").and_then(Value::as_u64).ok_or_else(|| EngineError::BadParams { cmd: "timeline.setFrame".into(), msg: "need `frame`".into() })? as usize; with_timeline(s, |t| { if let Some(t) = t { t.current = f; @@ -85,7 +86,9 @@ fn set_props(s: &mut Session, p: &Value) -> Result { fn info(s: &mut Session) -> Result { let st = s.active().ok_or(EngineError::NoDocument)?; Ok(match &st.doc.timeline { - Some(t) => json!({"timeline": {"fps": t.fps, "duration": t.duration, "current": t.current, "workStart": t.work_start, "workEnd": t.work_end, "time": t.time()}}), + Some(t) => { + json!({"timeline": {"fps": t.fps, "duration": t.duration, "current": t.current, "workStart": t.work_start, "workEnd": t.work_end, "time": t.time()}}) + } None => json!({"timeline": null}), }) } diff --git a/crates/engine/src/transform_cmds.rs b/crates/engine/src/transform_cmds.rs index 96f3920c47..b344cc5d7b 100644 --- a/crates/engine/src/transform_cmds.rs +++ b/crates/engine/src/transform_cmds.rs @@ -250,7 +250,8 @@ fn transform(s: &mut Session, p: &Value) -> Result { return Err(bad("the transform collapses the layer")); } let m = h.0; - let affine = (m[6].abs() < 1e-12 && m[7].abs() < 1e-12).then(|| Affine { m: [m[0] / m[8], m[3] / m[8], m[1] / m[8], m[4] / m[8], m[2] / m[8], m[5] / m[8]] }); + let affine = + (m[6].abs() < 1e-12 && m[7].abs() < 1e-12).then(|| Affine { m: [m[0] / m[8], m[3] / m[8], m[1] / m[8], m[4] / m[8], m[2] / m[8], m[5] / m[8]] }); let interp = Interp::parse(p.get("interpolation").and_then(Value::as_str).unwrap_or("bicubic")); s.edit("Free Transform", |doc, _| { let sel = doc.selection.clone(); diff --git a/crates/engine/src/trap_cmds.rs b/crates/engine/src/trap_cmds.rs index d435586205..ca63e6cb7d 100644 --- a/crates/engine/src/trap_cmds.rs +++ b/crates/engine/src/trap_cmds.rs @@ -75,14 +75,21 @@ mod tests { for y in 0..4 { for x in 0..8 { let px = &mut data[(y * 8 + x) * ch..][..ch]; - if x < 4 { px[0] = 1.0; } else { px[1] = 1.0; } - if fmt.alpha { px[ch - 1] = 1.0; } + if x < 4 { + px[0] = 1.0; + } else { + px[1] = 1.0; + } + if fmt.alpha { + px[ch - 1] = 1.0; + } } } surf.write_region(Rect::new(0, 0, 8, 4), &data); } Ok(()) - }).unwrap(); + }) + .unwrap(); let r = s.execute("image.trap", json!({"width": 1})).unwrap(); assert_eq!(r["trapped"], true); diff --git a/crates/engine/src/type_cmds.rs b/crates/engine/src/type_cmds.rs index bb0db0a98b..5656a8f2c8 100644 --- a/crates/engine/src/type_cmds.rs +++ b/crates/engine/src/type_cmds.rs @@ -10,8 +10,7 @@ use std::sync::Arc; use photocraft_color::Color; use photocraft_doc::text::{ - AntiAlias, Caps, CharStyle, FontFeature, FontVariation, Kerning, ParagraphRun, ParagraphStyle, - TextAlign, TextDirection, TextRun, TextShape, + AntiAlias, Caps, CharStyle, FontFeature, FontVariation, Kerning, ParagraphRun, ParagraphStyle, TextAlign, TextDirection, TextRun, TextShape, }; use photocraft_doc::{Affine, Document, Layer, LayerContent, LayerId, TextLayer}; use serde_json::{Value, json}; @@ -20,26 +19,18 @@ use crate::commands::CommandSpec; use crate::{EngineError, Result, Session}; fn has_doc(s: &Session) -> std::result::Result<(), String> { - s.active() - .map(|_| ()) - .ok_or_else(|| "no document open".into()) + s.active().map(|_| ()).ok_or_else(|| "no document open".into()) } fn layer_id(s: &Session, p: &Value) -> Result { match p.get("layer").and_then(Value::as_u64) { Some(id) => Ok(LayerId(id)), - None => s - .active() - .and_then(|d| d.active_layer) - .ok_or(EngineError::Other("no active layer".into())), + None => s.active().and_then(|d| d.active_layer).ok_or(EngineError::Other("no active layer".into())), } } fn bad(cmd: &str, msg: impl Into) -> EngineError { - EngineError::BadParams { - cmd: cmd.into(), - msg: msg.into(), - } + EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } } fn f32p(p: &Value, k: &str) -> Option { @@ -50,20 +41,11 @@ fn color(v: &Value) -> Option { match v { Value::Array(a) if a.len() >= 3 => { let c: Vec = a.iter().map(|x| x.as_f64().unwrap_or(0.0) as f32).collect(); - Some(Color::rgba( - c[0], - c[1], - c[2], - c.get(3).copied().unwrap_or(1.0), - )) + Some(Color::rgba(c[0], c[1], c[2], c.get(3).copied().unwrap_or(1.0))) } Value::String(s) => { let s = s.trim_start_matches('#'); - let h = |i: usize| { - s.get(i..i + 2) - .and_then(|x| u8::from_str_radix(x, 16).ok()) - .map(|v| f32::from(v) / 255.0) - }; + let h = |i: usize| s.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok()).map(|v| f32::from(v) / 255.0); match s.len() { 6 => Some(Color::rgb(h(0)?, h(2)?, h(4)?)), 8 => Some(Color::rgba(h(0)?, h(2)?, h(4)?, h(6)?)), @@ -104,11 +86,7 @@ fn range_param(text: &str, p: &Value) -> (usize, usize) { pub fn apply_char_props(s: &mut CharStyle, p: &Value) -> bool { let mut any = false; let mut hit = |b: bool| any |= b; - if let Some(v) = p - .get("font") - .or_else(|| p.get("family")) - .and_then(Value::as_str) - { + if let Some(v) = p.get("font").or_else(|| p.get("family")).and_then(Value::as_str) { s.font_family = v.to_string(); s.postscript_name = None; hit(true); @@ -160,17 +138,9 @@ pub fn apply_char_props(s: &mut CharStyle, p: &Value) -> bool { } _ => {} } - for (k, f) in [ - ("baselineShift", &mut s.baseline_shift_pt), - ("horizontalScale", &mut s.horizontal_scale), - ("verticalScale", &mut s.vertical_scale), - ] { + for (k, f) in [("baselineShift", &mut s.baseline_shift_pt), ("horizontalScale", &mut s.horizontal_scale), ("verticalScale", &mut s.vertical_scale)] { if let Some(v) = f32p(p, k) { - *f = if k == "baselineShift" { - v - } else { - (v / 100.0).max(0.01) - }; + *f = if k == "baselineShift" { v } else { (v / 100.0).max(0.01) }; hit(true); } } @@ -205,16 +175,9 @@ pub fn apply_char_props(s: &mut CharStyle, p: &Value) -> bool { } if let Some(Value::Object(m)) = p.get("features") { for (tag, v) in m { - let value = v - .as_u64() - .map(|x| x as u16) - .or_else(|| v.as_bool().map(u16::from)) - .unwrap_or(1); + let value = v.as_u64().map(|x| x as u16).or_else(|| v.as_bool().map(u16::from)).unwrap_or(1); s.features.retain(|f| &f.tag != tag); - s.features.push(FontFeature { - tag: tag.clone(), - value, - }); + s.features.push(FontFeature { tag: tag.clone(), value }); } hit(true); } @@ -222,10 +185,7 @@ pub fn apply_char_props(s: &mut CharStyle, p: &Value) -> bool { for (axis, v) in m { s.variations.retain(|f| &f.axis != axis); if let Some(x) = v.as_f64() { - s.variations.push(FontVariation { - axis: axis.clone(), - value: x as f32, - }); + s.variations.push(FontVariation { axis: axis.clone(), value: x as f32 }); } } hit(true); @@ -312,10 +272,7 @@ pub fn style_range(t: &mut TextLayer, a: usize, b: usize, f: &dyn Fn(&mut CharSt push_merge(&mut out, cb - ca, st); push_merge(&mut out, re - cb, r.style); } - t.runs = out - .into_iter() - .map(|(len, style)| TextRun { len, style }) - .collect(); + t.runs = out.into_iter().map(|(len, style)| TextRun { len, style }).collect(); t.sync_summary(); } @@ -330,16 +287,11 @@ pub fn style_paragraphs(t: &mut TextLayer, a: usize, b: usize, f: &dyn Fn(&mut P Some(s) }) .collect(); - let style_at = |off: usize| { - old[starts.iter().rposition(|&s| s <= off).unwrap_or(0)] - .style - .clone() - }; + let style_at = |off: usize| old[starts.iter().rposition(|&s| s <= off).unwrap_or(0)].style.clone(); let mut out: Vec<(usize, ParagraphStyle)> = Vec::new(); for pr in photocraft_text::layout::split_paragraphs(&t.text) { let mut st = style_at(pr.start); - let touches = (pr.start < b && pr.end > a) - || (a == b && a >= pr.start && (a < pr.end || pr.end == t.text.len())); + let touches = (pr.start < b && pr.end > a) || (a == b && a >= pr.start && (a < pr.end || pr.end == t.text.len())); if touches { f(&mut st); } @@ -348,21 +300,13 @@ pub fn style_paragraphs(t: &mut TextLayer, a: usize, b: usize, f: &dyn Fn(&mut P out.push((0, st)); } } - t.paragraphs = out - .into_iter() - .map(|(len, style)| ParagraphRun { len, style }) - .collect(); + t.paragraphs = out.into_iter().map(|(len, style)| ParagraphRun { len, style }).collect(); } /// Replaces bytes `a..b` with `new`, keeping styles: inserted text takes the style of the /// character before it (or after it at the start). pub fn replace_text(t: &mut TextLayer, a: usize, b: usize, new: &str) { - fn adjust( - runs: Vec<(usize, S)>, - a: usize, - b: usize, - ins: usize, - ) -> Vec<(usize, S)> { + fn adjust(runs: Vec<(usize, S)>, a: usize, b: usize, ins: usize) -> Vec<(usize, S)> { let mut out: Vec<(usize, S)> = Vec::new(); let mut at = 0; let host = runs @@ -372,13 +316,7 @@ pub fn replace_text(t: &mut TextLayer, a: usize, b: usize, new: &str) { *acc += r.0; Some((s, s + r.0)) }) - .position(|(s, e)| { - if a == 0 { - e > 0 || s == 0 - } else { - a > s && a <= e - } - }) + .position(|(s, e)| if a == 0 { e > 0 || s == 0 } else { a > s && a <= e }) .unwrap_or(0); for (i, (len, st)) in runs.into_iter().enumerate() { let (rs, re) = (at, at + len); @@ -389,21 +327,10 @@ pub fn replace_text(t: &mut TextLayer, a: usize, b: usize, new: &str) { } out } - let (a, b) = ( - a.min(t.text.len()), - b.min(t.text.len()).max(a.min(t.text.len())), - ); + let (a, b) = (a.min(t.text.len()), b.min(t.text.len()).max(a.min(t.text.len()))); let new = norm_text(new); - let cr: Vec<(usize, CharStyle)> = t - .char_runs() - .into_iter() - .map(|r| (r.len, r.style)) - .collect(); - let pr: Vec<(usize, ParagraphStyle)> = t - .paragraph_runs() - .into_iter() - .map(|r| (r.len, r.style)) - .collect(); + let cr: Vec<(usize, CharStyle)> = t.char_runs().into_iter().map(|r| (r.len, r.style)).collect(); + let pr: Vec<(usize, ParagraphStyle)> = t.paragraph_runs().into_iter().map(|r| (r.len, r.style)).collect(); let first_c = cr.first().map(|r| r.1.clone()).unwrap_or_default(); let first_p = pr.first().map(|r| r.1.clone()).unwrap_or_default(); let mut cr = adjust(cr, a, b, new.len()); @@ -415,48 +342,27 @@ pub fn replace_text(t: &mut TextLayer, a: usize, b: usize, new: &str) { if pr.is_empty() { pr.push((t.text.len(), first_p)); } - t.runs = cr - .into_iter() - .map(|(len, style)| TextRun { len, style }) - .collect(); - t.paragraphs = pr - .into_iter() - .map(|(len, style)| ParagraphRun { len, style }) - .collect(); + t.runs = cr.into_iter().map(|(len, style)| TextRun { len, style }).collect(); + t.paragraphs = pr.into_iter().map(|(len, style)| ParagraphRun { len, style }).collect(); t.sync_summary(); } /// Re-renders the layer's pixels and regenerates its PSD `TySh` data. pub fn refresh(doc: &Document, t: &mut TextLayer) { let dpi = doc.resolution_dpi; - let mut eng = photocraft_text::shared() - .lock() - .unwrap_or_else(|e| e.into_inner()); + let mut eng = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner()); let (layout, r) = eng.render(t, dpi, doc.pixel_format()); t.cache = Some(r.surface); - t.psd_raw = Some(Arc::new(photocraft_text::psd::build_tysh( - t, - dpi, - layout.bounds(), - ))); + t.psd_raw = Some(Arc::new(photocraft_text::psd::build_tysh(t, dpi, layout.bounds()))); } -fn with_text_layer( - s: &mut Session, - p: &Value, - label: &str, - f: impl FnOnce(&mut TextLayer, &Document) -> Result, -) -> Result { +fn with_text_layer(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut TextLayer, &Document) -> Result) -> Result { let id = layer_id(s, p)?; s.edit(label, |doc, _| { let snapshot = doc.clone(); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; let LayerContent::Text(t) = &mut l.content else { - return Err(EngineError::Other(format!( - "layer {} is a {} layer, not a type layer", - id.0, - l.content.kind_name() - ))); + return Err(EngineError::Other(format!("layer {} is a {} layer, not a type layer", id.0, l.content.kind_name()))); }; let r = f(t, &snapshot)?; refresh(&snapshot, t); @@ -467,11 +373,7 @@ fn with_text_layer( fn layer_name(text: &str) -> String { let first = text.lines().next().unwrap_or("").trim(); let name: String = first.chars().take(40).collect(); - if name.is_empty() { - "Type Layer".into() - } else { - name - } + if name.is_empty() { "Type Layer".into() } else { name } } fn info(s: &Session, p: &Value) -> Result { @@ -479,10 +381,7 @@ fn info(s: &Session, p: &Value) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; let l = d.doc.layer(id).ok_or(EngineError::NoLayer(id))?; let LayerContent::Text(t) = &l.content else { - return Err(EngineError::Other(format!( - "layer {} is not a type layer", - id.0 - ))); + return Err(EngineError::Other(format!("layer {} is not a type layer", id.0))); }; let text = &t.text; let mut at = 0; @@ -505,20 +404,13 @@ fn info(s: &Session, p: &Value) -> Result { v }) .collect(); - let layout = photocraft_text::shared() - .lock() - .unwrap_or_else(|e| e.into_inner()) - .layout(t, d.doc.resolution_dpi); + let layout = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner()).layout(t, d.doc.resolution_dpi); let lines: Vec = layout .lines .iter() .map(|ln| json!({ "start": char_at(text, ln.range.start), "end": char_at(text, ln.range.end), "baseline": ln.baseline, "x0": ln.x0, "x1": ln.x1, "ascent": ln.ascent, "descent": ln.descent })) .collect(); - let bounds = t - .cache - .as_ref() - .map(|c| c.content_bounds()) - .map(|r| json!([r.x0, r.y0, r.x1, r.y1])); + let bounds = t.cache.as_ref().map(|c| c.content_bounds()).map(|r| json!([r.x0, r.y0, r.x1, r.y1])); Ok(json!({ "layer": id.0, "text": text, @@ -754,11 +646,7 @@ mod tests { fn session() -> Session { let mut s = Session::new(); - s.execute( - "file.new", - json!({"width": 200, "height": 100, "background": "transparent"}), - ) - .unwrap(); + s.execute("file.new", json!({"width": 200, "height": 100, "background": "transparent"})).unwrap(); s } @@ -772,18 +660,10 @@ mod tests { #[test] fn create_renders_and_is_undoable() { let mut s = session(); - let r = s - .execute( - "type.create", - json!({"x": 10, "y": 50, "text": "Hello", "size": 24, "color": "#ff0000"}), - ) - .unwrap(); + let r = s.execute("type.create", json!({"x": 10, "y": 50, "text": "Hello", "size": 24, "color": "#ff0000"})).unwrap(); let id = r["layer"].as_u64().unwrap(); let b = r["bounds"].as_array().unwrap(); - assert!( - b[0].as_i64().unwrap() >= 9 && b[3].as_i64().unwrap() <= 56, - "{b:?}" - ); + assert!(b[0].as_i64().unwrap() >= 9 && b[3].as_i64().unwrap() <= 56, "{b:?}"); let t = text_layer(&s, id); assert_eq!(t.text, "Hello"); assert_eq!(t.runs[0].style.size_pt, 24.0); @@ -793,14 +673,9 @@ mod tests { // Red pixels exist. let c = t.cache.as_ref().unwrap(); let r = c.content_bounds(); - assert!( - c.read_region(r) - .chunks_exact(4) - .any(|p| p[3] > 0.9 && p[0] > 0.9 && p[1] < 0.1) - ); + assert!(c.read_region(r).chunks_exact(4).any(|p| p[3] > 0.9 && p[0] > 0.9 && p[1] < 0.1)); // The TySh we generated parses back to the same model. - let back = - photocraft_text::psd::text_layer_from_tysh(t.psd_raw.as_ref().unwrap(), 72.0).unwrap(); + let back = photocraft_text::psd::text_layer_from_tysh(t.psd_raw.as_ref().unwrap(), 72.0).unwrap(); assert_eq!(back.text, "Hello"); let strip = |mut r: Vec| { for x in &mut r { @@ -809,10 +684,7 @@ mod tests { r }; assert_eq!(strip(back.char_runs()), strip(t.char_runs())); - assert_eq!( - back.char_runs()[0].style.postscript_name.as_deref(), - Some("Inter-Regular") - ); + assert_eq!(back.char_runs()[0].style.postscript_name.as_deref(), Some("Inter-Regular")); assert!(s.undo()); assert!(s.active().unwrap().doc.layer(LayerId(id)).is_none()); } @@ -820,45 +692,24 @@ mod tests { #[test] fn edit_replace_keeps_styles() { let mut s = session(); - let id = s - .execute( - "type.create", - json!({"x": 0, "y": 40, "text": "Hello world"}), - ) - .unwrap()["layer"] - .as_u64() - .unwrap(); - s.execute( - "type.setStyle", - json!({"layer": id, "range": [6, 11], "fauxBold": true, "color": [0, 0, 1]}), - ) - .unwrap(); + let id = s.execute("type.create", json!({"x": 0, "y": 40, "text": "Hello world"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("type.setStyle", json!({"layer": id, "range": [6, 11], "fauxBold": true, "color": [0, 0, 1]})).unwrap(); let t = text_layer(&s, id); assert_eq!(t.runs.len(), 2); assert_eq!(t.runs[0].len, 6); assert!(t.runs[1].style.faux_bold); // Insert inside the bold word: inherits bold. - s.execute( - "type.edit", - json!({"layer": id, "replace": {"start": 8, "end": 8, "text": "XX"}}), - ) - .unwrap(); + s.execute("type.edit", json!({"layer": id, "replace": {"start": 8, "end": 8, "text": "XX"}})).unwrap(); let t = text_layer(&s, id); assert_eq!(t.text, "Hello woXXrld"); assert_eq!(t.runs[1].len, 7); // Delete across the style boundary. - s.execute( - "type.edit", - json!({"layer": id, "replace": {"start": 3, "end": 8, "text": ""}}), - ) - .unwrap(); + s.execute("type.edit", json!({"layer": id, "replace": {"start": 3, "end": 8, "text": ""}})).unwrap(); let t = text_layer(&s, id); assert_eq!(t.text, "HelXXrld"); assert_eq!(t.runs.iter().map(|r| r.len).collect::>(), vec![3, 5]); // Whole-text replace with a multi-byte string. - let info = s - .execute("type.edit", json!({"layer": id, "text": "Größe ✓\nzwei"})) - .unwrap(); + let info = s.execute("type.edit", json!({"layer": id, "text": "Größe ✓\nzwei"})).unwrap(); assert_eq!(info["lines"].as_array().unwrap().len(), 2); let t = text_layer(&s, id); assert_eq!(t.runs.iter().map(|r| r.len).sum::(), t.text.len()); @@ -867,17 +718,10 @@ mod tests { #[test] fn set_style_paragraph_and_box() { let mut s = session(); - let id = s - .execute("type.create", json!({"box": [10, 10, 80, 80], "text": "one two three four five six seven", "size": 12})) - .unwrap()["layer"] + let id = s.execute("type.create", json!({"box": [10, 10, 80, 80], "text": "one two three four five six seven", "size": 12})).unwrap()["layer"] .as_u64() .unwrap(); - let info = s - .execute( - "type.setStyle", - json!({"layer": id, "align": "center", "leading": 20}), - ) - .unwrap(); + let info = s.execute("type.setStyle", json!({"layer": id, "align": "center", "leading": 20})).unwrap(); let lines = info["lines"].as_array().unwrap(); assert!(lines.len() >= 3); let b0 = lines[0]["baseline"].as_f64().unwrap(); @@ -890,10 +734,8 @@ mod tests { let r = t.cache.as_ref().unwrap().content_bounds(); assert!(r.x0 >= 9 && r.x1 <= 91, "{r:?}"); // Convert to point text and move. - s.execute("type.edit", json!({"layer": id, "point": [5, 30]})) - .unwrap(); - s.execute("type.edit", json!({"layer": id, "move": [10, 0]})) - .unwrap(); + s.execute("type.edit", json!({"layer": id, "point": [5, 30]})).unwrap(); + s.execute("type.edit", json!({"layer": id, "move": [10, 0]})).unwrap(); let t = text_layer(&s, id); assert_eq!(t.shape, TextShape::Point); assert_eq!(t.transform.m[4], 15.0); @@ -902,27 +744,14 @@ mod tests { #[test] fn rasterize_and_fonts() { let mut s = session(); - let id = s - .execute("type.create", json!({"x": 5, "y": 30, "text": "Raster"})) - .unwrap()["layer"] - .as_u64() - .unwrap(); + let id = s.execute("type.create", json!({"x": 5, "y": 30, "text": "Raster"})).unwrap()["layer"].as_u64().unwrap(); s.execute("type.rasterize", json!({"layer": id})).unwrap(); let l = s.active().unwrap().doc.layer(LayerId(id)).unwrap(); assert!(matches!(l.content, LayerContent::Raster(_))); assert!(l.surface().unwrap().content_bounds().width() > 10); - assert!( - s.execute("type.edit", json!({"layer": id, "text": "x"})) - .is_err() - ); + assert!(s.execute("type.edit", json!({"layer": id, "text": "x"})).is_err()); let fonts = s.execute("type.fonts", json!({})).unwrap(); - assert!( - fonts["families"] - .as_array() - .unwrap() - .iter() - .any(|f| f == "Inter") - ); + assert!(fonts["families"].as_array().unwrap().iter().any(|f| f == "Inter")); let faces = s.execute("type.fonts", json!({"family": "Inter"})).unwrap(); assert!(!faces["faces"].as_array().unwrap().is_empty()); } diff --git a/crates/engine/src/type_extra_cmds.rs b/crates/engine/src/type_extra_cmds.rs index 941c8f166e..2032d30790 100644 --- a/crates/engine/src/type_extra_cmds.rs +++ b/crates/engine/src/type_extra_cmds.rs @@ -192,10 +192,13 @@ fn to_point(s: &mut Session, p: &Value) -> Result { fn signed_area(knots: &[Knot]) -> f64 { let n = knots.len(); - (0..n).map(|i| { - let (a, b) = (knots[i].anchor, knots[(i + 1) % n].anchor); - a.x * b.y - b.x * a.y - }).sum::() / 2.0 + (0..n) + .map(|i| { + let (a, b) = (knots[i].anchor, knots[(i + 1) % n].anchor); + a.x * b.y - b.x * a.y + }) + .sum::() + / 2.0 } /// One glyph's outline elements as closed subpaths. @@ -297,7 +300,8 @@ fn convert_to_shape(s: &mut Session, p: &Value) -> Result { fn warp_text(s: &mut Session, p: &Value) -> Result { let cmd = "type.warpText"; let style = p.get("style").and_then(Value::as_str).unwrap_or("arc"); - let psd = photocraft_text::warp::psd_style(style).ok_or_else(|| EngineError::BadParams { cmd: cmd.into(), msg: format!("unknown warp style \"{style}\"") })?; + let psd = + photocraft_text::warp::psd_style(style).ok_or_else(|| EngineError::BadParams { cmd: cmd.into(), msg: format!("unknown warp style \"{style}\"") })?; let f = |k: &str, d: f64| p.get(k).and_then(Value::as_f64).unwrap_or(d).clamp(-100.0, 100.0) as f32; let warp = (psd != "warpNone").then(|| TextWarp { style: psd.into(), @@ -497,7 +501,14 @@ pub fn specs() -> Vec { spec!("type.antiAlias.windowsLcd", "Windows LCD", &["Type", "Anti-Alias"], LP, has_text, |s, p| set_aa(s, p, AntiAlias::WindowsLcd)), spec!("type.antiAlias.windows", "Windows", &["Type", "Anti-Alias"], LP, has_text, |s, p| set_aa(s, p, AntiAlias::Windows)), spec!("type.orientation.horizontal", "Horizontal", &["Type", "Orientation"], LP, has_text, |s, p| set_orientation(s, p, Orientation::Horizontal)), - spec!("type.orientation.vertical", "Vertical", &["Type", "Orientation"], r##"{"layer":id?} (stored and saved; rendered horizontally for now)"##, has_text, |s, p| set_orientation(s, p, Orientation::Vertical)), + spec!( + "type.orientation.vertical", + "Vertical", + &["Type", "Orientation"], + r##"{"layer":id?} (stored and saved; rendered horizontally for now)"##, + has_text, + |s, p| set_orientation(s, p, Orientation::Vertical) + ), spec!("type.openType.standardLigatures", "Standard Ligatures", &["Type", "OpenType"], OT, has_text, |s, p| toggle_opentype(s, p, "liga")), spec!("type.openType.contextualAlternates", "Contextual Alternates", &["Type", "OpenType"], OT, has_text, |s, p| toggle_opentype(s, p, "calt")), spec!("type.openType.discretionaryLigatures", "Discretionary Ligatures", &["Type", "OpenType"], OT, has_text, |s, p| toggle_opentype(s, p, "dlig")), @@ -513,12 +524,44 @@ pub fn specs() -> Vec { spec!("type.rasterizeTypeLayer", "Rasterize Type Layer", &["Type"], LP, has_text, |s, p| s.execute("type.rasterize", p.clone())), spec!("type.convertToParagraphText", "Convert to Paragraph Text", &["Type"], LP, has_point_text, to_paragraph), spec!("type.convertToPointText", "Convert to Point Text", &["Type"], LP, has_box_text, to_point), - spec!("type.warpText", "Warp Text…", &["Type"], r##"{"layer":id?,"style":"none|arc|arcLower|arcUpper|arch|bulge|shellLower|shellUpper|flag|wave|fish|rise|fisheye|inflate|squeeze|twist"="arc","bend":-100..100=50,"horizontalDistortion":-100..100=0,"verticalDistortion":-100..100=0,"orientation":"horizontal|vertical"="horizontal"}"##, has_text, warp_text), + spec!( + "type.warpText", + "Warp Text…", + &["Type"], + r##"{"layer":id?,"style":"none|arc|arcLower|arcUpper|arch|bulge|shellLower|shellUpper|flag|wave|fish|rise|fisheye|inflate|squeeze|twist"="arc","bend":-100..100=50,"horizontalDistortion":-100..100=0,"verticalDistortion":-100..100=0,"orientation":"horizontal|vertical"="horizontal"}"##, + has_text, + warp_text + ), spec!("type.updateAllTextLayers", "Update All Text Layers", &["Type"], "{}", any_text, |s, _| update_all(s)), - spec!("type.replaceAllMissingFonts", "Replace All Missing Fonts", &["Type"], "{} (with the default family)", any_text, |s, _| replace_fonts(s, &serde_json::Map::new(), true)), - spec!("type.resolveMissingFonts", "Resolve Missing Fonts…", &["Type"], r##"{"map":{"Missing Family":"Installed Family"}?} (no map: list the missing families)"##, any_text, resolve_missing), - spec!("type.pasteLoremIpsum", "Paste Lorem Ipsum", &["Type"], r##"{"layer":id?,"at":char? (default end),"new":bool=false (new paragraph text layer)}"##, has_doc, paste_lorem), - spec!("type.saveDefaultTypeStyles", "Save Default Type Styles", &["Type"], "{} (from the active type layer; new type layers start from them)", has_text, |s, _| save_defaults(s)), + spec!("type.replaceAllMissingFonts", "Replace All Missing Fonts", &["Type"], "{} (with the default family)", any_text, |s, _| replace_fonts( + s, + &serde_json::Map::new(), + true + )), + spec!( + "type.resolveMissingFonts", + "Resolve Missing Fonts…", + &["Type"], + r##"{"map":{"Missing Family":"Installed Family"}?} (no map: list the missing families)"##, + any_text, + resolve_missing + ), + spec!( + "type.pasteLoremIpsum", + "Paste Lorem Ipsum", + &["Type"], + r##"{"layer":id?,"at":char? (default end),"new":bool=false (new paragraph text layer)}"##, + has_doc, + paste_lorem + ), + spec!( + "type.saveDefaultTypeStyles", + "Save Default Type Styles", + &["Type"], + "{} (from the active type layer; new type layers start from them)", + has_text, + |s, _| save_defaults(s) + ), spec!("type.loadDefaultTypeStyles", "Load Default Type Styles", &["Type"], LP, has_defaults, load_defaults), ] } diff --git a/crates/engine/src/type_extra_cmds/tests.rs b/crates/engine/src/type_extra_cmds/tests.rs index df26a2182e..eda5908213 100644 --- a/crates/engine/src/type_extra_cmds/tests.rs +++ b/crates/engine/src/type_extra_cmds/tests.rs @@ -27,7 +27,12 @@ fn anti_alias_and_orientation_are_one_step_each() { let mut s = session(8); assert!(!s.is_enabled("type.antiAlias.crisp")); let id = text_layer(&mut s, "Aa"); - for (cmd, aa) in [("type.antiAlias.crisp", AntiAlias::Crisp), ("type.antiAlias.windows", AntiAlias::Windows), ("type.antiAlias.windowsLcd", AntiAlias::WindowsLcd), ("type.antiAlias.none", AntiAlias::None)] { + for (cmd, aa) in [ + ("type.antiAlias.crisp", AntiAlias::Crisp), + ("type.antiAlias.windows", AntiAlias::Windows), + ("type.antiAlias.windowsLcd", AntiAlias::WindowsLcd), + ("type.antiAlias.none", AntiAlias::None), + ] { s.execute(cmd, json!({})).unwrap(); assert_eq!(text(&s, id).antialias, aa); } diff --git a/crates/engine/src/type_spell_cmds.rs b/crates/engine/src/type_spell_cmds.rs index 507a903a1b..26e801839b 100644 --- a/crates/engine/src/type_spell_cmds.rs +++ b/crates/engine/src/type_spell_cmds.rs @@ -77,7 +77,9 @@ fn list(s: &Session, p: &Value) -> Result { for id in layers_param(s, p) { let Some(t) = text(&st.doc, id) else { continue }; for m in dict.misspellings(t, &ok) { - out.push(json!({ "layer": id.0, "start": char_at(t, m.start), "end": char_at(t, m.end), "word": m.word, "suggestions": dict.suggest(&m.word, max) })); + out.push( + json!({ "layer": id.0, "start": char_at(t, m.start), "end": char_at(t, m.end), "word": m.word, "suggestions": dict.suggest(&m.word, max) }), + ); } } Ok(json!({ "misspellings": out, "count": out.len() })) @@ -142,7 +144,13 @@ fn change_all(s: &mut Session, p: &Value) -> Result { } fn add_word(s: &mut Session, p: &Value) -> Result { - let word = p.get("word").and_then(Value::as_str).map(str::trim).filter(|w| !w.is_empty() && !w.contains(char::is_whitespace)).ok_or_else(|| bad("edit.checkSpelling", "`add` needs a single `word`"))?.to_string(); + let word = p + .get("word") + .and_then(Value::as_str) + .map(str::trim) + .filter(|w| !w.is_empty() && !w.contains(char::is_whitespace)) + .ok_or_else(|| bad("edit.checkSpelling", "`add` needs a single `word`"))? + .to_string(); let added = s.edit_prefs(|pr| { if pr.user_dictionary.iter().any(|w| w.eq_ignore_ascii_case(&word)) { false @@ -176,7 +184,9 @@ fn check_spelling(s: &mut Session, p: &Value) -> Result { let w = p.get("word").and_then(Value::as_str).unwrap_or(""); let ok = accepted(s, p); let d = Dictionary::english(); - Ok(json!({ "word": w, "correct": d.check(w, &ok), "suggestions": d.suggest(w, p.get("suggestions").and_then(Value::as_u64).unwrap_or(5) as usize) })) + Ok( + json!({ "word": w, "correct": d.check(w, &ok), "suggestions": d.suggest(w, p.get("suggestions").and_then(Value::as_u64).unwrap_or(5) as usize) }), + ) } other => Err(bad("edit.checkSpelling", format!("unknown action {other:?}"))), } diff --git a/crates/engine/src/type_styles_cmds.rs b/crates/engine/src/type_styles_cmds.rs index bc8d38dd9e..d677df50f0 100644 --- a/crates/engine/src/type_styles_cmds.rs +++ b/crates/engine/src/type_styles_cmds.rs @@ -204,11 +204,14 @@ fn restyle(t: &mut TextLayer, old: &TextStyles, new: &TextStyles, range: Option< let whole = range.is_none(); // Paragraphs. let old_paras = t.paragraph_runs(); - let starts: Vec = old_paras.iter().scan(0, |acc, r| { - let s = *acc; - *acc += r.len; - Some(s) - }).collect(); + let starts: Vec = old_paras + .iter() + .scan(0, |acc, r| { + let s = *acc; + *acc += r.len; + Some(s) + }) + .collect(); let para_at = |off: usize| old_paras[starts.iter().rposition(|&s| s <= off).unwrap_or(0)].style.clone(); let mut paras: Vec<(std::ops::Range, ParagraphStyle, Option)> = Vec::new(); // range, new style, old ref for pr in photocraft_text::layout::split_paragraphs(&text) { @@ -433,7 +436,11 @@ fn new_style(s: &mut Session, p: &Value, paragraph: bool) -> Result { ca = c; } } - let name = p.get("name").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| st.unique_name(paragraph, if paragraph { "Paragraph Style" } else { "Character Style" })); + let name = p + .get("name") + .and_then(Value::as_str) + .map(str::to_string) + .unwrap_or_else(|| st.unique_name(paragraph, if paragraph { "Paragraph Style" } else { "Character Style" })); let id = if paragraph { let id = st.next_para_id(); st.paragraph.push(ParagraphStyleDef { id, name: name.clone(), para_attrs: pa, char_attrs: ca }); @@ -553,7 +560,8 @@ fn set_options(s: &mut Session, p: &Value, paragraph: bool) -> Result { let id = lookup_id(cmd, &st, p, paragraph, paragraph)?; let (ca, pa) = parse_attrs(cmd, p.get("attrs").unwrap_or(&Value::Null))?; let replace = p.get("replace").and_then(Value::as_bool).unwrap_or(false); - let clear: Vec = p.get("clear").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default(); + let clear: Vec = + p.get("clear").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default(); let name = p.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()).map(str::to_string); let merge = |dst: &mut StyleAttrs, src: StyleAttrs| { if replace { @@ -604,7 +612,11 @@ fn apply(s: &mut Session, p: &Value, paragraph: bool) -> Result { let target = target_ranges(s, p)?; let clear = p.get("clearOverrides").and_then(Value::as_bool).unwrap_or(false); let r = Some(id).filter(|i| *i != 0); - let op = if paragraph { Op { set_para: Some(r), clear_para: clear, clear_char: clear, ..Op::default() } } else { Op { set_char: Some(r), clear_char: clear, ..Op::default() } }; + let op = if paragraph { + Op { set_para: Some(r), clear_para: clear, clear_char: clear, ..Op::default() } + } else { + Op { set_char: Some(r), clear_char: clear, ..Op::default() } + }; let n = commit(s, if paragraph { "Apply Paragraph Style" } else { "Apply Character Style" }, st, &target, op)?; Ok(json!({ "id": id, "layers": n, "current": current(s, p) })) } diff --git a/crates/engine/src/type_styles_cmds/tests.rs b/crates/engine/src/type_styles_cmds/tests.rs index 2b4a5f4a77..2164580490 100644 --- a/crates/engine/src/type_styles_cmds/tests.rs +++ b/crates/engine/src/type_styles_cmds/tests.rs @@ -96,7 +96,8 @@ fn paragraph_styles_and_basic_paragraph() { // A fresh layer is Basic Paragraph with no paragraph override. let cur = s.execute("type.paragraphStyle.list", json!({"layer": id})).unwrap()["current"].clone(); assert_eq!((cur["paragraph"].as_u64(), cur["paragraphOverride"].as_bool()), (Some(0), Some(false))); - let r = s.execute("type.paragraphStyle.new", json!({"name": "Head", "fromSelection": false, "attrs": {"align": "center", "size": 24, "weight": 700}})).unwrap(); + let r = + s.execute("type.paragraphStyle.new", json!({"name": "Head", "fromSelection": false, "attrs": {"align": "center", "size": 24, "weight": 700}})).unwrap(); let ps = r["id"].as_u64().unwrap() as u32; s.execute("type.paragraphStyle.apply", json!({"id": ps, "layer": id, "range": [0, 1]})).unwrap(); let t = layer(&s, id); diff --git a/crates/engine/src/variables_cmds.rs b/crates/engine/src/variables_cmds.rs index cc9fd58a8f..c219e8da58 100644 --- a/crates/engine/src/variables_cmds.rs +++ b/crates/engine/src/variables_cmds.rs @@ -7,11 +7,8 @@ use std::sync::Arc; use photocraft_algo::resample::{Resample, resize_surface, translate_surface}; -use photocraft_doc::{ - DataSet, DataValue, Document, LayerContent, LayerId, PixelAlign, PixelMethod, VarKind, VariableDef, - Variables, -}; use photocraft_doc::variables::Value as VarValue; +use photocraft_doc::{DataSet, DataValue, Document, LayerContent, LayerId, PixelAlign, PixelMethod, VarKind, VariableDef, Variables}; use serde_json::{Value, json}; use crate::commands::CommandSpec; @@ -201,11 +198,7 @@ fn replace_pixels(doc: &mut Document, layer: LayerId, path: &str, method: PixelM PixelMethod::Conform => (tw / iw, th / ih), PixelMethod::AsIs => (1.0, 1.0), }; - let scaled = if (sx - 1.0).abs() > 1e-9 || (sy - 1.0).abs() > 1e-9 { - resize_surface(&img, sx, sy, Resample::Bicubic) - } else { - img - }; + let scaled = if (sx - 1.0).abs() > 1e-9 || (sy - 1.0).abs() > 1e-9 { resize_surface(&img, sx, sy, Resample::Bicubic) } else { img }; let (sw, sh) = (iw * sx, ih * sy); // Aligned placement within the target rect. let fx = match align { @@ -348,10 +341,7 @@ fn export_as_files(s: &mut Session, p: &Value) -> Result { let mut doc = (*original).clone(); apply_to_doc(&mut doc, &vars, set)?; let safe: String = set.name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect(); - let stem = template - .replace("{name}", &safe) - .replace("{index}", &format!("{:03}", n + 1)) - .replace("{document}", &doc_stem); + let stem = template.replace("{name}", &safe).replace("{index}", &format!("{:03}", n + 1)).replace("{document}", &doc_stem); let path = format!("{dir}/{stem}.{format}"); let opts = photocraft_io::ExportOptions::default(); let bytes = photocraft_io::export(&doc, format, &opts).map(|r| r.bytes).map_err(|e| EngineError::Other(format!("export `{}`: {e}", set.name)))?; @@ -389,23 +379,51 @@ macro_rules! spec { pub fn specs() -> Vec { vec![ - spec!("image.variables.define", "Define…", &["Image", "Variables"], + spec!( + "image.variables.define", + "Define…", + &["Image", "Variables"], "{defs:[{name, layer:id, type:visibility|textReplacement|pixelReplacement, method?:fit|fill|asIs|conform, align?, clip?}]} → {defs,dataSets,active}", - |s, p| define(s, p)), - spec!("image.variables.dataSets", "Data Sets…", &["Image", "Variables"], + |s, p| define(s, p) + ), + spec!( + "image.variables.dataSets", + "Data Sets…", + &["Image", "Variables"], "{dataSets:[{name, values:[{variable, kind:visibility|text|pixels, value}]}], append?} → {defs,dataSets,active}", - |s, p| data_sets(s, p)), - spec!("image.applyDataSet", "Apply Data Set…", &["Image"], + |s, p| data_sets(s, p) + ), + spec!( + "image.applyDataSet", + "Apply Data Set…", + &["Image"], "{name|index} → {applied, index}: sets layer visibility/text/pixels from the data set (one history step)", - |s, p| apply_data_set(s, p)), - spec!("file.import.variableDataSets", "Variable Data Sets…", &["File", "Import"], + |s, p| apply_data_set(s, p) + ), + spec!( + "file.import.variableDataSets", + "Variable Data Sets…", + &["File", "Import"], "{path, delimiter?} → {imported}: CSV header = variable names, each row a data set (first column may be the data-set name)", - |s, p| import_data_sets(s, p)), - spec!("file.export.dataSetsAsFiles", "Data Sets as Files…", &["File", "Export"], + |s, p| import_data_sets(s, p) + ), + spec!( + "file.export.dataSetsAsFiles", + "Data Sets as Files…", + &["File", "Export"], "{dir, format?:png, dataSets?[names], naming?:\"{name}|{index}|{document}\"} → {files,count}: apply each data set and export the flattened document", - |s, p| export_as_files(s, p)), - CommandSpec { id: "variables.list", label: "List Variables", menu: &[], shortcut: None, journal: false, - params: "{} → {defs,dataSets,active}", enabled: has_doc, run: |s, _| list(s) }, + |s, p| export_as_files(s, p) + ), + CommandSpec { + id: "variables.list", + label: "List Variables", + menu: &[], + shortcut: None, + journal: false, + params: "{} → {defs,dataSets,active}", + enabled: has_doc, + run: |s, _| list(s), + }, ] } diff --git a/crates/engine/src/variables_cmds/tests.rs b/crates/engine/src/variables_cmds/tests.rs index 0acbb1a547..55ff20540d 100644 --- a/crates/engine/src/variables_cmds/tests.rs +++ b/crates/engine/src/variables_cmds/tests.rs @@ -44,16 +44,24 @@ fn tmp(name: &str) -> String { #[test] fn define_apply_visibility_and_text() { let (mut s, _photo, badge, title) = session(); - s.execute("image.variables.define", json!({"defs": [ - {"name": "showBadge", "layer": badge.0, "type": "visibility"}, - {"name": "headline", "layer": title.0, "type": "textReplacement"}, - ]})).unwrap(); - s.execute("image.variables.dataSets", json!({"dataSets": [ - {"name": "A", "values": [ - {"variable": "showBadge", "kind": "visibility", "value": false}, - {"variable": "headline", "kind": "text", "value": "Hello"}, - ]}, - ]})).unwrap(); + s.execute( + "image.variables.define", + json!({"defs": [ + {"name": "showBadge", "layer": badge.0, "type": "visibility"}, + {"name": "headline", "layer": title.0, "type": "textReplacement"}, + ]}), + ) + .unwrap(); + s.execute( + "image.variables.dataSets", + json!({"dataSets": [ + {"name": "A", "values": [ + {"variable": "showBadge", "kind": "visibility", "value": false}, + {"variable": "headline", "kind": "text", "value": "Hello"}, + ]}, + ]}), + ) + .unwrap(); assert!(doc(&s).layer(badge).unwrap().visible); assert_eq!(text_of(&s, title), "Old"); @@ -86,10 +94,14 @@ fn bad_params_are_errors() { #[test] fn csv_import_and_apply() { let (mut s, _p, badge, title) = session(); - s.execute("image.variables.define", json!({"defs": [ - {"name": "showBadge", "layer": badge.0, "type": "visibility"}, - {"name": "headline", "layer": title.0, "type": "textReplacement"}, - ]})).unwrap(); + s.execute( + "image.variables.define", + json!({"defs": [ + {"name": "showBadge", "layer": badge.0, "type": "visibility"}, + {"name": "headline", "layer": title.0, "type": "textReplacement"}, + ]}), + ) + .unwrap(); let dir = tmp("csv"); let csv = format!("{dir}/sets.csv"); std::fs::write(&csv, "DataSet,showBadge,headline\nrow-on,true,On Sale\nrow-off,false,Sold Out\n").unwrap(); @@ -108,14 +120,22 @@ fn csv_import_and_apply() { #[test] fn export_data_sets_as_files() { let (mut s, _p, badge, title) = session(); - s.execute("image.variables.define", json!({"defs": [ - {"name": "showBadge", "layer": badge.0, "type": "visibility"}, - {"name": "headline", "layer": title.0, "type": "textReplacement"}, - ]})).unwrap(); - s.execute("image.variables.dataSets", json!({"dataSets": [ - {"name": "one", "values": [{"variable": "headline", "kind": "text", "value": "1"}]}, - {"name": "two", "values": [{"variable": "showBadge", "kind": "visibility", "value": false}]}, - ]})).unwrap(); + s.execute( + "image.variables.define", + json!({"defs": [ + {"name": "showBadge", "layer": badge.0, "type": "visibility"}, + {"name": "headline", "layer": title.0, "type": "textReplacement"}, + ]}), + ) + .unwrap(); + s.execute( + "image.variables.dataSets", + json!({"dataSets": [ + {"name": "one", "values": [{"variable": "headline", "kind": "text", "value": "1"}]}, + {"name": "two", "values": [{"variable": "showBadge", "kind": "visibility", "value": false}]}, + ]}), + ) + .unwrap(); let dir = tmp("out"); let r = s.execute("file.export.dataSetsAsFiles", json!({"dir": dir, "format": "png"})).unwrap(); assert_eq!(r["count"], 2); @@ -143,12 +163,20 @@ fn pixel_replacement_changes_the_layer() { let bytes = photocraft_io::export(&src.active().unwrap().doc, "png", &photocraft_io::ExportOptions::default()).unwrap().bytes; std::fs::write(&png, bytes).unwrap(); } - s.execute("image.variables.define", json!({"defs": [ - {"name": "img", "layer": photo.0, "type": "pixelReplacement", "method": "conform"}, - ]})).unwrap(); - s.execute("image.variables.dataSets", json!({"dataSets": [ - {"name": "blue", "values": [{"variable": "img", "kind": "pixels", "value": png}]}, - ]})).unwrap(); + s.execute( + "image.variables.define", + json!({"defs": [ + {"name": "img", "layer": photo.0, "type": "pixelReplacement", "method": "conform"}, + ]}), + ) + .unwrap(); + s.execute( + "image.variables.dataSets", + json!({"dataSets": [ + {"name": "blue", "values": [{"variable": "img", "kind": "pixels", "value": png}]}, + ]}), + ) + .unwrap(); // Before: grey. let before = s.execute("document.pixel", json!({"x": 32, "y": 24})).unwrap(); s.execute("image.applyDataSet", json!({"name": "blue"})).unwrap(); diff --git a/crates/engine/src/vector_cmds.rs b/crates/engine/src/vector_cmds.rs index 9d6f55b89a..104d0ac245 100644 --- a/crates/engine/src/vector_cmds.rs +++ b/crates/engine/src/vector_cmds.rs @@ -19,8 +19,8 @@ use photocraft_algo::selection::{self as sel, SelectionMode}; use photocraft_color::{BlendMode, Color}; use photocraft_doc::{ - Document, Fill, FillRule, GradientStyle, Knot, Layer, LayerContent, LayerId, LineCap, LineJoin, LiveShape, NamedPath, Path, PathOp, - ShapeLayer, ShapeStroke, StrokeAlign, Subpath, VectorMask, + Document, Fill, FillRule, GradientStyle, Knot, Layer, LayerContent, LayerId, LineCap, LineJoin, LiveShape, NamedPath, Path, PathOp, ShapeLayer, + ShapeStroke, StrokeAlign, Subpath, VectorMask, }; use photocraft_geom::{Affine, Point, Rect}; use photocraft_vector as vector; @@ -223,7 +223,14 @@ fn parse_fill(v: &Value) -> std::result::Result, String> { })); } if let Some(name) = v.get("pattern").and_then(Value::as_str) { - return Ok(Some(Fill::Pattern { name: name.into(), scale: f64p(v, "scale").unwrap_or(100.0) as f32 / 100.0, id: String::new(), angle: 0.0, link: true, phase: (0.0, 0.0) })); + return Ok(Some(Fill::Pattern { + name: name.into(), + scale: f64p(v, "scale").unwrap_or(100.0) as f32 / 100.0, + id: String::new(), + angle: 0.0, + link: true, + phase: (0.0, 0.0), + })); } Err(format!("unrecognised fill {v}")) } @@ -611,7 +618,9 @@ fn paths_list(s: &Session) -> Result { let knots = |p: &Path| p.subpaths.iter().map(|s| s.knots.len()).sum::(); let layer = d.active_layer.and_then(|id| doc.layer(id)).and_then(|l| match &l.content { LayerContent::Shape(sh) => Some(json!({ "kind": "shape", "layer": l.id.0, "name": format!("{} Shape Path", l.name), "knots": knots(&sh.path) })), - _ => l.vector_mask.as_ref().map(|v| json!({ "kind": "vectorMask", "layer": l.id.0, "name": format!("{} Vector Mask", l.name), "knots": knots(&v.path) })), + _ => { + l.vector_mask.as_ref().map(|v| json!({ "kind": "vectorMask", "layer": l.id.0, "name": format!("{} Vector Mask", l.name), "knots": knots(&v.path) })) + } }); Ok(json!({ "paths": doc.paths.iter().map(|p| json!({ "name": p.name, "knots": knots(&p.path), "subpaths": p.path.subpaths.len() })).collect::>(), @@ -828,7 +837,9 @@ fn path_stroke(s: &mut Session, p: &Value) -> Result { let mut dmg = Rect::EMPTY; for pl in &lines { let mut pts: Vec = pl.pts.iter().map(|&(x, y)| photocraft_paint::StrokePoint::new(x, y, 1.0)).collect(); - if pl.closed && let Some(first) = pts.first().copied() { + if pl.closed + && let Some(first) = pts.first().copied() + { pts.push(first); } if pts.is_empty() { @@ -850,7 +861,9 @@ fn vector_mask_info(s: &Session, id: LayerId) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; let l = d.doc.layer(id).ok_or(EngineError::NoLayer(id))?; Ok(match &l.vector_mask { - Some(v) => json!({ "layer": id.0, "path": path_json(&v.path), "enabled": v.enabled, "linked": v.linked, "density": v.density * 100.0, "feather": v.feather }), + Some(v) => { + json!({ "layer": id.0, "path": path_json(&v.path), "enabled": v.enabled, "linked": v.linked, "density": v.density * 100.0, "feather": v.feather }) + } None => json!({ "layer": id.0, "vectorMask": null }), }) } @@ -940,7 +953,8 @@ fn vector_mask_rasterize(s: &mut Session, p: &Value) -> Result { let vals = vector::vector_mask_values(&vm, area); let mut mask = l.mask.take().unwrap_or_else(|| { let mut m = photocraft_doc::LayerMask::reveal_all(); - m.surface = photocraft_raster::Surface::with_default(photocraft_color::PixelFormat::new(photocraft_color::ColorMode::Grayscale, depth, false), &[1.0]); + m.surface = + photocraft_raster::Surface::with_default(photocraft_color::PixelFormat::new(photocraft_color::ColorMode::Grayscale, depth, false), &[1.0]); m }); // Outside the canvas the vector mask is 0 unless it is empty/inverted; keep the old default there. @@ -1096,7 +1110,9 @@ pub fn specs() -> Vec { "path.set", "Set Path", [], - leak(format!(r##"{{"name":str|"work"="work","path":{{…}},"op":"combine|subtract|intersect|exclude"? (append to the existing path with this op instead of replacing)}}. {PATH_FORM}"##)), + leak(format!( + r##"{{"name":str|"work"="work","path":{{…}},"op":"combine|subtract|intersect|exclude"? (append to the existing path with this op instead of replacing)}}. {PATH_FORM}"## + )), has_doc, path_set ), @@ -1152,18 +1168,54 @@ pub fn specs() -> Vec { vector_mask_edit ), spec!("layer.vectorMask.delete", "Delete Vector Mask", ["Layer", "Vector Mask"], r##"{"layer":id?}"##, has_layer, vector_mask_delete), - spec!("layer.rasterize.vectorMask", "Rasterize Vector Mask", [], r##"{"layer":id?} (multiplied into the layer mask)"##, has_layer, vector_mask_rasterize), + spec!( + "layer.rasterize.vectorMask", + "Rasterize Vector Mask", + [], + r##"{"layer":id?} (multiplied into the layer mask)"##, + has_layer, + vector_mask_rasterize + ), // Menu ids of Photoshop's Layer › Vector Mask / Rasterize / Combine Shapes. - spec!("layer.vectorMask.revealAll", "Vector Mask: Reveal All", [], r##"{"layer":id?}"##, has_layer, |s, p| vector_mask_add(s, &with(p, "hide", json!(false)), false)), - spec!("layer.vectorMask.hideAll", "Vector Mask: Hide All", [], r##"{"layer":id?}"##, has_layer, |s, p| vector_mask_add(s, &with(p, "hide", json!(true)), false)), - spec!("layer.vectorMask.currentPath", "Vector Mask: Current Path", [], r##"{"layer":id?,"name":str|"work"="work"}"##, has_layer, |s, p| vector_mask_add(s, p, true)), - spec!("layer.vectorMask.enabled", "Enable Vector Mask", [], r##"{"layer":id?,"enabled":bool? (default: toggle)}"##, has_layer, |s, p| toggle_vector_mask(s, p, "enabled")), - spec!("layer.vectorMask.linked", "Link Vector Mask", [], r##"{"layer":id?,"linked":bool? (default: toggle)}"##, has_layer, |s, p| toggle_vector_mask(s, p, "linked")), + spec!("layer.vectorMask.revealAll", "Vector Mask: Reveal All", [], r##"{"layer":id?}"##, has_layer, |s, p| vector_mask_add( + s, + &with(p, "hide", json!(false)), + false + )), + spec!("layer.vectorMask.hideAll", "Vector Mask: Hide All", [], r##"{"layer":id?}"##, has_layer, |s, p| vector_mask_add( + s, + &with(p, "hide", json!(true)), + false + )), + spec!("layer.vectorMask.currentPath", "Vector Mask: Current Path", [], r##"{"layer":id?,"name":str|"work"="work"}"##, has_layer, |s, p| { + vector_mask_add(s, p, true) + }), + spec!("layer.vectorMask.enabled", "Enable Vector Mask", [], r##"{"layer":id?,"enabled":bool? (default: toggle)}"##, has_layer, |s, p| { + toggle_vector_mask(s, p, "enabled") + }), + spec!("layer.vectorMask.linked", "Link Vector Mask", [], r##"{"layer":id?,"linked":bool? (default: toggle)}"##, has_layer, |s, p| toggle_vector_mask( + s, p, "linked" + )), spec!("layer.rasterize.shape", "Rasterize Shape", [], r##"{"layer":id?}"##, has_layer, shape_rasterize), - spec!("layer.combineShapes.unite", "Unite Shapes", [], r##"{"layer":id?,"subpath":index? (default all but the first)}"##, has_layer, |s, p| shape_edit(s, &with(p, "op", json!("combine")))), - spec!("layer.combineShapes.subtractFrontShape", "Subtract Front Shape", [], r##"{"layer":id?,"subpath":index?}"##, has_layer, |s, p| shape_edit(s, &with(p, "op", json!("subtract")))), - spec!("layer.combineShapes.intersectShapeAreas", "Intersect Shape Areas", [], r##"{"layer":id?,"subpath":index?}"##, has_layer, |s, p| shape_edit(s, &with(p, "op", json!("intersect")))), - spec!("layer.combineShapes.excludeOverlappingShapes", "Exclude Overlapping Shapes", [], r##"{"layer":id?,"subpath":index?}"##, has_layer, |s, p| shape_edit(s, &with(p, "op", json!("exclude")))), + spec!( + "layer.combineShapes.unite", + "Unite Shapes", + [], + r##"{"layer":id?,"subpath":index? (default all but the first)}"##, + has_layer, + |s, p| shape_edit(s, &with(p, "op", json!("combine"))) + ), + spec!("layer.combineShapes.subtractFrontShape", "Subtract Front Shape", [], r##"{"layer":id?,"subpath":index?}"##, has_layer, |s, p| shape_edit( + s, + &with(p, "op", json!("subtract")) + )), + spec!("layer.combineShapes.intersectShapeAreas", "Intersect Shape Areas", [], r##"{"layer":id?,"subpath":index?}"##, has_layer, |s, p| shape_edit( + s, + &with(p, "op", json!("intersect")) + )), + spec!("layer.combineShapes.excludeOverlappingShapes", "Exclude Overlapping Shapes", [], r##"{"layer":id?,"subpath":index?}"##, has_layer, |s, p| { + shape_edit(s, &with(p, "op", json!("exclude"))) + }), spec!( "layer.combineShapes.mergeShapeComponents", "Merge Shape Components", diff --git a/crates/engine/src/vector_cmds/tests.rs b/crates/engine/src/vector_cmds/tests.rs index 78f6cdec46..dfce1dc06b 100644 --- a/crates/engine/src/vector_cmds/tests.rs +++ b/crates/engine/src/vector_cmds/tests.rs @@ -73,7 +73,10 @@ fn shape_create_every_kind_and_undo() { fn shape_create_fill_stroke_and_composite() { let mut s = session(100, 100, 8); let r = s - .execute("shape.create", json!({"kind": "rect", "rect": [20, 20, 60, 60], "fill": "#ff0000", "stroke": {"width": 4, "color": "#0000ff", "align": "outside"}})) + .execute( + "shape.create", + json!({"kind": "rect", "rect": [20, 20, 60, 60], "fill": "#ff0000", "stroke": {"width": 4, "color": "#0000ff", "align": "outside"}}), + ) .unwrap(); assert_eq!(r["fill"], "#ff0000"); assert_eq!(r["stroke"]["align"], "outside"); @@ -110,7 +113,12 @@ fn shape_edit_live_params_and_undo() { assert_eq!(r["kind"], "path"); // Fill/stroke edits merge. s.execute("shape.edit", json!({"layer": id, "stroke": {"width": 3}})).unwrap(); - let r = s.execute("shape.edit", json!({"layer": id, "stroke": {"cap": "round", "dashes": [2, 1]}, "fill": {"gradient": {"stops": [[0, "#000000"], [1, "#ffffff"]], "angle": 0}}})).unwrap(); + let r = s + .execute( + "shape.edit", + json!({"layer": id, "stroke": {"cap": "round", "dashes": [2, 1]}, "fill": {"gradient": {"stops": [[0, "#000000"], [1, "#ffffff"]], "angle": 0}}}), + ) + .unwrap(); assert_eq!(r["stroke"]["width"], 3.0); assert_eq!(r["stroke"]["cap"], "round"); assert!(r["fill"]["gradient"].is_object()); diff --git a/crates/engine/src/video_cmds.rs b/crates/engine/src/video_cmds.rs index 5ed7df0b86..43bbe22084 100644 --- a/crates/engine/src/video_cmds.rs +++ b/crates/engine/src/video_cmds.rs @@ -190,7 +190,11 @@ fn load_frames(path: &str, fmt: photocraft_color::PixelFormat) -> Result Result { - let path = p.get("path").and_then(Value::as_str).ok_or_else(|| EngineError::BadParams { cmd: "layer.videoLayers.newVideoLayerFromFile".into(), msg: "need `path`".into() })?.to_string(); + let path = p + .get("path") + .and_then(Value::as_str) + .ok_or_else(|| EngineError::BadParams { cmd: "layer.videoLayers.newVideoLayerFromFile".into(), msg: "need `path`".into() })? + .to_string(); let fps = p.get("fps").and_then(Value::as_f64).unwrap_or(30.0) as f32; let fmt = s.active().ok_or(EngineError::NoDocument)?.doc.pixel_format(); let frames = load_frames(&path, fmt)?; @@ -216,7 +220,11 @@ fn new_from_file(s: &mut Session, p: &Value) -> Result { } fn replace_footage(s: &mut Session, p: &Value) -> Result { - let path = p.get("path").and_then(Value::as_str).ok_or_else(|| EngineError::BadParams { cmd: "layer.videoLayers.replaceFootage".into(), msg: "need `path`".into() })?.to_string(); + let path = p + .get("path") + .and_then(Value::as_str) + .ok_or_else(|| EngineError::BadParams { cmd: "layer.videoLayers.replaceFootage".into(), msg: "need `path`".into() })? + .to_string(); let fmt = s.active().ok_or(EngineError::NoDocument)?.doc.pixel_format(); let frames = load_frames(&path, fmt)?; edit_video(s, "Replace Footage", move |v, _| { @@ -241,7 +249,11 @@ fn reload_frame(s: &mut Session, _p: &Value) -> Result { let (src, fmt) = { let st = s.active().ok_or(EngineError::NoDocument)?; let id = st.active_layer.ok_or(EngineError::NoDocument)?; - let v = st.doc.layer(id).and_then(|l| l.video.as_ref()).ok_or_else(|| EngineError::BadParams { cmd: "layer.videoLayers.reloadFrame".into(), msg: "not a video layer".into() })?; + let v = st + .doc + .layer(id) + .and_then(|l| l.video.as_ref()) + .ok_or_else(|| EngineError::BadParams { cmd: "layer.videoLayers.reloadFrame".into(), msg: "not a video layer".into() })?; (v.source.clone(), st.doc.pixel_format()) }; let photocraft_doc::VideoSource::File { path } = src else { @@ -256,7 +268,11 @@ fn reload_frame(s: &mut Session, _p: &Value) -> Result { /// Import › Video Frames to Layers: load frames from a file/sequence and add each as its own layer. fn frames_to_layers(s: &mut Session, p: &Value) -> Result { - let path = p.get("path").and_then(Value::as_str).ok_or_else(|| EngineError::BadParams { cmd: "file.import.videoFramesToLayers".into(), msg: "need `path`".into() })?.to_string(); + let path = p + .get("path") + .and_then(Value::as_str) + .ok_or_else(|| EngineError::BadParams { cmd: "file.import.videoFramesToLayers".into(), msg: "need `path`".into() })? + .to_string(); let fmt = s.active().ok_or(EngineError::NoDocument)?.doc.pixel_format(); let frames = load_frames(&path, fmt)?; let n = frames.len(); diff --git a/crates/engine/src/video_cmds/tests.rs b/crates/engine/src/video_cmds/tests.rs index f2f8bb13cf..18bd1a04e6 100644 --- a/crates/engine/src/video_cmds/tests.rs +++ b/crates/engine/src/video_cmds/tests.rs @@ -1,6 +1,6 @@ use super::*; -use serde_json::json; use photocraft_doc::LayerId; +use serde_json::json; fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/vp_cmds.rs b/crates/engine/src/vp_cmds.rs index 94500587e1..46fd182984 100644 --- a/crates/engine/src/vp_cmds.rs +++ b/crates/engine/src/vp_cmds.rs @@ -24,7 +24,11 @@ fn pt(v: &Value) -> Option<[f64; 2]> { /// Builds the scene from `"planes"`. pub fn scene(p: &Value, canvas: Rect) -> Result { - let planes = p.get("planes").and_then(Value::as_array).filter(|a| !a.is_empty()).ok_or_else(|| bad("pass `planes`: [{\"corners\":[[x,y]×4]}, {\"from\":0,\"edge\":\"top\",\"angle\":90}]"))?; + let planes = p + .get("planes") + .and_then(Value::as_array) + .filter(|a| !a.is_empty()) + .ok_or_else(|| bad("pass `planes`: [{\"corners\":[[x,y]×4]}, {\"from\":0,\"edge\":\"top\",\"angle\":90}]"))?; let center = [(canvas.x0 + canvas.x1) as f64 / 2.0, (canvas.y0 + canvas.y1) as f64 / 2.0]; let corners = |v: &Value| -> Option<[[f64; 2]; 4]> { let a = v.get("corners")?.as_array()?; @@ -38,7 +42,12 @@ pub fn scene(p: &Value, canvas: Rect) -> Result { sc.planes.push(VpPlane { corners: c }); continue; } - let from = pl.get("from").and_then(Value::as_u64).map(|v| v as usize).filter(|f| *f < sc.planes.len()).ok_or_else(|| bad(format!("plane {i}: `corners` or `from` (an earlier plane)")))?; + let from = pl + .get("from") + .and_then(Value::as_u64) + .map(|v| v as usize) + .filter(|f| *f < sc.planes.len()) + .ok_or_else(|| bad(format!("plane {i}: `corners` or `from` (an earlier plane)")))?; let edge = match pl.get("edge").and_then(Value::as_str).unwrap_or("top") { "top" => Edge::Top, "right" => Edge::Right, @@ -173,10 +182,15 @@ fn vanishing_point(s: &mut Session, p: &Value) -> Result { surf.prune(); Ok(id) })?; - let planes: Vec = sc.planes.iter().enumerate().map(|(i, pl)| { - let (lu, lv) = sc.lifted(i).lengths(); - json!({"corners": pl.corners, "aspect": lu / lv}) - }).collect(); + let planes: Vec = sc + .planes + .iter() + .enumerate() + .map(|(i, pl)| { + let (lu, lv) = sc.lifted(i).lengths(); + json!({"corners": pl.corners, "aspect": lu / lv}) + }) + .collect(); Ok(json!({"layer": target.0, "focal": sc.focal, "planes": planes, "pasted": pastes.len(), "dabs": dabs})) } @@ -231,7 +245,9 @@ mod tests { }) .unwrap(); s.execute("layer.select", json!({"layer": floor.0})).unwrap(); - let r = s.execute(VP, json!({"planes": planes, "clone": [{"source": [200, 257], "points": [[200, 160], [205, 160]], "size": 12, "hardness": 80}]})).unwrap(); + let r = s + .execute(VP, json!({"planes": planes, "clone": [{"source": [200, 257], "points": [[200, 160], [205, 160]], "size": 12, "hardness": 80}]})) + .unwrap(); assert!(r["dabs"].as_u64().unwrap() >= 1, "{r}"); let l = s.active().unwrap().doc.layer(floor).unwrap().surface().unwrap(); assert!(l.rgba(200, 160)[1] > 0.8, "{:?}", l.rgba(200, 160)); diff --git a/crates/engine/src/warp_cmds.rs b/crates/engine/src/warp_cmds.rs index 360f125aaf..2b1da85172 100644 --- a/crates/engine/src/warp_cmds.rs +++ b/crates/engine/src/warp_cmds.rs @@ -132,7 +132,8 @@ fn warp_layer(doc_sel: Option<&Surface>, l: &mut Layer, w: &Warp, rect: Rect, in // Store in source space: undo the placement transform around the warp. let t = sm.transform; let inv = t.inverse().ok_or_else(|| EngineError::Other("the smart object's transform is degenerate".into()))?; - let corners = [[w.bounds[0], w.bounds[1]], [w.bounds[2], w.bounds[1]], [w.bounds[2], w.bounds[3]], [w.bounds[0], w.bounds[3]]].map(|p| affine_apply(&inv, p)); + let corners = + [[w.bounds[0], w.bounds[1]], [w.bounds[2], w.bounds[1]], [w.bounds[2], w.bounds[3]], [w.bounds[0], w.bounds[3]]].map(|p| affine_apply(&inv, p)); let sb = corners.iter().fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p[0]), b[1].min(p[1]), b[2].max(p[0]), b[3].max(p[1])]); let src_warp = match w.style { WarpStyle::None => None, @@ -313,12 +314,66 @@ pub fn specs() -> Vec { const P: &str = r##"{"layer":id?,"rect":[x0,y0,x1,y1]? (warp box; default = layer content ∩ selection),"style":"custom|none|arc|arcLower|arcUpper|arch|bulge|shellLower|shellUpper|flag|wave|fish|rise|fisheye|inflate|squeeze|twist","bend":%=50,"hDistort":%,"vDistort":%,"vertical":bool,"mesh":{"us":[0,…,1],"vs":[0,…,1],"points":[[x,y]…]} ((3c+1)×(3r+1) control points, row-major, document px),"grid":[cols,rows]?,"warp":{full warp object}?,"interpolation":"bicubic|bilinear|nearest"}"##; const S: &str = r##"{"warp":{…}? (the warp being edited; returned split),"rect":[x0,y0,x1,y1]?,"at":[x,y]? (document point; default = middle of the patch)} — without `warp`, edits the active smart object's warp"##; vec![ - CommandSpec { id: "edit.transform.warp", label: "Warp", menu: &["Edit", "Transform"], shortcut: None, params: P, enabled: has_layer, journal: true, run: |s, p| apply(s, p, "edit.transform.warp") }, - CommandSpec { id: "layer.smartObjects.warp", label: "Warp", menu: &["Layer", "Smart Objects"], shortcut: None, params: P, enabled: active_smart, journal: true, run: |s, p| apply(s, p, "layer.smartObjects.warp") }, - CommandSpec { id: "edit.transform.splitWarpCrosswise", label: "Split Warp Crosswise", menu: &["Edit", "Transform"], shortcut: None, params: S, enabled: has_layer, journal: true, run: |s, p| split(s, p, "edit.transform.splitWarpCrosswise", Split::Crosswise) }, - CommandSpec { id: "edit.transform.splitWarpHorizontally", label: "Split Warp Horizontally", menu: &["Edit", "Transform"], shortcut: None, params: S, enabled: has_layer, journal: true, run: |s, p| split(s, p, "edit.transform.splitWarpHorizontally", Split::Horizontal) }, - CommandSpec { id: "edit.transform.splitWarpVertically", label: "Split Warp Vertically", menu: &["Edit", "Transform"], shortcut: None, params: S, enabled: has_layer, journal: true, run: |s, p| split(s, p, "edit.transform.splitWarpVertically", Split::Vertical) }, - CommandSpec { id: "edit.transform.removeWarpSplit", label: "Remove Warp Split", menu: &["Edit", "Transform"], shortcut: None, params: S, enabled: has_layer, journal: true, run: |s, p| split(s, p, "edit.transform.removeWarpSplit", Split::Remove) }, + CommandSpec { + id: "edit.transform.warp", + label: "Warp", + menu: &["Edit", "Transform"], + shortcut: None, + params: P, + enabled: has_layer, + journal: true, + run: |s, p| apply(s, p, "edit.transform.warp"), + }, + CommandSpec { + id: "layer.smartObjects.warp", + label: "Warp", + menu: &["Layer", "Smart Objects"], + shortcut: None, + params: P, + enabled: active_smart, + journal: true, + run: |s, p| apply(s, p, "layer.smartObjects.warp"), + }, + CommandSpec { + id: "edit.transform.splitWarpCrosswise", + label: "Split Warp Crosswise", + menu: &["Edit", "Transform"], + shortcut: None, + params: S, + enabled: has_layer, + journal: true, + run: |s, p| split(s, p, "edit.transform.splitWarpCrosswise", Split::Crosswise), + }, + CommandSpec { + id: "edit.transform.splitWarpHorizontally", + label: "Split Warp Horizontally", + menu: &["Edit", "Transform"], + shortcut: None, + params: S, + enabled: has_layer, + journal: true, + run: |s, p| split(s, p, "edit.transform.splitWarpHorizontally", Split::Horizontal), + }, + CommandSpec { + id: "edit.transform.splitWarpVertically", + label: "Split Warp Vertically", + menu: &["Edit", "Transform"], + shortcut: None, + params: S, + enabled: has_layer, + journal: true, + run: |s, p| split(s, p, "edit.transform.splitWarpVertically", Split::Vertical), + }, + CommandSpec { + id: "edit.transform.removeWarpSplit", + label: "Remove Warp Split", + menu: &["Edit", "Transform"], + shortcut: None, + params: S, + enabled: has_layer, + journal: true, + run: |s, p| split(s, p, "edit.transform.removeWarpSplit", Split::Remove), + }, ] } diff --git a/crates/engine/src/web_cmds.rs b/crates/engine/src/web_cmds.rs index f58f7afee9..aa6ffcd14a 100644 --- a/crates/engine/src/web_cmds.rs +++ b/crates/engine/src/web_cmds.rs @@ -142,7 +142,9 @@ impl WebSettings { if let Some(k) = s("palette") { st.palette = match k { "restrictive" | "web" => PaletteKind::Web, - k => PaletteKind::from_id(k).ok_or_else(|| bad(cmd, format!("unknown palette `{k}` (perceptual|selective|adaptive|restrictive|exact|systemMac|systemWindows|uniform)")))?, + k => PaletteKind::from_id(k).ok_or_else(|| { + bad(cmd, format!("unknown palette `{k}` (perceptual|selective|adaptive|restrictive|exact|systemMac|systemWindows|uniform)")) + })?, }; } if let Some(n) = crate::commands::int(p, "colors").filter(|v| *v > 0).map(|v| v as u64) { @@ -168,7 +170,13 @@ impl WebSettings { Some(c) => st.matte = Some(hex3(c).ok_or_else(|| bad(cmd, "matte is \"none\" or \"#rrggbb\""))?), None => {} } - for (k, slot) in [("interlaced", &mut st.interlaced), ("progressive", &mut st.progressive), ("optimized", &mut st.optimized), ("embedIcc", &mut st.embed_icc), ("convertToSrgb", &mut st.convert_to_srgb)] { + for (k, slot) in [ + ("interlaced", &mut st.interlaced), + ("progressive", &mut st.progressive), + ("optimized", &mut st.optimized), + ("embedIcc", &mut st.embed_icc), + ("convertToSrgb", &mut st.convert_to_srgb), + ] { if let Some(b) = p.get(k).and_then(Value::as_bool) { *slot = b; } @@ -230,11 +238,29 @@ impl WebSettings { } /// Save for Web's named presets. -pub const PRESETS: [&str; 12] = ["GIF 128 Dithered", "GIF 128 No Dither", "GIF 32 Dithered", "GIF 32 No Dither", "GIF 64 Dithered", "GIF 64 No Dither", "GIF Restrictive", "JPEG High", "JPEG Low", "JPEG Medium", "PNG-24", "PNG-8 128 Dithered"]; +pub const PRESETS: [&str; 12] = [ + "GIF 128 Dithered", + "GIF 128 No Dither", + "GIF 32 Dithered", + "GIF 32 No Dither", + "GIF 64 Dithered", + "GIF 64 No Dither", + "GIF Restrictive", + "JPEG High", + "JPEG Low", + "JPEG Medium", + "PNG-24", + "PNG-8 128 Dithered", +]; pub fn preset_settings(name: &str) -> Option { let d = WebSettings::default(); - let gif = |colors: usize, dither: bool| WebSettings { format: WebFormat::Gif, colors, dither: if dither { Dither::Diffusion } else { Dither::None }, ..WebSettings::default() }; + let gif = |colors: usize, dither: bool| WebSettings { + format: WebFormat::Gif, + colors, + dither: if dither { Dither::Diffusion } else { Dither::None }, + ..WebSettings::default() + }; Some(match name { "GIF 128 Dithered" => gif(128, true), "GIF 128 No Dither" => gif(128, false), @@ -343,7 +369,9 @@ pub fn optimize(px: &[[f32; 4]], bw: usize, rect: Rect, st: &WebSettings, icc: O }; let region = || (rect.y0..rect.y1).flat_map(row); let matte = st.matte.unwrap_or([1.0, 1.0, 1.0]); - let over = |p: &[f32; 4]| -> [f32; 4] { [p[0] * p[3] + matte[0] * (1.0 - p[3]), p[1] * p[3] + matte[1] * (1.0 - p[3]), p[2] * p[3] + matte[2] * (1.0 - p[3]), 1.0] }; + let over = |p: &[f32; 4]| -> [f32; 4] { + [p[0] * p[3] + matte[0] * (1.0 - p[3]), p[1] * p[3] + matte[1] * (1.0 - p[3]), p[2] * p[3] + matte[2] * (1.0 - p[3]), 1.0] + }; let (ww, hh) = (w as u32, h as u32); let e = |e: photocraft_codecs::CodecError| other(e); let mut look: Vec = Vec::new(); @@ -362,7 +390,12 @@ pub fn optimize(px: &[[f32; 4]], bw: usize, rect: Rect, st: &WebSettings, icc: O let layout = if alpha { photocraft_codecs::ChannelLayout::Rgba } else { photocraft_codecs::ChannelLayout::Rgb }; let meta = photocraft_codecs::Metadata { dpi: Some((dpi, dpi)), xmp: xmp.map(str::to_string), ..Default::default() }; let img = photocraft_codecs::Image::from_u8(ww, hh, layout, data).map_err(e)?.with_icc(icc.map(<[u8]>::to_vec)).with_meta(meta); - let opts = photocraft_codecs::EncodeOptions { png_interlaced: st.interlaced, embed_icc: icc.is_some(), embed_metadata: xmp.is_some(), ..Default::default() }; + let opts = photocraft_codecs::EncodeOptions { + png_interlaced: st.interlaced, + embed_icc: icc.is_some(), + embed_metadata: xmp.is_some(), + ..Default::default() + }; (photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &opts).map_err(e)?, None) } WebFormat::Jpeg => { @@ -373,7 +406,8 @@ pub fn optimize(px: &[[f32; 4]], bw: usize, rect: Rect, st: &WebSettings, icc: O } // Save for Web's 0–100 quality → encoder 1–100. let q = st.quality.max(1); - let bytes = photocraft_codecs::web::encode_jpeg_rgb8(ww, hh, &rgb, q, st.progressive, st.optimized, icc.filter(|_| st.embed_icc), Some(dpi), xmp).map_err(e)?; + let bytes = photocraft_codecs::web::encode_jpeg_rgb8(ww, hh, &rgb, q, st.progressive, st.optimized, icc.filter(|_| st.embed_icc), Some(dpi), xmp) + .map_err(e)?; if preview { // The preview shows the compression artefacts. look = match photocraft_codecs::decode(&bytes) { @@ -444,10 +478,13 @@ pub fn optimize(px: &[[f32; 4]], bw: usize, rect: Rect, st: &WebSettings, icc: O }); let idx = quantize_rows(&mut work, w, &pal, st.dither, st.dither_amount, t); if preview { - look = idx.iter().flat_map(|k| { - let c = pal[*k as usize]; - [c[0], c[1], c[2], if Some(*k as usize) == t { 0 } else { 255 }] - }).collect(); + look = idx + .iter() + .flat_map(|k| { + let c = pal[*k as usize]; + [c[0], c[1], c[2], if Some(*k as usize) == t { 0 } else { 255 }] + }) + .collect(); } let tt = t.map(|v| v as u8); let bytes = if st.format == WebFormat::Gif { @@ -575,7 +612,10 @@ fn quantize_rows(px: &mut [[f32; 4]], w: usize, pal: &[[u8; 3]], dither: Dither, use rayon::prelude::*; let band = w * 64; let mut idx = vec![0u8; px.len()]; - px.par_chunks_mut(band).zip(idx.par_chunks_mut(band)).enumerate().for_each(|(i, (p, out))| out.copy_from_slice(&quantize_band(p, w, i * 64, pal, dither, amount, t))); + px.par_chunks_mut(band) + .zip(idx.par_chunks_mut(band)) + .enumerate() + .for_each(|(i, (p, out))| out.copy_from_slice(&quantize_band(p, w, i * 64, pal, dither, amount, t))); idx } #[cfg(target_arch = "wasm32")] @@ -593,7 +633,12 @@ fn html_escape(s: &str) -> String { } fn scale_rect(r: Rect, sx: f64, sy: f64) -> Rect { - Rect::new((f64::from(r.x0) * sx).round() as i32, (f64::from(r.y0) * sy).round() as i32, (f64::from(r.x1) * sx).round() as i32, (f64::from(r.y1) * sy).round() as i32) + Rect::new( + (f64::from(r.x0) * sx).round() as i32, + (f64::from(r.y0) * sy).round() as i32, + (f64::from(r.x1) * sx).round() as i32, + (f64::from(r.y1) * sy).round() as i32, + ) } /// The HTML table for `cells` (rect, td body) over a `w`×`h` image: one column per distinct x @@ -696,7 +741,8 @@ fn save_for_web(s: &mut Session, p: &Value) -> Result { crate::automate_cmds::fire_event(s, "export"); return Ok(json!({"files": [path], "bytes": o.bytes.len(), "width": o.width, "height": o.height, "colors": o.colors})); } - let dir = dir.map(str::to_string).or_else(|| single.and_then(|f| std::path::Path::new(f).parent().map(|d| d.to_string_lossy().into_owned()))).unwrap_or_default(); + let dir = + dir.map(str::to_string).or_else(|| single.and_then(|f| std::path::Path::new(f).parent().map(|d| d.to_string_lossy().into_owned()))).unwrap_or_default(); let html = p.get("html").and_then(Value::as_bool).unwrap_or(false); let images = p.get("imagesFolder").and_then(Value::as_str).unwrap_or("images").to_string(); let base = single.map(stem).unwrap_or_else(|| slices::base_name(&doc)); @@ -755,7 +801,13 @@ fn save_for_web(s: &mut Session, p: &Value) -> Result { fn export_preferences(s: &mut Session, p: &Value) -> Result { let cmd = "file.export.exportPreferences"; let mut values = serde_json::Map::new(); - for (k, key) in [("quickExportFormat", "export.quickExportFormat"), ("quickExportLocation", "export.quickExportLocation"), ("jpegQuality", "export.jpegQuality"), ("metadata", "export.metadata"), ("convertToSrgb", "export.convertToSrgb")] { + for (k, key) in [ + ("quickExportFormat", "export.quickExportFormat"), + ("quickExportLocation", "export.quickExportLocation"), + ("jpegQuality", "export.jpegQuality"), + ("metadata", "export.metadata"), + ("convertToSrgb", "export.convertToSrgb"), + ] { if let Some(v) = p.get(k) { values.insert(key.into(), v.clone()); } @@ -813,7 +865,16 @@ fn quick_export(s: &mut Session, p: &Value) -> Result { let (wdoc, _, _) = web_document(&doc, &json!({}), &st)?; let buf = photocraft_compose::flatten(&wdoc); let xmp = web_xmp(&doc, st.metadata); - let opt = optimize(&buf.px, wdoc.size.width as usize, wdoc.bounds(), &st, wdoc.icc_profile.as_deref().map(|v| v.as_slice()), xmp.as_deref(), wdoc.resolution_dpi, false)?; + let opt = optimize( + &buf.px, + wdoc.size.width as usize, + wdoc.bounds(), + &st, + wdoc.icc_profile.as_deref().map(|v| v.as_slice()), + xmp.as_deref(), + wdoc.resolution_dpi, + false, + )?; write_file(&path, &opt.bytes)?; crate::automate_cmds::fire_event(s, "export"); Ok(json!({"path": path, "format": fmt, "bytes": opt.bytes.len()})) @@ -1098,9 +1159,33 @@ pub fn specs() -> Vec { has_doc, save_for_web ), - spec!("file.export.exportPreferences", "Export Preferences…", &["File", "Export"], None, r##"{"quickExportFormat":"png|jpg|gif|webp"?,"quickExportLocation":"ask|sameFolder"?,"jpegQuality":1..100?,"metadata":"none|copyright|all"?,"convertToSrgb":bool?} → {values}"##, |_| Ok(()), export_preferences), - spec!("file.export.quickExport", "Quick Export", &[], None, r##"{"path":str? (required unless Export Preferences › Location is "sameFolder" and the document is saved)} → {path, format, bytes}"##, native_doc, quick_export), - spec!("file.generate.imageAssets", "Image Assets", &["File", "Generate"], None, r##"{"on":bool? (default: toggle),"dir":folder? (default -assets next to the file)} → {enabled, files, errors}; layers named like "foo.png", "200% foo@2x.png", "48x48 icons/a.png8", "photo.jpg80%" are exported, now and after each save"##, has_doc, image_assets), + spec!( + "file.export.exportPreferences", + "Export Preferences…", + &["File", "Export"], + None, + r##"{"quickExportFormat":"png|jpg|gif|webp"?,"quickExportLocation":"ask|sameFolder"?,"jpegQuality":1..100?,"metadata":"none|copyright|all"?,"convertToSrgb":bool?} → {values}"##, + |_| Ok(()), + export_preferences + ), + spec!( + "file.export.quickExport", + "Quick Export", + &[], + None, + r##"{"path":str? (required unless Export Preferences › Location is "sameFolder" and the document is saved)} → {path, format, bytes}"##, + native_doc, + quick_export + ), + spec!( + "file.generate.imageAssets", + "Image Assets", + &["File", "Generate"], + None, + r##"{"on":bool? (default: toggle),"dir":folder? (default -assets next to the file)} → {enabled, files, errors}; layers named like "foo.png", "200% foo@2x.png", "48x48 icons/a.png8", "photo.jpg80%" are exported, now and after each save"##, + has_doc, + image_assets + ), ] } diff --git a/crates/engine/src/web_cmds/tests.rs b/crates/engine/src/web_cmds/tests.rs index 8b25f43e4e..c04cd49771 100644 --- a/crates/engine/src/web_cmds/tests.rs +++ b/crates/engine/src/web_cmds/tests.rs @@ -118,10 +118,13 @@ fn slices_export_with_html_table() { assert!(html.contains("images/spacer.gif")); assert!(std::path::Path::new(&format!("{dir}/images/spacer.gif")).is_file()); // Total area of the exported slices plus the text cell is the canvas. - let area: u64 = files.iter().map(|f| { - let (w, h) = decode(f).dimensions(); - u64::from(w) * u64::from(h) - }).sum(); + let area: u64 = files + .iter() + .map(|f| { + let (w, h) = decode(f).dimensions(); + u64::from(w) * u64::from(h) + }) + .sum(); assert_eq!(area + 24 * 20, 64 * 48); // Scaled export scales slices. let dir2 = tmp("slices2"); @@ -166,7 +169,13 @@ fn generator_naming_grammar() { assert!(parse_asset_name("notes.txt", 72.0).is_empty()); assert!(parse_asset_name("x.jpgq", 72.0).is_empty()); let d = parse_defaults("default 50% low/ + 200% @2x", 72.0).unwrap(); - assert_eq!(d, vec![AssetDefault { scale: Some(0.5), folder: "low/".into(), ..Default::default() }, AssetDefault { scale: Some(2.0), suffix: "@2x".into(), ..Default::default() }]); + assert_eq!( + d, + vec![ + AssetDefault { scale: Some(0.5), folder: "low/".into(), ..Default::default() }, + AssetDefault { scale: Some(2.0), suffix: "@2x".into(), ..Default::default() } + ] + ); assert!(parse_defaults("defaults are nice", 72.0).is_none()); } diff --git a/crates/engine/tests/panic_hunt.rs b/crates/engine/tests/panic_hunt.rs index d334ef7c75..1ffaa1dfda 100644 --- a/crates/engine/tests/panic_hunt.rs +++ b/crates/engine/tests/panic_hunt.rs @@ -2,9 +2,9 @@ //! (Rule 9 in AGENTS.md.) Each command runs on its own thread with a timeout, so one bad command //! can't wedge the run — panics are caught and hangs are reported. Opt-in (slow): //! `cargo test -p photocraft-engine --test panic_hunt -- --ignored`. -use photocraft_engine::{command_specs, Session}; -use serde_json::{json, Value}; -use std::panic::{catch_unwind, AssertUnwindSafe}; +use photocraft_engine::{Session, command_specs}; +use serde_json::{Value, json}; +use std::panic::{AssertUnwindSafe, catch_unwind}; use std::sync::mpsc; use std::time::Duration; diff --git a/crates/format/src/autosave.rs b/crates/format/src/autosave.rs index b93d84d19e..cd8929046f 100644 --- a/crates/format/src/autosave.rs +++ b/crates/format/src/autosave.rs @@ -54,16 +54,7 @@ pub struct Autosaver { } fn sanitize(key: &str) -> String { - let s: String = key - .chars() - .map(|c| { - if c.is_ascii_alphanumeric() || c == '-' || c == '_' { - c - } else { - '_' - } - }) - .collect(); + let s: String = key.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect(); if s.is_empty() { "untitled".into() } else { s } } @@ -77,17 +68,8 @@ impl Autosaver { let bundle = dir.join(format!("{key}.pcraft")); let sidecar = dir.join(format!("{key}.json")); let last2 = last.clone(); - let handle = std::thread::Builder::new() - .name(format!("autosave-{key}")) - .spawn(move || worker(rx, bundle, sidecar, last2)) - .ok(); - Autosaver { - dir, - key, - tx: Some(tx), - handle, - last, - } + let handle = std::thread::Builder::new().name(format!("autosave-{key}")).spawn(move || worker(rx, bundle, sidecar, last2)).ok(); + Autosaver { dir, key, tx: Some(tx), handle, last } } pub fn bundle_path(&self) -> PathBuf { @@ -95,37 +77,23 @@ impl Autosaver { } /// Queue a snapshot for saving (returns immediately). - pub fn request( - &self, - snapshot: Arc, - revision: u64, - original_path: Option, - opts: SaveOptions, - ) { + pub fn request(&self, snapshot: Arc, revision: u64, original_path: Option, opts: SaveOptions) { let info = RecoveryInfo { key: self.key.clone(), document_name: snapshot.name.clone(), original_path, - saved_at: SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0), + saved_at: SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0), revision, }; if let Some(tx) = &self.tx { - let _ = tx.send(Job { - snapshot, - info, - opts, - }); + let _ = tx.send(Job { snapshot, info, opts }); } } /// Result of the most recent completed save. pub fn last_result(&self) -> Option> { let g = self.last.lock().ok()?; - g.as_ref() - .map(|r| r.as_ref().map(|s| *s).map_err(|e| e.to_string())) + g.as_ref().map(|r| r.as_ref().map(|s| *s).map_err(|e| e.to_string())) } /// Finish pending saves and stop the thread. @@ -154,12 +122,7 @@ impl Drop for Autosaver { } } -fn worker( - rx: Receiver, - bundle: PathBuf, - sidecar: PathBuf, - last: Arc>>>, -) { +fn worker(rx: Receiver, bundle: PathBuf, sidecar: PathBuf, last: Arc>>>) { let mut writer = PcraftWriter::new(); while let Ok(mut job) = rx.recv() { // Coalesce: skip to the newest queued snapshot. @@ -196,12 +159,7 @@ pub fn list_recovery(recovery_dir: &Path) -> Vec { } } } - out.sort_by(|a, b| { - b.info - .saved_at - .cmp(&a.info.saved_at) - .then(a.info.key.cmp(&b.info.key)) - }); + out.sort_by(|a, b| b.info.saved_at.cmp(&a.info.saved_at).then(a.info.key.cmp(&b.info.key))); out } diff --git a/crates/format/src/convert.rs b/crates/format/src/convert.rs index fd8c581a8a..1862afb773 100644 --- a/crates/format/src/convert.rs +++ b/crates/format/src/convert.rs @@ -5,8 +5,8 @@ use std::sync::Arc; use photocraft_color::{PixelFormat, SampleType}; use photocraft_doc::{ - AlphaChannel, CompAppearance, CompLayerState, DocId, Document, Effects, LayerComp, FillCache, Group, Layer, LayerContent, LayerId, - LayerMask, Metadata, NamedPath, Pattern, ShapeLayer, SmartObject, SmartSource, TextLayer, + AlphaChannel, CompAppearance, CompLayerState, DocId, Document, Effects, FillCache, Group, Layer, LayerComp, LayerContent, LayerId, LayerMask, Metadata, + NamedPath, Pattern, ShapeLayer, SmartObject, SmartSource, TextLayer, }; use photocraft_geom::{TILE_SIZE, TileCoord}; use photocraft_raster::{Surface, Tile, decode_pixel, encode_pixel}; @@ -42,23 +42,16 @@ pub(crate) fn unhex(s: &str) -> Result> { } (0..s.len()) .step_by(2) - .map(|i| { - u8::from_str_radix(s.get(i..i + 2).unwrap_or("x"), 16) - .map_err(|_| FormatError::corrupt(format!("bad hex `{s}`"))) - }) + .map(|i| u8::from_str_radix(s.get(i..i + 2).unwrap_or("x"), 16).map_err(|_| FormatError::corrupt(format!("bad hex `{s}`")))) .collect() } fn key4(s: &str) -> Result<[u8; 4]> { - unhex(s)? - .try_into() - .map_err(|_| FormatError::corrupt(format!("bad 4-byte key `{s}`"))) + unhex(s)?.try_into().map_err(|_| FormatError::corrupt(format!("bad 4-byte key `{s}`"))) } pub(crate) fn is_valid_hash(h: &str) -> bool { - h.len() == 64 - && h.bytes() - .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + h.len() == 64 && h.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) } /// Convert native-endian sample bytes to little-endian in place (and back: @@ -86,18 +79,7 @@ fn surface_m(s: &Surface, sink: &mut dyn Sink) -> SurfaceM { let mut dp = vec![0u8; f.bytes_per_pixel()]; encode_pixel(&f, &s.default_pixel(), &mut dp); swap_to_le(&mut dp, f.sample); - SurfaceM { - format: f, - default: hex(&dp), - tiles: s - .tiles() - .map(|(c, t)| TileRef { - tx: c.tx, - ty: c.ty, - hash: sink.tile(f, t), - }) - .collect(), - } + SurfaceM { format: f, default: hex(&dp), tiles: s.tiles().map(|(c, t)| TileRef { tx: c.tx, ty: c.ty, hash: sink.tile(f, t) }).collect() } } fn opt_blob(b: &Option>>, sink: &mut dyn Sink) -> Option { @@ -115,17 +97,11 @@ fn video_m(v: &photocraft_doc::VideoData, sink: &mut dyn Sink) -> crate::manifes fn layer_m(l: &Layer, sink: &mut dyn Sink) -> LayerM { let content = match &l.content { - LayerContent::Raster(s) => ContentM::Raster { - surface: surface_m(s, sink), - }, - LayerContent::Group(g) => ContentM::Group { - children: g.children.iter().map(|c| layer_m(c, sink)).collect(), - expanded: g.expanded, - artboard: g.artboard.clone(), - }, - LayerContent::Adjustment(a) => ContentM::Adjustment { - adjustment: a.clone(), - }, + LayerContent::Raster(s) => ContentM::Raster { surface: surface_m(s, sink) }, + LayerContent::Group(g) => { + ContentM::Group { children: g.children.iter().map(|c| layer_m(c, sink)).collect(), expanded: g.expanded, artboard: g.artboard.clone() } + } + LayerContent::Adjustment(a) => ContentM::Adjustment { adjustment: a.clone() }, LayerContent::Fill(f) => ContentM::Fill { fill: f.clone() }, LayerContent::Text(t) => ContentM::Text { text: t.text.clone(), @@ -152,10 +128,7 @@ fn layer_m(l: &Layer, sink: &mut dyn Sink) -> LayerM { }, LayerContent::Smart(s) => ContentM::Smart { source: match &s.source { - SmartSource::Embedded { file_name, bytes } => SmartSourceM::Embedded { - file_name: file_name.clone(), - blob: sink.blob(bytes), - }, + SmartSource::Embedded { file_name, bytes } => SmartSourceM::Embedded { file_name: file_name.clone(), blob: sink.blob(bytes) }, SmartSource::Linked { path } => SmartSourceM::Linked { path: path.clone() }, }, transform: s.transform, @@ -199,16 +172,9 @@ fn layer_m(l: &Layer, sink: &mut dyn Sink) -> LayerM { }, label: l.label, content, - psd_blocks: l - .psd_blocks - .iter() - .map(|(k, d)| (hex(k), sink.blob(d))) - .collect(), + psd_blocks: l.psd_blocks.iter().map(|(k, d)| (hex(k), sink.blob(d))).collect(), psd_id: l.psd_id, - fill_cache: l.fill_cache.as_ref().map(|fc| FillCacheM { - fill: fc.fill.clone(), - surface: surface_m(&fc.surface, sink), - }), + fill_cache: l.fill_cache.as_ref().map(|fc| FillCacheM { fill: fc.fill.clone(), surface: surface_m(&fc.surface, sink) }), link_group: l.link_group, excluded_channels: l.excluded_channels, blend_if: l.blend_if.clone(), @@ -219,14 +185,7 @@ fn layer_m(l: &Layer, sink: &mut dyn Sink) -> LayerM { fn channel_m(c: &AlphaChannel, sink: &mut dyn Sink) -> ChannelM { // Destructured so a new `AlphaChannel` field fails to compile here until it is saved. let AlphaChannel { name, surface, spot, color, opacity, indicates } = c; - ChannelM { - name: name.clone(), - surface: surface_m(surface, sink), - spot: *spot, - color: *color, - opacity: *opacity, - indicates: *indicates, - } + ChannelM { name: name.clone(), surface: surface_m(surface, sink), spot: *spot, color: *color, opacity: *opacity, indicates: *indicates } } pub(crate) fn doc_m(d: &Document, sink: &mut dyn Sink) -> DocM { @@ -245,42 +204,18 @@ pub(crate) fn doc_m(d: &Document, sink: &mut dyn Sink) -> DocM { metadata: MetadataM { xmp: d.metadata.xmp.clone(), exif: opt_blob(&d.metadata.exif, sink), - psd_resources: d - .metadata - .psd_resources - .iter() - .map(|(id, n, b)| (*id, n.clone(), sink.blob(b))) - .collect(), - psd_global_blocks: d - .metadata - .psd_global_blocks - .iter() - .map(|(s, k, b)| (hex(s), hex(k), sink.blob(b))) - .collect(), + psd_resources: d.metadata.psd_resources.iter().map(|(id, n, b)| (*id, n.clone(), sink.blob(b))).collect(), + psd_global_blocks: d.metadata.psd_global_blocks.iter().map(|(s, k, b)| (hex(s), hex(k), sink.blob(b))).collect(), }, global_light: d.global_light, - paths: d - .paths - .iter() - .map(|p| NamedPathM { - name: p.name.clone(), - path: p.path.clone(), - psd_raw: opt_blob(&p.psd_raw, sink), - }) - .collect(), + paths: d.paths.iter().map(|p| NamedPathM { name: p.name.clone(), path: p.path.clone(), psd_raw: opt_blob(&p.psd_raw, sink) }).collect(), work_path: d.work_path.clone(), clipping_path: d.clipping_path.clone(), quick_mask: d.quick_mask.as_ref().map(|c| channel_m(c, sink)), patterns: d .patterns .iter() - .map(|p| PatternM { - id: p.id.clone(), - name: p.name.clone(), - width: p.width, - height: p.height, - surface: surface_m(&p.surface, sink), - }) + .map(|p| PatternM { id: p.id.clone(), name: p.name.clone(), width: p.width, height: p.height, surface: surface_m(&p.surface, sink) }) .collect(), color_table: d.color_table.clone(), duotone: d.duotone.clone(), @@ -350,9 +285,7 @@ impl Loader<'_> { let f = m.format; let mut dp = unhex(&m.default)?; if dp.len() != f.bytes_per_pixel() { - return Err(FormatError::corrupt( - "default pixel has wrong size for its format", - )); + return Err(FormatError::corrupt("default pixel has wrong size for its format")); } swap_to_le(&mut dp, f.sample); let mut s = Surface::with_default(f, &decode_pixel(&f, &dp)); @@ -360,10 +293,7 @@ impl Loader<'_> { for t in &m.tiles { let c = TileCoord::new(t.tx, t.ty); if s.tile(c).is_some() { - return Err(FormatError::corrupt(format!( - "duplicate tile ({}, {})", - t.tx, t.ty - ))); + return Err(FormatError::corrupt(format!("duplicate tile ({}, {})", t.tx, t.ty))); } let mut bytes = self.fetch.tile(&t.hash, len)?; swap_to_le(&mut bytes, f.sample); @@ -426,59 +356,35 @@ impl Loader<'_> { fn layer(&mut self, m: &LayerM, depth: usize) -> Result { if depth > 256 { - return Err(FormatError::LimitExceeded( - "layer groups nested deeper than 256".into(), - )); + return Err(FormatError::LimitExceeded("layer groups nested deeper than 256".into())); } let content = match &m.content { ContentM::Raster { surface } => LayerContent::Raster(self.surface(surface)?), ContentM::Group { children, expanded, artboard } => LayerContent::Group(Group { artboard: artboard.clone(), - children: children - .iter() - .map(|c| self.layer(c, depth + 1)) - .collect::>()?, + children: children.iter().map(|c| self.layer(c, depth + 1)).collect::>()?, expanded: *expanded, }), ContentM::Adjustment { adjustment } => LayerContent::Adjustment(adjustment.clone()), ContentM::Fill { fill } => LayerContent::Fill(fill.clone()), - ContentM::Text { - text, - font_family, - size_pt, - color, - transform, - cache, - psd_raw, - runs, - paragraphs, - shape, - orientation, - antialias, - warp, - } => LayerContent::Text(TextLayer { - text: text.clone(), - font_family: font_family.clone(), - size_pt: *size_pt, - color: *color, - transform: *transform, - cache: self.opt_surface(cache)?, - psd_raw: self.opt_blob(psd_raw)?, - runs: runs.clone(), - paragraphs: paragraphs.clone(), - shape: *shape, - orientation: *orientation, - antialias: *antialias, - warp: warp.clone(), - }), - ContentM::Shape { - fill, - cache, - psd_raw, - path, - stroke, - live, - } => LayerContent::Shape(ShapeLayer { + ContentM::Text { text, font_family, size_pt, color, transform, cache, psd_raw, runs, paragraphs, shape, orientation, antialias, warp } => { + LayerContent::Text(TextLayer { + text: text.clone(), + font_family: font_family.clone(), + size_pt: *size_pt, + color: *color, + transform: *transform, + cache: self.opt_surface(cache)?, + psd_raw: self.opt_blob(psd_raw)?, + runs: runs.clone(), + paragraphs: paragraphs.clone(), + shape: *shape, + orientation: *orientation, + antialias: *antialias, + warp: warp.clone(), + }) + } + ContentM::Shape { fill, cache, psd_raw, path, stroke, live } => LayerContent::Shape(ShapeLayer { path: path.clone(), fill: fill.clone(), stroke: stroke.clone(), @@ -486,58 +392,40 @@ impl Loader<'_> { cache: self.opt_surface(cache)?, psd_raw: self.opt_blob(psd_raw)?, }), - ContentM::Smart { - source, - transform, - smart_filters, - cache, - psd_raw, - filters_enabled, - filter_mask, - warp, - stack_mode, - } => LayerContent::Smart(SmartObject { - source: match source { - SmartSourceM::Embedded { file_name, blob } => SmartSource::Embedded { - file_name: file_name.clone(), - bytes: self.fetch.blob(blob)?, + ContentM::Smart { source, transform, smart_filters, cache, psd_raw, filters_enabled, filter_mask, warp, stack_mode } => { + LayerContent::Smart(SmartObject { + source: match source { + SmartSourceM::Embedded { file_name, blob } => SmartSource::Embedded { file_name: file_name.clone(), bytes: self.fetch.blob(blob)? }, + SmartSourceM::Linked { path } => SmartSource::Linked { path: path.clone() }, }, - SmartSourceM::Linked { path } => SmartSource::Linked { path: path.clone() }, - }, - transform: *transform, - smart_filters: smart_filters.clone(), - cache: self.opt_surface(cache)?, - psd_raw: self.opt_blob(psd_raw)?, - filters_enabled: *filters_enabled, - filter_mask: match filter_mask { - Some(mm) => Some(LayerMask { - surface: self.surface(&mm.surface)?, - enabled: mm.enabled, - linked: mm.linked, - density: mm.density, - feather: mm.feather, - }), - None => None, - }, - warp: warp.clone().filter(|w| w.mesh.as_ref().is_none_or(|m| m.is_valid())), - stack_mode: *stack_mode, - }), + transform: *transform, + smart_filters: smart_filters.clone(), + cache: self.opt_surface(cache)?, + psd_raw: self.opt_blob(psd_raw)?, + filters_enabled: *filters_enabled, + filter_mask: match filter_mask { + Some(mm) => Some(LayerMask { + surface: self.surface(&mm.surface)?, + enabled: mm.enabled, + linked: mm.linked, + density: mm.density, + feather: mm.feather, + }), + None => None, + }, + warp: warp.clone().filter(|w| w.mesh.as_ref().is_none_or(|m| m.is_valid())), + stack_mode: *stack_mode, + }) + } }; let mask = match &m.mask { - Some(mm) => Some(LayerMask { - surface: self.surface(&mm.surface)?, - enabled: mm.enabled, - linked: mm.linked, - density: mm.density, - feather: mm.feather, - }), + Some(mm) => { + Some(LayerMask { surface: self.surface(&mm.surface)?, enabled: mm.enabled, linked: mm.linked, density: mm.density, feather: mm.feather }) + } None => None, }; let fill_cache = match &m.fill_cache { - Some(fc) => Some(FillCache { - fill: fc.fill.clone(), - surface: self.surface(&fc.surface)?, - }), + Some(fc) => Some(FillCache { fill: fc.fill.clone(), surface: self.surface(&fc.surface)? }), None => None, }; let mut psd_blocks = Vec::with_capacity(m.psd_blocks.len()); @@ -574,46 +462,26 @@ impl Loader<'_> { } pub(crate) fn document(&mut self, m: &DocM) -> Result { - let layers = m - .layers - .iter() - .map(|l| self.layer(l, 0)) - .collect::>>()?; + let layers = m.layers.iter().map(|l| self.layer(l, 0)).collect::>>()?; let mut channels = Vec::with_capacity(m.channels.len()); for c in &m.channels { channels.push(self.channel(c)?); } let quick_mask = m.quick_mask.as_ref().map(|c| self.channel(c)).transpose()?; - let mut md = Metadata { - xmp: m.metadata.xmp.clone(), - exif: self.opt_blob(&m.metadata.exif)?, - psd_resources: Vec::new(), - psd_global_blocks: Vec::new(), - }; + let mut md = Metadata { xmp: m.metadata.xmp.clone(), exif: self.opt_blob(&m.metadata.exif)?, psd_resources: Vec::new(), psd_global_blocks: Vec::new() }; for (id, n, h) in &m.metadata.psd_resources { md.psd_resources.push((*id, n.clone(), self.fetch.blob(h)?)); } for (s, k, h) in &m.metadata.psd_global_blocks { - md.psd_global_blocks - .push((key4(s)?, key4(k)?, self.fetch.blob(h)?)); + md.psd_global_blocks.push((key4(s)?, key4(k)?, self.fetch.blob(h)?)); } let mut paths = Vec::with_capacity(m.paths.len()); for p in &m.paths { - paths.push(NamedPath { - name: p.name.clone(), - path: p.path.clone(), - psd_raw: self.opt_blob(&p.psd_raw)?, - }); + paths.push(NamedPath { name: p.name.clone(), path: p.path.clone(), psd_raw: self.opt_blob(&p.psd_raw)? }); } let mut patterns = Vec::with_capacity(m.patterns.len()); for p in &m.patterns { - patterns.push(Pattern { - id: p.id.clone(), - name: p.name.clone(), - width: p.width, - height: p.height, - surface: self.surface(&p.surface)?, - }); + patterns.push(Pattern { id: p.id.clone(), name: p.name.clone(), width: p.width, height: p.height, surface: self.surface(&p.surface)? }); } let layer_comps = m.layer_comps.iter().map(|c| self.comp(c)).collect::>>()?; let last_document_state = m.last_document_state.as_ref().map(|c| self.comp(c)).transpose()?; @@ -668,14 +536,7 @@ impl Loader<'_> { } fn channel(&mut self, c: &ChannelM) -> Result { - Ok(AlphaChannel { - name: c.name.clone(), - surface: self.surface(&c.surface)?, - spot: c.spot, - color: c.color, - opacity: c.opacity, - indicates: c.indicates, - }) + Ok(AlphaChannel { name: c.name.clone(), surface: self.surface(&c.surface)?, spot: c.spot, color: c.color, opacity: c.opacity, indicates: c.indicates }) } } diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 6e66d615d4..799df78873 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -44,9 +44,7 @@ pub enum FormatError { Corrupt(String), #[error("unsupported .pcraft feature: {0}")] Unsupported(String), - #[error( - "bundle written by a newer version (format {found}, this build reads up to {supported})" - )] + #[error("bundle written by a newer version (format {found}, this build reads up to {supported})")] TooNew { found: u32, supported: u32 }, #[error("limit exceeded: {0}")] LimitExceeded(String), @@ -88,12 +86,7 @@ pub struct LoadOptions { impl Default for LoadOptions { fn default() -> Self { - LoadOptions { - max_manifest_bytes: 256 << 20, - max_blob_bytes: 1 << 30, - max_total_bytes: 16 << 30, - preserve_ids: true, - } + LoadOptions { max_manifest_bytes: 256 << 20, max_blob_bytes: 1 << 30, max_total_bytes: 16 << 30, preserve_ids: true } } } @@ -129,12 +122,7 @@ pub fn load_path(path: &Path) -> Result { pub fn load_path_with(path: &Path, opts: &LoadOptions) -> Result { if path.is_dir() { - store::load( - &store::DirSource { - root: path.to_path_buf(), - }, - opts, - ) + store::load(&store::DirSource { root: path.to_path_buf() }, opts) } else { let bytes = std::fs::read(path)?; load_from_bytes_with(&bytes, opts) diff --git a/crates/format/src/manifest.rs b/crates/format/src/manifest.rs index 8589383d15..4206f2fdc1 100644 --- a/crates/format/src/manifest.rs +++ b/crates/format/src/manifest.rs @@ -9,8 +9,7 @@ use photocraft_color::{BlendMode, Color, ColorMode, PixelFormat, SampleType}; use photocraft_doc::{ - Adjustment, ClippingPath, Effect, Fill, GlobalLight, Guides, LabelColor, LiveShape, Locks, Path, ShapeStroke, - SmartFilter, VectorMask, text, + Adjustment, ClippingPath, Effect, Fill, GlobalLight, Guides, LabelColor, LiveShape, Locks, Path, ShapeStroke, SmartFilter, VectorMask, text, }; use photocraft_geom::{Affine, Size}; use serde::{Deserialize, Serialize}; diff --git a/crates/format/src/migrate.rs b/crates/format/src/migrate.rs index 648d3a8089..d24581275e 100644 --- a/crates/format/src/migrate.rs +++ b/crates/format/src/migrate.rs @@ -14,25 +14,16 @@ pub(crate) const STEPS: &[Step] = &[]; /// Upgrade `v` in place to `target` using `steps`. pub(crate) fn migrate_with(v: &mut Value, steps: &[Step], target: u32) -> Result { - let found = v - .get("format_version") - .and_then(Value::as_u64) - .ok_or_else(|| FormatError::corrupt("manifest has no format_version"))?; - let found = - u32::try_from(found).map_err(|_| FormatError::corrupt("format_version out of range"))?; + let found = v.get("format_version").and_then(Value::as_u64).ok_or_else(|| FormatError::corrupt("manifest has no format_version"))?; + let found = u32::try_from(found).map_err(|_| FormatError::corrupt("format_version out of range"))?; if found == 0 { return Err(FormatError::corrupt("format_version 0 is invalid")); } if found > target { - return Err(FormatError::TooNew { - found, - supported: target, - }); + return Err(FormatError::TooNew { found, supported: target }); } for ver in found..target { - let step = steps - .get(ver as usize - 1) - .ok_or_else(|| FormatError::Unsupported(format!("no migration from format {ver}")))?; + let step = steps.get(ver as usize - 1).ok_or_else(|| FormatError::Unsupported(format!("no migration from format {ver}")))?; step(v)?; v["format_version"] = Value::from(ver + 1); } @@ -79,10 +70,7 @@ mod tests { #[test] fn too_new_and_invalid_versions() { let mut v = json!({"format_version": 99}); - assert!(matches!( - migrate(&mut v), - Err(FormatError::TooNew { found: 99, .. }) - )); + assert!(matches!(migrate(&mut v), Err(FormatError::TooNew { found: 99, .. }))); assert!(migrate(&mut json!({"format_version": 0})).is_err()); assert!(migrate(&mut json!({})).is_err()); assert!(migrate_with(&mut json!({"format_version": 1}), &[], 2).is_err()); diff --git a/crates/format/src/store.rs b/crates/format/src/store.rs index 39e599d17b..267df071e4 100644 --- a/crates/format/src/store.rs +++ b/crates/format/src/store.rs @@ -30,18 +30,11 @@ fn compress(data: &[u8]) -> Vec { } fn decompress(data: &[u8], expected: usize, what: &str) -> Result> { - let mut dec = ruzstd::decoding::StreamingDecoder::new(data) - .map_err(|e| FormatError::corrupt(format!("{what}: zstd: {e}")))?; + let mut dec = ruzstd::decoding::StreamingDecoder::new(data).map_err(|e| FormatError::corrupt(format!("{what}: zstd: {e}")))?; let mut out = Vec::with_capacity(expected.min(64 << 20)); - (&mut dec) - .take(expected as u64 + 1) - .read_to_end(&mut out) - .map_err(|e| FormatError::corrupt(format!("{what}: zstd: {e}")))?; + (&mut dec).take(expected as u64 + 1).read_to_end(&mut out).map_err(|e| FormatError::corrupt(format!("{what}: zstd: {e}")))?; if out.len() != expected { - return Err(FormatError::corrupt(format!( - "{what}: expected {expected} bytes, got {}", - out.len() - ))); + return Err(FormatError::corrupt(format!("{what}: expected {expected} bytes, got {}", out.len()))); } Ok(out) } @@ -51,15 +44,9 @@ fn hash_bytes(b: &[u8]) -> Hash { } fn png(img: &Rgba8Image) -> Result> { - let image = photocraft_codecs::Image::from_u8( - img.width, - img.height, - photocraft_codecs::ChannelLayout::Rgba, - img.pixels.clone(), - ) - .map_err(|e| FormatError::Unsupported(format!("preview: {e}")))?; - photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()) - .map_err(|e| FormatError::Unsupported(format!("preview: {e}"))) + let image = photocraft_codecs::Image::from_u8(img.width, img.height, photocraft_codecs::ChannelLayout::Rgba, img.pixels.clone()) + .map_err(|e| FormatError::Unsupported(format!("preview: {e}")))?; + photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).map_err(|e| FormatError::Unsupported(format!("preview: {e}"))) } // --------------------------------------------------------------------------- @@ -76,9 +63,7 @@ pub(crate) struct ZipSource<'a> { impl<'a> ZipSource<'a> { pub fn new(bytes: &'a [u8]) -> Result { - Ok(ZipSource { - zip: ZipReader::new(bytes)?, - }) + Ok(ZipSource { zip: ZipReader::new(bytes)? }) } } @@ -95,13 +80,9 @@ pub(crate) struct DirSource { impl Source for DirSource { fn get(&self, path: &str, max: usize) -> Result> { let p = self.root.join(path); - let len = std::fs::metadata(&p) - .map_err(|_| FormatError::corrupt(format!("missing `{path}`")))? - .len(); + let len = std::fs::metadata(&p).map_err(|_| FormatError::corrupt(format!("missing `{path}`")))?.len(); if len > max as u64 { - return Err(FormatError::LimitExceeded(format!( - "`{path}` is {len} bytes (max {max})" - ))); + return Err(FormatError::LimitExceeded(format!("`{path}` is {len} bytes (max {max})"))); } Ok(std::fs::read(p)?) } @@ -129,10 +110,7 @@ impl LoadFetch<'_> { fn account(&mut self, n: usize) -> Result<()> { self.total += n as u64; if self.total > self.opts.max_total_bytes { - return Err(FormatError::LimitExceeded(format!( - "bundle expands beyond {} bytes", - self.opts.max_total_bytes - ))); + return Err(FormatError::LimitExceeded(format!("bundle expands beyond {} bytes", self.opts.max_total_bytes))); } Ok(()) } @@ -148,9 +126,7 @@ impl Fetch for LoadFetch<'_> { let z = self.src.get(&path, len + len / 8 + 4096)?; let data = decompress(&z, len, &path)?; if hash_bytes(&data) != hash { - return Err(FormatError::corrupt(format!( - "{path}: content does not match its hash" - ))); + return Err(FormatError::corrupt(format!("{path}: content does not match its hash"))); } Ok(data) } @@ -166,23 +142,15 @@ impl Fetch for LoadFetch<'_> { let z = self.src.get(&path, self.opts.max_blob_bytes)?; // Blob length is not in the manifest; the frame header bounds it and // we cap at max_blob_bytes. - let mut dec = ruzstd::decoding::StreamingDecoder::new(&z[..]) - .map_err(|e| FormatError::corrupt(format!("{path}: zstd: {e}")))?; + let mut dec = ruzstd::decoding::StreamingDecoder::new(&z[..]).map_err(|e| FormatError::corrupt(format!("{path}: zstd: {e}")))?; let mut data = Vec::new(); - (&mut dec) - .take(self.opts.max_blob_bytes as u64 + 1) - .read_to_end(&mut data) - .map_err(|e| FormatError::corrupt(format!("{path}: zstd: {e}")))?; + (&mut dec).take(self.opts.max_blob_bytes as u64 + 1).read_to_end(&mut data).map_err(|e| FormatError::corrupt(format!("{path}: zstd: {e}")))?; if data.len() > self.opts.max_blob_bytes { - return Err(FormatError::LimitExceeded(format!( - "{path} exceeds max_blob_bytes" - ))); + return Err(FormatError::LimitExceeded(format!("{path} exceeds max_blob_bytes"))); } self.account(data.len())?; if hash_bytes(&data) != hash { - return Err(FormatError::corrupt(format!( - "{path}: content does not match its hash" - ))); + return Err(FormatError::corrupt(format!("{path}: content does not match its hash"))); } let data = Arc::new(data); self.blobs.insert(hash.to_owned(), data.clone()); @@ -192,25 +160,12 @@ impl Fetch for LoadFetch<'_> { pub(crate) fn load(src: &dyn Source, opts: &LoadOptions) -> Result { let m = read_manifest(src, opts)?; - let mut fetch = LoadFetch { - src, - opts: *opts, - total: 0, - blobs: HashMap::new(), - }; - let mut loader = Loader { - fetch: &mut fetch, - preserve_ids: opts.preserve_ids, - max_id: 0, - id_map: HashMap::new(), - }; + let mut fetch = LoadFetch { src, opts: *opts, total: 0, blobs: HashMap::new() }; + let mut loader = Loader { fetch: &mut fetch, preserve_ids: opts.preserve_ids, max_id: 0, id_map: HashMap::new() }; let doc = loader.document(&m.document)?; if opts.preserve_ids && !convert::reserve_ids_through(loader.max_id) { // Ids far beyond our counter: remap instead of risking collisions. - let fresh = LoadOptions { - preserve_ids: false, - ..*opts - }; + let fresh = LoadOptions { preserve_ids: false, ..*opts }; return load(src, &fresh); } Ok(doc) @@ -252,9 +207,7 @@ impl Sink for Collect<'_> { && w.upgrade().is_some_and(|t| Arc::ptr_eq(&t, tile)) { let h = h.clone(); - self.tiles - .entry(h.clone()) - .or_insert_with(|| (tile.clone(), format.sample)); + self.tiles.entry(h.clone()).or_insert_with(|| (tile.clone(), format.sample)); return h; } let h = if cfg!(target_endian = "big") { @@ -267,9 +220,7 @@ impl Sink for Collect<'_> { if let Some(cache) = self.hash_cache.as_deref_mut() { cache.insert(key, (Arc::downgrade(tile), h.clone())); } - self.tiles - .entry(h.clone()) - .or_insert_with(|| (tile.clone(), format.sample)); + self.tiles.entry(h.clone()).or_insert_with(|| (tile.clone(), format.sample)); h } @@ -353,10 +304,7 @@ impl PcraftWriter { fn prepare(&mut self, doc: &Document, opts: &SaveOptions) -> Result { self.hash_cache.retain(|_, (w, _)| w.strong_count() > 0); - let mut c = Collect { - hash_cache: Some(&mut self.hash_cache), - ..Default::default() - }; + let mut c = Collect { hash_cache: Some(&mut self.hash_cache), ..Default::default() }; let document = convert::doc_m(doc, &mut c); let mut previews = Vec::new(); if let Some(t) = &opts.thumbnail { @@ -373,12 +321,7 @@ impl PcraftWriter { composite: opts.composite.as_ref().map(|_| COMPOSITE.to_owned()), }; let manifest = serde_json::to_vec_pretty(&manifest)?; - let stats = SaveStats { - tiles_total: c.tiles.len(), - blobs_total: c.blobs.len(), - manifest_bytes: manifest.len(), - ..Default::default() - }; + let stats = SaveStats { tiles_total: c.tiles.len(), blobs_total: c.blobs.len(), manifest_bytes: manifest.len(), ..Default::default() }; let mut objects = BTreeMap::new(); for (h, (t, s)) in c.tiles { objects.insert(tile_path(&h), Object::Tile(t, s)); @@ -386,12 +329,7 @@ impl PcraftWriter { for (h, b) in c.blobs { objects.insert(blob_path(&h), Object::Blob(b)); } - Ok(Prepared { - manifest, - objects, - previews, - stats, - }) + Ok(Prepared { manifest, objects, previews, stats }) } /// Save as a ZIP bundle in memory. @@ -432,12 +370,7 @@ impl PcraftWriter { /// Save into a directory bundle: writes only missing objects, then the /// manifest (atomically), then removes unreferenced objects. - pub fn save_dir( - &mut self, - doc: &Document, - dir: &Path, - opts: &SaveOptions, - ) -> Result { + pub fn save_dir(&mut self, doc: &Document, dir: &Path, opts: &SaveOptions) -> Result { let p = self.prepare(doc, opts)?; let mut stats = p.stats; for sub in ["tiles", "blobs", "composite"] { @@ -477,12 +410,7 @@ impl PcraftWriter { /// Save to `path`: a directory bundle if `path` is an existing directory /// or ends with a path separator, otherwise a ZIP file (written atomically). - pub fn save_path( - &mut self, - doc: &Document, - path: &Path, - opts: &SaveOptions, - ) -> Result { + pub fn save_path(&mut self, doc: &Document, path: &Path, opts: &SaveOptions) -> Result { let s = path.to_string_lossy(); if path.is_dir() || s.ends_with('/') || s.ends_with('\\') { self.save_dir(doc, path, opts) @@ -514,12 +442,7 @@ pub(crate) fn write_atomic(path: &Path, data: &[u8]) -> Result<()> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } - let tmp = path.with_extension(format!( - "{}tmp", - path.extension() - .map(|e| format!("{}.", e.to_string_lossy())) - .unwrap_or_default() - )); + let tmp = path.with_extension(format!("{}tmp", path.extension().map(|e| format!("{}.", e.to_string_lossy())).unwrap_or_default())); std::fs::write(&tmp, data)?; std::fs::rename(&tmp, path)?; Ok(()) diff --git a/crates/format/src/zip.rs b/crates/format/src/zip.rs index 4f7de67527..0ef2910214 100644 --- a/crates/format/src/zip.rs +++ b/crates/format/src/zip.rs @@ -19,11 +19,7 @@ pub fn crc32(data: &[u8]) -> u32 { for (i, e) in t.iter_mut().enumerate() { let mut c = i as u32; for _ in 0..8 { - c = if c & 1 != 0 { - 0xEDB8_8320 ^ (c >> 1) - } else { - c >> 1 - }; + c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 }; } *e = c; } @@ -51,23 +47,15 @@ impl Default for ZipWriter { impl ZipWriter { pub fn new() -> Self { - ZipWriter { - out: Vec::new(), - central: Vec::new(), - count: 0, - } + ZipWriter { out: Vec::new(), central: Vec::new(), count: 0 } } pub fn add(&mut self, name: &str, data: &[u8]) -> Result<(), FormatError> { if self.count == u16::MAX { - return Err(FormatError::Unsupported( - "more than 65535 zip entries (ZIP64 not supported)".into(), - )); + return Err(FormatError::Unsupported("more than 65535 zip entries (ZIP64 not supported)".into())); } - let size = u32::try_from(data.len()) - .map_err(|_| FormatError::Unsupported("zip entry over 4 GiB".into()))?; - let offset = u32::try_from(self.out.len()) - .map_err(|_| FormatError::Unsupported("zip over 4 GiB".into()))?; + let size = u32::try_from(data.len()).map_err(|_| FormatError::Unsupported("zip entry over 4 GiB".into()))?; + let offset = u32::try_from(self.out.len()).map_err(|_| FormatError::Unsupported("zip over 4 GiB".into()))?; let crc = crc32(data); let name_b = name.as_bytes(); // Local file header. @@ -107,8 +95,7 @@ impl ZipWriter { } pub fn finish(mut self) -> Result, FormatError> { - let cd_offset = u32::try_from(self.out.len()) - .map_err(|_| FormatError::Unsupported("zip over 4 GiB".into()))?; + let cd_offset = u32::try_from(self.out.len()).map_err(|_| FormatError::Unsupported("zip over 4 GiB".into()))?; let cd_size = self.central.len() as u32; self.out.extend_from_slice(&self.central); self.out.extend_from_slice(&EOCD_SIG.to_le_bytes()); @@ -139,14 +126,10 @@ pub struct ZipReader<'a> { } fn u16_at(b: &[u8], o: usize) -> Result { - b.get(o..o + 2) - .map(|s| u16::from_le_bytes([s[0], s[1]])) - .ok_or_else(|| FormatError::corrupt("truncated zip")) + b.get(o..o + 2).map(|s| u16::from_le_bytes([s[0], s[1]])).ok_or_else(|| FormatError::corrupt("truncated zip")) } fn u32_at(b: &[u8], o: usize) -> Result { - b.get(o..o + 4) - .map(|s| u32::from_le_bytes([s[0], s[1], s[2], s[3]])) - .ok_or_else(|| FormatError::corrupt("truncated zip")) + b.get(o..o + 4).map(|s| u32::from_le_bytes([s[0], s[1], s[2], s[3]])).ok_or_else(|| FormatError::corrupt("truncated zip")) } impl<'a> ZipReader<'a> { @@ -181,9 +164,7 @@ impl<'a> ZipReader<'a> { let elen = u16_at(bytes, p + 30)? as usize; let clen = u16_at(bytes, p + 32)? as usize; let local = u32_at(bytes, p + 42)? as usize; - let name = bytes - .get(p + 46..p + 46 + nlen) - .ok_or_else(|| FormatError::corrupt("truncated zip name"))?; + let name = bytes.get(p + 46..p + 46 + nlen).ok_or_else(|| FormatError::corrupt("truncated zip name"))?; let name = String::from_utf8_lossy(name).into_owned(); if flags & 1 != 0 { return Err(FormatError::Unsupported("encrypted zip entries".into())); @@ -195,22 +176,10 @@ impl<'a> ZipReader<'a> { let lnlen = u16_at(bytes, local + 26)? as usize; let lelen = u16_at(bytes, local + 28)? as usize; let data_offset = local + 30 + lnlen + lelen; - if data_offset - .checked_add(compressed) - .is_none_or(|e| e > bytes.len()) - { - return Err(FormatError::corrupt(format!( - "zip entry `{name}` out of range" - ))); + if data_offset.checked_add(compressed).is_none_or(|e| e > bytes.len()) { + return Err(FormatError::corrupt(format!("zip entry `{name}` out of range"))); } - entries.push(Entry { - name, - method, - crc, - compressed, - uncompressed, - data_offset, - }); + entries.push(Entry { name, method, crc, compressed, uncompressed, data_offset }); p += 46 + nlen + elen + clen; } Ok(ZipReader { bytes, entries }) @@ -223,10 +192,7 @@ impl<'a> ZipReader<'a> { /// Entry payload (decompressed), at most `max` bytes, CRC-checked. pub fn read(&self, e: &Entry, max: usize) -> Result, FormatError> { if e.uncompressed > max { - return Err(FormatError::LimitExceeded(format!( - "zip entry `{}` is {} bytes (max {max})", - e.name, e.uncompressed - ))); + return Err(FormatError::LimitExceeded(format!("zip entry `{}` is {} bytes (max {max})", e.name, e.uncompressed))); } let raw = &self.bytes[e.data_offset..e.data_offset + e.compressed]; let data = match e.method { @@ -236,37 +202,24 @@ impl<'a> ZipReader<'a> { flate2::read::DeflateDecoder::new(raw) .take(max as u64 + 1) .read_to_end(&mut out) - .map_err(|err| { - FormatError::corrupt(format!("zip entry `{}`: {err}", e.name)) - })?; + .map_err(|err| FormatError::corrupt(format!("zip entry `{}`: {err}", e.name)))?; out } m => { - return Err(FormatError::Unsupported(format!( - "zip compression method {m} for `{}`", - e.name - ))); + return Err(FormatError::Unsupported(format!("zip compression method {m} for `{}`", e.name))); } }; if data.len() != e.uncompressed || data.len() > max { - return Err(FormatError::corrupt(format!( - "zip entry `{}` has wrong size", - e.name - ))); + return Err(FormatError::corrupt(format!("zip entry `{}` has wrong size", e.name))); } if crc32(&data) != e.crc { - return Err(FormatError::corrupt(format!( - "zip entry `{}` failed its CRC check", - e.name - ))); + return Err(FormatError::corrupt(format!("zip entry `{}` failed its CRC check", e.name))); } Ok(data) } pub fn read_by_name(&self, name: &str, max: usize) -> Result, FormatError> { - let e = self - .find(name) - .ok_or_else(|| FormatError::corrupt(format!("missing `{name}`")))?; + let e = self.find(name).ok_or_else(|| FormatError::corrupt(format!("missing `{name}`")))?; self.read(e, max) } } @@ -292,10 +245,7 @@ mod tests { assert_eq!(r.read_by_name("a.txt", 100).unwrap(), b"hello"); assert_eq!(r.read_by_name("dir/b.bin", 100).unwrap(), [0, 1, 2, 3]); assert!(r.read_by_name("empty", 0).unwrap().is_empty()); - assert!(matches!( - r.read_by_name("a.txt", 3), - Err(FormatError::LimitExceeded(_)) - )); + assert!(matches!(r.read_by_name("a.txt", 3), Err(FormatError::LimitExceeded(_)))); } #[test] diff --git a/crates/format/tests/analysis.rs b/crates/format/tests/analysis.rs index d4e0bffd37..81ad8bada4 100644 --- a/crates/format/tests/analysis.rs +++ b/crates/format/tests/analysis.rs @@ -8,9 +8,23 @@ use photocraft_format::*; fn measurement_and_notes_roundtrip_and_default_when_absent() { let mut doc = Document::new("a", Size::new(40, 30), ColorMode::Rgb, SampleType::U16); doc.measurement.scale = MeasurementScale { pixel_length: 72.0, logical_length: 1.0, units: "inches".into() }; - doc.measurement.count_groups = vec![CountGroup { name: "Cells".into(), color: Color::rgb(0.0, 0.5, 1.0), marker_size: 3, label_size: 12, visible: true, points: vec![[1.0, 2.0], [5.5, 6.5]] }]; + doc.measurement.count_groups = vec![CountGroup { + name: "Cells".into(), + color: Color::rgb(0.0, 0.5, 1.0), + marker_size: 3, + label_size: 12, + visible: true, + points: vec![[1.0, 2.0], [5.5, 6.5]], + }]; doc.measurement.ruler = Some(Ruler { start: [1.0, 1.0], end: [30.0, 20.0], protractor: Some([1.0, 25.0]) }); - doc.notes = vec![Note { author: "A".into(), text: "Fix the sky\nplease".into(), position: [10.0, 12.0], popup: [10.0, 30.0, 200.0, 150.0], open: true, ..Default::default() }]; + doc.notes = vec![Note { + author: "A".into(), + text: "Fix the sky\nplease".into(), + position: [10.0, 12.0], + popup: [10.0, 30.0, 200.0, 150.0], + open: true, + ..Default::default() + }]; let bytes = save_to_bytes(&doc, &SaveOptions::default()).unwrap(); let back = load_from_bytes(&bytes).unwrap(); assert_eq!(back.measurement, doc.measurement); diff --git a/crates/format/tests/autosave.rs b/crates/format/tests/autosave.rs index be6d8f40be..a055fc714c 100644 --- a/crates/format/tests/autosave.rs +++ b/crates/format/tests/autosave.rs @@ -12,22 +12,14 @@ fn autosave_then_recover() { let dir = temp_dir("recovery"); let doc = Arc::new(rich_doc(ColorMode::Rgb, SampleType::U16)); let saver = Autosaver::new(&dir, "doc-1"); - saver.request( - doc.clone(), - 7, - Some("/work/a.pcraft".into()), - SaveOptions::default(), - ); + saver.request(doc.clone(), 7, Some("/work/a.pcraft".into()), SaveOptions::default()); let r = saver.flush().expect("a save ran"); let stats = r.unwrap(); assert!(stats.tiles_written > 0); let entries = list_recovery(&dir); assert_eq!(entries.len(), 1); assert_eq!(entries[0].info.revision, 7); - assert_eq!( - entries[0].info.original_path.as_deref(), - Some("/work/a.pcraft") - ); + assert_eq!(entries[0].info.original_path.as_deref(), Some("/work/a.pcraft")); assert_eq!(entries[0].info.document_name, "Rich"); assert_eq!(recover(&entries[0]).unwrap(), *doc); discard_recovery(&dir, &entries[0]).unwrap(); @@ -42,17 +34,8 @@ fn repeated_autosaves_are_incremental_and_coalesced() { let mut doc = rich_doc(ColorMode::Rgb, SampleType::U8); for i in 0..5 { let id = doc.layers[1].id; - doc.layer_mut(id) - .unwrap() - .surface_mut() - .unwrap() - .write_pixel(i, 0, &[1.0, 0.0, 0.0, 1.0]); - saver.request( - Arc::new(doc.clone()), - i as u64, - None, - SaveOptions::default(), - ); + doc.layer_mut(id).unwrap().surface_mut().unwrap().write_pixel(i, 0, &[1.0, 0.0, 0.0, 1.0]); + saver.request(Arc::new(doc.clone()), i as u64, None, SaveOptions::default()); } saver.flush().unwrap().unwrap(); let e = list_recovery(&dir); @@ -65,19 +48,9 @@ fn repeated_autosaves_are_incremental_and_coalesced() { fn discard_removes_everything() { let dir = temp_dir("discard"); let saver = Autosaver::new(&dir, "x y/z"); - saver.request( - Arc::new(rich_doc(ColorMode::Grayscale, SampleType::U8)), - 1, - None, - SaveOptions::default(), - ); + saver.request(Arc::new(rich_doc(ColorMode::Grayscale, SampleType::U8)), 1, None, SaveOptions::default()); let path = saver.bundle_path(); - assert!( - path.file_name() - .unwrap() - .to_string_lossy() - .starts_with("x_y_z") - ); + assert!(path.file_name().unwrap().to_string_lossy().starts_with("x_y_z")); saver.discard().unwrap(); assert!(list_recovery(&dir).is_empty()); assert!(!path.exists()); diff --git a/crates/format/tests/common/mod.rs b/crates/format/tests/common/mod.rs index bbcbb95297..5272f44f37 100644 --- a/crates/format/tests/common/mod.rs +++ b/crates/format/tests/common/mod.rs @@ -33,9 +33,7 @@ pub fn scribble(s: &mut Surface, seed: u64, hdr: bool) { for &(x0, y0) in &[(-12, -7), (0, 0), (250, 250), (600, 30)] { for dy in 0..9 { for dx in 0..11 { - let px: Vec = (0..ch) - .map(|_| if hdr { r.f() * 4.0 - 1.0 } else { r.f() }) - .collect(); + let px: Vec = (0..ch).map(|_| if hdr { r.f() * 4.0 - 1.0 } else { r.f() }).collect(); s.write_pixel(x0 + dx, y0 + dy, &px); } } @@ -43,11 +41,7 @@ pub fn scribble(s: &mut Surface, seed: u64, hdr: bool) { } pub fn blob(seed: u8, n: usize) -> Arc> { - Arc::new( - (0..n) - .map(|i| (i as u8).wrapping_mul(31).wrapping_add(seed)) - .collect(), - ) + Arc::new((0..n).map(|i| (i as u8).wrapping_mul(31).wrapping_add(seed)).collect()) } pub fn rich_doc(mode: ColorMode, depth: SampleType) -> Document { @@ -71,15 +65,8 @@ pub fn rich_doc(mode: ColorMode, depth: SampleType) -> Document { mask.feather = 3.5; mask.linked = false; paint.mask = Some(mask); - paint.effects.items.push(Effect::ColorOverlay { - common: FxCommon::new(BlendMode::Normal, 0.8), - color: Color::rgb(1.0, 0.2, 0.1), - }); - paint.effects.items.push(Effect::GradientOverlay { - common: FxCommon::new(BlendMode::Normal, 0.8), - gradient: Gradient::default(), - dither: true, - }); + paint.effects.items.push(Effect::ColorOverlay { common: FxCommon::new(BlendMode::Normal, 0.8), color: Color::rgb(1.0, 0.2, 0.1) }); + paint.effects.items.push(Effect::GradientOverlay { common: FxCommon::new(BlendMode::Normal, 0.8), gradient: Gradient::default(), dither: true }); paint.effects.psd_raw = Some(blob(3, 64)); paint.psd_blocks.push((*b"vmsk", blob(4, 40))); paint.psd_blocks.push((*b"lnsr", blob(5, 4))); @@ -88,31 +75,17 @@ pub fn rich_doc(mode: ColorMode, depth: SampleType) -> Document { let mut inner = Layer::raster("Inner", pf); scribble(inner.surface_mut().unwrap(), 6, hdr); let nested = Layer::group("Nested", vec![inner]); - let mut adj = Layer::new( - "Hue/Sat", - LayerContent::Adjustment(Adjustment::HueSaturation { - hue: 30.0, - saturation: -10.0, - lightness: 5.0, - colorize: false, - }), - ); + let mut adj = Layer::new("Hue/Sat", LayerContent::Adjustment(Adjustment::HueSaturation { hue: 30.0, saturation: -10.0, lightness: 5.0, colorize: false })); adj.visible = false; let mut group = Layer::group("Group", vec![nested, adj]); if let LayerContent::Group(g) = &mut group.content { g.expanded = false; } - let mut fill = Layer::new( - "Fill", - LayerContent::Fill(Fill::Solid(Color::rgba(0.1, 0.2, 0.3, 0.9))), - ); + let mut fill = Layer::new("Fill", LayerContent::Fill(Fill::Solid(Color::rgba(0.1, 0.2, 0.3, 0.9)))); let mut fc = Surface::new(pf); scribble(&mut fc, 7, false); - fill.fill_cache = Some(FillCache { - fill: Fill::Solid(Color::rgba(0.1, 0.2, 0.3, 0.9)), - surface: fc, - }); + fill.fill_cache = Some(FillCache { fill: Fill::Solid(Color::rgba(0.1, 0.2, 0.3, 0.9)), surface: fc }); let grad = Layer::new( "Gradient", LayerContent::Fill(Fill::Gradient { @@ -136,33 +109,12 @@ pub fn rich_doc(mode: ColorMode, depth: SampleType) -> Document { transform: Affine::translate(10.5, 20.25), cache: Some(cache.clone()), psd_raw: Some(blob(9, 100)), - runs: vec![ - TextRun { - len: 6, - style: CharStyle::default(), - }, - TextRun { - len: 9, - style: CharStyle::default(), - }, - ], - paragraphs: vec![ParagraphRun { - len: 15, - style: ParagraphStyle::default(), - }], - shape: TextShape::Box { - x: 1.0, - y: 2.0, - width: 100.0, - height: 50.0, - }, + runs: vec![TextRun { len: 6, style: CharStyle::default() }, TextRun { len: 9, style: CharStyle::default() }], + paragraphs: vec![ParagraphRun { len: 15, style: ParagraphStyle::default() }], + shape: TextShape::Box { x: 1.0, y: 2.0, width: 100.0, height: 50.0 }, orientation: text::Orientation::Vertical, antialias: text::AntiAlias::Crisp, - warp: Some(TextWarp { - style: "warpArc".into(), - value: 25.0, - ..Default::default() - }), + warp: Some(TextWarp { style: "warpArc".into(), value: 25.0, ..Default::default() }), }), ); let mut shape = Layer::new( @@ -196,10 +148,7 @@ pub fn rich_doc(mode: ColorMode, depth: SampleType) -> Document { let smart = Layer::new( "Smart", LayerContent::Smart(SmartObject { - source: SmartSource::Embedded { - file_name: "inner.png".into(), - bytes: blob(11, 2000), - }, + source: SmartSource::Embedded { file_name: "inner.png".into(), bytes: blob(11, 2000) }, transform: Affine::scale(0.5), smart_filters: vec![SmartFilter { command: "filter.blur.gaussian".into(), @@ -225,9 +174,7 @@ pub fn rich_doc(mode: ColorMode, depth: SampleType) -> Document { let linked = Layer::new( "Linked", LayerContent::Smart(SmartObject { - source: SmartSource::Linked { - path: "/tmp/linked.psd".into(), - }, + source: SmartSource::Linked { path: "/tmp/linked.psd".into() }, transform: Affine::IDENTITY, smart_filters: vec![], cache: None, @@ -238,8 +185,7 @@ pub fn rich_doc(mode: ColorMode, depth: SampleType) -> Document { stack_mode: None, }), ); - d.layers - .extend([paint, group, fill, grad, text, shape, smart, linked]); + d.layers.extend([paint, group, fill, grad, text, shape, smart, linked]); let mut ch = Surface::new(PixelFormat::GRAY8.with_sample(depth)); scribble(&mut ch, 13, false); @@ -248,35 +194,25 @@ pub fn rich_doc(mode: ColorMode, depth: SampleType) -> Document { alpha.opacity = 0.35; alpha.indicates = photocraft_doc::ColorIndicates::SelectedAreas; d.channels.push(alpha); - d.channels.push(AlphaChannel { - spot: Some((Color::rgb(1.0, 0.0, 0.5), 0.7)), - ..AlphaChannel::new("Spot", ch.clone()) - }); + d.channels.push(AlphaChannel { spot: Some((Color::rgb(1.0, 0.0, 0.5), 0.7)), ..AlphaChannel::new("Spot", ch.clone()) }); d.quick_mask = Some(AlphaChannel::new("Quick Mask", ch)); let mut pat = Surface::new(PixelFormat::new(photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U16, true)); scribble(&mut pat, 21, false); d.patterns.push(photocraft_doc::Pattern::new("$$$/Patterns/Test=Scribble", pat, 32, 24)); d.color_table = Some(photocraft_doc::ColorTable { colors: vec![[0, 0, 0], [255, 128, 0]], transparent: Some(1) }); - d.duotone = Some(photocraft_doc::Duotone { inks: vec![photocraft_doc::DuotoneInk::new("Black", [0.0; 3]), photocraft_doc::DuotoneInk::new("PANTONE 151 C", [1.0, 0.5, 0.0])], psd_raw: Some(vec![1, 2, 3]) }); - d.guides = Guides { - horizontal: vec![10.0, 20.5], - vertical: vec![100.25], - }; + d.duotone = Some(photocraft_doc::Duotone { + inks: vec![photocraft_doc::DuotoneInk::new("Black", [0.0; 3]), photocraft_doc::DuotoneInk::new("PANTONE 151 C", [1.0, 0.5, 0.0])], + psd_raw: Some(vec![1, 2, 3]), + }); + d.guides = Guides { horizontal: vec![10.0, 20.5], vertical: vec![100.25] }; let mut sel = Surface::new(PixelFormat::GRAY8); scribble(&mut sel, 14, false); d.selection = Some(sel); d.metadata.xmp = Some("".into()); d.metadata.exif = Some(blob(15, 64)); - d.metadata - .psd_resources - .push((1036, "thumb".into(), blob(16, 20))); - d.metadata - .psd_global_blocks - .push((*b"8BIM", *b"Patt", blob(17, 50))); - d.global_light = GlobalLight { - angle: 120.0, - altitude: 30.0, - }; + d.metadata.psd_resources.push((1036, "thumb".into(), blob(16, 20))); + d.metadata.psd_global_blocks.push((*b"8BIM", *b"Patt", blob(17, 50))); + d.global_light = GlobalLight { angle: 120.0, altitude: 30.0 }; d.paths.push(NamedPath { name: "Path 1".into(), path: vector_path(), psd_raw: Some(blob(18, 52)) }); d.paths.push(NamedPath { name: "Empty".into(), path: Path::default(), psd_raw: None }); let mut wp = vector_path(); diff --git a/crates/format/tests/corruption.rs b/crates/format/tests/corruption.rs index c8aa231528..92bb8a1e65 100644 --- a/crates/format/tests/corruption.rs +++ b/crates/format/tests/corruption.rs @@ -7,11 +7,7 @@ use photocraft_format::*; use proptest::prelude::*; fn sample() -> Vec { - save_to_bytes( - &rich_doc(ColorMode::Rgb, SampleType::U8), - &SaveOptions::default(), - ) - .unwrap() + save_to_bytes(&rich_doc(ColorMode::Rgb, SampleType::U8), &SaveOptions::default()).unwrap() } fn rebuild(bytes: &[u8], f: impl Fn(&str, Vec) -> Option>) -> Vec { @@ -67,12 +63,7 @@ fn missing_manifest_is_error() { fn swapped_tile_content_fails_hash_check() { let b = sample(); let r = zip::ZipReader::new(&b).unwrap(); - let tiles: Vec<_> = r - .entries - .iter() - .filter(|e| e.name.starts_with("tiles/")) - .map(|e| e.name.clone()) - .collect(); + let tiles: Vec<_> = r.entries.iter().filter(|e| e.name.starts_with("tiles/")).map(|e| e.name.clone()).collect(); let other = r.read_by_name(&tiles[1], usize::MAX).unwrap(); let first = tiles[0].clone(); let b2 = rebuild(&b, |n, d| Some(if n == first { other.clone() } else { d })); @@ -82,13 +73,7 @@ fn swapped_tile_content_fails_hash_check() { #[test] fn bad_manifest_json() { - let b = rebuild(&sample(), |n, d| { - Some(if n == "manifest.json" { - b"{not json".to_vec() - } else { - d - }) - }); + let b = rebuild(&sample(), |n, d| Some(if n == "manifest.json" { b"{not json".to_vec() } else { d })); assert!(matches!(load_from_bytes(&b), Err(FormatError::Json(_)))); } @@ -103,10 +88,7 @@ fn too_new_version_rejected() { d }) }); - assert!(matches!( - load_from_bytes(&b), - Err(FormatError::TooNew { found: 999, .. }) - )); + assert!(matches!(load_from_bytes(&b), Err(FormatError::TooNew { found: 999, .. }))); } #[test] @@ -128,42 +110,23 @@ fn invalid_hash_strings_rejected() { #[test] fn manifest_size_limit() { let b = sample(); - let opts = LoadOptions { - max_manifest_bytes: 100, - ..Default::default() - }; - assert!(matches!( - load_from_bytes_with(&b, &opts), - Err(FormatError::LimitExceeded(_)) - )); + let opts = LoadOptions { max_manifest_bytes: 100, ..Default::default() }; + assert!(matches!(load_from_bytes_with(&b, &opts), Err(FormatError::LimitExceeded(_)))); } #[test] fn total_size_limit() { let b = sample(); - let opts = LoadOptions { - max_total_bytes: 1000, - ..Default::default() - }; - assert!(matches!( - load_from_bytes_with(&b, &opts), - Err(FormatError::LimitExceeded(_)) - )); + let opts = LoadOptions { max_total_bytes: 1000, ..Default::default() }; + assert!(matches!(load_from_bytes_with(&b, &opts), Err(FormatError::LimitExceeded(_)))); } #[test] fn directory_bundle_tampered_tile() { let doc = rich_doc(ColorMode::Rgb, SampleType::U8); let dir = temp_dir("tamper"); - PcraftWriter::new() - .save_dir(&doc, &dir, &SaveOptions::default()) - .unwrap(); - let tile = std::fs::read_dir(dir.join("tiles")) - .unwrap() - .next() - .unwrap() - .unwrap() - .path(); + PcraftWriter::new().save_dir(&doc, &dir, &SaveOptions::default()).unwrap(); + let tile = std::fs::read_dir(dir.join("tiles")).unwrap().next().unwrap().unwrap().path(); std::fs::write(&tile, b"garbage").unwrap(); assert!(load_path(&dir).is_err()); std::fs::remove_dir_all(dir).unwrap(); @@ -179,8 +142,7 @@ fn deflated_zip_entries_are_readable() { let mut central = Vec::new(); for e in &r.entries { let data = r.read(e, usize::MAX).unwrap(); - let mut enc = - flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default()); + let mut enc = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default()); enc.write_all(&data).unwrap(); let comp = enc.finish().unwrap(); let crc = zip::crc32(&data); diff --git a/crates/format/tests/roundtrip.rs b/crates/format/tests/roundtrip.rs index 4bb4b8f2a7..f52094bf9b 100644 --- a/crates/format/tests/roundtrip.rs +++ b/crates/format/tests/roundtrip.rs @@ -7,12 +7,7 @@ use photocraft_doc::{Document, Layer, LayerId}; use photocraft_format::*; use photocraft_raster::Rgba8Image; -const MODES: [ColorMode; 4] = [ - ColorMode::Rgb, - ColorMode::Grayscale, - ColorMode::Cmyk, - ColorMode::Lab, -]; +const MODES: [ColorMode; 4] = [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab]; fn check_zip(mode: ColorMode, depth: SampleType) { let doc = rich_doc(mode, depth); @@ -44,9 +39,7 @@ fn directory_bundle_roundtrip_all_modes() { for depth in SampleType::ALL { let doc = rich_doc(mode, depth); let dir = temp_dir("dir"); - PcraftWriter::new() - .save_dir(&doc, &dir, &SaveOptions::default()) - .unwrap(); + PcraftWriter::new().save_dir(&doc, &dir, &SaveOptions::default()).unwrap(); assert!(dir.join("manifest.json").is_file()); assert_eq!(load_path(&dir).unwrap(), doc, "{mode:?} {depth:?}"); std::fs::remove_dir_all(dir).unwrap(); @@ -59,9 +52,7 @@ fn save_path_zip_file() { let doc = rich_doc(ColorMode::Rgb, SampleType::U8); let dir = temp_dir("file"); let path = dir.join("a.pcraft"); - PcraftWriter::new() - .save_path(&doc, &path, &SaveOptions::default()) - .unwrap(); + PcraftWriter::new().save_path(&doc, &path, &SaveOptions::default()).unwrap(); assert!(path.is_file()); assert_eq!(load_path(&path).unwrap(), doc); std::fs::remove_dir_all(dir).unwrap(); @@ -69,16 +60,8 @@ fn save_path_zip_file() { #[test] fn empty_document_roundtrips() { - let doc = Document::new( - "empty", - photocraft_doc::Size::new(1, 1), - ColorMode::Rgb, - SampleType::U8, - ); - assert_eq!( - load_from_bytes(&save_to_bytes(&doc, &SaveOptions::default()).unwrap()).unwrap(), - doc - ); + let doc = Document::new("empty", photocraft_doc::Size::new(1, 1), ColorMode::Rgb, SampleType::U8); + assert_eq!(load_from_bytes(&save_to_bytes(&doc, &SaveOptions::default()).unwrap()).unwrap(), doc); } #[test] @@ -94,11 +77,7 @@ fn zip_incremental_only_new_tiles() { assert_eq!(s2.blobs_written, 0); // Touch one pixel of one layer: exactly one tile is new. let id = doc.layers[1].id; - doc.layer_mut(id) - .unwrap() - .surface_mut() - .unwrap() - .write_pixel(3, 3, &[0.5, 0.5, 0.5, 1.0]); + doc.layer_mut(id).unwrap().surface_mut().unwrap().write_pixel(3, 3, &[0.5, 0.5, 0.5, 1.0]); let (bytes, s3) = w.save_zip(&doc, &SaveOptions::default()).unwrap(); assert_eq!(s3.tiles_written, 1); assert_eq!(load_from_bytes(&bytes).unwrap(), doc); @@ -112,9 +91,7 @@ fn directory_incremental_and_gc() { let s1 = w.save_dir(&doc, &dir, &SaveOptions::default()).unwrap(); assert_eq!(s1.tiles_written, s1.tiles_total); // A fresh writer still skips files already on disk. - let s2 = PcraftWriter::new() - .save_dir(&doc, &dir, &SaveOptions::default()) - .unwrap(); + let s2 = PcraftWriter::new().save_dir(&doc, &dir, &SaveOptions::default()).unwrap(); assert_eq!(s2.tiles_written, 0); assert_eq!(s2.objects_removed, 0); // Delete a layer: its unique tiles get garbage-collected. @@ -130,20 +107,13 @@ fn directory_incremental_and_gc() { #[test] fn identical_tiles_are_stored_once() { - let mut doc = Document::new( - "d", - photocraft_doc::Size::new(10, 10), - ColorMode::Rgb, - SampleType::U8, - ); + let mut doc = Document::new("d", photocraft_doc::Size::new(10, 10), ColorMode::Rgb, SampleType::U8); let mut a = Layer::raster("a", doc.pixel_format()); scribble(a.surface_mut().unwrap(), 5, false); let b = a.duplicate(); doc.layers.push(a); doc.layers.push(b); - let (_, s) = PcraftWriter::new() - .save_zip(&doc, &SaveOptions::default()) - .unwrap(); + let (_, s) = PcraftWriter::new().save_zip(&doc, &SaveOptions::default()).unwrap(); let per_layer = doc.layers[0].surface().unwrap().tile_count(); assert_eq!(s.tiles_total, per_layer); } @@ -165,24 +135,14 @@ fn ids_preserved_and_counter_advanced() { } let back = load_from_bytes(&z.finish().unwrap()).unwrap(); assert_eq!(back.layers[0].id.0, big); - assert!( - LayerId::fresh().0 > big, - "fresh ids must not collide with loaded ids" - ); + assert!(LayerId::fresh().0 > big, "fresh ids must not collide with loaded ids"); } #[test] fn fresh_ids_option() { let doc = rich_doc(ColorMode::Rgb, SampleType::U8); let bytes = save_to_bytes(&doc, &SaveOptions::default()).unwrap(); - let back = load_from_bytes_with( - &bytes, - &LoadOptions { - preserve_ids: false, - ..Default::default() - }, - ) - .unwrap(); + let back = load_from_bytes_with(&bytes, &LoadOptions { preserve_ids: false, ..Default::default() }).unwrap(); assert_ne!(back.id, doc.id); assert_ne!(back.layers[0].id, doc.layers[0].id); assert_eq!(back.layers.len(), doc.layers.len()); @@ -193,15 +153,8 @@ fn fresh_ids_option() { fn previews_embedded_and_readable() { let doc = rich_doc(ColorMode::Rgb, SampleType::U8); let mut thumb = Rgba8Image::new(4, 3); - thumb - .pixels - .iter_mut() - .enumerate() - .for_each(|(i, p)| *p = i as u8); - let opts = SaveOptions { - thumbnail: Some(thumb.clone()), - composite: Some(thumb.clone()), - }; + thumb.pixels.iter_mut().enumerate().for_each(|(i, p)| *p = i as u8); + let opts = SaveOptions { thumbnail: Some(thumb.clone()), composite: Some(thumb.clone()) }; let bytes = save_to_bytes(&doc, &opts).unwrap(); let png = read_thumbnail(&bytes).unwrap().unwrap(); let img = photocraft_codecs::decode(&png).unwrap(); @@ -210,11 +163,7 @@ fn previews_embedded_and_readable() { let m = read_manifest(&bytes).unwrap(); assert_eq!(m.composite.as_deref(), Some("composite/preview.png")); assert_eq!(m.format_version, FORMAT_VERSION); - assert!( - read_thumbnail(&save_to_bytes(&doc, &SaveOptions::default()).unwrap()) - .unwrap() - .is_none() - ); + assert!(read_thumbnail(&save_to_bytes(&doc, &SaveOptions::default()).unwrap()).unwrap().is_none()); } #[test] @@ -227,11 +176,7 @@ fn manifest_is_human_readable_json() { assert!(text.contains("\"format_version\": 1")); assert!(text.contains("\"Hue/Sat\"")); assert!(text.contains("filter.blur.gaussian")); - assert!( - r.entries - .iter() - .any(|e| e.name.starts_with("tiles/") && e.name.ends_with(".zst")) - ); + assert!(r.entries.iter().any(|e| e.name.starts_with("tiles/") && e.name.ends_with(".zst"))); assert!(r.entries.iter().any(|e| e.name.starts_with("blobs/"))); } @@ -323,26 +268,8 @@ fn blend_if_roundtrips() { use photocraft_doc::{BlendIf, BlendRange}; let mut doc = rich_doc(ColorMode::Rgb, SampleType::U16); let mut bi = BlendIf::default(); - bi.set( - 0, - [ - BlendRange { - black: [20, 60], - white: [255, 255], - }, - BlendRange::FULL, - ], - ); - bi.set( - 3, - [ - BlendRange::FULL, - BlendRange { - black: [0, 0], - white: [180, 220], - }, - ], - ); + bi.set(0, [BlendRange { black: [20, 60], white: [255, 255] }, BlendRange::FULL]); + bi.set(3, [BlendRange::FULL, BlendRange { black: [0, 0], white: [180, 220] }]); doc.layers[0].blend_if = bi.clone(); let back = load_from_bytes(&save_to_bytes(&doc, &SaveOptions::default()).unwrap()).unwrap(); assert_eq!(back, doc); diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 592f53c1c5..c57584c095 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -162,16 +162,7 @@ impl Affine { pub fn mul(&self, o: &Affine) -> Affine { let [a, b, c, d, e, f] = self.m; let [oa, ob, oc, od, oe, of] = o.m; - Affine { - m: [ - a * oa + c * ob, - b * oa + d * ob, - a * oc + c * od, - b * oc + d * od, - a * oe + c * of + e, - b * oe + d * of + f, - ], - } + Affine { m: [a * oa + c * ob, b * oa + d * ob, a * oc + c * od, b * oc + d * od, a * oe + c * of + e, b * oe + d * of + f] } } pub fn apply(&self, p: Point) -> Point { let [a, b, c, d, e, f] = self.m; @@ -187,9 +178,7 @@ impl Affine { } let [a, b, c, d, e, f] = self.m; let inv = 1.0 / det; - Some(Affine { - m: [d * inv, -b * inv, -c * inv, a * inv, (c * f - d * e) * inv, (b * e - a * f) * inv], - }) + Some(Affine { m: [d * inv, -b * inv, -c * inv, a * inv, (c * f - d * e) * inv, (b * e - a * f) * inv] }) } } diff --git a/crates/geom/src/warp.rs b/crates/geom/src/warp.rs index 91e36e8c6d..a0194ea213 100644 --- a/crates/geom/src/warp.rs +++ b/crates/geom/src/warp.rs @@ -188,7 +188,8 @@ impl StyleWarp { pub fn apply(&self, x: f64, y: f64) -> (f64, f64) { // Normalised coordinates: u along the warp axis, v across it, both -1..1 over the box. let (dx, dy) = (x - self.c.0, y - self.c.1); - let (u, v, hu, hv) = if self.vertical { (dy / self.h.1, dx / self.h.0, self.h.1, self.h.0) } else { (dx / self.h.0, dy / self.h.1, self.h.0, self.h.1) }; + let (u, v, hu, hv) = + if self.vertical { (dy / self.h.1, dx / self.h.0, self.h.1, self.h.0) } else { (dx / self.h.0, dy / self.h.1, self.h.0, self.h.1) }; let (mut u2, mut v2) = self.bend(u, v, hu / hv); // Distortions: a perspective-like taper along each axis. if self.hd != 0.0 { @@ -291,12 +292,7 @@ fn split_cubic(p: [[f64; 2]; 4], t: f64) -> [[f64; 2]; 7] { /// Inverse of the Bernstein interpolation matrix at t = 0, 1/3, 2/3, 1 (rows: control point, /// columns: sample). Exact rational values. -const BERN_INV: [[f64; 4]; 4] = [ - [1.0, 0.0, 0.0, 0.0], - [-5.0 / 6.0, 3.0, -1.5, 1.0 / 3.0], - [1.0 / 3.0, -1.5, 3.0, -5.0 / 6.0], - [0.0, 0.0, 0.0, 1.0], -]; +const BERN_INV: [[f64; 4]; 4] = [[1.0, 0.0, 0.0, 0.0], [-5.0 / 6.0, 3.0, -1.5, 1.0 / 3.0], [1.0 / 3.0, -1.5, 3.0, -5.0 / 6.0], [0.0, 0.0, 0.0, 1.0]]; /// A grid of bicubic Bezier patches over the unit square of a source box. /// @@ -369,7 +365,10 @@ impl BezierMesh { /// Checks the invariants (knots and point count); malformed data from files is rejected. pub fn is_valid(&self) -> bool { let knots_ok = |k: &[f64]| k.len() >= 2 && k[0] == 0.0 && k[k.len() - 1] == 1.0 && k.windows(2).all(|w| w[1] > w[0]); - knots_ok(&self.us) && knots_ok(&self.vs) && self.points.len() == self.nx() * self.ny() && self.points.iter().all(|p| p[0].is_finite() && p[1].is_finite()) + knots_ok(&self.us) + && knots_ok(&self.vs) + && self.points.len() == self.nx() * self.ny() + && self.points.iter().all(|p| p[0].is_finite() && p[1].is_finite()) } fn locate(knots: &[f64], s: f64) -> (usize, f64) { diff --git a/crates/gpu/src/fx.rs b/crates/gpu/src/fx.rs index d0993e4d70..a8f998d074 100644 --- a/crates/gpu/src/fx.rs +++ b/crates/gpu/src/fx.rs @@ -23,9 +23,9 @@ use std::collections::HashMap; use std::hash::{Hash, Hasher}; use std::sync::Arc; -use photocraft_compose::effects::FieldKind; use photocraft_compose::effects::BevelPaint; -use photocraft_doc::{BevelTechnique, Contour, Pattern, Effect, GlobalLight, GlowSource, GlowTechnique, Layer, LayerContent}; +use photocraft_compose::effects::FieldKind; +use photocraft_doc::{BevelTechnique, Contour, Effect, GlobalLight, GlowSource, GlowTechnique, Layer, LayerContent, Pattern}; use photocraft_geom::{Rect, TileCoord}; use photocraft_raster::{Surface, Tile}; @@ -278,7 +278,11 @@ pub(crate) fn program_with(e: &Effect, light: &GlobalLight, vector_shape: bool, // dist_outside of the shifted map is the shifted field (integer offsets; shifted-in // pixels are far away for a drop shadow, inside for an inner shadow). let f = b.field(if inner { FieldKind::OutsideInverse } else { FieldKind::Outside }, r); - let d = if dx == 0.0 && dy == 0.0 { f } else { b.push(stage(Kernel::MShift, Some(f), None, [dx, dy, if inner { -0.5 } else { 1e10 }, 0.0], dx.abs().max(dy.abs()) as i32)) }; + let d = if dx == 0.0 && dy == 0.0 { + f + } else { + b.push(stage(Kernel::MShift, Some(f), None, [dx, dy, if inner { -0.5 } else { 1e10 }, 0.0], dx.abs().max(dy.abs()) as i32)) + }; m = b.dilate(src, d, r); } let m = b.blur(m, bw); @@ -663,7 +667,14 @@ mod tests { shape[y * w + x] = (90.0 - r).clamp(0.0, 1.0) * if (x / 13 + y / 17) % 5 == 0 { 0.6 } else { 1.0 }; } } - for kind in [FieldKind::Outside, FieldKind::Inside, FieldKind::OutsideInverse, FieldKind::StrokeOutside, FieldKind::StrokeInside, FieldKind::StrokeOutsideVector] { + for kind in [ + FieldKind::Outside, + FieldKind::Inside, + FieldKind::OutsideInverse, + FieldKind::StrokeOutside, + FieldKind::StrokeInside, + FieldKind::StrokeOutsideVector, + ] { let reach = 9; let whole = photocraft_compose::effects::distance_field(kind, shape.clone(), w, h); let banded = field(kind, reach, &shape, region, region); diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 78f24a572f..5b6c25491c 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -161,7 +161,14 @@ impl Tex { Tex { texture, view } } fn map(device: &wgpu::Device, label: &str, r: Rect, format: wgpu::TextureFormat) -> Self { - Tex::new(device, label, r.width(), r.height(), format, wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST) + Tex::new( + device, + label, + r.width(), + r.height(), + format, + wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + ) } /// Upload R32F values (row-major over `r`) into this region-space texture at `r`. fn write_r32(&self, queue: &wgpu::Queue, region: Rect, r: Rect, v: &[f32]) { @@ -170,7 +177,12 @@ impl Tex { } let bytes: Vec = v.iter().flat_map(|x| x.to_le_bytes()).collect(); queue.write_texture( - wgpu::TexelCopyTextureInfo { texture: &self.texture, mip_level: 0, origin: wgpu::Origin3d { x: (r.x0 - region.x0) as u32, y: (r.y0 - region.y0) as u32, z: 0 }, aspect: wgpu::TextureAspect::All }, + wgpu::TexelCopyTextureInfo { + texture: &self.texture, + mip_level: 0, + origin: wgpu::Origin3d { x: (r.x0 - region.x0) as u32, y: (r.y0 - region.y0) as u32, z: 0 }, + aspect: wgpu::TextureAspect::All, + }, &bytes, wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(r.width() * 4), rows_per_image: Some(r.height()) }, wgpu::Extent3d { width: r.width(), height: r.height(), depth_or_array_layers: 1 }, @@ -214,7 +226,10 @@ struct FxEntry { impl FxEntry { fn bytes(&self) -> usize { - self.shape.bytes() + self.shape_cpu.len() * 4 + self.fields.values().map(|(_, t)| t.bytes()).sum::() + self.progs.iter().flat_map(|p| p.maps.iter().flatten()).map(Tex::bytes).sum::() + self.shape.bytes() + + self.shape_cpu.len() * 4 + + self.fields.values().map(|(_, t)| t.bytes()).sum::() + + self.progs.iter().flat_map(|p| p.maps.iter().flatten()).map(Tex::bytes).sum::() } } @@ -253,7 +268,12 @@ impl Kit { data[2 * i * STRIDE as usize..][..w.len()].copy_from_slice(&w); data[(2 * i + 1) * STRIDE as usize..][..d.op.len()].copy_from_slice(&d.op); } - let ubuf = device.create_buffer(&wgpu::BufferDescriptor { label: Some("pc_fx_uniforms"), size: data.len() as u64, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }); + let ubuf = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("pc_fx_uniforms"), + size: data.len() as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); queue.write_buffer(&ubuf, 0, &data); let bg0 = uniform_group(device, &self.bgl0, &ubuf); for (i, d) in draws.iter().enumerate() { @@ -269,7 +289,12 @@ impl Kit { let bg1 = texture_group(device, &self.bgl1, &views); let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("pc_fx_map"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: &d.target, resolve_target: None, depth_slice: None, ops: wgpu::Operations { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store } })], + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &d.target, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store }, + })], depth_stencil_attachment: None, timestamp_writes: None, occlusion_query_set: None, @@ -289,14 +314,21 @@ fn uniform_group(device: &wgpu::Device, layout: &wgpu::BindGroupLayout, ubuf: &w label: Some("pc_compose_uniforms"), layout, entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { buffer: ubuf, offset: 0, size: NonZeroU64::new(CHUNK_UNIFORM) }) }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { buffer: ubuf, offset: 0, size: NonZeroU64::new(OP_UNIFORM) }) }, + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { buffer: ubuf, offset: 0, size: NonZeroU64::new(CHUNK_UNIFORM) }), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { buffer: ubuf, offset: 0, size: NonZeroU64::new(OP_UNIFORM) }), + }, ], }) } fn texture_group(device: &wgpu::Device, layout: &wgpu::BindGroupLayout, views: &[&wgpu::TextureView]) -> wgpu::BindGroup { - let e: Vec = views.iter().enumerate().map(|(b, v)| wgpu::BindGroupEntry { binding: b as u32, resource: wgpu::BindingResource::TextureView(v) }).collect(); + let e: Vec = + views.iter().enumerate().map(|(b, v)| wgpu::BindGroupEntry { binding: b as u32, resource: wgpu::BindingResource::TextureView(v) }).collect(); device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("pc_compose_pass"), layout, entries: &e }) } @@ -318,7 +350,11 @@ fn tex_entry(binding: u32) -> wgpu::BindGroupLayoutEntry { wgpu::BindGroupLayoutEntry { binding, visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { multisampled: false, sample_type: wgpu::TextureSampleType::Float { filterable: false }, view_dimension: wgpu::TextureViewDimension::D2 }, + ty: wgpu::BindingType::Texture { + multisampled: false, + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D2, + }, count: None, } } @@ -355,11 +391,7 @@ impl Compositor { /// and would otherwise panic at render-pipeline creation. pub fn preferred_acc_format(adapter: &wgpu::Adapter) -> wgpu::TextureFormat { let feats = adapter.get_texture_format_features(wgpu::TextureFormat::Rgba32Float); - if feats.allowed_usages.contains(wgpu::TextureUsages::RENDER_ATTACHMENT) { - wgpu::TextureFormat::Rgba32Float - } else { - wgpu::TextureFormat::Rgba16Float - } + if feats.allowed_usages.contains(wgpu::TextureUsages::RENDER_ATTACHMENT) { wgpu::TextureFormat::Rgba32Float } else { wgpu::TextureFormat::Rgba16Float } } /// Create a compositor whose accumulation/render-target format is `acc_format`. Pass @@ -367,9 +399,19 @@ impl Compositor { /// which otherwise panics at render-pipeline creation. See `Compositor::preferred_acc_format`. pub fn new_with_format(device: &wgpu::Device, acc_format: wgpu::TextureFormat) -> Self { let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("pc_compose"), source: wgpu::ShaderSource::Wgsl(SHADER.into()) }); - let bgl0 = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("pc_compose_uniforms"), entries: &[uniform_entry(0, CHUNK_UNIFORM), uniform_entry(1, OP_UNIFORM)] }); - let bgl1 = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("pc_compose_textures"), entries: &(0..8).map(tex_entry).collect::>() }); - let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("pc_compose"), bind_group_layouts: &[Some(&bgl0), Some(&bgl1)], immediate_size: 0 }); + let bgl0 = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("pc_compose_uniforms"), + entries: &[uniform_entry(0, CHUNK_UNIFORM), uniform_entry(1, OP_UNIFORM)], + }); + let bgl1 = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("pc_compose_textures"), + entries: &(0..8).map(tex_entry).collect::>(), + }); + let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("pc_compose"), + bind_group_layouts: &[Some(&bgl0), Some(&bgl1)], + immediate_size: 0, + }); let mut pipelines = HashMap::new(); for k in Kernel::DRAWN { let entry = k.entry().expect("drawn kernels have an entry point"); @@ -382,7 +424,12 @@ impl Compositor { primitive: wgpu::PrimitiveState::default(), depth_stencil: None, multisample: wgpu::MultisampleState::default(), - fragment: Some(wgpu::FragmentState { module: &module, entry_point: Some(entry), targets: &[Some(wgpu::ColorTargetState { format, blend: None, write_mask: wgpu::ColorWrites::ALL })], compilation_options: Default::default() }), + fragment: Some(wgpu::FragmentState { + module: &module, + entry_point: Some(entry), + targets: &[Some(wgpu::ColorTargetState { format, blend: None, write_mask: wgpu::ColorWrites::ALL })], + compilation_options: Default::default(), + }), multiview_mask: None, cache: None, }); @@ -451,7 +498,14 @@ impl Compositor { /// Composite `region` of `doc`. Each finished chunk is passed to `sink` while its encoder is /// still open; the chunk texture is reused afterwards, so the sink must record any copies /// or passes that read it into the given encoder. Submits the work before returning. - pub fn render(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, doc: &Document, region: Rect, mut sink: impl FnMut(&mut wgpu::CommandEncoder, ChunkOut<'_>)) -> Result { + pub fn render( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + doc: &Document, + region: Rect, + mut sink: impl FnMut(&mut wgpu::CommandEncoder, ChunkOut<'_>), + ) -> Result { let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("pc_compose") }); let stats = self.encode(device, queue, &mut encoder, doc, region, &mut sink)?; queue.submit([encoder.finish()]); @@ -459,7 +513,15 @@ impl Compositor { } /// Like [`Self::render`] but records into `encoder` without submitting. - pub fn encode(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, encoder: &mut wgpu::CommandEncoder, doc: &Document, region: Rect, sink: &mut dyn FnMut(&mut wgpu::CommandEncoder, ChunkOut<'_>)) -> Result { + pub fn encode( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + encoder: &mut wgpu::CommandEncoder, + doc: &Document, + region: Rect, + sink: &mut dyn FnMut(&mut wgpu::CommandEncoder, ChunkOut<'_>), + ) -> Result { let canvas = doc.bounds(); if canvas.width() > self.max_dim || canvas.height() > self.max_dim { return Err(Unsupported(format!("document larger than the GPU texture limit ({})", self.max_dim))); @@ -526,10 +588,11 @@ impl Compositor { keys.push(k); (keys.len() - 1, r) }); - let mask = p.mask.as_ref().and_then(|m| self.sync(device, queue, doc, m.layer, Role::Mask, m.surface.get(), tile_grid(canvas), stats)).map(|(k, r)| { - keys.push(k); - (keys.len() - 1, r) - }); + let mask = + p.mask.as_ref().and_then(|m| self.sync(device, queue, doc, m.layer, Role::Mask, m.surface.get(), tile_grid(canvas), stats)).map(|(k, r)| { + keys.push(k); + (keys.len() - 1, r) + }); views.push((tex, mask)); maps.push(p.map.and_then(|m| { let e = self.fx.get(&plan.fx[m.fx].layer.id)?; @@ -544,7 +607,18 @@ impl Compositor { /// Run `plan` over `chunks` (document rects, at most CHUNK square), handing each finished /// chunk to `sink`. #[allow(clippy::too_many_arguments)] - fn run_plan(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, encoder: &mut wgpu::CommandEncoder, doc: DocId, canvas: Rect, plan: &Plan<'_>, chunks: &[Rect], stats: &mut Stats, sink: &mut dyn FnMut(&mut wgpu::CommandEncoder, ChunkOut<'_>)) { + fn run_plan( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + encoder: &mut wgpu::CommandEncoder, + doc: DocId, + canvas: Rect, + plan: &Plan<'_>, + chunks: &[Rect], + stats: &mut Stats, + sink: &mut dyn FnMut(&mut wgpu::CommandEncoder, ChunkOut<'_>), + ) { let bound = self.bind_plan(device, queue, doc, canvas, plan, stats); // Chunk pool. @@ -556,7 +630,10 @@ impl Compositor { sample_count: 1, dimension: wgpu::TextureDimension::D2, format: self.acc_format, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::COPY_DST, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::COPY_SRC + | wgpu::TextureUsages::COPY_DST, view_formats: &[], }); let v = t.create_view(&Default::default()); @@ -577,7 +654,12 @@ impl Compositor { let off = (op_base + i as u64 * STRIDE) as usize; data[off..][..w.len()].copy_from_slice(&w); } - let ubuf = device.create_buffer(&wgpu::BufferDescriptor { label: Some("pc_compose_uniforms"), size: data.len() as u64, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }); + let ubuf = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("pc_compose_uniforms"), + size: data.len() as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); queue.write_buffer(&ubuf, 0, &data); let bg0 = uniform_group(device, &self.kit.bgl0, &ubuf); @@ -618,8 +700,18 @@ impl Compositor { let src = &self.pool[p.a.expect("copy source") as usize].0; let dst = &self.pool[p.dst as usize].0; encoder.copy_texture_to_texture( - wgpu::TexelCopyTextureInfo { texture: src, mip_level: 0, origin: wgpu::Origin3d { x: scissor.x0 as u32, y: scissor.y0 as u32, z: 0 }, aspect: wgpu::TextureAspect::All }, - wgpu::TexelCopyTextureInfo { texture: dst, mip_level: 0, origin: wgpu::Origin3d { x: scissor.x0 as u32, y: scissor.y0 as u32, z: 0 }, aspect: wgpu::TextureAspect::All }, + wgpu::TexelCopyTextureInfo { + texture: src, + mip_level: 0, + origin: wgpu::Origin3d { x: scissor.x0 as u32, y: scissor.y0 as u32, z: 0 }, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyTextureInfo { + texture: dst, + mip_level: 0, + origin: wgpu::Origin3d { x: scissor.x0 as u32, y: scissor.y0 as u32, z: 0 }, + aspect: wgpu::TextureAspect::All, + }, wgpu::Extent3d { width: scissor.width(), height: scissor.height(), depth_or_array_layers: 1 }, ); continue; @@ -634,7 +726,10 @@ impl Compositor { view: target, resolve_target: None, depth_slice: None, - ops: wgpu::Operations { load: if clear { wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT) } else { wgpu::LoadOp::Load }, store: wgpu::StoreOp::Store }, + ops: wgpu::Operations { + load: if clear { wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT) } else { wgpu::LoadOp::Load }, + store: wgpu::StoreOp::Store, + }, })], depth_stencil_attachment: None, timestamp_writes: None, @@ -657,7 +752,17 @@ impl Compositor { /// Upload changed tiles of `surface`; returns the resident key and its region (x, y, w, h). #[allow(clippy::too_many_arguments)] - fn sync(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, doc: DocId, layer: LayerId, role: Role, surface: &Surface, grid: Rect, stats: &mut Stats) -> Option<((LayerId, Role), [i32; 4])> { + fn sync( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + doc: DocId, + layer: LayerId, + role: Role, + surface: &Surface, + grid: Rect, + stats: &mut Stats, + ) -> Option<((LayerId, Role), [i32; 4])> { let region = surface.tile_bounds().intersect(&grid); if region.is_empty() { return None; @@ -709,7 +814,11 @@ impl Compositor { } let gone: Vec = r.tiles.keys().filter(|c| surface.tile(**c).is_none()).copied().collect(); if !gone.is_empty() { - let bytes = if r.default_nonzero { convert_tile(surface, None, kind, TileCoord::new(0, 0)) } else { vec![0u8; (TILE_SIZE * TILE_SIZE) as usize * kind.bytes_per_pixel()] }; + let bytes = if r.default_nonzero { + convert_tile(surface, None, kind, TileCoord::new(0, 0)) + } else { + vec![0u8; (TILE_SIZE * TILE_SIZE) as usize * kind.bytes_per_pixel()] + }; for c in gone { write_tile(queue, r, c, &bytes); r.tiles.remove(&c); @@ -720,7 +829,9 @@ impl Compositor { /// Premultiplied RGBA32F texture of a pattern (cached by pixel identity). fn pattern_view(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, pat: &Pattern) -> wgpu::TextureView { - let fp = pat.surface.tiles().fold((pat.width as u64) << 32 | pat.height as u64, |acc, (c, t)| acc.wrapping_mul(0x9e37_79b9_7f4a_7c15) ^ (Arc::as_ptr(t) as usize as u64) ^ ((c.tx as u64) << 20) ^ c.ty as u64); + let fp = pat.surface.tiles().fold((pat.width as u64) << 32 | pat.height as u64, |acc, (c, t)| { + acc.wrapping_mul(0x9e37_79b9_7f4a_7c15) ^ (Arc::as_ptr(t) as usize as u64) ^ ((c.tx as u64) << 20) ^ c.ty as u64 + }); let key = format!("{}\u{0}{}", pat.id, pat.name); if let Some((f, t)) = self.patterns.get(&key) && *f == fp @@ -761,7 +872,15 @@ impl Compositor { } /// Build or update the effect maps of one layer. - fn sync_fx(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, encoder: &mut wgpu::CommandEncoder, doc: &Document, f: &plan::FxLayer<'_>, stats: &mut Stats) { + fn sync_fx( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + encoder: &mut wgpu::CommandEncoder, + doc: &Document, + f: &plan::FxLayer<'_>, + stats: &mut Stats, + ) { let layer = f.layer; let region = f.region; let canvas = doc.bounds(); @@ -805,7 +924,21 @@ impl Compositor { if rebuild { let shape = Tex::map(device, "pc_fx_shape", region, MAP32); let n = rw as usize * rh as usize; - self.fx.insert(layer.id, FxEntry { doc: doc.id, region, shape_key, tiles: [None, None], _pin: is_group.then(|| layer.clone()), shape_cpu: vec![0.0; n], shape, fields: HashMap::new(), progs: Vec::new(), last_used: frame }); + self.fx.insert( + layer.id, + FxEntry { + doc: doc.id, + region, + shape_key, + tiles: [None, None], + _pin: is_group.then(|| layer.clone()), + shape_cpu: vec![0.0; n], + shape, + fields: HashMap::new(), + progs: Vec::new(), + last_used: frame, + }, + ); damage = region; } let e = self.fx.get_mut(&layer.id).expect("inserted"); @@ -828,7 +961,8 @@ impl Compositor { // Programs, and the distance fields they read (max reach per field). let vector_shape = matches!(layer.content, LayerContent::Shape(_)); let anchor = layer.effects.reference.unwrap_or((f64::from(f.bounds.x0), f64::from(f.bounds.y0))); - let progs: Vec = layer.effects.items.iter().filter(|e| e.enabled()).map(|e| fx::program_with(e, &doc.global_light, vector_shape, &doc.patterns, anchor)).collect(); + let progs: Vec = + layer.effects.items.iter().filter(|e| e.enabled()).map(|e| fx::program_with(e, &doc.global_light, vector_shape, &doc.patterns, anchor)).collect(); let mut want: HashMap = HashMap::new(); for (k, r) in progs.iter().flat_map(|p| p.fields.iter()) { let w = want.entry(*k).or_insert(0); @@ -873,7 +1007,10 @@ impl Compositor { continue; } if !same { - e.progs[i] = ProgState { key: prog.key, maps: (0..prog.maps).map(|k| prog.stages.iter().any(|s| s.out == Some(k)).then(|| Tex::map(device, "pc_fx_map", region, MAP16))).collect() }; + e.progs[i] = ProgState { + key: prog.key, + maps: (0..prog.maps).map(|k| prog.stages.iter().any(|s| s.out == Some(k)).then(|| Tex::map(device, "pc_fx_map", region, MAP16))).collect(), + }; } stats.fx_programs += 1; self.run_program(device, queue, encoder, layer.id, i, prog, d, stats); @@ -882,8 +1019,19 @@ impl Compositor { /// Record the passes of `prog` (maps of item `item` of `layer`) recomputing damage `d`. #[allow(clippy::too_many_arguments)] - fn run_program(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, encoder: &mut wgpu::CommandEncoder, layer: LayerId, item: usize, prog: &fx::MapProgram, d: Rect, stats: &mut Stats) { - let pattern_views: Vec> = prog.stages.iter().map(|s| s.pattern.as_ref().map(|p| self.pattern_view(device, queue, p))).collect(); + fn run_program( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + encoder: &mut wgpu::CommandEncoder, + layer: LayerId, + item: usize, + prog: &fx::MapProgram, + d: Rect, + stats: &mut Stats, + ) { + let pattern_views: Vec> = + prog.stages.iter().map(|s| s.pattern.as_ref().map(|p| self.pattern_view(device, queue, p))).collect(); let e = &self.fx[&layer]; let region = e.region; let shape = e.shape.view.clone(); @@ -973,7 +1121,24 @@ fn op_words(p: &plan::Pass<'_>, tex: Option<[i32; 4]>, mask: Option<[i32; 4]>, m flags |= 8; } let (mpo, mps) = map.map_or(([0, 0], [0, 0]), |r| ([r.x0, r.y0], [r.width() as i32, r.height() as i32])); - let mut v = vec![I(plan::mode_index(p.mode)), I(p.adjust_kind), U(flags), U(0), F(p.opacity), F(density), F(mdefault), F(0.0), I(to[0]), I(to[1]), I(ts[0]), I(ts[1]), I(mo[0]), I(mo[1]), I(ms[0]), I(ms[1])]; + let mut v = vec![ + I(plan::mode_index(p.mode)), + I(p.adjust_kind), + U(flags), + U(0), + F(p.opacity), + F(density), + F(mdefault), + F(0.0), + I(to[0]), + I(to[1]), + I(ts[0]), + I(ts[1]), + I(mo[0]), + I(mo[1]), + I(ms[0]), + I(ms[1]), + ]; v.extend(p.color.iter().map(|f| F(*f))); for row in &p.params { v.extend(row.iter().map(|f| F(*f))); @@ -993,7 +1158,12 @@ fn write_tile(queue: &wgpu::Queue, r: &Resident, c: TileCoord, bytes: &[u8]) { let tr = c.rect(); let bpp = r.kind.bytes_per_pixel() as u32; queue.write_texture( - wgpu::TexelCopyTextureInfo { texture: &r.texture, mip_level: 0, origin: wgpu::Origin3d { x: (tr.x0 - r.region.x0) as u32, y: (tr.y0 - r.region.y0) as u32, z: 0 }, aspect: wgpu::TextureAspect::All }, + wgpu::TexelCopyTextureInfo { + texture: &r.texture, + mip_level: 0, + origin: wgpu::Origin3d { x: (tr.x0 - r.region.x0) as u32, y: (tr.y0 - r.region.y0) as u32, z: 0 }, + aspect: wgpu::TextureAspect::All, + }, bytes, wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(TILE_SIZE as u32 * bpp), rows_per_image: Some(TILE_SIZE as u32) }, wgpu::Extent3d { width: TILE_SIZE as u32, height: TILE_SIZE as u32, depth_or_array_layers: 1 }, @@ -1129,7 +1299,13 @@ pub fn render_to_vec(comp: &mut Compositor, device: &wgpu::Device, queue: &wgpu: /// [`render_to_vec`] plus the render's [`Stats`]. #[cfg(not(target_arch = "wasm32"))] -pub fn render_to_vec_stats(comp: &mut Compositor, device: &wgpu::Device, queue: &wgpu::Queue, doc: &Document, rect: Rect) -> Result<(Vec<[f32; 4]>, Stats), Unsupported> { +pub fn render_to_vec_stats( + comp: &mut Compositor, + device: &wgpu::Device, + queue: &wgpu::Queue, + doc: &Document, + rect: Rect, +) -> Result<(Vec<[f32; 4]>, Stats), Unsupported> { let rect = rect.intersect(&doc.bounds()); let mut staging: Vec<(Rect, wgpu::Buffer, u32)> = Vec::new(); let mut bpp = 16u32; @@ -1137,10 +1313,18 @@ pub fn render_to_vec_stats(comp: &mut Compositor, device: &wgpu::Device, queue: // Bytes per pixel of the accumulation format (Rgba32Float = 16, Rgba16Float = 8). bpp = if out.texture.format() == wgpu::TextureFormat::Rgba32Float { 16 } else { 8 }; let row = (out.rect.width() * bpp).div_ceil(256) * 256; - let buf = device.create_buffer(&wgpu::BufferDescriptor { label: Some("pc_readback"), size: (row * out.rect.height()) as u64, usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, mapped_at_creation: false }); + let buf = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("pc_readback"), + size: (row * out.rect.height()) as u64, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); enc.copy_texture_to_buffer( wgpu::TexelCopyTextureInfo { texture: out.texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, - wgpu::TexelCopyBufferInfo { buffer: &buf, layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(row), rows_per_image: Some(out.rect.height()) } }, + wgpu::TexelCopyBufferInfo { + buffer: &buf, + layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(row), rows_per_image: Some(out.rect.height()) }, + }, wgpu::Extent3d { width: out.rect.width(), height: out.rect.height(), depth_or_array_layers: 1 }, ); staging.push((out.rect, buf, row)); @@ -1184,7 +1368,9 @@ mod tests { #[test] fn shader_validates() { let module = wgpu::naga::front::wgsl::parse_str(SHADER).unwrap_or_else(|e| panic!("{}", e.emit_to_string(SHADER))); - wgpu::naga::valid::Validator::new(wgpu::naga::valid::ValidationFlags::all(), wgpu::naga::valid::Capabilities::empty()).validate(&module).unwrap_or_else(|e| panic!("{e:?}")); + wgpu::naga::valid::Validator::new(wgpu::naga::valid::ValidationFlags::all(), wgpu::naga::valid::Capabilities::empty()) + .validate(&module) + .unwrap_or_else(|e| panic!("{e:?}")); for k in Kernel::DRAWN { let e = k.entry().unwrap(); assert!(module.entry_points.iter().any(|ep| ep.name == e), "missing entry point {e}"); diff --git a/crates/gpu/src/plan.rs b/crates/gpu/src/plan.rs index 642ce5f6d0..a806918f6b 100644 --- a/crates/gpu/src/plan.rs +++ b/crates/gpu/src/plan.rs @@ -293,7 +293,14 @@ pub struct DocCtx<'a> { impl<'a> DocCtx<'a> { pub fn of(doc: &'a Document) -> Self { - DocCtx { canvas: doc.bounds(), transfer: Transfer::for_mode(doc.mode), light: doc.global_light, patterns: &doc.patterns, mode: doc.mode, depth: doc.depth } + DocCtx { + canvas: doc.bounds(), + transfer: Transfer::for_mode(doc.mode), + light: doc.global_light, + patterns: &doc.patterns, + mode: doc.mode, + depth: doc.depth, + } } } @@ -443,16 +450,18 @@ impl<'a> Planner<'a> { if !m.enabled { return None; } - Some(MaskUse { layer: layer.id, surface: SurfaceRef::Doc(&m.surface), density: m.density, default: m.surface.default_pixel().first().copied().unwrap_or(1.0) }) + Some(MaskUse { + layer: layer.id, + surface: SurfaceRef::Doc(&m.surface), + density: m.density, + default: m.surface.default_pixel().first().copied().unwrap_or(1.0), + }) } /// Blending Options › Blend If has no GPU pass yet: such documents use the CPU compositor. fn check_blend_if(&self, layer: &Layer) -> Result<(), Unsupported> { if photocraft_compose::blend_if_active(layer, self.cx.mode) { - return Err(Unsupported(format!( - "Blend If on `{}` (composited on the CPU)", - layer.name - ))); + return Err(Unsupported(format!("Blend If on `{}` (composited on the CPU)", layer.name))); } Ok(()) } @@ -837,7 +846,18 @@ impl<'a> Planner<'a> { // The layer hides the shadow beneath it where its fill is see-through. let flags = if s.knocks_out { F_KNOCKOUT } else { 0 }; let see_through = 1.0 - layer.fill_opacity.clamp(0.0, 1.0); - w = self.paint_k(w, content, Cov::Map(map(i, 0), 0.0), &Paint::Color(s.color.to_rgb()), s.common.blend, s.common.opacity, flags, clip, sb, see_through); + w = self.paint_k( + w, + content, + Cov::Map(map(i, 0), 0.0), + &Paint::Color(s.color.to_rgb()), + s.common.blend, + s.common.opacity, + flags, + clip, + sb, + see_through, + ); } } for &(i, e) in &rev { @@ -900,7 +920,17 @@ impl<'a> Planner<'a> { && b.style != photocraft_doc::BevelStyle::OuterBevel { // Inner part (emboss styles: maps 0–1 inside, 2–3 outside). - l = self.paint(l, content, Cov::Map(map(i, 0), 0.0), &Paint::Color(b.highlight_color.to_rgb()), b.highlight.blend, b.highlight.opacity, F_REL, clip, sb); + l = self.paint( + l, + content, + Cov::Map(map(i, 0), 0.0), + &Paint::Color(b.highlight_color.to_rgb()), + b.highlight.blend, + b.highlight.opacity, + F_REL, + clip, + sb, + ); l = self.paint(l, content, Cov::Map(map(i, 1), 0.0), &Paint::Color(b.shadow_color.to_rgb()), b.shadow.blend, b.shadow.opacity, F_REL, clip, sb); } } @@ -910,7 +940,13 @@ impl<'a> Planner<'a> { // Outside stroke parts lie beneath the layer and blend onto the exterior result with their // own mode; a higher stroke covers the ones below it (each takes its coverage × opacity // not yet taken above it, blended over the result as it was before the strokes). - let outs: Vec<(usize, &photocraft_doc::StrokeFx)> = items.iter().copied().enumerate().filter_map(|(i, e)| if let Effect::Stroke(st) = e { Some((i, st)) } else { None }).filter(|(_, st)| stroke_widths(st).1 > 0.0).collect(); + let outs: Vec<(usize, &photocraft_doc::StrokeFx)> = items + .iter() + .copied() + .enumerate() + .filter_map(|(i, e)| if let Effect::Stroke(st) = e { Some((i, st)) } else { None }) + .filter(|(_, st)| stroke_widths(st).1 > 0.0) + .collect(); if !outs.is_empty() { let (mut acc, mut cover): (Option, Option) = (None, None); for (n, &(i, st)) in outs.iter().enumerate() { @@ -947,7 +983,17 @@ impl<'a> Planner<'a> { && paint != photocraft_compose::effects::BevelPaint::Inner { let k = if paint == photocraft_compose::effects::BevelPaint::Both { 2 } else { 0 }; - w = self.paint(w, content, Cov::Map(map(i, k), 0.0), &Paint::Color(b.highlight_color.to_rgb()), b.highlight.blend, b.highlight.opacity, 0, clip, sb); + w = self.paint( + w, + content, + Cov::Map(map(i, k), 0.0), + &Paint::Color(b.highlight_color.to_rgb()), + b.highlight.blend, + b.highlight.opacity, + 0, + clip, + sb, + ); w = self.paint(w, content, Cov::Map(map(i, k + 1), 0.0), &Paint::Color(b.shadow_color.to_rgb()), b.shadow.blend, b.shadow.opacity, 0, clip, sb); } } @@ -1002,7 +1048,18 @@ impl<'a> Planner<'a> { } #[allow(clippy::too_many_arguments)] - fn fx_pass_kernel(&mut self, kernel: Kernel, dst: Slot, content: Slot, paint: &Paint<'a>, blend: BlendMode, opacity: f32, flags: u32, clip: Rect, sb: Rect) -> Pass<'a> { + fn fx_pass_kernel( + &mut self, + kernel: Kernel, + dst: Slot, + content: Slot, + paint: &Paint<'a>, + blend: BlendMode, + opacity: f32, + flags: u32, + clip: Rect, + sb: Rect, + ) -> Pass<'a> { let mut p = Pass::new(kernel, 0); p.a = Some(self.retain(dst)); p.b = Some(self.retain(content)); @@ -1046,7 +1103,19 @@ impl<'a> Planner<'a> { /// [`Self::paint`] with the knockout strength (`F_KNOCKOUT`: coverage × (1 − alpha × k)). #[allow(clippy::too_many_arguments)] - fn paint_k(&mut self, dst: Slot, content: Slot, cov: Cov, paint: &Paint<'a>, blend: BlendMode, opacity: f32, flags: u32, clip: Rect, sb: Rect, knockout: f32) -> Slot { + fn paint_k( + &mut self, + dst: Slot, + content: Slot, + cov: Cov, + paint: &Paint<'a>, + blend: BlendMode, + opacity: f32, + flags: u32, + clip: Rect, + sb: Rect, + knockout: f32, + ) -> Slot { if matches!(paint, Paint::None) { // Missing pattern: compose paints nothing. return dst; @@ -1343,24 +1412,9 @@ mod tests { #[test] fn blend_if_falls_back_to_the_cpu() { - let mut d = Document::with_background( - "t", - Size::new(8, 8), - ColorMode::Rgb, - SampleType::U8, - Color::WHITE, - ); + let mut d = Document::with_background("t", Size::new(8, 8), ColorMode::Rgb, SampleType::U8, Color::WHITE); let mut l = Layer::raster("bi", d.pixel_format()); - l.blend_if.set( - 0, - [ - photocraft_doc::BlendRange { - black: [40, 40], - white: [255, 255], - }, - photocraft_doc::BlendRange::FULL, - ], - ); + l.blend_if.set(0, [photocraft_doc::BlendRange { black: [40, 40], white: [255, 255] }, photocraft_doc::BlendRange::FULL]); d.layers.push(l.clone()); assert!(plan(&d).unwrap_err().0.contains("Blend If")); // Clipped layers too. diff --git a/crates/gpu/tests/parity.rs b/crates/gpu/tests/parity.rs index aa42e21145..0f688a5565 100644 --- a/crates/gpu/tests/parity.rs +++ b/crates/gpu/tests/parity.rs @@ -116,7 +116,10 @@ fn adjustments() -> Vec { Adjustment::BrightnessContrast { brightness: -20.0, contrast: -30.0, legacy: true }, Adjustment::Exposure { exposure: 0.7, offset: 0.02, gamma: 1.2 }, Adjustment::Levels { master: lc(0.1, 0.9, 1.3), per_channel: [lc(0.0, 1.0, 0.8), LevelsChannel::default(), lc(0.2, 0.8, 1.0)] }, - Adjustment::Curves { master: pts(&[(0.0, 0.1), (0.4, 0.6), (1.0, 0.9)]), per_channel: [pts(&[(0.0, 0.0), (0.5, 0.3), (1.0, 1.0)]), pts(&[(0.0, 0.0), (1.0, 1.0)]), pts(&[(0.0, 0.2), (1.0, 1.0)])] }, + Adjustment::Curves { + master: pts(&[(0.0, 0.1), (0.4, 0.6), (1.0, 0.9)]), + per_channel: [pts(&[(0.0, 0.0), (0.5, 0.3), (1.0, 1.0)]), pts(&[(0.0, 0.0), (1.0, 1.0)]), pts(&[(0.0, 0.2), (1.0, 1.0)])], + }, Adjustment::HueSaturation { hue: 40.0, saturation: 30.0, lightness: -10.0, colorize: false }, Adjustment::HueSaturation { hue: 200.0, saturation: 50.0, lightness: 20.0, colorize: true }, Adjustment::Vibrance { vibrance: 50.0, saturation: -20.0 }, @@ -327,7 +330,8 @@ fn incremental_updates_follow_the_document() { use photocraft_doc::adjust::CurvePoint as Cp; use photocraft_doc::{ - Bevel, BevelStyle, BevelTechnique, Contour, Effect, FxCommon, FxPaint, Glow, GlowSource, GlowTechnique, Gradient, Pattern, Satin, Shadow, StrokeFx, StrokePosition, + Bevel, BevelStyle, BevelTechnique, Contour, Effect, FxCommon, FxPaint, Glow, GlowSource, GlowTechnique, Gradient, Pattern, Satin, Shadow, StrokeFx, + StrokePosition, }; /// An anti-aliased blob (disc plus a soft-edged bar and a hole) with partially transparent parts, @@ -382,7 +386,19 @@ fn shadow(blend: BlendMode, opacity: f32, angle: f32, distance: f32, size: f32, } fn glow(paint: FxPaint, technique: GlowTechnique, size: f32, spread: f32, source: GlowSource) -> Glow { - Glow { common: FxCommon::new(BlendMode::Screen, 0.8), paint, technique, spread, size, contour: Contour::Linear, anti_alias: false, range: 0.5, jitter: 0.0, noise: 0.0, source } + Glow { + common: FxCommon::new(BlendMode::Screen, 0.8), + paint, + technique, + spread, + size, + contour: Contour::Linear, + anti_alias: false, + range: 0.5, + jitter: 0.0, + noise: 0.0, + source, + } } fn gradient() -> Gradient { @@ -443,8 +459,26 @@ fn effect_cases() -> Vec<(&'static str, Vec)> { ds_global.use_global_light = true; let mut og_contour = glow(FxPaint::Color(Color::rgb(1.0, 0.9, 0.2)), GlowTechnique::Softer, 9.0, 0.0, GlowSource::Edge); og_contour.contour = contour(); - let mut satin_inv = Satin { common: FxCommon::new(BlendMode::Multiply, 0.6), color: Color::rgb(0.3, 0.0, 0.4), angle: 19.0, distance: 7.0, size: 8.0, contour: contour(), anti_alias: false, invert: true }; - let satin = Satin { common: FxCommon::new(BlendMode::Overlay, 0.7), color: Color::rgb(0.9, 0.4, 0.1), angle: 60.0, distance: 5.0, size: 6.0, contour: Contour::Linear, anti_alias: false, invert: false }; + let mut satin_inv = Satin { + common: FxCommon::new(BlendMode::Multiply, 0.6), + color: Color::rgb(0.3, 0.0, 0.4), + angle: 19.0, + distance: 7.0, + size: 8.0, + contour: contour(), + anti_alias: false, + invert: true, + }; + let satin = Satin { + common: FxCommon::new(BlendMode::Overlay, 0.7), + color: Color::rgb(0.9, 0.4, 0.1), + angle: 60.0, + distance: 5.0, + size: 6.0, + contour: Contour::Linear, + anti_alias: false, + invert: false, + }; satin_inv.common.enabled = true; let mut bevel_contour = bevel(BevelStyle::InnerBevel, true, 8.0, 3.0); bevel_contour.gloss_contour = contour(); @@ -460,23 +494,55 @@ fn effect_cases() -> Vec<(&'static str, Vec)> { ("outer glow contour", vec![Effect::OuterGlow(og_contour)]), ("outer glow precise gradient", vec![Effect::OuterGlow(glow(FxPaint::Gradient(gradient()), GlowTechnique::Precise, 8.0, 0.25, GlowSource::Edge))]), ("inner glow softer edge", vec![Effect::InnerGlow(glow(FxPaint::Color(Color::rgb(0.9, 1.0, 0.8)), GlowTechnique::Softer, 7.0, 0.2, GlowSource::Edge))]), - ("inner glow softer center", vec![Effect::InnerGlow(glow(FxPaint::Color(Color::rgb(0.9, 1.0, 0.8)), GlowTechnique::Softer, 6.0, 0.0, GlowSource::Center))]), - ("inner glow precise edge", vec![Effect::InnerGlow(glow(FxPaint::Color(Color::rgb(0.2, 1.0, 0.8)), GlowTechnique::Precise, 6.0, 0.3, GlowSource::Edge))]), + ( + "inner glow softer center", + vec![Effect::InnerGlow(glow(FxPaint::Color(Color::rgb(0.9, 1.0, 0.8)), GlowTechnique::Softer, 6.0, 0.0, GlowSource::Center))], + ), + ( + "inner glow precise edge", + vec![Effect::InnerGlow(glow(FxPaint::Color(Color::rgb(0.2, 1.0, 0.8)), GlowTechnique::Precise, 6.0, 0.3, GlowSource::Edge))], + ), ("inner glow precise center", vec![Effect::InnerGlow(glow(pat_paint.clone(), GlowTechnique::Precise, 5.0, 0.0, GlowSource::Center))]), ("bevel inner", vec![Effect::BevelEmboss(bevel(BevelStyle::InnerBevel, true, 7.0, 2.0))]), ("bevel outer", vec![Effect::BevelEmboss(bevel(BevelStyle::OuterBevel, true, 6.0, 0.0))]), ("bevel emboss down", vec![Effect::BevelEmboss(bevel(BevelStyle::Emboss, false, 8.0, 1.0))]), ("bevel pillow", vec![Effect::BevelEmboss(bevel(BevelStyle::PillowEmboss, true, 5.0, 4.0))]), - ("bevel inner chisel hard", vec![Effect::BevelEmboss(Bevel { technique: BevelTechnique::ChiselHard, ..bevel(BevelStyle::InnerBevel, true, 9.0, 0.0) })]), - ("bevel outer chisel soft", vec![Effect::BevelEmboss(Bevel { technique: BevelTechnique::ChiselSoft, ..bevel(BevelStyle::OuterBevel, false, 7.5, 2.0) })]), - ("bevel pillow chisel hard", vec![Effect::BevelEmboss(Bevel { technique: BevelTechnique::ChiselHard, ..bevel(BevelStyle::PillowEmboss, false, 7.0, 0.0) })]), + ( + "bevel inner chisel hard", + vec![Effect::BevelEmboss(Bevel { technique: BevelTechnique::ChiselHard, ..bevel(BevelStyle::InnerBevel, true, 9.0, 0.0) })], + ), + ( + "bevel outer chisel soft", + vec![Effect::BevelEmboss(Bevel { technique: BevelTechnique::ChiselSoft, ..bevel(BevelStyle::OuterBevel, false, 7.5, 2.0) })], + ), + ( + "bevel pillow chisel hard", + vec![Effect::BevelEmboss(Bevel { technique: BevelTechnique::ChiselHard, ..bevel(BevelStyle::PillowEmboss, false, 7.0, 0.0) })], + ), ("bevel emboss smooth wide", vec![Effect::BevelEmboss(bevel(BevelStyle::Emboss, true, 21.0, 0.0))]), ("bevel stroke emboss", vec![Effect::BevelEmboss(bevel(BevelStyle::StrokeEmboss, true, 6.0, 0.0))]), - ("bevel texture", vec![Effect::BevelEmboss(Bevel { - texture: Some(photocraft_doc::BevelTexture { name: "checker".into(), id: String::new(), scale: 1.4, depth: -1.5, invert: true, link: true, phase: (2.0, 1.0) }), - ..bevel(BevelStyle::InnerBevel, true, 7.0, 1.0) - })]), - ("bevel contour", vec![Effect::BevelEmboss(Bevel { contour: Some(photocraft_doc::BevelContour { contour: contour(), range: 0.6, anti_alias: false }), ..bevel(BevelStyle::Emboss, true, 9.0, 1.0) })]), + ( + "bevel texture", + vec![Effect::BevelEmboss(Bevel { + texture: Some(photocraft_doc::BevelTexture { + name: "checker".into(), + id: String::new(), + scale: 1.4, + depth: -1.5, + invert: true, + link: true, + phase: (2.0, 1.0), + }), + ..bevel(BevelStyle::InnerBevel, true, 7.0, 1.0) + })], + ), + ( + "bevel contour", + vec![Effect::BevelEmboss(Bevel { + contour: Some(photocraft_doc::BevelContour { contour: contour(), range: 0.6, anti_alias: false }), + ..bevel(BevelStyle::Emboss, true, 9.0, 1.0) + })], + ), ("bevel contour own light", vec![Effect::BevelEmboss(bevel_contour)]), ("satin", vec![Effect::Satin(satin)]), ("satin inverted contour", vec![Effect::Satin(satin_inv)]), @@ -484,13 +550,50 @@ fn effect_cases() -> Vec<(&'static str, Vec)> { ("stroke inside gradient", vec![Effect::Stroke(stroke(3.0, StrokePosition::Inside, FxPaint::Gradient(gradient())))]), ("stroke centre pattern", vec![Effect::Stroke(stroke(5.0, StrokePosition::Center, pat_paint.clone()))]), ("colour overlay", vec![Effect::ColorOverlay { common: FxCommon::new(BlendMode::Multiply, 0.7), color: Color::rgb(0.2, 0.7, 0.9) }]), - ("gradient overlay", vec![Effect::GradientOverlay { common: FxCommon::new(BlendMode::Normal, 0.8), gradient: Gradient { style: GradientStyle::Radial, ..gradient() }, dither: false }]), - ("pattern overlay", vec![Effect::PatternOverlay { common: FxCommon::new(BlendMode::Normal, 0.9), name: pat.name.clone(), id: pat.id.clone(), scale: 1.0, angle: 0.0, link: true, phase: (2.0, 1.0) }]), - ("pattern overlay scaled rotated", vec![Effect::PatternOverlay { common: FxCommon::new(BlendMode::Screen, 0.8), name: pat.name.clone(), id: pat.id.clone(), scale: 1.7, angle: 30.0, link: false, phase: (0.0, 0.0) }]), + ( + "gradient overlay", + vec![Effect::GradientOverlay { + common: FxCommon::new(BlendMode::Normal, 0.8), + gradient: Gradient { style: GradientStyle::Radial, ..gradient() }, + dither: false, + }], + ), + ( + "pattern overlay", + vec![Effect::PatternOverlay { + common: FxCommon::new(BlendMode::Normal, 0.9), + name: pat.name.clone(), + id: pat.id.clone(), + scale: 1.0, + angle: 0.0, + link: true, + phase: (2.0, 1.0), + }], + ), + ( + "pattern overlay scaled rotated", + vec![Effect::PatternOverlay { + common: FxCommon::new(BlendMode::Screen, 0.8), + name: pat.name.clone(), + id: pat.id.clone(), + scale: 1.7, + angle: 30.0, + link: false, + phase: (0.0, 0.0), + }], + ), ( "missing pattern", vec![ - Effect::PatternOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), name: "nope".into(), id: "nope".into(), scale: 1.0, angle: 0.0, link: true, phase: (0.0, 0.0) }, + Effect::PatternOverlay { + common: FxCommon::new(BlendMode::Normal, 1.0), + name: "nope".into(), + id: "nope".into(), + scale: 1.0, + angle: 0.0, + link: true, + phase: (0.0, 0.0), + }, Effect::Stroke(stroke(3.0, StrokePosition::Outside, FxPaint::Pattern { name: "nope".into(), id: "nope".into(), scale: 1.0 })), Effect::Stroke(stroke(6.0, StrokePosition::Outside, FxPaint::Color(Color::rgb(0.0, 0.0, 0.0)))), ], @@ -513,7 +616,16 @@ fn effect_cases() -> Vec<(&'static str, Vec)> { Effect::OuterGlow(glow(FxPaint::Color(Color::rgb(1.0, 1.0, 0.6)), GlowTechnique::Softer, 6.0, 0.0, GlowSource::Edge)), Effect::InnerGlow(glow(FxPaint::Color(Color::rgb(1.0, 1.0, 0.6)), GlowTechnique::Softer, 4.0, 0.0, GlowSource::Edge)), Effect::BevelEmboss(bevel(BevelStyle::InnerBevel, true, 5.0, 1.0)), - Effect::Satin(Satin { common: FxCommon::new(BlendMode::Multiply, 0.4), color: Color::rgb(0.0, 0.0, 0.0), angle: 19.0, distance: 4.0, size: 5.0, contour: Contour::Linear, anti_alias: false, invert: false }), + Effect::Satin(Satin { + common: FxCommon::new(BlendMode::Multiply, 0.4), + color: Color::rgb(0.0, 0.0, 0.0), + angle: 19.0, + distance: 4.0, + size: 5.0, + contour: Contour::Linear, + anti_alias: false, + invert: false, + }), Effect::ColorOverlay { common: FxCommon::new(BlendMode::SoftLight, 0.5), color: Color::rgb(0.9, 0.3, 0.2) }, Effect::GradientOverlay { common: FxCommon::new(BlendMode::Overlay, 0.4), gradient: gradient(), dither: false }, Effect::Stroke(stroke(3.0, StrokePosition::Outside, FxPaint::Color(Color::rgb(0.0, 0.0, 0.0)))), @@ -572,7 +684,13 @@ fn layer_effects_every_kind() { fn layer_effects_opacity_fill_blend_and_off_canvas() { let Some(mut g) = gpu() else { return }; let stack = effect_cases().into_iter().find(|(n, _)| *n == "full stack").unwrap().1; - for (opacity, fill, blend) in [(0.7, 1.0, BlendMode::Normal), (1.0, 0.0, BlendMode::Normal), (0.6, 0.35, BlendMode::Multiply), (1.0, 0.5, BlendMode::Screen), (0.8, 1.0, BlendMode::Dissolve)] { + for (opacity, fill, blend) in [ + (0.7, 1.0, BlendMode::Normal), + (1.0, 0.0, BlendMode::Normal), + (0.6, 0.35, BlendMode::Multiply), + (1.0, 0.5, BlendMode::Screen), + (0.8, 1.0, BlendMode::Dissolve), + ] { let mut d = fx_doc(96, 80, SampleType::U8); let mut l = blob("fx", d.pixel_format(), 46.0, 40.0, 22.0, [0.2, 0.6, 0.9]); l.effects.items = stack.clone(); @@ -797,7 +915,10 @@ fn type_layers_blend_with_text_gamma() { // With effects (the merge of the layer onto its exterior effects). let mut d = fx_doc(96, 80, SampleType::U8); let mut l = as_text(blob("fx", d.pixel_format(), 46.0, 40.0, 22.0, [0.2, 0.1, 0.6])); - l.effects.items = vec![Effect::ColorOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), color: Color::rgb(0.0, 0.2, 0.6) }, Effect::DropShadow(shadow(BlendMode::Multiply, 0.6, 90.0, 5.0, 6.0, 0.0))]; + l.effects.items = vec![ + Effect::ColorOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), color: Color::rgb(0.0, 0.2, 0.6) }, + Effect::DropShadow(shadow(BlendMode::Multiply, 0.6, 90.0, 5.0, 6.0, 0.0)), + ]; d.layers.push(l); fx_check(&mut g, &d, "text fx"); // The gamma changes edge pixels against a linear mix. @@ -873,11 +994,15 @@ fn stroked_shapes_with_clipped_layers() { fn artboards() { let Some(mut g) = gpu() else { return }; use photocraft_doc::{Artboard, ArtboardBackground}; - let backgrounds = [ArtboardBackground::White, ArtboardBackground::Transparent, ArtboardBackground::Custom({ - let mut c = Color::rgb(0.2, 0.7, 0.4); - c.alpha = 0.5; - c - })]; + let backgrounds = [ + ArtboardBackground::White, + ArtboardBackground::Transparent, + ArtboardBackground::Custom({ + let mut c = Color::rgb(0.2, 0.7, 0.4); + c.alpha = 0.5; + c + }), + ]; for bg in backgrounds { for blend in [BlendMode::PassThrough, BlendMode::Normal, BlendMode::Multiply] { let mut d = base_doc(72, 48); @@ -931,7 +1056,6 @@ fn pattern_fill_layers() { } } - #[test] fn lab_documents_mix_in_lab() { let Some(mut g) = gpu() else { return }; diff --git a/crates/io/examples/oracle_diff.rs b/crates/io/examples/oracle_diff.rs index 479f8db45d..890f3922d9 100644 --- a/crates/io/examples/oracle_diff.rs +++ b/crates/io/examples/oracle_diff.rs @@ -40,7 +40,16 @@ fn main() { println!("mode {:?} depth {:?} layers {} warnings {:?} light {:?}", doc.mode, doc.depth, doc.layer_count(), imp.warnings, doc.global_light); for l in doc.walk() { let l = l.2; - println!(" layer {:?} {:?} blend {:?} op {} fill {} fill_cache {} visible {}", l.name, l.content.kind_name(), l.blend, l.opacity, l.fill_opacity, l.fill_cache.is_some(), l.visible); + println!( + " layer {:?} {:?} blend {:?} op {} fill {} fill_cache {} visible {}", + l.name, + l.content.kind_name(), + l.blend, + l.opacity, + l.fill_opacity, + l.fill_cache.is_some(), + l.visible + ); if let photocraft_doc::LayerContent::Adjustment(a) = &l.content { println!(" {}", format!("{a:?}").chars().take(800).collect::()); } @@ -92,10 +101,16 @@ fn main() { // Recursively list descriptor keys (effects), skipping colour stop lists. fn walk(d: &photocraft_psd::descriptor::Descriptor, depth: usize, out: &mut Vec) { for (k, v) in &d.items { - let key = match k { photocraft_psd::descriptor::Id::Code(c) => String::from_utf8_lossy(c).to_string(), other => String::from_utf8_lossy(other.as_bytes()).to_string() }; + let key = match k { + photocraft_psd::descriptor::Id::Code(c) => String::from_utf8_lossy(c).to_string(), + other => String::from_utf8_lossy(other.as_bytes()).to_string(), + }; let vs = format!("{v:?}"); match v { - photocraft_psd::descriptor::Value::Descriptor(sub) => { out.push(format!("{}{key}:", " ".repeat(depth))); walk(sub, depth + 1, out); } + photocraft_psd::descriptor::Value::Descriptor(sub) => { + out.push(format!("{}{key}:", " ".repeat(depth))); + walk(sub, depth + 1, out); + } _ => out.push(format!("{}{key} = {}", " ".repeat(depth), vs.chars().take(90).collect::())), } } @@ -103,13 +118,30 @@ fn main() { if let Ok((vd, _)) = photocraft_psd::descriptor::VersionedDescriptor::parse_prefix(&v[4..]) { let mut out = Vec::new(); walk(&vd.descriptor, 3, &mut out); - for l in out.iter().filter(|l| !l.trim_start().starts_with("Clrs") && !l.trim_start().starts_with("Trns")) { println!("{l}"); } + for l in out.iter().filter(|l| !l.trim_start().starts_with("Clrs") && !l.trim_start().starts_with("Trns")) { + println!("{l}"); + } } } if k == b"GdFl" { // Skip the 4-byte version before the descriptor. if let Ok((vd, _)) = photocraft_psd::descriptor::VersionedDescriptor::parse_prefix(v) { - println!(" GdFl keys: {:?}", vd.descriptor.items.iter().filter(|(k, _)| !format!("{k:?}").contains("71, 114, 97, 100")).map(|(k, v)| format!("{:?}={}", match k { photocraft_psd::descriptor::Id::Code(c) => String::from_utf8_lossy(c).to_string(), other => format!("{other:?}") }, format!("{v:?}").chars().take(120).collect::())).collect::>()); + println!( + " GdFl keys: {:?}", + vd.descriptor + .items + .iter() + .filter(|(k, _)| !format!("{k:?}").contains("71, 114, 97, 100")) + .map(|(k, v)| format!( + "{:?}={}", + match k { + photocraft_psd::descriptor::Id::Code(c) => String::from_utf8_lossy(c).to_string(), + other => format!("{other:?}"), + }, + format!("{v:?}").chars().take(120).collect::() + )) + .collect::>() + ); } } } @@ -171,7 +203,9 @@ fn main() { } } } - let image = photocraft_codecs::Image::from_raw((w * 3) as u32, h as u32, photocraft_codecs::ChannelLayout::Rgba, photocraft_codecs::SampleType::U8, img).unwrap(); + let image = + photocraft_codecs::Image::from_raw((w * 3) as u32, h as u32, photocraft_codecs::ChannelLayout::Rgba, photocraft_codecs::SampleType::U8, img) + .unwrap(); std::fs::write(&out, photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap(); println!("wrote {out}"); return; @@ -189,7 +223,10 @@ fn main() { wr(format!("{out}_ps.f32"), &merged); let mut meta = format!("{} {}\n", doc.size.width, doc.size.height); for (i, l) in doc.layers.iter().enumerate() { - let b = photocraft_compose::surface_to_buffer(l.surface().unwrap_or(&photocraft_raster::Surface::new(photocraft_color::PixelFormat::RGBA8)), doc.bounds()); + let b = photocraft_compose::surface_to_buffer( + l.surface().unwrap_or(&photocraft_raster::Surface::new(photocraft_color::PixelFormat::RGBA8)), + doc.bounds(), + ); wr(format!("{out}_L{i}.f32"), &b.px); meta += &format!("{i} {}\n", l.name); } @@ -247,7 +284,8 @@ fn main() { if std::env::args().nth(3).as_deref() == Some("row") { let w = doc.size.width as usize; let y: usize = std::env::args().nth(4).and_then(|s| s.parse().ok()).unwrap_or(0); - let (x0, x1): (usize, usize) = (std::env::args().nth(5).and_then(|s| s.parse().ok()).unwrap_or(0), std::env::args().nth(6).and_then(|s| s.parse().ok()).unwrap_or(w)); + let (x0, x1): (usize, usize) = + (std::env::args().nth(5).and_then(|s| s.parse().ok()).unwrap_or(0), std::env::args().nth(6).and_then(|s| s.parse().ok()).unwrap_or(w)); for x in (x0..x1).step_by(((x1 - x0) / 20).max(1)) { let (a, b) = (ours[y * w + x], merged[y * w + x]); println!("x={x:5} ours {:?} ps {:?}", a.map(|v| (v * 1000.0).round() / 1000.0), b.map(|v| (v * 1000.0).round() / 1000.0)); @@ -289,25 +327,25 @@ fn corpus_summary(root: &std::path::Path) { const TOL: f32 = 2.0 / 255.0; type Row = (String, Option<(f32, f32)>); let row = |p: &std::path::PathBuf| -> Row { - let name = p.strip_prefix(root).unwrap_or(p).display().to_string(); - let Ok(bytes) = std::fs::read(p) else { return (name, None) }; - let Ok(file) = photocraft_psd::PsdFile::from_bytes(&bytes) else { return (name, None) }; - let Ok(imp) = photocraft_io::import(&name, &bytes) else { return (name, None) }; - if file.has_real_merged_data() == Some(false) || file.layers().is_empty() { - return (name, None); + let name = p.strip_prefix(root).unwrap_or(p).display().to_string(); + let Ok(bytes) = std::fs::read(p) else { return (name, None) }; + let Ok(file) = photocraft_psd::PsdFile::from_bytes(&bytes) else { return (name, None) }; + let Ok(imp) = photocraft_io::import(&name, &bytes) else { return (name, None) }; + if file.has_real_merged_data() == Some(false) || file.layers().is_empty() { + return (name, None); + } + let Ok(merged) = photocraft_io::merged_composite(&file) else { return (name, None) }; + let ours = photocraft_compose::flatten(&imp.document).px; + let mut m = 0.0f32; + let mut bad = 0usize; + for (a, b) in ours.iter().zip(&merged) { + let d = (0..4).map(|c| (a[c] * a[3] - b[c] * b[3]).abs()).fold((a[3] - b[3]).abs(), f32::max); + m = m.max(d); + if (0..4).any(|c| (a[c] * a[3] - b[c] * b[3]).abs() > TOL) { + bad += 1; } - let Ok(merged) = photocraft_io::merged_composite(&file) else { return (name, None) }; - let ours = photocraft_compose::flatten(&imp.document).px; - let mut m = 0.0f32; - let mut bad = 0usize; - for (a, b) in ours.iter().zip(&merged) { - let d = (0..4).map(|c| (a[c] * a[3] - b[c] * b[3]).abs()).fold((a[3] - b[3]).abs(), f32::max); - m = m.max(d); - if (0..4).any(|c| (a[c] * a[3] - b[c] * b[3]).abs() > TOL) { - bad += 1; - } - } - (name, Some((m, 100.0 * bad as f32 / ours.len().max(1) as f32))) + } + (name, Some((m, 100.0 * bad as f32 / ours.len().max(1) as f32))) }; // Files in parallel (a few threads; flatten itself is tile-parallel). let next = std::sync::atomic::AtomicUsize::new(0); diff --git a/crates/io/src/adjust_map.rs b/crates/io/src/adjust_map.rs index 24adb467e5..31243dc6b5 100644 --- a/crates/io/src/adjust_map.rs +++ b/crates/io/src/adjust_map.rs @@ -9,10 +9,8 @@ use photocraft_doc::adjust::{CurvePoint, LevelsChannel}; use photocraft_psd::descriptor::{Descriptor, Value, VersionedDescriptor}; /// All PSD adjustment keys recognized as adjustment layers. -pub const ADJUSTMENT_KEYS: [&[u8; 4]; 16] = [ - b"levl", b"curv", b"hue2", b"brit", b"nvrt", b"thrs", b"post", b"expA", b"vibA", b"blnc", b"mixr", b"grdm", b"phfl", - b"selc", b"blwh", b"clrL", -]; +pub const ADJUSTMENT_KEYS: [&[u8; 4]; 16] = + [b"levl", b"curv", b"hue2", b"brit", b"nvrt", b"thrs", b"post", b"expA", b"vibA", b"blnc", b"mixr", b"grdm", b"phfl", b"selc", b"blwh", b"clrL"]; fn be16(d: &[u8], at: usize) -> Option { d.get(at..at + 2).map(|b| u16::from_be_bytes([b[0], b[1]])) @@ -137,13 +135,8 @@ fn parse_any(key: &[u8; 4], data: &[u8], cged: Option<&[u8]>) -> Adjustment { legacy: desc_bool(&d, "useLegacy").unwrap_or(false), }) }); - modern.or_else(|| { - Some(Adjustment::BrightnessContrast { - brightness: f32::from(bei16(data, 0)?), - contrast: f32::from(bei16(data, 2)?), - legacy: true, - }) - }) + modern + .or_else(|| Some(Adjustment::BrightnessContrast { brightness: f32::from(bei16(data, 0)?), contrast: f32::from(bei16(data, 2)?), legacy: true })) } b"hue2" => (|| { let colorize = *data.get(2)? != 0; @@ -155,9 +148,7 @@ fn parse_any(key: &[u8; 4], data: &[u8], cged: Option<&[u8]>) -> Adjustment { colorize, }) })(), - b"expA" => (|| { - Some(Adjustment::Exposure { exposure: bef32(data, 2)?, offset: bef32(data, 6)?, gamma: bef32(data, 10)? }) - })(), + b"expA" => (|| Some(Adjustment::Exposure { exposure: bef32(data, 2)?, offset: bef32(data, 6)?, gamma: bef32(data, 10)? }))(), b"levl" => (|| { let m = levels_rec(data, 2)?; let r = levels_rec(data, 12)?; @@ -386,14 +377,8 @@ pub fn write(adj: &Adjustment) -> Vec<([u8; 4], Vec)> { } } // Six default hue ranges (reds, yellows, greens, cyans, blues, magentas). - let ranges: [[u16; 4]; 6] = [ - [315, 345, 15, 45], - [15, 45, 75, 105], - [75, 105, 135, 165], - [135, 165, 195, 225], - [195, 225, 255, 285], - [255, 285, 315, 345], - ]; + let ranges: [[u16; 4]; 6] = + [[315, 345, 15, 45], [15, 45, 75, 105], [75, 105, 135, 165], [135, 165, 195, 225], [195, 225, 255, 285], [255, 285, 315, 345]]; for r in ranges { for x in r { put16(&mut v, x); @@ -447,7 +432,8 @@ pub fn write(adj: &Adjustment) -> Vec<([u8; 4], Vec)> { } Adjustment::ColorLookup { name, lut: Some(table), size, dither, .. } => { let file = photocraft_cms::lutfile::LutFile { title: String::new(), size: *size as usize, data: table.to_vec() }; - let en = |t: &str, val: &str| Value::Enumerated { type_id: photocraft_psd::descriptor::Id::new(t), value: photocraft_psd::descriptor::Id::new(val) }; + let en = + |t: &str, val: &str| Value::Enumerated { type_id: photocraft_psd::descriptor::Id::new(t), value: photocraft_psd::descriptor::Id::new(val) }; let text = |t: &str| Value::Text(photocraft_psd::descriptor::UnicodeString::new_nul(t)); let d = Descriptor::new("null") .with("lookupType", en("colorLookupType", "3DLUT")) @@ -533,17 +519,9 @@ mod tests { rt(Adjustment::HueSaturation { hue: 30.0, saturation: -20.0, lightness: 5.0, colorize: false }); rt(Adjustment::HueSaturation { hue: 200.0, saturation: 50.0, lightness: 0.0, colorize: true }); rt(Adjustment::Exposure { exposure: 1.5, offset: -0.01, gamma: 0.9 }); - let lc = |a: u16, b: u16| LevelsChannel { - in_black: f32::from(a) / 255.0, - in_white: f32::from(b) / 255.0, - gamma: 1.2, - out_black: 0.0, - out_white: 1.0, - }; + let lc = |a: u16, b: u16| LevelsChannel { in_black: f32::from(a) / 255.0, in_white: f32::from(b) / 255.0, gamma: 1.2, out_black: 0.0, out_white: 1.0 }; rt(Adjustment::Levels { master: lc(10, 240), per_channel: [lc(0, 255), lc(5, 250), lc(20, 200)] }); - let pts = |v: &[(u8, u8)]| { - v.iter().map(|&(i, o)| CurvePoint { input: f32::from(i) / 255.0, output: f32::from(o) / 255.0 }).collect::>() - }; + let pts = |v: &[(u8, u8)]| v.iter().map(|&(i, o)| CurvePoint { input: f32::from(i) / 255.0, output: f32::from(o) / 255.0 }).collect::>(); rt(Adjustment::Curves { master: pts(&[(0, 0), (128, 150), (255, 255)]), per_channel: [pts(&[(0, 10), (255, 255)]), pts(&[(0, 0), (255, 245)]), pts(&[(0, 0), (64, 32), (255, 255)])], diff --git a/crates/io/src/blocks.rs b/crates/io/src/blocks.rs index 6b5ca1440b..3da1bb88f6 100644 --- a/crates/io/src/blocks.rs +++ b/crates/io/src/blocks.rs @@ -23,22 +23,10 @@ pub fn brst_data(mask: u32) -> Option> { /// as black low, black high, white low, white high) → Blend If. Full ranges map to the /// default, so files without Blend If keep an empty setting. pub fn blend_if_from_ranges(r: &BlendingRanges) -> BlendIf { - let mut ranges: Vec<[BlendRange; 2]> = r - .ranges() - .iter() - .map(|e| { - [ - BlendRange::from_bytes(e.source), - BlendRange::from_bytes(e.dest), - ] - }) - .collect(); + let mut ranges: Vec<[BlendRange; 2]> = r.ranges().iter().map(|e| [BlendRange::from_bytes(e.source), BlendRange::from_bytes(e.dest)]).collect(); // Trailing full entries carry nothing (export pads them back), so they are dropped, as // `BlendIf::set` does. - let keep = ranges - .iter() - .rposition(|p| !p.iter().all(BlendRange::is_full)) - .map_or(0, |i| i + 1); + let keep = ranges.iter().rposition(|p| !p.iter().all(BlendRange::is_full)).map_or(0, |i| i + 1); ranges.truncate(keep); BlendIf { ranges } } @@ -54,31 +42,19 @@ pub fn ranges_from_blend_if(b: &BlendIf, channels: usize) -> BlendingRanges { let [src, dst] = b.get(i); src.to_bytes().into_iter().chain(dst.to_bytes()) }); - BlendingRanges { - data: data.collect(), - } + BlendingRanges { data: data.collect() } } /// `lspf` bits (Adobe spec: bit 0 transparency, 1 composite, 2 position). /// Artboard (bit 3... here `0x10` as observed by ag-psd) and "all" (bit 31) /// are undocumented. pub fn locks_from_lspf(v: u32) -> Locks { - Locks { - transparency: v & 1 != 0, - pixels: v & 2 != 0, - position: v & 4 != 0, - artboard: v & 0x10 != 0, - all: v & 0x8000_0000 != 0, - } + Locks { transparency: v & 1 != 0, pixels: v & 2 != 0, position: v & 4 != 0, artboard: v & 0x10 != 0, all: v & 0x8000_0000 != 0 } } /// Inverse of [`locks_from_lspf`]. pub fn lspf_from_locks(l: &Locks) -> u32 { - u32::from(l.transparency) - | u32::from(l.pixels) << 1 - | u32::from(l.position) << 2 - | u32::from(l.artboard) << 4 - | u32::from(l.all) << 31 + u32::from(l.transparency) | u32::from(l.pixels) << 1 | u32::from(l.position) << 2 | u32::from(l.artboard) << 4 | u32::from(l.all) << 31 } /// `lclr` index → label. @@ -126,11 +102,7 @@ pub fn color_from_desc(d: &Descriptor) -> Option { // Newer Photoshop writes 0..1 floats (`RGBC` with redFloat / greenFloat / blueFloat). b"RGBC" if d.get("redFloat").is_some() => Color::rgb(g("redFloat")?, g("greenFloat")?, g("blueFloat")?), b"RGBC" => Color::rgb(g("Rd ")? / 255.0, g("Grn ")? / 255.0, g("Bl ")? / 255.0), - b"CMYC" => Color { - mode: ColorMode::Cmyk, - c: [g("Cyn ")? / 100.0, g("Mgnt")? / 100.0, g("Ylw ")? / 100.0, g("Blck")? / 100.0], - alpha: 1.0, - }, + b"CMYC" => Color { mode: ColorMode::Cmyk, c: [g("Cyn ")? / 100.0, g("Mgnt")? / 100.0, g("Ylw ")? / 100.0, g("Blck")? / 100.0], alpha: 1.0 }, b"Grsc" => Color::gray(1.0 - g("Gry ")? / 100.0), // Newer Photoshop writes 0..1 floats. _ if d.get("redFloat").is_some() => Color::rgb(g("redFloat")?, g("greenFloat")?, g("blueFloat")?), @@ -363,8 +335,10 @@ pub(crate) fn with_pattern_placement(mut d: Descriptor, angle: f32, link: bool, if angle != 0.0 { d = d.with("Angl", Value::UnitFloat { unit: *b"#Ang", value: f64::from(angle) }); } - d.with("Algn", Value::Boolean(link)) - .with("phase", Value::Descriptor(Descriptor::new("Pnt ").with("Hrzn", Value::Double(f64::from(phase.0))).with("Vrtc", Value::Double(f64::from(phase.1))))) + d.with("Algn", Value::Boolean(link)).with( + "phase", + Value::Descriptor(Descriptor::new("Pnt ").with("Hrzn", Value::Double(f64::from(phase.0))).with("Vrtc", Value::Double(f64::from(phase.1)))), + ) } /// The warp of a placed layer (`SoLd`/`SoLE` `warp` descriptor): style, bend, distortions, @@ -427,11 +401,7 @@ pub(crate) fn parse_prefix_versioned(data: &[u8]) -> Option { /// Smart object identifier and transform from `SoLd`/`PlLd` (best effort). pub fn parse_smart(key: &[u8; 4], data: &[u8]) -> (String, Affine) { // SoLd: 'soLD' + version(4) + versioned descriptor. - let desc = if key == b"SoLd" || key == b"SoLE" { - data.get(8..).and_then(parse_prefix_versioned) - } else { - None - }; + let desc = if key == b"SoLd" || key == b"SoLE" { data.get(8..).and_then(parse_prefix_versioned) } else { None }; let Some(d) = desc else { return (String::new(), Affine::IDENTITY) }; let id = match d.get("Idnt") { Some(Value::Text(t)) => t.to_string_lossy(), @@ -466,58 +436,22 @@ mod tests { use photocraft_doc::{BlendIf, BlendRange}; use photocraft_psd::layer::BlendingRanges; // Full ranges (what every layer without Blend If carries) → the default setting. - assert_eq!( - super::blend_if_from_ranges(&BlendingRanges::full(3)), - BlendIf::default() - ); - assert_eq!( - super::blend_if_from_ranges(&BlendingRanges::default()), - BlendIf::default() - ); + assert_eq!(super::blend_if_from_ranges(&BlendingRanges::full(3)), BlendIf::default()); + assert_eq!(super::blend_if_from_ranges(&BlendingRanges::default()), BlendIf::default()); // Gray: This Layer black split 10/40; Blue (entry 3): Underlying white at 200. let mut data = BlendingRanges::full(3).data; data[..4].copy_from_slice(&[10, 40, 255, 255]); data[3 * 8 + 4..3 * 8 + 8].copy_from_slice(&[0, 0, 200, 200]); let b = super::blend_if_from_ranges(&BlendingRanges { data: data.clone() }); - assert_eq!( - b.get(0), - [ - BlendRange { - black: [10, 40], - white: [255, 255] - }, - BlendRange::FULL - ] - ); - assert_eq!( - b.get(3), - [ - BlendRange::FULL, - BlendRange { - black: [0, 0], - white: [200, 200] - } - ] - ); + assert_eq!(b.get(0), [BlendRange { black: [10, 40], white: [255, 255] }, BlendRange::FULL]); + assert_eq!(b.get(3), [BlendRange::FULL, BlendRange { black: [0, 0], white: [200, 200] }]); assert_eq!(b.ranges.len(), 4); assert_eq!(super::ranges_from_blend_if(&b, 3).data, data); // The default writes full ranges for gray + each channel. - assert_eq!( - super::ranges_from_blend_if(&BlendIf::default(), 4), - BlendingRanges::full(4) - ); + assert_eq!(super::ranges_from_blend_if(&BlendIf::default(), 4), BlendingRanges::full(4)); // A trimmed setting is padded to the document's channel count. let mut short = BlendIf::default(); - short.set( - 0, - [ - BlendRange { - black: [5, 5], - white: [255, 255], - }, - BlendRange::FULL, - ], - ); + short.set(0, [BlendRange { black: [5, 5], white: [255, 255] }, BlendRange::FULL]); let r = super::ranges_from_blend_if(&short, 3); assert_eq!(r.data.len(), 4 * 8); assert_eq!(&r.data[..8], &[5, 5, 255, 255, 0, 0, 255, 255]); @@ -569,10 +503,10 @@ mod tests { let mesh = Descriptor::new("null") .with("Hrzn", Value::UnitFloats { unit: *b"#Pxl", values: xs }) .with("Vrtc", Value::UnitFloats { unit: *b"#Pxl", values: ys }); - let custom = Descriptor::new("warp") - .with("warpStyle", e("warpStyle", "warpCustom")) - .with("bounds", Value::Descriptor(bounds)) - .with("customEnvelopeWarp", Value::Descriptor(Descriptor::new("customEnvelopeWarp").with("meshPoints", Value::ObjectArray(ObjectArray { prefix: 16, body: mesh })))); + let custom = Descriptor::new("warp").with("warpStyle", e("warpStyle", "warpCustom")).with("bounds", Value::Descriptor(bounds)).with( + "customEnvelopeWarp", + Value::Descriptor(Descriptor::new("customEnvelopeWarp").with("meshPoints", Value::ObjectArray(ObjectArray { prefix: 16, body: mesh }))), + ); let w = parse_placed_warp(b"SoLd", &sold(custom)).unwrap(); assert_eq!(w.style, WarpStyle::Custom); assert_eq!(w.mesh.as_ref().unwrap().points[15], [67.0, 30.0]); @@ -595,7 +529,13 @@ mod tests { Fill::Solid(Color::rgb(1.0, 0.5, 0.0)), Fill::Solid(Color { mode: ColorMode::Cmyk, c: [0.1, 0.2, 0.3, 0.4], alpha: 1.0 }), Fill::Solid(Color::gray(0.25)), - Fill::Gradient { stops: vec![(0.0, Color::rgb(1.0, 0.0, 0.0)), (1.0, Color::rgb(0.0, 0.0, 1.0))], angle: 45.0, scale: 1.0, style: GradientStyle::Reflected, reverse: true }, + Fill::Gradient { + stops: vec![(0.0, Color::rgb(1.0, 0.0, 0.0)), (1.0, Color::rgb(0.0, 0.0, 1.0))], + angle: 45.0, + scale: 1.0, + style: GradientStyle::Reflected, + reverse: true, + }, Fill::Pattern { name: "Bubbles".into(), scale: 0.5, id: "abc".into(), angle: 30.0, link: false, phase: (3.0, -2.0) }, ] { let (k, d) = write_fill(&f); diff --git a/crates/io/src/comps_map.rs b/crates/io/src/comps_map.rs index e28181c316..38cfbf9cd1 100644 --- a/crates/io/src/comps_map.rs +++ b/crates/io/src/comps_map.rs @@ -13,7 +13,7 @@ //! regenerated otherwise (comp appearance is not written to PSD; `.pcraft` keeps it). use photocraft_doc::comps::layer_position; -use photocraft_doc::{Guides, Artboard, ArtboardBackground, CompLayerState, Document, Layer, LayerComp}; +use photocraft_doc::{Artboard, ArtboardBackground, CompLayerState, Document, Guides, Layer, LayerComp}; use photocraft_geom::Rect; use photocraft_psd::descriptor::{Descriptor, UnicodeString, Value, VersionedDescriptor}; use photocraft_psd::metadata::{MetadataItem, parse_shmd, write_shmd}; @@ -188,7 +188,15 @@ pub fn comps_from_psd(resource: Option<&[u8]>, doc: &Document) -> (Vec, l: &Layer, psd_ let mut settings: Vec = comps.iter().map(|c| setting_value(&[c.id], c.state(l.id), here)).collect(); settings.push(setting_value(&[0], last.and_then(|c| c.state(l.id)), here)); let origin = obj("null").with("Hrzn", Value::Double(0.0)).with("Vrtc", Value::Double(0.0)); - let d = Descriptor::new("null").with("origFXRefPoint", Value::Descriptor(origin)).with("LyrI", Value::Integer(psd_id as i32)).with("layerSettings", Value::List(settings)); + let d = Descriptor::new("null") + .with("origFXRefPoint", Value::Descriptor(origin)) + .with("LyrI", Value::Integer(psd_id as i32)) + .with("layerSettings", Value::List(settings)); Some(VersionedDescriptor::new(d).to_bytes()) } diff --git a/crates/io/src/effects_map.rs b/crates/io/src/effects_map.rs index 913a645622..d4e0bda7e6 100644 --- a/crates/io/src/effects_map.rs +++ b/crates/io/src/effects_map.rs @@ -6,8 +6,8 @@ use photocraft_color::{BlendMode, Color}; use photocraft_doc::adjust::CurvePoint; use photocraft_doc::{ - Bevel, BevelContour, BevelStyle, BevelTechnique, BevelTexture, Contour, Effect, FxCommon, FxPaint, Glow, GlowSource, GlowTechnique, Gradient, Satin, Shadow, - StrokeFx, StrokePosition, + Bevel, BevelContour, BevelStyle, BevelTechnique, BevelTexture, Contour, Effect, FxCommon, FxPaint, Glow, GlowSource, GlowTechnique, Gradient, Satin, + Shadow, StrokeFx, StrokePosition, }; use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value, VersionedDescriptor}; @@ -80,11 +80,7 @@ fn contour(d: &Descriptor, key: &str) -> Contour { } } let linear = points.len() == 2 && points[0].input == 0.0 && points[0].output == 0.0 && points[1].input == 1.0 && points[1].output == 1.0; - if points.is_empty() || (linear && (name.is_empty() || name == "Linear")) { - Contour::Linear - } else { - Contour::Custom { name, points } - } + if points.is_empty() || (linear && (name.is_empty() || name == "Linear")) { Contour::Linear } else { Contour::Custom { name, points } } } fn contour_value(c: &Contour) -> Value { let (name, points) = match c { @@ -103,11 +99,7 @@ fn contour_value(c: &Contour) -> Value { } fn common(d: &Descriptor, blend: BlendMode, opacity: f32) -> FxCommon { - FxCommon { - enabled: !matches!(d.get("enab"), Some(Value::Boolean(false))), - blend: blend_of(d, "Md ", blend), - opacity: pct(d, "Opct", opacity), - } + FxCommon { enabled: !matches!(d.get("enab"), Some(Value::Boolean(false))), blend: blend_of(d, "Md ", blend), opacity: pct(d, "Opct", opacity) } } fn gradient_from(d: &Descriptor) -> Gradient { @@ -312,10 +304,7 @@ fn gradient_keys(mut d: Descriptor, g: &Gradient) -> Descriptor { .with("Rvrs", Value::Boolean(g.reverse)) .with("Algn", Value::Boolean(g.align)) .with("Scl ", unit(b"#Prc", g.scale * 100.0)); - d.with( - "Ofst", - Value::Descriptor(Descriptor::new("Pnt ").with("Hrzn", unit(b"#Prc", g.offset.0 * 100.0)).with("Vrtc", unit(b"#Prc", g.offset.1 * 100.0))), - ) + d.with("Ofst", Value::Descriptor(Descriptor::new("Pnt ").with("Hrzn", unit(b"#Prc", g.offset.0 * 100.0)).with("Vrtc", unit(b"#Prc", g.offset.1 * 100.0)))) } fn write_one(e: &Effect) -> (&'static str, Descriptor) { @@ -358,10 +347,8 @@ fn write_one(e: &Effect) -> (&'static str, Descriptor) { .with("TrnS", contour_value(&g.contour)) .with("Inpr", unit(b"#Prc", g.range * 100.0)); if inner { - d = d.with( - "glwS", - Value::Enumerated { type_id: Id::new("IGSr"), value: Id::new(if g.source == GlowSource::Center { "SrcC" } else { "SrcE" }) }, - ); + d = d + .with("glwS", Value::Enumerated { type_id: Id::new("IGSr"), value: Id::new(if g.source == GlowSource::Center { "SrcC" } else { "SrcE" }) }); } (if inner { "IrGl" } else { "OrGl" }, d) } @@ -375,9 +362,9 @@ fn write_one(e: &Effect) -> (&'static str, Descriptor) { .with("Styl", Value::Enumerated { type_id: Id::new("FStl"), value: Id::new(styl) }) .with("Sz ", unit(b"#Pxl", s.size)); d = match &s.paint { - FxPaint::Color(c) => d - .with("PntT", Value::Enumerated { type_id: Id::new("FrFl"), value: Id::new("SClr") }) - .with("Clr ", Value::Descriptor(color_to_desc(c))), + FxPaint::Color(c) => { + d.with("PntT", Value::Enumerated { type_id: Id::new("FrFl"), value: Id::new("SClr") }).with("Clr ", Value::Descriptor(color_to_desc(c))) + } FxPaint::Gradient(g) => gradient_keys(d.with("PntT", Value::Enumerated { type_id: Id::new("FrFl"), value: Id::new("GrFl") }), g), FxPaint::Pattern { name, id, scale } => d .with("PntT", Value::Enumerated { type_id: Id::new("FrFl"), value: Id::new("Ptrn") }) @@ -393,16 +380,16 @@ fn write_one(e: &Effect) -> (&'static str, Descriptor) { }; ("FrFX", d) } - Effect::ColorOverlay { common: c, color } => { - ("SoFi", with_common(Descriptor::new("SoFi"), c).with("Clr ", Value::Descriptor(color_to_desc(color)))) - } + Effect::ColorOverlay { common: c, color } => ("SoFi", with_common(Descriptor::new("SoFi"), c).with("Clr ", Value::Descriptor(color_to_desc(color)))), Effect::GradientOverlay { common: c, gradient, dither } => { ("GrFl", gradient_keys(with_common(Descriptor::new("GrFl"), c), gradient).with("Dthr", Value::Boolean(*dither))) } Effect::PatternOverlay { common: c, name, id, scale, angle, link, phase } => ( "patternFill", with_pattern_placement( - with_common(Descriptor::new("patternFill"), c).with("Ptrn", Value::Descriptor(pattern_ref_desc(name, id))).with("Scl ", unit(b"#Prc", scale * 100.0)), + with_common(Descriptor::new("patternFill"), c) + .with("Ptrn", Value::Descriptor(pattern_ref_desc(name, id))) + .with("Scl ", unit(b"#Prc", scale * 100.0)), *angle, *link, *phase, @@ -453,7 +440,11 @@ fn write_one(e: &Effect) -> (&'static str, Descriptor) { .with("TrnS", contour_value(&b.gloss_contour)) .with("Sftn", unit(b"#Pxl", b.soften)); let d = match &b.contour { - Some(c) => d.with("useShape", Value::Boolean(true)).with("MpgS", contour_value(&c.contour)).with("AntA", Value::Boolean(c.anti_alias)).with("Inpr", unit(b"#Prc", c.range * 100.0)), + Some(c) => d + .with("useShape", Value::Boolean(true)) + .with("MpgS", contour_value(&c.contour)) + .with("AntA", Value::Boolean(c.anti_alias)) + .with("Inpr", unit(b"#Prc", c.range * 100.0)), None => d.with("useShape", Value::Boolean(false)), }; let d = match &b.texture { @@ -507,9 +498,7 @@ pub fn parse_lrfx(data: &[u8]) -> Option<(bool, Vec)> { Some(Color::rgb(c(0)?, c(1)?, c(2)?)) }; let blend_at = |at: usize| -> BlendMode { - data.get(at + 4..at + 8) - .and_then(|k| photocraft_color::BlendMode::from_psd_key([k[0], k[1], k[2], k[3]])) - .unwrap_or(BlendMode::Normal) + data.get(at + 4..at + 8).and_then(|k| photocraft_color::BlendMode::from_psd_key([k[0], k[1], k[2], k[3]])).unwrap_or(BlendMode::Normal) }; let count = be16(2)?; let mut at = 4; @@ -595,8 +584,25 @@ mod tests { }; vec![ Effect::default_drop_shadow(), - Effect::DropShadow(Shadow { distance: 9.0, spread: 0.25, contour: Contour::Custom { name: "Cone".into(), points: vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 0.5, output: 1.0 }, CurvePoint { input: 1.0, output: 0.0 }] }, ..match Effect::default_drop_shadow() { Effect::DropShadow(s) => s, _ => unreachable!() } }), - Effect::InnerShadow(Shadow { knocks_out: false, ..match Effect::default_drop_shadow() { Effect::DropShadow(s) => s, _ => unreachable!() } }), + Effect::DropShadow(Shadow { + distance: 9.0, + spread: 0.25, + contour: Contour::Custom { + name: "Cone".into(), + points: vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 0.5, output: 1.0 }, CurvePoint { input: 1.0, output: 0.0 }], + }, + ..match Effect::default_drop_shadow() { + Effect::DropShadow(s) => s, + _ => unreachable!(), + } + }), + Effect::InnerShadow(Shadow { + knocks_out: false, + ..match Effect::default_drop_shadow() { + Effect::DropShadow(s) => s, + _ => unreachable!(), + } + }), Effect::OuterGlow(Glow { common: FxCommon::new(BlendMode::Screen, 0.5), paint: FxPaint::Color(Color::rgb(1.0, 1.0, 0.0)), @@ -623,12 +629,39 @@ mod tests { noise: 0.0, source: GlowSource::Center, }), - Effect::Stroke(StrokeFx { common: FxCommon::new(BlendMode::Normal, 1.0), size: 3.0, position: StrokePosition::Inside, paint: FxPaint::Color(Color::rgb(0.0, 1.0, 0.0)) }), - Effect::Stroke(StrokeFx { common: FxCommon::new(BlendMode::Multiply, 0.5), size: 6.0, position: StrokePosition::Center, paint: FxPaint::Gradient(g.clone()) }), + Effect::Stroke(StrokeFx { + common: FxCommon::new(BlendMode::Normal, 1.0), + size: 3.0, + position: StrokePosition::Inside, + paint: FxPaint::Color(Color::rgb(0.0, 1.0, 0.0)), + }), + Effect::Stroke(StrokeFx { + common: FxCommon::new(BlendMode::Multiply, 0.5), + size: 6.0, + position: StrokePosition::Center, + paint: FxPaint::Gradient(g.clone()), + }), Effect::ColorOverlay { common: FxCommon::new(BlendMode::Overlay, 0.5), color: Color::rgb(0.0, 0.0, 1.0) }, Effect::GradientOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), gradient: g, dither: true }, - Effect::PatternOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), name: "Bubbles".into(), id: "abc".into(), scale: 1.0, angle: 15.0, link: false, phase: (4.0, 2.0) }, - Effect::Satin(Satin { common: FxCommon::new(BlendMode::Multiply, 0.5), color: Color::BLACK, angle: 19.0, distance: 11.0, size: 14.0, contour: Contour::Linear, anti_alias: true, invert: true }), + Effect::PatternOverlay { + common: FxCommon::new(BlendMode::Normal, 1.0), + name: "Bubbles".into(), + id: "abc".into(), + scale: 1.0, + angle: 15.0, + link: false, + phase: (4.0, 2.0), + }, + Effect::Satin(Satin { + common: FxCommon::new(BlendMode::Multiply, 0.5), + color: Color::BLACK, + angle: 19.0, + distance: 11.0, + size: 14.0, + contour: Contour::Linear, + anti_alias: true, + invert: true, + }), Effect::BevelEmboss(Bevel { enabled: true, style: BevelStyle::Emboss, diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index c66a2ae813..58d51b5baa 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -19,14 +19,14 @@ mod adjust_map; pub mod annotations_map; +pub mod blocks; mod channel_map; pub mod comps_map; pub mod effects_map; -pub mod blocks; -mod gradient_bake; mod flat; -mod multichannel_map; +mod gradient_bake; pub mod linked; +mod multichannel_map; pub mod pattern_map; mod pixels; mod psd_export; diff --git a/crates/io/src/pattern_map.rs b/crates/io/src/pattern_map.rs index 1473b281c5..dbc47ce0d5 100644 --- a/crates/io/src/pattern_map.rs +++ b/crates/io/src/pattern_map.rs @@ -35,7 +35,9 @@ pub fn from_psd(p: &PsdPattern) -> Option { // 1-bit patterns: expand to 8-bit grey. let (channels, depth): (Vec>, u16) = if p.depth == 1 { let row = w.div_ceil(8); - let expand = |plane: &Vec| -> Vec { (0..w * h).map(|i| if plane.get((i / w) * row + (i % w) / 8).is_some_and(|b| b & (0x80 >> (i % w % 8)) != 0) { 0 } else { 255 }).collect() }; + let expand = |plane: &Vec| -> Vec { + (0..w * h).map(|i| if plane.get((i / w) * row + (i % w) / 8).is_some_and(|b| b & (0x80 >> (i % w % 8)) != 0) { 0 } else { 255 }).collect() + }; (p.channels.iter().map(expand).collect(), 8) } else { (p.channels.clone(), p.depth) @@ -89,7 +91,17 @@ pub fn to_psd(p: &Pattern) -> PsdPattern { } let mut planes = deinterleave(&bytes, ch, fmt.sample, &invert); let alpha = fmt.alpha.then(|| planes.pop()).flatten(); - PsdPattern { mode: psd_mode(mode), width: p.width, height: p.height, name: p.name.clone(), id: p.id.clone(), palette: None, depth: psd_depth(fmt.sample), channels: planes, alpha } + PsdPattern { + mode: psd_mode(mode), + width: p.width, + height: p.height, + name: p.name.clone(), + id: p.id.clone(), + palette: None, + depth: psd_depth(fmt.sample), + channels: planes, + alpha, + } } /// Patterns decoded from the document's raw pattern blocks (malformed blocks are skipped). diff --git a/crates/io/src/pixels.rs b/crates/io/src/pixels.rs index 408802f789..ab5a28d790 100644 --- a/crates/io/src/pixels.rs +++ b/crates/io/src/pixels.rs @@ -204,4 +204,3 @@ mod tests { assert_eq!(decode_be(&f, 0, SampleType::F32), 2.5); } } - diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 17c11c8c68..cac48fbfbc 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -6,8 +6,7 @@ use photocraft_psd::file::{GlobalLayerMask, LayerInfoPlacement}; use photocraft_psd::layer::{BlendingRanges, ChannelData, LayerFlags, LayerInfo, LayerMask as PsdMask, MaskData, MaskParameters}; use photocraft_psd::resources::{ImageResource, ResolutionInfo, ids, version_info_resource}; use photocraft_psd::{ - BlendMode as PsdBlend, ColorMode as PsdMode, Compression, Header, ImageData, LayerRecord, PsdFile, Rect as PsdRect, - SectionType, TaggedBlock, Version, + BlendMode as PsdBlend, ColorMode as PsdMode, Compression, Header, ImageData, LayerRecord, PsdFile, Rect as PsdRect, SectionType, TaggedBlock, Version, }; use photocraft_raster::Surface; @@ -16,7 +15,6 @@ use crate::blocks; use crate::pixels::{deinterleave, encode_be, psd_depth}; use crate::psd_import::REGENERATED; - /// Options for [`document_to_psd_with`]. #[derive(Debug, Clone, Default)] pub struct PsdExportOptions { @@ -96,7 +94,8 @@ impl Ex { let planes = deinterleave(&bytes, self.cc + 1, self.fmt.sample, &invert); let (w, h) = (r.width() as usize, r.height() as usize); // Alpha (-1) first, then the colour channels; each compressed on its own thread. - let order: Vec<(i16, &Vec)> = std::iter::once((-1, &planes[self.cc])).chain(planes.iter().take(self.cc).enumerate().map(|(c, p)| (c as i16, p))).collect(); + let order: Vec<(i16, &Vec)> = + std::iter::once((-1, &planes[self.cc])).chain(planes.iter().take(self.cc).enumerate().map(|(c, p)| (c as i16, p))).collect(); let ch = crate::pixels::par_map(order, |(id, plane)| self.encode(id, plane, w, h)); (to_psd_rect(r), ch) } @@ -106,11 +105,7 @@ impl Ex { let default = s.default_pixel().first().copied().unwrap_or(0.0); let r = s.content_bounds(); let (w, h) = (r.width() as usize, r.height() as usize); - let plane = if r.is_empty() { - Vec::new() - } else { - deinterleave(&s.to_interleaved(r), 1, self.mask_fmt.sample, &[false]).remove(0) - }; + let plane = if r.is_empty() { Vec::new() } else { deinterleave(&s.to_interleaved(r), 1, self.mask_fmt.sample, &[false]).remove(0) }; let mut flags = 0u8; if !m.linked { flags |= PsdMask::FLAG_RELATIVE; @@ -211,8 +206,7 @@ impl Ex { /// adjustment or fill block is reused verbatim while it still decodes to /// the layer's current parameters; otherwise it is regenerated. fn content_blocks(&mut self, l: &Layer) -> Vec { - let mut raw: Vec<([u8; 4], Vec)> = - l.psd_blocks.iter().filter(|(k, _)| !REGENERATED.contains(&k)).map(|(k, d)| (*k, d.to_vec())).collect(); + let mut raw: Vec<([u8; 4], Vec)> = l.psd_blocks.iter().filter(|(k, _)| !REGENERATED.contains(&k)).map(|(k, d)| (*k, d.to_vec())).collect(); // A stale multi-effects block would override the regenerated lfx2. if effects_unchanged(l) == Some(false) { raw.retain(|(k, _)| k != b"lmfx"); @@ -240,9 +234,7 @@ impl Ex { _ => adjust_map::Channels::Other, }; let cged = raw.iter().find(|(k, _)| k == b"CgEd").map(|(_, d)| d.clone()); - let keep = raw - .iter() - .any(|(k, d)| adjust_map::ADJUSTMENT_KEYS.contains(&k) && adjust_map::parse(k, d, cged.as_deref(), channels) == *a); + let keep = raw.iter().any(|(k, d)| adjust_map::ADJUSTMENT_KEYS.contains(&k) && adjust_map::parse(k, d, cged.as_deref(), channels) == *a); if !keep { raw.retain(|(k, _)| !adjust_map::ADJUSTMENT_KEYS.contains(&k) && k != b"CgEd"); let w = adjust_map::write(a); @@ -730,12 +722,8 @@ pub fn document_to_psd_with(doc: &Document, opts: &PsdExportOptions) -> (PsdFile let has_layers = !ex.records.is_empty(); let placement = match (has_layers, sample) { - (true, SampleType::U16) => { - LayerInfoPlacement::GlobalBlock { index: 0, signature: *b"8BIM", key: *b"Lr16", padding: None } - } - (true, SampleType::F32) => { - LayerInfoPlacement::GlobalBlock { index: 0, signature: *b"8BIM", key: *b"Lr32", padding: None } - } + (true, SampleType::U16) => LayerInfoPlacement::GlobalBlock { index: 0, signature: *b"8BIM", key: *b"Lr16", padding: None }, + (true, SampleType::F32) => LayerInfoPlacement::GlobalBlock { index: 0, signature: *b"8BIM", key: *b"Lr32", padding: None }, _ => LayerInfoPlacement::Section, }; let records = std::mem::take(&mut ex.records); diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index fab9c0905d..c05d914960 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -3,9 +3,7 @@ use std::sync::Arc; use photocraft_color::{BlendMode, ColorMode, PixelFormat, SampleType}; -use photocraft_doc::{ - AlphaChannel, Document, Effects, FillCache, Group, Layer, LayerContent, LayerMask, ShapeLayer, SmartObject, SmartSource, TextLayer, -}; +use photocraft_doc::{AlphaChannel, Document, Effects, FillCache, Group, Layer, LayerContent, LayerMask, ShapeLayer, SmartObject, SmartSource, TextLayer}; use photocraft_geom::{Rect, Size}; use photocraft_psd::layer::{CHANNEL_REAL_USER_MASK, CHANNEL_TRANSPARENCY, CHANNEL_USER_MASK}; use photocraft_psd::resources::ids; @@ -146,11 +144,7 @@ impl Ctx<'_> { l.blend = match BlendMode::from_psd_key(key) { Some(b) => b, None => { - self.warn(format!( - "layer \"{}\": unknown blend mode {:?}; using Normal", - l.name, - String::from_utf8_lossy(&key) - )); + self.warn(format!("layer \"{}\": unknown blend mode {:?}; using Normal", l.name, String::from_utf8_lossy(&key))); BlendMode::Normal } }; @@ -212,11 +206,16 @@ impl Ctx<'_> { let content = if let Some(k) = adj_key { let data = rec.block(k).map(|b| b.data.clone()).unwrap_or_default(); let cged = rec.block(b"CgEd").map(|b| &b.data[..]); - LayerContent::Adjustment(adjust_map::parse(k, &data, cged, match self.fmt.mode { - ColorMode::Rgb => adjust_map::Channels::Rgb, - ColorMode::Grayscale => adjust_map::Channels::Gray, - _ => adjust_map::Channels::Other, - })) + LayerContent::Adjustment(adjust_map::parse( + k, + &data, + cged, + match self.fmt.mode { + ColorMode::Rgb => adjust_map::Channels::Rgb, + ColorMode::Grayscale => adjust_map::Channels::Gray, + _ => adjust_map::Channels::Other, + }, + )) } else if rec.block(b"TySh").is_some() { // Typed model from TySh/EngineData (photocraft-text); Photoshop's pixels stay the cache. let data = rec.block(b"TySh").map(|b| b.data.clone()).unwrap_or_default(); @@ -410,16 +409,14 @@ pub fn psd_to_document(file: &PsdFile) -> (Document, Vec) { } } } - id if crate::vector_map::SAVED_PATHS.contains(&id) => { - match crate::vector_map::path_from_resource(&r.data, h.width, h.height) { - Some(path) => doc.paths.push(photocraft_doc::NamedPath { - name: String::from_utf8_lossy(&r.name).into_owned(), - path, - psd_raw: Some(Arc::new(r.data.clone())), - }), - None => doc.metadata.psd_resources.push((id, String::from_utf8_lossy(&r.name).into_owned(), Arc::new(r.data.clone()))), - } - } + id if crate::vector_map::SAVED_PATHS.contains(&id) => match crate::vector_map::path_from_resource(&r.data, h.width, h.height) { + Some(path) => doc.paths.push(photocraft_doc::NamedPath { + name: String::from_utf8_lossy(&r.name).into_owned(), + path, + psd_raw: Some(Arc::new(r.data.clone())), + }), + None => doc.metadata.psd_resources.push((id, String::from_utf8_lossy(&r.name).into_owned(), Arc::new(r.data.clone()))), + }, crate::vector_map::WORK_PATH => { doc.work_path = crate::vector_map::path_from_resource(&r.data, h.width, h.height); } @@ -518,11 +515,7 @@ pub fn psd_to_document(file: &PsdFile) -> (Document, Vec) { } } else { if !file.layers().is_empty() { - cx.warn(format!( - "{:?} documents are imported flattened: {} layer records were not imported", - h.color_mode, - file.layers().len() - )); + cx.warn(format!("{:?} documents are imported flattened: {} layer records were not imported", h.color_mode, file.layers().len())); } else { cx.warn(format!("{:?} {}-bit document converted to {:?} 8-bit for editing", h.color_mode, h.depth, fmt.mode)); } @@ -533,7 +526,8 @@ pub fn psd_to_document(file: &PsdFile) -> (Document, Vec) { doc.color_table = Some(photocraft_doc::ColorTable { colors, transparent: None }); } else if h.color_mode == PsdMode::Duotone && !file.color_mode_data.is_empty() { // The duotone ink block is undocumented: keep it raw; the image shows as its gray plate. - doc.duotone = Some(photocraft_doc::Duotone { inks: vec![photocraft_doc::DuotoneInk::new("Black", [0.0; 3])], psd_raw: Some(file.color_mode_data.clone()) }); + doc.duotone = + Some(photocraft_doc::Duotone { inks: vec![photocraft_doc::DuotoneInk::new("Black", [0.0; 3])], psd_raw: Some(file.color_mode_data.clone()) }); cx.warn("duotone inks are not interpreted (shown as grayscale)"); } else if !file.color_mode_data.is_empty() { cx.warn("color mode data is not preserved"); @@ -571,10 +565,7 @@ pub fn psd_to_document(file: &PsdFile) -> (Document, Vec) { if layered && let Ok(all) = &merged { let first = cc + usize::from(file.merged_has_alpha()); let plane = h.row_bytes() * hh; - let names = file - .resource(1045) - .map(|r| unicode_names(&r.data)) - .unwrap_or_default(); + let names = file.resource(1045).map(|r| unicode_names(&r.data)).unwrap_or_default(); for (k, idx) in (first..usize::from(h.channels)).enumerate() { let Some(p) = all.get(idx * plane..(idx + 1) * plane) else { break }; let mut s = Surface::new(cx.mask_fmt); diff --git a/crates/io/src/slices_map.rs b/crates/io/src/slices_map.rs index fe512a24b1..06bfd91552 100644 --- a/crates/io/src/slices_map.rs +++ b/crates/io/src/slices_map.rs @@ -124,8 +124,23 @@ mod tests { l.psd_id = Some(5); let lid = l.id; d.layers.push(l); - d.slices.list.push(Slice { id: 1, name: "hero".into(), rect: Rect::new(10, 10, 60, 40), url: "https://example.org".into(), alt: "Hero".into(), ..Default::default() }); - d.slices.list.push(Slice { id: 2, origin: SliceOrigin::Layer, layer: Some(lid), rect: Rect::new(70, 50, 100, 80), kind: SliceKind::NoImage, outsets: [1, 1, 1, 1], ..Default::default() }); + d.slices.list.push(Slice { + id: 1, + name: "hero".into(), + rect: Rect::new(10, 10, 60, 40), + url: "https://example.org".into(), + alt: "Hero".into(), + ..Default::default() + }); + d.slices.list.push(Slice { + id: 2, + origin: SliceOrigin::Layer, + layer: Some(lid), + rect: Rect::new(70, 50, 100, 80), + kind: SliceKind::NoImage, + outsets: [1, 1, 1, 1], + ..Default::default() + }); let ids: HashMap = [(lid, 5)].into_iter().collect(); let data = export_resource(&d, &ids).unwrap().unwrap(); // Read back into a copy of the document without slices. diff --git a/crates/io/src/vector_map.rs b/crates/io/src/vector_map.rs index 5e59536295..695d5da5d1 100644 --- a/crates/io/src/vector_map.rs +++ b/crates/io/src/vector_map.rs @@ -6,9 +6,7 @@ //! a preserved block is written back byte-identical while it still decodes to the layer's //! current model; otherwise it is regenerated from the model. -use photocraft_doc::{ - Fill, FillRule, Knot, LineCap, LineJoin, LiveShape, Path, PathOp, ShapeLayer, ShapeStroke, StrokeAlign, Subpath, VectorMask, -}; +use photocraft_doc::{Fill, FillRule, Knot, LineCap, LineJoin, LiveShape, Path, PathOp, ShapeLayer, ShapeStroke, StrokeAlign, Subpath, VectorMask}; use photocraft_geom::Point; use photocraft_psd::descriptor::{Descriptor, Id, Value, VersionedDescriptor}; use photocraft_psd::path::{PathData, PsdKnot, PsdSubpath, VectorMaskBlock}; @@ -240,34 +238,40 @@ pub fn vstk_bytes(stroke: Option<&ShapeStroke>, fill_enabled: bool, dpi: f32) -> .with("strokeStyleMiterLimit", Value::Double(f64::from(s.miter_limit))) .with( "strokeStyleLineCapType", - en("strokeStyleLineCapType", match s.cap { - LineCap::Butt => "strokeStyleButtCap", - LineCap::Round => "strokeStyleRoundCap", - LineCap::Square => "strokeStyleSquareCap", - }), + en( + "strokeStyleLineCapType", + match s.cap { + LineCap::Butt => "strokeStyleButtCap", + LineCap::Round => "strokeStyleRoundCap", + LineCap::Square => "strokeStyleSquareCap", + }, + ), ) .with( "strokeStyleLineJoinType", - en("strokeStyleLineJoinType", match s.join { - LineJoin::Miter => "strokeStyleMiterJoin", - LineJoin::Round => "strokeStyleRoundJoin", - LineJoin::Bevel => "strokeStyleBevelJoin", - }), + en( + "strokeStyleLineJoinType", + match s.join { + LineJoin::Miter => "strokeStyleMiterJoin", + LineJoin::Round => "strokeStyleRoundJoin", + LineJoin::Bevel => "strokeStyleBevelJoin", + }, + ), ) .with( "strokeStyleLineAlignment", - en("strokeStyleLineAlignment", match s.align { - StrokeAlign::Inside => "strokeStyleAlignInside", - StrokeAlign::Center => "strokeStyleAlignCenter", - StrokeAlign::Outside => "strokeStyleAlignOutside", - }), + en( + "strokeStyleLineAlignment", + match s.align { + StrokeAlign::Inside => "strokeStyleAlignInside", + StrokeAlign::Center => "strokeStyleAlignCenter", + StrokeAlign::Outside => "strokeStyleAlignOutside", + }, + ), ) .with("strokeStyleScaleLock", Value::Boolean(false)) .with("strokeStyleStrokeAdjust", Value::Boolean(false)) - .with( - "strokeStyleLineDashSet", - Value::List(s.dashes.iter().map(|v| Value::UnitFloat { unit: *b"#Nne", value: f64::from(*v) }).collect()), - ) + .with("strokeStyleLineDashSet", Value::List(s.dashes.iter().map(|v| Value::UnitFloat { unit: *b"#Nne", value: f64::from(*v) }).collect())) .with("strokeStyleBlendMode", en("BlnM", "Nrml")) .with("strokeStyleOpacity", Value::UnitFloat { unit: *b"#Prc", value: f64::from(s.opacity * 100.0) }) .with("strokeStyleContent", Value::Descriptor(content)) diff --git a/crates/io/tests/common/mod.rs b/crates/io/tests/common/mod.rs index 2bab8ee029..433e4e4fd7 100644 --- a/crates/io/tests/common/mod.rs +++ b/crates/io/tests/common/mod.rs @@ -67,8 +67,7 @@ pub struct Features { impl Features { pub const ALL: Features = Features { groups: true, masks: true, adjustments: true, fills: true, all_blends: true, extras: true }; - pub const PIXELS: Features = - Features { groups: true, masks: true, adjustments: false, fills: false, all_blends: true, extras: false }; + pub const PIXELS: Features = Features { groups: true, masks: true, adjustments: false, fills: false, all_blends: true, extras: false }; } pub fn raster(name: &str, fmt: PixelFormat, r: Rect, seed: u32, alpha: bool) -> Layer { @@ -105,22 +104,11 @@ pub fn gen_doc(mode: ColorMode, depth: SampleType, f: Features) -> Document { if f.masks { let mut m = raster("Masked", fmt, Rect::new(2, 2, 14, 12), 5, true); - m.mask = Some(LayerMask { - surface: mask_surface(depth, Rect::new(4, 4, 10, 9), 1.0, 1), - enabled: true, - linked: true, - density: 1.0, - feather: 0.0, - }); + m.mask = Some(LayerMask { surface: mask_surface(depth, Rect::new(4, 4, 10, 9), 1.0, 1), enabled: true, linked: true, density: 1.0, feather: 0.0 }); d.layers.push(m); let mut m2 = raster("Masked2", fmt, Rect::new(-2, 1, 6, 5), 6, true); - m2.mask = Some(LayerMask { - surface: mask_surface(depth, Rect::new(-1, 0, 3, 4), 0.0, 2), - enabled: false, - linked: false, - density: g(128), - feather: 2.5, - }); + m2.mask = + Some(LayerMask { surface: mask_surface(depth, Rect::new(-1, 0, 3, 4), 0.0, 2), enabled: false, linked: false, density: g(128), feather: 2.5 }); d.layers.push(m2); } diff --git a/crates/io/tests/comps_artboards.rs b/crates/io/tests/comps_artboards.rs index 9f9d668231..5dfcd6b5b2 100644 --- a/crates/io/tests/comps_artboards.rs +++ b/crates/io/tests/comps_artboards.rs @@ -127,18 +127,35 @@ fn synthetic(depth: SampleType) -> Document { g.artboard = Some(Artboard { rect: Rect::new(0, 0, 40, 40), background: ArtboardBackground::White, preset: "Custom".into() }); } if let LayerContent::Group(g) = &mut g2.content { - g.artboard = Some(Artboard { rect: Rect::new(40, 0, 80, 40), background: ArtboardBackground::Custom(Color::rgb(0.0, 1.0, 0.0)), preset: String::new() }); + g.artboard = + Some(Artboard { rect: Rect::new(40, 0, 80, 40), background: ArtboardBackground::Custom(Color::rgb(0.0, 1.0, 0.0)), preset: String::new() }); } d.layers = vec![g1, g2]; let states = capture_states(&d); - d.layer_comps.push(LayerComp { id: 11, name: "Both".into(), comment: "all".into(), apply_visibility: true, apply_position: true, apply_appearance: false, states }); + d.layer_comps.push(LayerComp { + id: 11, + name: "Both".into(), + comment: "all".into(), + apply_visibility: true, + apply_position: true, + apply_appearance: false, + states, + }); d.layers[1].visible = false; if let Some(ch) = d.layers[0].children_mut() { ch[0].blend = BlendMode::Multiply; } let mut states = capture_states(&d); states.retain(|s| s.layer != d.layers[0].id); - d.layer_comps.push(LayerComp { id: 12, name: "One".into(), comment: String::new(), apply_visibility: true, apply_position: false, apply_appearance: true, states }); + d.layer_comps.push(LayerComp { + id: 12, + name: "One".into(), + comment: String::new(), + apply_visibility: true, + apply_position: false, + apply_appearance: true, + states, + }); d.last_applied_comp = Some(12); d } @@ -178,7 +195,15 @@ fn synthetic_psd_roundtrip_all_depths() { #[test] fn synthetic_pcraft_roundtrip_keeps_everything() { let mut d = synthetic(SampleType::U16); - d.last_document_state = Some(LayerComp { id: 0, name: "Last Document State".into(), comment: String::new(), apply_visibility: true, apply_position: true, apply_appearance: true, states: capture_states(&d) }); + d.last_document_state = Some(LayerComp { + id: 0, + name: "Last Document State".into(), + comment: String::new(), + apply_visibility: true, + apply_position: true, + apply_appearance: true, + states: capture_states(&d), + }); let bytes = photocraft_format::save_to_bytes(&d, &Default::default()).unwrap(); let back = photocraft_format::load_from_bytes(&bytes).unwrap(); assert_eq!(back.layer_comps, d.layer_comps); diff --git a/crates/io/tests/corpus.rs b/crates/io/tests/corpus.rs index 2b8880e125..162bb1a320 100644 --- a/crates/io/tests/corpus.rs +++ b/crates/io/tests/corpus.rs @@ -88,11 +88,7 @@ fn corpus_import_flatten_oracle() { }; let ours = photocraft_compose::flatten(doc).px; let m = common::max_diff(&ours, &merged); - let bad = ours - .iter() - .zip(&merged) - .filter(|(a, b)| (0..4).any(|c| (a[c] * a[3] - b[c] * b[3]).abs() > PASS_TOL)) - .count(); + let bad = ours.iter().zip(&merged).filter(|(a, b)| (0..4).any(|c| (a[c] * a[3] - b[c] * b[3]).abs() > PASS_TOL)).count(); let pct = 100.0 * bad as f32 / ours.len().max(1) as f32; let status = if m <= PASS_TOL { pass += 1; @@ -104,10 +100,7 @@ fn corpus_import_flatten_oracle() { let notes: Vec<&str> = imp.warnings.iter().map(String::as_str).take(2).collect(); eprintln!("{name:<60} {layers:>6} {:>9.4} {:>7.2}% {status} {}", m, pct, notes.join(" | ")); } - eprintln!( - "io corpus: {} files: {pass} pass (<= 2/255), {diff} differ, {skipped} skipped, {errors} errors", - files.len() - ); + eprintln!("io corpus: {} files: {pass} pass (<= 2/255), {diff} differ, {skipped} skipped, {errors} errors", files.len()); if std::env::var_os("PHOTOCRAFT_CORPUS_STRICT").is_some() { assert_eq!(errors, 0); } diff --git a/crates/io/tests/modes.rs b/crates/io/tests/modes.rs index 5d5d23e065..3d4427e47c 100644 --- a/crates/io/tests/modes.rs +++ b/crates/io/tests/modes.rs @@ -4,13 +4,7 @@ use photocraft_color::{Color, ColorMode, SampleType}; use photocraft_doc::{ColorTable, Document, Duotone, DuotoneInk, Size}; fn doc(mode: ColorMode) -> Document { - let mut d = Document::with_background( - "m", - Size::new(4, 2), - ColorMode::Rgb, - SampleType::U8, - Color::rgba(1.0, 0.0, 0.0, 1.0), - ); + let mut d = Document::with_background("m", Size::new(4, 2), ColorMode::Rgb, SampleType::U8, Color::rgba(1.0, 0.0, 0.0, 1.0)); d.mode = mode; d } @@ -18,10 +12,7 @@ fn doc(mode: ColorMode) -> Document { #[test] fn indexed_png_is_palette_png() { let mut d = doc(ColorMode::Indexed); - d.color_table = Some(ColorTable { - colors: vec![[0, 0, 0], [255, 0, 0]], - transparent: None, - }); + d.color_table = Some(ColorTable { colors: vec![[0, 0, 0], [255, 0, 0]], transparent: None }); let r = photocraft_io::export(&d, "x.png", &Default::default()).unwrap(); // IHDR colour type 3 = indexed; a PLTE chunk follows. assert_eq!(r.bytes[25], 3); @@ -37,27 +28,11 @@ fn indexed_png_is_palette_png() { #[test] fn duotone_exports_the_inks_as_rgb() { // Mid gray background. - let mut d = Document::with_background( - "m", - Size::new(4, 2), - ColorMode::Grayscale, - SampleType::U8, - Color::rgba(0.5, 0.5, 0.5, 1.0), - ); + let mut d = Document::with_background("m", Size::new(4, 2), ColorMode::Grayscale, SampleType::U8, Color::rgba(0.5, 0.5, 0.5, 1.0)); d.mode = ColorMode::Duotone; - d.duotone = Some(Duotone { - inks: vec![ - DuotoneInk::new("Black", [0.0; 3]), - DuotoneInk::new("Orange", [1.0, 0.5, 0.0]), - ], - psd_raw: None, - }); + d.duotone = Some(Duotone { inks: vec![DuotoneInk::new("Black", [0.0; 3]), DuotoneInk::new("Orange", [1.0, 0.5, 0.0])], psd_raw: None }); let r = photocraft_io::export(&d, "x.png", &Default::default()).unwrap(); - assert!( - r.warnings.iter().any(|w| w.contains("Duotone")), - "{:?}", - r.warnings - ); + assert!(r.warnings.iter().any(|w| w.contains("Duotone")), "{:?}", r.warnings); let back = photocraft_io::import("x.png", &r.bytes).unwrap().document; let px = back.layers[0].surface().unwrap().rgba(0, 0); assert!(px[0] > px[2] + 0.05, "warm: {px:?}"); diff --git a/crates/io/tests/psd_structure.rs b/crates/io/tests/psd_structure.rs index ef72e8f719..1bf2909c09 100644 --- a/crates/io/tests/psd_structure.rs +++ b/crates/io/tests/psd_structure.rs @@ -14,9 +14,7 @@ fn sample(f: &PsdFile, li: usize, id: i16, x: i32, y: i32, cache: &mut std::coll let default = if id == -2 { rec.layer_mask().map_or(0, |m| m.default_color) } else { 0 }; return if default == 0 || bps != 1 { vec![0; bps] } else { vec![default] }; } - let plane = cache - .entry((li, id)) - .or_insert_with(|| rec.decode_channel(id, f.header.depth, f.header.version).unwrap_or_default()); + let plane = cache.entry((li, id)).or_insert_with(|| rec.decode_channel(id, f.header.depth, f.header.version).unwrap_or_default()); let w = (r.right - r.left) as usize; let i = ((y - r.top) as usize * w + (x - r.left) as usize) * bps; plane.get(i..i + bps).map(<[u8]>::to_vec).unwrap_or(vec![0; bps]) @@ -58,11 +56,7 @@ fn assert_structurally_equal(a: &PsdFile, b: &PsdFile) { let (ra, rb) = (&a.layers()[ia], &b.layers()[ib]); let n = ra.name(); assert_eq!(n, rb.name()); - let blend = if matches!(ra.blend_mode, photocraft_psd::BlendMode::Unknown(_)) { - photocraft_psd::BlendMode::Normal - } else { - ra.blend_mode - }; + let blend = if matches!(ra.blend_mode, photocraft_psd::BlendMode::Unknown(_)) { photocraft_psd::BlendMode::Normal } else { ra.blend_mode }; if ra.section_type().is_folder() { assert_eq!(ra.section_divider().and_then(|s| s.blend_mode), rb.section_divider().and_then(|s| s.blend_mode), "{n}"); } else { @@ -77,9 +71,7 @@ fn assert_structurally_equal(a: &PsdFile, b: &PsdFile) { continue; } // Pixels: compare every channel over the union of both rects. - let u = |r: photocraft_psd::Rect, s: photocraft_psd::Rect| { - (r.left.min(s.left), r.top.min(s.top), r.right.max(s.right), r.bottom.max(s.bottom)) - }; + let u = |r: photocraft_psd::Rect, s: photocraft_psd::Rect| (r.left.min(s.left), r.top.min(s.top), r.right.max(s.right), r.bottom.max(s.bottom)); let mut ids: Vec = (-1..cc).collect(); if ra.layer_mask().is_some() && (ra.channel(-2).is_some() || ra.channel(-3).is_some()) { ids.push(-2); @@ -296,7 +288,9 @@ fn adjustment_and_fill_layers_from_psd() { use photocraft_doc::{Adjustment, Fill}; assert!(matches!(d.layers[n - 4].content, LayerContent::Adjustment(Adjustment::Invert))); assert!(matches!(d.layers[n - 3].content, LayerContent::Adjustment(Adjustment::Posterize { levels: 4 }))); - assert!(matches!(&d.layers[n - 2].content, LayerContent::Adjustment(Adjustment::Unsupported { psd_key, raw }) if psd_key == "selc" && raw == &vec![1, 2, 3])); + assert!( + matches!(&d.layers[n - 2].content, LayerContent::Adjustment(Adjustment::Unsupported { psd_key, raw }) if psd_key == "selc" && raw == &vec![1, 2, 3]) + ); assert!(matches!(&d.layers[n - 1].content, LayerContent::Fill(Fill::Solid(c)) if c.c[0] == 1.0)); } @@ -376,7 +370,11 @@ fn guides_and_alpha_names_roundtrip() { for name in ["Spot \u{e9}", "Alpha 2"] { d.channels.push(photocraft_doc::AlphaChannel::new( name, - photocraft_raster::Surface::new(photocraft_color::PixelFormat::new(photocraft_color::ColorMode::Grayscale, photocraft_color::SampleType::U8, false)), + photocraft_raster::Surface::new(photocraft_color::PixelFormat::new( + photocraft_color::ColorMode::Grayscale, + photocraft_color::SampleType::U8, + false, + )), )); } let f = document_to_psd(&d); diff --git a/crates/io/tests/roundtrip.rs b/crates/io/tests/roundtrip.rs index 2a87d3be8f..618b0f45b2 100644 --- a/crates/io/tests/roundtrip.rs +++ b/crates/io/tests/roundtrip.rs @@ -141,7 +141,8 @@ fn text_shape_smart_raw_blocks_survive() { #[test] fn raster_layer_blocks_preserved() { let mut d = gen_doc(ColorMode::Rgb, SampleType::U8, Features::PIXELS); - let blocks = vec![(*b"vmsk", std::sync::Arc::new(vec![0u8; 12])), (*b"clbl", std::sync::Arc::new(vec![0u8, 0, 0, 0])), (*b"Zzzz", std::sync::Arc::new(vec![1u8]))]; + let blocks = + vec![(*b"vmsk", std::sync::Arc::new(vec![0u8; 12])), (*b"clbl", std::sync::Arc::new(vec![0u8, 0, 0, 0])), (*b"Zzzz", std::sync::Arc::new(vec![1u8]))]; d.layers[1].psd_blocks = blocks.clone(); d.layers[1].vector_mask = photocraft_io::vector_map::vector_mask_from_block(&[0; 12], d.size.width, d.size.height); let back = roundtrip(&d); @@ -263,7 +264,10 @@ fn gradient_and_pattern_fills_roundtrip() { reverse: true, }), )); - d.layers.push(Layer::new("pat", LayerContent::Fill(Fill::Pattern { name: "Dots".into(), scale: 0.5, id: String::new(), angle: 0.0, link: true, phase: (0.0, 0.0) }))); + d.layers.push(Layer::new( + "pat", + LayerContent::Fill(Fill::Pattern { name: "Dots".into(), scale: 0.5, id: String::new(), angle: 0.0, link: true, phase: (0.0, 0.0) }), + )); let back = roundtrip(&d); assert_docs_eq(&d, &back); } @@ -302,8 +306,18 @@ fn effects_roundtrip_typed_and_raw() { d.global_light = GlobalLight { angle: 45.0, altitude: 25.0 }; d.layers[1].effects.items = vec![ Effect::default_drop_shadow(), - Effect::Stroke(StrokeFx { common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), size: 3.0, position: StrokePosition::Center, paint: FxPaint::Color(photocraft_color::Color::rgb(0.0, 1.0, 0.0)) }), - Effect::Stroke(StrokeFx { common: FxCommon::new(photocraft_color::BlendMode::Normal, 0.5), size: 6.0, position: StrokePosition::Outside, paint: FxPaint::Color(photocraft_color::Color::rgb(0.0, 0.0, 1.0)) }), + Effect::Stroke(StrokeFx { + common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), + size: 3.0, + position: StrokePosition::Center, + paint: FxPaint::Color(photocraft_color::Color::rgb(0.0, 1.0, 0.0)), + }), + Effect::Stroke(StrokeFx { + common: FxCommon::new(photocraft_color::BlendMode::Normal, 0.5), + size: 6.0, + position: StrokePosition::Outside, + paint: FxPaint::Color(photocraft_color::Color::rgb(0.0, 0.0, 1.0)), + }), ]; let back = roundtrip(&d); assert_eq!(back.global_light, d.global_light); @@ -332,7 +346,10 @@ fn group_effects_use_lfxs() { use photocraft_doc::*; let mut d = gen_doc(ColorMode::Rgb, SampleType::U8, Features::PIXELS); let n = d.layers.iter().position(|l| l.is_group()).unwrap(); - d.layers[n].effects.items.push(Effect::ColorOverlay { common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), color: photocraft_color::Color::rgb(1.0, 0.0, 0.0) }); + d.layers[n] + .effects + .items + .push(Effect::ColorOverlay { common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), color: photocraft_color::Color::rgb(1.0, 0.0, 0.0) }); let f = document_to_psd(&d); let rec = f.layers().iter().find(|r| r.name() == d.layers[n].name && r.section_type().is_folder()).unwrap(); assert!(rec.block(b"lfxs").is_some() && rec.block(b"lfx2").is_none()); @@ -429,45 +446,15 @@ fn blend_if_round_trips_as_blending_ranges() { use photocraft_doc::{BlendIf, BlendRange, Document, Layer, Size}; let mut d = Document::new("b", Size::new(8, 8), ColorMode::Rgb, SampleType::U8); let mut a = Layer::raster("a", d.pixel_format()); - a.surface_mut().unwrap().fill_rect( - photocraft_geom::Rect::new(0, 0, 4, 4), - &[1.0, 1.0, 1.0, 1.0], - ); + a.surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(0, 0, 4, 4), &[1.0, 1.0, 1.0, 1.0]); let mut bi = BlendIf::default(); // Gray › This Layer: hide the whites, fading from 200 to 230. - bi.set( - 0, - [ - BlendRange { - black: [0, 0], - white: [200, 230], - }, - BlendRange::FULL, - ], - ); + bi.set(0, [BlendRange { black: [0, 0], white: [200, 230] }, BlendRange::FULL]); // Green › Underlying Layer: show only over values from 64 up. - bi.set( - 2, - [ - BlendRange::FULL, - BlendRange { - black: [64, 64], - white: [255, 255], - }, - ], - ); + bi.set(2, [BlendRange::FULL, BlendRange { black: [64, 64], white: [255, 255] }]); a.blend_if = bi.clone(); let mut g = Layer::group("g", vec![Layer::raster("c", d.pixel_format())]); - g.blend_if.set( - 1, - [ - BlendRange { - black: [1, 2], - white: [3, 4], - }, - BlendRange::FULL, - ], - ); + g.blend_if.set(1, [BlendRange { black: [1, 2], white: [3, 4] }, BlendRange::FULL]); let b = Layer::raster("b", d.pixel_format()); d.layers = vec![a, b, g]; let f = document_to_psd(&d); @@ -485,11 +472,5 @@ fn blend_if_round_trips_as_blending_ranges() { let back = roundtrip(&d); assert_eq!(back.layers[0].blend_if, bi); assert!(back.layers[1].blend_if.is_default()); - assert_eq!( - back.layers[2].blend_if.get(1)[0], - BlendRange { - black: [1, 2], - white: [3, 4] - } - ); + assert_eq!(back.layers[2].blend_if.get(1)[0], BlendRange { black: [1, 2], white: [3, 4] }); } diff --git a/crates/io/tests/text_corpus.rs b/crates/io/tests/text_corpus.rs index 4eb273537a..9d59783fcf 100644 --- a/crates/io/tests/text_corpus.rs +++ b/crates/io/tests/text_corpus.rs @@ -19,11 +19,7 @@ fn collect(dir: &Path, out: &mut Vec) { let p = e.path(); if p.is_dir() { collect(&p, out); - } else if p - .extension() - .and_then(|e| e.to_str()) - .is_some_and(|e| e.eq_ignore_ascii_case("psd") || e.eq_ignore_ascii_case("psb")) - { + } else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("psd") || e.eq_ignore_ascii_case("psb")) { out.push(p); } } @@ -72,39 +68,16 @@ fn corpus_text_layers() { }; n += 1; assert!(t.psd_raw.is_some()); - assert!( - !t.runs.is_empty(), - "{name}/{}: no style runs parsed", - l.name - ); - assert_eq!( - t.runs - .iter() - .map(|r| r.len) - .sum::() - .min(t.text.len()), - t.text.len(), - "{name}/{}: runs cover text", - l.name - ); + assert!(!t.runs.is_empty(), "{name}/{}: no style runs parsed", l.name); + assert_eq!(t.runs.iter().map(|r| r.len).sum::().min(t.text.len()), t.text.len(), "{name}/{}: runs cover text", l.name); let st = &t.runs[0].style; - assert!( - st.size_pt > 0.0 && st.postscript_name.is_some(), - "{name}/{}: {st:?}", - l.name - ); + assert!(st.size_pt > 0.0 && st.postscript_name.is_some(), "{name}/{}: {st:?}", l.name); // Round trip of the model through our own TySh writer. let rebuilt = photocraft_text::psd::build_tysh(t, doc.resolution_dpi, None); - let back = - photocraft_text::psd::text_layer_from_tysh(&rebuilt, doc.resolution_dpi).unwrap(); + let back = photocraft_text::psd::text_layer_from_tysh(&rebuilt, doc.resolution_dpi).unwrap(); assert_eq!(back.text, t.text); assert_eq!(back.char_runs(), t.char_runs(), "{name}/{}", l.name); - assert_eq!( - back.paragraph_runs(), - t.paragraph_runs(), - "{name}/{}", - l.name - ); + assert_eq!(back.paragraph_runs(), t.paragraph_runs(), "{name}/{}", l.name); assert_eq!(back.shape, t.shape); // Geometry vs Photoshop's pixels. @@ -112,12 +85,7 @@ fn corpus_text_layers() { let ps_rect = ps.content_bounds(); let (_, ours) = engine.render(t, doc.resolution_dpi, doc.pixel_format()); let our_rect = ours.surface.content_bounds(); - let u = Rect::new( - ps_rect.x0.min(our_rect.x0), - ps_rect.y0.min(our_rect.y0), - ps_rect.x1.max(our_rect.x1), - ps_rect.y1.max(our_rect.y1), - ); + let u = Rect::new(ps_rect.x0.min(our_rect.x0), ps_rect.y0.min(our_rect.y0), ps_rect.x1.max(our_rect.x1), ps_rect.y1.max(our_rect.y1)); let (a, b) = (alpha(ps, u), alpha(&ours.surface, u)); let inter: f32 = a.iter().zip(&b).map(|(x, y)| x.min(*y)).sum(); let union: f32 = a.iter().zip(&b).map(|(x, y)| x.max(*y)).sum(); @@ -135,37 +103,26 @@ fn corpus_text_layers() { st.size_pt, t.shape, (ps_rect.x0, ps_rect.y0, ps_rect.width(), ps_rect.height()), - ( - our_rect.x0, - our_rect.y0, - our_rect.width(), - our_rect.height() - ), + (our_rect.x0, our_rect.y0, our_rect.width(), our_rect.height()), if ok { "ok" } else { "GEOMETRY OFF" } ); } } println!("text layers: {n}, geometry within tolerance: {good_geometry}"); if n > 0 { - assert!( - good_geometry * 2 >= n, - "most text layers should land where Photoshop drew them" - ); + assert!(good_geometry * 2 >= n, "most text layers should land where Photoshop drew them"); } } /// Unedited text layers are written back byte-for-byte. #[test] fn corpus_tysh_lossless() { - let root = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../../corpus/psd/ag-psd/read-write/text/src.psd"); + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/psd/ag-psd/read-write/text/src.psd"); let Ok(bytes) = std::fs::read(&root) else { return; }; let doc = photocraft_io::import("src.psd", &bytes).unwrap().document; - let out = photocraft_io::export(&doc, "out.psd", &Default::default()) - .unwrap() - .bytes; + let out = photocraft_io::export(&doc, "out.psd", &Default::default()).unwrap().bytes; let find = |b: &[u8]| { let i = b.windows(8).position(|w| w == b"8BIMTySh").unwrap(); let len = u32::from_be_bytes(b[i + 8..i + 12].try_into().unwrap()) as usize; @@ -179,70 +136,30 @@ fn corpus_tysh_lossless() { #[test] fn created_text_layer_roundtrips_through_psd() { use photocraft_color::{Color, ColorMode, SampleType}; - use photocraft_doc::text::{ - CharStyle, ParagraphRun, ParagraphStyle, TextAlign, TextRun, TextShape, - }; + use photocraft_doc::text::{CharStyle, ParagraphRun, ParagraphStyle, TextAlign, TextRun, TextShape}; use photocraft_doc::{Document, Size, TextLayer}; let mut doc = Document::new("t", Size::new(120, 80), ColorMode::Rgb, SampleType::U8); doc.resolution_dpi = 144.0; - let a = CharStyle { - font_family: "Inter".into(), - size_pt: 10.0, - color: Color::rgb(0.2, 0.4, 0.6), - ..Default::default() - }; - let b = CharStyle { - faux_bold: true, - underline: true, - tracking: 50.0, - leading_pt: Some(14.0), - ..a.clone() - }; + let a = CharStyle { font_family: "Inter".into(), size_pt: 10.0, color: Color::rgb(0.2, 0.4, 0.6), ..Default::default() }; + let b = CharStyle { faux_bold: true, underline: true, tracking: 50.0, leading_pt: Some(14.0), ..a.clone() }; let mut t = TextLayer { text: "Größe\nzwei".into(), runs: vec![TextRun { len: 3, style: a }, TextRun { len: 9, style: b }], paragraphs: vec![ - ParagraphRun { - len: 7, - style: ParagraphStyle { - align: TextAlign::Center, - space_after_pt: 3.0, - ..Default::default() - }, - }, - ParagraphRun { - len: 5, - style: ParagraphStyle { - align: TextAlign::JustifyAll, - ..Default::default() - }, - }, + ParagraphRun { len: 7, style: ParagraphStyle { align: TextAlign::Center, space_after_pt: 3.0, ..Default::default() } }, + ParagraphRun { len: 5, style: ParagraphStyle { align: TextAlign::JustifyAll, ..Default::default() } }, ], - shape: TextShape::Box { - x: 0.0, - y: 0.0, - width: 100.0, - height: 60.0, - }, + shape: TextShape::Box { x: 0.0, y: 0.0, width: 100.0, height: 60.0 }, transform: photocraft_geom::Affine::translate(8.0, 6.0), ..Default::default() }; t.sync_summary(); photocraft_text::TextEngine::new().render_layer(&mut t, doc.resolution_dpi, doc.pixel_format()); - doc.layers.push(photocraft_doc::Layer::new( - "t", - LayerContent::Text(t.clone()), - )); + doc.layers.push(photocraft_doc::Layer::new("t", LayerContent::Text(t.clone()))); let out = photocraft_io::export(&doc, "t.psd", &Default::default()).unwrap(); - assert!( - !out.warnings.iter().any(|w| w.contains("text layer")), - "{:?}", - out.warnings - ); + assert!(!out.warnings.iter().any(|w| w.contains("text layer")), "{:?}", out.warnings); let back = photocraft_io::import("t.psd", &out.bytes).unwrap().document; - let LayerContent::Text(bt) = &back.layers[0].content else { - panic!("not text") - }; + let LayerContent::Text(bt) = &back.layers[0].content else { panic!("not text") }; let strip = |v: Vec| { v.into_iter() .map(|mut r| { @@ -257,9 +174,5 @@ fn created_text_layer_roundtrips_through_psd() { assert_eq!(bt.paragraph_runs(), t.paragraph_runs()); assert_eq!(bt.shape, t.shape); assert_eq!(bt.transform, t.transform); - assert!( - bt.cache - .as_ref() - .is_some_and(|c| c.content_bounds().width() > 10) - ); + assert!(bt.cache.as_ref().is_some_and(|c| c.content_bounds().width() > 10)); } diff --git a/crates/io/tests/text_styles.rs b/crates/io/tests/text_styles.rs index f026aaf156..0e7ec60e51 100644 --- a/crates/io/tests/text_styles.rs +++ b/crates/io/tests/text_styles.rs @@ -8,8 +8,17 @@ use photocraft_doc::{Document, Layer, LayerContent, Size, TextLayer, TextStyles} fn styles() -> TextStyles { let mut s = TextStyles::default(); - s.character.push(CharacterStyleDef { id: 3, name: "Loud".into(), attrs: diff_attrs(&CharStyle { size_pt: 30.0, underline: true, ..Default::default() }, &CharStyle::default()) }); - s.paragraph.push(ParagraphStyleDef { id: 1, name: "Middle".into(), para_attrs: diff_attrs(&ParagraphStyle { align: TextAlign::Center, ..Default::default() }, &ParagraphStyle::default()), ..Default::default() }); + s.character.push(CharacterStyleDef { + id: 3, + name: "Loud".into(), + attrs: diff_attrs(&CharStyle { size_pt: 30.0, underline: true, ..Default::default() }, &CharStyle::default()), + }); + s.paragraph.push(ParagraphStyleDef { + id: 1, + name: "Middle".into(), + para_attrs: diff_attrs(&ParagraphStyle { align: TextAlign::Center, ..Default::default() }, &ParagraphStyle::default()), + ..Default::default() + }); s } diff --git a/crates/io/tests/vector.rs b/crates/io/tests/vector.rs index fce2378807..231b05b006 100644 --- a/crates/io/tests/vector.rs +++ b/crates/io/tests/vector.rs @@ -7,8 +7,8 @@ use std::sync::Arc; use photocraft_color::{Color, ColorMode, SampleType}; use photocraft_doc::{ - Document, Fill, Knot, Layer, LayerContent, LineCap, LineJoin, LiveShape, NamedPath, Path, PathOp, ShapeLayer, ShapeStroke, - Size, StrokeAlign, Subpath, VectorMask, + Document, Fill, Knot, Layer, LayerContent, LineCap, LineJoin, LiveShape, NamedPath, Path, PathOp, ShapeLayer, ShapeStroke, Size, StrokeAlign, Subpath, + VectorMask, }; use photocraft_geom::{Point, Rect}; use photocraft_io::*; @@ -125,7 +125,9 @@ fn shape_layer_typed_roundtrip() { // A second round trip keeps every vector block byte-identical. let c = roundtrip(&b); for i in 1..3 { - let keys = |l: &Layer| l.psd_blocks.iter().filter(|(k, _)| matches!(k, b"vmsk" | b"vsms" | b"vstk" | b"vogk" | b"vscg" | b"SoCo")).cloned().collect::>(); + let keys = |l: &Layer| { + l.psd_blocks.iter().filter(|(k, _)| matches!(k, b"vmsk" | b"vsms" | b"vstk" | b"vogk" | b"vscg" | b"SoCo")).cloned().collect::>() + }; assert_eq!(keys(&c.layers[i]), keys(&b.layers[i]), "layer {i}"); } } @@ -194,7 +196,8 @@ fn corpus_shape_coverage_matches_photoshop() { if sh.stroke.is_some() || !matches!(sh.fill, Some(Fill::Solid(_))) || !l.effects.items.is_empty() || sh.path.is_empty() { continue; } - let r = cache.content_bounds().union(&photocraft_vector::fill_rasterizer(&sh.path, 0.01).pixel_bounds().unwrap_or(Rect::EMPTY)).intersect(&d.bounds()); + let r = + cache.content_bounds().union(&photocraft_vector::fill_rasterizer(&sh.path, 0.01).pixel_bounds().unwrap_or(Rect::EMPTY)).intersect(&d.bounds()); if r.is_empty() { continue; } @@ -253,7 +256,15 @@ fn corpus_vector_blocks_survive_roundtrip() { checked += a.len(); let summary = |v: &[NamedBlock]| v.iter().map(|(n, k, d)| format!("{n}/{}:{}", String::from_utf8_lossy(k), d.len())).collect::>(); for x in &a { - assert!(b.contains(x), "{}: {}/{} changed or missing; before {:?} after {:?}", p.display(), x.0, String::from_utf8_lossy(&x.1), summary(&a), summary(&b)); + assert!( + b.contains(x), + "{}: {}/{} changed or missing; before {:?} after {:?}", + p.display(), + x.0, + String::from_utf8_lossy(&x.1), + summary(&a), + summary(&b) + ); } // Saved paths too. for r in file.resources.iter().filter(|r| (2000..=2997).contains(&r.id)) { diff --git a/crates/paint/examples/bench_brush.rs b/crates/paint/examples/bench_brush.rs index 205af05ccf..a9360b45fb 100644 --- a/crates/paint/examples/bench_brush.rs +++ b/crates/paint/examples/bench_brush.rs @@ -19,7 +19,10 @@ fn run(fmt: PixelFormat, label: &str, brush: &BrushSettings) { let pts: Vec = (0..500) .map(|i| { let t = i as f64 / 499.0; - StrokePoint { time: i as f64 * 8.0, ..StrokePoint::new(300.0 + t * 5400.0, 3000.0 + (t * 12.0).sin() * 2000.0, 0.6 + 0.4 * (t * 30.0).sin().abs() as f32) } + StrokePoint { + time: i as f64 * 8.0, + ..StrokePoint::new(300.0 + t * 5400.0, 3000.0 + (t * 12.0).sin() * 2000.0, 0.6 + 0.4 * (t * 30.0).sin().abs() as f32) + } }) .collect(); let pre = s.clone(); diff --git a/crates/paint/src/brush.rs b/crates/paint/src/brush.rs index a4a63fb6ec..7681cec6f1 100644 --- a/crates/paint/src/brush.rs +++ b/crates/paint/src/brush.rs @@ -282,7 +282,16 @@ pub struct Pose { impl Default for Pose { fn default() -> Self { - Self { enabled: false, tilt_x: 0.0, tilt_y: 0.0, rotation: 0.0, pressure: 1.0, override_tilt: false, override_rotation: false, override_pressure: false } + Self { + enabled: false, + tilt_x: 0.0, + tilt_y: 0.0, + rotation: 0.0, + pressure: 1.0, + override_tilt: false, + override_rotation: false, + override_pressure: false, + } } } diff --git a/crates/paint/src/brush_tests.rs b/crates/paint/src/brush_tests.rs index c9f3abd494..d591c28121 100644 --- a/crates/paint/src/brush_tests.rs +++ b/crates/paint/src/brush_tests.rs @@ -150,7 +150,11 @@ fn spacing_sets_dab_count() { fn pressure_controls_size_with_minimum() { let b = BrushSettings { size: 40.0, - shape_dynamics: ShapeDynamics { enabled: true, size: Dynamic { control: Control::PenPressure, minimum: 0.25, ..Default::default() }, ..Default::default() }, + shape_dynamics: ShapeDynamics { + enabled: true, + size: Dynamic { control: Control::PenPressure, minimum: 0.25, ..Default::default() }, + ..Default::default() + }, ..brush() }; let pts = vec![StrokePoint::new(0.0, 0.0, 0.0), StrokePoint::new(200.0, 0.0, 1.0)]; @@ -165,7 +169,11 @@ fn fade_ramps_over_steps() { let b = BrushSettings { size: 20.0, spacing: 0.5, - shape_dynamics: ShapeDynamics { enabled: true, size: Dynamic { control: Control::Fade, fade_steps: 10, minimum: 0.0, ..Default::default() }, ..Default::default() }, + shape_dynamics: ShapeDynamics { + enabled: true, + size: Dynamic { control: Control::Fade, fade_steps: 10, minimum: 0.0, ..Default::default() }, + ..Default::default() + }, ..brush() }; let d = dabs_of(&b, &line(0.0, 300.0, 0.0)); @@ -173,7 +181,10 @@ fn fade_ramps_over_steps() { assert!(d[5].radius < d[0].radius * 0.6, "{}", d[5].radius); assert!(d.iter().skip(10).all(|x| x.radius <= 0.5 + 1e-6)); // Fade on opacity (Transfer). - let b = BrushSettings { transfer: Transfer { enabled: true, opacity: Dynamic { control: Control::Fade, fade_steps: 4, ..Default::default() }, ..Default::default() }, ..brush() }; + let b = BrushSettings { + transfer: Transfer { enabled: true, opacity: Dynamic { control: Control::Fade, fade_steps: 4, ..Default::default() }, ..Default::default() }, + ..brush() + }; let d = dabs_of(&b, &line(0.0, 100.0, 0.0)); assert_eq!(d[0].opacity, 1.0); assert_eq!(d[4].opacity, 0.0); @@ -198,7 +209,11 @@ fn tilt_direction_and_rotation_drive_angle() { let d = dabs_of(&mk(Control::Rotation), &[q, StrokePoint { x: 20.0, ..q }]); assert!((d[0].angle.to_degrees() - 30.0).abs() < 1e-3); // Pen tilt on size: upright pen full size, flat pen minimum. - let b = BrushSettings { shape_dynamics: ShapeDynamics { enabled: true, size: Dynamic::controlled(Control::PenTilt), ..Default::default() }, size: 20.0, ..brush() }; + let b = BrushSettings { + shape_dynamics: ShapeDynamics { enabled: true, size: Dynamic::controlled(Control::PenTilt), ..Default::default() }, + size: 20.0, + ..brush() + }; let mut flat = StrokePoint::new(0.0, 0.0, 1.0); flat.tilt_x = 90.0; let d = dabs_of(&b, &[flat]); @@ -210,7 +225,14 @@ fn jitter_is_deterministic_per_seed() { let b = BrushSettings { size: 30.0, seed: 42, - shape_dynamics: ShapeDynamics { enabled: true, size: Dynamic::jitter(1.0), angle: Dynamic::jitter(1.0), roundness: Dynamic { jitter: 1.0, minimum: 0.2, ..Default::default() }, flip_x_jitter: true, flip_y_jitter: true }, + shape_dynamics: ShapeDynamics { + enabled: true, + size: Dynamic::jitter(1.0), + angle: Dynamic::jitter(1.0), + roundness: Dynamic { jitter: 1.0, minimum: 0.2, ..Default::default() }, + flip_x_jitter: true, + flip_y_jitter: true, + }, scattering: Scattering { enabled: true, scatter: Dynamic::jitter(2.0), both_axes: true, count: 3, count_jitter: Dynamic::jitter(1.0) }, ..brush() }; @@ -283,7 +305,8 @@ fn color_dynamics_ranges() { let d = dabs_of(&b, &line(0.0, 200.0, 0.0)); assert!(d.windows(2).all(|w| w[0].color == w[1].color)); // Per-tip colours land in the pixels. - let b = BrushSettings { size: 10.0, spacing: 3.0, color_dynamics: ColorDynamics { enabled: true, fg_bg: Dynamic::jitter(1.0), ..Default::default() }, ..base }; + let b = + BrushSettings { size: 10.0, spacing: 3.0, color_dynamics: ColorDynamics { enabled: true, fg_bg: Dynamic::jitter(1.0), ..Default::default() }, ..base }; let s = paint(&b, &line(10.0, 310.0, 20.0), 330, 40); let colours: Vec<[f32; 4]> = (0..11).map(|i| s.rgba(10 + i * 30, 20)).collect(); assert!(colours.iter().any(|c| c[2] > 0.6) && colours.iter().any(|c| c[0] > 0.6), "{colours:?}"); @@ -292,7 +315,8 @@ fn color_dynamics_ranges() { #[test] fn transfer_opacity_caps_and_flow_builds() { // Pressure on opacity caps coverage. - let b = BrushSettings { transfer: Transfer { enabled: true, opacity: Dynamic::controlled(Control::PenPressure), ..Default::default() }, size: 10.0, ..brush() }; + let b = + BrushSettings { transfer: Transfer { enabled: true, opacity: Dynamic::controlled(Control::PenPressure), ..Default::default() }, size: 10.0, ..brush() }; let pts = vec![StrokePoint::new(10.0, 10.0, 0.4), StrokePoint::new(90.0, 10.0, 0.4)]; let s = paint(&b, &pts, 100, 20); assert!((s.rgba(50, 10)[3] - 0.4).abs() < 0.01, "{}", s.rgba(50, 10)[3]); @@ -363,7 +387,18 @@ fn texture_modulates_coverage() { let s = paint(&b, &line(20.0, 80.0, 30.0), 100, 60); assert!(s.rgba(46, 30)[3] > 0.9); // All modes keep zero coverage at zero and stay in range. - for m in [MaskMode::Multiply, MaskMode::Subtract, MaskMode::Darken, MaskMode::Overlay, MaskMode::ColorDodge, MaskMode::ColorBurn, MaskMode::LinearBurn, MaskMode::HardMix, MaskMode::LinearHeight, MaskMode::Height] { + for m in [ + MaskMode::Multiply, + MaskMode::Subtract, + MaskMode::Darken, + MaskMode::Overlay, + MaskMode::ColorDodge, + MaskMode::ColorBurn, + MaskMode::LinearBurn, + MaskMode::HardMix, + MaskMode::LinearHeight, + MaskMode::Height, + ] { for t in [0.0, 0.3, 1.0] { assert_eq!(mask_combine(m, 0.0, t, 1.0), 0.0, "{m:?}"); let v = mask_combine(m, 0.7, t, 0.8); @@ -383,11 +418,7 @@ fn texture_modulates_coverage() { #[test] fn dual_brush_intersects() { // Dual tip: small dabs far apart → the stroke survives only where they land. - let b = BrushSettings { - size: 30.0, - dual_brush: DualBrush { enabled: true, size: 6.0, spacing: 5.0, hardness: 1.0, ..Default::default() }, - ..brush() - }; + let b = BrushSettings { size: 30.0, dual_brush: DualBrush { enabled: true, size: 6.0, spacing: 5.0, hardness: 1.0, ..Default::default() }, ..brush() }; let s = paint(&b, &line(20.0, 200.0, 30.0), 220, 60); // Dual dabs every 30 px starting at x = 20. assert!(s.rgba(20, 30)[3] > 0.9 && s.rgba(50, 30)[3] > 0.9); @@ -416,10 +447,15 @@ fn wet_edges_and_noise() { let soft = BrushSettings { size: 40.0, hardness: 0.0, ..brush() }; let a = paint(&soft, &[StrokePoint::new(50.0, 50.0, 1.0)], 100, 100); let n = paint(&BrushSettings { noise: true, ..soft }, &[StrokePoint::new(50.0, 50.0, 1.0)], 100, 100); - let extreme = |s: &Surface| (30..70).flat_map(|y| (30..70).map(move |x| (x, y))).filter(|&(x, y)| { - let v = s.rgba(x, y)[3]; - !(0.15..=0.85).contains(&v) - }).count(); + let extreme = |s: &Surface| { + (30..70) + .flat_map(|y| (30..70).map(move |x| (x, y))) + .filter(|&(x, y)| { + let v = s.rgba(x, y)[3]; + !(0.15..=0.85).contains(&v) + }) + .count() + }; assert!(extreme(&n) > extreme(&a) + 100, "{} vs {}", extreme(&n), extreme(&a)); } diff --git a/crates/paint/src/dynamics.rs b/crates/paint/src/dynamics.rs index b04589dac7..68a98bacb5 100644 --- a/crates/paint/src/dynamics.rs +++ b/crates/paint/src/dynamics.rs @@ -369,7 +369,8 @@ impl DabBuilder { let f = (1.0 - sd.size.jitter.clamp(0.0, 1.0) * r(stream::SIZE)).max(min); diameter *= f; } - angle += control_angle(sd.angle.control, sd.angle.fade_steps, s) + rand_signed(seed, i, stream::ANGLE) * sd.angle.jitter.clamp(0.0, 1.0) * 180.0; + angle += + control_angle(sd.angle.control, sd.angle.fade_steps, s) + rand_signed(seed, i, stream::ANGLE) * sd.angle.jitter.clamp(0.0, 1.0) * 180.0; roundness = (roundness * scalar(&sd.roundness, s, r(stream::ROUNDNESS))).clamp(0.01, 1.0); if sd.flip_x_jitter && r(stream::FLIP_X) < 0.5 { fx = !fx; @@ -407,17 +408,14 @@ impl DabBuilder { } // Colour. let color = if b.color_dynamics.enabled { - if b.color_dynamics.per_tip { - dynamic_color(b, s, seed, i) - } else { - *self.stroke_color.get_or_insert_with(|| dynamic_color(b, s, seed, 0)) - } + if b.color_dynamics.per_tip { dynamic_color(b, s, seed, i) } else { *self.stroke_color.get_or_insert_with(|| dynamic_color(b, s, seed, 0)) } } else { b.color }; // Texture depth (per tip). let tx = &b.texture; - let depth = if tx.enabled && tx.each_tip { tx.depth.clamp(0.0, 1.0) * scalar(&tx.depth_jitter, s, r(stream::DEPTH)) } else { tx.depth.clamp(0.0, 1.0) }; + let depth = + if tx.enabled && tx.each_tip { tx.depth.clamp(0.0, 1.0) * scalar(&tx.depth_jitter, s, r(stream::DEPTH)) } else { tx.depth.clamp(0.0, 1.0) }; out.push(Dab { center: c, radius, diff --git a/crates/paint/src/lib.rs b/crates/paint/src/lib.rs index b1a48dbd20..f051b86b91 100644 --- a/crates/paint/src/lib.rs +++ b/crates/paint/src/lib.rs @@ -29,7 +29,8 @@ pub mod rng; pub mod tile; pub use brush::{ - BrushPreset, BrushSettings, ColorDynamics, Control, DualBrush, Dynamic, MaskMode, Pattern, PatternStyle, Pose, Scattering, ShapeDynamics, Smoothing, Texture, TipShape, Transfer, + BrushPreset, BrushSettings, ColorDynamics, Control, DualBrush, Dynamic, MaskMode, Pattern, PatternStyle, Pose, Scattering, ShapeDynamics, Smoothing, + Texture, TipShape, Transfer, }; pub use render::{BrushContext, StrokeRenderer, render_stroke}; pub use tile::GrayTile; @@ -96,7 +97,19 @@ pub struct Dab { impl Dab { /// A plain round dab. pub fn round(center: Point, radius: f32, alpha: f32) -> Self { - Self { center, radius, alpha, angle: 0.0, roundness: 1.0, flip_x: false, flip_y: false, opacity: 1.0, color: [0.0, 0.0, 0.0, 1.0], depth: 1.0, index: 0 } + Self { + center, + radius, + alpha, + angle: 0.0, + roundness: 1.0, + flip_x: false, + flip_y: false, + opacity: 1.0, + color: [0.0, 0.0, 0.0, 1.0], + depth: 1.0, + index: 0, + } } } diff --git a/crates/paint/src/presets.rs b/crates/paint/src/presets.rs index 5017924f59..fc65fdd0d8 100644 --- a/crates/paint/src/presets.rs +++ b/crates/paint/src/presets.rs @@ -15,11 +15,21 @@ pub fn builtin() -> Vec { preset("Soft Round", BrushSettings { size: 45.0, hardness: 0.0, ..base.clone() }), preset( "Hard Round Pressure Size", - BrushSettings { size: 30.0, hardness: 1.0, shape_dynamics: ShapeDynamics { enabled: true, size: Dynamic::controlled(Control::PenPressure), ..Default::default() }, ..base.clone() }, + BrushSettings { + size: 30.0, + hardness: 1.0, + shape_dynamics: ShapeDynamics { enabled: true, size: Dynamic::controlled(Control::PenPressure), ..Default::default() }, + ..base.clone() + }, ), preset( "Soft Round Pressure Opacity", - BrushSettings { size: 45.0, hardness: 0.0, transfer: Transfer { enabled: true, opacity: Dynamic::controlled(Control::PenPressure), ..Default::default() }, ..base.clone() }, + BrushSettings { + size: 45.0, + hardness: 0.0, + transfer: Transfer { enabled: true, opacity: Dynamic::controlled(Control::PenPressure), ..Default::default() }, + ..base.clone() + }, ), preset("Airbrush Soft", BrushSettings { size: 80.0, hardness: 0.0, flow: 0.1, spacing: 0.1, build_up: true, build_up_rate: 25.0, ..base.clone() }), preset("Hard Pencil", BrushSettings { size: 3.0, hardness: 1.0, aliased: true, spacing: 0.1, ..base.clone() }), @@ -30,8 +40,18 @@ pub fn builtin() -> Vec { size: 40.0, tip: TipShape::Sampled(chalk_tip(64, 7)), spacing: 0.2, - shape_dynamics: ShapeDynamics { enabled: true, angle: Dynamic::jitter(1.0), size: Dynamic { jitter: 0.15, ..Default::default() }, ..Default::default() }, - texture: Texture { enabled: true, pattern: Pattern::Procedural { style: PatternStyle::Paper, size: 128, seed: 3 }, depth: 0.6, ..Default::default() }, + shape_dynamics: ShapeDynamics { + enabled: true, + angle: Dynamic::jitter(1.0), + size: Dynamic { jitter: 0.15, ..Default::default() }, + ..Default::default() + }, + texture: Texture { + enabled: true, + pattern: Pattern::Procedural { style: PatternStyle::Paper, size: 128, seed: 3 }, + depth: 0.6, + ..Default::default() + }, ..base.clone() }, ), @@ -60,7 +80,11 @@ pub fn builtin() -> Vec { tip: TipShape::Sampled(bristle_tip(64, 5)), spacing: 0.04, shape_dynamics: ShapeDynamics { enabled: true, angle: Dynamic::controlled(Control::Direction), ..Default::default() }, - transfer: Transfer { enabled: true, flow: Dynamic { control: Control::Fade, fade_steps: 400, minimum: 0.1, ..Default::default() }, ..Default::default() }, + transfer: Transfer { + enabled: true, + flow: Dynamic { control: Control::Fade, fade_steps: 400, minimum: 0.1, ..Default::default() }, + ..Default::default() + }, ..base.clone() }, ), @@ -70,7 +94,13 @@ pub fn builtin() -> Vec { size: 60.0, hardness: 0.3, wet_edges: true, - texture: Texture { enabled: true, pattern: Pattern::Procedural { style: PatternStyle::Paper, size: 256, seed: 9 }, depth: 0.35, scale: 1.5, ..Default::default() }, + texture: Texture { + enabled: true, + pattern: Pattern::Procedural { style: PatternStyle::Paper, size: 256, seed: 9 }, + depth: 0.35, + scale: 1.5, + ..Default::default() + }, ..base.clone() }, ), @@ -79,7 +109,13 @@ pub fn builtin() -> Vec { BrushSettings { size: 50.0, hardness: 0.8, - texture: Texture { enabled: true, pattern: Pattern::Procedural { style: PatternStyle::Canvas, size: 64, seed: 1 }, mode: MaskMode::Subtract, depth: 0.7, ..Default::default() }, + texture: Texture { + enabled: true, + pattern: Pattern::Procedural { style: PatternStyle::Canvas, size: 64, seed: 1 }, + mode: MaskMode::Subtract, + depth: 0.7, + ..Default::default() + }, ..base.clone() }, ), @@ -102,7 +138,17 @@ pub fn builtin() -> Vec { size: 50.0, hardness: 0.6, noise: true, - dual_brush: DualBrush { enabled: true, tip: TipShape::Sampled(spatter_tip(48, 23, 20)), size: 30.0, spacing: 0.3, scatter: 1.0, both_axes: true, count: 2, flip: true, ..Default::default() }, + dual_brush: DualBrush { + enabled: true, + tip: TipShape::Sampled(spatter_tip(48, 23, 20)), + size: 30.0, + spacing: 0.3, + scatter: 1.0, + both_axes: true, + count: 2, + flip: true, + ..Default::default() + }, ..base }, ), diff --git a/crates/paint/src/render.rs b/crates/paint/src/render.rs index 08c0010534..588836cc18 100644 --- a/crates/paint/src/render.rs +++ b/crates/paint/src/render.rs @@ -306,7 +306,13 @@ impl CoverageMap { } let ti = trow + (x - tx * COV_TILE) as usize; let c = tile.cov[ti]; - let nv = if use_max { c.max(v * ceil) } else if ceil > c { c + v * (ceil - c) } else { c }; + let nv = if use_max { + c.max(v * ceil) + } else if ceil > c { + c + v * (ceil - c) + } else { + c + }; if nv <= c { continue; } @@ -561,7 +567,14 @@ impl StrokeRenderer { } /// Render a whole stroke onto `target` (one-shot). Returns the damaged rectangle. -pub fn render_stroke(target: &mut Surface, brush: &BrushSettings, points: &[StrokePoint], selection: Option<&Surface>, lock_transparency: bool, zoom: f32) -> Rect { +pub fn render_stroke( + target: &mut Surface, + brush: &BrushSettings, + points: &[StrokePoint], + selection: Option<&Surface>, + lock_transparency: bool, + zoom: f32, +) -> Rect { if points.is_empty() { return Rect::EMPTY; } diff --git a/crates/paint/src/replace.rs b/crates/paint/src/replace.rs index bbc00087a3..53a90835ef 100644 --- a/crates/paint/src/replace.rs +++ b/crates/paint/src/replace.rs @@ -61,7 +61,15 @@ pub struct ReplaceSettings { impl Default for ReplaceSettings { fn default() -> Self { - Self { mode: ReplaceMode::Color, sampling: Sampling::Continuous, limits: Limits::Contiguous, tolerance: 0.3, anti_alias: true, color: [1.0, 0.0, 0.0, 1.0], background: [1.0; 4] } + Self { + mode: ReplaceMode::Color, + sampling: Sampling::Continuous, + limits: Limits::Contiguous, + tolerance: 0.3, + anti_alias: true, + color: [1.0, 0.0, 0.0, 1.0], + background: [1.0; 4], + } } } @@ -231,7 +239,14 @@ mod tests { #[test] fn continuous_sampling_follows_the_cursor_and_swatch_limits() { let mut s = setup(PixelFormat::RGBA8); - let rs = ReplaceSettings { mode: ReplaceMode::Color, sampling: Sampling::Continuous, limits: Limits::Contiguous, tolerance: 0.1, color: [1.0, 0.0, 0.0, 1.0], ..Default::default() }; + let rs = ReplaceSettings { + mode: ReplaceMode::Color, + sampling: Sampling::Continuous, + limits: Limits::Contiguous, + tolerance: 0.1, + color: [1.0, 0.0, 0.0, 1.0], + ..Default::default() + }; apply_color_replacement(&mut s, &stroke(), &rs, None, false); assert!(s.rgba(20, 20)[0] > 0.3 && s.rgba(70, 20)[0] > s.rgba(70, 20)[1], "both halves recoloured {:?} {:?}", s.rgba(20, 20), s.rgba(70, 20)); // Background swatch = white: nothing matches. @@ -251,7 +266,14 @@ mod tests { let st = Stroke { points: vec![StrokePoint::new(20.0, 20.0, 1.0)], ..stroke() }; for (limits, island_changed) in [(Limits::Contiguous, false), (Limits::Discontiguous, true)] { let mut t = s.clone(); - let rs = ReplaceSettings { mode: ReplaceMode::Color, sampling: Sampling::Once, limits, tolerance: 0.05, color: [1.0, 0.0, 0.0, 1.0], ..Default::default() }; + let rs = ReplaceSettings { + mode: ReplaceMode::Color, + sampling: Sampling::Once, + limits, + tolerance: 0.05, + color: [1.0, 0.0, 0.0, 1.0], + ..Default::default() + }; apply_color_replacement(&mut t, &st, &rs, None, false); let changed = (t.rgba(34, 20)[0] - 0.2).abs() > 0.05; assert_eq!(changed, island_changed, "{limits:?}"); diff --git a/crates/paint/src/retouch.rs b/crates/paint/src/retouch.rs index 6c4e404baa..a0da053b00 100644 --- a/crates/paint/src/retouch.rs +++ b/crates/paint/src/retouch.rs @@ -365,7 +365,10 @@ mod tests { fn coverage_composite_copies_exactly_at_full_hardness() { for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F, PixelFormat::CMYKA8, PixelFormat::GRAYA8] { let mut s = Surface::new(fmt); - s.fill_rect(Rect::new(0, 0, 64, 64), &vec![0.2; fmt.channels()].iter().enumerate().map(|(i, v)| if i + 1 == fmt.channels() { 1.0 } else { *v }).collect::>()); + s.fill_rect( + Rect::new(0, 0, 64, 64), + &vec![0.2; fmt.channels()].iter().enumerate().map(|(i, v)| if i + 1 == fmt.channels() { 1.0 } else { *v }).collect::>(), + ); let st = stroke(&[(20.0, 20.0)], 10.0, 1.0); let (r, cov) = stroke_coverage(&st); let mut paint = Region::new(r, fmt.channels()); diff --git a/crates/psd/src/blend.rs b/crates/psd/src/blend.rs index d8d6d30627..a968c10c8b 100644 --- a/crates/psd/src/blend.rs +++ b/crates/psd/src/blend.rs @@ -108,11 +108,7 @@ impl BlendMode { /// Parses a stored key; unknown keys map to [`BlendMode::Unknown`]. pub fn from_key(key: [u8; 4]) -> Self { - BlendMode::ALL - .iter() - .copied() - .find(|m| m.key() == key) - .unwrap_or(BlendMode::Unknown(key)) + BlendMode::ALL.iter().copied().find(|m| m.key() == key).unwrap_or(BlendMode::Unknown(key)) } } diff --git a/crates/psd/src/builder.rs b/crates/psd/src/builder.rs index b290c55dd9..7343bcf479 100644 --- a/crates/psd/src/builder.rs +++ b/crates/psd/src/builder.rs @@ -78,12 +78,8 @@ impl PixelData { fn alpha_is_opaque(&self, n: usize) -> bool { let spp = self.spp(); match self { - PixelData::Rgba8(v) | PixelData::GrayA8(v) | PixelData::Cmyka8(v) => { - (0..n).all(|i| v[i * spp + spp - 1] == 255) - } - PixelData::Rgba16(v) | PixelData::GrayA16(v) | PixelData::Cmyka16(v) => { - (0..n).all(|i| v[i * spp + spp - 1] == 65535) - } + PixelData::Rgba8(v) | PixelData::GrayA8(v) | PixelData::Cmyka8(v) => (0..n).all(|i| v[i * spp + spp - 1] == 255), + PixelData::Rgba16(v) | PixelData::GrayA16(v) | PixelData::Cmyka16(v) => (0..n).all(|i| v[i * spp + spp - 1] == 65535), } } } @@ -282,12 +278,7 @@ impl PsdBuilder { fn check_pixels(&self, p: &PixelData, n: usize) -> Result<()> { if p.mode_depth() != (self.color_mode, self.depth) { - return Err(PsdError::invalid(format!( - "pixel format {:?} does not match document {:?}/{}", - p.mode_depth(), - self.color_mode, - self.depth - ))); + return Err(PsdError::invalid(format!("pixel format {:?} does not match document {:?}/{}", p.mode_depth(), self.color_mode, self.depth))); } if p.len() != n * p.spp() { return Err(PsdError::invalid(format!("pixel buffer has {} samples, expected {}", p.len(), n * p.spp()))); @@ -321,11 +312,7 @@ impl PsdBuilder { if m.data.len() != mw * mh { return Err(PsdError::invalid("mask buffer size mismatch")); } - let plane: Vec = if self.depth == 16 { - m.data.iter().flat_map(|&v| (u16::from(v) * 257).to_be_bytes()).collect() - } else { - m.data.clone() - }; + let plane: Vec = if self.depth == 16 { m.data.iter().flat_map(|&v| (u16::from(v) * 257).to_be_bytes()).collect() } else { m.data.clone() }; channels.push(self.channel(-2, &plane, mw, mh)?); let flags = if m.disabled { LayerMask::FLAG_DISABLED } else { 0 }; mask = MaskData::Mask(LayerMask::new(m.rect, m.default_color, flags)); diff --git a/crates/psd/src/compression.rs b/crates/psd/src/compression.rs index e666f21168..b2d6d55c28 100644 --- a/crates/psd/src/compression.rs +++ b/crates/psd/src/compression.rs @@ -218,11 +218,7 @@ pub fn decode_planes(compression: Compression, data: &[u8], layout: &PlaneLayout pub fn encode_planes(compression: Compression, decoded: &[u8], layout: &PlaneLayout) -> Result> { let total = layout.decoded_len()?; if decoded.len() != total { - return Err(PsdError::invalid(format!( - "decoded buffer has {} bytes, layout requires {}", - decoded.len(), - total - ))); + return Err(PsdError::invalid(format!("decoded buffer has {} bytes, layout requires {}", decoded.len(), total))); } match compression { Compression::Raw => Ok(decoded.to_vec()), @@ -246,10 +242,7 @@ pub(crate) fn validate_planes(compression: Compression, data: &[u8], layout: &Pl match compression { Compression::Raw => { if (data.len() as u64) < total { - return Err(PsdError::UnexpectedEof { - offset: data.len(), - needed: usize::try_from(total - data.len() as u64).unwrap_or(usize::MAX), - }); + return Err(PsdError::UnexpectedEof { offset: data.len(), needed: usize::try_from(total - data.len() as u64).unwrap_or(usize::MAX) }); } Ok(()) } @@ -262,10 +255,7 @@ pub(crate) fn validate_planes(compression: Compression, data: &[u8], layout: &Pl sum += read_count(&mut r, layout.version)? as u64; } if sum > r.remaining() as u64 { - return Err(PsdError::UnexpectedEof { - offset: data.len(), - needed: usize::try_from(sum - r.remaining() as u64).unwrap_or(usize::MAX), - }); + return Err(PsdError::UnexpectedEof { offset: data.len(), needed: usize::try_from(sum - r.remaining() as u64).unwrap_or(usize::MAX) }); } Ok(()) } @@ -355,9 +345,7 @@ pub fn zip_compress(data: &[u8]) -> Vec { pub fn zip_decompress(data: &[u8], expected: usize) -> Result> { let dec = flate2::read::ZlibDecoder::new(data); let mut out = Vec::with_capacity(expected.min(data.len().saturating_mul(1032))); - dec.take(expected as u64) - .read_to_end(&mut out) - .map_err(|e| PsdError::Decompress(e.to_string()))?; + dec.take(expected as u64).read_to_end(&mut out).map_err(|e| PsdError::Decompress(e.to_string()))?; if out.len() < expected { return Err(PsdError::Decompress(format!("zlib stream produced {} of {} bytes", out.len(), expected))); } diff --git a/crates/psd/src/descriptor.rs b/crates/psd/src/descriptor.rs index 50358861aa..a2eab7ae52 100644 --- a/crates/psd/src/descriptor.rs +++ b/crates/psd/src/descriptor.rs @@ -60,11 +60,7 @@ impl Id { /// [`Id::Str`]. pub fn new(s: &str) -> Self { let b = s.as_bytes(); - if b.len() == 4 { - Id::Code([b[0], b[1], b[2], b[3]]) - } else { - Id::Str(b.to_vec()) - } + if b.len() == 4 { Id::Code([b[0], b[1], b[2], b[3]]) } else { Id::Str(b.to_vec()) } } /// The identifier's bytes. pub fn as_bytes(&self) -> &[u8] { @@ -79,11 +75,7 @@ impl Id { } pub(crate) fn read(r: &mut Reader<'_>) -> Result { let n = r.u32()?; - if n == 0 { - Ok(Id::Code(r.array()?)) - } else { - Ok(Id::Str(r.bytes_u64(u64::from(n))?.to_vec())) - } + if n == 0 { Ok(Id::Code(r.array()?)) } else { Ok(Id::Str(r.bytes_u64(u64::from(n))?.to_vec())) } } pub(crate) fn write(&self, out: &mut Vec) { match self { @@ -432,10 +424,7 @@ fn read_typed_value(r: &mut Reader<'_>, ty: &[u8; 4], depth: usize) -> Result { - return Err(PsdError::Unsupported(format!( - "descriptor OSType {:?}", - String::from_utf8_lossy(other) - ))); + return Err(PsdError::Unsupported(format!("descriptor OSType {:?}", String::from_utf8_lossy(other)))); } }) } @@ -451,10 +440,7 @@ fn read_reference_item(r: &mut Reader<'_>) -> Result { b"indx" => ReferenceItem::Index(r.u32()?), b"name" => ReferenceItem::Name { class: Class::read(r)?, name: UnicodeString::read(r)? }, other => { - return Err(PsdError::Unsupported(format!( - "reference item type {:?}", - String::from_utf8_lossy(other) - ))); + return Err(PsdError::Unsupported(format!("reference item type {:?}", String::from_utf8_lossy(other)))); } }) } @@ -609,8 +595,7 @@ mod tests { "ObAr", Value::ObjectArray(ObjectArray { prefix: 16, - body: Descriptor::new("rationalPoint") - .with("Hrzn", Value::UnitFloats { unit: *b"#Pxl", values: vec![1.0, 2.0] }), + body: Descriptor::new("rationalPoint").with("Hrzn", Value::UnitFloats { unit: *b"#Pxl", values: vec![1.0, 2.0] }), }), ) } @@ -732,10 +717,7 @@ pub(crate) mod proptests { use proptest::prelude::*; fn arb_id() -> impl Strategy { - prop_oneof![ - any::<[u8; 4]>().prop_map(Id::Code), - proptest::collection::vec(any::(), 1..12).prop_map(Id::Str), - ] + prop_oneof![any::<[u8; 4]>().prop_map(Id::Code), proptest::collection::vec(any::(), 1..12).prop_map(Id::Str),] } fn arb_ustr() -> impl Strategy { proptest::collection::vec(any::(), 0..8).prop_map(UnicodeString) @@ -747,8 +729,7 @@ pub(crate) mod proptests { prop_oneof![ (arb_class(), arb_id()).prop_map(|(class, key)| ReferenceItem::Property { class, key }), arb_class().prop_map(ReferenceItem::Class), - (arb_class(), arb_id(), arb_id()) - .prop_map(|(class, type_id, value)| ReferenceItem::Enumerated { class, type_id, value }), + (arb_class(), arb_id(), arb_id()).prop_map(|(class, type_id, value)| ReferenceItem::Enumerated { class, type_id, value }), (arb_class(), any::()).prop_map(|(class, offset)| ReferenceItem::Offset { class, offset }), any::().prop_map(ReferenceItem::Identifier), any::().prop_map(ReferenceItem::Index), @@ -762,8 +743,7 @@ pub(crate) mod proptests { prop_oneof![ finite().prop_map(Value::Double), (any::<[u8; 4]>(), finite()).prop_map(|(unit, value)| Value::UnitFloat { unit, value }), - (any::<[u8; 4]>(), proptest::collection::vec(finite(), 0..4)) - .prop_map(|(unit, values)| Value::UnitFloats { unit, values }), + (any::<[u8; 4]>(), proptest::collection::vec(finite(), 0..4)).prop_map(|(unit, values)| Value::UnitFloats { unit, values }), arb_ustr().prop_map(Value::Text), (arb_id(), arb_id()).prop_map(|(type_id, value)| Value::Enumerated { type_id, value }), any::().prop_map(Value::Integer), @@ -784,19 +764,15 @@ pub(crate) mod proptests { proptest::collection::vec(inner.clone(), 0..4).prop_map(Value::List), (arb_ustr(), arb_id(), proptest::collection::vec((arb_id(), inner.clone()), 0..4)) .prop_map(|(name, id, items)| Value::Descriptor(Descriptor { name, class_id: id, items })), - (arb_ustr(), arb_id(), proptest::collection::vec((arb_id(), inner.clone()), 0..3)).prop_map( - |(name, id, items)| Value::GlobalObject(Descriptor { name, class_id: id, items }) - ), - (any::(), arb_id(), proptest::collection::vec((arb_id(), inner), 0..3)).prop_map( - |(prefix, id, items)| Value::ObjectArray(ObjectArray { - prefix, - body: Descriptor { name: UnicodeString::default(), class_id: id, items } - }) - ), + (arb_ustr(), arb_id(), proptest::collection::vec((arb_id(), inner.clone()), 0..3)) + .prop_map(|(name, id, items)| Value::GlobalObject(Descriptor { name, class_id: id, items })), + (any::(), arb_id(), proptest::collection::vec((arb_id(), inner), 0..3)).prop_map(|(prefix, id, items)| Value::ObjectArray(ObjectArray { + prefix, + body: Descriptor { name: UnicodeString::default(), class_id: id, items } + })), ] }); - (arb_ustr(), arb_id(), proptest::collection::vec((arb_id(), value), 0..6)) - .prop_map(|(name, id, items)| Descriptor { name, class_id: id, items }) + (arb_ustr(), arb_id(), proptest::collection::vec((arb_id(), value), 0..6)).prop_map(|(name, id, items)| Descriptor { name, class_id: id, items }) } proptest! { diff --git a/crates/psd/src/file.rs b/crates/psd/src/file.rs index c907c4690f..529eeb2d11 100644 --- a/crates/psd/src/file.rs +++ b/crates/psd/src/file.rs @@ -126,21 +126,10 @@ impl PsdFile { { let b = global_blocks.remove(i); layer_info = Some(info); - layer_info_placement = - LayerInfoPlacement::GlobalBlock { index: i, signature: b.signature, key: b.key, padding: b.padding }; + layer_info_placement = LayerInfoPlacement::GlobalBlock { index: i, signature: b.signature, key: b.key, padding: b.padding }; } let image_data = ImageData::read(&mut r, &header)?; - Ok(PsdFile { - header, - color_mode_data, - resources, - layer_info, - layer_info_placement, - global_layer_mask, - global_blocks, - layer_mask_trailing, - image_data, - }) + Ok(PsdFile { header, color_mode_data, resources, layer_info, layer_info_placement, global_layer_mask, global_blocks, layer_mask_trailing, image_data }) } /// Serializes the file. @@ -157,10 +146,8 @@ impl PsdFile { // An entirely empty section is written as a zero length. (A section // containing only a zero layer-info length cannot be distinguished in // the model and is normalized to this form.) - let section_empty = self.layer_info.is_none() - && self.global_layer_mask.is_none() - && self.global_blocks.is_empty() - && self.layer_mask_trailing.is_empty(); + let section_empty = + self.layer_info.is_none() && self.global_layer_mask.is_none() && self.global_blocks.is_empty() && self.layer_mask_trailing.is_empty(); let at = out.begin_len(psb); if !section_empty { let li = out.begin_len(psb); diff --git a/crates/psd/src/image_data.rs b/crates/psd/src/image_data.rs index 515b2f9251..0f05eed909 100644 --- a/crates/psd/src/image_data.rs +++ b/crates/psd/src/image_data.rs @@ -43,9 +43,7 @@ impl ImageData { pub fn decode_channel(&self, header: &Header, index: usize) -> Result> { let all = self.decode(header)?; let plane = header.row_bytes() * header.height as usize; - all.get(index * plane..(index + 1) * plane) - .map(<[u8]>::to_vec) - .ok_or_else(|| crate::error::PsdError::invalid(format!("no merged channel {index}"))) + all.get(index * plane..(index + 1) * plane).map(<[u8]>::to_vec).ok_or_else(|| crate::error::PsdError::invalid(format!("no merged channel {index}"))) } pub(crate) fn read(r: &mut Reader<'_>, header: &Header) -> Result { diff --git a/crates/psd/src/io.rs b/crates/psd/src/io.rs index 31e7ed76f9..52a500da23 100644 --- a/crates/psd/src/io.rs +++ b/crates/psd/src/io.rs @@ -33,10 +33,7 @@ impl<'a> Reader<'a> { pub(crate) fn bytes(&mut self, n: usize) -> Result<&'a [u8]> { if n > self.remaining() { - return Err(PsdError::UnexpectedEof { - offset: self.pos, - needed: n - self.remaining(), - }); + return Err(PsdError::UnexpectedEof { offset: self.pos, needed: n - self.remaining() }); } let s = &self.data[self.pos..self.pos + n]; self.pos += n; @@ -91,11 +88,7 @@ impl<'a> Reader<'a> { /// Reads a length field that is 4 bytes, or 8 bytes when `long`. pub(crate) fn len_field(&mut self, long: bool) -> Result { - if long { - self.u64() - } else { - Ok(u64::from(self.u32()?)) - } + if long { self.u64() } else { Ok(u64::from(self.u32()?)) } } /// Returns `Err` unless `count * min_item_size` bytes remain. Used to @@ -103,10 +96,7 @@ impl<'a> Reader<'a> { pub(crate) fn check_count(&self, count: u64, min_item_size: u64) -> Result<()> { let need = count.saturating_mul(min_item_size); if need > self.remaining() as u64 { - return Err(PsdError::UnexpectedEof { - offset: self.pos, - needed: usize::try_from(need - self.remaining() as u64).unwrap_or(usize::MAX), - }); + return Err(PsdError::UnexpectedEof { offset: self.pos, needed: usize::try_from(need - self.remaining() as u64).unwrap_or(usize::MAX) }); } Ok(()) } @@ -216,10 +206,7 @@ pub(crate) fn decode_legacy_name(b: &[u8]) -> String { /// Encodes a name for a legacy Pascal field: non-ASCII chars become `?`. pub(crate) fn encode_legacy_name(s: &str) -> Vec { - s.chars() - .map(|c| if c.is_ascii() && !c.is_ascii_control() { c as u8 } else { b'?' }) - .take(255) - .collect() + s.chars().map(|c| if c.is_ascii() && !c.is_ascii_control() { c as u8 } else { b'?' }).take(255).collect() } /// Reads a Photoshop Unicode string: u32 code-unit count + UTF-16BE units. @@ -291,14 +278,7 @@ mod tests { #[test] fn pascal_padding() { - for (name, align, total) in [ - (&b""[..], 2, 2), - (&b"a"[..], 2, 2), - (&b"ab"[..], 2, 4), - (&b""[..], 4, 4), - (&b"abc"[..], 4, 4), - (&b"abcd"[..], 4, 8), - ] { + for (name, align, total) in [(&b""[..], 2, 2), (&b"a"[..], 2, 2), (&b"ab"[..], 2, 4), (&b""[..], 4, 4), (&b"abc"[..], 4, 4), (&b"abcd"[..], 4, 8)] { let mut v = Vec::new(); write_pascal(&mut v, name, align); assert_eq!(v.len(), total, "{name:?} align {align}"); diff --git a/crates/psd/src/layer.rs b/crates/psd/src/layer.rs index 7ea1f3096e..cdd8255a81 100644 --- a/crates/psd/src/layer.rs +++ b/crates/psd/src/layer.rs @@ -35,12 +35,7 @@ pub struct Rect { impl Rect { /// Rectangle from origin and size. pub fn from_xywh(left: i32, top: i32, width: u32, height: u32) -> Self { - Rect { - top, - left, - bottom: top.saturating_add(height.min(i32::MAX as u32) as i32), - right: left.saturating_add(width.min(i32::MAX as u32) as i32), - } + Rect { top, left, bottom: top.saturating_add(height.min(i32::MAX as u32) as i32), right: left.saturating_add(width.min(i32::MAX as u32) as i32) } } /// Width (may be negative for malformed data). pub fn width(&self) -> i64 { @@ -219,11 +214,7 @@ impl LayerMask { } else { None }; - let real = if r.remaining() >= 18 { - Some(RealMask { flags: r.u8()?, background: r.u8()?, rect: Rect::read(&mut r)? }) - } else { - None - }; + let real = if r.remaining() >= 18 { Some(RealMask { flags: r.u8()?, background: r.u8()?, rect: Rect::read(&mut r)? }) } else { None }; Ok(LayerMask { rect, default_color, flags, parameters, real, trailing: r.peek_rest().to_vec() }) } @@ -298,10 +289,7 @@ impl BlendingRanges { } /// Parsed entries (incomplete trailing bytes are ignored). pub fn ranges(&self) -> Vec { - self.data - .chunks_exact(8) - .map(|c| BlendRange { source: [c[0], c[1], c[2], c[3]], dest: [c[4], c[5], c[6], c[7]] }) - .collect() + self.data.chunks_exact(8).map(|c| BlendRange { source: [c[0], c[1], c[2], c[3]], dest: [c[4], c[5], c[6], c[7]] }).collect() } } @@ -321,15 +309,7 @@ pub struct ChannelData { impl ChannelData { /// Encodes planar samples (`height × row_bytes`). - pub fn encode( - id: i16, - compression: Compression, - decoded: &[u8], - width: usize, - height: usize, - depth: u16, - version: Version, - ) -> Result { + pub fn encode(id: i16, compression: Compression, decoded: &[u8], width: usize, height: usize, depth: u16, version: Version) -> Result { let layout = PlaneLayout { planes: 1, width, height, depth, version }; Ok(ChannelData { id, compression: Some(compression), data: encode_planes(compression, decoded, &layout)? }) } @@ -351,15 +331,7 @@ impl ChannelData { /// Replaces the channel contents, re-encoding with `compression`. #[allow(clippy::too_many_arguments)] - pub fn set_decoded( - &mut self, - compression: Compression, - decoded: &[u8], - width: usize, - height: usize, - depth: u16, - version: Version, - ) -> Result<()> { + pub fn set_decoded(&mut self, compression: Compression, decoded: &[u8], width: usize, height: usize, depth: u16, version: Version) -> Result<()> { *self = Self::encode(self.id, compression, decoded, width, height, depth, version)?; Ok(()) } @@ -549,23 +521,7 @@ impl LayerRecord { let blending_ranges = BlendingRanges { data: x.bytes_u64(u64::from(br_len))?.to_vec() }; let name = read_pascal(&mut x, 4)?; let (blocks, extra_trailing) = read_blocks(&mut x, version)?; - Ok(( - LayerRecord { - rect, - channels, - blend_mode, - opacity, - clipping, - flags, - filler, - mask, - blending_ranges, - name, - blocks, - extra_trailing, - }, - lens, - )) + Ok((LayerRecord { rect, channels, blend_mode, opacity, clipping, flags, filler, mask, blending_ranges, name, blocks, extra_trailing }, lens)) } fn write(&self, out: &mut Vec, version: Version) -> Result<()> { @@ -772,11 +728,7 @@ mod tests { #[test] fn mask_variants_roundtrip() { let base = LayerMask::new(Rect::from_xywh(1, 2, 3, 4), 0, 0); - let with_real = LayerMask { - real: Some(RealMask { flags: 1, background: 255, rect: Rect::from_xywh(0, 0, 9, 9) }), - trailing: vec![], - ..base.clone() - }; + let with_real = LayerMask { real: Some(RealMask { flags: 1, background: 255, rect: Rect::from_xywh(0, 0, 9, 9) }), trailing: vec![], ..base.clone() }; let with_params = LayerMask { flags: LayerMask::FLAG_PARAMETERS, parameters: Some(MaskParameters { @@ -808,11 +760,7 @@ mod tests { let mut out = Vec::new(); LayerMask::new(Rect::default(), 0, 0).write(&mut out); assert_eq!(out.len(), 20); - let m = LayerMask { - real: Some(RealMask::default()), - trailing: vec![], - ..LayerMask::new(Rect::default(), 0, 0) - }; + let m = LayerMask { real: Some(RealMask::default()), trailing: vec![], ..LayerMask::new(Rect::default(), 0, 0) }; let mut out = Vec::new(); m.write(&mut out); assert_eq!(out.len(), 36); @@ -905,11 +853,7 @@ mod tests { #[test] fn unknown_blend_and_blocks_passthrough() { - let rec = LayerRecord { - blend_mode: BlendMode::Unknown(*b"wxyz"), - blocks: vec![TaggedBlock::new(*b"Zzzz", vec![1, 2, 3, 4, 5])], - ..Default::default() - }; + let rec = LayerRecord { blend_mode: BlendMode::Unknown(*b"wxyz"), blocks: vec![TaggedBlock::new(*b"Zzzz", vec![1, 2, 3, 4, 5])], ..Default::default() }; rt(&LayerInfo { layers: vec![rec], ..Default::default() }, Version::Psd); } } diff --git a/crates/psd/src/lib.rs b/crates/psd/src/lib.rs index 5d401c247c..fcbe9b546d 100644 --- a/crates/psd/src/lib.rs +++ b/crates/psd/src/lib.rs @@ -68,9 +68,7 @@ pub use error::{PsdError, Result}; pub use file::{GlobalLayerMask, LayerInfoPlacement, PsdFile}; pub use header::{ColorMode, Header, Version}; pub use image_data::ImageData; -pub use layer::{ - BlendingRanges, ChannelData, LayerFlags, LayerInfo, LayerMask, LayerRecord, MaskData, MaskParameters, RealMask, Rect, -}; +pub use layer::{BlendingRanges, ChannelData, LayerFlags, LayerInfo, LayerMask, LayerRecord, MaskData, MaskParameters, RealMask, Rect}; pub use pixels::{GrayImage, Layer, RgbaImage}; pub use resources::{ImageResource, ResolutionInfo, ResourceData}; pub use tagged::{BlockData, SectionDivider, SectionType, TaggedBlock}; diff --git a/crates/psd/src/path.rs b/crates/psd/src/path.rs index 1ed24ade3b..e94e4d1cee 100644 --- a/crates/psd/src/path.rs +++ b/crates/psd/src/path.rs @@ -163,12 +163,7 @@ impl PathData { remaining = remaining.saturating_sub(1); let pt = |i: usize| (fixed_to_f64(r.i32_at(i * 8 + 4)), fixed_to_f64(r.i32_at(i * 8))); if let Some(s) = out.last_mut() { - s.knots.push(PsdKnot { - linked: matches!(r.selector, 1 | 4), - pre: pt(0), - anchor: pt(1), - post: pt(2), - }); + s.knots.push(PsdKnot { linked: matches!(r.selector, 1 | 4), pre: pt(0), anchor: pt(1), post: pt(2) }); } } } @@ -178,10 +173,7 @@ impl PathData { /// Builds a record list: a fill rule record, an initial fill record, /// then each subpath's length record and knots. pub fn from_subpaths(subpaths: &[PsdSubpath], initial_fill: bool) -> Self { - let mut records = vec![ - PathRecord { selector: selector::FILL_RULE, data: [0; 24] }, - PathRecord { selector: selector::INITIAL_FILL, data: [0; 24] }, - ]; + let mut records = vec![PathRecord { selector: selector::FILL_RULE, data: [0; 24] }, PathRecord { selector: selector::INITIAL_FILL, data: [0; 24] }]; records[1].data[..2].copy_from_slice(&u16::from(initial_fill).to_be_bytes()); for s in subpaths { let mut data = [0u8; 24]; @@ -263,11 +255,7 @@ mod tests { PsdKnot { linked: false, pre: (0.5, 0.75), anchor: (0.5, 0.75), post: (0.5, 0.75) }, ], }, - PsdSubpath { - closed: false, - operation: 2, - knots: vec![PsdKnot { linked: false, pre: (-0.5, 1.5), anchor: (0.0, 1.0), post: (0.0625, 0.9375) }], - }, + PsdSubpath { closed: false, operation: 2, knots: vec![PsdKnot { linked: false, pre: (-0.5, 1.5), anchor: (0.0, 1.0), post: (0.0625, 0.9375) }] }, ] } diff --git a/crates/psd/src/patterns.rs b/crates/psd/src/patterns.rs index 53de5b1065..61c1aa85a3 100644 --- a/crates/psd/src/patterns.rs +++ b/crates/psd/src/patterns.rs @@ -163,7 +163,13 @@ fn read_pattern(r: &mut Rd) -> Result { depth = d; let (cw, ch) = ((cr - cl).max(0) as usize, (cb - ct).max(0) as usize); let layout = PlaneLayout { planes: 1, width: cw, height: ch, depth: d, version: Version::Psd }; - let compression = if comp == 1 { Compression::Rle } else if comp == 0 { Compression::Raw } else { Compression::Unknown(u16::from(comp)) }; + let compression = if comp == 1 { + Compression::Rle + } else if comp == 0 { + Compression::Raw + } else { + Compression::Unknown(u16::from(comp)) + }; let plane = decode_planes(compression, data, &layout)?; // Place a channel rect smaller than the pattern rect into a full plane. let bpp = (usize::from(d) / 8).max(1); @@ -230,7 +236,8 @@ fn write_pattern(p: &PsdPattern, out: &mut Vec) -> Result<()> { body.extend_from_slice(&SLOTS.to_be_bytes()); let layout = PlaneLayout { planes: 1, width: p.width as usize, height: p.height as usize, depth: p.depth, version: Version::Psd }; let array = |plane: &[u8], body: &mut Vec| -> Result<()> { - let (comp, data) = if p.depth == 8 { (1u8, encode_planes(Compression::Rle, plane, &layout)?) } else { (0u8, encode_planes(Compression::Raw, plane, &layout)?) }; + let (comp, data) = + if p.depth == 8 { (1u8, encode_planes(Compression::Rle, plane, &layout)?) } else { (0u8, encode_planes(Compression::Raw, plane, &layout)?) }; body.extend_from_slice(&1u32.to_be_bytes()); body.extend_from_slice(&((23 + data.len()) as u32).to_be_bytes()); body.extend_from_slice(&u32::from(p.depth).to_be_bytes()); diff --git a/crates/psd/src/pixels.rs b/crates/psd/src/pixels.rs index 23ff25c4e5..94192c38bf 100644 --- a/crates/psd/src/pixels.rs +++ b/crates/psd/src/pixels.rs @@ -50,10 +50,7 @@ pub fn plane_to_u8(bytes: &[u8], depth: u16, width: usize, height: usize) -> Res 1 => unpack_bits(bytes, width, height).into_iter().map(|b| if b == 1 { 0 } else { 255 }).collect(), 8 => bytes.to_vec(), 16 => samples_u16(bytes).into_iter().map(|v| ((u32::from(v) * 255 + 32767) / 65535) as u8).collect(), - 32 => samples_f32(bytes) - .into_iter() - .map(|v| if v.is_nan() { 0 } else { (v.clamp(0.0, 1.0) * 255.0).round() as u8 }) - .collect(), + 32 => samples_f32(bytes).into_iter().map(|v| if v.is_nan() { 0 } else { (v.clamp(0.0, 1.0) * 255.0).round() as u8 }).collect(), d => return Err(PsdError::Unsupported(format!("depth {d}"))), }) } @@ -90,13 +87,7 @@ pub struct GrayImage { pub default_value: u8, } -fn interleave( - mode: ColorMode, - color: &[Vec], - alpha: Option<&[u8]>, - palette: &[u8], - n: usize, -) -> Result> { +fn interleave(mode: ColorMode, color: &[Vec], alpha: Option<&[u8]>, palette: &[u8], n: usize) -> Result> { let mut out = vec![0u8; n * 4]; for i in 0..n { let (r, g, b) = match mode { diff --git a/crates/psd/src/resources.rs b/crates/psd/src/resources.rs index cbc7ef1fe8..8a1966d07f 100644 --- a/crates/psd/src/resources.rs +++ b/crates/psd/src/resources.rs @@ -185,9 +185,7 @@ impl ImageResource { if !d.len().is_multiple_of(2) { Err(PsdError::invalid("layer group info has odd length")) } else { - Ok(ResourceData::LayerGroupInfo( - d.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect(), - )) + Ok(ResourceData::LayerGroupInfo(d.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect())) } } ids::THUMBNAIL | ids::THUMBNAIL_PS4 => Ok(ResourceData::Thumbnail(d)), diff --git a/crates/psd/src/slices.rs b/crates/psd/src/slices.rs index 65fe9e6d90..dadfb95af3 100644 --- a/crates/psd/src/slices.rs +++ b/crates/psd/src/slices.rs @@ -234,7 +234,25 @@ impl SlicesResource { let horizontal_align = r.u32()?; let vertical_align = r.u32()?; let color = [r.u8()?, r.u8()?, r.u8()?, r.u8()?]; - slices.push(SliceRecord { id, group_id, origin, layer_id, name, kind, rect, url, target, message, alt, cell_text_is_html, cell_text, horizontal_align, vertical_align, color, outsets: [0; 4] }); + slices.push(SliceRecord { + id, + group_id, + origin, + layer_id, + name, + kind, + rect, + url, + target, + message, + alt, + cell_text_is_html, + cell_text, + horizontal_align, + vertical_align, + color, + outsets: [0; 4], + }); } // Optional trailing descriptor (Photoshop 7+): per-slice extras such as outsets. if r.remaining() >= 4 @@ -368,7 +386,24 @@ mod tests { group_name: "site".into(), slices: vec![ SliceRecord { id: 0, origin: 0, kind: 1, rect: [0, 0, 100, 10], ..Default::default() }, - SliceRecord { id: 1, group_id: 0, origin: 2, name: "logo".into(), kind: 1, rect: [20, 10, 60, 30], url: "https://example.org/".into(), target: "_blank".into(), message: "hi".into(), alt: "Logo".into(), cell_text_is_html: true, cell_text: "x".into(), horizontal_align: 1, vertical_align: 2, color: [255, 10, 20, 30], ..Default::default() }, + SliceRecord { + id: 1, + group_id: 0, + origin: 2, + name: "logo".into(), + kind: 1, + rect: [20, 10, 60, 30], + url: "https://example.org/".into(), + target: "_blank".into(), + message: "hi".into(), + alt: "Logo".into(), + cell_text_is_html: true, + cell_text: "x".into(), + horizontal_align: 1, + vertical_align: 2, + color: [255, 10, 20, 30], + ..Default::default() + }, SliceRecord { id: 2, origin: 1, layer_id: Some(7), kind: 0, rect: [5, 40, 25, 70], outsets: [1, 2, 3, 4], ..Default::default() }, ], } diff --git a/crates/psd/src/tagged.rs b/crates/psd/src/tagged.rs index ce67c45d77..b3d141bd5f 100644 --- a/crates/psd/src/tagged.rs +++ b/crates/psd/src/tagged.rs @@ -12,9 +12,7 @@ use crate::header::Version; use crate::io::{Reader, WriteExt}; /// Keys whose length field is 8 bytes in PSB files. -pub const PSB_LONG_KEYS: [&[u8; 4]; 13] = [ - b"LMsk", b"Lr16", b"Lr32", b"Layr", b"Mt16", b"Mt32", b"Mtrn", b"Alph", b"FMsk", b"lnk2", b"FEid", b"FXid", b"PxSD", -]; +pub const PSB_LONG_KEYS: [&[u8; 4]; 13] = [b"LMsk", b"Lr16", b"Lr32", b"Layr", b"Mt16", b"Mt32", b"Mtrn", b"Alph", b"FMsk", b"lnk2", b"FEid", b"FXid", b"PxSD"]; /// Returns `true` if `key` uses an 8-byte length in `version`. pub fn uses_long_length(version: Version, key: &[u8; 4]) -> bool { @@ -290,12 +288,7 @@ pub(crate) fn read_blocks(r: &mut Reader<'_>, version: Version) -> Result<(Vec &'static [u16] { } /// All color modes covered. -pub const MODES: [ColorMode; 8] = [ - ColorMode::Bitmap, - ColorMode::Grayscale, - ColorMode::Indexed, - ColorMode::Rgb, - ColorMode::Cmyk, - ColorMode::Lab, - ColorMode::Multichannel, - ColorMode::Duotone, -]; +pub const MODES: [ColorMode; 8] = + [ColorMode::Bitmap, ColorMode::Grayscale, ColorMode::Indexed, ColorMode::Rgb, ColorMode::Cmyk, ColorMode::Lab, ColorMode::Multichannel, ColorMode::Duotone]; fn color_mode_data(mode: ColorMode) -> Vec { match mode { @@ -162,16 +152,10 @@ fn raster_layer(g: LayerGen<'_>) -> LayerRecord { let mut mask_data = MaskData::None; if let Some(m) = g.mask { let (mw, mh) = m.rect.size().unwrap_or((0, 0)); - channels.push( - ChannelData::encode(-2, g.compression, &pattern_plane(mw, mh, depth, g.seed + 99), mw, mh, depth, v) - .expect("encode"), - ); + channels.push(ChannelData::encode(-2, g.compression, &pattern_plane(mw, mh, depth, g.seed + 99), mw, mh, depth, v).expect("encode")); if let Some(real) = m.real { let (rw, rh) = real.rect.size().unwrap_or((0, 0)); - channels.push( - ChannelData::encode(-3, g.compression, &pattern_plane(rw, rh, depth, g.seed + 7), rw, rh, depth, v) - .expect("encode"), - ); + channels.push(ChannelData::encode(-3, g.compression, &pattern_plane(rw, rh, depth, g.seed + 7), rw, rh, depth, v).expect("encode")); } mask_data = MaskData::Mask(m); } @@ -195,9 +179,7 @@ fn raster_layer(g: LayerGen<'_>) -> LayerRecord { fn group_record(header: &Header, name: &str, kind: SectionType, blend: BlendMode, id: u32) -> LayerRecord { let cc = mode_channels(header.color_mode) as i16; - let channels = (-1..cc) - .map(|c| ChannelData { id: c, compression: Some(Compression::Raw), data: Vec::new() }) - .collect(); + let channels = (-1..cc).map(|c| ChannelData { id: c, compression: Some(Compression::Raw), data: Vec::new() }).collect(); let lsct = if kind == SectionType::BoundingDivider { TaggedBlock::section_divider(kind, None, None) } else { @@ -296,13 +278,7 @@ pub fn layered(version: Version, mode: ColorMode, depth: u16, compression: Compr rect: Rect::from_xywh(1, 1, 4, 3), default_color: 255, flags: LayerMask::FLAG_PARAMETERS, - parameters: Some(MaskParameters { - flags: 0b0011, - user_density: Some(200), - user_feather: Some(1.5), - vector_density: None, - vector_feather: None, - }), + parameters: Some(MaskParameters { flags: 0b0011, user_density: Some(200), user_feather: Some(1.5), vector_density: None, vector_feather: None }), real: Some(RealMask { flags: 0, background: 255, rect: Rect::from_xywh(0, 0, 3, 3) }), trailing: Vec::new(), }; @@ -324,18 +300,10 @@ pub fn layered(version: Version, mode: ColorMode, depth: u16, compression: Compr let layer_info = LayerInfo { merged_alpha: true, layers, padding: None }; let (placement, blocks) = match depth { 16 | 32 => ( - LayerInfoPlacement::GlobalBlock { - index: 1, - signature: *b"8BIM", - key: if depth == 16 { *b"Lr16" } else { *b"Lr32" }, - padding: None, - }, + LayerInfoPlacement::GlobalBlock { index: 1, signature: *b"8BIM", key: if depth == 16 { *b"Lr16" } else { *b"Lr32" }, padding: None }, vec![TaggedBlock::new(*b"Patt", vec![]), TaggedBlock::new(*b"Txt2", vec![0, 1, 2, 3])], ), - _ => ( - LayerInfoPlacement::Section, - vec![TaggedBlock::new(*b"Patt", vec![]), TaggedBlock::new(*b"FMsk", vec![0; 10])], - ), + _ => (LayerInfoPlacement::Section, vec![TaggedBlock::new(*b"Patt", vec![]), TaggedBlock::new(*b"FMsk", vec![0; 10])]), }; PsdFile { header, @@ -365,15 +333,7 @@ pub fn small(version: Version, compression: Compression) -> PsdFile { seed: 1, }), group_record(&header, "", SectionType::BoundingDivider, BlendMode::Normal, 9), - raster_layer(LayerGen { - header: &header, - compression, - rect: Rect::from_xywh(-1, 1, 2, 2), - name: "b", - blend: BlendMode::Screen, - mask: None, - seed: 2, - }), + raster_layer(LayerGen { header: &header, compression, rect: Rect::from_xywh(-1, 1, 2, 2), name: "b", blend: BlendMode::Screen, mask: None, seed: 2 }), group_record(&header, "g", SectionType::OpenFolder, BlendMode::PassThrough, 10), ]; let mut planes = Vec::new(); @@ -400,30 +360,21 @@ pub fn all_cases() -> Vec { for mode in MODES { for &depth in mode_depths(mode) { for c in Compression::ALL { - v.push(Case { - name: format!("merged {version:?} {mode:?} {depth}bit {c:?}"), - file: merged_only(version, mode, depth, c, 13, 7), - }); + v.push(Case { name: format!("merged {version:?} {mode:?} {depth}bit {c:?}"), file: merged_only(version, mode, depth, c, 13, 7) }); } } } for mode in [ColorMode::Grayscale, ColorMode::Rgb, ColorMode::Cmyk, ColorMode::Lab] { for &depth in mode_depths(mode).iter().filter(|&&d| d >= 8) { for c in Compression::ALL { - v.push(Case { - name: format!("layered {version:?} {mode:?} {depth}bit {c:?}"), - file: layered(version, mode, depth, c), - }); + v.push(Case { name: format!("layered {version:?} {mode:?} {depth}bit {c:?}"), file: layered(version, mode, depth, c) }); } } } for c in Compression::ALL { v.push(Case { name: format!("small {version:?} {c:?}"), file: small(version, c) }); } - v.push(Case { - name: format!("empty canvas {version:?}"), - file: merged_only(version, ColorMode::Rgb, 8, Compression::Rle, 0, 0), - }); + v.push(Case { name: format!("empty canvas {version:?}"), file: merged_only(version, ColorMode::Rgb, 8, Compression::Rle, 0, 0) }); } v } diff --git a/crates/psd/src/tree.rs b/crates/psd/src/tree.rs index 156699f8c0..02682ef841 100644 --- a/crates/psd/src/tree.rs +++ b/crates/psd/src/tree.rs @@ -65,8 +65,7 @@ impl PsdFile { match rec.section_type() { SectionType::BoundingDivider => stack.push((Some(i), Vec::new())), SectionType::OpenFolder | SectionType::ClosedFolder => { - let (divider, children) = - if stack.len() > 1 { stack.pop().unwrap_or_default() } else { (None, Vec::new()) }; + let (divider, children) = if stack.len() > 1 { stack.pop().unwrap_or_default() } else { (None, Vec::new()) }; if let Some(top) = stack.last_mut() { top.1.push(LayerNode::Group { index: i, divider, children }); } diff --git a/crates/psd/tests/builder.rs b/crates/psd/tests/builder.rs index c1f179921e..87e3b8dc98 100644 --- a/crates/psd/tests/builder.rs +++ b/crates/psd/tests/builder.rs @@ -6,12 +6,7 @@ fn rgba_pattern(w: usize, h: usize, seed: u8) -> Vec { let mut v = Vec::with_capacity(w * h * 4); for y in 0..h { for x in 0..w { - v.extend_from_slice(&[ - (x * 10) as u8 ^ seed, - (y * 20) as u8, - (x + y) as u8 ^ seed, - if (x + y) % 3 == 0 { 0 } else { 200 + (x % 50) as u8 }, - ]); + v.extend_from_slice(&[(x * 10) as u8 ^ seed, (y * 20) as u8, (x + y) as u8 ^ seed, if (x + y) % 3 == 0 { 0 } else { 200 + (x % 50) as u8 }]); } } v diff --git a/crates/psd/tests/corpus.rs b/crates/psd/tests/corpus.rs index 1bf2d60fab..d5e28cea9a 100644 --- a/crates/psd/tests/corpus.rs +++ b/crates/psd/tests/corpus.rs @@ -12,11 +12,7 @@ fn collect(dir: &Path, out: &mut Vec) { let p = e.path(); if p.is_dir() { collect(&p, out); - } else if p - .extension() - .and_then(|e| e.to_str()) - .is_some_and(|e| e.eq_ignore_ascii_case("psd") || e.eq_ignore_ascii_case("psb")) - { + } else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("psd") || e.eq_ignore_ascii_case("psb")) { out.push(p); } } @@ -24,8 +20,7 @@ fn collect(dir: &Path, out: &mut Vec) { /// Files known to be invalid upstream, with the reason. They are reported but /// do not fail the test. -const KNOWN_BAD: &[(&str, &str)] = - &[("group-divider-blend-mode.psd", "psd-tools fixture stripped to 1906 bytes: merged image data missing")]; +const KNOWN_BAD: &[(&str, &str)] = &[("group-divider-blend-mode.psd", "psd-tools fixture stripped to 1906 bytes: merged image data missing")]; #[test] fn corpus_parse_and_byte_stable() { diff --git a/crates/psd/tests/proptests.rs b/crates/psd/tests/proptests.rs index 6ca4d6c893..58a02de4fb 100644 --- a/crates/psd/tests/proptests.rs +++ b/crates/psd/tests/proptests.rs @@ -9,12 +9,7 @@ fn version() -> impl Strategy { prop_oneof![Just(Version::Psd), Just(Version::Psb)] } fn compression() -> impl Strategy { - prop_oneof![ - Just(Compression::Raw), - Just(Compression::Rle), - Just(Compression::Zip), - Just(Compression::ZipPrediction) - ] + prop_oneof![Just(Compression::Raw), Just(Compression::Rle), Just(Compression::Zip), Just(Compression::ZipPrediction)] } fn depth() -> impl Strategy { prop_oneof![Just(1u16), Just(8), Just(16), Just(32)] diff --git a/crates/psd/tests/roundtrip.rs b/crates/psd/tests/roundtrip.rs index a425bfbc19..8666ba3cc4 100644 --- a/crates/psd/tests/roundtrip.rs +++ b/crates/psd/tests/roundtrip.rs @@ -29,9 +29,7 @@ fn all_generated_channels_decode() { assert_eq!(merged.len(), f.header.row_bytes() * h * usize::from(f.header.channels)); for (i, layer) in f.layers().iter().enumerate() { for ch in &layer.channels { - layer - .decode_channel(ch.id, f.header.depth, f.header.version) - .unwrap_or_else(|e| panic!("{} layer {i} ch {}: {e}", c.name, ch.id)); + layer.decode_channel(ch.id, f.header.depth, f.header.version).unwrap_or_else(|e| panic!("{} layer {i} ch {}: {e}", c.name, ch.id)); } } let _ = (w, h); diff --git a/crates/psd/tests/tree.rs b/crates/psd/tests/tree.rs index 8d06f0f1ce..7e42394316 100644 --- a/crates/psd/tests/tree.rs +++ b/crates/psd/tests/tree.rs @@ -96,14 +96,7 @@ fn empty_group() { #[test] fn sibling_groups() { - let f = file_with(vec![ - rec("", END), - rec("a", None), - rec("G1", OPEN), - rec("", END), - rec("b", None), - rec("G2", OPEN), - ]); + let f = file_with(vec![rec("", END), rec("a", None), rec("G1", OPEN), rec("", END), rec("b", None), rec("G2", OPEN)]); assert_eq!(names(&f, &f.layer_tree()), "G1[a],G2[b]"); } diff --git a/crates/text/src/engine_data.rs b/crates/text/src/engine_data.rs index 72df9d1e3f..a3cb321989 100644 --- a/crates/text/src/engine_data.rs +++ b/crates/text/src/engine_data.rs @@ -177,12 +177,7 @@ impl Parser<'_> { Some(b'/') => Ok(Value::Name(self.name())), Some(_) => { let start = self.i; - while self.i < b.len() - && !matches!( - b[self.i], - b' ' | b'\t' | b'\r' | b'\n' | b'[' | b']' | b'<' | b'>' | b'(' | b'/' - ) - { + while self.i < b.len() && !matches!(b[self.i], b' ' | b'\t' | b'\r' | b'\n' | b'[' | b']' | b'<' | b'>' | b'(' | b'/') { self.i += 1; } let tok = std::str::from_utf8(&b[start..self.i]).unwrap_or(""); @@ -193,15 +188,8 @@ impl Parser<'_> { self.i += 1; self.err("unexpected byte") } - t if t.contains('.') || t.contains('e') || t.contains('E') => t - .parse() - .map(Value::Real) - .or_else(|_| self.err("bad number")), - t => t.parse().map(Value::Int).or_else(|_| { - t.parse() - .map(Value::Real) - .or_else(|_| self.err("bad token")) - }), + t if t.contains('.') || t.contains('e') || t.contains('E') => t.parse().map(Value::Real).or_else(|_| self.err("bad number")), + t => t.parse().map(Value::Int).or_else(|_| t.parse().map(Value::Real).or_else(|_| self.err("bad token"))), } } } @@ -209,12 +197,7 @@ impl Parser<'_> { fn name(&mut self) -> String { self.i += 1; // '/' let start = self.i; - while self.i < self.b.len() - && !matches!( - self.b[self.i], - b' ' | b'\t' | b'\r' | b'\n' | b'[' | b']' | b'<' | b'>' | b'(' | b'/' - ) - { + while self.i < self.b.len() && !matches!(self.b[self.i], b' ' | b'\t' | b'\r' | b'\n' | b'[' | b']' | b'<' | b'>' | b'(' | b'/') { self.i += 1; } String::from_utf8_lossy(&self.b[start..self.i]).into_owned() @@ -223,10 +206,7 @@ impl Parser<'_> { fn decode_string(raw: &[u8]) -> String { if raw.len() >= 2 && raw[0] == 0xFE && raw[1] == 0xFF { - let u: Vec = raw[2..] - .chunks_exact(2) - .map(|c| u16::from_be_bytes([c[0], c[1]])) - .collect(); + let u: Vec = raw[2..].chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect(); String::from_utf16_lossy(&u) } else { raw.iter().map(|&c| c as char).collect() @@ -252,8 +232,7 @@ fn indent(out: &mut Vec, n: usize) { } fn is_container(v: &Value) -> bool { - matches!(v, Value::Dict(_)) - || matches!(v, Value::Array(a) if a.iter().any(|x| matches!(x, Value::Dict(_) | Value::Array(_)))) + matches!(v, Value::Dict(_)) || matches!(v, Value::Array(a) if a.iter().any(|x| matches!(x, Value::Dict(_) | Value::Array(_)))) } fn write_value(v: &Value, depth: usize, out: &mut Vec) { @@ -357,34 +336,12 @@ mod tests { #[test] fn parses_sample() { let v = parse(SAMPLE).unwrap(); - assert_eq!( - v.path(&["EngineDict", "Editor", "Text"]) - .and_then(Value::as_str), - Some("Hi)\r") - ); - let nums: Vec = v - .path(&["EngineDict", "Nums"]) - .unwrap() - .as_array() - .unwrap() - .iter() - .filter_map(Value::as_f64) - .collect(); + assert_eq!(v.path(&["EngineDict", "Editor", "Text"]).and_then(Value::as_str), Some("Hi)\r")); + let nums: Vec = v.path(&["EngineDict", "Nums"]).unwrap().as_array().unwrap().iter().filter_map(Value::as_f64).collect(); assert_eq!(nums, [1.0, 0.5, -2.0, 0.0]); - assert_eq!( - v.path(&["EngineDict", "Flag"]).and_then(Value::as_bool), - Some(true) - ); - assert_eq!( - v.path(&["EngineDict", "Kind"]), - Some(&Value::Name("Roman".into())) - ); - assert_eq!( - v.path(&["EngineDict", "Runs"]).unwrap().as_array().unwrap()[0] - .get("A") - .and_then(Value::as_i64), - Some(1) - ); + assert_eq!(v.path(&["EngineDict", "Flag"]).and_then(Value::as_bool), Some(true)); + assert_eq!(v.path(&["EngineDict", "Kind"]), Some(&Value::Name("Roman".into()))); + assert_eq!(v.path(&["EngineDict", "Runs"]).unwrap().as_array().unwrap()[0].get("A").and_then(Value::as_i64), Some(1)); } #[test] diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 3d387bb4ae..e5631a34e1 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -6,9 +6,7 @@ use std::collections::HashMap; use std::sync::Arc; use parley::FontContext; -use parley::fontique::{ - Blob, Collection, CollectionOptions, FontStyle, GenericFamily, SourceCache, -}; +use parley::fontique::{Blob, Collection, CollectionOptions, FontStyle, GenericFamily, SourceCache}; use skrifa::raw::FileRef; use skrifa::{MetadataProvider, string::StringId}; @@ -19,22 +17,10 @@ pub const MONO_FAMILY: &str = "JetBrains Mono"; /// Fonts shipped with Photocraft (OFL; licences in `assets/fonts`). pub const BUNDLED: &[(&str, &[u8])] = &[ - ( - "Inter-Regular.ttf", - include_bytes!("../../../assets/fonts/Inter-Regular.ttf"), - ), - ( - "Inter-Medium.ttf", - include_bytes!("../../../assets/fonts/Inter-Medium.ttf"), - ), - ( - "Inter-SemiBold.ttf", - include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf"), - ), - ( - "JetBrainsMono-Regular.ttf", - include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf"), - ), + ("Inter-Regular.ttf", include_bytes!("../../../assets/fonts/Inter-Regular.ttf")), + ("Inter-Medium.ttf", include_bytes!("../../../assets/fonts/Inter-Medium.ttf")), + ("Inter-SemiBold.ttf", include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf")), + ("JetBrainsMono-Regular.ttf", include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf")), ]; /// Families tried (if installed) after the requested one, for missing glyphs. @@ -93,15 +79,9 @@ impl Default for FontDb { impl FontDb { /// Bundled fonts only (deterministic: used by tests and the web build). pub fn new() -> Self { - let collection = Collection::new(CollectionOptions { - shared: false, - system_fonts: false, - }); + let collection = Collection::new(CollectionOptions { shared: false, system_fonts: false }); let mut db = FontDb { - fcx: FontContext { - collection, - source_cache: SourceCache::default(), - }, + fcx: FontContext { collection, source_cache: SourceCache::default() }, system_loaded: false, ps_cache: HashMap::new(), fallbacks: Vec::new(), @@ -144,10 +124,7 @@ impl FontDb { /// Registers font data (TTF/OTF, or every face of a TTC/OTC). Returns the family names added. pub fn register_font_data(&mut self, bytes: Vec) -> Vec { - let added = self - .fcx - .collection - .register_fonts(Blob::new(Arc::new(bytes)), None); + let added = self.fcx.collection.register_fonts(Blob::new(Arc::new(bytes)), None); let mut names = Vec::new(); for (id, _) in added { if let Some(n) = self.fcx.collection.family_name(id) @@ -164,12 +141,7 @@ impl FontDb { fn refresh_generics(&mut self) { let c = &mut self.fcx.collection; if let Some(inter) = c.family_id(DEFAULT_FAMILY) { - for g in [ - GenericFamily::SansSerif, - GenericFamily::Serif, - GenericFamily::SystemUi, - GenericFamily::UiSansSerif, - ] { + for g in [GenericFamily::SansSerif, GenericFamily::Serif, GenericFamily::SystemUi, GenericFamily::UiSansSerif] { if c.generic_families(g).next().is_none() { c.set_generic_families(g, std::iter::once(inter)); } @@ -182,11 +154,7 @@ impl FontDb { } } } - self.fallbacks = FALLBACK_CANDIDATES - .iter() - .filter(|f| c.family_id(f).is_some()) - .map(|s| s.to_string()) - .collect(); + self.fallbacks = FALLBACK_CANDIDATES.iter().filter(|f| c.family_id(f).is_some()).map(|s| s.to_string()).collect(); } /// Families available after the requested one (bundled default + installed coverage fonts). @@ -196,12 +164,7 @@ impl FontDb { /// All family names, sorted. pub fn families(&mut self) -> Vec { - let mut v: Vec = self - .fcx - .collection - .family_names() - .map(str::to_string) - .collect(); + let mut v: Vec = self.fcx.collection.family_names().map(str::to_string).collect(); v.sort_by_key(|s| s.to_lowercase()); v.dedup(); v @@ -222,11 +185,7 @@ impl FontDb { family: info.name().to_string(), weight: f.weight().value(), italic: !matches!(f.style(), FontStyle::Normal), - axes: f - .axes() - .iter() - .map(|a| (a.tag.to_string(), a.min, a.default, a.max)) - .collect(), + axes: f.axes().iter().map(|a| (a.tag.to_string(), a.min, a.default, a.max)).collect(), }) .collect() } @@ -243,10 +202,7 @@ impl FontDb { // Keep the guessed family only if we have it; otherwise let fallback pick. let mut g = guess.clone(); if !self.has_family(&g.family) - && let Some(f) = self.families().into_iter().find(|f| { - f.replace(' ', "") - .eq_ignore_ascii_case(&g.family.replace(' ', "")) - }) + && let Some(f) = self.families().into_iter().find(|f| f.replace(' ', "").eq_ignore_ascii_case(&g.family.replace(' ', ""))) { g.family = f; } @@ -257,16 +213,8 @@ impl FontDb { } fn find_exact(&mut self, ps: &str, family_guess: &str) -> Option { - let first_word = family_guess - .split(' ') - .next() - .unwrap_or(family_guess) - .to_lowercase(); - let candidates: Vec = self - .families() - .into_iter() - .filter(|f| f.to_lowercase().replace(' ', "").starts_with(&first_word)) - .collect(); + let first_word = family_guess.split(' ').next().unwrap_or(family_guess).to_lowercase(); + let candidates: Vec = self.families().into_iter().filter(|f| f.to_lowercase().replace(' ', "").starts_with(&first_word)).collect(); for fam in candidates { let Some(info) = self.fcx.collection.family_by_name(&fam) else { continue; @@ -278,10 +226,7 @@ impl FontDb { let Ok(fr) = skrifa::FontRef::from_index(blob.as_ref(), font.index()) else { continue; }; - let name = fr - .localized_strings(StringId::POSTSCRIPT_NAME) - .english_or_first() - .map(|s| s.to_string()); + let name = fr.localized_strings(StringId::POSTSCRIPT_NAME).english_or_first().map(|s| s.to_string()); if name.as_deref() == Some(ps) { return Some(ResolvedFont { family: info.name().to_string(), @@ -308,11 +253,7 @@ pub fn face_count(bytes: &[u8]) -> usize { /// Heuristic PostScript-name split: `MyriadPro-BoldIt` → ("Myriad Pro", 700, italic). pub fn guess_from_postscript(ps: &str) -> ResolvedFont { let (fam, style) = ps.split_once('-').unwrap_or((ps, "")); - let fam = fam - .trim_end_matches("MT") - .trim_end_matches("PS") - .trim_end_matches("Std") - .trim_end_matches("Pro"); + let fam = fam.trim_end_matches("MT").trim_end_matches("PS").trim_end_matches("Std").trim_end_matches("Pro"); let pro = ps.split_once('-').map_or(ps, |p| p.0); let suffix = if pro.ends_with("Pro") { " Pro" @@ -325,12 +266,7 @@ pub fn guess_from_postscript(ps: &str) -> ResolvedFont { let mut family = String::new(); let chars: Vec = fam.chars().collect(); for (i, &c) in chars.iter().enumerate() { - if i > 0 - && c.is_uppercase() - && (chars[i - 1].is_lowercase() - || chars.get(i + 1).is_some_and(|n| n.is_lowercase()) - && chars[i - 1].is_uppercase()) - { + if i > 0 && c.is_uppercase() && (chars[i - 1].is_lowercase() || chars.get(i + 1).is_some_and(|n| n.is_lowercase()) && chars[i - 1].is_uppercase()) { family.push(' '); } family.push(c); @@ -357,12 +293,7 @@ pub fn guess_from_postscript(ps: &str) -> ResolvedFont { 400 }; let italic = s.contains("italic") || s.ends_with("it") || s.contains("oblique"); - ResolvedFont { - family: family.trim().to_string(), - weight, - italic, - exact: false, - } + ResolvedFont { family: family.trim().to_string(), weight, italic, exact: false } } #[cfg(not(target_arch = "wasm32"))] @@ -376,12 +307,7 @@ fn collect_font_files(dir: &std::path::Path, depth: u32, out: &mut Vec Vec { v.push(h.join("Library/Fonts")); } } else if cfg!(target_os = "windows") { - let windir = - std::env::var_os("WINDIR").map_or_else(|| PathBuf::from("C:\\Windows"), PathBuf::from); + let windir = std::env::var_os("WINDIR").map_or_else(|| PathBuf::from("C:\\Windows"), PathBuf::from); v.push(windir.join("Fonts")); if let Some(l) = std::env::var_os("LOCALAPPDATA") { v.push(PathBuf::from(l).join("Microsoft\\Windows\\Fonts")); diff --git a/crates/text/src/glyphs.rs b/crates/text/src/glyphs.rs index cf42ff19af..26c55ce444 100644 --- a/crates/text/src/glyphs.rs +++ b/crates/text/src/glyphs.rs @@ -38,8 +38,7 @@ pub fn in_category(c: char, category: &str) -> bool { "Greek" => (0x370..=0x3FF).contains(&u) || (0x1F00..=0x1FFF).contains(&u), "Cyrillic" => (0x400..=0x52F).contains(&u), "Punctuation" => { - c.is_ascii_punctuation() - && !matches!(c, '$' | '+' | '<' | '=' | '>' | '^' | '`' | '|' | '~') + c.is_ascii_punctuation() && !matches!(c, '$' | '+' | '<' | '=' | '>' | '^' | '`' | '|' | '~') || matches!(u, 0xA1 | 0xA7 | 0xAB | 0xB6 | 0xB7 | 0xBB | 0xBF) || (0x2010..=0x2027).contains(&u) || (0x2030..=0x205E).contains(&u) @@ -49,60 +48,20 @@ pub fn in_category(c: char, category: &str) -> bool { "Numbers" => c.is_numeric() || (0x2150..=0x218F).contains(&u), "Currency" => matches!(c, '$' | '¢' | '£' | '¤' | '¥') || (0x20A0..=0x20CF).contains(&u), "Symbols" => { - matches!( - c, - '©' | '®' - | '°' - | '¦' - | '¨' - | '¯' - | '´' - | '¸' - | '™' - | '℠' - | '№' - | '℮' - | '^' - | '`' - | '|' - | '~' - ) || (0x2100..=0x214F).contains(&u) + matches!(c, '©' | '®' | '°' | '¦' | '¨' | '¯' | '´' | '¸' | '™' | '℠' | '№' | '℮' | '^' | '`' | '|' | '~') + || (0x2100..=0x214F).contains(&u) || (0x2300..=0x23FF).contains(&u) || (0x2500..=0x25FF).contains(&u) } "Math Symbols" => { - matches!( - c, - '+' | '<' - | '=' - | '>' - | '±' - | '×' - | '÷' - | '¬' - | 'µ' - | '¹' - | '²' - | '³' - | '¼' - | '½' - | '¾' - ) || (0x2070..=0x209F).contains(&u) + matches!(c, '+' | '<' | '=' | '>' | '±' | '×' | '÷' | '¬' | 'µ' | '¹' | '²' | '³' | '¼' | '½' | '¾') + || (0x2070..=0x209F).contains(&u) || (0x2200..=0x22FF).contains(&u) || (0x27C0..=0x27EF).contains(&u) || (0x2980..=0x2AFF).contains(&u) } - "Arrows" => { - (0x2190..=0x21FF).contains(&u) - || (0x27F0..=0x27FF).contains(&u) - || (0x2900..=0x297F).contains(&u) - || (0x2B00..=0x2BFF).contains(&u) - } - "Ornaments" => { - (0x2600..=0x27BF).contains(&u) - || (0x1F300..=0x1FAFF).contains(&u) - || (0xE000..=0xF8FF).contains(&u) - } + "Arrows" => (0x2190..=0x21FF).contains(&u) || (0x27F0..=0x27FF).contains(&u) || (0x2900..=0x297F).contains(&u) || (0x2B00..=0x2BFF).contains(&u), + "Ornaments" => (0x2600..=0x27BF).contains(&u) || (0x1F300..=0x1FAFF).contains(&u) || (0xE000..=0xF8FF).contains(&u), _ => true, } } @@ -117,8 +76,7 @@ impl FontDb { let fonts = info.fonts(); let Some(font) = fonts.iter().min_by_key(|f| { let it = !matches!(f.style(), parley::fontique::FontStyle::Normal); - (u32::from(it != italic) * 10_000) - + (f.weight().value() - f32::from(weight)).abs() as u32 + (u32::from(it != italic) * 10_000) + (f.weight().value() - f32::from(weight)).abs() as u32 }) else { return Vec::new(); }; @@ -133,12 +91,7 @@ impl FontDb { .mappings() .filter(|(_, g)| g.to_u32() != 0) .filter_map(|(u, _)| char::from_u32(u)) - .filter(|c| { - *c == ' ' - || !(c.is_control() - || c.is_whitespace() - || ('\u{FE00}'..='\u{FE0F}').contains(c)) - }) + .filter(|c| *c == ' ' || !(c.is_control() || c.is_whitespace() || ('\u{FE00}'..='\u{FE0F}').contains(c))) .collect(); out.sort_unstable(); out.dedup(); @@ -148,12 +101,7 @@ impl FontDb { /// An 8-bit coverage preview of one character in a `px`-pixel em box, centred horizontally on /// the baseline at 80 % of the box: (width = height = `px`, alpha bytes). -pub fn preview( - engine: &mut crate::TextEngine, - style: &CharStyle, - c: char, - px: u32, -) -> (u32, Vec) { +pub fn preview(engine: &mut crate::TextEngine, style: &CharStyle, c: char, px: u32) -> (u32, Vec) { let px = px.clamp(4, 512); let text = c.to_string(); let mut st = style.clone(); @@ -162,14 +110,7 @@ pub fn preview( st.strikethrough = false; st.tracking = 0.0; st.baseline_shift_pt = 0.0; - let layer = TextLayer { - runs: vec![TextRun { - len: text.len(), - style: st, - }], - text, - ..Default::default() - }; + let layer = TextLayer { runs: vec![TextRun { len: text.len(), style: st }], text, ..Default::default() }; let layout = engine.layout(&layer, 72.0); let w = layout.bounds().map_or(0.0, |b| b[2] - b[0]); let x0 = layout.bounds().map_or(0.0, |b| b[0]); @@ -183,10 +124,7 @@ pub fn preview( None, ); let mut rgba = vec![[0u8; 4]; (px * px) as usize]; - r.surface.read_rgba8_into( - photocraft_geom::Rect::new(0, 0, px as i32, px as i32), - &mut rgba, - ); + r.surface.read_rgba8_into(photocraft_geom::Rect::new(0, 0, px as i32, px as i32), &mut rgba); (px, rgba.into_iter().map(|p| p[3]).collect()) } @@ -202,27 +140,16 @@ mod tests { assert!(cm.contains(&'A') && cm.contains(&'€') && cm.contains(&' ')); assert!(!cm.contains(&'\n')); assert!(db.charmap("No Such Family", 400, false).is_empty()); - let latin: Vec = cm - .iter() - .copied() - .filter(|c| in_category(*c, "Basic Latin")) - .collect(); + let latin: Vec = cm.iter().copied().filter(|c| in_category(*c, "Basic Latin")).collect(); assert_eq!(latin.len(), 95); - assert!( - in_category('€', "Currency") - && in_category('—', "Punctuation") - && in_category('→', "Arrows") - ); + assert!(in_category('€', "Currency") && in_category('—', "Punctuation") && in_category('→', "Arrows")); assert!(!in_category('A', "Punctuation") && in_category('A', "Entire Font")); } #[test] fn preview_draws_ink() { let mut eng = crate::TextEngine::new(); - let st = CharStyle { - font_family: crate::fonts::DEFAULT_FAMILY.into(), - ..Default::default() - }; + let st = CharStyle { font_family: crate::fonts::DEFAULT_FAMILY.into(), ..Default::default() }; let (n, a) = preview(&mut eng, &st, 'W', 32); assert_eq!((n, a.len()), (32, 32 * 32)); let ink: u32 = a.iter().map(|&v| u32::from(v)).sum(); diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 004a055c67..fb25172c57 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -10,8 +10,8 @@ use std::borrow::Cow; use std::ops::Range; use parley::{ - Alignment, AlignmentOptions, FontData, FontFamily, FontFeatures, FontStyle, FontVariations, - FontWeight, IndentOptions, Layout, LayoutContext, PositionedLayoutItem, StyleProperty, + Alignment, AlignmentOptions, FontData, FontFamily, FontFeatures, FontStyle, FontVariations, FontWeight, IndentOptions, Layout, LayoutContext, + PositionedLayoutItem, StyleProperty, }; use photocraft_doc::TextLayer; use photocraft_doc::text::{Caps, CharStyle, Kerning, TextAlign, TextDirection, TextShape}; @@ -100,12 +100,7 @@ impl TextLayout { let r = [l.x0, l.baseline - l.ascent, l.x1, l.baseline + l.descent]; Some(match acc { None => r, - Some(a) => [ - a[0].min(r[0]), - a[1].min(r[1]), - a[2].max(r[2]), - a[3].max(r[3]), - ], + Some(a) => [a[0].min(r[0]), a[1].min(r[1]), a[2].max(r[2]), a[3].max(r[3])], }) }) } @@ -129,16 +124,8 @@ impl TextLayout { let mut best = (f32::MAX, line.range.end); for c in self.clusters.iter().filter(|c| c.line == li) { let mid = c.x + c.advance / 2.0; - let (before, after) = if c.rtl { - (c.range.end, c.range.start) - } else { - (c.range.start, c.range.end) - }; - let (dist, off) = if x < mid { - ((x - c.x).abs(), before) - } else { - ((x - c.x - c.advance).abs(), after) - }; + let (before, after) = if c.rtl { (c.range.end, c.range.start) } else { (c.range.start, c.range.end) }; + let (dist, off) = if x < mid { ((x - c.x).abs(), before) } else { ((x - c.x - c.advance).abs(), after) }; if dist < best.0 { best = (dist, off); } @@ -156,11 +143,7 @@ impl TextLayout { } } // End of a line (or empty line): after the last cluster of the line containing it. - let li = self - .lines - .iter() - .position(|l| offset >= l.range.start && offset <= l.range.end) - .unwrap_or(self.lines.len().saturating_sub(1)); + let li = self.lines.iter().position(|l| offset >= l.range.start && offset <= l.range.end).unwrap_or(self.lines.len().saturating_sub(1)); match self.lines.get(li) { Some(l) => { let x = self @@ -186,19 +169,14 @@ pub(crate) struct Layouter { impl Layouter { pub fn new() -> Self { - Self { - lcx: LayoutContext::new(), - } + Self { lcx: LayoutContext::new() } } pub fn layout(&mut self, fonts: &mut FontDb, t: &TextLayer, dpi: f32) -> TextLayout { let k = if dpi > 0.0 { dpi / 72.0 } else { 1.0 }; let runs = t.char_runs(); let paras = t.paragraph_runs(); - let mut out = TextLayout { - px_per_pt: k, - ..Default::default() - }; + let mut out = TextLayout { px_per_pt: k, ..Default::default() }; // Resolve families (PostScript names from PSDs, unknown families). for r in &runs { let mut s = r.style.clone(); @@ -230,17 +208,11 @@ impl Layouter { Some(s) }) .collect(); - let para_style_at = - |off: usize| ¶s[para_starts.iter().rposition(|&s| s <= off).unwrap_or(0)].style; + let para_style_at = |off: usize| ¶s[para_starts.iter().rposition(|&s| s <= off).unwrap_or(0)].style; let text = &t.text; let (box_rect, is_box) = match t.shape { - TextShape::Box { - x, - y, - width, - height, - } => ((x, y, width, height), true), + TextShape::Box { x, y, width, height } => ((x, y, width, height), true), TextShape::Point => ((0.0, 0.0, 0.0, 0.0), false), }; let mut prev_baseline: Option = None; @@ -288,8 +260,7 @@ impl Layouter { if a >= z { continue; } - let range = - (a - prange.start + prefix.len())..(z - prange.start + prefix.len()); + let range = (a - prange.start + prefix.len())..(z - prange.start + prefix.len()); for p in style_props(st, k, &fallback, ri as u32) { b.push(p, range.clone()); } @@ -301,11 +272,7 @@ impl Layouter { if ps.first_line_indent_pt != 0.0 { layout.set_text_indent(ps.first_line_indent_pt * k, IndentOptions::default()); } - let avail = if is_box { - Some((box_rect.2 - indent_start - indent_end).max(1.0)) - } else { - None - }; + let avail = if is_box { Some((box_rect.2 - indent_start - indent_end).max(1.0)) } else { None }; layout.break_all_lines(avail); let alignment = if is_box { match ps.align { @@ -317,12 +284,7 @@ impl Layouter { } else { Alignment::Left }; - layout.align( - alignment, - AlignmentOptions { - align_when_overflowing: !is_box, - }, - ); + layout.align(alignment, AlignmentOptions { align_when_overflowing: !is_box }); // Stack lines. if prev_baseline.is_some() { @@ -337,21 +299,14 @@ impl Layouter { if let PositionedLayoutItem::GlyphRun(gr) = item { let st = &out.styles[gr.style().brush.0 as usize]; let px = gr.run().font_size(); - leading = - leading.max(st.leading_pt.map_or(ps.auto_leading * px, |l| l * k)); + leading = leading.max(st.leading_pt.map_or(ps.auto_leading * px, |l| l * k)); } } if leading == 0.0 { let st = &out.styles[style_at(prange.start)]; - leading = st - .leading_pt - .map_or(ps.auto_leading * st.size_pt * k, |l| l * k); + leading = st.leading_pt.map_or(ps.auto_leading * st.size_pt * k, |l| l * k); } - let (ascent, descent) = if m.ascent > 0.0 || m.descent > 0.0 { - (m.ascent, m.descent) - } else { - (first_px * 0.8, first_px * 0.2) - }; + let (ascent, descent) = if m.ascent > 0.0 || m.descent > 0.0 { (m.ascent, m.descent) } else { (first_px * 0.8, first_px * 0.2) }; let baseline = match prev_baseline { // Photoshop's "first baseline: ascent": the top of the tallest ascender // (height of 'd') touches the box top, not the font's hhea ascent. @@ -389,8 +344,7 @@ impl Layouter { }; let justify_all = is_box && last_line && ps.align == TextAlign::JustifyAll; let line_index = out.lines.len(); - let map = - |o: usize| (prange.start + o.saturating_sub(prefix.len())).min(content_end); + let map = |o: usize| (prange.start + o.saturating_sub(prefix.len())).min(content_end); let lr = line.text_range(); let g0 = out.glyphs.len(); let c0 = out.clusters.len(); @@ -403,11 +357,7 @@ impl Layouter { let run = gr.run(); let si = gr.style().brush.0; let st = &out.styles[si as usize]; - let hs = if st.horizontal_scale > 0.0 { - st.horizontal_scale - } else { - 1.0 - }; + let hs = if st.horizontal_scale > 0.0 { st.horizontal_scale } else { 1.0 }; let synth = run.synthesis(); let face = out.faces.len() as u32; out.faces.push(GlyphFace { @@ -425,26 +375,14 @@ impl Layouter { let a = c.advance() * hs; let r = c.text_range(); if r.end > prefix.len() || prefix.is_empty() { - out.clusters.push(ClusterInfo { - range: map(r.start)..map(r.end), - x: cx, - advance: a, - line: line_index, - rtl: c.is_rtl(), - }); + out.clusters.push(ClusterInfo { range: map(r.start)..map(r.end), x: cx, advance: a, line: line_index, rtl: c.is_rtl() }); } cx += a; } } let mut pen = gr.offset(); for g in gr.glyphs() { - out.glyphs.push(PlacedGlyph { - face, - id: g.id, - x: dx + pen + g.x + extra, - y: baseline + g.y, - style: si, - }); + out.glyphs.push(PlacedGlyph { face, id: g.id, x: dx + pen + g.x + extra, y: baseline + g.y, style: si }); extra += g.advance * (hs - 1.0); pen += g.advance; } @@ -453,23 +391,11 @@ impl Layouter { let shift = st.baseline_shift_pt * k; if gr.style().underline.is_some() { let y0 = baseline - rm.underline_offset - shift; - out.decorations.push(DecorationRect { - x0: run_x0, - y0, - x1: run_x1, - y1: y0 + rm.underline_size.max(1.0), - style: si, - }); + out.decorations.push(DecorationRect { x0: run_x0, y0, x1: run_x1, y1: y0 + rm.underline_size.max(1.0), style: si }); } if gr.style().strikethrough.is_some() { let y0 = baseline - rm.strikethrough_offset - shift; - out.decorations.push(DecorationRect { - x0: run_x0, - y0, - x1: run_x1, - y1: y0 + rm.strikethrough_size.max(1.0), - style: si, - }); + out.decorations.push(DecorationRect { x0: run_x0, y0, x1: run_x1, y1: y0 + rm.strikethrough_size.max(1.0), style: si }); } } if justify_all { @@ -515,11 +441,7 @@ pub fn split_paragraphs(text: &str) -> Vec> { while i < b.len() { match b[i] { b'\r' => { - let end = if b.get(i + 1) == Some(&b'\n') { - i + 2 - } else { - i + 1 - }; + let end = if b.get(i + 1) == Some(&b'\n') { i + 2 } else { i + 1 }; v.push(start..end); start = end; i = end; @@ -545,12 +467,7 @@ fn quote(s: &str) -> String { format!("\"{}\"", s.replace(['\\', '"'], "")) } -fn style_props( - st: &CharStyle, - k: f32, - fallback: &[String], - idx: u32, -) -> Vec> { +fn style_props(st: &CharStyle, k: f32, fallback: &[String], idx: u32) -> Vec> { let px = (st.size_pt * k).max(0.01); let mut fam: Vec = Vec::new(); if !st.font_family.is_empty() { @@ -577,32 +494,19 @@ fn style_props( feats.push(format!("\"{}\" {}", f.tag, f.value)); } } - let vars: Vec = st - .variations - .iter() - .filter(|v| v.axis.len() == 4 && v.axis.is_ascii()) - .map(|v| format!("\"{}\" {}", v.axis, v.value)) - .collect(); + let vars: Vec = st.variations.iter().filter(|v| v.axis.len() == 4 && v.axis.is_ascii()).map(|v| format!("\"{}\" {}", v.axis, v.value)).collect(); vec![ StyleProperty::FontFamily(FontFamily::Source(Cow::Owned(fam.join(", ")))), StyleProperty::FontSize(px), StyleProperty::FontWeight(FontWeight::new(st.weight.clamp(1, 1000) as f32)), - StyleProperty::FontStyle(if st.italic { - FontStyle::Italic - } else { - FontStyle::Normal - }), + StyleProperty::FontStyle(if st.italic { FontStyle::Italic } else { FontStyle::Normal }), StyleProperty::FontFeatures(FontFeatures::Source(Cow::Owned(feats.join(", ")))), StyleProperty::FontVariations(FontVariations::Source(Cow::Owned(vars.join(", ")))), StyleProperty::LetterSpacing(st.tracking / 1000.0 * px), StyleProperty::Underline(st.underline), StyleProperty::Strikethrough(st.strikethrough), StyleProperty::Brush(RunBrush(idx)), - StyleProperty::Locale( - st.language - .as_deref() - .and_then(|l| parley::fontique::Language::parse(l).ok()), - ), + StyleProperty::Locale(st.language.as_deref().and_then(|l| parley::fontique::Language::parse(l).ok())), ] } @@ -615,22 +519,13 @@ fn first_ascent(line: &parley::Line<'_, RunBrush>) -> Option { let Ok(font) = skrifa::FontRef::from_index(fd.data.as_ref(), fd.index) else { continue; }; - let coords: Vec = run - .normalized_coords() - .iter() - .map(|&c| skrifa::instance::NormalizedCoord::from_bits(c)) - .collect(); + let coords: Vec = run.normalized_coords().iter().map(|&c| skrifa::instance::NormalizedCoord::from_bits(c)).collect(); let loc = skrifa::instance::LocationRef::new(&coords); let size = skrifa::instance::Size::new(run.font_size()); - let h = font - .charmap() - .map('d') - .and_then(|g| font.glyph_metrics(size, loc).bounds(g)) - .map(|b| b.y_max) - .or_else(|| { - let m = font.metrics(size, loc); - m.cap_height.or(Some(m.ascent * 0.75)) - }); + let h = font.charmap().map('d').and_then(|g| font.glyph_metrics(size, loc).bounds(g)).map(|b| b.y_max).or_else(|| { + let m = font.metrics(size, loc); + m.cap_height.or(Some(m.ascent * 0.75)) + }); if let Some(h) = h { best = Some(best.map_or(h, |b: f32| b.max(h))); } diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 3dee905303..3b18ff4f1f 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -46,18 +46,12 @@ impl Default for TextEngine { impl TextEngine { /// Bundled fonts only: deterministic output (tests, web). pub fn new() -> Self { - Self { - fonts: FontDb::new(), - layouter: layout::Layouter::new(), - } + Self { fonts: FontDb::new(), layouter: layout::Layouter::new() } } /// Bundled plus installed system fonts (native desktop). pub fn with_system_fonts() -> Self { - Self { - fonts: FontDb::with_system_fonts(), - layouter: layout::Layouter::new(), - } + Self { fonts: FontDb::with_system_fonts(), layouter: layout::Layouter::new() } } /// Lays out a text layer (text space: pixels, before `layer.transform`). @@ -66,26 +60,15 @@ impl TextEngine { } /// Lays out and rasterizes a text layer into document space. - pub fn render( - &mut self, - layer: &TextLayer, - dpi: f32, - format: PixelFormat, - ) -> (TextLayout, Rendered) { + pub fn render(&mut self, layer: &TextLayer, dpi: f32, format: PixelFormat) -> (TextLayout, Rendered) { let l = self.layout(layer, dpi); let warp = render::layout_warp(&l, layer.warp.as_ref()); - let r = - render::rasterize_warped(&l, &layer.transform, format, layer.antialias, warp.as_ref()); + let r = render::rasterize_warped(&l, &layer.transform, format, layer.antialias, warp.as_ref()); (l, r) } /// Re-renders `layer.cache` from its model. Returns the document-space rectangle drawn. - pub fn render_layer( - &mut self, - layer: &mut TextLayer, - dpi: f32, - format: PixelFormat, - ) -> photocraft_geom::Rect { + pub fn render_layer(&mut self, layer: &mut TextLayer, dpi: f32, format: PixelFormat) -> photocraft_geom::Rect { let (_, r) = self.render(layer, dpi, format); layer.cache = Some(r.surface); r.rect @@ -97,14 +80,8 @@ impl TextEngine { pub fn shared() -> &'static std::sync::Mutex { static ENGINE: std::sync::OnceLock> = std::sync::OnceLock::new(); ENGINE.get_or_init(|| { - let use_system = cfg!(not(target_arch = "wasm32")) - && !cfg!(test) - && std::env::var_os("PHOTOCRAFT_NO_SYSTEM_FONTS").is_none(); - std::sync::Mutex::new(if use_system { - TextEngine::with_system_fonts() - } else { - TextEngine::new() - }) + let use_system = cfg!(not(target_arch = "wasm32")) && !cfg!(test) && std::env::var_os("PHOTOCRAFT_NO_SYSTEM_FONTS").is_none(); + std::sync::Mutex::new(if use_system { TextEngine::with_system_fonts() } else { TextEngine::new() }) }) } diff --git a/crates/text/src/psd.rs b/crates/text/src/psd.rs index ea121d6f98..6de79e35ac 100644 --- a/crates/text/src/psd.rs +++ b/crates/text/src/psd.rs @@ -11,10 +11,7 @@ use photocraft_color::{Color, ColorMode}; use photocraft_doc::TextLayer; -use photocraft_doc::text::{ - AntiAlias, Caps, CharStyle, Kerning, Orientation, ParagraphRun, ParagraphStyle, TextAlign, - TextRun, TextShape, TextWarp, -}; +use photocraft_doc::text::{AntiAlias, Caps, CharStyle, Kerning, Orientation, ParagraphRun, ParagraphStyle, TextAlign, TextRun, TextShape, TextWarp}; use photocraft_geom::Affine; use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value as D, VersionedDescriptor}; @@ -33,20 +30,14 @@ pub struct TySh { /// Parses a `TySh` block. pub fn parse_tysh(data: &[u8]) -> Option { - let f = |at: usize| { - data.get(at..at + 8) - .map(|b| f64::from_be_bytes(b.try_into().unwrap_or([0; 8]))) - }; + let f = |at: usize| data.get(at..at + 8).map(|b| f64::from_be_bytes(b.try_into().unwrap_or([0; 8]))); let mut m = [0.0; 6]; for (i, v) in m.iter_mut().enumerate() { *v = f(2 + i * 8)?; } let (text, used) = VersionedDescriptor::parse_prefix(data.get(52..)?).ok()?; let rest = data.get(52 + used..).unwrap_or(&[]); - let (warp, wused) = match rest - .get(2..) - .and_then(|r| VersionedDescriptor::parse_prefix(r).ok()) - { + let (warp, wused) = match rest.get(2..).and_then(|r| VersionedDescriptor::parse_prefix(r).ok()) { Some((w, n)) => (Some(w.descriptor), n + 2), None => (None, 0), }; @@ -57,12 +48,7 @@ pub fn parse_tysh(data: &[u8]) -> Option { *b = i32::from_be_bytes(x.try_into().unwrap_or([0; 4])); } } - Some(TySh { - transform: Affine { m }, - text: text.descriptor, - warp, - bounds, - }) + Some(TySh { transform: Affine { m }, text: text.descriptor, warp, bounds }) } /// Serializes a `TySh` block. @@ -91,17 +77,12 @@ fn default_warp() -> Descriptor { } fn enumv(t: &str, v: &str) -> D { - D::Enumerated { - type_id: Id::new(t), - value: Id::new(v), - } + D::Enumerated { type_id: Id::new(t), value: Id::new(v) } } fn enum_value(d: &Descriptor, key: &str) -> Option { match d.get(key) { - Some(D::Enumerated { value, .. }) => { - Some(String::from_utf8_lossy(value.as_bytes()).into_owned()) - } + Some(D::Enumerated { value, .. }) => Some(String::from_utf8_lossy(value.as_bytes()).into_owned()), _ => None, } } @@ -126,19 +107,12 @@ pub fn engine_data(text: &Descriptor) -> Option { /// Builds a [`TextLayer`] (model, text and transform; no cache, no `psd_raw`) from `TySh` data. pub fn text_layer_from_tysh(data: &[u8], dpi: f32) -> Option { let t = parse_tysh(data)?; - let mut layer = TextLayer { - transform: t.transform, - ..Default::default() - }; + let mut layer = TextLayer { transform: t.transform, ..Default::default() }; let txt = match t.text.get("Txt ") { Some(D::Text(s)) => Some(s.to_string_lossy().trim_end_matches('\0').to_string()), _ => None, }; - layer.orientation = if enum_value(&t.text, "Ornt").as_deref() == Some("Vrtc") { - Orientation::Vertical - } else { - Orientation::Horizontal - }; + layer.orientation = if enum_value(&t.text, "Ornt").as_deref() == Some("Vrtc") { Orientation::Vertical } else { Orientation::Horizontal }; layer.antialias = match enum_value(&t.text, "AntA").as_deref() { Some("Anno" | "antiAliasNone") => AntiAlias::None, Some("antiAliasSharp" | "AnSh") => AntiAlias::Sharp, @@ -171,9 +145,7 @@ pub fn text_layer_from_tysh(data: &[u8], dpi: f32) -> Option { } pub(crate) fn arr_f(v: Option<&E>) -> Vec { - v.and_then(E::as_array) - .map(|a| a.iter().filter_map(E::as_f64).collect()) - .unwrap_or_default() + v.and_then(E::as_array).map(|a| a.iter().filter_map(E::as_f64).collect()).unwrap_or_default() } /// Converts UTF-16 run lengths over `text` into UTF-8 byte lengths. @@ -198,39 +170,20 @@ fn utf16_to_byte_lengths(text: &str, lens: &[f64]) -> Vec { } fn apply_engine_data(layer: &mut TextLayer, e: &E, txt: Option<&str>, k: f32) { - let ed_text = e - .path(&["EngineDict", "Editor", "Text"]) - .and_then(E::as_str) - .unwrap_or("") - .to_string(); + let ed_text = e.path(&["EngineDict", "Editor", "Text"]).and_then(E::as_str).unwrap_or("").to_string(); // Photoshop always ends the engine text with a paragraph break; the descriptor text doesn't. - let text = txt - .map(str::to_string) - .unwrap_or_else(|| ed_text.strip_suffix('\r').unwrap_or(&ed_text).to_string()); + let text = txt.map(str::to_string).unwrap_or_else(|| ed_text.strip_suffix('\r').unwrap_or(&ed_text).to_string()); layer.text = text.replace('\r', "\n"); let res = e.get("ResourceDict").or_else(|| e.get("DocumentResources")); let fonts: Vec = res .and_then(|r| r.get("FontSet")) .and_then(E::as_array) - .map(|a| { - a.iter() - .map(|f| f.get("Name").and_then(E::as_str).unwrap_or("").to_string()) - .collect() - }) + .map(|a| a.iter().map(|f| f.get("Name").and_then(E::as_str).unwrap_or("").to_string()).collect()) .unwrap_or_default(); - let default_sheet = res - .and_then(|r| r.get("TheNormalStyleSheet")) - .and_then(E::as_i64) - .unwrap_or(0) as usize; - let base_style = res - .and_then(|r| r.get("StyleSheetSet")) - .and_then(E::as_array) - .and_then(|a| a.get(default_sheet).or(a.first())) - .and_then(|s| s.get("StyleSheetData")); - let default_para_sheet = res - .and_then(|r| r.get("TheNormalParagraphSheet")) - .and_then(E::as_i64) - .unwrap_or(0) as usize; + let default_sheet = res.and_then(|r| r.get("TheNormalStyleSheet")).and_then(E::as_i64).unwrap_or(0) as usize; + let base_style = + res.and_then(|r| r.get("StyleSheetSet")).and_then(E::as_array).and_then(|a| a.get(default_sheet).or(a.first())).and_then(|s| s.get("StyleSheetData")); + let default_para_sheet = res.and_then(|r| r.get("TheNormalParagraphSheet")).and_then(E::as_i64).unwrap_or(0) as usize; let base_para = res .and_then(|r| r.get("ParagraphSheetSet")) .and_then(E::as_array) @@ -242,59 +195,29 @@ fn apply_engine_data(layer: &mut TextLayer, e: &E, txt: Option<&str>, k: f32) { let sdata: Vec<&E> = srun .and_then(|r| r.get("RunArray")) .and_then(E::as_array) - .map(|a| { - a.iter() - .filter_map(|x| x.path(&["StyleSheet", "StyleSheetData"])) - .collect() - }) + .map(|a| a.iter().filter_map(|x| x.path(&["StyleSheet", "StyleSheetData"])).collect()) .unwrap_or_default(); let slens = utf16_to_byte_lengths(&ed_text, &arr_f(srun.and_then(|r| r.get("RunLengthArray")))); - layer.runs = sdata - .iter() - .zip(slens) - .map(|(d, len)| TextRun { - len, - style: char_style(base_style, d, &fonts, k), - }) - .collect(); + layer.runs = sdata.iter().zip(slens).map(|(d, len)| TextRun { len, style: char_style(base_style, d, &fonts, k) }).collect(); // Paragraph runs. let prun = e.path(&["EngineDict", "ParagraphRun"]); let pdata: Vec<&E> = prun .and_then(|r| r.get("RunArray")) .and_then(E::as_array) - .map(|a| { - a.iter() - .filter_map(|x| x.path(&["ParagraphSheet", "Properties"])) - .collect() - }) + .map(|a| a.iter().filter_map(|x| x.path(&["ParagraphSheet", "Properties"])).collect()) .unwrap_or_default(); let plens = utf16_to_byte_lengths(&ed_text, &arr_f(prun.and_then(|r| r.get("RunLengthArray")))); - layer.paragraphs = pdata - .iter() - .zip(plens) - .map(|(d, len)| ParagraphRun { - len, - style: para_style(base_para, d, k), - }) - .collect(); + layer.paragraphs = pdata.iter().zip(plens).map(|(d, len)| ParagraphRun { len, style: para_style(base_para, d, k) }).collect(); // Shape. - let shape = e - .path(&["EngineDict", "Rendered", "Shapes", "Children"]) - .and_then(E::as_array) - .and_then(|a| a.first()); + let shape = e.path(&["EngineDict", "Rendered", "Shapes", "Children"]).and_then(E::as_array).and_then(|a| a.first()); if let Some(sh) = shape && sh.get("ShapeType").and_then(E::as_i64) == Some(1) { let b = arr_f(sh.path(&["Cookie", "Photoshop", "BoxBounds"])); if b.len() == 4 { - layer.shape = TextShape::Box { - x: b[0] as f32, - y: b[1] as f32, - width: (b[2] - b[0]) as f32, - height: (b[3] - b[1]) as f32, - }; + layer.shape = TextShape::Box { x: b[0] as f32, y: b[1] as f32, width: (b[2] - b[0]) as f32, height: (b[3] - b[1]) as f32 }; } } } @@ -308,10 +231,7 @@ pub(crate) fn char_style(base: Option<&E>, d: &E, fonts: &[String], k: f32) -> C let num = |key: &str| g(key).and_then(E::as_f64); let flag = |key: &str| g(key).and_then(E::as_bool); let mut s = CharStyle::default(); - if let Some(ps) = g("Font") - .and_then(E::as_i64) - .and_then(|i| fonts.get(i as usize)) - { + if let Some(ps) = g("Font").and_then(E::as_i64).and_then(|i| fonts.get(i as usize)) { let r = guess_from_postscript(ps); s.font_family = r.family; s.weight = r.weight; @@ -338,17 +258,10 @@ pub(crate) fn char_style(base: Option<&E>, d: &E, fonts: &[String], k: f32) -> C s.discretionary_ligatures = flag("DLigatures").unwrap_or(false); for (key, tag) in OPENTYPE_KEYS { if flag(key) == Some(true) { - s.features.push(photocraft_doc::text::FontFeature { - tag: tag.to_string(), - value: 1, - }); + s.features.push(photocraft_doc::text::FontFeature { tag: tag.to_string(), value: 1 }); } } - s.kerning = if flag("AutoKerning") == Some(false) { - Kerning::Off - } else { - Kerning::Metrics - }; + s.kerning = if flag("AutoKerning") == Some(false) { Kerning::Off } else { Kerning::Metrics }; s.caps = match g("FontCaps").and_then(E::as_i64) { Some(1) => Caps::SmallCaps, Some(2) => Caps::AllCaps, @@ -358,16 +271,8 @@ pub(crate) fn char_style(base: Option<&E>, d: &E, fonts: &[String], k: f32) -> C let v = arr_f(c.get("Values")); let a = v.first().copied().unwrap_or(1.0) as f32; s.color = match v.len() { - 5 => Color { - mode: ColorMode::Cmyk, - c: [v[1] as f32, v[2] as f32, v[3] as f32, v[4] as f32], - alpha: a, - }, - 2 => Color { - mode: ColorMode::Grayscale, - c: [v[1] as f32, 0.0, 0.0, 0.0], - alpha: a, - }, + 5 => Color { mode: ColorMode::Cmyk, c: [v[1] as f32, v[2] as f32, v[3] as f32, v[4] as f32], alpha: a }, + 2 => Color { mode: ColorMode::Grayscale, c: [v[1] as f32, 0.0, 0.0, 0.0], alpha: a }, 4 => Color::rgba(v[1] as f32, v[2] as f32, v[3] as f32, a), _ => Color::BLACK, }; @@ -393,9 +298,7 @@ pub(crate) fn para_style(base: Option<&E>, d: &E, k: f32) -> ParagraphStyle { space_before_pt: num("SpaceBefore").unwrap_or(0.0) as f32 * k, space_after_pt: num("SpaceAfter").unwrap_or(0.0) as f32 * k, auto_leading: num("AutoLeading").unwrap_or(1.2) as f32, - hyphenate: lookup(d, base, "AutoHyphenate") - .and_then(E::as_bool) - .unwrap_or(false), + hyphenate: lookup(d, base, "AutoHyphenate").and_then(E::as_bool).unwrap_or(false), ..Default::default() } } @@ -411,11 +314,7 @@ pub(crate) fn postscript_for(s: &CharStyle) -> String { if let Some(ps) = &s.postscript_name { return ps.clone(); } - let fam = if s.font_family.is_empty() { - crate::fonts::DEFAULT_FAMILY - } else { - &s.font_family - }; + let fam = if s.font_family.is_empty() { crate::fonts::DEFAULT_FAMILY } else { &s.font_family }; let w = match s.weight { 0..=150 => "Thin", 151..=250 => "ExtraLight", @@ -451,13 +350,7 @@ pub const OPENTYPE_KEYS: [(&str, &str); 8] = [ pub(crate) fn style_sheet_data(s: &CharStyle, font: usize, k: f32) -> E { let c = &s.color; let values = match c.mode { - ColorMode::Cmyk => vec![ - real(c.alpha), - real(c.c[0]), - real(c.c[1]), - real(c.c[2]), - real(c.c[3]), - ], + ColorMode::Cmyk => vec![real(c.alpha), real(c.c[0]), real(c.c[1]), real(c.c[2]), real(c.c[3])], ColorMode::Grayscale => vec![real(c.alpha), real(c.c[0])], _ => { let [r, g, b] = c.to_rgb(); @@ -499,13 +392,7 @@ pub(crate) fn style_sheet_data(s: &CharStyle, font: usize, k: f32) -> E { ("Strikethrough".into(), E::Bool(s.strikethrough)), ("Ligatures".into(), E::Bool(s.ligatures)), ("DLigatures".into(), E::Bool(s.discretionary_ligatures)), - ( - "FillColor".into(), - E::Dict(vec![ - ("Type".into(), E::Int(color_type)), - ("Values".into(), E::Array(values)), - ]), - ), + ("FillColor".into(), E::Dict(vec![("Type".into(), E::Int(color_type)), ("Values".into(), E::Array(values))])), ]; dict.append(&mut opentype); E::Dict(dict) @@ -539,23 +426,13 @@ fn utf16_len(s: &str) -> i64 { } pub(crate) fn font_entry(name: &str) -> E { - E::Dict(vec![ - ("Name".into(), E::String(name.into())), - ("Script".into(), E::Int(0)), - ("FontType".into(), E::Int(1)), - ("Synthetic".into(), E::Int(0)), - ]) + E::Dict(vec![("Name".into(), E::String(name.into())), ("Script".into(), E::Int(0)), ("FontType".into(), E::Int(1)), ("Synthetic".into(), E::Int(0))]) } /// Updates (or creates) an EngineData tree for `layer`. pub fn build_engine_data(layer: &TextLayer, template: Option, dpi: f32) -> E { let k = 72.0 / if dpi > 0.0 { dpi } else { 72.0 }; - let mut e = template.unwrap_or_else(|| { - E::Dict(vec![ - ("EngineDict".into(), E::dict()), - ("ResourceDict".into(), E::dict()), - ]) - }); + let mut e = template.unwrap_or_else(|| E::Dict(vec![("EngineDict".into(), E::dict()), ("ResourceDict".into(), E::dict())])); let runs = layer.char_runs(); let paras = layer.paragraph_runs(); let text = layer.text.replace('\n', "\r"); @@ -565,11 +442,7 @@ pub fn build_engine_data(layer: &TextLayer, template: Option, dpi: f32) -> E let mut fonts: Vec = e .path(&["ResourceDict", "FontSet"]) .and_then(E::as_array) - .map(|a| { - a.iter() - .map(|f| f.get("Name").and_then(E::as_str).unwrap_or("").to_string()) - .collect() - }) + .map(|a| a.iter().map(|f| f.get("Name").and_then(E::as_str).unwrap_or("").to_string()).collect()) .unwrap_or_default(); let mut font_index = |ps: String| match fonts.iter().position(|f| *f == ps) { Some(i) => i, @@ -589,13 +462,7 @@ pub fn build_engine_data(layer: &TextLayer, template: Option, dpi: f32) -> E n += 1; // trailing paragraph break } let fi = font_index(postscript_for(&r.style)); - sruns.push(E::Dict(vec![( - "StyleSheet".into(), - E::Dict(vec![( - "StyleSheetData".into(), - style_sheet_data(&r.style, fi, k), - )]), - )])); + sruns.push(E::Dict(vec![("StyleSheet".into(), E::Dict(vec![("StyleSheetData".into(), style_sheet_data(&r.style, fi, k))]))])); slens.push(E::Int(n)); } let mut pruns = Vec::new(); @@ -610,10 +477,7 @@ pub fn build_engine_data(layer: &TextLayer, template: Option, dpi: f32) -> E } pruns.push(E::Dict(vec![( "ParagraphSheet".into(), - E::Dict(vec![ - ("DefaultStyleSheet".into(), E::Int(0)), - ("Properties".into(), paragraph_properties(&p.style, k)), - ]), + E::Dict(vec![("DefaultStyleSheet".into(), E::Int(0)), ("Properties".into(), paragraph_properties(&p.style, k))]), )])); plens.push(E::Int(n)); } @@ -654,12 +518,7 @@ pub fn build_engine_data(layer: &TextLayer, template: Option, dpi: f32) -> E } let (shape_type, box_bounds) = match layer.shape { TextShape::Point => (0, None), - TextShape::Box { - x, - y, - width, - height, - } => (1, Some([x, y, x + width, y + height])), + TextShape::Box { x, y, width, height } => (1, Some([x, y, x + width, y + height])), }; let mut photoshop = E::Dict(vec![("ShapeType".into(), E::Int(shape_type))]); match box_bounds { @@ -670,46 +529,22 @@ pub fn build_engine_data(layer: &TextLayer, template: Option, dpi: f32) -> E "Base", E::Dict(vec![ ("ShapeType".into(), E::Int(shape_type)), - ( - "TransformPoint0".into(), - E::Array(vec![real(1.0), real(0.0)]), - ), - ( - "TransformPoint1".into(), - E::Array(vec![real(0.0), real(1.0)]), - ), - ( - "TransformPoint2".into(), - E::Array(vec![real(0.0), real(0.0)]), - ), + ("TransformPoint0".into(), E::Array(vec![real(1.0), real(0.0)])), + ("TransformPoint1".into(), E::Array(vec![real(0.0), real(1.0)])), + ("TransformPoint2".into(), E::Array(vec![real(0.0), real(0.0)])), ]), ); let child = E::Dict(vec![ ("ShapeType".into(), E::Int(shape_type)), ("Procession".into(), E::Int(0)), - ( - "Lines".into(), - E::Dict(vec![ - ("WritingDirection".into(), E::Int(0)), - ("Children".into(), E::Array(vec![])), - ]), - ), - ( - "Cookie".into(), - E::Dict(vec![("Photoshop".into(), photoshop)]), - ), + ("Lines".into(), E::Dict(vec![("WritingDirection".into(), E::Int(0)), ("Children".into(), E::Array(vec![]))])), + ("Cookie".into(), E::Dict(vec![("Photoshop".into(), photoshop)])), ]); dict.set( "Rendered", E::Dict(vec![ ("Version".into(), E::Int(1)), - ( - "Shapes".into(), - E::Dict(vec![ - ("WritingDirection".into(), E::Int(0)), - ("Children".into(), E::Array(vec![child])), - ]), - ), + ("Shapes".into(), E::Dict(vec![("WritingDirection".into(), E::Int(0)), ("Children".into(), E::Array(vec![child]))])), ]), ); @@ -725,10 +560,7 @@ pub fn build_engine_data(layer: &TextLayer, template: Option, dpi: f32) -> E let base = runs.first().map(|r| r.style.clone()).unwrap_or_default(); rd.set( "StyleSheetSet", - E::Array(vec![E::Dict(vec![ - ("Name".into(), E::String("Normal RGB".into())), - ("StyleSheetData".into(), style_sheet_data(&base, 0, k)), - ])]), + E::Array(vec![E::Dict(vec![("Name".into(), E::String("Normal RGB".into())), ("StyleSheetData".into(), style_sheet_data(&base, 0, k))])]), ); } if rd.get("ParagraphSheetSet").is_none() { @@ -737,10 +569,7 @@ pub fn build_engine_data(layer: &TextLayer, template: Option, dpi: f32) -> E E::Array(vec![E::Dict(vec![ ("Name".into(), E::String("Normal RGB".into())), ("DefaultStyleSheet".into(), E::Int(0)), - ( - "Properties".into(), - paragraph_properties(&ParagraphStyle::default(), k), - ), + ("Properties".into(), paragraph_properties(&ParagraphStyle::default(), k)), ])]), ); } @@ -759,35 +588,16 @@ pub fn build_tysh(layer: &TextLayer, dpi: f32, ink: Option<[f32; 4]>) -> Vec let old = layer.psd_raw.as_deref().and_then(|d| parse_tysh(d)); let template = old.as_ref().and_then(|t| engine_data(&t.text)); let e = build_engine_data(layer, template, dpi); - let mut text = old - .as_ref() - .map(|t| t.text.clone()) - .unwrap_or_else(|| Descriptor::new("TxLr")); - let set = - |d: &mut Descriptor, key: &str, v: D| match d.items.iter_mut().find(|(k, _)| k.is(key)) { - Some(e) => e.1 = v, - None => d.items.push((Id::new(key), v)), - }; - set( - &mut text, - "Txt ", - D::Text(UnicodeString::new_nul(&layer.text.replace('\n', "\r"))), - ); + let mut text = old.as_ref().map(|t| t.text.clone()).unwrap_or_else(|| Descriptor::new("TxLr")); + let set = |d: &mut Descriptor, key: &str, v: D| match d.items.iter_mut().find(|(k, _)| k.is(key)) { + Some(e) => e.1 = v, + None => d.items.push((Id::new(key), v)), + }; + set(&mut text, "Txt ", D::Text(UnicodeString::new_nul(&layer.text.replace('\n', "\r")))); if text.get("textGridding").is_none() { set(&mut text, "textGridding", enumv("textGridding", "None")); } - set( - &mut text, - "Ornt", - enumv( - "Ornt", - if layer.orientation == Orientation::Vertical { - "Vrtc" - } else { - "Hrzn" - }, - ), - ); + set(&mut text, "Ornt", enumv("Ornt", if layer.orientation == Orientation::Vertical { "Vrtc" } else { "Hrzn" })); set( &mut text, "AntA", @@ -805,49 +615,17 @@ pub fn build_tysh(layer: &TextLayer, dpi: f32, ink: Option<[f32; 4]>) -> Vec ), ); let [l, t, r, b] = match (layer.shape, ink) { - ( - TextShape::Box { - x, - y, - width, - height, - }, - _, - ) => [x, y, x + width, y + height], + (TextShape::Box { x, y, width, height }, _) => [x, y, x + width, y + height], (_, Some(i)) => i, _ => [0.0; 4], }; let rect = |cls: &str| { D::Descriptor( Descriptor::new(cls) - .with( - "Left", - D::UnitFloat { - unit: *b"#Pnt", - value: f64::from(l), - }, - ) - .with( - "Top ", - D::UnitFloat { - unit: *b"#Pnt", - value: f64::from(t), - }, - ) - .with( - "Rght", - D::UnitFloat { - unit: *b"#Pnt", - value: f64::from(r), - }, - ) - .with( - "Btom", - D::UnitFloat { - unit: *b"#Pnt", - value: f64::from(b), - }, - ), + .with("Left", D::UnitFloat { unit: *b"#Pnt", value: f64::from(l) }) + .with("Top ", D::UnitFloat { unit: *b"#Pnt", value: f64::from(t) }) + .with("Rght", D::UnitFloat { unit: *b"#Pnt", value: f64::from(r) }) + .with("Btom", D::UnitFloat { unit: *b"#Pnt", value: f64::from(b) }), ) }; set(&mut text, "bounds", rect("bounds")); @@ -861,34 +639,13 @@ pub fn build_tysh(layer: &TextLayer, dpi: f32, ink: Option<[f32; 4]>) -> Vec Descriptor::new("warp") .with("warpStyle", enumv("warpStyle", &w.style)) .with("warpValue", D::Double(f64::from(w.value))) - .with( - "warpPerspective", - D::Double(f64::from(w.horizontal_distortion)), - ) - .with( - "warpPerspectiveOther", - D::Double(f64::from(w.vertical_distortion)), - ) - .with( - "warpRotate", - enumv("Ornt", if w.horizontal { "Hrzn" } else { "Vrtc" }), - ), + .with("warpPerspective", D::Double(f64::from(w.horizontal_distortion))) + .with("warpPerspectiveOther", D::Double(f64::from(w.vertical_distortion))) + .with("warpRotate", enumv("Ornt", if w.horizontal { "Hrzn" } else { "Vrtc" })), ), - (None, Some(old)) if enum_value(&old, "warpStyle").as_deref() == Some("warpNone") => { - Some(old) - } + (None, Some(old)) if enum_value(&old, "warpStyle").as_deref() == Some("warpNone") => Some(old), _ => None, }; - let bounds = [ - l.floor() as i32, - t.floor() as i32, - r.ceil() as i32, - b.ceil() as i32, - ]; - write_tysh(&TySh { - transform: layer.transform, - text, - warp, - bounds, - }) + let bounds = [l.floor() as i32, t.floor() as i32, r.ceil() as i32, b.ceil() as i32]; + write_tysh(&TySh { transform: layer.transform, text, warp, bounds }) } diff --git a/crates/text/src/psd_styles.rs b/crates/text/src/psd_styles.rs index abd418ed95..97d9e7180c 100644 --- a/crates/text/src/psd_styles.rs +++ b/crates/text/src/psd_styles.rs @@ -15,10 +15,7 @@ use photocraft_doc::text::{CharStyle, ParagraphStyle}; use photocraft_doc::{TextLayer, TextStyles}; use crate::engine_data::Value as E; -use crate::psd::{ - char_style, engine_data, para_style, paragraph_properties, parse_tysh, postscript_for, - style_sheet_data, write_tysh, -}; +use crate::psd::{char_style, engine_data, para_style, paragraph_properties, parse_tysh, postscript_for, style_sheet_data, write_tysh}; /// Style sheets read from one type layer. #[derive(Clone, Debug, Default, PartialEq)] @@ -42,26 +39,16 @@ fn k_of(dpi: f32) -> f32 { } fn names(set: Option<&E>) -> Vec { - set.and_then(E::as_array) - .map(|a| { - a.iter() - .map(|s| s.get("Name").and_then(E::as_str).unwrap_or("").to_string()) - .collect() - }) - .unwrap_or_default() + set.and_then(E::as_array).map(|a| a.iter().map(|s| s.get("Name").and_then(E::as_str).unwrap_or("").to_string()).collect()).unwrap_or_default() } /// Index a run's sheet dictionary refers to (`Parent` index, else `Name`), skipping `normal`. fn reference(sheet: Option<&E>, set_names: &[String], normal: usize) -> Option { let s = sheet?; - let i = s - .get("Parent") - .and_then(E::as_i64) - .map(|i| i as usize) - .or_else(|| { - let n = s.get("Name").and_then(E::as_str)?; - set_names.iter().position(|x| x == n) - })?; + let i = s.get("Parent").and_then(E::as_i64).map(|i| i as usize).or_else(|| { + let n = s.get("Name").and_then(E::as_str)?; + set_names.iter().position(|x| x == n) + })?; (i != normal && i < set_names.len()).then_some(i) } @@ -70,44 +57,25 @@ pub fn read_style_sheets(tysh: &[u8], dpi: f32) -> Option { let t = parse_tysh(tysh)?; let e = engine_data(&t.text)?; let k = k_of(dpi); - let res = e - .get("ResourceDict") - .or_else(|| e.get("DocumentResources"))?; + let res = e.get("ResourceDict").or_else(|| e.get("DocumentResources"))?; let fonts: Vec = names(res.get("FontSet")); - let sset = res - .get("StyleSheetSet") - .and_then(E::as_array) - .unwrap_or(&[]); - let pset = res - .get("ParagraphSheetSet") - .and_then(E::as_array) - .unwrap_or(&[]); - let normal_c = res - .get("TheNormalStyleSheet") - .and_then(E::as_i64) - .unwrap_or(0) as usize; - let normal_p = res - .get("TheNormalParagraphSheet") - .and_then(E::as_i64) - .unwrap_or(0) as usize; + let sset = res.get("StyleSheetSet").and_then(E::as_array).unwrap_or(&[]); + let pset = res.get("ParagraphSheetSet").and_then(E::as_array).unwrap_or(&[]); + let normal_c = res.get("TheNormalStyleSheet").and_then(E::as_i64).unwrap_or(0) as usize; + let normal_p = res.get("TheNormalParagraphSheet").and_then(E::as_i64).unwrap_or(0) as usize; let empty = E::dict(); let base_data = sset.get(normal_c).and_then(|s| s.get("StyleSheetData")); let normal_char = char_style(None, base_data.unwrap_or(&empty), &fonts, k); let base_props = pset.get(normal_p).and_then(|s| s.get("Properties")); let normal_para = para_style(None, base_props.unwrap_or(&empty), k); - let mut out = PsdStyleSheets { - normal_char, - normal_para, - ..Default::default() - }; + let mut out = PsdStyleSheets { normal_char, normal_para, ..Default::default() }; for (i, s) in sset.iter().enumerate() { if i == normal_c { continue; } let name = s.get("Name").and_then(E::as_str).unwrap_or("").to_string(); let data = s.get("StyleSheetData").unwrap_or(&empty); - out.char_sheets - .push((i, name, char_style(base_data, data, &fonts, k))); + out.char_sheets.push((i, name, char_style(base_data, data, &fonts, k))); } for (i, s) in pset.iter().enumerate() { if i == normal_p { @@ -115,41 +83,17 @@ pub fn read_style_sheets(tysh: &[u8], dpi: f32) -> Option { } let name = s.get("Name").and_then(E::as_str).unwrap_or("").to_string(); let props = s.get("Properties").unwrap_or(&empty); - let dss = s - .get("DefaultStyleSheet") - .and_then(E::as_i64) - .map(|i| i as usize) - .filter(|i| *i != normal_c); - let cs = dss.and_then(|d| sset.get(d)).map(|d| { - char_style( - base_data, - d.get("StyleSheetData").unwrap_or(&empty), - &fonts, - k, - ) - }); - out.para_sheets - .push((i, name, para_style(base_props, props, k), cs)); + let dss = s.get("DefaultStyleSheet").and_then(E::as_i64).map(|i| i as usize).filter(|i| *i != normal_c); + let cs = dss.and_then(|d| sset.get(d)).map(|d| char_style(base_data, d.get("StyleSheetData").unwrap_or(&empty), &fonts, k)); + out.para_sheets.push((i, name, para_style(base_props, props, k), cs)); } let snames = names(res.get("StyleSheetSet")); let pnames = names(res.get("ParagraphSheetSet")); - if let Some(a) = e - .path(&["EngineDict", "StyleRun", "RunArray"]) - .and_then(E::as_array) - { - out.run_char = a - .iter() - .map(|r| reference(r.get("StyleSheet"), &snames, normal_c)) - .collect(); + if let Some(a) = e.path(&["EngineDict", "StyleRun", "RunArray"]).and_then(E::as_array) { + out.run_char = a.iter().map(|r| reference(r.get("StyleSheet"), &snames, normal_c)).collect(); } - if let Some(a) = e - .path(&["EngineDict", "ParagraphRun", "RunArray"]) - .and_then(E::as_array) - { - out.run_para = a - .iter() - .map(|r| reference(r.get("ParagraphSheet"), &pnames, normal_p)) - .collect(); + if let Some(a) = e.path(&["EngineDict", "ParagraphRun", "RunArray"]).and_then(E::as_array) { + out.run_para = a.iter().map(|r| reference(r.get("ParagraphSheet"), &pnames, normal_p)).collect(); } Some(out) } @@ -174,36 +118,17 @@ pub fn has_named_sheets(tysh: &[u8]) -> bool { /// Rewrites the style sheets of a `TySh` block from the document's styles and the layer's run /// references. The normal sheets are kept; other sheets are replaced by `styles`. -pub fn write_style_sheets( - tysh: &[u8], - layer: &TextLayer, - styles: &TextStyles, - dpi: f32, -) -> Option> { +pub fn write_style_sheets(tysh: &[u8], layer: &TextLayer, styles: &TextStyles, dpi: f32) -> Option> { let mut t = parse_tysh(tysh)?; let mut e = engine_data(&t.text)?; let k = k_of(dpi); for key in ["ResourceDict", "DocumentResources"] { let Some(rd) = e.get_mut(key) else { continue }; let mut fonts = names(rd.get("FontSet")); - let normal_c = rd - .get("TheNormalStyleSheet") - .and_then(E::as_i64) - .unwrap_or(0) as usize; - let normal_p = rd - .get("TheNormalParagraphSheet") - .and_then(E::as_i64) - .unwrap_or(0) as usize; - let keep_c = rd - .get("StyleSheetSet") - .and_then(E::as_array) - .and_then(|a| a.get(normal_c).or(a.first())) - .cloned(); - let keep_p = rd - .get("ParagraphSheetSet") - .and_then(E::as_array) - .and_then(|a| a.get(normal_p).or(a.first())) - .cloned(); + let normal_c = rd.get("TheNormalStyleSheet").and_then(E::as_i64).unwrap_or(0) as usize; + let normal_p = rd.get("TheNormalParagraphSheet").and_then(E::as_i64).unwrap_or(0) as usize; + let keep_c = rd.get("StyleSheetSet").and_then(E::as_array).and_then(|a| a.get(normal_c).or(a.first())).cloned(); + let keep_p = rd.get("ParagraphSheetSet").and_then(E::as_array).and_then(|a| a.get(normal_p).or(a.first())).cloned(); let mut font_index = |s: &CharStyle| { let ps = postscript_for(s); match fonts.iter().position(|f| *f == ps) { @@ -238,25 +163,12 @@ pub fn write_style_sheets( rd.set("ParagraphSheetSet", E::Array(pset)); rd.set("TheNormalStyleSheet", E::Int(0)); rd.set("TheNormalParagraphSheet", E::Int(0)); - rd.set( - "FontSet", - E::Array(fonts.iter().map(|f| crate::psd::font_entry(f)).collect()), - ); + rd.set("FontSet", E::Array(fonts.iter().map(|f| crate::psd::font_entry(f)).collect())); } - let char_ix = |id: Option| { - id.and_then(|id| styles.character.iter().position(|d| d.id == id)) - .map(|i| i as i64 + 1) - }; - let para_ix = |id: Option| { - id.and_then(|id| styles.paragraph.iter().position(|d| d.id == id)) - .map(|i| i as i64 + 1) - }; + let char_ix = |id: Option| id.and_then(|id| styles.character.iter().position(|d| d.id == id)).map(|i| i as i64 + 1); + let para_ix = |id: Option| id.and_then(|id| styles.paragraph.iter().position(|d| d.id == id)).map(|i| i as i64 + 1); let runs = layer.char_runs(); - if let Some(E::Array(a)) = e - .get_mut("EngineDict") - .and_then(|d| d.get_mut("StyleRun")) - .and_then(|r| r.get_mut("RunArray")) - { + if let Some(E::Array(a)) = e.get_mut("EngineDict").and_then(|d| d.get_mut("StyleRun")).and_then(|r| r.get_mut("RunArray")) { for (i, r) in a.iter_mut().enumerate() { if let Some(sheet) = r.get_mut("StyleSheet") { remove_key(sheet, "Parent"); @@ -268,11 +180,7 @@ pub fn write_style_sheets( } } let paras = layer.paragraph_runs(); - if let Some(E::Array(a)) = e - .get_mut("EngineDict") - .and_then(|d| d.get_mut("ParagraphRun")) - .and_then(|r| r.get_mut("RunArray")) - { + if let Some(E::Array(a)) = e.get_mut("EngineDict").and_then(|d| d.get_mut("ParagraphRun")).and_then(|r| r.get_mut("RunArray")) { for (i, r) in a.iter_mut().enumerate() { if let Some(sheet) = r.get_mut("ParagraphSheet") { remove_key(sheet, "Parent"); @@ -303,48 +211,20 @@ mod tests { styles.character.push(CharacterStyleDef { id: 4, name: "Emphasis".into(), - attrs: diff_attrs( - &CharStyle { - size_pt: 30.0, - underline: true, - ..Default::default() - }, - &CharStyle::default(), - ), + attrs: diff_attrs(&CharStyle { size_pt: 30.0, underline: true, ..Default::default() }, &CharStyle::default()), }); styles.paragraph.push(ParagraphStyleDef { id: 2, name: "Centered".into(), - para_attrs: diff_attrs( - &ParagraphStyle { - align: TextAlign::Center, - ..Default::default() - }, - &ParagraphStyle::default(), - ), + para_attrs: diff_attrs(&ParagraphStyle { align: TextAlign::Center, ..Default::default() }, &ParagraphStyle::default()), ..Default::default() }); - let base = CharStyle { - font_family: crate::fonts::DEFAULT_FAMILY.into(), - ..Default::default() - }; + let base = CharStyle { font_family: crate::fonts::DEFAULT_FAMILY.into(), ..Default::default() }; let emph = styles.resolve_char(Some(2), Some(4), crate::fonts::DEFAULT_FAMILY); let mut layer = TextLayer { text: "plain bold".into(), - runs: vec![ - TextRun { - len: 6, - style: base.clone(), - }, - TextRun { - len: 4, - style: emph, - }, - ], - paragraphs: vec![photocraft_doc::text::ParagraphRun { - len: 10, - style: styles.resolve_para(Some(2)), - }], + runs: vec![TextRun { len: 6, style: base.clone() }, TextRun { len: 4, style: emph }], + paragraphs: vec![photocraft_doc::text::ParagraphRun { len: 10, style: styles.resolve_para(Some(2)) }], ..Default::default() }; layer.sync_summary(); @@ -355,10 +235,7 @@ mod tests { let r = read_style_sheets(&out, 72.0).unwrap(); assert_eq!(r.char_sheets.len(), 1); assert_eq!(r.char_sheets[0].1, "Emphasis"); - assert_eq!( - (r.char_sheets[0].2.size_pt, r.char_sheets[0].2.underline), - (30.0, true) - ); + assert_eq!((r.char_sheets[0].2.size_pt, r.char_sheets[0].2.underline), (30.0, true)); assert_eq!(r.para_sheets[0].1, "Centered"); assert_eq!(r.para_sheets[0].2.align, TextAlign::Center); assert_eq!(r.run_char, vec![None, Some(1)]); @@ -370,13 +247,7 @@ mod tests { // No styles: the sets shrink back to the normal sheets. let plain = write_style_sheets(&out, &layer, &TextStyles::default(), 72.0).unwrap(); assert!(!has_named_sheets(&plain)); - assert!( - read_style_sheets(&plain, 72.0) - .unwrap() - .run_char - .iter() - .all(Option::is_none) - ); + assert!(read_style_sheets(&plain, 72.0).unwrap().run_char.iter().all(Option::is_none)); } #[test] @@ -384,14 +255,6 @@ mod tests { assert!(read_style_sheets(&[], 72.0).is_none()); assert!(read_style_sheets(&[0u8; 60], 72.0).is_none()); assert!(!has_named_sheets(b"junk")); - assert!( - write_style_sheets( - &[1, 2, 3], - &TextLayer::default(), - &TextStyles::default(), - 72.0 - ) - .is_none() - ); + assert!(write_style_sheets(&[1, 2, 3], &TextLayer::default(), &TextStyles::default(), 72.0).is_none()); } } diff --git a/crates/text/src/raster.rs b/crates/text/src/raster.rs index 5a8482d5ae..431d020b23 100644 --- a/crates/text/src/raster.rs +++ b/crates/text/src/raster.rs @@ -15,14 +15,7 @@ impl Xform { pub fn mul(&self, o: &Xform) -> Xform { let [a, b, c, d, e, f] = self.0; let [oa, ob, oc, od, oe, of] = o.0; - Xform([ - a * oa + c * ob, - b * oa + d * ob, - a * oc + c * od, - b * oc + d * od, - a * oe + c * of + e, - b * oe + d * of + f, - ]) + Xform([a * oa + c * ob, b * oa + d * ob, a * oc + c * od, b * oc + d * od, a * oe + c * of + e, b * oe + d * of + f]) } /// Largest scale factor (for flattening tolerance). pub fn max_scale(&self) -> f64 { @@ -69,12 +62,7 @@ impl LineSink for Coverage { /// Adds a transformed axis-aligned rectangle. pub fn rect_to(sink: &mut impl LineSink, xf: &Xform, x0: f64, y0: f64, x1: f64, y1: f64) { - let p = [ - xf.apply(x0, y0), - xf.apply(x1, y0), - xf.apply(x1, y1), - xf.apply(x0, y1), - ]; + let p = [xf.apply(x0, y0), xf.apply(x1, y0), xf.apply(x1, y1), xf.apply(x0, y1)]; for i in 0..4 { sink.line(p[i], p[(i + 1) % 4]); } @@ -89,26 +77,16 @@ pub struct Coverage { impl Coverage { pub fn new(width: usize, height: usize) -> Self { - Self { - width, - height, - acc: vec![0.0; width * height + 2], - } + Self { width, height, acc: vec![0.0; width * height + 2] } } /// Adds a line segment (pixel coordinates, y down). pub fn line(&mut self, p0: (f64, f64), p1: (f64, f64)) { let (w, h) = (self.width as f32, self.height as f32); - if p0.1 == p1.1 - || !(p0.0.is_finite() && p0.1.is_finite() && p1.0.is_finite() && p1.1.is_finite()) - { + if p0.1 == p1.1 || !(p0.0.is_finite() && p0.1.is_finite() && p1.0.is_finite() && p1.1.is_finite()) { return; } - let (dir, (x0, y0), (x1, y1)) = if p0.1 < p1.1 { - (1.0f32, p0, p1) - } else { - (-1.0f32, p1, p0) - }; + let (dir, (x0, y0), (x1, y1)) = if p0.1 < p1.1 { (1.0f32, p0, p1) } else { (-1.0f32, p1, p0) }; let (x0, y0, x1, y1) = (x0 as f32, y0 as f32, x1 as f32, y1 as f32); if y1 <= 0.0 || y0 >= h { return; @@ -190,13 +168,7 @@ pub struct Pen<'a, S: LineSink> { impl<'a, S: LineSink> Pen<'a, S> { pub fn new(cov: &'a mut S, xf: Xform) -> Self { - Pen { - cov, - xf, - start: (0.0, 0.0), - last: (0.0, 0.0), - tol: 0.03, - } + Pen { cov, xf, start: (0.0, 0.0), last: (0.0, 0.0), tol: 0.03 } } fn to(&mut self, p: (f64, f64)) { self.cov.line(self.last, p); @@ -228,10 +200,7 @@ impl skrifa::outline::OutlinePen for Pen<'_, S> { for i in 1..=n { let t = i as f64 / n as f64; let mt = 1.0 - t; - let p = ( - mt * mt * a.0 + 2.0 * mt * t * b.0 + t * t * c.0, - mt * mt * a.1 + 2.0 * mt * t * b.1 + t * t * c.1, - ); + let p = (mt * mt * a.0 + 2.0 * mt * t * b.0 + t * t * c.0, mt * mt * a.1 + 2.0 * mt * t * b.1 + t * t * c.1); self.to(p); } } @@ -245,10 +214,7 @@ impl skrifa::outline::OutlinePen for Pen<'_, S> { let t = i as f64 / n as f64; let mt = 1.0 - t; let (k0, k1, k2, k3) = (mt * mt * mt, 3.0 * mt * mt * t, 3.0 * mt * t * t, t * t * t); - let p = ( - k0 * a.0 + k1 * b.0 + k2 * c.0 + k3 * d.0, - k0 * a.1 + k1 * b.1 + k2 * c.1 + k3 * d.1, - ); + let p = (k0 * a.0 + k1 * b.0 + k2 * c.0 + k3 * d.0, k0 * a.1 + k1 * b.1 + k2 * c.1 + k3 * d.1); self.to(p); } } diff --git a/crates/text/src/render.rs b/crates/text/src/render.rs index 144ace683a..16245deae6 100644 --- a/crates/text/src/render.rs +++ b/crates/text/src/render.rs @@ -37,9 +37,7 @@ impl LineSink for WarpSink<'_, S> { let len = ((p1.0 - p0.0).powi(2) + (p1.1 - p0.1).powi(2)).sqrt(); let n = ((len / self.warp.max_segment()).ceil() as usize).clamp(1, 256); let map = |t: f64| { - let (x, y) = self - .warp - .apply(p0.0 + (p1.0 - p0.0) * t, p0.1 + (p1.1 - p0.1) * t); + let (x, y) = self.warp.apply(p0.0 + (p1.0 - p0.0) * t, p0.1 + (p1.1 - p0.1) * t); self.post.apply(x, y) }; let mut a = map(0.0); @@ -54,13 +52,7 @@ impl LineSink for WarpSink<'_, S> { /// Draws every glyph and decoration of `layout` whose style colour is `color` (or all of them /// when `color` is `None`) into `sink`, through `transform` (text space → sink space), bending /// the outlines with `warp` first (in text space). -fn draw( - layout: &TextLayout, - transform: &Xform, - sink: &mut impl LineSink, - only_color: Option<&Color>, - warp: Option<&Warp>, -) { +fn draw(layout: &TextLayout, transform: &Xform, sink: &mut impl LineSink, only_color: Option<&Color>, warp: Option<&Warp>) { for g in &layout.glyphs { let st = &layout.styles[g.style as usize]; if only_color.is_some_and(|c| c != &st.color) { @@ -73,52 +65,23 @@ fn draw( let Some(outline) = font.outline_glyphs().get(GlyphId::new(g.id)) else { continue; }; - let coords: Vec = face - .coords - .iter() - .map(|&c| NormalizedCoord::from_bits(c)) - .collect(); - let hs = if st.horizontal_scale > 0.0 { - st.horizontal_scale - } else { - 1.0 - } as f64; - let vs = if st.vertical_scale > 0.0 { - st.vertical_scale - } else { - 1.0 - } as f64; + let coords: Vec = face.coords.iter().map(|&c| NormalizedCoord::from_bits(c)).collect(); + let hs = if st.horizontal_scale > 0.0 { st.horizontal_scale } else { 1.0 } as f64; + let vs = if st.vertical_scale > 0.0 { st.vertical_scale } else { 1.0 } as f64; let skew_deg = if st.faux_italic { FAUX_ITALIC_DEG } else { 0.0 } + face.skew_deg; let skew = (skew_deg as f64).to_radians().tan() * vs; let shift = (st.baseline_shift_pt * layout.px_per_pt) as f64; let glyph = Xform([hs, 0.0, skew, -vs, g.x as f64, g.y as f64 - shift]); let bold = st.faux_bold || face.embolden; let r = (face.size_px * FAUX_BOLD_RADIUS) as f64; - let offsets: &[(f64, f64)] = if bold { - &[ - (-1.0, 0.0), - (1.0, 0.0), - (0.0, -1.0), - (0.0, 1.0), - (0.7, 0.7), - (-0.7, -0.7), - (0.7, -0.7), - (-0.7, 0.7), - ] - } else { - &[(0.0, 0.0)] - }; + let offsets: &[(f64, f64)] = + if bold { &[(-1.0, 0.0), (1.0, 0.0), (0.0, -1.0), (0.0, 1.0), (0.7, 0.7), (-0.7, -0.7), (0.7, -0.7), (-0.7, 0.7)] } else { &[(0.0, 0.0)] }; for &(ox, oy) in offsets { let local = Xform([1.0, 0.0, 0.0, 1.0, ox * r, oy * r]).mul(&glyph); - let settings = - DrawSettings::unhinted(Size::new(face.size_px), LocationRef::new(&coords)); + let settings = DrawSettings::unhinted(Size::new(face.size_px), LocationRef::new(&coords)); match warp { Some(w) => { - let mut ws = WarpSink { - inner: &mut *sink, - warp: w, - post: *transform, - }; + let mut ws = WarpSink { inner: &mut *sink, warp: w, post: *transform }; let mut pen = Pen::new(&mut ws, local); if outline.draw(settings, &mut pen).is_ok() { skrifa::outline::OutlinePen::close(&mut pen); @@ -140,37 +103,16 @@ fn draw( } match warp { Some(w) => { - let mut ws = WarpSink { - inner: &mut *sink, - warp: w, - post: *transform, - }; - rect_to( - &mut ws, - &Xform::IDENTITY, - d.x0 as f64, - d.y0 as f64, - d.x1 as f64, - d.y1 as f64, - ); + let mut ws = WarpSink { inner: &mut *sink, warp: w, post: *transform }; + rect_to(&mut ws, &Xform::IDENTITY, d.x0 as f64, d.y0 as f64, d.x1 as f64, d.y1 as f64); } - None => rect_to( - sink, - transform, - d.x0 as f64, - d.y0 as f64, - d.x1 as f64, - d.y1 as f64, - ), + None => rect_to(sink, transform, d.x0 as f64, d.y0 as f64, d.x1 as f64, d.y1 as f64), } } } /// The warp to apply to `layout` (None when `warp` is absent, `warpNone` or flat). -pub fn layout_warp( - layout: &TextLayout, - warp: Option<&photocraft_doc::text::TextWarp>, -) -> Option { +pub fn layout_warp(layout: &TextLayout, warp: Option<&photocraft_doc::text::TextWarp>) -> Option { Warp::new(warp?, layout.bounds()?) } @@ -181,13 +123,7 @@ fn color_in(format: &PixelFormat, c: &Color) -> Vec { c.c[..n].to_vec() } else { let [r, g, b] = c.to_rgb(); - let mut v = photocraft_raster::from_rgba( - &PixelFormat { - alpha: false, - ..*format - }, - [r, g, b, 1.0], - ); + let mut v = photocraft_raster::from_rgba(&PixelFormat { alpha: false, ..*format }, [r, g, b, 1.0]); v.truncate(n); v } @@ -203,47 +139,25 @@ pub fn ink_rect_warped(layout: &TextLayout, transform: &Affine, warp: Option<&Wa let mut b = Bounds::default(); draw(layout, &Xform(transform.m), &mut b, None, warp); match b.rect { - Some([x0, y0, x1, y1]) => Rect::new( - x0.floor() as i32 - 1, - y0.floor() as i32 - 1, - x1.ceil() as i32 + 1, - y1.ceil() as i32 + 1, - ), + Some([x0, y0, x1, y1]) => Rect::new(x0.floor() as i32 - 1, y0.floor() as i32 - 1, x1.ceil() as i32 + 1, y1.ceil() as i32 + 1), None => Rect::new(0, 0, 0, 0), } } /// Rasterizes `layout` through `transform` (text space → document pixels). The result always /// has an alpha channel; colour is written in `format`'s colour model and sample depth. -pub fn rasterize( - layout: &TextLayout, - transform: &Affine, - format: PixelFormat, - antialias: AntiAlias, -) -> Rendered { +pub fn rasterize(layout: &TextLayout, transform: &Affine, format: PixelFormat, antialias: AntiAlias) -> Rendered { rasterize_warped(layout, transform, format, antialias, None) } /// [`rasterize`] with the glyph outlines bent by `warp` (Type › Warp Text). -pub fn rasterize_warped( - layout: &TextLayout, - transform: &Affine, - format: PixelFormat, - antialias: AntiAlias, - warp: Option<&Warp>, -) -> Rendered { - let format = PixelFormat { - alpha: true, - ..format - }; +pub fn rasterize_warped(layout: &TextLayout, transform: &Affine, format: PixelFormat, antialias: AntiAlias, warp: Option<&Warp>) -> Rendered { + let format = PixelFormat { alpha: true, ..format }; let rect = ink_rect_warped(layout, transform, warp); let (w, h) = (rect.width() as usize, rect.height() as usize); let mut surface = Surface::new(format); if w == 0 || h == 0 || (w as u64) * (h as u64) > MAX_PIXELS { - return Rendered { - surface, - rect: Rect::new(0, 0, 0, 0), - }; + return Rendered { surface, rect: Rect::new(0, 0, 0, 0) }; } let n = format.mode.color_channels(); let stride = n + 1; @@ -263,11 +177,7 @@ pub fn rasterize_warped( let comps = color_in(&format, c); let a = c.alpha.clamp(0.0, 1.0); for (i, &cv) in cov.iter().enumerate() { - let cv = if antialias == AntiAlias::None { - if cv >= 0.5 { 1.0 } else { 0.0 } - } else { - cv - }; + let cv = if antialias == AntiAlias::None { if cv >= 0.5 { 1.0 } else { 0.0 } } else { cv }; let s = cv * a; if s <= 0.0 { continue; @@ -362,31 +272,14 @@ pub fn outlines(layout: &TextLayout, transform: &Affine, warp: Option<&Warp>) -> let Some(outline) = font.outline_glyphs().get(GlyphId::new(g.id)) else { continue; }; - let coords: Vec = face - .coords - .iter() - .map(|&c| NormalizedCoord::from_bits(c)) - .collect(); - let hs = if st.horizontal_scale > 0.0 { - st.horizontal_scale - } else { - 1.0 - } as f64; - let vs = if st.vertical_scale > 0.0 { - st.vertical_scale - } else { - 1.0 - } as f64; + let coords: Vec = face.coords.iter().map(|&c| NormalizedCoord::from_bits(c)).collect(); + let hs = if st.horizontal_scale > 0.0 { st.horizontal_scale } else { 1.0 } as f64; + let vs = if st.vertical_scale > 0.0 { st.vertical_scale } else { 1.0 } as f64; let skew_deg = if st.faux_italic { FAUX_ITALIC_DEG } else { 0.0 } + face.skew_deg; let skew = (skew_deg as f64).to_radians().tan() * vs; let shift = (st.baseline_shift_pt * layout.px_per_pt) as f64; let glyph = Xform([hs, 0.0, skew, -vs, g.x as f64, g.y as f64 - shift]); - let mut rec = Recorder { - glyph, - post: Xform(transform.m), - warp, - out: Vec::new(), - }; + let mut rec = Recorder { glyph, post: Xform(transform.m), warp, out: Vec::new() }; let settings = DrawSettings::unhinted(Size::new(face.size_px), LocationRef::new(&coords)); if outline.draw(settings, &mut rec).is_ok() && !rec.out.is_empty() { glyphs.push(rec.out); diff --git a/crates/text/src/spell.rs b/crates/text/src/spell.rs index 5d0746592c..83576c8999 100644 --- a/crates/text/src/spell.rs +++ b/crates/text/src/spell.rs @@ -60,10 +60,7 @@ impl Dictionary { continue; } words.insert(w.to_string(), level); - by_lower - .entry(w.to_lowercase()) - .or_default() - .push(w.to_string()); + by_lower.entry(w.to_lowercase()).or_default().push(w.to_string()); } let mut by_len: Vec, u8)>> = Vec::new(); for (lw, forms) in &by_lower { @@ -71,22 +68,13 @@ impl Dictionary { if by_len.len() <= n { by_len.resize(n + 1, Vec::new()); } - let lvl = forms - .iter() - .filter_map(|f| words.get(f)) - .min() - .copied() - .unwrap_or(50); + let lvl = forms.iter().filter_map(|f| words.get(f)).min().copied().unwrap_or(50); by_len[n].push((lw.clone(), lw.chars().collect(), lvl)); } for v in &mut by_len { v.sort(); } - Dictionary { - words, - by_lower, - by_len, - } + Dictionary { words, by_lower, by_len } } pub fn len(&self) -> usize { @@ -102,8 +90,7 @@ impl Dictionary { let w = normalize(word); let w = w.strip_suffix("'s").filter(|b| !b.is_empty()).unwrap_or(&w); let w = w.trim_matches('\''); - if w.is_empty() || w.chars().any(|c| c.is_numeric()) || !w.chars().any(char::is_alphabetic) - { + if w.is_empty() || w.chars().any(|c| c.is_numeric()) || !w.chars().any(char::is_alphabetic) { return true; } let lower = w.to_lowercase(); @@ -113,12 +100,8 @@ impl Dictionary { let Some(forms) = self.by_lower.get(&lower) else { return false; }; - let all_caps = w - .chars() - .filter(|c| c.is_alphabetic()) - .all(char::is_uppercase); - let first_cap = w.chars().next().is_some_and(char::is_uppercase) - && w.chars().skip(1).all(|c| !c.is_uppercase()); + let all_caps = w.chars().filter(|c| c.is_alphabetic()).all(char::is_uppercase); + let first_cap = w.chars().next().is_some_and(char::is_uppercase) && w.chars().skip(1).all(|c| !c.is_uppercase()); forms.iter().any(|f| { let f_lower = f.chars().all(|c| !c.is_uppercase()); // "the" → "The"/"THE"; "Paris" → "PARIS"; "NASA" stays as listed (exact match above). @@ -155,11 +138,7 @@ impl Dictionary { } } found.sort(); - let all_caps = w - .chars() - .filter(|c| c.is_alphabetic()) - .all(char::is_uppercase) - && w.chars().filter(|c| c.is_alphabetic()).count() > 1; + let all_caps = w.chars().filter(|c| c.is_alphabetic()).all(char::is_uppercase) && w.chars().filter(|c| c.is_alphabetic()).count() > 1; let first_cap = w.chars().next().is_some_and(char::is_uppercase); let mut out: Vec = Vec::new(); for (_, _, _, cand) in found { @@ -191,11 +170,7 @@ impl Dictionary { words(text) .into_iter() .filter(|(a, b)| !self.check(&text[*a..*b], user)) - .map(|(a, b)| Misspelling { - start: a, - end: b, - word: text[a..b].to_string(), - }) + .map(|(a, b)| Misspelling { start: a, end: b, word: text[a..b].to_string() }) .collect() } } @@ -220,9 +195,7 @@ pub fn words(text: &str) -> Vec<(usize, usize)> { let mut start: Option = None; let chars: Vec<(usize, char)> = text.char_indices().collect(); for (i, &(b, c)) in chars.iter().enumerate() { - let inner_apos = is_apos(c) - && start.is_some() - && chars.get(i + 1).is_some_and(|(_, n)| n.is_alphabetic()); + let inner_apos = is_apos(c) && start.is_some() && chars.get(i + 1).is_some_and(|(_, n)| n.is_alphabetic()); if c.is_alphanumeric() || inner_apos { start.get_or_insert(b); } else if let Some(s) = start.take() { @@ -289,20 +262,7 @@ mod tests { fn bundled_dictionary_loads() { let d = Dictionary::english(); assert!(d.len() > 70_000, "{}", d.len()); - for w in [ - "the", - "The", - "THE", - "color", - "colors", - "running", - "don't", - "don’t", - "Paris", - "PARIS", - "photographer's", - "NASA", - ] { + for w in ["the", "The", "THE", "color", "colors", "running", "don't", "don’t", "Paris", "PARIS", "photographer's", "NASA"] { assert!(d.check(w, &none()), "{w}"); } for w in ["teh", "recieve", "paris", "Photograpy", "zxqv"] { @@ -319,23 +279,14 @@ mod tests { assert!(d.suggest("recieve", 5).contains(&"receive".to_string())); assert_eq!(d.suggest("Wrold", 1), vec!["World".to_string()]); assert_eq!(d.suggest("brwon", 1), vec!["brown".to_string()]); - assert_eq!( - d.suggest("HELO", 5) - .first() - .map(|s| s.chars().all(|c| c.is_uppercase())), - Some(true) - ); + assert_eq!(d.suggest("HELO", 5).first().map(|s| s.chars().all(|c| c.is_uppercase())), Some(true)); } #[test] fn misspellings_and_user_words() { let d = Dictionary::from_list("#10\nhello\nworld\nParis\n"); let text = "Hello wrld, Paris paris photocraft 42x"; - let m: Vec = d - .misspellings(text, &none()) - .into_iter() - .map(|m| m.word) - .collect(); + let m: Vec = d.misspellings(text, &none()).into_iter().map(|m| m.word).collect(); assert_eq!(m, vec!["wrld", "paris", "photocraft"]); let user: HashSet = ["photocraft".to_string()].into(); assert_eq!(d.misspellings(text, &user).len(), 2); @@ -351,18 +302,7 @@ mod tests { let d = Dictionary::english(); let load = t.elapsed(); let t = std::time::Instant::now(); - for w in [ - "recieve", - "teh", - "photograpy", - "definately", - "seperate", - "wierd", - "acommodate", - "begining", - "occurence", - "untill", - ] { + for w in ["recieve", "teh", "photograpy", "definately", "seperate", "wierd", "acommodate", "begining", "occurence", "untill"] { let _ = d.suggest(w, 8); } eprintln!("load {load:?}, suggest {:?}/word", t.elapsed() / 10); diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index fd06268750..d668618e60 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1,38 +1,20 @@ use photocraft_color::{Color, PixelFormat, SampleType}; use photocraft_doc::TextLayer; -use photocraft_doc::text::{ - CharStyle, FontFeature, ParagraphRun, ParagraphStyle, TextAlign, TextDirection, TextRun, - TextShape, -}; +use photocraft_doc::text::{CharStyle, FontFeature, ParagraphRun, ParagraphStyle, TextAlign, TextDirection, TextRun, TextShape}; use photocraft_geom::Affine; use crate::{TextEngine, fonts}; fn point(text: &str, size_pt: f32) -> TextLayer { - TextLayer { - text: text.into(), - font_family: "Inter".into(), - size_pt, - ..Default::default() - } + TextLayer { text: text.into(), font_family: "Inter".into(), size_pt, ..Default::default() } } fn styled(text: &str, style: CharStyle) -> TextLayer { - TextLayer { - text: text.into(), - runs: vec![TextRun { - len: text.len(), - style, - }], - ..Default::default() - } + TextLayer { text: text.into(), runs: vec![TextRun { len: text.len(), style }], ..Default::default() } } fn with_para(mut t: TextLayer, p: ParagraphStyle) -> TextLayer { - t.paragraphs = vec![ParagraphRun { - len: t.text.len(), - style: p, - }]; + t.paragraphs = vec![ParagraphRun { len: t.text.len(), style: p }]; t } @@ -42,10 +24,7 @@ fn width(l: &crate::TextLayout) -> f32 { fn alpha_sum(s: &photocraft_raster::Surface, r: photocraft_geom::Rect) -> f64 { let n = s.channels(); - s.read_region(r) - .chunks_exact(n) - .map(|p| f64::from(p[n - 1])) - .sum() + s.read_region(r).chunks_exact(n).map(|p| f64::from(p[n - 1])).sum() } #[test] @@ -70,12 +49,7 @@ fn metrics_are_stable_and_scale_with_dpi() { assert_eq!(w, again); assert!((80.0..110.0).contains(&w), "{w}"); let b = e.layout(&point("Hamburgefonstiv", 12.0), 144.0); - assert!( - (width(&b) - 2.0 * w).abs() < 0.05, - "{} vs {}", - width(&b), - 2.0 * w - ); + assert!((width(&b) - 2.0 * w).abs() < 0.05, "{} vs {}", width(&b), 2.0 * w); // Point text: first baseline at the anchor. assert_eq!(a.lines[0].baseline, 0.0); assert!(a.lines[0].ascent > 8.0 && a.lines[0].ascent < 13.0); @@ -86,62 +60,27 @@ fn auto_and_explicit_leading() { let mut e = TextEngine::new(); let l = e.layout(&point("one\ntwo\rthree", 10.0), 72.0); assert_eq!(l.lines.len(), 3); - assert!( - (l.lines[1].baseline - 12.0).abs() < 1e-3, - "auto leading = 1.2 × size" - ); + assert!((l.lines[1].baseline - 12.0).abs() < 1e-3, "auto leading = 1.2 × size"); assert!((l.lines[2].baseline - 24.0).abs() < 1e-3); assert_eq!(&"one\ntwo\rthree"[l.lines[2].range.clone()], "three"); - let t = styled( - "a\nb", - CharStyle { - size_pt: 10.0, - leading_pt: Some(30.0), - ..Default::default() - }, - ); + let t = styled("a\nb", CharStyle { size_pt: 10.0, leading_pt: Some(30.0), ..Default::default() }); let l = e.layout(&t, 144.0); - assert!( - (l.lines[1].baseline - 60.0).abs() < 1e-3, - "{}", - l.lines[1].baseline - ); + assert!((l.lines[1].baseline - 60.0).abs() < 1e-3, "{}", l.lines[1].baseline); // Space before/after in points. - let t = with_para( - point("a\nb", 10.0), - ParagraphStyle { - space_after_pt: 5.0, - ..Default::default() - }, - ); + let t = with_para(point("a\nb", 10.0), ParagraphStyle { space_after_pt: 5.0, ..Default::default() }); let l = e.layout(&t, 72.0); - assert!( - (l.lines[1].baseline - 17.0).abs() < 1e-3, - "{}", - l.lines[1].baseline - ); + assert!((l.lines[1].baseline - 17.0).abs() < 1e-3, "{}", l.lines[1].baseline); } #[test] fn box_text_wraps_inside_width() { let mut e = TextEngine::new(); - let mut t = point( - "The quick brown fox jumps over the lazy dog again and again", - 12.0, - ); - t.shape = TextShape::Box { - x: 10.0, - y: 20.0, - width: 100.0, - height: 1000.0, - }; + let mut t = point("The quick brown fox jumps over the lazy dog again and again", 12.0); + t.shape = TextShape::Box { x: 10.0, y: 20.0, width: 100.0, height: 1000.0 }; let l = e.layout(&t, 72.0); assert!(l.lines.len() >= 3, "{}", l.lines.len()); for line in &l.lines { - assert!( - line.x0 >= 10.0 - 1e-3 && line.x1 <= 110.0 + 1e-3, - "{line:?}" - ); + assert!(line.x0 >= 10.0 - 1e-3 && line.x1 <= 110.0 + 1e-3, "{line:?}"); } // First baseline = top + ascender height ('d'), a bit less than the hhea ascent. let drop = l.lines[0].baseline - 20.0; @@ -152,12 +91,7 @@ fn box_text_wraps_inside_width() { assert!(w[1].range.start >= w[0].range.end); } // Overflowing lines are hidden, like Photoshop. - t.shape = TextShape::Box { - x: 0.0, - y: 0.0, - width: 100.0, - height: 30.0, - }; + t.shape = TextShape::Box { x: 0.0, y: 0.0, width: 100.0, height: 30.0 }; let l2 = e.layout(&t, 72.0); assert_eq!(l2.lines.len(), 2); } @@ -165,69 +99,24 @@ fn box_text_wraps_inside_width() { #[test] fn alignment_point_and_box() { let mut e = TextEngine::new(); - let c = e.layout( - &with_para( - point("Centered", 20.0), - ParagraphStyle { - align: TextAlign::Center, - ..Default::default() - }, - ), - 72.0, - ); + let c = e.layout(&with_para(point("Centered", 20.0), ParagraphStyle { align: TextAlign::Center, ..Default::default() }), 72.0); let ln = &c.lines[0]; assert!((ln.x0 + ln.x1).abs() < 0.01, "centered on anchor: {ln:?}"); - let r = e.layout( - &with_para( - point("Right", 20.0), - ParagraphStyle { - align: TextAlign::Right, - ..Default::default() - }, - ), - 72.0, - ); + let r = e.layout(&with_para(point("Right", 20.0), ParagraphStyle { align: TextAlign::Right, ..Default::default() }), 72.0); assert!(r.lines[0].x1.abs() < 0.01 && r.lines[0].x0 < -10.0); let mut t = point("aaa bbb ccc ddd eee fff ggg hhh iii jjj kkk", 12.0); - t.shape = TextShape::Box { - x: 0.0, - y: 0.0, - width: 120.0, - height: 500.0, - }; - let t = with_para( - t, - ParagraphStyle { - align: TextAlign::JustifyLeft, - ..Default::default() - }, - ); + t.shape = TextShape::Box { x: 0.0, y: 0.0, width: 120.0, height: 500.0 }; + let t = with_para(t, ParagraphStyle { align: TextAlign::JustifyLeft, ..Default::default() }); let l = e.layout(&t, 72.0); assert!(l.lines.len() > 1); let first = &l.lines[0]; // Justified line: last glyph ends at the right edge (within a pixel). - let last_glyph_x = l - .glyphs - .iter() - .filter(|g| (g.y - first.baseline).abs() < 1e-3) - .map(|g| g.x) - .fold(f32::MIN, f32::max); + let last_glyph_x = l.glyphs.iter().filter(|g| (g.y - first.baseline).abs() < 1e-3).map(|g| g.x).fold(f32::MIN, f32::max); assert!(last_glyph_x > 110.0, "{last_glyph_x}"); let right = with_para( - TextLayer { - shape: TextShape::Box { - x: 0.0, - y: 0.0, - width: 200.0, - height: 100.0, - }, - ..point("end", 12.0) - }, - ParagraphStyle { - align: TextAlign::Right, - ..Default::default() - }, + TextLayer { shape: TextShape::Box { x: 0.0, y: 0.0, width: 200.0, height: 100.0 }, ..point("end", 12.0) }, + ParagraphStyle { align: TextAlign::Right, ..Default::default() }, ); let l = e.layout(&right, 72.0); assert!((l.lines[0].x1 - 200.0).abs() < 0.5, "{:?}", l.lines[0]); @@ -239,34 +128,16 @@ fn rtl_text_is_reordered() { // Hebrew isn't in the bundled fonts, but bidi still resolves (glyphs may be .notdef). let text = "abc שלום def"; let l = e.layout(&point(text, 12.0), 72.0); - let heb: Vec<_> = l - .clusters - .iter() - .filter(|c| { - text[c.range.clone()] - .chars() - .all(|ch| ('\u{0590}'..='\u{05FF}').contains(&ch)) - }) - .collect(); + let heb: Vec<_> = l.clusters.iter().filter(|c| text[c.range.clone()].chars().all(|ch| ('\u{0590}'..='\u{05FF}').contains(&ch))).collect(); assert_eq!(heb.len(), 4); assert!(heb.iter().all(|c| c.rtl)); // Visual order: the first logical Hebrew letter is rightmost among them. let first = heb.iter().min_by_key(|c| c.range.start).unwrap(); assert!(heb.iter().all(|c| c.x <= first.x + 1e-3)); // Forced RTL paragraph: the Latin run ends up to the right of the Hebrew. - let t = with_para( - point("שלום abc", 12.0), - ParagraphStyle { - direction: TextDirection::Rtl, - ..Default::default() - }, - ); + let t = with_para(point("שלום abc", 12.0), ParagraphStyle { direction: TextDirection::Rtl, ..Default::default() }); let l = e.layout(&t, 72.0); - let a = l - .clusters - .iter() - .find(|c| &"שלום abc"[c.range.clone()] == "a") - .unwrap(); + let a = l.clusters.iter().find(|c| &"שלום abc"[c.range.clone()] == "a").unwrap(); let shin = l.clusters.iter().find(|c| c.range.start == 0).unwrap(); assert!(a.x < shin.x, "abc left of the Hebrew in an RTL paragraph"); assert!(l.clusters.iter().all(|c| c.range.end <= "שלום abc".len())); @@ -312,23 +183,11 @@ fn truetype_collections_load() { // and check both faces are indexed (families already present gain faces). let before = db.faces("JetBrains Mono").len(); let names = db.register_font_data(ttc); - assert!( - names.iter().any(|n| n == "Inter") && names.iter().any(|n| n == "JetBrains Mono"), - "{names:?}" - ); + assert!(names.iter().any(|n| n == "Inter") && names.iter().any(|n| n == "JetBrains Mono"), "{names:?}"); assert_eq!(db.faces("JetBrains Mono").len(), before + 1); // Layout with the TTC-backed face works. - let mut e = TextEngine { - fonts: db, - layouter: crate::layout::Layouter::new(), - }; - let t = styled( - "mono", - CharStyle { - font_family: "JetBrains Mono".into(), - ..Default::default() - }, - ); + let mut e = TextEngine { fonts: db, layouter: crate::layout::Layouter::new() }; + let t = styled("mono", CharStyle { font_family: "JetBrains Mono".into(), ..Default::default() }); let l = e.layout(&t, 72.0); assert!(l.glyphs.iter().all(|g| g.id != 0)); } @@ -337,44 +196,14 @@ fn truetype_collections_load() { fn opentype_features_and_tracking() { let mut e = TextEngine::new(); let ids = |e: &mut TextEngine, feats: Vec, lig: bool| { - let t = styled( - "a0", - CharStyle { - features: feats, - ligatures: lig, - ..Default::default() - }, - ); - e.layout(&t, 72.0) - .glyphs - .iter() - .map(|g| g.id) - .collect::>() + let t = styled("a0", CharStyle { features: feats, ligatures: lig, ..Default::default() }); + e.layout(&t, 72.0).glyphs.iter().map(|g| g.id).collect::>() }; let plain = ids(&mut e, vec![], true); - let alt = ids( - &mut e, - vec![FontFeature { - tag: "zero".into(), - value: 1, - }], - true, - ); - assert_ne!( - plain, alt, - "Inter's `zero` feature selects the slashed zero" - ); + let alt = ids(&mut e, vec![FontFeature { tag: "zero".into(), value: 1 }], true); + assert_ne!(plain, alt, "Inter's `zero` feature selects the slashed zero"); let a = width(&e.layout(&styled("tracking", CharStyle::default()), 72.0)); - let b = width(&e.layout( - &styled( - "tracking", - CharStyle { - tracking: 100.0, - ..Default::default() - }, - ), - 72.0, - )); + let b = width(&e.layout(&styled("tracking", CharStyle { tracking: 100.0, ..Default::default() }), 72.0)); // 100/1000 em at 12 px per letter (8 letters; trailing spacing counts too). assert!((b - a - 8.0 * 1.2).abs() < 1.3, "{a} → {b}"); } @@ -382,10 +211,7 @@ fn opentype_features_and_tracking() { #[test] fn raster_coverage_scales_and_depths_agree() { let mut e = TextEngine::new(); - let t = |size: f32| TextLayer { - transform: Affine::translate(10.0, 50.0), - ..point("Ink", size) - }; + let t = |size: f32| TextLayer { transform: Affine::translate(10.0, 50.0), ..point("Ink", size) }; let (_, r12) = e.render(&t(12.0), 72.0, PixelFormat::RGBA8); let (_, r24) = e.render(&t(24.0), 72.0, PixelFormat::RGBA8); let s12 = alpha_sum(&r12.surface, r12.rect); @@ -394,17 +220,8 @@ fn raster_coverage_scales_and_depths_agree() { let ratio = s24 / s12; assert!((3.6..4.4).contains(&ratio), "ink ∝ size²: {ratio}"); // Placement: anchored at (10, 50) baseline. - assert!( - r12.rect.x0 >= 8 && r12.rect.x0 <= 11 && r12.rect.y1 <= 53 && r12.rect.y1 >= 50, - "{:?}", - r12.rect - ); - for fmt in [ - PixelFormat::RGBA16, - PixelFormat::RGBA32F, - PixelFormat::GRAYA8, - PixelFormat::CMYKA8, - ] { + assert!(r12.rect.x0 >= 8 && r12.rect.x0 <= 11 && r12.rect.y1 <= 53 && r12.rect.y1 >= 50, "{:?}", r12.rect); + for fmt in [PixelFormat::RGBA16, PixelFormat::RGBA32F, PixelFormat::GRAYA8, PixelFormat::CMYKA8] { let (_, r) = e.render(&t(12.0), 72.0, fmt); assert_eq!(r.rect, r12.rect); let s = alpha_sum(&r.surface, r.rect); @@ -414,10 +231,7 @@ fn raster_coverage_scales_and_depths_agree() { let (_, again) = e.render(&t(12.0), 72.0, PixelFormat::RGBA8); assert_eq!(again.surface, r12.surface); // Golden-ish total ink for "Ink" in Inter 12 px (±3%). - assert!( - (s12 - GOLDEN_INK_12).abs() / GOLDEN_INK_12 < 0.03, - "ink sum {s12}" - ); + assert!((s12 - GOLDEN_INK_12).abs() / GOLDEN_INK_12 < 0.03, "ink sum {s12}"); } /// Sum of alpha for "Ink", Inter Regular 12 px (measured once; guards rasterizer regressions). @@ -426,85 +240,32 @@ const GOLDEN_INK_12: f64 = 44.4; #[test] fn styles_change_pixels() { let mut e = TextEngine::new(); - let base = CharStyle { - size_pt: 30.0, - ..Default::default() - }; + let base = CharStyle { size_pt: 30.0, ..Default::default() }; let ink = |e: &mut TextEngine, s: CharStyle| { - let (_, r) = e.render( - &TextLayer { - transform: Affine::translate(5.0, 40.0), - ..styled("Hi", s) - }, - 72.0, - PixelFormat::RGBA8, - ); + let (_, r) = e.render(&TextLayer { transform: Affine::translate(5.0, 40.0), ..styled("Hi", s) }, 72.0, PixelFormat::RGBA8); (alpha_sum(&r.surface, r.rect), r.rect) }; let (plain, prect) = ink(&mut e, base.clone()); - let (bold, _) = ink( - &mut e, - CharStyle { - faux_bold: true, - ..base.clone() - }, - ); + let (bold, _) = ink(&mut e, CharStyle { faux_bold: true, ..base.clone() }); assert!(bold > plain * 1.1, "{plain} → {bold}"); - let (under, urect) = ink( - &mut e, - CharStyle { - underline: true, - ..base.clone() - }, - ); + let (under, urect) = ink(&mut e, CharStyle { underline: true, ..base.clone() }); assert!(under > plain && urect.y1 > prect.y1); - let (_, irect) = ink( - &mut e, - CharStyle { - faux_italic: true, - ..base.clone() - }, - ); + let (_, irect) = ink(&mut e, CharStyle { faux_italic: true, ..base.clone() }); assert!(irect.x1 > prect.x1, "slanted top extends right"); - let (_, srect) = ink( - &mut e, - CharStyle { - baseline_shift_pt: 10.0, - ..base.clone() - }, - ); + let (_, srect) = ink(&mut e, CharStyle { baseline_shift_pt: 10.0, ..base.clone() }); assert_eq!(srect.y0, prect.y0 - 10); - let (_, hrect) = ink( - &mut e, - CharStyle { - horizontal_scale: 2.0, - ..base.clone() - }, - ); + let (_, hrect) = ink(&mut e, CharStyle { horizontal_scale: 2.0, ..base.clone() }); assert!(hrect.width() as f32 > prect.width() as f32 * 1.7); } #[test] fn multicolor_runs() { let mut e = TextEngine::new(); - let red = CharStyle { - size_pt: 40.0, - color: Color::rgb(1.0, 0.0, 0.0), - ..Default::default() - }; - let blue = CharStyle { - color: Color::rgb(0.0, 0.0, 1.0), - ..red.clone() - }; + let red = CharStyle { size_pt: 40.0, color: Color::rgb(1.0, 0.0, 0.0), ..Default::default() }; + let blue = CharStyle { color: Color::rgb(0.0, 0.0, 1.0), ..red.clone() }; let t = TextLayer { text: "HH".into(), - runs: vec![ - TextRun { len: 1, style: red }, - TextRun { - len: 1, - style: blue, - }, - ], + runs: vec![TextRun { len: 1, style: red }, TextRun { len: 1, style: blue }], transform: Affine::translate(0.0, 40.0), ..Default::default() }; @@ -516,12 +277,7 @@ fn multicolor_runs() { assert!(opaque.iter().any(|p| p[2] > 0.99 && p[0] < 0.01)); // CMYK target keeps colour in the document model. let (_, rc) = e.render(&t, 72.0, PixelFormat::CMYKA8); - assert!( - rc.surface - .read_region(rc.rect) - .chunks_exact(5) - .any(|p| p[4] > 0.99 && p[1] > 0.9 && p[2] > 0.9) - ); + assert!(rc.surface.read_region(rc.rect).chunks_exact(5).any(|p| p[4] > 0.99 && p[1] > 0.9 && p[2] > 0.9)); } #[test] @@ -554,15 +310,9 @@ fn empty_text_and_empty_lines() { #[test] fn postscript_names() { let g = fonts::guess_from_postscript("MyriadPro-BoldIt"); - assert_eq!( - (g.family.as_str(), g.weight, g.italic), - ("Myriad Pro", 700, true) - ); + assert_eq!((g.family.as_str(), g.weight, g.italic), ("Myriad Pro", 700, true)); let g = fonts::guess_from_postscript("TimesNewRomanPSMT"); - assert_eq!( - (g.family.as_str(), g.weight, g.italic), - ("Times New Roman", 400, false) - ); + assert_eq!((g.family.as_str(), g.weight, g.italic), ("Times New Roman", 400, false)); let g = fonts::guess_from_postscript("Arial-BoldMT"); assert_eq!((g.family.as_str(), g.weight), ("Arial", 700)); let mut db = fonts::FontDb::new(); @@ -570,28 +320,14 @@ fn postscript_names() { assert_eq!((r.family.as_str(), r.weight, r.exact), ("Inter", 600, true)); // Unknown fonts fall back gracefully in layout. let mut e = TextEngine::new(); - let t = styled( - "x", - CharStyle { - font_family: "Nonexistent Sans".into(), - postscript_name: Some("Nope-Bold".into()), - ..Default::default() - }, - ); + let t = styled("x", CharStyle { font_family: "Nonexistent Sans".into(), postscript_name: Some("Nope-Bold".into()), ..Default::default() }); assert!(e.layout(&t, 72.0).glyphs[0].id != 0); } #[test] fn depth_is_respected() { let mut e = TextEngine::new(); - let (_, r) = e.render( - &TextLayer { - transform: Affine::translate(2.0, 20.0), - ..point("a", 20.0) - }, - 72.0, - PixelFormat::RGBA16, - ); + let (_, r) = e.render(&TextLayer { transform: Affine::translate(2.0, 20.0), ..point("a", 20.0) }, 72.0, PixelFormat::RGBA16); assert_eq!(r.surface.format().sample, SampleType::U16); assert!(r.surface.format().alpha); } @@ -604,84 +340,25 @@ fn dump_sample() { let Some(path) = std::env::var_os("PHOTOCRAFT_TEXT_DUMP") else { return; }; - let mut e = if std::env::var_os("PHOTOCRAFT_TEXT_SYSTEM").is_some() { - TextEngine::with_system_fonts() - } else { - TextEngine::new() - }; - let s = CharStyle { - size_pt: 28.0, - ..Default::default() - }; - let text = "Photocraft Type\nBold faux, italic faux, underline\nשלום عربي mixed ✓\nJustified paragraph text wraps inside the box nicely and evenly across lines."; + let mut e = if std::env::var_os("PHOTOCRAFT_TEXT_SYSTEM").is_some() { TextEngine::with_system_fonts() } else { TextEngine::new() }; + let s = CharStyle { size_pt: 28.0, ..Default::default() }; + let text = + "Photocraft Type\nBold faux, italic faux, underline\nשלום عربي mixed ✓\nJustified paragraph text wraps inside the box nicely and evenly across lines."; let runs = vec![ - TextRun { - len: 16, - style: CharStyle { - size_pt: 40.0, - color: Color::rgb(0.1, 0.3, 0.9), - caps: Caps::Normal, - ..s.clone() - }, - }, - TextRun { - len: 5, - style: CharStyle { - faux_bold: true, - ..s.clone() - }, - }, - TextRun { - len: 13, - style: CharStyle { - faux_italic: true, - color: Color::rgb(0.8, 0.1, 0.1), - ..s.clone() - }, - }, - TextRun { - len: 11, - style: CharStyle { - underline: true, - strikethrough: false, - ..s.clone() - }, - }, - TextRun { - len: 1000, - style: CharStyle { - size_pt: 20.0, - tracking: 20.0, - ..s.clone() - }, - }, + TextRun { len: 16, style: CharStyle { size_pt: 40.0, color: Color::rgb(0.1, 0.3, 0.9), caps: Caps::Normal, ..s.clone() } }, + TextRun { len: 5, style: CharStyle { faux_bold: true, ..s.clone() } }, + TextRun { len: 13, style: CharStyle { faux_italic: true, color: Color::rgb(0.8, 0.1, 0.1), ..s.clone() } }, + TextRun { len: 11, style: CharStyle { underline: true, strikethrough: false, ..s.clone() } }, + TextRun { len: 1000, style: CharStyle { size_pt: 20.0, tracking: 20.0, ..s.clone() } }, ]; let t = TextLayer { text: text.into(), runs, paragraphs: vec![ - ParagraphRun { - len: 45, - style: ParagraphStyle { - align: TextAlign::Left, - ..Default::default() - }, - }, - ParagraphRun { - len: 1000, - style: ParagraphStyle { - align: TextAlign::JustifyLeft, - space_before_pt: 6.0, - ..Default::default() - }, - }, + ParagraphRun { len: 45, style: ParagraphStyle { align: TextAlign::Left, ..Default::default() } }, + ParagraphRun { len: 1000, style: ParagraphStyle { align: TextAlign::JustifyLeft, space_before_pt: 6.0, ..Default::default() } }, ], - shape: TextShape::Box { - x: 0.0, - y: 0.0, - width: 560.0, - height: 400.0, - }, + shape: TextShape::Box { x: 0.0, y: 0.0, width: 560.0, height: 400.0 }, transform: Affine::translate(20.0, 20.0), ..Default::default() }; @@ -703,11 +380,8 @@ fn dump_sample() { /// Variable-font axes reach the outlines (uses a system variable font when one is present). #[test] fn variable_font_axes() { - let candidates = [ - "/System/Library/Fonts/SFNS.ttf", - "/usr/share/fonts/truetype/noto/NotoSans-VariableFont_wdth,wght.ttf", - "C:\\Windows\\Fonts\\bahnschrift.ttf", - ]; + let candidates = + ["/System/Library/Fonts/SFNS.ttf", "/usr/share/fonts/truetype/noto/NotoSans-VariableFont_wdth,wght.ttf", "C:\\Windows\\Fonts\\bahnschrift.ttf"]; let Some(bytes) = candidates.iter().find_map(|p| std::fs::read(p).ok()) else { return; }; @@ -716,32 +390,17 @@ fn variable_font_axes() { let Some(fam) = fams.first().cloned() else { return; }; - if !e - .fonts - .faces(&fam) - .iter() - .any(|f| f.axes.iter().any(|a| a.0 == "wght")) - { + if !e.fonts.faces(&fam).iter().any(|f| f.axes.iter().any(|a| a.0 == "wght")) { return; } let ink = |e: &mut TextEngine, w: f32| { let s = CharStyle { font_family: fam.clone(), size_pt: 40.0, - variations: vec![photocraft_doc::text::FontVariation { - axis: "wght".into(), - value: w, - }], + variations: vec![photocraft_doc::text::FontVariation { axis: "wght".into(), value: w }], ..Default::default() }; - let (_, r) = e.render( - &TextLayer { - transform: Affine::translate(0.0, 50.0), - ..styled("Weight", s) - }, - 72.0, - PixelFormat::RGBA8, - ); + let (_, r) = e.render(&TextLayer { transform: Affine::translate(0.0, 50.0), ..styled("Weight", s) }, 72.0, PixelFormat::RGBA8); alpha_sum(&r.surface, r.rect) }; let light = ink(&mut e, 200.0); @@ -757,29 +416,15 @@ fn warp_bends_rendered_text_and_outlines() { t.transform = Affine::translate(20.0, 60.0); let fmt = PixelFormat::RGBA8; let (_, flat) = e.render(&t, 72.0, fmt); - t.warp = Some(TextWarp { - style: "warpArc".into(), - value: 60.0, - horizontal: true, - ..Default::default() - }); + t.warp = Some(TextWarp { style: "warpArc".into(), value: 60.0, horizontal: true, ..Default::default() }); let (layout, arced) = e.render(&t, 72.0, fmt); // An arc pushes the ends down: the warped ink is taller and pixels differ. - assert!( - arced.rect.height() > flat.rect.height() + 10, - "{:?} vs {:?}", - arced.rect, - flat.rect - ); + assert!(arced.rect.height() > flat.rect.height() + 10, "{:?} vs {:?}", arced.rect, flat.rect); assert!(alpha_sum(&arced.surface, arced.rect) > 0.0); // Outlines follow the same warp: the first glyph's outline sits lower than the middle one's. let warp = crate::render::layout_warp(&layout, t.warp.as_ref()); let outs = crate::render::outlines(&layout, &t.transform, warp.as_ref()); - assert_eq!( - outs.len(), - layout.glyphs.iter().filter(|g| g.id != 0).count() - 1, - "space has no outline" - ); + assert_eq!(outs.len(), layout.glyphs.iter().filter(|g| g.id != 0).count() - 1, "space has no outline"); let low_y = |els: &Vec| { els.iter() .filter_map(|e| match e { @@ -795,12 +440,7 @@ fn warp_bends_rendered_text_and_outlines() { for els in &flat_outs { for el in els { if let crate::render::PathEl::MoveTo(p) | crate::render::PathEl::LineTo(p) = el { - assert!( - p[0] >= f64::from(r.x0) - && p[0] <= f64::from(r.x1) - && p[1] >= f64::from(r.y0) - && p[1] <= f64::from(r.y1) - ); + assert!(p[0] >= f64::from(r.x0) && p[0] <= f64::from(r.x1) && p[1] >= f64::from(r.y0) && p[1] <= f64::from(r.y1)); } } } @@ -812,34 +452,14 @@ fn psd_round_trips_antialias_opentype_and_warp() { let style = CharStyle { font_family: "Inter".into(), size_pt: 20.0, - features: vec![ - FontFeature { - tag: "swsh".into(), - value: 1, - }, - FontFeature { - tag: "frac".into(), - value: 1, - }, - ], + features: vec![FontFeature { tag: "swsh".into(), value: 1 }, FontFeature { tag: "frac".into(), value: 1 }], discretionary_ligatures: true, ..Default::default() }; - for aa in [ - AntiAlias::Windows, - AntiAlias::WindowsLcd, - AntiAlias::Crisp, - AntiAlias::None, - ] { + for aa in [AntiAlias::Windows, AntiAlias::WindowsLcd, AntiAlias::Crisp, AntiAlias::None] { let mut t = styled("1/2 Swash", style.clone()); t.antialias = aa; - t.warp = Some(TextWarp { - style: "warpFlag".into(), - value: -35.0, - horizontal_distortion: 10.0, - vertical_distortion: 0.0, - horizontal: false, - }); + t.warp = Some(TextWarp { style: "warpFlag".into(), value: -35.0, horizontal_distortion: 10.0, vertical_distortion: 0.0, horizontal: false }); let bytes = crate::psd::build_tysh(&t, 72.0, None); let back = crate::psd::text_layer_from_tysh(&bytes, 72.0).unwrap(); assert_eq!(back.antialias, aa); diff --git a/crates/text/src/warp.rs b/crates/text/src/warp.rs index 9cd8968b32..13a5c8a394 100644 --- a/crates/text/src/warp.rs +++ b/crates/text/src/warp.rs @@ -34,10 +34,7 @@ pub fn psd_style(id: &str) -> Option<&'static str> { if l == "none" || l == "warpnone" { return Some("warpNone"); } - STYLES - .iter() - .find(|(psd, short)| psd.to_ascii_lowercase() == l || short.to_ascii_lowercase() == l) - .map(|(psd, _)| *psd) + STYLES.iter().find(|(psd, short)| psd.to_ascii_lowercase() == l || short.to_ascii_lowercase() == l).map(|(psd, _)| *psd) } /// A warp prepared for one layout: the shared style math of @@ -50,15 +47,8 @@ impl Warp { /// `warpNone`, unknown styles, an all-zero warp or empty bounds. pub fn new(w: &TextWarp, bounds: [f32; 4]) -> Option { let style = WarpStyle::parse(&w.style).filter(|s| s.is_preset() && s.psd_name() == w.style)?; - StyleWarp::new( - style, - f64::from(w.value), - f64::from(w.horizontal_distortion), - f64::from(w.vertical_distortion), - !w.horizontal, - bounds.map(f64::from), - ) - .map(Warp) + StyleWarp::new(style, f64::from(w.value), f64::from(w.horizontal_distortion), f64::from(w.vertical_distortion), !w.horizontal, bounds.map(f64::from)) + .map(Warp) } /// Longest segment (text-space px) worth sending through [`Warp::apply`] unsplit. @@ -77,13 +67,7 @@ mod tests { use super::*; fn warp(style: &str, value: f32) -> Warp { - let w = TextWarp { - style: psd_style(style).unwrap().into(), - value, - horizontal_distortion: 0.0, - vertical_distortion: 0.0, - horizontal: true, - }; + let w = TextWarp { style: psd_style(style).unwrap().into(), value, horizontal_distortion: 0.0, vertical_distortion: 0.0, horizontal: true }; Warp::new(&w, [0.0, -40.0, 200.0, 10.0]).unwrap() } @@ -93,17 +77,9 @@ mod tests { assert_eq!(psd_style("warpTwist"), Some("warpTwist")); assert_eq!(psd_style("none"), Some("warpNone")); assert_eq!(psd_style("spiral"), None); - let none = TextWarp { - style: "warpNone".into(), - value: 50.0, - ..Default::default() - }; + let none = TextWarp { style: "warpNone".into(), value: 50.0, ..Default::default() }; assert!(Warp::new(&none, [0.0, 0.0, 10.0, 10.0]).is_none()); - let flat = TextWarp { - style: "warpArc".into(), - value: 0.0, - ..Default::default() - }; + let flat = TextWarp { style: "warpArc".into(), value: 0.0, ..Default::default() }; assert!(Warp::new(&flat, [0.0, 0.0, 10.0, 10.0]).is_none()); } diff --git a/crates/ui-egui/examples/fx_bench.rs b/crates/ui-egui/examples/fx_bench.rs index f7fbd14ff2..4da70f1566 100644 --- a/crates/ui-egui/examples/fx_bench.rs +++ b/crates/ui-egui/examples/fx_bench.rs @@ -14,7 +14,9 @@ use std::time::Instant; use eframe::wgpu; use photocraft_color::{BlendMode, Color, ColorMode, SampleType}; -use photocraft_doc::{Adjustment, Bevel, BevelStyle, BevelTechnique, Contour, Document, Effect, FxCommon, FxPaint, Layer, LayerContent, StrokeFx, StrokePosition}; +use photocraft_doc::{ + Adjustment, Bevel, BevelStyle, BevelTechnique, Contour, Document, Effect, FxCommon, FxPaint, Layer, LayerContent, StrokeFx, StrokePosition, +}; use photocraft_geom::{Rect, Size}; use serde_json::json; @@ -39,7 +41,12 @@ fn effects() -> Vec { ds.size = 16.0; vec![ Effect::DropShadow(ds), - Effect::Stroke(StrokeFx { common: FxCommon::new(BlendMode::Normal, 1.0), size: 6.0, position: StrokePosition::Outside, paint: FxPaint::Color(Color::rgb(0.95, 0.85, 0.2)) }), + Effect::Stroke(StrokeFx { + common: FxCommon::new(BlendMode::Normal, 1.0), + size: 6.0, + position: StrokePosition::Outside, + paint: FxPaint::Color(Color::rgb(0.95, 0.85, 0.2)), + }), Effect::BevelEmboss(Bevel { enabled: true, style: BevelStyle::InnerBevel, @@ -90,7 +97,10 @@ fn synthetic(w: u32, h: u32) -> Document { let rows = lines.len() as u32; for (i, t) in lines.iter().enumerate() { let y = (h / (rows + 1)) * (i as u32 + 1); - let r = s.execute("type.create", json!({"x": (w / 12) as i32 + (i as i32 % 3) * 300, "y": y, "text": t, "size": (h / rows / 2).max(12), "color": "#d04020"})); + let r = s.execute( + "type.create", + json!({"x": (w / 12) as i32 + (i as i32 % 3) * 300, "y": y, "text": t, "size": (h / rows / 2).max(12), "color": "#d04020"}), + ); if let Err(e) = r { eprintln!("type.create: {e}"); } @@ -115,7 +125,9 @@ struct Gpu { fn gpu() -> Option { let instance = wgpu::Instance::default(); - let adapter = block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() })).ok()?; + let adapter = + block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() })) + .ok()?; eprintln!("adapter: {:?}", adapter.get_info().name); let limits = adapter.limits(); let (device, queue) = block_on(adapter.request_device(&wgpu::DeviceDescriptor { required_limits: limits, ..Default::default() })).ok()?; @@ -168,7 +180,17 @@ fn compare(files: &[String]) { if std::env::var_os("FX_DUMP").is_some() && worst.0 > 1.0 / 255.0 { println!(" cpu {:?} gpu {:?}", cpu.px[worst.1], out[worst.1]); for (_, depth, l) in doc.walk() { - println!(" {}{} {:?} op {} fill {} clipped {} visible {} fx {:?}", " ".repeat(depth), l.name, l.blend, l.opacity, l.fill_opacity, l.clipped, l.visible, l.effects.items.iter().map(|e| e.label()).collect::>()); + println!( + " {}{} {:?} op {} fill {} clipped {} visible {} fx {:?}", + " ".repeat(depth), + l.name, + l.blend, + l.opacity, + l.fill_opacity, + l.clipped, + l.visible, + l.effects.items.iter().map(|e| e.label()).collect::>() + ); } } } @@ -228,10 +250,15 @@ fn main() { Err(e) => gerr = Some(e), } } - show(what, region, stats(&mut cs), match gerr { - Some(e) => Err(e), - None => Ok(stats(&mut gs).unwrap_or((0.0, 0.0))), - }); + show( + what, + region, + stats(&mut cs), + match gerr { + Some(e) => Err(e), + None => Ok(stats(&mut gs).unwrap_or((0.0, 0.0))), + }, + ); }; if args.iter().any(|a| a == "--baseline") { @@ -241,46 +268,76 @@ fn main() { } case(&mut plain, "baseline: no effects, full refresh", reps, &mut |_, _| full, false); } - case(&mut doc, "full refresh, cold effect caches", 1, &mut |_, _| { - photocraft_compose::purge_effect_cache(); - full - }, true); + case( + &mut doc, + "full refresh, cold effect caches", + 1, + &mut |_, _| { + photocraft_compose::purge_effect_cache(); + full + }, + true, + ); case(&mut doc, "full refresh, warm caches", reps.min(3), &mut |_, _| full, true); // Adjustment tweak (maps unaffected). - case(&mut doc, "adjustment tweak (full refresh)", reps.min(3), &mut |d, _| { - if let Some(LayerContent::Adjustment(Adjustment::HueSaturation { hue, .. })) = d.layers.last_mut().map(|l| &mut l.content) { - *hue += 5.0; - } - full - }, true); + case( + &mut doc, + "adjustment tweak (full refresh)", + reps.min(3), + &mut |d, _| { + if let Some(LayerContent::Adjustment(Adjustment::HueSaturation { hue, .. })) = d.layers.last_mut().map(|l| &mut l.content) { + *hue += 5.0; + } + full + }, + true, + ); // Brush dabs on a plain layer and on the effect layer: the canvas refreshes the damage rect // grown by the effect reach (`canvas::effect_reach`). let margin = doc.walk().iter().map(|(_, _, l)| photocraft_compose::effects::margin(l)).max().unwrap_or(0); let (w, h) = (full.width() as i32, full.height() as i32); - case(&mut doc, "dab on a plain layer (damage + reach)", reps, &mut |d, i| { - let r = Rect::new(w / 3 + i as i32 * 70, h / 3, w / 3 + i as i32 * 70 + 64, h / 3 + 64); - if let Some(l) = d.layers.iter_mut().find(|l| l.name == "paint") { - l.surface_mut().unwrap().fill_rect(r, &[0.1, 0.9, 0.1, 1.0]); - } - r.inflate(margin) - }, true); + case( + &mut doc, + "dab on a plain layer (damage + reach)", + reps, + &mut |d, i| { + let r = Rect::new(w / 3 + i as i32 * 70, h / 3, w / 3 + i as i32 * 70 + 64, h / 3 + 64); + if let Some(l) = d.layers.iter_mut().find(|l| l.name == "paint") { + l.surface_mut().unwrap().fill_rect(r, &[0.1, 0.9, 0.1, 1.0]); + } + r.inflate(margin) + }, + true, + ); // Moving a text layer by whole pixels: its effect maps move with it. - case(&mut doc, "move a text layer 7 px (old + new bounds)", reps, &mut |d, _| { - let Some(l) = d.layers.iter_mut().find(|l| matches!(l.content, LayerContent::Text(_))) else { return Rect::EMPTY }; - let LayerContent::Text(t) = &mut l.content else { return Rect::EMPTY }; - let Some(src) = t.cache.as_ref() else { return Rect::EMPTY }; - let b = src.content_bounds(); - let to = Rect::new(b.x0 + 7, b.y0, b.x1 + 7, b.y1); - let mut moved = photocraft_raster::Surface::new(src.format()); - moved.write_region(to, &src.read_region(b)); - t.cache = Some(moved); - b.union(&to).inflate(margin) - }, true); - case(&mut doc, "dab on the effect layer (damage + reach)", reps, &mut |d, i| { - let r = Rect::new(w / 2 - 32 + i as i32 * 300, h / 2 - 32, w / 2 + 32 + i as i32 * 300, h / 2 + 32); - if let Some(l) = d.layers.iter_mut().find(|l| l.name == "blob") { - l.surface_mut().unwrap().fill_rect(r, &[0.9, 0.1, 0.1, 1.0]); - } - r.inflate(margin) - }, true); + case( + &mut doc, + "move a text layer 7 px (old + new bounds)", + reps, + &mut |d, _| { + let Some(l) = d.layers.iter_mut().find(|l| matches!(l.content, LayerContent::Text(_))) else { return Rect::EMPTY }; + let LayerContent::Text(t) = &mut l.content else { return Rect::EMPTY }; + let Some(src) = t.cache.as_ref() else { return Rect::EMPTY }; + let b = src.content_bounds(); + let to = Rect::new(b.x0 + 7, b.y0, b.x1 + 7, b.y1); + let mut moved = photocraft_raster::Surface::new(src.format()); + moved.write_region(to, &src.read_region(b)); + t.cache = Some(moved); + b.union(&to).inflate(margin) + }, + true, + ); + case( + &mut doc, + "dab on the effect layer (damage + reach)", + reps, + &mut |d, i| { + let r = Rect::new(w / 2 - 32 + i as i32 * 300, h / 2 - 32, w / 2 + 32 + i as i32 * 300, h / 2 + 32); + if let Some(l) = d.layers.iter_mut().find(|l| l.name == "blob") { + l.surface_mut().unwrap().fill_rect(r, &[0.9, 0.1, 0.1, 1.0]); + } + r.inflate(margin) + }, + true, + ); } diff --git a/crates/ui-egui/examples/interactive_bench.rs b/crates/ui-egui/examples/interactive_bench.rs index e4ffac60dc..45da964ebf 100644 --- a/crates/ui-egui/examples/interactive_bench.rs +++ b/crates/ui-egui/examples/interactive_bench.rs @@ -45,7 +45,9 @@ struct Gpu { fn gpu() -> Option { let instance = wgpu::Instance::default(); - let adapter = block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() })).ok()?; + let adapter = + block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() })) + .ok()?; eprintln!("adapter: {}", adapter.get_info().name); let limits = adapter.limits(); let (device, queue) = block_on(adapter.request_device(&wgpu::DeviceDescriptor { required_limits: limits, ..Default::default() })).ok()?; @@ -155,7 +157,8 @@ fn exec(s: &mut Session, id: &str, p: Value) { fn main() { let args: Vec = std::env::args().collect(); - let (w, h) = arg(&args, "--size").and_then(|s| s.split_once('x').map(|(a, b)| (a.parse().unwrap_or(7360), b.parse().unwrap_or(4912)))).unwrap_or((7360, 4912)); + let (w, h) = + arg(&args, "--size").and_then(|s| s.split_once('x').map(|(a, b)| (a.parse().unwrap_or(7360), b.parse().unwrap_or(4912)))).unwrap_or((7360, 4912)); let reps: usize = arg(&args, "--reps").and_then(|v| v.parse().ok()).unwrap_or(5).max(1); let mut b = Bench { gpu: if args.iter().any(|a| a == "--cpu") { None } else { gpu() }, reps, only: arg(&args, "--only"), rows: Vec::new() }; println!("document {w}×{h} ({:.1} MP), 8-bit RGB, {} reps, {}", (w * h) as f64 / 1e6, reps, if b.gpu.is_some() { "GPU canvas" } else { "CPU canvas" }); @@ -288,7 +291,9 @@ fn main() { }); if let Some(out) = arg(&args, "--json") { - let rows: Vec = b.rows.iter().map(|(n, med, min, max, cold)| json!({"name": n, "median_ms": med, "min_ms": min, "max_ms": max, "cold_ms": cold})).collect(); - std::fs::write(&out, serde_json::to_string_pretty(&json!({"width": w, "height": h, "gpu": b.gpu.is_some(), "rows": rows})).unwrap()).expect("write json"); + let rows: Vec = + b.rows.iter().map(|(n, med, min, max, cold)| json!({"name": n, "median_ms": med, "min_ms": min, "max_ms": max, "cold_ms": cold})).collect(); + std::fs::write(&out, serde_json::to_string_pretty(&json!({"width": w, "height": h, "gpu": b.gpu.is_some(), "rows": rows})).unwrap()) + .expect("write json"); } } diff --git a/crates/ui-egui/examples/snapshot.rs b/crates/ui-egui/examples/snapshot.rs index 7a7e767bde..74d44d423e 100644 --- a/crates/ui-egui/examples/snapshot.rs +++ b/crates/ui-egui/examples/snapshot.rs @@ -24,9 +24,8 @@ fn main() { .and_then(|s| s.split_once('x').and_then(|(a, b)| Some((a.parse::().ok()?, b.parse::().ok()?)))) .unwrap_or((1440.0, 900.0)); let scale: f32 = arg(&args, "--scale").and_then(|s| s.parse().ok()).unwrap_or(2.0); - let script: Vec<(String, Value)> = arg(&args, "--script") - .map(|s| serde_json::from_str::>(&s).expect("--script must be [[method, params], …]")) - .unwrap_or_default(); + let script: Vec<(String, Value)> = + arg(&args, "--script").map(|s| serde_json::from_str::>(&s).expect("--script must be [[method, params], …]")).unwrap_or_default(); let services = Services { import: Some(Box::new(|name: &str, bytes: &[u8]| photocraft_io::import(name, bytes).map(|r| r.document).map_err(|e| e.to_string()))), @@ -40,19 +39,20 @@ fn main() { ..Default::default() }; let open = arg(&args, "--open"); - let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(w, h)).with_pixels_per_point(scale).with_max_steps(64).wgpu().build_eframe(move |cc| { - PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); - let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services); - if let Some(rs) = cc.wgpu_render_state.as_ref() { - app.set_wgpu(rs.clone()); - } - if let Some(path) = &open { - let bytes = std::fs::read(path).expect("read --open file"); - let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); - app.open_bytes(&name, &bytes).expect("open file"); - } - app - }); + let mut harness = + egui_kittest::Harness::builder().with_size(egui::vec2(w, h)).with_pixels_per_point(scale).with_max_steps(64).wgpu().build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services); + if let Some(rs) = cc.wgpu_render_state.as_ref() { + app.set_wgpu(rs.clone()); + } + if let Some(path) = &open { + let bytes = std::fs::read(path).expect("read --open file"); + let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + app.open_bytes(&name, &bytes).expect("open file"); + } + app + }); harness.run_steps(4); let ctx = harness.ctx.clone(); let timing = std::env::var_os("SNAPSHOT_TIMING").is_some(); diff --git a/crates/ui-egui/src/actions.rs b/crates/ui-egui/src/actions.rs index 99aac03961..3217a9a986 100644 --- a/crates/ui-egui/src/actions.rs +++ b/crates/ui-egui/src/actions.rs @@ -84,13 +84,23 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } // Expander triangle. let tri = Rect::from_center_size(pos2(rect.left() + 12.0, rect.center().y), vec2(8.0, 8.0)); - let pts = if a.expanded { vec![tri.left_top(), tri.right_top(), tri.center_bottom()] } else { vec![tri.left_top(), tri.right_center(), tri.left_bottom()] }; + let pts = if a.expanded { + vec![tri.left_top(), tri.right_top(), tri.center_bottom()] + } else { + vec![tri.left_top(), tri.right_center(), tri.left_bottom()] + }; ui.painter().add(egui::Shape::convex_polygon(pts, t.text_dim, Stroke::NONE)); crate::icons::paint(ui, Rect::from_center_size(pos2(rect.left() + 30.0, rect.center().y), vec2(16.0, 16.0)), "play", 11.0, t.icon); let recording_this = app.ui.actions.recording.is_some_and(|(_, r)| r == i); let steps = a.steps.len() + if recording_this { live } else { 0 }; ui.painter().text(pos2(rect.left() + 44.0, rect.center().y), Align2::LEFT_CENTER, &a.name, egui::FontId::proportional(12.0), t.text); - ui.painter().text(pos2(rect.right() - 8.0, rect.center().y), Align2::RIGHT_CENTER, format!("{steps} steps"), egui::FontId::proportional(11.0), t.text_faint); + ui.painter().text( + pos2(rect.right() - 8.0, rect.center().y), + Align2::RIGHT_CENTER, + format!("{steps} steps"), + egui::FontId::proportional(11.0), + t.text_faint, + ); if recording_this { ui.painter().circle_filled(pos2(rect.right() - 64.0, rect.center().y), 4.0, Color32::from_rgb(230, 60, 60)); } diff --git a/crates/ui-egui/src/adjust_ui.rs b/crates/ui-egui/src/adjust_ui.rs index 629b318ce6..53cdf4b28e 100644 --- a/crates/ui-egui/src/adjust_ui.rs +++ b/crates/ui-egui/src/adjust_ui.rs @@ -12,17 +12,11 @@ use crate::PhotocraftApp; use crate::theme::Tokens; use crate::widgets; -const RANGE_LABELS: [&str; 9] = [ - "Reds", "Yellows", "Greens", "Cyans", "Blues", "Magentas", "Whites", "Neutrals", "Blacks", -]; +const RANGE_LABELS: [&str; 9] = ["Reds", "Yellows", "Greens", "Cyans", "Blues", "Magentas", "Whites", "Neutrals", "Blacks"]; /// The full parameter set of a Selective Color adjustment (every range as `[c, m, y, k]`). pub fn selective_values(adj: &Adjustment) -> Value { - let Adjustment::SelectiveColor { - relative, - adjustments, - } = adj - else { + let Adjustment::SelectiveColor { relative, adjustments } = adj else { return json!({}); }; let mut v = json!({"method": if *relative { "relative" } else { "absolute" }}); @@ -32,12 +26,7 @@ pub fn selective_values(adj: &Adjustment) -> Value { v } -pub fn selective_color_editor( - app: &mut PhotocraftApp, - ui: &mut egui::Ui, - id: LayerId, - adj: &Adjustment, -) { +pub fn selective_color_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: LayerId, adj: &Adjustment) { let t = Tokens::get(ui.ctx()); let committed = selective_values(adj); let mut values = match &app.live_adjust { @@ -49,13 +38,7 @@ pub fn selective_color_editor( ui.horizontal(|ui| { ui.label(egui::RichText::new("Colors").color(t.text_dim)); let opts: Vec<(usize, &str)> = RANGE_LABELS.iter().copied().enumerate().collect(); - if widgets::dropdown( - ui, - &format!("selc-colors-{}", id.0), - &mut range, - &opts, - 150.0, - ) { + if widgets::dropdown(ui, &format!("selc-colors-{}", id.0), &mut range, &opts, 150.0) { ui.data_mut(|d| d.insert_temp(key, range)); } }); @@ -95,32 +78,14 @@ pub fn selective_color_editor( } } -pub fn color_lookup_editor( - app: &mut PhotocraftApp, - ui: &mut egui::Ui, - id: LayerId, - adj: &Adjustment, -) { +pub fn color_lookup_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: LayerId, adj: &Adjustment) { let t = Tokens::get(ui.ctx()); - let Adjustment::ColorLookup { - name, - lut, - size, - tetrahedral, - dither, - } = adj - else { + let Adjustment::ColorLookup { name, lut, size, tetrahedral, dither } = adj else { return; }; let builtins = photocraft_engine::adjust_cmds::LOOKS; - let current = if lut.is_none() { - "none".to_string() - } else { - builtins - .iter() - .find(|b| b.1 == name) - .map_or_else(|| "custom".to_string(), |b| b.0.to_string()) - }; + let current = + if lut.is_none() { "none".to_string() } else { builtins.iter().find(|b| b.1 == name).map_or_else(|| "custom".to_string(), |b| b.0.to_string()) }; let custom_label = format!("{name} ({size}³)"); let mut opts: Vec<(String, &str)> = vec![("none".into(), "Load 3D LUT…")]; opts.extend(builtins.iter().map(|(id, label)| (id.to_string(), *label))); @@ -131,10 +96,7 @@ pub fn color_lookup_editor( let mut params: Option = None; ui.horizontal(|ui| { ui.label(egui::RichText::new("3D LUT File").color(t.text_dim)); - if widgets::dropdown(ui, &format!("clrl-{}", id.0), &mut sel, &opts, 170.0) - && sel != current - && sel != "custom" - { + if widgets::dropdown(ui, &format!("clrl-{}", id.0), &mut sel, &opts, 170.0) && sel != current && sel != "custom" { params = Some(json!({"lut": sel})); } }); @@ -142,11 +104,7 @@ pub fn color_lookup_editor( let path_key = egui::Id::new(("clrl-path", id.0)); let mut path: String = ui.data(|d| d.get_temp(path_key)).unwrap_or_default(); ui.horizontal(|ui| { - let r = ui.add( - egui::TextEdit::singleline(&mut path) - .hint_text(".cube / .3dl / .look path") - .desired_width(170.0), - ); + let r = ui.add(egui::TextEdit::singleline(&mut path).hint_text(".cube / .3dl / .look path").desired_width(170.0)); if r.changed() { ui.data_mut(|d| d.insert_temp(path_key, path.clone())); } diff --git a/crates/ui-egui/src/analysis_ui.rs b/crates/ui-egui/src/analysis_ui.rs index a24bdc9051..43686c432e 100644 --- a/crates/ui-egui/src/analysis_ui.rs +++ b/crates/ui-egui/src/analysis_ui.rs @@ -53,7 +53,17 @@ pub struct AnalysisUi { impl Default for AnalysisUi { fn default() -> Self { - Self { measurement_log: false, notes: false, log_selected: Vec::new(), note_selected: None, note_author: String::new(), note_color: [1.0, 1.0, 0.51], use_measurement_scale: false, dialog: None, drag: None } + Self { + measurement_log: false, + notes: false, + log_selected: Vec::new(), + note_selected: None, + note_author: String::new(), + note_color: [1.0, 1.0, 0.51], + use_measurement_scale: false, + dialog: None, + drag: None, + } } } @@ -116,7 +126,11 @@ pub fn menu(app: &mut PhotocraftApp, id: &str, params: &Value) -> Option { let d = doc?; let sc = &d.measurement.scale; - open(app, "scale", json!({"preset": if sc.is_default() { "default" } else { "custom" }, "pixelLength": sc.pixel_length, "logicalLength": sc.logical_length, "units": sc.units})) + open( + app, + "scale", + json!({"preset": if sc.is_default() { "default" } else { "custom" }, "pixelLength": sc.pixel_length, "logicalLength": sc.logical_length, "units": sc.units}), + ) } "image.analysis.selectDataPoints" => { let dp = serde_json::to_value(&app.session.analysis.data_points).unwrap_or_default(); @@ -125,7 +139,11 @@ pub fn menu(app: &mut PhotocraftApp, id: &str, params: &Value) -> Option { let d = doc?; let units = d.measurement.scale.units.clone(); - open(app, "scaleMarker", json!({"length": suggested_length(&d), "units": units, "fontSize": 12.0, "displayText": true, "textPosition": "bottom", "color": "black"})) + open( + app, + "scaleMarker", + json!({"length": suggested_length(&d), "units": units, "fontSize": 12.0, "displayText": true, "textPosition": "bottom", "color": "black"}), + ) } "image.analysis.recordMeasurements" => { // The current tool picks the source, as in Photoshop. @@ -271,7 +289,8 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> } (Tool::Note, ToolEvent::Down { x, y, .. }) => { let z = f64::from(app.current_zoom().max(0.01)); - let hit = doc.notes.iter().rposition(|n| x >= n.position[0] && x <= n.position[0] + 16.0 / z && y >= n.position[1] && y <= n.position[1] + 20.0 / z); + let hit = + doc.notes.iter().rposition(|n| x >= n.position[0] && x <= n.position[0] + 16.0 / z && y >= n.position[1] && y <= n.position[1] + 20.0 / z); match hit { Some(i) => { app.ui.analysis.note_selected = Some(i); @@ -363,7 +382,12 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform let fill = color32(n.color.to_rgb()); painter.rect_filled(r, CornerRadius::same(2), fill); let sel = app.ui.analysis.note_selected == Some(i); - painter.rect_stroke(r, CornerRadius::same(2), Stroke::new(if sel { 2.0 } else { 1.0 }, if sel { Color32::WHITE } else { Color32::from_black_alpha(200) }), egui::StrokeKind::Outside); + painter.rect_stroke( + r, + CornerRadius::same(2), + Stroke::new(if sel { 2.0 } else { 1.0 }, if sel { Color32::WHITE } else { Color32::from_black_alpha(200) }), + egui::StrokeKind::Outside, + ); // Folded corner and text lines. let ink = Color32::from_black_alpha(140); painter.line_segment([r.right_top() + vec2(-5.0, 0.0), r.right_top() + vec2(0.0, 5.0)], Stroke::new(1.0, ink)); @@ -697,11 +721,17 @@ pub fn dialog_command(kind: &str, f: &Map) -> Option<(&'static st if f.get("preset").and_then(Value::as_str) == Some("default") { ("image.analysis.setMeasurementScale", json!({"preset": "default"})) } else { - ("image.analysis.setMeasurementScale", json!({"preset": "custom", "pixelLength": f.get("pixelLength"), "logicalLength": f.get("logicalLength"), "units": f.get("units")})) + ( + "image.analysis.setMeasurementScale", + json!({"preset": "custom", "pixelLength": f.get("pixelLength"), "logicalLength": f.get("logicalLength"), "units": f.get("units")}), + ) } } "dataPoints" => ("image.analysis.selectDataPoints", json!({"selection": f.get("selection"), "ruler": f.get("ruler"), "count": f.get("count")})), - "scaleMarker" => ("image.analysis.placeScaleMarker", json!({"length": f.get("length"), "fontSize": f.get("fontSize"), "displayText": f.get("displayText"), "textPosition": f.get("textPosition"), "color": f.get("color")})), + "scaleMarker" => ( + "image.analysis.placeScaleMarker", + json!({"length": f.get("length"), "fontSize": f.get("fontSize"), "displayText": f.get("displayText"), "textPosition": f.get("textPosition"), "color": f.get("color")}), + ), _ => return None, }) } @@ -750,7 +780,11 @@ fn dialog(app: &mut PhotocraftApp, ctx: &egui::Context) { for (source, label) in [("selection", "Selections"), ("ruler", "Ruler Tool"), ("count", "Count Tool")] { ui.vertical(|ui| { ui.label(RichText::new(label).color(t.text).size(11.0).strong()); - let mut list: Vec = f.get(source).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(String::from).collect()).unwrap_or_default(); + let mut list: Vec = f + .get(source) + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(Value::as_str).map(String::from).collect()) + .unwrap_or_default(); let mut changed = false; for key in photocraft_engine::analysis_cmds::available(source) { let name = photocraft_engine::analysis_cmds::COLUMNS.iter().find(|c| c.0 == key).map_or(key, |c| c.1); @@ -853,7 +887,14 @@ pub(crate) mod tests { crate::menus::invoke(&mut app, &ctx, "window.panel.notes", json!({"show": true})).unwrap(); assert!(app.ui.analysis.notes); let items = crate::menus::menu_items(&app); - for id in ["window.panel.measurementLog", "window.panel.notes", "image.analysis.setMeasurementScale", "image.analysis.placeScaleMarker", "file.import.notes", "view.proofSetup.workingCyanPlate"] { + for id in [ + "window.panel.measurementLog", + "window.panel.notes", + "image.analysis.setMeasurementScale", + "image.analysis.placeScaleMarker", + "file.import.notes", + "view.proofSetup.workingCyanPlate", + ] { assert!(items.iter().any(|i| i.id == id && i.enabled), "{id} live"); } } diff --git a/crates/ui-egui/src/artboard_ui.rs b/crates/ui-egui/src/artboard_ui.rs index 26d561d31f..6dd66a9df3 100644 --- a/crates/ui-egui/src/artboard_ui.rs +++ b/crates/ui-egui/src/artboard_ui.rs @@ -163,7 +163,15 @@ mod tests { #[test] fn fit_artboard_and_menu_items() { let (mut app, ctx) = app(); - for id in ["view.fitArtboardOnScreen", "window.panel.layerComps", "layer.new.artboard", "file.export.artboardsToFiles", "file.export.artboardsToPdf", "file.export.layerCompsToFiles", "view.clearSelectedArtboardGuides"] { + for id in [ + "view.fitArtboardOnScreen", + "window.panel.layerComps", + "layer.new.artboard", + "file.export.artboardsToFiles", + "file.export.artboardsToPdf", + "file.export.layerCompsToFiles", + "view.clearSelectedArtboardGuides", + ] { assert!(is_live(id), "{id}"); } let enabled = |app: &PhotocraftApp, id: &str| menu_items(app).into_iter().find(|i| i.id == id).is_some_and(|i| i.enabled); diff --git a/crates/ui-egui/src/camera_raw_ui.rs b/crates/ui-egui/src/camera_raw_ui.rs index eed50d750c..f9dbadee35 100644 --- a/crates/ui-egui/src/camera_raw_ui.rs +++ b/crates/ui-egui/src/camera_raw_ui.rs @@ -118,7 +118,22 @@ pub fn open(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> } } let float = surf.format().sample == photocraft_color::SampleType::F32; - let mut d = CameraRawDialog { layer, layer_name: name, params: CameraRaw::default(), proxy, pw, ph, full_w: w, float, tex: None, before_tex: None, dirty: true, show_before: false, mixer_tab: 1, render_ms: 0.0 }; + let mut d = CameraRawDialog { + layer, + layer_name: name, + params: CameraRaw::default(), + proxy, + pw, + ph, + full_w: w, + float, + tex: None, + before_tex: None, + dirty: true, + show_before: false, + mixer_tab: 1, + render_ms: 0.0, + }; d.render(ctx); app.camera_raw = Some(d); Ok(()) @@ -382,7 +397,17 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { row(ui, &mut dirty, "Roughness", &mut p.grain_roughness, 0.0..=100.0, None); widgets::section_label(ui, "Vignetting"); let mut style = p.vignette_style.clone(); - if widgets::dropdown(ui, "cr-vig-style", &mut style, &[("highlightPriority".to_string(), "Highlight Priority"), ("colorPriority".to_string(), "Color Priority"), ("paintOverlay".to_string(), "Paint Overlay")], 200.0) { + if widgets::dropdown( + ui, + "cr-vig-style", + &mut style, + &[ + ("highlightPriority".to_string(), "Highlight Priority"), + ("colorPriority".to_string(), "Color Priority"), + ("paintOverlay".to_string(), "Paint Overlay"), + ], + 200.0, + ) { p.vignette_style = style; dirty = true; } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ecf216af87..447fcc7b0d 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -9,8 +9,8 @@ use photocraft_doc::{Document, LayerContent}; use photocraft_geom::Rect as DRect; use serde_json::json; -use crate::state::{Tool, View}; use crate::PhotocraftApp; +use crate::state::{Tool, View}; /// Largest texture side we upload; bigger documents display downsampled until the GPU path lands. pub const MAX_TEXTURE: u32 = 4096; @@ -82,12 +82,9 @@ pub fn fit_view(view: &mut View, doc: &Document, area: Vec2) { /// Zoom steps like Photoshop's (⌘+ / ⌘−). pub fn zoom_step(z: f32, dir: i32) -> f32 { - const STEPS: [f32; 22] = [0.01, 0.02, 0.03, 0.05, 0.0667, 0.1, 0.125, 0.1667, 0.25, 0.333, 0.5, 0.6667, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 8.0, 12.0, 16.0, 32.0]; - if dir > 0 { - STEPS.iter().copied().find(|s| *s > z * 1.001).unwrap_or(32.0) - } else { - STEPS.iter().rev().copied().find(|s| *s < z * 0.999).unwrap_or(0.01) - } + const STEPS: [f32; 22] = + [0.01, 0.02, 0.03, 0.05, 0.0667, 0.1, 0.125, 0.1667, 0.25, 0.333, 0.5, 0.6667, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 8.0, 12.0, 16.0, 32.0]; + if dir > 0 { STEPS.iter().copied().find(|s| *s > z * 1.001).unwrap_or(32.0) } else { STEPS.iter().rev().copied().find(|s| *s < z * 0.999).unwrap_or(0.01) } } fn checker(app: &mut PhotocraftApp, ctx: &egui::Context) -> egui::TextureId { @@ -101,7 +98,12 @@ fn checker(app: &mut PhotocraftApp, ctx: &egui::Context) -> egui::TextureId { .get_or_insert_with(|| { let (a, b) = (Color32::from_rgb(ca[0], ca[1], ca[2]), Color32::from_rgb(cb[0], cb[1], cb[2])); let img = egui::ColorImage::new([2, 2], vec![a, b, b, a]); - let opts = TextureOptions { magnification: egui::TextureFilter::Nearest, minification: egui::TextureFilter::Nearest, wrap_mode: egui::TextureWrapMode::Repeat, mipmap_mode: None }; + let opts = TextureOptions { + magnification: egui::TextureFilter::Nearest, + minification: egui::TextureFilter::Nearest, + wrap_mode: egui::TextureWrapMode::Repeat, + mipmap_mode: None, + }; ctx.load_texture("checker", img, opts) }) .id() @@ -199,9 +201,21 @@ pub fn ensure_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize) (st.revision, st.last_damage.map(|r| if r.is_empty() { r } else { r.inflate(effect_reach(&st.doc.layers)) }), st.doc.id) }; let (doc, preview_key) = display_doc(app, idx); - let cache = app.canvases.entry(id).or_insert(CanvasCache { revision: 0, texture: None, scale: 1.0, preview_key: 0, on_gpu: false, tex_revision: 0, tex_preview_key: 0 }); + let cache = app.canvases.entry(id).or_insert(CanvasCache { + revision: 0, + texture: None, + scale: 1.0, + preview_key: 0, + on_gpu: false, + tex_revision: 0, + tex_preview_key: 0, + }); if cache.tex_revision != revision || cache.texture.is_none() || cache.tex_preview_key != preview_key { - let partial = cache.texture.is_some() && cache.tex_preview_key == preview_key && cache.tex_revision + 1 == revision && cache.scale == 1.0 && last_damage.is_some(); + let partial = cache.texture.is_some() + && cache.tex_preview_key == preview_key + && cache.tex_revision + 1 == revision + && cache.scale == 1.0 + && last_damage.is_some(); let t0 = crate::gpu_canvas::now_ms(); if partial { let r = last_damage.unwrap().intersect(&doc.bounds()); @@ -255,10 +269,25 @@ fn effect_reach(layers: &[photocraft_doc::Layer]) -> i32 { /// Returns false if there is no GPU canvas. fn ensure_gpu(app: &mut PhotocraftApp, idx: usize) -> bool { let Some(gpu) = app.gpu.clone() else { return false }; - let Some((revision, last_damage, id)) = app.session.documents().get(idx).map(|st| (st.revision, st.last_damage.map(|r| if r.is_empty() { r } else { r.inflate(effect_reach(&st.doc.layers)) }), st.doc.id)) else { return false }; + let Some((revision, last_damage, id)) = app + .session + .documents() + .get(idx) + .map(|st| (st.revision, st.last_damage.map(|r| if r.is_empty() { r } else { r.inflate(effect_reach(&st.doc.layers)) }), st.doc.id)) + else { + return false; + }; let (doc, preview_key) = display_doc(app, idx); let size = [doc.size.width, doc.size.height]; - let cache = app.canvases.entry(id).or_insert(CanvasCache { revision: 0, texture: None, scale: 1.0, preview_key: 0, on_gpu: false, tex_revision: 0, tex_preview_key: 0 }); + let cache = app.canvases.entry(id).or_insert(CanvasCache { + revision: 0, + texture: None, + scale: 1.0, + preview_key: 0, + on_gpu: false, + tex_revision: 0, + tex_preview_key: 0, + }); let present = cache.on_gpu && gpu.has(id.0, size); if present && cache.revision == revision && cache.preview_key == preview_key { return true; @@ -544,7 +573,12 @@ fn dots(ctx: &egui::Context, t: &crate::theme::Tokens) -> Option(key)).as_deref() == Some(sig.as_str()) { @@ -696,7 +739,10 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: doc_size: [doc.size.width.div_ceil(k), doc.size.height.div_ceil(k)], zoom: view.zoom * k as f32, center: [view.center[0] / k as f32, view.center[1] / k as f32], - shadow: { let t = crate::theme::Tokens::get(&ctx); !t.bevel && !t.pro && drop_shadow }, + shadow: { + let t = crate::theme::Tokens::get(&ctx); + !t.bevel && !t.pro && drop_shadow + }, pixel_grid: false, view_key: egui::Id::new(("pc-canvas-proxy", ctx.viewport_id(), idx)).value(), display: 0, @@ -711,7 +757,10 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: doc_size: [doc.size.width, doc.size.height], zoom: view.zoom, center: view.center, - shadow: { let t = crate::theme::Tokens::get(&ctx); !t.bevel && !t.pro && drop_shadow }, + shadow: { + let t = crate::theme::Tokens::get(&ctx); + !t.bevel && !t.pro && drop_shadow + }, pixel_grid, view_key: egui::Id::new(("pc-canvas", ctx.viewport_id(), idx)).value(), display: sync_display_lut(app, &ctx, &doc), @@ -809,7 +858,13 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: ((br[0].ceil() as i32).div_euclid(q) + 2) * q, ((br[1].ceil() as i32).div_euclid(q) + 2) * q, ); - let key = crate::surface_fingerprint(sel) ^ (step as u64) << 56 ^ doc.id.0.rotate_left(17) ^ (vis.x0 as u64) << 8 ^ (vis.y0 as u64) << 24 ^ (vis.x1 as u64) << 36 ^ (vis.y1 as u64) << 48; + let key = crate::surface_fingerprint(sel) + ^ (step as u64) << 56 + ^ doc.id.0.rotate_left(17) + ^ (vis.x0 as u64) << 8 + ^ (vis.y0 as u64) << 24 + ^ (vis.x1 as u64) << 36 + ^ (vis.y1 as u64) << 48; let fresh = matches!(&app.outline_cache, Some((d, k, _)) if *d == doc.id && *k == key); if !fresh { let t0 = crate::gpu_canvas::now_ms(); @@ -962,13 +1017,23 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: } } // Preferences › Cursors › Other Cursors: Precise shows a crosshair for every tool. - Tool::Move | Tool::Type | Tool::Eyedropper if app.session.prefs().cursors.other == photocraft_engine::prefs::OtherCursor::Precise => egui::CursorIcon::Crosshair, + Tool::Move | Tool::Type | Tool::Eyedropper if app.session.prefs().cursors.other == photocraft_engine::prefs::OtherCursor::Precise => { + egui::CursorIcon::Crosshair + } Tool::Move => egui::CursorIcon::Move, Tool::Hand => { - if response.dragged() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab } + if response.dragged() { + egui::CursorIcon::Grabbing + } else { + egui::CursorIcon::Grab + } } Tool::Zoom => { - if alt { egui::CursorIcon::ZoomOut } else { egui::CursorIcon::ZoomIn } + if alt { + egui::CursorIcon::ZoomOut + } else { + egui::CursorIcon::ZoomIn + } } Tool::Type => egui::CursorIcon::Text, _ => egui::CursorIcon::Crosshair, @@ -1162,9 +1227,19 @@ fn draw_drag_preview(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXfor } } Tool::Line => { - painter.line_segment([xf.to_screen(d.start[0] as f32, d.start[1] as f32), xf.to_screen(last[0] as f32, last[1] as f32)], Stroke::new(1.0, crate::theme::Tokens::get(painter.ctx()).accent)); + painter.line_segment( + [xf.to_screen(d.start[0] as f32, d.start[1] as f32), xf.to_screen(last[0] as f32, last[1] as f32)], + Stroke::new(1.0, crate::theme::Tokens::get(painter.ctx()).accent), + ); } - Tool::RectMarquee | Tool::EllipseMarquee | Tool::ObjectSelection | Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::CustomShape => { + Tool::RectMarquee + | Tool::EllipseMarquee + | Tool::ObjectSelection + | Tool::Rectangle + | Tool::EllipseShape + | Tool::Triangle + | Tool::Polygon + | Tool::CustomShape => { let r = Rect::from_two_pos(xf.to_screen(d.start[0] as f32, d.start[1] as f32), xf.to_screen(last[0] as f32, last[1] as f32)); if matches!(d.tool, Tool::EllipseMarquee | Tool::EllipseShape) { painter.add(egui::Shape::ellipse_stroke(r.center(), r.size() / 2.0, Stroke::new(1.0, Color32::WHITE))); @@ -1291,7 +1366,9 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) } Tool::PolygonLasso => { // Click adds a vertex; clicking near the first vertex closes the polygon. - let close = app.ui.polygon.first().is_some_and(|p0| app.ui.polygon.len() >= 3 && ((p0[0] - x).powi(2) + (p0[1] - y).powi(2)).sqrt() < 8.0 / app.current_zoom().max(0.01) as f64); + let close = app.ui.polygon.first().is_some_and(|p0| { + app.ui.polygon.len() >= 3 && ((p0[0] - x).powi(2) + (p0[1] - y).powi(2)).sqrt() < 8.0 / app.current_zoom().max(0.01) as f64 + }); if close { commit_polygon(app, mods); } else { diff --git a/crates/ui-egui/src/channels_panel.rs b/crates/ui-egui/src/channels_panel.rs index 0145ed08a3..1f4ed13217 100644 --- a/crates/ui-egui/src/channels_panel.rs +++ b/crates/ui-egui/src/channels_panel.rs @@ -71,10 +71,30 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { egui::ScrollArea::vertical().max_height((ui.available_height() - footer).max(60.0)).auto_shrink([false, true]).show(ui, |ui| { for row in rows { let (name, thumb_idx, slot, visible, selected) = match row { - Row::Composite => (channel_cmds::composite_name(mode).to_string(), 0, Some(2), view.visible_colors(colors) == colors, view.target == ChannelTarget::Composite && !quick), - Row::Color(k) => (channel_cmds::color_names(mode)[k].to_string(), 1 + k, Some(3 + k), view.color_visible(k), (view.target == ChannelTarget::Composite && !quick) || view.target == ChannelTarget::Color(k)), - Row::Alpha(i) => (doc.channels[i].name.clone(), 1 + shown_colors + i, Some(3 + shown_colors + i).filter(|s| *s <= 9), view.alpha_shown(i), view.target == ChannelTarget::Alpha(i)), - Row::QuickMask => ("Quick Mask".to_string(), 1 + shown_colors + doc.channels.len(), None, !view.quick_mask_hidden, view.target == ChannelTarget::Composite), + Row::Composite => ( + channel_cmds::composite_name(mode).to_string(), + 0, + Some(2), + view.visible_colors(colors) == colors, + view.target == ChannelTarget::Composite && !quick, + ), + Row::Color(k) => ( + channel_cmds::color_names(mode)[k].to_string(), + 1 + k, + Some(3 + k), + view.color_visible(k), + (view.target == ChannelTarget::Composite && !quick) || view.target == ChannelTarget::Color(k), + ), + Row::Alpha(i) => ( + doc.channels[i].name.clone(), + 1 + shown_colors + i, + Some(3 + shown_colors + i).filter(|s| *s <= 9), + view.alpha_shown(i), + view.target == ChannelTarget::Alpha(i), + ), + Row::QuickMask => { + ("Quick Mask".to_string(), 1 + shown_colors + doc.channels.len(), None, !view.quick_mask_hidden, view.target == ChannelTarget::Composite) + } }; let row_h = if t.pro { 36.0 } else { 40.0 }; let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click()); @@ -115,7 +135,13 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let text_pos = pos2(thumb.right() + 10.0, rect.center().y - galley.size().y / 2.0); painter.galley(text_pos, galley, t.text); if let Some(slot) = slot { - painter.text(pos2(rect.right() - 8.0, rect.center().y), Align2::RIGHT_CENTER, crate::shortcuts::pretty(&format!("Cmd+{slot}")), theme::mono(11.0), t.text_faint); + painter.text( + pos2(rect.right() - 8.0, rect.center().y), + Align2::RIGHT_CENTER, + crate::shortcuts::pretty(&format!("Cmd+{slot}")), + theme::mono(11.0), + t.text_faint, + ); } let rename_id = egui::Id::new(("chan-rename", doc.id.0, format!("{:?}", row.reference()))); if resp.clicked() && !eye_resp.clicked() { @@ -239,7 +265,8 @@ fn indicates_items(ui: &mut egui::Ui, actions: &mut Vec<(String, Value)>, channe /// Overlay colour presets (Channel Options › Color) and opacity. fn overlay_colors(ui: &mut egui::Ui, actions: &mut Vec<(String, Value)>, channel: Value) { ui.menu_button("Overlay Color", |ui| { - for (label, hex) in [("Red", "#ff0000"), ("Green", "#00c000"), ("Blue", "#0050ff"), ("Cyan", "#00d0e0"), ("Magenta", "#e000c0"), ("Yellow", "#f0d000")] { + for (label, hex) in [("Red", "#ff0000"), ("Green", "#00c000"), ("Blue", "#0050ff"), ("Cyan", "#00d0e0"), ("Magenta", "#e000c0"), ("Yellow", "#f0d000")] + { if ui.button(label).clicked() { actions.push(("channel.options".into(), json!({ "channel": channel.clone(), "color": hex }))); ui.close(); diff --git a/crates/ui-egui/src/chrome_ui.rs b/crates/ui-egui/src/chrome_ui.rs index ab9563d8ad..9894dcf3ab 100644 --- a/crates/ui-egui/src/chrome_ui.rs +++ b/crates/ui-egui/src/chrome_ui.rs @@ -173,7 +173,15 @@ pub fn marquee_end(style: &str, w: f64, h: f64, shift: bool, start: [f64; 2], en } /// Crop options bar ratio presets: (key, label). -pub const CROP_RATIOS: &[(&str, &str)] = &[("", "Ratio"), ("original", "Original Ratio"), ("1:1", "1 : 1 (Square)"), ("4:5", "4 : 5 (8 : 10)"), ("5:7", "5 : 7"), ("2:3", "2 : 3 (4 : 6)"), ("16:9", "16 : 9")]; +pub const CROP_RATIOS: &[(&str, &str)] = &[ + ("", "Ratio"), + ("original", "Original Ratio"), + ("1:1", "1 : 1 (Square)"), + ("4:5", "4 : 5 (8 : 10)"), + ("5:7", "5 : 7"), + ("2:3", "2 : 3 (4 : 6)"), + ("16:9", "16 : 9"), +]; /// Width/height of a crop ratio key (`original` uses the document size). pub fn crop_ratio(key: &str, doc_w: f64, doc_h: f64) -> Option<(f64, f64)> { diff --git a/crates/ui-egui/src/comps_ui.rs b/crates/ui-egui/src/comps_ui.rs index cfb100965b..b22805aa70 100644 --- a/crates/ui-egui/src/comps_ui.rs +++ b/crates/ui-egui/src/comps_ui.rs @@ -59,7 +59,11 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if doc.layer_comps.is_empty() { ui.add_space(6.0); - ui.label(egui::RichText::new("Capture the visibility, position and style of every layer with + below, then switch between versions.").color(t.text_faint).size(11.5)); + ui.label( + egui::RichText::new("Capture the visibility, position and style of every layer with + below, then switch between versions.") + .color(t.text_faint) + .size(11.5), + ); } for c in &doc.layer_comps { let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click()); @@ -75,7 +79,11 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { crate::icons::paint(ui, marker, "check", 10.0, t.accent); } // The three apply toggles, right-aligned. - let toggles = [("eye", "Visibility", c.apply_visibility, "visibility"), ("move", "Position", c.apply_position, "position"), ("sparkles", "Appearance (Layer Style)", c.apply_appearance, "appearance")]; + let toggles = [ + ("eye", "Visibility", c.apply_visibility, "visibility"), + ("move", "Position", c.apply_position, "position"), + ("sparkles", "Appearance (Layer Style)", c.apply_appearance, "appearance"), + ]; let mut x = rect.right() - 14.0; let mut toggle_hit = false; for (icon, tip, on, key) in toggles.iter().rev() { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index b30b257228..f3883c38b0 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -46,7 +46,10 @@ impl ControlRequest { pub enum Outcome { Done(Value), - Screenshot { token: u64, path: Option }, + Screenshot { + token: u64, + path: Option, + }, /// Synthetic input queued: reply once the app has processed all of it (so a following /// `ui.inspect`/`engine.execute` observes the effect). AfterInput, @@ -213,7 +216,13 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest // Modifier flags may be top-level or grouped under "modifiers". let m = p.get("modifiers").unwrap_or(p); let flag = |k: &str| m.get(k).and_then(Value::as_bool).unwrap_or(false); - let mods = egui::Modifiers { shift: flag("shift"), alt: flag("alt"), command: flag("command"), mac_cmd: cfg!(target_os = "macos") && flag("command"), ctrl: flag("ctrl") }; + let mods = egui::Modifiers { + shift: flag("shift"), + alt: flag("alt"), + command: flag("command"), + mac_cmd: cfg!(target_os = "macos") && flag("command"), + ctrl: flag("ctrl"), + }; if let Some(t) = s("tool").and_then(Tool::from_name) { app.ui.tool = t; } @@ -257,7 +266,13 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest // Modifier flags may be top-level or grouped under "modifiers". let m = p.get("modifiers").unwrap_or(p); let flag = |k: &str| m.get(k).and_then(Value::as_bool).unwrap_or(false); - let modifiers = egui::Modifiers { command: flag("command"), mac_cmd: cfg!(target_os = "macos") && flag("command"), shift: flag("shift"), alt: flag("alt"), ctrl: flag("ctrl") }; + let modifiers = egui::Modifiers { + command: flag("command"), + mac_cmd: cfg!(target_os = "macos") && flag("command"), + shift: flag("shift"), + alt: flag("alt"), + ctrl: flag("ctrl"), + }; app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }); app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }); ctx.request_repaint(); @@ -308,12 +323,8 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest /// Snapshot of everything on screen, addressable by id. pub fn inspect(app: &PhotocraftApp, ctx: &egui::Context) -> Value { let screen = ctx.content_rect(); - let dialogs: Vec = app - .ui - .dialogs - .iter() - .map(|d| json!({"id": d.id, "kind": d.kind, "title": crate::dialogs::title(d), "fields": d.fields})) - .collect(); + let dialogs: Vec = + app.ui.dialogs.iter().map(|d| json!({"id": d.id, "kind": d.kind, "title": crate::dialogs::title(d), "fields": d.fields})).collect(); json!({ "window": {"width": screen.width(), "height": screen.height(), "pixelsPerPoint": ctx.pixels_per_point()}, "tool": app.ui.tool, diff --git a/crates/ui-egui/src/distort_ui.rs b/crates/ui-egui/src/distort_ui.rs index ffd77ff3bd..e576c4f039 100644 --- a/crates/ui-egui/src/distort_ui.rs +++ b/crates/ui-egui/src/distort_ui.rs @@ -106,10 +106,18 @@ pub fn menu(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Val "filter.filterGallery" if ui.is_some() && app.distort.gallery.is_some() => Some(crate::gallery_ui::control(app, ui.unwrap_or(&Value::Null))), "filter.liquify" if empty => Some(crate::liquify_ui::open(app, ctx).map(|_| json!({"liquify": app.distort.describe()["liquify"]}))), "filter.liquify" if ui.is_some() && app.distort.liquify.is_some() => Some(crate::liquify_ui::control(app, ui.unwrap_or(&Value::Null))), - "edit.puppetWarp" | "layer.smartObjects.puppetWarp" if empty => Some(crate::puppet_ui::begin(app, ctx, id).map(|_| json!({"puppet": app.distort.describe()["puppet"]}))), - "edit.puppetWarp" | "layer.smartObjects.puppetWarp" if ui.is_some() && app.distort.puppet.is_some() => Some(crate::puppet_ui::control(app, ui.unwrap_or(&Value::Null))), - "edit.perspectiveWarp" | "layer.smartObjects.perspectiveWarp" if empty => Some(crate::perspective_ui::begin(app, ctx, id).map(|_| json!({"perspective": app.distort.describe()["perspective"]}))), - "edit.perspectiveWarp" | "layer.smartObjects.perspectiveWarp" if ui.is_some() && app.distort.perspective.is_some() => Some(crate::perspective_ui::control(app, ui.unwrap_or(&Value::Null))), + "edit.puppetWarp" | "layer.smartObjects.puppetWarp" if empty => { + Some(crate::puppet_ui::begin(app, ctx, id).map(|_| json!({"puppet": app.distort.describe()["puppet"]}))) + } + "edit.puppetWarp" | "layer.smartObjects.puppetWarp" if ui.is_some() && app.distort.puppet.is_some() => { + Some(crate::puppet_ui::control(app, ui.unwrap_or(&Value::Null))) + } + "edit.perspectiveWarp" | "layer.smartObjects.perspectiveWarp" if empty => { + Some(crate::perspective_ui::begin(app, ctx, id).map(|_| json!({"perspective": app.distort.describe()["perspective"]}))) + } + "edit.perspectiveWarp" | "layer.smartObjects.perspectiveWarp" if ui.is_some() && app.distort.perspective.is_some() => { + Some(crate::perspective_ui::control(app, ui.unwrap_or(&Value::Null))) + } _ => None, } } diff --git a/crates/ui-egui/src/doc_props_ui.rs b/crates/ui-egui/src/doc_props_ui.rs index 9264a1ff4e..4dfe79fbf5 100644 --- a/crates/ui-egui/src/doc_props_ui.rs +++ b/crates/ui-egui/src/doc_props_ui.rs @@ -34,11 +34,22 @@ pub const MODES: &[(ColorMode, &str, &str)] = &[ (ColorMode::Multichannel, "Multichannel", "image.mode.multichannel"), ]; -pub const DEPTHS: &[(SampleType, &str, &str)] = - &[(SampleType::U8, "8 Bits/Channel", "image.mode.bits8"), (SampleType::U16, "16 Bits/Channel", "image.mode.bits16"), (SampleType::F32, "32 Bits/Channel", "image.mode.bits32")]; +pub const DEPTHS: &[(SampleType, &str, &str)] = &[ + (SampleType::U8, "8 Bits/Channel", "image.mode.bits8"), + (SampleType::U16, "16 Bits/Channel", "image.mode.bits16"), + (SampleType::F32, "32 Bits/Channel", "image.mode.bits32"), +]; /// Ruler units offered in the Rulers & Grids dropdown (`unitsAndRulers.rulers` values). -pub const UNITS: &[(&str, &str)] = &[("pixels", "Pixels"), ("inches", "Inches"), ("cm", "Centimeters"), ("mm", "Millimeters"), ("points", "Points"), ("picas", "Picas"), ("percent", "Percent")]; +pub const UNITS: &[(&str, &str)] = &[ + ("pixels", "Pixels"), + ("inches", "Inches"), + ("cm", "Centimeters"), + ("mm", "Millimeters"), + ("points", "Points"), + ("picas", "Picas"), + ("percent", "Percent"), +]; /// `image.canvasSize` params for a new width/height, keeping the aspect ratio when linked. pub fn canvas_resize_params(old: (u32, u32), new_w: Option, new_h: Option, linked: bool) -> Option { @@ -60,7 +71,13 @@ fn section(ui: &mut egui::Ui, id: &str, title: &str) -> bool { let key = egui::Id::new(("doc-props-section", id)); let mut open = ui.data(|d| d.get_temp::(key)).unwrap_or(true); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); - icons::paint(ui, Rect::from_center_size(pos2(r.left() + 7.0, r.center().y), vec2(12.0, 12.0)), if open { "chevron-down" } else { "chevron-right" }, 11.0, t.text_dim); + icons::paint( + ui, + Rect::from_center_size(pos2(r.left() + 7.0, r.center().y), vec2(12.0, 12.0)), + if open { "chevron-down" } else { "chevron-right" }, + 11.0, + t.text_dim, + ); ui.painter().text(pos2(r.left() + 18.0, r.center().y), Align2::LEFT_CENTER, title, crate::theme::semibold(12.0), t.text); if resp.clicked() { open = !open; @@ -199,7 +216,11 @@ pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.add_space(8.0); ui.spacing_mut().item_spacing.x = 6.0; let pixel_grid = crate::view_cmds::checked(app, "view.show.pixelGrid").unwrap_or(false); - for (icon, tip, id, on) in [("ruler", "Rulers (⌘R)", "view.rulers", app.ui.extras.rulers), ("grid-3x3", "Grid (⌘')", "view.show.grid", app.ui.extras.grid), ("grid-2x2", "Pixel Grid", "view.show.pixelGrid", pixel_grid)] { + for (icon, tip, id, on) in [ + ("ruler", "Rulers (⌘R)", "view.rulers", app.ui.extras.rulers), + ("grid-3x3", "Grid (⌘')", "view.show.grid", app.ui.extras.grid), + ("grid-2x2", "Pixel Grid", "view.show.pixelGrid", pixel_grid), + ] { if icons::button(ui, icon, 26.0, on, tip).clicked() { run.push((id.to_string(), Value::Null)); } diff --git a/crates/ui-egui/src/enable_rules.rs b/crates/ui-egui/src/enable_rules.rs index 32fc4c80bf..08a5c3c680 100644 --- a/crates/ui-egui/src/enable_rules.rs +++ b/crates/ui-egui/src/enable_rules.rs @@ -42,7 +42,9 @@ fn kind_blocks(id: &str, l: &Layer) -> bool { "layer.rasterize.fillContent" => !matches!(c, LayerContent::Fill(_)), "layer.rasterize.smartObject" => !matches!(c, LayerContent::Smart(_)), "layer.rasterize.vectorMask" => l.vector_mask.is_none(), - "layer.rasterize.layer" => matches!(c, LayerContent::Raster(_) | LayerContent::Group(_) | LayerContent::Adjustment(_)) && l.vector_mask.is_none() && l.effects.items.is_empty(), + "layer.rasterize.layer" => { + matches!(c, LayerContent::Raster(_) | LayerContent::Group(_) | LayerContent::Adjustment(_)) && l.vector_mask.is_none() && l.effects.items.is_empty() + } "layer.layerStyle.copyLayerStyle" | "layer.layerStyle.clear" => l.effects.items.is_empty(), _ => id.starts_with("layer.combineShapes.") && !matches!(c, LayerContent::Shape(_)), } @@ -89,7 +91,20 @@ mod tests { #[test] fn background_greys_styles_masks_and_transforms() { let mut s = session(); - for id in ["layer.layerStyle.dropShadow", "layer.layerMask.revealAll", "layer.arrange.bringForward", "layer.mergeDown", "edit.freeTransform", "edit.transform.scale", "layer.flattenImage", "layer.mergeVisible", "layer.createClippingMask", "layer.rasterize.layer", "layer.combineShapes.unite", "layer.delete.hiddenLayers"] { + for id in [ + "layer.layerStyle.dropShadow", + "layer.layerMask.revealAll", + "layer.arrange.bringForward", + "layer.mergeDown", + "edit.freeTransform", + "edit.transform.scale", + "layer.flattenImage", + "layer.mergeVisible", + "layer.createClippingMask", + "layer.rasterize.layer", + "layer.combineShapes.unite", + "layer.delete.hiddenLayers", + ] { assert!(off(&s, id), "{id} should be greyed on a lone Background"); } assert!(!off(&s, "layer.layerStyle.globalLight")); diff --git a/crates/ui-egui/src/export_dialog.rs b/crates/ui-egui/src/export_dialog.rs index a61d9c965b..3168872fc8 100644 --- a/crates/ui-egui/src/export_dialog.rs +++ b/crates/ui-egui/src/export_dialog.rs @@ -153,7 +153,8 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map, Arc)> = ui.data(|d| d.get_temp(key)); let (size, tex) = match cached.filter(|c| c.0 == sig) { Some((_, size, tex)) => (size, tex), @@ -231,7 +232,13 @@ mod tests { #[test] fn export_document_scales_and_flattens() { - let doc = Document::with_background("x", photocraft_doc::Size::new(200, 100), photocraft_doc::ColorMode::Rgb, photocraft_doc::SampleType::U8, photocraft_doc::Color::WHITE); + let doc = Document::with_background( + "x", + photocraft_doc::Size::new(200, 100), + photocraft_doc::ColorMode::Rgb, + photocraft_doc::SampleType::U8, + photocraft_doc::Color::WHITE, + ); let mut f = Map::new(); f.insert("format".into(), json!("jpg")); f.insert("scale".into(), json!(50)); diff --git a/crates/ui-egui/src/file_ui.rs b/crates/ui-egui/src/file_ui.rs index e04de4d524..1afe3ef08e 100644 --- a/crates/ui-egui/src/file_ui.rs +++ b/crates/ui-egui/src/file_ui.rs @@ -75,7 +75,13 @@ pub fn invoke(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &V }; let steps: Vec = action.steps.iter().map(|(id, p)| json!([id, p])).collect(); let name = action.name.clone(); - form(app, id, "Create Droplet", json!({"path": format!("{dir}/{name}.pcdroplet"), "name": name, "steps": steps, "output": format!("{dir}/droplet-output"), "format": "same"}), json!({"format": ["same", "png", "jpg", "psd", "tiff"]})) + form( + app, + id, + "Create Droplet", + json!({"path": format!("{dir}/{name}.pcdroplet"), "name": name, "steps": steps, "output": format!("{dir}/droplet-output"), "format": "same"}), + json!({"format": ["same", "png", "jpg", "psd", "tiff"]}), + ) } "file.scripts.statistics" => { let modes: Vec<&str> = photocraft_doc::StackMode::ALL.iter().map(|m| m.id()).collect(); @@ -187,7 +193,24 @@ pub fn open_web(app: &mut PhotocraftApp) -> u64 { } } } - for (k, v) in [("format", json!("jpeg")), ("quality", json!(60)), ("palette", json!("selective")), ("colors", json!(128)), ("dither", json!("diffusion")), ("ditherAmount", json!(88)), ("transparency", json!(true)), ("matte", json!("#ffffff")), ("interlaced", json!(false)), ("progressive", json!(false)), ("optimized", json!(true)), ("embedIcc", json!(false)), ("convertToSrgb", json!(true)), ("metadata", json!("copyright")), ("percent", json!(100.0)), ("webSnap", json!(0))] { + for (k, v) in [ + ("format", json!("jpeg")), + ("quality", json!(60)), + ("palette", json!("selective")), + ("colors", json!(128)), + ("dither", json!("diffusion")), + ("ditherAmount", json!(88)), + ("transparency", json!(true)), + ("matte", json!("#ffffff")), + ("interlaced", json!(false)), + ("progressive", json!(false)), + ("optimized", json!(true)), + ("embedIcc", json!(false)), + ("convertToSrgb", json!(true)), + ("metadata", json!("copyright")), + ("percent", json!(100.0)), + ("webSnap", json!(0)), + ] { f.entry(k.to_string()).or_insert(v); } app.ui.open_dialog(DialogKind::Command, f) @@ -234,7 +257,11 @@ fn web_pane(app: &PhotocraftApp, ui: &mut egui::Ui, size: egui::Vec2, p: Option< 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 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 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())); @@ -249,7 +276,9 @@ fn web_pane(app: &PhotocraftApp, ui: &mut egui::Ui, size: egui::Vec2, p: Option< 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::>(), 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)) { + 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), }, @@ -274,7 +303,10 @@ fn web_pane(app: &PhotocraftApp, ui: &mut egui::Ui, size: egui::Vec2, p: Option< (Some(_), Some((bytes, colors, ext))) => { // Photoshop's download estimate at 56.6 Kbps. let secs = (bytes as f64 * 8.0 / 56_600.0).ceil(); - (format!("{} {}", ext.to_uppercase(), crate::sizing::human_bytes(bytes as f64)), format!("{secs} sec @ 56.6 Kbps{}", colors.map(|c| format!(" · {c} colors")).unwrap_or_default())) + ( + format!("{} {}", ext.to_uppercase(), crate::sizing::human_bytes(bytes as f64)), + format!("{secs} sec @ 56.6 Kbps{}", colors.map(|c| format!(" · {c} colors")).unwrap_or_default()), + ) } (Some(_), None) => ("—".into(), String::new()), }; @@ -370,9 +402,29 @@ fn web_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map { - dropdown_str(ui, "web-palette", f, "palette", &[("perceptual", "Perceptual"), ("selective", "Selective"), ("adaptive", "Adaptive"), ("restrictive", "Restrictive (Web)"), ("exact", "Exact")], 150.0); + dropdown_str( + ui, + "web-palette", + f, + "palette", + &[ + ("perceptual", "Perceptual"), + ("selective", "Selective"), + ("adaptive", "Adaptive"), + ("restrictive", "Restrictive (Web)"), + ("exact", "Exact"), + ], + 150.0, + ); number(ui, f, "colors", "Colors", 2.0..=256.0, "", 128.0); - dropdown_str(ui, "web-dither", f, "dither", &[("none", "No Dither"), ("diffusion", "Diffusion"), ("pattern", "Pattern"), ("noise", "Noise")], 150.0); + dropdown_str( + ui, + "web-dither", + f, + "dither", + &[("none", "No Dither"), ("diffusion", "Diffusion"), ("pattern", "Pattern"), ("noise", "Noise")], + 150.0, + ); number(ui, f, "ditherAmount", "Dither", 0.0..=100.0, "%", 88.0); check(ui, f, "transparency", "Transparency", true); check(ui, f, "interlaced", "Interlaced", false); @@ -405,13 +457,24 @@ fn web_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map u64 { if let Some(Value::Object(m)) = app.session.file_menu.last_print.clone() { f.extend(m); } - for (k, v) in [("printer", json!("")), ("copies", json!(1)), ("paper", json!("letter")), ("orientation", json!("portrait")), ("colorHandling", json!("printerManages")), ("printerProfile", json!("coated-cmyk")), ("intent", json!("relative")), ("bpc", json!(true)), ("center", json!(true)), ("top", json!(0.0)), ("left", json!(0.0)), ("scale", json!(100.0)), ("scaleToFit", json!(false)), ("cornerCropMarks", json!(false)), ("centerCropMarks", json!(false)), ("registrationMarks", json!(false)), ("description", json!(false)), ("labels", json!(false)), ("output", json!(""))] { + for (k, v) in [ + ("printer", json!("")), + ("copies", json!(1)), + ("paper", json!("letter")), + ("orientation", json!("portrait")), + ("colorHandling", json!("printerManages")), + ("printerProfile", json!("coated-cmyk")), + ("intent", json!("relative")), + ("bpc", json!(true)), + ("center", json!(true)), + ("top", json!(0.0)), + ("left", json!(0.0)), + ("scale", json!(100.0)), + ("scaleToFit", json!(false)), + ("cornerCropMarks", json!(false)), + ("centerCropMarks", json!(false)), + ("registrationMarks", json!(false)), + ("description", json!(false)), + ("labels", json!(false)), + ("output", json!("")), + ] { f.entry(k.to_string()).or_insert(v); } app.ui.open_dialog(DialogKind::Command, f) @@ -467,48 +550,62 @@ fn print_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map { - let (pw, ph) = l.paper; - let k = ((area.width() - 20.0) / pw as f32).min((area.height() - 20.0) / ph as f32); - let page = egui::Rect::from_center_size(area.center(), egui::vec2(pw as f32 * k, ph as f32 * k)); - ui.painter().rect_filled(page, 0.0, egui::Color32::WHITE); - ui.painter().rect_stroke(page, 0.0, egui::Stroke::new(1.0, t.separator), egui::StrokeKind::Outside); - let (x, y, w, h) = l.rect; - let img = egui::Rect::from_min_size(egui::pos2(page.left() + x as f32 * k, page.bottom() - (y + h) as f32 * k), egui::vec2(w as f32 * k, h as f32 * k)); - let key = egui::Id::new(("print-thumb", doc.id.0, app.session.active().map_or(0, |d| d.revision))); - let tex: Option> = ui.data(|d| d.get_temp(key)); - let tex = tex.unwrap_or_else(|| { - let th = photocraft_compose::thumbnail(&doc, 400); - let ci = egui::ColorImage::from_rgba_unmultiplied([th.width as usize, th.height as usize], &th.pixels); - let tx = Arc::new(ui.ctx().load_texture("print-thumb", ci, egui::TextureOptions::LINEAR)); - ui.data_mut(|d| d.insert_temp(key, tx.clone())); - tx - }); - let clip = ui.painter().with_clip_rect(page); - clip.image(tex.id(), img, egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), egui::Color32::WHITE); - let mark = egui::Stroke::new(1.0, egui::Color32::BLACK); - if l.marks.corner_crop { - for (c, dx, dy) in [(img.left_top(), -1.0, -1.0), (img.right_top(), 1.0, -1.0), (img.left_bottom(), -1.0, 1.0), (img.right_bottom(), 1.0, 1.0)] { - clip.line_segment([c + egui::vec2(dx * 3.0, 0.0), c + egui::vec2(dx * 12.0, 0.0)], mark); - clip.line_segment([c + egui::vec2(0.0, dy * 3.0), c + egui::vec2(0.0, dy * 12.0)], mark); + let (area, _) = ui.allocate_exact_size(egui::vec2(330.0, 400.0), egui::Sense::hover()); + ui.painter().rect_filled(area, 0.0, t.canvas); + match photocraft_engine::print_cmds::layout(&doc, &p, "file.print") { + Ok(l) => { + let (pw, ph) = l.paper; + let k = ((area.width() - 20.0) / pw as f32).min((area.height() - 20.0) / ph as f32); + let page = egui::Rect::from_center_size(area.center(), egui::vec2(pw as f32 * k, ph as f32 * k)); + ui.painter().rect_filled(page, 0.0, egui::Color32::WHITE); + ui.painter().rect_stroke(page, 0.0, egui::Stroke::new(1.0, t.separator), egui::StrokeKind::Outside); + let (x, y, w, h) = l.rect; + let img = egui::Rect::from_min_size( + egui::pos2(page.left() + x as f32 * k, page.bottom() - (y + h) as f32 * k), + egui::vec2(w as f32 * k, h as f32 * k), + ); + let key = egui::Id::new(("print-thumb", doc.id.0, app.session.active().map_or(0, |d| d.revision))); + let tex: Option> = ui.data(|d| d.get_temp(key)); + let tex = tex.unwrap_or_else(|| { + let th = photocraft_compose::thumbnail(&doc, 400); + let ci = egui::ColorImage::from_rgba_unmultiplied([th.width as usize, th.height as usize], &th.pixels); + let tx = Arc::new(ui.ctx().load_texture("print-thumb", ci, egui::TextureOptions::LINEAR)); + ui.data_mut(|d| d.insert_temp(key, tx.clone())); + tx + }); + let clip = ui.painter().with_clip_rect(page); + clip.image(tex.id(), img, egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), egui::Color32::WHITE); + let mark = egui::Stroke::new(1.0, egui::Color32::BLACK); + if l.marks.corner_crop { + for (c, dx, dy) in + [(img.left_top(), -1.0, -1.0), (img.right_top(), 1.0, -1.0), (img.left_bottom(), -1.0, 1.0), (img.right_bottom(), 1.0, 1.0)] + { + clip.line_segment([c + egui::vec2(dx * 3.0, 0.0), c + egui::vec2(dx * 12.0, 0.0)], mark); + clip.line_segment([c + egui::vec2(0.0, dy * 3.0), c + egui::vec2(0.0, dy * 12.0)], mark); + } } - } - if l.marks.registration { - for c in [img.center_top() - egui::vec2(0.0, 9.0), img.center_bottom() + egui::vec2(0.0, 9.0), img.left_center() - egui::vec2(9.0, 0.0), img.right_center() + egui::vec2(9.0, 0.0)] { - clip.circle_stroke(c, 3.5, mark); + if l.marks.registration { + for c in [ + img.center_top() - egui::vec2(0.0, 9.0), + img.center_bottom() + egui::vec2(0.0, 9.0), + img.left_center() - egui::vec2(9.0, 0.0), + img.right_center() + egui::vec2(9.0, 0.0), + ] { + clip.circle_stroke(c, 3.5, mark); + } } + let fits = x >= 0.0 && y >= 0.0 && x + w <= pw && y + h <= ph; + let note = if fits { String::new() } else { " · larger than the paper".to_string() }; + ui.label( + egui::RichText::new(format!("Scale {:.1}% · {:.2} × {:.2} in{note}", l.scale * 100.0, w / 72.0, h / 72.0)) + .color(if fits { t.text_dim } else { t.warning }) + .size(11.0), + ); + } + Err(e) => { + ui.painter().text(area.center(), egui::Align2::CENTER_CENTER, e.to_string(), egui::FontId::proportional(12.0), t.text_dim); } - let fits = x >= 0.0 && y >= 0.0 && x + w <= pw && y + h <= ph; - let note = if fits { String::new() } else { " · larger than the paper".to_string() }; - ui.label(egui::RichText::new(format!("Scale {:.1}% · {:.2} × {:.2} in{note}", l.scale * 100.0, w / 72.0, h / 72.0)).color(if fits { t.text_dim } else { t.warning }).size(11.0)); } - Err(e) => { - ui.painter().text(area.center(), egui::Align2::CENTER_CENTER, e.to_string(), egui::FontId::proportional(12.0), t.text_dim); - } - } }); ui.add_space(12.0); ui.vertical(|ui| { @@ -532,11 +629,47 @@ fn print_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) -> Result { let r = app.run("file.print", params(f))?; - app.ui.status = if r["sent"] == json!(true) { format!("Sent to the printer ({})", r["spooler"].as_str().unwrap_or("")) } else { format!("Printed to {}", r["pdf"].as_str().unwrap_or("")) }; + app.ui.status = if r["sent"] == json!(true) { + format!("Sent to the printer ({})", r["spooler"].as_str().unwrap_or("")) + } else { + format!("Printed to {}", r["pdf"].as_str().unwrap_or("")) + }; Ok(r) } diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index 76e7c9329e..6ee72f7c79 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -13,10 +13,16 @@ use crate::theme::Tokens; #[derive(Clone, Debug, PartialEq)] pub enum Kind { - Range { min: f32, max: f32, default: f32 }, + Range { + min: f32, + max: f32, + default: f32, + }, Choice(Vec), Bool(bool), - Int { default: i64 }, + Int { + default: i64, + }, /// Free text (e.g. a file path). Text, /// One of the open documents (stored as its index). @@ -102,7 +108,23 @@ pub fn parse_spec(spec: &str) -> Vec { /// Parameter keys measured in pixels (scaled for proxy previews). fn is_pixel_param(key: &str) -> bool { - matches!(key, "radius" | "distance" | "cellSize" | "horizontal" | "vertical" | "height" | "wavelengthMin" | "wavelengthMax" | "amplitudeMin" | "amplitudeMax" | "maxRadius" | "size" | "blur" | "speed") + matches!( + key, + "radius" + | "distance" + | "cellSize" + | "horizontal" + | "vertical" + | "height" + | "wavelengthMin" + | "wavelengthMax" + | "amplitudeMin" + | "amplitudeMax" + | "maxRadius" + | "size" + | "blur" + | "speed" + ) } /// Commands outside `filter.*` that get the schema dialog *with* live preview. @@ -127,7 +149,21 @@ pub fn has_dialog(command: &str) -> bool { if command == "filter.filterGallery" { return false; } - (command.starts_with("filter.") || command.starts_with("select.modify.") || PREVIEWED.contains(&command) || matches!(command, "image.trim" | "view.newGuide" | "select.refineEdge" | "edit.assignProfile" | "edit.convertToProfile" | "view.proofSetup" | "layer.layerStyle.globalLight" | "image.mode.colorTable")) && photocraft_engine::commands::find(command).is_some_and(|c| !parse_spec(c.params).is_empty()) + (command.starts_with("filter.") + || command.starts_with("select.modify.") + || PREVIEWED.contains(&command) + || matches!( + command, + "image.trim" + | "view.newGuide" + | "select.refineEdge" + | "edit.assignProfile" + | "edit.convertToProfile" + | "view.proofSetup" + | "layer.layerStyle.globalLight" + | "image.mode.colorTable" + )) + && photocraft_engine::commands::find(command).is_some_and(|c| !parse_spec(c.params).is_empty()) } pub fn open(app: &mut PhotocraftApp, command: &str) -> Option { @@ -194,7 +230,15 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { match p.kind { Kind::Range { min, max, default } => { let mut v = f.get(&p.key).and_then(Value::as_f64).unwrap_or(default as f64) as f32; - let unit = if is_pixel_param(&p.key) { "px" } else if p.key == "angle" { "°" } else if p.key == "amount" && max <= 500.0 { "%" } else { "" }; + let unit = if is_pixel_param(&p.key) { + "px" + } else if p.key == "angle" { + "°" + } else if p.key == "amount" && max <= 500.0 { + "%" + } else { + "" + }; let logarithmic = min > 0.0 && max / min > 500.0; if logarithmic { // Scrub the value field directly; add a log-scaled slider for huge ranges. @@ -238,7 +282,8 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { } } Kind::Document => { - let names: Vec = f.get("__docs").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default(); + let names: Vec = + f.get("__docs").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default(); let cur = f.get(&p.key).and_then(Value::as_i64).unwrap_or(-1); let mut sel = cur.to_string(); let mut opts: Vec<(String, &str)> = vec![("-1".to_string(), "None")]; @@ -254,7 +299,8 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { } Kind::Grid(n) => { let side = (n as f32).sqrt().round().max(1.0) as usize; - let mut vals: Vec = f.get(&p.key).and_then(Value::as_array).map(|a| a.iter().map(|v| v.as_f64().unwrap_or(0.0) as f32).collect()).unwrap_or_default(); + let mut vals: Vec = + f.get(&p.key).and_then(Value::as_array).map(|a| a.iter().map(|v| v.as_f64().unwrap_or(0.0) as f32).collect()).unwrap_or_default(); vals.resize(n, 0.0); ui.label(egui::RichText::new(label(&p.key)).color(t.text_dim)); egui::Grid::new(format!("flt-grid-{cmd}-{}", p.key)).spacing([4.0, 4.0]).show(ui, |ui| { @@ -340,12 +386,23 @@ mod tests { assert!(parse_spec("{}").is_empty()); let p = parse_spec(r#"{"lighting":bool=true,"kernel":int[25],"pins":json,"points":[[0,0],[1,0]],"mapPath":text,"mapDocument":doc,"scale":1..9999=1}"#); let kinds: Vec<&Kind> = p.iter().map(|p| &p.kind).collect(); - assert_eq!(kinds, [&Kind::Bool(true), &Kind::Grid(25), &Kind::Json, &Kind::Json, &Kind::Text, &Kind::Document, &Kind::Range { min: 1.0, max: 9999.0, default: 1.0 }]); + assert_eq!( + kinds, + [&Kind::Bool(true), &Kind::Grid(25), &Kind::Json, &Kind::Json, &Kind::Text, &Kind::Document, &Kind::Range { min: 1.0, max: 9999.0, default: 1.0 }] + ); } #[test] fn new_filters_have_dialogs_or_run_directly() { - for id in ["filter.stylize.oilPaint", "filter.blur.lensBlur", "filter.blurGallery.irisBlur", "filter.other.custom", "filter.distort.displace", "filter.pixelate.mezzotint", "filter.render.lightingEffects"] { + for id in [ + "filter.stylize.oilPaint", + "filter.blur.lensBlur", + "filter.blurGallery.irisBlur", + "filter.other.custom", + "filter.distort.displace", + "filter.pixelate.mezzotint", + "filter.render.lightingEffects", + ] { assert!(has_dialog(id), "{id}"); } for id in ["filter.pixelate.facet", "filter.pixelate.fragment", "filter.video.ntscColors"] { @@ -364,7 +421,13 @@ mod tests { #[test] fn preview_runs_engine_command_on_proxy() { - let mut doc = Document::with_background("p", photocraft_doc::Size::new(64, 64), photocraft_doc::ColorMode::Rgb, photocraft_doc::SampleType::U8, photocraft_doc::Color::WHITE); + let mut doc = Document::with_background( + "p", + photocraft_doc::Size::new(64, 64), + photocraft_doc::ColorMode::Rgb, + photocraft_doc::SampleType::U8, + photocraft_doc::Color::WHITE, + ); let bg = doc.layers[0].id; doc.layers[0].surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(0, 0, 32, 64), &[0.0, 0.0, 0.0, 1.0]); let out = preview_document(&doc, Some(bg), "filter.blur.gaussianBlur", &json!({"radius": 4.0}), 1).unwrap(); diff --git a/crates/ui-egui/src/gallery_ui.rs b/crates/ui-egui/src/gallery_ui.rs index 0f65be442e..ac685b27e8 100644 --- a/crates/ui-egui/src/gallery_ui.rs +++ b/crates/ui-egui/src/gallery_ui.rs @@ -290,7 +290,9 @@ fn update_preview(d: &mut GalleryDialog, ctx: &egui::Context, area: ERect) { d.center = [d.canvas.x0 as f32 + cw / 2.0, d.canvas.y0 as f32 + ch / 2.0]; } let (hw, hh) = (area.width() / zoom / 2.0, area.height() / zoom / 2.0); - let vis = Rect::new((d.center[0] - hw).floor() as i32, (d.center[1] - hh).floor() as i32, (d.center[0] + hw).ceil() as i32, (d.center[1] + hh).ceil() as i32).intersect(&d.canvas); + let vis = + Rect::new((d.center[0] - hw).floor() as i32, (d.center[1] - hh).floor() as i32, (d.center[0] + hw).ceil() as i32, (d.center[1] + hh).ceil() as i32) + .intersect(&d.canvas); let k = (1.0 / zoom).ceil().max(1.0) as u32; let params = d.params(); let mut h = std::collections::hash_map::DefaultHasher::new(); @@ -429,7 +431,10 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { } let stroke = if selected { Stroke::new(2.0, t.accent) } else { Stroke::new(1.0, t.separator) }; ui.painter().rect_stroke(r, 0.0, stroke, egui::StrokeKind::Outside); - ui.add_sized([THUMB[0] as f32, 14.0], egui::Label::new(egui::RichText::new(f.name()).size(10.5).color(if selected { t.text } else { t.text_dim })).truncate()); + ui.add_sized( + [THUMB[0] as f32, 14.0], + egui::Label::new(egui::RichText::new(f.name()).size(10.5).color(if selected { t.text } else { t.text_dim })).truncate(), + ); resp }); if cell.inner.clicked() { @@ -494,7 +499,12 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { e.params.insert(p.key, json!(b)); } Kind::Json if p.key == "glowColor" => { - let c = e.params.get("glowColor").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_f64).map(|v| v as f32).collect::>()).filter(|v| v.len() >= 3); + let c = e + .params + .get("glowColor") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(Value::as_f64).map(|v| v as f32).collect::>()) + .filter(|v| v.len() >= 3); let neon = photocraft_algo::GalleryEffect::new(GalleryFilter::NeonGlow).color; let mut rgb = c.map_or([neon[0], neon[1], neon[2]], |v| [v[0], v[1], v[2]]); ui.horizontal(|ui| { @@ -525,7 +535,13 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { } let eye = ERect::from_min_size(row.min + vec2(4.0, 4.0), vec2(16.0, 16.0)); crate::icons::paint(ui, eye, if d.effects[i].visible { "eye" } else { "eye-off" }, 14.0, t.icon); - ui.painter().text(row.left_center() + vec2(28.0, 0.0), Align2::LEFT_CENTER, d.effects[i].filter.name(), FontId::proportional(12.5), if d.effects[i].visible { t.text } else { t.text_faint }); + ui.painter().text( + row.left_center() + vec2(28.0, 0.0), + Align2::LEFT_CENTER, + d.effects[i].filter.name(), + FontId::proportional(12.5), + if d.effects[i].visible { t.text } else { t.text_faint }, + ); if resp.clicked() { if resp.interact_pointer_pos().is_some_and(|p| p.x < eye.right() + 4.0) { toggle = Some(i); diff --git a/crates/ui-egui/src/gpu_canvas.rs b/crates/ui-egui/src/gpu_canvas.rs index 65df324589..c6b410c626 100644 --- a/crates/ui-egui/src/gpu_canvas.rs +++ b/crates/ui-egui/src/gpu_canvas.rs @@ -160,7 +160,10 @@ impl GpuCanvas { } let mut renderer = self.rs.renderer.write(); let Some(res) = renderer.callback_resources.get_mut::() else { return Err(photocraft_gpu::Unsupported("no GPU canvas".into())) }; - let mut comp = res.compositor.take().unwrap_or_else(|| photocraft_gpu::Compositor::new_with_format(device, photocraft_gpu::Compositor::preferred_acc_format(&self.rs.adapter))); + let mut comp = res + .compositor + .take() + .unwrap_or_else(|| photocraft_gpu::Compositor::new_with_format(device, photocraft_gpu::Compositor::preferred_acc_format(&self.rs.adapter))); let key = doc.id.0; let fresh = res.docs.get(&key).is_none_or(|d| d.size != size); let region = if fresh { doc.bounds() } else { region.intersect(&doc.bounds()) }; @@ -182,15 +185,27 @@ impl GpuCanvas { continue; } let offset = [out.rect.x0 - tx, out.rect.y0 - ty, 0, 0]; - let ubuf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("pc_encode"), contents: &offset.iter().flat_map(|v| v.to_le_bytes()).collect::>(), usage: wgpu::BufferUsages::UNIFORM }); + let ubuf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("pc_encode"), + contents: &offset.iter().flat_map(|v| v.to_le_bytes()).collect::>(), + usage: wgpu::BufferUsages::UNIFORM, + }); let bg = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("pc_encode"), layout: bgl, - entries: &[wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(out.view) }, wgpu::BindGroupEntry { binding: 1, resource: ubuf.as_entire_binding() }], + entries: &[ + wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(out.view) }, + wgpu::BindGroupEntry { binding: 1, resource: ubuf.as_entire_binding() }, + ], }); let mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("pc_encode"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: &t.levels[0], resolve_target: None, depth_slice: None, ops: wgpu::Operations { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store } })], + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &t.levels[0], + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store }, + })], depth_stencil_attachment: None, timestamp_writes: None, occlusion_query_set: None, @@ -415,7 +430,11 @@ fn lut_bind_group(device: &wgpu::Device, queue: &wgpu::Queue, bgl: &wgpu::BindGr extent, ); let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); - device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("pc_display_lut"), layout: bgl, entries: &[wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&view) }] }) + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("pc_display_lut"), + layout: bgl, + entries: &[wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&view) }], + }) } /// The 2³ identity LUT (unused unless a document has none and `display` is set). @@ -455,19 +474,36 @@ fn texture_entry(binding: u32) -> wgpu::BindGroupLayoutEntry { wgpu::BindGroupLayoutEntry { binding, visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { multisampled: false, sample_type: wgpu::TextureSampleType::Float { filterable: true }, view_dimension: wgpu::TextureViewDimension::D2 }, + ty: wgpu::BindingType::Texture { + multisampled: false, + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + }, count: None, } } fn sampler_entry(binding: u32) -> wgpu::BindGroupLayoutEntry { - wgpu::BindGroupLayoutEntry { binding, visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), count: None } + wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + } } fn f32_bytes(v: &[f32]) -> Vec { v.iter().flat_map(|f| f.to_ne_bytes()).collect() } -fn pipeline(device: &wgpu::Device, label: &str, layout: &wgpu::PipelineLayout, module: &wgpu::ShaderModule, (vs, fs): (&str, &str), format: wgpu::TextureFormat, blend: Option) -> wgpu::RenderPipeline { +fn pipeline( + device: &wgpu::Device, + label: &str, + layout: &wgpu::PipelineLayout, + module: &wgpu::ShaderModule, + (vs, fs): (&str, &str), + format: wgpu::TextureFormat, + blend: Option, +) -> wgpu::RenderPipeline { device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some(label), layout: Some(layout), @@ -475,7 +511,12 @@ fn pipeline(device: &wgpu::Device, label: &str, layout: &wgpu::PipelineLayout, m primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, ..Default::default() }, depth_stencil: None, multisample: wgpu::MultisampleState { count: 1, mask: !0, alpha_to_coverage_enabled: false }, - fragment: Some(wgpu::FragmentState { module, entry_point: Some(fs), targets: &[Some(wgpu::ColorTargetState { format, blend, write_mask: wgpu::ColorWrites::ALL })], compilation_options: Default::default() }), + fragment: Some(wgpu::FragmentState { + module, + entry_point: Some(fs), + targets: &[Some(wgpu::ColorTargetState { format, blend, write_mask: wgpu::ColorWrites::ALL })], + compilation_options: Default::default(), + }), multiview_mask: None, cache: None, }) @@ -483,23 +524,50 @@ fn pipeline(device: &wgpu::Device, label: &str, layout: &wgpu::PipelineLayout, m impl Resources { fn new(device: &wgpu::Device, queue: &wgpu::Queue, target: wgpu::TextureFormat) -> Self { - let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("pc_canvas"), source: wgpu::ShaderSource::Wgsl(CANVAS_WGSL.into()) }); + let module = + device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("pc_canvas"), source: wgpu::ShaderSource::Wgsl(CANVAS_WGSL.into()) }); let vis = wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT; - let view_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("pc_canvas_view"), entries: &[uniform_entry(0, VIEW_UNIFORM_SIZE, vis), sampler_entry(1)] }); - let tile_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("pc_canvas_tile"), entries: &[texture_entry(0), uniform_entry(1, TILE_UNIFORM_SIZE, vis)] }); + let view_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("pc_canvas_view"), + entries: &[uniform_entry(0, VIEW_UNIFORM_SIZE, vis), sampler_entry(1)], + }); + let tile_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("pc_canvas_tile"), + entries: &[texture_entry(0), uniform_entry(1, TILE_UNIFORM_SIZE, vis)], + }); let lut_entry = wgpu::BindGroupLayoutEntry { binding: 0, visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { multisampled: false, sample_type: wgpu::TextureSampleType::Float { filterable: true }, view_dimension: wgpu::TextureViewDimension::D3 }, + ty: wgpu::BindingType::Texture { + multisampled: false, + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D3, + }, count: None, }; let lut_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("pc_display_lut"), entries: &[lut_entry] }); - let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("pc_canvas"), bind_group_layouts: &[Some(&view_bgl), Some(&tile_bgl), Some(&lut_bgl)], immediate_size: 0 }); - let shadow_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("pc_canvas_shadow"), bind_group_layouts: &[Some(&view_bgl)], immediate_size: 0 }); + let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("pc_canvas"), + bind_group_layouts: &[Some(&view_bgl), Some(&tile_bgl), Some(&lut_bgl)], + immediate_size: 0, + }); + let shadow_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("pc_canvas_shadow"), + bind_group_layouts: &[Some(&view_bgl)], + immediate_size: 0, + }); // Premultiplied "over", with egui's alpha rule (keeps a transparent window's alpha sane). let blend = wgpu::BlendState { - color: wgpu::BlendComponent { src_factor: wgpu::BlendFactor::One, dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, operation: wgpu::BlendOperation::Add }, - alpha: wgpu::BlendComponent { src_factor: wgpu::BlendFactor::OneMinusDstAlpha, dst_factor: wgpu::BlendFactor::One, operation: wgpu::BlendOperation::Add }, + color: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::One, + dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, + operation: wgpu::BlendOperation::Add, + }, + alpha: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::OneMinusDstAlpha, + dst_factor: wgpu::BlendFactor::One, + operation: wgpu::BlendOperation::Add, + }, }; let shadow_pipeline = pipeline(device, "pc_canvas_shadow", &shadow_layout, &module, ("vs_shadow", "fs_shadow"), target, Some(blend)); let tile_pipeline = pipeline(device, "pc_canvas_tile", &layout, &module, ("vs_tile", "fs_tile"), target, Some(blend)); @@ -512,20 +580,39 @@ impl Resources { }); let mip_module = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("pc_mip"), source: wgpu::ShaderSource::Wgsl(MIP_WGSL.into()) }); - let mip_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("pc_mip"), entries: &[texture_entry(0), sampler_entry(1)] }); - let mip_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("pc_mip"), bind_group_layouts: &[Some(&mip_bgl)], immediate_size: 0 }); + let mip_bgl = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("pc_mip"), entries: &[texture_entry(0), sampler_entry(1)] }); + let mip_layout = + device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("pc_mip"), bind_group_layouts: &[Some(&mip_bgl)], immediate_size: 0 }); let mip_pipeline = pipeline(device, "pc_mip", &mip_layout, &mip_module, ("vs", "fs"), FORMAT, None); - let mip_sampler = device.create_sampler(&wgpu::SamplerDescriptor { label: Some("pc_mip"), mag_filter: wgpu::FilterMode::Linear, min_filter: wgpu::FilterMode::Linear, ..Default::default() }); + let mip_sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("pc_mip"), + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + ..Default::default() + }); // Compositor output (straight RGBA32F chunk) -> premultiplied RGBA8 display tile. - let encode_module = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("pc_encode"), source: wgpu::ShaderSource::Wgsl(ENCODE_WGSL.into()) }); + let encode_module = + device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("pc_encode"), source: wgpu::ShaderSource::Wgsl(ENCODE_WGSL.into()) }); let unfilterable = wgpu::BindGroupLayoutEntry { binding: 0, visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { multisampled: false, sample_type: wgpu::TextureSampleType::Float { filterable: false }, view_dimension: wgpu::TextureViewDimension::D2 }, + ty: wgpu::BindingType::Texture { + multisampled: false, + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D2, + }, count: None, }; - let encode_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("pc_encode"), entries: &[unfilterable, uniform_entry(1, 16, wgpu::ShaderStages::FRAGMENT)] }); - let encode_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("pc_encode"), bind_group_layouts: &[Some(&encode_bgl)], immediate_size: 0 }); + let encode_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("pc_encode"), + entries: &[unfilterable, uniform_entry(1, 16, wgpu::ShaderStages::FRAGMENT)], + }); + let encode_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("pc_encode"), + bind_group_layouts: &[Some(&encode_bgl)], + immediate_size: 0, + }); let encode_pipeline = pipeline(device, "pc_encode", &encode_layout, &encode_module, ("vs", "fs"), FORMAT, None); let identity_lut = lut_bind_group(device, queue, &lut_bgl, 2, &identity_lut_bytes()); Self { @@ -572,13 +659,22 @@ impl DocTextures { usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::RENDER_ATTACHMENT, view_formats: &[], }); - let levels = (0..levels_n).map(|l| texture.create_view(&wgpu::TextureViewDescriptor { base_mip_level: l, mip_level_count: Some(1), ..Default::default() })).collect(); + let levels = (0..levels_n) + .map(|l| texture.create_view(&wgpu::TextureViewDescriptor { base_mip_level: l, mip_level_count: Some(1), ..Default::default() })) + .collect(); let full = texture.create_view(&wgpu::TextureViewDescriptor::default()); - let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("pc_canvas_tile"), contents: &f32_bytes(&[tx as f32, ty as f32, w as f32, h as f32]), usage: wgpu::BufferUsages::UNIFORM }); + let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("pc_canvas_tile"), + contents: &f32_bytes(&[tx as f32, ty as f32, w as f32, h as f32]), + usage: wgpu::BufferUsages::UNIFORM, + }); let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("pc_canvas_tile"), layout: &res.tile_bgl, - entries: &[wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&full) }, wgpu::BindGroupEntry { binding: 1, resource: uniform.as_entire_binding() }], + entries: &[ + wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&full) }, + wgpu::BindGroupEntry { binding: 1, resource: uniform.as_entire_binding() }, + ], }); tiles.push(Tile { rect: [tx, ty, w, h], texture, levels, bind_group, _uniform: uniform }); } @@ -598,7 +694,12 @@ impl DocTextures { } let offset = ((iy0 - y0) as u64 * stride as u64 + (ix0 - x0) as u64) * 4; queue.write_texture( - wgpu::TexelCopyTextureInfo { texture: &t.texture, mip_level: 0, origin: wgpu::Origin3d { x: ix0 - tx, y: iy0 - ty, z: 0 }, aspect: wgpu::TextureAspect::All }, + wgpu::TexelCopyTextureInfo { + texture: &t.texture, + mip_level: 0, + origin: wgpu::Origin3d { x: ix0 - tx, y: iy0 - ty, z: 0 }, + aspect: wgpu::TextureAspect::All, + }, rgba, wgpu::TexelCopyBufferLayout { offset, bytes_per_row: Some(stride * 4), rows_per_image: Some(iy1 - iy0) }, wgpu::Extent3d { width: ix1 - ix0, height: iy1 - iy0, depth_or_array_layers: 1 }, @@ -624,11 +725,19 @@ impl DocTextures { let bg = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("pc_mip"), layout: &res.mip_bgl, - entries: &[wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&t.levels[level - 1]) }, wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&res.mip_sampler) }], + entries: &[ + wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&t.levels[level - 1]) }, + wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&res.mip_sampler) }, + ], }); let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("pc_mip"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: &t.levels[level], resolve_target: None, depth_slice: None, ops: wgpu::Operations { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store } })], + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &t.levels[level], + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store }, + })], depth_stencil_attachment: None, timestamp_writes: None, occlusion_query_set: None, @@ -682,28 +791,64 @@ impl CanvasCallback { let square = if style.checker_square > 0.0 { (style.checker_square * ppp).round().max(1.0) } else { 0.0 }; let (l, d, g) = (style.checker_light, style.checker_dark, style.gamut_color); [ - screen[0] as f32, screen[1] as f32, scale, ppp, - origin[0], origin[1], p.doc_size[0] as f32, p.doc_size[1] as f32, - mode, lod, grid, square, - (self.rect.min.x * ppp).round(), (self.rect.min.y * ppp).round(), p.display as f32, if out_linear { 1.0 } else { 0.0 }, - l[0], l[1], l[2], style.gamut_opacity, - d[0], d[1], d[2], 0.0, - g[0], g[1], g[2], 0.0, + screen[0] as f32, + screen[1] as f32, + scale, + ppp, + origin[0], + origin[1], + p.doc_size[0] as f32, + p.doc_size[1] as f32, + mode, + lod, + grid, + square, + (self.rect.min.x * ppp).round(), + (self.rect.min.y * ppp).round(), + p.display as f32, + if out_linear { 1.0 } else { 0.0 }, + l[0], + l[1], + l[2], + style.gamut_opacity, + d[0], + d[1], + d[2], + 0.0, + g[0], + g[1], + g[2], + 0.0, ] } } impl CallbackTrait for CanvasCallback { - fn prepare(&self, device: &wgpu::Device, queue: &wgpu::Queue, screen: &ScreenDescriptor, _encoder: &mut wgpu::CommandEncoder, resources: &mut CallbackResources) -> Vec { + fn prepare( + &self, + device: &wgpu::Device, + queue: &wgpu::Queue, + screen: &ScreenDescriptor, + _encoder: &mut wgpu::CommandEncoder, + resources: &mut CallbackResources, + ) -> Vec { let Some(res) = resources.get_mut::() else { return Vec::new() }; let data = self.uniforms(screen.size_in_pixels, screen.pixels_per_point, res.out_linear, &res.style); let (bgl, sampler) = (&res.view_bgl, &res.sampler); let v = res.views.entry(self.params.view_key).or_insert_with(|| { - let buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("pc_canvas_view"), size: VIEW_UNIFORM_SIZE, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }); + let buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("pc_canvas_view"), + size: VIEW_UNIFORM_SIZE, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("pc_canvas_view"), layout: bgl, - entries: &[wgpu::BindGroupEntry { binding: 0, resource: buffer.as_entire_binding() }, wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(sampler) }], + entries: &[ + wgpu::BindGroupEntry { binding: 0, resource: buffer.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(sampler) }, + ], }); ViewGpu { buffer, bind_group, frame_data: [f32::NAN; VIEW_FLOATS] } }); @@ -955,7 +1100,9 @@ mod tests { use wgpu::naga; for (name, src) in [("canvas", CANVAS_WGSL), ("mip", MIP_WGSL), ("encode", ENCODE_WGSL)] { let module = naga::front::wgsl::parse_str(src).unwrap_or_else(|e| panic!("{name}: {}", e.emit_to_string(src))); - naga::valid::Validator::new(naga::valid::ValidationFlags::all(), naga::valid::Capabilities::empty()).validate(&module).unwrap_or_else(|e| panic!("{name}: {e:?}")); + naga::valid::Validator::new(naga::valid::ValidationFlags::all(), naga::valid::Capabilities::empty()) + .validate(&module) + .unwrap_or_else(|e| panic!("{name}: {e:?}")); } } } diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 7567f442e0..bdf2f63eb2 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -97,7 +97,13 @@ pub fn button(ui: &mut egui::Ui, name: &str, box_size: f32, selected: bool, tool } else if hovered { ui.painter().rect_filled(rect, t.radius_sm, t.hover); } - let tint = if selected { t.accent_text } else if hovered { t.text } else { t.icon }; + let tint = if selected { + t.accent_text + } else if hovered { + t.text + } else { + t.icon + }; paint(ui, rect, name, (box_size * 0.52).round(), tint); if tooltip.is_empty() { resp } else { resp.on_hover_text(tooltip) } } diff --git a/crates/ui-egui/src/layer_menu_ui.rs b/crates/ui-egui/src/layer_menu_ui.rs index af00c6f04d..82d7a19b51 100644 --- a/crates/ui-egui/src/layer_menu_ui.rs +++ b/crates/ui-egui/src/layer_menu_ui.rs @@ -51,7 +51,10 @@ pub fn entries(l: &Layer, multi: bool, has_selection: bool) -> Vec { } else { v.push(Some(("Add Layer Mask", add_mask_command(has_selection, false)))); } - v.push(Some((if l.clipped { "Release Clipping Mask" } else { "Create Clipping Mask" }, if l.clipped { "layer.releaseClippingMask" } else { "layer.createClippingMask" }))); + v.push(Some(( + if l.clipped { "Release Clipping Mask" } else { "Create Clipping Mask" }, + if l.clipped { "layer.releaseClippingMask" } else { "layer.createClippingMask" }, + ))); v.push(None); v.push(Some(("Link Layers", "layer.linkLayers"))); v.push(Some(("Select Linked Layers", "layer.selectLinkedLayers"))); diff --git a/crates/ui-egui/src/layer_props_ui.rs b/crates/ui-egui/src/layer_props_ui.rs index acb53c38bc..ee42938511 100644 --- a/crates/ui-egui/src/layer_props_ui.rs +++ b/crates/ui-egui/src/layer_props_ui.rs @@ -37,7 +37,13 @@ fn section(ui: &mut egui::Ui, id: &str, title: &str) -> bool { let key = egui::Id::new(("layer-props-section", id)); let mut open = ui.data(|d| d.get_temp::(key)).unwrap_or(true); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); - crate::icons::paint(ui, Rect::from_center_size(pos2(r.left() + 7.0, r.center().y), vec2(12.0, 12.0)), if open { "chevron-down" } else { "chevron-right" }, 11.0, t.text_dim); + crate::icons::paint( + ui, + Rect::from_center_size(pos2(r.left() + 7.0, r.center().y), vec2(12.0, 12.0)), + if open { "chevron-down" } else { "chevron-right" }, + 11.0, + t.text_dim, + ); ui.painter().text(pos2(r.left() + 18.0, r.center().y), egui::Align2::LEFT_CENTER, title, crate::theme::semibold(12.0), t.text); if resp.clicked() { open = !open; diff --git a/crates/ui-egui/src/layer_style.rs b/crates/ui-egui/src/layer_style.rs index f6918c8166..cd1c61cd23 100644 --- a/crates/ui-egui/src/layer_style.rs +++ b/crates/ui-egui/src/layer_style.rs @@ -2,8 +2,8 @@ //! State lives in the dialog's `fields` (JSON), so automation can drive it like any other dialog. use egui::{Color32, RichText, Sense, Stroke, StrokeKind, vec2}; -use photocraft_doc::{Effect, Layer}; use photocraft_doc::effects::{FxPaint, StrokePosition}; +use photocraft_doc::{Effect, Layer}; use serde_json::{Map, Value, json}; use crate::theme::Tokens; @@ -44,17 +44,90 @@ fn spec(kind: &str) -> &'static [(&'static str, &'static str, P)] { const DIR: &[(&str, &str)] = &[("up", "Up"), ("down", "Down")]; const SRC: &[(&str, &str)] = &[("edge", "Edge"), ("center", "Center")]; match kind { - "dropShadow" => &[("blend", "Blend Mode", P::Blend), ("color", "Color", P::Color), ("opacity", "Opacity", P::Slider(0.0, 100.0, "%")), ("angle", "Angle", P::Slider(-180.0, 180.0, "°")), ("distance", "Distance", P::Slider(0.0, 300.0, "px")), ("spread", "Spread", P::Slider(0.0, 100.0, "%")), ("size", "Size", P::Slider(0.0, 250.0, "px")), ("knocksOut", "Layer Knocks Out Drop Shadow", P::Check)], - "innerShadow" => &[("blend", "Blend Mode", P::Blend), ("color", "Color", P::Color), ("opacity", "Opacity", P::Slider(0.0, 100.0, "%")), ("angle", "Angle", P::Slider(-180.0, 180.0, "°")), ("distance", "Distance", P::Slider(0.0, 300.0, "px")), ("choke", "Choke", P::Slider(0.0, 100.0, "%")), ("size", "Size", P::Slider(0.0, 250.0, "px"))], - "outerGlow" => &[("blend", "Blend Mode", P::Blend), ("opacity", "Opacity", P::Slider(0.0, 100.0, "%")), ("color", "Color", P::Color), ("spread", "Spread", P::Slider(0.0, 100.0, "%")), ("size", "Size", P::Slider(0.0, 250.0, "px")), ("range", "Range", P::Slider(1.0, 100.0, "%"))], - "innerGlow" => &[("blend", "Blend Mode", P::Blend), ("opacity", "Opacity", P::Slider(0.0, 100.0, "%")), ("color", "Color", P::Color), ("source", "Source", P::Choice(SRC)), ("choke", "Choke", P::Slider(0.0, 100.0, "%")), ("size", "Size", P::Slider(0.0, 250.0, "px"))], - "stroke" => &[("size", "Size", P::Slider(1.0, 250.0, "px")), ("position", "Position", P::Choice(POS)), ("blend", "Blend Mode", P::Blend), ("opacity", "Opacity", P::Slider(0.0, 100.0, "%")), ("color", "Color", P::Color)], + "dropShadow" => &[ + ("blend", "Blend Mode", P::Blend), + ("color", "Color", P::Color), + ("opacity", "Opacity", P::Slider(0.0, 100.0, "%")), + ("angle", "Angle", P::Slider(-180.0, 180.0, "°")), + ("distance", "Distance", P::Slider(0.0, 300.0, "px")), + ("spread", "Spread", P::Slider(0.0, 100.0, "%")), + ("size", "Size", P::Slider(0.0, 250.0, "px")), + ("knocksOut", "Layer Knocks Out Drop Shadow", P::Check), + ], + "innerShadow" => &[ + ("blend", "Blend Mode", P::Blend), + ("color", "Color", P::Color), + ("opacity", "Opacity", P::Slider(0.0, 100.0, "%")), + ("angle", "Angle", P::Slider(-180.0, 180.0, "°")), + ("distance", "Distance", P::Slider(0.0, 300.0, "px")), + ("choke", "Choke", P::Slider(0.0, 100.0, "%")), + ("size", "Size", P::Slider(0.0, 250.0, "px")), + ], + "outerGlow" => &[ + ("blend", "Blend Mode", P::Blend), + ("opacity", "Opacity", P::Slider(0.0, 100.0, "%")), + ("color", "Color", P::Color), + ("spread", "Spread", P::Slider(0.0, 100.0, "%")), + ("size", "Size", P::Slider(0.0, 250.0, "px")), + ("range", "Range", P::Slider(1.0, 100.0, "%")), + ], + "innerGlow" => &[ + ("blend", "Blend Mode", P::Blend), + ("opacity", "Opacity", P::Slider(0.0, 100.0, "%")), + ("color", "Color", P::Color), + ("source", "Source", P::Choice(SRC)), + ("choke", "Choke", P::Slider(0.0, 100.0, "%")), + ("size", "Size", P::Slider(0.0, 250.0, "px")), + ], + "stroke" => &[ + ("size", "Size", P::Slider(1.0, 250.0, "px")), + ("position", "Position", P::Choice(POS)), + ("blend", "Blend Mode", P::Blend), + ("opacity", "Opacity", P::Slider(0.0, 100.0, "%")), + ("color", "Color", P::Color), + ], "colorOverlay" => &[("blend", "Blend Mode", P::Blend), ("color", "Color", P::Color), ("opacity", "Opacity", P::Slider(0.0, 100.0, "%"))], - "gradientOverlay" => &[("blend", "Blend Mode", P::Blend), ("opacity", "Opacity", P::Slider(0.0, 100.0, "%")), ("from", "From", P::Color), ("to", "To", P::Color), ("reverse", "Reverse", P::Check), ("style", "Style", P::Choice(GSTYLE)), ("angle", "Angle", P::Slider(-180.0, 180.0, "°")), ("scale", "Scale", P::Slider(10.0, 150.0, "%"))], - "patternOverlay" => &[("blend", "Blend Mode", P::Blend), ("opacity", "Opacity", P::Slider(0.0, 100.0, "%")), ("pattern", "Pattern", P::Pattern), ("angle", "Angle", P::Slider(-180.0, 180.0, "°")), ("scale", "Scale", P::Slider(1.0, 1000.0, "%")), ("link", "Link with Layer", P::Check)], - "bevelEmboss" => &[("style", "Style", P::Choice(BSTYLE)), ("depth", "Depth", P::Slider(1.0, 1000.0, "%")), ("direction", "Direction", P::Choice(DIR)), ("size", "Size", P::Slider(0.0, 250.0, "px")), ("soften", "Soften", P::Slider(0.0, 16.0, "px")), ("angle", "Angle", P::Slider(-180.0, 180.0, "°")), ("altitude", "Altitude", P::Slider(0.0, 90.0, "°"))], - "satin" => &[("blend", "Blend Mode", P::Blend), ("color", "Color", P::Color), ("opacity", "Opacity", P::Slider(0.0, 100.0, "%")), ("angle", "Angle", P::Slider(-180.0, 180.0, "°")), ("distance", "Distance", P::Slider(0.0, 250.0, "px")), ("size", "Size", P::Slider(0.0, 250.0, "px")), ("invert", "Invert", P::Check)], - BLENDING => &[("blend", "Blend Mode", P::Blend), ("opacity", "Opacity", P::Slider(0.0, 100.0, "%")), ("fillOpacity", "Fill Opacity", P::Slider(0.0, 100.0, "%"))], + "gradientOverlay" => &[ + ("blend", "Blend Mode", P::Blend), + ("opacity", "Opacity", P::Slider(0.0, 100.0, "%")), + ("from", "From", P::Color), + ("to", "To", P::Color), + ("reverse", "Reverse", P::Check), + ("style", "Style", P::Choice(GSTYLE)), + ("angle", "Angle", P::Slider(-180.0, 180.0, "°")), + ("scale", "Scale", P::Slider(10.0, 150.0, "%")), + ], + "patternOverlay" => &[ + ("blend", "Blend Mode", P::Blend), + ("opacity", "Opacity", P::Slider(0.0, 100.0, "%")), + ("pattern", "Pattern", P::Pattern), + ("angle", "Angle", P::Slider(-180.0, 180.0, "°")), + ("scale", "Scale", P::Slider(1.0, 1000.0, "%")), + ("link", "Link with Layer", P::Check), + ], + "bevelEmboss" => &[ + ("style", "Style", P::Choice(BSTYLE)), + ("depth", "Depth", P::Slider(1.0, 1000.0, "%")), + ("direction", "Direction", P::Choice(DIR)), + ("size", "Size", P::Slider(0.0, 250.0, "px")), + ("soften", "Soften", P::Slider(0.0, 16.0, "px")), + ("angle", "Angle", P::Slider(-180.0, 180.0, "°")), + ("altitude", "Altitude", P::Slider(0.0, 90.0, "°")), + ], + "satin" => &[ + ("blend", "Blend Mode", P::Blend), + ("color", "Color", P::Color), + ("opacity", "Opacity", P::Slider(0.0, 100.0, "%")), + ("angle", "Angle", P::Slider(-180.0, 180.0, "°")), + ("distance", "Distance", P::Slider(0.0, 250.0, "px")), + ("size", "Size", P::Slider(0.0, 250.0, "px")), + ("invert", "Invert", P::Check), + ], + BLENDING => &[ + ("blend", "Blend Mode", P::Blend), + ("opacity", "Opacity", P::Slider(0.0, 100.0, "%")), + ("fillOpacity", "Fill Opacity", P::Slider(0.0, 100.0, "%")), + ], _ => &[], } } @@ -67,7 +140,9 @@ fn defaults(kind: &str) -> Value { "innerGlow" => json!({"blend": "Screen", "opacity": 75, "color": "#ffffbe", "source": "edge", "choke": 0, "size": 5}), "stroke" => json!({"size": 3, "position": "outside", "blend": "Normal", "opacity": 100, "color": "#000000"}), "colorOverlay" => json!({"blend": "Normal", "color": "#ff0000", "opacity": 100}), - "gradientOverlay" => json!({"blend": "Normal", "opacity": 100, "from": "#000000", "to": "#ffffff", "reverse": false, "style": "linear", "angle": 90, "scale": 100}), + "gradientOverlay" => { + json!({"blend": "Normal", "opacity": 100, "from": "#000000", "to": "#ffffff", "reverse": false, "style": "linear", "angle": 90, "scale": 100}) + } "patternOverlay" => json!({"blend": "Normal", "opacity": 100, "pattern": "", "angle": 0, "scale": 100, "link": true}), "bevelEmboss" => json!({"style": "inner", "depth": 100, "direction": "up", "size": 5, "soften": 0, "angle": 120, "altitude": 30}), "satin" => json!({"blend": "Multiply", "color": "#000000", "opacity": 50, "angle": 19, "distance": 11, "size": 14, "invert": true}), @@ -125,7 +200,15 @@ fn values_of(e: &Effect) -> Value { set(&mut v, "blend", json!(s.common.blend.label())); set(&mut v, "opacity", json!((s.common.opacity * 100.0).round())); set(&mut v, "size", json!(s.size)); - set(&mut v, "position", json!(match s.position { StrokePosition::Inside => "inside", StrokePosition::Center => "center", _ => "outside" })); + set( + &mut v, + "position", + json!(match s.position { + StrokePosition::Inside => "inside", + StrokePosition::Center => "center", + _ => "outside", + }), + ); if let FxPaint::Color(c) = &s.paint { set(&mut v, "color", json!(hex(c))); } @@ -174,7 +257,10 @@ fn values_of(e: &Effect) -> Value { pub fn initial_fields(layer: &Layer, select: Option<&str>) -> Map { let mut f = Map::new(); f.insert("layer".into(), json!(layer.id.0)); - f.insert(format!("p:{BLENDING}"), json!({"blend": layer.blend.label(), "opacity": (layer.opacity * 100.0).round(), "fillOpacity": (layer.fill_opacity * 100.0).round()})); + f.insert( + format!("p:{BLENDING}"), + json!({"blend": layer.blend.label(), "opacity": (layer.opacity * 100.0).round(), "fillOpacity": (layer.fill_opacity * 100.0).round()}), + ); let mut first = None; for &(kind, _) in KINDS { let existing = layer.effects.items.iter().find(|e| kind_of(e) == kind); @@ -246,7 +332,11 @@ pub fn preview_hash(f: &Map) -> u64 { } /// `doc` with the dialog's style applied, run on a scratch session (no history) for the live preview. -pub fn preview_document(doc: &photocraft_doc::Document, patterns: &photocraft_engine::pattern_cmds::PatternLibrary, f: &Map) -> Option { +pub fn preview_document( + doc: &photocraft_doc::Document, + patterns: &photocraft_engine::pattern_cmds::PatternLibrary, + f: &Map, +) -> Option { let mut s = photocraft_engine::Session::new(); s.patterns = patterns.clone(); s.add_document(doc.clone(), None); @@ -264,7 +354,8 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { ui.set_width(190.0); ui.label(RichText::new("Styles").color(t.text_faint)); // Blending Options page (layer blend mode, opacity and fill opacity). - let bo = ui.add(egui::Label::new(RichText::new("Blending Options").color(if selected == BLENDING { t.text } else { t.text_dim })).sense(Sense::click())); + let bo = + ui.add(egui::Label::new(RichText::new("Blending Options").color(if selected == BLENDING { t.text } else { t.text_dim })).sense(Sense::click())); if bo.clicked() { f.insert("selected".into(), json!(BLENDING)); } @@ -288,7 +379,13 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { } else { ui.painter().rect_stroke(cb, 2.0, Stroke::new(1.5, t.text_faint), StrokeKind::Inside); } - ui.painter().text(rect.left_center() + vec2(28.0, 0.0), egui::Align2::LEFT_CENTER, label, egui::FontId::proportional(12.5), if on || is_sel { t.text } else { t.text_dim }); + ui.painter().text( + rect.left_center() + vec2(28.0, 0.0), + egui::Align2::LEFT_CENTER, + label, + egui::FontId::proportional(12.5), + if on || is_sel { t.text } else { t.text_dim }, + ); if cresp.clicked() { on = !on; f.insert(key, json!(on)); @@ -330,7 +427,8 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { ui.horizontal(|ui| { ui.label(RichText::new(label).color(t.text_dim)); let mut cur = p.get(key).and_then(Value::as_str).unwrap_or("Normal").to_string(); - let opts: Vec<(String, &str)> = photocraft_color::BlendMode::LAYER_MODES.iter().map(|m| (m.label().to_string(), m.label())).collect(); + let opts: Vec<(String, &str)> = + photocraft_color::BlendMode::LAYER_MODES.iter().map(|m| (m.label().to_string(), m.label())).collect(); if widgets::dropdown(ui, &format!("fx-blend-{selected}"), &mut cur, &opts, 150.0) { p[key] = json!(cur); } @@ -354,9 +452,17 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { .unwrap_or_default(); ui.horizontal(|ui| { ui.label(RichText::new(label).color(t.text_dim)); - let mut cur = p.get(key).and_then(Value::as_str).filter(|c| !c.is_empty()).map(str::to_string).or_else(|| list.first().map(|l| l.0.clone())).unwrap_or_default(); + let mut cur = p + .get(key) + .and_then(Value::as_str) + .filter(|c| !c.is_empty()) + .map(str::to_string) + .or_else(|| list.first().map(|l| l.0.clone())) + .unwrap_or_default(); let opts: Vec<(String, &str)> = list.iter().map(|(id, n)| (id.clone(), n.as_str())).collect(); - if widgets::dropdown(ui, &format!("fx-{selected}-{key}"), &mut cur, &opts, 180.0) || p.get(key).and_then(Value::as_str).is_none_or(str::is_empty) { + if widgets::dropdown(ui, &format!("fx-{selected}-{key}"), &mut cur, &opts, 180.0) + || p.get(key).and_then(Value::as_str).is_none_or(str::is_empty) + { p[key] = json!(cur); } }); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a736c29c82..12221d3d9d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -9,10 +9,9 @@ pub mod actions; pub mod adjust_ui; pub mod analysis_ui; -pub mod file_ui; -pub mod slice_ui; pub mod artboard_ui; pub mod brush_panel; +pub mod camera_raw_ui; pub mod canvas; pub mod channel_view; pub mod channels_panel; @@ -21,52 +20,53 @@ pub mod color_picker_ui; pub mod comps_ui; pub mod control; pub mod dialogs; -mod variables_ui; -mod timeline_ui; +pub mod distort_ui; pub mod doc_props_ui; pub mod enable_rules; pub mod export_dialog; -pub mod gpu_canvas; +pub mod file_ui; pub mod filter_dialog; +pub mod gallery_ui; +pub mod gpu_canvas; +mod icon_data; pub mod icons; pub mod layer_menu_ui; pub mod layer_props_ui; pub mod layer_style; pub mod links; +pub mod liquify_ui; pub mod menu_catalog; pub mod menus; pub mod new_doc_ui; pub mod outline; pub mod palette; -pub mod proxy; -pub mod retouch_ui; -pub mod rulers; -pub mod smart_ui; pub mod panels; pub mod parity; -pub mod shortcuts; +pub mod perspective_ui; pub mod prefs_ui; pub mod preset_panels; -pub mod type_panels_ui; -pub mod snap_ui; +pub mod proxy; +pub mod puppet_ui; +pub mod retouch_ui; +pub mod rulers; +pub mod shortcuts; mod sizing; +pub mod slice_ui; +pub mod smart_ui; +pub mod snap_ui; pub mod state; pub mod theme; +mod timeline_ui; pub mod tone; pub mod transform_tool; -pub mod distort_ui; -pub mod camera_raw_ui; -pub mod wide_angle_ui; -pub mod gallery_ui; -pub mod liquify_ui; -pub mod puppet_ui; -pub mod perspective_ui; +pub mod type_panels_ui; pub mod type_tool; +mod variables_ui; pub mod vector_ui; pub mod view_cmds; +pub mod wide_angle_ui; pub mod widgets; pub mod workspace_ui; -mod icon_data; use std::collections::HashMap; use std::sync::mpsc::{Receiver, Sender}; @@ -494,7 +494,8 @@ impl eframe::App for PhotocraftApp { self.issue_screenshots(ctx); prefs_ui::tick(self, ctx); shortcuts::handle(self, ctx); - let arrived: Vec<(String, Vec)> = self.services.inbox.as_ref().map(|q| std::mem::take(&mut *q.lock().unwrap_or_else(|e| e.into_inner()))).unwrap_or_default(); + let arrived: Vec<(String, Vec)> = + self.services.inbox.as_ref().map(|q| std::mem::take(&mut *q.lock().unwrap_or_else(|e| e.into_inner()))).unwrap_or_default(); for (name, bytes) in arrived { if let Err(e) = self.open_bytes(&name, &bytes) { self.ui.status = format!("Couldn't open {name}: {e}"); @@ -692,7 +693,11 @@ impl PhotocraftApp { /// release, so egui sees press and release in separate frames. Hosts that don't call /// `raw_input_hook` (offscreen harnesses) feed these to their input themselves. pub fn take_synthetic_step(&mut self) -> Vec { - let n = self.synthetic.iter().position(|e| matches!(e, egui::Event::PointerButton { pressed: false, .. } | egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1); + let n = self + .synthetic + .iter() + .position(|e| matches!(e, egui::Event::PointerButton { pressed: false, .. } | egui::Event::Key { pressed: false, .. })) + .map_or(self.synthetic.len(), |i| i + 1); self.synthetic.drain(..n).collect() } diff --git a/crates/ui-egui/src/links.rs b/crates/ui-egui/src/links.rs index 033e09f4f7..4986151660 100644 --- a/crates/ui-egui/src/links.rs +++ b/crates/ui-egui/src/links.rs @@ -13,13 +13,8 @@ pub const GITHUB: &str = "https://github.com/storytold/photocraft"; pub const ISSUES: &str = "https://github.com/storytold/photocraft/issues"; /// Help-menu link commands: (id, url). Labels live in `menus::UI_COMMANDS`. -pub const COMMANDS: &[(&str, &str)] = &[ - ("help.discord", DISCORD), - ("help.website", APP_PAGE), - ("help.artcraftWebsite", ARTCRAFT_WEBSITE), - ("help.github", GITHUB), - ("help.reportIssue", ISSUES), -]; +pub const COMMANDS: &[(&str, &str)] = + &[("help.discord", DISCORD), ("help.website", APP_PAGE), ("help.artcraftWebsite", ARTCRAFT_WEBSITE), ("help.github", GITHUB), ("help.reportIssue", ISSUES)]; pub fn url_for(id: &str) -> Option<&'static str> { COMMANDS.iter().find(|c| c.0 == id).map(|c| c.1) @@ -116,7 +111,11 @@ mod tests { fn link_commands_open_their_urls() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); let ctx = egui::Context::default(); - for (id, url) in [("help.discord", "https://discord.gg/artcraft"), ("help.website", "https://getartcraft.com/apps/photocraft"), ("help.github", "https://github.com/storytold/photocraft")] { + for (id, url) in [ + ("help.discord", "https://discord.gg/artcraft"), + ("help.website", "https://getartcraft.com/apps/photocraft"), + ("help.github", "https://github.com/storytold/photocraft"), + ] { let r = crate::menus::invoke(&mut app, &ctx, id, serde_json::json!({})).unwrap(); assert_eq!(r["url"], url); assert_eq!(app.ui.status, format!("Opened {url}")); diff --git a/crates/ui-egui/src/liquify_ui.rs b/crates/ui-egui/src/liquify_ui.rs index f6de33b71a..cd2e71ed60 100644 --- a/crates/ui-egui/src/liquify_ui.rs +++ b/crates/ui-egui/src/liquify_ui.rs @@ -43,7 +43,19 @@ pub struct LiquifyOpts { impl Default for LiquifyOpts { fn default() -> Self { - LiquifyOpts { tool: LiquifyTool::ForwardWarp, size: 100.0, density: 50.0, pressure: 100.0, rate: 80.0, show_mesh: false, mesh_size: 1, show_mask: true, show_backdrop: false, backdrop_opacity: 50.0, reconstruct_amount: 100.0 } + LiquifyOpts { + tool: LiquifyTool::ForwardWarp, + size: 100.0, + density: 50.0, + pressure: 100.0, + rate: 80.0, + show_mesh: false, + mesh_size: 1, + show_mask: true, + show_backdrop: false, + backdrop_opacity: 50.0, + reconstruct_amount: 100.0, + } } } @@ -100,7 +112,15 @@ impl LiquifyDialog { fn template(&self) -> LiquifyStroke { let o = &self.opts; - LiquifyStroke { tool: o.tool, size: f64::from(o.size), density: f64::from(o.density), pressure: f64::from(o.pressure), rate: f64::from(o.rate), points: Vec::new(), amount: None } + LiquifyStroke { + tool: o.tool, + size: f64::from(o.size), + density: f64::from(o.density), + pressure: f64::from(o.pressure), + rate: f64::from(o.rate), + points: Vec::new(), + amount: None, + } } fn mark(&mut self, doc_rect: Rect, mask: bool) { @@ -200,7 +220,10 @@ impl LiquifyDialog { if self.mask_dirty || self.mask_tex.is_none() { self.mask_dirty = false; let f = &self.field; - let img = egui::ColorImage::new([f.w, f.h], f.freeze.iter().map(|v| Color32::from_rgba_unmultiplied(230, 40, 40, (v.clamp(0.0, 1.0) * 140.0) as u8)).collect()); + let img = egui::ColorImage::new( + [f.w, f.h], + f.freeze.iter().map(|v| Color32::from_rgba_unmultiplied(230, 40, 40, (v.clamp(0.0, 1.0) * 140.0) as u8)).collect(), + ); match &mut self.mask_tex { Some(t) => t.set(img, egui::TextureOptions::LINEAR), None => self.mask_tex = Some(ctx.load_texture("liquify-mask", img, egui::TextureOptions::LINEAR)), @@ -354,7 +377,17 @@ pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) { if ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::CloseBracket)) { d.opts.size = (d.opts.size * 1.1).min(15000.0); } - let tools = [(egui::Key::W, LiquifyTool::ForwardWarp), (egui::Key::R, LiquifyTool::Reconstruct), (egui::Key::E, LiquifyTool::Smooth), (egui::Key::C, LiquifyTool::TwirlCw), (egui::Key::S, LiquifyTool::Pucker), (egui::Key::B, LiquifyTool::Bloat), (egui::Key::O, LiquifyTool::PushLeft), (egui::Key::F, LiquifyTool::Freeze), (egui::Key::D, LiquifyTool::Thaw)]; + let tools = [ + (egui::Key::W, LiquifyTool::ForwardWarp), + (egui::Key::R, LiquifyTool::Reconstruct), + (egui::Key::E, LiquifyTool::Smooth), + (egui::Key::C, LiquifyTool::TwirlCw), + (egui::Key::S, LiquifyTool::Pucker), + (egui::Key::B, LiquifyTool::Bloat), + (egui::Key::O, LiquifyTool::PushLeft), + (egui::Key::F, LiquifyTool::Freeze), + (egui::Key::D, LiquifyTool::Thaw), + ]; for (k, t) in tools { if ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, k)) { d.opts.tool = t; @@ -493,7 +526,11 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { widgets::slider_row(ui, "Opacity", &mut d.opts.backdrop_opacity, 0.0..=100.0, "", None); } widgets::hairline(ui); - ui.label(egui::RichText::new(format!("{} stroke(s) · field {} px/node · dab {:.2} ms", d.strokes.len(), d.cell, d.dab_ms)).color(t.text_faint).size(11.0)); + ui.label( + egui::RichText::new(format!("{} stroke(s) · field {} px/node · dab {:.2} ms", d.strokes.len(), d.cell, d.dab_ms)) + .color(t.text_faint) + .size(11.0), + ); }); // Footer buttons. let foot = ERect::from_min_size(pos2(right.left() + 14.0, right.bottom() - 48.0), vec2(RIGHT_W - 28.0, 32.0)); @@ -525,7 +562,10 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { d.center = [d.center[0] + before[0] - after[0], d.center[1] + before[1] - after[1]]; } } - let img = ERect::from_min_max(to_screen(d, area, [f64::from(d.canvas.x0), f64::from(d.canvas.y0)]), to_screen(d, area, [f64::from(d.canvas.x1), f64::from(d.canvas.y1)])); + let img = ERect::from_min_max( + to_screen(d, area, [f64::from(d.canvas.x0), f64::from(d.canvas.y0)]), + to_screen(d, area, [f64::from(d.canvas.x1), f64::from(d.canvas.y1)]), + ); let clip = painter.with_clip_rect(area); widgets::checker(&clip, img.intersect(area), 8.0); let uv = ERect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); @@ -535,7 +575,8 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { clip.image(b.id(), img, uv, Color32::from_white_alpha((d.opts.backdrop_opacity / 100.0 * 255.0) as u8)); } // The proxy covers whole proxy pixels, a little past the canvas edge. - let pr = ERect::from_min_max(img.min, img.min + vec2(d.proxy.w as f32 * d.proxy.scale as f32 * d.zoom, d.proxy.h as f32 * d.proxy.scale as f32 * d.zoom)); + let pr = + ERect::from_min_max(img.min, img.min + vec2(d.proxy.w as f32 * d.proxy.scale as f32 * d.zoom, d.proxy.h as f32 * d.proxy.scale as f32 * d.zoom)); if let Some(tex) = &d.tex { clip.image(tex.id(), pr, uv, Color32::WHITE); } @@ -543,7 +584,10 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { && let Some(m) = &d.mask_tex { let f = &d.field; - let mr = ERect::from_min_size(img.min - vec2(0.5, 0.5) * d.zoom * f.cell as f32 + vec2(0.5, 0.5) * d.zoom, vec2(f.w as f32, f.h as f32) * f.cell as f32 * d.zoom); + let mr = ERect::from_min_size( + img.min - vec2(0.5, 0.5) * d.zoom * f.cell as f32 + vec2(0.5, 0.5) * d.zoom, + vec2(f.w as f32, f.h as f32) * f.cell as f32 * d.zoom, + ); clip.image(m.id(), mr, uv, Color32::WHITE); } if d.opts.show_mesh { @@ -682,11 +726,17 @@ mod tests { let mut app = app_with_layer(); open(&mut app, &ctx).unwrap(); control(&mut app, &json!({"tool": "forwardWarp", "size": 40})).unwrap(); - for ev in [ToolEvent::Down { x: 40.0, y: 40.0, pressure: 1.0 }, ToolEvent::Move { x: 48.0, y: 41.0, pressure: 1.0 }, ToolEvent::Move { x: 56.0, y: 40.0, pressure: 1.0 }, ToolEvent::Up { x: 56.0, y: 40.0 }] { + for ev in [ + ToolEvent::Down { x: 40.0, y: 40.0, pressure: 1.0 }, + ToolEvent::Move { x: 48.0, y: 41.0, pressure: 1.0 }, + ToolEvent::Move { x: 56.0, y: 40.0, pressure: 1.0 }, + ToolEvent::Up { x: 56.0, y: 40.0 }, + ] { pointer(&mut app, ev, egui::Modifiers::NONE); } control(&mut app, &json!({"tool": "bloat"})).unwrap(); - for ev in [ToolEvent::Down { x: 80.0, y: 40.0, pressure: 1.0 }, ToolEvent::Move { x: 80.0, y: 40.0, pressure: 1.0 }, ToolEvent::Up { x: 80.0, y: 40.0 }] { + for ev in [ToolEvent::Down { x: 80.0, y: 40.0, pressure: 1.0 }, ToolEvent::Move { x: 80.0, y: 40.0, pressure: 1.0 }, ToolEvent::Up { x: 80.0, y: 40.0 }] + { pointer(&mut app, ev, egui::Modifiers::NONE); } let d = app.distort.liquify.as_ref().unwrap(); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 4c946cc99f..a7e4ccb1f5 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -201,7 +201,11 @@ pub fn invoke(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: Va } } "file.save" => { - let path = params.get("path").and_then(Value::as_str).map(str::to_string).or_else(|| app.session.active().and_then(|d| d.path.clone()).filter(|p| p.ends_with(".psd") || p.ends_with(".psb"))); + let path = params + .get("path") + .and_then(Value::as_str) + .map(str::to_string) + .or_else(|| app.session.active().and_then(|d| d.path.clone()).filter(|p| p.ends_with(".psd") || p.ends_with(".psb"))); app.save_as(path).map(|p| json!({"path": p})) } "file.exit" => { @@ -265,7 +269,11 @@ pub fn invoke(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: Va // Layer › Export As… / Quick Export as PNG: the export pipeline on just the active layer. l if matches!(l, "layer.exportAs" | "layer.quickExportAsPng") && params.as_object().is_none_or(|o| o.is_empty()) => { let layer = app.session.active().and_then(|d| d.active_layer).ok_or("no active layer")?; - if l == "layer.exportAs" { Ok(json!({"dialog": crate::export_dialog::open_layer(app, layer)?})) } else { crate::export_dialog::quick_export_layer_png(app, layer) } + if l == "layer.exportAs" { + Ok(json!({"dialog": crate::export_dialog::open_layer(app, layer)?})) + } else { + crate::export_dialog::quick_export_layer_png(app, layer) + } } "layer.layerStyle.blendingOptions" if params.as_object().is_none_or(|o| o.is_empty()) => { crate::layer_style::open(app, Some(crate::layer_style::BLENDING)).map(|d| json!({"dialog": d})).ok_or_else(|| "no active layer".to_string()) @@ -287,12 +295,14 @@ pub fn invoke(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: Va // Photoshop: paste in place when the copied area is visible, else centred in the view; // images from other apps are always centred. let external = app.import_os_clipboard(); - let visible = !external && app.session.active_index().zip(app.session.clipboard.as_ref()).is_some_and(|(i, clip)| { - let v = &app.ui.views[i]; - let (hw, hh) = (app.last_canvas_rect.width() / 2.0 / v.zoom, app.last_canvas_rect.height() / 2.0 / v.zoom); - let r = photocraft_geom::Rect::new((v.center[0] - hw) as i32, (v.center[1] - hh) as i32, (v.center[0] + hw) as i32, (v.center[1] + hh) as i32); - !clip.bounds.intersect(&r).is_empty() - }); + let visible = !external + && app.session.active_index().zip(app.session.clipboard.as_ref()).is_some_and(|(i, clip)| { + let v = &app.ui.views[i]; + let (hw, hh) = (app.last_canvas_rect.width() / 2.0 / v.zoom, app.last_canvas_rect.height() / 2.0 / v.zoom); + let r = + photocraft_geom::Rect::new((v.center[0] - hw) as i32, (v.center[1] - hh) as i32, (v.center[0] + hw) as i32, (v.center[1] + hh) as i32); + !clip.bounds.intersect(&r).is_empty() + }); let p = match app.session.active_index() { Some(i) if !visible => json!({"center": app.ui.views[i].center}), _ => json!({}), @@ -311,15 +321,21 @@ pub fn invoke(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: Va *slot = !*slot; Ok(json!(*slot)) } - "edit.freeTransform" | "edit.transform.scale" | "edit.transform.rotate" | "edit.transform.skew" | "edit.transform.distort" | "edit.transform.perspective" => { - crate::transform_tool::begin(app, ctx).map(|_| json!({"transform": app.ui.transform})) - } + "edit.freeTransform" + | "edit.transform.scale" + | "edit.transform.rotate" + | "edit.transform.skew" + | "edit.transform.distort" + | "edit.transform.perspective" => crate::transform_tool::begin(app, ctx).map(|_| json!({"transform": app.ui.transform})), // Edit › Transform › Warp from the menu: interactive Warp mode (with params: the engine). "edit.transform.warp" | "layer.smartObjects.warp" if params.as_object().is_none_or(|o| o.is_empty()) => { crate::transform_tool::begin_warp(app, ctx).map(|_| json!({"transform": app.ui.transform})) } // Split warps edit the mesh of an active Warp session. - "edit.transform.splitWarpCrosswise" | "edit.transform.splitWarpHorizontally" | "edit.transform.splitWarpVertically" | "edit.transform.removeWarpSplit" + "edit.transform.splitWarpCrosswise" + | "edit.transform.splitWarpHorizontally" + | "edit.transform.splitWarpVertically" + | "edit.transform.removeWarpSplit" if app.ui.transform.as_ref().is_some_and(|t| t.warp.is_some()) && params.get("warp").is_none() => { let at = params.get("at").and_then(Value::as_array).and_then(|a| Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?])); @@ -404,7 +420,9 @@ pub fn is_enabled(app: &PhotocraftApp, id: &str) -> bool { i if i.starts_with("file.openRecent.") => true, i if crate::links::url_for(i).is_some() => true, i if i.starts_with("window.theme.") => true, - "file.save" | "file.saveAs" | "file.export.exportAs" | "file.export.quickExportAsPng" => app.session.active().is_some() && app.services.export.is_some(), + "file.save" | "file.saveAs" | "file.export.exportAs" | "file.export.quickExportAsPng" => { + app.session.active().is_some() && app.services.export.is_some() + } i if i.starts_with("window.toggle.") => true, i if panel_alias(i).is_some() || workspace_name(i).is_some() => true, i if proof_preset(i).is_some() => app.session.active().is_some(), @@ -413,10 +431,15 @@ pub fn is_enabled(app: &PhotocraftApp, id: &str) -> bool { "view.rulers" | "view.show.grid" | "view.show.guides" | "view.snap" | "view.lockGuides" => true, "select.selectAndMask" => app.session.is_enabled("select.refineEdge"), "select.transformSelection" => app.ui.transform.is_none() && app.session.is_enabled("select.transformSelection"), - i if (i.starts_with("view.zoom") || i == "view.fitOnScreen" || i == "view.actualPixels") || i == "window.newWindowForDocument" => app.session.active().is_some(), - "edit.freeTransform" | "edit.transform.scale" | "edit.transform.rotate" | "edit.transform.skew" | "edit.transform.distort" | "edit.transform.perspective" => { - app.ui.transform.is_none() && app.session.active().and_then(|s| s.active_layer).is_some() + i if (i.starts_with("view.zoom") || i == "view.fitOnScreen" || i == "view.actualPixels") || i == "window.newWindowForDocument" => { + app.session.active().is_some() } + "edit.freeTransform" + | "edit.transform.scale" + | "edit.transform.rotate" + | "edit.transform.skew" + | "edit.transform.distort" + | "edit.transform.perspective" => app.ui.transform.is_none() && app.session.active().and_then(|s| s.active_layer).is_some(), i => app.session.is_enabled(i), } } @@ -511,7 +534,18 @@ pub struct MenuItem { /// Is `id` implemented by the engine or the shell (a live menu item)? Shared by the menus and /// the parity report ([`crate::parity`]). pub fn is_live(id: &str) -> bool { - photocraft_engine::commands::find(id).is_some() || UI_COMMANDS.iter().any(|c| c.0 == id) || panel_alias(id).is_some() || workspace_name(id).is_some() || proof_preset(id).is_some() || id == "view.proofSetup.custom" || crate::view_cmds::handles(id) || crate::analysis_ui::handles(id) || crate::workspace_ui::handles(id) || crate::preset_panels::handles(id) || crate::type_panels_ui::handles(id) || crate::timeline_ui::handles(id) + photocraft_engine::commands::find(id).is_some() + || UI_COMMANDS.iter().any(|c| c.0 == id) + || panel_alias(id).is_some() + || workspace_name(id).is_some() + || proof_preset(id).is_some() + || id == "view.proofSetup.custom" + || crate::view_cmds::handles(id) + || crate::analysis_ui::handles(id) + || crate::workspace_ui::handles(id) + || crate::preset_panels::handles(id) + || crate::type_panels_ui::handles(id) + || crate::timeline_ui::handles(id) } pub fn menu_items(app: &PhotocraftApp) -> Vec { @@ -532,10 +566,26 @@ pub fn menu_items(app: &PhotocraftApp) -> Vec { // 2) Our commands that Photoshop's tree doesn't list (or lists under another id). let mut extra: Vec = Vec::new(); for &(id, label, path, sc) in UI_COMMANDS { - extra.push(MenuItem { id: id.into(), label: label.into(), path: path.iter().map(|s| s.to_string()).collect(), shortcut: sc.map(Into::into), enabled: is_enabled(app, id), checked: checked(app, id), color: None }); + extra.push(MenuItem { + id: id.into(), + label: label.into(), + path: path.iter().map(|s| s.to_string()).collect(), + shortcut: sc.map(Into::into), + enabled: is_enabled(app, id), + checked: checked(app, id), + color: None, + }); } for c in photocraft_engine::command_specs().iter().filter(|c| !c.menu.is_empty()) { - extra.push(MenuItem { id: c.id.into(), label: c.label.into(), path: c.menu.iter().map(|s| s.to_string()).collect(), shortcut: c.shortcut.map(Into::into), enabled: is_enabled(app, c.id), checked: None, color: None }); + extra.push(MenuItem { + id: c.id.into(), + label: c.label.into(), + path: c.menu.iter().map(|s| s.to_string()).collect(), + shortcut: c.shortcut.map(Into::into), + enabled: is_enabled(app, c.id), + checked: None, + color: None, + }); } for e in extra { let dup = items.iter().any(|i| i.id == e.id || (i.path == e.path && i.label.trim_end_matches('…') == e.label.trim_end_matches('…'))); @@ -562,14 +612,25 @@ pub fn menu_items(app: &PhotocraftApp) -> Vec { if !recent.is_empty() { recent.push(MenuItem { id: "---".into(), label: "---".into(), path: rp.clone(), shortcut: None, enabled: false, checked: None, color: None }); } - recent.push(MenuItem { id: "file.clearRecent".into(), label: "Clear Recent Files".into(), path: rp.clone(), shortcut: None, enabled: !app.ui.recent_files.is_empty(), checked: None, color: None }); + recent.push(MenuItem { + id: "file.clearRecent".into(), + label: "Clear Recent Files".into(), + path: rp.clone(), + shortcut: None, + enabled: !app.ui.recent_files.is_empty(), + checked: None, + color: None, + }); for (k, it) in recent.into_iter().enumerate() { items.insert(after + 1 + k, it); } } // Help: the link items, a separator, then About. if let Some(at) = items.iter().position(|i| i.id == "help.about") { - items.insert(at, MenuItem { id: "---".into(), label: "---".into(), path: vec!["Help".into()], shortcut: None, enabled: false, checked: None, color: None }); + items.insert( + at, + MenuItem { id: "---".into(), label: "---".into(), path: vec!["Help".into()], shortcut: None, enabled: false, checked: None, color: None }, + ); } // Edit › Keyboard Shortcuts overrides, Edit › Menus hidden items and colours. let prefs = app.session.prefs(); diff --git a/crates/ui-egui/src/new_doc_ui.rs b/crates/ui-egui/src/new_doc_ui.rs index b646c4db59..5b16f10f51 100644 --- a/crates/ui-egui/src/new_doc_ui.rs +++ b/crates/ui-egui/src/new_doc_ui.rs @@ -15,16 +15,67 @@ pub type Preset = (&'static str, u32, u32, f32); /// Photoshop's New Document categories and their blank-document presets. pub const CATEGORIES: &[(&str, &[Preset])] = &[ ("Recent", &[("Default Photoshop Size", 2100, 1500, 300.0), ("HDTV 1080p", 1920, 1080, 72.0)]), - ("Photo", &[("Landscape, 6 x 4", 1800, 1200, 300.0), ("Landscape, 7 x 5", 2100, 1500, 300.0), ("Landscape, 10 x 8", 3000, 2400, 300.0), ("Portrait, 4 x 6", 1200, 1800, 300.0), ("Portrait, 5 x 7", 1500, 2100, 300.0), ("Square, 5 x 5", 1500, 1500, 300.0)]), - ("Print", &[("Letter", 2550, 3300, 300.0), ("Legal", 2550, 4200, 300.0), ("Tabloid", 3300, 5100, 300.0), ("A4", 2480, 3508, 300.0), ("A3", 3508, 4961, 300.0), ("A5", 1748, 2480, 300.0)]), - ("Art & Illustration", &[("Poster", 5400, 7200, 300.0), ("Postcard", 1800, 1200, 300.0), ("Comic Book", 1988, 3075, 300.0), ("Square, 12 x 12", 3600, 3600, 300.0)]), - ("Web", &[("Web Most Common", 1366, 768, 72.0), ("Web Minimum", 1024, 768, 72.0), ("Web Large", 1920, 1080, 72.0), ("MacBook Pro 16\"", 3456, 2234, 72.0), ("iMac 24\"", 4480, 2520, 72.0)]), - ("Mobile", &[("iPhone 16", 1179, 2556, 72.0), ("iPhone 16 Pro Max", 1320, 2868, 72.0), ("iPad Pro 13\"", 2064, 2752, 72.0), ("Android 1080p", 1080, 1920, 72.0), ("Apple Watch 45mm", 396, 484, 72.0)]), - ("Film & Video", &[("HDTV 1080p", 1920, 1080, 72.0), ("HDTV 720p", 1280, 720, 72.0), ("UHD 4K", 3840, 2160, 72.0), ("DCI 4K", 4096, 2160, 72.0), ("UHD 8K", 7680, 4320, 72.0)]), + ( + "Photo", + &[ + ("Landscape, 6 x 4", 1800, 1200, 300.0), + ("Landscape, 7 x 5", 2100, 1500, 300.0), + ("Landscape, 10 x 8", 3000, 2400, 300.0), + ("Portrait, 4 x 6", 1200, 1800, 300.0), + ("Portrait, 5 x 7", 1500, 2100, 300.0), + ("Square, 5 x 5", 1500, 1500, 300.0), + ], + ), + ( + "Print", + &[ + ("Letter", 2550, 3300, 300.0), + ("Legal", 2550, 4200, 300.0), + ("Tabloid", 3300, 5100, 300.0), + ("A4", 2480, 3508, 300.0), + ("A3", 3508, 4961, 300.0), + ("A5", 1748, 2480, 300.0), + ], + ), + ( + "Art & Illustration", + &[("Poster", 5400, 7200, 300.0), ("Postcard", 1800, 1200, 300.0), ("Comic Book", 1988, 3075, 300.0), ("Square, 12 x 12", 3600, 3600, 300.0)], + ), + ( + "Web", + &[ + ("Web Most Common", 1366, 768, 72.0), + ("Web Minimum", 1024, 768, 72.0), + ("Web Large", 1920, 1080, 72.0), + ("MacBook Pro 16\"", 3456, 2234, 72.0), + ("iMac 24\"", 4480, 2520, 72.0), + ], + ), + ( + "Mobile", + &[ + ("iPhone 16", 1179, 2556, 72.0), + ("iPhone 16 Pro Max", 1320, 2868, 72.0), + ("iPad Pro 13\"", 2064, 2752, 72.0), + ("Android 1080p", 1080, 1920, 72.0), + ("Apple Watch 45mm", 396, 484, 72.0), + ], + ), + ( + "Film & Video", + &[ + ("HDTV 1080p", 1920, 1080, 72.0), + ("HDTV 720p", 1280, 720, 72.0), + ("UHD 4K", 3840, 2160, 72.0), + ("DCI 4K", 4096, 2160, 72.0), + ("UHD 8K", 7680, 4320, 72.0), + ], + ), ]; /// Width/Height units: (key, label, units per inch; 0 = pixels). -pub const UNITS: &[(&str, &str, f32)] = &[("px", "Pixels", 0.0), ("in", "Inches", 1.0), ("cm", "Centimeters", 2.54), ("mm", "Millimeters", 25.4), ("pt", "Points", 72.0), ("pica", "Picas", 6.0)]; +pub const UNITS: &[(&str, &str, f32)] = + &[("px", "Pixels", 0.0), ("in", "Inches", 1.0), ("cm", "Centimeters", 2.54), ("mm", "Millimeters", 25.4), ("pt", "Points", 72.0), ("pica", "Picas", 6.0)]; /// Pixels to the display unit at `ppi`. pub fn to_unit(px: f32, unit: &str, ppi: f32) -> f32 { @@ -112,7 +163,17 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { for p in row { let (r, resp) = ui.allocate_exact_size(card, Sense::click()); let on = chosen == p.0; - ui.painter().rect_filled(r, t.radius, if on { t.row_selected } else if resp.hovered() { t.hover } else { t.field }); + ui.painter().rect_filled( + r, + t.radius, + if on { + t.row_selected + } else if resp.hovered() { + t.hover + } else { + t.field + }, + ); if on { ui.painter().rect_stroke(r, t.radius, Stroke::new(1.5, t.accent), StrokeKind::Inside); } @@ -120,7 +181,12 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { ui.painter().text(pos2(r.center().x, r.top() + 72.0), Align2::CENTER_CENTER, p.0, egui::FontId::proportional(12.0), t.text); let unit = if p.3 >= 300.0 { "in" } else { "px" }; let size = if unit == "in" { - format!("{} x {} in @ {} ppi", widgets::fmt_num(to_unit(p.1 as f32, "in", p.3) as f64), widgets::fmt_num(to_unit(p.2 as f32, "in", p.3) as f64), p.3) + format!( + "{} x {} in @ {} ppi", + widgets::fmt_num(to_unit(p.1 as f32, "in", p.3) as f64), + widgets::fmt_num(to_unit(p.2 as f32, "in", p.3) as f64), + p.3 + ) } else { format!("{} x {} px @ {} ppi", p.1, p.2, p.3) }; @@ -192,7 +258,18 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { small_label(ui, "Color Mode"); ui.horizontal(|ui| { let mut mode = get_s(f, "mode", "rgb"); - if widgets::dropdown(ui, "nd-mode", &mut mode, &[("gray".to_string(), "Grayscale"), ("rgb".to_string(), "RGB Color"), ("cmyk".to_string(), "CMYK Color"), ("lab".to_string(), "Lab Color")], 110.0) { + if widgets::dropdown( + ui, + "nd-mode", + &mut mode, + &[ + ("gray".to_string(), "Grayscale"), + ("rgb".to_string(), "RGB Color"), + ("cmyk".to_string(), "CMYK Color"), + ("lab".to_string(), "Lab Color"), + ], + 110.0, + ) { f.insert("mode".into(), json!(mode)); } let mut depth = f.get("depth").and_then(Value::as_u64).unwrap_or(8); @@ -203,7 +280,12 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { ui.add_space(4.0); small_label(ui, "Background Contents"); let mut bg = get_s(f, "background", "white"); - let opts = [("white".to_string(), "White"), ("black".to_string(), "Black"), ("backgroundColor".to_string(), "Background Color"), ("transparent".to_string(), "Transparent")]; + let opts = [ + ("white".to_string(), "White"), + ("black".to_string(), "Black"), + ("backgroundColor".to_string(), "Background Color"), + ("transparent".to_string(), "Transparent"), + ]; if widgets::dropdown(ui, "nd-bg", &mut bg, &opts, 240.0) { f.insert("background".into(), json!(bg)); } diff --git a/crates/ui-egui/src/palette.rs b/crates/ui-egui/src/palette.rs index 627c2a28ac..2d942cd95e 100644 --- a/crates/ui-egui/src/palette.rs +++ b/crates/ui-egui/src/palette.rs @@ -52,83 +52,106 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { }); let width = 560.0; let mut run: Option = None; - egui::Area::new(egui::Id::new("palette")).order(egui::Order::Tooltip).pivot(Align2::CENTER_TOP).fixed_pos(egui::pos2(screen.center().x, screen.top() + 110.0)).show(ctx, |ui| { - egui::Frame::NONE - .fill(t.card) - .stroke(Stroke::new(1.0, t.card_border)) - .corner_radius(CornerRadius::same(t.radius_lg as u8)) - .shadow(egui::Shadow { offset: [0, 18], blur: 48, spread: 0, color: t.shadow }) - .inner_margin(egui::Margin::same(10)) - .show(ui, |ui| { - ui.set_width(width); - let qid = egui::Id::new("palette-query"); - let mut q: String = ui.data_mut(|d| d.get_temp(qid).unwrap_or_default()); - ui.horizontal(|ui| { - let (r, _) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::hover()); - icons::paint(ui, r, "search", 16.0, t.text_dim); - let te = egui::TextEdit::singleline(&mut q).hint_text("Search commands, tools and panels…").frame(egui::Frame::NONE).font(egui::FontId::proportional(15.0)).desired_width(width - 40.0); - let resp = ui.add(te); - resp.request_focus(); + egui::Area::new(egui::Id::new("palette")) + .order(egui::Order::Tooltip) + .pivot(Align2::CENTER_TOP) + .fixed_pos(egui::pos2(screen.center().x, screen.top() + 110.0)) + .show(ctx, |ui| { + egui::Frame::NONE + .fill(t.card) + .stroke(Stroke::new(1.0, t.card_border)) + .corner_radius(CornerRadius::same(t.radius_lg as u8)) + .shadow(egui::Shadow { offset: [0, 18], blur: 48, spread: 0, color: t.shadow }) + .inner_margin(egui::Margin::same(10)) + .show(ui, |ui| { + ui.set_width(width); + let qid = egui::Id::new("palette-query"); + let mut q: String = ui.data_mut(|d| d.get_temp(qid).unwrap_or_default()); + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::hover()); + icons::paint(ui, r, "search", 16.0, t.text_dim); + let te = egui::TextEdit::singleline(&mut q) + .hint_text("Search commands, tools and panels…") + .frame(egui::Frame::NONE) + .font(egui::FontId::proportional(15.0)) + .desired_width(width - 40.0); + let resp = ui.add(te); + resp.request_focus(); + }); + ui.add_space(6.0); + crate::widgets::hairline(ui); + ui.add_space(6.0); + let mut hits: Vec<(i32, String, String, Option, bool)> = crate::menus::menu_items(app) + .into_iter() + .filter_map(|m| { + let path = m.path.join(" › "); + let s = fuzzy_score(&q, &format!("{} {}", m.label, path))?; + Some(( + s, + m.id, + m.label.trim_end_matches('…').to_string(), + Some(path) + .filter(|_| true) + .map(|p| p + &m.shortcut.map(|s| format!(" {}", crate::shortcuts::pretty(&s))).unwrap_or_default()), + m.enabled, + )) + }) + .collect(); + for tool in crate::state::Tool::ALL { + if let Some(s) = fuzzy_score(&q, tool.label()) { + hits.push((s + 2, format!("tool:{tool:?}"), tool.label().into(), Some(format!("Tool {}", tool.key())), true)); + } + } + hits.sort_by(|a, b| b.0.cmp(&a.0).then(a.2.cmp(&b.2))); + hits.truncate(12); + let sel_id = egui::Id::new("palette-sel"); + let mut sel: usize = ui.data_mut(|d| d.get_temp(sel_id).unwrap_or(0)); + let (down, up, enter, esc) = ui.input(|i| { + ( + i.key_pressed(egui::Key::ArrowDown), + i.key_pressed(egui::Key::ArrowUp), + i.key_pressed(egui::Key::Enter), + i.key_pressed(egui::Key::Escape), + ) + }); + if down { + sel = (sel + 1).min(hits.len().saturating_sub(1)); + } + if up { + sel = sel.saturating_sub(1); + } + sel = sel.min(hits.len().saturating_sub(1)); + if hits.is_empty() { + ui.label(RichText::new("No matching commands").color(t.text_faint)); + } + for (i, (_, id, label, detail, enabled)) in hits.iter().enumerate() { + let (rect, resp) = ui.allocate_exact_size(vec2(width, 32.0), Sense::click()); + if i == sel || resp.hovered() { + ui.painter().rect_filled(rect, t.radius_sm, if i == sel { t.accent_soft } else { t.hover }); + } + let color = if *enabled { t.text } else { t.text_faint }; + ui.painter().text(rect.left_center() + vec2(12.0, 0.0), Align2::LEFT_CENTER, label, theme::medium(13.0), color); + if let Some(d) = detail { + ui.painter().text(rect.right_center() - vec2(12.0, 0.0), Align2::RIGHT_CENTER, d, egui::FontId::proportional(11.5), t.text_faint); + } + if resp.clicked() && *enabled { + run = Some(id.clone()); + } + } + if enter && let Some(h) = hits.get(sel).filter(|h| h.4) { + run = Some(h.1.clone()); + } + if esc { + app.ui.palette_open = false; + } + ui.add_space(4.0); + ui.label(RichText::new("↑↓ navigate ↵ run esc close").small().color(t.text_faint)); + ui.data_mut(|d| { + d.insert_temp(qid, q); + d.insert_temp(sel_id, sel); + }); }); - ui.add_space(6.0); - crate::widgets::hairline(ui); - ui.add_space(6.0); - let mut hits: Vec<(i32, String, String, Option, bool)> = crate::menus::menu_items(app) - .into_iter() - .filter_map(|m| { - let path = m.path.join(" › "); - let s = fuzzy_score(&q, &format!("{} {}", m.label, path))?; - Some((s, m.id, m.label.trim_end_matches('…').to_string(), Some(path).filter(|_| true).map(|p| p + &m.shortcut.map(|s| format!(" {}", crate::shortcuts::pretty(&s))).unwrap_or_default()), m.enabled)) - }) - .collect(); - for tool in crate::state::Tool::ALL { - if let Some(s) = fuzzy_score(&q, tool.label()) { - hits.push((s + 2, format!("tool:{tool:?}"), tool.label().into(), Some(format!("Tool {}", tool.key())), true)); - } - } - hits.sort_by(|a, b| b.0.cmp(&a.0).then(a.2.cmp(&b.2))); - hits.truncate(12); - let sel_id = egui::Id::new("palette-sel"); - let mut sel: usize = ui.data_mut(|d| d.get_temp(sel_id).unwrap_or(0)); - let (down, up, enter, esc) = ui.input(|i| (i.key_pressed(egui::Key::ArrowDown), i.key_pressed(egui::Key::ArrowUp), i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape))); - if down { - sel = (sel + 1).min(hits.len().saturating_sub(1)); - } - if up { - sel = sel.saturating_sub(1); - } - sel = sel.min(hits.len().saturating_sub(1)); - if hits.is_empty() { - ui.label(RichText::new("No matching commands").color(t.text_faint)); - } - for (i, (_, id, label, detail, enabled)) in hits.iter().enumerate() { - let (rect, resp) = ui.allocate_exact_size(vec2(width, 32.0), Sense::click()); - if i == sel || resp.hovered() { - ui.painter().rect_filled(rect, t.radius_sm, if i == sel { t.accent_soft } else { t.hover }); - } - let color = if *enabled { t.text } else { t.text_faint }; - ui.painter().text(rect.left_center() + vec2(12.0, 0.0), Align2::LEFT_CENTER, label, theme::medium(13.0), color); - if let Some(d) = detail { - ui.painter().text(rect.right_center() - vec2(12.0, 0.0), Align2::RIGHT_CENTER, d, egui::FontId::proportional(11.5), t.text_faint); - } - if resp.clicked() && *enabled { - run = Some(id.clone()); - } - } - if enter && let Some(h) = hits.get(sel).filter(|h| h.4) { - run = Some(h.1.clone()); - } - if esc { - app.ui.palette_open = false; - } - ui.add_space(4.0); - ui.label(RichText::new("↑↓ navigate ↵ run esc close").small().color(t.text_faint)); - ui.data_mut(|d| { - d.insert_temp(qid, q); - d.insert_temp(sel_id, sel); - }); - }); - }); + }); if let Some(id) = run { app.ui.palette_open = false; ctx.data_mut(|d| d.insert_temp(egui::Id::new("palette-query"), String::new())); diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 638c1cd947..a58300e177 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -16,7 +16,13 @@ use crate::{PhotocraftApp, icons, widgets}; /// shows the group's last-used tool. const TOOL_SECTIONS: &[&[&[Tool]]] = &[ &[&[Tool::Move]], - &[&[Tool::RectMarquee, Tool::EllipseMarquee], &[Tool::Lasso, Tool::PolygonLasso], &[Tool::ObjectSelection, Tool::QuickSelection, Tool::MagicWand], &[Tool::Crop, Tool::Slice, Tool::SliceSelect], &[Tool::Eyedropper, Tool::Ruler, Tool::Note, Tool::Count]], + &[ + &[Tool::RectMarquee, Tool::EllipseMarquee], + &[Tool::Lasso, Tool::PolygonLasso], + &[Tool::ObjectSelection, Tool::QuickSelection, Tool::MagicWand], + &[Tool::Crop, Tool::Slice, Tool::SliceSelect], + &[Tool::Eyedropper, Tool::Ruler, Tool::Note, Tool::Count], + ], &[ &[Tool::SpotHealing, Tool::Healing], &[Tool::Brush], @@ -27,7 +33,12 @@ const TOOL_SECTIONS: &[&[&[Tool]]] = &[ &[Tool::Blur, Tool::Sharpen, Tool::Smudge], &[Tool::Dodge, Tool::Burn, Tool::Sponge], ], - &[&[Tool::Pen], &[Tool::Type], &[Tool::PathSelection], &[Tool::Rectangle, Tool::EllipseShape, Tool::Triangle, Tool::Polygon, Tool::Line, Tool::CustomShape]], + &[ + &[Tool::Pen], + &[Tool::Type], + &[Tool::PathSelection], + &[Tool::Rectangle, Tool::EllipseShape, Tool::Triangle, Tool::Polygon, Tool::Line, Tool::CustomShape], + ], &[&[Tool::Hand], &[Tool::Zoom]], ]; @@ -47,132 +58,172 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let slots: usize = TOOL_SECTIONS.iter().map(|g| g.len()).sum(); let double = toolbar_needs_double(slots, TOOL_SECTIONS.len(), bx, t.pro, ui.available_rect_before_wrap().height()); let w = if double { w1 + bx + 2.0 } else { w1 }; - egui::Panel::left("toolbar").resizable(false).exact_size(w).frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(m, 8))).show(ui, |ui| { - if t.pro { - let r = ui.max_rect(); - ui.painter().line_segment([r.right_top() + vec2(m as f32, -8.0), r.right_bottom() + vec2(m as f32, 8.0)], Stroke::new(1.0, t.separator)); - // collapse chevrons like Photoshop's toolbar header - let (cr, _) = ui.allocate_exact_size(vec2(bx, 14.0), Sense::hover()); - icons::paint(ui, cr, "chevrons-right", 11.0, t.text_faint); - ui.add_space(4.0); - } - // Subtle violet wash at the bottom of the toolbar. - let full = ui.max_rect(); - if !t.bevel && !t.pro && t.dark() { - let mut mesh = egui::Mesh::default(); - let r = Rect::from_min_max(pos2(full.left() - 7.0, full.bottom() - 260.0), pos2(full.right() + 7.0, full.bottom() + 8.0)); - let top = Color32::TRANSPARENT; - let bottom = Color32::from_rgba_unmultiplied(90, 70, 190, 34); - mesh.colored_vertex(r.left_top(), top); - mesh.colored_vertex(r.right_top(), top); - mesh.colored_vertex(r.right_bottom(), bottom); - mesh.colored_vertex(r.left_bottom(), bottom); - mesh.add_triangle(0, 1, 2); - mesh.add_triangle(0, 2, 3); - ui.painter().add(mesh); - } - ui.spacing_mut().item_spacing = vec2(2.0, 3.0); - let flyout_id = egui::Id::new("tool-flyout"); - let mut slot_index = 0usize; - for (si, section) in TOOL_SECTIONS.iter().enumerate() { - // Photoshop 2026 draws one uninterrupted column (no group dividers). - if si > 0 && !t.pro { - ui.add_space(4.0); - let (r, _) = ui.allocate_exact_size(vec2(if double { bx * 2.0 + 2.0 } else { bx }, 1.0), Sense::hover()); - ui.painter().line_segment([r.left_center() + vec2(8.0, 0.0), r.right_center() - vec2(8.0, 0.0)], Stroke::new(1.0, t.separator)); + egui::Panel::left("toolbar").resizable(false).exact_size(w).frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(m, 8))).show( + ui, + |ui| { + if t.pro { + let r = ui.max_rect(); + ui.painter().line_segment([r.right_top() + vec2(m as f32, -8.0), r.right_bottom() + vec2(m as f32, 8.0)], Stroke::new(1.0, t.separator)); + // collapse chevrons like Photoshop's toolbar header + let (cr, _) = ui.allocate_exact_size(vec2(bx, 14.0), Sense::hover()); + icons::paint(ui, cr, "chevrons-right", 11.0, t.text_faint); ui.add_space(4.0); } - let rows: Vec<&[&[Tool]]> = if double { section.chunks(2).collect() } else { section.chunks(1).collect() }; - for row in rows { - ui.horizontal(|ui| { - for slot in row.iter() { - let key = egui::Id::new(("tool-slot", slot_index)); - slot_index += 1; - let tool = slot_tool(ui, app.ui.tool, slot, key); - let sel = slot.contains(&app.ui.tool); - let tip = if tool.key() == '\0' { tool.label().to_string() } else { format!("{} ({})", tool.label(), tool.key()) }; - let resp = icons::button(ui, icons::tool_icon(tool), bx, sel, &tip); - if slot.len() > 1 { - let r = resp.rect; - let tri = vec![r.right_bottom() + vec2(-2.0, -2.0), r.right_bottom() + vec2(-6.0, -2.0), r.right_bottom() + vec2(-2.0, -6.0)]; - ui.painter().add(egui::Shape::convex_polygon(tri, t.text_faint, Stroke::NONE)); - } - if resp.clicked() { - app.ui.tool = tool; - } - // Right-click or long-press opens the flyout (Photoshop). - let long_press = resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.press_start_time().is_some_and(|t0| i.time - t0 > 0.35)); - if slot.len() > 1 && (resp.secondary_clicked() || long_press) { - ui.data_mut(|d| d.insert_temp(flyout_id, (key, resp.rect))); - } - if slot.len() > 1 && long_press { - ui.ctx().request_repaint(); - } - if let Some((open_key, anchor)) = ui.data(|d| d.get_temp::<(egui::Id, Rect)>(flyout_id)) - && open_key == key - { - let area = egui::Area::new(key.with("flyout")).order(egui::Order::Foreground).fixed_pos(anchor.right_top() + vec2(6.0, 0.0)).show(ui.ctx(), |ui| { - egui::Frame::popup(ui.style()).show(ui, |ui| { - let label_w = slot.iter().map(|it| ui.painter().layout_no_wrap(it.label().to_string(), egui::FontId::proportional(12.5), t.text).size().x).fold(0.0f32, f32::max); - let fw = (label_w + 42.0 + 40.0).max(180.0); - for &item in slot.iter() { - let (r, ir) = ui.allocate_exact_size(vec2(fw, 26.0), Sense::click()); - if ir.hovered() { - ui.painter().rect_filled(r, 3.0, t.hover); - } - if item == tool { - ui.painter().rect_filled(Rect::from_center_size(pos2(r.left() + 8.0, r.center().y), vec2(4.0, 4.0)), 0.0, t.text); - } - icons::paint(ui, Rect::from_center_size(pos2(r.left() + 26.0, r.center().y), vec2(18.0, 18.0)), icons::tool_icon(item), 14.0, t.icon); - ui.painter().text(pos2(r.left() + 42.0, r.center().y), Align2::LEFT_CENTER, item.label(), egui::FontId::proportional(12.5), t.text); - if item.key() != '\0' { - ui.painter().text(pos2(r.right() - 8.0, r.center().y), Align2::RIGHT_CENTER, item.key().to_string(), egui::FontId::proportional(12.0), t.text_dim); - } - if ir.clicked() { - app.ui.tool = item; - ui.data_mut(|d| d.remove::<(egui::Id, Rect)>(flyout_id)); - } - } - }); - }); - let clicked_outside = ui.input(|i| i.pointer.any_click()) && !area.response.hovered() && !resp.hovered(); - if clicked_outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) { - ui.data_mut(|d| d.remove::<(egui::Id, Rect)>(flyout_id)); + // Subtle violet wash at the bottom of the toolbar. + let full = ui.max_rect(); + if !t.bevel && !t.pro && t.dark() { + let mut mesh = egui::Mesh::default(); + let r = Rect::from_min_max(pos2(full.left() - 7.0, full.bottom() - 260.0), pos2(full.right() + 7.0, full.bottom() + 8.0)); + let top = Color32::TRANSPARENT; + let bottom = Color32::from_rgba_unmultiplied(90, 70, 190, 34); + mesh.colored_vertex(r.left_top(), top); + mesh.colored_vertex(r.right_top(), top); + mesh.colored_vertex(r.right_bottom(), bottom); + mesh.colored_vertex(r.left_bottom(), bottom); + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(0, 2, 3); + ui.painter().add(mesh); + } + ui.spacing_mut().item_spacing = vec2(2.0, 3.0); + let flyout_id = egui::Id::new("tool-flyout"); + let mut slot_index = 0usize; + for (si, section) in TOOL_SECTIONS.iter().enumerate() { + // Photoshop 2026 draws one uninterrupted column (no group dividers). + if si > 0 && !t.pro { + ui.add_space(4.0); + let (r, _) = ui.allocate_exact_size(vec2(if double { bx * 2.0 + 2.0 } else { bx }, 1.0), Sense::hover()); + ui.painter().line_segment([r.left_center() + vec2(8.0, 0.0), r.right_center() - vec2(8.0, 0.0)], Stroke::new(1.0, t.separator)); + ui.add_space(4.0); + } + let rows: Vec<&[&[Tool]]> = if double { section.chunks(2).collect() } else { section.chunks(1).collect() }; + for row in rows { + ui.horizontal(|ui| { + for slot in row.iter() { + let key = egui::Id::new(("tool-slot", slot_index)); + slot_index += 1; + let tool = slot_tool(ui, app.ui.tool, slot, key); + let sel = slot.contains(&app.ui.tool); + let tip = if tool.key() == '\0' { tool.label().to_string() } else { format!("{} ({})", tool.label(), tool.key()) }; + let resp = icons::button(ui, icons::tool_icon(tool), bx, sel, &tip); + if slot.len() > 1 { + let r = resp.rect; + let tri = vec![r.right_bottom() + vec2(-2.0, -2.0), r.right_bottom() + vec2(-6.0, -2.0), r.right_bottom() + vec2(-2.0, -6.0)]; + ui.painter().add(egui::Shape::convex_polygon(tri, t.text_faint, Stroke::NONE)); } + if resp.clicked() { + app.ui.tool = tool; + } + // Right-click or long-press opens the flyout (Photoshop). + let long_press = + resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.press_start_time().is_some_and(|t0| i.time - t0 > 0.35)); + if slot.len() > 1 && (resp.secondary_clicked() || long_press) { + ui.data_mut(|d| d.insert_temp(flyout_id, (key, resp.rect))); + } + if slot.len() > 1 && long_press { + ui.ctx().request_repaint(); + } + if let Some((open_key, anchor)) = ui.data(|d| d.get_temp::<(egui::Id, Rect)>(flyout_id)) + && open_key == key + { + let area = egui::Area::new(key.with("flyout")) + .order(egui::Order::Foreground) + .fixed_pos(anchor.right_top() + vec2(6.0, 0.0)) + .show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + let label_w = slot + .iter() + .map(|it| { + ui.painter().layout_no_wrap(it.label().to_string(), egui::FontId::proportional(12.5), t.text).size().x + }) + .fold(0.0f32, f32::max); + let fw = (label_w + 42.0 + 40.0).max(180.0); + for &item in slot.iter() { + let (r, ir) = ui.allocate_exact_size(vec2(fw, 26.0), Sense::click()); + if ir.hovered() { + ui.painter().rect_filled(r, 3.0, t.hover); + } + if item == tool { + ui.painter().rect_filled( + Rect::from_center_size(pos2(r.left() + 8.0, r.center().y), vec2(4.0, 4.0)), + 0.0, + t.text, + ); + } + icons::paint( + ui, + Rect::from_center_size(pos2(r.left() + 26.0, r.center().y), vec2(18.0, 18.0)), + icons::tool_icon(item), + 14.0, + t.icon, + ); + ui.painter().text( + pos2(r.left() + 42.0, r.center().y), + Align2::LEFT_CENTER, + item.label(), + egui::FontId::proportional(12.5), + t.text, + ); + if item.key() != '\0' { + ui.painter().text( + pos2(r.right() - 8.0, r.center().y), + Align2::RIGHT_CENTER, + item.key().to_string(), + egui::FontId::proportional(12.0), + t.text_dim, + ); + } + if ir.clicked() { + app.ui.tool = item; + ui.data_mut(|d| d.remove::<(egui::Id, Rect)>(flyout_id)); + } + } + }); + }); + let clicked_outside = ui.input(|i| i.pointer.any_click()) && !area.response.hovered() && !resp.hovered(); + if clicked_outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + ui.data_mut(|d| d.remove::<(egui::Id, Rect)>(flyout_id)); + } + } + } + }); + } + } + let ctx = ui.ctx().clone(); + if t.pro && icons::button(ui, "ellipsis", bx, false, "Edit Toolbar…").clicked() { + let _ = crate::menus::invoke(app, &ctx, "edit.toolbar", json!({})); + } + ui.add_space(if t.pro { 8.0 } else { 14.0 }); + color_chips(app, ui); + if t.pro { + ui.add_space(8.0); + let quick_mask = app.session.active().is_some_and(|s| s.doc.quick_mask.is_some()); + if icons::button(ui, "square-dashed", bx, quick_mask, if quick_mask { "Edit in Standard Mode (Q)" } else { "Edit in Quick Mask Mode (Q)" }) + .clicked() + { + let _ = crate::menus::invoke(app, &ctx, "select.editInQuickMaskMode", json!({})); + } + let sm = icons::button(ui, "app-window", bx, false, "Change Screen Mode (F)"); + if sm.clicked() { + let _ = crate::menus::invoke(app, &ctx, "view.screenMode.cycle", json!({})); + } + // Right-click (or long-press) lists the modes, like Photoshop's flyout. + egui::Popup::context_menu(&sm).show(|ui| { + ui.set_min_width(220.0); + for (id, label) in [ + ("view.screenMode.standard", "Standard Screen Mode"), + ("view.screenMode.fullScreenWithMenuBar", "Full Screen Mode With Menu Bar"), + ("view.screenMode.fullScreen", "Full Screen Mode"), + ] { + let on = crate::view_cmds::checked(app, id).unwrap_or(false); + if ui.add(egui::Button::selectable(on, label)).clicked() { + let _ = crate::menus::invoke(app, &ctx, id, json!({})); + ui.close(); } } }); } - } - let ctx = ui.ctx().clone(); - if t.pro && icons::button(ui, "ellipsis", bx, false, "Edit Toolbar…").clicked() { - let _ = crate::menus::invoke(app, &ctx, "edit.toolbar", json!({})); - } - ui.add_space(if t.pro { 8.0 } else { 14.0 }); - color_chips(app, ui); - if t.pro { - ui.add_space(8.0); - let quick_mask = app.session.active().is_some_and(|s| s.doc.quick_mask.is_some()); - if icons::button(ui, "square-dashed", bx, quick_mask, if quick_mask { "Edit in Standard Mode (Q)" } else { "Edit in Quick Mask Mode (Q)" }).clicked() { - let _ = crate::menus::invoke(app, &ctx, "select.editInQuickMaskMode", json!({})); - } - let sm = icons::button(ui, "app-window", bx, false, "Change Screen Mode (F)"); - if sm.clicked() { - let _ = crate::menus::invoke(app, &ctx, "view.screenMode.cycle", json!({})); - } - // Right-click (or long-press) lists the modes, like Photoshop's flyout. - egui::Popup::context_menu(&sm).show(|ui| { - ui.set_min_width(220.0); - for (id, label) in [("view.screenMode.standard", "Standard Screen Mode"), ("view.screenMode.fullScreenWithMenuBar", "Full Screen Mode With Menu Bar"), ("view.screenMode.fullScreen", "Full Screen Mode")] { - let on = crate::view_cmds::checked(app, id).unwrap_or(false); - if ui.add(egui::Button::selectable(on, label)).clicked() { - let _ = crate::menus::invoke(app, &ctx, id, json!({})); - ui.close(); - } - } - }); - } - }); + }, + ); } /// Does the toolbar need two columns? Height of one column (header, tool slots, Edit Toolbar, @@ -227,325 +278,362 @@ fn color_chips(app: &mut PhotocraftApp, ui: &mut egui::Ui) { pub fn title_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let left = if cfg!(target_os = "macos") && app.integrated_titlebar { 78 } else { 10 }; - egui::Panel::top("title_bar").exact_size(if t.pro { 32.0 } else { 38.0 }).frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin { left, right: 10, top: 0, bottom: 0 })).show(ui, |ui| { - let full = ui.max_rect(); - let drag = ui.interact(full, ui.id().with("titledrag"), Sense::click_and_drag()); - if drag.drag_started() { - ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); - } - if drag.double_clicked() { - let max = ui.ctx().input(|i| i.viewport().maximized.unwrap_or(false)); - ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max)); - } - let title = app.session.active().map(|d| format!("{}{}", d.doc.name, if d.is_dirty() { " •" } else { "" })).unwrap_or_else(|| "PhotoCraft".into()); - ui.painter().text(full.center(), Align2::CENTER_CENTER, title, theme::medium(13.0), t.text_dim); - ui.horizontal_centered(|ui| { - crate::menus::menu_bar(app, ui); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.spacing_mut().item_spacing.x = 6.0; - let mut ws = app.ui.workspace.clone(); - let opts = [("Essentials".to_string(), "Essentials"), ("Photography".to_string(), "Photography"), ("Painting".to_string(), "Painting"), ("Graphic and Web".to_string(), "Graphic and Web")]; - if widgets::dropdown(ui, "workspace", &mut ws, &opts, 130.0) { - app.ui.workspace = ws; - crate::menus::apply_workspace(app); - } - if icons::button(ui, "search", 28.0, app.ui.palette_open, "Search commands (⌘K)").clicked() { - app.ui.palette_open = !app.ui.palette_open; - } - let theme_icon = if t.dark() { "sun" } else { "moon" }; - if icons::button(ui, theme_icon, 28.0, false, "Switch theme").clicked() { - let next = app.ui.theme.next(); - app.set_theme(ui.ctx(), next); - } - // Always one click away: the community Discord. - let discord = egui::Button::image_and_text(icons::image("message-square", 14.0, t.text_dim), egui::RichText::new("Discord").color(t.text_dim).size(12.0)).frame(false); - if ui.add(discord).on_hover_text(format!("Join the ArtCraft Discord ({})", crate::links::DISCORD)).clicked() { - crate::links::open(app, ui.ctx(), crate::links::DISCORD); - } + egui::Panel::top("title_bar") + .exact_size(if t.pro { 32.0 } else { 38.0 }) + .frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin { left, right: 10, top: 0, bottom: 0 })) + .show(ui, |ui| { + let full = ui.max_rect(); + let drag = ui.interact(full, ui.id().with("titledrag"), Sense::click_and_drag()); + if drag.drag_started() { + ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); + } + if drag.double_clicked() { + let max = ui.ctx().input(|i| i.viewport().maximized.unwrap_or(false)); + ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max)); + } + let title = app.session.active().map(|d| format!("{}{}", d.doc.name, if d.is_dirty() { " •" } else { "" })).unwrap_or_else(|| "PhotoCraft".into()); + ui.painter().text(full.center(), Align2::CENTER_CENTER, title, theme::medium(13.0), t.text_dim); + ui.horizontal_centered(|ui| { + crate::menus::menu_bar(app, ui); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.spacing_mut().item_spacing.x = 6.0; + let mut ws = app.ui.workspace.clone(); + let opts = [ + ("Essentials".to_string(), "Essentials"), + ("Photography".to_string(), "Photography"), + ("Painting".to_string(), "Painting"), + ("Graphic and Web".to_string(), "Graphic and Web"), + ]; + if widgets::dropdown(ui, "workspace", &mut ws, &opts, 130.0) { + app.ui.workspace = ws; + crate::menus::apply_workspace(app); + } + if icons::button(ui, "search", 28.0, app.ui.palette_open, "Search commands (⌘K)").clicked() { + app.ui.palette_open = !app.ui.palette_open; + } + let theme_icon = if t.dark() { "sun" } else { "moon" }; + if icons::button(ui, theme_icon, 28.0, false, "Switch theme").clicked() { + let next = app.ui.theme.next(); + app.set_theme(ui.ctx(), next); + } + // Always one click away: the community Discord. + let discord = egui::Button::image_and_text( + icons::image("message-square", 14.0, t.text_dim), + egui::RichText::new("Discord").color(t.text_dim).size(12.0), + ) + .frame(false); + if ui.add(discord).on_hover_text(format!("Join the ArtCraft Discord ({})", crate::links::DISCORD)).clicked() { + crate::links::open(app, ui.ctx(), crate::links::DISCORD); + } + }); }); }); - }); } // ----------------------------------------------------------------------------- options bar pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - egui::Panel::top("options_bar").exact_size(if t.pro { 36.0 } else { 42.0 }).frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(10, 0))).show(ui, |ui| { - let r = ui.max_rect(); - ui.painter().line_segment([r.left_top(), r.right_top()], Stroke::new(1.0, t.separator)); - ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.separator)); - ui.horizontal_centered(|ui| { - if t.pro { - crate::chrome_ui::home_button(app, ui); + egui::Panel::top("options_bar") + .exact_size(if t.pro { 36.0 } else { 42.0 }) + .frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(10, 0))) + .show(ui, |ui| { + let r = ui.max_rect(); + ui.painter().line_segment([r.left_top(), r.right_top()], Stroke::new(1.0, t.separator)); + ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.separator)); + ui.horizontal_centered(|ui| { + if t.pro { + crate::chrome_ui::home_button(app, ui); + widgets::vline(ui, 22.0); + } + let _ = icons::button(ui, icons::tool_icon(app.ui.tool), if t.pro { 26.0 } else { 28.0 }, !t.pro, app.ui.tool.label()); + if t.pro { + let (r, _) = ui.allocate_exact_size(vec2(10.0, 20.0), Sense::hover()); + icons::paint(ui, r, "chevron-down", 10.0, t.text_faint); + } widgets::vline(ui, 22.0); - } - let _ = icons::button(ui, icons::tool_icon(app.ui.tool), if t.pro { 26.0 } else { 28.0 }, !t.pro, app.ui.tool.label()); - if t.pro { - let (r, _) = ui.allocate_exact_size(vec2(10.0, 20.0), Sense::hover()); - icons::paint(ui, r, "chevron-down", 10.0, t.text_faint); - } - widgets::vline(ui, 22.0); - if app.ui.transform.is_some() { - crate::transform_tool::options_bar(app, ui); - return; - } - if crate::distort_ui::options_bar(app, ui) { - return; - } - let tool = app.ui.tool; - if (tool.is_brushlike() && !matches!(tool, Tool::Brush | Tool::Eraser)) || tool == Tool::QuickSelection { - brush_preset_chip(ui, &mut app.session.tools.brush); - widgets::vline(ui, 22.0); - } - if crate::retouch_ui::options_bar(app, ui, tool) || crate::vector_ui::options_bar(app, ui, tool) || crate::analysis_ui::options_bar(app, ui, tool) || crate::slice_ui::options_bar(app, ui, tool) { - return; - } - let b = &mut app.session.tools.brush; - match app.ui.tool { - Tool::Brush | Tool::Eraser if t.pro => { - brush_preset_chip(ui, b); - widgets::vline(ui, 22.0); - opt_label(ui, "Mode"); - let mut mode = b.mode; - let opts: Vec<(BlendMode, &str)> = BlendMode::LAYER_MODES.iter().map(|m| (*m, m.label())).collect(); - if widgets::dropdown(ui, "brush-mode", &mut mode, &opts, 96.0) { - b.mode = mode; - } - opt_label(ui, "Opacity"); - let mut o = b.opacity * 100.0; - if widgets::value_field(ui, &mut o, 0.0..=100.0, "%", 62.0).changed() { - b.opacity = o / 100.0; - } - if icons::button(ui, "circle-dot", 24.0, b.pressure_opacity, "Always use pressure for opacity").clicked() { - b.pressure_opacity = !b.pressure_opacity; - } - opt_label(ui, "Flow"); - let mut f = b.flow * 100.0; - if widgets::value_field(ui, &mut f, 1.0..=100.0, "%", 62.0).changed() { - b.flow = f / 100.0; - } - let _ = icons::button(ui, "sparkles", 24.0, false, "Enable airbrush-style build-up effects"); - opt_label(ui, "Smoothing"); - let mut sm = 10.0f32; - widgets::value_field(ui, &mut sm, 0.0..=100.0, "%", 58.0); - let _ = icons::button(ui, "settings", 24.0, false, "Set additional smoothing options"); - widgets::vline(ui, 22.0); - if icons::button(ui, "circle-dot", 24.0, b.pressure_size, "Always use pressure for size").clicked() { - b.pressure_size = !b.pressure_size; - } - let _ = icons::button(ui, "arrow-left-right", 24.0, false, "Set painting symmetry options"); + if app.ui.transform.is_some() { + crate::transform_tool::options_bar(app, ui); + return; } - Tool::Brush | Tool::Eraser => { - opt_label(ui, "Size"); - widgets::value_field(ui, &mut b.size, 1.0..=2500.0, "px", 76.0); - widgets::vline(ui, 22.0); - opt_label(ui, "Hardness"); - let mut h = b.hardness * 100.0; - if widgets::value_field(ui, &mut h, 0.0..=100.0, "%", 66.0).changed() { - b.hardness = h / 100.0; - } - opt_label(ui, "Opacity"); - let mut o = b.opacity * 100.0; - if widgets::value_field(ui, &mut o, 0.0..=100.0, "%", 66.0).changed() { - b.opacity = o / 100.0; - } - opt_label(ui, "Flow"); - let mut f = b.flow * 100.0; - if widgets::value_field(ui, &mut f, 1.0..=100.0, "%", 66.0).changed() { - b.flow = f / 100.0; - } - widgets::vline(ui, 22.0); - widgets::toggle(ui, &mut b.pressure_size, "Pressure for Size"); - widgets::toggle(ui, &mut b.pressure_opacity, "Pressure for Opacity"); + if crate::distort_ui::options_bar(app, ui) { + return; } - Tool::RectMarquee | Tool::EllipseMarquee if t.pro => { - ui.spacing_mut().item_spacing.x = 2.0; - for (i, (icon, tip)) in [("square", "New selection"), ("plus", "Add to selection (⇧)"), ("minus", "Subtract from selection (⌥)"), ("squares-subtract", "Intersect with selection (⇧⌥)")].iter().enumerate() { - if icons::button(ui, icon, 24.0, app.ui.selection_mode == i as u8, tip).clicked() { - app.ui.selection_mode = i as u8; + let tool = app.ui.tool; + if (tool.is_brushlike() && !matches!(tool, Tool::Brush | Tool::Eraser)) || tool == Tool::QuickSelection { + brush_preset_chip(ui, &mut app.session.tools.brush); + widgets::vline(ui, 22.0); + } + if crate::retouch_ui::options_bar(app, ui, tool) + || crate::vector_ui::options_bar(app, ui, tool) + || crate::analysis_ui::options_bar(app, ui, tool) + || crate::slice_ui::options_bar(app, ui, tool) + { + return; + } + let b = &mut app.session.tools.brush; + match app.ui.tool { + Tool::Brush | Tool::Eraser if t.pro => { + brush_preset_chip(ui, b); + widgets::vline(ui, 22.0); + opt_label(ui, "Mode"); + let mut mode = b.mode; + let opts: Vec<(BlendMode, &str)> = BlendMode::LAYER_MODES.iter().map(|m| (*m, m.label())).collect(); + if widgets::dropdown(ui, "brush-mode", &mut mode, &opts, 96.0) { + b.mode = mode; } - } - ui.spacing_mut().item_spacing.x = 8.0; - widgets::vline(ui, 22.0); - let o = &mut app.ui.tool_options; - opt_label(ui, "Feather"); - widgets::value_field(ui, &mut o.feather, 0.0..=1000.0, "px", 62.0); - // Photoshop greys Anti-alias for the Rectangular Marquee (its edges are always hard). - ui.add_enabled_ui(app.ui.tool == Tool::EllipseMarquee, |ui| widgets::checkbox(ui, &mut o.anti_alias, "Anti-alias")); - widgets::vline(ui, 22.0); - opt_label(ui, "Style"); - let styles = [("normal".to_string(), "Normal"), ("fixedRatio".to_string(), "Fixed Ratio"), ("fixedSize".to_string(), "Fixed Size")]; - if widgets::dropdown(ui, "marquee-style", &mut o.marquee_style, &styles, 96.0) { - (o.marquee_width, o.marquee_height) = if o.marquee_style == "fixedSize" { (64.0, 64.0) } else { (1.0, 1.0) }; - } - let fixed = o.marquee_style != "normal"; - let (unit, range) = if o.marquee_style == "fixedSize" { ("px", 1.0..=300_000.0) } else { ("", 0.001..=999.0) }; - ui.add_enabled_ui(fixed, |ui| { - opt_label(ui, "Width"); - widgets::value_field(ui, &mut o.marquee_width, range.clone(), unit, 62.0); - if icons::button(ui, "arrow-left-right", 22.0, false, "Swaps height and width").clicked() { - std::mem::swap(&mut o.marquee_width, &mut o.marquee_height); + opt_label(ui, "Opacity"); + let mut o = b.opacity * 100.0; + if widgets::value_field(ui, &mut o, 0.0..=100.0, "%", 62.0).changed() { + b.opacity = o / 100.0; } - opt_label(ui, "Height"); - widgets::value_field(ui, &mut o.marquee_height, range, unit, 62.0); - }); - widgets::vline(ui, 22.0); - if widgets::secondary_button(ui, "Select and Mask…", 0.0).clicked() { - let _ = crate::menus::invoke(app, ui.ctx(), "select.selectAndMask", json!({})); - } - } - Tool::Lasso | Tool::PolygonLasso if t.pro => { - selection_mode_buttons(app, ui); - widgets::vline(ui, 22.0); - opt_label(ui, "Feather"); - widgets::value_field(ui, &mut app.ui.tool_options.feather, 0.0..=1000.0, "px", 62.0); - widgets::checkbox(ui, &mut app.ui.tool_options.anti_alias, "Anti-alias"); - widgets::vline(ui, 22.0); - if widgets::secondary_button(ui, "Select and Mask…", 0.0).clicked() { - let _ = crate::menus::invoke(app, ui.ctx(), "select.selectAndMask", json!({})); - } - if app.ui.tool == Tool::PolygonLasso && !app.ui.polygon.is_empty() { - hint(ui, "Click the first point or press ↵ to close · Esc cancels"); - } - } - Tool::MagicWand if t.pro => { - selection_mode_buttons(app, ui); - widgets::vline(ui, 22.0); - opt_label(ui, "Tolerance"); - widgets::value_field(ui, &mut app.ui.tool_options.tolerance, 0.0..=255.0, "", 58.0); - widgets::checkbox(ui, &mut app.ui.tool_options.anti_alias, "Anti-alias"); - widgets::checkbox(ui, &mut app.ui.tool_options.contiguous, "Contiguous"); - widgets::checkbox(ui, &mut app.ui.tool_options.sample_all_layers, "Sample All Layers"); - widgets::vline(ui, 22.0); - if widgets::secondary_button(ui, "Select Subject", 0.0).clicked() { - let _ = app.run("select.subject", json!({})); - } - if widgets::secondary_button(ui, "Select and Mask…", 0.0).clicked() { - let _ = crate::menus::invoke(app, ui.ctx(), "select.selectAndMask", json!({})); - } - } - Tool::PaintBucket if t.pro => { - opt_label(ui, "Fill"); - let mut src = u8::from(app.ui.tool_options.bucket_fill_pattern); - if widgets::dropdown(ui, "bucket-src", &mut src, &[(0u8, "Foreground"), (1, "Pattern")], 100.0) { - app.ui.tool_options.bucket_fill_pattern = src == 1; - } - opt_label(ui, "Opacity"); - widgets::value_field(ui, &mut app.ui.tool_options.fill_opacity, 1.0..=100.0, "%", 62.0); - opt_label(ui, "Tolerance"); - widgets::value_field(ui, &mut app.ui.tool_options.tolerance, 0.0..=255.0, "", 58.0); - widgets::checkbox(ui, &mut app.ui.tool_options.anti_alias, "Anti-alias"); - widgets::checkbox(ui, &mut app.ui.tool_options.contiguous, "Contiguous"); - widgets::checkbox(ui, &mut app.ui.tool_options.sample_all_layers, "All Layers"); - } - Tool::Gradient if t.pro => { - gradient_swatch(ui, app.session.tools.foreground, app.session.tools.background); - widgets::vline(ui, 22.0); - ui.spacing_mut().item_spacing.x = 2.0; - for (style, icon, tip) in [("linear", "blend", "Linear Gradient"), ("radial", "circle", "Radial Gradient"), ("angle", "rotate-cw", "Angle Gradient"), ("reflected", "arrow-left-right", "Reflected Gradient"), ("diamond", "diamond", "Diamond Gradient")] { - if icons::button(ui, icon, 24.0, app.ui.tool_options.gradient_style == style, tip).clicked() { - app.ui.tool_options.gradient_style = style.into(); + if icons::button(ui, "circle-dot", 24.0, b.pressure_opacity, "Always use pressure for opacity").clicked() { + b.pressure_opacity = !b.pressure_opacity; } - } - ui.spacing_mut().item_spacing.x = 8.0; - widgets::vline(ui, 22.0); - opt_label(ui, "Opacity"); - widgets::value_field(ui, &mut app.ui.tool_options.fill_opacity, 1.0..=100.0, "%", 62.0); - widgets::checkbox(ui, &mut app.ui.tool_options.gradient_reverse, "Reverse"); - widgets::checkbox(ui, &mut app.ui.tool_options.gradient_dither, "Dither"); - } - Tool::Crop if t.pro => { - let o = &mut app.ui.tool_options; - let ratios: Vec<(String, &str)> = crate::chrome_ui::CROP_RATIOS.iter().map(|(k, l)| (k.to_string(), *l)).collect(); - widgets::dropdown(ui, "crop-ratio", &mut o.crop_ratio, &ratios, 120.0); - // Custom ratio fields (Photoshop shows the preset's numbers here). - let (mut rw, mut rh) = crate::chrome_ui::crop_ratio(&o.crop_ratio, 0.0, 0.0).map_or((0.0, 0.0), |(w, h)| (w as f32, h as f32)); - let cw = widgets::value_field(ui, &mut rw, 0.0..=99_999.0, "", 54.0).changed(); - let swap = icons::button(ui, "arrow-left-right", 22.0, false, "Swaps height and width").clicked(); - let ch = widgets::value_field(ui, &mut rh, 0.0..=99_999.0, "", 54.0).changed(); - if swap { - std::mem::swap(&mut rw, &mut rh); - } - if (cw || ch || swap) && rw > 0.0 && rh > 0.0 { - o.crop_ratio = format!("{}:{}", widgets::fmt_num(rw as f64), widgets::fmt_num(rh as f64)); - } - widgets::vline(ui, 22.0); - if widgets::secondary_button(ui, "Clear", 0.0).clicked() { - o.crop_ratio.clear(); - } - let _ = icons::button(ui, "grid-3x3", 24.0, true, "Overlay: Rule of Thirds"); - widgets::checkbox(ui, &mut o.crop_delete, "Delete Cropped Pixels"); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if icons::button(ui, "check", 26.0, false, "Commit current crop operation (↵)").clicked() { - crate::canvas::commit_crop(app); + opt_label(ui, "Flow"); + let mut f = b.flow * 100.0; + if widgets::value_field(ui, &mut f, 1.0..=100.0, "%", 62.0).changed() { + b.flow = f / 100.0; } - if icons::button(ui, "ban", 26.0, false, "Cancel current crop operation (Esc)").clicked() { - app.ui.crop_rect = None; - } - }); - } - Tool::Type if t.pro => crate::type_tool::options_bar(app, ui), - Tool::Move if t.pro => { - let o = &mut app.ui.tool_options; - widgets::checkbox(ui, &mut o.move_auto_select, "Auto-Select:"); - widgets::dropdown(ui, "move-target", &mut o.move_target, &[("layer".to_string(), "Layer"), ("group".to_string(), "Group")], 76.0); - widgets::vline(ui, 22.0); - widgets::checkbox(ui, &mut o.move_show_transform, "Show Transform Controls"); - widgets::vline(ui, 22.0); - ui.spacing_mut().item_spacing.x = 0.0; - for (icon, tip, cmd) in [("panels-top-left", "Align left edges", "layer.align.leftEdges"), ("app-window", "Align horizontal centers", "layer.align.horizontalCenters"), ("panel-right", "Align right edges", "layer.align.rightEdges")] { - let on = app.session.is_enabled(cmd); - if ui.add_enabled_ui(on, |ui| icons::button(ui, icon, 24.0, false, tip)).inner.clicked() { - let _ = app.run(cmd, json!({})); + let _ = icons::button(ui, "sparkles", 24.0, false, "Enable airbrush-style build-up effects"); + opt_label(ui, "Smoothing"); + let mut sm = 10.0f32; + widgets::value_field(ui, &mut sm, 0.0..=100.0, "%", 58.0); + let _ = icons::button(ui, "settings", 24.0, false, "Set additional smoothing options"); + widgets::vline(ui, 22.0); + if icons::button(ui, "circle-dot", 24.0, b.pressure_size, "Always use pressure for size").clicked() { + b.pressure_size = !b.pressure_size; } + let _ = icons::button(ui, "arrow-left-right", 24.0, false, "Set painting symmetry options"); } - ui.add_space(6.0); - let more = icons::button(ui, "ellipsis", 24.0, false, "Align and distribute"); - egui::Popup::menu(&more).show(|ui| { - ui.set_min_width(200.0); - let mut section = |ui: &mut egui::Ui, title: &str, prefix: &str| { - ui.label(egui::RichText::new(title).small().color(t.text_dim)); - for c in photocraft_engine::command_specs().iter().filter(|c| c.id.starts_with(prefix)) { - if ui.add_enabled(app.session.is_enabled(c.id), egui::Button::new(c.label)).clicked() { - let _ = app.run(c.id, json!({})); - ui.close(); - } + Tool::Brush | Tool::Eraser => { + opt_label(ui, "Size"); + widgets::value_field(ui, &mut b.size, 1.0..=2500.0, "px", 76.0); + widgets::vline(ui, 22.0); + opt_label(ui, "Hardness"); + let mut h = b.hardness * 100.0; + if widgets::value_field(ui, &mut h, 0.0..=100.0, "%", 66.0).changed() { + b.hardness = h / 100.0; + } + opt_label(ui, "Opacity"); + let mut o = b.opacity * 100.0; + if widgets::value_field(ui, &mut o, 0.0..=100.0, "%", 66.0).changed() { + b.opacity = o / 100.0; + } + opt_label(ui, "Flow"); + let mut f = b.flow * 100.0; + if widgets::value_field(ui, &mut f, 1.0..=100.0, "%", 66.0).changed() { + b.flow = f / 100.0; + } + widgets::vline(ui, 22.0); + widgets::toggle(ui, &mut b.pressure_size, "Pressure for Size"); + widgets::toggle(ui, &mut b.pressure_opacity, "Pressure for Opacity"); + } + Tool::RectMarquee | Tool::EllipseMarquee if t.pro => { + ui.spacing_mut().item_spacing.x = 2.0; + for (i, (icon, tip)) in [ + ("square", "New selection"), + ("plus", "Add to selection (⇧)"), + ("minus", "Subtract from selection (⌥)"), + ("squares-subtract", "Intersect with selection (⇧⌥)"), + ] + .iter() + .enumerate() + { + if icons::button(ui, icon, 24.0, app.ui.selection_mode == i as u8, tip).clicked() { + app.ui.selection_mode = i as u8; } - }; - section(ui, "Align", "layer.align."); - ui.separator(); - section(ui, "Distribute", "layer.distribute."); - }); - } - Tool::RectMarquee | Tool::EllipseMarquee => hint(ui, "Drag to select · ⇧ add · ⌥ subtract · ⇧⌥ intersect · click to deselect"), - Tool::Move => hint(ui, "Drag to move the active layer"), - Tool::Eyedropper => hint(ui, "Click to sample the foreground colour · ⌥-click for background"), - Tool::Zoom => { - hint(ui, "Click to zoom in · ⌥-click to zoom out"); - if widgets::secondary_button(ui, "Fit Screen", 0.0).clicked() - && let Some(i) = app.session.active_index() - { - app.ui.views[i].fit_pending = true; + } + ui.spacing_mut().item_spacing.x = 8.0; + widgets::vline(ui, 22.0); + let o = &mut app.ui.tool_options; + opt_label(ui, "Feather"); + widgets::value_field(ui, &mut o.feather, 0.0..=1000.0, "px", 62.0); + // Photoshop greys Anti-alias for the Rectangular Marquee (its edges are always hard). + ui.add_enabled_ui(app.ui.tool == Tool::EllipseMarquee, |ui| widgets::checkbox(ui, &mut o.anti_alias, "Anti-alias")); + widgets::vline(ui, 22.0); + opt_label(ui, "Style"); + let styles = [("normal".to_string(), "Normal"), ("fixedRatio".to_string(), "Fixed Ratio"), ("fixedSize".to_string(), "Fixed Size")]; + if widgets::dropdown(ui, "marquee-style", &mut o.marquee_style, &styles, 96.0) { + (o.marquee_width, o.marquee_height) = if o.marquee_style == "fixedSize" { (64.0, 64.0) } else { (1.0, 1.0) }; + } + let fixed = o.marquee_style != "normal"; + let (unit, range) = if o.marquee_style == "fixedSize" { ("px", 1.0..=300_000.0) } else { ("", 0.001..=999.0) }; + ui.add_enabled_ui(fixed, |ui| { + opt_label(ui, "Width"); + widgets::value_field(ui, &mut o.marquee_width, range.clone(), unit, 62.0); + if icons::button(ui, "arrow-left-right", 22.0, false, "Swaps height and width").clicked() { + std::mem::swap(&mut o.marquee_width, &mut o.marquee_height); + } + opt_label(ui, "Height"); + widgets::value_field(ui, &mut o.marquee_height, range, unit, 62.0); + }); + widgets::vline(ui, 22.0); + if widgets::secondary_button(ui, "Select and Mask…", 0.0).clicked() { + let _ = crate::menus::invoke(app, ui.ctx(), "select.selectAndMask", json!({})); + } } - if widgets::secondary_button(ui, "100%", 0.0).clicked() - && let Some(i) = app.session.active_index() - { - app.ui.views[i].zoom = 1.0; + Tool::Lasso | Tool::PolygonLasso if t.pro => { + selection_mode_buttons(app, ui); + widgets::vline(ui, 22.0); + opt_label(ui, "Feather"); + widgets::value_field(ui, &mut app.ui.tool_options.feather, 0.0..=1000.0, "px", 62.0); + widgets::checkbox(ui, &mut app.ui.tool_options.anti_alias, "Anti-alias"); + widgets::vline(ui, 22.0); + if widgets::secondary_button(ui, "Select and Mask…", 0.0).clicked() { + let _ = crate::menus::invoke(app, ui.ctx(), "select.selectAndMask", json!({})); + } + if app.ui.tool == Tool::PolygonLasso && !app.ui.polygon.is_empty() { + hint(ui, "Click the first point or press ↵ to close · Esc cancels"); + } } + Tool::MagicWand if t.pro => { + selection_mode_buttons(app, ui); + widgets::vline(ui, 22.0); + opt_label(ui, "Tolerance"); + widgets::value_field(ui, &mut app.ui.tool_options.tolerance, 0.0..=255.0, "", 58.0); + widgets::checkbox(ui, &mut app.ui.tool_options.anti_alias, "Anti-alias"); + widgets::checkbox(ui, &mut app.ui.tool_options.contiguous, "Contiguous"); + widgets::checkbox(ui, &mut app.ui.tool_options.sample_all_layers, "Sample All Layers"); + widgets::vline(ui, 22.0); + if widgets::secondary_button(ui, "Select Subject", 0.0).clicked() { + let _ = app.run("select.subject", json!({})); + } + if widgets::secondary_button(ui, "Select and Mask…", 0.0).clicked() { + let _ = crate::menus::invoke(app, ui.ctx(), "select.selectAndMask", json!({})); + } + } + Tool::PaintBucket if t.pro => { + opt_label(ui, "Fill"); + let mut src = u8::from(app.ui.tool_options.bucket_fill_pattern); + if widgets::dropdown(ui, "bucket-src", &mut src, &[(0u8, "Foreground"), (1, "Pattern")], 100.0) { + app.ui.tool_options.bucket_fill_pattern = src == 1; + } + opt_label(ui, "Opacity"); + widgets::value_field(ui, &mut app.ui.tool_options.fill_opacity, 1.0..=100.0, "%", 62.0); + opt_label(ui, "Tolerance"); + widgets::value_field(ui, &mut app.ui.tool_options.tolerance, 0.0..=255.0, "", 58.0); + widgets::checkbox(ui, &mut app.ui.tool_options.anti_alias, "Anti-alias"); + widgets::checkbox(ui, &mut app.ui.tool_options.contiguous, "Contiguous"); + widgets::checkbox(ui, &mut app.ui.tool_options.sample_all_layers, "All Layers"); + } + Tool::Gradient if t.pro => { + gradient_swatch(ui, app.session.tools.foreground, app.session.tools.background); + widgets::vline(ui, 22.0); + ui.spacing_mut().item_spacing.x = 2.0; + for (style, icon, tip) in [ + ("linear", "blend", "Linear Gradient"), + ("radial", "circle", "Radial Gradient"), + ("angle", "rotate-cw", "Angle Gradient"), + ("reflected", "arrow-left-right", "Reflected Gradient"), + ("diamond", "diamond", "Diamond Gradient"), + ] { + if icons::button(ui, icon, 24.0, app.ui.tool_options.gradient_style == style, tip).clicked() { + app.ui.tool_options.gradient_style = style.into(); + } + } + ui.spacing_mut().item_spacing.x = 8.0; + widgets::vline(ui, 22.0); + opt_label(ui, "Opacity"); + widgets::value_field(ui, &mut app.ui.tool_options.fill_opacity, 1.0..=100.0, "%", 62.0); + widgets::checkbox(ui, &mut app.ui.tool_options.gradient_reverse, "Reverse"); + widgets::checkbox(ui, &mut app.ui.tool_options.gradient_dither, "Dither"); + } + Tool::Crop if t.pro => { + let o = &mut app.ui.tool_options; + let ratios: Vec<(String, &str)> = crate::chrome_ui::CROP_RATIOS.iter().map(|(k, l)| (k.to_string(), *l)).collect(); + widgets::dropdown(ui, "crop-ratio", &mut o.crop_ratio, &ratios, 120.0); + // Custom ratio fields (Photoshop shows the preset's numbers here). + let (mut rw, mut rh) = crate::chrome_ui::crop_ratio(&o.crop_ratio, 0.0, 0.0).map_or((0.0, 0.0), |(w, h)| (w as f32, h as f32)); + let cw = widgets::value_field(ui, &mut rw, 0.0..=99_999.0, "", 54.0).changed(); + let swap = icons::button(ui, "arrow-left-right", 22.0, false, "Swaps height and width").clicked(); + let ch = widgets::value_field(ui, &mut rh, 0.0..=99_999.0, "", 54.0).changed(); + if swap { + std::mem::swap(&mut rw, &mut rh); + } + if (cw || ch || swap) && rw > 0.0 && rh > 0.0 { + o.crop_ratio = format!("{}:{}", widgets::fmt_num(rw as f64), widgets::fmt_num(rh as f64)); + } + widgets::vline(ui, 22.0); + if widgets::secondary_button(ui, "Clear", 0.0).clicked() { + o.crop_ratio.clear(); + } + let _ = icons::button(ui, "grid-3x3", 24.0, true, "Overlay: Rule of Thirds"); + widgets::checkbox(ui, &mut o.crop_delete, "Delete Cropped Pixels"); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if icons::button(ui, "check", 26.0, false, "Commit current crop operation (↵)").clicked() { + crate::canvas::commit_crop(app); + } + if icons::button(ui, "ban", 26.0, false, "Cancel current crop operation (Esc)").clicked() { + app.ui.crop_rect = None; + } + }); + } + Tool::Type if t.pro => crate::type_tool::options_bar(app, ui), + Tool::Move if t.pro => { + let o = &mut app.ui.tool_options; + widgets::checkbox(ui, &mut o.move_auto_select, "Auto-Select:"); + widgets::dropdown(ui, "move-target", &mut o.move_target, &[("layer".to_string(), "Layer"), ("group".to_string(), "Group")], 76.0); + widgets::vline(ui, 22.0); + widgets::checkbox(ui, &mut o.move_show_transform, "Show Transform Controls"); + widgets::vline(ui, 22.0); + ui.spacing_mut().item_spacing.x = 0.0; + for (icon, tip, cmd) in [ + ("panels-top-left", "Align left edges", "layer.align.leftEdges"), + ("app-window", "Align horizontal centers", "layer.align.horizontalCenters"), + ("panel-right", "Align right edges", "layer.align.rightEdges"), + ] { + let on = app.session.is_enabled(cmd); + if ui.add_enabled_ui(on, |ui| icons::button(ui, icon, 24.0, false, tip)).inner.clicked() { + let _ = app.run(cmd, json!({})); + } + } + ui.add_space(6.0); + let more = icons::button(ui, "ellipsis", 24.0, false, "Align and distribute"); + egui::Popup::menu(&more).show(|ui| { + ui.set_min_width(200.0); + let mut section = |ui: &mut egui::Ui, title: &str, prefix: &str| { + ui.label(egui::RichText::new(title).small().color(t.text_dim)); + for c in photocraft_engine::command_specs().iter().filter(|c| c.id.starts_with(prefix)) { + if ui.add_enabled(app.session.is_enabled(c.id), egui::Button::new(c.label)).clicked() { + let _ = app.run(c.id, json!({})); + ui.close(); + } + } + }; + section(ui, "Align", "layer.align."); + ui.separator(); + section(ui, "Distribute", "layer.distribute."); + }); + } + Tool::RectMarquee | Tool::EllipseMarquee => hint(ui, "Drag to select · ⇧ add · ⌥ subtract · ⇧⌥ intersect · click to deselect"), + Tool::Move => hint(ui, "Drag to move the active layer"), + Tool::Eyedropper => hint(ui, "Click to sample the foreground colour · ⌥-click for background"), + Tool::Zoom => { + hint(ui, "Click to zoom in · ⌥-click to zoom out"); + if widgets::secondary_button(ui, "Fit Screen", 0.0).clicked() + && let Some(i) = app.session.active_index() + { + app.ui.views[i].fit_pending = true; + } + if widgets::secondary_button(ui, "100%", 0.0).clicked() + && let Some(i) = app.session.active_index() + { + app.ui.views[i].zoom = 1.0; + } + } + Tool::Hand => hint(ui, "Drag to pan · hold Space with any tool"), + Tool::Lasso | Tool::PolygonLasso => hint(ui, "Drag (lasso) or click points (polygonal) · ⇧ add · ⌥ subtract"), + Tool::MagicWand => hint(ui, "Click to select similar colours"), + Tool::Crop => hint(ui, "Drag a crop box · ↵ commits · Esc cancels"), + Tool::Gradient => hint(ui, "Drag to draw a gradient"), + Tool::PaintBucket => hint(ui, "Click to fill similar colours"), + Tool::Type => hint(ui, "Click to add text"), + // Retouching and smart-selection tools draw their bar in `retouch_ui::options_bar`. + _ => {} } - Tool::Hand => hint(ui, "Drag to pan · hold Space with any tool"), - Tool::Lasso | Tool::PolygonLasso => hint(ui, "Drag (lasso) or click points (polygonal) · ⇧ add · ⌥ subtract"), - Tool::MagicWand => hint(ui, "Click to select similar colours"), - Tool::Crop => hint(ui, "Drag a crop box · ↵ commits · Esc cancels"), - Tool::Gradient => hint(ui, "Drag to draw a gradient"), - Tool::PaintBucket => hint(ui, "Click to fill similar colours"), - Tool::Type => hint(ui, "Click to add text"), - // Retouching and smart-selection tools draw their bar in `retouch_ui::options_bar`. - _ => {} - } + }); }); - }); } fn label(ui: &mut egui::Ui, s: &str) { @@ -569,46 +657,50 @@ fn hint(ui: &mut egui::Ui, s: &str) { pub fn status_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - egui::Panel::bottom("status_bar").exact_size(if t.pro { 24.0 } else { 30.0 }).frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(10, 0))).show(ui, |ui| { - let r = ui.max_rect(); - ui.painter().line_segment([r.left_top(), r.right_top()], Stroke::new(1.0, t.separator)); - ui.horizontal_centered(|ui| { - if t.pro { - crate::chrome_ui::status_bar_pro(app, ui); - return; - } - if let (Some(st), Some(i)) = (app.session.active(), app.session.active_index()) { - let (w, h, mode, bits, layers) = (st.doc.size.width, st.doc.size.height, crate::canvas::mode_label(&st.doc), st.doc.depth.bits(), st.doc.layer_count()); - let mut pct = app.ui.views[i].zoom * 100.0; - if widgets::value_field(ui, &mut pct, 1.0..=3200.0, "%", 78.0).changed() { - app.ui.views[i].zoom = pct / 100.0; - app.ui.views[i].fit_pending = false; + egui::Panel::bottom("status_bar") + .exact_size(if t.pro { 24.0 } else { 30.0 }) + .frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(10, 0))) + .show(ui, |ui| { + let r = ui.max_rect(); + ui.painter().line_segment([r.left_top(), r.right_top()], Stroke::new(1.0, t.separator)); + ui.horizontal_centered(|ui| { + if t.pro { + crate::chrome_ui::status_bar_pro(app, ui); + return; } - widgets::vline(ui, 16.0); - label(ui, &format!("{mode} Color · {bits} bit")); - widgets::vline(ui, 16.0); - label(ui, &format!("{w} × {h} px")); - widgets::vline(ui, 16.0); - label(ui, &format!("{layers} layers")); - } else { - label(ui, "No document"); - } - if !app.ui.status.is_empty() { - widgets::vline(ui, 16.0); - let is_err = app.ui.status_error || app.ui.status.starts_with("Couldn"); - ui.label(RichText::new(&app.ui.status).color(if is_err { t.warning } else { t.text_faint })); - } - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if app.session.active().is_some() - && !t.pro - && widgets::secondary_button(ui, "Fit", 0.0).clicked() - && let Some(i) = app.session.active_index() - { - app.ui.views[i].fit_pending = true; + if let (Some(st), Some(i)) = (app.session.active(), app.session.active_index()) { + let (w, h, mode, bits, layers) = + (st.doc.size.width, st.doc.size.height, crate::canvas::mode_label(&st.doc), st.doc.depth.bits(), st.doc.layer_count()); + let mut pct = app.ui.views[i].zoom * 100.0; + if widgets::value_field(ui, &mut pct, 1.0..=3200.0, "%", 78.0).changed() { + app.ui.views[i].zoom = pct / 100.0; + app.ui.views[i].fit_pending = false; + } + widgets::vline(ui, 16.0); + label(ui, &format!("{mode} Color · {bits} bit")); + widgets::vline(ui, 16.0); + label(ui, &format!("{w} × {h} px")); + widgets::vline(ui, 16.0); + label(ui, &format!("{layers} layers")); + } else { + label(ui, "No document"); } + if !app.ui.status.is_empty() { + widgets::vline(ui, 16.0); + let is_err = app.ui.status_error || app.ui.status.starts_with("Couldn"); + ui.label(RichText::new(&app.ui.status).color(if is_err { t.warning } else { t.text_faint })); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if app.session.active().is_some() + && !t.pro + && widgets::secondary_button(ui, "Fit", 0.0).clicked() + && let Some(i) = app.session.active_index() + { + app.ui.views[i].fit_pending = true; + } + }); }); }); - }); } // ----------------------------------------------------------------------------- dock @@ -621,30 +713,33 @@ pub fn right_dock(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } // Narrow icon rail (always visible) that toggles panels. let (rw, rb) = if t.pro { (36.0, 28.0) } else { (44.0, 32.0) }; - egui::Panel::right("rail").resizable(false).exact_size(rw).frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(4, 8))).show(ui, |ui| { - let r = ui.max_rect(); - ui.painter().line_segment([r.left_top() - vec2(6.0, 8.0), r.left_bottom() + vec2(-6.0, 8.0)], Stroke::new(1.0, t.separator)); - ui.spacing_mut().item_spacing.y = 4.0; - let entries: [(&str, &str, bool); 5] = [ - ("sliders-horizontal", "Properties", p.properties), - ("navigation", "Navigator", p.navigator), - ("palette", "Color & Swatches", p.color), - ("layers", "Layers", p.layers), - ("clock", "History", p.history), - ]; - for (icon, name, on) in entries { - if icons::rail_button(ui, icon, rb, on, name).clicked() { - let panels = &mut app.ui.panels; - match name { - "Properties" => panels.properties = !panels.properties, - "Navigator" => panels.navigator = !panels.navigator, - "Color & Swatches" => panels.color = !panels.color, - "Layers" => panels.layers = !panels.layers, - _ => panels.history = !panels.history, + egui::Panel::right("rail").resizable(false).exact_size(rw).frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(4, 8))).show( + ui, + |ui| { + let r = ui.max_rect(); + ui.painter().line_segment([r.left_top() - vec2(6.0, 8.0), r.left_bottom() + vec2(-6.0, 8.0)], Stroke::new(1.0, t.separator)); + ui.spacing_mut().item_spacing.y = 4.0; + let entries: [(&str, &str, bool); 5] = [ + ("sliders-horizontal", "Properties", p.properties), + ("navigation", "Navigator", p.navigator), + ("palette", "Color & Swatches", p.color), + ("layers", "Layers", p.layers), + ("clock", "History", p.history), + ]; + for (icon, name, on) in entries { + if icons::rail_button(ui, icon, rb, on, name).clicked() { + let panels = &mut app.ui.panels; + match name { + "Properties" => panels.properties = !panels.properties, + "Navigator" => panels.navigator = !panels.navigator, + "Color & Swatches" => panels.color = !panels.color, + "Layers" => panels.layers = !panels.layers, + _ => panels.history = !panels.history, + } } } - } - }); + }, + ); if !t.pro { dock_panels(app, ui, &p, &t); } @@ -656,70 +751,79 @@ fn dock_panels(app: &mut PhotocraftApp, ui: &mut egui::Ui, p: &crate::state::Pan return; } let margin = if t.pro { 2 } else { 8 }; - egui::Panel::right("dock").resizable(true).default_size(if t.pro { 290.0 } else { 300.0 }).size_range(250.0..=520.0).frame(egui::Frame::NONE.fill(t.dock).inner_margin(egui::Margin::same(margin))).show(ui, |ui| { - let draw = |app: &mut PhotocraftApp, ui: &mut egui::Ui| { - ui.spacing_mut().item_spacing.y = if t.pro { 0.0 } else { 6.0 }; - // Photoshop Essentials order: Color, Properties, (Navigator, History), Layers last and filling. - if p.color { - let mut sel = app.ui.dock_tabs.color; - // Photoshop Essentials: Color | Swatches | Gradients | Patterns. - let tabs: &[&str] = if t.pro { &["Color", "Swatches", "Gradients", "Patterns"] } else { &["Swatches", "Color", "Gradients", "Patterns"] }; - let pro = t.pro; - widgets::card(ui, "color", tabs, &mut sel, |ui, tab| match (pro, tab) { - (_, 2) => crate::preset_panels::gradients_panel(app, ui), - (_, 3) => crate::preset_panels::patterns_panel(app, ui), - (true, 0) => color_field(app, ui), - (true, _) => swatches(app, ui), - (false, 0) => swatches(app, ui), - (false, _) => color_picker(app, ui), - }); - app.ui.dock_tabs.color = sel; + egui::Panel::right("dock") + .resizable(true) + .default_size(if t.pro { 290.0 } else { 300.0 }) + .size_range(250.0..=520.0) + .frame(egui::Frame::NONE.fill(t.dock).inner_margin(egui::Margin::same(margin))) + .show(ui, |ui| { + let draw = |app: &mut PhotocraftApp, ui: &mut egui::Ui| { + ui.spacing_mut().item_spacing.y = if t.pro { 0.0 } else { 6.0 }; + // Photoshop Essentials order: Color, Properties, (Navigator, History), Layers last and filling. + if p.color { + let mut sel = app.ui.dock_tabs.color; + // Photoshop Essentials: Color | Swatches | Gradients | Patterns. + let tabs: &[&str] = if t.pro { &["Color", "Swatches", "Gradients", "Patterns"] } else { &["Swatches", "Color", "Gradients", "Patterns"] }; + let pro = t.pro; + widgets::card(ui, "color", tabs, &mut sel, |ui, tab| match (pro, tab) { + (_, 2) => crate::preset_panels::gradients_panel(app, ui), + (_, 3) => crate::preset_panels::patterns_panel(app, ui), + (true, 0) => color_field(app, ui), + (true, _) => swatches(app, ui), + (false, 0) => swatches(app, ui), + (false, _) => color_picker(app, ui), + }); + app.ui.dock_tabs.color = sel; + } + if t.pro && p.properties { + let mut sel = app.ui.dock_tabs.properties; + // Leave Layers at least ~240 px; Properties scrolls within what remains. + let reserve = if p.layers { 240.0 } else { 40.0 }; + let max_h = (ui.available_height() - reserve - 40.0).max(70.0); + widgets::card(ui, "properties", &["Properties", "Adjustments"], &mut sel, |ui, tab| { + egui::ScrollArea::vertical() + .id_salt("props-scroll") + .max_height(max_h) + .auto_shrink([false, true]) + .show(ui, |ui| if tab == 0 { properties_body(app, ui) } else { adjustments_grid(app, ui) }); + }); + app.ui.dock_tabs.properties = sel; + } + if p.navigator { + let mut sel = app.ui.dock_tabs.navigator; + widgets::card(ui, "navigator", &["Navigator", "Histogram", "Info"], &mut sel, |ui, tab| match tab { + 0 => navigator(app, ui), + 1 => crate::tone::histogram_panel(app, ui), + _ => info_panel(app, ui), + }); + app.ui.dock_tabs.navigator = sel; + } + if p.history { + let mut sel = app.ui.dock_tabs.history; + widgets::card(ui, "history", &["History", "Actions", "Layer Comps"], &mut sel, |ui, tab| match tab { + 0 => history(app, ui), + 1 => crate::actions::panel(app, ui), + _ => crate::comps_ui::panel(app, ui), + }); + app.ui.dock_tabs.history = sel; + } + if p.layers { + let mut sel = app.ui.dock_tabs.layers; + widgets::card(ui, "layers", &["Layers", "Channels", "Paths"], &mut sel, |ui, tab| match tab { + 0 => layers(app, ui), + 1 => channels(app, ui), + _ => crate::vector_ui::paths_panel(app, ui), + }); + app.ui.dock_tabs.layers = sel; + } + }; + if t.pro { + // Fixed panels on top, Layers takes the rest (it scrolls internally). + draw(app, ui); + } else { + egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| draw(app, ui)); } - if t.pro && p.properties { - let mut sel = app.ui.dock_tabs.properties; - // Leave Layers at least ~240 px; Properties scrolls within what remains. - let reserve = if p.layers { 240.0 } else { 40.0 }; - let max_h = (ui.available_height() - reserve - 40.0).max(70.0); - widgets::card(ui, "properties", &["Properties", "Adjustments"], &mut sel, |ui, tab| { - egui::ScrollArea::vertical().id_salt("props-scroll").max_height(max_h).auto_shrink([false, true]).show(ui, |ui| if tab == 0 { properties_body(app, ui) } else { adjustments_grid(app, ui) }); - }); - app.ui.dock_tabs.properties = sel; - } - if p.navigator { - let mut sel = app.ui.dock_tabs.navigator; - widgets::card(ui, "navigator", &["Navigator", "Histogram", "Info"], &mut sel, |ui, tab| match tab { - 0 => navigator(app, ui), - 1 => crate::tone::histogram_panel(app, ui), - _ => info_panel(app, ui), - }); - app.ui.dock_tabs.navigator = sel; - } - if p.history { - let mut sel = app.ui.dock_tabs.history; - widgets::card(ui, "history", &["History", "Actions", "Layer Comps"], &mut sel, |ui, tab| match tab { - 0 => history(app, ui), - 1 => crate::actions::panel(app, ui), - _ => crate::comps_ui::panel(app, ui), - }); - app.ui.dock_tabs.history = sel; - } - if p.layers { - let mut sel = app.ui.dock_tabs.layers; - widgets::card(ui, "layers", &["Layers", "Channels", "Paths"], &mut sel, |ui, tab| match tab { - 0 => layers(app, ui), - 1 => channels(app, ui), - _ => crate::vector_ui::paths_panel(app, ui), - }); - app.ui.dock_tabs.layers = sel; - } - }; - if t.pro { - // Fixed panels on top, Layers takes the rest (it scrolls internally). - draw(app, ui); - } else { - egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| draw(app, ui)); - } - }); + }); } /// Photoshop's Info panel: colour under the pointer (RGB and CMYK), position, selection size. @@ -737,7 +841,10 @@ fn info_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let units_y = app.session.prefs().units_and_rulers.clone(); let fy = move |px: f64| units_y.format(px, dpi, size.height as f64); let sel = st.doc.selection.as_ref().map(|m| m.content_bounds()); - let pos = app.hover_doc.map(|p| (p[0].floor() as i32, p[1].floor() as i32)).filter(|(x, y)| *x >= 0 && *y >= 0 && *x < size.width as i32 && *y < size.height as i32); + let pos = app + .hover_doc + .map(|p| (p[0].floor() as i32, p[1].floor() as i32)) + .filter(|(x, y)| *x >= 0 && *y >= 0 && *x < size.width as i32 && *y < size.height as i32); let rgba = pos.and_then(|(x, y)| { if let Some(((cx, cy, cr), v)) = app.info_sample && (cx, cy, cr) == (x, y, rev) @@ -848,10 +955,46 @@ fn empty(ui: &mut egui::Ui, s: &str) { } const SWATCHES: [[u8; 3]; 40] = [ - [0, 0, 0], [26, 26, 26], [51, 51, 51], [77, 77, 77], [102, 102, 102], [128, 128, 128], [153, 153, 153], [179, 179, 179], [204, 204, 204], [255, 255, 255], - [236, 128, 128], [244, 176, 132], [250, 224, 128], [214, 240, 128], [150, 232, 150], [128, 232, 200], [128, 220, 240], [128, 176, 244], [168, 144, 244], [232, 144, 232], - [230, 40, 40], [245, 120, 30], [250, 210, 30], [160, 220, 40], [40, 200, 80], [30, 200, 170], [30, 170, 230], [40, 100, 230], [120, 70, 220], [210, 50, 180], - [120, 20, 20], [130, 60, 10], [130, 110, 10], [80, 120, 20], [20, 100, 40], [10, 100, 90], [10, 80, 120], [20, 50, 120], [60, 30, 110], [110, 20, 90], + [0, 0, 0], + [26, 26, 26], + [51, 51, 51], + [77, 77, 77], + [102, 102, 102], + [128, 128, 128], + [153, 153, 153], + [179, 179, 179], + [204, 204, 204], + [255, 255, 255], + [236, 128, 128], + [244, 176, 132], + [250, 224, 128], + [214, 240, 128], + [150, 232, 150], + [128, 232, 200], + [128, 220, 240], + [128, 176, 244], + [168, 144, 244], + [232, 144, 232], + [230, 40, 40], + [245, 120, 30], + [250, 210, 30], + [160, 220, 40], + [40, 200, 80], + [30, 200, 170], + [30, 170, 230], + [40, 100, 230], + [120, 70, 220], + [210, 50, 180], + [120, 20, 20], + [130, 60, 10], + [130, 110, 10], + [80, 120, 20], + [20, 100, 40], + [10, 100, 90], + [10, 80, 120], + [20, 50, 120], + [60, 30, 110], + [110, 20, 90], ]; fn swatches(app: &mut PhotocraftApp, ui: &mut egui::Ui) { @@ -890,7 +1033,8 @@ fn color_picker(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let mut v = hsva0.v * 100.0; let hue = widgets::hue_stops(); widgets::slider_row(ui, "Hue", &mut h, 0.0..=360.0, "°", Some(&hue)); - let sat_stops = [egui::ecolor::Hsva::new(hsva0.h, 0.0, hsva0.v.max(0.2), 1.0), egui::ecolor::Hsva::new(hsva0.h, 1.0, hsva0.v.max(0.2), 1.0)].map(Color32::from); + let sat_stops = + [egui::ecolor::Hsva::new(hsva0.h, 0.0, hsva0.v.max(0.2), 1.0), egui::ecolor::Hsva::new(hsva0.h, 1.0, hsva0.v.max(0.2), 1.0)].map(Color32::from); widgets::slider_row(ui, "Saturation", &mut s, 0.0..=100.0, "%", Some(&sat_stops)); let val_stops = [Color32::BLACK, Color32::from(egui::ecolor::Hsva::new(hsva0.h, hsva0.s, 1.0, 1.0))]; widgets::slider_row(ui, "Brightness", &mut v, 0.0..=100.0, "%", Some(&val_stops)); @@ -936,7 +1080,13 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { icons::paint(ui, Rect::from_center_size(r.left_center() + vec2(11.0, 0.0), vec2(14.0, 14.0)), "search", 11.0, t.text_faint); ui.painter().text(r.left_center() + vec2(22.0, 0.0), Align2::LEFT_CENTER, "Kind", egui::FontId::proportional(11.5), t.text_dim); ui.add_space(4.0); - for (kind, icon, tip) in [("pixel", "image", "Filter for pixel layers"), ("adjustment", "contrast", "Filter for adjustment layers"), ("type", "type", "Filter for type layers"), ("shape", "square", "Filter for shape layers"), ("smart", "app-window", "Filter for smart objects")] { + for (kind, icon, tip) in [ + ("pixel", "image", "Filter for pixel layers"), + ("adjustment", "contrast", "Filter for adjustment layers"), + ("type", "type", "Filter for type layers"), + ("shape", "square", "Filter for shape layers"), + ("smart", "app-window", "Filter for smart objects"), + ] { let on = app.ui.layer_filter.iter().any(|k| k == kind); if icons::button(ui, icon, 22.0, on, tip).clicked() { if on { @@ -954,18 +1104,18 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let bg = crate::doc_props_ui::is_background(&doc, l); ui.horizontal(|ui| { ui.add_enabled_ui(!bg, |ui| { - let mut m = l.blend; - let w = ui.available_width() - 150.0; - if widgets::dropdown(ui, "blend", &mut m, &blend_options(l.is_group()), w.max(100.0)) { - actions.push(("layer.setProps".into(), json!({"layer": l.id.0, "blend": m.label()}))); - } - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let mut o = l.opacity * 100.0; - if widgets::value_field(ui, &mut o, 0.0..=100.0, "%", 66.0).changed() { - actions.push(("layer.setProps".into(), json!({"layer": l.id.0, "opacity": o / 100.0}))); + let mut m = l.blend; + let w = ui.available_width() - 150.0; + if widgets::dropdown(ui, "blend", &mut m, &blend_options(l.is_group()), w.max(100.0)) { + actions.push(("layer.setProps".into(), json!({"layer": l.id.0, "blend": m.label()}))); } - label(ui, if t.pro { "Opacity:" } else { "Opacity" }); - }); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let mut o = l.opacity * 100.0; + if widgets::value_field(ui, &mut o, 0.0..=100.0, "%", 66.0).changed() { + actions.push(("layer.setProps".into(), json!({"layer": l.id.0, "opacity": o / 100.0}))); + } + label(ui, if t.pro { "Opacity:" } else { "Opacity" }); + }); }); }); ui.horizontal(|ui| { @@ -991,13 +1141,15 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { actions.push(("layer.setProps".into(), json!({"layer": l.id.0, "locks": {"all": !l.locks.all}}))); } } - ui.add_enabled_ui(!bg, |ui| ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let mut f = l.fill_opacity * 100.0; - if widgets::value_field(ui, &mut f, 0.0..=100.0, "%", 66.0).changed() { - actions.push(("layer.setProps".into(), json!({"layer": l.id.0, "fill": f / 100.0}))); - } - label(ui, if t.pro { "Fill:" } else { "Fill" }); - })); + ui.add_enabled_ui(!bg, |ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let mut f = l.fill_opacity * 100.0; + if widgets::value_field(ui, &mut f, 0.0..=100.0, "%", 66.0).changed() { + actions.push(("layer.setProps".into(), json!({"layer": l.id.0, "fill": f / 100.0}))); + } + label(ui, if t.pro { "Fill:" } else { "Fill" }); + }) + }); }); ui.add_space(2.0); } @@ -1007,34 +1159,39 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let footer = 38.0; let fill = t.pro && ui.available_height() > footer + 60.0; let rows_h = if fill { ui.available_height() - footer } else { f32::INFINITY }; - egui::ScrollArea::vertical().id_salt("layer-rows").max_height(rows_h).min_scrolled_height(if fill { rows_h } else { 0.0 }).auto_shrink([false, !fill]).show(ui, |ui| { - let filter = app.ui.layer_filter.clone(); - for (_, depth, l) in rows.iter().rev() { - // Select › Isolate Layers. - if !photocraft_engine::select_extra_cmds::isolation_shows(&doc, &isolated, l.id) { - continue; - } - if !filter.is_empty() { - let kind = match &l.content { - LayerContent::Raster(_) => "pixel", - LayerContent::Adjustment(_) | LayerContent::Fill(_) => "adjustment", - LayerContent::Text(_) => "type", - LayerContent::Shape(_) => "shape", - LayerContent::Smart(_) => "smart", - LayerContent::Group(_) => "", - }; - if !filter.iter().any(|k| k == kind) { + egui::ScrollArea::vertical() + .id_salt("layer-rows") + .max_height(rows_h) + .min_scrolled_height(if fill { rows_h } else { 0.0 }) + .auto_shrink([false, !fill]) + .show(ui, |ui| { + let filter = app.ui.layer_filter.clone(); + for (_, depth, l) in rows.iter().rev() { + // Select › Isolate Layers. + if !photocraft_engine::select_extra_cmds::isolation_shows(&doc, &isolated, l.id) { continue; } + if !filter.is_empty() { + let kind = match &l.content { + LayerContent::Raster(_) => "pixel", + LayerContent::Adjustment(_) | LayerContent::Fill(_) => "adjustment", + LayerContent::Text(_) => "type", + LayerContent::Shape(_) => "shape", + LayerContent::Smart(_) => "smart", + LayerContent::Group(_) => "", + }; + if !filter.iter().any(|k| k == kind) { + continue; + } + } + let row = RowSel { selected: selection.contains(&l.id), primary: active == Some(l.id), multi: selection.len() > 1 }; + layer_row(app, &ctx, ui, &doc, l, *depth, row, &mut actions); + if !l.effects.items.is_empty() { + effect_rows(app, ui, l, *depth); + } + crate::smart_ui::filter_rows(app, ui, l, *depth, &mut actions); } - let row = RowSel { selected: selection.contains(&l.id), primary: active == Some(l.id), multi: selection.len() > 1 }; - layer_row(app, &ctx, ui, &doc, l, *depth, row, &mut actions); - if !l.effects.items.is_empty() { - effect_rows(app, ui, l, *depth); - } - crate::smart_ui::filter_rows(app, ui, l, *depth, &mut actions); - } - }); + }); // End any layer drag after every row has had a chance to accept the drop. if ctx.input(|i| i.pointer.any_released()) { ctx.data_mut(|d| d.remove::(egui::Id::new("layer-drag"))); @@ -1153,7 +1310,16 @@ fn select_mode(m: egui::Modifiers) -> &'static str { } #[allow(clippy::too_many_arguments)] -fn layer_row(app: &mut PhotocraftApp, ctx: &egui::Context, ui: &mut egui::Ui, doc: &photocraft_doc::Document, l: &Layer, depth: usize, row: RowSel, actions: &mut Vec<(String, Value)>) { +fn layer_row( + app: &mut PhotocraftApp, + ctx: &egui::Context, + ui: &mut egui::Ui, + doc: &photocraft_doc::Document, + l: &Layer, + depth: usize, + row: RowSel, + actions: &mut Vec<(String, Value)>, +) { let selected = row.selected; let t = Tokens::get(ctx); // Photoshop's default (medium) thumbnails: 32 pt rows. @@ -1197,7 +1363,12 @@ fn layer_row(app: &mut PhotocraftApp, ctx: &egui::Context, ui: &mut egui::Ui, do let mr = Rect::from_min_size(pos2(x, rect.center().y - ts / 2.0), vec2(ts, ts)); let tex = app.mask_thumb(ctx, doc, l.id, m); painter.image(tex, mr, Rect::from_min_max(egui::Pos2::ZERO, pos2(1.0, 1.0)), Color32::WHITE); - painter.rect_stroke(mr, if t.pro { 0.0 } else { 4.0 }, Stroke::new(1.0, if t.pro { Color32::from_gray(20) } else { t.field_border }), StrokeKind::Outside); + painter.rect_stroke( + mr, + if t.pro { 0.0 } else { 4.0 }, + Stroke::new(1.0, if t.pro { Color32::from_gray(20) } else { t.field_border }), + StrokeKind::Outside, + ); mask_rect = Some(mr); x += ts + 6.0; } @@ -1252,7 +1423,8 @@ fn layer_row(app: &mut PhotocraftApp, ctx: &egui::Context, ui: &mut egui::Ui, do let thumb_load = resp.clicked().then(|| (ui.input(|i| i.modifiers), resp.interact_pointer_pos())).and_then(|(m, pos)| { let p = pos.filter(|_| m.command)?; let on_mask = mask_rect.is_some_and(|r| r.expand(2.0).contains(p)); - (on_mask || thumb.expand(2.0).contains(p)).then(|| json!({"channel": if on_mask { "mask" } else { "transparency" }, "layer": l.id.0, "operation": crate::channels_panel::load_operation(m)})) + (on_mask || thumb.expand(2.0).contains(p)) + .then(|| json!({"channel": if on_mask { "mask" } else { "transparency" }, "layer": l.id.0, "operation": crate::channels_panel::load_operation(m)})) }); if let Some(p) = thumb_load { actions.push(("select.loadSelection".into(), p)); @@ -1329,7 +1501,13 @@ fn draw_layer_thumb(app: &mut PhotocraftApp, ctx: &egui::Context, ui: &egui::Ui, p.image(tex, rect, Rect::from_min_max(egui::Pos2::ZERO, pos2(1.0, 1.0)), Color32::WHITE); } } - let stroke = if t.pro { Stroke::new(1.0, Color32::from_gray(20)) } else if selected { Stroke::new(1.5, t.text) } else { Stroke::new(1.0, t.field_border) }; + let stroke = if t.pro { + Stroke::new(1.0, Color32::from_gray(20)) + } else if selected { + Stroke::new(1.5, t.text) + } else { + Stroke::new(1.0, t.field_border) + }; p.rect_stroke(rect, CornerRadius::same(if t.pro { 0 } else { 5 }), stroke, StrokeKind::Outside); crate::smart_ui::thumb_badge(ui, l, rect); } @@ -1396,7 +1574,13 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { _ => "sliders-horizontal", }; let color = if is_redo { t.text_faint } else { t.text }; - icons::paint(ui, Rect::from_center_size(pos2(rect.left() + 42.0, rect.center().y), vec2(16.0, 16.0)), icon, 12.0, if is_redo { t.text_faint } else { t.icon }); + icons::paint( + ui, + Rect::from_center_size(pos2(rect.left() + 42.0, rect.center().y), vec2(16.0, 16.0)), + icon, + 12.0, + if is_redo { t.text_faint } else { t.icon }, + ); ui.painter().text(pos2(rect.left() + 58.0, rect.center().y), Align2::LEFT_CENTER, &e, egui::FontId::proportional(12.0), color); if resp.clicked() { target = Some(i as isize - current as isize); @@ -1429,7 +1613,9 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { /// Slider spec for adjustment properties: (param key, label, min, max, default). pub fn adjustment_sliders(kind: &str) -> &'static [(&'static str, &'static str, f32, f32, f32)] { match kind { - "hueSaturation" => &[("hue", "Hue", -180.0, 180.0, 0.0), ("saturation", "Saturation", -100.0, 100.0, 0.0), ("lightness", "Lightness", -100.0, 100.0, 0.0)], + "hueSaturation" => { + &[("hue", "Hue", -180.0, 180.0, 0.0), ("saturation", "Saturation", -100.0, 100.0, 0.0), ("lightness", "Lightness", -100.0, 100.0, 0.0)] + } "brightnessContrast" => &[("brightness", "Brightness", -150.0, 150.0, 0.0), ("contrast", "Contrast", -50.0, 100.0, 0.0)], "exposure" => &[("exposure", "Exposure", -5.0, 5.0, 0.0), ("offset", "Offset", -0.5, 0.5, 0.0), ("gamma", "Gamma Correction", 0.1, 3.0, 1.0)], "vibrance" => &[("vibrance", "Vibrance", -100.0, 100.0, 0.0), ("saturation", "Saturation", -100.0, 100.0, 0.0)], @@ -1444,7 +1630,9 @@ pub fn adjustment_sliders(kind: &str) -> &'static [(&'static str, &'static str, pub fn adjustment_values(a: &photocraft_doc::Adjustment) -> Value { use photocraft_doc::Adjustment as A; match a { - A::HueSaturation { hue, saturation, lightness, colorize } => json!({"hue": hue, "saturation": saturation, "lightness": lightness, "colorize": colorize}), + A::HueSaturation { hue, saturation, lightness, colorize } => { + json!({"hue": hue, "saturation": saturation, "lightness": lightness, "colorize": colorize}) + } A::BrightnessContrast { brightness, contrast, .. } => json!({"brightness": brightness, "contrast": contrast}), A::Exposure { exposure, offset, gamma } => json!({"exposure": exposure, "offset": offset, "gamma": gamma}), A::Vibrance { vibrance, saturation } => json!({"vibrance": vibrance, "saturation": saturation}), @@ -1874,16 +2062,45 @@ fn brush_preset_chip(ui: &mut egui::Ui, b: &mut photocraft_engine::BrushSettings ui.add_space(6.0); ui.label(RichText::new("General Brushes").color(t.text_dim).size(11.5)); // Photoshop's default round presets: soft and hard at common sizes. - let presets: [(f32, f32); 12] = [(1.0, 1.0), (3.0, 1.0), (5.0, 1.0), (9.0, 1.0), (13.0, 1.0), (19.0, 1.0), (5.0, 0.0), (9.0, 0.0), (13.0, 0.0), (17.0, 0.0), (45.0, 0.0), (65.0, 0.0)]; + let presets: [(f32, f32); 12] = [ + (1.0, 1.0), + (3.0, 1.0), + (5.0, 1.0), + (9.0, 1.0), + (13.0, 1.0), + (19.0, 1.0), + (5.0, 0.0), + (9.0, 0.0), + (13.0, 0.0), + (17.0, 0.0), + (45.0, 0.0), + (65.0, 0.0), + ]; ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing = vec2(4.0, 4.0); for (sz, hd) in presets { let (cell, cr) = ui.allocate_exact_size(vec2(38.0, 44.0), Sense::click()); let on = (b.size - sz).abs() < 0.5 && (b.hardness - hd).abs() < 0.01; - ui.painter().rect_filled(cell, 3.0, if on { t.accent_soft } else if cr.hovered() { t.hover } else { t.field }); + ui.painter().rect_filled( + cell, + 3.0, + if on { + t.accent_soft + } else if cr.hovered() { + t.hover + } else { + t.field + }, + ); let rad = (sz / 2.0).clamp(1.0, 13.0); brush_tip(ui.painter(), pos2(cell.center().x, cell.top() + 17.0), rad, hd, t.text); - ui.painter().text(pos2(cell.center().x, cell.bottom() - 7.0), Align2::CENTER_CENTER, format!("{}", sz as i64), egui::FontId::proportional(9.5), t.text_dim); + ui.painter().text( + pos2(cell.center().x, cell.bottom() - 7.0), + Align2::CENTER_CENTER, + format!("{}", sz as i64), + egui::FontId::proportional(9.5), + t.text_dim, + ); if cr.on_hover_text(format!("{} Round {sz:.0} px", if hd >= 1.0 { "Hard" } else { "Soft" })).clicked() { b.size = sz; b.hardness = hd; @@ -1979,7 +2196,15 @@ fn effect_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usi /// New / add / subtract / intersect selection buttons (shared by selection tools). fn selection_mode_buttons(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.spacing_mut().item_spacing.x = 2.0; - for (i, (icon, tip)) in [("square", "New selection"), ("plus", "Add to selection (⇧)"), ("minus", "Subtract from selection (⌥)"), ("squares-subtract", "Intersect with selection (⇧⌥)")].iter().enumerate() { + for (i, (icon, tip)) in [ + ("square", "New selection"), + ("plus", "Add to selection (⇧)"), + ("minus", "Subtract from selection (⌥)"), + ("squares-subtract", "Intersect with selection (⇧⌥)"), + ] + .iter() + .enumerate() + { if icons::button(ui, icon, 24.0, app.ui.selection_mode == i as u8, tip).clicked() { app.ui.selection_mode = i as u8; } diff --git a/crates/ui-egui/src/perspective_ui.rs b/crates/ui-egui/src/perspective_ui.rs index d5b3d68eb9..c43b689b48 100644 --- a/crates/ui-egui/src/perspective_ui.rs +++ b/crates/ui-egui/src/perspective_ui.rs @@ -26,9 +26,15 @@ pub enum PerspMode { } enum Drag { - NewPlane { start: [f64; 2], cur: [f64; 2] }, + NewPlane { + start: [f64; 2], + cur: [f64; 2], + }, /// Linked corners being moved, and the pointer offset from the corner. - Corner { group: Vec<(usize, usize)>, offset: [f64; 2] }, + Corner { + group: Vec<(usize, usize)>, + offset: [f64; 2], + }, } pub struct PerspSession { @@ -122,7 +128,19 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context, command: &str) -> Res let preview_doc = crate::distort_ui::without_layer(&st.doc, layer); let key = app.ui.alloc_id(); let texture = ctx.load_texture(format!("persp-{key}"), crate::distort_ui::surface_image(&surface, bounds, 2048), egui::TextureOptions::LINEAR); - let mut s = PerspSession { key, layer, command: command.to_string(), bounds, planes: Vec::new(), mode: PerspMode::Layout, drag: None, texture, opacity, preview_doc, grid: Vec::new() }; + let mut s = PerspSession { + key, + layer, + command: command.to_string(), + bounds, + planes: Vec::new(), + mode: PerspMode::Layout, + drag: None, + texture, + opacity, + preview_doc, + grid: Vec::new(), + }; s.update_grid(); app.distort.perspective = Some(s); app.ui.status = "Perspective Warp: draw planes along the image's perspective, then switch to Warp".into(); @@ -283,7 +301,11 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } crate::widgets::vline(ui, 22.0); if s.mode == PerspMode::Warp { - for (label, tip, m) in [("Straighten", "Automatically straighten near-vertical line segments", Straighten::Vertical), ("Level", "Automatically level near-horizontal line segments", Straighten::Horizontal), ("Both", "Automatically straighten and level", Straighten::Auto)] { + for (label, tip, m) in [ + ("Straighten", "Automatically straighten near-vertical line segments", Straighten::Vertical), + ("Level", "Automatically level near-horizontal line segments", Straighten::Horizontal), + ("Both", "Automatically straighten and level", Straighten::Auto), + ] { if ui.button(label).on_hover_text(tip).clicked() { straighten(&mut s.planes, m); s.update_grid(); diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index a2dae00075..3d901e9504 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -44,7 +44,13 @@ pub fn canvas_style(app: &PhotocraftApp) -> crate::gpu_canvas::CanvasStyle { let [l, d] = t.colors(); let f = |c: [u8; 3]| c.map(|v| v as f32 / 255.0); let g = prefs::parse_hex(&t.gamut_warning_color).unwrap_or([128; 3]); - crate::gpu_canvas::CanvasStyle { checker_square: t.square().unwrap_or(0.0), checker_light: f(l), checker_dark: f(d), gamut_color: f(g), gamut_opacity: t.gamut_warning_opacity as f32 / 100.0 } + crate::gpu_canvas::CanvasStyle { + checker_square: t.square().unwrap_or(0.0), + checker_light: f(l), + checker_dark: f(d), + gamut_color: f(g), + gamut_opacity: t.gamut_warning_opacity as f32 / 100.0, + } } /// Pasteboard colour from Preferences › Interface (`None` = the theme's default canvas). @@ -182,7 +188,13 @@ fn autosave(app: &mut PhotocraftApp) { return; } app.prefs_rt.next_autosave_ms = now + interval; - let jobs: Vec<_> = app.session.documents().iter().filter(|d| d.is_dirty() && app.prefs_rt.autosaved.get(&d.doc.id) != Some(&d.revision)).map(|d| (d.doc.clone(), d.revision, d.path.clone())).collect(); + let jobs: Vec<_> = app + .session + .documents() + .iter() + .filter(|d| d.is_dirty() && app.prefs_rt.autosaved.get(&d.doc.id) != Some(&d.revision)) + .map(|d| (d.doc.clone(), d.revision, d.path.clone())) + .collect(); for (doc, rev, path) in jobs { if let Some(save) = app.services.autosave.as_mut() { match save(&doc, rev, path.as_deref()) { @@ -330,7 +342,8 @@ pub fn invoke(app: &mut PhotocraftApp, _ctx: &egui::Context, id: &str, params: & } "edit.fade" | "edit.findAndReplaceText" | "edit.defineBrushPreset" | "edit.defineCustomShape" | "edit.autoAlignLayers" | "edit.autoBlendLayers" => { if !app.session.is_enabled(id) { - return Some(Err(photocraft_engine::commands::find(id).map_or("not available".into(), |c| format!("{} is not available right now", c.label.trim_end_matches('…'))))); + return Some(Err(photocraft_engine::commands::find(id) + .map_or("not available".into(), |c| format!("{} is not available right now", c.label.trim_end_matches('…'))))); } let d = crate::filter_dialog::open(app, id); if let Some(dm) = d.and_then(|d| app.ui.dialog_mut(d)) { @@ -370,8 +383,12 @@ pub fn invoke(app: &mut PhotocraftApp, _ctx: &egui::Context, id: &str, params: & } dialog(d) } - "edit.presets.presetManager" => Some(Ok(json!({"dialog": open_kind(app, "presets", "Preset Manager", json!({"kind": "brushes", "selected": 0, "newName": ""}))}))), - "edit.presets.exportImportPresets" => Some(Ok(json!({"dialog": open_kind(app, "presetsIO", "Export/Import Presets", json!({"action": "export", "brushes": true, "customShapes": true}))}))), + "edit.presets.presetManager" => { + Some(Ok(json!({"dialog": open_kind(app, "presets", "Preset Manager", json!({"kind": "brushes", "selected": 0, "newName": ""}))}))) + } + "edit.presets.exportImportPresets" => Some(Ok( + json!({"dialog": open_kind(app, "presetsIO", "Export/Import Presets", json!({"action": "export", "brushes": true, "customShapes": true}))}), + )), _ => None, } } @@ -419,7 +436,8 @@ fn field_order(p: &prefs::Preferences, sections: &Map) -> Value { let body = &text[start..]; let end = body.find('}').unwrap_or(body.len()); let region = &body[..end]; - let mut keys: Vec<(usize, String)> = v.as_object().into_iter().flatten().map(|(k, _)| (region.find(&format!("\"{k}\":")).unwrap_or(usize::MAX), k.clone())).collect(); + let mut keys: Vec<(usize, String)> = + v.as_object().into_iter().flatten().map(|(k, _)| (region.find(&format!("\"{k}\":")).unwrap_or(usize::MAX), k.clone())).collect(); keys.sort(); out.insert(id.clone(), json!(keys.into_iter().map(|(_, k)| k).collect::>())); } @@ -448,14 +466,23 @@ pub fn open_mismatch(app: &mut PhotocraftApp, report: &Value) -> u64 { let msg = if report.get("missing").and_then(Value::as_bool) == Some(true) { format!("The document does not have an embedded colour profile. Working space: {}.", report["working"].as_str().unwrap_or("?")) } else { - format!("The document has an embedded colour profile that does not match the working space.\nEmbedded: {}\nWorking: {}", report["embedded"].as_str().unwrap_or("?"), report["working"].as_str().unwrap_or("?")) + format!( + "The document has an embedded colour profile that does not match the working space.\nEmbedded: {}\nWorking: {}", + report["embedded"].as_str().unwrap_or("?"), + report["working"].as_str().unwrap_or("?") + ) }; let action = match report["action"].as_str() { Some("converted") => "convert", Some("discarded") => "discard", _ => "preserve", }; - open_kind(app, "mismatch", "Embedded Profile Mismatch", json!({"message": msg, "action": action, "applied": action, "missing": report.get("missing").is_some()})) + open_kind( + app, + "mismatch", + "Embedded Profile Mismatch", + json!({"message": msg, "action": action, "applied": action, "missing": report.get("missing").is_some()}), + ) } // ------------------------------------------------------------------ bodies @@ -522,7 +549,13 @@ fn prefs_body(ui: &mut egui::Ui, f: &mut Map) { } else if resp.hovered() { ui.painter().rect_filled(rect, t.radius_sm, t.hover); } - ui.painter().text(rect.left_center() + vec2(8.0, 0.0), egui::Align2::LEFT_CENTER, title, crate::theme::medium(12.5), if sel { t.text } else { t.text_dim }); + ui.painter().text( + rect.left_center() + vec2(8.0, 0.0), + egui::Align2::LEFT_CENTER, + title, + crate::theme::medium(12.5), + if sel { t.text } else { t.text_dim }, + ); if resp.clicked() { section = id.to_string(); } @@ -535,7 +568,8 @@ fn prefs_body(ui: &mut egui::Ui, f: &mut Map) { ui.label(RichText::new(title).font(crate::theme::semibold(14.0)).color(t.text)); ui.add_space(6.0); egui::ScrollArea::vertical().max_height(390.0).id_salt("prefs-scroll").show(ui, |ui| { - let order: Vec = f.get("__order").and_then(|o| o.get(§ion)).and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); + let order: Vec = + f.get("__order").and_then(|o| o.get(§ion)).and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); if let Some(obj) = values.get_mut(§ion).and_then(Value::as_object_mut) { section_fields(ui, §ion, obj, &order); } @@ -683,10 +717,12 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map Some((*key, *modifiers)), - _ => None, - })); + let pressed = ui.input(|i| { + i.events.iter().find_map(|e| match e { + egui::Event::Key { key, pressed: true, modifiers, .. } => Some((*key, *modifiers)), + _ => None, + }) + }); if let Some((key, m)) = pressed { ui.input_mut(|i| { i.consume_key(m, key); @@ -698,9 +734,17 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = items.iter().filter(|(id, _, _, def)| *id != selected && eff(&overrides, id, def).as_deref().and_then(prefs::normalize_shortcut) == Some(sc.clone())).map(|(_, label, path, _)| format!("{} › {}", path.join(" › "), label.trim_end_matches('…'))).collect(); + let clash: Vec = items + .iter() + .filter(|(id, _, _, def)| *id != selected && eff(&overrides, id, def).as_deref().and_then(prefs::normalize_shortcut) == Some(sc.clone())) + .map(|(_, label, path, _)| format!("{} › {}", path.join(" › "), label.trim_end_matches('…'))) + .collect(); overrides.insert(selected.clone(), sc.clone()); - message = if clash.is_empty() { format!("{} assigned.", crate::shortcuts::pretty(&sc)) } else { format!("{} is already in use by {} and will be removed from it when you click OK.", crate::shortcuts::pretty(&sc), clash.join(", ")) }; + message = if clash.is_empty() { + format!("{} assigned.", crate::shortcuts::pretty(&sc)) + } else { + format!("{} is already in use by {} and will be removed from it when you click OK.", crate::shortcuts::pretty(&sc), clash.join(", ")) + }; capture = false; } } @@ -734,7 +778,8 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = ["none", "red", "orange", "yellow", "green", "blue", "violet", "gray"].iter().map(|c| (c.to_string(), *c)).collect(); + let opts: Vec<(String, &str)> = + ["none", "red", "orange", "yellow", "green", "blue", "violet", "gray"].iter().map(|c| (c.to_string(), *c)).collect(); crate::widgets::dropdown(ui, &format!("menu-color-{id}"), &mut col, &opts, 90.0); if col == "none" { colors.remove(id); @@ -827,7 +873,13 @@ fn presets_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map>(v).ok()).unwrap_or_default(); + let list = app + .session + .execute("edit.presets.presetManager", json!({"kind": kind})) + .ok() + .and_then(|v| v.get(&kind).cloned()) + .and_then(|v| serde_json::from_value::>(v).ok()) + .unwrap_or_default(); let mut selected = f.get("selected").and_then(Value::as_u64).unwrap_or(0) as usize; egui::ScrollArea::vertical().max_height(260.0).id_salt("preset-list").show(ui, |ui| { for (i, name) in list.iter().enumerate() { @@ -879,7 +931,11 @@ fn mismatch_body(ui: &mut egui::Ui, f: &mut Map) { let opts: &[(&str, &str)] = if missing { &[("preserve", "Leave as is (don't color manage)"), ("assignWorking", "Assign working space")] } else { - &[("preserve", "Use the embedded profile (instead of the working space)"), ("convert", "Convert document's colors to the working space"), ("discard", "Discard the embedded profile (don't color manage)")] + &[ + ("preserve", "Use the embedded profile (instead of the working space)"), + ("convert", "Convert document's colors to the working space"), + ("discard", "Discard the embedded profile (don't color manage)"), + ] }; for (v, label) in opts { ui.radio_value(&mut action, v.to_string(), *label); @@ -905,7 +961,10 @@ pub fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result = ov.iter().filter(|(_, s)| !s.is_empty()).map(|(k, s)| (k.clone(), s.clone())).collect(); for (id, sc) in changed { for (other, _, _, def) in &items { - if *other != id && !ov.contains_key(other) && def.as_deref().and_then(prefs::normalize_shortcut).as_deref() == prefs::normalize_shortcut(&sc).as_deref() { + if *other != id + && !ov.contains_key(other) + && def.as_deref().and_then(prefs::normalize_shortcut).as_deref() == prefs::normalize_shortcut(&sc).as_deref() + { ov.insert(other.clone(), String::new()); } } diff --git a/crates/ui-egui/src/preset_panels.rs b/crates/ui-egui/src/preset_panels.rs index 75f6d40e9f..612eefa1a3 100644 --- a/crates/ui-egui/src/preset_panels.rs +++ b/crates/ui-egui/src/preset_panels.rs @@ -48,7 +48,8 @@ impl PresetUi { } } -const PANELS: [&str; 6] = ["window.panel.gradients", "window.panel.patterns", "window.panel.styles", "window.panel.shapes", "window.panel.toolPresets", "window.panel.cloneSource"]; +const PANELS: [&str; 6] = + ["window.panel.gradients", "window.panel.patterns", "window.panel.styles", "window.panel.shapes", "window.panel.toolPresets", "window.panel.cloneSource"]; const MIN_THUMB: f32 = 20.0; /// Window-menu ids handled here. @@ -237,7 +238,14 @@ struct Place<'a> { new_tip: &'a str, } -fn browser(ui: &mut egui::Ui, st: &mut PresetUi, panel: &str, groups: &[GroupView], place: Place, thumb: &mut dyn FnMut(&egui::Ui, Rect, usize, usize)) -> Vec { +fn browser( + ui: &mut egui::Ui, + st: &mut PresetUi, + panel: &str, + groups: &[GroupView], + place: Place, + thumb: &mut dyn FnMut(&egui::Ui, Rect, usize, usize), +) -> Vec { let t = Tokens::get(ui.ctx()); let Place { canvas, max_h, new_tip } = place; let mut ev = Vec::new(); @@ -305,7 +313,9 @@ fn browser(ui: &mut egui::Ui, st: &mut PresetUi, panel: &str, groups: &[GroupVie continue; } let w = ui.available_width(); - let (cell, cols) = if list { (vec2(w, 24.0), 1) } else { + let (cell, cols) = if list { + (vec2(w, 24.0), 1) + } else { let cols = ((w - 8.0 + 4.0) / (size + 4.0)).floor().max(1.0) as usize; (vec2(size, size), cols) }; @@ -329,7 +339,13 @@ fn browser(ui: &mut egui::Ui, st: &mut PresetUi, panel: &str, groups: &[GroupVie ui.painter().text(pos2(tr.right() + 8.0, r.center().y), Align2::LEFT_CENTER, &it.name, FontId::proportional(11.5), t.text); } else { thumb(ui, r, gi, ii); - let (stroke, kind) = if is_sel { (Stroke::new(2.0, t.accent), egui::StrokeKind::Outside) } else if resp.hovered() { (Stroke::new(1.0, t.text_dim), egui::StrokeKind::Outside) } else { (Stroke::new(1.0, t.card_border), egui::StrokeKind::Inside) }; + let (stroke, kind) = if is_sel { + (Stroke::new(2.0, t.accent), egui::StrokeKind::Outside) + } else if resp.hovered() { + (Stroke::new(1.0, t.text_dim), egui::StrokeKind::Outside) + } else { + (Stroke::new(1.0, t.card_border), egui::StrokeKind::Inside) + }; ui.painter().rect_stroke(r, CornerRadius::same(2), stroke, kind); } let resp = if list { resp } else { resp.on_hover_text(&it.name) }; @@ -470,7 +486,10 @@ pub fn gradients_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { paint_gradient(ui, r, &cur.resolve(fg, bg)); ui.painter().rect_stroke(r, 2.0, Stroke::new(1.0, Tokens::get(ui.ctx()).card_border), egui::StrokeKind::Inside); ui.add_space(4.0); - let groups: Vec = groups_src.iter().map(|g| GroupView { name: g.name.clone(), items: g.items.iter().map(|i| ItemView { key: i.name.clone(), name: i.name.clone() }).collect() }).collect(); + let groups: Vec = groups_src + .iter() + .map(|g| GroupView { name: g.name.clone(), items: g.items.iter().map(|i| ItemView { key: i.name.clone(), name: i.name.clone() }).collect() }) + .collect(); let canvas = app.last_canvas_rect; let mut st = std::mem::take(&mut app.ui.presets_ui); let max_h = (ui.available_height() - 40.0).clamp(90.0, 260.0); @@ -506,9 +525,18 @@ pub fn patterns_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { .presets .pattern_groups .iter() - .map(|g| GroupView { name: g.name.clone(), items: g.items.iter().filter_map(|id| lib.iter().find(|p| &p.id == id)).map(|p| ItemView { key: p.id.clone(), name: p.display_name().to_string() }).collect() }) + .map(|g| GroupView { + name: g.name.clone(), + items: g + .items + .iter() + .filter_map(|id| lib.iter().find(|p| &p.id == id)) + .map(|p| ItemView { key: p.id.clone(), name: p.display_name().to_string() }) + .collect(), + }) .collect(); - let pats: Vec> = app.session.presets.pattern_groups.iter().map(|g| g.items.iter().filter_map(|id| lib.iter().find(|p| &p.id == id).cloned()).collect()).collect(); + let pats: Vec> = + app.session.presets.pattern_groups.iter().map(|g| g.items.iter().filter_map(|id| lib.iter().find(|p| &p.id == id).cloned()).collect()).collect(); let canvas = app.last_canvas_rect; let mut st = std::mem::take(&mut app.ui.presets_ui); let max_h = (ui.available_height() - 40.0).clamp(90.0, 280.0); @@ -537,7 +565,15 @@ pub fn patterns_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // ------------------------------------------------------------------ floating panels -fn float_window(app: &mut PhotocraftApp, ctx: &egui::Context, key: &str, title: &str, width: f32, offset: f32, body: impl FnOnce(&mut PhotocraftApp, &mut egui::Ui)) -> bool { +fn float_window( + app: &mut PhotocraftApp, + ctx: &egui::Context, + key: &str, + title: &str, + width: f32, + offset: f32, + body: impl FnOnce(&mut PhotocraftApp, &mut egui::Ui), +) -> bool { let t = Tokens::get(ctx); let frame = egui::Frame::NONE .fill(t.card) @@ -592,7 +628,10 @@ pub fn windows(app: &mut PhotocraftApp, ctx: &egui::Context) { pub fn styles_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let src = app.session.presets.styles.clone(); - let groups: Vec = src.iter().map(|g| GroupView { name: g.name.clone(), items: g.items.iter().map(|i| ItemView { key: i.name.clone(), name: i.name.clone() }).collect() }).collect(); + let groups: Vec = src + .iter() + .map(|g| GroupView { name: g.name.clone(), items: g.items.iter().map(|i| ItemView { key: i.name.clone(), name: i.name.clone() }).collect() }) + .collect(); let canvas = app.last_canvas_rect; let mut st = std::mem::take(&mut app.ui.presets_ui); let ctx = ui.ctx().clone(); @@ -601,10 +640,11 @@ pub fn styles_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { for g in &src { texes.push(g.items.iter().map(|s| style_texture(app, &ctx, s)).collect()); } - let events = browser(ui, &mut st, "styles", &groups, Place { canvas, max_h: 300.0, new_tip: "Create new style from the selected layer" }, &mut |ui, r, gi, ii| { - ui.painter().rect_filled(r, 2.0, t.field); - ui.painter().image(texes[gi][ii].id(), r, Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0)), Color32::WHITE); - }); + let events = + browser(ui, &mut st, "styles", &groups, Place { canvas, max_h: 300.0, new_tip: "Create new style from the selected layer" }, &mut |ui, r, gi, ii| { + ui.painter().rect_filled(r, 2.0, t.field); + ui.painter().image(texes[gi][ii].id(), r, Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0)), Color32::WHITE); + }); app.ui.presets_ui = st; let shift = ui.input(|i| i.modifiers.shift); for e in events { @@ -630,15 +670,19 @@ pub fn styles_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { pub fn shapes_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let src = photocraft_engine::presets::shapes::all_groups(&app.session); - let groups: Vec = src.iter().map(|g| GroupView { name: g.name.clone(), items: g.items.iter().map(|i| ItemView { key: i.name.clone(), name: i.name.clone() }).collect() }).collect(); + let groups: Vec = src + .iter() + .map(|g| GroupView { name: g.name.clone(), items: g.items.iter().map(|i| ItemView { key: i.name.clone(), name: i.name.clone() }).collect() }) + .collect(); let canvas = app.last_canvas_rect; let mut st = std::mem::take(&mut app.ui.presets_ui); let ctx = ui.ctx().clone(); let t = Tokens::get(&ctx); - let events = browser(ui, &mut st, "shapes", &groups, Place { canvas, max_h: 300.0, new_tip: "Create new shape from the current path" }, &mut |ui, r, gi, ii| { - let tex = shape_texture(&ctx, &src[gi].items[ii]); - ui.painter().image(tex.id(), r.shrink(2.0), Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0)), t.text); - }); + let events = + browser(ui, &mut st, "shapes", &groups, Place { canvas, max_h: 300.0, new_tip: "Create new shape from the current path" }, &mut |ui, r, gi, ii| { + let tex = shape_texture(&ctx, &src[gi].items[ii]); + ui.painter().image(tex.id(), r.shrink(2.0), Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0)), t.text); + }); app.ui.presets_ui = st; let fill = shape_fill(app); for e in events { @@ -687,7 +731,12 @@ pub fn shape_picker(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.painter().rect_stroke(r, 3.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); if let Some(sh) = groups.iter().flat_map(|g| g.items.iter()).find(|s| s.name == cur) { let tex = shape_texture(&ctx, sh); - ui.painter().image(tex.id(), Rect::from_center_size(r.center() - vec2(5.0, 0.0), vec2(18.0, 18.0)), Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0)), t.text); + ui.painter().image( + tex.id(), + Rect::from_center_size(r.center() - vec2(5.0, 0.0), vec2(18.0, 18.0)), + Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0)), + t.text, + ); } crate::icons::paint(ui, Rect::from_center_size(pos2(r.right() - 8.0, r.center().y), vec2(10.0, 10.0)), "chevron-down", 9.0, t.text_dim); let resp = resp.on_hover_text(&cur); @@ -701,7 +750,17 @@ pub fn shape_picker(app: &mut PhotocraftApp, ui: &mut egui::Ui) { for sh in &g.items { let (cr, cresp) = ui.allocate_exact_size(vec2(32.0, 32.0), Sense::click()); let on = sh.name == cur; - ui.painter().rect_filled(cr, 2.0, if on { t.accent_soft } else if cresp.hovered() { t.hover } else { t.field }); + ui.painter().rect_filled( + cr, + 2.0, + if on { + t.accent_soft + } else if cresp.hovered() { + t.hover + } else { + t.field + }, + ); let tex = shape_texture(&ctx, sh); ui.painter().image(tex.id(), cr.shrink(4.0), Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0)), t.text); if cresp.on_hover_text(&sh.name).clicked() { @@ -755,7 +814,8 @@ pub fn tool_presets_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let only = app.ui.presets_ui.current_tool_only; let cur = app.ui.tool; - let items: Vec<(String, String)> = app.session.presets.tool_presets.iter().filter(|p| !only || Tool::from_name(&p.tool) == Some(cur)).map(|p| (p.name.clone(), p.tool.clone())).collect(); + let items: Vec<(String, String)> = + app.session.presets.tool_presets.iter().filter(|p| !only || Tool::from_name(&p.tool) == Some(cur)).map(|p| (p.name.clone(), p.tool.clone())).collect(); let selected = app.ui.presets_ui.selected.get("toolPresets").cloned(); let mut action: Option<(String, Value)> = None; let mut pick: Option = None; @@ -867,11 +927,12 @@ pub fn clone_source_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } }); ui.add_space(4.0); - let src_line = match (slot.source, slot.layer.and_then(|l| app.session.active().and_then(|d| d.doc.layer(photocraft_doc::LayerId(l)).map(|x| x.name.clone())))) { - (Some(_), Some(l)) => format!("Source: {} : {l}", app.session.active().map(|d| d.doc.name.clone()).unwrap_or_default()), - (Some(_), None) => "Source: set".to_string(), - _ => "Source: not set (⌥-click with Clone Stamp)".to_string(), - }; + let src_line = + match (slot.source, slot.layer.and_then(|l| app.session.active().and_then(|d| d.doc.layer(photocraft_doc::LayerId(l)).map(|x| x.name.clone())))) { + (Some(_), Some(l)) => format!("Source: {} : {l}", app.session.active().map(|d| d.doc.name.clone()).unwrap_or_default()), + (Some(_), None) => "Source: set".to_string(), + _ => "Source: not set (⌥-click with Clone Stamp)".to_string(), + }; ui.label(egui::RichText::new(src_line).color(t.text_dim).size(11.0)); ui.add_space(4.0); let lbl = |ui: &mut egui::Ui, s: &str| { @@ -933,7 +994,8 @@ pub fn clone_source_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.add_space(18.0); ui.label(egui::RichText::new("Opacity:").color(t.text_dim).size(11.5)); crate::widgets::value_field(ui, &mut o.opacity, 0.0..=100.0, "%", 56.0); - let opts = [("normal".to_string(), "Normal"), ("darken".to_string(), "Darken"), ("lighten".to_string(), "Lighten"), ("difference".to_string(), "Difference")]; + let opts = + [("normal".to_string(), "Normal"), ("darken".to_string(), "Darken"), ("lighten".to_string(), "Lighten"), ("difference".to_string(), "Difference")]; crate::widgets::dropdown(ui, "clone-overlay-mode", &mut o.blend, &opts, 90.0); }); ui.horizontal(|ui| { @@ -943,7 +1005,11 @@ pub fn clone_source_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { crate::widgets::checkbox(ui, &mut o.invert, "Invert"); }); if o != before { - run(app, "cloneSource.overlay", json!({"show": o.show, "opacity": o.opacity, "clipped": o.clipped, "autoHide": o.auto_hide, "invert": o.invert, "blend": o.blend})); + run( + app, + "cloneSource.overlay", + json!({"show": o.show, "opacity": o.opacity, "clipped": o.clipped, "autoHide": o.auto_hide, "invert": o.invert, "blend": o.blend}), + ); } } diff --git a/crates/ui-egui/src/puppet_ui.rs b/crates/ui-egui/src/puppet_ui.rs index 4a745c4da8..3bf70044a3 100644 --- a/crates/ui-egui/src/puppet_ui.rs +++ b/crates/ui-egui/src/puppet_ui.rs @@ -80,7 +80,13 @@ impl PuppetSession { } fn pin_at(&self, p: [f64; 2], tol: f64) -> Option { - self.warp.pins.iter().enumerate().filter(|(_, q)| (q.dst[0] - p[0]).hypot(q.dst[1] - p[1]) <= tol).min_by(|a, b| dist(a.1.dst, p).total_cmp(&dist(b.1.dst, p))).map(|(i, _)| i) + self.warp + .pins + .iter() + .enumerate() + .filter(|(_, q)| (q.dst[0] - p[0]).hypot(q.dst[1] - p[1]) <= tol) + .min_by(|a, b| dist(a.1.dst, p).total_cmp(&dist(b.1.dst, p))) + .map(|(i, _)| i) } /// The rest position of a point on the deformed mesh (where a new pin attaches). @@ -289,11 +295,23 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let mut rebind = false; ui.label("Mode:"); let mode = s.warp.mode; - crate::widgets::dropdown(ui, "puppet-mode", &mut s.warp.mode, &[(PuppetMode::Rigid, "Rigid"), (PuppetMode::Normal, "Normal"), (PuppetMode::Distort, "Distort")], 90.0); + crate::widgets::dropdown( + ui, + "puppet-mode", + &mut s.warp.mode, + &[(PuppetMode::Rigid, "Rigid"), (PuppetMode::Normal, "Normal"), (PuppetMode::Distort, "Distort")], + 90.0, + ); resolve |= mode != s.warp.mode; ui.label("Density:"); let density = s.warp.density; - crate::widgets::dropdown(ui, "puppet-density", &mut s.warp.density, &[(PuppetDensity::Fewer, "Fewer Points"), (PuppetDensity::Normal, "Normal"), (PuppetDensity::More, "More Points")], 110.0); + crate::widgets::dropdown( + ui, + "puppet-density", + &mut s.warp.density, + &[(PuppetDensity::Fewer, "Fewer Points"), (PuppetDensity::Normal, "Normal"), (PuppetDensity::More, "More Points")], + 110.0, + ); remesh |= density != s.warp.density; ui.label("Expansion:"); let mut e = s.warp.expansion as f32; diff --git a/crates/ui-egui/src/retouch_ui.rs b/crates/ui-egui/src/retouch_ui.rs index eb1741e2f7..467d33ad45 100644 --- a/crates/ui-egui/src/retouch_ui.rs +++ b/crates/ui-egui/src/retouch_ui.rs @@ -37,13 +37,17 @@ pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]], m Tool::Blur => ("paint.blur", json!({"strength": o.strength})), Tool::Sharpen => ("paint.sharpen", json!({"strength": o.strength, "protectDetail": o.protect_detail})), Tool::Smudge => ("paint.smudge", json!({"strength": o.strength, "fingerPainting": o.finger_painting})), - Tool::Dodge | Tool::Burn => (if tool == Tool::Dodge { "paint.dodge" } else { "paint.burn" }, json!({"range": o.tone_range, "exposure": o.exposure, "protectTones": o.protect_tones})), + Tool::Dodge | Tool::Burn => ( + if tool == Tool::Dodge { "paint.dodge" } else { "paint.burn" }, + json!({"range": o.tone_range, "exposure": o.exposure, "protectTones": o.protect_tones}), + ), Tool::Sponge => ("paint.sponge", json!({"mode": o.sponge_mode, "vibrance": o.vibrance})), Tool::QuickSelection => { let size = app.session.tools.brush.size; let mode = if mods.alt { "subtract" } else { "add" }; let xy: Vec<[f64; 2]> = points.iter().map(|q| [q[0], q[1]]).collect(); - let _ = app.run("select.quick", json!({"points": xy, "size": size, "mode": mode, "enhanceEdge": o.enhance_edge, "sampleAllLayers": o.sample_all_layers})); + let _ = app + .run("select.quick", json!({"points": xy, "size": size, "mode": mode, "enhanceEdge": o.enhance_edge, "sampleAllLayers": o.sample_all_layers})); return true; } _ => return false, @@ -70,8 +74,17 @@ pub fn finish_object_selection(app: &mut PhotocraftApp, start: [f64; 2], end: [f if w < 2.0 || h < 2.0 { return; } - let mode = if mods.alt { "subtract" } else if mods.shift { "add" } else { "replace" }; - let _ = app.run("select.object", json!({"rect": [x.round(), y.round(), w.round(), h.round()], "mode": mode, "sampleAllLayers": app.ui.tool_options.sample_all_layers})); + let mode = if mods.alt { + "subtract" + } else if mods.shift { + "add" + } else { + "replace" + }; + let _ = app.run( + "select.object", + json!({"rect": [x.round(), y.round(), w.round(), h.round()], "mode": mode, "sampleAllLayers": app.ui.tool_options.sample_all_layers}), + ); } /// ⌥-click with Clone Stamp / Healing Brush sets the source. diff --git a/crates/ui-egui/src/rulers.rs b/crates/ui-egui/src/rulers.rs index f42c118a48..562d339360 100644 --- a/crates/ui-egui/src/rulers.rs +++ b/crates/ui-egui/src/rulers.rs @@ -179,15 +179,18 @@ pub fn draw_rulers(app: &mut PhotocraftApp, ui: &mut egui::Ui, full: Rect, xf: & // Preferences › Units & Rulers: labels in the ruler unit (percent of the document's side). let ur = &app.session.prefs().units_and_rulers; let (unit, ppi) = (ur.rulers, ur.point_size.per_inch()); - let (dpi, size) = app.session.active().map_or((72.0, [1.0, 1.0]), |d| (d.doc.resolution_dpi.max(1.0) as f64, [d.doc.size.width as f64, d.doc.size.height as f64])); + let (dpi, size) = + app.session.active().map_or((72.0, [1.0, 1.0]), |d| (d.doc.resolution_dpi.max(1.0) as f64, [d.doc.size.width as f64, d.doc.size.height as f64])); let whole = unit == photocraft_engine::prefs::Unit::Pixels; - let label = |v: f64, step: f64| -> String { - if step >= 1.0 || whole { format!("{}", v.round() as i64) } else { crate::widgets::fmt_num2(v) } - }; + let label = |v: f64, step: f64| -> String { if step >= 1.0 || whole { format!("{}", v.round() as i64) } else { crate::widgets::fmt_num2(v) } }; for (vertical, extent) in [(false, size[0]), (true, size[1])] { let px_per_unit = unit.to_px(1.0, dpi, extent, ppi).max(1e-9); let step = nice_step(60.0 / (xf.zoom as f64 * px_per_unit).max(1e-6), whole); - let (d0, d1) = if vertical { (xf.to_doc(left.left_top())[1], xf.to_doc(left.left_bottom())[1]) } else { (xf.to_doc(top.left_top())[0], xf.to_doc(top.right_top())[0]) }; + let (d0, d1) = if vertical { + (xf.to_doc(left.left_top())[1], xf.to_doc(left.left_bottom())[1]) + } else { + (xf.to_doc(top.left_top())[0], xf.to_doc(top.right_top())[0]) + }; let (u0, u1) = (d0 / px_per_unit, d1 / px_per_unit); let mut v = (u0 / step).floor() * step; while v <= u1 { diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index e22f49d726..38a0731f51 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -17,15 +17,17 @@ pub fn parse(s: &str) -> Option { "Alt" => mods |= Modifiers::ALT, "Ctrl" => mods |= Modifiers::CTRL, "" => key = Some(Key::Plus), - k => key = Key::from_name(k).or(match k { - "=" => Some(Key::Equals), - "-" => Some(Key::Minus), - "[" => Some(Key::OpenBracket), - ";" => Some(Key::Semicolon), - "'" => Some(Key::Quote), - "]" => Some(Key::CloseBracket), - _ => None, - }), + k => { + key = Key::from_name(k).or(match k { + "=" => Some(Key::Equals), + "-" => Some(Key::Minus), + "[" => Some(Key::OpenBracket), + ";" => Some(Key::Semicolon), + "'" => Some(Key::Quote), + "]" => Some(Key::CloseBracket), + _ => None, + }) + } } } Some(KeyboardShortcut::new(mods, key?)) @@ -89,7 +91,9 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { prefs .shortcuts .iter() - .filter(|(id, sc)| !sc.is_empty() && photocraft_engine::commands::find(id).is_none() && !crate::menus::UI_COMMANDS.iter().any(|c| c.0 == id.as_str())) + .filter(|(id, sc)| { + !sc.is_empty() && photocraft_engine::commands::find(id).is_none() && !crate::menus::UI_COMMANDS.iter().any(|c| c.0 == id.as_str()) + }) .filter_map(|(id, sc)| Some((id.clone(), parse(sc)?))), ) .filter(|(_, sc)| sc.modifiers != Modifiers::NONE || !matches!(sc.logical_key, Key::X | Key::D)) @@ -201,7 +205,10 @@ mod tests { #[test] fn no_duplicate_shortcuts() { let mut seen = std::collections::HashMap::new(); - let all = photocraft_engine::command_specs().iter().filter_map(|c| c.shortcut.map(|s| (c.id, s))).chain(crate::menus::UI_COMMANDS.iter().filter_map(|(id, _, _, s)| s.map(|s| (*id, s)))); + let all = photocraft_engine::command_specs() + .iter() + .filter_map(|c| c.shortcut.map(|s| (c.id, s))) + .chain(crate::menus::UI_COMMANDS.iter().filter_map(|(id, _, _, s)| s.map(|s| (*id, s)))); for (id, s) in all { if let Some(prev) = seen.insert(s, id) { panic!("shortcut {s} bound to both {prev} and {id}"); diff --git a/crates/ui-egui/src/sizing.rs b/crates/ui-egui/src/sizing.rs index 100e5763a5..f5ddf329fd 100644 --- a/crates/ui-egui/src/sizing.rs +++ b/crates/ui-egui/src/sizing.rs @@ -10,7 +10,8 @@ use crate::PhotocraftApp; use crate::state::DialogKind; use crate::theme::Tokens; -const UNITS: [(&str, &str); 7] = [("px", "Pixels"), ("percent", "Percent"), ("in", "Inches"), ("cm", "Centimeters"), ("mm", "Millimeters"), ("pt", "Points"), ("pica", "Picas")]; +const UNITS: [(&str, &str); 7] = + [("px", "Pixels"), ("percent", "Percent"), ("in", "Inches"), ("cm", "Centimeters"), ("mm", "Millimeters"), ("pt", "Points"), ("pica", "Picas")]; /// Dialog unit for the Units & Rulers preference. fn pref_unit(u: photocraft_engine::prefs::Unit) -> &'static str { @@ -326,7 +327,17 @@ fn anchor_grid(ui: &mut egui::Ui, current: &str, dw: f64, dh: f64) -> Option<&'s let r = Rect::from_min_size(rect.min + vec2(x as f32 * cell, y as f32 * cell), vec2(cell, cell)).shrink(1.0); let resp = ui.interact(r, ui.id().with(("anchor", i)), Sense::click()); let selected = i as i32 == ci; - ui.painter().rect_filled(r, 2.0, if selected { t.field.gamma_multiply(1.6) } else if resp.hovered() { t.hover } else { t.field }); + ui.painter().rect_filled( + r, + 2.0, + if selected { + t.field.gamma_multiply(1.6) + } else if resp.hovered() { + t.hover + } else { + t.field + }, + ); ui.painter().rect_stroke(r, 2.0, Stroke::new(1.0, t.separator), StrokeKind::Inside); if selected { ui.painter().rect_filled(Rect::from_center_size(r.center(), vec2(8.0, 8.0)), 1.0, t.text); diff --git a/crates/ui-egui/src/slice_ui.rs b/crates/ui-egui/src/slice_ui.rs index 58792802a8..6bcc1d6fa7 100644 --- a/crates/ui-egui/src/slice_ui.rs +++ b/crates/ui-egui/src/slice_ui.rs @@ -145,7 +145,16 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform if selected && tool_on { painter.rect_stroke(sr, 0.0, Stroke::new(2.0, selected_color), egui::StrokeKind::Inside); if r.origin == SliceOrigin::User { - for c in [sr.left_top(), sr.right_top(), sr.left_bottom(), sr.right_bottom(), sr.center_top(), sr.center_bottom(), sr.left_center(), sr.right_center()] { + for c in [ + sr.left_top(), + sr.right_top(), + sr.left_bottom(), + sr.right_bottom(), + sr.center_top(), + sr.center_bottom(), + sr.left_center(), + sr.right_center(), + ] { painter.rect_filled(Rect::from_center_size(c, vec2(5.0, 5.0)), 0.0, selected_color); } } @@ -158,7 +167,13 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform let font = egui::FontId::proportional(10.0); let galley = painter.layout_no_wrap(label, font, Color32::WHITE); let badge = Rect::from_min_size(sr.left_top() + vec2(2.0, 2.0), galley.size() + vec2(6.0, 2.0)); - let bg = if pass { Color32::from_gray(110) } else if selected && tool_on { selected_color } else { color }; + let bg = if pass { + Color32::from_gray(110) + } else if selected && tool_on { + selected_color + } else { + color + }; painter.rect_filled(badge, 2.0, bg); painter.galley(badge.min + vec2(3.0, 1.0), galley, Color32::WHITE); } @@ -241,7 +256,8 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo } } else { let sel = app.ui.slices.selected.is_some(); - let stored = sel && app.session.active().is_some_and(|d| slices::resolve(&d.doc).iter().any(|r| Some(r.number) == app.ui.slices.selected && r.id.is_some())); + let stored = + sel && app.session.active().is_some_and(|d| slices::resolve(&d.doc).iter().any(|r| Some(r.number) == app.ui.slices.selected && r.id.is_some())); if ui.add_enabled_ui(sel && !locked, |ui| crate::widgets::secondary_button(ui, "Promote", 0.0)).inner.clicked() { let _ = selected_id(app, true).map(|id| app.run("slice.promote", json!({"slice": id}))); } diff --git a/crates/ui-egui/src/smart_ui.rs b/crates/ui-egui/src/smart_ui.rs index f1bbbe6120..d7c3e9e85d 100644 --- a/crates/ui-egui/src/smart_ui.rs +++ b/crates/ui-egui/src/smart_ui.rs @@ -22,7 +22,8 @@ pub fn thumb_badge(ui: &egui::Ui, l: &Layer, thumb: Rect) { /// The display name of a smart filter (its command's label without the ellipsis). fn filter_label(command: &str) -> String { - photocraft_engine::commands::find(command).map_or_else(|| command.rsplit('.').next().unwrap_or(command).to_string(), |c| c.label.trim_end_matches('…').to_string()) + photocraft_engine::commands::find(command) + .map_or_else(|| command.rsplit('.').next().unwrap_or(command).to_string(), |c| c.label.trim_end_matches('…').to_string()) } /// Smart Filters header + one row per filter (top filter first, as in Photoshop) under a smart diff --git a/crates/ui-egui/src/snap_ui.rs b/crates/ui-egui/src/snap_ui.rs index 665a4af8d1..14da92a31e 100644 --- a/crates/ui-egui/src/snap_ui.rs +++ b/crates/ui-egui/src/snap_ui.rs @@ -81,7 +81,20 @@ fn moving_rect(app: &PhotocraftApp) -> Option<[f64; 4]> { } fn is_point_tool(t: Tool) -> bool { - matches!(t, Tool::RectMarquee | Tool::EllipseMarquee | Tool::Crop | Tool::ObjectSelection | Tool::Pen | Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::Line | Tool::Type) + matches!( + t, + Tool::RectMarquee + | Tool::EllipseMarquee + | Tool::Crop + | Tool::ObjectSelection + | Tool::Pen + | Tool::Rectangle + | Tool::EllipseShape + | Tool::Triangle + | Tool::Polygon + | Tool::Line + | Tool::Type + ) } fn quad_rect(q: &[[f64; 2]; 4]) -> [f64; 4] { @@ -151,7 +164,9 @@ fn begin(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) { /// Round to whole pixels when Preferences › Tools asks vector tools and transforms to snap to /// the pixel grid. fn pixel_round(app: &PhotocraftApp, p: [f64; 2]) -> [f64; 2] { - if app.session.prefs().tools.snap_vector_tools_and_transforms_to_pixel_grid && matches!(app.ui.tool, Tool::Pen | Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::Line) { + if app.session.prefs().tools.snap_vector_tools_and_transforms_to_pixel_grid + && matches!(app.ui.tool, Tool::Pen | Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::Line) + { [p[0].round(), p[1].round()] } else { p @@ -244,8 +259,13 @@ pub fn draw(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) { let Some(snap) = &app.prefs_rt.snap else { return }; let smart_color = hex_color(&app.session.prefs().guides_grid_and_slices.smart_guide_color, Color32::from_rgb(255, 0, 255)); // The moved layers' bounds follow the Move tool (Free Transform draws its own box). - if let (Gesture::Move { rect }, Some(d)) = (&snap.gesture, app.drag.as_ref().and_then(|d| d.points.last()).map(|p| [p[0] - snap.start[0], p[1] - snap.start[1]])) { - let r = egui::Rect::from_two_pos(xf.to_screen((rect[0] + d[0]) as f32, (rect[1] + d[1]) as f32), xf.to_screen((rect[2] + d[0]) as f32, (rect[3] + d[1]) as f32)); + if let (Gesture::Move { rect }, Some(d)) = + (&snap.gesture, app.drag.as_ref().and_then(|d| d.points.last()).map(|p| [p[0] - snap.start[0], p[1] - snap.start[1]])) + { + let r = egui::Rect::from_two_pos( + xf.to_screen((rect[0] + d[0]) as f32, (rect[1] + d[1]) as f32), + xf.to_screen((rect[2] + d[0]) as f32, (rect[3] + d[1]) as f32), + ); painter.rect_stroke(r, 0.0, Stroke::new(1.0, crate::theme::Tokens::get(painter.ctx()).accent), egui::StrokeKind::Middle); } if !smart_on(app) { @@ -260,7 +280,11 @@ pub fn draw(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) { } fn draw_line(painter: &egui::Painter, xf: &ViewXform, l: &SnapLine, color: Color32) { - let (a, b) = if l.vertical { (xf.to_screen(l.pos as f32, l.from as f32), xf.to_screen(l.pos as f32, l.to as f32)) } else { (xf.to_screen(l.from as f32, l.pos as f32), xf.to_screen(l.to as f32, l.pos as f32)) }; + let (a, b) = if l.vertical { + (xf.to_screen(l.pos as f32, l.from as f32), xf.to_screen(l.pos as f32, l.to as f32)) + } else { + (xf.to_screen(l.from as f32, l.pos as f32), xf.to_screen(l.to as f32, l.pos as f32)) + }; painter.line_segment([pos2(a.x.round() + 0.5, a.y.round() + 0.5), pos2(b.x.round() + 0.5, b.y.round() + 0.5)], Stroke::new(1.0, color)); } @@ -376,7 +400,11 @@ mod tests { fn grid_snapping_uses_preferences() { let mut app = app_with_box(); app.ui.extras.grid = true; - app.run("prefs.set", json!({"values": {"guidesGridAndSlices.gridlineEvery": 100, "guidesGridAndSlices.gridUnit": "pixels", "guidesGridAndSlices.subdivisions": 4}})).unwrap(); + app.run( + "prefs.set", + json!({"values": {"guidesGridAndSlices.gridlineEvery": 100, "guidesGridAndSlices.gridUnit": "pixels", "guidesGridAndSlices.subdivisions": 4}}), + ) + .unwrap(); assert_eq!(options(&app).grid_step, 25.0); app.ui.tool = Tool::RectMarquee; let m = egui::Modifiers::NONE; diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 32f6490f7c..cd9e1ce1a8 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -137,7 +137,21 @@ impl Tool { } /// Retouching and painting tools that stroke with the brush (share the brush cursor and chip). pub fn is_brushlike(self) -> bool { - matches!(self, Tool::Brush | Tool::Eraser | Tool::SpotHealing | Tool::Healing | Tool::CloneStamp | Tool::HistoryBrush | Tool::Blur | Tool::Sharpen | Tool::Smudge | Tool::Dodge | Tool::Burn | Tool::Sponge) + matches!( + self, + Tool::Brush + | Tool::Eraser + | Tool::SpotHealing + | Tool::Healing + | Tool::CloneStamp + | Tool::HistoryBrush + | Tool::Blur + | Tool::Sharpen + | Tool::Smudge + | Tool::Dodge + | Tool::Burn + | Tool::Sponge + ) } /// Photoshop default single-key shortcut (`'\0'` = none, e.g. the Blur group). pub fn key(self) -> char { @@ -210,7 +224,17 @@ pub struct Panels { impl Default for Panels { fn default() -> Self { - Self { layers: true, history: false, properties: true, color: true, navigator: false, toolbar: true, options_bar: true, status_bar: true, brush_settings: false } + Self { + layers: true, + history: false, + properties: true, + color: true, + navigator: false, + toolbar: true, + options_bar: true, + status_bar: true, + brush_settings: false, + } } } diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index cd155be67e..59a0d0b069 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -334,7 +334,12 @@ pub fn apply(ctx: &egui::Context, kind: ThemeKind) { w.noninteractive.bg_stroke = Stroke::new(1.0, t.separator); w.noninteractive.fg_stroke = Stroke::new(1.0, t.text_dim); w.noninteractive.corner_radius = r; - for (wv, bg, stroke) in [(&mut w.inactive, t.field, t.field_border), (&mut w.hovered, t.hover, t.field_border), (&mut w.active, t.pressed, t.accent_border), (&mut w.open, t.hover, t.field_border)] { + for (wv, bg, stroke) in [ + (&mut w.inactive, t.field, t.field_border), + (&mut w.hovered, t.hover, t.field_border), + (&mut w.active, t.pressed, t.accent_border), + (&mut w.open, t.hover, t.field_border), + ] { wv.bg_fill = bg; wv.weak_bg_fill = bg; wv.bg_stroke = if t.bevel { Stroke::new(1.0, Color32::from_gray(64)) } else { Stroke::new(1.0, stroke) }; @@ -484,7 +489,34 @@ pub mod live { } }; } - c!(chrome, canvas, canvas_dot, dock, card, card_border, field, field_border, hover, pressed, text, text_dim, text_faint, icon, accent, accent_soft, accent_border, accent_text, separator, shadow, primary_bg, primary_text, danger, warning, tab_strip, row_selected); + c!( + chrome, + canvas, + canvas_dot, + dock, + card, + card_border, + field, + field_border, + hover, + pressed, + text, + text_dim, + text_faint, + icon, + accent, + accent_soft, + accent_border, + accent_text, + separator, + shadow, + primary_bg, + primary_text, + danger, + warning, + tab_strip, + row_selected + ); } unknown } diff --git a/crates/ui-egui/src/timeline_ui.rs b/crates/ui-egui/src/timeline_ui.rs index 43ea2229db..b21dfc3fcd 100644 --- a/crates/ui-egui/src/timeline_ui.rs +++ b/crates/ui-egui/src/timeline_ui.rs @@ -1,12 +1,12 @@ //! Window › Timeline: a floating panel that creates and drives the document timeline. All actions //! run the engine `timeline.*` commands, so the control channel drives it the same way. -use egui::{vec2, RichText}; +use egui::{RichText, vec2}; use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; +use serde_json::{Value, json}; -use crate::theme::Tokens; use crate::PhotocraftApp; +use crate::theme::Tokens; /// Timeline panel view state. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] @@ -104,12 +104,7 @@ pub fn windows(app: &mut PhotocraftApp, ctx: &egui::Context) { // Scrubber: frame playhead across the duration. if tl.duration > 1 { let mut frame = tl.current as f64; - let resp = ui.add( - egui::Slider::new(&mut frame, 0.0..=(tl.duration - 1) as f64) - .integer() - .show_value(false) - .text("playhead"), - ); + let resp = ui.add(egui::Slider::new(&mut frame, 0.0..=(tl.duration - 1) as f64).integer().show_value(false).text("playhead")); if resp.changed() { act = Some(("timeline.setFrame", json!({ "frame": frame as u64 }))); } diff --git a/crates/ui-egui/src/tone.rs b/crates/ui-egui/src/tone.rs index 04d4e0ca25..14b4fdbbfc 100644 --- a/crates/ui-egui/src/tone.rs +++ b/crates/ui-egui/src/tone.rs @@ -168,7 +168,13 @@ pub fn histogram_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { }); ui.add_space(4.0); egui::Grid::new("hist-stats").num_columns(2).spacing(vec2(12.0, 2.0)).show(ui, |ui| { - for (k, v) in [("Mean:", format!("{mean:.2}")), ("Std Dev:", format!("{:.2}", var.sqrt())), ("Median:", median.unwrap_or(0).to_string()), ("Pixels:", (size.width as u64 * size.height as u64).to_string()), ("Cache Level:", level.to_string())] { + for (k, v) in [ + ("Mean:", format!("{mean:.2}")), + ("Std Dev:", format!("{:.2}", var.sqrt())), + ("Median:", median.unwrap_or(0).to_string()), + ("Pixels:", (size.width as u64 * size.height as u64).to_string()), + ("Cache Level:", level.to_string()), + ] { ui.label(egui::RichText::new(k).color(t.text_dim).size(11.5)); ui.label(egui::RichText::new(v).font(crate::theme::mono(11.5)).color(t.text)); ui.end_row(); @@ -194,7 +200,8 @@ fn curves_params(pts: &[Vec<[f32; 2]>; 4]) -> Value { } fn lut_of(points: &[[f32; 2]]) -> Vec { - let cp: Vec = points.iter().map(|p| photocraft_doc::adjust::CurvePoint { input: p[0] / 255.0, output: p[1] / 255.0 }).collect(); + let cp: Vec = + points.iter().map(|p| photocraft_doc::adjust::CurvePoint { input: p[0] / 255.0, output: p[1] / 255.0 }).collect(); photocraft_compose::adjust::curve_lut(&cp) } @@ -245,7 +252,8 @@ pub fn curves_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: LayerId) { mesh.add_triangle(4, 6, 7); p.add(mesh); let to_scr = |q: [f32; 2]| pos2(graph.left() + q[0] / 255.0 * graph.width(), graph.bottom() - q[1] / 255.0 * graph.height()); - let to_val = |s: Pos2| [((s.x - graph.left()) / graph.width() * 255.0).clamp(0.0, 255.0), ((graph.bottom() - s.y) / graph.height() * 255.0).clamp(0.0, 255.0)]; + let to_val = + |s: Pos2| [((s.x - graph.left()) / graph.width() * 255.0).clamp(0.0, 255.0), ((graph.bottom() - s.y) / graph.height() * 255.0).clamp(0.0, 255.0)]; // Other channels' curves, faintly, when editing the composite (Photoshop shows them too). let draw_curve = |pts: &[[f32; 2]], color: Color32, width: f32| { let lut = lut_of(pts); @@ -270,7 +278,8 @@ pub fn curves_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: LayerId) { if (resp.drag_started() || resp.clicked()) && let Some(pos) = resp.interact_pointer_pos() { - let hit = cur.iter().enumerate().map(|(i, q)| (i, to_scr(*q).distance(pos))).filter(|(_, d)| *d < 9.0).min_by(|a, b| a.1.total_cmp(&b.1)).map(|(i, _)| i); + let hit = + cur.iter().enumerate().map(|(i, q)| (i, to_scr(*q).distance(pos))).filter(|(_, d)| *d < 9.0).min_by(|a, b| a.1.total_cmp(&b.1)).map(|(i, _)| i); sel = match hit { Some(i) => Some(i), None if graph.contains(pos) => { @@ -568,7 +577,12 @@ mod tests { for (a, b) in v.iter().flatten().zip(back.iter().flatten()) { assert!((a - b).abs() < 0.01, "{a} vs {b}"); } - let pts: [Vec<[f32; 2]>; 4] = [vec![[0.0, 0.0], [128.0, 150.0], [255.0, 255.0]], vec![[0.0, 10.0], [255.0, 245.0]], vec![[0.0, 0.0], [255.0, 255.0]], vec![[0.0, 0.0], [255.0, 255.0]]]; + let pts: [Vec<[f32; 2]>; 4] = [ + vec![[0.0, 0.0], [128.0, 150.0], [255.0, 255.0]], + vec![[0.0, 10.0], [255.0, 245.0]], + vec![[0.0, 0.0], [255.0, 255.0]], + vec![[0.0, 0.0], [255.0, 255.0]], + ]; let adj = photocraft_engine::commands::adjustment_from_params("curves", &curves_params(&pts)); assert_eq!(curves_points(&adj), pts); } diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs index afdd150d70..6023029856 100644 --- a/crates/ui-egui/src/transform_tool.rs +++ b/crates/ui-egui/src/transform_tool.rs @@ -63,7 +63,8 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> } let image = preview_image(&doc, id, lifted.as_ref(), b); let texture = ctx.load_texture(format!("transform-{session}"), image, egui::TextureOptions::LINEAR); - app.transform_preview = Some(TransformPreview { session, doc: Arc::new(pd), texture, opacity: layer.opacity * layer.fill_opacity, gesture: None, warp_drag: None }); + app.transform_preview = + Some(TransformPreview { session, doc: Arc::new(pd), texture, opacity: layer.opacity * layer.fill_opacity, gesture: None, warp_drag: None }); app.ui.transform = Some(TransformSession { session, layer: id.0, @@ -130,7 +131,8 @@ pub fn begin_warp(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), St /// Switch to Warp mode: a smart object's existing warp (when the box is untouched), else a /// custom mesh following the current box (so a transform made first carries over). pub fn enter_warp(app: &mut PhotocraftApp) { - let existing = app.session.active().and_then(|d| d.doc.layer(LayerId(app.ui.transform.as_ref()?.layer)).and_then(photocraft_engine::warp_cmds::smart_warp_doc_space)); + let existing = + app.session.active().and_then(|d| d.doc.layer(LayerId(app.ui.transform.as_ref()?.layer)).and_then(photocraft_engine::warp_cmds::smart_warp_doc_space)); let Some(t) = app.ui.transform.as_mut() else { return }; if t.warp.is_some() { return; @@ -397,8 +399,20 @@ fn apply_drag(s: &mut TransformSession, g: Gesture, p: [f64; 2], mods: egui::Mod let k = if sx.abs() > sy.abs() { sx.abs() } else { sy.abs() }; let (nx, ny) = (k * sx.signum(), k * sy.signum()); let (ax, ay) = if mods.alt { (pu, pv) } else { (if mu.0 { r[2] } else { r[0] }, if mv.0 { r[3] } else { r[1] }) }; - let fx = if mods.alt { 0.5 } else if mu.0 { 1.0 } else { 0.0 }; - let fy = if mods.alt { 0.5 } else if mv.0 { 1.0 } else { 0.0 }; + let fx = if mods.alt { + 0.5 + } else if mu.0 { + 1.0 + } else { + 0.0 + }; + let fy = if mods.alt { + 0.5 + } else if mv.0 { + 1.0 + } else { + 0.0 + }; r[0] = ax - nx * fx; r[2] = r[0] + nx; r[1] = ay - ny * fy; @@ -813,7 +827,16 @@ mod tests { use super::*; fn session() -> TransformSession { - TransformSession { session: 1, layer: 1, rect: [0.0, 0.0, 100.0, 50.0], quad: corners([0.0, 0.0, 100.0, 50.0]), pivot: [50.0, 25.0], interpolation: "bicubic".into(), warp: None, selection: false } + TransformSession { + session: 1, + layer: 1, + rect: [0.0, 0.0, 100.0, 50.0], + quad: corners([0.0, 0.0, 100.0, 50.0]), + pivot: [50.0, 25.0], + interpolation: "bicubic".into(), + warp: None, + selection: false, + } } fn drag(s: &mut TransformSession, from: [f64; 2], to: [f64; 2], mods: egui::Modifiers) { diff --git a/crates/ui-egui/src/type_panels_ui.rs b/crates/ui-egui/src/type_panels_ui.rs index b486c7a780..e639490c96 100644 --- a/crates/ui-egui/src/type_panels_ui.rs +++ b/crates/ui-egui/src/type_panels_ui.rs @@ -50,7 +50,14 @@ pub struct SpellDialog { pub message: String, } -const IDS: [&str; 6] = ["type.panels.characterStyles", "window.panel.characterStyles", "type.panels.paragraphStyles", "window.panel.paragraphStyles", "type.panels.glyphs", "window.panel.glyphs"]; +const IDS: [&str; 6] = [ + "type.panels.characterStyles", + "window.panel.characterStyles", + "type.panels.paragraphStyles", + "window.panel.paragraphStyles", + "type.panels.glyphs", + "window.panel.glyphs", +]; /// Menu ids handled here (live menu items). pub fn handles(id: &str) -> bool { @@ -214,7 +221,11 @@ pub fn styles_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui, paragraph: bool) let Ok(list) = app.session.execute(&format!("{prefix}.list"), tgt.clone()) else { return }; let styles = list["styles"].as_array().cloned().unwrap_or_default(); let cur = &list["current"]; - let (cur_id, over) = if paragraph { (cur["paragraph"].as_u64(), cur["paragraphOverride"].as_bool().unwrap_or(false)) } else { (cur["character"].as_u64(), cur["characterOverride"].as_bool().unwrap_or(false)) }; + let (cur_id, over) = if paragraph { + (cur["paragraph"].as_u64(), cur["paragraphOverride"].as_bool().unwrap_or(false)) + } else { + (cur["character"].as_u64(), cur["characterOverride"].as_bool().unwrap_or(false)) + }; let has_target = !cur.is_null(); let selected = app.ui.type_panels.options.filter(|o| o.0 == paragraph).map(|o| u64::from(o.1)).or(cur_id); let mut action: Option<(String, Value)> = None; @@ -254,7 +265,11 @@ pub fn styles_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui, paragraph: bool) app.ui.type_panels.options = Some((paragraph, id as u32)); } } - resp.on_hover_text(if id == 0 && !paragraph { "No character style" } else { "Click to apply, ⌥-click to clear overrides, double-click for Style Options" }); + resp.on_hover_text(if id == 0 && !paragraph { + "No character style" + } else { + "Click to apply, ⌥-click to clear overrides, double-click for Style Options" + }); } }); ui.add_space(4.0); @@ -266,7 +281,10 @@ pub fn styles_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui, paragraph: bool) if crate::icons::button(ui, "ban", 22.0, false, "Clear Override").clicked() && has_target && over { action = Some((format!("{prefix}.clearOverride"), tgt.clone())); } - if crate::icons::button(ui, "check", 22.0, false, "Redefine style by current selection").clicked() && has_target && cur_id.is_some_and(|i| i != 0 || paragraph) { + if crate::icons::button(ui, "check", 22.0, false, "Redefine style by current selection").clicked() + && has_target + && cur_id.is_some_and(|i| i != 0 || paragraph) + { action = Some((format!("{prefix}.redefine"), tgt.clone())); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { @@ -411,7 +429,15 @@ fn options_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, paragraph: bool, s } } if paragraph { - let rows: &[(&str, &str, &str)] = &[("Align", "align", "align"), ("Indent First", "first_line_indent_pt", "pt"), ("Indent Left", "start_indent_pt", "pt"), ("Indent Right", "end_indent_pt", "pt"), ("Space Before", "space_before_pt", "pt"), ("Space After", "space_after_pt", "pt"), ("Hyphenate", "hyphenate", "bool")]; + let rows: &[(&str, &str, &str)] = &[ + ("Align", "align", "align"), + ("Indent First", "first_line_indent_pt", "pt"), + ("Indent Left", "start_indent_pt", "pt"), + ("Indent Right", "end_indent_pt", "pt"), + ("Space Before", "space_before_pt", "pt"), + ("Space After", "space_after_pt", "pt"), + ("Hyphenate", "hyphenate", "bool"), + ]; for &(label, key, kind) in rows { let mut on = defined(¶_attrs, key); let was = on; @@ -657,7 +683,11 @@ pub fn glyphs_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // Footer: family name, size slider. ui.add_space(4.0); ui.horizontal(|ui| { - let info = selected.as_deref().and_then(|s| s.chars().next()).map(|c| format!("U+{:04X} · {} glyphs", c as u32, chars.len())).unwrap_or_else(|| format!("{} glyphs", chars.len())); + let info = selected + .as_deref() + .and_then(|s| s.chars().next()) + .map(|c| format!("U+{:04X} · {} glyphs", c as u32, chars.len())) + .unwrap_or_else(|| format!("{} glyphs", chars.len())); ui.label(RichText::new(info).color(t.text_faint).size(10.5)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let mut s = size; @@ -724,7 +754,13 @@ fn spelling_window(app: &mut PhotocraftApp, ctx: &egui::Context) { ui.set_width(250.0); ui.label(RichText::new("Not in Dictionary:").color(t.text_dim).size(11.5)); let word = item.as_ref().and_then(|i| i["word"].as_str()).unwrap_or(""); - let shown = if item.is_some() { word.to_string() } else if d.items.is_empty() || d.index >= d.items.len() { "".into() } else { String::new() }; + let shown = if item.is_some() { + word.to_string() + } else if d.items.is_empty() || d.index >= d.items.len() { + "".into() + } else { + String::new() + }; ui.add_enabled(false, egui::TextEdit::singleline(&mut shown.clone()).desired_width(240.0)); ui.add_space(4.0); ui.label(RichText::new("Change To:").color(t.text_dim).size(11.5)); @@ -763,7 +799,8 @@ fn spelling_window(app: &mut PhotocraftApp, ctx: &egui::Context) { } ui.add_space(6.0); ui.add_enabled_ui(item.is_some(), |ui| { - for (label, a) in [("Ignore", "ignore"), ("Ignore All", "ignoreAll"), ("Change", "change"), ("Change All", "changeAll"), ("Add", "add")] { + for (label, a) in [("Ignore", "ignore"), ("Ignore All", "ignoreAll"), ("Change", "change"), ("Change All", "changeAll"), ("Add", "add")] + { if crate::widgets::secondary_button(ui, label, 100.0).clicked() { act = Some(a); } diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index 05fd1a3bc6..a50e42d5fb 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -58,7 +58,8 @@ fn to_text(aff: &Affine, x: f64, y: f64) -> (f32, f32) { fn hit_layer(app: &mut PhotocraftApp, x: f64, y: f64) -> Option { let doc = app.session.active()?.doc.clone(); let slop = 6.0 / app.current_zoom().max(0.01); - let mut ids: Vec = doc.walk().into_iter().filter(|(_, _, l)| l.visible && matches!(l.content, LayerContent::Text(_))).map(|(_, _, l)| l.id).collect(); + let mut ids: Vec = + doc.walk().into_iter().filter(|(_, _, l)| l.visible && matches!(l.content, LayerContent::Text(_))).map(|(_, _, l)| l.id).collect(); ids.reverse(); // walk() is bottom-up; hit the topmost first ids.into_iter().find(|id| { let Some((l, aff, _)) = layout(app, *id) else { return false }; @@ -409,7 +410,8 @@ pub fn draw_overlay(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewX if a < b { let fill = Color32::from_rgba_unmultiplied(t.accent.r(), t.accent.g(), t.accent.b(), 110); for (li, ln) in l.lines.iter().enumerate() { - let xs: Vec<(f32, f32)> = l.clusters.iter().filter(|c| c.line == li && c.range.start >= a && c.range.end <= b).map(|c| (c.x, c.x + c.advance)).collect(); + let xs: Vec<(f32, f32)> = + l.clusters.iter().filter(|c| c.line == li && c.range.start >= a && c.range.end <= b).map(|c| (c.x, c.x + c.advance)).collect(); let (mut x0, mut x1) = xs.iter().fold((f32::MAX, f32::MIN), |(lo, hi), (p, q)| (lo.min(*p), hi.max(*q))); // A selected line break shows as a small sliver past the line end. if b > ln.range.end && a <= ln.range.end { @@ -560,7 +562,13 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { apply(app, json!({"size": size})); } let mut aa = o.type_aa.clone(); - let aa_opts = [("none".to_string(), "None"), ("sharp".to_string(), "Sharp"), ("crisp".to_string(), "Crisp"), ("strong".to_string(), "Strong"), ("smooth".to_string(), "Smooth")]; + let aa_opts = [ + ("none".to_string(), "None"), + ("sharp".to_string(), "Sharp"), + ("crisp".to_string(), "Crisp"), + ("strong".to_string(), "Strong"), + ("smooth".to_string(), "Smooth"), + ]; if crate::widgets::dropdown(ui, "type-aa", &mut aa, &aa_opts, 80.0) { app.ui.tool_options.type_aa = aa.clone(); if let Some((layer, _)) = target(app) { @@ -573,7 +581,9 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } crate::widgets::vline(ui, 22.0); ui.spacing_mut().item_spacing.x = 2.0; - for (align, icon, tip) in [("left", "align-left", "Left align text"), ("center", "align-center", "Center text"), ("right", "align-right", "Right align text")] { + for (align, icon, tip) in + [("left", "align-left", "Left align text"), ("center", "align-center", "Center text"), ("right", "align-right", "Right align text")] + { if crate::icons::button(ui, icon, 24.0, o.type_align == align, tip).clicked() { app.ui.tool_options.type_align = align.into(); apply(app, json!({"align": align})); @@ -582,7 +592,14 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.spacing_mut().item_spacing.x = 8.0; crate::widgets::vline(ui, 22.0); // Text colour swatch with a picker popup. - let c = shown.as_ref().map(|s| s.3).map(|c| { let v = c.to_rgb(); [v[0], v[1], v[2], 1.0] }).unwrap_or(app.session.tools.foreground); + let c = shown + .as_ref() + .map(|s| s.3) + .map(|c| { + let v = c.to_rgb(); + [v[0], v[1], v[2], 1.0] + }) + .unwrap_or(app.session.tools.foreground); let (rect, resp) = ui.allocate_exact_size(egui::vec2(28.0, 18.0), egui::Sense::click()); let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; ui.painter().rect_filled(rect, 2.0, Color32::from_rgb(q(c[0]), q(c[1]), q(c[2]))); @@ -722,7 +739,13 @@ pub fn type_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } .to_string(); icon_label(ui, "text-cursor", "Kerning"); - if crate::widgets::dropdown(ui, "props-kern", &mut k, &[("metrics".to_string(), "Metrics"), ("optical".to_string(), "Optical"), ("off".to_string(), "0")], w) { + if crate::widgets::dropdown( + ui, + "props-kern", + &mut k, + &[("metrics".to_string(), "Metrics"), ("optical".to_string(), "Optical"), ("off".to_string(), "0")], + w, + ) { apply(app, json!({"kerning": k})); } if let Some(v) = num_field(ui, "VA", "Tracking (1/1000 em)", c.tracking, -1000.0..=10000.0, "", w) { @@ -763,14 +786,30 @@ pub fn type_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let toggles: [(&str, &str, bool, serde_json::Value); 6] = [ ("T", "Faux Bold", c.faux_bold, json!({"fauxBold": !c.faux_bold})), ("T", "Faux Italic", c.faux_italic, json!({"fauxItalic": !c.faux_italic})), - ("TT", "All Caps", caps == photocraft_doc::text::Caps::AllCaps, json!({"caps": if caps == photocraft_doc::text::Caps::AllCaps { "normal" } else { "allCaps" }})), - ("Tᴛ", "Small Caps", caps == photocraft_doc::text::Caps::SmallCaps, json!({"caps": if caps == photocraft_doc::text::Caps::SmallCaps { "normal" } else { "smallCaps" }})), + ( + "TT", + "All Caps", + caps == photocraft_doc::text::Caps::AllCaps, + json!({"caps": if caps == photocraft_doc::text::Caps::AllCaps { "normal" } else { "allCaps" }}), + ), + ( + "Tᴛ", + "Small Caps", + caps == photocraft_doc::text::Caps::SmallCaps, + json!({"caps": if caps == photocraft_doc::text::Caps::SmallCaps { "normal" } else { "smallCaps" }}), + ), ("T", "Underline", c.underline, json!({"underline": !c.underline})), ("T", "Strikethrough", c.strikethrough, json!({"strikethrough": !c.strikethrough})), ]; for (i, (glyph, tip, on, props)) in toggles.into_iter().enumerate() { let (r, resp) = ui.allocate_exact_size(egui::vec2(28.0, 24.0), egui::Sense::click()); - let bg = if on { t.accent_soft } else if resp.hovered() { t.hover } else { Color32::TRANSPARENT }; + let bg = if on { + t.accent_soft + } else if resp.hovered() { + t.hover + } else { + Color32::TRANSPARENT + }; ui.painter().rect_filled(r, 3.0, bg); let font = if i == 0 { crate::theme::semibold(13.0) } else { egui::FontId::proportional(13.0) }; let col = if on { t.text } else { t.text_dim }; @@ -813,7 +852,17 @@ pub fn type_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui) { for (key, tip, a) in items { let (r, resp) = ui.allocate_exact_size(egui::vec2(26.0, 24.0), egui::Sense::click()); let on = para.align == a; - ui.painter().rect_filled(r, 3.0, if on { t.accent_soft } else if resp.hovered() { t.hover } else { Color32::TRANSPARENT }); + ui.painter().rect_filled( + r, + 3.0, + if on { + t.accent_soft + } else if resp.hovered() { + t.hover + } else { + Color32::TRANSPARENT + }, + ); if on { ui.painter().rect_stroke(r, 3.0, Stroke::new(1.0, t.accent_border), egui::StrokeKind::Inside); } diff --git a/crates/ui-egui/src/variables_ui.rs b/crates/ui-egui/src/variables_ui.rs index 766f3a23e1..8a21b3b7e8 100644 --- a/crates/ui-egui/src/variables_ui.rs +++ b/crates/ui-egui/src/variables_ui.rs @@ -20,11 +20,7 @@ pub fn owns(fields: &Map) -> bool { /// Layers available to bind, top to bottom: (id, display name, is-text). fn layer_options(app: &PhotocraftApp) -> Vec<(u64, String, bool)> { let Some(st) = app.session.active() else { return Vec::new() }; - st.doc - .walk() - .iter() - .map(|(_, _, l)| (l.id.0, l.name.clone(), matches!(l.content, photocraft_doc::LayerContent::Text(_)))) - .collect() + st.doc.walk().iter().map(|(_, _, l)| (l.id.0, l.name.clone(), matches!(l.content, photocraft_doc::LayerContent::Text(_)))).collect() } /// Menu entry points. Returns None for ids this module doesn't own. @@ -191,7 +187,13 @@ fn data_sets_page(ui: &mut egui::Ui, _t: &Tokens, state: &mut Value, fields: &mu let def_meta: Vec<(String, String)> = state .get("defs") .and_then(Value::as_array) - .map(|a| a.iter().map(|d| (d.get("name").and_then(Value::as_str).unwrap_or("").to_string(), d.get("type").and_then(Value::as_str).unwrap_or("visibility").to_string())).collect()) + .map(|a| { + a.iter() + .map(|d| { + (d.get("name").and_then(Value::as_str).unwrap_or("").to_string(), d.get("type").and_then(Value::as_str).unwrap_or("visibility").to_string()) + }) + .collect() + }) .unwrap_or_default(); if def_meta.is_empty() { ui.weak("Define at least one variable first."); @@ -296,7 +298,7 @@ pub fn confirm(app: &mut PhotocraftApp, fields: &Map) -> Result 0.0 => json!({"kind": "roundedRect", "rect": rect, "radii": vec![o.corner_radius; 4], "fill": fill, "stroke": stroke}), + Tool::Rectangle if o.corner_radius > 0.0 => { + json!({"kind": "roundedRect", "rect": rect, "radii": vec![o.corner_radius; 4], "fill": fill, "stroke": stroke}) + } Tool::Rectangle => json!({"kind": "rect", "rect": rect, "fill": fill, "stroke": stroke}), Tool::EllipseShape => json!({"kind": "ellipse", "rect": rect, "fill": fill, "stroke": stroke}), Tool::Triangle => json!({"kind": "polygon", "rect": rect, "sides": 3, "fill": fill, "stroke": stroke}), @@ -131,7 +133,8 @@ pub fn pen_commit(app: &mut PhotocraftApp, closed: bool) { let path = pen_to_json(&pen, closed); let r = if app.ui.tool_options.vector_mode == "shape" { let fill = if closed && app.ui.tool_options.shape_fill { json!(hex(app.session.tools.foreground)) } else { Value::Null }; - let stroke = if closed { stroke_param(app) } else { json!({"width": app.ui.tool_options.stroke_width.max(1.0), "color": hex(app.session.tools.foreground)}) }; + let stroke = + if closed { stroke_param(app) } else { json!({"width": app.ui.tool_options.stroke_width.max(1.0), "color": hex(app.session.tools.foreground)}) }; app.run("shape.create", json!({"kind": "path", "path": path, "fill": fill, "stroke": stroke})) } else { app.run("path.set", json!({"name": "work", "path": path})) @@ -191,7 +194,8 @@ fn path_lines(path: &Path, xf: &dyn Fn([f64; 2]) -> Pos2) -> Vec<(Vec, boo for i in 0..segs { let (a, b) = (&s.knots[i], &s.knots[(i + 1) % n]); for k in 0..=16 { - let q = bezier([a.anchor.x, a.anchor.y], [a.out_ctrl.x, a.out_ctrl.y], [b.in_ctrl.x, b.in_ctrl.y], [b.anchor.x, b.anchor.y], k as f64 / 16.0); + let q = + bezier([a.anchor.x, a.anchor.y], [a.out_ctrl.x, a.out_ctrl.y], [b.in_ctrl.x, b.in_ctrl.y], [b.anchor.x, b.anchor.y], k as f64 / 16.0); pts.push(xf(q)); } } @@ -424,7 +428,11 @@ pub fn shape_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: photocra ui.add_space(12.0); ui.label(egui::RichText::new("Stroke").color(t.text_dim).size(12.0)); if let Some(c) = swatch(ui, sh.stroke.as_ref().map(|s| &s.paint), "Set shape stroke type") { - edit = Some(if c == "none" { json!({"stroke": null}) } else { json!({"stroke": {"color": c, "width": sh.stroke.as_ref().map_or(3.0, |s| s.width)}, "coalesce": key("stroke")}) }); + edit = Some(if c == "none" { + json!({"stroke": null}) + } else { + json!({"stroke": {"color": c, "width": sh.stroke.as_ref().map_or(3.0, |s| s.width)}, "coalesce": key("stroke")}) + }); } let mut w = sh.stroke.as_ref().map_or(0.0, |s| s.width); if crate::widgets::value_field(ui, &mut w, 0.0..=288.0, "px", 60.0).changed() { @@ -451,7 +459,9 @@ pub fn shape_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: photocra crate::widgets::value_field(ui, v, range, unit, 64.0).changed() }; match live { - photocraft_doc::vector::LiveShape::Rect { rect, .. } | photocraft_doc::vector::LiveShape::Ellipse { rect } | photocraft_doc::vector::LiveShape::Polygon { rect, .. } => { + photocraft_doc::vector::LiveShape::Rect { rect, .. } + | photocraft_doc::vector::LiveShape::Ellipse { rect } + | photocraft_doc::vector::LiveShape::Polygon { rect, .. } => { ui.horizontal(|ui| { let (mut w, mut h) = (rect[2] as f32, rect[3] as f32); let cw = num(ui, "W", &mut w, 1.0..=300000.0, "px"); diff --git a/crates/ui-egui/src/view_cmds.rs b/crates/ui-egui/src/view_cmds.rs index f80ed875d9..b8facbd13e 100644 --- a/crates/ui-egui/src/view_cmds.rs +++ b/crates/ui-egui/src/view_cmds.rs @@ -34,7 +34,21 @@ pub struct Show { impl Default for Show { fn default() -> Self { - Self { layer_edges: false, selection_edges: true, target_path: true, pixel_grid: true, smart_guides: true, brush_preview: true, slices: true, notes: true, mesh: false, edit_pins: true, count: true, canvas_guides: true, artboard_guides: true } + Self { + layer_edges: false, + selection_edges: true, + target_path: true, + pixel_grid: true, + smart_guides: true, + brush_preview: true, + slices: true, + notes: true, + mesh: false, + edit_pins: true, + count: true, + canvas_guides: true, + artboard_guides: true, + } } } @@ -120,7 +134,18 @@ const PIXEL_ASPECTS: [&str; 8] = ["square", "d1DvNtsc", "d1DvPal", "d1DvNtscWide const FONT_PREVIEW: [&str; 5] = ["small", "medium", "large", "extraLarge", "huge"]; const LANGUAGE: [&str; 3] = ["defaultFeatures", "eastAsianFeatures", "middleEasternFeatures"]; /// Window › Arrange layouts drawn by [`cells`]. -const LAYOUTS: [&str; 10] = ["tileAllVertically", "tileAllHorizontally", "twoUpVertical", "twoUpHorizontal", "threeUpVertical", "threeUpHorizontal", "threeUpStacked", "fourUp", "sixUp", "tile"]; +const LAYOUTS: [&str; 10] = [ + "tileAllVertically", + "tileAllHorizontally", + "twoUpVertical", + "twoUpHorizontal", + "threeUpVertical", + "threeUpHorizontal", + "threeUpStacked", + "fourUp", + "sixUp", + "tile", +]; /// Pixel aspect ratio (width / height of a pixel) of a preset id. pub fn pixel_aspect_ratio(id: &str) -> f32 { @@ -184,7 +209,9 @@ fn panel_tab(app: &PhotocraftApp, id: &str) -> Option<(&'static str, usize)> { "window.panel.paths" => ("layers", 2), "window.panel.layers" => ("layers", 0), "window.panel.adjustments" => ("properties", 1), - "window.panel.properties" | "window.panel.character" | "window.panel.paragraph" | "type.panels.character" | "type.panels.paragraph" => ("properties", 0), + "window.panel.properties" | "window.panel.character" | "window.panel.paragraph" | "type.panels.character" | "type.panels.paragraph" => { + ("properties", 0) + } "window.panel.swatches" => ("color", usize::from(pro)), "window.panel.color" => ("color", usize::from(!pro)), _ => return None, @@ -223,7 +250,19 @@ pub fn handles(id: &str) -> bool { return LANGUAGE.contains(&k) || k == "middleEasternAndSouthAsianComposer"; } if let Some(k) = id.strip_prefix("window.arrange.") { - return LAYOUTS.contains(&k) || matches!(k, "consolidateAllToTabs" | "cascade" | "floatInWindow" | "floatAllInWindows" | "newWindowForDocument" | "matchZoom" | "matchLocation" | "matchRotation" | "matchAll"); + return LAYOUTS.contains(&k) + || matches!( + k, + "consolidateAllToTabs" + | "cascade" + | "floatInWindow" + | "floatAllInWindows" + | "newWindowForDocument" + | "matchZoom" + | "matchLocation" + | "matchRotation" + | "matchAll" + ); } matches!( id, @@ -269,7 +308,10 @@ pub fn is_enabled(app: &PhotocraftApp, id: &str) -> Option { "consolidateAllToTabs" => true, "floatInWindow" | "newWindowForDocument" => doc, "matchZoom" | "matchLocation" | "matchRotation" | "matchAll" => app.session.documents().len() > 1 || !app.ui.windows.is_empty(), - _ => app.session.documents().len() > 1 || (doc && matches!(&i["window.arrange.".len()..], "floatAllInWindows" | "cascade" | "tile" | "tileAllVertically" | "tileAllHorizontally")), + _ => { + app.session.documents().len() > 1 + || (doc && matches!(&i["window.arrange.".len()..], "floatAllInWindows" | "cascade" | "tile" | "tileAllVertically" | "tileAllHorizontally")) + } }, i if wraps(i) => app.session.is_enabled(i), _ => true, @@ -296,7 +338,11 @@ pub fn checked(app: &PhotocraftApp, id: &str) -> Option { return FONT_PREVIEW.contains(&k).then(|| o.font_preview_size == k); } if let Some(k) = id.strip_prefix("type.languageOptions.") { - return if k == "middleEasternAndSouthAsianComposer" { Some(o.middle_eastern_composer) } else { LANGUAGE.contains(&k).then(|| o.language_features == k) }; + return if k == "middleEasternAndSouthAsianComposer" { + Some(o.middle_eastern_composer) + } else { + LANGUAGE.contains(&k).then(|| o.language_features == k) + }; } if let Some(k) = id.strip_prefix("window.arrange.") && LAYOUTS.contains(&k) @@ -593,7 +639,9 @@ pub fn cells(layout: &str, rect: egui::Rect, n: usize) -> Option } let grid = |cols: usize, rows: usize, count: usize| -> Vec { let (w, h) = (rect.width() / cols as f32, rect.height() / rows as f32); - (0..count.min(cols * rows)).map(|i| egui::Rect::from_min_size(rect.min + egui::vec2((i % cols) as f32 * w, (i / cols) as f32 * h), egui::vec2(w, h))).collect() + (0..count.min(cols * rows)) + .map(|i| egui::Rect::from_min_size(rect.min + egui::vec2((i % cols) as f32 * w, (i / cols) as f32 * h), egui::vec2(w, h))) + .collect() }; Some(match layout { "tileAllVertically" => grid(n, 1, n), @@ -699,11 +747,7 @@ pub fn form_body(ui: &mut egui::Ui, f: &mut Map) { /// Folder the batch dialogs start from: next to the active document, else the working directory. fn default_dir(app: &PhotocraftApp) -> String { - app.session - .active() - .and_then(|d| d.path.as_deref()) - .and_then(|p| p.rfind(['/', '\\']).map(|i| p[..i].to_string())) - .unwrap_or_else(|| ".".into()) + app.session.active().and_then(|d| d.path.as_deref()).and_then(|p| p.rfind(['/', '\\']).map(|i| p[..i].to_string())).unwrap_or_else(|| ".".into()) } fn front(app: &mut PhotocraftApp, id: &str, params: &Value) -> Option> { @@ -765,15 +809,29 @@ fn front(app: &mut PhotocraftApp, id: &str, params: &Value) -> Option dialog(app, json!({"from": "any", "to": "rgb"}), json!({"from": ["any", "rgb", "grayscale", "cmyk", "lab", "indexed", "bitmap", "duotone", "multichannel"], "to": ["rgb", "grayscale", "cmyk", "lab"]})), - "view.newGuideLayout" => dialog(app, json!({"columns": 8, "gutter": 20, "rows": 0, "rowGutter": 0, "margin": 0, "centerColumns": false, "clearExisting": false}), json!({})), + "file.automate.conditionalModeChange" => dialog( + app, + json!({"from": "any", "to": "rgb"}), + json!({"from": ["any", "rgb", "grayscale", "cmyk", "lab", "indexed", "bitmap", "duotone", "multichannel"], "to": ["rgb", "grayscale", "cmyk", "lab"]}), + ), + "view.newGuideLayout" => { + dialog(app, json!({"columns": 8, "gutter": 20, "rows": 0, "rowGutter": 0, "margin": 0, "centerColumns": false, "clearExisting": false}), json!({})) + } "type.warpText" => { let styles: Vec<&str> = std::iter::once("none").chain(photocraft_text::warp::STYLES.iter().map(|(_, s)| *s)).collect(); - dialog(app, json!({"style": "arc", "orientation": "horizontal", "bend": 50.0, "horizontalDistortion": 0.0, "verticalDistortion": 0.0}), json!({"style": styles, "orientation": ["horizontal", "vertical"]})) + dialog( + app, + json!({"style": "arc", "orientation": "horizontal", "bend": 50.0, "horizontalDistortion": 0.0, "verticalDistortion": 0.0}), + json!({"style": styles, "orientation": ["horizontal", "vertical"]}), + ) } "file.export.layersToFiles" => { let (_, _, name) = doc?; - dialog(app, json!({"dir": dir, "prefix": name.rsplit_once('.').map_or(name.as_str(), |(a, _)| a), "format": "png", "visibleOnly": true}), json!({"format": ["png", "jpg", "psd", "tiff", "webp", "bmp"]})) + dialog( + app, + json!({"dir": dir, "prefix": name.rsplit_once('.').map_or(name.as_str(), |(a, _)| a), "format": "png", "visibleOnly": true}), + json!({"format": ["png", "jpg", "psd", "tiff", "webp", "bmp"]}), + ) } "file.export.layerCompsToFiles" | "file.export.artboardsToFiles" => { let (_, _, name) = doc?; @@ -793,7 +851,11 @@ fn front(app: &mut PhotocraftApp, id: &str, params: &Value) -> Option { let (w, h, _) = doc?; let presets: Vec<&str> = std::iter::once("").chain(photocraft_engine::artboard_cmds::PRESETS.iter().map(|p| p.0)).collect(); - dialog(app, json!({"name": "", "preset": "", "width": w, "height": h, "background": "white"}), json!({"preset": presets, "background": ["white", "black", "transparent"]})) + dialog( + app, + json!({"name": "", "preset": "", "width": w, "height": h, "background": "white"}), + json!({"preset": presets, "background": ["white", "black", "transparent"]}), + ) } "file.export.colorLookupTables" => { let (_, _, name) = doc?; @@ -817,7 +879,11 @@ fn front(app: &mut PhotocraftApp, id: &str, params: &Value) -> Option dialog(app, json!({"input": dir, "output": format!("{dir}/processed"), "format": "jpg", "quality": 8, "width": 0, "height": 0, "convertToSrgb": true}), json!({"format": ["jpg", "png", "psd", "tiff"]})), + "file.scripts.imageProcessor" => dialog( + app, + json!({"input": dir, "output": format!("{dir}/processed"), "format": "jpg", "quality": 8, "width": 0, "height": 0, "convertToSrgb": true}), + json!({"format": ["jpg", "png", "psd", "tiff"]}), + ), "file.automate.batch" => { let a = &app.ui.actions; let action = a.selected.and_then(|i| a.list.get(i)).or(a.list.first()); @@ -826,7 +892,11 @@ fn front(app: &mut PhotocraftApp, id: &str, params: &Value) -> Option = action.steps.iter().map(|(id, p)| json!([id, p])).collect(); let name = action.name.clone(); - dialog(app, json!({"action": name, "steps": steps, "input": dir, "output": format!("{dir}/batch"), "format": "same"}), json!({"format": ["same", "png", "jpg", "psd", "tiff"]})) + dialog( + app, + json!({"action": name, "steps": steps, "input": dir, "output": format!("{dir}/batch"), "format": "same"}), + json!({"format": ["same", "png", "jpg", "psd", "tiff"]}), + ) } // Channel workflow dialogs (see channels_panel.rs for the channel references). "select.saveSelection" | "select.loadSelection" | "image.applyImage" | "image.calculations" => { @@ -839,21 +909,54 @@ fn front(app: &mut PhotocraftApp, id: &str, params: &Value) -> Option = std::iter::once("new".to_string()).chain(d.channels.iter().map(|c| c.name.clone())).collect(); - let blends = ["multiply", "screen", "normal", "overlay", "softLight", "hardLight", "darken", "lighten", "colorDodge", "colorBurn", "linearBurn", "linearDodge", "difference", "exclusion", "add", "subtract"]; + let blends = [ + "multiply", + "screen", + "normal", + "overlay", + "softLight", + "hardLight", + "darken", + "lighten", + "colorDodge", + "colorBurn", + "linearBurn", + "linearDodge", + "difference", + "exclusion", + "add", + "subtract", + ]; let mut load = chans.clone(); load.extend(["transparency", "mask", "selection"].map(String::from)); match id { - "select.saveSelection" => dialog(app, json!({"channel": "new", "name": "", "operation": "new"}), json!({"channel": alphas, "operation": ["new", "replace", "add", "subtract", "intersect"]})), + "select.saveSelection" => dialog( + app, + json!({"channel": "new", "name": "", "operation": "new"}), + json!({"channel": alphas, "operation": ["new", "replace", "add", "subtract", "intersect"]}), + ), "select.loadSelection" => { let first = d.channels.first().map_or_else(|| chans[0].clone(), |c| c.name.clone()); - dialog(app, json!({"channel": first, "invert": false, "operation": "new"}), json!({"channel": load, "operation": ["new", "add", "subtract", "intersect"]})) + dialog( + app, + json!({"channel": first, "invert": false, "operation": "new"}), + json!({"channel": load, "operation": ["new", "add", "subtract", "intersect"]}), + ) } "image.applyImage" => { let mut masks = vec!["none".to_string()]; masks.extend(load.iter().cloned()); - dialog(app, json!({"sourceChannel": chans[0], "sourceInvert": false, "blending": "multiply", "opacity": 100.0, "preserveTransparency": false, "maskChannel": "none"}), json!({"sourceChannel": load, "blending": blends, "maskChannel": masks})) + dialog( + app, + json!({"sourceChannel": chans[0], "sourceInvert": false, "blending": "multiply", "opacity": 100.0, "preserveTransparency": false, "maskChannel": "none"}), + json!({"sourceChannel": load, "blending": blends, "maskChannel": masks}), + ) } - _ => dialog(app, json!({"source1Channel": chans[0], "source1Invert": false, "source2Channel": chans[0], "source2Invert": false, "blending": "multiply", "opacity": 100.0, "result": "newChannel"}), json!({"source1Channel": load.clone(), "source2Channel": load, "blending": blends, "result": ["newChannel", "newDocument", "selection"]})), + _ => dialog( + app, + json!({"source1Channel": chans[0], "source1Invert": false, "source2Channel": chans[0], "source2Invert": false, "blending": "multiply", "opacity": 100.0, "result": "newChannel"}), + json!({"source1Channel": load.clone(), "source2Channel": load, "blending": blends, "result": ["newChannel", "newDocument", "selection"]}), + ), } } _ => None, diff --git a/crates/ui-egui/src/view_cmds/tests.rs b/crates/ui-egui/src/view_cmds/tests.rs index d1cb4202a0..b639243b4e 100644 --- a/crates/ui-egui/src/view_cmds/tests.rs +++ b/crates/ui-egui/src/view_cmds/tests.rs @@ -17,7 +17,17 @@ fn checked(app: &PhotocraftApp, id: &str) -> Option { #[test] fn show_snap_extras_and_screen_modes_are_state() { let (mut app, ctx) = app_with(1); - for id in ["view.show.layerEdges", "view.show.pixelGrid", "view.snapTo.layers", "view.extras", "view.screenMode.fullScreen", "view.flipHorizontal", "window.arrange.fourUp", "type.fontPreviewSize.huge", "type.languageOptions.eastAsianFeatures"] { + for id in [ + "view.show.layerEdges", + "view.show.pixelGrid", + "view.snapTo.layers", + "view.extras", + "view.screenMode.fullScreen", + "view.flipHorizontal", + "window.arrange.fourUp", + "type.fontPreviewSize.huge", + "type.languageOptions.eastAsianFeatures", + ] { assert!(is_live(id), "{id}"); } assert_eq!(checked(&app, "view.show.layerEdges"), Some(false)); diff --git a/crates/ui-egui/src/wide_angle_ui.rs b/crates/ui-egui/src/wide_angle_ui.rs index 6b12ac269d..dc388b6139 100644 --- a/crates/ui-egui/src/wide_angle_ui.rs +++ b/crates/ui-egui/src/wide_angle_ui.rs @@ -58,7 +58,11 @@ impl WideAngleDialog { let k = self.scale; let mut p = self.params.clone(); let (fx, fy) = (self.frame.x0 as f64, self.frame.y0 as f64); - p.constraints = p.constraints.iter().map(|c| Constraint { a: [(c.a[0] - fx) * k, (c.a[1] - fy) * k], b: [(c.b[0] - fx) * k, (c.b[1] - fy) * k], orientation: c.orientation }).collect(); + p.constraints = p + .constraints + .iter() + .map(|c| Constraint { a: [(c.a[0] - fx) * k, (c.a[1] - fy) * k], b: [(c.b[0] - fx) * k, (c.b[1] - fy) * k], orientation: c.orientation }) + .collect(); p } @@ -97,15 +101,41 @@ pub fn open(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> let (w, h) = (frame.width() as usize, frame.height() as usize); let k = w.max(h).div_ceil(PROXY_SIDE).max(1); let (pw, ph) = (w.div_ceil(k), h.div_ceil(k)); - let proxy = photocraft_algo::resample::resize_surface(&surf.convert(PixelFormat::RGBA8), 1.0 / k as f64, 1.0 / k as f64, photocraft_algo::resample::Resample::Bilinear); + let proxy = photocraft_algo::resample::resize_surface( + &surf.convert(PixelFormat::RGBA8), + 1.0 / k as f64, + 1.0 / k as f64, + photocraft_algo::resample::Resample::Bilinear, + ); // Normalise the proxy to start at the origin. let mut px = Surface::new(PixelFormat::RGBA8); - let src_r = Rect::new((frame.x0 as f64 / k as f64) as i32, (frame.y0 as f64 / k as f64) as i32, (frame.x0 as f64 / k as f64) as i32 + pw as i32, (frame.y0 as f64 / k as f64) as i32 + ph as i32); + let src_r = Rect::new( + (frame.x0 as f64 / k as f64) as i32, + (frame.y0 as f64 / k as f64) as i32, + (frame.x0 as f64 / k as f64) as i32 + pw as i32, + (frame.y0 as f64 / k as f64) as i32 + ph as i32, + ); px.write_region(Rect::new(0, 0, pw as i32, ph as i32), &proxy.read_region(src_r)); // EXIF focal length / crop factor, as the engine would resolve them. let info = st.doc.metadata.exif.as_ref().map(|e| photocraft_algo::exif::read(e)); let params = photocraft_engine::lens_cmds::wide_params("filter.adaptiveWideAngle", &json!({}), info.as_ref()).unwrap_or_default(); - let mut d = WideAngleDialog { layer, layer_name: name, params, frame, scale: 1.0 / k as f64, proxy: px, pw, ph, src_tex: None, out_tex: None, dirty: true, preview: true, drag: None, residual: 0.0, render_ms: 0.0 }; + let mut d = WideAngleDialog { + layer, + layer_name: name, + params, + frame, + scale: 1.0 / k as f64, + proxy: px, + pw, + ph, + src_tex: None, + out_tex: None, + dirty: true, + preview: true, + drag: None, + residual: 0.0, + render_ms: 0.0, + }; d.render(ctx); app.wide_angle = Some(d); Ok(()) @@ -233,7 +263,13 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { && let Some(p) = resp.interact_pointer_pos() { // Delete the constraint whose curve passes nearest the click. - let near = d.params.constraints.iter().enumerate().map(|(i, c)| (i, cam.arc(c.a, c.b, 24).iter().map(|q| (to_screen(*q) - p).length()).fold(f32::MAX, f32::min))).min_by(|a, b| a.1.total_cmp(&b.1)); + let near = d + .params + .constraints + .iter() + .enumerate() + .map(|(i, c)| (i, cam.arc(c.a, c.b, 24).iter().map(|q| (to_screen(*q) - p).length()).fold(f32::MAX, f32::min))) + .min_by(|a, b| a.1.total_cmp(&b.1)); if let Some((i, dist)) = near && dist < 10.0 { @@ -257,10 +293,14 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { let mesh = (!show_src && !d.params.constraints.is_empty()).then(|| wideangle::solve(&d.proxy_params(), pf)); for c in &d.params.constraints { let pts: Vec = match &mesh { - Some(m) => cam.arc(c.a, c.b, 24).iter().map(|q| { - let pq = m.map(pf, [(q[0] - fx) * k, (q[1] - fy) * k]); - to_screen([pq[0] / k + fx, pq[1] / k + fy]) - }).collect(), + Some(m) => cam + .arc(c.a, c.b, 24) + .iter() + .map(|q| { + let pq = m.map(pf, [(q[0] - fx) * k, (q[1] - fy) * k]); + to_screen([pq[0] / k + fx, pq[1] / k + fy]) + }) + .collect(), None => cam.arc(c.a, c.b, 24).iter().map(|q| to_screen(*q)).collect(), }; if let (Some(a), Some(b)) = (pts.first(), pts.last()) { @@ -282,7 +322,18 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { ui.spacing_mut().item_spacing.y = 5.0; widgets::section_label(ui, "Correction"); let mut model = d.params.model; - if widgets::dropdown(ui, "awa-model", &mut model, &[(WideModel::Auto, "Auto"), (WideModel::Fisheye, "Fisheye"), (WideModel::Perspective, "Perspective"), (WideModel::FullSpherical, "Full Spherical")], 200.0) { + if widgets::dropdown( + ui, + "awa-model", + &mut model, + &[ + (WideModel::Auto, "Auto"), + (WideModel::Fisheye, "Fisheye"), + (WideModel::Perspective, "Perspective"), + (WideModel::FullSpherical, "Full Spherical"), + ], + 200.0, + ) { d.params.model = model; d.dirty = true; } @@ -308,7 +359,13 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { ui.horizontal(|ui| { ui.label(format!("{}", i + 1)); let mut o = c.orientation; - if widgets::dropdown(ui, &format!("awa-o-{i}"), &mut o, &[(Orientation::Free, "Free"), (Orientation::Horizontal, "Horizontal"), (Orientation::Vertical, "Vertical")], 120.0) { + if widgets::dropdown( + ui, + &format!("awa-o-{i}"), + &mut o, + &[(Orientation::Free, "Free"), (Orientation::Horizontal, "Horizontal"), (Orientation::Vertical, "Vertical")], + 120.0, + ) { c.orientation = o; d.dirty = true; } @@ -323,7 +380,11 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { } widgets::hairline(ui); widgets::checkbox(ui, &mut d.preview, "Preview"); - ui.label(egui::RichText::new("Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete.").color(t.text_faint).size(11.0)); + ui.label( + egui::RichText::new("Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete.") + .color(t.text_faint) + .size(11.0), + ); }); let foot = ERect::from_min_max(pos2(full.left(), full.bottom() - footer_h), full.max); painter.rect_filled(foot, 0.0, t.dock); @@ -363,7 +424,14 @@ mod tests { app.run("edit.fill", json!({"color": "#808080"})).unwrap(); let r = menu(&mut app, &ctx, "filter.adaptiveWideAngle", &json!({})).unwrap().unwrap(); assert_eq!(r["proxy"], json!([160, 100])); - let r = menu(&mut app, &ctx, "filter.adaptiveWideAngle", &json!({"ui": {"set": {"model": "fisheye", "focalLength": 10.0}, "add": {"a": [20, 20], "b": [140, 20], "orientation": "horizontal"}}})).unwrap().unwrap(); + let r = menu( + &mut app, + &ctx, + "filter.adaptiveWideAngle", + &json!({"ui": {"set": {"model": "fisheye", "focalLength": 10.0}, "add": {"a": [20, 20], "b": [140, 20], "orientation": "horizontal"}}}), + ) + .unwrap() + .unwrap(); assert_eq!(r["params"]["constraints"].as_array().unwrap().len(), 1); assert!(r["residual"].as_f64().unwrap() < 3.0, "{r}"); let r = menu(&mut app, &ctx, "filter.adaptiveWideAngle", &json!({"ui": {"commit": true}})).unwrap().unwrap(); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 51e9ff6e4a..76ec908930 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -69,7 +69,13 @@ fn pro_panel(ui: &mut Ui, id: &str, tabs: &[&str], selected: &mut usize, body: i } else if resp.hovered() { ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.4)); } - let color = if active { t.text } else if resp.hovered() { t.text_dim } else { t.text_faint }; + let color = if active { + t.text + } else if resp.hovered() { + t.text_dim + } else { + t.text_faint + }; ui.painter().galley_with_override_text_color(r.center() - galley.size() / 2.0, galley, color); if resp.clicked() { *selected = i; @@ -85,10 +91,14 @@ fn pro_panel(ui: &mut Ui, id: &str, tabs: &[&str], selected: &mut usize, body: i ui.painter().line_segment([pos2(menu.center().x - 5.0, y), pos2(menu.center().x + 5.0, y)], Stroke::new(1.0, c)); } // Body. - egui::Frame::NONE.fill(t.card).corner_radius(CornerRadius { nw: 0, ne: 0, sw: 3, se: 3 }).inner_margin(egui::Margin { left: 8, right: 8, top: 8, bottom: 8 }).show(ui, |ui| { - ui.set_width(width - 16.0); - body(ui, *selected); - }); + egui::Frame::NONE + .fill(t.card) + .corner_radius(CornerRadius { nw: 0, ne: 0, sw: 3, se: 3 }) + .inner_margin(egui::Margin { left: 8, right: 8, top: 8, bottom: 8 }) + .show(ui, |ui| { + ui.set_width(width - 16.0); + body(ui, *selected); + }); ui.add_space(2.0); } @@ -135,7 +145,13 @@ pub fn value_field(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInclusive ui.style_mut().visuals.widgets.hovered.weak_bg_fill = Color32::TRANSPARENT; ui.style_mut().override_font_id = Some(theme::mono(12.0)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.add_sized(field.size(), egui::DragValue::new(value).range(range).speed(if fine { 0.01 } else { 0.5 }).custom_formatter(|v, _| if fine { fmt_num2(v) } else { fmt_num(v) })) + ui.add_sized( + field.size(), + egui::DragValue::new(value) + .range(range) + .speed(if fine { 0.01 } else { 0.5 }) + .custom_formatter(|v, _| if fine { fmt_num2(v) } else { fmt_num(v) }), + ) }) .inner } @@ -279,7 +295,13 @@ fn button_impl(ui: &mut Ui, label: &str, min_width: f32, bg: Color32, fg: Color3 let down = resp.is_pointer_button_down_on(); let r = h / 2.0; if primary { - let fill = if down { bg.gamma_multiply(0.8) } else if resp.hovered() { bg.gamma_multiply(0.9) } else { bg }; + let fill = if down { + bg.gamma_multiply(0.8) + } else if resp.hovered() { + bg.gamma_multiply(0.9) + } else { + bg + }; ui.painter().rect_filled(rect, r, fill); } else { if resp.hovered() || down { diff --git a/crates/ui-egui/src/workspace_ui.rs b/crates/ui-egui/src/workspace_ui.rs index 9719f1a68b..9e5aa3214e 100644 --- a/crates/ui-egui/src/workspace_ui.rs +++ b/crates/ui-egui/src/workspace_ui.rs @@ -105,7 +105,11 @@ pub fn menu(app: &mut PhotocraftApp, id: &str, params: &Value) -> Option Option open(app, "newWorkspace", json!({"name": "", "keyboardShortcuts": false, "menus": false, "toolbar": false})), + "window.workspace.newWorkspace" if no_params(params) => { + open(app, "newWorkspace", json!({"name": "", "keyboardShortcuts": false, "menus": false, "toolbar": false})) + } "window.workspace.newWorkspace" => new_workspace(app, params), "window.workspace.deleteWorkspace" if no_params(params) => { let first = app.session.prefs().workspaces.keys().next().cloned().unwrap_or_default(); @@ -321,12 +327,9 @@ fn dialog(app: &mut PhotocraftApp, ctx: &egui::Context) { }; let names: Vec = app.session.prefs().workspaces.keys().cloned().collect(); let mut result: Option = None; - egui::Window::new(title) - .id(egui::Id::new("shell-dialog")) - .collapsible(false) - .resizable(false) - .anchor(Align2::CENTER_CENTER, vec2(0.0, 0.0)) - .show(ctx, |ui| { + egui::Window::new(title).id(egui::Id::new("shell-dialog")).collapsible(false).resizable(false).anchor(Align2::CENTER_CENTER, vec2(0.0, 0.0)).show( + ctx, + |ui| { ui.label(RichText::new(title).color(t.text).size(13.0).strong()); crate::widgets::hairline(ui); ui.add_space(6.0); @@ -358,7 +361,11 @@ fn dialog(app: &mut PhotocraftApp, ctx: &egui::Context) { "newWorkspace" => { text(ui, &mut f, "name", "Name:"); ui.label(RichText::new("Capture").color(t.text_dim).size(11.0)); - ui.label(RichText::new("Panel locations are saved in the workspace. Keyboard shortcuts, menus and toolbar are optional.").color(t.text_faint).size(10.5)); + ui.label( + RichText::new("Panel locations are saved in the workspace. Keyboard shortcuts, menus and toolbar are optional.") + .color(t.text_faint) + .size(10.5), + ); check(ui, &mut f, "keyboardShortcuts", "Keyboard Shortcuts"); check(ui, &mut f, "menus", "Menus"); check(ui, &mut f, "toolbar", "Toolbar"); @@ -376,7 +383,13 @@ fn dialog(app: &mut PhotocraftApp, ctx: &egui::Context) { } "preview32" => { let mut m = f.get("method").and_then(Value::as_str).unwrap_or("exposureGamma").to_string(); - if crate::widgets::dropdown(ui, "p32-method", &mut m, &[("exposureGamma".to_string(), "Exposure and Gamma"), ("highlightCompression".to_string(), "Highlight Compression")], 180.0) { + if crate::widgets::dropdown( + ui, + "p32-method", + &mut m, + &[("exposureGamma".to_string(), "Exposure and Gamma"), ("highlightCompression".to_string(), "Highlight Compression")], + 180.0, + ) { f.insert("method".into(), json!(m)); } ui.add_enabled_ui(m == "exposureGamma", |ui| { @@ -385,7 +398,21 @@ fn dialog(app: &mut PhotocraftApp, ctx: &egui::Context) { }); } _ => { - for (k, label) in [("layerEdges", "Layer Edges"), ("selectionEdges", "Selection Edges"), ("targetPath", "Target Path"), ("notes", "Notes"), ("pixelGrid", "Pixel Grid"), ("slices", "Slices"), ("count", "Count"), ("smartGuides", "Smart Guides"), ("brushPreview", "Brush Preview"), ("mesh", "Mesh"), ("editPins", "Edit Pins"), ("canvasGuides", "Canvas Guides"), ("artboardGuides", "Artboard Guides")] { + for (k, label) in [ + ("layerEdges", "Layer Edges"), + ("selectionEdges", "Selection Edges"), + ("targetPath", "Target Path"), + ("notes", "Notes"), + ("pixelGrid", "Pixel Grid"), + ("slices", "Slices"), + ("count", "Count"), + ("smartGuides", "Smart Guides"), + ("brushPreview", "Brush Preview"), + ("mesh", "Mesh"), + ("editPins", "Edit Pins"), + ("canvasGuides", "Canvas Guides"), + ("artboardGuides", "Artboard Guides"), + ] { check(ui, &mut f, k, label); } } @@ -402,7 +429,8 @@ fn dialog(app: &mut PhotocraftApp, ctx: &egui::Context) { } }); }); - }); + }, + ); match result { Some(true) => { if let Some((id, p)) = dialog_command(&kind, &f) { diff --git a/crates/vector/src/lib.rs b/crates/vector/src/lib.rs index eea7308d94..35059fd21b 100644 --- a/crates/vector/src/lib.rs +++ b/crates/vector/src/lib.rs @@ -176,7 +176,14 @@ pub enum Paint { None, /// Straight RGBA. Solid([f32; 4]), - Gradient { stops: Vec<(f32, [f32; 4])>, style: GradientStyle, angle: f32, scale: f32, reverse: bool, bounds: (f64, f64, f64, f64) }, + Gradient { + stops: Vec<(f32, [f32; 4])>, + style: GradientStyle, + angle: f32, + scale: f32, + reverse: bool, + bounds: (f64, f64, f64, f64), + }, } impl Paint { diff --git a/crates/vector/src/tests.rs b/crates/vector/src/tests.rs index 83dc92b66f..8a45736ad4 100644 --- a/crates/vector/src/tests.rs +++ b/crates/vector/src/tests.rs @@ -321,7 +321,14 @@ fn perf_6016() { let t = std::time::Instant::now(); let lines = flatten_path(&path, 0.04); let polys = stroke_polygons(&lines, &st, 0.01); - eprintln!("flatten+pieces {:.0} ms: {} polylines {} pts, {} pieces {} verts", t.elapsed().as_secs_f64() * 1000.0, lines.len(), lines.iter().map(|l| l.pts.len()).sum::(), polys.len(), polys.iter().map(|p| p.len()).sum::()); + eprintln!( + "flatten+pieces {:.0} ms: {} polylines {} pts, {} pieces {} verts", + t.elapsed().as_secs_f64() * 1000.0, + lines.len(), + lines.iter().map(|l| l.pts.len()).sum::(), + polys.len(), + polys.iter().map(|p| p.len()).sum::() + ); let t = std::time::Instant::now(); let sr = stroke_rasterizer(&path, &st, DEFAULT_TOLERANCE); let sc = sr.render(canvas); diff --git a/crates/vector/src/trace.rs b/crates/vector/src/trace.rs index aab5ce6655..e06c2ee441 100644 --- a/crates/vector/src/trace.rs +++ b/crates/vector/src/trace.rs @@ -414,10 +414,13 @@ mod tests { let c = contours(&v, Rect::new(0, 0, w as i32, h as i32), 0.0); assert_eq!(c.len(), 1); // Inside on the left in y-down screen space = counter-clockwise = negative shoelace area. - let a: f64 = (0..c[0].len()).map(|i| { - let (p, q) = (c[0][i], c[0][(i + 1) % c[0].len()]); - p.0 * q.1 - q.0 * p.1 - }).sum::() * 0.5; + let a: f64 = (0..c[0].len()) + .map(|i| { + let (p, q) = (c[0][i], c[0][(i + 1) % c[0].len()]); + p.0 * q.1 - q.0 * p.1 + }) + .sum::() + * 0.5; assert!(a < 0.0, "area {a}"); let a = -a; // Pixels 1..5 x 1..4 → contour through their outer half-pixel boundary (4×3 area with diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000000..6b089d5c5b --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,3 @@ +# Same style as the other craft apps (EffectCraft, FilmCraft): wide lines, compact small items. +max_width = 160 +use_small_heuristics = "Max" diff --git a/xtask/src/ico.rs b/xtask/src/ico.rs index 616f8b3977..021572a12f 100644 --- a/xtask/src/ico.rs +++ b/xtask/src/ico.rs @@ -49,10 +49,7 @@ pub fn run(args: &[&str]) -> Result<(), String> { let [out, inputs @ ..] = args else { return Err("usage: cargo xtask ico …".into()); }; - let pngs = inputs - .iter() - .map(|p| std::fs::read(p).map_err(|e| format!("read {p}: {e}"))) - .collect::, _>>()?; + let pngs = inputs.iter().map(|p| std::fs::read(p).map_err(|e| format!("read {p}: {e}"))).collect::, _>>()?; let ico = pack(&pngs)?; std::fs::write(Path::new(out), &ico).map_err(|e| format!("write {out}: {e}"))?; println!("{out}: {} images, {} bytes", pngs.len(), ico.len()); diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index af9533072e..d39b7a4bee 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -74,14 +74,9 @@ pub const INTRA_LAYER_ORDER: &[&[&str]] = &[&["geom", "cms", "color", "raster"], fn intra_layer_allowed(from: &str, to: &str) -> bool { let (from, to) = (short_name(from), short_name(to)); - INTRA_LAYER_ORDER.iter().any(|chain| { - match ( - chain.iter().position(|n| *n == from), - chain.iter().position(|n| *n == to), - ) { - (Some(f), Some(t)) => t < f, - _ => false, - } + INTRA_LAYER_ORDER.iter().any(|chain| match (chain.iter().position(|n| *n == from), chain.iter().position(|n| *n == to)) { + (Some(f), Some(t)) => t < f, + _ => false, }) } @@ -131,64 +126,30 @@ pub struct Crate { #[derive(Debug, Clone, PartialEq, Eq)] pub enum Violation { - Unregistered { - krate: String, - }, - Upward { - krate: String, - dep: String, - from: u8, - to: u8, - kind: DepKind, - }, - StandaloneHasWorkspaceDep { - krate: String, - dep: String, - }, - TestkitAsNormalDep { - krate: String, - }, - UiBelowL6 { - krate: String, - dep: String, - layer: u8, - }, + Unregistered { krate: String }, + Upward { krate: String, dep: String, from: u8, to: u8, kind: DepKind }, + StandaloneHasWorkspaceDep { krate: String, dep: String }, + TestkitAsNormalDep { krate: String }, + UiBelowL6 { krate: String, dep: String, layer: u8 }, } impl std::fmt::Display for Violation { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Violation::Unregistered { krate } => write!( - f, - "{krate}: unknown workspace crate; register it in xtask/src/layers.rs TABLE (see plan/architecture.md §3)" - ), - Violation::Upward { - krate, - dep, - from, - to, - kind, - } => write!( - f, - "{krate} (L{from}) -> {dep} (L{to}) [{kind:?}]: may only depend on strictly lower layers" - ), + Violation::Unregistered { krate } => { + write!(f, "{krate}: unknown workspace crate; register it in xtask/src/layers.rs TABLE (see plan/architecture.md §3)") + } + Violation::Upward { krate, dep, from, to, kind } => { + write!(f, "{krate} (L{from}) -> {dep} (L{to}) [{kind:?}]: may only depend on strictly lower layers") + } Violation::StandaloneHasWorkspaceDep { krate, dep } => { - write!( - f, - "{krate}: standalone crate must not depend on workspace crate {dep}" - ) + write!(f, "{krate}: standalone crate must not depend on workspace crate {dep}") } Violation::TestkitAsNormalDep { krate } => { - write!( - f, - "{krate}: photocraft-testkit may only be a dev-dependency" - ) + write!(f, "{krate}: photocraft-testkit may only be a dev-dependency") } Violation::UiBelowL6 { krate, dep, layer } => { - write!( - f, - "{krate} (L{layer}) depends on UI crate `{dep}`; UI toolkits are only allowed in L6+" - ) + write!(f, "{krate} (L{layer}) depends on UI crate `{dep}`; UI toolkits are only allowed in L6+") } } } @@ -199,9 +160,7 @@ pub fn check(crates: &[Crate]) -> Vec { let mut out = Vec::new(); for c in crates { let Some(class) = classify(&c.name) else { - out.push(Violation::Unregistered { - krate: c.name.clone(), - }); + out.push(Violation::Unregistered { krate: c.name.clone() }); continue; }; if class == Class::Exempt { @@ -215,10 +174,7 @@ pub fn check(crates: &[Crate]) -> Vec { } if d.workspace { if class == Class::Standalone { - out.push(Violation::StandaloneHasWorkspaceDep { - krate: c.name.clone(), - dep: d.name.clone(), - }); + out.push(Violation::StandaloneHasWorkspaceDep { krate: c.name.clone(), dep: d.name.clone() }); continue; } match classify(&d.name) { @@ -226,9 +182,7 @@ pub fn check(crates: &[Crate]) -> Vec { None => {} Some(Class::Testkit) => { if d.kind != DepKind::Dev { - out.push(Violation::TestkitAsNormalDep { - krate: c.name.clone(), - }); + out.push(Violation::TestkitAsNormalDep { krate: c.name.clone() }); } } Some(dc) => { @@ -245,11 +199,7 @@ pub fn check(crates: &[Crate]) -> Vec { } } } else if layer < UI_MIN_LAYER && is_ui_crate(&d.name) { - out.push(Violation::UiBelowL6 { - krate: c.name.clone(), - dep: d.name.clone(), - layer, - }); + out.push(Violation::UiBelowL6 { krate: c.name.clone(), dep: d.name.clone(), layer }); } } } @@ -260,9 +210,7 @@ pub fn check(crates: &[Crate]) -> Vec { /// Build the model from `cargo metadata --format-version 1 --no-deps`. pub fn from_metadata(meta: &Value) -> Result, String> { - let pkgs = meta["packages"] - .as_array() - .ok_or("metadata: no packages array")?; + let pkgs = meta["packages"].as_array().ok_or("metadata: no packages array")?; let members: Vec<&str> = pkgs.iter().filter_map(|p| p["name"].as_str()).collect(); let mut out = Vec::new(); for p in pkgs { @@ -276,11 +224,7 @@ pub fn from_metadata(meta: &Value) -> Result, String> { _ => DepKind::Normal, }; let workspace = members.contains(&dname.as_str()) || d["path"].is_string(); - deps.push(Dep { - name: dname, - kind, - workspace, - }); + deps.push(Dep { name: dname, kind, workspace }); } out.push(Crate { name, deps }); } @@ -303,17 +247,7 @@ mod tests { use super::*; fn c(name: &str, deps: &[(&str, DepKind, bool)]) -> Crate { - Crate { - name: name.into(), - deps: deps - .iter() - .map(|(n, k, w)| Dep { - name: (*n).into(), - kind: *k, - workspace: *w, - }) - .collect(), - } + Crate { name: name.into(), deps: deps.iter().map(|(n, k, w)| Dep { name: (*n).into(), kind: *k, workspace: *w }).collect() } } use DepKind::*; @@ -322,17 +256,8 @@ mod tests { let g = [ c("photocraft-geom", &[("kurbo", Normal, false)]), c("photocraft-doc", &[("photocraft-geom", Normal, true)]), - c( - "photocraft-engine", - &[ - ("photocraft-doc", Normal, true), - ("photocraft-testkit", Dev, true), - ], - ), - c( - "photocraft-ui-egui", - &[("photocraft-engine", Normal, true), ("egui", Normal, false)], - ), + c("photocraft-engine", &[("photocraft-doc", Normal, true), ("photocraft-testkit", Dev, true)]), + c("photocraft-ui-egui", &[("photocraft-engine", Normal, true), ("egui", Normal, false)]), c("photocraft-cli", &[("photocraft-ui-egui", Normal, true)]), ]; assert!(check(&g).is_empty(), "{:?}", check(&g)); @@ -352,16 +277,7 @@ mod tests { #[test] fn l0_foundation_chain_allowed_one_way() { - assert!( - check(&[c( - "photocraft-raster", - &[ - ("photocraft-color", Normal, true), - ("photocraft-geom", Normal, true) - ] - )]) - .is_empty() - ); + assert!(check(&[c("photocraft-raster", &[("photocraft-color", Normal, true), ("photocraft-geom", Normal, true)])]).is_empty()); assert!(check(&[c("photocraft-color", &[("photocraft-geom", Normal, true)])]).is_empty()); let v = check(&[c("photocraft-geom", &[("photocraft-raster", Normal, true)])]); assert!(matches!(v[..], [Violation::Upward { from: 0, to: 0, .. }])); @@ -380,16 +296,9 @@ mod tests { #[test] fn standalone_crates_have_no_workspace_deps() { - for s in [ - "photocraft-psd", - "photocraft-codecs", - "photocraft-adobe-assets", - ] { + for s in ["photocraft-psd", "photocraft-codecs", "photocraft-adobe-assets"] { let v = check(&[c(s, &[("photocraft-geom", Normal, true)])]); - assert!( - matches!(v[..], [Violation::StandaloneHasWorkspaceDep { .. }]), - "{s}" - ); + assert!(matches!(v[..], [Violation::StandaloneHasWorkspaceDep { .. }]), "{s}"); assert!(check(&[c(s, &[("image", Normal, false)])]).is_empty()); } } @@ -403,78 +312,35 @@ mod tests { #[test] fn ui_crates_below_l6_flagged() { - for dep in [ - "egui", - "eframe", - "winit", - "egui_kittest", - "rfd", - "bevy_ecs", - "bevy", - ] { + for dep in ["egui", "eframe", "winit", "egui_kittest", "rfd", "bevy_ecs", "bevy"] { let v = check(&[c("photocraft-engine", &[(dep, Normal, false)])]); - assert!( - matches!(v[..], [Violation::UiBelowL6 { layer: 5, .. }]), - "{dep}" - ); + assert!(matches!(v[..], [Violation::UiBelowL6 { layer: 5, .. }]), "{dep}"); } - assert!( - check(&[c( - "photocraft-engine", - &[("egui_extras_not", Normal, false)] - )]) - .is_empty() - ); + assert!(check(&[c("photocraft-engine", &[("egui_extras_not", Normal, false)])]).is_empty()); assert!(check(&[c("photocraft-platform", &[("winit", Normal, false)])]).is_empty()); } #[test] fn unregistered_crate_is_error() { let v = check(&[c("photocraft-mystery", &[])]); - assert!( - matches!(&v[..], [Violation::Unregistered { krate }] if krate == "photocraft-mystery") - ); + assert!(matches!(&v[..], [Violation::Unregistered { krate }] if krate == "photocraft-mystery")); assert!(v[0].to_string().contains("register")); } #[test] fn testkit_only_as_dev_dependency() { - let v = check(&[c( - "photocraft-raster", - &[("photocraft-testkit", Normal, true)], - )]); + let v = check(&[c("photocraft-raster", &[("photocraft-testkit", Normal, true)])]); assert!(matches!(v[..], [Violation::TestkitAsNormalDep { .. }])); assert!(check(&[c("photocraft-raster", &[("photocraft-testkit", Dev, true)])]).is_empty()); // testkit itself may use anything up to L5 but not L6 crates. - assert!( - check(&[c( - "photocraft-testkit", - &[("photocraft-engine", Normal, true)] - )]) - .is_empty() - ); - assert!( - !check(&[c( - "photocraft-testkit", - &[("photocraft-ui-egui", Normal, true)] - )]) - .is_empty() - ); + assert!(check(&[c("photocraft-testkit", &[("photocraft-engine", Normal, true)])]).is_empty()); + assert!(!check(&[c("photocraft-testkit", &[("photocraft-ui-egui", Normal, true)])]).is_empty()); } #[test] fn apps_and_xtask_exempt() { for app in ["photocraft", "photocraft-cli", "photocraft-web", "xtask"] { - assert!( - check(&[c( - app, - &[ - ("egui", Normal, false), - ("photocraft-ui-egui", Normal, true) - ] - )]) - .is_empty() - ); + assert!(check(&[c(app, &[("egui", Normal, false), ("photocraft-ui-egui", Normal, true)])]).is_empty()); } } diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 0121c05581..ea785f46c8 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -57,10 +57,7 @@ fn main() -> ExitCode { /// Workspace root (parent of the xtask crate). pub fn root() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .expect("xtask has a parent dir") - .to_path_buf() + Path::new(env!("CARGO_MANIFEST_DIR")).parent().expect("xtask has a parent dir").to_path_buf() } pub fn cargo() -> Command { @@ -71,26 +68,14 @@ pub fn cargo() -> Command { fn run(mut cmd: Command, what: &str) -> Result<(), String> { eprintln!("$ {what}"); - let status = cmd - .status() - .map_err(|e| format!("{what}: failed to spawn: {e}"))?; - if status.success() { - Ok(()) - } else { - Err(format!("{what}: exited with {status}")) - } + let status = cmd.status().map_err(|e| format!("{what}: failed to spawn: {e}"))?; + if status.success() { Ok(()) } else { Err(format!("{what}: exited with {status}")) } } pub fn metadata() -> Result { - let out = cargo() - .args(["metadata", "--format-version", "1", "--no-deps"]) - .output() - .map_err(|e| format!("cargo metadata: {e}"))?; + let out = cargo().args(["metadata", "--format-version", "1", "--no-deps"]).output().map_err(|e| format!("cargo metadata: {e}"))?; if !out.status.success() { - return Err(format!( - "cargo metadata failed:\n{}", - String::from_utf8_lossy(&out.stderr) - )); + return Err(format!("cargo metadata failed:\n{}", String::from_utf8_lossy(&out.stderr))); } serde_json::from_slice(&out.stdout).map_err(|e| format!("cargo metadata: bad JSON: {e}")) } @@ -113,12 +98,7 @@ fn cmd_layers() -> Result<(), String> { format!("{}{k}", layers::short_name(&d.name)) }) .collect(); - println!( - "{:<28} {:<14} {}", - c.name, - layers::describe(layers::classify(&c.name)), - ws.join(", ") - ); + println!("{:<28} {:<14} {}", c.name, layers::describe(layers::classify(&c.name)), ws.join(", ")); } let violations = layers::check(&crates); println!(); @@ -155,11 +135,7 @@ fn cmd_wasm() -> Result<(), String> { for pkg in &set { let mut c = cargo(); c.args(["check", "--target", "wasm32-unknown-unknown", "-p", pkg]); - let ok = run( - c, - &format!("cargo check --target wasm32-unknown-unknown -p {pkg}"), - ) - .is_ok(); + let ok = run(c, &format!("cargo check --target wasm32-unknown-unknown -p {pkg}")).is_ok(); results.push((pkg.clone(), ok)); } println!("\nwasm32-unknown-unknown check:"); @@ -167,11 +143,7 @@ fn cmd_wasm() -> Result<(), String> { println!(" {:<6} {p}", if *ok { "ok" } else { "FAIL" }); } let failed = results.iter().filter(|r| !r.1).count(); - if failed == 0 { - Ok(()) - } else { - Err(format!("{failed} crate(s) failed the wasm check")) - } + if failed == 0 { Ok(()) } else { Err(format!("{failed} crate(s) failed the wasm check")) } } fn cmd_parity() -> Result<(), String> { @@ -195,14 +167,7 @@ fn cmd_ci() -> Result<(), String> { "clippy", Box::new(|| { let mut c = cargo(); - c.args([ - "clippy", - "--workspace", - "--all-targets", - "--", - "-D", - "warnings", - ]); + c.args(["clippy", "--workspace", "--all-targets", "--", "-D", "warnings"]); run(c, "cargo clippy --workspace --all-targets -- -D warnings") }), ), @@ -233,11 +198,7 @@ fn cmd_ci() -> Result<(), String> { } done.push(*name); } - println!( - "\nCI summary: all {} steps passed ({})", - done.len(), - done.join(", ") - ); + println!("\nCI summary: all {} steps passed ({})", done.len(), done.join(", ")); Ok(()) } @@ -272,17 +233,7 @@ Tests that use a corpus skip cleanly when it is absent. tar.arg("-xzf").arg(&tgz).arg("-C").arg(&dest); run(tar, "tar -xzf PngSuite-2017jul19.tgz")?; let _ = std::fs::remove_file(&tgz); - let n = std::fs::read_dir(&dest) - .map(|d| { - d.flatten() - .filter(|e| e.path().extension().is_some_and(|x| x == "png")) - .count() - }) - .unwrap_or(0); + let n = std::fs::read_dir(&dest).map(|d| d.flatten().filter(|e| e.path().extension().is_some_and(|x| x == "png")).count()).unwrap_or(0); println!("PngSuite: {n} PNG files in {}", dest.display()); - if n == 0 { - Err("no PNG files extracted".into()) - } else { - Ok(()) - } + if n == 0 { Err("no PNG files extracted".into()) } else { Ok(()) } } diff --git a/xtask/src/stats.rs b/xtask/src/stats.rs index e9c87b954e..f40b1300ac 100644 --- a/xtask/src/stats.rs +++ b/xtask/src/stats.rs @@ -55,19 +55,11 @@ fn count_tests(src: &str) -> (usize, usize) { } fn exact_tests(pkg: &str) -> Option { - let out = crate::cargo() - .args(["test", "-q", "-p", pkg, "--", "--list"]) - .output() - .ok()?; + let out = crate::cargo().args(["test", "-q", "-p", pkg, "--", "--list"]).output().ok()?; if !out.status.success() { return None; } - Some( - String::from_utf8_lossy(&out.stdout) - .lines() - .filter(|l| l.ends_with(": test")) - .count(), - ) + Some(String::from_utf8_lossy(&out.stdout).lines().filter(|l| l.ends_with(": test")).count()) } pub fn run(_root: &Path, exact: bool) -> Result<(), String> { @@ -75,10 +67,7 @@ pub fn run(_root: &Path, exact: bool) -> Result<(), String> { let mut rows = Vec::new(); for p in meta["packages"].as_array().into_iter().flatten() { let name = p["name"].as_str().unwrap_or("?").to_owned(); - let Some(dir) = p["manifest_path"] - .as_str() - .and_then(|m| Path::new(m).parent().map(Path::to_path_buf)) - else { + let Some(dir) = p["manifest_path"].as_str().and_then(|m| Path::new(m).parent().map(Path::to_path_buf)) else { continue; }; let mut c = Counts::default(); @@ -88,15 +77,8 @@ pub fn run(_root: &Path, exact: bool) -> Result<(), String> { } rows.sort_by(|a, b| a.0.cmp(&b.0)); - let exact_col = if exact { - format!(" {:>8}", "harness") - } else { - String::new() - }; - println!( - "{:<26} {:>6} {:>8} {:>7} {:>10}{exact_col}", - "crate", "files", "lines", "#[test]", "proptest!" - ); + let exact_col = if exact { format!(" {:>8}", "harness") } else { String::new() }; + println!("{:<26} {:>6} {:>8} {:>7} {:>10}{exact_col}", "crate", "files", "lines", "#[test]", "proptest!"); println!("{}", "-".repeat(62 + if exact { 9 } else { 0 })); let mut total = Counts::default(); let mut total_exact = 0; @@ -109,29 +91,17 @@ pub fn run(_root: &Path, exact: bool) -> Result<(), String> { } (true, None) => format!(" {:>8}", "error"), }; - println!( - "{name:<26} {:>6} {:>8} {:>7} {:>10}{ex_s}", - c.files, c.lines, c.tests, c.proptests - ); + println!("{name:<26} {:>6} {:>8} {:>7} {:>10}{ex_s}", c.files, c.lines, c.tests, c.proptests); total.files += c.files; total.lines += c.lines; total.tests += c.tests; total.proptests += c.proptests; } println!("{}", "-".repeat(62 + if exact { 9 } else { 0 })); - let ex_s = if exact { - format!(" {total_exact:>8}") - } else { - String::new() - }; - println!( - "{:<26} {:>6} {:>8} {:>7} {:>10}{ex_s}", - "TOTAL", total.files, total.lines, total.tests, total.proptests - ); + let ex_s = if exact { format!(" {total_exact:>8}") } else { String::new() }; + println!("{:<26} {:>6} {:>8} {:>7} {:>10}{ex_s}", "TOTAL", total.files, total.lines, total.tests, total.proptests); if !exact { - println!( - "\n(#[test] counts source attributes; macro-generated tests count once. Use --exact for harness counts.)" - ); + println!("\n(#[test] counts source attributes; macro-generated tests count once. Use --exact for harness counts.)"); } Ok(()) } @@ -142,8 +112,7 @@ mod tests { #[test] fn counts_attributes_and_proptest_blocks() { - let src = - "#[test]\nfn a() {}\n// #[test] commented\n #[test]\nfn b() {}\nproptest! {\n}\n"; + let src = "#[test]\nfn a() {}\n// #[test] commented\n #[test]\nfn b() {}\nproptest! {\n}\n"; assert_eq!(count_tests(src), (2, 1)); } } diff --git a/xtask/src/version.rs b/xtask/src/version.rs index 489b626789..8c4c1fc34e 100644 --- a/xtask/src/version.rs +++ b/xtask/src/version.rs @@ -8,10 +8,7 @@ use std::path::Path; /// The `version = "…"` value inside `[workspace.package]`. pub fn read(manifest: &str) -> Result { let (_, line) = find_line(manifest)?; - let v = line - .split_once('=') - .map(|(_, v)| v.trim().trim_matches('"').to_string()) - .unwrap_or_default(); + let v = line.split_once('=').map(|(_, v)| v.trim().trim_matches('"').to_string()).unwrap_or_default(); if v.is_empty() { return Err("empty [workspace.package] version".into()); } @@ -48,20 +45,11 @@ pub fn validate(v: &str) -> Result<(), String> { None => (v, None), }; let nums: Vec<&str> = core.split('.').collect(); - if nums.len() != 3 - || nums.iter().any(|n| { - n.is_empty() - || !n.bytes().all(|b| b.is_ascii_digit()) - || (n.len() > 1 && n.starts_with('0')) - }) - { + if nums.len() != 3 || nums.iter().any(|n| n.is_empty() || !n.bytes().all(|b| b.is_ascii_digit()) || (n.len() > 1 && n.starts_with('0'))) { return bad(); } if let Some(p) = pre - && (p.is_empty() - || p.split('.').any(|id| { - id.is_empty() || !id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') - })) + && (p.is_empty() || p.split('.').any(|id| id.is_empty() || !id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-'))) { return bad(); }