From 4165c57f92b90cb5308d812620d54078ee2c9452 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 11 Oct 2026 16:13:40 +0200 Subject: [PATCH 001/219] xtask: check that every panel button routes through a command id (#3495) `cargo xtask panel-commands` scans crates/ui-egui/src (test code skipped) for click handlers -- the block of an `if` whose condition calls .clicked()/.changed()/.dragged() and friends, a local bound from one, or a widget fn taking a Ui and returning bool -- that change the document or session directly: active_mut(), Arc::make_mut on a doc, session.undo/redo/select_layer/add_document/close(), or an assignment to a session field. Lexical, std-only scanner; reports file:line and function. Existing violations sit in a commented allowlist (file + function + reason); stale entries are errors so the list only shrinks. Two today: panels.rs color_picker and color_field assign the foreground/background colour instead of dispatching tools.setColors. Runs from `cargo xtask ci` and as a CI step. Documented in docs/development.md and the AGENTS.md checklist; AUTO-219-8 is partial. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 6 + AGENTS.md | 1 + docs/development.md | 14 + docs/scorecard.md | 4 +- scorecard/automation.toml | 4 +- xtask/src/main.rs | 7 +- xtask/src/panel_commands.rs | 678 ++++++++++++++++++++++++++++++++++++ 7 files changed, 709 insertions(+), 5 deletions(-) create mode 100644 xtask/src/panel_commands.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3116e3e941..3dca7be0d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -147,6 +147,12 @@ jobs: - name: Dependency layering run: cargo xtask layers + # AGENTS.md rule 1: shell click handlers dispatch command ids (allowlist in + # xtask/src/panel_commands.rs). Static source scan, so one OS is enough. + - name: Panel buttons route through command ids + if: runner.os == 'Linux' + run: cargo xtask panel-commands + # docs/scorecard.md must match its sources (checklists, budgets, baseline, corpus floors, # prefs audit). Static: it reads committed files only, no benchmarks run here. - name: Scorecard up to date diff --git a/AGENTS.md b/AGENTS.md index e44dbe32a8..6942745852 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,6 +94,7 @@ When parity rises, raise `FLOOR` in `crates/ui-egui/src/parity.rs` (never lower cargo test -p cargo clippy -p --all-targets -- -D warnings cargo xtask layers +cargo xtask panel-commands # if you touched ui-egui: click handlers dispatch command ids (rule 1) cargo xtask wasm # if you touched L0–L6 cargo xtask parity # if you added commands; commit the regenerated docs/parity-checklist.md cargo test -p photocraft-engine --test panic_hunt -- --ignored # if you added/changed commands: no panic on adversarial input (Rule 9) diff --git a/docs/development.md b/docs/development.md index 7d9dac3c7a..38418749dd 100644 --- a/docs/development.md +++ b/docs/development.md @@ -262,6 +262,20 @@ derived from UI `tl!` literals, the menu catalog and UI command table, and menu the engine source. Catalog rows are validated for malformed and duplicate keys; unused legacy translations do not affect the denominator. Unregistered locale TSV files are ignored. +## Panel command routing + +Every panel button must dispatch an engine command by id (AGENTS.md rule 1), so the UI, CLI, +control channel and MCP stay equivalent. `cargo xtask panel-commands` (also a step of +`cargo xtask ci` and of CI) scans `crates/ui-egui/src` outside test code for click handlers: the +block of an `if` whose condition calls `.clicked()`, `.changed()`, `.dragged()` and friends, or a +widget function that takes a `Ui` and returns `bool`. Inside one it fails, with file:line, on a +direct document or session change: `active_mut()`, `Arc::make_mut` on a doc, +`session.undo/redo/select_layer/add_document/close(…)` or an assignment to a session field +(`app.session.tools.foreground = …`). Dispatch the command instead (`app.run`, `menus::invoke`). +The scanner is lexical and does not follow calls into helpers. Existing violations are listed in +`ALLOWLIST` in `xtask/src/panel_commands.rs` by file and function, each with its reason; an entry +that matches nothing any more is an error, so the list only shrinks. + ## Testing strategy - **Unit and property tests** in every crate. proptest is used for tile COW, regions and codecs. diff --git a/docs/scorecard.md b/docs/scorecard.md index 3357d5465e..f189fd9cc6 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -11,7 +11,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Tools](#tools) | 25 | 16 | 2 | | | [Workspace and UI](#workspace-and-ui) | 9 | 8 | 6 | settings that do nothing: 33 of 154 (target 0) | | [Type](#type) | 1 | 4 | 4 | | -| [Automation](#automation) | 3 | 2 | 6 | 630 of 630 menu items live (100.0%) | +| [Automation](#automation) | 3 | 3 | 5 | 630 of 630 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 28 of 28 crates; panic_hunt present | | [Distribution](#distribution) | 5 | 4 | 2 | | | [Open bugs](#open-bugs) | 29 | 2 | 1 | | @@ -234,7 +234,7 @@ Menu wiring (`docs/parity-checklist.md`): 630 of 630 menu items live (100.0%). T | AUTO-219-5 | Batch: subfolders, open documents, naming template, progress/cancel, error log, stop | partial | [#219](https://github.com/storytold/photocraft/issues/219) | file_cmds.rs:590-631 folder/array input, per-file errors; no recursion, template or cancel | | AUTO-219-6 | Plug-in ABI v2: panel kind, progress/cancel calls, read other layers, Last Filter repeats | missing | [#219](https://github.com/storytold/photocraft/issues/219) | runtime.rs:15 ABI_VERSION = 1; manifest.rs:18 Kind::Filter only | | AUTO-219-7 | Local ONNX models behind a flag, managed in Preferences; subject select, background removal | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No ONNX runtime dependency | -| AUTO-219-8 | xtask check that every panel button routes through a command id | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No such xtask check | +| AUTO-219-8 | xtask check that every panel button routes through a command id | partial | [#219](https://github.com/storytold/photocraft/issues/219) | cargo xtask panel-commands (run by xtask ci and CI) fails on a shell click handler that changes the document or session directly; 2 allowlisted functions left (panels.rs color_picker, color_field assign the foreground/background colour instead of tools.setColors) | | AUTO-219-9 | Headless commands for in-canvas type editing, Liquify/Puppet strokes, transform handles | done | [#219](https://github.com/storytold/photocraft/issues/219) | type.hitTest, type.caret, type.navigate; filter.liquify strokes, edit.puppetWarp pins, type.edit, edit.transform | | AUTO-219-10 | Agent acceptance suite grown to 30-50 workflows | partial | [#219](https://github.com/storytold/photocraft/issues/219) | automation/tests/agent_tasks.rs runs 10 workflows over MCP | diff --git a/scorecard/automation.toml b/scorecard/automation.toml index 002adc76db..872e7fce31 100644 --- a/scorecard/automation.toml +++ b/scorecard/automation.toml @@ -63,9 +63,9 @@ note = "No ONNX runtime dependency" [[item]] id = "AUTO-219-8" target = "xtask check that every panel button routes through a command id" -status = "missing" +status = "partial" issue = 219 -note = "No such xtask check" +note = "cargo xtask panel-commands (run by xtask ci and CI) fails on a shell click handler that changes the document or session directly; 2 allowlisted functions left (panels.rs color_picker, color_field assign the foreground/background colour instead of tools.setColors)" [[item]] id = "AUTO-219-9" diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 6c0f016464..17252d6b29 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -8,6 +8,7 @@ mod corpus_pins; mod i18n_coverage; mod ico; mod layers; +mod panel_commands; mod perf; mod pinned; mod scorecard; @@ -23,8 +24,10 @@ usage: cargo xtask commands: layers enforce the crate dependency layering (plan/architecture.md §3) + panel-commands fail on a shell click handler that changes the document or session directly instead of + dispatching a command id (allowlist: xtask/src/panel_commands.rs) wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates - ci fmt --check, clippy -D warnings, test, layers, wasm (stops at first failure) + ci fmt --check, clippy -D warnings, test, layers, panel-commands, wasm (stops at first failure) corpus [--all | --pngsuite | --psd | --psd-tools | --pixls | --photoshop] [--local] [--update-manifest] show where test corpora live and their pins (xtask/src/corpus_pins.rs), or fetch them into corpus/ (pinned commits, sha256-verified; --all = every corpus but pixls, @@ -53,6 +56,7 @@ fn main() -> ExitCode { let rest: Vec<&str> = args.iter().skip(1).map(String::as_str).collect(); let result = match args.first().map(String::as_str) { Some("layers") => cmd_layers(), + Some("panel-commands") => panel_commands::run(&root()), Some("wasm") => cmd_wasm(), Some("ci") => cmd_ci(), Some("corpus") => corpus::cmd(&rest), @@ -214,6 +218,7 @@ fn cmd_ci() -> Result<(), String> { }), ), ("layers", Box::new(cmd_layers)), + ("panel-commands", Box::new(|| panel_commands::run(&root()))), ("wasm", Box::new(cmd_wasm)), ]; let mut done = Vec::new(); diff --git a/xtask/src/panel_commands.rs b/xtask/src/panel_commands.rs new file mode 100644 index 0000000000..e986512fc8 --- /dev/null +++ b/xtask/src/panel_commands.rs @@ -0,0 +1,678 @@ +//! `cargo xtask panel-commands`: every panel button routes through a command id (AGENTS.md rule 1). +//! +//! The UI, CLI, control channel and MCP stay equivalent only while panels dispatch engine commands +//! by id (`app.run`, `menus::invoke`, `session.execute`, the shell's `UI_COMMANDS`). This check +//! scans the egui shell's source for click handlers that change the document or its history +//! directly instead. +//! +//! It is a lexical scanner, not a type checker (std only, no `syn`): +//! +//! * a **click handler** is the block of an `if` whose condition calls `.clicked()` (or +//! `double_clicked`, `secondary_clicked`, `triple_clicked`, `middle_clicked`, `changed`, +//! `dragged`, `drag_stopped`), reads a local bound by `let x = … .clicked() …;` in the same file, +//! or calls a widget function (one that takes a `Ui` and returns `bool`, like `color_readout`); +//! * inside it, a **direct mutation** is `active_mut()` (the mutable document state), +//! `Arc::make_mut` on a `doc`, `session.undo/redo/select_layer/add_document/close(…)`, or an +//! assignment to a session field (`app.session.tools.foreground = …`); +//! * test code is skipped: `*_tests.rs`, `tests.rs`, files under `tests/` and `#[cfg(test)]` items. +//! +//! Out of scope: preferences (`edit_prefs`), shell view state (`app.ui`, the `UI_COMMANDS`) and +//! switching the active document. Calls into helper functions are not followed, so a handler that calls a helper which mutates the +//! document passes; the check catches the inline pattern, which is how such shortcuts start. +//! +//! Existing violations are listed in [`ALLOWLIST`] by file and enclosing function, each with the +//! reason it is still there. An entry that no longer matches anything is an error, so the list only +//! shrinks: fix the handler (dispatch a command id), then delete its entry. + +use std::path::{Path, PathBuf}; + +/// The shell source the check scans, relative to the workspace root. +pub const SCAN_ROOT: &str = "crates/ui-egui/src"; + +/// A known violation that is tolerated until its handler routes through a command. +#[derive(Debug, Clone, Copy)] +pub struct Allowed { + /// Path relative to the workspace root, with `/` separators. + pub file: &'static str, + /// Enclosing function of the click handler (`` outside any function). + pub func: &'static str, + /// Why it still mutates directly (and what the fix is). + pub reason: &'static str, +} + +/// Existing click handlers that change the document directly. Shrink only: each entry is a +/// follow-up (route it through an engine command), never a place to park new code. +pub const ALLOWLIST: &[Allowed] = &[ + // Window › Color: the HSB sliders and the hex/RGB readout set the foreground colour by + // assignment, so the change never reaches the journal, action recording or the control + // channel's event log. Fix: dispatch `tools.setColors` (it already exists). + Allowed { + file: "crates/ui-egui/src/panels.rs", + func: "color_picker", + reason: "Color panel sliders and readout assign session.tools.foreground; dispatch tools.setColors", + }, + // The Color panel's colour field and hue strip (and their readout) assign the foreground or + // background colour on every drag frame. Fix: dispatch `tools.setColors`. + Allowed { + file: "crates/ui-egui/src/panels.rs", + func: "color_field", + reason: "Color panel field, hue strip and readout assign session.tools.foreground/background; dispatch tools.setColors", + }, +]; + +/// A click handler that changes the document or its history without a command id. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Violation { + pub file: String, + pub line: usize, + pub func: String, + pub what: String, +} + +const TRIGGERS: &[&str] = &["clicked", "double_clicked", "secondary_clicked", "triple_clicked", "middle_clicked", "changed", "dragged", "drag_stopped"]; + +/// Session methods that change documents or history behind the command registry's back. +const SESSION_MUTATORS: &[&str] = &["undo", "redo", "select_layer", "add_document", "close"]; + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Kind { + Ident(String), + Punct(char), + /// Literals and lifetimes: never part of a pattern. + Other, +} + +#[derive(Debug, Clone)] +struct Token { + kind: Kind, + line: usize, +} + +impl Token { + fn is_ident(&self, s: &str) -> bool { + matches!(&self.kind, Kind::Ident(i) if i == s) + } + fn is_punct(&self, c: char) -> bool { + self.kind == Kind::Punct(c) + } + fn ident(&self) -> Option<&str> { + match &self.kind { + Kind::Ident(i) => Some(i), + _ => None, + } + } +} + +/// Split Rust source into identifiers and punctuation, dropping comments and literals. +fn lex(src: &str) -> Vec { + let c: Vec = src.chars().collect(); + let at = |i: usize| c.get(i).copied().unwrap_or('\0'); + let mut out = Vec::new(); + let mut line = 1; + let mut i = 0; + while i < c.len() { + let ch = at(i); + if ch == '\n' { + line += 1; + i += 1; + } else if ch.is_whitespace() { + i += 1; + } else if ch == '/' && at(i + 1) == '/' { + while i < c.len() && at(i) != '\n' { + i += 1; + } + } else if ch == '/' && at(i + 1) == '*' { + let mut depth = 0usize; + while i < c.len() { + if at(i) == '/' && at(i + 1) == '*' { + depth += 1; + i += 2; + } else if at(i) == '*' && at(i + 1) == '/' { + depth = depth.saturating_sub(1); + i += 2; + if depth == 0 { + break; + } + } else { + if at(i) == '\n' { + line += 1; + } + i += 1; + } + } + } else if let Some(hashes) = raw_string_start(&c, i) { + // r"…", r#"…"#, br#"…"#: skip to the quote followed by the same number of hashes. + let start_line = line; + i += hashes.prefix; + while i < c.len() { + if at(i) == '"' && (1..=hashes.count).all(|k| at(i + k) == '#') { + i += 1 + hashes.count; + break; + } + if at(i) == '\n' { + line += 1; + } + i += 1; + } + out.push(Token { kind: Kind::Other, line: start_line }); + } else if ch == '"' || (ch == 'b' && at(i + 1) == '"') || (ch == 'c' && at(i + 1) == '"') { + let start_line = line; + i += if ch == '"' { 1 } else { 2 }; + while i < c.len() { + match at(i) { + '\\' => i += 2, + '"' => { + i += 1; + break; + } + '\n' => { + line += 1; + i += 1; + } + _ => i += 1, + } + } + out.push(Token { kind: Kind::Other, line: start_line }); + } else if ch == '\'' || (ch == 'b' && at(i + 1) == '\'') { + // Char literal ('a', '\n', b'x') or lifetime/label ('a, 'static). + let q = if ch == 'b' { i + 1 } else { i }; + if at(q + 1) == '\\' { + i = q + 2; + while i < c.len() && at(i) != '\'' && at(i) != '\n' { + i += 1; + } + i += 1; + } else if at(q + 2) == '\'' { + i = q + 3; + } else { + i = q + 1; + while i < c.len() && (at(i).is_alphanumeric() || at(i) == '_') { + i += 1; + } + } + out.push(Token { kind: Kind::Other, line }); + } else if ch.is_alphabetic() || ch == '_' { + let s = i; + while i < c.len() && (at(i).is_alphanumeric() || at(i) == '_') { + i += 1; + } + out.push(Token { kind: Kind::Ident(c.get(s..i).unwrap_or_default().iter().collect()), line }); + } else if ch.is_ascii_digit() { + while i < c.len() && (at(i).is_alphanumeric() || at(i) == '_' || (at(i) == '.' && at(i + 1).is_ascii_digit())) { + i += 1; + } + out.push(Token { kind: Kind::Other, line }); + } else { + out.push(Token { kind: Kind::Punct(ch), line }); + i += 1; + } + } + out +} + +struct RawStart { + /// Characters before the body (`r#"` is 3). + prefix: usize, + count: usize, +} + +fn raw_string_start(c: &[char], i: usize) -> Option { + let at = |k: usize| c.get(k).copied().unwrap_or('\0'); + // Not the tail of an identifier such as `for` or `bar`. + if i > 0 && (at(i - 1).is_alphanumeric() || at(i - 1) == '_') { + return None; + } + let mut j = i; + if at(j) == 'b' || at(j) == 'c' { + j += 1; + } + if at(j) != 'r' { + return None; + } + j += 1; + let mut count = 0; + while at(j) == '#' { + count += 1; + j += 1; + } + (at(j) == '"').then_some(RawStart { prefix: j + 1 - i, count }) +} + +/// Index of the bracket closing the one at `open` (`{`, `(` or `[`), or the last token. +fn matching(toks: &[Token], open: usize) -> usize { + let mut depth = 0usize; + for (k, t) in toks.iter().enumerate().skip(open) { + match t.kind { + Kind::Punct('{' | '(' | '[') => depth += 1, + Kind::Punct('}' | ')' | ']') => { + depth = depth.saturating_sub(1); + if depth == 0 { + return k; + } + } + _ => {} + } + } + toks.len().saturating_sub(1) +} + +/// First `{` (or `;`) after `from` outside parentheses and brackets. +fn next_block_or_semi(toks: &[Token], from: usize) -> Option { + let mut depth = 0usize; + for (k, t) in toks.iter().enumerate().skip(from) { + match t.kind { + Kind::Punct('(' | '[') => depth += 1, + Kind::Punct(')' | ']') => depth = depth.saturating_sub(1), + Kind::Punct('{' | ';') if depth == 0 => return Some(k), + _ => {} + } + } + None +} + +/// Drop every item under `#[cfg(test)]` (or a `cfg(all(test, …))`), keeping `cfg(not(test))`. +fn strip_cfg_test(toks: Vec) -> Vec { + let mut out = Vec::with_capacity(toks.len()); + let mut i = 0; + while i < toks.len() { + if is_test_attr(&toks, i) { + // Skip this attribute, any further attributes, then the item itself. + let mut k = i; + while toks.get(k).is_some_and(|t| t.is_punct('#')) && toks.get(k + 1).is_some_and(|t| t.is_punct('[')) { + k = matching(&toks, k + 1) + 1; + } + i = match next_block_or_semi(&toks, k) { + Some(e) if toks.get(e).is_some_and(|t| t.is_punct('{')) => matching(&toks, e) + 1, + Some(e) => e + 1, + None => toks.len(), + }; + continue; + } + if let Some(t) = toks.get(i) { + out.push(t.clone()); + } + i += 1; + } + out +} + +fn is_test_attr(toks: &[Token], i: usize) -> bool { + let (Some(hash), Some(open), Some(cfg)) = (toks.get(i), toks.get(i + 1), toks.get(i + 2)) else { return false }; + if !hash.is_punct('#') || !open.is_punct('[') || !cfg.is_ident("cfg") { + return false; + } + let close = matching(toks, i + 1); + let inner = toks.get(i + 3..close).unwrap_or_default(); + inner.iter().any(|t| t.is_ident("test")) && !inner.iter().any(|t| t.is_ident("not")) +} + +/// `(body open, body close, name)` of every function with a body. +fn functions(toks: &[Token]) -> Vec<(usize, usize, String)> { + let mut out = Vec::new(); + for (i, t) in toks.iter().enumerate() { + if !t.is_ident("fn") { + continue; + } + let Some(name) = toks.get(i + 1).and_then(Token::ident) else { continue }; + if let Some(open) = next_block_or_semi(toks, i + 2).filter(|&o| toks.get(o).is_some_and(|t| t.is_punct('{'))) { + out.push((open, matching(toks, open), name.to_string())); + } + } + out +} + +/// Does `toks` call `.trigger(` for one of the click triggers? +fn calls_trigger(toks: &[Token]) -> bool { + toks.windows(3).any(|w| { + w.first().is_some_and(|t| t.is_punct('.')) + && w.get(1).and_then(Token::ident).is_some_and(|n| TRIGGERS.contains(&n)) + && w.get(2).is_some_and(|t| t.is_punct('(')) + }) +} + +/// Locals bound by `let name = … .clicked() …;`. +fn click_locals(toks: &[Token]) -> Vec { + let mut out = Vec::new(); + for (i, t) in toks.iter().enumerate() { + if !t.is_ident("let") { + continue; + } + let mut n = i + 1; + if toks.get(n).is_some_and(|t| t.is_ident("mut")) { + n += 1; + } + let (Some(name), Some(eq)) = (toks.get(n).and_then(Token::ident), toks.get(n + 1)) else { continue }; + if !eq.is_punct('=') { + continue; + } + let Some(end) = next_block_or_semi(toks, n + 2) else { continue }; + // A `{` before the `;` (a block or closure value) is out of scope for this heuristic. + if toks.get(end).is_some_and(|t| t.is_punct(';')) && calls_trigger(toks.get(n + 2..end).unwrap_or_default()) { + out.push(name.to_string()); + } + } + out +} + +/// Direct document/history mutations in `toks`: `(token index, description)`. +fn mutations(toks: &[Token]) -> Vec<(usize, String)> { + let mut out = Vec::new(); + for (i, t) in toks.iter().enumerate() { + let next = |k: usize| toks.get(i + k); + let Some(name) = t.ident() else { continue }; + let call = next(1).is_some_and(|t| t.is_punct('(')); + if name == "active_mut" && call && i > 0 && toks.get(i - 1).is_some_and(|p| p.is_punct('.')) { + out.push((i, "`active_mut()` changes the document directly".to_string())); + } else if name == "make_mut" && call { + let close = matching(toks, i + 1); + let args = toks.get(i + 2..close).unwrap_or_default(); + if args.iter().any(|a| a.ident().is_some_and(|n| n == "doc" || n.ends_with("_doc") || n == "document")) { + out.push((i, "`Arc::make_mut` on a document changes it directly".to_string())); + } + } else if name == "session" + && next(1).is_some_and(|t| t.is_punct('.')) + && let Some(m) = next(2).and_then(Token::ident).filter(|m| SESSION_MUTATORS.contains(m)) + && next(3).is_some_and(|t| t.is_punct('(')) + { + out.push((i, format!("`session.{m}()` bypasses the command registry"))); + } else if name == "session" + && let Some(field) = assigned_field(toks, i) + { + out.push((i, format!("`session.{field} = …` changes session state directly"))); + } + } + out +} + +/// `a.b` when `toks[i]` (`session`) starts `session.a.b = …` or a compound assignment (`+=`, …). +fn assigned_field(toks: &[Token], i: usize) -> Option { + let mut path = Vec::new(); + let mut k = i + 1; + while toks.get(k).is_some_and(|t| t.is_punct('.')) { + path.push(toks.get(k + 1)?.ident()?.to_string()); + k += 2; + } + let first = toks.get(k)?; + let second = toks.get(k + 1); + let plain = first.is_punct('=') && !second.is_some_and(|t| t.is_punct('=') || t.is_punct('>')); + let compound = matches!(first.kind, Kind::Punct('+' | '-' | '*' | '/' | '|' | '&' | '^')) && second.is_some_and(|t| t.is_punct('=')); + (!path.is_empty() && (plain || compound)).then(|| path.join(".")) +} + +/// Names of functions that draw a widget and report an edit: they take a `Ui` and return `bool` +/// (`fn color_readout(ui: &mut Ui, …) -> bool`). `if widget(…) { … }` is a click handler too. +pub fn widget_fns(src: &str) -> Vec { + let toks = strip_cfg_test(lex(src)); + let mut out = Vec::new(); + for (i, t) in toks.iter().enumerate() { + if !t.is_ident("fn") { + continue; + } + let Some(name) = toks.get(i + 1).and_then(Token::ident) else { continue }; + let Some(open) = toks.iter().enumerate().skip(i + 2).find(|(_, t)| t.is_punct('(')).map(|(k, _)| k) else { continue }; + let close = matching(&toks, open); + let takes_ui = toks.get(open..close).unwrap_or_default().iter().any(|t| t.is_ident("Ui")); + let returns_bool = toks.get(close + 1).is_some_and(|t| t.is_punct('-')) + && toks.get(close + 2).is_some_and(|t| t.is_punct('>')) + && toks.get(close + 3).is_some_and(|t| t.is_ident("bool")); + if takes_ui && returns_bool && !out.iter().any(|n| n == name) { + out.push(name.to_string()); + } + } + out +} + +/// Every click handler in `src` that changes the document directly. `file` labels the results. +/// `widgets` lists the widget functions of the whole tree (see [`widget_fns`]). +pub fn scan(file: &str, src: &str, widgets: &[String]) -> Vec { + let toks = strip_cfg_test(lex(src)); + let fns = functions(&toks); + let locals = click_locals(&toks); + let mut out: Vec = Vec::new(); + for (i, t) in toks.iter().enumerate() { + if !t.is_ident("if") { + continue; + } + let Some(open) = next_block_or_semi(&toks, i + 1) else { continue }; + if !toks.get(open).is_some_and(|t| t.is_punct('{')) { + continue; + } + let cond = toks.get(i + 1..open).unwrap_or_default(); + let is_click = calls_trigger(cond) + || cond.iter().any(|c| c.ident().is_some_and(|n| locals.iter().any(|l| l == n))) + || cond + .windows(2) + .any(|w| w.first().and_then(Token::ident).is_some_and(|n| widgets.iter().any(|f| f == n)) && w.get(1).is_some_and(|t| t.is_punct('('))); + if !is_click { + continue; + } + let close = matching(&toks, open); + let body = toks.get(open..=close).unwrap_or_default(); + for (k, what) in mutations(body) { + let at = open + k; + let line = toks.get(at).map_or(0, |t| t.line); + let func = fns.iter().filter(|(o, c, _)| *o < at && at < *c).min_by_key(|(o, c, _)| c - o).map_or("", |f| f.2.as_str()); + let v = Violation { file: file.to_string(), line, func: func.to_string(), what }; + if !out.contains(&v) { + out.push(v); + } + } + } + out.sort_by_key(|v| v.line); + out +} + +/// Is `path` (relative, `/`-separated) test code? +fn is_test_file(path: &str) -> bool { + path.ends_with("_tests.rs") || path.ends_with("/tests.rs") || path == "tests.rs" || path.contains("/tests/") || path.starts_with("tests/") +} + +/// Violations not covered by `allow`, and allowlist entries that match nothing (stale). +pub fn check<'a>(violations: &[Violation], allow: &'a [Allowed]) -> (Vec, Vec<&'a Allowed>) { + let covered = |v: &Violation, a: &Allowed| v.file == a.file && v.func == a.func; + let new = violations.iter().filter(|v| !allow.iter().any(|a| covered(v, a))).cloned().collect(); + let stale = allow.iter().filter(|a| !violations.iter().any(|v| covered(v, a))).collect(); + (new, stale) +} + +fn rust_files(dir: &Path, out: &mut Vec) -> Result<(), String> { + let entries = std::fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))?; + for e in entries { + let p = e.map_err(|e| format!("{}: {e}", dir.display()))?.path(); + if p.is_dir() { + rust_files(&p, out)?; + } else if p.extension().is_some_and(|x| x == "rs") { + out.push(p); + } + } + Ok(()) +} + +/// Scan the shell source under `root` (the workspace root). +pub fn scan_tree(root: &Path) -> Result, String> { + let base = root.join(SCAN_ROOT); + let mut files = Vec::new(); + rust_files(&base, &mut files)?; + files.sort(); + let mut sources = Vec::new(); + for f in files { + let rel = f.strip_prefix(&base).map_err(|e| e.to_string())?.to_string_lossy().replace('\\', "/"); + if is_test_file(&rel) { + continue; + } + let src = std::fs::read_to_string(&f).map_err(|e| format!("{}: {e}", f.display()))?; + sources.push((format!("{SCAN_ROOT}/{rel}"), src)); + } + let mut widgets = Vec::new(); + for (_, src) in &sources { + for name in widget_fns(src) { + if !widgets.contains(&name) { + widgets.push(name); + } + } + } + Ok(sources.iter().flat_map(|(file, src)| scan(file, src, &widgets)).collect()) +} + +pub fn run(root: &Path) -> Result<(), String> { + let violations = scan_tree(root)?; + let (new, stale) = check(&violations, ALLOWLIST); + println!("Panel command routing (AGENTS.md rule 1): click handlers in {SCAN_ROOT} must dispatch a command id\n"); + println!("{} direct mutation(s) in click handlers, {} allowlisted function(s)", violations.len(), ALLOWLIST.len()); + for v in &new { + println!(" {}:{}: in `{}`: {}; dispatch an engine command by id instead", v.file, v.line, v.func, v.what); + } + for a in ALLOWLIST.iter().filter(|a| !stale.iter().any(|s| s.file == a.file && s.func == a.func)) { + println!(" allowlisted {} `{}`: {}", a.file, a.func, a.reason); + } + for a in &stale { + println!(" stale allowlist entry {} `{}`: no direct mutation left there; delete it from xtask/src/panel_commands.rs", a.file, a.func); + } + if new.is_empty() && stale.is_empty() { + println!("OK: every other click handler routes through a command id."); + Ok(()) + } else { + Err(format!("{} unrouted click handler(s), {} stale allowlist entr(y/ies)", new.len(), stale.len())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn lines(src: &str) -> Vec<(usize, String)> { + scan("f.rs", src, &[]).into_iter().map(|v| (v.line, v.func)).collect() + } + + #[test] + fn routed_button_passes() { + let src = r#" +fn panel(app: &mut App, ui: &mut Ui) { + if ui.button("Flatten").clicked() { + let _ = app.run("layer.flatten", json!({})); + } + if ui.button("Undo").clicked() { + menus::invoke(app, ctx, "edit.undo", Value::Null); + } +} +"#; + assert!(scan("f.rs", src, &[]).is_empty()); + } + + #[test] + fn direct_mutation_fails_with_file_line_and_function() { + let src = r#" +fn panel(app: &mut App, ui: &mut Ui) { + if ui.button("Hide").clicked() { + if let Some(st) = app.session.active_mut() { + st.active_layer = None; + } + } +} +"#; + let v = scan("crates/ui-egui/src/x.rs", src, &[]); + assert_eq!(v.len(), 1); + assert_eq!((v[0].file.as_str(), v[0].line, v[0].func.as_str()), ("crates/ui-egui/src/x.rs", 4, "panel")); + assert!(v[0].what.contains("active_mut")); + } + + #[test] + fn every_mutation_kind_and_trigger_is_caught() { + let src = r#" +fn a(app: &mut App, ui: &mut Ui) { + if ui.button("x").secondary_clicked() { app.session.undo(); } + if resp.double_clicked() && ok { Arc::make_mut(&mut st.doc).layers.clear(); } + let pressed = ui.button("y").clicked(); + if pressed { app.session.close(0); } + if slider.changed() { app.session.select_layer(id); } +} +"#; + assert_eq!(lines(src), vec![(3, "a".into()), (4, "a".into()), (6, "a".into()), (7, "a".into())]); + } + + #[test] + fn mutation_outside_click_handlers_test_code_comments_and_strings_pass() { + let src = r##" +fn sync(app: &mut App) { + if let Some(st) = app.session.active_mut() { st.dirty = true; } +} +fn panel(app: &mut App, ui: &mut Ui) { + if ui.button("x").clicked() { + // app.session.active_mut() + let s = "app.session.undo()"; + let r = r#"session.close(0)"#; + let c = '{'; + let _ = app.run("edit.undo", Value::Null); + } + if ui.button("y").clicked() { theme(Arc::make_mut(&mut fonts)); } +} +#[cfg(test)] +mod tests { + fn t(app: &mut App, ui: &mut Ui) { + if ui.button("x").clicked() { app.session.undo(); } + } +} +"##; + assert!(scan("f.rs", src, &[]).is_empty(), "{:?}", scan("f.rs", src, &[])); + } + + #[test] + fn widget_functions_and_session_assignments_count() { + let widgets = widget_fns("fn color_readout(ui: &mut egui::Ui, c: &mut [f32; 4]) -> bool { false }\nfn label(ui: &mut Ui) {}"); + assert_eq!(widgets, vec!["color_readout".to_string()]); + let src = r#" +fn field(app: &mut App, ui: &mut Ui) { + if color_readout(ui, &mut c) { app.session.tools.foreground = c; } + if r.dragged() { app.session.tools.brush.size += 1.0; } + if r.clicked() { let same = app.session.tools.foreground == c; app.ui.show = true; } + if r.clicked() { let _ = app.run("tools.setColors", json!({"foreground": "red"})); } +} +"#; + let v = scan("f.rs", src, &widgets); + assert_eq!(v.iter().map(|v| v.line).collect::>(), vec![3, 4]); + assert!(v[0].what.contains("session.tools.foreground"), "{}", v[0].what); + assert!(v[1].what.contains("session.tools.brush.size"), "{}", v[1].what); + } + + #[test] + fn cfg_not_test_is_scanned_and_lifetimes_do_not_confuse_the_lexer() { + let src = r#" +#[cfg(not(test))] +fn panel<'a>(app: &'a mut App, ui: &mut Ui) { + if ui.button("x").clicked() { app.session.redo(); } +} +"#; + assert_eq!(lines(src), vec![(4, "panel".into())]); + } + + #[test] + fn allowlisted_passes_and_stale_entry_is_reported() { + let v = vec![Violation { file: "a.rs".into(), line: 3, func: "panel".into(), what: "x".into() }]; + let allow = [Allowed { file: "a.rs", func: "panel", reason: "known" }, Allowed { file: "a.rs", func: "gone", reason: "fixed already" }]; + let (new, stale) = check(&v, &allow); + assert!(new.is_empty()); + assert_eq!(stale.len(), 1); + assert_eq!(stale[0].func, "gone"); + let (new, stale) = check(&v, &[]); + assert_eq!(new, v); + assert!(stale.is_empty()); + } + + #[test] + fn test_files_are_recognised() { + assert!(is_test_file("panels_tests.rs")); + assert!(is_test_file("file_dialog/tests.rs")); + assert!(is_test_file("discard_ui/tests/tiff_tests.rs")); + assert!(!is_test_file("panels.rs")); + assert!(!is_test_file("tests_ui.rs")); + } + + #[test] + fn the_allowlist_has_reasons_and_no_duplicates() { + for (i, a) in ALLOWLIST.iter().enumerate() { + assert!(!a.reason.trim().is_empty(), "{} `{}` needs a reason", a.file, a.func); + assert!(a.file.starts_with(SCAN_ROOT), "{} is outside {SCAN_ROOT}", a.file); + assert!(!ALLOWLIST.iter().skip(i + 1).any(|b| b.file == a.file && b.func == a.func), "duplicate {} `{}`", a.file, a.func); + } + } +} From d71e6dab0f5b0f28da0fb4cddd7295f022f62f56 Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 01:42:55 +0200 Subject: [PATCH 002/219] Regenerate docs/scorecard.md after merging main Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/scorecard.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/scorecard.md b/docs/scorecard.md index d2ee641b1e..350733493b 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -11,7 +11,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Tools](#tools) | 28 | 14 | 1 | | | [Workspace and UI](#workspace-and-ui) | 13 | 5 | 5 | settings that do nothing: 26 of 156 (target 0) | | [Type](#type) | 4 | 2 | 3 | | -| [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | +| [Automation](#automation) | 5 | 3 | 3 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 28 of 28 crates; panic_hunt present | | [Distribution](#distribution) | 5 | 4 | 2 | | | [Open bugs](#open-bugs) | 29 | 2 | 1 | | @@ -234,7 +234,7 @@ Menu wiring (`docs/parity-checklist.md`): 631 of 631 menu items live (100.0%). T | AUTO-219-5 | Batch: subfolders, open documents, naming template, progress/cancel, error log, stop | partial | [#219](https://github.com/storytold/photocraft/issues/219) | file_cmds.rs:590-631 folder/array input, per-file errors; no recursion, template or cancel | | AUTO-219-6 | Plug-in ABI v2: panel kind, progress/cancel calls, read other layers, Last Filter repeats | missing | [#219](https://github.com/storytold/photocraft/issues/219) | runtime.rs:15 ABI_VERSION = 1; manifest.rs:18 Kind::Filter only | | AUTO-219-7 | Local ONNX models behind a flag, managed in Preferences; subject select, background removal | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No ONNX runtime dependency | -| AUTO-219-8 | xtask check that every panel button routes through a command id | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No such xtask check | +| AUTO-219-8 | xtask check that every panel button routes through a command id | partial | [#219](https://github.com/storytold/photocraft/issues/219) | cargo xtask panel-commands (run by xtask ci and CI) fails on a shell click handler that changes the document or session directly; 2 allowlisted functions left (panels.rs color_picker, color_field assign the foreground/background colour instead of tools.setColors) | | AUTO-219-9 | Headless commands for in-canvas type editing, Liquify/Puppet strokes, transform handles | done | [#219](https://github.com/storytold/photocraft/issues/219) | type.hitTest, type.caret, type.navigate; filter.liquify strokes, edit.puppetWarp pins, type.edit, edit.transform | | AUTO-219-10 | Agent acceptance suite grown to 30-50 workflows | partial | [#219](https://github.com/storytold/photocraft/issues/219) | automation/tests/agent_tasks.rs runs 10 workflows over MCP | From 6ceb0dbae22e81efec2856a75fa663e2f501738c Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 14:06:39 +0200 Subject: [PATCH 003/219] panel-commands: follow main's move of the HSB sliders into hsb_sliders Main split the Color panel's HSB sliders out of color_picker (#4399), so the allowlist entry moves with them; regenerate docs/scorecard.md after the merge. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/scorecard.md | 4 ++-- scorecard/automation.toml | 2 +- xtask/src/panel_commands.rs | 10 +++++----- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/scorecard.md b/docs/scorecard.md index 1ad3f3aa3d..5008607ec1 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -11,7 +11,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Tools](#tools) | 28 | 14 | 1 | | | [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 156 (target 0) | | [Type](#type) | 4 | 3 | 2 | | -| [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | +| [Automation](#automation) | 5 | 3 | 3 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 28 of 28 crates; panic_hunt present | | [Distribution](#distribution) | 5 | 4 | 2 | | | [Open bugs](#open-bugs) | 29 | 2 | 1 | | @@ -234,7 +234,7 @@ Menu wiring (`docs/parity-checklist.md`): 631 of 631 menu items live (100.0%). T | AUTO-219-5 | Batch: subfolders, open documents, naming template, progress/cancel, error log, stop | partial | [#219](https://github.com/storytold/photocraft/issues/219) | file_cmds.rs:590-631 folder/array input, per-file errors; no recursion, template or cancel | | AUTO-219-6 | Plug-in ABI v2: panel kind, progress/cancel calls, read other layers, Last Filter repeats | missing | [#219](https://github.com/storytold/photocraft/issues/219) | runtime.rs:15 ABI_VERSION = 1; manifest.rs:18 Kind::Filter only | | AUTO-219-7 | Local ONNX models behind a flag, managed in Preferences; subject select, background removal | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No ONNX runtime dependency | -| AUTO-219-8 | xtask check that every panel button routes through a command id | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No such xtask check | +| AUTO-219-8 | xtask check that every panel button routes through a command id | partial | [#219](https://github.com/storytold/photocraft/issues/219) | cargo xtask panel-commands (run by xtask ci and CI) fails on a shell click handler that changes the document or session directly; 2 allowlisted functions left (panels.rs hsb_sliders, color_field assign the foreground/background colour instead of tools.setColors) | | AUTO-219-9 | Headless commands for in-canvas type editing, Liquify/Puppet strokes, transform handles | done | [#219](https://github.com/storytold/photocraft/issues/219) | type.hitTest, type.caret, type.navigate; filter.liquify strokes, edit.puppetWarp pins, type.edit, edit.transform | | AUTO-219-10 | Agent acceptance suite grown to 30-50 workflows | partial | [#219](https://github.com/storytold/photocraft/issues/219) | automation/tests/agent_tasks.rs runs 10 workflows over MCP | diff --git a/scorecard/automation.toml b/scorecard/automation.toml index e0f977d210..32d1d5fcf2 100644 --- a/scorecard/automation.toml +++ b/scorecard/automation.toml @@ -65,7 +65,7 @@ id = "AUTO-219-8" target = "xtask check that every panel button routes through a command id" status = "partial" issue = 219 -note = "cargo xtask panel-commands (run by xtask ci and CI) fails on a shell click handler that changes the document or session directly; 2 allowlisted functions left (panels.rs color_picker, color_field assign the foreground/background colour instead of tools.setColors)" +note = "cargo xtask panel-commands (run by xtask ci and CI) fails on a shell click handler that changes the document or session directly; 2 allowlisted functions left (panels.rs hsb_sliders, color_field assign the foreground/background colour instead of tools.setColors)" [[item]] id = "AUTO-219-9" diff --git a/xtask/src/panel_commands.rs b/xtask/src/panel_commands.rs index e986512fc8..ec76ff9f86 100644 --- a/xtask/src/panel_commands.rs +++ b/xtask/src/panel_commands.rs @@ -43,13 +43,13 @@ pub struct Allowed { /// Existing click handlers that change the document directly. Shrink only: each entry is a /// follow-up (route it through an engine command), never a place to park new code. pub const ALLOWLIST: &[Allowed] = &[ - // Window › Color: the HSB sliders and the hex/RGB readout set the foreground colour by - // assignment, so the change never reaches the journal, action recording or the control - // channel's event log. Fix: dispatch `tools.setColors` (it already exists). + // Window › Color: the HSB sliders set the foreground colour by assignment, so the change + // never reaches the journal, action recording or the control channel's event log. + // Fix: dispatch `tools.setColors` (it already exists). Allowed { file: "crates/ui-egui/src/panels.rs", - func: "color_picker", - reason: "Color panel sliders and readout assign session.tools.foreground; dispatch tools.setColors", + func: "hsb_sliders", + reason: "Color panel HSB sliders assign session.tools.foreground; dispatch tools.setColors", }, // The Color panel's colour field and hue strip (and their readout) assign the foreground or // background colour on every drag frame. Fix: dispatch `tools.setColors`. From 02fff63cb4b8a439dab8a3bd53376f96367ee991 Mon Sep 17 00:00:00 2001 From: augrclk <217665348+augrclk@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:55:47 +0300 Subject: [PATCH 004/219] Brush Settings: combine the Spacing checkbox and percentage header Keep the saved spacing while Off or toggling, and retain percentage limits and tools.setBrush routing. Cover the shared header in all themes and add disabled-input, value-retention and journal regressions. Addresses only the Spacing header subsection of #3825. Co-authored-by: GPT-6 --- crates/ui-egui/src/brush_panel_tests.rs | 41 +++++++++++++ crates/ui-egui/src/brush_sections.rs | 78 +++++++++++++++++++++++-- docs/brush-parity.md | 4 +- docs/gaps.md | 7 ++- 4 files changed, 123 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/brush_panel_tests.rs b/crates/ui-egui/src/brush_panel_tests.rs index 750a9d5506..14fa1e40ba 100644 --- a/crates/ui-egui/src/brush_panel_tests.rs +++ b/crates/ui-egui/src/brush_panel_tests.rs @@ -547,6 +547,47 @@ fn harness(tab: usize, section: usize) -> Harness<'static, PhotocraftApp> { h } +#[test] +fn spacing_row_edits_route_only_the_changed_fields_through_the_brush_command() { + use egui_kittest::kittest::{NodeT, Queryable}; + + let mut h = harness(0, 0); + h.state_mut().run("tools.setBrush", json!({"brush": {"spacing": 0.427, "spacingEnabled": false}})).unwrap(); + h.run_steps(2); + let journal = h.state().session.journal.len(); + let spacing = h.state().session.tools.brush.spacing; + let checkbox = h.query_all_by_label("Spacing").min_by(|a, b| a.rect().top().total_cmp(&b.rect().top())).unwrap(); + checkbox.click(); + h.run_steps(2); + assert!(h.state().session.tools.brush.spacing_enabled); + assert_eq!(h.state().session.tools.brush.spacing, spacing); + assert_eq!(last_journal(h.state()).unwrap().0, "tools.setBrush"); + assert_eq!(last_journal(h.state()).unwrap().1["brush"], json!({"spacingEnabled": true})); + let field = h.query_all_by_role(egui::accesskit::Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(43.0)).unwrap(); + field.click(); + h.key_press(egui::Key::ArrowUp); + h.run_steps(2); + assert_eq!(h.state().session.tools.brush.spacing, 0.44); + assert_eq!(last_journal(h.state()).unwrap().0, "tools.setBrush"); + assert_eq!(last_journal(h.state()).unwrap().1["brush"], json!({"spacing": 0.44_f32})); + let checkbox = h.query_all_by_label("Spacing").min_by(|a, b| a.rect().top().total_cmp(&b.rect().top())).unwrap(); + checkbox.click(); + h.run_steps(2); + assert_eq!(last_journal(h.state()).unwrap().1["brush"], json!({"spacingEnabled": false})); + let field = h.query_all_by_role(egui::accesskit::Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(44.0)).unwrap(); + assert!(field.accesskit_node().is_disabled()); + field.click(); + h.key_press(egui::Key::ArrowUp); + h.run_steps(2); + assert_eq!(h.state().session.tools.brush.spacing, 0.44); + assert_eq!(h.state().session.journal.len(), journal + 3, "disabled input adds no command"); + let checkbox = h.query_all_by_label("Spacing").min_by(|a, b| a.rect().top().total_cmp(&b.rect().top())).unwrap(); + checkbox.click(); + h.run_steps(2); + assert_eq!(h.state().session.tools.brush.spacing, 0.44); + assert_eq!(h.state().session.journal.len(), journal + 4); +} + fn toggle_value(app: &PhotocraftApp, section: usize) -> bool { let brush = serde_json::to_value(&app.session.tools.brush).unwrap(); let path = match section { diff --git a/crates/ui-egui/src/brush_sections.rs b/crates/ui-egui/src/brush_sections.rs index ff7ca5d292..4b6a1ea6fb 100644 --- a/crates/ui-egui/src/brush_sections.rs +++ b/crates/ui-egui/src/brush_sections.rs @@ -53,6 +53,23 @@ fn signed_pct(ui: &mut egui::Ui, label: &str, v: &mut f32, min: f32, max: f32) - changed } +/// The checkbox labels the spacing field; toggling it must retain imported fractional values. +fn spacing_row(ui: &mut egui::Ui, b: &mut BrushSettings) { + let mut percent = (b.spacing * 100.0).round(); + let mut changed = false; + ui.horizontal(|ui| { + widgets::checkbox(ui, &mut b.spacing_enabled, tl!("Spacing")).on_hover_text(tl!("Off: the speed of the pointer sets the spacing")); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + changed = ui.add_enabled_ui(b.spacing_enabled, |ui| widgets::value_field(ui, &mut percent, 1.0..=1000.0, "%", 74.0)).inner.changed(); + }); + }); + changed |= ui.add_enabled_ui(b.spacing_enabled, |ui| widgets::slider(ui, &mut percent, 1.0..=1000.0, None)).inner.changed(); + if changed { + b.spacing = (percent.round() / 100.0).clamp(0.01, 10.0); + } + ui.add_space(4.0); +} + fn num(ui: &mut egui::Ui, label: &str, v: &mut f32, range: std::ops::RangeInclusive, suffix: &str) -> bool { let mut x = *v; let changed = widgets::slider_row(ui, label, &mut x, range.clone(), suffix, None).changed(); @@ -325,11 +342,7 @@ fn tip_shape(ui: &mut egui::Ui, b: &mut BrushSettings, presets: &[BrushPreset], ui.add_enabled_ui(matches!(b.tip, TipShape::Round), |ui| { pct(ui, tl!("Hardness"), &mut b.hardness, 1.0); }); - // Unchecked, the pointer's speed sets the spacing (Photoshop). - widgets::checkbox(ui, &mut b.spacing_enabled, tl!("Spacing")).on_hover_text(tl!("Off: the speed of the pointer sets the spacing")); - ui.add_space(2.0); - let on = b.spacing_enabled; - ui.add_enabled_ui(on, |ui| signed_pct(ui, tl!("Spacing"), &mut b.spacing, 0.01, 10.0)); + spacing_row(ui, b); None } @@ -645,6 +658,61 @@ mod tests { h } + #[test] + fn spacing_row_has_one_label_and_a_shared_header_in_every_theme() { + for kind in theme::ThemeKind::ALL { + for enabled in [false, true] { + let b = BrushSettings { spacing: 0.427, spacing_enabled: enabled, ..Default::default() }; + let mut h = section_harness(0, b.clone()); + crate::PhotocraftApp::setup_context(&h.ctx, kind); + h.run_steps(3); + assert_eq!(h.query_all_by_label("Spacing").count(), 1, "{kind:?}: the checkbox is the single label"); + let checkbox = h.get_by_label("Spacing").rect(); + let field = h.query_all_by_role(Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(43.0)).unwrap(); + assert!((checkbox.center().y - field.rect().center().y).abs() < 2.0, "{kind:?}: checkbox and percentage share a row"); + assert_eq!(field.accesskit_node().is_disabled(), !enabled); + assert_eq!(h.state(), &b, "rendering does not round the saved spacing"); + } + } + } + + #[test] + fn spacing_row_retains_disabled_values_and_percentage_editing_limits() { + let mut h = section_harness(0, BrushSettings { spacing: 0.42, spacing_enabled: false, ..Default::default() }); + let before = h.state().clone(); + let field = h.query_all_by_role(Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(42.0)).unwrap(); + let from = pos2(field.rect().left() - 100.0, field.rect().bottom() + 9.0); + let to = pos2(field.rect().right() - 25.0, from.y); + field.click(); + h.key_press(egui::Key::ArrowUp); + h.event(egui::Event::Text("999".into())); + h.key_press(egui::Key::Enter); + h.run_steps(2); + drag(&mut h, from, to); + assert_eq!(h.state(), &before, "Off ignores both field input and a track drag"); + let checkbox = h.query_all_by_label("Spacing").min_by(|a, b| a.rect().top().total_cmp(&b.rect().top())).unwrap(); + checkbox.click(); + h.run_steps(2); + assert!(h.state().spacing_enabled); + assert_eq!(h.state().spacing, 0.42, "enabling keeps the saved value"); + let field = h.query_all_by_role(Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(42.0)).unwrap(); + field.click(); + h.key_press(egui::Key::ArrowUp); + h.run_steps(2); + assert_eq!(h.state().spacing, 0.43, "integer percentage edits become a fraction"); + drag(&mut h, from, to); + assert!(h.state().spacing > 5.0, "the same track is editable when enabled"); + for (spacing, value, key) in [(0.01, 1.0, egui::Key::ArrowDown), (10.0, 1000.0, egui::Key::ArrowUp)] { + h.state_mut().spacing = spacing; + h.run_steps(2); + let field = h.query_all_by_role(Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(value)).unwrap(); + field.click(); + h.key_press(key); + h.run_steps(2); + assert_eq!(h.state().spacing, spacing, "the percentage stays within 1..1000"); + } + } + /// #3825: jitter is an amount, not a hue selector; it uses the themed amount track. #[test] fn hue_jitter_paints_an_amount_track_in_every_theme() { diff --git a/docs/brush-parity.md b/docs/brush-parity.md index 7a5e499820..6585b533e4 100644 --- a/docs/brush-parity.md +++ b/docs/brush-parity.md @@ -1,6 +1,6 @@ # Brush and painting parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Hue Jitter amount track; Brush Settings minimum fields follow their Control; checkbox-only rows and non-destructive Smoothing switch; shaped-tip cursor follows the view) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Spacing checkbox and percentage share one header; Hue Jitter amount track; Brush Settings minimum fields follow their Control; checkbox-only rows and non-destructive Smoothing switch; shaped-tip cursor follows the view) · **Target:** Adobe Photoshop 2026 Painting is where Photoshop users notice differences first, so it gets its own checklist: the brush engine (`crates/paint/src/brush.rs`), the Brush Settings UI @@ -19,6 +19,7 @@ tablets against Photoshop side by side. | Section | Status | Evidence | |---|---|---| | Brush Tip Shape: size, angle, roundness, hardness, spacing, flip X/Y | done | `brush.rs` | +| Brush Tip Shape Spacing checkbox and percentage share one header without a duplicated label | done | #3825 (Spacing header only); UI regressions cover all 14 themes, enabled/disabled editing, percentage limits, fractional-value retention and `tools.setBrush` routing. | | Shape Dynamics (size/angle/roundness jitter, pen pressure/tilt/rotation/wheel controls, minimums, flip jitter, brush projection) | done | #174 | | Scattering (both axes, count, count jitter) | done | | | Texture (pattern, scale, brightness, contrast, mode, depth, each tip) | done | | @@ -121,3 +122,4 @@ tablets against Photoshop side by side. | 2026-10-12 | minor | A shaped tip's cursor outline turns and flips with a rotated or flipped view, and follows pixel aspect correction | | 2026-10-13 | minor | Shape Dynamics and Transfer minimum inputs follow their Control and retain their values while disabled (#3825, partial). No brush-feel estimate changed. | | 2026-10-13 | minor | Hue Jitter uses the same themed amount slider as other jitter controls (#3825, partial). No brush-feel estimate changed. | +| 2026-10-13 | minor | Brush Tip Shape combines the Spacing checkbox and percentage into one header (#3825, partial), retaining the value while disabled. No brush-feel estimate changed. | diff --git a/docs/gaps.md b/docs/gaps.md index 25a286aab8..a3d116ee9b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G4: combined Spacing header, #3825 partial; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -156,6 +156,10 @@ what an agent can't finish alone. - **Estimate:** 250–400 h (many overlap with other gaps; count each fix once). ### G4. Brush feel +- **Progress (2026-10-13, #3825 partial):** Brush Tip Shape's Spacing checkbox and + percentage share one header, removing the duplicated label. Disabled controls retain + the saved spacing; toggling does not round fractional imported values. The issue's + remaining layout and control gaps stay open. - **Progress (2026-10-13, #3825 partial):** Hue Jitter shows a standard themed percentage-amount track rather than a hue spectrum. Other Brush Settings layout and brush-behaviour gaps remain open. @@ -264,6 +268,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| | 2026-10-13 | minor | G4: Hue Jitter amount-track appearance (#3825, partial); no readiness percentage or estimate changed | +| 2026-10-13 | minor | G4: combined Spacing checkbox/percentage header (#3825, partial); disabled-value and percentage-edit behavior retained; no readiness percentage or estimate changed | | 2026-10-13 | minor | G4: minimum inputs in Shape Dynamics and Transfer follow their Control (#3825, partial); no readiness percentage or estimate changed | | 2026-10-12 | minor | G5: Free Transform options bar H/V skew fields done | | 2026-10-12 | minor | G11: `.atn` import maps 112 events (Select and Mask, Spatter, Ripple, Crystallize, Pattern Fill, mask targeting, Step Backward, points, Bitmap) | From fe083602a4fcff30159257c89af699f561bbaef3 Mon Sep 17 00:00:00 2001 From: augrclk <217665348+augrclk@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:29:15 +0300 Subject: [PATCH 005/219] Brush Settings: place Both Axes on the Scatter header Share the header layout between Scattering and Dual Brush, retaining label scrubbing, percentage limits, fractional values and tools.setBrush routing. Cover layout in all themes and guard disabled input and independent brush blocks. Addresses only the Both Axes placement subsection of #3825. Co-authored-by: GPT-6 --- crates/ui-egui/src/brush_panel_tests.rs | 63 ++++++++++++ crates/ui-egui/src/brush_sections.rs | 126 +++++++++++++++++++++++- crates/ui-egui/src/widgets.rs | 3 +- docs/brush-parity.md | 4 +- docs/gaps.md | 7 +- 5 files changed, 196 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/brush_panel_tests.rs b/crates/ui-egui/src/brush_panel_tests.rs index 750a9d5506..94cc3e4f4b 100644 --- a/crates/ui-egui/src/brush_panel_tests.rs +++ b/crates/ui-egui/src/brush_panel_tests.rs @@ -547,6 +547,69 @@ fn harness(tab: usize, section: usize) -> Harness<'static, PhotocraftApp> { h } +#[test] +fn scatter_header_edits_route_to_the_correct_brush_block_and_disabled_sections_ignore_input() { + use egui::accesskit::Role; + use egui_kittest::kittest::{NodeT, Queryable}; + + for section in [2, 4] { + let block = if section == 2 { "scattering" } else { "dualBrush" }; + let mut h = harness(0, section); + let mut initial = json!({}); + initial[block] = if section == 2 { + json!({"enabled": true, "bothAxes": false, "scatter": {"jitter": 2.137, "control": "penPressure"}, "count": 5}) + } else { + json!({"enabled": true, "bothAxes": false, "scatter": 2.137, "count": 7}) + }; + h.state_mut().run("tools.setBrush", json!({"brush": initial})).unwrap(); + h.run_steps(3); + let journal = h.state().session.journal.len(); + let before = h.state().session.tools.brush.clone(); + h.get_by_label("Both Axes").click(); + h.run_steps(2); + let mut expected = json!({}); + expected[block] = json!({"bothAxes": true}); + assert_eq!(last_journal(h.state()).unwrap().0, "tools.setBrush"); + assert_eq!(last_journal(h.state()).unwrap().1["brush"], expected); + let mut after = before; + if section == 2 { + after.scattering.both_axes = true; + } else { + after.dual_brush.both_axes = true; + } + assert_eq!(h.state().session.tools.brush, after, "toggle changes only the matching axis flag"); + let field = h.query_all_by_role(Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(214.0)).unwrap(); + field.click(); + h.key_press(egui::Key::ArrowUp); + h.run_steps(2); + expected[block] = if section == 2 { json!({"scatter": {"jitter": 2.15_f32}}) } else { json!({"scatter": 2.15_f32}) }; + assert_eq!(last_journal(h.state()).unwrap().1["brush"], expected); + if section == 2 { + after.scattering.scatter.jitter = 2.15; + } else { + after.dual_brush.scatter = 2.15; + } + assert_eq!(h.state().session.tools.brush, after); + assert_eq!(h.state().session.journal.len(), journal + 2); + let mut off = json!({}); + off[block] = json!({"enabled": false}); + h.state_mut().run("tools.setBrush", json!({"brush": off})).unwrap(); + h.run_steps(2); + let before = h.state().session.tools.brush.clone(); + let journal = h.state().session.journal.len(); + let field = h.query_all_by_role(Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(215.0)).unwrap(); + assert!(field.accesskit_node().is_disabled()); + field.click(); + h.key_press(egui::Key::ArrowUp); + h.event(egui::Event::Text("999".into())); + h.key_press(egui::Key::Enter); + h.get_by_label("Both Axes").click(); + h.run_steps(2); + assert_eq!(h.state().session.tools.brush, before, "the disabled header retains both values"); + assert_eq!(h.state().session.journal.len(), journal, "disabled edits add no command"); + } +} + fn toggle_value(app: &PhotocraftApp, section: usize) -> bool { let brush = serde_json::to_value(&app.session.tools.brush).unwrap(); let path = match section { diff --git a/crates/ui-egui/src/brush_sections.rs b/crates/ui-egui/src/brush_sections.rs index ff7ca5d292..c79ec8d7ed 100644 --- a/crates/ui-egui/src/brush_sections.rs +++ b/crates/ui-egui/src/brush_sections.rs @@ -353,10 +353,29 @@ fn shape_dynamics(ui: &mut egui::Ui, b: &mut BrushSettings) { widgets::checkbox(ui, &mut sd.brush_projection, tl!("Brush Projection")).on_hover_text(tl!("Apply the pen's tilt and rotation to the tip shape")); } +/// Both scatter controls put their axis switch between the label and percentage field. +fn scatter_row(ui: &mut egui::Ui, amount: &mut f32, both_axes: &mut bool) { + let t = Tokens::get(ui.ctx()); + let mut percent = (*amount * 100.0).round(); + let mut changed = false; + ui.horizontal(|ui| { + let label = ui.label(RichText::new(tl!("Scatter")).color(t.text_dim)); + widgets::scrub_label_within(ui, &label, f32::INFINITY); + widgets::checkbox(ui, both_axes, tl!("Both Axes")); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + changed = widgets::value_field(ui, &mut percent, 0.0..=1000.0, "%", 74.0).changed(); + }); + }); + changed |= widgets::slider(ui, &mut percent, 0.0..=1000.0, None).changed(); + if changed { + *amount = (percent.round() / 100.0).clamp(0.0, 10.0); + } + ui.add_space(4.0); +} + fn scattering(ui: &mut egui::Ui, b: &mut BrushSettings) { let sc = &mut b.scattering; - pct(ui, tl!("Scatter"), &mut sc.scatter.jitter, 10.0); - widgets::checkbox(ui, &mut sc.both_axes, tl!("Both Axes")); + scatter_row(ui, &mut sc.scatter.jitter, &mut sc.both_axes); ui.add_space(2.0); control_row(ui, "brush-scatter-ctl", &mut sc.scatter, false); ui.add_space(6.0); @@ -498,8 +517,7 @@ fn dual_brush(ui: &mut egui::Ui, b: &mut BrushSettings, presets: &[BrushPreset]) let orig = d.tip.bitmap_size().map(|(w, h)| w.max(h) as f32); size_row(ui, tl!("Size"), &mut d.size, 2500.0, orig); signed_pct(ui, tl!("Spacing"), &mut d.spacing, 0.01, 10.0); - pct(ui, tl!("Scatter"), &mut d.scatter, 10.0); - widgets::checkbox(ui, &mut d.both_axes, tl!("Both Axes")); + scatter_row(ui, &mut d.scatter, &mut d.both_axes); ui.add_space(4.0); let mut c = d.count as f32; if num(ui, tl!("Count"), &mut c, 1.0..=16.0, "") { @@ -645,6 +663,106 @@ mod tests { h } + fn check_scatter_header(section: usize) { + for kind in theme::ThemeKind::ALL { + for both_axes in [false, true] { + let mut b = BrushSettings::default(); + b.scattering.scatter.jitter = 2.137; + b.scattering.both_axes = both_axes; + b.dual_brush.scatter = 2.137; + b.dual_brush.both_axes = both_axes; + let mut h = section_harness(section, b.clone()); + crate::PhotocraftApp::setup_context(&h.ctx, kind); + h.run_steps(3); + let label = h.get_by_label("Scatter").rect(); + let axes = h.get_by_label("Both Axes").rect(); + let field = h.query_all_by_role(Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(214.0)).unwrap().rect(); + assert!((label.center().y - axes.center().y).abs() < 2.0, "{kind:?}, section {section}: axes belongs on the Scatter header"); + assert!((axes.center().y - field.center().y).abs() < 2.0, "{kind:?}, section {section}: axes and percentage share a row"); + assert!(label.right() < axes.left() && axes.right() < field.left(), "{kind:?}, section {section}: label, checkbox, percentage order"); + assert_eq!(h.state(), &b, "rendering retains fractional scatter and other settings"); + } + } + } + + #[test] + fn scatter_header_scattering_places_axes_between_label_and_percentage_in_all_themes() { + check_scatter_header(2); + } + + #[test] + fn scatter_header_dual_brush_places_axes_between_label_and_percentage_in_all_themes() { + check_scatter_header(4); + } + + #[test] + fn scatter_header_preserves_values_percentage_limits_and_label_scrubbing() { + for section in [2, 4] { + let mut b = BrushSettings::default(); + b.scattering.scatter.jitter = 2.137; + b.scattering.scatter.control = Control::PenPressure; + b.scattering.count = 5; + b.dual_brush.scatter = 2.137; + b.dual_brush.count = 7; + let mut h = section_harness(section, b.clone()); + h.get_by_label("Both Axes").click(); + h.run_steps(2); + if section == 2 { + b.scattering.both_axes = true; + } else { + b.dual_brush.both_axes = true; + } + assert_eq!(h.state(), &b, "a toggle does not round scatter or change unrelated fields"); + let field = h.query_all_by_role(Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(214.0)).unwrap(); + field.click(); + h.key_press(egui::Key::ArrowUp); + h.run_steps(2); + if section == 2 { + b.scattering.scatter.jitter = 2.15; + } else { + b.dual_brush.scatter = 2.15; + } + assert_eq!(h.state(), &b, "percentage edits preserve the other brush block"); + let label = h.get_by_label("Scatter").rect().center(); + drag(&mut h, label, label + vec2(18.0, 0.0)); + let amount = if section == 2 { h.state().scattering.scatter.jitter } else { h.state().dual_brush.scatter }; + assert!(amount > 2.15, "the label still scrubs the percentage across the checkbox"); + if section == 2 { + b.scattering.scatter.jitter = amount; + } else { + b.dual_brush.scatter = amount; + } + assert_eq!(h.state(), &b, "scrubbing changes only the scatter amount"); + let field = + h.query_all_by_role(Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(f64::from((amount * 100.0).round()))).unwrap(); + let from = pos2(field.rect().left() - 100.0, field.rect().bottom() + 9.0); + let to = pos2(field.rect().right() - 25.0, from.y); + drag(&mut h, from, to); + let amount = if section == 2 { h.state().scattering.scatter.jitter } else { h.state().dual_brush.scatter }; + assert!(amount > 5.0, "the existing amount track remains editable"); + if section == 2 { + b.scattering.scatter.jitter = amount; + } else { + b.dual_brush.scatter = amount; + } + assert_eq!(h.state(), &b); + for (amount, value, key) in [(0.0, 0.0, egui::Key::ArrowDown), (10.0, 1000.0, egui::Key::ArrowUp)] { + if section == 2 { + b.scattering.scatter.jitter = amount; + } else { + b.dual_brush.scatter = amount; + } + *h.state_mut() = b.clone(); + h.run_steps(2); + let field = h.query_all_by_role(Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(value)).unwrap(); + field.click(); + h.key_press(key); + h.run_steps(2); + assert_eq!(h.state(), &b, "the percentage stays within 0..1000"); + } + } + } + /// #3825: jitter is an amount, not a hue selector; it uses the themed amount track. #[test] fn hue_jitter_paints_an_amount_track_in_every_theme() { diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 9e56022cef..9850453c77 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -400,7 +400,8 @@ pub fn scrub_label(ui: &Ui, label: &Response) -> Response { scrub_label_within(ui, label, 24.0) } -fn scrub_label_within(ui: &Ui, label: &Response, max_gap: f32) -> Response { +// Shared headers may put a control between the label and its field. +pub(crate) fn scrub_label_within(ui: &Ui, label: &Response, max_gap: f32) -> Response { // Drag only, not focusable: Tab goes to the value field, not to its label as well. let resp = label.interact(Sense::DRAG); // Whether a field took this label's drags last frame: only then is it scrubby. diff --git a/docs/brush-parity.md b/docs/brush-parity.md index 7a5e499820..ed11de5bf0 100644 --- a/docs/brush-parity.md +++ b/docs/brush-parity.md @@ -1,6 +1,6 @@ # Brush and painting parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Hue Jitter amount track; Brush Settings minimum fields follow their Control; checkbox-only rows and non-destructive Smoothing switch; shaped-tip cursor follows the view) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Both Axes on Scatter headers; Hue Jitter amount track; Brush Settings minimum fields follow their Control; checkbox-only rows and non-destructive Smoothing switch; shaped-tip cursor follows the view) · **Target:** Adobe Photoshop 2026 Painting is where Photoshop users notice differences first, so it gets its own checklist: the brush engine (`crates/paint/src/brush.rs`), the Brush Settings UI @@ -21,6 +21,7 @@ tablets against Photoshop side by side. | Brush Tip Shape: size, angle, roundness, hardness, spacing, flip X/Y | done | `brush.rs` | | Shape Dynamics (size/angle/roundness jitter, pen pressure/tilt/rotation/wheel controls, minimums, flip jitter, brush projection) | done | #174 | | Scattering (both axes, count, count jitter) | done | | +| Scattering and Dual Brush place Both Axes between the Scatter label and percentage | done | #3825 (Both Axes header placement only); all-theme UI regressions cover both axis values, fractional-value retention, percentage limits, label scrubbing, amount-track edits and existing command routing. | | Texture (pattern, scale, brightness, contrast, mode, depth, each tip) | done | | | Dual Brush | done | | | Color Dynamics (FG/BG, hue/sat/brightness jitter, purity, per tip) | done | | @@ -121,3 +122,4 @@ tablets against Photoshop side by side. | 2026-10-12 | minor | A shaped tip's cursor outline turns and flips with a rotated or flipped view, and follows pixel aspect correction | | 2026-10-13 | minor | Shape Dynamics and Transfer minimum inputs follow their Control and retain their values while disabled (#3825, partial). No brush-feel estimate changed. | | 2026-10-13 | minor | Hue Jitter uses the same themed amount slider as other jitter controls (#3825, partial). No brush-feel estimate changed. | +| 2026-10-13 | minor | Scattering and Dual Brush place Both Axes on the shared Scatter header (#3825, partial), preserving label scrubbing and values. No brush-feel estimate changed. | diff --git a/docs/gaps.md b/docs/gaps.md index 25a286aab8..da01374bc6 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G4: Both Axes on Scatter headers, #3825 partial; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -156,6 +156,10 @@ what an agent can't finish alone. - **Estimate:** 250–400 h (many overlap with other gaps; count each fix once). ### G4. Brush feel +- **Progress (2026-10-13, #3825 partial):** Scattering and Dual Brush place Both Axes + between the Scatter label and percentage field. The existing amount track, label + scrubbing, percentage limits and disabled-section value retention are preserved. + The assessment's remaining layout and control gaps stay open. - **Progress (2026-10-13, #3825 partial):** Hue Jitter shows a standard themed percentage-amount track rather than a hue spectrum. Other Brush Settings layout and brush-behaviour gaps remain open. @@ -264,6 +268,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| | 2026-10-13 | minor | G4: Hue Jitter amount-track appearance (#3825, partial); no readiness percentage or estimate changed | +| 2026-10-13 | minor | G4: Scattering and Dual Brush Both Axes header placement (#3825, partial); label scrubbing and value retention preserved; no readiness percentage or estimate changed | | 2026-10-13 | minor | G4: minimum inputs in Shape Dynamics and Transfer follow their Control (#3825, partial); no readiness percentage or estimate changed | | 2026-10-12 | minor | G5: Free Transform options bar H/V skew fields done | | 2026-10-12 | minor | G11: `.atn` import maps 112 events (Select and Mask, Spatter, Ripple, Crystallize, Pattern Fill, mask targeting, Step Backward, points, Bitmap) | From ff0eb52d7a338dfc7585e29b5de6882e58edf953 Mon Sep 17 00:00:00 2001 From: augrclk <217665348+augrclk@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:12:44 +0300 Subject: [PATCH 006/219] test(autosave): wait for the retried snapshot metadata Recovery discovery can list a committed bundle before its sidecar is published. Wait for the expected revision rather than treating a fallback entry as write completion. Keep all failure, retry, revision and pixel assertions. Co-authored-by: GPT-6 --- apps/photocraft/src/services.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index d4a3869f31..b7a5a4cb85 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -932,7 +932,9 @@ mod tests { while std::time::Instant::now() < deadline { prefs_ui::tick(&mut app, &ctx); recovered = list_recovery(&recovery); - if !recovered.is_empty() { + // A committed bundle is recoverable before its sidecar is published. Wait for + // the sidecar's revision, not the early fallback entry (revision 0). + if recovered.len() == 1 && recovered[0].info.revision == revision { break; } std::thread::sleep(std::time::Duration::from_millis(10)); From 01e4ef5ef59fae458735baa717b234d196dd4cd6 Mon Sep 17 00:00:00 2001 From: augrclk <217665348+augrclk@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:12:44 +0300 Subject: [PATCH 007/219] test(autosave): wait for the retried snapshot metadata Recovery discovery can list a committed bundle before its sidecar is published. Wait for the expected revision rather than treating a fallback entry as write completion. Keep all failure, retry, revision and pixel assertions. Co-authored-by: GPT-6 --- apps/photocraft/src/services.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index d4a3869f31..b7a5a4cb85 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -932,7 +932,9 @@ mod tests { while std::time::Instant::now() < deadline { prefs_ui::tick(&mut app, &ctx); recovered = list_recovery(&recovery); - if !recovered.is_empty() { + // A committed bundle is recoverable before its sidecar is published. Wait for + // the sidecar's revision, not the early fallback entry (revision 0). + if recovered.len() == 1 && recovered[0].info.revision == revision { break; } std::thread::sleep(std::time::Duration::from_millis(10)); From b0e30c3ed02b276f51e0c31454f8cf004577e140 Mon Sep 17 00:00:00 2001 From: augrclk <217665348+augrclk@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:20:54 +0300 Subject: [PATCH 008/219] test(autosave): align retry coverage with upstream Upstream 7250e6e2 independently fixed the same manifest/sidecar race. Adopt its canonical test, including the nonzero revision guard, to clear the conflict while preserving the Brush Settings changes. Co-authored-by: GPT-6 --- apps/photocraft/src/services.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index b7a5a4cb85..e6c882d198 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -907,6 +907,7 @@ mod tests { let mut app = launch(&recovery); new_doc(&mut app, "#ff0000"); let revision = app.session.active().unwrap().revision; + assert_ne!(revision, 0, "a sidecar-less bundle lists as revision 0"); autosave(&mut app, &ctx); let mut failed = false; @@ -927,14 +928,16 @@ mod tests { std::fs::remove_file(&recovery).unwrap(); std::fs::create_dir(&recovery).unwrap(); autosave(&mut app, &ctx); + // The worker commits the bundle's manifest before it publishes the sidecar, and + // `list_recovery` lists a committed sidecar-less bundle (revision 0) on its own, so + // the first non-empty listing can come from the middle of the write. Wait for the write + // to finish: the entry then carries the sidecar's revision. let mut recovered = Vec::new(); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); while std::time::Instant::now() < deadline { prefs_ui::tick(&mut app, &ctx); recovered = list_recovery(&recovery); - // A committed bundle is recoverable before its sidecar is published. Wait for - // the sidecar's revision, not the early fallback entry (revision 0). - if recovered.len() == 1 && recovered[0].info.revision == revision { + if recovered.iter().any(|e| e.info.revision == revision) { break; } std::thread::sleep(std::time::Duration::from_millis(10)); From 2813f5e348a02e0fc9e054a33b105bf71fb4f5b9 Mon Sep 17 00:00:00 2001 From: augrclk <217665348+augrclk@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:20:54 +0300 Subject: [PATCH 009/219] test(autosave): align retry coverage with upstream Upstream 7250e6e2 independently fixed the same manifest/sidecar race. Adopt its canonical test, including the nonzero revision guard, to clear the conflict while preserving the Brush Settings changes. Co-authored-by: GPT-6 --- apps/photocraft/src/services.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index b7a5a4cb85..e6c882d198 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -907,6 +907,7 @@ mod tests { let mut app = launch(&recovery); new_doc(&mut app, "#ff0000"); let revision = app.session.active().unwrap().revision; + assert_ne!(revision, 0, "a sidecar-less bundle lists as revision 0"); autosave(&mut app, &ctx); let mut failed = false; @@ -927,14 +928,16 @@ mod tests { std::fs::remove_file(&recovery).unwrap(); std::fs::create_dir(&recovery).unwrap(); autosave(&mut app, &ctx); + // The worker commits the bundle's manifest before it publishes the sidecar, and + // `list_recovery` lists a committed sidecar-less bundle (revision 0) on its own, so + // the first non-empty listing can come from the middle of the write. Wait for the write + // to finish: the entry then carries the sidecar's revision. let mut recovered = Vec::new(); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); while std::time::Instant::now() < deadline { prefs_ui::tick(&mut app, &ctx); recovered = list_recovery(&recovery); - // A committed bundle is recoverable before its sidecar is published. Wait for - // the sidecar's revision, not the early fallback entry (revision 0). - if recovered.len() == 1 && recovered[0].info.revision == revision { + if recovered.iter().any(|e| e.info.revision == revision) { break; } std::thread::sleep(std::time::Duration::from_millis(10)); From b45be327b66dd4f31c82d65ffa52e9c90f806966 Mon Sep 17 00:00:00 2001 From: David Mann Date: Wed, 21 Oct 2026 11:37:15 +0200 Subject: [PATCH 010/219] Address CodeRabbit review: count line continuations in string literals A backslash before a newline inside a string skipped the newline without counting it, so every later violation reported a line one too low. Co-Authored-By: Claude Opus 5.5 --- xtask/src/panel_commands.rs | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/xtask/src/panel_commands.rs b/xtask/src/panel_commands.rs index ec76ff9f86..82da2154f4 100644 --- a/xtask/src/panel_commands.rs +++ b/xtask/src/panel_commands.rs @@ -160,7 +160,13 @@ fn lex(src: &str) -> Vec { i += if ch == '"' { 1 } else { 2 }; while i < c.len() { match at(i) { - '\\' => i += 2, + '\\' => { + // A line continuation (`\` before a newline) still ends a source line. + if at(i + 1) == '\n' { + line += 1; + } + i += 2; + } '"' => { i += 1; break; @@ -576,6 +582,25 @@ fn panel(app: &mut App, ui: &mut Ui) { assert!(v[0].what.contains("active_mut")); } + #[test] + fn line_continuations_in_strings_keep_line_numbers() { + let src = " +fn panel(app: &mut App, ui: &mut Ui) { + ui.label(\"first \\ + second \\ + third\"); + if ui.button(\"Hide\").clicked() { + if let Some(st) = app.session.active_mut() { + st.active_layer = None; + } + } +} +"; + let v = scan("f.rs", src, &[]); + assert_eq!(v.len(), 1); + assert_eq!(v[0].line, 7); + } + #[test] fn every_mutation_kind_and_trigger_is_caught() { let src = r#" From 9b213580353b7a8adeb9b28a0ccf0246b3abc3e4 Mon Sep 17 00:00:00 2001 From: David Mann Date: Wed, 21 Oct 2026 12:43:58 +0200 Subject: [PATCH 011/219] Address CodeRabbit review: document the panel scan's blind spots Co-Authored-By: Claude Opus 5.5 --- docs/development.md | 7 ++++++- xtask/src/panel_commands.rs | 22 ++++++++++++++++++++-- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/docs/development.md b/docs/development.md index d4e42d83ed..6167239639 100644 --- a/docs/development.md +++ b/docs/development.md @@ -307,7 +307,12 @@ widget function that takes a `Ui` and returns `bool`. Inside one it fails, with direct document or session change: `active_mut()`, `Arc::make_mut` on a doc, `session.undo/redo/select_layer/add_document/close(…)` or an assignment to a session field (`app.session.tools.foreground = …`). Dispatch the command instead (`app.run`, `menus::invoke`). -The scanner is lexical and does not follow calls into helpers. Existing violations are listed in +A passing scan is not complete coverage. The scanner is lexical: it does not follow calls into +helpers, and it only recognises an `if` block (a `match` arm guard with a block counts) or a +`let` local holding a response, so a `match` on a response without such a guard, a response kept +in a struct field and tested later, or a closure callback is not checked. Preferences +(`edit_prefs`), shell view state (`app.ui`, the `UI_COMMANDS`) and switching the active document +are out of scope. Existing violations are listed in `ALLOWLIST` in `xtask/src/panel_commands.rs` by file and function, each with its reason; an entry that matches nothing any more is an error, so the list only shrinks. diff --git a/xtask/src/panel_commands.rs b/xtask/src/panel_commands.rs index 82da2154f4..76d28a83e5 100644 --- a/xtask/src/panel_commands.rs +++ b/xtask/src/panel_commands.rs @@ -17,8 +17,11 @@ //! * test code is skipped: `*_tests.rs`, `tests.rs`, files under `tests/` and `#[cfg(test)]` items. //! //! Out of scope: preferences (`edit_prefs`), shell view state (`app.ui`, the `UI_COMMANDS`) and -//! switching the active document. Calls into helper functions are not followed, so a handler that calls a helper which mutates the -//! document passes; the check catches the inline pattern, which is how such shortcuts start. +//! switching the active document. Calls into helper functions are not followed, so a handler +//! that calls a helper which mutates the document passes; the check catches the inline pattern, +//! which is how such shortcuts start. Only `if` blocks (also a `match` arm guard with a block) +//! and `let` locals count as handlers: a `match` on a response without one, a response kept in a +//! struct field and tested later, or a closure callback is not scanned. //! //! Existing violations are listed in [`ALLOWLIST`] by file and enclosing function, each with the //! reason it is still there. An entry that no longer matches anything is an error, so the list only @@ -601,6 +604,21 @@ fn panel(app: &mut App, ui: &mut Ui) { assert_eq!(v[0].line, 7); } + #[test] + fn a_match_arm_guard_with_a_block_is_a_click_handler() { + let src = " +fn panel(app: &mut App, ui: &mut Ui) { + match ui.button(\"Hide\") { + r if r.clicked() => { + app.session.active_mut(); + } + _ => {} + } +} +"; + assert_eq!(lines(src), [(5, "panel".to_string())]); + } + #[test] fn every_mutation_kind_and_trigger_is_caught() { let src = r#" From beb50a43d371c2f7dbe813797b3a45abb70fcdbe Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 23 Oct 2026 00:06:24 +0200 Subject: [PATCH 012/219] Allowlist main's Color panel RGB sliders in panel-commands; regenerate scorecard Co-Authored-By: Claude Opus 5.5 --- docs/scorecard.md | 4 ++-- xtask/src/panel_commands.rs | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/scorecard.md b/docs/scorecard.md index 6e84b9847e..5895456bcd 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -11,7 +11,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Tools](#tools) | 36 | 8 | 0 | | | [Workspace and UI](#workspace-and-ui) | 14 | 6 | 3 | settings that do nothing: 21 of 176 (target 0) | | [Type](#type) | 5 | 4 | 0 | | -| [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | +| [Automation](#automation) | 5 | 3 | 3 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 30 of 30 crates; panic_hunt present | | [Distribution](#distribution) | 5 | 4 | 2 | | | [Open bugs](#open-bugs) | 31 | 1 | 0 | | @@ -239,7 +239,7 @@ Menu wiring (`docs/parity-checklist.md`): 631 of 631 menu items live (100.0%). T | AUTO-219-5 | Batch: subfolders, open documents, naming template, progress/cancel, error log, stop | partial | [#219](https://github.com/storytold/photocraft/issues/219) | file_cmds.rs and file_cmds/batch_naming.rs: folder/array input, per-file errors, {name}/{serial}/{date} output templates and starting serial, Include All Subfolders (includeSubfolders: depth/loop/file-count bounded, results keep their subfolder; Batch and Image Processor dialogs, #3753); no open-documents source, cancel or error-log file | | AUTO-219-6 | Plug-in ABI v2: panel kind, progress/cancel calls, read other layers, Last Filter repeats | missing | [#219](https://github.com/storytold/photocraft/issues/219) | runtime.rs:15 ABI_VERSION = 1; manifest.rs:18 Kind::Filter only | | AUTO-219-7 | Local ONNX models behind a flag, managed in Preferences; subject select, background removal | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No ONNX runtime dependency | -| AUTO-219-8 | xtask check that every panel button routes through a command id | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No such xtask check | +| AUTO-219-8 | xtask check that every panel button routes through a command id | partial | [#219](https://github.com/storytold/photocraft/issues/219) | cargo xtask panel-commands (run by xtask ci and CI) fails on a shell click handler that changes the document or session directly; 2 allowlisted functions left (panels.rs hsb_sliders, color_field assign the foreground/background colour instead of tools.setColors) | | AUTO-219-9 | Headless commands for in-canvas type editing, Liquify/Puppet strokes, transform handles | done | [#219](https://github.com/storytold/photocraft/issues/219) | type.hitTest, type.caret, type.navigate; filter.liquify strokes, edit.puppetWarp pins, type.edit, edit.transform | | AUTO-219-10 | Agent acceptance suite grown to 30-50 workflows | partial | [#219](https://github.com/storytold/photocraft/issues/219) | automation/tests/it/agent_tasks.rs runs 10 workflows over MCP | diff --git a/xtask/src/panel_commands.rs b/xtask/src/panel_commands.rs index 76d28a83e5..06777140af 100644 --- a/xtask/src/panel_commands.rs +++ b/xtask/src/panel_commands.rs @@ -54,6 +54,13 @@ pub const ALLOWLIST: &[Allowed] = &[ func: "hsb_sliders", reason: "Color panel HSB sliders assign session.tools.foreground; dispatch tools.setColors", }, + // The Color panel's RGB sliders assign the foreground or background colour the same way. + // Fix: dispatch `tools.setColors`. + Allowed { + file: "crates/ui-egui/src/panels.rs", + func: "rgb_sliders", + reason: "Color panel RGB sliders assign session.tools.foreground/background; dispatch tools.setColors", + }, // The Color panel's colour field and hue strip (and their readout) assign the foreground or // background colour on every drag frame. Fix: dispatch `tools.setColors`. Allowed { From f26bd3e2570d31c3fed00b10cd99068e64dfe644 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 24 Oct 2026 02:57:09 +0200 Subject: [PATCH 013/219] xtask: drop the panel-commands allowlist entries main fixed Main's Color panel rework removed hsb_sliders and rgb_sliders, so their allowlist entries were stale (the check fails on stale entries). One allowlisted function is left; the scorecard note and docs/scorecard.md follow. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/scorecard.md | 4 ++-- scorecard/automation.toml | 2 +- xtask/src/panel_commands.rs | 20 +++----------------- 3 files changed, 6 insertions(+), 20 deletions(-) diff --git a/docs/scorecard.md b/docs/scorecard.md index 09f8c61ab9..862720c536 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -11,7 +11,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Tools](#tools) | 36 | 8 | 0 | | | [Workspace and UI](#workspace-and-ui) | 15 | 6 | 2 | settings that do nothing: 20 of 177 (target 0) | | [Type](#type) | 5 | 5 | 0 | | -| [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | +| [Automation](#automation) | 5 | 3 | 3 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 30 of 30 crates; panic_hunt present | | [Distribution](#distribution) | 5 | 5 | 1 | | | [Open bugs](#open-bugs) | 31 | 1 | 0 | | @@ -244,7 +244,7 @@ Menu wiring (`docs/parity-checklist.md`): 631 of 631 menu items live (100.0%). T | AUTO-219-5 | Batch: subfolders, open documents, naming template, progress/cancel, error log, stop | partial | [#219](https://github.com/storytold/photocraft/issues/219) | file_cmds.rs and file_cmds/batch_naming.rs: folder/array input, per-file errors, {name}/{serial}/{date} output templates and starting serial, Include All Subfolders (includeSubfolders: depth/loop/file-count bounded, results keep their subfolder; Batch and Image Processor dialogs, #3753); no open-documents source, cancel or error-log file | | AUTO-219-6 | Plug-in ABI v2: panel kind, progress/cancel calls, read other layers, Last Filter repeats | missing | [#219](https://github.com/storytold/photocraft/issues/219) | runtime.rs:15 ABI_VERSION = 1; manifest.rs:18 Kind::Filter only | | AUTO-219-7 | Local ONNX models behind a flag, managed in Preferences; subject select, background removal | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No ONNX runtime dependency | -| AUTO-219-8 | xtask check that every panel button routes through a command id | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No such xtask check | +| AUTO-219-8 | xtask check that every panel button routes through a command id | partial | [#219](https://github.com/storytold/photocraft/issues/219) | cargo xtask panel-commands (run by xtask ci and CI) fails on a shell click handler that changes the document or session directly; 1 allowlisted function left (panels.rs color_field assigns the foreground/background colour instead of tools.setColors) | | AUTO-219-9 | Headless commands for in-canvas type editing, Liquify/Puppet strokes, transform handles | done | [#219](https://github.com/storytold/photocraft/issues/219) | type.hitTest, type.caret, type.navigate; filter.liquify strokes, edit.puppetWarp pins, type.edit, edit.transform | | AUTO-219-10 | Agent acceptance suite grown to 30-50 workflows | partial | [#219](https://github.com/storytold/photocraft/issues/219) | automation/tests/it/agent_tasks.rs runs 10 workflows over MCP | diff --git a/scorecard/automation.toml b/scorecard/automation.toml index 60ba6f8729..573d1e7ec2 100644 --- a/scorecard/automation.toml +++ b/scorecard/automation.toml @@ -65,7 +65,7 @@ id = "AUTO-219-8" target = "xtask check that every panel button routes through a command id" status = "partial" issue = 219 -note = "cargo xtask panel-commands (run by xtask ci and CI) fails on a shell click handler that changes the document or session directly; 2 allowlisted functions left (panels.rs hsb_sliders, color_field assign the foreground/background colour instead of tools.setColors)" +note = "cargo xtask panel-commands (run by xtask ci and CI) fails on a shell click handler that changes the document or session directly; 1 allowlisted function left (panels.rs color_field assigns the foreground/background colour instead of tools.setColors)" [[item]] id = "AUTO-219-9" diff --git a/xtask/src/panel_commands.rs b/xtask/src/panel_commands.rs index 06777140af..5fd82abeb5 100644 --- a/xtask/src/panel_commands.rs +++ b/xtask/src/panel_commands.rs @@ -46,23 +46,9 @@ pub struct Allowed { /// Existing click handlers that change the document directly. Shrink only: each entry is a /// follow-up (route it through an engine command), never a place to park new code. pub const ALLOWLIST: &[Allowed] = &[ - // Window › Color: the HSB sliders set the foreground colour by assignment, so the change - // never reaches the journal, action recording or the control channel's event log. - // Fix: dispatch `tools.setColors` (it already exists). - Allowed { - file: "crates/ui-egui/src/panels.rs", - func: "hsb_sliders", - reason: "Color panel HSB sliders assign session.tools.foreground; dispatch tools.setColors", - }, - // The Color panel's RGB sliders assign the foreground or background colour the same way. - // Fix: dispatch `tools.setColors`. - Allowed { - file: "crates/ui-egui/src/panels.rs", - func: "rgb_sliders", - reason: "Color panel RGB sliders assign session.tools.foreground/background; dispatch tools.setColors", - }, - // The Color panel's colour field and hue strip (and their readout) assign the foreground or - // background colour on every drag frame. Fix: dispatch `tools.setColors`. + // Window › Color: the colour field and hue strip (and their readout) assign the foreground or + // background colour on every drag frame, so the change never reaches the journal, action + // recording or the control channel's event log. Fix: dispatch `tools.setColors` (it already exists). Allowed { file: "crates/ui-egui/src/panels.rs", func: "color_field", From 239f5604a0a0c63a548444fbcef32cfc150cb73d Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 25 Oct 2026 01:31:26 +0200 Subject: [PATCH 014/219] Crop: Shift+H, Shift+P and Shift+I switch tools again (#9232) egui's consume_key ignores an extra Shift, so the Crop tool's plain H/P/I checks also took Shift+H/P/I before the tool-switch loop saw them. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/crop_shield.rs | 24 ++++++++++++++++++++++-- crates/ui-egui/src/crop_size.rs | 19 +++++++++++++++++-- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/crop_shield.rs b/crates/ui-egui/src/crop_shield.rs index aef70e5afb..29588199fb 100644 --- a/crates/ui-egui/src/crop_shield.rs +++ b/crates/ui-egui/src/crop_shield.rs @@ -122,18 +122,19 @@ pub fn hides_outside(app: &PhotocraftApp) -> bool { /// H with the Crop tool showing a box and no drag in progress toggles Show Cropped Area (in /// Classic Mode, where the option is greyed out, H is taken and does nothing), and P Use Classic /// Mode. Returns true when a key was used; otherwise H stays the Hand tool key and P the Pen's. +/// ⇧H and ⇧P are always the tool keys (`consume_key` would also take them: it ignores ⇧). pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { if app.ui.tool != Tool::Crop || app.ui.crop_rect.is_none() || app.crop.drag.is_some() { return false; } - if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::H)) { + if ctx.input_mut(|i| !i.modifiers.shift && i.consume_key(Modifiers::NONE, Key::H)) { let s = &mut app.ui.tool_options.crop_shield; if !s.classic_mode { s.show_cropped_area = !s.show_cropped_area; } return true; } - if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::P)) { + if ctx.input_mut(|i| !i.modifiers.shift && i.consume_key(Modifiers::NONE, Key::P)) { let s = &mut app.ui.tool_options.crop_shield; s.classic_mode = !s.classic_mode; // The view follows at once, about its own centre: default mode turns it with the frame, @@ -342,6 +343,25 @@ mod tests { assert!(app.ui.tool_options.crop_shield.show_cropped_area); } + /// ⇧H and ⇧P are the Hand and Pen tool keys even with a crop box: egui's `consume_key` ignores + /// an extra ⇧, so the plain-key checks must refuse it. + #[test] + fn shift_h_and_shift_p_switch_tools_instead_of_crop_options() { + for (key, tool) in [(Key::H, Tool::Hand), (Key::P, Tool::Pen)] { + let mut app = crop_app(); + assert!(app.session.prefs().tools.use_shift_key_for_tool_switch); + let before = app.ui.tool_options.crop_shield.clone(); + let ctx = egui::Context::default(); + ctx.input_mut(|i| { + i.modifiers = Modifiers::SHIFT; + i.events.push(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::SHIFT }); + }); + crate::shortcuts::handle(&mut app, &ctx); + assert_eq!(app.ui.tool, tool, "{key:?}"); + assert_eq!(app.ui.tool_options.crop_shield, before, "{key:?} leaves Show Cropped Area and Classic Mode alone"); + } + } + #[test] fn the_color_picker_sets_the_custom_colour() { let mut app = crop_app(); diff --git a/crates/ui-egui/src/crop_size.rs b/crates/ui-egui/src/crop_size.rs index 71fc57f495..df1fcc19fe 100644 --- a/crates/ui-egui/src/crop_size.rs +++ b/crates/ui-egui/src/crop_size.rs @@ -260,12 +260,12 @@ pub fn chosen(app: &mut PhotocraftApp) { } /// I with the Crop tool (no text field focused, which the caller checks): Front Image. Returns -/// true when the key was used; otherwise I stays the Eyedropper tool key. +/// true when the key was used; otherwise I (and always ⇧I) stays the Eyedropper tool key. pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { if app.ui.tool != Tool::Crop || app.crop.drag.is_some() || app.session.active().is_none() { return false; } - if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, egui::Key::I)) { + if ctx.input_mut(|i| !i.modifiers.shift && i.consume_key(Modifiers::NONE, egui::Key::I)) { front_image(app); crate::crop_ui::apply_ratio(app); return true; @@ -497,6 +497,21 @@ mod tests { assert!(!keys(&mut app2, &ctx)); } + /// ⇧I is the Eyedropper group's key even with the Crop tool, not Front Image. + #[test] + fn shift_i_with_the_crop_tool_switches_to_the_eyedropper() { + let mut app = app(SampleType::U8); + let ratio = app.ui.tool_options.crop_ratio.clone(); + let ctx = egui::Context::default(); + ctx.input_mut(|i| { + i.modifiers = Modifiers::SHIFT; + i.events.push(egui::Event::Key { key: egui::Key::I, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::SHIFT }); + }); + crate::shortcuts::handle(&mut app, &ctx); + assert_eq!(app.ui.tool, Tool::Eyedropper); + assert_eq!(app.ui.tool_options.crop_ratio, ratio); + } + /// Committing a W x H x Resolution crop resamples the frame's area to exactly W x H px and sets /// the resolution, in one undo step, at 8 and 16 bits. #[test] From 5988587b22cb3695cbcc8a050e178c881ed69f17 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 25 Oct 2026 01:49:50 +0200 Subject: [PATCH 015/219] Crop: check each key event's own Shift, so control-channel keys are covered (#9232) Synthetic keys from the control channel carry their modifiers on the event only; the frame's modifier state doesn't see them. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/crop_shield.rs | 17 +++++++++++++---- crates/ui-egui/src/crop_size.rs | 7 ++++--- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/crop_shield.rs b/crates/ui-egui/src/crop_shield.rs index 29588199fb..2acdd18c63 100644 --- a/crates/ui-egui/src/crop_shield.rs +++ b/crates/ui-egui/src/crop_shield.rs @@ -122,19 +122,19 @@ pub fn hides_outside(app: &PhotocraftApp) -> bool { /// H with the Crop tool showing a box and no drag in progress toggles Show Cropped Area (in /// Classic Mode, where the option is greyed out, H is taken and does nothing), and P Use Classic /// Mode. Returns true when a key was used; otherwise H stays the Hand tool key and P the Pen's. -/// ⇧H and ⇧P are always the tool keys (`consume_key` would also take them: it ignores ⇧). +/// ⇧H and ⇧P are always the tool keys. pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { if app.ui.tool != Tool::Crop || app.ui.crop_rect.is_none() || app.crop.drag.is_some() { return false; } - if ctx.input_mut(|i| !i.modifiers.shift && i.consume_key(Modifiers::NONE, Key::H)) { + if ctx.input_mut(|i| consume_unshifted(i, Key::H)) { let s = &mut app.ui.tool_options.crop_shield; if !s.classic_mode { s.show_cropped_area = !s.show_cropped_area; } return true; } - if ctx.input_mut(|i| !i.modifiers.shift && i.consume_key(Modifiers::NONE, Key::P)) { + if ctx.input_mut(|i| consume_unshifted(i, Key::P)) { let s = &mut app.ui.tool_options.crop_shield; s.classic_mode = !s.classic_mode; // The view follows at once, about its own centre: default mode turns it with the frame, @@ -145,6 +145,15 @@ pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { false } +/// `consume_key(Modifiers::NONE, key)` without the presses that hold ⇧: egui ignores an extra ⇧ +/// there, but ⇧+letter is the tool key. Reads each event's own modifiers, which synthetic +/// (control channel) keys carry without changing the frame's. +pub(crate) fn consume_unshifted(i: &mut egui::InputState, key: Key) -> bool { + let before = i.events.len(); + i.events.retain(|e| !matches!(e, egui::Event::Key { key: k, pressed: true, modifiers, .. } if *k == key && !modifiers.shift && modifiers.matches_logically(Modifiers::NONE))); + i.events.len() != before +} + /// The options bar's gear button and its menu. Returns true when Custom… asks for the Color /// Picker ([`pick_custom_color`], which needs the app). pub fn options_button(s: &mut CropShield, ui: &mut egui::Ui) -> bool { @@ -353,7 +362,7 @@ mod tests { let before = app.ui.tool_options.crop_shield.clone(); let ctx = egui::Context::default(); ctx.input_mut(|i| { - i.modifiers = Modifiers::SHIFT; + // Only the event holds ⇧, as with a control-channel `ui.key` (the frame's state lags). i.events.push(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::SHIFT }); }); crate::shortcuts::handle(&mut app, &ctx); diff --git a/crates/ui-egui/src/crop_size.rs b/crates/ui-egui/src/crop_size.rs index df1fcc19fe..90662997c9 100644 --- a/crates/ui-egui/src/crop_size.rs +++ b/crates/ui-egui/src/crop_size.rs @@ -15,7 +15,7 @@ //! with the Crop tool, [`keys`]) fills them with the active document's size in px and its //! resolution; Clear empties them; ⇄ and X swap W and H. -use egui::{Modifiers, Stroke, StrokeKind, vec2}; +use egui::{Stroke, StrokeKind, vec2}; use photocraft_engine::prefs::Unit; use serde_json::{Value, json}; @@ -265,7 +265,7 @@ pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { if app.ui.tool != Tool::Crop || app.crop.drag.is_some() || app.session.active().is_none() { return false; } - if ctx.input_mut(|i| !i.modifiers.shift && i.consume_key(Modifiers::NONE, egui::Key::I)) { + if ctx.input_mut(|i| crate::crop_shield::consume_unshifted(i, egui::Key::I)) { front_image(app); crate::crop_ui::apply_ratio(app); return true; @@ -322,6 +322,7 @@ pub fn fields(o: &mut ToolOptions, ui: &mut egui::Ui) { #[cfg(test)] mod tests { use super::*; + use egui::Modifiers; use photocraft_doc::{Color, ColorMode, Document, SampleType, Size}; #[test] @@ -504,7 +505,7 @@ mod tests { let ratio = app.ui.tool_options.crop_ratio.clone(); let ctx = egui::Context::default(); ctx.input_mut(|i| { - i.modifiers = Modifiers::SHIFT; + // Only the event holds ⇧, as with a control-channel `ui.key` (the frame's state lags). i.events.push(egui::Event::Key { key: egui::Key::I, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::SHIFT }); }); crate::shortcuts::handle(&mut app, &ctx); From 79f9ff4cdea688eb5b39696c2a5db5cb4e4f1a1d Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 25 Oct 2026 02:28:49 +0200 Subject: [PATCH 016/219] Scale Effects and Image Size scale Bevel & Emboss Texture's scale (#9254) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scale_effects scaled Pattern Overlay's and a pattern Stroke's scale but left Bevel & Emboss › Texture at its old size, so a texture aligned with the overlay drifted after Scale Effects, Image Size or Crop W x H. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/layer_menu_cmds/tests.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/crates/engine/src/layer_menu_cmds/tests.rs b/crates/engine/src/layer_menu_cmds/tests.rs index b0cceb72a8..4a388c29e1 100644 --- a/crates/engine/src/layer_menu_cmds/tests.rs +++ b/crates/engine/src/layer_menu_cmds/tests.rs @@ -140,6 +140,26 @@ fn global_light_blending_options_scale_effects() { } } +#[test] +fn scale_effects_scales_the_bevel_texture_with_the_pattern_overlay() { + let mut s = session(8, "rgb"); + paint(&mut s, disc); + s.execute("layer.layerStyle.patternOverlay", json!({"scale": 100})).unwrap(); + s.execute("layer.layerStyle.bevelEmboss", json!({"texture": "Bubbles", "textureScale": 100})).unwrap(); + s.execute("layer.layerStyle.scaleEffects", json!({"scale": 50})).unwrap(); + let fx = &active(&s).effects.items; + let overlay = fx.iter().find_map(|e| match e { + Effect::PatternOverlay { scale, .. } => Some(*scale), + _ => None, + }); + let texture = fx.iter().find_map(|e| match e { + Effect::BevelEmboss(b) => b.texture.as_ref().map(|t| t.scale), + _ => None, + }); + // Both patterns tile at half size, so a texture aligned with the overlay stays aligned. + assert_eq!((overlay, texture), (Some(0.5), Some(0.5))); +} + #[test] fn global_light_rejects_numbers_beyond_f32() { let mut s = session(8, "rgb"); From 42ec692355a1b842c94dfd9c3104c7250d00d3db Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 25 Oct 2026 02:34:00 +0200 Subject: [PATCH 017/219] Scale Effects and Image Size scale Bevel & Emboss Texture's scale (#9254) Multiply BevelTexture::scale by the effects factor in scale_effects, as Pattern Overlay's and a pattern Stroke's scale already are. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/image_cmds.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index 6353f1f2cc..32eadaed41 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -150,6 +150,9 @@ pub fn scale_effects(fx: &mut Effects, k: f32) { Effect::BevelEmboss(b) => { b.size *= k; b.soften *= k; + if let Some(t) = &mut b.texture { + t.scale *= k; + } } Effect::PatternOverlay { scale, .. } => *scale *= k, Effect::GradientOverlay { .. } | Effect::ColorOverlay { .. } => {} From 46ac4fc8f3af8579ee7dbfe6d323bacbcbbfd650 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 25 Oct 2026 02:28:59 +0100 Subject: [PATCH 018/219] Crop: refuse an invalid explicit rectangle instead of cropping to the selection (#9279) image.crop built an explicit rectangle only when x, y, width and height were all integers with width and height above 0; anything else fell back to the selection's bounds, so width 0, a negative or string size, or only some of the four keys cropped to the selection and reported success. A rectangle that is passed (any of the four keys non-null) but incomplete or empty is now a bad-params error and the document is unchanged; {} still crops to the selection. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/image_cmds.rs | 53 ++++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index 6353f1f2cc..24f39f2184 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -567,6 +567,7 @@ fn crop(s: &mut Session, p: &Value) -> Result { }; // Values beyond i32 would wrap through the narrowing casts into a rectangle unrelated to // the numbers passed; reject them (see `commands::int_i32`). + let given = ["x", "y", "width", "height"].into_iter().any(|k| p.get(k).is_some_and(|v| !v.is_null())); let explicit = match ( crate::commands::int_i32("image.crop", p, "x")?, crate::commands::int_i32("image.crop", p, "y")?, @@ -587,6 +588,14 @@ fn crop(s: &mut Session, p: &Value) -> Result { }; Some(Rect::new(x, y, end(x, w, "x", "width")?, end(y, h, "y", "height")?)) } + // A rectangle that is passed but incomplete or empty is refused: falling back to the + // selection would crop somewhere the caller didn't ask for (#9279). + _ if given => { + return Err(bad( + "image.crop", + "`x`, `y`, `width` and `height` go together: pass all four as numbers of pixels, with `width` and `height` rounding to at least 1", + )); + } _ => None, }; let target = crop_target(p)?; @@ -975,7 +984,7 @@ pub fn specs() -> Vec { "image.crop", "Crop", ["Image"], - r##"{"x":px,"y":px,"width":px,"height":px,"angle":-3600..3600=0,"deleteCroppedPixels":bool=true,"contentAware":bool=false,"targetWidth":px,"targetHeight":px,"resolution":ppi,"resample":"bicubic|bilinear|nearest|lanczos|preserveDetails"="bicubic"} → {x,y,width,height,documentWidth,documentHeight,resolution} (targetWidth/targetHeight, both or neither: the cropped area is resampled to exactly that size, as Image Size, in the same undo step; resolution: the document's new ppi; contentAware: the Crop tool's Fill › Content-Aware Fill, the area a rotated or extending crop uncovers filled from its surroundings on the Background, else the active pixel layer)"##, + r##"{"x":px,"y":px,"width":px,"height":px,"angle":-3600..3600=0,"deleteCroppedPixels":bool=true,"contentAware":bool=false,"targetWidth":px,"targetHeight":px,"resolution":ppi,"resample":"bicubic|bilinear|nearest|lanczos|preserveDetails"="bicubic"} → {x,y,width,height,documentWidth,documentHeight,resolution} (x/y/width/height, all four or none: without them the selection's bounds are cropped to; targetWidth/targetHeight, both or neither: the cropped area is resampled to exactly that size, as Image Size, in the same undo step; resolution: the document's new ppi; contentAware: the Crop tool's Fill › Content-Aware Fill, the area a rotated or extending crop uncovers filled from its surroundings on the Background, else the active pixel layer)"##, has_doc, crop ), @@ -1423,6 +1432,48 @@ mod tests { assert_eq!(doc(&s).layers[1].surface().unwrap().pixel(20, 5), vec![1.0, 0.0, 0.0, 1.0]); } + /// A rectangle that is passed but invalid is refused, not replaced by the selection (#9279). + #[test] + fn crop_refuses_a_bad_explicit_rect_instead_of_cropping_to_the_selection() { + let bad = [ + json!({"x": 10, "y": 10, "width": 0, "height": 50}), + json!({"x": 10, "y": 10, "width": 20, "height": 0}), + json!({"x": 10, "y": 10, "width": -50, "height": 50}), + json!({"x": 10, "y": 10, "width": "100", "height": 50}), + json!({"x": 10, "y": 10, "width": true, "height": 50}), + json!({"width": 20, "height": 10}), + json!({"x": 1, "y": 1}), + json!({"x": 1, "y": null, "width": 20, "height": 10}), + json!({"height": 10}), + ]; + for p in bad { + for selection in [true, false] { + let mut s = session(); + if selection { + s.execute("select.rect", json!({"x": 5, "y": 2, "width": 20, "height": 10})).unwrap(); + } + let (size, past) = (doc(&s).size, s.active().unwrap().history.past_len()); + let err = s.execute("image.crop", p.clone()).unwrap_err(); + assert!(matches!(err, EngineError::BadParams { .. }), "{p} (selection: {selection}): {err:?}"); + assert_eq!(doc(&s).size, size, "{p} (selection: {selection})"); + assert_eq!(doc(&s).selection.is_some(), selection, "{p}: the selection stays"); + assert_eq!(s.active().unwrap().history.past_len(), past, "{p} (selection: {selection})"); + } + } + // No rectangle at all (or every side null) still crops to the selection. + for p in [json!({}), json!({"x": null, "y": null, "width": null, "height": null})] { + let mut s = session(); + s.execute("select.rect", json!({"x": 5, "y": 2, "width": 20, "height": 10})).unwrap(); + s.execute("image.crop", p.clone()).unwrap(); + assert_eq!(doc(&s).size, Size::new(20, 10), "{p}"); + } + // Whole-number floats, as the Crop tool sends them, are a valid rectangle. + let mut s = session(); + s.execute("select.rect", json!({"x": 5, "y": 2, "width": 20, "height": 10})).unwrap(); + s.execute("image.crop", json!({"x": 1.0, "y": 1.0, "width": 30.0, "height": 15.0})).unwrap(); + assert_eq!(doc(&s).size, Size::new(30, 15)); + } + #[test] fn crop_extension_fills_the_background_with_the_background_colour() { let mut shrink = Session::new(); From 91e18fa45a5d70f7760ffe9287448e3071b50682 Mon Sep 17 00:00:00 2001 From: VoiceLessQ Date: Sun, 25 Oct 2026 02:19:26 -0200 Subject: [PATCH 019/219] paint: 13 more painting tools wrap across the canvas edges in Pattern Preview Clone Stamp already wraps in Pattern Preview (#4997). Blur, Sharpen, Smudge, Dodge, Burn, Sponge, Mixer Brush, Color Replacement, Background Eraser, Pattern Stamp, History Brush, Healing Brush and Spot Healing now take "wrap": true as well, and the canvas sends it while Pattern Preview is on (#8883). The sequential-dab tools run their dabs on WorkTiles, moved into photocraft_paint::retouch and given a wrap tile: each canvas pixel is kept once and read and written wherever it repeats, so a live stroke and its commit still give the same pixels. The composite tools go through #4997's periodic::Composite via one periodic::stroke helper. Spot Healing centres its sampling window on the stroke, at most one canvas across. With wrap off, every command takes the path it took before. --- crates/engine/src/brush_cmds.rs | 20 +- crates/engine/src/eraser_cmds.rs | 12 +- crates/engine/src/retouch_cmds.rs | 364 ++++++++++-------- .../engine/src/retouch_cmds/pattern_stamp.rs | 14 +- crates/engine/src/retouch_cmds/periodic.rs | 25 ++ crates/engine/src/retouch_cmds/tests.rs | 289 ++++++++++++++ crates/paint/src/bg_erase.rs | 19 +- crates/paint/src/lib.rs | 4 +- crates/paint/src/mixer.rs | 31 +- crates/paint/src/replace.rs | 42 +- crates/paint/src/retouch.rs | 264 +++++++++++++ crates/paint/src/wrap.rs | 166 ++++++++ crates/ui-egui/src/canvas.rs | 8 + crates/ui-egui/src/eraser_ui.rs | 6 +- crates/ui-egui/src/retouch_ui.rs | 56 ++- crates/ui-egui/src/stroke_timing_tests.rs | 54 +++ 16 files changed, 1179 insertions(+), 195 deletions(-) create mode 100644 crates/paint/src/wrap.rs diff --git a/crates/engine/src/brush_cmds.rs b/crates/engine/src/brush_cmds.rs index feb238cf40..bd82e4f808 100644 --- a/crates/engine/src/brush_cmds.rs +++ b/crates/engine/src/brush_cmds.rs @@ -9,7 +9,7 @@ use photocraft_doc::LayerContent; use photocraft_geom::Rect; use photocraft_paint::mixer::{MixerSettings, apply_mixer_stroke}; -use photocraft_paint::replace::{Limits, ReplaceMode, ReplaceSettings, Sampling, apply_color_replacement, color_replacement_effect}; +use photocraft_paint::replace::{Limits, ReplaceMode, ReplaceSettings, Sampling, apply_color_replacement_wrapped, color_replacement_effect}; use photocraft_paint::tile::PREVIEW_SIDE; use photocraft_paint::{ BrushPreset, BrushSettings, GrayTile, Pattern, PresetCaptures, StoredTile, Stroke, StrokePoint, StrokeRenderer, TipShape, render_stroke, @@ -667,10 +667,13 @@ fn mixer_brush(s: &mut Session, p: &Value) -> Result { let stroke = Stroke { brush, points: pts }; let dmg = s.edit("Mixer Brush", |doc, _| { let sel = doc.selection.clone(); + // View › Pattern Preview: the stroke paints and picks up where it lies on the canvas. + let wrap = flag(p, "wrap", false).then(|| doc.bounds()).filter(|t| !t.is_empty()); let sample = if sample_all { let ds = photocraft_paint::dabs(&stroke); let ctx = photocraft_paint::BrushContext::new(&stroke.brush); let area = ds.iter().fold(Rect::EMPTY, |r, d| r.union(&ctx.dab_rect(d, false))); + let area = wrap.map_or(area, |t| photocraft_paint::wrap::fold_area(area, t)); let buf = photocraft_compose::render(doc, area); let mut surf = Surface::new(photocraft_color::PixelFormat::RGBA32F); let flat: Vec = buf.px.iter().flatten().copied().collect(); @@ -682,7 +685,7 @@ fn mixer_brush(s: &mut Session, p: &Value) -> Result { None }; let (surf, lock) = crate::channel_cmds::target_surface(doc, id, p)?; - Ok(apply_mixer_stroke(surf, sample.as_ref(), &stroke, &m, &mut state, sel.as_ref(), lock)) + Ok(apply_mixer_stroke(surf, sample.as_ref(), &stroke, &m, &mut state, sel.as_ref(), lock, wrap)) })?; state.end_stroke(paint, m.clean_after_stroke, m.load_after_stroke); s.tools.mixer = state; @@ -752,8 +755,9 @@ fn color_replacement(s: &mut Session, p: &Value) -> Result { let dmg = s.edit("Color Replacement", |doc, _| { let sel = doc.selection.clone(); let lock = doc.effective_locks(id).transparency; + let wrap = flag(p, "wrap", false).then(|| doc.bounds()); let surf = paint_surface(doc, id, &json!({}))?; - Ok(apply_color_replacement(surf, &stroke, &rs, sel.as_ref(), lock)) + Ok(apply_color_replacement_wrapped(surf, &stroke, &rs, sel.as_ref(), lock, wrap)) })?; Ok(damage_json(s, dmg)) } @@ -762,8 +766,10 @@ fn color_replacement(s: &mut Session, p: &Value) -> Result { /// recolouring per dab as the commit, so the preview at the last point is the committed result. pub fn live_color_replacement(s: &Session, p: &Value) -> Result { let (stroke, rs, id) = color_replacement_setup(s, p)?; - crate::retouch_cmds::LiveDab::with_effect(s, "paint.colorReplacement", &json!({}), stroke, Some(id), 0, |pre| { - Box::new(color_replacement_effect(pre.clone(), rs)) + // Layer pixels, as the commit paints, wrapped as it is. + let params = json!({"wrap": flag(p, "wrap", false)}); + crate::retouch_cmds::LiveDab::with_effect(s, "paint.colorReplacement", ¶ms, stroke, Some(id), 0, |pre, wrap| { + Box::new(color_replacement_effect(pre.clone(), rs, wrap)) })? .with_tail_preview() } @@ -1203,7 +1209,7 @@ pub fn specs() -> Vec { spec!( "paint.mixerBrush", "Mixer Brush", - r##"{"points":[…],"brush":{…}?,"preset":name?,"size":0.5..5000 px?,"wet":0..100=brush.mixer.wet,"load":0..100=brush.mixer.load,"mix":0..100=brush.mixer.mix,"flow":0..100=brush.mixer.flow,"color":"#rrggbb"?=foreground (a color other than the one recorded by the last stroke, load or clean loads it first; a load or clean records the foreground),"sampleAllLayers":bool=brush.mixer.sampleAllLayers,"cleanAfterStroke":bool=brush.mixer.cleanAfterStroke (empty the brush after the stroke),"loadAfterStroke":bool=brush.mixer.loadAfterStroke (load the brush with `color` after the stroke),"seed":u64?}"##, + r##"{"points":[…],"brush":{…}?,"preset":name?,"size":0.5..5000 px?,"wet":0..100=brush.mixer.wet,"load":0..100=brush.mixer.load,"mix":0..100=brush.mixer.mix,"flow":0..100=brush.mixer.flow,"color":"#rrggbb"?=foreground (a color other than the one recorded by the last stroke, load or clean loads it first; a load or clean records the foreground),"sampleAllLayers":bool=brush.mixer.sampleAllLayers,"cleanAfterStroke":bool=brush.mixer.cleanAfterStroke (empty the brush after the stroke),"loadAfterStroke":bool=brush.mixer.loadAfterStroke (load the brush with `color` after the stroke),"seed":u64?,"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges)}"##, has_paintable, mixer_brush, true @@ -1213,7 +1219,7 @@ pub fn specs() -> Vec { 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,"target":"pixels"="pixels","seed":u64?}"##, + 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,"target":"pixels"="pixels","seed":u64?,"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges)}"##, crate::commands::has_paintable, color_replacement, true diff --git a/crates/engine/src/eraser_cmds.rs b/crates/engine/src/eraser_cmds.rs index 129753ea86..653cd79993 100644 --- a/crates/engine/src/eraser_cmds.rs +++ b/crates/engine/src/eraser_cmds.rs @@ -11,7 +11,7 @@ use photocraft_algo::selection as sel; use photocraft_doc::{Document, Layer, LayerContent, LayerId}; use photocraft_geom::Rect; use photocraft_paint::Stroke; -use photocraft_paint::bg_erase::{BgEraseSettings, apply_background_eraser, background_eraser_effect, background_eraser_stroke}; +use photocraft_paint::bg_erase::{BgEraseSettings, apply_background_eraser_wrapped, background_eraser_effect, background_eraser_stroke}; use photocraft_paint::replace::{Limits, Sampling}; use serde_json::{Value, json}; @@ -152,10 +152,12 @@ fn background_eraser(s: &mut Session, p: &Value) -> Result { let (stroke, bs, id) = background_eraser_setup(s, p)?; let dmg = s.edit("Background Eraser", |doc, _| { let selection = doc.selection.clone(); + // View › Pattern Preview: the stroke carries on across the canvas edges. + let wrap = flag(p, "wrap", false).then(|| doc.bounds()); crate::extra_cmds::background_to_layer(doc, id); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; let surf = l.surface_mut().ok_or_else(|| bad(CMD, "not a pixel layer"))?; - Ok(apply_background_eraser(surf, &stroke, &bs, selection.as_ref())) + Ok(apply_background_eraser_wrapped(surf, &stroke, &bs, selection.as_ref(), wrap)) })?; Ok(damage(s, dmg, true)) } @@ -170,7 +172,9 @@ pub fn live_background_eraser(s: &Session, p: &Value) -> Result Box::new(effect), // No alpha: the commit erases nothing either. None => Box::new(|_: &mut photocraft_paint::retouch::Region, _: &photocraft_paint::retouch::Footprint| {}), @@ -196,7 +200,7 @@ pub fn specs() -> Vec { label: "Background Eraser", menu: &[], shortcut: None, - params: r##"{"points":[[x,y,pressure?],…],"size":px?,"hardness":0..1?,"brush":{…}?,"preset":name?,"sampling":"continuous|once|backgroundSwatch"="continuous","limits":"discontiguous|contiguous|findEdges"="contiguous","tolerance":0..100=50,"protectForegroundColor":bool=false,"seed":u64?}"##, + params: r##"{"points":[[x,y,pressure?],…],"size":px?,"hardness":0..1?,"brush":{…}?,"preset":name?,"sampling":"continuous|once|backgroundSwatch"="continuous","limits":"discontiguous|contiguous|findEdges"="contiguous","tolerance":0..100=50,"protectForegroundColor":bool=false,"seed":u64?,"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges)}"##, enabled: has_pixel_layer, run: background_eraser, journal: true, diff --git a/crates/engine/src/retouch_cmds.rs b/crates/engine/src/retouch_cmds.rs index eae429f8c0..4480f9b8b8 100644 --- a/crates/engine/src/retouch_cmds.rs +++ b/crates/engine/src/retouch_cmds.rs @@ -15,7 +15,8 @@ use photocraft_algo::retouch::{ToneRange, dodge_burn, local_blur, local_sharpen, use photocraft_color::{BlendMode, PixelFormat, SampleType}; use photocraft_doc::{Document, LayerContent, LayerId}; use photocraft_geom::Rect; -use photocraft_paint::retouch::{Footprint, Region, Smudge, alpha_index, apply_coverage, apply_dab_stroke}; +use photocraft_paint::retouch::{Footprint, Region, Smudge, WorkTiles, alpha_index, apply_coverage, apply_dab_stroke_wrapped}; +use photocraft_paint::wrap::wrap_coord; use photocraft_paint::{BrushSettings, Stroke, StrokePoint}; use photocraft_raster::{Surface, from_rgba_into, to_rgba}; use rayon::prelude::*; @@ -120,6 +121,10 @@ fn num(p: &Value, k: &str, d: f32) -> f32 { fn flag(p: &Value, k: &str, d: bool) -> bool { p.get(k).and_then(Value::as_bool).unwrap_or(d) } +/// The canvas a stroke with `"wrap": true` repeats across (View › Pattern Preview). +fn wrap_tile(pre: &Document, p: &Value) -> Option { + flag(p, "wrap", false).then(|| pre.bounds()).filter(|t| !t.is_empty()) +} fn string<'a>(p: &'a Value, k: &str, d: &'a str) -> &'a str { p.get(k).and_then(Value::as_str).unwrap_or(d) } @@ -382,26 +387,19 @@ fn clone_stamp(s: &mut Session, p: &Value) -> Result { let from_live = live.is_some(); let map = clone_mapping(s, p, &stroke, CMD)?; let off = map.offset(); - let dmg = - run_stroke(s, "Clone Stamp", id, p, |pre, surf, sel, lock| { - if let Some(l) = live { - *surf = l.surface; - return Ok(l.bounds); - } - if flag(p, "wrap", false) { - let mut renderer = photocraft_paint::StrokeRenderer::new(&stroke.brush, None, 1.0); - renderer.push(&stroke.points); - renderer.finish(); - let before = surf.clone(); - let bounds = renderer.bounds(); - return periodic::Composite { target: surf, before: &before, tile: pre.bounds(), opacity: stroke.brush.opacity, selection: sel, lock, mode } - .draw(bounds, &stroke.brush, &stroke.points, |r| clone_sample(pre, id, &before, which, r, &map, true, CMD)); - } - let (bounds, cov) = coverage(CMD, &stroke)?; - // Samples come from the pre-stroke state, so pixels painted earlier in the stroke are never re-cloned. - let paint = clone_sample(pre, id, surf, which, bounds, &map, flag(p, "wrap", false), CMD)?; - Ok(apply_coverage(surf, bounds, &cov, stroke.brush.opacity, sel, lock, &paint, mode)) - })?; + let dmg = run_stroke(s, "Clone Stamp", id, p, |pre, surf, sel, lock| { + if let Some(l) = live { + *surf = l.surface; + return Ok(l.bounds); + } + if flag(p, "wrap", false) { + return periodic::stroke(pre, surf, &stroke, sel, lock, mode, |before, r| clone_sample(pre, id, before, which, r, &map, true, CMD)); + } + let (bounds, cov) = coverage(CMD, &stroke)?; + // Samples come from the pre-stroke state, so pixels painted earlier in the stroke are never re-cloned. + let paint = clone_sample(pre, id, surf, which, bounds, &map, flag(p, "wrap", false), CMD)?; + Ok(apply_coverage(surf, bounds, &cov, stroke.brush.opacity, sel, lock, &paint, mode)) + })?; let mut r = clone_result(dmg, off, aligned, p, &stroke); if from_live { r["fromLive"] = json!(true); @@ -520,11 +518,7 @@ impl LiveRetouch { /// Everything the stroke has touched so far. pub fn bounds(&self) -> Rect { let bounds = self.renderer.bounds().union(&self.tail); - if self.cmd == "paint.cloneStamp" && flag(&self.params, "wrap", false) { - periodic::project(bounds, self.pre.bounds()).into_iter().fold(Rect::EMPTY, |a, b| a.union(&b)) - } else { - bounds - } + if flag(&self.params, "wrap", false) { periodic::project(bounds, self.pre.bounds()).into_iter().fold(Rect::EMPTY, |a, b| a.union(&b)) } else { bounds } } /// Every point rendered so far, in order. @@ -567,7 +561,7 @@ impl LiveRetouch { let pts: Vec<_> = pts.iter().map(|q| StrokePoint::new(q.x, q.y, q.pressure)).collect(); self.points.extend_from_slice(&pts); self.renderer.push(&pts); - if self.cmd == "paint.cloneStamp" && flag(&self.params, "wrap", false) { + if flag(&self.params, "wrap", false) { return self.push_periodic(); } let old = std::mem::replace(&mut self.tail, Rect::EMPTY); @@ -596,8 +590,12 @@ impl LiveRetouch { let mut tail = self.renderer.tail_preview(); self.tail = tail.as_mut().map_or(Rect::EMPTY, |t| t.take_dirty_rect()); dirty = dirty.union(&self.tail); - let LivePaint::Clone { which, map, cmd } = &self.paint else { - return Err(bad(&self.cmd, "periodic painting requires a clone source")); + let (pre, id, pre_surf, live_paint, live_cmd) = (&self.pre, self.id, &self.pre_surf, &self.paint, self.cmd.as_str()); + let fmt = pre_surf.format(); + let paint = |r| match live_paint { + LivePaint::Clone { which, map, cmd } => clone_sample(pre, id, pre_surf, *which, r, map, true, cmd), + LivePaint::History(src) => periodic::sample(pre, id, src, SampleLayers::Current, r, live_cmd), + LivePaint::Pattern { tile, place } => Ok(pattern_stamp::paint_region(tile, place, &fmt, r)), }; let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.id, &self.params)?; periodic::Composite { @@ -609,7 +607,7 @@ impl LiveRetouch { lock: self.lock, mode: self.mode, } - .draw(dirty, &self.brush, &self.points, |r| clone_sample(&self.pre, self.id, &self.pre_surf, *which, r, map, true, cmd)) + .draw(dirty, &self.brush, &self.points, paint) } /// Redraw `r` from the pre-stroke pixels with the clone source through coverage `cov`. @@ -678,15 +676,30 @@ fn healing_brush(s: &mut Session, p: &Value) -> Result { let map = clone_mapping(s, p, &stroke, CMD)?; let off = map.offset(); let dmg = run_stroke(s, "Healing Brush", id, p, |pre, surf, sel, lock| { + // Pattern Preview: source and destination repeat across the canvas edges, so a stroke over + // a seam heals as one patch and goes on wherever it repeats. + let wrap = flag(p, "wrap", false); let (bounds, cov) = coverage(CMD, &stroke)?; let (g, cov) = pad_coverage(bounds, &cov, 2); let fmt = surf.format(); - let src = clone_sample(pre, id, surf, which, g, &map, false, CMD)?; + let src = clone_sample(pre, id, surf, which, g, &map, wrap, CMD)?; // The destination the texture is fitted to: the layer itself, or what the user sees when // sampling several layers (so healing onto an empty layer matches the composite). - let dst = if which == SampleLayers::Current { Region::read(surf, g) } else { composite_region(pre, id, which, g, fmt) }; + let dst = if wrap { + periodic::sample(pre, id, surf, which, g, CMD)? + } else if which == SampleLayers::Current { + Region::read(surf, g) + } else { + composite_region(pre, id, which, g, fmt) + }; let mask: Vec = cov.iter().map(|c| *c > 0.0).collect(); let healed = heal_region(&fmt, &src, &dst, &mask); + if wrap { + return periodic::stroke(pre, surf, &stroke, sel, lock, mode, |_, r| { + let part = healed.crop(r); + if part.rect == r { Ok(part) } else { Err(bad(CMD, "the healed patch does not cover the stroke")) } + }); + } Ok(apply_coverage(surf, g, &cov, stroke.brush.opacity, sel, lock, &healed, mode)) })?; Ok(clone_result(dmg, off, aligned, p, &stroke)) @@ -714,13 +727,17 @@ fn history_brush(s: &mut Session, p: &Value) -> Result { let live = s.live_commit.take().filter(|l| l.matches(s, CMD, p, &stroke.points)); let from_live = live.is_some(); let src_surface = history_source(s, id, p, CMD)?; - let dmg = run_stroke(s, "History Brush", id, p, |_, surf, sel, lock| { + let dmg = run_stroke(s, "History Brush", id, p, |pre, surf, sel, lock| { if let Some(l) = live { *surf = l.surface; return Ok(l.bounds); } let fmt = surf.format(); let src = if src_surface.format() == fmt { src_surface } else { src_surface.convert(fmt) }; + if flag(p, "wrap", false) { + // Pattern Preview: the history state repeats with the canvas, as the stroke does. + return periodic::stroke(pre, surf, &stroke, sel, lock, mode, |_, r| periodic::sample(pre, id, &src, SampleLayers::Current, r, CMD)); + } let (bounds, cov) = coverage(CMD, &stroke)?; let paint = Region::read(&src, bounds); Ok(apply_coverage(surf, bounds, &cov, stroke.brush.opacity, sel, lock, &paint, mode)) @@ -798,24 +815,63 @@ enum SpotType { /// Photoshop 25.4 (measured): a dab changes exactly the brush's footprint, and Content-Aware takes /// its texture from around it, as Edit › Fill does from its window /// ([`crate::fill_cmds::sampling_window`] of the stroke's bounds), which is what this samples. -fn spot_heal_surface(surf: &mut Surface, pre: &Document, stroke: &Stroke, kind: SpotType, all: bool, sel: Option<&Surface>, lock: bool) -> Result { - let (bounds, cov) = coverage("paint.spotHealing", stroke)?; +/// +/// Wrapped around the canvas `wrap` (View › Pattern Preview), the window is centred on the stroke +/// whatever the canvas edges and at most one canvas across, so it holds no second copy of the +/// stroke; its pixels repeat across the edges, and the healed pixels go where they lie on the +/// canvas. +#[allow(clippy::too_many_arguments)] +fn spot_heal_surface( + surf: &mut Surface, + pre: &Document, + stroke: &Stroke, + kind: SpotType, + all: bool, + sel: Option<&Surface>, + lock: bool, + wrap: Option, +) -> Result { + const CMD: &str = "paint.spotHealing"; + let (bounds, cov) = coverage(CMD, stroke)?; let size = stroke.brush.size; - let region_rect = crate::fill_cmds::sampling_window(bounds, pre.bounds()); + let region_rect = match wrap { + Some(tile) => { + let (tw, th) = (i64::from(tile.width()), i64::from(tile.height())); + let x0 = (i64::from(bounds.x0) + i64::from(bounds.x1) - tw) / 2; + let y0 = (i64::from(bounds.y0) + i64::from(bounds.y1) - th) / 2; + let around = Rect::new(x0 as i32, y0 as i32, (x0 + tw) as i32, (y0 + th) as i32); + crate::fill_cmds::sampling_window(bounds, around) + } + None => crate::fill_cmds::sampling_window(bounds, pre.bounds()), + }; if region_rect.is_empty() { return Ok(Rect::EMPTY); } // Proximity Match looks for its source anywhere in that window. let margin = (region_rect.width().max(region_rect.height()) / 2) as i32; let fmt = surf.format(); - let img = if all { composite_region(pre, None, SampleLayers::All, region_rect, fmt) } else { Region::read(surf, region_rect) }; + let img = match wrap { + Some(_) => periodic::sample(pre, None, surf, if all { SampleLayers::All } else { SampleLayers::Current }, region_rect, CMD)?, + None if all => composite_region(pre, None, SampleLayers::All, region_rect, fmt), + None => Region::read(surf, region_rect), + }; let (w, h, ch) = (img.width(), img.height(), img.ch); let bw = bounds.width() as usize; let mut stroke_cov = vec![0.0f32; w * h]; for (i, c) in cov.iter().enumerate() { let (x, y) = (bounds.x0 + (i % bw) as i32, bounds.y0 + (i / bw) as i32); + // A wrapped stroke longer than the window (one canvas) folds onto it: the strongest + // coverage of each pixel heals it once. + let (x, y) = match wrap { + Some(t) => { + let wrap_into = |v: i32, lo: i32, period: u32| wrap_coord(v, lo, lo.saturating_add(period as i32)); + (wrap_into(x, region_rect.x0, t.width()), wrap_into(y, region_rect.y0, t.height())) + } + None => (x, y), + }; if region_rect.contains(x, y) { - stroke_cov[img.index(x, y)] = *c; + let px = &mut stroke_cov[img.index(x, y)]; + *px = px.max(*c); } } let hole: Vec = stroke_cov.iter().map(|c| *c > 0.0).collect(); @@ -843,8 +899,19 @@ fn spot_heal_surface(surf: &mut Surface, pre: &Document, stroke: &Stroke, kind: let src = Region { rect: region_rect, ch, data: filled }; let healed = heal_region(&fmt, &src, &img, &heal_mask); let dmg = bounds.intersect(®ion_rect); - let dmg_cov: Vec = (dmg.y0..dmg.y1).flat_map(|y| (dmg.x0..dmg.x1).map(move |x| (x, y))).map(|(x, y)| stroke_cov[img.index(x, y)]).collect(); - Ok(apply_coverage(surf, dmg, &dmg_cov, stroke.brush.opacity, sel, lock, &healed, BlendMode::Normal)) + let cov_of = |r: Rect, (dx, dy): (i32, i32)| -> Vec { + (r.y0..r.y1).flat_map(|y| (r.x0..r.x1).map(move |x| (x, y))).map(|(x, y)| stroke_cov[img.index(x + dx, y + dy)]).collect() + }; + let Some(tile) = wrap else { + return Ok(apply_coverage(surf, dmg, &cov_of(dmg, (0, 0)), stroke.brush.opacity, sel, lock, &healed, BlendMode::Normal)); + }; + let mut done = Rect::EMPTY; + for (part, off) in photocraft_paint::wrap::pieces(dmg, tile) { + let mut paint = healed.crop(part.translate(off.0, off.1)); + paint.rect = part; + done = done.union(&apply_coverage(surf, part, &cov_of(part, off), stroke.brush.opacity, sel, lock, &paint, BlendMode::Normal)); + } + Ok(done) } fn spot_healing(s: &mut Session, p: &Value) -> Result { @@ -858,7 +925,8 @@ fn spot_healing(s: &mut Session, p: &Value) -> Result { o => return Err(bad(CMD, format!("unknown type `{o}` (contentAware|createTexture|proximityMatch)"))), }; let all = sample_all_layers(p); - let dmg = run_stroke(s, "Spot Healing Brush", id, p, |pre, surf, sel, lock| spot_heal_surface(surf, pre, &stroke, kind, all, sel, lock))?; + let dmg = + run_stroke(s, "Spot Healing Brush", id, p, |pre, surf, sel, lock| spot_heal_surface(surf, pre, &stroke, kind, all, sel, lock, wrap_tile(pre, p)))?; Ok(json!({ "damage": damage_json(dmg) })) } @@ -1139,8 +1207,9 @@ fn dab_cmd(s: &mut Session, p: &Value, cmd: &str) -> Result { } let fmt = surf.format(); let mut effect = tool.effect(fmt, stroke.brush.spacing); - let mut sampler = all.then(|| AllLayers::new(pre, fmt)); - Ok(apply_dab_stroke(surf, &stroke, sel, lock, tool.neighborhood().halo(), |work, fp| match &mut sampler { + let (halo, wrap) = (tool.neighborhood().halo(), wrap_tile(pre, p)); + let mut sampler = all.then(|| AllLayers::new(pre, fmt).wrapped(wrap, &stroke, halo)); + Ok(apply_dab_stroke_wrapped(surf, &stroke, sel, lock, halo, wrap, |work, fp| match &mut sampler { Some(all) => all.dab(work, fp, |w, f| effect(w, f)), None => effect(work, fp), })) @@ -1152,81 +1221,6 @@ fn dab_cmd(s: &mut Session, p: &Value, cmd: &str) -> Result { Ok(r) } -/// Side of the tiles a live sequential-dab stroke keeps its working pixels in ([`WorkTiles`]). -const WORK_TILE: i32 = 128; - -/// The working copy of a live sequential-dab stroke: the target's native channels as `f32`, in -/// tiles copied from the pre-stroke surface the first time a dab writes to them. Unlike a -/// `Surface`, it isn't rounded to the document's depth between dabs, so each dab sees what the -/// commit's working copy (`apply_dab_stroke`) holds and the preview is the commit's pixels. -struct WorkTiles { - ch: usize, - tiles: std::collections::HashMap<(i32, i32), Vec>, -} - -impl WorkTiles { - fn new(ch: usize) -> Self { - Self { ch, tiles: std::collections::HashMap::new() } - } - - fn tile_rect((tx, ty): (i32, i32)) -> Rect { - Rect::new(tx * WORK_TILE, ty * WORK_TILE, tx * WORK_TILE + WORK_TILE, ty * WORK_TILE + WORK_TILE) - } - - /// The tiles `r` overlaps. - fn coords(r: Rect) -> impl Iterator { - let (x0, x1) = (r.x0.div_euclid(WORK_TILE), (r.x1 - 1).div_euclid(WORK_TILE)); - let (y0, y1) = (r.y0.div_euclid(WORK_TILE), (r.y1 - 1).div_euclid(WORK_TILE)); - let empty = r.is_empty(); - (y0..=y1).filter(move |_| !empty).flat_map(move |ty| (x0..=x1).map(move |tx| (tx, ty))) - } - - /// The working pixels of `r`: the pre-stroke pixels `pre` where no dab has written yet. - fn read(&self, pre: &Surface, r: Rect) -> Region { - let mut out = Region::new(r, self.ch); - let n = self.ch; - let mut buf = Vec::new(); - for c in Self::coords(r) { - let tr = Self::tile_rect(c); - let part = r.intersect(&tr); - let w = part.width() as usize * n; - let tile = self.tiles.get(&c); - if tile.is_none() { - pre.read_region_into(part, &mut buf); - } - for y in part.y0..part.y1 { - let src = match tile { - Some(t) => t.get(((y - tr.y0) * WORK_TILE + (part.x0 - tr.x0)) as usize * n..).and_then(|s| s.get(..w)), - None => buf.get((y - part.y0) as usize * w..).and_then(|s| s.get(..w)), - }; - let dst = out.index(part.x0, y) * n; - if let (Some(s), Some(d)) = (src, out.data.get_mut(dst..dst + w)) { - d.copy_from_slice(s); - } - } - } - out - } - - /// Store `region` as the working pixels of its rectangle. - fn write(&mut self, pre: &Surface, region: &Region) { - let (r, n) = (region.rect, self.ch); - for c in Self::coords(r) { - let tr = Self::tile_rect(c); - let tile = self.tiles.entry(c).or_insert_with(|| pre.read_region(tr)); - let part = r.intersect(&tr); - let w = part.width() as usize * n; - for y in part.y0..part.y1 { - let dst = ((y - tr.y0) * WORK_TILE + (part.x0 - tr.x0)) as usize * n; - let src = region.index(part.x0, y) * n; - if let (Some(s), Some(d)) = (region.data.get(src..src + w), tile.get_mut(dst..dst + w)) { - d.copy_from_slice(s); - } - } - } - } -} - /// A Dodge, Burn, Sponge, Blur, Sharpen or Smudge stroke shown while it is drawn: each new dab /// runs the tool's effect on a working copy of the target, in order, and the changed area is /// mixed back over the original at the brush opacity, as `apply_dab_stroke` does on release. @@ -1249,6 +1243,8 @@ pub struct LiveDab { sel: Option, lock: bool, bounds: Rect, + /// The canvas a wrapped stroke repeats across (View › Pattern Preview, `"wrap": true`). + wrap: Option, /// Show the dabs finishing the stroke would add, so the preview at the last point is the /// commit. Only for effects that a repeated dab doesn't change (Color Replacement): the /// tail runs on a scratch copy at every push, so an effect that carries paint from dab to @@ -1278,13 +1274,14 @@ impl LiveDab { let (tool, _) = DabTool::parse(s, cmd, p, &stroke)?; let (neighborhood, spacing, brush) = (tool.neighborhood(), stroke.brush.spacing, Box::new(stroke.brush.clone())); let effect = tool.clone(); - let mut live = Self::with_neighborhood(s, cmd, p, stroke, id, neighborhood, |pre| effect.effect(pre.format(), spacing))?; + let mut live = Self::with_neighborhood(s, cmd, p, stroke, id, neighborhood, |pre, _| effect.effect(pre.format(), spacing))?; live.tool = Some((tool, brush)); Ok(live) } - /// Start a live stroke of `cmd` that runs `effect` (made from the target before the stroke) - /// at each dab of `stroke`, reading `halo` pixels around it. `p` gives the target. + /// Start a live stroke of `cmd` that runs `effect` (made from the target before the stroke + /// and the canvas a wrapped stroke repeats across) at each dab of `stroke`, reading `halo` + /// pixels around it. `p` gives the target and `wrap`. pub(crate) fn with_effect( s: &Session, cmd: &str, @@ -1292,7 +1289,7 @@ impl LiveDab { stroke: Stroke, id: Option, halo: i32, - effect: impl FnOnce(&Surface) -> DabEffect, + effect: impl FnOnce(&Surface, Option) -> DabEffect, ) -> Result { Self::with_neighborhood(s, cmd, p, stroke, id, DabNeighborhood::Halo(halo), effect) } @@ -1304,7 +1301,7 @@ impl LiveDab { stroke: Stroke, id: Option, neighborhood: DabNeighborhood, - effect: impl FnOnce(&Surface) -> DabEffect, + effect: impl FnOnce(&Surface, Option) -> DabEffect, ) -> Result { let st = s.active().ok_or(EngineError::NoDocument)?; let base = (st.doc.id, st.revision); @@ -1323,7 +1320,7 @@ impl LiveDab { stroke: Stroke, id: Option, halo: i32, - effect: impl FnOnce(&Surface) -> DabEffect, + effect: impl FnOnce(&Surface, Option) -> DabEffect, ) -> Result { Self::with_neighborhood_on(doc, cmd, p, stroke, id, DabNeighborhood::Halo(halo), effect) } @@ -1335,12 +1332,13 @@ impl LiveDab { stroke: Stroke, id: Option, neighborhood: DabNeighborhood, - effect: impl FnOnce(&Surface) -> DabEffect, + effect: impl FnOnce(&Surface, Option) -> DabEffect, ) -> Result { + let wrap = wrap_tile(&doc, p); let sel = doc.selection.clone(); let (surf, lock) = crate::channel_cmds::target_surface(&mut doc, id, p)?; let pre_surf = surf.clone(); - let effect = effect(&pre_surf); + let effect = effect(&pre_surf, wrap); // `apply_dab_stroke` generates the same dabs (`dabs`: zoom 1, no smoothing scale). let renderer = photocraft_paint::StrokeRenderer::new(&stroke.brush, None, 1.0).record_dabs(); let mut live = Self { @@ -1350,7 +1348,7 @@ impl LiveDab { done: 0, effect, neighborhood, - work: WorkTiles::new(pre_surf.channels()), + work: WorkTiles::new(pre_surf.channels()).wrapped(wrap), pre_surf, id, params: p.clone(), @@ -1358,6 +1356,7 @@ impl LiveDab { sel, lock, bounds: Rect::EMPTY, + wrap, preview_tail: false, tail: Rect::EMPTY, tool: None, @@ -1391,15 +1390,34 @@ impl LiveDab { if r.is_empty() { continue; } + let fp = ctx.footprint(d, self.done + k); let mut region = self.work.read(&self.pre_surf, self.neighborhood.read_rect(r, d)); - (self.effect)(&mut region, &ctx.footprint(d, self.done + k)); - self.work.write(&self.pre_surf, ®ion.crop(r)); - dmg = dmg.union(&r); + (self.effect)(&mut region, &fp); + self.work.write_dab(&self.pre_surf, ®ion, &fp); + dmg = dmg.union(&self.on_canvas(r)); } self.done += new.len(); dmg } + /// Where the stroke's rectangle `r` lands on the document: `r`, or in a wrapped stroke the + /// part of the canvas it covers. + fn on_canvas(&self, r: Rect) -> Rect { + self.wrap.map_or(r, |t| photocraft_paint::wrap::fold_area(r, t)) + } + + /// [`Self::show`] working pixels laid out in the stroke's own coordinates: a wrapped stroke + /// shows each part where it lies on the canvas. + fn show_unwrapped(&mut self, work: &Region) -> Result<()> { + let Some(tile) = self.wrap else { return self.show(work.rect, work) }; + for (part, (dx, dy)) in photocraft_paint::wrap::pieces(work.rect, tile) { + let mut piece = work.crop(part.translate(dx, dy)); + piece.rect = part; + self.show(part, &piece)?; + } + Ok(()) + } + /// Mix the working pixels of `r` back over the original into the shown document. fn show(&mut self, r: Rect, work: &Region) -> Result<()> { let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.id, &self.params)?; @@ -1433,7 +1451,7 @@ impl LiveDab { // Back to the stroke without the previous tail. let old = std::mem::replace(&mut self.tail, Rect::EMPTY); let shown = dmg.union(&old); - let tail_rect = tail.as_ref().map_or(Rect::EMPTY, |(r, _)| *r); + let tail_rect = tail.as_ref().map_or(Rect::EMPTY, |(r, _)| self.on_canvas(*r)); if shown.is_empty() && tail_rect.is_empty() { return Ok(Rect::EMPTY); } @@ -1442,9 +1460,9 @@ impl LiveDab { let work = self.work.read(&self.pre_surf, shown); self.show(shown, &work)?; } - if let Some((r, work)) = tail { - self.show(r, &work)?; - self.tail = r; + if let Some((_, work)) = tail { + self.show_unwrapped(&work)?; + self.tail = tail_rect; } Ok(shown.union(&tail_rect)) } @@ -1490,26 +1508,56 @@ struct AllLayers<'a> { pre: &'a Document, fmt: PixelFormat, visible: Option, + /// A stroke wrapped around the canvas (View › Pattern Preview): the composite at full + /// precision under everything the stroke reads, and the dabs' edits of it, kept once per + /// pixel of the canvas as the layer's working pixels are. + wrapped: Option<(Surface, WorkTiles)>, } impl<'a> AllLayers<'a> { fn new(pre: &'a Document, fmt: PixelFormat) -> Self { - Self { pre, fmt, visible: None } + Self { pre, fmt, visible: None, wrapped: None } + } + + /// Sample for `stroke` wrapped around `wrap`, whose dabs read `halo` pixels around them. + fn wrapped(mut self, wrap: Option, stroke: &Stroke, halo: i32) -> Self { + let Some(tile) = wrap else { return self }; + let ctx = photocraft_paint::BrushContext::new(&stroke.brush); + let reach = photocraft_paint::dabs(stroke).iter().fold(Rect::EMPTY, |r, d| r.union(&ctx.dab_rect(d, false))).inflate(halo); + let area = photocraft_paint::wrap::fold_area(reach, tile); + let mut visible = Surface::new(PixelFormat { sample: SampleType::F32, ..self.fmt }); + if !area.is_empty() { + visible.write_region(area, &composite_region(self.pre, None, SampleLayers::All, area, self.fmt).data); + } + self.wrapped = Some((visible, WorkTiles::new(self.fmt.channels()).wrapped(Some(tile)))); + self } fn dab(&mut self, work: &mut Region, fp: &Footprint, effect: impl FnOnce(&mut Region, &Footprint)) { + if let Some((visible, edits)) = self.wrapped.as_mut() { + let mut shown = edits.read(visible, work.rect); + effect(&mut shown, fp); + lay(work, &shown, fp); + edits.write_dab(visible, &shown, fp); + return; + } let (pre, fmt) = (self.pre, self.fmt); let visible = self.visible.get_or_insert_with(|| composite_region(pre, None, SampleLayers::All, work.rect, fmt)); if visible.rect != work.rect || visible.ch != work.ch { return effect(work, fp); } effect(visible, fp); - let r = fp.rect.intersect(&work.rect); - for y in r.y0..r.y1 { - for x in r.x0..r.x1 { - if fp.at(x, y) > 0.0 { - work.px_mut(x, y).copy_from_slice(visible.px(x, y)); - } + lay(work, visible, fp); + } +} + +/// Lay what a dab did to the visible pixels onto the layer's working pixels under it. +fn lay(work: &mut Region, visible: &Region, fp: &Footprint) { + let r = fp.rect.intersect(&work.rect); + for y in r.y0..r.y1 { + for x in r.x0..r.x1 { + if fp.at(x, y) > 0.0 { + work.px_mut(x, y).copy_from_slice(visible.px(x, y)); } } } @@ -1547,7 +1595,7 @@ pub fn specs() -> Vec { menu: &[], shortcut: None, params: brush_params!( - r#","pattern":id|name?=Patterns panel current,"scale":1..1000 %=100,"angle":deg=0,"aligned":bool=true,"impressionist":bool=false,"mode":"normal|multiply|…"="normal","phase":[px,py]? → {"damage","phase","aligned","pattern"}"# + r#","pattern":id|name?=Patterns panel current,"scale":1..1000 %=100,"angle":deg=0,"aligned":bool=true,"impressionist":bool=false,"mode":"normal|multiply|…"="normal","phase":[px,py]?,"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges) → {"damage","phase","aligned","pattern"}"# ), enabled: pattern_stamp::enabled, run: pattern_stamp::run, @@ -1559,7 +1607,7 @@ pub fn specs() -> Vec { 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"}"# + r#","source":[sx,sy] | "offset":[dx,dy],"aligned":bool=true,"sampleLayer":"current|currentAndBelow|all"="current","mode":"normal|…"="normal","wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges) → {"damage","offset","aligned","nextSource"}"# ), enabled: has_pixel_layer, run: healing_brush, @@ -1570,7 +1618,9 @@ pub fn specs() -> Vec { label: "Spot Healing Brush", menu: &[], shortcut: None, - params: brush_params!(r#","type":"contentAware|createTexture|proximityMatch"="contentAware","sampleAllLayers":bool=false"#), + params: brush_params!( + r#","type":"contentAware|createTexture|proximityMatch"="contentAware","sampleAllLayers":bool=false,"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges)"# + ), enabled: has_pixel_layer, run: spot_healing, journal: true, @@ -1612,7 +1662,9 @@ pub fn specs() -> Vec { label: "Dodge", menu: &[], shortcut: None, - params: brush_params!(r#","range":"shadows|midtones|highlights"="midtones","exposure":1..100=50,"protectTones":bool=true"#), + params: brush_params!( + r#","range":"shadows|midtones|highlights"="midtones","exposure":1..100=50,"protectTones":bool=true,"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges)"# + ), enabled: has_pixel_layer, run: |s, p| dab_cmd(s, p, "paint.dodge"), journal: true, @@ -1622,7 +1674,9 @@ pub fn specs() -> Vec { label: "Burn", menu: &[], shortcut: None, - params: brush_params!(r#","range":"shadows|midtones|highlights"="midtones","exposure":1..100=50,"protectTones":bool=true"#), + params: brush_params!( + r#","range":"shadows|midtones|highlights"="midtones","exposure":1..100=50,"protectTones":bool=true,"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges)"# + ), enabled: has_pixel_layer, run: |s, p| dab_cmd(s, p, "paint.burn"), journal: true, @@ -1632,7 +1686,9 @@ pub fn specs() -> Vec { label: "Sponge", menu: &[], shortcut: None, - params: brush_params!(r#","mode":"desaturate|saturate"="desaturate","vibrance":bool=true (flow = sponge strength)"#), + params: brush_params!( + r#","mode":"desaturate|saturate"="desaturate","vibrance":bool=true (flow = sponge strength),"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges)"# + ), enabled: has_pixel_layer, run: |s, p| dab_cmd(s, p, "paint.sponge"), journal: true, @@ -1642,7 +1698,9 @@ pub fn specs() -> Vec { label: "Blur", menu: &[], shortcut: None, - params: brush_params!(r#","strength":1..100=50,"sampleAllLayers":bool=false"#), + params: brush_params!( + r#","strength":1..100=50,"sampleAllLayers":bool=false,"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges)"# + ), enabled: has_pixel_layer, run: |s, p| dab_cmd(s, p, "paint.blur"), journal: true, @@ -1652,7 +1710,9 @@ pub fn specs() -> Vec { label: "Sharpen", menu: &[], shortcut: None, - params: brush_params!(r#","strength":1..100=50,"protectDetail":bool=true,"sampleAllLayers":bool=false"#), + params: brush_params!( + r#","strength":1..100=50,"protectDetail":bool=true,"sampleAllLayers":bool=false,"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges)"# + ), enabled: has_pixel_layer, run: |s, p| dab_cmd(s, p, "paint.sharpen"), journal: true, @@ -1662,7 +1722,9 @@ pub fn specs() -> Vec { label: "Smudge", menu: &[], shortcut: None, - params: brush_params!(r#","strength":1..100=50,"fingerPainting":bool=false (starts with the foreground colour),"sampleAllLayers":bool=false"#), + params: brush_params!( + r#","strength":1..100=50,"fingerPainting":bool=false (starts with the foreground colour),"sampleAllLayers":bool=false,"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges)"# + ), enabled: has_pixel_layer, run: |s, p| dab_cmd(s, p, "paint.smudge"), journal: true, @@ -1673,7 +1735,7 @@ pub fn specs() -> Vec { menu: &[], shortcut: None, params: brush_params!( - r#","state":row (a History panel row, as history.setSource: 0 = oldest state, then the current one, then the greyed redo states),"snapshot":id (a history.newSnapshot id; default: the History panel's source, else the oldest held state; a source of another canvas size is an error)"# + r#","state":row (a History panel row, as history.setSource: 0 = oldest state, then the current one, then the greyed redo states),"snapshot":id (a history.newSnapshot id; default: the History panel's source, else the oldest held state; a source of another canvas size is an error),"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges)"# ), enabled: has_pixel_layer, run: history_brush, diff --git a/crates/engine/src/retouch_cmds/pattern_stamp.rs b/crates/engine/src/retouch_cmds/pattern_stamp.rs index f13afc112e..7ccb293d4f 100644 --- a/crates/engine/src/retouch_cmds/pattern_stamp.rs +++ b/crates/engine/src/retouch_cmds/pattern_stamp.rs @@ -8,12 +8,12 @@ use photocraft_color::{BlendMode, PixelFormat}; use photocraft_compose::pattern::{Placement, Tile, render}; use photocraft_doc::{LayerId, Pattern}; use photocraft_geom::Rect; -use photocraft_paint::retouch::{Footprint, Region, apply_coverage, apply_dab_stroke, over_native}; +use photocraft_paint::retouch::{Footprint, Region, apply_coverage, apply_dab_stroke_wrapped, over_native}; use photocraft_paint::{Stroke, StrokePoint}; use photocraft_raster::{Surface, from_rgba_into, to_rgba}; use serde_json::{Value, json}; -use super::{bad, blend_param, damage_json, flag, has_pixel_layer, parse_brush, reject_huge_stroke}; +use super::{bad, blend_param, damage_json, flag, has_pixel_layer, parse_brush, periodic, reject_huge_stroke, wrap_tile}; use crate::{Result, Session}; const STREAM_PHASE_X: u64 = 101; @@ -88,10 +88,16 @@ pub(super) fn run(s: &mut Session, p: &Value) -> Result { let pre = doc.clone(); let (surf, lock) = crate::channel_cmds::target_surface(doc, st.id, p)?; let sel = pre.selection.as_ref(); + // View › Pattern Preview: the stroke carries on across the canvas edges, the pattern + // following it as it goes. + let wrap = wrap_tile(&pre, p); if st.impressionist { // Halo is the dab footprint only: Impressionist jitters phase, it does not blur. let effect = st.impressionist_effect(surf.format()); - Ok(apply_dab_stroke(surf, &st.stroke, sel, lock, 0, effect)) + Ok(apply_dab_stroke_wrapped(surf, &st.stroke, sel, lock, 0, wrap, effect)) + } else if wrap.is_some() { + let (place, fmt) = (st.placement(), surf.format()); + periodic::stroke(&pre, surf, &st.stroke, sel, lock, st.mode, |_, r| Ok(paint_region(&st.tile, &place, &fmt, r))) } else { paint_aligned(surf, &st.stroke, sel, lock, &st.tile, st.placement(), st.mode, CMD) } @@ -119,7 +125,7 @@ pub fn live_impressionist(s: &Session, p: &Value) -> Result { } let (stroke, id) = (st.stroke.clone(), st.id); let doc = (*s.active().ok_or(crate::EngineError::NoDocument)?.doc).clone(); - super::LiveDab::with_effect_on(doc, CMD, p, stroke, id, 0, |pre| st.impressionist_effect(pre.format()))?.with_tail_preview() + super::LiveDab::with_effect_on(doc, CMD, p, stroke, id, 0, |pre, _| st.impressionist_effect(pre.format()))?.with_tail_preview() } /// The pattern under destination `rect` in `fmt` (native channels): what an aligned stroke paints diff --git a/crates/engine/src/retouch_cmds/periodic.rs b/crates/engine/src/retouch_cmds/periodic.rs index f31542fb5a..27f4c9c86c 100644 --- a/crates/engine/src/retouch_cmds/periodic.rs +++ b/crates/engine/src/retouch_cmds/periodic.rs @@ -23,6 +23,31 @@ pub(super) fn project(rect: Rect, tile: Rect) -> Vec { ys.into_iter().flat_map(|(y0, y1)| xs.iter().map(move |&(x0, x1)| Rect::new(x0, y0, x1, y1))).collect() } +/// A composite tool's stroke wrapped around the document: `paint` gives the pixels for a +/// rectangle in the stroke's own coordinates, from the target as it was before the stroke, and +/// they go on through the stroke's coverage wherever it repeats on the document. +pub(super) fn stroke( + pre: &Document, + target: &mut Surface, + stroke: &Stroke, + selection: Option<&Surface>, + lock: bool, + mode: BlendMode, + mut paint: impl FnMut(&Surface, Rect) -> Result, +) -> Result { + let mut renderer = photocraft_paint::StrokeRenderer::new(&stroke.brush, None, 1.0); + renderer.push(&stroke.points); + renderer.finish(); + let before = target.clone(); + let bounds = renderer.bounds(); + Composite { target, before: &before, tile: pre.bounds(), opacity: stroke.brush.opacity, selection, lock, mode }.draw( + bounds, + &stroke.brush, + &stroke.points, + |r| paint(&before, r), + ) +} + /// Composite periodic copies of one stroke, restoring each dirty patch from pre-stroke pixels. /// Each dab wraps once, then builds flow towards its opacity ceiling. Stroke opacity and /// selection are applied once after all dabs, including repeated visits through other tiles. diff --git a/crates/engine/src/retouch_cmds/tests.rs b/crates/engine/src/retouch_cmds/tests.rs index 524e12f080..e88129cb67 100644 --- a/crates/engine/src/retouch_cmds/tests.rs +++ b/crates/engine/src/retouch_cmds/tests.rs @@ -2407,3 +2407,292 @@ fn periodic_clone_interior_matches_ordinary_brush_masks_and_transparency() { } } } + +// --------------------------------------------------------------------------------------------- +// View › Pattern Preview: every retouching stroke wraps across the canvas edges (#8883) +// --------------------------------------------------------------------------------------------- + +/// The canvas the Pattern Preview tests wrap around. +const WRAP: (u32, u32) = (48, 36); + +/// `texture` repeated every canvas, so each copy of a pixel holds the same colour. +fn tiled_texture(x: i32, y: i32) -> [f32; 4] { + texture(x.rem_euclid(WRAP.0 as i32), y.rem_euclid(WRAP.1 as i32)) +} + +/// A stroke from just inside the corner where the middle copy of a three-by-three tiling meets +/// the next copies, out across both of its edges. +fn corner_stroke(extra: &Value) -> Value { + let mut p = json!({"points": [[84, 64], [106, 79]], "size": 12, "hardness": 100}); + for (k, v) in extra.as_object().unwrap() { + p[k] = v.clone(); + } + p +} + +/// The texture on the layer. +fn textured(s: &mut Session) { + paint_layer(s, tiled_texture); +} + +/// The texture under an empty layer, which is the target (Sample All Layers). +fn textured_below(s: &mut Session) { + paint_layer(s, tiled_texture); + s.execute("layer.new.layer", json!({})).unwrap(); +} + +/// The texture in history state 1, painted over since. +fn textured_in_history(s: &mut Session) { + paint_layer(s, tiled_texture); + paint_layer(s, |_, _| [0.15, 0.2, 0.7, 1.0]); +} + +type Setup = fn(&mut Session); + +/// Every stroke tool that wraps, with options and a document for it. `true` marks the tools +/// that composite a paint image through the stroke's coverage (`periodic::Composite`), which +/// matches the ordinary composite only within rounding. +fn wrap_cases() -> Vec<(&'static str, Value, Setup, bool)> { + vec![ + ("paint.dodge", json!({"exposure": 60}), textured, false), + ("paint.burn", json!({"exposure": 40, "range": "shadows", "protectTones": false}), textured, false), + ("paint.sponge", json!({"mode": "saturate"}), textured, false), + ("paint.blur", json!({"strength": 80}), textured, false), + ("paint.sharpen", json!({"strength": 60}), textured, false), + ("paint.smudge", json!({"strength": 70}), textured, false), + ("paint.blur", json!({"strength": 80, "sampleAllLayers": true}), textured_below, false), + ("paint.smudge", json!({"strength": 70, "sampleAllLayers": true}), textured_below, false), + ("paint.mixerBrush", json!({"wet": 100, "mix": 80, "load": 50}), textured, false), + ("paint.mixerBrush", json!({"wet": 100, "mix": 80, "sampleAllLayers": true}), textured_below, false), + ("paint.colorReplacement", json!({"color": "#ff8000", "tolerance": 60}), textured, false), + ("paint.backgroundEraser", json!({"tolerance": 40}), textured, false), + ("paint.patternStamp", json!({"pattern": "Bricks", "impressionist": true}), textured, false), + ("paint.patternStamp", json!({"pattern": "Checkerboard"}), textured, true), + ("paint.historyBrush", json!({"state": 1}), textured_in_history, true), + ("paint.healingBrush", json!({"offset": [-20, -14]}), textured, true), + ("paint.cloneStamp", json!({"offset": [-20, -14]}), textured, true), + ] +} + +/// The native-channel difference `periodic::Composite` may leave against the ordinary composite. +fn composite_tolerance(depth: u64) -> f32 { + match depth { + 8 => 1.1 / 255.0, + 16 => 1.1 / 65535.0, + _ => 0.00001, + } +} + +#[test] +fn every_wrapping_stroke_paints_the_canvas_as_on_the_canvas_tiled_three_by_three() { + // A stroke with `"wrap": true` gives each pixel of the canvas what the same stroke gives the + // copy of that pixel it goes over, on a document holding the canvas tiled three by three. + let (w, h) = (WRAP.0 as usize, WRAP.1 as usize); + for depth in DEPTHS { + for (cmd, extra, setup, composite) in wrap_cases() { + let mut canvas = session(WRAP.0, WRAP.1, depth, "rgb"); + setup(&mut canvas); + let mut tiled = session(3 * WRAP.0, 3 * WRAP.1, depth, "rgb"); + setup(&mut tiled); + let (mut wrapped, plain) = (corner_stroke(&extra), corner_stroke(&extra)); + wrapped["wrap"] = json!(true); + canvas.execute(cmd, wrapped).unwrap_or_else(|e| panic!("{cmd} at {depth}: {e}")); + let before = layer_pixels(&tiled); + tiled.execute(cmd, plain).unwrap(); + let (got, want) = (layer_pixels(&canvas), layer_pixels(&tiled)); + let n = got.len() / (w * h); + let at = |x: usize, y: usize| (y * 3 * w + x) * n..(y * 3 * w + x + 1) * n; + let tolerance = if composite { composite_tolerance(depth) } else { 0.0 }; + let (mut moved, mut edges) = (0, [false; 4]); + for y in 0..h { + for x in 0..w { + let copies = (0..3).flat_map(|j| (0..3).map(move |i| (x + i * w, y + j * h))); + let changed: Vec<_> = copies.filter(|&(u, v)| want[at(u, v)] != before[at(u, v)]).collect(); + let (pixel, expected) = match changed.as_slice() { + [] => (&got[(y * w + x) * n..][..n], &before[at(x, y)]), + [(u, v)] => (&got[(y * w + x) * n..][..n], &want[at(*u, *v)]), + _ => panic!("{cmd}: ({x}, {y}) has two copies under the stroke"), + }; + let error = pixel.iter().zip(expected).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max); + assert!(error <= tolerance, "{cmd} {extra} at {depth}-bit: ({x}, {y}) is off by {error}"); + if !changed.is_empty() { + moved += 1; + edges[0] |= x < 8; + edges[1] |= x >= w - 8; + edges[2] |= y < 8; + edges[3] |= y >= h - 8; + } + } + } + assert!(moved > 150 && edges.iter().all(|&e| e), "{cmd} {extra} at {depth}-bit: the stroke crossed both edges ({moved} px, {edges:?})"); + } + } +} + +#[test] +fn a_wrapped_stroke_inside_the_canvas_paints_as_an_ordinary_one() { + let p = json!({"points": [[130, 110], [150, 118], [168, 112]], "size": 14, "hardness": 60}); + let mut cases: Vec<(&str, Value, Setup, bool)> = wrap_cases(); + cases.push(("paint.spotHealing", json!({"type": "contentAware"}), textured, false)); + cases.push(("paint.spotHealing", json!({"type": "proximityMatch"}), textured, false)); + for depth in [8, 32] { + for (cmd, extra, setup, composite) in &cases { + let run = |wrap: bool| { + // Big enough that Spot Healing's window stays clear of the edges either way. + let mut s = session(300, 230, depth, "rgb"); + setup(&mut s); + let mut q = p.clone(); + for (k, v) in extra.as_object().unwrap() { + q[k] = v.clone(); + } + q["wrap"] = json!(wrap); + let r = s.execute(cmd, q).unwrap(); + (layer_pixels(&s), r["damage"].clone()) + }; + let ((plain, d0), (wrapped, d1)) = (run(false), run(true)); + let tolerance = if *composite { composite_tolerance(depth) } else { 0.0 }; + let error = plain.iter().zip(&wrapped).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max); + assert!(error <= tolerance, "{cmd} {extra} at {depth}-bit: off by {error}"); + if !*composite { + assert_eq!(d0, d1, "{cmd} {extra} at {depth}-bit: the same damage"); + } + } + } +} + +#[test] +fn spot_healing_across_the_canvas_corner_heals_every_copy_of_the_stroke_and_nothing_else() { + // The window is centred on the stroke whatever the edges and one canvas across, so the + // corner heals from what surrounds it in the copies, the same as when the stroke stays inside. + for kind in ["contentAware", "createTexture", "proximityMatch"] { + let mut s = session(WRAP.0, WRAP.1, 8, "rgb"); + paint_layer(&mut s, |x, y| if (x - 2).abs() < 3 && (y - 2).abs() < 3 { [0.0, 0.0, 0.0, 1.0] } else { tiled_texture(x, y) }); + let before = layer_pixels(&s); + // The dark blemish straddles the corner (0, 0); the stroke is drawn on the copy to its + // lower right, around (48, 36). + s.execute("paint.spotHealing", json!({"points": [[48, 36], [50, 38]], "size": 14, "hardness": 100, "type": kind, "wrap": true})).unwrap(); + let after = layer_pixels(&s); + let n = after.len() / (WRAP.0 * WRAP.1) as usize; + let px = |v: &[f32], x: usize, y: usize| v[(y * WRAP.0 as usize + x) * n..][..n].to_vec(); + for (x, y) in [(1, 1), (46, 1), (1, 34), (46, 34)] { + assert!(px(&after, x, y)[0] > 0.1, "{kind}: ({x}, {y}) healed: {:?}", px(&after, x, y)); + } + for (x, y) in [(24, 18), (10, 18), (24, 4), (38, 28)] { + assert_eq!(px(&after, x, y), px(&before, x, y), "{kind}: ({x}, {y}) is far from the stroke"); + } + } +} + +#[test] +fn a_wrapped_dab_bigger_than_the_canvas_stays_on_it() { + let mut cases = wrap_cases(); + cases.push(("paint.spotHealing", json!({"type": "contentAware"}), textured, false)); + for (cmd, extra, setup, _) in cases { + let mut s = session(16, 12, 8, "rgb"); + setup(&mut s); + let mut p = json!({"points": [[8, 6], [30, 20]], "size": 60, "hardness": 0, "wrap": true}); + for (k, v) in extra.as_object().unwrap() { + p[k] = v.clone(); + } + let r = s.execute(cmd, p).unwrap_or_else(|e| panic!("{cmd}: {e}")); + let d = r["damage"].as_array().map(|d| d.iter().filter_map(Value::as_i64).collect::>()).unwrap_or_default(); + assert!(matches!(d.as_slice(), [x, y, w, h] if *x >= 0 && *y >= 0 && x + w <= 16 && y + h <= 12), "{cmd}: damage {d:?} is on the canvas"); + let st = s.active().unwrap(); + let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + assert!([(-1, 5), (16, 5), (5, -1), (5, 12), (40, 30)].iter().all(|&(x, y)| surf.rgba(x, y)[3] == 0.0), "{cmd}: nothing lands off the canvas"); + } +} + +#[test] +fn wrapped_live_strokes_show_what_their_commits_paint() { + // Across the left and bottom edges, as the canvas draws them: Dodge … Smudge commit the + // pixels they drew; the strokes that preview their finishing dabs show their commit's. + let points = [[30.0, 20.0], [10.0, 30.0], [-6.0, 40.0], [-12.0, 34.0]]; + for depth in DEPTHS { + for (cmd, extra) in dab_cases() { + let mut s = session(WRAP.0, WRAP.1, depth, "rgb"); + paint_layer(&mut s, tiled_texture); + let mut extra = extra.clone(); + extra["wrap"] = json!(true); + let rendered = s.execute(cmd, dab_opts(&points, 50, &extra)).unwrap(); + let want = layer_pixels(&s); + s.execute("edit.undo", json!({})).unwrap(); + offer_live(&mut s, cmd, &points, 50, &extra); + let taken = s.execute(cmd, dab_opts(&points, 50, &extra)).unwrap(); + assert_eq!(taken["fromLive"], json!(true), "{cmd} at {depth}-bit"); + assert_eq!(taken["damage"], rendered["damage"], "{cmd} at {depth}-bit"); + assert!(layer_pixels(&s) == want, "{cmd} at {depth}-bit: the live pixels are the command's"); + } + let pts: Vec<_> = points.iter().map(|q| StrokePoint::new(q[0], q[1], 1.0)).collect(); + type Begin = fn(&Session, &Value) -> Result; + let shown: [(&str, Value, Begin); 3] = [ + ("paint.colorReplacement", json!({"color": "#ff8000", "tolerance": 60}), crate::brush_cmds::live_color_replacement), + ("paint.backgroundEraser", json!({"tolerance": 40}), crate::eraser_cmds::live_background_eraser), + // A seed, as the canvas sends: without one the jitter hashes all the points. + ("paint.patternStamp", json!({"pattern": "Bricks", "impressionist": true, "seed": 7}), live_pattern_impressionist), + ]; + for (cmd, extra, begin) in shown { + let mut s = session(WRAP.0, WRAP.1, depth, "rgb"); + paint_layer(&mut s, tiled_texture); + let mut p = dab_opts(&points[..1], 50, &extra); + p["wrap"] = json!(true); + let mut live = begin(&s, &p).unwrap(); + live.push(&pts[1..]).unwrap(); + let shown = live.doc.clone(); + let pre = s.active().unwrap().doc.clone(); + p["points"] = json!(points); + s.execute(cmd, p).unwrap(); + let st = s.active().unwrap(); + let id = st.active_layer.unwrap(); + let (a, b) = (shown.layer(id).unwrap().surface().unwrap(), st.doc.layer(id).unwrap().surface().unwrap()); + let before = pre.layer(id).unwrap().surface().unwrap(); + let worst = (0..WRAP.1 as i32) + .flat_map(|y| (0..WRAP.0 as i32).map(move |x| (x, y))) + .map(|(x, y)| (0..4).map(|c| (a.rgba(x, y)[c] - b.rgba(x, y)[c]).abs()).fold(0.0f32, f32::max)) + .fold(0.0f32, f32::max); + assert!(worst <= 2.0 / 255.0 + 1e-6, "{cmd} at {depth}-bit: live and commit differ by {worst}"); + let right = WRAP.0 as i32 - 3; + assert!((0..WRAP.1 as i32).any(|y| b.rgba(right, y) != before.rgba(right, y)), "{cmd} at {depth}-bit: the stroke came back at the right edge"); + } + } +} + +#[test] +fn wrapped_live_retouch_strokes_show_what_their_commits_paint() { + // History Brush commits the pixels its live stroke drew across the edges; the aligned Pattern + // Stamp's preview is its commit. + let points = [[30.0, 20.0], [10.0, 30.0], [-6.0, 40.0], [-12.0, 34.0]]; + let pts: Vec<_> = points.iter().map(|q| StrokePoint::new(q[0], q[1], 1.0)).collect(); + for depth in DEPTHS { + for (cmd, extra, setup) in [ + ("paint.historyBrush", json!({"state": 1}), textured_in_history as Setup), + ("paint.patternStamp", json!({"pattern": "Checkerboard"}), textured as Setup), + ] { + let mut s = session(WRAP.0, WRAP.1, depth, "rgb"); + setup(&mut s); + let mut p = dab_opts(&points[..1], 50, &extra); + p["wrap"] = json!(true); + let mut live = LiveRetouch::begin(&s, cmd, &p).unwrap(); + live.push(&pts[1..]).unwrap(); + p["points"] = json!(points); + let shown = live.doc.layer(s.active().unwrap().active_layer.unwrap()).unwrap().surface().unwrap().read_region(Rect::new(0, 0, 48, 36)); + let plain = s.execute(cmd, p.clone()).unwrap(); + let want = layer_pixels(&s); + let error = shown.iter().zip(&want).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max); + assert!(error <= composite_tolerance(depth), "{cmd} at {depth}-bit: the preview is off by {error}"); + if cmd == "paint.historyBrush" { + // A stroke drawn on the document as it is after the undo. + s.execute("edit.undo", json!({})).unwrap(); + let mut first = p.clone(); + first["points"] = json!(points[..1]); + let mut live = LiveRetouch::begin(&s, cmd, &first).unwrap(); + live.push(&pts[1..]).unwrap(); + s.offer_live_retouch(live).unwrap(); + let taken = s.execute(cmd, p).unwrap(); + assert_eq!(taken["fromLive"], json!(true), "{depth}-bit"); + assert_eq!(taken["damage"], plain["damage"], "{depth}-bit"); + assert!(layer_pixels(&s) == want, "{depth}-bit: the committed live pixels are the command's"); + } + } + } +} diff --git a/crates/paint/src/bg_erase.rs b/crates/paint/src/bg_erase.rs index 323a90036a..e41308e76a 100644 --- a/crates/paint/src/bg_erase.rs +++ b/crates/paint/src/bg_erase.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use crate::Stroke; use crate::replace::{Limits, Sampling}; -use crate::retouch::{Footprint, Region, alpha_index, apply_dab_stroke}; +use crate::retouch::{Footprint, Region, alpha_index, apply_dab_stroke_wrapped}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -49,8 +49,14 @@ fn dist(a: &[f32], b: &[f32]) -> f32 { /// the selection limits the effect. Returns the damaged rectangle; empty when the surface has no /// alpha channel. pub fn apply_background_eraser(target: &mut Surface, stroke: &Stroke, bs: &BgEraseSettings, selection: Option<&Surface>) -> Rect { - let Some(effect) = background_eraser_effect(target, bs) else { return Rect::EMPTY }; - apply_dab_stroke(target, &background_eraser_stroke(stroke), selection, false, 0, effect) + apply_background_eraser_wrapped(target, stroke, bs, selection, None) +} + +/// [`apply_background_eraser`] wrapped around `wrap` (View › Pattern Preview): the stroke samples +/// and erases where its pixels lie on that canvas, so it carries on across the edges. +pub fn apply_background_eraser_wrapped(target: &mut Surface, stroke: &Stroke, bs: &BgEraseSettings, selection: Option<&Surface>, wrap: Option) -> Rect { + let Some(effect) = background_eraser_effect(target, bs, wrap) else { return Rect::EMPTY }; + apply_dab_stroke_wrapped(target, &background_eraser_stroke(stroke), selection, false, 0, wrap, effect) } /// The stroke [`apply_background_eraser`] runs its dabs along: full opacity (the tool has none), @@ -64,8 +70,9 @@ pub fn background_eraser_stroke(stroke: &Stroke) -> Stroke { /// One Background Eraser dab's edit of a working copy of `pre` (the target before the stroke): /// shared by [`apply_background_eraser`] and live previews that run the dabs as they come, so both -/// erase the same pixels. `None` when `pre` has no alpha channel (nothing can be erased). -pub fn background_eraser_effect(pre: &Surface, bs: &BgEraseSettings) -> Option> { +/// erase the same pixels. `None` when `pre` has no alpha channel (nothing can be erased). In a +/// stroke wrapped around `wrap`, `pre` is read where the working pixels lie on that canvas. +pub fn background_eraser_effect(pre: &Surface, bs: &BgEraseSettings, wrap: Option) -> Option> { let fmt = pre.format(); let a = alpha_index(&fmt)?; let nc = a; @@ -97,7 +104,7 @@ pub fn background_eraser_effect(pre: &Surface, bs: &BgEraseSettings) -> Option, @@ -101,7 +105,9 @@ pub fn apply_mixer_stroke( state: &mut MixerState, selection: Option<&Surface>, lock_transparency: bool, + wrap: Option, ) -> Rect { + let wrap = wrap.filter(|t| !t.is_empty()); let fmt = target.format(); let n = fmt.channels(); let a = alpha_index(&fmt); @@ -121,7 +127,7 @@ pub fn apply_mixer_stroke( st.brush.opacity = 1.0; let mut acc = vec![0.0f32; n]; let mut paint = vec![0.0f32; n]; - let dmg = apply_dab_stroke(target, &st, selection, lock_transparency, 0, |work, fp| { + let dmg = apply_dab_stroke_wrapped(target, &st, selection, lock_transparency, 0, wrap, |work, fp| { // Transfer › Wetness / Mix Jitter scale the settings per dab. let (wet, mix) = (wet * fp.dab.wet.clamp(0.0, 1.0), mix * fp.dab.mix.clamp(0.0, 1.0)); // Sample the canvas under the dab. @@ -136,6 +142,7 @@ pub fn apply_mixer_stroke( } let px: Vec = match sample { Some(s) => { + let (x, y) = wrap.map_or((x, y), |t| (wrap_coord(x, t.x0, t.x1), wrap_coord(y, t.y0, t.y1))); let v = s.pixel(x, y); if s.format() == fmt { v } else { from_rgba(&fmt, s.rgba(x, y)) } } @@ -202,7 +209,16 @@ mod tests { let mut s = Surface::new(PixelFormat::RGBA8); let mut st = MixerState::default(); st.load([1.0, 0.0, 0.0, 1.0]); - apply_mixer_stroke(&mut s, None, &stroke(10.0), &MixerSettings { wet: 0.0, load, mix: 0.0, flow: 1.0, ..Default::default() }, &mut st, None, false); + apply_mixer_stroke( + &mut s, + None, + &stroke(10.0), + &MixerSettings { wet: 0.0, load, mix: 0.0, flow: 1.0, ..Default::default() }, + &mut st, + None, + false, + None, + ); let (start, end) = (s.rgba(10, 10)[3], s.rgba(190, 10)[3]); assert!(start > 0.95, "{start}"); if expect_dry { @@ -231,6 +247,7 @@ mod tests { &mut st, None, false, + None, ); // Blue is dragged into the white half. let c = s.rgba(115, 10); @@ -252,7 +269,7 @@ mod tests { // On red, the clean wet brush smears red and lays down none of the blue it was loaded with. let mut s = Surface::new(PixelFormat::RGBA8); s.fill_rect(Rect::new(0, 0, 200, 20), &[1.0, 0.0, 0.0, 1.0]); - apply_mixer_stroke(&mut s, None, &stroke(10.0), &MixerSettings::default(), &mut st, None, false); + apply_mixer_stroke(&mut s, None, &stroke(10.0), &MixerSettings::default(), &mut st, None, false, None); let c = s.rgba(100, 10); assert!(c[0] > 0.99 && c[1] < 0.01 && c[2] < 0.01, "{c:?}"); } @@ -288,7 +305,7 @@ mod tests { fn dry_empty_brush_paints_nothing() { let mut s = Surface::new(PixelFormat::RGBA8); let mut st = MixerState::default(); - let dmg = apply_mixer_stroke(&mut s, None, &stroke(10.0), &MixerSettings { wet: 0.0, ..Default::default() }, &mut st, None, false); + let dmg = apply_mixer_stroke(&mut s, None, &stroke(10.0), &MixerSettings { wet: 0.0, ..Default::default() }, &mut st, None, false, None); assert!(!dmg.is_empty()); assert_eq!(s.rgba(50, 10)[3], 0.0); } @@ -306,7 +323,7 @@ mod tests { for p in &mut k.points { p.pressure = pressure; } - apply_mixer_stroke(&mut s, None, &k, &MixerSettings { wet: 1.0, load: 1.0, mix: 1.0, flow: 1.0, ..Default::default() }, &mut st, None, false); + apply_mixer_stroke(&mut s, None, &k, &MixerSettings { wet: 1.0, load: 1.0, mix: 1.0, flow: 1.0, ..Default::default() }, &mut st, None, false, None); st.pickup }; assert!(run(0.0).is_none()); diff --git a/crates/paint/src/replace.rs b/crates/paint/src/replace.rs index 20619cd48a..957037a0bd 100644 --- a/crates/paint/src/replace.rs +++ b/crates/paint/src/replace.rs @@ -8,7 +8,8 @@ use photocraft_raster::{Surface, from_rgba_into, to_rgba}; use serde::{Deserialize, Serialize}; use crate::Stroke; -use crate::retouch::{Footprint, Region, apply_dab_stroke}; +use crate::retouch::{Footprint, Region, apply_dab_stroke_wrapped}; +use crate::wrap::wrap_coord; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -79,14 +80,29 @@ fn dist(a: &[f32; 4], b: [f32; 3]) -> f32 { /// Replace colours along a stroke. Returns the damaged rectangle. pub fn apply_color_replacement(target: &mut Surface, stroke: &Stroke, rs: &ReplaceSettings, selection: Option<&Surface>, lock_transparency: bool) -> Rect { - let effect = color_replacement_effect(target.clone(), rs.clone()); - apply_dab_stroke(target, stroke, selection, lock_transparency, 0, effect) + apply_color_replacement_wrapped(target, stroke, rs, selection, lock_transparency, None) +} + +/// [`apply_color_replacement`] wrapped around `wrap` (View › Pattern Preview): the stroke samples +/// and recolours where its pixels lie on that canvas, so it carries on across the edges. +pub fn apply_color_replacement_wrapped( + target: &mut Surface, + stroke: &Stroke, + rs: &ReplaceSettings, + selection: Option<&Surface>, + lock_transparency: bool, + wrap: Option, +) -> Rect { + let effect = color_replacement_effect(target.clone(), rs.clone(), wrap); + apply_dab_stroke_wrapped(target, stroke, selection, lock_transparency, 0, wrap, effect) } /// One Color Replacement dab's edit of a working copy, matching against `pre` (the target before /// the stroke). Shared by [`apply_color_replacement`] and live previews that run the dabs as they -/// come, so both recolour the same pixels. -pub fn color_replacement_effect(pre: Surface, rs: ReplaceSettings) -> impl FnMut(&mut Region, &Footprint) { +/// come, so both recolour the same pixels. In a stroke wrapped around `wrap` (View › Pattern +/// Preview), `pre` is read where the dab's pixels lie on that canvas. +pub fn color_replacement_effect(pre: Surface, rs: ReplaceSettings, wrap: Option) -> impl FnMut(&mut Region, &Footprint) { + let wrap = wrap.filter(|t| !t.is_empty()); let fmt = pre.format(); let n = fmt.channels(); let mode = match rs.mode { @@ -111,13 +127,14 @@ pub fn color_replacement_effect(pre: Surface, rs: ReplaceSettings) -> impl FnMut }; move |work: &mut Region, fp: &Footprint| { let (cx, cy) = (fp.dab.center.x.floor() as i32, fp.dab.center.y.floor() as i32); + let (sx, sy) = wrap.map_or((cx, cy), |t| (wrap_coord(cx, t.x0, t.x1), wrap_coord(cy, t.y0, t.y1))); let sampled = match rs.sampling { Sampling::Continuous => { - let c = pre.rgba(cx, cy); + let c = pre.rgba(sx, sy); [c[0], c[1], c[2]] } Sampling::Once => *once.get_or_insert_with(|| { - let c = pre.rgba(cx, cy); + let c = pre.rgba(sx, sy); [c[0], c[1], c[2]] }), Sampling::BackgroundSwatch => [rs.background[0], rs.background[1], rs.background[2]], @@ -127,8 +144,15 @@ pub fn color_replacement_effect(pre: Surface, rs: ReplaceSettings) -> impl FnMut return; } let (w, h) = (r.width() as usize, r.height() as usize); - let mut pre_px = vec![[0.0f32; 4]; w * h]; - pre.read_rgba_into(r, &mut pre_px); + let read = |q: Rect| { + let mut px = vec![[0.0f32; 4]; q.width() as usize * q.height() as usize]; + pre.read_rgba_into(q, &mut px); + px + }; + let pre_px = match wrap { + Some(t) => crate::wrap::read(r, t, 1, read), + None => read(r), + }; // Per-pixel match weight. let mut weight = vec![0.0f32; w * h]; for y in r.y0..r.y1 { diff --git a/crates/paint/src/retouch.rs b/crates/paint/src/retouch.rs index 06bafe6cfb..09c38fc78a 100644 --- a/crates/paint/src/retouch.rs +++ b/crates/paint/src/retouch.rs @@ -14,10 +14,13 @@ //! All pixel data here is the surface's *native* channels as normalised `f32` (any depth, any colour //! model); colour-space-specific work happens in the callbacks. +use std::collections::HashMap; + use photocraft_color::{BlendMode, PixelFormat}; use photocraft_geom::Rect; use photocraft_raster::{Surface, from_rgba_into, to_rgba}; +use crate::wrap::{fits, fold_area, wrap_coord}; use crate::{Dab, Stroke, dab_coverage, dabs}; /// A rectangle of native-channel pixels (`rect.width() × rect.height() × ch` floats, row-major). @@ -75,6 +78,14 @@ impl Region { pub fn write(&self, s: &mut Surface) { s.write_region(self.rect, &self.data); } + /// Read `rect` from a surface that repeats `wrap` apart (View › Pattern Preview): `rect` may + /// cross its edges. `None` (or an empty tile) is [`Region::read`]. + pub fn read_wrapped(s: &Surface, rect: Rect, wrap: Option) -> Self { + match wrap.filter(|t| !t.is_empty()) { + Some(tile) => Self { rect, ch: s.channels(), data: crate::wrap::read(rect, tile, s.channels(), |r| s.read_region(r)) }, + None => Self::read(s, rect), + } + } } /// One dab's footprint: coverage (hardness falloff × dab alpha) over its bounding rectangle. @@ -266,6 +277,174 @@ pub fn apply_dab_stroke( bounds } +/// [`apply_dab_stroke`] wrapped around `wrap` (View › Pattern Preview): each dab reads and edits +/// the pixels where it repeats on that tile, so a stroke crossing an edge carries on at the +/// opposite one. The dabs run one by one on a [`WorkTiles`] working copy, as live previews run +/// them. `None` (or an empty tile) is [`apply_dab_stroke`]. Returns the damaged rectangle. +pub fn apply_dab_stroke_wrapped( + target: &mut Surface, + stroke: &Stroke, + selection: Option<&Surface>, + lock_transparency: bool, + halo: i32, + wrap: Option, + mut effect: impl FnMut(&mut Region, &Footprint), +) -> Rect { + let Some(tile) = wrap.filter(|t| !t.is_empty()) else { + return apply_dab_stroke(target, stroke, selection, lock_transparency, halo, effect); + }; + let ctx = crate::BrushContext::new(&stroke.brush); + let mut work = WorkTiles::new(target.channels()).wrapped(Some(tile)); + let mut damage = Rect::EMPTY; + for (i, d) in dabs(stroke).iter().enumerate() { + let fp = ctx.footprint(d, i); + if fp.rect.is_empty() { + continue; + } + let mut region = work.read(target, fp.rect.inflate(halo)); + effect(&mut region, &fp); + work.write_dab(target, ®ion, &fp); + damage = damage.union(&fold_area(fp.rect, tile)); + } + let written: Vec = work.areas().map(|r| r.intersect(&tile)).filter(|r| !r.is_empty()).collect(); + for r in written { + let edited = work.read(target, r); + mix_back(target, &Region::read(target, r), &edited, r, stroke.brush.opacity, selection, lock_transparency); + } + damage +} + +/// Side of the tiles a [`WorkTiles`] working copy keeps its pixels in. +const WORK_TILE: i32 = 128; + +/// The working copy of a sequential-dab stroke run dab by dab (live previews, +/// [`apply_dab_stroke_wrapped`]): the target's native channels as `f32`, in tiles copied from the +/// pre-stroke surface the first time a dab writes to them. Unlike a `Surface`, it isn't rounded to +/// the document's depth between dabs, so each dab sees what [`apply_dab_stroke`]'s working copy +/// holds. Wrapped around a tile ([`Self::wrapped`]), it keeps each pixel once, where it lies on +/// the tile, and reads and writes it wherever it repeats. +pub struct WorkTiles { + ch: usize, + tiles: HashMap<(i32, i32), Vec>, + wrap: Option, +} + +impl WorkTiles { + pub fn new(ch: usize) -> Self { + Self { ch, tiles: HashMap::new(), wrap: None } + } + + /// Keep the pixels of a stroke wrapped around `tile` (View › Pattern Preview). `None` or an + /// empty tile doesn't wrap. + pub fn wrapped(mut self, tile: Option) -> Self { + self.wrap = tile.filter(|t| !t.is_empty()); + self + } + + fn tile_rect((tx, ty): (i32, i32)) -> Rect { + Rect::new(tx * WORK_TILE, ty * WORK_TILE, tx * WORK_TILE + WORK_TILE, ty * WORK_TILE + WORK_TILE) + } + + /// The tiles `r` overlaps. + fn coords(r: Rect) -> impl Iterator { + let (x0, x1) = (r.x0.div_euclid(WORK_TILE), (r.x1 - 1).div_euclid(WORK_TILE)); + let (y0, y1) = (r.y0.div_euclid(WORK_TILE), (r.y1 - 1).div_euclid(WORK_TILE)); + let empty = r.is_empty(); + (y0..=y1).filter(move |_| !empty).flat_map(move |ty| (x0..=x1).map(move |tx| (tx, ty))) + } + + /// The working pixels of `r`: the pre-stroke pixels `pre` where no dab has written yet. + /// Wrapped, `r` may cross the tile's edges or be bigger than it. + pub fn read(&self, pre: &Surface, r: Rect) -> Region { + match self.wrap { + Some(tile) => Region { rect: r, ch: self.ch, data: crate::wrap::read(r, tile, self.ch, |part| self.read_at(pre, part).data) }, + None => self.read_at(pre, r), + } + } + + fn read_at(&self, pre: &Surface, r: Rect) -> Region { + let mut out = Region::new(r, self.ch); + let n = self.ch; + let mut buf = Vec::new(); + for c in Self::coords(r) { + let tr = Self::tile_rect(c); + let part = r.intersect(&tr); + let w = part.width() as usize * n; + let tile = self.tiles.get(&c); + if tile.is_none() { + pre.read_region_into(part, &mut buf); + } + for y in part.y0..part.y1 { + let src = match tile { + Some(t) => t.get(((y - tr.y0) * WORK_TILE + (part.x0 - tr.x0)) as usize * n..).and_then(|s| s.get(..w)), + None => buf.get((y - part.y0) as usize * w..).and_then(|s| s.get(..w)), + }; + let dst = out.index(part.x0, y) * n; + if let (Some(s), Some(d)) = (src, out.data.get_mut(dst..dst + w)) { + d.copy_from_slice(s); + } + } + } + out + } + + /// Store a dab's edit: the pixels of `region` (which covers the dab) over the dab's rectangle + /// `fp.rect`. Wrapped, they land where they lie on the tile; a dab bigger than the tile covers + /// some pixels more than once, and each keeps the copy the dab covers most. + pub fn write_dab(&mut self, pre: &Surface, region: &Region, fp: &Footprint) { + let r = fp.rect.intersect(®ion.rect); + let Some(tile) = self.wrap else { + return self.write_at(pre, ®ion.crop(r)); + }; + if fits(r, tile) { + for (part, (dx, dy)) in crate::wrap::pieces(r, tile) { + let mut piece = region.crop(part.translate(dx, dy)); + piece.rect = part; + self.write_at(pre, &piece); + } + return; + } + let area = fold_area(r, tile); + let mut folded = self.read_at(pre, area); + let mut most = vec![0.0f32; area.width() as usize * area.height() as usize]; + for y in r.y0..r.y1 { + for x in r.x0..r.x1 { + let (c, tx, ty) = (fp.at(x, y), wrap_coord(x, tile.x0, tile.x1), wrap_coord(y, tile.y0, tile.y1)); + if let Some(m) = most.get_mut(folded.index(tx, ty)) + && c > *m + { + *m = c; + folded.px_mut(tx, ty).copy_from_slice(region.px(x, y)); + } + } + } + self.write_at(pre, &folded); + } + + /// Store `region` as the working pixels of its rectangle. + fn write_at(&mut self, pre: &Surface, region: &Region) { + let (r, n) = (region.rect, self.ch); + for c in Self::coords(r) { + let tr = Self::tile_rect(c); + let tile = self.tiles.entry(c).or_insert_with(|| pre.read_region(tr)); + let part = r.intersect(&tr); + let w = part.width() as usize * n; + for y in part.y0..part.y1 { + let dst = ((y - tr.y0) * WORK_TILE + (part.x0 - tr.x0)) as usize * n; + let src = region.index(part.x0, y) * n; + if let (Some(s), Some(d)) = (region.data.get(src..src + w), tile.get_mut(dst..dst + w)) { + d.copy_from_slice(s); + } + } + } + } + + /// The rectangles of the tiles dabs have written to. + pub fn areas(&self) -> impl Iterator + '_ { + self.tiles.keys().map(|&c| Self::tile_rect(c)) + } +} + /// Mix a sequential-dab stroke's working pixels `work` back over the original `orig` at /// `opacity × selection` inside `rect`, and write that area to `target`: the last step of /// [`apply_dab_stroke`], shared with live previews that run the dabs as they come. @@ -637,4 +816,89 @@ mod tests { } } } + + /// The canvas the Pattern Preview strokes below wrap around. + const TILE: Rect = Rect::new(0, 0, 40, 30); + + /// `area` of a surface showing the canvas repeated: every pixel of the canvas has its own colour. + fn tiled(fmt: PixelFormat, area: Rect) -> Surface { + let mut s = Surface::new(fmt); + let px = (area.y0..area.y1).flat_map(|y| (area.x0..area.x1).map(move |x| (wrap_coord(x, 0, 40), wrap_coord(y, 0, 30)))); + let data: Vec = px.flat_map(|(x, y)| [x as f32 / 40.0, y as f32 / 30.0, ((x * 7 + y * 3) % 13) as f32 / 13.0, 1.0]).collect(); + s.write_region(area, &data); + s + } + + fn smudge(size: f32) -> impl FnMut(&mut Region, &Footprint) { + let mut sm = Smudge::new(0.8, None, size).with_alpha(Some(3)); + move |work, fp| sm.dab(work, fp) + } + + #[test] + fn a_wrapped_sequential_stroke_paints_the_canvas_as_its_tiled_copies_would() { + // View › Pattern Preview: a smudge across a corner of the canvas copy it is drawn on gives + // the canvas exactly what it gives the canvas tiled three by three, where it runs into the + // next copies and carries their colours along. (The same points both times: moved points + // would place the dabs a rounding apart.) + let fmt = PixelFormat::RGBA32F; + let far = stroke(&[(68.0, 48.0), (92.0, 70.0)], 12.0, 0.5); + let halo = Smudge::sampling_radius(12.0); + let mut canvas = tiled(fmt, TILE); + let dmg = apply_dab_stroke_wrapped(&mut canvas, &far, None, false, halo, Some(TILE), smudge(12.0)); + let three = Rect::new(0, 0, 120, 90); + let (before, mut big) = (tiled(fmt, three), tiled(fmt, three)); + apply_dab_stroke(&mut big, &far, None, false, halo, smudge(12.0)); + let mut changed = 0; + for y in 0..30 { + for x in 0..40 { + let copies = [(40, 30), (80, 30), (40, 60), (80, 60)].map(|(dx, dy)| (x + dx, y + dy)); + let moved: Vec<_> = copies.into_iter().filter(|&(u, v)| big.pixel(u, v) != before.pixel(u, v)).collect(); + let want = match moved.as_slice() { + [] => before.pixel(x, y), + [(u, v)] => big.pixel(*u, *v), + _ => panic!("({x}, {y}): two copies under the stroke"), + }; + assert_eq!(canvas.pixel(x, y), want, "({x}, {y})"); + changed += usize::from(!moved.is_empty()); + } + } + assert!(changed > 300, "the smudge crossed both edges ({changed} px)"); + assert!(dmg.x0 >= 0 && dmg.y0 >= 0 && dmg.x1 <= 40 && dmg.y1 <= 30, "{dmg:?}"); + assert!((-12..0).all(|x| canvas.pixel(x, 20)[3] == 0.0) && (40..52).all(|x| canvas.pixel(x, 20)[3] == 0.0), "nothing off the canvas"); + } + + #[test] + fn a_wrapped_sequential_stroke_inside_the_canvas_paints_as_before() { + for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA32F] { + let st = stroke(&[(16.0, 12.0), (24.0, 18.0)], 6.0, 0.8); + let run = |wrap| { + let mut s = tiled(fmt, TILE); + let dmg = apply_dab_stroke_wrapped(&mut s, &st, None, false, Smudge::sampling_radius(6.0), wrap, smudge(6.0)); + (s, dmg) + }; + let ((plain, d0), (wrapped, d1)) = (run(None), run(Some(TILE))); + assert_eq!(d0, d1, "{fmt:?}"); + assert!((0..30).all(|y| (0..40).all(|x| plain.pixel(x, y) == wrapped.pixel(x, y))), "{fmt:?}"); + assert!(plain.pixel(20, 15) != tiled(fmt, TILE).pixel(20, 15), "{fmt:?}: the stroke smudged"); + } + } + + #[test] + fn a_wrapped_dab_bigger_than_the_canvas_edits_all_of_it_and_nothing_else() { + let small = Rect::new(0, 0, 10, 8); + let mut s = Surface::new(PixelFormat::RGBA32F); + s.fill_rect(small, &[1.0, 0.0, 0.0, 1.0]); + let darken = |work: &mut Region, fp: &Footprint| { + let r = fp.rect.intersect(&work.rect); + for y in r.y0..r.y1 { + for x in r.x0..r.x1 { + work.px_mut(x, y)[0] *= 1.0 - 0.5 * fp.at(x, y); + } + } + }; + let dmg = apply_dab_stroke_wrapped(&mut s, &stroke(&[(5.0, 4.0), (9.0, 6.0)], 30.0, 0.0), None, false, 0, Some(small), darken); + assert_eq!(dmg, small); + assert!((0..8).all(|y| (0..10).all(|x| s.pixel(x, y)[0] < 1.0)), "every pixel under the dab's copies darkens"); + assert!((-5..15).all(|x| s.pixel(x, -1)[3] == 0.0 && s.pixel(x, 8)[3] == 0.0), "nothing off the canvas"); + } } diff --git a/crates/paint/src/wrap.rs b/crates/paint/src/wrap.rs new file mode 100644 index 0000000000..efad49d3ca --- /dev/null +++ b/crates/paint/src/wrap.rs @@ -0,0 +1,166 @@ +//! View › Pattern Preview geometry: the document repeats its canvas (the *tile*) in every +//! direction, so a stroke past one edge carries on at the opposite one. Coordinates outside the +//! tile name the pixel a whole number of tile sizes away that lies on it. + +use photocraft_geom::Rect; + +/// `v` moved a whole number of periods into `lo..hi` (`v` itself when that is empty). +#[inline] +pub fn wrap_coord(v: i32, lo: i32, hi: i32) -> i32 { + let period = i64::from(hi) - i64::from(lo); + if period <= 0 { + return v; + } + (i64::from(lo) + (i64::from(v) - i64::from(lo)).rem_euclid(period)) as i32 +} + +/// Whether `rect` fits in `tile`, so it covers no pixel of the tile twice. +pub fn fits(rect: Rect, tile: Rect) -> bool { + rect.width() <= tile.width() && rect.height() <= tile.height() +} + +/// The part of `tile` that `rect` covers once moved onto it: per axis, the rectangle's span moved +/// a whole number of periods, or the whole tile where the span is longer or crosses an edge. +pub fn fold_area(rect: Rect, tile: Rect) -> Rect { + if rect.is_empty() || tile.is_empty() { + return Rect::EMPTY; + } + let span = |a0: i32, a1: i32, lo: i32, hi: i32| { + let start = wrap_coord(a0, lo, hi); + let len = i64::from(a1) - i64::from(a0); + if i64::from(start) + len > i64::from(hi) { (lo, hi) } else { (start, start + len as i32) } + }; + let (x0, x1) = span(rect.x0, rect.x1, tile.x0, tile.x1); + let (y0, y1) = span(rect.y0, rect.y1, tile.y0, tile.y1); + Rect::new(x0, y0, x1, y1) +} + +/// One axis of `a0..a1` cut where it crosses the edges of `lo..hi`: each run's start on the tile, +/// its start in `a0..a1`, and its length. +fn runs(a0: i32, a1: i32, lo: i32, hi: i32) -> Vec<(i32, i32, i32)> { + let period = i64::from(hi) - i64::from(lo); + let mut out = Vec::new(); + let mut a = i64::from(a0); + while a < i64::from(a1) { + let k = (a - i64::from(lo)).div_euclid(period) * period; + let end = (i64::from(a1) - k).min(i64::from(hi)); + let start = a - k; + out.push((start as i32, a as i32, (end - start) as i32)); + a = end + k; + } + out +} + +/// `r` cut where it crosses the edges of `tile`: each part moved onto the tile, with the offset +/// that moves it back (`part.translate(dx, dy)` is that part of `r`). A rectangle bigger than the +/// tile gives parts that overlap on it. +pub fn pieces(r: Rect, tile: Rect) -> Vec<(Rect, (i32, i32))> { + if r.is_empty() || tile.is_empty() { + return Vec::new(); + } + let xs = runs(r.x0, r.x1, tile.x0, tile.x1); + runs(r.y0, r.y1, tile.y0, tile.y1) + .into_iter() + .flat_map(|(ty, uy, h)| xs.iter().map(move |&(tx, ux, w)| (Rect::new(tx, ty, tx + w, ty + h), (ux - tx, uy - ty)))) + .collect() +} + +/// The values over `r` of an image repeating `tile` apart, given `read`, which returns a +/// rectangle of the tile (`n` values per pixel, row-major). A rectangle crossing at most one edge +/// per axis is read in its parts; a bigger one reads the tile area it covers once. +pub fn read(r: Rect, tile: Rect, n: usize, mut read: impl FnMut(Rect) -> Vec) -> Vec { + let w = r.width() as usize; + let mut out = vec![T::default(); w * r.height() as usize * n]; + if r.is_empty() || tile.is_empty() { + return out; + } + let (xs, ys) = (runs(r.x0, r.x1, tile.x0, tile.x1), runs(r.y0, r.y1, tile.y0, tile.y1)); + let mut copy = |src: &[T], at: usize, x: i32, y: i32, len: i32| { + let o = ((y - r.y0) as usize * w + (x - r.x0) as usize) * n; + let len = len as usize * n; + if let (Some(d), Some(s)) = (out.get_mut(o..o + len), src.get(at..at + len)) { + d.copy_from_slice(s); + } + }; + if xs.len() * ys.len() <= 4 { + for &(ty, uy, h) in &ys { + for &(tx, ux, len) in &xs { + let part = read(Rect::new(tx, ty, tx + len, ty + h)); + for row in 0..h { + copy(&part, row as usize * len as usize * n, ux, uy + row, len); + } + } + } + return out; + } + let area = fold_area(r, tile); + let src = read(area); + let aw = area.width() as usize; + for &(ty, uy, h) in &ys { + for row in 0..h { + for &(tx, ux, len) in &xs { + copy(&src, ((ty + row - area.y0) as usize * aw + (tx - area.x0) as usize) * n, ux, uy + row, len); + } + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn coordinates_wrap_onto_the_tile_from_any_distance() { + assert_eq!([-17, -1, 0, 15, 16, 33, 1_000_000].map(|v| wrap_coord(v, 0, 16)), [15, 15, 0, 15, 0, 1, 0]); + assert_eq!(wrap_coord(-3, 10, 20), 17); + } + + #[test] + fn pieces_cover_the_rectangle_once_and_land_on_the_tile() { + let tile = Rect::new(0, 0, 10, 8); + for r in [Rect::new(7, 6, 13, 11), Rect::new(-25, 3, 4, 5), Rect::new(2, 2, 5, 5), Rect::new(-3, -3, 30, 20)] { + let mut seen = std::collections::HashMap::new(); + for (part, (dx, dy)) in pieces(r, tile) { + assert!(tile.contains(part.x0, part.y0) && part.x1 <= tile.x1 && part.y1 <= tile.y1, "{part:?} on the tile"); + for y in part.y0..part.y1 { + for x in part.x0..part.x1 { + *seen.entry((x + dx, y + dy)).or_insert(0) += 1; + assert_eq!((wrap_coord(x + dx, 0, 10), wrap_coord(y + dy, 0, 8)), (x, y)); + } + } + } + assert_eq!(seen.len(), (r.width() * r.height()) as usize, "{r:?}"); + assert!(seen.values().all(|&c| c == 1) && seen.keys().all(|&(x, y)| r.contains(x, y)), "{r:?}"); + } + } + + #[test] + fn a_read_repeats_the_tile_whatever_the_rectangle() { + let tile = Rect::new(4, 2, 14, 10); + let value = |x: i32, y: i32| (wrap_coord(x, 4, 14) * 100 + wrap_coord(y, 2, 10)) as f32; + let mut reads = 0; + // Two values per pixel; `read` only ever asks for rectangles of the tile. + let mut from_tile = |q: Rect| { + reads += 1; + assert!(q.x0 >= tile.x0 && q.y0 >= tile.y0 && q.x1 <= tile.x1 && q.y1 <= tile.y1, "{q:?}"); + (q.y0..q.y1).flat_map(|y| (q.x0..q.x1).flat_map(move |x| [value(x, y), -value(x, y)])).collect::>() + }; + for r in [Rect::new(10, 7, 17, 12), Rect::new(5, 3, 9, 6), Rect::new(-31, -5, 40, 30), Rect::new(0, 0, 0, 5)] { + let got = read(r, tile, 2, &mut from_tile); + let want: Vec = (r.y0..r.y1).flat_map(|y| (r.x0..r.x1).flat_map(move |x| [value(x, y), -value(x, y)])).collect(); + assert_eq!(got, want, "{r:?}"); + } + assert!(reads <= 4 + 1 + 1, "small rectangles read in parts, big ones once ({reads})"); + } + + #[test] + fn the_fold_area_is_where_a_rectangle_lands() { + let tile = Rect::new(0, 0, 10, 8); + assert_eq!(fold_area(Rect::new(12, 9, 15, 12), tile), Rect::new(2, 1, 5, 4)); + assert_eq!(fold_area(Rect::new(8, 2, 12, 4), tile), Rect::new(0, 2, 10, 4)); + assert_eq!(fold_area(Rect::new(-50, -50, 50, 50), tile), tile); + assert_eq!(fold_area(Rect::EMPTY, tile), Rect::EMPTY); + assert!(fits(Rect::new(3, 3, 13, 11), tile) && !fits(Rect::new(0, 0, 11, 2), tile)); + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ab3e6939a7..83614c9244 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -700,6 +700,13 @@ fn stroke_params(app: &PhotocraftApp, d: &Drag, points: &[Vec]) -> serde_js p } +/// View › Pattern Preview: a painting stroke goes on across the canvas edges (#8883). +pub(crate) fn pattern_wrap(app: &PhotocraftApp, p: &mut serde_json::Value) { + if app.ui.view.pattern_preview { + p["wrap"] = json!(true); + } +} + /// Tools whose strokes the engine renders while they are drawn (`LiveStroke`). pub(crate) fn strokes_live(tool: Tool) -> bool { matches!( @@ -881,6 +888,7 @@ fn begin_live_stroke(app: &PhotocraftApp) -> Option { } else if let Some(cmd) = live_retouch_command(d.tool) { // The params `retouch_ui::finish_stroke` commits. let mut p = if d.tool == Tool::HistoryBrush { json!({}) } else { crate::retouch_ui::clone_params(app)? }; + pattern_wrap(app, &mut p); p["points"] = json!(d.points); p["target"] = paint_target(app); EngineStroke::Retouch(photocraft_engine::retouch_cmds::LiveRetouch::begin(&app.session, cmd, &p).ok()?) diff --git a/crates/ui-egui/src/eraser_ui.rs b/crates/ui-egui/src/eraser_ui.rs index f92c3427e9..c13dca17f6 100644 --- a/crates/ui-egui/src/eraser_ui.rs +++ b/crates/ui-egui/src/eraser_ui.rs @@ -49,14 +49,16 @@ pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]]) - /// the commit, with the jitter seed from the first point (`retouch_ui::stroke_seed`). pub(crate) fn background_eraser_params(app: &PhotocraftApp, points: &[[f64; 3]]) -> Value { let o = &app.ui.tool_options; - json!({ + let mut p = json!({ "points": points, "sampling": o.bg_sampling, "limits": o.bg_limits, "tolerance": o.bg_tolerance, "protectForegroundColor": o.bg_protect_fg, "seed": crate::retouch_ui::stroke_seed(points), - }) + }); + crate::canvas::pattern_wrap(app, &mut p); + p } /// Whether `tool` is the Eraser in Pencil mode: it erases hard, aliased pixels through diff --git a/crates/ui-egui/src/retouch_ui.rs b/crates/ui-egui/src/retouch_ui.rs index 5ed59869de..e62371bef5 100644 --- a/crates/ui-egui/src/retouch_ui.rs +++ b/crates/ui-egui/src/retouch_ui.rs @@ -32,6 +32,8 @@ pub(crate) fn clone_params(app: &PhotocraftApp) -> Option { p["wrap"] = json!(app.ui.view.pattern_preview); p["opacity"] = json!(app.session.tools.brush.opacity * 100.0); p["flow"] = json!(app.session.tools.brush.flow * 100.0); + } else { + crate::canvas::pattern_wrap(app, &mut p); } // The Clone Source panel's active slot (set by ⌥-click) drives the stroke: the engine keeps // the aligned pairing and applies the slot's scale/rotation/flip. @@ -49,7 +51,7 @@ pub(crate) fn clone_params(app: &PhotocraftApp) -> Option { /// Blur, Sharpen, Smudge, Dodge, Burn, Sponge. Shared by the live preview and the commit. pub(crate) fn dab_params(app: &PhotocraftApp, tool: Tool) -> Option<(&'static str, Value)> { let o = &app.ui.tool_options; - Some(match tool { + let (cmd, mut p) = match tool { Tool::Blur => ("paint.blur", json!({"strength": o.strength, "sampleAllLayers": o.sample_all.blur})), Tool::Sharpen => ("paint.sharpen", json!({"strength": o.strength, "protectDetail": o.protect_detail, "sampleAllLayers": o.sample_all.sharpen})), Tool::Smudge => ("paint.smudge", json!({"strength": o.strength, "fingerPainting": o.finger_painting, "sampleAllLayers": o.sample_all.smudge})), @@ -59,7 +61,9 @@ pub(crate) fn dab_params(app: &PhotocraftApp, tool: Tool) -> Option<(&'static st ), Tool::Sponge => ("paint.sponge", json!({"mode": o.sponge_mode, "vibrance": o.vibrance})), _ => return None, - }) + }; + crate::canvas::pattern_wrap(app, &mut p); + Some((cmd, p)) } /// `paint.colorReplacement` params from the options bar (without `points`): shared by the live @@ -67,7 +71,7 @@ pub(crate) fn dab_params(app: &PhotocraftApp, tool: Tool) -> Option<(&'static st /// stroke paints the same colour; Background Swatch sampling matches the background colour. pub(crate) fn color_replacement_params(app: &PhotocraftApp) -> Value { let o = &app.ui.tool_options; - json!({ + let mut p = json!({ "color": app.session.tools.foreground, "mode": o.cr_mode, "sampling": o.cr_sampling, @@ -76,7 +80,9 @@ pub(crate) fn color_replacement_params(app: &PhotocraftApp) -> Value { "antiAlias": o.cr_anti_alias, // A mask or channel target is refused: the tool recolours layer pixels only. "target": crate::canvas::paint_target(app), - }) + }); + crate::canvas::pattern_wrap(app, &mut p); + p } /// `paint.patternStamp` params from the options bar and the Patterns panel (without `points` and @@ -99,6 +105,7 @@ pub(crate) fn pattern_stamp_params(app: &PhotocraftApp, points: &[[f64; 3]]) -> if let Some(id) = photocraft_engine::presets::patterns::current(&app.session).or(app.session.patterns.items.first()).map(|x| x.id.clone()) { p["pattern"] = json!(id); } + crate::canvas::pattern_wrap(app, &mut p); p } @@ -158,6 +165,10 @@ pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]], m _ => return false, }; p["points"] = pts; + // Spot Healing, Mixer Brush and History Brush; the others' params have it already. + if tool != Tool::Remove { + crate::canvas::pattern_wrap(app, &mut p); + } // The layer mask, an alpha channel or the Quick Mask when targeted, as the Brush paints. p["target"] = crate::canvas::paint_target(app); let result = app.run(cmd, p); @@ -730,6 +741,43 @@ mod tests { } } + #[test] + fn every_retouching_stroke_wraps_in_pattern_preview() { + // View › Pattern Preview (#8883): a stroke across the right edge asks its command to go + // on at the opposite one, and only then. + let tools = [ + (Tool::Blur, "paint.blur"), + (Tool::Sharpen, "paint.sharpen"), + (Tool::Smudge, "paint.smudge"), + (Tool::Dodge, "paint.dodge"), + (Tool::Burn, "paint.burn"), + (Tool::Sponge, "paint.sponge"), + (Tool::MixerBrush, "paint.mixerBrush"), + (Tool::ColorReplacement, "paint.colorReplacement"), + (Tool::PatternStamp, "paint.patternStamp"), + (Tool::Healing, "paint.healingBrush"), + (Tool::HistoryBrush, "paint.historyBrush"), + (Tool::SpotHealing, "paint.spotHealing"), + (Tool::BackgroundEraser, "paint.backgroundEraser"), + ]; + for (tool, cmd) in tools { + for pattern_preview in [false, true] { + let mut app = app(); + stripes(&mut app, 8, "pixels"); + app.ui.view.pattern_preview = pattern_preview; + app.ui.clone_source = Some([20.0, 30.0]); + app.ui.tool = tool; + let m = egui::Modifiers::NONE; + tool_event(&mut app, ToolEvent::Down { x: 86.0, y: 30.0, pressure: 1.0 }, m); + tool_event(&mut app, ToolEvent::Move { x: 112.0, y: 30.0, pressure: 1.0 }, m); + tool_event(&mut app, ToolEvent::Up { x: 112.0, y: 30.0 }, m); + assert!(!app.ui.status_error, "{tool:?}: {}", app.ui.status); + let (_, p) = app.session.journal.iter().rev().find(|(id, _)| id == cmd).cloned().unwrap_or_else(|| panic!("{tool:?}: no {cmd}")); + assert_eq!(p.get("wrap").and_then(Value::as_bool).unwrap_or(false), pattern_preview, "{tool:?}: {p}"); + } + } + } + #[test] fn clone_stamp_in_pattern_preview_wraps_a_dab_across_the_right_edge() { for pattern_preview in [false, true] { diff --git a/crates/ui-egui/src/stroke_timing_tests.rs b/crates/ui-egui/src/stroke_timing_tests.rs index ac88a6c899..cd50206d7d 100644 --- a/crates/ui-egui/src/stroke_timing_tests.rs +++ b/crates/ui-egui/src/stroke_timing_tests.rs @@ -500,6 +500,60 @@ fn releasing_a_sequential_dab_stroke_commits_what_a_replay_paints() { } } +/// View › Pattern Preview (#8883): a Blur, Smudge or Dodge stroke dragged past the right edge +/// carries on at the left one, and the release still commits the pixels its live stroke drew. +#[test] +fn a_sequential_dab_stroke_past_the_edge_in_pattern_preview_commits_its_live_pixels() { + for (tool, cmd) in [(Tool::Blur, "paint.blur"), (Tool::Smudge, "paint.smudge"), (Tool::Dodge, "paint.dodge")] { + let setup = |run: &mut dyn FnMut(&str, Value)| { + run("tools.setBrush", json!({"brush": {"size": 24, "hardness": 0.2}})); + run("paint.stroke", json!({"points": [[0, 50], [200, 50]], "size": 30, "hardness": 0.5, "color": "#c04020"})); + run("paint.stroke", json!({"points": [[0, 35], [200, 65]], "size": 8, "hardness": 1.0, "color": "#2060c0"})); + }; + let mut h = harness(); + setup(&mut |id, p| { + h.state_mut().run(id, p).unwrap(); + }); + let before = h.state().session.active().unwrap().doc.clone(); + { + let app = h.state_mut(); + app.ui.tool = tool; + app.ui.view.pattern_preview = true; + app.ui.views[0].zoom = 2.0; + app.ui.views[0].center = [100.0, 50.0]; + } + h.run_steps(1); + move_to(&mut h, 150.0, 50.0); + button(&mut h, 150.0, 50.0, true); + for (x, y) in [(175.0, 52.0), (200.0, 50.0), (225.0, 47.0), (245.0, 50.0)] { + move_to(&mut h, x, y); + } + assert!(h.state().live_stroke.is_some(), "{cmd}: a live stroke"); + h.state_mut().retouch_from_live = false; + button(&mut h, 245.0, 50.0, false); + h.run_steps(2); + let app = h.state(); + assert!(app.retouch_from_live, "{cmd}: the release took the live pixels instead of rendering the stroke again"); + let (_, p) = app.session.journal.iter().rev().find(|(id, _)| id == cmd).cloned().expect("a committed stroke"); + assert_eq!(p["wrap"], json!(true), "{cmd}: {p}"); + let mut fresh = photocraft_engine::Session::new(); + fresh.execute("file.new", json!({"width": 200, "height": 100, "background": "white"})).unwrap(); + setup(&mut |id, p| { + fresh.execute(id, p).unwrap(); + }); + fresh.execute(cmd, p).unwrap(); + let st = app.session.active().unwrap(); + let (a, b, pre) = ( + photocraft_compose::render(&st.doc, st.doc.bounds()), + photocraft_compose::render(&fresh.active().unwrap().doc, st.doc.bounds()), + photocraft_compose::render(&before, st.doc.bounds()), + ); + assert!(a.px.iter().zip(&b.px).all(|(x, y)| x == y), "{cmd}: live commit and replay differ"); + let left = |buf: &photocraft_compose::Buffer| (30..70).flat_map(|y| (0..30).map(move |x| (x, y))).map(|(x, y)| buf.px[y * 200 + x]).collect::>(); + assert_ne!(left(&a), left(&pre), "{cmd}: the stroke came back at the left edge"); + } +} + /// Pressure changes Smudge's painted footprint, but not the square of colour it carries. /// Exercise the actual pen → live preview → release handoff → journal replay, then undo/redo. #[test] From 6cbcd89f0c63d4afd46554d42647df322a9c670e Mon Sep 17 00:00:00 2001 From: Leel Wasanatha <260480244+kavishkashehan726-sudo@users.noreply.github.com> Date: Sun, 25 Oct 2026 09:50:47 +0000 Subject: [PATCH 020/219] Pen: carry on a shape's or vector mask's open path, and show the continue pointer (#9356) A Pen press on an endpoint of an open path carried it on only for the Work Path or a saved path (#6593). In Shape mode the press started a new shape layer, and on a targeted vector mask a new subpath, so a pen shape could not be continued once drawing stopped. Now the selected shape layer's path (Shape mode) and the targeted vector mask resume like the Work Path, and the commit updates the same layer in one History step (shape.edit / layer.vectorMask.edit). Nothing showed that the endpoints can be clicked, either: the Pen now shows Photoshop's continue-path pointer (the cross with a slash) over an endpoint it would carry on from. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016NgLQVizswqgNnzBv1taRD --- crates/ui-egui/src/canvas.rs | 9 +++ crates/ui-egui/src/pen_variants_tests.rs | 62 ++++++++++++++++++++ crates/ui-egui/src/tool_cursor.rs | 10 ++++ crates/ui-egui/src/tool_cursor/tests.rs | 13 +++++ crates/ui-egui/src/vector_ui.rs | 72 ++++++++++++++++++------ 5 files changed, 150 insertions(+), 16 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ab3e6939a7..b0fd9e5c48 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -4835,6 +4835,15 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: { crate::tool_cursor::close_path(&painter, p) } + // Over an endpoint of an open path, the continue-path pointer (#9356). + Tool::Pen + if { + let [x, y] = xf.to_doc(p); + crate::vector_ui::continues_path_at(app, tool, x, y) + } => + { + crate::tool_cursor::continue_path(&painter, p) + } // The pen tools: their own icon with its nib on the point, or the crosshair for // precise Other Cursors (#4639). Tool::Pen | Tool::FreeformPen | Tool::CurvaturePen | Tool::AddAnchorPoint | Tool::DeleteAnchorPoint | Tool::ConvertPoint => { diff --git a/crates/ui-egui/src/pen_variants_tests.rs b/crates/ui-egui/src/pen_variants_tests.rs index cee8e37bbd..54037c40fa 100644 --- a/crates/ui-egui/src/pen_variants_tests.rs +++ b/crates/ui-egui/src/pen_variants_tests.rs @@ -273,3 +273,65 @@ fn undo_in_a_resumed_draft_stops_at_the_paths_own_anchors() { assert!(app.ui.direct_selection.anchors.is_empty()); assert_eq!(work_path(&app), before, "the path is as it was"); } + +/// #9356: in Shape mode a Pen press on an endpoint of the selected shape layer's open path carries +/// that path on, from either end, and the commit updates the same layer in one History step +/// instead of making a new shape layer. Elsewhere the press starts a new shape as before. +#[test] +fn shape_mode_carries_on_the_selected_shapes_open_path() { + let mut app = app(Tool::Pen); + app.ui.tool_options.vector_mode = "shape".into(); + let layers = |app: &PhotocraftApp| app.session.active().unwrap().doc.layers.len(); + let shape = |app: &PhotocraftApp| { + let st = app.session.active().unwrap(); + match &st.doc.layer(st.active_layer.unwrap()).unwrap().content { + LayerContent::Shape(sh) => { + sh.path.subpaths.iter().map(|s| s.knots.iter().map(|k| [k.anchor.x, k.anchor.y]).collect::>()).collect::>() + } + other => panic!("not a shape layer: {other:?}"), + } + }; + let steps = |app: &PhotocraftApp| app.session.active().unwrap().history.past_len(); + click(&mut app, 20.0, 20.0); + click(&mut app, 80.0, 20.0); + crate::vector_ui::pen_commit(&mut app, false); + let (n, before) = (layers(&app), steps(&app)); + assert!(crate::vector_ui::continues_path_at(&app, Tool::Pen, 81.0, 21.0), "the continue pointer over the endpoint"); + assert!(!crate::vector_ui::continues_path_at(&app, Tool::Pen, 50.0, 20.0), "not mid-segment"); + click(&mut app, 80.0, 20.0); + click(&mut app, 80.0, 80.0); + crate::vector_ui::pen_commit(&mut app, false); + assert_eq!(layers(&app), n, "no new shape layer"); + assert_eq!(steps(&app), before + 1, "one History step"); + assert_eq!(shape(&app), [vec![[20.0, 20.0], [80.0, 20.0], [80.0, 80.0]]]); + // From the first anchor, reversed. + click(&mut app, 20.0, 20.0); + click(&mut app, 20.0, 80.0); + crate::vector_ui::pen_commit(&mut app, false); + assert_eq!(shape(&app), [vec![[80.0, 80.0], [80.0, 20.0], [20.0, 20.0], [20.0, 80.0]]]); + // Anywhere else: a new shape layer. + click(&mut app, 150.0, 150.0); + click(&mut app, 200.0, 150.0); + crate::vector_ui::pen_commit(&mut app, false); + assert_eq!(layers(&app), n + 1); +} + +/// #9356: with a vector mask targeted, the Pen carries on the mask's open subpath in place. +#[test] +fn a_targeted_vector_mask_carries_on_its_open_path() { + let mut app = app(Tool::Pen); + let id = app.session.active().unwrap().active_layer.unwrap(); + let open = json!({"subpaths": [{"closed": false, "knots": [{"anchor": [20.0, 20.0]}, {"anchor": [80.0, 20.0]}]}]}); + app.run("layer.vectorMask.add", json!({"layer": id.0, "path": open})).unwrap(); + app.ui.vector_mask_target = true; + let mask = |app: &PhotocraftApp| { + let st = app.session.active().unwrap(); + let p = &st.doc.layer(id).unwrap().vector_mask.as_ref().unwrap().path; + p.subpaths.iter().map(|s| s.knots.len()).collect::>() + }; + assert_eq!(mask(&app), [2]); + click(&mut app, 80.0, 20.0); + click(&mut app, 80.0, 80.0); + crate::vector_ui::pen_commit(&mut app, false); + assert_eq!(mask(&app), [3], "the same subpath, extended"); +} diff --git a/crates/ui-egui/src/tool_cursor.rs b/crates/ui-egui/src/tool_cursor.rs index 5062c839da..28e4d45f95 100644 --- a/crates/ui-egui/src/tool_cursor.rs +++ b/crates/ui-egui/src/tool_cursor.rs @@ -88,6 +88,16 @@ pub(crate) fn close_path(painter: &Painter, at: Pos2) -> CursorIcon { outline(painter, at, vec![close_ring()], true) } +/// The Pen's pointer over an endpoint a click would carry the path on from (#9356): the centre +/// cross with a short slash below right, as Photoshop's pen shows one there. +pub(crate) fn continue_path(painter: &Painter, at: Pos2) -> CursorIcon { + outline(painter, at, vec![continue_slash()], true) +} + +fn continue_slash() -> Vec { + [(6.5, 12.5), (11.5, 5.5), (12.5, 6.5), (7.5, 13.5)].into_iter().map(|(x, y)| vec2(x, y)).collect() +} + fn close_ring() -> Vec { (0..16).map(|i| (i as f32 / 16.0 * std::f32::consts::TAU, vec2(9.0, 9.0))).map(|(a, c)| c + vec2(a.cos(), a.sin()) * 3.5).collect() } diff --git a/crates/ui-egui/src/tool_cursor/tests.rs b/crates/ui-egui/src/tool_cursor/tests.rs index a57e7f0ae2..fa241f6e5c 100644 --- a/crates/ui-egui/src/tool_cursor/tests.rs +++ b/crates/ui-egui/src/tool_cursor/tests.rs @@ -599,3 +599,16 @@ fn type_path_cursors_are_small_and_centred() { } } } + +/// #9356: the continue-path pointer is the centre cross with a slash below right. +#[test] +fn continue_path_bitmap_has_the_centre_cross_and_a_slash_beside_it() { + for scale in [1.0, 2.0] { + let image = rasterize(&Shape::Outline { rings: vec![continue_slash()].into(), centre: true }, scale).unwrap(); + let [x, y] = image.hotspot; + let at = |dx: f32, dy: f32| alpha(&image, x + (dx * scale) as u16, y + (dy * scale) as u16); + assert!(at(0.0, 0.0) > 230, "the centre cross"); + assert!(at(9.5, 9.5) > 0, "the slash"); + assert_eq!(at(0.0, 9.0), 0, "apart from the cross"); + } +} diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index 81426ccb5e..ce63a26dca 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -52,6 +52,10 @@ pub struct Resumed { pub path: String, pub index: usize, pub was: photocraft_doc::vector::Subpath, + /// The layer whose shape path (Shape mode) or targeted vector mask the subpath is in (#9356); + /// `None` for the Paths panel's path `path`. + #[serde(default)] + pub layer: Option, } impl PenPath { @@ -237,6 +241,12 @@ pub fn closes_path_at(app: &PhotocraftApp, tool: Tool, x: f64, y: f64) -> bool { } } +/// Would a Pen click at `(x, y)` carry on an open path from its endpoint there (#6593)? The canvas +/// shows Photoshop's continue-path pointer there (#9356), as the endpoints look like any anchor. +pub fn continues_path_at(app: &PhotocraftApp, tool: Tool, x: f64, y: f64) -> bool { + tool == Tool::Pen && app.ui.pen.as_ref().is_none_or(|p| p.knots.is_empty()) && resume_at(app, x, y, 6.0 / app.point_zoom().max(0.01) as f64).is_some() +} + /// Pen press: close on the first anchor, reshape the last anchor's outgoing handle, /// or add a new anchor (dragging a new anchor pulls symmetrical handles). With ⇧ a new anchor /// lands on the nearest 45° ray from the previous one, at the pointer's projection onto it. @@ -546,7 +556,12 @@ fn commit_path(app: &mut PhotocraftApp, pen: &PenPath, closed: bool) { return; } let path = pen_to_json(pen, closed); - let r = if app.ui.tool_options.vector_mode == "shape" { + let resumed = pen.resumes.as_ref().and_then(|r| app.session.active().and_then(|st| with_resumed_subpath(&st.doc, r, &path)).map(|p| (r, p))); + let r = if let Some((Resumed { layer: Some(id), .. }, p)) = &resumed { + // A shape layer's path or a vector mask carried on (#9356): the same layer, one step. + let shape = app.session.active().and_then(|st| st.doc.layer(photocraft_doc::LayerId(*id))).is_some_and(|l| matches!(l.content, LayerContent::Shape(_))); + app.run(if shape { "shape.edit" } else { "layer.vectorMask.edit" }, json!({"layer": id, "path": p})) + } else if app.ui.tool_options.vector_mode == "shape" { let fill = if closed { fill_param(app) } else { Value::Null }; let stroke = if closed { stroke_param(app) @@ -557,10 +572,9 @@ fn commit_path(app: &mut PhotocraftApp, pen: &PenPath, closed: bool) { stroke }; app.run("shape.create", json!({"kind": "path", "path": path, "fill": fill, "stroke": stroke})) - } else if let Some((name, p)) = - pen.resumes.as_ref().and_then(|r| app.session.active().and_then(|st| with_resumed_subpath(&st.doc, r, &path)).map(|p| (r.path.clone(), p))) - { + } else if let Some((r, p)) = resumed { // The resumed subpath, carried on, takes its old place: one step, no second subpath. + let name = r.path.clone(); app.run("path.set", json!({"name": name, "path": p})).inspect(|_| app.ui.selected_path = Some(name)) } else if let Some((id, existing)) = targeted_vector_mask(app) { // A targeted vector mask takes the new subpath (#196), as in Photoshop. @@ -576,7 +590,8 @@ fn commit_path(app: &mut PhotocraftApp, pen: &PenPath, closed: bool) { // A resumed draft whose subpath changed meanwhile joins the path it came from, as a new // subpath, even with nothing selected in the Paths panel. let exists = |n: &String| app.session.active().is_some_and(|st| path_exists(&st.doc, n)); - let resumed_from = pen.resumes.as_ref().filter(|r| app.session.active().is_some_and(|st| st.doc.id.0 == r.doc)).map(|r| r.path.clone()); + let resumed_from = + pen.resumes.as_ref().filter(|r| r.layer.is_none() && app.session.active().is_some_and(|st| st.doc.id.0 == r.doc)).map(|r| r.path.clone()); let target = resumed_from.filter(exists).or_else(|| app.ui.selected_path.clone().filter(exists)); let name = target.clone().unwrap_or_else(|| "work".into()); let mut params = json!({"name": name, "path": path}); @@ -601,19 +616,44 @@ fn doc_path<'a>(doc: &'a Document, name: &str) -> Option<&'a Path> { if name == "work" { doc.work_path.as_ref() } else { doc.paths.iter().find(|p| p.name == name).map(|p| &p.path) } } -/// The open subpath of the path a new Pen path would join (the one selected in the Paths panel, -/// else the work path) that has its first or last anchor at `(x, y)`, as a Pen path to carry on -/// drawing from there (#6593). Its knots run towards that anchor: reversed, handles swapped, when -/// it is the first. Shape mode and a targeted vector mask take new paths, so they resume nothing. -fn resume_at(app: &PhotocraftApp, x: f64, y: f64, tol: f64) -> Option { - if app.ui.tool_options.vector_mode == "shape" || targeted_vector_mask(app).is_some() { - return None; +/// The path of `layer` that the Pen draws into: a shape layer's own path, else its vector mask. +fn layer_path(doc: &Document, layer: u64) -> Option<&Path> { + let l = doc.layer(photocraft_doc::LayerId(layer))?; + match &l.content { + LayerContent::Shape(sh) => Some(&sh.path), + _ => l.vector_mask.as_ref().map(|m| &m.path), } +} + +/// The path a resumed subpath is in, if it is still in `r`'s document. +fn resumed_path<'a>(doc: &'a Document, r: &Resumed) -> Option<&'a Path> { + (doc.id.0 == r.doc).then_some(())?; + match r.layer { + Some(id) => layer_path(doc, id), + None => doc_path(doc, &r.path), + } +} + +/// The open subpath of the path a new Pen path would go into that has its first or last anchor at +/// `(x, y)`, as a Pen path to carry on drawing from there (#6593). That path is the selected shape +/// layer's in Shape mode, the targeted vector mask's (#9356), else the one selected in the Paths +/// panel or the work path. Its knots run towards that anchor: reversed, handles swapped, when it is +/// the first. +fn resume_at(app: &PhotocraftApp, x: f64, y: f64, tol: f64) -> Option { let st = app.session.active()?; let doc = st.doc.id.0; - let name = app.ui.selected_path.clone().filter(|n| path_exists(&st.doc, n)).unwrap_or_else(|| "work".into()); + let (name, layer, path) = if app.ui.tool_options.vector_mode == "shape" { + let (id, _) = active_shape_path(app)?; + ("layer".to_string(), Some(id), layer_path(&st.doc, id)?) + } else if let Some((id, _)) = targeted_vector_mask(app) { + ("layer".to_string(), Some(id), layer_path(&st.doc, id)?) + } else { + let name = app.ui.selected_path.clone().filter(|n| path_exists(&st.doc, n)).unwrap_or_else(|| "work".into()); + let path = doc_path(&st.doc, &name)?; + (name, None, path) + }; let near = |k: &photocraft_doc::Knot| (k.anchor.x - x).hypot(k.anchor.y - y) < tol; - doc_path(&st.doc, &name)?.subpaths.iter().enumerate().filter(|(_, s)| !s.closed).find_map(|(i, s)| { + path.subpaths.iter().enumerate().filter(|(_, s)| !s.closed).find_map(|(i, s)| { let reversed = if s.knots.last().is_some_and(near) { false } else if s.knots.first().is_some_and(near) { @@ -629,7 +669,7 @@ fn resume_at(app: &PhotocraftApp, x: f64, y: f64, tol: f64) -> Option { } } let unlinked = knots.iter().enumerate().filter(|(_, (_, smooth))| !smooth).map(|(i, _)| i).collect(); - let resumes = Resumed { doc, path: name.clone(), index: i, was: s.clone() }; + let resumes = Resumed { doc, path: name.clone(), index: i, was: s.clone(), layer }; Some(PenPath { knots: knots.into_iter().map(|(k, _)| k).collect(), unlinked, resumes: Some(resumes), ..Default::default() }) }) } @@ -638,7 +678,7 @@ fn resume_at(app: &PhotocraftApp, x: f64, y: f64, tol: f64) -> Option { /// anchors and closing, which keeps its other settings (its path operation). `None` when the /// subpath is no longer the one drawing resumed (another document, or the path changed). fn with_resumed_subpath(doc: &Document, r: &Resumed, drawn: &Value) -> Option { - let path = doc_path(doc, &r.path).filter(|_| doc.id.0 == r.doc)?; + let path = resumed_path(doc, r)?; if path.subpaths.get(r.index) != Some(&r.was) { return None; } From 26c218f722746e174b8fabe8e0eea4dfce6a64f0 Mon Sep 17 00:00:00 2001 From: VoiceLessQ Date: Sun, 25 Oct 2026 11:36:15 -0200 Subject: [PATCH 021/219] retouch: wrapped Dodge and Burn tone each pixel once; Spot Healing skips a window with nothing to heal from Dodge and Burn remember each pixel's coverage so one stroke tones it once. With wrap on, that memory was keyed by the unwrapped position. A stroke longer than the canvas came back to a pixel under a new key and toned it again from its toned value: mid grey went to 0.816 instead of 0.651 under Dodge at 60%. Coverage is now kept per canvas pixel. A dab bigger than the canvas is folded first, and each pixel takes the most coverage any of its copies gets (the copy WorkTiles keeps). Spot Healing returns early when the stroke covers its whole sampling window, as a wrapped dab bigger than the canvas does. The fill had nothing to sample from. It left the pixels alone but reported the whole canvas as damage. Wrap off, Dodge in bench_live_dab_release (6000x4000, 8-bit, medians of 4 interleaved runs) stays within 0.8% of before at every brush size. --- crates/engine/src/retouch_cmds.rs | 75 +++++++++++++++++++------ crates/engine/src/retouch_cmds/tests.rs | 45 +++++++++++++++ 2 files changed, 102 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/retouch_cmds.rs b/crates/engine/src/retouch_cmds.rs index 4480f9b8b8..b2eb58059f 100644 --- a/crates/engine/src/retouch_cmds.rs +++ b/crates/engine/src/retouch_cmds.rs @@ -16,7 +16,7 @@ use photocraft_color::{BlendMode, PixelFormat, SampleType}; use photocraft_doc::{Document, LayerContent, LayerId}; use photocraft_geom::Rect; use photocraft_paint::retouch::{Footprint, Region, Smudge, WorkTiles, alpha_index, apply_coverage, apply_dab_stroke_wrapped}; -use photocraft_paint::wrap::wrap_coord; +use photocraft_paint::wrap::{fits, fold_area, pieces, wrap_coord}; use photocraft_paint::{BrushSettings, Stroke, StrokePoint}; use photocraft_raster::{Surface, from_rgba_into, to_rgba}; use rayon::prelude::*; @@ -875,7 +875,8 @@ fn spot_heal_surface( } } let hole: Vec = stroke_cov.iter().map(|c| *c > 0.0).collect(); - if !hole.iter().any(|h| *h) { + // Nothing to heal, or (a wrapped dab covering its whole window) nothing to heal from. + if !hole.iter().any(|h| *h) || hole.iter().all(|h| *h) { return Ok(Rect::EMPTY); } // Synthesise over a slightly larger domain than the Poisson region, so the solve's boundary pixels @@ -971,8 +972,9 @@ fn color_dab(fmt: &PixelFormat, work: &mut Region, fp: &Footprint, strength: f32 /// stroke's coverage there (`toned` is the full-exposure colour). Dabs build up coverage as in a /// brush stroke, capped at full coverage, so Exposure caps a stroke the way Opacity caps a paint /// stroke: overlapping dabs and passes within one stroke don't compound the tone curve, and a -/// new stroke builds on the last. -fn tone_stroke(fmt: PixelFormat, toned: impl Fn([f32; 3]) -> [f32; 3] + 'static) -> DabEffect { +/// new stroke builds on the last. Wrapped around `wrap` (View › Pattern Preview), the coverage is +/// kept per canvas pixel, so a stroke coming back to a pixel one canvas away tones it once. +fn tone_stroke(fmt: PixelFormat, wrap: Option, toned: impl Fn([f32; 3]) -> [f32; 3] + 'static) -> DabEffect { /// Side of the cells that remember, per touched pixel, the coverage applied so far and the /// original pixel (copied from the working pixels the first time a dab reaches it). Dense /// cells rather than a map per pixel: a map lookup per covered pixel made long strokes with @@ -985,11 +987,8 @@ fn tone_stroke(fmt: PixelFormat, toned: impl Fn([f32; 3]) -> [f32; 3] + 'static) let mut cells: std::collections::HashMap<(i32, i32), Cell> = std::collections::HashMap::new(); let a = alpha_index(&fmt); let n = fmt.channels(); - Box::new(move |work, fp| { - let r = fp.rect.intersect(&work.rect); - if r.is_empty() { - return; - } + // Tone the pixels of `px` at `r` moved by `(dx, dy)`, remembered at `r`. + let mut tone = move |px: &mut Region, fp: &Footprint, r: Rect, (dx, dy): (i32, i32)| { let mut enc = [0.0f32; 8]; for cy in r.y0.div_euclid(CELL)..=(r.y1 - 1).div_euclid(CELL) { for cx in r.x0.div_euclid(CELL)..=(r.x1 - 1).div_euclid(CELL) { @@ -1003,7 +1002,7 @@ fn tone_stroke(fmt: PixelFormat, toned: impl Fn([f32; 3]) -> [f32; 3] + 'static) let cell = cells.entry((cx, cy)).or_insert_with(|| Cell { cov: vec![-1.0; len], orig: vec![0.0; len * n] }); for y in part.y0..part.y1 { for x in part.x0..part.x1 { - let k = fp.at(x, y).clamp(0.0, 1.0); + let k = fp.at(x + dx, y + dy).clamp(0.0, 1.0); if k <= 0.0 { continue; } @@ -1011,7 +1010,7 @@ fn tone_stroke(fmt: PixelFormat, toned: impl Fn([f32; 3]) -> [f32; 3] + 'static) let (Some(prev), Some(src)) = (cell.cov.get_mut(i), cell.orig.get_mut(i * n..i * n + n)) else { continue }; if *prev < 0.0 { // First touch: the working pixel is still the original. - src.copy_from_slice(work.px(x, y)); + src.copy_from_slice(px.px(x + dx, y + dy)); *prev = 0.0; } // Dabs build up the stroke's coverage as a brush stroke's do (flow), up @@ -1025,16 +1024,55 @@ fn tone_stroke(fmt: PixelFormat, toned: impl Fn([f32; 3]) -> [f32; 3] + 'static) let t = toned([o[0], o[1], o[2]]); let m = [o[0] + (t[0] - o[0]) * k, o[1] + (t[1] - o[1]) * k, o[2] + (t[2] - o[2]) * k]; from_rgba_into(&fmt, [m[0], m[1], m[2], o[3]], &mut enc); - let px = work.px_mut(x, y); + let out = px.px_mut(x + dx, y + dy); for c in 0..n { if Some(c) != a { - px[c] = enc[c]; + out[c] = enc[c]; } } } } } } + }; + Box::new(move |work, fp| { + let r = fp.rect.intersect(&work.rect); + if r.is_empty() { + return; + } + match wrap { + None => tone(work, fp, r, (0, 0)), + Some(tile) if fits(r, tile) => { + for (part, off) in pieces(r, tile) { + tone(work, fp, part, off); + } + } + // A dab bigger than the canvas covers its pixels more than once: tone each at the + // most coverage any copy gets (the copy `WorkTiles` keeps), and give every copy that. + Some(tile) => { + let area = fold_area(r, tile); + let mut on = Region::new(area, work.ch); + let mut most = Footprint { dab: fp.dab, index: fp.index, rect: area, cov: vec![0.0; area.width() as usize * area.height() as usize] }; + let at = |x, y| (wrap_coord(x, tile.x0, tile.x1), wrap_coord(y, tile.y0, tile.y1)); + for y in r.y0..r.y1 { + for x in r.x0..r.x1 { + let (tx, ty) = at(x, y); + let i = on.index(tx, ty); + on.px_mut(tx, ty).copy_from_slice(work.px(x, y)); + if let Some(m) = most.cov.get_mut(i) { + *m = m.max(fp.at(x, y)); + } + } + } + tone(&mut on, &most, area, (0, 0)); + for y in r.y0..r.y1 { + for x in r.x0..r.x1 { + let (tx, ty) = at(x, y); + work.px_mut(x, y).copy_from_slice(on.px(tx, ty)); + } + } + } + } }) } @@ -1140,11 +1178,12 @@ impl DabTool { } } - /// The per-dab effect on a working copy in `fmt`, for a brush of `spacing`. - fn effect(&self, fmt: PixelFormat, spacing: f32) -> DabEffect { + /// The per-dab effect on a working copy in `fmt`, for a brush of `spacing`, on a stroke + /// wrapped around `wrap`. + fn effect(&self, fmt: PixelFormat, spacing: f32, wrap: Option) -> DabEffect { let sp = spacing; match self.clone() { - Self::Tone { burn, range, exposure, protect } => tone_stroke(fmt, move |c| dodge_burn(c, exposure, range, burn, protect)), + Self::Tone { burn, range, exposure, protect } => tone_stroke(fmt, wrap, move |c| dodge_burn(c, exposure, range, burn, protect)), // Flow is already in each dab's coverage; a full-flow pass moves colours half-way. Self::Sponge { saturate, vibrance } => Box::new(move |work, fp| color_dab(&fmt, work, fp, 0.5, sp, |c, k| sponge(c, k, saturate, vibrance))), Self::Focus { sharpen, strength, protect, sigma } => { @@ -1206,8 +1245,8 @@ fn dab_cmd(s: &mut Session, p: &Value, cmd: &str) -> Result { return Ok(l.bounds); } let fmt = surf.format(); - let mut effect = tool.effect(fmt, stroke.brush.spacing); let (halo, wrap) = (tool.neighborhood().halo(), wrap_tile(pre, p)); + let mut effect = tool.effect(fmt, stroke.brush.spacing, wrap); let mut sampler = all.then(|| AllLayers::new(pre, fmt).wrapped(wrap, &stroke, halo)); Ok(apply_dab_stroke_wrapped(surf, &stroke, sel, lock, halo, wrap, |work, fp| match &mut sampler { Some(all) => all.dab(work, fp, |w, f| effect(w, f)), @@ -1274,7 +1313,7 @@ impl LiveDab { let (tool, _) = DabTool::parse(s, cmd, p, &stroke)?; let (neighborhood, spacing, brush) = (tool.neighborhood(), stroke.brush.spacing, Box::new(stroke.brush.clone())); let effect = tool.clone(); - let mut live = Self::with_neighborhood(s, cmd, p, stroke, id, neighborhood, |pre, _| effect.effect(pre.format(), spacing))?; + let mut live = Self::with_neighborhood(s, cmd, p, stroke, id, neighborhood, |pre, wrap| effect.effect(pre.format(), spacing, wrap))?; live.tool = Some((tool, brush)); Ok(live) } diff --git a/crates/engine/src/retouch_cmds/tests.rs b/crates/engine/src/retouch_cmds/tests.rs index e88129cb67..cbe535097b 100644 --- a/crates/engine/src/retouch_cmds/tests.rs +++ b/crates/engine/src/retouch_cmds/tests.rs @@ -2603,6 +2603,51 @@ fn a_wrapped_dab_bigger_than_the_canvas_stays_on_it() { } } +#[test] +fn spot_healing_with_nothing_left_to_sample_leaves_the_layer_alone() { + // Wrapped, the dab covers all of a canvas smaller than it, so its window has no pixel left + // to heal from. + for depth in DEPTHS { + for kind in ["contentAware", "createTexture", "proximityMatch"] { + let mut s = session(48, 36, depth, "rgb"); + textured(&mut s); + let before = layer_pixels(&s); + let r = s.execute("paint.spotHealing", json!({"points": [[48, 36]], "size": 64, "hardness": 100, "type": kind, "wrap": true})).unwrap(); + assert_eq!(layer_pixels(&s), before, "{kind} at {depth}-bit"); + assert_eq!(r["damage"][2].as_i64().unwrap() * r["damage"][3].as_i64().unwrap(), 0, "{kind} at {depth}-bit: no damage, got {}", r["damage"]); + } + } +} + +#[test] +fn a_wrapped_dodge_or_burn_stroke_tones_each_pixel_once() { + // A stroke round the canvas, or a dab bigger than it, comes back to pixels one canvas away; + // the stroke still tones each of them once, as a single dab does. + for depth in DEPTHS { + for cmd in ["paint.dodge", "paint.burn"] { + let run = |w: u32, h: u32, mut p: Value| { + let mut s = session(w, h, depth, "rgb"); + paint_layer(&mut s, |_, _| [0.5, 0.5, 0.5, 1.0]); + p["exposure"] = json!(60); + s.execute(cmd, p).unwrap(); + layer_pixels(&s) + }; + let once = run(WRAP.0, WRAP.1, json!({"points": [[24, 18]], "size": 10, "hardness": 100})); + let n = once.len() / (WRAP.0 * WRAP.1) as usize; + let toned = &once[(18 * WRAP.0 as usize + 24) * n..][..n]; + assert_ne!(toned, &once[..n], "{cmd} at {depth}-bit: the dab tones"); + let round = run(WRAP.0, WRAP.1, json!({"points": [[2, 18], [122, 18]], "size": 10, "hardness": 100, "wrap": true})); + for x in 0..WRAP.0 as usize { + assert_eq!(&round[(18 * WRAP.0 as usize + x) * n..][..n], toned, "{cmd} at {depth}-bit: ({x}, 18)"); + } + let big = run(16, 12, json!({"points": [[8, 6], [30, 20]], "size": 60, "hardness": 100, "wrap": true})); + for (i, px) in big.chunks(n).enumerate() { + assert_eq!(px, toned, "{cmd} at {depth}-bit: pixel {i} under the big dab"); + } + } + } +} + #[test] fn wrapped_live_strokes_show_what_their_commits_paint() { // Across the left and bottom edges, as the canvas draws them: Dodge … Smudge commit the From a9b878834d171b571d0d171cd111f1df0b6e2f49 Mon Sep 17 00:00:00 2001 From: ljkx <177671075+ljkx@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:50:32 +0100 Subject: [PATCH 022/219] Photo Filter opens at its last settings (#9879) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Image › Adjustments › Photo Filter… now opens at the filter, colour, density and Preserve Luminosity of its last OK, as Photoshop's does, without holding ⌥. The other adjustment dialogs keep opening at their neutral settings (⌥ still reopens them at the last ones, #8900). `adjust_dialog::open` picks the last settings for the kinds listed in `REMEMBERS_LAST`; the old neutral behaviour is `open_neutral`, which the smart filter editor now uses so a smart filter's dialog always shows that filter's own settings. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/adjust_dialog.rs | 15 ++++++-- .../src/dialogs/adjust_dialog/tests.rs | 36 +++++++++++++++++++ crates/ui-egui/src/panels/smart_ui.rs | 3 +- docs/ui-parity.md | 4 ++- 4 files changed, 54 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/dialogs/adjust_dialog.rs b/crates/ui-egui/src/dialogs/adjust_dialog.rs index e1d61ab659..1ac70db208 100644 --- a/crates/ui-egui/src/dialogs/adjust_dialog.rs +++ b/crates/ui-egui/src/dialogs/adjust_dialog.rs @@ -261,8 +261,19 @@ pub fn reset(app: &PhotocraftApp, ctx: &egui::Context, fields: &mut Map Option { + if command.strip_prefix(PREFIX).is_some_and(|kind| REMEMBERS_LAST.contains(&kind)) { open_last(app, command) } else { open_neutral(app, command) } +} + +/// Opens the dialog for `command` with the kind's neutral settings. +pub fn open_neutral(app: &mut PhotocraftApp, command: &str) -> Option { let kind = command.strip_prefix(PREFIX).filter(|k| adjust_editors::has_editor(k))?; let spec = photocraft_engine::commands::find(command)?; let mode = curve_mode(app, kind); @@ -283,7 +294,7 @@ pub fn open(app: &mut PhotocraftApp, command: &str) -> Option { /// adjustment's shortcut, ⌥⌘L / ⌥⌘M, #8900), or at the neutral ones when there are none or they /// don't fit the current colour mode. pub fn open_last(app: &mut PhotocraftApp, command: &str) -> Option { - let id = open(app, command)?; + let id = open_neutral(app, command)?; let kind = command.strip_prefix(PREFIX).unwrap_or_default(); let mode = curve_mode(app, kind); let Some(last) = app.session.prefs().dialogs.get(command).cloned() else { return Some(id) }; diff --git a/crates/ui-egui/src/dialogs/adjust_dialog/tests.rs b/crates/ui-egui/src/dialogs/adjust_dialog/tests.rs index d3bcf2b688..e2ba88ca61 100644 --- a/crates/ui-egui/src/dialogs/adjust_dialog/tests.rs +++ b/crates/ui-egui/src/dialogs/adjust_dialog/tests.rs @@ -849,3 +849,39 @@ fn curves_on_a_targeted_filter_mask_is_a_gray_plane() { assert!(h.ch[0][0] > 0 && h.ch[0][255] > 0, "black and white levels"); assert_eq!(curve_mode(&app, "curves"), photocraft_doc::ColorMode::Grayscale); } + +/// #9879: Photo Filter opens at the filter and density last confirmed in it, without ⌥; Levels +/// still opens at its neutral settings, and a smart filter's dialog never takes the last ones. +#[test] +fn photo_filter_opens_at_its_last_settings() { + let mut app = app_with_image(); + let cmd = "image.adjustments.photoFilter"; + let first = open_neutral(&mut app, cmd).unwrap(); + let neutral = crate::filter_dialog::params_of(&app.ui.dialog_mut(first).unwrap().fields.clone()); + app.ui.dialogs.clear(); + let id = open(&mut app, cmd).unwrap(); + let sample = photocraft_engine::adjust_params::to_params( + &photocraft_engine::adjust_params::from_params("photoFilter", &sample_params("photoFilter"), None, photocraft_doc::ColorMode::Rgb).unwrap(), + ); + assert_ne!(sample, neutral, "the sample differs from the neutral settings"); + if let Value::Object(p) = sample.clone() { + app.ui.dialog_mut(id).unwrap().fields.extend(p); + } + crate::dialogs::confirm(&mut app, id).unwrap(); + + let again = open(&mut app, cmd).unwrap(); + assert_eq!(crate::filter_dialog::params_of(&app.ui.dialog_mut(again).unwrap().fields.clone()), sample, "reopened at the last settings"); + app.ui.dialogs.clear(); + let smart = open_neutral(&mut app, cmd).unwrap(); + assert_eq!(crate::filter_dialog::params_of(&app.ui.dialog_mut(smart).unwrap().fields.clone()), neutral, "open_neutral ignores them"); + app.ui.dialogs.clear(); + + let levels = "image.adjustments.levels"; + let id = open(&mut app, levels).unwrap(); + app.ui.dialog_mut(id).unwrap().fields.insert("inBlack".into(), json!(30)); + crate::dialogs::confirm(&mut app, id).unwrap(); + let again = open(&mut app, levels).unwrap(); + let defaults = + photocraft_engine::adjust_params::to_params(&photocraft_engine::adjust_params::default_for("levels", photocraft_doc::ColorMode::Rgb).unwrap()); + assert_eq!(app.ui.dialog_mut(again).unwrap().fields["inBlack"], defaults["inBlack"], "Levels opens neutral without ⌥"); +} diff --git a/crates/ui-egui/src/panels/smart_ui.rs b/crates/ui-egui/src/panels/smart_ui.rs index 8e531b47b6..c1b592be49 100644 --- a/crates/ui-egui/src/panels/smart_ui.rs +++ b/crates/ui-egui/src/panels/smart_ui.rs @@ -483,7 +483,8 @@ pub fn open_editor(app: &mut PhotocraftApp, ctx: &egui::Context, layer: u64, sm: return; } }; - let Some(id) = crate::adjust_dialog::open(app, &f.command).or_else(|| { + // A smart filter's dialog shows the filter's own settings, never the last-used ones. + let Some(id) = crate::adjust_dialog::open_neutral(app, &f.command).or_else(|| { let id = crate::filter_dialog::has_dialog(&f.command).then(|| crate::filter_dialog::open(app, &f.command)).flatten()?; // The filter dialog opens on the user's last-used values; a recorded filter that lacks a // key must show (and save) the schema default instead. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 4490d693c2..660ad19448 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity: tools, handles, modifiers and feel -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (New Group dialog and button modifiers; new layers go into an open active group and join a clipping mask, except that type, shapes, pastes and placed files go above a group whose lock would refuse them; Color panel Hue and Brightness Cube show the out-of-gamut ⚠ and nearest printable colour when the field is at least 53 pt tall, #8672; 12 of 181 preferences do nothing, from the scorecard; Art History Brush in the History group of the Y flyout, partial, TOOL-213-20, #1610; Histogram panel: Compact / Expanded / All Channels views, Luminosity and Colors channels, Source, range statistics, Uncached Refresh, live adjustment overlay, Cache Levels preference; the font menu opens on the current family, marked in the recent group when it is a recent one, #9160; Type options bar alignment and direction follow the target paragraph; a swatch click recolours selected type and shape layers with any tool, #9238; UI scale presets done: 105–120% steps, #3222; ⇧⌥ blend mode letters; painting tool Mode menus; ⌥ + an adjustment's shortcut reopens it at its last settings, ⌥⌘L / ⌥⌘M, #8900; Crop default mode: moving the image under the box snaps the frame's edges and centre, #8580; View › Extras hides the Move transform controls, #6699; adjustment clipping stays visible in compact Properties, #5231; Color panel modes from its panel menu, the same in every theme, UI-217-17, #6000; a picked colour recolours the selected type and shapes; Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests; Properties › Transform shows a shape's path geometry, without its stroke) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (Image › Adjustments › Photo Filter… opens at the filter and density of its last OK, #9879; New Group dialog and button modifiers; new layers go into an open active group and join a clipping mask, except that type, shapes, pastes and placed files go above a group whose lock would refuse them; Color panel Hue and Brightness Cube show the out-of-gamut ⚠ and nearest printable colour when the field is at least 53 pt tall, #8672; 12 of 181 preferences do nothing, from the scorecard; Art History Brush in the History group of the Y flyout, partial, TOOL-213-20, #1610; Histogram panel: Compact / Expanded / All Channels views, Luminosity and Colors channels, Source, range statistics, Uncached Refresh, live adjustment overlay, Cache Levels preference; the font menu opens on the current family, marked in the recent group when it is a recent one, #9160; Type options bar alignment and direction follow the target paragraph; a swatch click recolours selected type and shape layers with any tool, #9238; UI scale presets done: 105–120% steps, #3222; ⇧⌥ blend mode letters; painting tool Mode menus; ⌥ + an adjustment's shortcut reopens it at its last settings, ⌥⌘L / ⌥⌘M, #8900; Crop default mode: moving the image under the box snaps the frame's edges and centre, #8580; View › Extras hides the Move transform controls, #6699; adjustment clipping stays visible in compact Properties, #5231; Color panel modes from its panel menu, the same in every theme, UI-217-17, #6000; a picked colour recolours the selected type and shapes; Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests; Properties › Transform shows a shape's path geometry, without its stroke) · **Target:** Adobe Photoshop 2026 How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation, @@ -208,6 +208,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Tool keys: ← / → turn the brush tip (1°, ⇧ 15°), ⇧-click with Type starts new text over old, Space+F / ⇧Space+F cycle the pasteboard colour | done (2026-10-11) | | | Type formatting keys while editing text | done: ⇧⌘K, U, B, I and / toggle All Caps, Underline, Faux Bold, Faux Italic, Strikethrough (off when every selected character has it, else on); ⌥↑/↓ leading ∓2 pt, ⇧⌥↑/↓ baseline shift ±2 pt (10 with ⌘); ⌥←/→ tracking ±20 over a selection (100 with ⌘; a collapsed caret still kerns); ⌥⇧⌘H toggles Hyphenate for the touched paragraphs. One `type.setStyle` step per press (new `leadingBy`/`baselineShiftBy`/`trackingBy`; auto leading starts from its laid-out value); the global ⇧⌘K/U/B/I never fire while typing. Differs: a collapsed caret in non-empty text changes nothing, since the model can't hold a pending style for the next characters typed (empty text takes it as its typing style) (`type_tool.rs`, `input_tests.rs` `type_tool_format_keys_*`) | | | ⌥ + an adjustment's shortcut opens it at its last settings (⌥⌘L Levels, ⌥⌘M Curves, and so ⌥⌘U, ⌥⌘B) | done (#8900): the settings of the last OK, kept in the preferences; Cancel and smart filter edits keep nothing; a key assigned to another command stays that command's. ⌥ while choosing the item from Image › Adjustments does the same | `shortcut_dispatch/tests.rs` `alt_adjustment_shortcut_reopens_the_last_confirmed_settings`, `alt_choosing_an_adjustment_from_the_menu_reopens_the_last_settings` | +| Photo Filter remembers its last settings: Image › Adjustments › Photo Filter… opens at the filter, colour, density and Preserve Luminosity of its last OK | done (#9879): the other adjustments still open at their neutral settings and take the last ones with ⌥; a smart filter's dialog shows that filter's own settings | `adjust_dialog/tests.rs` `photo_filter_opens_at_its_last_settings` | | Guides: click selects, Delete deletes, right-click menu, ⌥-click turns a guide, double-click opens Guides, Grid & Slices | done (2026-10-12) | a guide let go over either ruler is removed, not only off the canvas (#6286) | | Blend mode keys: ⇧⌥ + letter picks a mode (N Normal, I Dissolve, R Clear, P Pass Through, K M B A darken, G S D W lighten, O F H V J Z L contrast, E X difference, U T C Y component), ⇧+ / ⇧− step through them | done (2026-10-14): Brush, Pencil and Gradient take them as their own mode (Clear on Brush, Pencil and Paint Bucket only), any non-painting tool sets every selected layer in one undo step (Pass Through on groups only; the Background is skipped); the typed letter wins, the physical key is the fallback for ⌥ characters on a Mac. ⇧⌥ letters no longer switch tools. While a Free Transform box is open they, and the number keys, still reach the tool (or the layers) without closing it; Puppet Warp and Perspective Warp keep them to themselves. Clone Stamp, Pattern Stamp, History Brush, Healing (its own short list) and Paint Bucket (with Clear) take them as their own mode; tools without a blend mode (the Erasers, Mixer Brush) set the layers. Differs: Behind (⇧⌥Q) is not implemented; Spot Healing, Color Replacement, Blur, Sharpen and Smudge ignore the keys until their options bar has a Mode, and Dodge, Burn and Sponge ignore them until their Range and mode keys (⇧⌥S/M/H, ⇧⌥D/S) exist | `blend_keys.rs` | @@ -252,6 +253,7 @@ Paragraph; the rest open as in-app floating windows. | Date | Change | Summary | |---|---|---| +| 2026-10-28 | minor | Image › Adjustments › Photo Filter… opens at the filter and density of its last OK, without ⌥, as in Photoshop (#9879); no percentage changed | | 2026-10-27 | minor | Every preference takes effect: 12 of 181 do nothing, the scorecard's count with integration batch 9 (#9775) on main; new layers go above a group whose lock would refuse them (#4329) | | 2026-10-25 | minor | Every preference takes effect: 12 of 178 do nothing, the scorecard's count (it still said 46 of 149) | | 2026-10-25 | minor | Art History Brush is in the History group of the Y flyout (partial, TOOL-213-20, #1610) | From 744651bb4dd5bd4dce1a8662a2a1ca2cf7f06d21 Mon Sep 17 00:00:00 2001 From: ljkx <177671075+ljkx@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:08:52 +0100 Subject: [PATCH 023/219] Photo Filter: reopen with the choice its colour implies; complete recorded settings Review follow-ups for #9879: - Photo Filter's Filter / Color choice lives in the editor memory, so an opening that switched it and was cancelled left the next opening on Filter with a custom colour (the colour button disabled). Each opening now forgets it once, and the editor derives it from the colour again. - A smart filter's recorded adjustment settings are completed through the engine's parser before they fill the dialog, so a recorded Photo Filter that names only its `filter` shows and saves that preset's colour instead of the dialog's neutral one. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/adjust_dialog.rs | 17 +++++++++ .../src/dialogs/adjust_dialog/tests.rs | 35 +++++++++++++++++++ crates/ui-egui/src/dialogs/adjust_editors.rs | 7 ++++ .../src/dialogs/adjust_editors/basic.rs | 6 +++- crates/ui-egui/src/panels/smart_ui.rs | 5 ++- 5 files changed, 68 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs/adjust_dialog.rs b/crates/ui-egui/src/dialogs/adjust_dialog.rs index 1ac70db208..a1199ccea2 100644 --- a/crates/ui-egui/src/dialogs/adjust_dialog.rs +++ b/crates/ui-egui/src/dialogs/adjust_dialog.rs @@ -17,6 +17,8 @@ use crate::tone::{self, HistSource}; const PREFIX: &str = "image.adjustments."; const CURVE_PICKER: &str = "__curvePicker"; const CURVE_PICKER_BASE: &str = "__curvePickerBase"; +/// Set once a Photo Filter dialog has drawn: its editor memory was refreshed for this opening. +const PHOTO_FILTER_FRESH: &str = "__photoFilterFresh"; fn picker(value: Option<&Value>) -> Option { use photocraft_engine::adjust_params::CurvesEyedropper; @@ -306,6 +308,15 @@ pub fn open_last(app: &mut PhotocraftApp, command: &str) -> Option { Some(id) } +/// `params` of the adjustment `command` with every key filled in as the engine reads them: a +/// recorded Photo Filter that names only its `filter` gets that preset's `color`, not the dialog's +/// neutral one. `None` for other commands and for params the engine rejects. +pub(crate) fn complete_params(app: &PhotocraftApp, command: &str, params: &Value) -> Option { + let kind = command.strip_prefix(PREFIX).filter(|k| adjust_editors::has_editor(k))?; + let adjustment = photocraft_engine::adjust_params::from_params(kind, params, None, curve_mode(app, kind)).ok()?; + Some(photocraft_engine::adjust_params::to_params(&adjustment)) +} + /// Remember an adjustment dialog's settings after a successful OK, for [`open_last`]. Editing a /// smart filter's settings is not a use of the dialog's own (as `filter_dialog::remember`). pub(crate) fn remember(app: &mut PhotocraftApp, fields: &Map) { @@ -340,6 +351,12 @@ fn curve_mode(app: &PhotocraftApp, kind: &str) -> photocraft_doc::ColorMode { pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, fields: &mut Map) { let kind = fields.get("__adjust").and_then(Value::as_str).unwrap_or_default().to_string(); let mode = curve_mode(app, &kind); + // Each opening starts from the settings it opened at: Photo Filter's Filter / Color choice + // follows its colour, whatever a cancelled opening left in the editor memory. + if kind == "photoFilter" && !fields.contains_key(PHOTO_FILTER_FRESH) { + adjust_editors::forget_photo_filter_mode(ui.ctx(), dialog_mem(&kind)); + fields.insert(PHOTO_FILTER_FRESH.into(), json!(false)); + } if matches!(kind.as_str(), "levels" | "curves") { picker_controls(ui, fields, mode); ui.add_space(4.0); diff --git a/crates/ui-egui/src/dialogs/adjust_dialog/tests.rs b/crates/ui-egui/src/dialogs/adjust_dialog/tests.rs index e2ba88ca61..d94f783a85 100644 --- a/crates/ui-egui/src/dialogs/adjust_dialog/tests.rs +++ b/crates/ui-egui/src/dialogs/adjust_dialog/tests.rs @@ -885,3 +885,38 @@ fn photo_filter_opens_at_its_last_settings() { photocraft_engine::adjust_params::to_params(&photocraft_engine::adjust_params::default_for("levels", photocraft_doc::ColorMode::Rgb).unwrap()); assert_eq!(app.ui.dialog_mut(again).unwrap().fields["inBlack"], defaults["inBlack"], "Levels opens neutral without ⌥"); } + +/// #9879 review: a custom colour confirmed in Photo Filter reopens with Color chosen, even after an +/// opening that switched to Filter and was cancelled (the choice lives in the editor memory). +#[test] +fn photo_filter_reopens_with_the_choice_its_colour_implies() { + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app_with_image()); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + let cmd = "image.adjustments.photoFilter"; + let id = open(h.state_mut(), cmd).unwrap(); + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("color".into(), json!("#123456")); + h.run_steps(2); + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + // The next opening chooses Filter (what clicking its radio stores), then is cancelled. + let mode = dialog_mem("photoFilter").with(crate::adjust_editors::PHOTO_FILTER_MODE); + let id = open(h.state_mut(), cmd).unwrap(); + h.run_steps(2); + h.ctx.data_mut(|d| d.insert_temp(mode, false)); + h.state_mut().ui.close_dialog(id); + h.run_steps(1); + let id = open(h.state_mut(), cmd).unwrap(); + h.run_steps(2); + assert_eq!(h.state_mut().ui.dialog_mut(id).unwrap().fields["color"], json!("#123456")); + assert_eq!(h.ctx.data(|d| d.get_temp::(mode)), Some(true), "Color is chosen for the custom colour"); +} + +/// #9879 review: a recorded Photo Filter that names only its preset completes to that preset's +/// colour, so editing it as a smart filter doesn't swap in the dialog's neutral colour. +#[test] +fn recorded_photo_filter_settings_complete_to_their_preset_colour() { + let app = app_with_image(); + let done = complete_params(&app, "image.adjustments.photoFilter", &json!({"filter": "cooling80", "density": 60})).unwrap(); + assert_eq!((done["color"].as_str(), done["density"].as_f64()), (Some("#006dff"), Some(60.0))); + assert!(complete_params(&app, "filter.blur.gaussian", &json!({"radius": 2})).is_none(), "not an adjustment"); + assert!(complete_params(&app, "image.adjustments.photoFilter", &json!({"filter": "nope"})).is_none(), "rejected params"); +} diff --git a/crates/ui-egui/src/dialogs/adjust_editors.rs b/crates/ui-egui/src/dialogs/adjust_editors.rs index cdee0d6502..522ed46705 100644 --- a/crates/ui-egui/src/dialogs/adjust_editors.rs +++ b/crates/ui-egui/src/dialogs/adjust_editors.rs @@ -30,6 +30,7 @@ mod hue_saturation; mod levels; mod session; +pub(crate) use basic::PHOTO_FILTER_MODE; #[cfg(test)] pub(crate) use basic::row_value; #[cfg(test)] @@ -71,6 +72,12 @@ pub fn has_editor(kind: &str) -> bool { KINDS.contains(&kind) } +/// Forget Photo Filter's Filter / Color choice in the editor memory `mem`, so the editor derives it +/// from the colour again: a choice changed and then cancelled must not outlive the dialog (#9879). +pub(crate) fn forget_photo_filter_mode(ctx: &egui::Context, mem: egui::Id) { + ctx.data_mut(|d| d.remove::(mem.with(PHOTO_FILTER_MODE))); +} + /// Kinds that draw a histogram behind their controls. pub fn needs_histogram(kind: &str) -> bool { matches!(kind, "levels" | "curves" | "threshold") diff --git a/crates/ui-egui/src/dialogs/adjust_editors/basic.rs b/crates/ui-egui/src/dialogs/adjust_editors/basic.rs index 18c740ab80..8a7ea8ce8e 100644 --- a/crates/ui-egui/src/dialogs/adjust_editors/basic.rs +++ b/crates/ui-egui/src/dialogs/adjust_editors/basic.rs @@ -194,12 +194,16 @@ pub(super) fn black_white(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Ed // --------------------------------------------------------------------------------------------- // Photo Filter +/// The editor memory key of Photo Filter's Filter / Color choice (unset: Color for a colour that +/// is no preset). +pub(crate) const PHOTO_FILTER_MODE: &str = "pf-mode"; + pub(super) fn photo_filter(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { let mut e = Edit::default(); let mut c = rgb_of(v, "color", [236.0 / 255.0, 138.0 / 255.0, 0.0]); let hex = adjust_params::hex(c); let preset = PHOTO_FILTERS.iter().find(|f| adjust_params::hex(f.2.map(|x| f32::from(x) / 255.0)) == hex).map(|f| f.0); - let mode_id = cx.mem.with("pf-mode"); + let mode_id = cx.mem.with(PHOTO_FILTER_MODE); let mut use_color: bool = ui.data(|d| d.get_temp(mode_id)).unwrap_or(preset.is_none()); ui.horizontal(|ui| { if ui.radio(!use_color, tl!("Filter:")).clicked() { diff --git a/crates/ui-egui/src/panels/smart_ui.rs b/crates/ui-egui/src/panels/smart_ui.rs index c1b592be49..ff32958bb0 100644 --- a/crates/ui-egui/src/panels/smart_ui.rs +++ b/crates/ui-egui/src/panels/smart_ui.rs @@ -495,9 +495,12 @@ pub fn open_editor(app: &mut PhotocraftApp, ctx: &egui::Context, layer: u64, sm: }) else { return; }; + // An adjustment's recorded settings are completed first, so a key the record lacks shows (and + // saves) what the engine reads for it rather than the dialog's neutral value. + let params = crate::adjust_dialog::complete_params(app, &f.command, &f.params).unwrap_or_else(|| f.params.clone()); if let Some(d) = app.ui.dialog_mut(id) { d.fields.insert("__smartFilter".into(), target); - if let Value::Object(p) = &f.params { + if let Value::Object(p) = ¶ms { for (k, v) in p { if !k.starts_with("__") { d.fields.insert(k.clone(), v.clone()); From e74c79d015321a89f849fd91f8e85035b2ce1c96 Mon Sep 17 00:00:00 2001 From: ljkx <177671075+ljkx@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:28:54 +0100 Subject: [PATCH 024/219] Smart filter: complete only a Photo Filter's missing colour, keep recorded values Review follow-up for #9879: round-tripping every recorded adjustment through the engine rewrote values it should keep (a float Photo Filter colour became hex, a pencil curve's map came back smoothed while its `smooth` stayed in the stored params, so each confirmation smoothed it again). `complete_params` now adds just the colour a Photo Filter that names only its `filter` lacks, and leaves every recorded value and every other adjustment untouched. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/adjust_dialog.rs | 16 +++++++++++----- .../ui-egui/src/dialogs/adjust_dialog/tests.rs | 16 ++++++++++++---- crates/ui-egui/src/panels/smart_ui.rs | 4 ++-- 3 files changed, 25 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/dialogs/adjust_dialog.rs b/crates/ui-egui/src/dialogs/adjust_dialog.rs index a1199ccea2..22c47b2960 100644 --- a/crates/ui-egui/src/dialogs/adjust_dialog.rs +++ b/crates/ui-egui/src/dialogs/adjust_dialog.rs @@ -308,13 +308,19 @@ pub fn open_last(app: &mut PhotocraftApp, command: &str) -> Option { Some(id) } -/// `params` of the adjustment `command` with every key filled in as the engine reads them: a -/// recorded Photo Filter that names only its `filter` gets that preset's `color`, not the dialog's -/// neutral one. `None` for other commands and for params the engine rejects. +/// `params` of the adjustment `command` with the keys the dialog would otherwise fill in wrongly: +/// a recorded Photo Filter that names only its `filter` gets that preset's `color`, not the +/// dialog's neutral one. Every recorded value is kept as it was (a round trip through the engine +/// would rewrite them: float colours as hex, a pencil curve smoothed again). `None` when nothing +/// needs adding, or for params the engine rejects. pub(crate) fn complete_params(app: &PhotocraftApp, command: &str, params: &Value) -> Option { - let kind = command.strip_prefix(PREFIX).filter(|k| adjust_editors::has_editor(k))?; + let kind = command.strip_prefix(PREFIX).filter(|k| *k == "photoFilter")?; + let recorded = params.as_object().filter(|p| p.contains_key("filter") && !p.contains_key("color"))?; let adjustment = photocraft_engine::adjust_params::from_params(kind, params, None, curve_mode(app, kind)).ok()?; - Some(photocraft_engine::adjust_params::to_params(&adjustment)) + let color = photocraft_engine::adjust_params::to_params(&adjustment).get("color").cloned()?; + let mut done = recorded.clone(); + done.insert("color".into(), color); + Some(Value::Object(done)) } /// Remember an adjustment dialog's settings after a successful OK, for [`open_last`]. Editing a diff --git a/crates/ui-egui/src/dialogs/adjust_dialog/tests.rs b/crates/ui-egui/src/dialogs/adjust_dialog/tests.rs index d94f783a85..999ccb5700 100644 --- a/crates/ui-egui/src/dialogs/adjust_dialog/tests.rs +++ b/crates/ui-egui/src/dialogs/adjust_dialog/tests.rs @@ -911,12 +911,20 @@ fn photo_filter_reopens_with_the_choice_its_colour_implies() { } /// #9879 review: a recorded Photo Filter that names only its preset completes to that preset's -/// colour, so editing it as a smart filter doesn't swap in the dialog's neutral colour. +/// colour, so editing it as a smart filter doesn't swap in the dialog's neutral colour. Recorded +/// values are kept as they were, and nothing else is rewritten. #[test] fn recorded_photo_filter_settings_complete_to_their_preset_colour() { let app = app_with_image(); - let done = complete_params(&app, "image.adjustments.photoFilter", &json!({"filter": "cooling80", "density": 60})).unwrap(); - assert_eq!((done["color"].as_str(), done["density"].as_f64()), (Some("#006dff"), Some(60.0))); + let pf = "image.adjustments.photoFilter"; + let done = complete_params(&app, pf, &json!({"filter": "cooling80", "density": 60, "extra": [1]})).unwrap(); + assert_eq!(done, json!({"filter": "cooling80", "density": 60, "extra": [1], "color": "#006dff"}), "only the colour is added"); + // A recorded colour (here a float triple the engine would rewrite as hex) is left alone. + assert!(complete_params(&app, pf, &json!({"filter": "cooling80", "color": [0.5, 0.0, 0.0]})).is_none()); + assert!(complete_params(&app, pf, &json!({"density": 60})).is_none(), "no preset to take a colour from"); + assert!(complete_params(&app, pf, &json!({"filter": "nope"})).is_none(), "rejected params"); + // Other adjustments keep their recorded values exactly (a pencil curve isn't smoothed again). + let curves = json!({"pencil": true, "smooth": 2, "rgb": [0, 255]}); + assert!(complete_params(&app, "image.adjustments.curves", &curves).is_none()); assert!(complete_params(&app, "filter.blur.gaussian", &json!({"radius": 2})).is_none(), "not an adjustment"); - assert!(complete_params(&app, "image.adjustments.photoFilter", &json!({"filter": "nope"})).is_none(), "rejected params"); } diff --git a/crates/ui-egui/src/panels/smart_ui.rs b/crates/ui-egui/src/panels/smart_ui.rs index ff32958bb0..9071bca7b1 100644 --- a/crates/ui-egui/src/panels/smart_ui.rs +++ b/crates/ui-egui/src/panels/smart_ui.rs @@ -495,8 +495,8 @@ pub fn open_editor(app: &mut PhotocraftApp, ctx: &egui::Context, layer: u64, sm: }) else { return; }; - // An adjustment's recorded settings are completed first, so a key the record lacks shows (and - // saves) what the engine reads for it rather than the dialog's neutral value. + // A recorded Photo Filter that names only its preset gets the preset's colour, which the + // dialog would otherwise fill in with its neutral one; recorded values stay as they were. let params = crate::adjust_dialog::complete_params(app, &f.command, &f.params).unwrap_or_else(|| f.params.clone()); if let Some(d) = app.ui.dialog_mut(id) { d.fields.insert("__smartFilter".into(), target); From f2a34f0326d958983917d1848131ad4e1be2f9ab Mon Sep 17 00:00:00 2001 From: ljkx <177671075+ljkx@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:56:14 +0100 Subject: [PATCH 025/219] Smart filter: a null Photo Filter colour completes from its preset Review follow-up for #9879: the engine reads `"color": null` as no colour and uses the `filter` preset, so `complete_params` now completes it the same way instead of leaving the dialog on its neutral colour. Regression case added to `recorded_photo_filter_settings_complete_to_their_preset_colour`. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/adjust_dialog.rs | 3 ++- crates/ui-egui/src/dialogs/adjust_dialog/tests.rs | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs/adjust_dialog.rs b/crates/ui-egui/src/dialogs/adjust_dialog.rs index 22c47b2960..48e8b26210 100644 --- a/crates/ui-egui/src/dialogs/adjust_dialog.rs +++ b/crates/ui-egui/src/dialogs/adjust_dialog.rs @@ -315,7 +315,8 @@ pub fn open_last(app: &mut PhotocraftApp, command: &str) -> Option { /// needs adding, or for params the engine rejects. pub(crate) fn complete_params(app: &PhotocraftApp, command: &str, params: &Value) -> Option { let kind = command.strip_prefix(PREFIX).filter(|k| *k == "photoFilter")?; - let recorded = params.as_object().filter(|p| p.contains_key("filter") && !p.contains_key("color"))?; + // A `null` colour reads as none, as the engine reads it. + let recorded = params.as_object().filter(|p| p.contains_key("filter") && p.get("color").is_none_or(Value::is_null))?; let adjustment = photocraft_engine::adjust_params::from_params(kind, params, None, curve_mode(app, kind)).ok()?; let color = photocraft_engine::adjust_params::to_params(&adjustment).get("color").cloned()?; let mut done = recorded.clone(); diff --git a/crates/ui-egui/src/dialogs/adjust_dialog/tests.rs b/crates/ui-egui/src/dialogs/adjust_dialog/tests.rs index 999ccb5700..b675e3d769 100644 --- a/crates/ui-egui/src/dialogs/adjust_dialog/tests.rs +++ b/crates/ui-egui/src/dialogs/adjust_dialog/tests.rs @@ -921,6 +921,8 @@ fn recorded_photo_filter_settings_complete_to_their_preset_colour() { assert_eq!(done, json!({"filter": "cooling80", "density": 60, "extra": [1], "color": "#006dff"}), "only the colour is added"); // A recorded colour (here a float triple the engine would rewrite as hex) is left alone. assert!(complete_params(&app, pf, &json!({"filter": "cooling80", "color": [0.5, 0.0, 0.0]})).is_none()); + let null = complete_params(&app, pf, &json!({"filter": "cooling80", "color": null})).unwrap(); + assert_eq!(null["color"], json!("#006dff"), "a null colour completes from the preset, as the engine reads it"); assert!(complete_params(&app, pf, &json!({"density": 60})).is_none(), "no preset to take a colour from"); assert!(complete_params(&app, pf, &json!({"filter": "nope"})).is_none(), "rejected params"); // Other adjustments keep their recorded values exactly (a pencil curve isn't smoothed again). From 2780df1f30dd0b687c5e9dfd702b2c7da24ca91e Mon Sep 17 00:00:00 2001 From: ljkx <177671075+ljkx@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:47:26 +0100 Subject: [PATCH 026/219] Camera Raw sidecars: apply the crop, stop reporting RawFileName A raw opened with a Lightroom or Camera Raw sidecar reported "3 not supported: CropBottom, CropRight, RawFileName" (#9985): the crop keys fell through to the unsupported list (CropTop/CropLeft were only spared because they were 0), and the raw's file name was treated as a setting. The XMP reader now resolves the crop from the public crs: schema: crs:HasCrop="True" with CropTop/CropLeft/CropBottom/CropRight, fractions of the developed frame in the raw's stored orientation. A missing or False HasCrop means no crop (the keys are stale and are not reported). Fractions are clamped to 0..1; an inverted, empty or unreadable crop, and a straightened one (CropAngle not 0, whose rectangle is measured on the rotated frame), is not applied and its keys are named as not supported. RawFileName is bookkeeping. Camera Raw's settings have no crop stage, so the crop is applied the way the sidecar's other raw-stage settings are: Open re-develops the raw (RawTuning::crop) and crops the upright frame after mapping the fractions through the raw's Orientation, before the Camera Raw step, so the post-crop vignette centres on the crop. This works with the dialog and with Open in Camera Raw off; the dialog previews the whole frame. PhotoCraft's own sidecar keeps the crop when it is rewritten, and Load Settings in the Camera Raw Filter names a crop as not supported. Co-Authored-By: Claude Opus 5.5 --- crates/io/src/camera_raw_xmp.rs | 246 +++++++++++++++++- crates/io/src/raw.rs | 33 ++- crates/io/tests/it/raw.rs | 30 +++ .../dialogs/camera_raw_ui/identity_tests.rs | 1 + .../ui-egui/src/dialogs/camera_raw_ui/open.rs | 26 +- .../src/dialogs/camera_raw_ui/sidecar.rs | 10 +- .../src/dialogs/camera_raw_ui/tests.rs | 44 +++- docs/control-protocol.md | 5 +- docs/file-format-parity.md | 5 +- 9 files changed, 376 insertions(+), 24 deletions(-) diff --git a/crates/io/src/camera_raw_xmp.rs b/crates/io/src/camera_raw_xmp.rs index 65df97e898..76fd8bb0ca 100644 --- a/crates/io/src/camera_raw_xmp.rs +++ b/crates/io/src/camera_raw_xmp.rs @@ -12,6 +12,13 @@ //! `IncrementalTemperature` / `IncrementalTint` (−100..=100), which are PhotoCraft's Temperature //! and Tint. The absolute `Temperature` (kelvin) / `Tint` of raw-file settings have no //! counterpart and are ignored. +//! +//! Crop (#9985): `crs:HasCrop="True"` with `crs:CropTop` / `CropLeft` / `CropBottom` / +//! `CropRight`, fractions of the developed frame in the raw's stored orientation (before its +//! Orientation tag turns it upright), is read into [`LoadReport::crop`]; the raw open crops the +//! document with it ([`crate::raw::RawTuning::crop`]). The settings have no crop (Camera Raw +//! Filter has none). Without `HasCrop`, or with `HasCrop="False"`, the crop keys are stale and +//! mean nothing. A straightened crop (`CropAngle` other than 0) isn't applied. use photocraft_algo::camera_raw::{CameraRaw, validate_curve}; @@ -126,7 +133,6 @@ const METADATA: &[&str] = &[ "Version", "ProcessVersion", "HasSettings", - "HasCrop", "WhiteBalance", "ToneCurveName2012", "CameraProfile", @@ -147,16 +153,114 @@ const METADATA: &[&str] = &[ "SortName", "Group", "Description", + // The raw file the sidecar belongs to (Lightroom and Camera Raw name it). + "RawFileName", ]; +/// The crop's keys besides `HasCrop`, and their values without a crop (the whole frame, level). +pub const CROP_KEYS: [&str; 5] = ["CropTop", "CropLeft", "CropBottom", "CropRight", "CropAngle"]; +const NO_CROP: [f32; 5] = [0.0, 0.0, 1.0, 1.0, 0.0]; + +/// A Camera Raw crop: fractions of the developed frame in the raw's stored orientation, before +/// its Orientation tag turns it upright, with `0 <= left < right <= 1` and +/// `0 <= top < bottom <= 1`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Crop { + pub top: f32, + pub left: f32, + pub bottom: f32, + pub right: f32, +} + +impl Crop { + /// The crop of the frame turned upright by TIFF orientation `o` (1–8; anything else is 1), + /// as fractions of the upright frame: the corners map as the pixels do. + pub fn oriented(self, o: u16) -> Crop { + let map = |u: f32, v: f32| match o { + 2 => (1.0 - u, v), + 3 => (1.0 - u, 1.0 - v), + 4 => (u, 1.0 - v), + 5 => (v, u), + 6 => (1.0 - v, u), + 7 => (1.0 - v, 1.0 - u), + 8 => (v, 1.0 - u), + _ => (u, v), + }; + let (ax, ay) = map(self.left, self.top); + let (bx, by) = map(self.right, self.bottom); + Crop { top: ay.min(by), left: ax.min(bx), bottom: ay.max(by), right: ax.max(bx) } + } + + /// The crop on a `w × h` frame in pixels, `(x0, y0, x1, y1)` with `x1` and `y1` exclusive: + /// at least one pixel, inside the frame. `None` for an empty frame. + pub fn pixels(self, w: usize, h: usize) -> Option<(usize, usize, usize, usize)> { + let edge = |f: f32, n: usize| ((f64::from(f).clamp(0.0, 1.0) * n as f64).round() as usize).min(n); + let span = |a: f32, b: f32, n: usize| { + let lo = edge(a, n).min(n.checked_sub(1)?); + Some((lo, edge(b, n).max(lo + 1))) + }; + let (x0, x1) = span(self.left, self.right, w)?; + let (y0, y1) = span(self.top, self.bottom, h)?; + Some((x0, y0, x1, y1)) + } +} + /// What [`load`] applied. #[derive(Clone, Debug, Default, PartialEq)] pub struct LoadReport { - /// How many settings were applied. + /// How many settings were applied (a crop counts as one). pub applied: usize, /// `crs:` settings without a PhotoCraft control (set to something other than their neutral - /// value), or with an unusable value, in file order. + /// value), or with an unusable value, in file order; then the keys of a crop that isn't + /// applied (straightened, unreadable or empty). pub ignored: Vec, + /// The file's crop, when it has one that isn't the whole frame. The settings hold no crop, + /// so the caller applies it (a raw open crops the document) or reports it. + pub crop: Option, +} + +/// The crop keys as read, resolved by [`crop`] once the whole file is read. +#[derive(Default)] +struct CropFields { + has: Option, + values: [Option; 5], +} + +/// The file's crop from its keys: applied when `HasCrop` is `True` and the values make a level, +/// non-empty rectangle (fractions are clamped to 0..1); otherwise its keys are reported. +fn crop(fields: &CropFields, report: &mut LoadReport) { + match fields.has.as_deref().map(str::trim) { + // No crop: the other keys are what Camera Raw left from an earlier or reset crop. + None => return, + Some(v) if v.eq_ignore_ascii_case("false") => return, + Some(v) if v.eq_ignore_ascii_case("true") => {} + Some(_) => { + report.ignored.push("HasCrop".into()); + return; + } + } + let mut v = NO_CROP; + let mut usable = true; + for (slot, text) in v.iter_mut().zip(&fields.values) { + if let Some(text) = text { + match number(text) { + Some(n) => *slot = n, + None => usable = false, + } + } + } + let [top, left, bottom, right] = [v[0], v[1], v[2], v[3]].map(|f| f.clamp(0.0, 1.0)); + // A straightened crop's rectangle is measured on the rotated frame: not applied. + usable &= v[4] == 0.0 && top < bottom && left < right; + if !usable { + let present = CROP_KEYS.iter().zip(&fields.values).filter(|(_, t)| t.is_some()); + report.ignored.extend(present.map(|(k, _)| k.to_string())); + return; + } + if [top, left, bottom, right] != [0.0, 0.0, 1.0, 1.0] { + report.crop = Some(Crop { top, left, bottom, right }); + report.applied += 1; + } } fn number(s: &str) -> Option { @@ -197,8 +301,14 @@ fn neutral(name: &str, value: &str) -> bool { } } -fn apply(params: &mut CameraRaw, report: &mut LoadReport, name: &str, value: &str) { - if let Some((_, field, lo, hi)) = SCALARS.iter().find(|(n, ..)| *n == name) { +fn apply(params: &mut CameraRaw, report: &mut LoadReport, crop: &mut CropFields, name: &str, value: &str) { + if name == "HasCrop" { + crop.has = Some(value.to_string()); + } else if let Some(i) = CROP_KEYS.iter().position(|k| *k == name) { + if let Some(slot) = crop.values.get_mut(i) { + *slot = Some(value.to_string()); + } + } else if let Some((_, field, lo, hi)) = SCALARS.iter().find(|(n, ..)| *n == name) { match number(value) { Some(v) => { *field(params) = v.clamp(*lo, *hi); @@ -232,10 +342,11 @@ pub fn load(params: &mut CameraRaw, bytes: &[u8]) -> Result let mut next = params.clone(); let mut report = LoadReport::default(); let mut found = false; + let mut crop_fields = CropFields::default(); for desc in doc.descendants().filter(|n| n.is_element() && n.tag_name().namespace() == Some(RDF_NS) && n.tag_name().name() == "Description") { for a in desc.attributes().filter(|a| a.namespace() == Some(CRS_NS)) { found = true; - apply(&mut next, &mut report, a.name(), a.value()); + apply(&mut next, &mut report, &mut crop_fields, a.name(), a.value()); } for c in desc.children().filter(|c| c.is_element() && c.tag_name().namespace() == Some(CRS_NS)) { found = true; @@ -254,13 +365,14 @@ pub fn load(params: &mut CameraRaw, bytes: &[u8]) -> Result report.ignored.push(name.to_string()); } } else { - apply(&mut next, &mut report, name, c.text().unwrap_or("")); + apply(&mut next, &mut report, &mut crop_fields, name, c.text().unwrap_or("")); } } } if !found { return Err("no Camera Raw settings in the file".into()); } + crop(&crop_fields, &mut report); *params = next; Ok(report) } @@ -295,6 +407,11 @@ pub fn is_photocraft_xmp(bytes: &[u8]) -> bool { /// Write `params` as a Camera Raw settings file Photoshop's Load Settings… reads. pub fn save(params: &CameraRaw) -> String { + save_with_crop(params, None) +} + +/// [`save`] with a raw file's crop, so rewriting a sidecar keeps the crop it was opened with. +pub fn save_with_crop(params: &CameraRaw, crop: Option) -> String { let mut p = params.clone(); let wb = if p.temperature != 0.0 || p.tint != 0.0 { "Custom" } else { "As Shot" }; let mut out = String::from( @@ -315,6 +432,12 @@ pub fn save(params: &CameraRaw) -> String { } let style = STYLES.iter().find(|(_, s)| *s == p.vignette_style).map_or("1", |(n, _)| n); out.push_str(&format!(" crs:PostCropVignetteStyle=\"{}\"\n", escape(style))); + if let Some(c) = crop { + out.push_str(&format!( + " crs:HasCrop=\"True\"\n crs:CropTop=\"{:.6}\"\n crs:CropLeft=\"{:.6}\"\n crs:CropBottom=\"{:.6}\"\n crs:CropRight=\"{:.6}\"\n crs:CropAngle=\"0\"\n", + c.top, c.left, c.bottom, c.right + )); + } out.push_str(" crs:ToneCurveName2012=\"Custom\"\n crs:HasSettings=\"True\">\n"); for (name, field) in CURVES { let points = field(&mut p); @@ -433,7 +556,7 @@ mod tests { abc"#; let r = load(&mut p, xmp).unwrap(); assert_eq!((p.exposure, p.contrast, p.clarity), (-5.0, 40.0, 0.0)); - assert_eq!(r, LoadReport { applied: 1, ignored: vec!["Clarity2012".into()] }); + assert_eq!(r, LoadReport { applied: 1, ignored: vec!["Clarity2012".into()], crop: None }); } #[test] @@ -479,4 +602,111 @@ mod tests { assert!(!is_photocraft_xmp(other), "{}", String::from_utf8_lossy(other)); } } + + /// A settings file with the `crs:` attributes `attrs`. + fn with(attrs: &str) -> String { + format!( + "" + ) + } + + fn report(attrs: &str) -> LoadReport { + load(&mut CameraRaw::default(), with(attrs).as_bytes()).unwrap() + } + + /// #9985: Lightroom's sidecar named the crop's bottom and right edges and the raw's file name + /// as unsupported; the crop is read now, and the file name is bookkeeping. + #[test] + fn reads_the_crop_and_not_the_file_name() { + let r = report(r#"crs:HasCrop="True" crs:CropTop="0.1" crs:CropLeft="0.2" crs:CropBottom="0.9" crs:CropRight="0.75" crs:CropAngle="0""#); + assert!(r.ignored.is_empty(), "{:?}", r.ignored); + assert_eq!(r.crop, Some(Crop { top: 0.1, left: 0.2, bottom: 0.9, right: 0.75 })); + assert_eq!(r.applied, 1); + // Element form, and the keys spread over two descriptions, read the same. + let xmp = format!( + "True0.5" + ); + let r = load(&mut CameraRaw::default(), xmp.as_bytes()).unwrap(); + assert_eq!(r.crop, Some(Crop { top: 0.0, left: 0.0, bottom: 0.5, right: 0.5 }), "missing edges are the frame's"); + assert!(r.ignored.is_empty(), "{:?}", r.ignored); + } + + #[test] + fn no_crop_without_has_crop_true() { + let keys = r#"crs:CropTop="0.1" crs:CropLeft="0.2" crs:CropBottom="0.9" crs:CropRight="0.75" crs:CropAngle="3""#; + for has in ["", r#"crs:HasCrop="False""#, r#"crs:HasCrop="false""#] { + let r = report(&format!("{has} {keys}")); + assert_eq!((r.crop, r.ignored.clone(), r.applied), (None, Vec::new(), 0), "{has:?}: {r:?}"); + } + // The whole frame is no crop, and nothing to report. + let r = report(r#"crs:HasCrop="True" crs:CropTop="0" crs:CropLeft="0" crs:CropBottom="1" crs:CropRight="1" crs:CropAngle="0""#); + assert_eq!((r.crop, r.ignored.len()), (None, 0)); + let r = report(r#"crs:HasCrop="yes" crs:CropBottom="0.5""#); + assert_eq!((r.crop, r.ignored), (None, vec!["HasCrop".to_string()])); + } + + /// Hostile and straightened crops never panic: out-of-range fractions are clamped to the + /// frame; an inverted, empty, unreadable or straightened crop isn't applied and its keys are + /// named. + #[test] + fn unusable_crops_are_reported_not_applied() { + let r = report(r#"crs:HasCrop="True" crs:CropTop="-0.5" crs:CropLeft="0.25" crs:CropBottom="7" crs:CropRight="1.0000001""#); + assert_eq!(r.crop, Some(Crop { top: 0.0, left: 0.25, bottom: 1.0, right: 1.0 })); + assert!(r.ignored.is_empty(), "{:?}", r.ignored); + let all = || CROP_KEYS.iter().map(|k| k.to_string()).collect::>(); + for bad in [ + r#"crs:CropTop="0.8" crs:CropLeft="0.1" crs:CropBottom="0.2" crs:CropRight="0.9" crs:CropAngle="0""#, + r#"crs:CropTop="0.5" crs:CropLeft="0.1" crs:CropBottom="0.5" crs:CropRight="0.9" crs:CropAngle="0""#, + r#"crs:CropTop="2" crs:CropLeft="0.1" crs:CropBottom="3" crs:CropRight="0.9" crs:CropAngle="0""#, + r#"crs:CropTop="NaN" crs:CropLeft="0.1" crs:CropBottom="0.9" crs:CropRight="0.9" crs:CropAngle="0""#, + r#"crs:CropTop="0.1" crs:CropLeft="inf" crs:CropBottom="0.9" crs:CropRight="0.9" crs:CropAngle="0""#, + r#"crs:CropTop="0.1" crs:CropLeft="0.1" crs:CropBottom="0.9" crs:CropRight="" crs:CropAngle="0""#, + r#"crs:CropTop="0.1" crs:CropLeft="0.1" crs:CropBottom="0.9" crs:CropRight="0.9" crs:CropAngle="1.38""#, + r#"crs:CropTop="0.1" crs:CropLeft="0.1" crs:CropBottom="0.9" crs:CropRight="0.9" crs:CropAngle="NaN""#, + ] { + let r = report(&format!(r#"crs:HasCrop="True" {bad}"#)); + assert_eq!((r.crop, r.ignored, r.applied), (None, all(), 0), "{bad}"); + } + let r = report(r#"crs:HasCrop="True" crs:CropAngle="-2.5""#); + assert_eq!(r.ignored, vec!["CropAngle".to_string()], "only the keys the file has"); + } + + /// Corners map as `photocraft-raw`'s orientation maps pixels (`develop::source_xy`). + #[test] + fn crops_follow_the_orientation() { + let c = Crop { top: 0.1, left: 0.2, bottom: 0.6, right: 0.5 }; + let want = [ + (1, Crop { top: 0.1, left: 0.2, bottom: 0.6, right: 0.5 }), + (2, Crop { top: 0.1, left: 0.5, bottom: 0.6, right: 0.8 }), + (3, Crop { top: 0.4, left: 0.5, bottom: 0.9, right: 0.8 }), + (4, Crop { top: 0.4, left: 0.2, bottom: 0.9, right: 0.5 }), + (5, Crop { top: 0.2, left: 0.1, bottom: 0.5, right: 0.6 }), + (6, Crop { top: 0.2, left: 0.4, bottom: 0.5, right: 0.9 }), + (7, Crop { top: 0.5, left: 0.4, bottom: 0.8, right: 0.9 }), + (8, Crop { top: 0.5, left: 0.1, bottom: 0.8, right: 0.6 }), + (0, Crop { top: 0.1, left: 0.2, bottom: 0.6, right: 0.5 }), + ]; + for (o, w) in want { + let got = c.oriented(o); + let close = [got.top - w.top, got.left - w.left, got.bottom - w.bottom, got.right - w.right].iter().all(|d| d.abs() < 1e-6); + assert!(close, "orientation {o}: {got:?}"); + } + assert_eq!(c.pixels(10, 20), Some((2, 2, 5, 12))); + assert_eq!(Crop { top: 0.5, left: 0.5, bottom: 0.5001, right: 0.5001 }.pixels(10, 10), Some((5, 5, 6, 6)), "at least a pixel"); + assert_eq!(Crop { top: 0.999, left: 0.999, bottom: 1.0, right: 1.0 }.pixels(10, 10), Some((9, 9, 10, 10)), "inside the frame"); + assert_eq!(c.pixels(0, 10), None); + } + + #[test] + fn save_with_crop_round_trips() { + let p = CameraRaw { contrast: 20.0, ..CameraRaw::default() }; + let crop = Crop { top: 0.125, left: 0.25, bottom: 0.875, right: 0.5 }; + let text = save_with_crop(&p, Some(crop)); + assert!(is_photocraft_xmp(text.as_bytes())); + let mut back = CameraRaw::default(); + let r = load(&mut back, text.as_bytes()).unwrap(); + assert_eq!((back, r.crop), (p.clone(), Some(crop))); + assert!(r.ignored.is_empty(), "{:?}", r.ignored); + assert_eq!(load(&mut CameraRaw::default(), save(&p).as_bytes()).unwrap().crop, None); + } } diff --git a/crates/io/src/raw.rs b/crates/io/src/raw.rs index a1ed4aaa01..a1d1c73067 100644 --- a/crates/io/src/raw.rs +++ b/crates/io/src/raw.rs @@ -50,7 +50,7 @@ fn upright_preview(raw: &[u8], jpeg: &[u8], limits: &photocraft_raw::Limits) -> /// Camera Raw's Temperature, Tint, Exposure, Shadows, Highlights, Clarity, Texture and Dehaze, /// applied while developing the sensor data (the open-time Camera Raw dialog) instead of to the -/// developed RGB pixels, and the demosaic method. +/// developed RGB pixels, the demosaic method, and the crop. #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct RawTuning { /// −100…100, as `photocraft_algo::camera_raw::CameraRaw::temperature`. @@ -71,6 +71,9 @@ pub struct RawTuning { pub texture: f32, /// −100…100, as `CameraRaw::dehaze`. pub dehaze: f32, + /// The crop of the raw's sidecar ([`crate::camera_raw_xmp::Crop`], in the stored + /// orientation): the developed frame is cropped to it once it is upright. + pub crop: Option, } /// Develops a raw file with `tuning` applied at the raw stage: Exposure as develop exposure, @@ -106,7 +109,15 @@ pub fn import_raw_tuned(name: &str, bytes: &[u8], tuning: &RawTuning) -> Result< } None => false, }; - let dev = photocraft_raw::develop_sensor(&sensor, &opts).map_err(IoError::Raw)?; + let mut dev = photocraft_raw::develop_sensor(&sensor, &opts).map_err(IoError::Raw)?; + let cropped = match tuning.crop { + Some(crop) => { + let o = if opts.orient { dev.info.orientation } else { 1 }; + crop_developed(&mut dev, crop.oriented(o))?; + format!(", cropped to {}×{}", dev.width, dev.height) + } + None => String::new(), + }; let wb = if wb_applied { format!("as-shot white balance, temperature {:+.0}, tint {:+.0}", tuning.temperature, tuning.tint) } else { @@ -117,10 +128,26 @@ pub fn import_raw_tuned(name: &str, bytes: &[u8], tuning: &RawTuning) -> Result< .filter(|(_, v)| *v != 0.0) .map(|(k, v)| format!(", {k} {v:+.0}")) .collect::(); - let summary = format!("developed in Camera Raw (exposure {:+.2} EV{tone}, {wb}, {} demosaic)", tuning.exposure, tuning.demosaic.id()); + let summary = format!("developed in Camera Raw (exposure {:+.2} EV{tone}, {wb}, {} demosaic{cropped})", tuning.exposure, tuning.demosaic.id()); Ok((developed_document(name, bytes, format, dev, &summary)?, wb_applied)) } +/// Crops the upright developed frame to `crop` (fractions of it), as Camera Raw crops a raw. +fn crop_developed(dev: &mut photocraft_raw::Developed, crop: crate::camera_raw_xmp::Crop) -> Result<(), IoError> { + let (w, h) = (dev.width as usize, dev.height as usize); + let bad = || IoError::Raw(RawError::Malformed(format!("the developed {w}x{h} frame holds fewer samples than its size"))); + let Some((x0, y0, x1, y1)) = crop.pixels(w, h) else { return Ok(()) }; + let mut rgb = Vec::with_capacity((x1 - x0) * (y1 - y0) * 3); + for y in y0..y1 { + let row = y.checked_mul(w).ok_or_else(bad)?; + rgb.extend_from_slice(dev.rgb.get((row + x0) * 3..(row + x1) * 3).ok_or_else(bad)?); + } + dev.rgb = rgb; + dev.width = u32::try_from(x1 - x0).map_err(|_| bad())?; + dev.height = u32::try_from(y1 - y0).map_err(|_| bad())?; + Ok(()) +} + /// The tone curve `bytes` develop with by default: the fitted look's for a file without colour /// calibration (see [`import_raw_with`]), else the camera profile's (empty: none, or not a raw /// this crate decodes). For previews of the open-time raw-stage sliders diff --git a/crates/io/tests/it/raw.rs b/crates/io/tests/it/raw.rs index 52250c24d7..12a3d362e4 100644 --- a/crates/io/tests/it/raw.rs +++ b/crates/io/tests/it/raw.rs @@ -420,6 +420,36 @@ fn uncalibrated_raw_takes_its_colour_from_the_camera_jpeg() { assert_eq!(r.document.depth, SampleType::U16); } +/// #9985: a sidecar's crop is measured on the raw as stored, before its Orientation turns it +/// upright: the developed frame is cropped to the same sensor pixels, whichever way it is turned. +#[test] +fn camera_raw_tuning_crops_in_the_stored_orientation() { + use photocraft_io::camera_raw_xmp::Crop; + use photocraft_io::raw::{RawTuning, import_raw_tuned}; + let (w, h) = (40, 24); + // Stored pixels x 4..24, y 6..18; then the upright frame's size and the same pixels in it. + let crop = Crop { top: 0.25, left: 0.1, bottom: 0.75, right: 0.6 }; + for (orientation, size, [x0, y0, x1, y1]) in + [(1, (40, 24), [4, 6, 24, 18]), (3, (40, 24), [16, 6, 36, 18]), (6, (24, 40), [6, 4, 18, 24]), (8, (24, 40), [6, 16, 18, 36])] + { + let mut spec = DngSpec::cfa(w, h, mosaic(&scene(w, h), w, [0, 1, 1, 2], 0, 65535)); + spec.as_shot_neutral = Some([0.6, 1.0, 0.8]); + spec.orientation = orientation; + let bytes = spec.build(); + let (full, _) = import_raw_tuned("shot.dng", &bytes, &RawTuning::default()).unwrap(); + let (cut, _) = import_raw_tuned("shot.dng", &bytes, &RawTuning { crop: Some(crop), ..Default::default() }).unwrap(); + assert_eq!((full.document.size.width, full.document.size.height), size, "orientation {orientation}"); + assert_eq!((cut.document.size.width, cut.document.size.height), (x1 - x0, y1 - y0), "orientation {orientation}"); + assert!(cut.warnings.iter().any(|n| n.contains(&format!("cropped to {}×{}", x1 - x0, y1 - y0))), "{:?}", cut.warnings); + let full = photocraft_io::document_to_image(&full.document, &mut Vec::new()).unwrap(); + let cut = photocraft_io::document_to_image(&cut.document, &mut Vec::new()).unwrap(); + // 16-bit RGB: 6 bytes a pixel. + let row = |y: u32| &full.data()[((y * size.0 + x0) * 6) as usize..((y * size.0 + x1) * 6) as usize]; + let expected: Vec = (y0..y1).flat_map(|y| row(y).to_vec()).collect(); + assert_eq!(cut.data(), expected, "orientation {orientation}: the crop is the same pixels of the uncropped develop"); + } +} + #[test] fn camera_raw_tuning_keeps_the_default_look_and_applies_user_gains_after_fitting() { use photocraft_io::raw::{RawTuning, fit_preview_look, import_raw_tuned}; diff --git a/crates/ui-egui/src/dialogs/camera_raw_ui/identity_tests.rs b/crates/ui-egui/src/dialogs/camera_raw_ui/identity_tests.rs index a66e172158..9ac37ee835 100644 --- a/crates/ui-egui/src/dialogs/camera_raw_ui/identity_tests.rs +++ b/crates/ui-egui/src/dialogs/camera_raw_ui/identity_tests.rs @@ -54,6 +54,7 @@ fn identity_app() -> (PhotocraftApp, RawOpen, Control) { bytes: Some(bytes), developed_with: Default::default(), demosaic: Default::default(), + crop: None, }; let mut app = PhotocraftApp::new(session, crate::Services::default()); app.sync_views(); diff --git a/crates/ui-egui/src/dialogs/camera_raw_ui/open.rs b/crates/ui-egui/src/dialogs/camera_raw_ui/open.rs index 87e1035a85..8748bfe7ef 100644 --- a/crates/ui-egui/src/dialogs/camera_raw_ui/open.rs +++ b/crates/ui-egui/src/dialogs/camera_raw_ui/open.rs @@ -16,9 +16,9 @@ pub(super) enum Target { /// A raw file opened interactively, shown in Camera Raw before it is kept (as Photoshop opens /// raws in Adobe Camera Raw first). The document is already developed with the defaults; Open /// re-develops it when Temperature, Tint, Exposure, Shadows, Highlights, Clarity, Texture, -/// Dehaze or the demosaic method changed (those act on the sensor data, see -/// [`photocraft_io::raw::import_raw_tuned`]) and applies the other settings as one Camera Raw -/// step, then marks the document unmodified. Cancel closes it. +/// Dehaze or the demosaic method changed, or the sidecar has a crop (those act on the sensor +/// data, see [`photocraft_io::raw::import_raw_tuned`]) and applies the other settings as one +/// Camera Raw step, then marks the document unmodified. Cancel closes it. #[derive(Clone, Debug)] pub struct RawOpen { pub document: photocraft_doc::DocId, @@ -30,6 +30,9 @@ pub struct RawOpen { pub developed_with: photocraft_io::raw::Demosaic, /// The method chosen in the dialog: Open re-develops when it differs from `developed_with`. pub demosaic: photocraft_io::raw::Demosaic, + /// The crop of the file's sidecar (in the raw's stored orientation): Open re-develops and + /// crops to it. The settings have no crop, so the dialog previews the whole frame. + pub crop: Option, } /// Should an interactive open of `name` show the open-time Camera Raw dialog? `warnings` are @@ -68,12 +71,13 @@ pub fn queue_open_dialog( bytes: bytes.map(|b| std::sync::Arc::new(b.to_vec())), developed_with: method, demosaic: method, + crop: None, }); Ok(()) } /// Opens the queued open-time dialog on its document's (only) layer. -pub(super) fn open_raw(app: &mut PhotocraftApp, ctx: &egui::Context, raw: RawOpen) -> Result<(), String> { +pub(super) fn open_raw(app: &mut PhotocraftApp, ctx: &egui::Context, mut raw: RawOpen) -> Result<(), String> { let index = document_index(app, raw.document).ok_or("the raw document was closed")?; app.session.set_active(index); let (layer, surf, _) = crate::distort_ui::active_pixels(app)?; @@ -83,6 +87,7 @@ pub(super) fn open_raw(app: &mut PhotocraftApp, ctx: &egui::Context, raw: RawOpe let mut from_sidecar = false; if let Some((loaded, report, file)) = raw.path.as_deref().and_then(read_sidecar) { params = loaded; + raw.crop = report.crop; from_sidecar = true; app.ui.status = if report.ignored.is_empty() { crate::i18n::fmt(tl!("Settings from {name}"), &[("name", &file)]) @@ -179,6 +184,9 @@ pub(super) fn commit_open_raw(app: &mut PhotocraftApp, params: &CameraRaw, raw: clarity: params.clarity, texture: params.texture, dehaze: params.dehaze, + // The sidecar's crop: re-developing crops the document, before the Camera Raw step, so + // the post-crop vignette centres on the crop as in Camera Raw. + crop: raw.crop, }; // The open itself: the default settings, developed with the method it was opened with. let as_opened = photocraft_io::raw::RawTuning { demosaic: raw.developed_with, ..Default::default() }; @@ -349,9 +357,13 @@ pub fn opened_without_dialog(app: &mut PhotocraftApp, index: usize, name: &str, } let Some(document) = app.session.documents().get(index).map(|st| st.doc.id) else { return }; let method = photocraft_io::raw::developed_demosaic(warnings).unwrap_or_else(|| app.session.prefs().raw_defaults.demosaic.method()); - let raw = RawOpen { document, name: name.to_string(), path: path.map(str::to_string), bytes: None, developed_with: method, demosaic: method }; + let mut raw = + RawOpen { document, name: name.to_string(), path: path.map(str::to_string), bytes: None, developed_with: method, demosaic: method, crop: None }; let mut params = CameraRaw::default(); - let sidecar = path.and_then(read_sidecar).map(|(loaded, _, _)| loaded); + let sidecar = path.and_then(read_sidecar).map(|(loaded, report, _)| { + raw.crop = report.crop; + loaded + }); if let Some(loaded) = &sidecar { params = loaded.clone(); } else if app.session.prefs().raw_defaults.apply_auto_tone @@ -359,7 +371,7 @@ pub fn opened_without_dialog(app: &mut PhotocraftApp, index: usize, name: &str, { photocraft_algo::camera_raw::auto_tone(&px, &mut params); } - let r = if params.is_identity() { + let r = if params.is_identity() && raw.crop.is_none() { let defaults = app.session.prefs().raw_defaults.clone(); finish_open_raw(app, index, params, None, &raw, &defaults) } else { diff --git a/crates/ui-egui/src/dialogs/camera_raw_ui/sidecar.rs b/crates/ui-egui/src/dialogs/camera_raw_ui/sidecar.rs index dfb03a13b9..f370a6b68b 100644 --- a/crates/ui-egui/src/dialogs/camera_raw_ui/sidecar.rs +++ b/crates/ui-egui/src/dialogs/camera_raw_ui/sidecar.rs @@ -68,7 +68,8 @@ pub(super) fn write_sidecar(app: &mut PhotocraftApp, raw: &RawOpen, params: &Cam None if params.is_identity() => return, _ => {} } - let bytes = photocraft_io::camera_raw_xmp::save(params).into_bytes(); + // The crop it opened with stays in the file, though the dialog has no control for it. + let bytes = photocraft_io::camera_raw_xmp::save_with_crop(params, raw.crop).into_bytes(); let Some(write) = app.services.write.as_mut() else { return }; if let Err(e) = write(&sidecar, &bytes) { crate::notices::error(app, crate::i18n::fmt(tl!("Could not write {name}: {error}"), &[("name", &name), ("error", &e)])); @@ -124,7 +125,12 @@ pub(super) fn load_settings(app: &mut PhotocraftApp) -> Result { let file = crate::file_open::display_name(&name); let d = app.camera_raw.as_mut().ok_or(tl!("Camera Raw isn't open"))?; let mut next = d.params.clone(); - let report = photocraft_io::camera_raw_xmp::load(&mut next, &bytes).map_err(|e| format!("{file}: {e}"))?; + let mut report = photocraft_io::camera_raw_xmp::load(&mut next, &bytes).map_err(|e| format!("{file}: {e}"))?; + // A crop only applies as a raw file opens; the filter has none, so it is not supported. + if report.crop.take().is_some() { + report.applied = report.applied.saturating_sub(1); + report.ignored.extend(photocraft_io::camera_raw_xmp::CROP_KEYS.iter().take(4).map(|k| k.to_string())); + } next.validate_changes(&d.original).map_err(|e| format!("{file}: {e}"))?; if d.params.point_curve != next.point_curve { d.curve_state = Default::default(); diff --git a/crates/ui-egui/src/dialogs/camera_raw_ui/tests.rs b/crates/ui-egui/src/dialogs/camera_raw_ui/tests.rs index bcae932f5e..5da2e61d71 100644 --- a/crates/ui-egui/src/dialogs/camera_raw_ui/tests.rs +++ b/crates/ui-egui/src/dialogs/camera_raw_ui/tests.rs @@ -558,7 +558,8 @@ fn cancel_closes_the_raw_and_the_preference_skips_the_dialog() { path: None, bytes: None, developed_with: Default::default(), - demosaic: Default::default() + demosaic: Default::default(), + crop: None } ) .is_err() @@ -801,6 +802,47 @@ fn a_rating_only_sidecar_is_left_alone() { let _ = std::fs::remove_dir_all(&dir); } +/// #9985: the crop of a Lightroom sidecar crops the opened raw (with the dialog's Open and +/// without the dialog), and neither its edges nor the raw's file name are named as unsupported. +/// A straightened crop isn't applied and is named. PhotoCraft's own sidecar keeps the crop. +#[test] +fn a_sidecar_crop_crops_the_opened_raw() { + let lightroom = r#""#; + let (mut app, dng, dir, raw_path, written) = raw_with_sidecar("crop", Some(lightroom)); + let ctx = egui::Context::default(); + let size = |app: &PhotocraftApp| app.session.active().map(|st| (st.doc.size.width, st.doc.size.height)); + open_dialog_at(&mut app, &ctx, &dng, &raw_path); + assert!(app.ui.status.contains("IMG_0100.xmp") && !app.ui.status.contains("not supported"), "{}", app.ui.status); + assert_eq!(size(&app), Some((48, 32)), "the dialog previews the whole frame"); + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"commit": true}})).unwrap().unwrap(); + assert_eq!(size(&app), Some((24, 16)), "Open crops the 48x32 frame to x 6..30, y 8..24"); + let st = app.session.active().unwrap(); + assert_eq!(st.saved_revision, st.revision, "the crop is part of opening: unmodified"); + assert!(written.lock().unwrap().is_empty(), "Lightroom's sidecar is kept"); + app.run("prefs.set", json!({"path": "rawDefaults.openInCameraRaw", "value": false})).unwrap(); + app.open_file(&raw_path, &dng).unwrap(); + assert_eq!(size(&app), Some((24, 16)), "without the dialog too"); + app.run("prefs.set", json!({"path": "rawDefaults.openInCameraRaw", "value": true})).unwrap(); + // Straightened: the rectangle is measured on the turned frame, so nothing is cropped. + std::fs::write(sidecar_path(&raw_path), lightroom.replace(r#"crs:CropAngle="0""#, r#"crs:CropAngle="1.38""#)).unwrap(); + open_dialog_at(&mut app, &ctx, &dng, &raw_path); + assert!(app.ui.status.contains("CropAngle") && !app.ui.status.contains("RawFileName"), "{}", app.ui.status); + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"commit": true}})).unwrap().unwrap(); + assert_eq!(size(&app), Some((48, 32))); + // PhotoCraft's own sidecar: a changed setting rewrites it, with the crop it was opened with. + let crop = photocraft_io::camera_raw_xmp::Crop { top: 0.25, left: 0.125, bottom: 0.75, right: 0.625 }; + std::fs::write(sidecar_path(&raw_path), photocraft_io::camera_raw_xmp::save_with_crop(&CameraRaw::default(), Some(crop))).unwrap(); + open_dialog_at(&mut app, &ctx, &dng, &raw_path); + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"contrast": 15}, "commit": true}})).unwrap().unwrap(); + assert_eq!(size(&app), Some((24, 16))); + let w = written.lock().unwrap().clone(); + assert_eq!(w.len(), 1); + let mut back = CameraRaw::default(); + let r = photocraft_io::camera_raw_xmp::load(&mut back, &w[0].1).unwrap(); + assert_eq!((back.contrast, r.crop), (15.0, Some(crop))); + let _ = std::fs::remove_dir_all(&dir); +} + /// With Open in Camera Raw off, opening a raw develops it with its sidecar and writes nothing /// back: nothing changed. #[test] diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 0c286693c2..8f648c8c34 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -529,7 +529,10 @@ Open also applies the other Camera Raw Defaults (Sharpen For as part of the Came colour space, bit depth and Open as Smart Object, so the step works on the pixels the dialog previewed; Apply auto tone fills the dialog), reported as `defaults` in the commit's reply; the resolution is set when the file opens. With "Open in Camera Raw" off the sidecar and the defaults -apply without the dialog. +apply without the dialog. A sidecar's crop (`crs:HasCrop="True"` with `CropTop` / `CropLeft` / +`CropBottom` / `CropRight`, fractions of the raw as stored, before its Orientation) crops the +document as Open re-develops it, before the Camera Raw step; the dialog previews the whole frame. +A straightened crop (`CropAngle` not 0) is named as not supported and not applied. **Opening a raw file.** An interactive open (File › Open, Open Recent, drag and drop, the command line) of a camera raw developed from its sensor data shows this dialog first, titled diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index f49fa461de..63746addaf 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-24 · **Change:** minor (PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-28 · **Change:** minor (Camera Raw: an `.xmp` sidecar's crop crops the opened raw, and `crs:RawFileName` is no longer named as unsupported, #9985; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 **PSD Filter Gallery (#8940, 2026-10-23):** Halftone Pattern maps its size, contrast and dot/circle/line pattern as editable settings on import and export, with the reference application's descriptor keys (`HlSz`, `Cntr`, `ScrT`: `ScrD`/`ScrC`/`ScrL`). PSD/PSB round trips pass at 8/16/32 bits. This does not claim pixel equivalence for the existing renderer. Unknown or malformed settings remain verbatim on import. @@ -74,7 +74,7 @@ supports many more, including CR3). | Olympus ORF | uncompressed only | | | Fujifilm RAF (Bayer and X-Trans) | uncompressed only; no Fuji colour calibration | | | Others (CRW, 3FR, IIQ, X3F, SRW, MRW, ERF…) | embedded preview only | | -| .xmp sidecars, highlight modes, raw preferences | missing | FILE-215-11 | +| .xmp sidecars, highlight modes, raw preferences | partial (sidecars and preferences; no highlight modes) | 2026-10-28: a sidecar's crop (`crs:HasCrop` with `CropTop` / `CropLeft` / `CropBottom` / `CropRight`, fractions of the developed frame as stored, mapped through its Orientation) crops the opened raw: Open re-develops and crops before the Camera Raw step, so the post-crop vignette centres on the crop; the dialog previews the whole frame. A straightened crop (`CropAngle` not 0) isn't applied and is named, as are unreadable or empty ones; `HasCrop` missing or `False` means no crop; `RawFileName` is bookkeeping (#9985; `crates/io/src/camera_raw_xmp.rs` tests, `crates/io/tests/it/raw.rs` `camera_raw_tuning_crops_in_the_stored_orientation`, `camera_raw_ui/tests.rs` `a_sidecar_crop_crops_the_opened_raw`). 2026-10-13: sidecars fill the open-time dialog and Open writes them back; Camera Raw Defaults wired. FILE-215-11 | | Camera Raw as a PSD smart filter | partial | 2026-10-07: WB, Light/Presence, curves, HSL, grading, detail, grain, vignette mapped (FILE-215-12) | 2026-10-11: on #2135's embedded-JPEG rendering work, open-time Camera Raw tuning @@ -144,6 +144,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-28 | minor | Camera Raw `.xmp` sidecars: the crop (`crs:HasCrop` with `CropTop` / `CropLeft` / `CropBottom` / `CropRight`, fractions of the raw as stored, mapped through its Orientation) crops the opened raw, with the dialog's Open and without the dialog; out-of-range fractions are clamped, and inverted, unreadable or straightened (`CropAngle` not 0) crops are named as not supported instead of applied; PhotoCraft's own sidecar keeps the crop when it is rewritten; `crs:RawFileName` is no longer named as unsupported (#9985) | | 2026-10-24 | minor | Layered TIFF: the `LMsk` block in tag 37724 carries Photoshop's default global layer mask (14 bytes) instead of none; with an empty block Photoshop read only the composite (#9095). PSD output unchanged | | 2026-10-24 | minor | Layered TIFF writes the TySh text version and descriptor in PSD big-endian order while retaining container order for the block header, transform matrix, warp section and bounds (#9098); the mixed-order read path remains covered by #4758/#4771. | | 2026-10-23 | minor | PSD: spot channels are stored and read as Photoshop 27.11's PSDs store them (ink 0 = solid; all 7 of its spot PSDs store the 100/25/75/0 % columns as 0/191/64/255), and resource 1053 numbers the extra channels 3, 4, 5, … after 0 for the merged transparency. A PSD with spot channels whose 1057 is exactly what PhotoCraft 0.1.0–0.16.0 wrote, and with no 1053 or one kept from an opened Photoshop file that doesn't list every channel, is read as those versions stored it (straight past the channels that 1053 lists), so it keeps its ink. Multichannel is unchanged (#7415 split, part 4) | From ea4ec7b80e7a0627b66ba824d75d29e5d65b5beb Mon Sep 17 00:00:00 2001 From: VoiceLessQ Date: Wed, 28 Oct 2026 18:04:57 -0200 Subject: [PATCH 027/219] retouch: a wrapped stroke over its work budget names its own command periodic::Composite said paint.cloneStamp for every tool; Healing Brush, History Brush and Pattern Stamp now go through it too. The command is passed in, and the live stroke passes its own. --- crates/engine/src/cmd/retouch_cmds/clone.rs | 3 ++- crates/engine/src/cmd/retouch_cmds/heal.rs | 4 ++-- .../src/cmd/retouch_cmds/pattern_stamp.rs | 2 +- crates/engine/src/cmd/retouch_cmds/periodic.rs | 8 ++++++-- crates/engine/src/cmd/retouch_cmds/tests.rs | 18 ++++++++++++++++++ 5 files changed, 29 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/retouch_cmds/clone.rs b/crates/engine/src/cmd/retouch_cmds/clone.rs index e0643d9fec..41f168244f 100644 --- a/crates/engine/src/cmd/retouch_cmds/clone.rs +++ b/crates/engine/src/cmd/retouch_cmds/clone.rs @@ -136,7 +136,7 @@ pub(super) fn clone_stamp(s: &mut Session, p: &Value) -> Result { return Ok(l.bounds); } if flag(p, "wrap", false) { - return periodic::stroke(pre, surf, &stroke, sel, lock, mode, |before, r| clone_sample(pre, id, before, which, r, &map, true, CMD)); + return periodic::stroke(pre, surf, &stroke, sel, lock, mode, CMD, |before, r| clone_sample(pre, id, before, which, r, &map, true, CMD)); } let (bounds, cov) = coverage(CMD, &stroke)?; // Samples come from the pre-stroke state, so pixels painted earlier in the stroke are never re-cloned. @@ -349,6 +349,7 @@ impl LiveRetouch { selection: self.sel.as_ref(), lock: self.lock, mode: self.mode, + cmd: live_cmd, } .draw(dirty, &self.brush, &self.points, paint) } diff --git a/crates/engine/src/cmd/retouch_cmds/heal.rs b/crates/engine/src/cmd/retouch_cmds/heal.rs index 791f65fc62..c68789e2c3 100644 --- a/crates/engine/src/cmd/retouch_cmds/heal.rs +++ b/crates/engine/src/cmd/retouch_cmds/heal.rs @@ -73,7 +73,7 @@ pub(super) fn healing_brush(s: &mut Session, p: &Value) -> Result { let mask: Vec = cov.iter().map(|c| *c > 0.0).collect(); let healed = heal_region(&fmt, &src, &dst, &mask); if wrap { - return periodic::stroke(pre, surf, &stroke, sel, lock, mode, |_, r| { + return periodic::stroke(pre, surf, &stroke, sel, lock, mode, CMD, |_, r| { let part = healed.crop(r); if part.rect == r { Ok(part) } else { Err(bad(CMD, "the healed patch does not cover the stroke")) } }); @@ -114,7 +114,7 @@ pub(super) fn history_brush(s: &mut Session, p: &Value) -> Result { let src = if src_surface.format() == fmt { src_surface } else { src_surface.convert(fmt) }; if flag(p, "wrap", false) { // Pattern Preview: the history state repeats with the canvas, as the stroke does. - return periodic::stroke(pre, surf, &stroke, sel, lock, mode, |_, r| periodic::sample(pre, id, &src, SampleLayers::Current, r, CMD)); + return periodic::stroke(pre, surf, &stroke, sel, lock, mode, CMD, |_, r| periodic::sample(pre, id, &src, SampleLayers::Current, r, CMD)); } let (bounds, cov) = coverage(CMD, &stroke)?; let paint = Region::read(&src, bounds); diff --git a/crates/engine/src/cmd/retouch_cmds/pattern_stamp.rs b/crates/engine/src/cmd/retouch_cmds/pattern_stamp.rs index 7ccb293d4f..021e02a08a 100644 --- a/crates/engine/src/cmd/retouch_cmds/pattern_stamp.rs +++ b/crates/engine/src/cmd/retouch_cmds/pattern_stamp.rs @@ -97,7 +97,7 @@ pub(super) fn run(s: &mut Session, p: &Value) -> Result { Ok(apply_dab_stroke_wrapped(surf, &st.stroke, sel, lock, 0, wrap, effect)) } else if wrap.is_some() { let (place, fmt) = (st.placement(), surf.format()); - periodic::stroke(&pre, surf, &st.stroke, sel, lock, st.mode, |_, r| Ok(paint_region(&st.tile, &place, &fmt, r))) + periodic::stroke(&pre, surf, &st.stroke, sel, lock, st.mode, CMD, |_, r| Ok(paint_region(&st.tile, &place, &fmt, r))) } else { paint_aligned(surf, &st.stroke, sel, lock, &st.tile, st.placement(), st.mode, CMD) } diff --git a/crates/engine/src/cmd/retouch_cmds/periodic.rs b/crates/engine/src/cmd/retouch_cmds/periodic.rs index 57527e1629..63c3b40f07 100644 --- a/crates/engine/src/cmd/retouch_cmds/periodic.rs +++ b/crates/engine/src/cmd/retouch_cmds/periodic.rs @@ -26,6 +26,7 @@ pub(super) fn project(rect: Rect, tile: Rect) -> Vec { /// A composite tool's stroke wrapped around the document: `paint` gives the pixels for a /// rectangle in the stroke's own coordinates, from the target as it was before the stroke, and /// they go on through the stroke's coverage wherever it repeats on the document. +#[allow(clippy::too_many_arguments)] pub(super) fn stroke( pre: &Document, target: &mut Surface, @@ -33,6 +34,7 @@ pub(super) fn stroke( selection: Option<&Surface>, lock: bool, mode: BlendMode, + cmd: &str, mut paint: impl FnMut(&Surface, Rect) -> Result, ) -> Result { let mut renderer = photocraft_paint::StrokeRenderer::new(&stroke.brush, None, 1.0); @@ -40,7 +42,7 @@ pub(super) fn stroke( renderer.finish(); let before = target.clone(); let bounds = renderer.bounds(); - Composite { target, before: &before, tile: pre.bounds(), opacity: stroke.brush.opacity, selection, lock, mode }.draw( + Composite { target, before: &before, tile: pre.bounds(), opacity: stroke.brush.opacity, selection, lock, mode, cmd }.draw( bounds, &stroke.brush, &stroke.points, @@ -59,6 +61,8 @@ pub(super) struct Composite<'a> { pub selection: Option<&'a Surface>, pub lock: bool, pub mode: BlendMode, + /// The command an over-budget stroke's error names. + pub cmd: &'a str, } impl Composite<'_> { @@ -163,7 +167,7 @@ impl Composite<'_> { ); *work = work.saturating_add(r.width() as u64 * r.height() as u64); if *work > 64 * 1024 * 1024 { - return Err(bad("paint.cloneStamp", "wrapped stroke exceeds its coverage budget")); + return Err(bad(self.cmd, "wrapped stroke exceeds its coverage budget")); } ctx.rasterize_into(dab, dual, r, &mut values, wet.then_some(&mut depths)); let mut sampled = None; diff --git a/crates/engine/src/cmd/retouch_cmds/tests.rs b/crates/engine/src/cmd/retouch_cmds/tests.rs index fe2ea3beab..eb031a1a1f 100644 --- a/crates/engine/src/cmd/retouch_cmds/tests.rs +++ b/crates/engine/src/cmd/retouch_cmds/tests.rs @@ -2766,3 +2766,21 @@ fn wrapped_live_retouch_strokes_show_what_their_commits_paint() { } } } + +#[test] +fn a_wrapped_composite_stroke_over_its_work_budget_names_its_own_command() { + // Big, tightly spaced dabs pass the stroke footprint check but not the wrapped-work budget. + for (cmd, extra, setup) in [ + ("paint.historyBrush", json!({"state": 1}), textured_in_history as Setup), + ("paint.patternStamp", json!({"pattern": "Checkerboard"}), textured as Setup), + ] { + let mut s = session(WRAP.0, WRAP.1, 8, "rgb"); + setup(&mut s); + let mut p = json!({"points": [[0, 0], [700, 0]], "size": 1000, "hardness": 100, "spacing": 1, "wrap": true}); + for (k, v) in extra.as_object().unwrap() { + p[k] = v.clone(); + } + let err = s.execute(cmd, p).unwrap_err().to_string(); + assert!(err.contains(cmd) && err.contains("coverage budget"), "{cmd}: {err}"); + } +} From 77339962cc12ccda3ea6df858b8c9fb210b06868 Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 29 Oct 2026 16:49:12 +0100 Subject: [PATCH 028/219] xtask: panel-commands allowlist follows main's panels/ split; lexer helpers keep clippy's complexity limit Main moved crates/ui-egui/src/panels.rs into panels/*.rs, so the color_field entry now names panels/color.rs (scorecard note too), and main's cognitive_complexity deny split lex()'s block comment and string skipping into helpers. Co-Authored-By: Claude Opus 5.5 --- docs/scorecard.md | 2 +- scorecard/automation.toml | 2 +- xtask/src/panel_commands.rs | 94 +++++++++++++++++++++---------------- 3 files changed, 56 insertions(+), 42 deletions(-) diff --git a/docs/scorecard.md b/docs/scorecard.md index 3ef2d3940c..2c9078f9ff 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -262,7 +262,7 @@ Menu wiring (`docs/parity-checklist.md`): 631 of 631 menu items live (100.0%). T | AUTO-219-5 | Batch: subfolders, open documents, naming template, progress/cancel, error log, stop | partial | [#219](https://github.com/storytold/photocraft/issues/219) | file_cmds.rs and file_cmds/batch_naming.rs: folder/array input, per-file errors, {name}/{serial}/{date} output templates and starting serial, Include All Subfolders (includeSubfolders: depth/loop/file-count bounded, results keep their subfolder; Batch and Image Processor dialogs, #3753); no open-documents source, cancel or error-log file | | AUTO-219-6 | Plug-in ABI v2: panel kind, progress/cancel calls, read other layers, Last Filter repeats | missing | [#219](https://github.com/storytold/photocraft/issues/219) | runtime.rs:15 ABI_VERSION = 1; manifest.rs:18 Kind::Filter only | | AUTO-219-7 | Local ONNX models behind a flag, managed in Preferences; subject select, background removal | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No ONNX runtime dependency | -| AUTO-219-8 | xtask check that every panel button routes through a command id | partial | [#219](https://github.com/storytold/photocraft/issues/219) | cargo xtask panel-commands (run by xtask ci and CI) fails on a shell click handler that changes the document or session directly; 1 allowlisted function left (panels.rs color_field assigns the foreground/background colour instead of tools.setColors) | +| AUTO-219-8 | xtask check that every panel button routes through a command id | partial | [#219](https://github.com/storytold/photocraft/issues/219) | cargo xtask panel-commands (run by xtask ci and CI) fails on a shell click handler that changes the document or session directly; 1 allowlisted function left (panels/color.rs color_field assigns the foreground/background colour instead of tools.setColors) | | AUTO-219-9 | Headless commands for in-canvas type editing, Liquify/Puppet strokes, transform handles | done | [#219](https://github.com/storytold/photocraft/issues/219) | type.hitTest, type.caret, type.navigate; filter.liquify strokes, edit.puppetWarp pins, type.edit, edit.transform | | AUTO-219-10 | Agent acceptance suite grown to 30-50 workflows | partial | [#219](https://github.com/storytold/photocraft/issues/219) | automation/tests/it/agent_tasks.rs runs 10 workflows over MCP | diff --git a/scorecard/automation.toml b/scorecard/automation.toml index 573d1e7ec2..137864c30e 100644 --- a/scorecard/automation.toml +++ b/scorecard/automation.toml @@ -65,7 +65,7 @@ id = "AUTO-219-8" target = "xtask check that every panel button routes through a command id" status = "partial" issue = 219 -note = "cargo xtask panel-commands (run by xtask ci and CI) fails on a shell click handler that changes the document or session directly; 1 allowlisted function left (panels.rs color_field assigns the foreground/background colour instead of tools.setColors)" +note = "cargo xtask panel-commands (run by xtask ci and CI) fails on a shell click handler that changes the document or session directly; 1 allowlisted function left (panels/color.rs color_field assigns the foreground/background colour instead of tools.setColors)" [[item]] id = "AUTO-219-9" diff --git a/xtask/src/panel_commands.rs b/xtask/src/panel_commands.rs index 5fd82abeb5..eb929acfc7 100644 --- a/xtask/src/panel_commands.rs +++ b/xtask/src/panel_commands.rs @@ -50,7 +50,7 @@ pub const ALLOWLIST: &[Allowed] = &[ // background colour on every drag frame, so the change never reaches the journal, action // recording or the control channel's event log. Fix: dispatch `tools.setColors` (it already exists). Allowed { - file: "crates/ui-egui/src/panels.rs", + file: "crates/ui-egui/src/panels/color.rs", func: "color_field", reason: "Color panel field, hue strip and readout assign session.tools.foreground/background; dispatch tools.setColors", }, @@ -118,24 +118,7 @@ fn lex(src: &str) -> Vec { i += 1; } } else if ch == '/' && at(i + 1) == '*' { - let mut depth = 0usize; - while i < c.len() { - if at(i) == '/' && at(i + 1) == '*' { - depth += 1; - i += 2; - } else if at(i) == '*' && at(i + 1) == '/' { - depth = depth.saturating_sub(1); - i += 2; - if depth == 0 { - break; - } - } else { - if at(i) == '\n' { - line += 1; - } - i += 1; - } - } + (i, line) = skip_block_comment(&c, i, line); } else if let Some(hashes) = raw_string_start(&c, i) { // r"…", r#"…"#, br#"…"#: skip to the quote followed by the same number of hashes. let start_line = line; @@ -153,27 +136,7 @@ fn lex(src: &str) -> Vec { out.push(Token { kind: Kind::Other, line: start_line }); } else if ch == '"' || (ch == 'b' && at(i + 1) == '"') || (ch == 'c' && at(i + 1) == '"') { let start_line = line; - i += if ch == '"' { 1 } else { 2 }; - while i < c.len() { - match at(i) { - '\\' => { - // A line continuation (`\` before a newline) still ends a source line. - if at(i + 1) == '\n' { - line += 1; - } - i += 2; - } - '"' => { - i += 1; - break; - } - '\n' => { - line += 1; - i += 1; - } - _ => i += 1, - } - } + (i, line) = skip_string(&c, i + if ch == '"' { 1 } else { 2 }, line); out.push(Token { kind: Kind::Other, line: start_line }); } else if ch == '\'' || (ch == 'b' && at(i + 1) == '\'') { // Char literal ('a', '\n', b'x') or lifetime/label ('a, 'static). @@ -212,6 +175,57 @@ fn lex(src: &str) -> Vec { out } +/// Skip a (nested) block comment starting at `i`; returns the index after it and the new line. +fn skip_block_comment(c: &[char], mut i: usize, mut line: usize) -> (usize, usize) { + let at = |k: usize| c.get(k).copied().unwrap_or('\0'); + let mut depth = 0usize; + while i < c.len() { + if at(i) == '/' && at(i + 1) == '*' { + depth += 1; + i += 2; + } else if at(i) == '*' && at(i + 1) == '/' { + depth = depth.saturating_sub(1); + i += 2; + if depth == 0 { + break; + } + } else { + if at(i) == '\n' { + line += 1; + } + i += 1; + } + } + (i, line) +} + +/// Skip a quoted string body starting just after its opening quote; returns the index after the +/// closing quote and the new line. +fn skip_string(c: &[char], mut i: usize, mut line: usize) -> (usize, usize) { + let at = |k: usize| c.get(k).copied().unwrap_or('\0'); + while i < c.len() { + match at(i) { + '\\' => { + // A line continuation (`\` before a newline) still ends a source line. + if at(i + 1) == '\n' { + line += 1; + } + i += 2; + } + '"' => { + i += 1; + break; + } + '\n' => { + line += 1; + i += 1; + } + _ => i += 1, + } + } + (i, line) +} + struct RawStart { /// Characters before the body (`r#"` is 3). prefix: usize, From 7a1d2dfd4c64bc19fca46cf8d5cefe1c88b9684a Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 29 Oct 2026 17:27:14 +0100 Subject: [PATCH 029/219] Tests: image.crop rounds fractional rectangle sizes like its other pixel params Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/image_cmds/tests.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/engine/src/cmd/image_cmds/tests.rs b/crates/engine/src/cmd/image_cmds/tests.rs index fb6f1ec883..d877e0270e 100644 --- a/crates/engine/src/cmd/image_cmds/tests.rs +++ b/crates/engine/src/cmd/image_cmds/tests.rs @@ -427,6 +427,14 @@ fn crop_refuses_a_bad_explicit_rect_instead_of_cropping_to_the_selection() { s.execute("select.rect", json!({"x": 5, "y": 2, "width": 20, "height": 10})).unwrap(); s.execute("image.crop", json!({"x": 1.0, "y": 1.0, "width": 30.0, "height": 15.0})).unwrap(); assert_eq!(doc(&s).size, Size::new(30, 15)); + // Fractional pixels round, like the rectangle's `x`/`y` and every other pixel param + // (`commands::int`); a side that rounds to 0 is refused. + let mut s = session(); + s.execute("image.crop", json!({"x": 0.4, "y": 0.6, "width": 20.6, "height": 9.5})).unwrap(); + assert_eq!(doc(&s).size, Size::new(21, 10)); + let err = s.execute("image.crop", json!({"x": 0, "y": 0, "width": 0.4, "height": 5})).unwrap_err(); + assert!(matches!(err, EngineError::BadParams { .. }), "{err:?}"); + assert_eq!(doc(&s).size, Size::new(21, 10)); } #[test] From b309b2784471d94231745f07d0d76d7113734966 Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 29 Oct 2026 17:27:22 +0100 Subject: [PATCH 030/219] Address CodeRabbit review: the crop box's H, P and I refuse Alt too egui's `matches_logically(Modifiers::NONE)` ignores Alt as well as Shift, so Alt+H, Alt+P and Alt+I toggled Show Cropped Area, Classic Mode and Front Image; they are now left for later handlers. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/tools/crop_shield.rs | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/tools/crop_shield.rs b/crates/ui-egui/src/tools/crop_shield.rs index eb8aeefacb..e7d91c4e82 100644 --- a/crates/ui-egui/src/tools/crop_shield.rs +++ b/crates/ui-egui/src/tools/crop_shield.rs @@ -146,12 +146,13 @@ pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { false } -/// `consume_key(Modifiers::NONE, key)` without the presses that hold ⇧: egui ignores an extra ⇧ -/// there, but ⇧+letter is the tool key. Reads each event's own modifiers, which synthetic +/// `consume_key(Modifiers::NONE, key)` without the presses that hold ⇧ or ⌥: egui ignores an +/// extra ⇧ and ⌥ there, but ⇧+letter is the tool key and Alt+letter opens the menu with that +/// mnemonic (Alt+H Help, Alt+I Image). Reads each event's own modifiers, which synthetic /// (control channel) keys carry without changing the frame's. pub(crate) fn consume_unshifted(i: &mut egui::InputState, key: Key) -> bool { let before = i.events.len(); - i.events.retain(|e| !matches!(e, egui::Event::Key { key: k, pressed: true, modifiers, .. } if *k == key && !modifiers.shift && modifiers.matches_logically(Modifiers::NONE))); + i.events.retain(|e| !matches!(e, egui::Event::Key { key: k, pressed: true, modifiers, .. } if *k == key && !modifiers.shift && !modifiers.alt && modifiers.matches_logically(Modifiers::NONE))); i.events.len() != before } @@ -398,6 +399,24 @@ mod tests { } } + /// Alt+H, Alt+P and Alt+I are not the crop box's H, P and I (Show Cropped Area, Classic Mode, + /// Front Image): egui's `consume_key` ignores an extra ⌥, so the plain-key checks must refuse + /// it and leave the press for later handlers. + #[test] + fn alt_letters_are_not_the_crop_box_keys() { + for key in [Key::H, Key::P, Key::I] { + let mut app = crop_app(); + let (shield, ratio) = (app.ui.tool_options.crop_shield.clone(), app.ui.tool_options.crop_ratio.clone()); + let ctx = egui::Context::default(); + let alt = egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::ALT }; + ctx.input_mut(|i| i.events.push(alt.clone())); + assert!(!keys(&mut app, &ctx) && !crate::crop_size::keys(&mut app, &ctx), "{key:?}"); + assert_eq!(app.ui.tool_options.crop_shield, shield, "{key:?}"); + assert_eq!(app.ui.tool_options.crop_ratio, ratio, "{key:?}"); + assert!(ctx.input(|i| i.events.contains(&alt)), "{key:?} is left for later handlers"); + } + } + #[test] fn the_color_picker_sets_the_custom_colour() { let mut app = crop_app(); From 13f84218e3cdcc2ca39159d32bf51bd34becc8e4 Mon Sep 17 00:00:00 2001 From: qvest-ssels <214787280+qvest-ssels@users.noreply.github.com> Date: Sat, 31 Oct 2026 03:04:59 +0100 Subject: [PATCH 031/219] feat(ui-egui): export dialogs start in the last exported folder (#10192) Export As, Quick Export and Save for Web now start in the folder of the last successful export (new preference export.lastFolder, kept across restarts), falling back to the document's folder when none is remembered or the folder is gone. Save As and non-export save dialogs are unchanged. Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/prefs.rs | 5 ++ crates/ui-egui/src/dialogs/export_dialog.rs | 8 +- crates/ui-egui/src/dialogs/file_ui/web.rs | 2 +- crates/ui-egui/src/file_dialog.rs | 70 ++++++++++++++-- crates/ui-egui/src/file_dialog/tests.rs | 92 +++++++++++++++++++++ 5 files changed, 166 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index f2445a2a54..6861adadad 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -543,6 +543,10 @@ pub struct Export { pub webp_quality: u32, pub metadata: ExportMetadata, pub convert_to_srgb: bool, + /// The folder the last successful export through a save dialog went to (Export As, Quick + /// Export, Save for Web, ...). The next export dialog starts there; empty until the first + /// export, and ignored while the folder no longer exists (#10192). + pub last_folder: String, } impl Default for Export { @@ -555,6 +559,7 @@ impl Default for Export { webp_quality: 85, metadata: ExportMetadata::Copyright, convert_to_srgb: true, + last_folder: String::new(), } } } diff --git a/crates/ui-egui/src/dialogs/export_dialog.rs b/crates/ui-egui/src/dialogs/export_dialog.rs index 68f46af65f..66efd1503a 100644 --- a/crates/ui-egui/src/dialogs/export_dialog.rs +++ b/crates/ui-egui/src/dialogs/export_dialog.rs @@ -209,7 +209,7 @@ pub fn quick_export_selected_layers_png(app: &mut PhotocraftApp) -> Result = layers.into_iter().map(|id| photocraft_engine::layer_menu_cmds::layer_document(&st.doc, id).map_err(|e| e.to_string())).collect::>()?; let suggested = format!("{}.png", export_name(&st.doc.name)); - app.pick_save(&suggested, move |app, path| { + app.pick_export(&suggested, move |app, path| { if !path.to_ascii_lowercase().ends_with(".png") { return Err("Quick Export as PNG writes .png files".into()); } @@ -1046,7 +1046,7 @@ pub fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result, ids: &[photoc let stem = st.doc.name.rsplit_once('.').map_or(st.doc.name.as_str(), |(a, _)| a); let suggested = format!("{}.{}", export_name(stem), s_fmt(f)); let (f, settings) = (f.clone(), settings(f)); - app.pick_save(&suggested, move |app, path| { + app.pick_export(&suggested, move |app, path| { let mut exported = Vec::new(); for (doc, path) in docs.iter().zip(batch_paths(&path, &docs)) { let out = export_document(doc, &f, None, &source_profile)?; @@ -1145,7 +1145,7 @@ pub fn quick_export_png(app: &mut PhotocraftApp) -> Result { let st = app.session.active().ok_or("no document")?; let stem = st.doc.name.rsplit_once('.').map_or(st.doc.name.as_str(), |(a, _)| a); let (suggested, doc) = (format!("{stem}.{fmt}"), st.doc.id); - return app.pick_save(&suggested, move |app, path| { + return app.pick_export(&suggested, move |app, path| { app.refocus(doc)?; quick_export(app, json!({"path": path})) }); diff --git a/crates/ui-egui/src/dialogs/file_ui/web.rs b/crates/ui-egui/src/dialogs/file_ui/web.rs index 62082b4f8d..3cca5f84e3 100644 --- a/crates/ui-egui/src/dialogs/file_ui/web.rs +++ b/crates/ui-egui/src/dialogs/file_ui/web.rs @@ -535,7 +535,7 @@ pub(super) fn web_confirm(app: &mut PhotocraftApp, f: &Map) -> Re let st = WebSettings::from_params(&p, "file.export.saveForWebLegacy").map_err(|e| e.to_string())?; let base = photocraft_doc::slices::base_name(&doc); let suggested = if has_slices && html { format!("{base}.html") } else { format!("{base}.{}", st.format.ext()) }; - app.pick_save(&suggested, move |app, path| { + app.pick_export(&suggested, move |app, path| { app.refocus(doc.id)?; if has_slices { let dir = path.rfind(['/', '\\']).map(|i| path[..i].to_string()).unwrap_or_else(|| ".".into()); diff --git a/crates/ui-egui/src/file_dialog.rs b/crates/ui-egui/src/file_dialog.rs index bddc6796d3..4a706970f2 100644 --- a/crates/ui-egui/src/file_dialog.rs +++ b/crates/ui-egui/src/file_dialog.rs @@ -148,17 +148,39 @@ impl PhotocraftApp { /// (#1826, #1866). A caller's explicit directory is kept. The chosen folder is remembered for /// the document the dialog was asked for. pub(crate) fn pick_save(&mut self, suggested: &str, then: impl FnOnce(&mut Self, String) -> Result + 'static) -> Result { + self.pick_save_in(suggested, false, then) + } + + /// [`Self::pick_save`] for the export flows (Export As, Quick Export, Save for Web, ...): a + /// bare file name starts in the folder of the last successful export (a preference, kept + /// across restarts), as in Photoshop, and beside the document where that folder is unset or + /// gone (#10192, [`export_start_dir`]). Once `then` has exported, the chosen file's folder is + /// remembered; a failed or cancelled export leaves the remembered folder alone. + pub(crate) fn pick_export(&mut self, suggested: &str, then: impl FnOnce(&mut Self, String) -> Result + 'static) -> Result { + self.pick_save_in(suggested, true, then) + } + + fn pick_save_in( + &mut self, + suggested: &str, + export: bool, + then: impl FnOnce(&mut Self, String) -> Result + 'static, + ) -> Result { let mut suggested = std::path::PathBuf::from(suggested); let doc = self.session.active().map(|d| d.doc.id); // Closed documents' folders are no longer needed. let open: Vec<_> = self.session.documents().iter().map(|d| d.doc.id).collect(); self.save_dirs.retain(|id, _| open.contains(id)); - if suggested.parent().is_some_and(|p| p.as_os_str().is_empty()) - && let Some(dir) = doc + if suggested.parent().is_some_and(|p| p.as_os_str().is_empty()) { + let document_dir = doc .and_then(|d| self.save_dirs.get(&d).cloned()) - .or_else(|| self.session.active().and_then(|d| d.path.as_deref()).and_then(|p| std::path::Path::new(p).parent()).map(Path::to_path_buf)) - { - suggested = dir.join(suggested); + // An export doesn't start in a folder that is gone. + .filter(|dir| !export || folder_exists(dir)) + .or_else(|| self.session.active().and_then(|d| d.path.as_deref()).and_then(|p| std::path::Path::new(p).parent()).map(Path::to_path_buf)); + let remembered = export.then(|| self.session.prefs().export.last_folder.clone()); + if let Some(dir) = export_start_dir(remembered.as_deref(), document_dir, folder_exists) { + suggested = dir.join(suggested); + } } // The format comes from the extension, so a name typed without one (common where file // extensions are hidden, #5039) gets the suggested file's extension, as Photoshop's @@ -174,7 +196,11 @@ impl PhotocraftApp { { app.save_dirs.insert(doc, dir.to_path_buf()); } - then(app, path) + let exported = then(app, path.clone())?; + if export { + app.remember_export_folder(&path); + } + Ok(exported) } _ => Err(UNEXPECTED.into()), })?; @@ -184,6 +210,16 @@ impl PhotocraftApp { Ok(asked) } + /// Remember the folder of an export that succeeded as where the next export dialog starts. + /// The web answers a download name, which has no folder, and leaves it alone. + fn remember_export_folder(&mut self, path: &str) { + let Some(dir) = Path::new(path).parent().filter(|p| !p.as_os_str().is_empty()) else { return }; + let dir = dir.to_string_lossy().into_owned(); + if self.session.prefs().export.last_folder != dir { + self.session.prefs.edit(|p| p.export.last_folder = dir); + } + } + /// Whether a save to `path` replaces a file the save dialog didn't ask about: the dialog /// answered `answered`, and [`saved_path`] changed it. A file at `path` that /// [`Services::same_file`](crate::Services::same_file) finds is the same entry as `answered` (the @@ -370,6 +406,28 @@ fn saved_path(app: &PhotocraftApp, answered: &str, suggested_ext: Option<&str>) } } +/// Whether `dir` is a folder on disk. The web has no file system, so nothing is remembered there. +#[cfg(not(target_arch = "wasm32"))] +fn folder_exists(dir: &Path) -> bool { + dir.is_dir() +} + +#[cfg(target_arch = "wasm32")] +fn folder_exists(_dir: &Path) -> bool { + false +} + +/// The folder an export dialog starts in (#10192): the `remembered` folder of the last export +/// while `exists` finds it, else the document's own folder (`document_dir`, whatever it is), +/// else none, which leaves the choice to the system dialog. +pub(crate) fn export_start_dir( + remembered: Option<&str>, + document_dir: Option, + exists: impl Fn(&Path) -> bool, +) -> Option { + remembered.filter(|d| !d.is_empty()).map(std::path::PathBuf::from).filter(|d| exists(d)).or(document_dir) +} + /// The folder an open dialog starts in: the directory of the most recent file (UI-217-3). pub(crate) fn last_used_dir(recent: &[String]) -> Option { let dir = std::path::Path::new(recent.first()?).parent()?; diff --git a/crates/ui-egui/src/file_dialog/tests.rs b/crates/ui-egui/src/file_dialog/tests.rs index 52149ab8e0..eed533891e 100644 --- a/crates/ui-egui/src/file_dialog/tests.rs +++ b/crates/ui-egui/src/file_dialog/tests.rs @@ -1484,3 +1484,95 @@ fn import_variable_data_sets_menu_asks_for_a_csv_and_imports_it() { app.poll_file_dialog(&ctx, None); assert_eq!(app.session.active().map(|d| d.doc.variables.data_sets.len()), Some(2)); } + +/// #10192: the folder an export dialog starts in, as a pure decision. +#[test] +fn export_start_dir_prefers_an_existing_remembered_folder() { + use std::path::PathBuf; + let doc = Some(PathBuf::from("/proj")); + let all_exist = |_: &Path| true; + let none_exist = |_: &Path| false; + // Remembered and present: used. + assert_eq!(export_start_dir(Some("/renders"), doc.clone(), all_exist), Some(PathBuf::from("/renders"))); + // Remembered but gone: the document's folder. + assert_eq!(export_start_dir(Some("/renders"), doc.clone(), none_exist), doc); + // Nothing remembered (unset or empty): the document's folder, or nothing for an untitled one. + assert_eq!(export_start_dir(None, doc.clone(), all_exist), doc); + assert_eq!(export_start_dir(Some(""), doc.clone(), all_exist), doc); + assert_eq!(export_start_dir(None, None, all_exist), None); + assert_eq!(export_start_dir(Some("/renders"), None, none_exist), None); +} + +/// #10192: exports start in the last successfully exported folder, across documents; a failed or +/// cancelled export, a folder that is gone and other save dialogs change nothing. +#[test] +fn exports_start_in_the_last_exported_folder() { + let (mut app, open, _) = app(); + let ctx = egui::Context::default(); + let root = tempfile::tempdir().unwrap(); + let renders = root.path().join("renders"); + std::fs::create_dir(&renders).unwrap(); + let renders_text = renders.to_string_lossy().into_owned(); + app.session.active_mut().unwrap().path = Some("/proj/original.psd".into()); + let suggested = |open: &Open| match &open.borrow()[0].0 { + FileDialogRequest::Save { suggested } => suggested.clone(), + other => panic!("save dialog expected, got {other:?}"), + }; + let remembered = |app: &PhotocraftApp| app.session.prefs().export.last_folder.clone(); + // No export yet: beside the document. Cancelled, nothing is remembered. + app.pick_export("a.png", |_, _| Ok(Value::Null)).unwrap(); + app.poll_file_dialog(&ctx, None); + assert_eq!(suggested(&open), joined("/proj", "a.png")); + answer(&open, None); + app.poll_file_dialog(&ctx, None); + assert_eq!(remembered(&app), ""); + // The user picks another folder, but the export fails: nothing is remembered. + app.pick_export("a.png", |_, _| Err("disk full".into())).unwrap(); + app.poll_file_dialog(&ctx, None); + answer(&open, Some(FileDialogAnswer::SaveTo(joined(&renders_text, "a.png")))); + app.poll_file_dialog(&ctx, None); + assert_eq!(remembered(&app), ""); + // A successful export is remembered... + app.pick_export("a.png", |_, _| Ok(Value::Null)).unwrap(); + app.poll_file_dialog(&ctx, None); + answer(&open, Some(FileDialogAnswer::SaveTo(joined(&renders_text, "a.png")))); + app.poll_file_dialog(&ctx, None); + assert_eq!(remembered(&app), renders_text); + // ...and starts the next export, of any document, even an untitled one. + app.run("file.new", json!({"width": 4, "height": 4})).unwrap(); + app.pick_export("b.png", |_, _| Ok(Value::Null)).unwrap(); + app.poll_file_dialog(&ctx, None); + assert_eq!(suggested(&open), joined(&renders_text, "b.png")); + // A cancelled dialog changes nothing. + answer(&open, None); + app.poll_file_dialog(&ctx, None); + assert_eq!(remembered(&app), renders_text); + // Other save dialogs keep their own start. + app.pick_save("c.psd", |_, _| Ok(Value::Null)).unwrap(); + app.poll_file_dialog(&ctx, None); + assert_eq!(suggested(&open), "c.psd"); + answer(&open, None); + app.poll_file_dialog(&ctx, None); + // The folder disappears: back to the document's folder. + std::fs::remove_dir(&renders).unwrap(); + app.session.set_active(0); + app.pick_export("d.png", |_, _| Ok(Value::Null)).unwrap(); + app.poll_file_dialog(&ctx, None); + assert_eq!(suggested(&open), joined("/proj", "d.png")); + answer(&open, None); + app.poll_file_dialog(&ctx, None); +} + +#[test] +fn the_last_export_folder_is_a_preference_that_old_files_lack() { + let prefs = photocraft_engine::prefs::Preferences::default(); + assert_eq!(prefs.export.last_folder, ""); + let mut json = serde_json::to_value(&prefs).unwrap(); + json["export"]["lastFolder"] = "/renders".into(); + let loaded: photocraft_engine::prefs::Preferences = serde_json::from_value(json.clone()).unwrap(); + assert_eq!(loaded.export.last_folder, "/renders"); + // Settings saved before the field existed still load. + json["export"].as_object_mut().unwrap().remove("lastFolder"); + let old: photocraft_engine::prefs::Preferences = serde_json::from_value(json).unwrap(); + assert_eq!(old.export.last_folder, ""); +} From 6f2fe52bb5387a680310a1bcb3b5cc00e081d916 Mon Sep 17 00:00:00 2001 From: qvest-ssels <214787280+qvest-ssels@users.noreply.github.com> Date: Sat, 31 Oct 2026 08:38:51 +0100 Subject: [PATCH 032/219] fix(ui): a failed export doesn't change where the next export starts (#10192) The document's save folder is now stored for an export only once the export has succeeded; other saves keep storing it before writing, as before. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/file_dialog.rs | 12 ++++++++---- crates/ui-egui/src/file_dialog/tests.rs | 6 ++++++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/file_dialog.rs b/crates/ui-egui/src/file_dialog.rs index 4a706970f2..e97318b2e2 100644 --- a/crates/ui-egui/src/file_dialog.rs +++ b/crates/ui-egui/src/file_dialog.rs @@ -191,13 +191,17 @@ impl PhotocraftApp { FileDialogAnswer::SaveTo(path) => { let path = saved_path(app, &path, suggested_ext.as_deref()); // The web answers a download name, which has no folder to remember. - if let Some(doc) = doc - && let Some(dir) = Path::new(&path).parent().filter(|p| !p.as_os_str().is_empty()) - { - app.save_dirs.insert(doc, dir.to_path_buf()); + let dir = doc.zip(Path::new(&path).parent().filter(|p| !p.as_os_str().is_empty()).map(Path::to_path_buf)); + // An export's folder only once it has exported: a failed one doesn't move where + // the next export starts. + if !export && let Some((doc, dir)) = dir.clone() { + app.save_dirs.insert(doc, dir); } let exported = then(app, path.clone())?; if export { + if let Some((doc, dir)) = dir { + app.save_dirs.insert(doc, dir); + } app.remember_export_folder(&path); } Ok(exported) diff --git a/crates/ui-egui/src/file_dialog/tests.rs b/crates/ui-egui/src/file_dialog/tests.rs index eed533891e..82ae0097aa 100644 --- a/crates/ui-egui/src/file_dialog/tests.rs +++ b/crates/ui-egui/src/file_dialog/tests.rs @@ -1532,6 +1532,12 @@ fn exports_start_in_the_last_exported_folder() { answer(&open, Some(FileDialogAnswer::SaveTo(joined(&renders_text, "a.png")))); app.poll_file_dialog(&ctx, None); assert_eq!(remembered(&app), ""); + // ...and the next export still starts beside the document, not in the failed export's folder. + app.pick_export("a.png", |_, _| Ok(Value::Null)).unwrap(); + app.poll_file_dialog(&ctx, None); + assert_eq!(suggested(&open), joined("/proj", "a.png")); + answer(&open, None); + app.poll_file_dialog(&ctx, None); // A successful export is remembered... app.pick_export("a.png", |_, _| Ok(Value::Null)).unwrap(); app.poll_file_dialog(&ctx, None); From 79ead9a4af28f76a93e20ac9951d2b7a8ac1f01f Mon Sep 17 00:00:00 2001 From: qvest-ssels <214787280+qvest-ssels@users.noreply.github.com> Date: Sat, 31 Oct 2026 09:12:10 +0100 Subject: [PATCH 033/219] docs: tidy comments (review) Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/file_dialog.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/file_dialog.rs b/crates/ui-egui/src/file_dialog.rs index e97318b2e2..cb30df9a44 100644 --- a/crates/ui-egui/src/file_dialog.rs +++ b/crates/ui-egui/src/file_dialog.rs @@ -151,11 +151,9 @@ impl PhotocraftApp { self.pick_save_in(suggested, false, then) } - /// [`Self::pick_save`] for the export flows (Export As, Quick Export, Save for Web, ...): a - /// bare file name starts in the folder of the last successful export (a preference, kept - /// across restarts), as in Photoshop, and beside the document where that folder is unset or - /// gone (#10192, [`export_start_dir`]). Once `then` has exported, the chosen file's folder is - /// remembered; a failed or cancelled export leaves the remembered folder alone. + /// [`Self::pick_save`] for the export flows (Export As, Quick Export, Save for Web, ...): starts in + /// the folder [`export_start_dir`] picks (#10192), and once `then` has exported, remembers the + /// chosen file's folder in `export.lastFolder`; a failed or cancelled export leaves it alone. pub(crate) fn pick_export(&mut self, suggested: &str, then: impl FnOnce(&mut Self, String) -> Result + 'static) -> Result { self.pick_save_in(suggested, true, then) } From 854f6ec9009317dcfd61ddcb9c82e9bcc0464e13 Mon Sep 17 00:00:00 2001 From: VoiceLessQ Date: Mon, 2 Nov 2026 00:40:54 -0200 Subject: [PATCH 034/219] retouch tests: a wrapped stroke has to change the pixels it covers The corner check passed on the pre-stroke texture, and the oversized-dab check passed on a no-op. Spot Healing stays excepted in the second one: it has nothing left to sample there. --- crates/engine/src/cmd/retouch_cmds/tests.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/retouch_cmds/tests.rs b/crates/engine/src/cmd/retouch_cmds/tests.rs index 38fb98fdf3..6946df3ff3 100644 --- a/crates/engine/src/cmd/retouch_cmds/tests.rs +++ b/crates/engine/src/cmd/retouch_cmds/tests.rs @@ -2699,8 +2699,9 @@ fn spot_healing_across_the_canvas_corner_heals_every_copy_of_the_stroke_and_noth let after = layer_pixels(&s); let n = after.len() / (WRAP.0 * WRAP.1) as usize; let px = |v: &[f32], x: usize, y: usize| v[(y * WRAP.0 as usize + x) * n..][..n].to_vec(); - for (x, y) in [(1, 1), (46, 1), (1, 34), (46, 34)] { - assert!(px(&after, x, y)[0] > 0.1, "{kind}: ({x}, {y}) healed: {:?}", px(&after, x, y)); + assert!(px(&after, 1, 1)[0] > 0.1, "{kind}: the blemish healed: {:?}", px(&after, 1, 1)); + for (x, y) in [(46, 1), (1, 34), (46, 34)] { + assert_ne!(px(&after, x, y), px(&before, x, y), "{kind}: ({x}, {y}) is under the stroke"); } for (x, y) in [(24, 18), (10, 18), (24, 4), (38, 28)] { assert_eq!(px(&after, x, y), px(&before, x, y), "{kind}: ({x}, {y}) is far from the stroke"); @@ -2715,6 +2716,7 @@ fn a_wrapped_dab_bigger_than_the_canvas_stays_on_it() { for (cmd, extra, setup, _) in cases { let mut s = session(16, 12, 8, "rgb"); setup(&mut s); + let before = layer_pixels(&s); let mut p = json!({"points": [[8, 6], [30, 20]], "size": 60, "hardness": 0, "wrap": true}); for (k, v) in extra.as_object().unwrap() { p[k] = v.clone(); @@ -2725,6 +2727,10 @@ fn a_wrapped_dab_bigger_than_the_canvas_stays_on_it() { let st = s.active().unwrap(); let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); assert!([(-1, 5), (16, 5), (5, -1), (5, 12), (40, 30)].iter().all(|&(x, y)| surf.rgba(x, y)[3] == 0.0), "{cmd}: nothing lands off the canvas"); + // Spot Healing has nothing left to sample here (the next test). + if cmd != "paint.spotHealing" { + assert!(layer_pixels(&s) != before, "{cmd} {extra}: the stroke changed the canvas"); + } } } From a7568b70b747786e027f3133b17434c18e01beed Mon Sep 17 00:00:00 2001 From: VoiceLessQ Date: Mon, 2 Nov 2026 01:27:12 -0200 Subject: [PATCH 035/219] retouch tests: a wrapped corner has to heal as well as change --- crates/engine/src/cmd/retouch_cmds/tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/engine/src/cmd/retouch_cmds/tests.rs b/crates/engine/src/cmd/retouch_cmds/tests.rs index 6946df3ff3..ede2cb9348 100644 --- a/crates/engine/src/cmd/retouch_cmds/tests.rs +++ b/crates/engine/src/cmd/retouch_cmds/tests.rs @@ -2701,6 +2701,7 @@ fn spot_healing_across_the_canvas_corner_heals_every_copy_of_the_stroke_and_noth let px = |v: &[f32], x: usize, y: usize| v[(y * WRAP.0 as usize + x) * n..][..n].to_vec(); assert!(px(&after, 1, 1)[0] > 0.1, "{kind}: the blemish healed: {:?}", px(&after, 1, 1)); for (x, y) in [(46, 1), (1, 34), (46, 34)] { + assert!(px(&after, x, y)[0] > 0.1, "{kind}: ({x}, {y}) healed: {:?}", px(&after, x, y)); assert_ne!(px(&after, x, y), px(&before, x, y), "{kind}: ({x}, {y}) is under the stroke"); } for (x, y) in [(24, 18), (10, 18), (24, 4), (38, 28)] { From 52ba921651368e2195dcbcd705648d5d7cfb6001 Mon Sep 17 00:00:00 2001 From: ljkx <177671075+ljkx@users.noreply.github.com> Date: Mon, 2 Nov 2026 08:29:56 +0100 Subject: [PATCH 036/219] Test: Photo Filter remembers Preserve Luminosity turned off photo_filter_opens_at_its_last_settings only confirmed a filter and density, so Preserve Luminosity kept its default (on) and a regression dropping it would still pass. It now turns Preserve Luminosity off, confirms, reopens and checks it stays off with the rest of the last settings (CodeRabbit review of #9970). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/adjust_dialog/tests.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/ui-egui/src/dialogs/adjust_dialog/tests.rs b/crates/ui-egui/src/dialogs/adjust_dialog/tests.rs index 1f6090025c..a06c1dc800 100644 --- a/crates/ui-egui/src/dialogs/adjust_dialog/tests.rs +++ b/crates/ui-egui/src/dialogs/adjust_dialog/tests.rs @@ -959,6 +959,15 @@ fn photo_filter_opens_at_its_last_settings() { let again = open(&mut app, cmd).unwrap(); assert_eq!(crate::filter_dialog::params_of(&app.ui.dialog_mut(again).unwrap().fields.clone()), sample, "reopened at the last settings"); + // Preserve Luminosity is on by default; turned off and confirmed, it reopens off. + assert_eq!(neutral["preserveLuminosity"], json!(true)); + app.ui.dialog_mut(again).unwrap().fields.insert("preserveLuminosity".into(), json!(false)); + crate::dialogs::confirm(&mut app, again).unwrap(); + let again = open(&mut app, cmd).unwrap(); + let reopened = crate::filter_dialog::params_of(&app.ui.dialog_mut(again).unwrap().fields.clone()); + assert_eq!(reopened["preserveLuminosity"], json!(false), "Preserve Luminosity off is remembered"); + assert_eq!(reopened["color"], sample["color"], "with the rest of the last settings"); + assert_eq!(reopened["density"], sample["density"]); app.ui.dialogs.clear(); let smart = open_neutral(&mut app, cmd).unwrap(); assert_eq!(crate::filter_dialog::params_of(&app.ui.dialog_mut(smart).unwrap().fields.clone()), neutral, "open_neutral ignores them"); From dec95112a06a56480b408a566b28adc9c5f6de53 Mon Sep 17 00:00:00 2001 From: ljkx <177671075+ljkx@users.noreply.github.com> Date: Mon, 2 Nov 2026 08:53:33 +0100 Subject: [PATCH 037/219] Remove gradient stops dragged off the bar in every stop editor (#10742) Dragging a colour stop down, or an opacity stop up, by about a marker's height off the gradient bar now removes it on release in every stop editor, as in Photoshop's Gradient Editor. At least two stops of that kind always stay. While the stop is off the bar the ramp and the preview show it gone, and dragging it back onto the bar cancels. The Gradient Editor, Properties and shape gradient strips already did this. Gradient Map had no drag-off at all, and Layer Style's Gradient Overlay only deleted after about 44 pt and showed nothing until release. All four now share stop_dragged_off() in gradient_ui.rs (26 pt past the bar edge, by kind). Gradient Map and Gradient Overlay take the stop out live and put it back if it returns. Adds harness tests for drag-off removal, the two-stop minimum, opacity stops dragged up and drag-back cancel, and updates docs/ui-parity.md. Co-Authored-By: Claude Opus 5.5 --- .../dialogs/adjust_editors/gradient_map.rs | 41 +++++- .../src/dialogs/adjust_editors/ui_tests.rs | 49 +++++++ .../src/dialogs/layer_style/gradient_field.rs | 112 ++++++++++++--- crates/ui-egui/src/tools/gradient_ui.rs | 21 ++- .../src/tools/gradient_ui/editor_tests.rs | 133 ++++++++++++++++++ crates/ui-egui/src/tools/shape_gradient_ui.rs | 4 +- docs/ui-parity.md | 4 +- 7 files changed, 330 insertions(+), 34 deletions(-) diff --git a/crates/ui-egui/src/dialogs/adjust_editors/gradient_map.rs b/crates/ui-egui/src/dialogs/adjust_editors/gradient_map.rs index 7079b11972..b15f06ba0c 100644 --- a/crates/ui-egui/src/dialogs/adjust_editors/gradient_map.rs +++ b/crates/ui-egui/src/dialogs/adjust_editors/gradient_map.rs @@ -57,6 +57,9 @@ struct MapStopDrag { index: usize, offset: f32, changed: bool, + /// The dragged stop while it is pulled off the bar (removed from the stops, so the preview + /// shows it gone); dragging back onto the bar puts it back. + removed: Option, } /// Keep selection on the same stop when it crosses another stop (including coincident stops). @@ -87,19 +90,43 @@ fn gradient_map_strip(ui: &mut egui::Ui, stops: &mut Vec, cx: &EditorCx, s { *selected = index; let offset = stops.get(index).map_or(0.0, |s| pos.x - x_of(s.0)); - ui.data_mut(|d| d.insert_temp(key, MapStopDrag { index, offset, changed: false })); + ui.data_mut(|d| d.insert_temp(key, MapStopDrag { index, offset, changed: false, removed: None })); } if let Some(mut drag) = ui.data(|d| d.get_temp::(key)) { if (resp.dragged() || resp.drag_stopped()) && let Some(pos) = resp.interact_pointer_pos() { let location = round(loc_of(pos.x - drag.offset), 0.001); - let changed = stops.get(drag.index).is_some_and(|s| s.0 != location); - if let Some(index) = move_map_stop(stops, drag.index, location) { - drag.index = index; - drag.changed |= changed; - *selected = index; - edit.changed |= changed; + // Pulled down off the bar, the stop is removed while at least two remain, as in + // Photoshop; released there it stays deleted, dragged back it returns. + let off = crate::gradient_ui::stop_dragged_off(bar, pos, false); + match drag.removed { + None if off && stops.len() > 2 && drag.index < stops.len() => { + drag.removed = Some(stops.remove(drag.index)); + *selected = drag.index.min(stops.len().saturating_sub(1)); + drag.changed = true; + edit.changed = true; + } + Some(stop) if !off => { + stops.push(stop); + let index = stops.len() - 1; + drag.removed = None; + if let Some(index) = move_map_stop(stops, index, location) { + drag.index = index; + *selected = index; + } + edit.changed = true; + } + Some(_) => {} + None => { + let changed = stops.get(drag.index).is_some_and(|s| s.0 != location); + if let Some(index) = move_map_stop(stops, drag.index, location) { + drag.index = index; + drag.changed |= changed; + *selected = index; + edit.changed |= changed; + } + } } } if resp.drag_stopped() { diff --git a/crates/ui-egui/src/dialogs/adjust_editors/ui_tests.rs b/crates/ui-egui/src/dialogs/adjust_editors/ui_tests.rs index f41fe4eca0..bb0f737265 100644 --- a/crates/ui-egui/src/dialogs/adjust_editors/ui_tests.rs +++ b/crates/ui-egui/src/dialogs/adjust_editors/ui_tests.rs @@ -560,6 +560,55 @@ fn gradient_map_strip_adds_selects_and_removes_stops_without_deleting_the_last_p assert_eq!(layer(&h).1, original, "keep two stops"); } +/// #10742: a Gradient Map stop dragged down off the bar is removed on release (one history step); +/// while it is off the bar the live preview already leaves it out, and dragging it back onto the +/// bar before release puts it back. The last two stops can't be dragged off. +#[test] +fn gradient_map_stop_dragged_down_off_the_bar_is_removed_and_dragging_back_cancels() { + let mut h = app_harness("gradientMap", "rgb", 8); + let (id, _) = layer(&h); + h.state_mut().run("layer.setAdjustment", json!({"layer": id.0, "stops": [[0, "#000000"], [0.5, "#ff0000"], [1, "#ffffff"]]})).unwrap(); + h.run_steps(3); + let three = layer(&h).1; + let live_stops = |h: &Harness<'_, PhotocraftApp>| h.state().live_adjust.as_ref().and_then(|(_, v)| v["stops"].as_array().map(Vec::len)); + // Off and back: nothing removed. + let start = map_handle(&h, 0.5); + let away = start + vec2(0.0, 30.0); + h.hover_at(start); + h.run_steps(1); + h.drag_at(start); + h.run_steps(1); + h.hover_at(start + vec2(0.0, 15.0)); + h.run_steps(1); + h.hover_at(away); + h.run_steps(1); + assert_eq!(live_stops(&h), Some(2), "off the bar the preview leaves the stop out"); + h.hover_at(start); + h.run_steps(1); + assert_eq!(live_stops(&h), Some(3), "back on the bar the stop returns"); + h.drop_at(start); + h.run_steps(3); + assert_eq!(map_stops(&layer(&h).1), map_stops(&three), "dragging back cancels the removal"); + // Off and released: removed in one step. + let steps = h.state().session.active().unwrap().history.past_len(); + drag(&mut h, start, away); + assert_eq!(map_stops(&layer(&h).1), vec![(0.0, [0, 0, 0]), (1.0, [255, 255, 255])]); + assert_eq!(h.state().session.active().unwrap().history.past_len(), steps + 1); + assert!(h.state().live_adjust.is_none()); + h.state_mut().run("edit.undo", json!({})).unwrap(); + h.run_steps(3); + assert_eq!(layer(&h).1, three); + h.state_mut().run("edit.redo", json!({})).unwrap(); + h.run_steps(3); + // Two stops stay. + let two = layer(&h).1; + for at in [0.0, 1.0] { + let from = map_handle(&h, at); + drag(&mut h, from, from + vec2(0.0, 40.0)); + assert_eq!(map_stops(&layer(&h).1), map_stops(&two), "two stops always remain"); + } +} + #[test] fn gradient_map_drag_clamps_at_the_endpoints_and_reverse_keeps_stop_coordinates() { let mut h = app_harness("gradientMap", "rgb", 8); diff --git a/crates/ui-egui/src/dialogs/layer_style/gradient_field.rs b/crates/ui-egui/src/dialogs/layer_style/gradient_field.rs index 8abbefbbd7..c59b666b3f 100644 --- a/crates/ui-egui/src/dialogs/layer_style/gradient_field.rs +++ b/crates/ui-egui/src/dialogs/layer_style/gradient_field.rs @@ -23,6 +23,16 @@ pub(super) const KEY: &str = "stops"; /// The dialog field holding the transparency stops. pub(super) const OPACITY_KEY: &str = "opacityStops"; +/// A colour stop being dragged on the page's ramp. +#[derive(Clone, Copy, Debug)] +struct StopDrag { + /// The stop's index (it changes as the stop passes others). + index: usize, + /// The stop while it is pulled off the bar: deleted from the gradient, so the ramp and the + /// canvas show it gone; dragging back onto the bar puts it back. + removed: Option<(f32, Color)>, +} + /// Where the last drawn ramp bar is, for tests that drive it: `(effect id, bar rect)`. pub(crate) fn bar_id() -> egui::Id { egui::Id::unique("fx-gradient-bar") @@ -207,39 +217,64 @@ pub(super) fn row(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map(drag_key)); + ui.data_mut(|d| d.remove::(drag_key)); } } - let dragging: Option = ui.data(|d| d.get_temp(drag_key)); - if let (Some(i), Some(p)) = (dragging, resp.interact_pointer_pos()) { - let away = p.y > outer.bottom() + 24.0 || p.y < bar.top() - 24.0; + let dragging: Option = ui.data(|d| d.get_temp(drag_key)); + if let (Some(mut drag), Some(p)) = (dragging, resp.interact_pointer_pos()) { + let off = crate::gradient_ui::stop_dragged_off(bar, p, false); + let loc = loc_of(p.x); if resp.drag_stopped() { - ui.data_mut(|d| d.remove::(drag_key)); - if away && stops.len() > 2 { - edit(app, f, effect, disp, json!({"action": "delete", "index": i})); - ui.data_mut(|d| d.insert_temp(sel_key, 0usize)); - } - } else if resp.dragged() && !away { - let loc = loc_of(p.x); - if (loc - stops.get(i).map_or(loc, |s| s.0)).abs() > 1e-4 - && let Some(at) = edit(app, f, effect, disp, json!({"action": "move", "index": i, "location": loc})) - { - ui.data_mut(|d| d.insert_temp(drag_key, at)); - ui.data_mut(|d| d.insert_temp(sel_key, at)); + // Released off the strip, the stop is already gone. + ui.data_mut(|d| d.remove::(drag_key)); + } else if resp.dragged() { + match drag.removed { + None if off && stops.len() > 2 => { + if let Some(stop) = stops.get(drag.index).copied() + && edit(app, f, effect, disp, json!({"action": "delete", "index": drag.index})).is_some() + { + drag.removed = Some(stop); + ui.data_mut(|d| d.insert_temp(sel_key, 0usize)); + } + } + Some((_, color)) if !off => { + let mut back = stops.clone(); + back.push((loc, color)); + back.sort_by(|a, b| a.0.total_cmp(&b.0)); + let index = back.iter().rposition(|s| s.0 == loc).unwrap_or(0); + let mut fill = fill.clone(); + if let Fill::Gradient { stops, .. } = &mut fill { + *stops = back; + } + commit(f, effect, disp, &fill); + drag = StopDrag { index, removed: None }; + ui.data_mut(|d| d.insert_temp(sel_key, index)); + } + Some(_) => {} + None => { + if (loc - stops.get(drag.index).map_or(loc, |s| s.0)).abs() > 1e-4 + && let Some(at) = edit(app, f, effect, disp, json!({"action": "move", "index": drag.index, "location": loc})) + { + drag.index = at; + ui.data_mut(|d| d.insert_temp(sel_key, at)); + } + } } + ui.data_mut(|d| d.insert_temp(drag_key, drag)); } } else if resp.drag_stopped() { - ui.data_mut(|d| d.remove::(drag_key)); + ui.data_mut(|d| d.remove::(drag_key)); } if resp.double_clicked() && let Some(p) = resp.interact_pointer_pos() @@ -385,6 +420,41 @@ mod tests { assert_eq!(stops_in(h.state(), parent).len(), 2); } + /// #10742: on the Gradient Overlay page a colour stop dragged down off the bar (about a + /// marker's height) disappears while it is off, is gone on release, and comes back when + /// dragged back onto the bar first. + #[test] + fn a_stop_dragged_off_the_ramp_shows_gone_and_dragging_back_cancels() { + let (mut app, parent) = dialog_app(); + let effect = crate::layer_style::effects_of(&app.ui.dialogs[0].fields)[0]["id"].as_str().unwrap().to_string(); + set_param(&mut app.ui.dialog_mut(parent).unwrap().fields, &effect, KEY, json!([[0.0, "#000000"], [0.5, "#ff0000"], [1.0, "#ffffff"]])); + let mut h = harness(app, parent); + let (_, bar): (String, Rect) = h.ctx.data(|d| d.get_temp(bar_id())).expect("the ramp was drawn"); + let mid = pos2(bar.center().x, bar.bottom() + 9.0); + let away = pos2(mid.x, mid.y + 22.0); + press(&mut h, mid); + h.event(egui::Event::PointerMoved(pos2(mid.x, mid.y + 11.0))); + h.run_steps(1); + h.event(egui::Event::PointerMoved(away)); + h.run_steps(1); + assert_eq!(stops_in(h.state(), parent).len(), 2, "off the bar the stop is gone"); + h.event(egui::Event::PointerMoved(mid)); + h.run_steps(1); + let back = stops_in(h.state(), parent); + assert_eq!(back.len(), 3, "back on the bar it returns: {back:?}"); + release(&mut h, mid); + let back = stops_in(h.state(), parent); + assert!(back.len() == 3 && (back[1].0 - 0.5).abs() < 0.01 && super::super::hex(&back[1].1) == "#ff0000", "{back:?}"); + press(&mut h, mid); + h.event(egui::Event::PointerMoved(pos2(mid.x, mid.y + 11.0))); + h.run_steps(1); + h.event(egui::Event::PointerMoved(away)); + h.run_steps(1); + release(&mut h, away); + let stops = stops_in(h.state(), parent); + assert_eq!(stops.iter().map(|s| super::super::hex(&s.1)).collect::>(), ["#000000", "#ffffff"], "{stops:?}"); + } + /// A preset from the Gradients panel becomes the page's stops; the Delete button and the /// Location field work on the selected stop. #[test] diff --git a/crates/ui-egui/src/tools/gradient_ui.rs b/crates/ui-egui/src/tools/gradient_ui.rs index 1978f27086..12d2faeedd 100644 --- a/crates/ui-egui/src/tools/gradient_ui.rs +++ b/crates/ui-egui/src/tools/gradient_ui.rs @@ -516,6 +516,19 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform } } +/// How far past the gradient bar's edge (in points) a dragged stop comes off it: the stop marker +/// (about 15 pt) plus roughly its height again, as in Photoshop's Gradient Editor. +pub(crate) const STOP_OFF_BAR: f32 = 26.0; + +/// Whether a stop dragged to `pos` is pulled off `bar`, as in Photoshop's Gradient Editor: a +/// colour stop (under the bar) dragged down, an opacity stop (above it) dragged up, about a +/// marker's height past the marker. Every stop strip uses this, so the gesture is the same in +/// the Gradient Editor, Properties, the shape gradient popover, Gradient Map and Layer Style. +/// The caller removes the stop only while more than two of its kind remain. +pub(crate) fn stop_dragged_off(bar: Rect, pos: Pos2, opacity: bool) -> bool { + if opacity { pos.y < bar.top() - STOP_OFF_BAR } else { pos.y > bar.bottom() + STOP_OFF_BAR } +} + // ---------------------------------------------------------------- options bar /// A ramp of RGBA stops over a checkerboard in `r`. @@ -995,12 +1008,14 @@ fn stop_strip(ui: &mut egui::Ui, key: egui::Id, f: &Fill, midpoints: bool, pendi let dragging: Option = ui.data(|d| d.get_temp(key)); let mut preview = None; if let (Some(m), Some(pos)) = (dragging, resp.interact_pointer_pos()) { - let off = if pos.y > bar.bottom() + 28.0 || pos.y < bar.top() - 26.0 { 1.0 } else { 0.0 }; + // Pulled off the bar, the stop is deleted (the preview shows it gone; dragging back + // cancels), while at least two of its kind remain. + let off = stop_dragged_off(bar, pos, matches!(m, Marker::Opacity(_))); let loc = loc_of(pos.x); let edit = match m { - Marker::Color(i) if off > 0.0 && stops.len() > 2 => json!({"action": "delete", "index": i}), + Marker::Color(i) if off && stops.len() > 2 => json!({"action": "delete", "index": i}), Marker::Color(i) => json!({"action": "move", "index": i, "location": loc}), - Marker::Opacity(i) if off > 0.0 && ops.len() > 2 => json!({"action": "delete", "kind": "opacity", "index": i}), + Marker::Opacity(i) if off && ops.len() > 2 => json!({"action": "delete", "kind": "opacity", "index": i}), Marker::Opacity(i) => json!({"action": "move", "kind": "opacity", "index": i, "location": loc}), Marker::Mid(i) => { let (a, b) = (sorted.get(i).map_or(0.0, |s| s.0), sorted.get(i + 1).map_or(1.0, |s| s.0)); diff --git a/crates/ui-egui/src/tools/gradient_ui/editor_tests.rs b/crates/ui-egui/src/tools/gradient_ui/editor_tests.rs index 8bc5019ddf..c761838f36 100644 --- a/crates/ui-egui/src/tools/gradient_ui/editor_tests.rs +++ b/crates/ui-egui/src/tools/gradient_ui/editor_tests.rs @@ -144,3 +144,136 @@ fn preset_editor_opacity_fields_do_not_edit_document_history() { assert_eq!(h.state().session.active().unwrap().doc, before); assert_eq!(h.state().session.active().unwrap().history.past_len(), history); } + +/// Press at `points[0]`, move through the rest, then release at the last one; `during` runs while +/// the button is still down at the last point. +fn drag_through(h: &mut Harness, points: &[Pos2], during: impl FnOnce(&mut Harness)) { + let (Some(&from), Some(&to)) = (points.first(), points.last()) else { return }; + h.event(egui::Event::PointerMoved(from)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + for &p in &points[1..] { + // Two small steps per leg, as a real pointer reports. + let last = h.ctx.input(|i| i.pointer.latest_pos()).unwrap_or(p); + h.event(egui::Event::PointerMoved(last + (p - last) * 0.5)); + h.run_steps(1); + h.event(egui::Event::PointerMoved(p)); + h.run_steps(1); + } + during(h); + h.event(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(3); +} + +/// A stop marker's centre on the strip: colour stops under the bar, opacity stops above it. +fn marker(h: &Harness, location: f32, opacity: bool) -> Pos2 { + let rect = h.get_by_label("Gradient stops").rect(); + pos2(rect.left() + 8.0 + location * (rect.width() - 16.0), rect.top() + if opacity { 6.0 } else { 50.0 }) +} + +fn counts(h: &Harness, sink: Sink) -> (usize, usize) { + let Fill::Gradient { stops, opacity_stops, .. } = sink.fill(h.state()).unwrap() else { panic!() }; + (stops.len(), opacity_stops.len()) +} + +/// The strip as drawn while the pointer is down: the pending edit applied to the gradient. +fn shown(h: &Harness, sink: Sink) -> (usize, usize) { + let f = sink.fill(h.state()).unwrap(); + let pending = match sink { + Sink::Layer(_) => h.state().gradient.panel.as_ref().map(|(.., p)| p.clone()), + Sink::Preset => h.state().gradient.preset_stop.clone(), + }; + let (fg, bg) = (h.state().session.tools.foreground, h.state().session.tools.background); + let f = pending.and_then(|p| cmds::apply_stop(&f, &p, fg, bg).ok()).unwrap_or(f); + let Fill::Gradient { stops, opacity_stops, .. } = f else { panic!() }; + (stops.len(), opacity_stops.len()) +} + +/// #10742: a colour stop dragged down off the bar (by about a marker's height) is removed on +/// release; while it is off the bar the strip already shows it gone; one undo step restores it. +#[test] +fn a_color_stop_dragged_down_off_the_bar_is_removed() { + for layer in [true, false] { + let (mut h, sink) = editor(layer); + let history = h.state().session.active().unwrap().history.past_len(); + let before = sink.fill(h.state()).unwrap(); + let from = marker(&h, 0.5, false); + drag_through(&mut h, &[from, from + vec2(0.0, 22.0)], |h| { + assert_eq!(shown(h, sink), (2, 3), "layer {layer}: the dragged-off stop is gone from the preview"); + assert_eq!(sink.fill(h.state()).unwrap(), before, "layer {layer}: nothing is committed before release"); + }); + let Fill::Gradient { stops, .. } = sink.fill(h.state()).unwrap() else { panic!() }; + assert_eq!(stops.iter().map(|s| s.0).collect::>(), [0.0, 1.0], "layer {layer}: the middle stop is removed"); + assert_eq!(counts(&h, sink), (2, 3), "layer {layer}: opacity stops are untouched"); + if layer { + assert_eq!(h.state().session.active().unwrap().history.past_len(), history + 1, "one undo step"); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(sink.fill(h.state()).unwrap(), before); + } + } +} + +/// Dragging a stop off the bar and back onto it before releasing cancels the removal. +#[test] +fn dragging_a_stop_back_onto_the_bar_cancels_the_removal() { + for layer in [true, false] { + let (mut h, sink) = editor(layer); + for opacity in [false, true] { + let from = marker(&h, 0.5, opacity); + let away = from + vec2(0.0, if opacity { -30.0 } else { 30.0 }); + h.event(egui::Event::PointerMoved(from)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + h.event(egui::Event::PointerMoved(from + (away - from) * 0.5)); + h.run_steps(1); + h.event(egui::Event::PointerMoved(away)); + h.run_steps(1); + assert_eq!(shown(&h, sink), if opacity { (3, 2) } else { (2, 3) }, "layer {layer}: off the bar the stop is gone"); + // Back on the bar before release: the stop returns and stays where it was. + h.event(egui::Event::PointerMoved(from)); + h.run_steps(1); + assert_eq!(shown(&h, sink), (3, 3), "layer {layer}: back on the bar, the stop is shown"); + h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(3); + let Fill::Gradient { stops, opacity_stops, .. } = sink.fill(h.state()).unwrap() else { panic!() }; + let locations: Vec = if opacity { opacity_stops.iter().map(|s| s.0).collect() } else { stops.iter().map(|s| s.0).collect() }; + assert_eq!((stops.len(), opacity_stops.len()), (3, 3), "layer {layer} opacity {opacity}: nothing removed"); + assert!((locations[1] - 0.5).abs() < 1e-3, "layer {layer} opacity {opacity}: {locations:?}"); + } + } +} + +/// The last two colour stops stay: dragging one off the bar leaves it where it is. +#[test] +fn the_last_two_color_stops_cannot_be_dragged_off() { + let (mut h, sink) = editor(true); + let from = marker(&h, 0.5, false); + drag_through(&mut h, &[from, from + vec2(0.0, 60.0)], |_| {}); + assert_eq!(counts(&h, sink), (2, 3)); + let after = sink.fill(h.state()).unwrap(); + for location in [0.0, 1.0] { + let from = marker(&h, location, false); + drag_through(&mut h, &[from, from + vec2(0.0, 60.0)], |h| assert_eq!(shown(h, sink).0, 2, "the preview keeps two stops")); + assert_eq!(sink.fill(h.state()).unwrap(), after, "two colour stops always remain"); + } +} + +/// An opacity stop dragged up off the bar is removed; dragging it down over the ramp is not. +#[test] +fn an_opacity_stop_dragged_up_off_the_bar_is_removed() { + for layer in [true, false] { + let (mut h, sink) = editor(layer); + let from = marker(&h, 0.5, true); + drag_through(&mut h, &[from, from - vec2(0.0, 30.0)], |h| assert_eq!(shown(h, sink), (3, 2), "layer {layer}")); + let Fill::Gradient { opacity_stops, stops, .. } = sink.fill(h.state()).unwrap() else { panic!() }; + assert_eq!(opacity_stops.iter().map(|s| s.0).collect::>(), [0.0, 1.0], "layer {layer}"); + assert_eq!(stops.len(), 3, "layer {layer}: colour stops are untouched"); + // Two opacity stops remain: the next one dragged up stays. + let before = sink.fill(h.state()).unwrap(); + let from = marker(&h, 0.0, true); + drag_through(&mut h, &[from, from - vec2(0.0, 30.0)], |_| {}); + assert_eq!(sink.fill(h.state()).unwrap(), before, "layer {layer}: two opacity stops always remain"); + } +} diff --git a/crates/ui-egui/src/tools/shape_gradient_ui.rs b/crates/ui-egui/src/tools/shape_gradient_ui.rs index e2fbfdb679..c4a6c9068a 100644 --- a/crates/ui-egui/src/tools/shape_gradient_ui.rs +++ b/crates/ui-egui/src/tools/shape_gradient_ui.rs @@ -347,8 +347,8 @@ fn stops_strip(ui: &mut egui::Ui, fill: &Fill, id: egui::Id, fg: [f32; 4], bg: [ let (start_cs, start_os, _) = sorted_stops(&drag.start); let pos = resp.interact_pointer_pos(); if let Some(pos) = pos { - // Dragged well off the strip, a stop is deleted (while at least two remain), as in Photoshop. - let off = pos.y > bar.bottom() + 28.0 || pos.y < bar.top() - 26.0; + // Dragged off the strip, a stop is deleted (while at least two remain), as in Photoshop. + let off = crate::gradient_ui::stop_dragged_off(bar, pos, matches!(drag.marker, Marker::Opacity(_))); let loc = loc_of(pos.x); let edit = match drag.marker { Marker::Color(i) if off && start_cs.len() > 2 => json!({"action": "delete", "index": i}), diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 56f26760e1..8bc4974646 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity: tools, handles, modifiers and feel -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; Shape Fill/Stroke popover as Photoshop's, every colour swatch opens the full Color Picker with a live preview; the text and gradient stop Color Pickers show the picked colour on the canvas before OK, #5530; selected colour sets edit drawing, tone and supported filters; geometry remains open; a paragraph box that can't hold its text enlarges its lower-right handle and shows a black + in it, as the reference application; Free Transform of several selected layers, #6501; Path Operations fixes: the first component keeps combining, a hidden component selection is left alone, a dragged live shape stays live, a refused Pen draft ends once; Pen, Freeform Pen, Curvature Pen and Path Selection Path Operations menu, which changes selected components, `+` / `-` keys, #1397; the Pen carries on an open shape or vector mask path; selection-aware font controls, pending caret fonts and focused commit, plus direct font-name editing with a compact no-match message, #9338; New Group dialog and button modifiers; new layers go into an open active group and join a clipping mask, except that type, shapes, pastes and placed files go above a group whose lock would refuse them; Color panel Hue and Brightness Cube show the out-of-gamut ⚠ and nearest printable colour when the field is at least 53 pt tall, #8672; Art History Brush in the History group of the Y flyout, partial, TOOL-213-20, #1610; Histogram panel: Compact / Expanded / All Channels views, Luminosity and Colors channels, Source, range statistics, Uncached Refresh, live adjustment overlay, Cache Levels preference; the font menu opens on the current family, marked in the recent group when it is a recent one, #9160; Type options bar alignment and direction follow the target paragraph; a swatch click recolours selected type and shape layers with any tool, #9238; UI scale presets done: 105–120% steps, #3222; ⇧⌥ blend mode letters; painting tool Mode menus; ⌥ + an adjustment's shortcut reopens it at its last settings, ⌥⌘L / ⌥⌘M, #8900; Crop default mode: moving the image under the box snaps the frame's edges and centre, #8580; View › Extras hides the Move transform controls, #6699; adjustment clipping stays visible in compact Properties, #5231; Color panel modes from its panel menu, the same in every theme, UI-217-17, #6000; a picked colour recolours the selected type and shapes; Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests; Properties › Transform shows a shape's path geometry, without its stroke) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (a gradient stop dragged off the bar is removed in every stop editor, Gradient Map and Layer Style included, #10742; 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; Shape Fill/Stroke popover as Photoshop's, every colour swatch opens the full Color Picker with a live preview; the text and gradient stop Color Pickers show the picked colour on the canvas before OK, #5530; selected colour sets edit drawing, tone and supported filters; geometry remains open; a paragraph box that can't hold its text enlarges its lower-right handle and shows a black + in it, as the reference application; Free Transform of several selected layers, #6501; Path Operations fixes: the first component keeps combining, a hidden component selection is left alone, a dragged live shape stays live, a refused Pen draft ends once; Pen, Freeform Pen, Curvature Pen and Path Selection Path Operations menu, which changes selected components, `+` / `-` keys, #1397; the Pen carries on an open shape or vector mask path; selection-aware font controls, pending caret fonts and focused commit, plus direct font-name editing with a compact no-match message, #9338; New Group dialog and button modifiers; new layers go into an open active group and join a clipping mask, except that type, shapes, pastes and placed files go above a group whose lock would refuse them; Color panel Hue and Brightness Cube show the out-of-gamut ⚠ and nearest printable colour when the field is at least 53 pt tall, #8672; Art History Brush in the History group of the Y flyout, partial, TOOL-213-20, #1610; Histogram panel: Compact / Expanded / All Channels views, Luminosity and Colors channels, Source, range statistics, Uncached Refresh, live adjustment overlay, Cache Levels preference; the font menu opens on the current family, marked in the recent group when it is a recent one, #9160; Type options bar alignment and direction follow the target paragraph; a swatch click recolours selected type and shape layers with any tool, #9238; UI scale presets done: 105–120% steps, #3222; ⇧⌥ blend mode letters; painting tool Mode menus; ⌥ + an adjustment's shortcut reopens it at its last settings, ⌥⌘L / ⌥⌘M, #8900; Crop default mode: moving the image under the box snaps the frame's edges and centre, #8580; View › Extras hides the Move transform controls, #6699; adjustment clipping stays visible in compact Properties, #5231; Color panel modes from its panel menu, the same in every theme, UI-217-17, #6000; a picked colour recolours the selected type and shapes; Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests; Properties › Transform shows a shape's path geometry, without its stroke) · **Target:** Adobe Photoshop 2026 How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation, @@ -240,6 +240,7 @@ Paragraph; the rest open as in-app floating windows. | Color panel modes from its panel menu (Hue Cube, Brightness Cube, Color Wheel, Grayscale/RGB/HSB/CMYK/Lab/Web Color sliders), colour ramp (RGB/CMYK Spectrum, Grayscale Ramp, Current Colors, Make Ramp Web Safe; Shift-click cycles, Alt-click picks the other colour), Copy Color as HTML / Hex Code; the out-of-gamut ⚠ with the nearest printable colour under the chips (sliders always; the cubes when the field is at least 53 pt tall); the same in every theme | done | `color_panel_ui.rs`, `color_panel_tests.rs`; UI-217-17, #2569, #6000, #8672 | | A colour picked in the Color or Swatches panel or the Color Picker recolours the selection: selected characters while editing; every selected type layer with a Type tool; every selected shape's fill with a shape, Pen or path selection tool; only the foreground with other tools, the Move tool too; a swatch click recolours the selected type layers and shape fills whatever the tool (#9238); one history step (a Color panel drag too) | done: `apply_color.rs`, `apply_color_tests.rs` | | | The Type tool's Color Picker (Text Color) and a gradient fill's Color Picker (Stop Color) preview the picked colour on the canvas before OK (the selected characters, every selected type layer, or the stop); Cancel restores the document with no history step, OK commits the previewed edit in one step (#5530) | done: `dialogs/color_picker_preview.rs`, `dialogs/color_picker_preview/tests.rs` | | +| Gradient stops come off the bar: a colour stop dragged down, or an opacity stop dragged up, by about a marker's height is removed on release while two of its kind remain; while it is off the ramp and canvas preview show it gone, and dragging it back onto the bar cancels; the selected stop's Delete button does the same. Gradient Editor, Properties, the shape gradient popover, Gradient Map and Layer Style (Gradient Overlay) share one rule (#10742) | done: `tools/gradient_ui.rs` `stop_dragged_off`, `tools/gradient_ui/editor_tests.rs`, `adjust_editors/ui_tests.rs`, `layer_style/gradient_field.rs`. Differs: Gradient Map and Layer Style show colour stops only; no Delete-key shortcut for the selected stop | | | Window size and dock width remembered | done | UI-217-2 | | Histogram panel: Compact (the default, of the entire image), Expanded and All Channels views (the channel kept across them); RGB/CMYK/Lab/Gray channels, Luminosity and Colors (overlaid, mixed where they overlap); Show Channels in Color and Show Statistics; Source (Entire Image, Selected Layer, Adjustment Composite) for multilayer documents; Mean, Std Dev, Median, Pixels, Level, Count, Percentile and Cache Level for the pointer level or a dragged range; the cached-data warning, Uncached Refresh (menu, button, warning click, double-click); the adjusted histogram over the original while an adjustment previews; Cache Levels 1–8 in Preferences › Performance; Color Sampler levels marked on the graph | partial | `histogram_panel.rs`, `histogram_panel_tests.rs`, `tone.rs`; differs: filter dialogs that preview on the CPU proxy aren't counted live, a selection doesn't limit the histogram yet, with Source Selected Layer an adjustment dialog's preview isn't counted, and new strings are English-only so far | | Command palette with recents | done | UI-217-7 | @@ -266,6 +267,7 @@ Paragraph; the rest open as in-app floating windows. | Date | Change | Summary | |---|---|---| +| 2026-11-02 | minor | Gradient stops dragged off the bar are removed, with a live preview and drag-back cancel, in every stop editor: Gradient Map and Layer Style gained it, the Gradient Editor, Properties and shape popover share the same threshold (#10742); row added (done); no percentage changed | | 2026-11-01 | minor | Every preference takes effect: 11 of 183 do nothing (was 12); Graphics Adapter always worked, but startup read it by raw JSON key, which the scorecard audit can't see | | 2026-10-29 | minor | Paragraph box overflow: text a box can't hold enlarges the box's lower-right handle and shows a black + in it, as the reference application does (#8311); agents read `typeBoxOverflow` in `ui.inspect`. Not a new command, so no percentage changed | | 2026-10-28 | minor | The text and gradient stop Color Pickers preview the picked colour on the canvas before OK, as Photoshop's do (#5530); no percentage changed | From 2d30a2b669ae008915d02633bbcf0fa16ba09568 Mon Sep 17 00:00:00 2001 From: ljkx <177671075+ljkx@users.noreply.github.com> Date: Mon, 2 Nov 2026 09:37:01 +0100 Subject: [PATCH 038/219] Gradient stops: the release frame decides drag-off; review fixes (#10742) The Gradient Editor/Properties strip and Layer Style's Gradient Overlay ramp ignored the release frame. When the last pointer move came in the same frame as the release, the stop stayed in the state the previous frame left it in. A stop flicked off the bar was kept, and one brought back onto the bar at release was deleted. Both now apply the same drag-off rule on that frame, as Gradient Map and the shape strip already did. The editor strip also counted opacity stops from its preview, which already leaves a dragged-off stop out. So while an opacity stop was off the bar, the preview flipped between deleting and moving it every frame. It now counts the committed stops, like colour stops. Tests: a quick release off the bar and back onto it (both editors); colour stops dragged up and opacity stops dragged down are kept; a cancelled Gradient Map drag adds no history step. The parity row names Gradient Map's control "Remove stop". Co-Authored-By: Claude Opus 5.5 --- .../src/dialogs/adjust_editors/ui_tests.rs | 2 + .../src/dialogs/layer_style/gradient_field.rs | 44 +++++++++++++-- crates/ui-egui/src/tools/gradient_ui.rs | 10 ++-- .../src/tools/gradient_ui/editor_tests.rs | 54 ++++++++++++++++++- docs/ui-parity.md | 2 +- 5 files changed, 102 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/dialogs/adjust_editors/ui_tests.rs b/crates/ui-egui/src/dialogs/adjust_editors/ui_tests.rs index bb0f737265..625039a692 100644 --- a/crates/ui-egui/src/dialogs/adjust_editors/ui_tests.rs +++ b/crates/ui-egui/src/dialogs/adjust_editors/ui_tests.rs @@ -570,6 +570,7 @@ fn gradient_map_stop_dragged_down_off_the_bar_is_removed_and_dragging_back_cance h.state_mut().run("layer.setAdjustment", json!({"layer": id.0, "stops": [[0, "#000000"], [0.5, "#ff0000"], [1, "#ffffff"]]})).unwrap(); h.run_steps(3); let three = layer(&h).1; + let before_cancel = h.state().session.active().unwrap().history.past_len(); let live_stops = |h: &Harness<'_, PhotocraftApp>| h.state().live_adjust.as_ref().and_then(|(_, v)| v["stops"].as_array().map(Vec::len)); // Off and back: nothing removed. let start = map_handle(&h, 0.5); @@ -589,6 +590,7 @@ fn gradient_map_stop_dragged_down_off_the_bar_is_removed_and_dragging_back_cance h.drop_at(start); h.run_steps(3); assert_eq!(map_stops(&layer(&h).1), map_stops(&three), "dragging back cancels the removal"); + assert_eq!(h.state().session.active().unwrap().history.past_len(), before_cancel, "a cancelled drag adds no history step"); // Off and released: removed in one step. let steps = h.state().session.active().unwrap().history.past_len(); drag(&mut h, start, away); diff --git a/crates/ui-egui/src/dialogs/layer_style/gradient_field.rs b/crates/ui-egui/src/dialogs/layer_style/gradient_field.rs index c59b666b3f..e5e253caab 100644 --- a/crates/ui-egui/src/dialogs/layer_style/gradient_field.rs +++ b/crates/ui-egui/src/dialogs/layer_style/gradient_field.rs @@ -235,10 +235,9 @@ pub(super) fn row(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map(drag_key)); - } else if resp.dragged() { + // The release frame applies the same rule (its last move may cross the edge), then ends + // the drag. + if resp.dragged() || resp.drag_stopped() { match drag.removed { None if off && stops.len() > 2 => { if let Some(stop) = stops.get(drag.index).copied() @@ -271,7 +270,11 @@ pub(super) fn row(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map(drag_key)); + } else { + ui.data_mut(|d| d.insert_temp(drag_key, drag)); + } } } else if resp.drag_stopped() { ui.data_mut(|d| d.remove::(drag_key)); @@ -455,6 +458,37 @@ mod tests { assert_eq!(stops.iter().map(|s| super::super::hex(&s.1)).collect::>(), ["#000000", "#ffffff"], "{stops:?}"); } + /// The release frame counts: a stop that leaves the strip in the frame it is released is + /// removed, and one brought back onto the strip in that frame is kept. + #[test] + fn the_release_frame_decides_whether_a_stop_comes_off_the_ramp() { + for back in [true, false] { + let (mut app, parent) = dialog_app(); + let effect = crate::layer_style::effects_of(&app.ui.dialogs[0].fields)[0]["id"].as_str().unwrap().to_string(); + set_param(&mut app.ui.dialog_mut(parent).unwrap().fields, &effect, KEY, json!([[0.0, "#000000"], [0.5, "#ff0000"], [1.0, "#ffffff"]])); + let mut h = harness(app, parent); + let (_, bar): (String, Rect) = h.ctx.data(|d| d.get_temp(bar_id())).expect("the ramp was drawn"); + let mid = pos2(bar.center().x, bar.bottom() + 9.0); + let away = pos2(mid.x, mid.y + 30.0); + press(&mut h, mid); + h.event(egui::Event::PointerMoved(pos2(mid.x, mid.y + 11.0))); + h.run_steps(1); + if back { + h.event(egui::Event::PointerMoved(away)); + h.run_steps(1); + assert_eq!(stops_in(h.state(), parent).len(), 2, "off the strip the stop is gone"); + } + let to = if back { mid } else { away }; + // The last move and the release in one frame (`Harness::event` gives each its own). + let input = &mut h.input_mut().events; + input.push(egui::Event::PointerMoved(to)); + input.push(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); + let stops = stops_in(h.state(), parent); + assert_eq!(stops.len(), if back { 3 } else { 2 }, "back {back}: {stops:?}"); + } + } + /// A preset from the Gradients panel becomes the page's stops; the Delete button and the /// Location field work on the selected stop. #[test] diff --git a/crates/ui-egui/src/tools/gradient_ui.rs b/crates/ui-egui/src/tools/gradient_ui.rs index 12d2faeedd..daa9a9344f 100644 --- a/crates/ui-egui/src/tools/gradient_ui.rs +++ b/crates/ui-egui/src/tools/gradient_ui.rs @@ -903,7 +903,10 @@ fn selection_after_edit(f: &Fill, p: &Value, previous: Option) -> Option } fn stop_strip(ui: &mut egui::Ui, key: egui::Id, f: &Fill, midpoints: bool, pending: Option<&Value>, colors: ([f32; 4], [f32; 4])) -> Option { - let Fill::Gradient { stops, .. } = f else { return None }; + // The stops as committed: a drag edit (and the two-stop minimum) addresses these, not the + // pending preview, which may already leave the dragged stop out. + let Fill::Gradient { stops, opacity_stops, .. } = f else { return None }; + let opacity_count = if opacity_stops.is_empty() { 2 } else { opacity_stops.len() }; let t = Tokens::get(ui.ctx()); let w = ui.available_width().max(120.0); let (outer, resp) = ui.allocate_exact_size(vec2(w, 58.0), Sense::click_and_drag()); @@ -1015,14 +1018,15 @@ fn stop_strip(ui: &mut egui::Ui, key: egui::Id, f: &Fill, midpoints: bool, pendi let edit = match m { Marker::Color(i) if off && stops.len() > 2 => json!({"action": "delete", "index": i}), Marker::Color(i) => json!({"action": "move", "index": i, "location": loc}), - Marker::Opacity(i) if off && ops.len() > 2 => json!({"action": "delete", "kind": "opacity", "index": i}), + Marker::Opacity(i) if off && opacity_count > 2 => json!({"action": "delete", "kind": "opacity", "index": i}), Marker::Opacity(i) => json!({"action": "move", "kind": "opacity", "index": i, "location": loc}), Marker::Mid(i) => { let (a, b) = (sorted.get(i).map_or(0.0, |s| s.0), sorted.get(i + 1).map_or(1.0, |s| s.0)); json!({"action": "midpoint", "index": i, "location": ((loc - a) / (b - a).max(1e-4)).clamp(0.05, 0.95)}) } }; - if resp.dragged() { + // The release frame counts too: its own last move decides whether the stop comes off. + if resp.dragged() || resp.drag_stopped() { preview = Some(edit); } } diff --git a/crates/ui-egui/src/tools/gradient_ui/editor_tests.rs b/crates/ui-egui/src/tools/gradient_ui/editor_tests.rs index c761838f36..34636bd376 100644 --- a/crates/ui-egui/src/tools/gradient_ui/editor_tests.rs +++ b/crates/ui-egui/src/tools/gradient_ui/editor_tests.rs @@ -260,7 +260,7 @@ fn the_last_two_color_stops_cannot_be_dragged_off() { } } -/// An opacity stop dragged up off the bar is removed; dragging it down over the ramp is not. +/// An opacity stop dragged up off the bar is removed while two remain. #[test] fn an_opacity_stop_dragged_up_off_the_bar_is_removed() { for layer in [true, false] { @@ -277,3 +277,55 @@ fn an_opacity_stop_dragged_up_off_the_bar_is_removed() { assert_eq!(sink.fill(h.state()).unwrap(), before, "layer {layer}: two opacity stops always remain"); } } + +/// Only the stop's own direction takes it off: an opacity stop dragged down over the ramp and a +/// colour stop dragged up over it are kept, however far they go. +#[test] +fn a_stop_dragged_the_other_way_is_kept() { + for layer in [true, false] { + let (mut h, sink) = editor(layer); + for opacity in [true, false] { + let from = marker(&h, 0.5, opacity); + let to = from + vec2(0.0, if opacity { 70.0 } else { -70.0 }); + drag_through(&mut h, &[from, to], |h| assert_eq!(shown(h, sink), (3, 3), "layer {layer} opacity {opacity}: still shown")); + assert_eq!(counts(&h, sink), (3, 3), "layer {layer} opacity {opacity}: the stop is kept"); + } + } +} + +/// Press on a stop, drag a little along the bar, then move to each of `then` in its own frame; +/// the last move and the release arrive in the same frame, as a quick flick does. +fn flick(h: &mut Harness, from: Pos2, then: &[Pos2]) { + h.event(egui::Event::PointerMoved(from)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + h.event(egui::Event::PointerMoved(from + vec2(10.0, 0.0))); + h.run_steps(1); + let Some((&last, moves)) = then.split_last() else { return }; + for &p in moves { + h.event(egui::Event::PointerMoved(p)); + h.run_steps(1); + } + // `Harness::event` runs one frame per event, so both go straight into the next frame. + let input = &mut h.input_mut().events; + input.push(egui::Event::PointerMoved(last)); + input.push(egui::Event::PointerButton { pos: last, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(3); +} + +/// The release frame counts: a stop flicked off the bar and released in the same frame is +/// removed, and one brought back onto the bar in the release frame is kept. +#[test] +fn the_release_frame_decides_whether_the_stop_comes_off() { + for layer in [true, false] { + let (mut h, sink) = editor(layer); + let from = marker(&h, 0.5, false); + let away = from + vec2(10.0, 40.0); + flick(&mut h, from, &[away, from]); + assert_eq!(counts(&h, sink), (3, 3), "layer {layer}: back on the bar at release, the stop is kept"); + let from = marker(&h, 0.5, false); + flick(&mut h, from, &[from + vec2(10.0, 40.0)]); + assert_eq!(counts(&h, sink), (2, 3), "layer {layer}: released off the bar in the same frame, the stop is removed"); + } +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 8bc4974646..abf9431ef4 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -240,7 +240,7 @@ Paragraph; the rest open as in-app floating windows. | Color panel modes from its panel menu (Hue Cube, Brightness Cube, Color Wheel, Grayscale/RGB/HSB/CMYK/Lab/Web Color sliders), colour ramp (RGB/CMYK Spectrum, Grayscale Ramp, Current Colors, Make Ramp Web Safe; Shift-click cycles, Alt-click picks the other colour), Copy Color as HTML / Hex Code; the out-of-gamut ⚠ with the nearest printable colour under the chips (sliders always; the cubes when the field is at least 53 pt tall); the same in every theme | done | `color_panel_ui.rs`, `color_panel_tests.rs`; UI-217-17, #2569, #6000, #8672 | | A colour picked in the Color or Swatches panel or the Color Picker recolours the selection: selected characters while editing; every selected type layer with a Type tool; every selected shape's fill with a shape, Pen or path selection tool; only the foreground with other tools, the Move tool too; a swatch click recolours the selected type layers and shape fills whatever the tool (#9238); one history step (a Color panel drag too) | done: `apply_color.rs`, `apply_color_tests.rs` | | | The Type tool's Color Picker (Text Color) and a gradient fill's Color Picker (Stop Color) preview the picked colour on the canvas before OK (the selected characters, every selected type layer, or the stop); Cancel restores the document with no history step, OK commits the previewed edit in one step (#5530) | done: `dialogs/color_picker_preview.rs`, `dialogs/color_picker_preview/tests.rs` | | -| Gradient stops come off the bar: a colour stop dragged down, or an opacity stop dragged up, by about a marker's height is removed on release while two of its kind remain; while it is off the ramp and canvas preview show it gone, and dragging it back onto the bar cancels; the selected stop's Delete button does the same. Gradient Editor, Properties, the shape gradient popover, Gradient Map and Layer Style (Gradient Overlay) share one rule (#10742) | done: `tools/gradient_ui.rs` `stop_dragged_off`, `tools/gradient_ui/editor_tests.rs`, `adjust_editors/ui_tests.rs`, `layer_style/gradient_field.rs`. Differs: Gradient Map and Layer Style show colour stops only; no Delete-key shortcut for the selected stop | | +| Gradient stops come off the bar: a colour stop dragged down, or an opacity stop dragged up, by about a marker's height is removed on release while two of its kind remain; while it is off the ramp and canvas preview show it gone, and dragging it back onto the bar cancels; the selected stop's Delete button (Remove stop, the × beside the colour, in Gradient Map) does the same. Gradient Editor, Properties, the shape gradient popover, Gradient Map and Layer Style (Gradient Overlay) share one rule (#10742) | done: `tools/gradient_ui.rs` `stop_dragged_off`, `tools/gradient_ui/editor_tests.rs`, `adjust_editors/ui_tests.rs`, `layer_style/gradient_field.rs`. Differs: Gradient Map and Layer Style show colour stops only; no Delete-key shortcut for the selected stop | | | Window size and dock width remembered | done | UI-217-2 | | Histogram panel: Compact (the default, of the entire image), Expanded and All Channels views (the channel kept across them); RGB/CMYK/Lab/Gray channels, Luminosity and Colors (overlaid, mixed where they overlap); Show Channels in Color and Show Statistics; Source (Entire Image, Selected Layer, Adjustment Composite) for multilayer documents; Mean, Std Dev, Median, Pixels, Level, Count, Percentile and Cache Level for the pointer level or a dragged range; the cached-data warning, Uncached Refresh (menu, button, warning click, double-click); the adjusted histogram over the original while an adjustment previews; Cache Levels 1–8 in Preferences › Performance; Color Sampler levels marked on the graph | partial | `histogram_panel.rs`, `histogram_panel_tests.rs`, `tone.rs`; differs: filter dialogs that preview on the CPU proxy aren't counted live, a selection doesn't limit the histogram yet, with Source Selected Layer an adjustment dialog's preview isn't counted, and new strings are English-only so far | | Command palette with recents | done | UI-217-7 | From b5770e42831371e19bcba700da923bdc49aa8d78 Mon Sep 17 00:00:00 2001 From: ljkx <177671075+ljkx@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:38:09 +0100 Subject: [PATCH 039/219] CLI: --metadata none|all for convert, run --out and batch (#9466) Since #733 a flat export from the CLI keeps the document's whole XMP packet and free-form text, as Save As does; only Export As starts at Metadata: None. Batch exports of Photoshop masters therefore carry the full DocumentAncestors list (and the text of every type layer), which can be many times larger than the image, with no way to leave it out. `--metadata` is a new export option shared by `convert`, `run --out` and `batch`. `none` sets ExportOptions::xmp to XmpEmbed::None, the same path Export As's Metadata: None and Quick Export use, so the XMP packet and free-form text are dropped while the ICC profile, EXIF and resolution are written as before; PSD, PSB and .pcraft keep the XMP either way. `all` is the default, so existing scripts write the same files. Any other value is a usage error (exit 2) naming the flag, before anything is read or written. Tested through the binary for PNG, WebP, JPEG and TIFF from all three subcommands (packet present by default and with `all`, absent with `none`; pixels, ICC and resolution unchanged), layered saves, and bad values. Documented in the CLI book page and file-format parity doc. --- apps/photocraft-cli/src/args.rs | 18 ++++++- apps/photocraft-cli/src/output.rs | 4 ++ apps/photocraft-cli/tests/it/cli.rs | 73 +++++++++++++++++++++++++++++ book/src/automation/cli.md | 16 +++++++ docs/file-format-parity.md | 9 ++-- 5 files changed, 115 insertions(+), 5 deletions(-) diff --git a/apps/photocraft-cli/src/args.rs b/apps/photocraft-cli/src/args.rs index 33175a15be..a5de7bbc68 100644 --- a/apps/photocraft-cli/src/args.rs +++ b/apps/photocraft-cli/src/args.rs @@ -16,7 +16,7 @@ use clap::parser::ValueSource; use clap::{ArgAction, ArgGroup, ArgMatches, Args, CommandFactory, FromArgMatches, Parser, Subcommand}; use photocraft_automation::args::{AutomationArgs, ControlArgs, blank_env_is_unset, conflict, control_port, name_env_vars}; use photocraft_codecs::{JpegFormat, TiffCompression}; -use photocraft_io::{DdsCompression, DpxBits, TgaBits}; +use photocraft_io::{DdsCompression, DpxBits, TgaBits, XmpEmbed}; use serde_json::Value; /// photocraft-cli: headless PhotoCraft @@ -78,6 +78,12 @@ pub(crate) struct ExportArgs { /// composite/full.png; it may be omitted. Check the manifest's full_composite member. #[arg(long)] pub(crate) pcraft_full_composite: bool, + /// The document metadata a flat export embeds: all (the default) keeps the document's XMP + /// packet and free-form text, as Save As does; none leaves them out, as Export As's Metadata: + /// None. The ICC profile, EXIF and resolution are kept either way, and PSD, PSB and .pcraft + /// always keep the XMP + #[arg(long, value_name = "none|all", value_parser = metadata)] + pub(crate) metadata: Option, } /// Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …) @@ -268,6 +274,8 @@ enum ValueError { TiffCompression, #[error("expected baseline, optimized or progressive")] JpegFormat, + #[error("expected none or all")] + Metadata, /// Why a `--bridge` value, or a PHOTOCRAFT_CONTROL_PORT value for `mcp`, is refused. #[error("expected a port, 0-65535 (--bridge also takes 127.0.0.1:)")] Bridge, @@ -326,6 +334,14 @@ fn jpeg_format(s: &str) -> Result { JpegFormat::parse(s).ok_or(ValueError::JpegFormat) } +fn metadata(s: &str) -> Result { + match s { + "none" => Ok(XmpEmbed::None), + "all" => Ok(XmpEmbed::All), + _ => Err(ValueError::Metadata), + } +} + /// Where `mcp --bridge` connects. Shown as the address: `127.0.0.1:` for a port. #[derive(Clone)] pub(crate) enum Bridge { diff --git a/apps/photocraft-cli/src/output.rs b/apps/photocraft-cli/src/output.rs index 831258b315..f296469655 100644 --- a/apps/photocraft-cli/src/output.rs +++ b/apps/photocraft-cli/src/output.rs @@ -26,6 +26,10 @@ impl From<&ExportArgs> for ExportOptions { o.encode.dds.compression = c; } o.encode.dds.mipmaps = a.dds_mipmaps; + // Conversions keep the whole packet unless asked, as Save As does (#733, #9466). + if let Some(m) = a.metadata { + o.xmp = m; + } o } } diff --git a/apps/photocraft-cli/tests/it/cli.rs b/apps/photocraft-cli/tests/it/cli.rs index 176fb970fd..caad46edc1 100644 --- a/apps/photocraft-cli/tests/it/cli.rs +++ b/apps/photocraft-cli/tests/it/cli.rs @@ -758,6 +758,79 @@ fn jpeg_format_chooses_the_scans() { } } +/// #9466: `--metadata none` leaves the document's XMP packet out of a flat export from `convert`, +/// `run --out` and `batch`, as Export As's Metadata: None does, and changes nothing else (pixels, +/// ICC profile, resolution). The default, and `all`, keep it as before, and a PSD or `.pcraft` +/// keeps it either way. A value it doesn't name is a usage error and nothing is written. +#[test] +fn metadata_none_leaves_out_the_document_xmp() { + const XMP: &str = "\ +\ +xmp.did:11111111-2222-3333-4444-555555555555\ +"; + let d = tmp("metadata"); + let input = d.join("in"); + std::fs::create_dir_all(&input).unwrap(); + let a = input.join("a.png"); + let data: Vec = (0..8 * 6 * 4).map(|i| (i as u8).wrapping_mul(7).wrapping_add(3)).collect(); + let mut img = photocraft_codecs::Image::from_u8(8, 6, photocraft_codecs::ChannelLayout::Rgba, data).unwrap(); + img.meta.xmp = Some(XMP.into()); + img.meta.dpi = Some((300.0, 300.0)); + std::fs::write(&a, photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap(); + let ancestors = |p: &Path| String::from_utf8_lossy(&std::fs::read(p).unwrap()).contains("DocumentAncestors"); + let decoded = |p: &Path| photocraft_codecs::decode(&std::fs::read(p).unwrap()).unwrap(); + assert!(ancestors(&a), "the input carries the packet"); + let invert = r#"[{"command":"image.adjustments.invert"}]"#; + for ext in ["png", "webp", "jpg", "tif"] { + let mut converted_files = Vec::new(); + for flag in [None, Some("all"), Some("none")] { + let extra: Vec<&str> = flag.map(|f| vec!["--metadata", f]).unwrap_or_default(); + let name = format!("{ext}-{}", flag.unwrap_or("default")); + let converted = d.join(format!("convert-{name}.{ext}")); + ok(bin().arg("convert").arg(&a).arg(&converted).args(&extra)); + let ran = d.join(format!("run-{name}.{ext}")); + ok(bin().arg("run").arg(&a).arg("--out").arg(&ran).args(&extra)); + let o = batch_with(&d, &input, &format!("batch-{name}"), invert, &[&["--format", ext][..], &extra[..]].concat()); + assert!(o.status.success(), "batch {name}: {}", String::from_utf8_lossy(&o.stderr)); + let batched = d.join(format!("batch-{name}")).join(format!("a.{ext}")); + for out in [&converted, &ran, &batched] { + assert_eq!(ancestors(out), flag != Some("none"), "{name}: {}", out.display()); + } + converted_files.push(converted); + } + // Only the XMP goes: the pixels, profile and resolution are the default export's. + let (default, none) = (decoded(&converted_files[0]), decoded(&converted_files[2])); + assert_eq!(none.data(), default.data(), "{ext}: pixels"); + assert_eq!(none.icc, default.icc, "{ext}: ICC profile"); + assert_eq!(none.meta.dpi, default.meta.dpi, "{ext}: resolution"); + assert!(none.meta.dpi.is_some_and(|(x, y)| (x - 300.0).abs() < 0.5 && (y - 300.0).abs() < 0.5), "{ext}: {:?}", none.meta.dpi); + } + // A layered save keeps the document's XMP whatever the option. + for ext in ["psd", "pcraft"] { + let out = d.join(format!("layered.{ext}")); + ok(bin().arg("convert").arg(&a).arg(&out).args(["--metadata", "none"])); + let back = d.join(format!("layered-{ext}.png")); + ok(bin().arg("convert").arg(&out).arg(&back)); + assert!(ancestors(&back), "{ext} kept the packet"); + } + for bad in ["None", "exif", "0", ""] { + let out = d.join("bad.png"); + let (mut convert, mut run) = (bin(), bin()); + convert.arg("convert").arg(&a).arg(&out).args(["--metadata", bad]); + run.arg("run").arg(&a).arg("--out").arg(&out).args(["--metadata", bad]); + for cmd in [&mut convert, &mut run] { + let o = cmd.output().unwrap(); + let err = String::from_utf8_lossy(&o.stderr); + assert_eq!(o.status.code(), Some(2), "--metadata {bad}: {err}"); + assert!(err.contains("--metadata") && err.contains("expected none or all"), "--metadata {bad}: {err}"); + assert!(!out.exists(), "--metadata {bad} wrote a file"); + } + let o = batch_with(&d, &input, "bad-batch", invert, &["--metadata", bad]); + assert_eq!(o.status.code(), Some(2), "batch --metadata {bad}"); + assert!(!d.join("bad-batch").exists(), "batch --metadata {bad} wrote a folder"); + } +} + /// #492: an `--out` folder that is the `--in` folder, however it is spelt, is refused before /// anything is written, unless `--in-place` asks for it. #[test] diff --git a/book/src/automation/cli.md b/book/src/automation/cli.md index 6137b12466..2f8c3628d7 100644 --- a/book/src/automation/cli.md +++ b/book/src/automation/cli.md @@ -35,6 +35,22 @@ Warnings do not change the saved document's font declarations and do not stop the operation. To list or replace the missing families intentionally, use `type.resolveMissingFonts` on the document. +### Document metadata + +`convert`, `run --out` and `batch` keep the document's XMP packet and +free-form text in a flat export (PNG, JPEG, WebP, TIFF, …), as Save As does. +`--metadata none` leaves them out, as Export As's Metadata: None does: a +Photoshop file's XMP can list every placed document's id +(`DocumentAncestors`) and the text of every type layer, and can be far larger +than the image. `--metadata all` is the default. The option changes nothing +else: the ICC profile, EXIF and resolution are written either way, and PSD, +PSB and `.pcraft` saves always keep the XMP. It is not a full metadata scrub. + +```sh +photocraft-cli batch --actions resize.json --in masters/ --out web/ \ + --format webp --metadata none +``` + ### Export selected adjustment layers as a .cube LUT The engine command `file.export.colorLookupTables` creates a 3D LUT from an diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index a0821f154e..c586cb0b60 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-31 · **Change:** minor (`.atn`: 144 supported events after Actions batch 12 (Groups A–I, command options, version 12 files, reference forms); the supported table and measured reference are in [atn.md](atn.md); TIFF: YCbCr images open as RGB, including JPEG-compressed scans with subsampled chroma, #10316; TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-03 · **Change:** minor (CLI `--metadata none|all` for `convert`, `run --out` and `batch`: `none` leaves the document XMP and free-form text out of flat exports, as Export As's Metadata: None, `all` the default, #9466; `.atn`: 144 supported events after Actions batch 12 (Groups A–I, command options, version 12 files, reference forms); the supported table and measured reference are in [atn.md](atn.md); TIFF: YCbCr images open as RGB, including JPEG-compressed scans with subsampled chroma, #10316; TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 **PSD Filter Gallery (#8940, 2026-10-23):** Halftone Pattern maps its size, contrast and dot/circle/line pattern as editable settings on import and export, with the reference application's descriptor keys (`HlSz`, `Cntr`, `ScrT`: `ScrD`/`ScrC`/`ScrL`). PSD/PSB round trips pass at 8/16/32 bits. This does not claim pixel equivalence for the existing renderer. Unknown or malformed settings remain verbatim on import. @@ -27,11 +27,11 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | PSB | R/W | yes | yes | Auto-switches above 2 GB (REL-212-2); generated 20 MP, 150-layer, 2 GB PSD and 4.4 GB PSB fixtures opened, saved and re-opened by io tests/it/large_files.rs (FILE-216-2, 2026-10-13; the 4.4 GB PSB opens on Windows); > 4 GB from the app's File › Open on Windows: #375 | | Cloud PSD (.psdc) | R/W | no | no | Out of scope (Adobe cloud) | | TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, YCbCr read as RGB (JPEG-compressed with subsampled chroma, or uncompressed without subsampling; #10316), BigTIFF, every IFD, banded parallel decode; flat pages up to 300,000 px a side open a group of rows at a time into the layer, with no full-size copy, and are refused with an error when the machine lacks the memory (Linux: free RAM and swap, cgroup limits; #8169); layered TIFF both byte orders, including Photoshop mixed-order type blocks on read (#4758/#4771) and TySh text descriptors written in PSD order (#9098) and Photoshop's 14-byte `LMsk` block (#9095); LZW/ZIP for scripted saves by `tiffCompression`; undecodable type keeps saved pixels (FILE-215-7); alpha and spot channels as extra samples (at most 16) with Photoshop's names, ink colours and solidity, spot ink stored 0 = solid (#7415); no Save Transparency choice yet (channels are written as Photoshop does with it off) | -| JPEG | R/W | yes | yes | 8-bit Gray/RGB/CMYK, ICC/EXIF/XMP; Save keeps the opened file's quality (estimated from its quantisation tables, #2399); File › Save, Save As and Save a Copy ask JPEG Options: Quality 0–12, starting at the opened file's quality or else the Export preference, and Baseline ("Standard") / Baseline Optimized / Progressive; scripts pass `quality`/`jpegFormat`, the CLI `--jpeg-format` (FILE-215-19). Baseline Optimized fits the Huffman tables only at 4:4:4 (Gray, CMYK) or progressive: RGB is written 4:2:0, which zune-jpeg 0.5.15 decodes wrongly with fitted tables, as in Save for Web. No Matte, progressive Scans (3–5) or size preview | -| PNG | R/W | yes | yes | 8/16-bit, ICC/text; APNG first frame only; alpha channels on open (#2225); palette PNGs open in Indexed Color mode with their transparent entry (#4804); Save keeps a stereo PNG's `sTER` pairing (#6063) | +| JPEG | R/W | yes | yes | 8-bit Gray/RGB/CMYK, ICC/EXIF/XMP (the CLI's `--metadata none` leaves the document XMP out, #9466); Save keeps the opened file's quality (estimated from its quantisation tables, #2399); File › Save, Save As and Save a Copy ask JPEG Options: Quality 0–12, starting at the opened file's quality or else the Export preference, and Baseline ("Standard") / Baseline Optimized / Progressive; scripts pass `quality`/`jpegFormat`, the CLI `--jpeg-format` (FILE-215-19). Baseline Optimized fits the Huffman tables only at 4:4:4 (Gray, CMYK) or progressive: RGB is written 4:2:0, which zune-jpeg 0.5.15 decodes wrongly with fitted tables, as in Save for Web. No Matte, progressive Scans (3–5) or size preview | +| PNG | R/W | yes | yes | 8/16-bit, ICC/text (the CLI's `--metadata none` leaves the document XMP and text out, #9466); APNG first frame only; alpha channels on open (#2225); palette PNGs open in Indexed Color mode with their transparent entry (#4804); Save keeps a stereo PNG's `sTER` pairing (#6063) | | GIF | R/W | yes | yes | File › Open keeps the first frame; New Video Layer from File and Video Frames to Layers take every frame, with the timeline rate from the delays (#572). Per-frame delays and keeping the frames in `.pcraft` are not done | | BMP | R/W | yes | yes | 8-bit; palette BMPs open in Indexed Color mode and Indexed Color documents save as palette BMPs (no transparency) | -| WebP | R/W | yes | yes | Lossless and our own lossy VP8 encoder; ICC/EXIF/XMP; Save keeps an opened file lossy (at its quality) or lossless (#2399); Save for Web (Legacy) writes it too, lossy at its Quality or lossless, a format Photoshop's legacy dialog lacks (#6391). File › Open keeps an animation's first frame; New Video Layer from File and Video Frames to Layers take every frame, with the timeline rate from the durations (#1198) | +| WebP | R/W | yes | yes | Lossless and our own lossy VP8 encoder; ICC/EXIF/XMP (the CLI's `--metadata none` leaves the document XMP out, #9466); Save keeps an opened file lossy (at its quality) or lossless (#2399); Save for Web (Legacy) writes it too, lossy at its Quality or lossless, a format Photoshop's legacy dialog lacks (#6391). File › Open keeps an animation's first frame; New Video Layer from File and Video Frames to Layers take every frame, with the timeline rate from the durations (#1198) | | AVIF | R/W | no (#463) | optional feature, off by default | | | JPEG XL | R/W | inside DNG only | no | | | HEIF/HEIC | R/W(HEIF) | yes (pure-Rust heic-rs, `heif` feature, on in releases) | no | 8/16-bit | @@ -151,6 +151,7 @@ The original reference entry retains its source date and historical totals; curr | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | CLI `--metadata` (`none` or `all`) for `convert`, `run --out` and `batch`: `none` leaves the document's XMP packet and free-form text out of flat exports (PNG, JPEG, WebP, TIFF, …) through Export As's Metadata: None path; `all`, which keeps them as Save As does, stays the default. ICC, EXIF and resolution are written either way; PSD, PSB and `.pcraft` always keep the XMP (#9466) | | 2026-11-01 | minor | `.atn`: event reference [atn.md](atn.md): the 117 supported events, and measured keys, defaults, ranges and enum values for 101 events not mapped yet | | 2026-10-31 | minor | `.atn` Group B: four Render events load, play and save; 120 supported and 97 unmapped. Supported-table columns corrected against the event registry; the original 117/101 reference entry remains historical. | | 2026-10-31 | minor | `.atn`: Pinch (`Pnch`), Polar Coordinates (`Plr `), Spherize (`Sphr`), Twirl (`Twrl`), Wave (`Wave`) and ZigZag (`ZgZg`) map to `filter.distort.*` with every key in recorded order; out-of-range values clamped and missing keys given Photoshop's defaults on import; a Wave scaled below 100 % stays a Photoshop step (the command has no scale); Shear still kept verbatim (#10605) | From a99e8696fc720e2dd8050163d36cbf076fc83eba Mon Sep 17 00:00:00 2001 From: ljkx <177671075+ljkx@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:59:20 +0100 Subject: [PATCH 040/219] CLI --metadata: qualify the docs per format, test text and ICC (#9466) Review follow-up on #9975. The help text and the CLI book page said the XMP and free-form text are kept in every flat export and the ICC profile, EXIF and resolution "either way". Not every format holds them: JPEG and WebP have no place for free-form text, TIFF writes only its named text tags, and an Indexed Color document's palette PNG carries no XMP, text, ICC or EXIF at all, only its resolution. The texts (and the parity-doc revision row) now say the XMP and text are kept where the format holds them and that the option leaves everything else as it is without it. The test fixture gains a free-form text entry and a Display P3 profile (from photocraft-cms, now a dev-dependency; no profile file committed), so it checks the text is gone with `none` and kept by a default PNG, and that the default export keeps a real ICC profile that `none` matches, instead of comparing None with None. --- Cargo.lock | 1 + apps/photocraft-cli/Cargo.toml | 2 ++ apps/photocraft-cli/src/args.rs | 6 +++--- apps/photocraft-cli/tests/it/cli.rs | 20 ++++++++++++++------ book/src/automation/cli.md | 17 +++++++++++------ docs/file-format-parity.md | 2 +- 6 files changed, 32 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ab0417b79b..0fab1b2a97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5102,6 +5102,7 @@ dependencies = [ "clap_complete", "clap_mangen", "photocraft-automation", + "photocraft-cms", "photocraft-codecs", "photocraft-engine", "photocraft-format", diff --git a/apps/photocraft-cli/Cargo.toml b/apps/photocraft-cli/Cargo.toml index 94e04b518a..19ef8bfa92 100644 --- a/apps/photocraft-cli/Cargo.toml +++ b/apps/photocraft-cli/Cargo.toml @@ -34,6 +34,8 @@ thiserror = { workspace = true } tokio = { version = "1", features = ["rt-multi-thread", "macros"] } [dev-dependencies] +# A real ICC profile for the --metadata test (#9466). +photocraft-cms = { workspace = true } # Synthetic camera raws (#7789). photocraft-raw = { workspace = true, features = ["testgen"] } # Shell completions and man pages (examples/man-and-completions.rs, for the packages); not in diff --git a/apps/photocraft-cli/src/args.rs b/apps/photocraft-cli/src/args.rs index a5de7bbc68..4ba08fbaae 100644 --- a/apps/photocraft-cli/src/args.rs +++ b/apps/photocraft-cli/src/args.rs @@ -79,9 +79,9 @@ pub(crate) struct ExportArgs { #[arg(long)] pub(crate) pcraft_full_composite: bool, /// The document metadata a flat export embeds: all (the default) keeps the document's XMP - /// packet and free-form text, as Save As does; none leaves them out, as Export As's Metadata: - /// None. The ICC profile, EXIF and resolution are kept either way, and PSD, PSB and .pcraft - /// always keep the XMP + /// packet and free-form text where the format holds them, as Save As does; none leaves them + /// out, as Export As's Metadata: None. Nothing else changes: the ICC profile, EXIF and + /// resolution are written as they are without it. PSD, PSB and .pcraft always keep the XMP #[arg(long, value_name = "none|all", value_parser = metadata)] pub(crate) metadata: Option, } diff --git a/apps/photocraft-cli/tests/it/cli.rs b/apps/photocraft-cli/tests/it/cli.rs index caad46edc1..cffeed0644 100644 --- a/apps/photocraft-cli/tests/it/cli.rs +++ b/apps/photocraft-cli/tests/it/cli.rs @@ -758,10 +758,11 @@ fn jpeg_format_chooses_the_scans() { } } -/// #9466: `--metadata none` leaves the document's XMP packet out of a flat export from `convert`, -/// `run --out` and `batch`, as Export As's Metadata: None does, and changes nothing else (pixels, -/// ICC profile, resolution). The default, and `all`, keep it as before, and a PSD or `.pcraft` -/// keeps it either way. A value it doesn't name is a usage error and nothing is written. +/// #9466: `--metadata none` leaves the document's XMP packet and free-form text out of a flat +/// export from `convert`, `run --out` and `batch`, as Export As's Metadata: None does, and changes +/// nothing else (pixels, ICC profile, resolution). The default, and `all`, keep them as before (the +/// text where the format holds it, as PNG does), and a PSD or `.pcraft` keeps the XMP either way. +/// A value it doesn't name is a usage error and nothing is written. #[test] fn metadata_none_leaves_out_the_document_xmp() { const XMP: &str = "\ @@ -776,10 +777,14 @@ fn metadata_none_leaves_out_the_document_xmp() { let mut img = photocraft_codecs::Image::from_u8(8, 6, photocraft_codecs::ChannelLayout::Rgba, data).unwrap(); img.meta.xmp = Some(XMP.into()); img.meta.dpi = Some((300.0, 300.0)); + img.meta.text = vec![("Comment".into(), "free-form-note".into())]; + // Display P3 rather than sRGB, so no export can pass for keeping it by writing its default. + img.icc = Some(photocraft_cms::Builtin::DisplayP3.profile().to_bytes().to_vec()); std::fs::write(&a, photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap(); let ancestors = |p: &Path| String::from_utf8_lossy(&std::fs::read(p).unwrap()).contains("DocumentAncestors"); let decoded = |p: &Path| photocraft_codecs::decode(&std::fs::read(p).unwrap()).unwrap(); - assert!(ancestors(&a), "the input carries the packet"); + let note = |p: &Path| decoded(p).meta.text.iter().any(|(_, v)| v == "free-form-note"); + assert!(ancestors(&a) && note(&a), "the input carries the packet and the text"); let invert = r#"[{"command":"image.adjustments.invert"}]"#; for ext in ["png", "webp", "jpg", "tif"] { let mut converted_files = Vec::new(); @@ -795,12 +800,15 @@ fn metadata_none_leaves_out_the_document_xmp() { let batched = d.join(format!("batch-{name}")).join(format!("a.{ext}")); for out in [&converted, &ran, &batched] { assert_eq!(ancestors(out), flag != Some("none"), "{name}: {}", out.display()); + // PNG holds free-form text; JPEG, WebP and TIFF (but for its named tags) don't. + assert_eq!(note(out), ext == "png" && flag != Some("none"), "{name} text: {}", out.display()); } converted_files.push(converted); } - // Only the XMP goes: the pixels, profile and resolution are the default export's. + // Only the XMP and text go: the pixels, profile and resolution are the default export's. let (default, none) = (decoded(&converted_files[0]), decoded(&converted_files[2])); assert_eq!(none.data(), default.data(), "{ext}: pixels"); + assert!(default.icc.is_some(), "{ext}: the default export keeps the ICC profile"); assert_eq!(none.icc, default.icc, "{ext}: ICC profile"); assert_eq!(none.meta.dpi, default.meta.dpi, "{ext}: resolution"); assert!(none.meta.dpi.is_some_and(|(x, y)| (x - 300.0).abs() < 0.5 && (y - 300.0).abs() < 0.5), "{ext}: {:?}", none.meta.dpi); diff --git a/book/src/automation/cli.md b/book/src/automation/cli.md index 2f8c3628d7..c7278455ef 100644 --- a/book/src/automation/cli.md +++ b/book/src/automation/cli.md @@ -37,14 +37,19 @@ the operation. To list or replace the missing families intentionally, use ### Document metadata -`convert`, `run --out` and `batch` keep the document's XMP packet and -free-form text in a flat export (PNG, JPEG, WebP, TIFF, …), as Save As does. -`--metadata none` leaves them out, as Export As's Metadata: None does: a -Photoshop file's XMP can list every placed document's id +`convert`, `run --out` and `batch` keep the document's XMP packet in a flat +export whose format holds one (PNG, JPEG, WebP, TIFF, …), and its free-form +text where the format holds that too (PNG text chunks, TIFF's named text tags +such as ImageDescription; JPEG and WebP have no place for it), as Save As +does. `--metadata none` leaves them out, as Export As's Metadata: None does: +a Photoshop file's XMP can list every placed document's id (`DocumentAncestors`) and the text of every type layer, and can be far larger than the image. `--metadata all` is the default. The option changes nothing -else: the ICC profile, EXIF and resolution are written either way, and PSD, -PSB and `.pcraft` saves always keep the XMP. It is not a full metadata scrub. +else: the ICC profile, EXIF and resolution are written as they are without +it, where the format holds them. An Indexed Color document saved as a palette +PNG carries no XMP, text, ICC profile or EXIF with either value, only its +resolution. PSD, PSB and `.pcraft` saves always keep the XMP. It is not a full +metadata scrub. ```sh photocraft-cli batch --actions resize.json --in masters/ --out web/ \ diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index c586cb0b60..3fcbc6eb84 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -151,7 +151,7 @@ The original reference entry retains its source date and historical totals; curr | Date | Change | Summary | |---|---|---| -| 2026-11-03 | minor | CLI `--metadata` (`none` or `all`) for `convert`, `run --out` and `batch`: `none` leaves the document's XMP packet and free-form text out of flat exports (PNG, JPEG, WebP, TIFF, …) through Export As's Metadata: None path; `all`, which keeps them as Save As does, stays the default. ICC, EXIF and resolution are written either way; PSD, PSB and `.pcraft` always keep the XMP (#9466) | +| 2026-11-03 | minor | CLI `--metadata` (`none` or `all`) for `convert`, `run --out` and `batch`: `none` leaves the document's XMP packet and free-form text out of flat exports (PNG, JPEG, WebP, TIFF, …) through Export As's Metadata: None path; `all`, which keeps them as Save As does, stays the default. The ICC profile, EXIF and resolution are written as they are without the option (a palette PNG carries only its resolution, either way); PSD, PSB and `.pcraft` always keep the XMP (#9466) | | 2026-11-01 | minor | `.atn`: event reference [atn.md](atn.md): the 117 supported events, and measured keys, defaults, ranges and enum values for 101 events not mapped yet | | 2026-10-31 | minor | `.atn` Group B: four Render events load, play and save; 120 supported and 97 unmapped. Supported-table columns corrected against the event registry; the original 117/101 reference entry remains historical. | | 2026-10-31 | minor | `.atn`: Pinch (`Pnch`), Polar Coordinates (`Plr `), Spherize (`Sphr`), Twirl (`Twrl`), Wave (`Wave`) and ZigZag (`ZgZg`) map to `filter.distort.*` with every key in recorded order; out-of-range values clamped and missing keys given Photoshop's defaults on import; a Wave scaled below 100 % stays a Photoshop step (the command has no scale); Shear still kept verbatim (#10605) | From a112beda7e4f11f9e936fd30bbc8ac9f472ecfe1 Mon Sep 17 00:00:00 2001 From: ljkx <177671075+ljkx@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:09:08 +0100 Subject: [PATCH 041/219] CLI --metadata test: the export keeps the input's own ICC profile Review nitpick (#9466): the test only checked that the default export had some ICC profile and that `none` matched it, so a regression writing sRGB instead of the input's Display P3 would still pass. It now compares the default export's profile with the input's bytes. --- apps/photocraft-cli/tests/it/cli.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/photocraft-cli/tests/it/cli.rs b/apps/photocraft-cli/tests/it/cli.rs index cffeed0644..f830c25dc7 100644 --- a/apps/photocraft-cli/tests/it/cli.rs +++ b/apps/photocraft-cli/tests/it/cli.rs @@ -779,7 +779,8 @@ fn metadata_none_leaves_out_the_document_xmp() { img.meta.dpi = Some((300.0, 300.0)); img.meta.text = vec![("Comment".into(), "free-form-note".into())]; // Display P3 rather than sRGB, so no export can pass for keeping it by writing its default. - img.icc = Some(photocraft_cms::Builtin::DisplayP3.profile().to_bytes().to_vec()); + let p3 = photocraft_cms::Builtin::DisplayP3.profile().to_bytes().to_vec(); + img.icc = Some(p3.clone()); std::fs::write(&a, photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap(); let ancestors = |p: &Path| String::from_utf8_lossy(&std::fs::read(p).unwrap()).contains("DocumentAncestors"); let decoded = |p: &Path| photocraft_codecs::decode(&std::fs::read(p).unwrap()).unwrap(); @@ -808,7 +809,7 @@ fn metadata_none_leaves_out_the_document_xmp() { // Only the XMP and text go: the pixels, profile and resolution are the default export's. let (default, none) = (decoded(&converted_files[0]), decoded(&converted_files[2])); assert_eq!(none.data(), default.data(), "{ext}: pixels"); - assert!(default.icc.is_some(), "{ext}: the default export keeps the ICC profile"); + assert_eq!(default.icc.as_deref(), Some(&p3[..]), "{ext}: the default export keeps the input's Display P3 profile"); assert_eq!(none.icc, default.icc, "{ext}: ICC profile"); assert_eq!(none.meta.dpi, default.meta.dpi, "{ext}: resolution"); assert!(none.meta.dpi.is_some_and(|(x, y)| (x - 300.0).abs() < 0.5 && (y - 300.0).abs() < 0.5), "{ext}: {:?}", none.meta.dpi); From d630efdce27ec7170dc842438f013452d5fb8df7 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Tue, 3 Nov 2026 19:22:40 -0800 Subject: [PATCH 042/219] Use whole-edge transform handles and consistent rotate cursors --- .../ui-egui/src/canvas/transform_controls.rs | 62 +++--- .../ui-egui/src/tools/transform_tool/draw.rs | 14 +- .../src/tools/transform_tool/pointer.rs | 120 +++++++---- .../ui-egui/src/transform_hit_zone_tests.rs | 186 ++++++++++++++++++ 4 files changed, 304 insertions(+), 78 deletions(-) create mode 100644 crates/ui-egui/src/transform_hit_zone_tests.rs diff --git a/crates/ui-egui/src/canvas/transform_controls.rs b/crates/ui-egui/src/canvas/transform_controls.rs index 7e00fd8cb3..d0ad6ee752 100644 --- a/crates/ui-egui/src/canvas/transform_controls.rs +++ b/crates/ui-egui/src/canvas/transform_controls.rs @@ -58,50 +58,40 @@ pub(super) fn transform_controls_rect(app: &PhotocraftApp, xf: &ViewXform) -> Op Some(Rect::from_two_pos(xf.to_screen(b.x0 as f32, b.y0 as f32), xf.to_screen(b.x1 as f32, b.y1 as f32))) } -/// The Move tool shows transform handles even before a Free Transform session exists. -/// Use the same hit areas for pointer-down and hover, so a control never behaves like -/// resize/rotate while showing the ordinary four-arrow Move cursor (#2871). +fn controls_quad(r: Rect) -> [[f64; 2]; 4] { + [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()].map(|p| [f64::from(p.x), f64::from(p.y)]) +} + +fn controls_zone(r: Rect, p: Pos2) -> crate::transform_tool::Hit { + crate::state::TransformSession::box_hit(&controls_quad(r), [f64::from(p.x), f64::from(p.y)], crate::transform_tool::HANDLE_PX) +} + +/// Share the open session's screen-sized zones before the first interaction (#11097). pub(super) fn transform_controls_cursor(r: Rect, p: Pos2) -> Option { - let handles = [r.left_top(), r.center_top(), r.right_top(), r.right_center(), r.right_bottom(), r.center_bottom(), r.left_bottom(), r.left_center()]; - let grab = crate::transform_tool::HANDLE_PX as f32; - for (i, handle) in handles.into_iter().enumerate() { - if handle.distance(p) <= grab { - return Some(match i { - 0 | 4 => egui::CursorIcon::ResizeNwSe, - 2 | 6 => egui::CursorIcon::ResizeNeSw, - 1 | 5 => egui::CursorIcon::ResizeVertical, - _ => egui::CursorIcon::ResizeHorizontal, - }); - } - } - // The rotation band: the pointer gives way to the curved rotate arrow (`transform_controls_turn`). - in_rotation_band(r, p).then_some(egui::CursorIcon::None) + use crate::transform_tool::Hit; + Some(match controls_zone(r, p) { + Hit::Corner(0 | 2) => egui::CursorIcon::ResizeNwSe, + Hit::Corner(_) => egui::CursorIcon::ResizeNeSw, + Hit::Edge(0 | 2) => egui::CursorIcon::ResizeVertical, + Hit::Edge(_) => egui::CursorIcon::ResizeHorizontal, + Hit::Outside => egui::CursorIcon::None, + Hit::Inside | Hit::Pivot => egui::CursorIcon::Default, + }) } -fn in_rotation_band(r: Rect, p: Pos2) -> bool { - r.expand(18.0).contains(p) && !r.expand(5.0).contains(p) -} - -/// In the rotation band just outside the Move tool's transform controls, the screen direction the -/// curved rotate arrow bends round: into the box along the nearest corner's bisector, as Free -/// Transform draws it (#4658). Before, the band showed a plain alias pointer, so nothing said a -/// drag there rotates. +/// At any distance outside the controls, the same edge/corner direction as Free Transform. pub(super) fn transform_controls_turn(r: Rect, p: Pos2) -> Option { - if !in_rotation_band(r, p) - || [r.left_top(), r.center_top(), r.right_top(), r.right_center(), r.right_bottom(), r.center_bottom(), r.left_bottom(), r.left_center()] - .into_iter() - .any(|h| h.distance(p) <= crate::transform_tool::HANDLE_PX as f32) - { + if controls_zone(r, p) != crate::transform_tool::Hit::Outside { return None; } - let corner = pos2(if p.x < r.center().x { r.left() } else { r.right() }, if p.y < r.center().y { r.top() } else { r.bottom() }); - Some(vec2((r.center().x - corner.x).signum(), (r.center().y - corner.y).signum()).normalized() * 16.0) + let k = crate::state::TransformSession::box_turn_direction(&controls_quad(r), [f64::from(p.x), f64::from(p.y)])?; + Some(vec2(k[0] as f32, k[1] as f32) * 16.0) } -/// A visible handle starts scaling; the narrow band just outside the box starts rotation. +/// A visible handle starts scaling; any point outside the box starts rotation. /// The interior stays the normal Move-tool drag target. pub(super) fn transform_controls_hit(r: Rect, p: Pos2) -> bool { - transform_controls_cursor(r, p).is_some() + !matches!(controls_zone(r, p), crate::transform_tool::Hit::Inside | crate::transform_tool::Hit::Pivot) } /// Enter the existing Free Transform session when a Move-tool transform control is pressed: on @@ -138,3 +128,7 @@ pub(super) fn draw_transform_controls(app: &mut PhotocraftApp, painter: &egui::P painter.rect_stroke(h, 0.0, Stroke::new(1.0, accent), egui::StrokeKind::Inside); } } + +#[cfg(test)] +#[path = "../transform_hit_zone_tests.rs"] +mod hit_zone_tests; diff --git a/crates/ui-egui/src/tools/transform_tool/draw.rs b/crates/ui-egui/src/tools/transform_tool/draw.rs index 26807afed6..21c14cb86f 100644 --- a/crates/ui-egui/src/tools/transform_tool/draw.rs +++ b/crates/ui-egui/src/tools/transform_tool/draw.rs @@ -1,11 +1,11 @@ use super::begin::warps_type; use super::drag_modes::{HandleIndicator, handle_indicator}; use super::options_bar::{quad_scale, readout}; -use super::pointer::{Hit, handle_at, rotate_dir, rotation_degrees}; +use super::pointer::{Hit, handle_at, rotation_degrees}; use super::warp::{on_section_edge, quick_split_kind, single_patch}; use super::{ - BezierMesh, Color32, DocId, HANDLE_PX, Homography, LayerId, PhotocraftApp, Pos2, SplitTool, Stroke, TransformPreview, TransformSession, ViewXform, Warp, - WarpDrag, WarpStyle, corners, pos2, vec2, + BezierMesh, Color32, DocId, Homography, LayerId, PhotocraftApp, Pos2, SplitTool, Stroke, TransformPreview, TransformSession, ViewXform, Warp, WarpDrag, + WarpStyle, corners, pos2, vec2, }; /// The distort or skew pointer at screen point `at`, where [`super::cursor`] hid the system @@ -212,14 +212,6 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform painter.line_segment([c - vec2(8.0, 0.0), c + vec2(8.0, 0.0)], Stroke::new(1.0, Color32::WHITE)); painter.line_segment([c - vec2(0.0, 8.0), c + vec2(0.0, 8.0)], Stroke::new(1.0, Color32::WHITE)); if let Some(at) = painter.ctx().pointer_hover_pos().filter(|p| xf.rect.contains(*p)) { - let p = xf.to_doc(at); - let tol = HANDLE_PX / f64::from(xf.zoom.max(0.01)); - if let Some(k) = rotate_dir(t, Some(pv), p, tol) { - // Through the view, so the arc bends round the corner as it shows under a turned or - // flipped view. - let toward = xf.to_screen((p[0] + k[0] * 16.0) as f32, (p[1] + k[1] * 16.0) as f32) - at; - crate::crop_ui::draw_turn_cursor(painter.ctx(), at, toward); - } // Preferences › Tools › Show Transformation Values off hides it, as in Photoshop (#204). if let Some(r) = live_readout(app).filter(|_| app.session.prefs().tools.show_transformation_values) { draw_live_readout(app, painter.ctx(), at, r); diff --git a/crates/ui-egui/src/tools/transform_tool/pointer.rs b/crates/ui-egui/src/tools/transform_tool/pointer.rs index dd76e446c9..6f9186164a 100644 --- a/crates/ui-egui/src/tools/transform_tool/pointer.rs +++ b/crates/ui-egui/src/tools/transform_tool/pointer.rs @@ -17,21 +17,91 @@ pub(crate) enum Hit { Outside, } -/// The nearest handle within `tol` (on a small box their grab areas overlap), else inside or -/// outside the box. +/// Screen-sized corner and outward edge bands, shared with Move's unopened controls. +impl TransformSession { + pub(crate) fn box_hit(quad: &[[f64; 2]; 4], p: [f64; 2], tol: f64) -> Hit { + // `tol` is the existing 12 pt handle tolerance, divided by the view zoom. + let corner_radius = tol * (8.0 / HANDLE_PX); + let edge_width = tol * (11.0 / HANDLE_PX); + let distance = |q: [f64; 2]| (p[0] - q[0]).hypot(p[1] - q[1]); + if let Some((i, _)) = quad.iter().enumerate().map(|(i, &q)| (i, distance(q))).filter(|(_, d)| *d <= corner_radius).min_by(|a, b| a.1.total_cmp(&b.1)) { + return Hit::Corner(i); + } + let interior = inside(quad, p); + let edge = (0..4) + .filter_map(|i| { + let (_, q) = edge_projection(quad, i, p)?; + let d = distance(q); + // One pixel inside is interior, not a scale handle. The line itself is a handle. + (d <= edge_width && (!interior || d <= tol.abs() * 1e-9)).then_some((i, d)) + }) + .min_by(|a, b| a.1.total_cmp(&b.1)); + match edge { + Some((i, _)) => Hit::Edge(i), + None if interior => Hit::Inside, + None => Hit::Outside, + } + } + + /// Into the nearest edge or corner: eight orientations for an upright rectangle, following + /// the actual quad when it is rotated, flipped or distorted. + pub(crate) fn box_turn_direction(quad: &[[f64; 2]; 4], p: [f64; 2]) -> Option<[f64; 2]> { + if p.iter().chain(quad.iter().flatten()).any(|v| !v.is_finite()) { + return None; + } + let (i, u, _) = (0..4) + .filter_map(|i| { + let (u, q) = edge_projection(quad, i, p)?; + Some((i, u, (p[0] - q[0]).hypot(p[1] - q[1]))) + }) + .min_by(|a, b| a.2.total_cmp(&b.2))?; + let unit = |v: [f64; 2]| { + let len = v[0].hypot(v[1]); + (len.is_finite() && len > 1e-9).then(|| [v[0] / len, v[1] / len]) + }; + let toward = if u <= 0.0 || u >= 1.0 { + let i = if u >= 1.0 { (i + 1) % 4 } else { i }; + let direction = |j: usize| unit([quad[j][0] - quad[i][0], quad[j][1] - quad[i][1]]); + let (a, b) = (direction((i + 1) % 4)?, direction((i + 3) % 4)?); + [a[0] + b[0], a[1] + b[1]] + } else { + let (a, b) = (quad[i], quad[(i + 1) % 4]); + let normal = [a[1] - b[1], b[0] - a[0]]; + let center = quad.iter().fold([0.0; 2], |c, q| [c[0] + q[0] / 4.0, c[1] + q[1] / 4.0]); + let sign = (normal[0] * (center[0] - a[0]) + normal[1] * (center[1] - a[1])).signum(); + normal.map(|v| v * sign) + }; + unit(toward) + } +} + +/// The nearest point on an edge segment and its clamped parameter. Degenerate edges have no +/// band or normal; the other edges and corner handles remain usable. +fn edge_projection(quad: &[[f64; 2]; 4], i: usize, p: [f64; 2]) -> Option<(f64, [f64; 2])> { + let (a, b) = (quad[i], quad[(i + 1) % 4]); + let d = [b[0] - a[0], b[1] - a[1]]; + let len2 = d[0] * d[0] + d[1] * d[1]; + if !len2.is_finite() || len2 <= 1e-18 { + return None; + } + let u = (((p[0] - a[0]) * d[0] + (p[1] - a[1]) * d[1]) / len2).clamp(0.0, 1.0); + Some((u, [a[0] + u * d[0], a[1] + u * d[1]])) +} + +/// The shared box zones, retaining the nearest pivot's existing grab priority on small boxes. pub(crate) fn hit(t: &TransformSession, p: [f64; 2], tol: f64) -> Hit { let d = |a: [f64; 2]| ((a[0] - p[0]).powi(2) + (a[1] - p[1]).powi(2)).sqrt(); let mid = |i: usize| { let (a, b) = (t.quad[i], t.quad[(i + 1) % 4]); [(a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0] }; - let handles = (0..4).map(|i| (t.quad[i], Hit::Corner(i))).chain((0..4).map(|i| (mid(i), Hit::Edge(i)))).chain([(t.pivot, Hit::Pivot)]); - let nearest = handles.map(|(q, h)| (d(q), h)).filter(|(dist, _)| *dist < tol).min_by(|a, b| a.0.total_cmp(&b.0)); - match nearest { - Some((_, h)) => h, - None if inside(&t.quad, p) => Hit::Inside, - None => Hit::Outside, - } + let h = TransformSession::box_hit(&t.quad, p, tol); + let handle_distance = match h { + Hit::Corner(i) => d(t.quad[i]), + Hit::Edge(i) => d(mid(i)), + _ => f64::INFINITY, + }; + if d(t.pivot) < tol && d(t.pivot) < handle_distance { Hit::Pivot } else { h } } fn inside(q: &[[f64; 2]; 4], p: [f64; 2]) -> bool { @@ -208,16 +278,12 @@ pub(super) fn carry_pivot(quad0: [[f64; 2]; 4], quad: [[f64; 2]; 4], pivot0: [f6 if x.is_finite() && y.is_finite() { [x, y] } else { pivot0 } } -/// True just outside a corner: rotate handle feedback lives here, not over the entire -/// empty canvas. The actual rotation gesture continues to work farther away. +/// Rotate feedback covers every outside point where a rotation gesture can start. pub(super) fn near_rotate_corner(t: &TransformSession, p: [f64; 2], tol: f64) -> bool { - t.warp.is_none() - && t.mode != TransformMode::Distort - && { - let h = hit(t, p, tol); - h == Hit::Outside || (t.mode == TransformMode::Rotate && matches!(h, Hit::Corner(_) | Hit::Edge(_))) - } - && t.quad.iter().any(|q| (p[0] - q[0]).hypot(p[1] - q[1]) <= tol * 3.0) + t.warp.is_none() && t.mode != TransformMode::Distort && { + let h = hit(t, p, tol); + h == Hit::Outside || (t.mode == TransformMode::Rotate && matches!(h, Hit::Corner(_) | Hit::Edge(_))) + } } /// Clockwise degrees in canvas coordinates (positive Y points down). @@ -228,8 +294,7 @@ pub(super) fn rotation_degrees(quad: [[f64; 2]; 4]) -> f64 { } /// Where the rotate cursor's arc bends at document point `p`, or `None` outside the rotate zone: -/// just outside a corner, or anywhere while a rotate drag is on. It points into the box along the -/// bisector of the nearest corner, so each corner gets its own arrow and they turn with the box. +/// anywhere outside, or while a rotate drag is on. It follows the nearest edge or corner. pub(crate) fn rotate_cursor_dir(app: &PhotocraftApp, p: [f64; 2]) -> Option<[f64; 2]> { rotate_dir(app.ui.transform.as_ref()?, app.transform_preview.as_ref(), p, handle_tolerance(app)) } @@ -251,17 +316,7 @@ pub(super) fn rotate_dir(t: &TransformSession, pv: Option<&TransformPreview>, p: None if !near_rotate_corner(t, p, tol) => return None, None => {} } - let dist = |q: [f64; 2]| (p[0] - q[0]).hypot(p[1] - q[1]); - let i = (0..4).min_by(|&a, &b| dist(t.quad[a]).total_cmp(&dist(t.quad[b])))?; - let unit = |to: [f64; 2]| { - let d = [to[0] - t.quad[i][0], to[1] - t.quad[i][1]]; - let l = d[0].hypot(d[1]); - (l.is_finite() && l > 1e-9).then(|| [d[0] / l, d[1] / l]) - }; - let (a, b) = (unit(t.quad[(i + 1) % 4])?, unit(t.quad[(i + 3) % 4])?); - let s = [a[0] + b[0], a[1] + b[1]]; - let l = s[0].hypot(s[1]); - (l.is_finite() && l > 1e-9).then(|| [s[0] / l, s[1] / l]) + TransformSession::box_turn_direction(&t.quad, p) } /// The handle a drag in progress holds, else the one under document point `p`. @@ -317,7 +372,6 @@ pub fn cursor(app: &PhotocraftApp, xf: &ViewXform, p: [f64; 2], mods: egui::Modi Hit::Corner(i) => crate::tool_cursor::corner_resize(&t.quad, i, xf), Hit::Edge(i) => resize(edge_mid(i + 2), edge_mid(i)), Hit::Pivot => CursorIcon::Crosshair, - Hit::Inside => CursorIcon::Move, - Hit::Outside => CursorIcon::Alias, + Hit::Inside | Hit::Outside => CursorIcon::Default, }) } diff --git a/crates/ui-egui/src/transform_hit_zone_tests.rs b/crates/ui-egui/src/transform_hit_zone_tests.rs new file mode 100644 index 0000000000..a388f6c909 --- /dev/null +++ b/crates/ui-egui/src/transform_hit_zone_tests.rs @@ -0,0 +1,186 @@ +use super::*; +use crate::state::{ContentAwareOptions, TransformMode, TransformSession}; +use crate::transform_tool::{Hit, cursor, hit, rotate_cursor_dir}; +use egui::{CursorIcon, Modifiers}; + +const QUAD: [[f64; 2]; 4] = [[100.0, 100.0], [500.0, 100.0], [500.0, 300.0], [100.0, 300.0]]; + +fn session(quad: [[f64; 2]; 4]) -> TransformSession { + TransformSession { + session: 1, + layer: 1, + rect: [100.0, 100.0, 500.0, 300.0], + quad, + pivot: quad.iter().fold([0.0; 2], |c, p| [c[0] + p[0] / 4.0, c[1] + p[1] / 4.0]), + interpolation: "bicubic".into(), + warp: None, + selection: false, + target: None, + path: None, + made: None, + mode: TransformMode::Free, + content_aware: None, + skew_v: 0.0, + layers: Vec::new(), + } +} + +fn app(quad: [[f64; 2]; 4], zoom: f32) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", serde_json::json!({"width": 32, "height": 32})).unwrap(); + app.sync_views(); + app.ui.views[0].zoom = zoom; + app.ui.transform = Some(session(quad)); + app +} + +fn view(zoom: f32) -> ViewXform { + ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(1000.0, 800.0)), zoom, center: [300.0, 200.0], flip: false, rotation: 0.0, aspect: 1.0 } +} + +fn near(actual: Option<[f64; 2]>, expected: [f64; 2]) { + let actual = actual.expect("rotate direction"); + assert!((actual[0] - expected[0]).hypot(actual[1] - expected[1]) < 1e-6, "{actual:?} != {expected:?}"); +} + +#[test] +fn whole_edge_and_corner_priority_table_at_100_and_200_percent() { + for zoom in [1.0, 2.0] { + let q = QUAD.map(|p| p.map(|v| v / zoom)); + let t = session(q); + // Coordinates are screen pixels, so the same table must hold at both document zooms. + for (p, expected) in [ + ([100.0, 100.0], Hit::Corner(0)), + ([105.0, 105.0], Hit::Corner(0)), + ([95.0, 95.0], Hit::Corner(0)), + ([500.0, 300.0], Hit::Corner(2)), + ([180.0, 100.0], Hit::Edge(0)), + ([180.0, 89.0], Hit::Edge(0)), + ([500.0, 150.0], Hit::Edge(1)), + ([511.0, 150.0], Hit::Edge(1)), + ([420.0, 311.0], Hit::Edge(2)), + ([89.0, 250.0], Hit::Edge(3)), + ([180.0, 101.0], Hit::Inside), + ([499.0, 150.0], Hit::Inside), + ([180.0, 180.0], Hit::Inside), + ([180.0, 88.0], Hit::Outside), + ([620.0, 200.0], Hit::Outside), + ] { + assert_eq!(hit(&t, p.map(|v| v / zoom), 12.0 / zoom), expected, "zoom {zoom}, screen {p:?}"); + } + let tiny = session([[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]]); + assert_eq!(TransformSession::box_hit(&tiny.quad, [5.0, -1.0], 12.0), Hit::Corner(0), "corners beat overlapping edge bands"); + } +} + +#[test] +fn turned_box_zones_and_resize_cursors_follow_the_quad() { + let turn = |p: [f64; 2]| { + let (x, y) = (p[0] - 300.0, p[1] - 200.0); + let k = std::f64::consts::FRAC_1_SQRT_2; + [300.0 + (x - y) * k, 200.0 + (x + y) * k] + }; + for zoom in [1.0_f32, 2.0] { + let scale = |p: [f64; 2]| turn(p).map(|v| v / f64::from(zoom)); + let q = QUAD.map(scale); + let app = app(q, zoom); + let xf = view(zoom); + let t = app.ui.transform.as_ref().unwrap(); + for (p, expected, icon) in [ + ([180.0, 95.0], Hit::Edge(0), CursorIcon::ResizeNeSw), + ([505.0, 150.0], Hit::Edge(1), CursorIcon::ResizeNwSe), + ([180.0, 101.0], Hit::Inside, CursorIcon::Default), + ([180.0, -20.0], Hit::Outside, CursorIcon::None), + ] { + assert_eq!(hit(t, scale(p), 12.0 / f64::from(zoom)), expected); + assert_eq!(cursor(&app, &xf, scale(p), Modifiers::NONE), Some(icon), "zoom {zoom}, {p:?}"); + } + assert_eq!(hit(t, scale([100.0, 100.0]), 12.0 / f64::from(zoom)), Hit::Corner(0)); + } +} + +#[test] +fn distorted_and_flipped_quads_use_their_edges_not_an_axis_aligned_box() { + let q = [[100.0, 100.0], [500.0, 140.0], [460.0, 300.0], [80.0, 260.0]]; + for q in [q, [q[1], q[0], q[3], q[2]]] { + let t = session(q); + assert_eq!(hit(&t, [220.0, 112.0], 12.0), Hit::Edge(0)); + assert_eq!(hit(&t, [220.0, 107.0], 12.0), Hit::Edge(0)); + assert_eq!(hit(&t, [220.0, 113.0], 12.0), Hit::Inside); + assert_eq!(hit(&t, [220.0, 90.0], 12.0), Hit::Outside); + near(TransformSession::box_turn_direction(&q, [220.0, 90.0]), [-0.1 / 1.01_f64.sqrt(), 1.0 / 1.01_f64.sqrt()]); + } +} + +#[test] +fn outside_rotation_has_eight_edge_and_corner_orientations() { + let app = app(QUAD, 1.0); + let h = std::f64::consts::FRAC_1_SQRT_2; + for (p, direction) in [ + ([300.0, -20.0], [0.0, 1.0]), + ([620.0, -20.0], [-h, h]), + ([620.0, 200.0], [-1.0, 0.0]), + ([620.0, 420.0], [-h, -h]), + ([300.0, 420.0], [0.0, -1.0]), + ([-20.0, 420.0], [h, -h]), + ([-20.0, 200.0], [1.0, 0.0]), + ([-20.0, -20.0], [h, h]), + ] { + near(rotate_cursor_dir(&app, p), direction); + assert_eq!(cursor(&app, &view(1.0), p, Modifiers::NONE), Some(CursorIcon::None)); + assert_eq!(cursor(&app, &view(1.0), p, Modifiers::COMMAND), Some(CursorIcon::None)); + } +} + +#[test] +fn unopened_move_controls_share_the_open_sessions_zones_and_cursors() { + let app = app(QUAD, 1.0); + let r = Rect::from_min_max(pos2(100.0, 100.0), pos2(500.0, 300.0)); + for p in [pos2(100.0, 100.0), pos2(180.0, 95.0), pos2(505.0, 150.0), pos2(180.0, 101.0), pos2(620.0, 200.0), pos2(-20.0, -20.0)] { + let d = [f64::from(p.x), f64::from(p.y)]; + assert_eq!(transform_controls_cursor(r, p), cursor(&app, &view(1.0), d, Modifiers::NONE), "{p:?}"); + assert_eq!(transform_controls_hit(r, p), hit(app.ui.transform.as_ref().unwrap(), d, 12.0) != Hit::Inside); + match (transform_controls_turn(r, p), rotate_cursor_dir(&app, d)) { + (None, None) => {} + (Some(a), Some(b)) => near(Some([f64::from(a.x) / 16.0, f64::from(a.y) / 16.0]), b), + pair => panic!("unopened/open rotation disagreement: {pair:?}"), + } + } + assert_eq!(transform_controls_cursor(r, r.center()), Some(CursorIcon::Default)); + assert!(!transform_controls_hit(r, r.center()), "inside remains an ordinary Move drag"); +} + +#[test] +fn modes_that_do_not_rotate_and_invalid_points_do_not_hide_the_pointer() { + let mut app = app(QUAD, 1.0); + for mode in [TransformMode::Distort, TransformMode::Skew] { + app.ui.transform.as_mut().unwrap().mode = mode; + assert_eq!(rotate_cursor_dir(&app, [620.0, 200.0]), None); + assert_eq!(cursor(&app, &view(1.0), [620.0, 200.0], Modifiers::NONE), Some(CursorIcon::Default)); + } + app.ui.transform.as_mut().unwrap().mode = TransformMode::Free; + app.ui.transform.as_mut().unwrap().content_aware = Some(ContentAwareOptions::default()); + assert_eq!(rotate_cursor_dir(&app, [620.0, 200.0]), None); + assert_eq!(cursor(&app, &view(1.0), [620.0, 200.0], Modifiers::NONE), Some(CursorIcon::Default)); + app.ui.transform.as_mut().unwrap().content_aware = None; + let t = app.ui.transform.as_mut().unwrap(); + t.warp = Some(photocraft_geom::warp::Warp::none(t.rect)); + assert_eq!(rotate_cursor_dir(&app, [620.0, 200.0]), None); + assert_eq!(cursor(&app, &view(1.0), [620.0, 200.0], Modifiers::NONE), Some(CursorIcon::Crosshair), "Warp retains its own cursor"); + app.ui.transform.as_mut().unwrap().warp = None; + assert_eq!(rotate_cursor_dir(&app, [f64::NAN, 200.0]), None); + assert_eq!(TransformSession::box_turn_direction(&[[0.0, 0.0]; 4], [20.0, 20.0]), None); +} + +#[test] +fn rotate_drag_keeps_the_arrow_when_the_pointer_enters_the_box() { + let mut app = app(QUAD, 1.0); + app.ui.transform = None; + app.session.execute("shape.create", serde_json::json!({"kind": "rect", "rect": [4, 4, 16, 16], "fill": "#ff0000"})).unwrap(); + let ctx = egui::Context::default(); + crate::transform_tool::begin(&mut app, &ctx).unwrap(); + crate::transform_tool::pointer(&mut app, crate::canvas::ToolEvent::Down { x: 80.0, y: 40.0, pressure: 1.0 }, Modifiers::NONE); + crate::transform_tool::pointer(&mut app, crate::canvas::ToolEvent::Move { x: 10.0, y: 10.0, pressure: 1.0 }, Modifiers::NONE); + assert!(rotate_cursor_dir(&app, [10.0, 10.0]).is_some(), "the held rotation gesture owns the arrow even inside"); + assert_eq!(cursor(&app, &view(1.0), [10.0, 10.0], Modifiers::NONE), Some(CursorIcon::None)); +} From 6eb625ccbfd8fcfa6f27e5de2f58323d490f85e9 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Tue, 3 Nov 2026 19:35:45 -0800 Subject: [PATCH 043/219] Orient transform rotate arrows by quad winding --- crates/ui-egui/src/tools/transform_tool/pointer.rs | 6 ++++-- crates/ui-egui/src/transform_hit_zone_tests.rs | 9 +++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/tools/transform_tool/pointer.rs b/crates/ui-egui/src/tools/transform_tool/pointer.rs index 6f9186164a..781e02ec20 100644 --- a/crates/ui-egui/src/tools/transform_tool/pointer.rs +++ b/crates/ui-egui/src/tools/transform_tool/pointer.rs @@ -67,8 +67,10 @@ impl TransformSession { } else { let (a, b) = (quad[i], quad[(i + 1) % 4]); let normal = [a[1] - b[1], b[0] - a[0]]; - let center = quad.iter().fold([0.0; 2], |c, q| [c[0] + q[0] / 4.0, c[1] + q[1] / 4.0]); - let sign = (normal[0] * (center[0] - a[0]) + normal[1] * (center[1] - a[1])).signum(); + // Winding orients every edge, even when a concave quad's mean corner position + // lies on the wrong side of this edge. Translate before taking the area. + let cross = |i: usize, j: usize| (quad[i][0] - quad[0][0]) * (quad[j][1] - quad[0][1]) - (quad[i][1] - quad[0][1]) * (quad[j][0] - quad[0][0]); + let sign = (cross(1, 2) + cross(2, 3)).signum(); normal.map(|v| v * sign) }; unit(toward) diff --git a/crates/ui-egui/src/transform_hit_zone_tests.rs b/crates/ui-egui/src/transform_hit_zone_tests.rs index a388f6c909..397cef40c8 100644 --- a/crates/ui-egui/src/transform_hit_zone_tests.rs +++ b/crates/ui-egui/src/transform_hit_zone_tests.rs @@ -87,6 +87,7 @@ fn turned_box_zones_and_resize_cursors_follow_the_quad() { let xf = view(zoom); let t = app.ui.transform.as_ref().unwrap(); for (p, expected, icon) in [ + ([500.0, 100.0], Hit::Corner(1), CursorIcon::ResizeHorizontal), ([180.0, 95.0], Hit::Edge(0), CursorIcon::ResizeNeSw), ([505.0, 150.0], Hit::Edge(1), CursorIcon::ResizeNwSe), ([180.0, 101.0], Hit::Inside, CursorIcon::Default), @@ -112,6 +113,14 @@ fn distorted_and_flipped_quads_use_their_edges_not_an_axis_aligned_box() { } } +#[test] +fn concave_quad_edge_direction_uses_winding_in_both_orientations() { + let q = [[0.0, 0.0], [100.0, 0.0], [10.0, 10.0], [0.0, 100.0]]; + for q in [q, [q[1], q[0], q[3], q[2]]] { + near(TransformSession::box_turn_direction(&q, [56.0, 14.0]), [-1.0 / 82.0_f64.sqrt(), -9.0 / 82.0_f64.sqrt()]); + } +} + #[test] fn outside_rotation_has_eight_edge_and_corner_orientations() { let app = app(QUAD, 1.0); From 5e1e4b85026c930d0692aec68ca8817c03b3a3d4 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Tue, 3 Nov 2026 19:36:59 -0800 Subject: [PATCH 044/219] Keep the transform controls point constructor test-only --- crates/ui-egui/src/canvas/transform_controls.rs | 2 +- crates/ui-egui/src/transform_hit_zone_tests.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/canvas/transform_controls.rs b/crates/ui-egui/src/canvas/transform_controls.rs index d0ad6ee752..68f37fb5b9 100644 --- a/crates/ui-egui/src/canvas/transform_controls.rs +++ b/crates/ui-egui/src/canvas/transform_controls.rs @@ -1,6 +1,6 @@ use super::selection::floating_outline; use super::view_xform::ViewXform; -use super::{Color32, LayerContent, PhotocraftApp, Pos2, Rect, Stroke, Tool, Vec2, pos2, vec2}; +use super::{Color32, LayerContent, PhotocraftApp, Pos2, Rect, Stroke, Tool, Vec2, vec2}; /// View › Show › Layer Edges: the colour of the active layer's outline. pub(crate) const LAYER_EDGES: Color32 = Color32::from_rgb(0x2d, 0x8c, 0xeb); diff --git a/crates/ui-egui/src/transform_hit_zone_tests.rs b/crates/ui-egui/src/transform_hit_zone_tests.rs index 397cef40c8..ca08d2f012 100644 --- a/crates/ui-egui/src/transform_hit_zone_tests.rs +++ b/crates/ui-egui/src/transform_hit_zone_tests.rs @@ -1,7 +1,7 @@ use super::*; use crate::state::{ContentAwareOptions, TransformMode, TransformSession}; use crate::transform_tool::{Hit, cursor, hit, rotate_cursor_dir}; -use egui::{CursorIcon, Modifiers}; +use egui::{CursorIcon, Modifiers, pos2}; const QUAD: [[f64; 2]; 4] = [[100.0, 100.0], [500.0, 100.0], [500.0, 300.0], [100.0, 300.0]]; From 4edd0914b0d620923241061889a3a20c6a839287 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Tue, 3 Nov 2026 19:49:56 -0800 Subject: [PATCH 045/219] Exercise unopened Move edge controls through native dispatch --- .../ui-egui/src/transform_hit_zone_tests.rs | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/crates/ui-egui/src/transform_hit_zone_tests.rs b/crates/ui-egui/src/transform_hit_zone_tests.rs index ca08d2f012..cd0e818853 100644 --- a/crates/ui-egui/src/transform_hit_zone_tests.rs +++ b/crates/ui-egui/src/transform_hit_zone_tests.rs @@ -159,6 +159,39 @@ fn unopened_move_controls_share_the_open_sessions_zones_and_cursors() { assert!(!transform_controls_hit(r, r.center()), "inside remains an ordinary Move drag"); } +#[test] +fn move_whole_edge_press_enters_scaling_at_both_zooms() { + for zoom in [1.0, 2.0] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", serde_json::json!({"width": 640, "height": 480})).unwrap(); + app.session.execute("shape.create", serde_json::json!({"kind": "rect", "rect": [100, 100, 400, 200], "fill": "#ff0000"})).unwrap(); + app.sync_views(); + app.ui.views[0].zoom = zoom; + app.ui.tool = Tool::Move; + app.ui.tool_options.move_show_transform = true; + app.ui.extras.snap = false; + let xf = view(zoom); + let ctx = egui::Context::default(); + let before = app.session.active().unwrap().doc.clone(); + let steps = app.session.active().unwrap().history.past_len(); + // Away from the top edge's midpoint and corners: the native canvas first opens the + // controls here, then forwards the same document-space press to the transform tool. + assert!(begin_transform_controls_at(&mut app, &ctx, &xf, xf.to_screen(180.0, 100.0))); + for event in [ + crate::canvas::ToolEvent::Down { x: 180.0, y: 100.0, pressure: 1.0 }, + crate::canvas::ToolEvent::Move { x: 180.0, y: 90.0, pressure: 1.0 }, + crate::canvas::ToolEvent::Up { x: 180.0, y: 90.0 }, + ] { + crate::canvas::tool_event(&mut app, event, Modifiers::SHIFT); + } + let t = app.ui.transform.as_ref().expect("a handle release retains the transform session"); + assert!(t.quad[0][1] < 100.0, "the pressed edge scales"); + assert!((t.quad[2][1] - 300.0).abs() < 1e-6, "the opposite edge anchors scaling instead of moving the whole box"); + assert_eq!(app.session.active().unwrap().doc, before, "a preview does not commit pixels"); + assert_eq!(app.session.active().unwrap().history.past_len(), steps); + } +} + #[test] fn modes_that_do_not_rotate_and_invalid_points_do_not_hide_the_pointer() { let mut app = app(QUAD, 1.0); From 49575e3a815e2affca3361a6d1d807dd74458509 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Tue, 3 Nov 2026 19:51:50 -0800 Subject: [PATCH 046/219] Keep invalid points outside Move transform control activation --- crates/ui-egui/src/canvas/transform_controls.rs | 5 ++++- crates/ui-egui/src/transform_hit_zone_tests.rs | 6 ++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas/transform_controls.rs b/crates/ui-egui/src/canvas/transform_controls.rs index 68f37fb5b9..5a26a5a281 100644 --- a/crates/ui-egui/src/canvas/transform_controls.rs +++ b/crates/ui-egui/src/canvas/transform_controls.rs @@ -68,6 +68,9 @@ fn controls_zone(r: Rect, p: Pos2) -> crate::transform_tool::Hit { /// Share the open session's screen-sized zones before the first interaction (#11097). pub(super) fn transform_controls_cursor(r: Rect, p: Pos2) -> Option { + if !p.is_finite() { + return None; + } use crate::transform_tool::Hit; Some(match controls_zone(r, p) { Hit::Corner(0 | 2) => egui::CursorIcon::ResizeNwSe, @@ -91,7 +94,7 @@ pub(super) fn transform_controls_turn(r: Rect, p: Pos2) -> Option { /// A visible handle starts scaling; any point outside the box starts rotation. /// The interior stays the normal Move-tool drag target. pub(super) fn transform_controls_hit(r: Rect, p: Pos2) -> bool { - !matches!(controls_zone(r, p), crate::transform_tool::Hit::Inside | crate::transform_tool::Hit::Pivot) + p.is_finite() && !matches!(controls_zone(r, p), crate::transform_tool::Hit::Inside | crate::transform_tool::Hit::Pivot) } /// Enter the existing Free Transform session when a Move-tool transform control is pressed: on diff --git a/crates/ui-egui/src/transform_hit_zone_tests.rs b/crates/ui-egui/src/transform_hit_zone_tests.rs index cd0e818853..66d13125b7 100644 --- a/crates/ui-egui/src/transform_hit_zone_tests.rs +++ b/crates/ui-egui/src/transform_hit_zone_tests.rs @@ -212,6 +212,12 @@ fn modes_that_do_not_rotate_and_invalid_points_do_not_hide_the_pointer() { app.ui.transform.as_mut().unwrap().warp = None; assert_eq!(rotate_cursor_dir(&app, [f64::NAN, 200.0]), None); assert_eq!(TransformSession::box_turn_direction(&[[0.0, 0.0]; 4], [20.0, 20.0]), None); + let r = Rect::from_min_max(pos2(100.0, 100.0), pos2(500.0, 300.0)); + for p in [pos2(f32::NAN, 200.0), pos2(f32::INFINITY, 200.0)] { + assert_eq!(transform_controls_cursor(r, p), None, "invalid points cannot hide the pointer"); + assert_eq!(transform_controls_turn(r, p), None); + assert!(!transform_controls_hit(r, p), "invalid points cannot activate the overlay"); + } } #[test] From d35d966bed91f7cdbea9339896ce718101f6cedf Mon Sep 17 00:00:00 2001 From: yoriswind Date: Tue, 3 Nov 2026 20:36:06 -0800 Subject: [PATCH 047/219] Preserve small transform handles and projected edge points --- .../src/tools/transform_tool/pointer.rs | 35 +++++++---- .../ui-egui/src/transform_hit_zone_tests.rs | 60 ++++++++++++++++++- 2 files changed, 83 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/tools/transform_tool/pointer.rs b/crates/ui-egui/src/tools/transform_tool/pointer.rs index 781e02ec20..735bec0658 100644 --- a/crates/ui-egui/src/tools/transform_tool/pointer.rs +++ b/crates/ui-egui/src/tools/transform_tool/pointer.rs @@ -23,19 +23,32 @@ impl TransformSession { // `tol` is the existing 12 pt handle tolerance, divided by the view zoom. let corner_radius = tol * (8.0 / HANDLE_PX); let edge_width = tol * (11.0 / HANDLE_PX); + // Canvas coordinates round through f32. Treat only sub-pixel projection noise as + // the line itself; a point one screen pixel inside still belongs to the interior. + let line_epsilon = tol.abs() * (1e-3 / HANDLE_PX); let distance = |q: [f64; 2]| (p[0] - q[0]).hypot(p[1] - q[1]); - if let Some((i, _)) = quad.iter().enumerate().map(|(i, &q)| (i, distance(q))).filter(|(_, d)| *d <= corner_radius).min_by(|a, b| a.1.total_cmp(&b.1)) { - return Hit::Corner(i); - } let interior = inside(quad, p); - let edge = (0..4) - .filter_map(|i| { - let (_, q) = edge_projection(quad, i, p)?; - let d = distance(q); - // One pixel inside is interior, not a scale handle. The line itself is a handle. - (d <= edge_width && (!interior || d <= tol.abs() * 1e-9)).then_some((i, d)) - }) - .min_by(|a, b| a.1.total_cmp(&b.1)); + let edge_distance = |i| { + let (_, q) = edge_projection(quad, i, p)?; + let d = distance(q); + (d <= edge_width && (!interior || d <= line_epsilon)).then_some(d) + }; + if let Some((i, corner_distance)) = + quad.iter().enumerate().map(|(i, &q)| (i, distance(q))).filter(|(_, d)| *d <= corner_radius).min_by(|a, b| a.1.total_cmp(&b.1)) + { + // Corners beat edge bands, but overlapping painted handles on small boxes keep + // their nearest-handle priority. Otherwise a 16pt box's edge center is a corner. + let painted_edge = (0..4) + .filter_map(|k| { + edge_distance(k)?; + let (a, b) = (quad[k], quad[(k + 1) % 4]); + let d = distance([(a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0]); + (d < corner_distance).then_some((k, d)) + }) + .min_by(|a, b| a.1.total_cmp(&b.1)); + return painted_edge.map_or(Hit::Corner(i), |(k, _)| Hit::Edge(k)); + } + let edge = (0..4).filter_map(|i| edge_distance(i).map(|d| (i, d))).min_by(|a, b| a.1.total_cmp(&b.1)); match edge { Some((i, _)) => Hit::Edge(i), None if interior => Hit::Inside, diff --git a/crates/ui-egui/src/transform_hit_zone_tests.rs b/crates/ui-egui/src/transform_hit_zone_tests.rs index 66d13125b7..010d4e68e9 100644 --- a/crates/ui-egui/src/transform_hit_zone_tests.rs +++ b/crates/ui-egui/src/transform_hit_zone_tests.rs @@ -69,7 +69,65 @@ fn whole_edge_and_corner_priority_table_at_100_and_200_percent() { assert_eq!(hit(&t, p.map(|v| v / zoom), 12.0 / zoom), expected, "zoom {zoom}, screen {p:?}"); } let tiny = session([[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]]); - assert_eq!(TransformSession::box_hit(&tiny.quad, [5.0, -1.0], 12.0), Hit::Corner(0), "corners beat overlapping edge bands"); + assert_eq!(TransformSession::box_hit(&tiny.quad, [1.0, -1.0], 12.0), Hit::Corner(0), "corners beat overlapping edge bands"); + } +} + +#[test] +fn small_boxes_keep_their_painted_mid_edge_handles() { + for side in [8.0, 16.0, 32.0] { + for zoom in [1.0, 2.0] { + let q = [[0.0, 0.0], [side, 0.0], [side, side], [0.0, side]].map(|p| p.map(|v| v / zoom)); + let t = session(q); + for i in 0..4 { + let (a, b) = (q[i], q[(i + 1) % 4]); + let midpoint = [(a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0]; + assert_eq!(hit(&t, midpoint, 12.0 / zoom), Hit::Edge(i), "side {side}, zoom {zoom}, edge {i}"); + assert_eq!(hit(&t, a, 12.0 / zoom), Hit::Corner(i)); + } + } + } +} + +#[test] +fn an_edge_survives_the_actual_f32_view_round_trip() { + for zoom in [1.0_f32, 2.0] { + let q = [[100.0, 100.00002], [500.0, 100.00002], [500.0, 300.0], [100.0, 300.0]].map(|p| p.map(|v| v / f64::from(zoom))); + let t = session(q); + let xf = view(zoom); + let p = xf.to_doc(xf.to_screen((180.0 / f64::from(zoom)) as f32, q[0][1] as f32)); + let inward_rounding = (p[1] - q[0][1]) * f64::from(zoom); + assert!(inward_rounding > 0.0 && inward_rounding < 1e-3, "fixture must expose inward f32 rounding: {inward_rounding}"); + assert_eq!(hit(&t, p, 12.0 / f64::from(zoom)), Hit::Edge(0)); + assert_eq!(hit(&t, [p[0], q[0][1] + 1.0 / f64::from(zoom)], 12.0 / f64::from(zoom)), Hit::Inside, "a whole pixel inside still moves"); + } +} + +#[test] +fn a_small_edge_keeps_distort_and_legacy_aspect_behavior() { + for mode in [TransformMode::Distort, TransformMode::Free] { + let mut app = app(QUAD, 1.0); + app.ui.transform = None; + app.ui.extras.snap = false; + app.ui.transform_unlinked = true; + app.session.edit_prefs(|p| p.general.use_legacy_free_transform = true); + app.session.execute("shape.create", serde_json::json!({"kind":"rect","rect":[8,8,16,16],"fill":"#ff0000"})).unwrap(); + crate::transform_tool::begin(&mut app, &egui::Context::default()).unwrap(); + app.ui.transform.as_mut().unwrap().mode = mode; + let before = app.ui.transform.as_ref().unwrap().quad; + for event in [ + crate::canvas::ToolEvent::Down { x: 24.0, y: 16.0, pressure: 1.0 }, + crate::canvas::ToolEvent::Move { x: 40.0, y: 16.0, pressure: 1.0 }, + crate::canvas::ToolEvent::Up { x: 40.0, y: 16.0 }, + ] { + crate::canvas::tool_event(&mut app, event, Modifiers::NONE); + } + let q = app.ui.transform.as_ref().unwrap().quad; + if mode == TransformMode::Distort { + assert_eq!(q, before, "Distort still ignores an edge press"); + } else { + assert!((q[2][0] - q[0][0] - 32.0).abs() < 1e-6 && (q[2][1] - q[0][1] - 16.0).abs() < 1e-6, "a legacy edge stretches one axis: {q:?}"); + } } } From 6b38df86bcfe8406bbb920dc5bb255521d951bcd Mon Sep 17 00:00:00 2001 From: yoriswind Date: Tue, 3 Nov 2026 22:11:16 -0800 Subject: [PATCH 048/219] Document transform hit-zone invariants and regression controls --- crates/ui-egui/src/canvas/transform_controls.rs | 2 ++ crates/ui-egui/src/tools/transform_tool/pointer.rs | 7 +++++++ crates/ui-egui/src/transform_hit_zone_tests.rs | 12 ++++++++++++ 3 files changed, 21 insertions(+) diff --git a/crates/ui-egui/src/canvas/transform_controls.rs b/crates/ui-egui/src/canvas/transform_controls.rs index 5a26a5a281..76237b1e20 100644 --- a/crates/ui-egui/src/canvas/transform_controls.rs +++ b/crates/ui-egui/src/canvas/transform_controls.rs @@ -58,10 +58,12 @@ pub(super) fn transform_controls_rect(app: &PhotocraftApp, xf: &ViewXform) -> Op Some(Rect::from_two_pos(xf.to_screen(b.x0 as f32, b.y0 as f32), xf.to_screen(b.x1 as f32, b.y1 as f32))) } +/// Express the unopened screen rectangle in the same corner order as a transform quad. fn controls_quad(r: Rect) -> [[f64; 2]; 4] { [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()].map(|p| [f64::from(p.x), f64::from(p.y)]) } +/// Classify screen coordinates with the fixed screen-space tolerance, without a pivot. fn controls_zone(r: Rect, p: Pos2) -> crate::transform_tool::Hit { crate::state::TransformSession::box_hit(&controls_quad(r), [f64::from(p.x), f64::from(p.y)], crate::transform_tool::HANDLE_PX) } diff --git a/crates/ui-egui/src/tools/transform_tool/pointer.rs b/crates/ui-egui/src/tools/transform_tool/pointer.rs index 735bec0658..d06f921574 100644 --- a/crates/ui-egui/src/tools/transform_tool/pointer.rs +++ b/crates/ui-egui/src/tools/transform_tool/pointer.rs @@ -19,6 +19,9 @@ pub(crate) enum Hit { /// Screen-sized corner and outward edge bands, shared with Move's unopened controls. impl TransformSession { + /// Classify a document point with screen-sized corner and outward edge zones. + /// Corners outrank free edge bands; overlapping painted handles keep nearest-handle priority. + /// Only projection-scale rounding is treated as an edge on the interior side. pub(crate) fn box_hit(quad: &[[f64; 2]; 4], p: [f64; 2], tol: f64) -> Hit { // `tol` is the existing 12 pt handle tolerance, divided by the view zoom. let corner_radius = tol * (8.0 / HANDLE_PX); @@ -119,6 +122,8 @@ pub(crate) fn hit(t: &TransformSession, p: [f64; 2], tol: f64) -> Hit { if d(t.pivot) < tol && d(t.pivot) < handle_distance { Hit::Pivot } else { h } } +/// Even-odd quad membership, independent of winding and valid for concave boxes. +/// Handles and edge-rounding tolerance are classified separately by the caller. fn inside(q: &[[f64; 2]; 4], p: [f64; 2]) -> bool { let mut c = false; for i in 0..4 { @@ -314,6 +319,8 @@ pub(crate) fn rotate_cursor_dir(app: &PhotocraftApp, p: [f64; 2]) -> Option<[f64 rotate_dir(app.ui.transform.as_ref()?, app.transform_preview.as_ref(), p, handle_tolerance(app)) } +/// Choose hover or captured rotation feedback without overriding scaling gestures. +/// Warp, Distort, Skew and Content-Aware Scale keep their own cursor behavior. pub(super) fn rotate_dir(t: &TransformSession, pv: Option<&TransformPreview>, p: [f64; 2], tol: f64) -> Option<[f64; 2]> { // Content-Aware Scale's box only scales, and Skew doesn't rotate: no rotate arrow. if !(p[0].is_finite() && p[1].is_finite()) diff --git a/crates/ui-egui/src/transform_hit_zone_tests.rs b/crates/ui-egui/src/transform_hit_zone_tests.rs index 010d4e68e9..1f9b52ae1c 100644 --- a/crates/ui-egui/src/transform_hit_zone_tests.rs +++ b/crates/ui-egui/src/transform_hit_zone_tests.rs @@ -43,6 +43,7 @@ fn near(actual: Option<[f64; 2]>, expected: [f64; 2]) { assert!((actual[0] - expected[0]).hypot(actual[1] - expected[1]) < 1e-6, "{actual:?} != {expected:?}"); } +/// Screen-sized zones must survive zoom changes; the interior control rejects an all-sided edge grab area. #[test] fn whole_edge_and_corner_priority_table_at_100_and_200_percent() { for zoom in [1.0, 2.0] { @@ -73,6 +74,7 @@ fn whole_edge_and_corner_priority_table_at_100_and_200_percent() { } } +/// An overlapping corner zone must not steal a closer painted midpoint on a small box. #[test] fn small_boxes_keep_their_painted_mid_edge_handles() { for side in [8.0, 16.0, 32.0] { @@ -89,6 +91,7 @@ fn small_boxes_keep_their_painted_mid_edge_handles() { } } +/// Reproduce inward rounding through the view transform, with a one-pixel interior control. #[test] fn an_edge_survives_the_actual_f32_view_round_trip() { for zoom in [1.0_f32, 2.0] { @@ -103,6 +106,7 @@ fn an_edge_survives_the_actual_f32_view_round_trip() { } } +/// The same small-edge press must be ignored by Distort and stretch one axis in legacy unlinked scaling. #[test] fn a_small_edge_keeps_distort_and_legacy_aspect_behavior() { for mode in [TransformMode::Distort, TransformMode::Free] { @@ -131,6 +135,7 @@ fn a_small_edge_keeps_distort_and_legacy_aspect_behavior() { } } +/// At 45 degrees, corners and edges exchange cursor axis orientation at both tested zooms. #[test] fn turned_box_zones_and_resize_cursors_follow_the_quad() { let turn = |p: [f64; 2]| { @@ -158,6 +163,7 @@ fn turned_box_zones_and_resize_cursors_follow_the_quad() { } } +/// Skewed edges and reversed winding must use actual segment geometry for hits and turn direction. #[test] fn distorted_and_flipped_quads_use_their_edges_not_an_axis_aligned_box() { let q = [[100.0, 100.0], [500.0, 140.0], [460.0, 300.0], [80.0, 260.0]]; @@ -171,6 +177,7 @@ fn distorted_and_flipped_quads_use_their_edges_not_an_axis_aligned_box() { } } +/// A concave quad whose corner mean is beyond an edge rejects center-based normal orientation. #[test] fn concave_quad_edge_direction_uses_winding_in_both_orientations() { let q = [[0.0, 0.0], [100.0, 0.0], [10.0, 10.0], [0.0, 100.0]]; @@ -179,6 +186,7 @@ fn concave_quad_edge_direction_uses_winding_in_both_orientations() { } } +/// Each outside position selects its inward turn orientation and hides the system cursor. #[test] fn outside_rotation_has_eight_edge_and_corner_orientations() { let app = app(QUAD, 1.0); @@ -199,6 +207,7 @@ fn outside_rotation_has_eight_edge_and_corner_orientations() { } } +/// Compare unopened controls with an active session at identical points, including a free interior. #[test] fn unopened_move_controls_share_the_open_sessions_zones_and_cursors() { let app = app(QUAD, 1.0); @@ -217,6 +226,7 @@ fn unopened_move_controls_share_the_open_sessions_zones_and_cursors() { assert!(!transform_controls_hit(r, r.center()), "inside remains an ordinary Move drag"); } +/// A press away from midpoint handles must scale an anchored preview without creating a history edit. #[test] fn move_whole_edge_press_enters_scaling_at_both_zooms() { for zoom in [1.0, 2.0] { @@ -250,6 +260,7 @@ fn move_whole_edge_press_enters_scaling_at_both_zooms() { } } +/// Excluded modes retain cursor ownership, and non-finite coordinates cannot activate controls. #[test] fn modes_that_do_not_rotate_and_invalid_points_do_not_hide_the_pointer() { let mut app = app(QUAD, 1.0); @@ -278,6 +289,7 @@ fn modes_that_do_not_rotate_and_invalid_points_do_not_hide_the_pointer() { } } +/// Captured rotation must retain feedback after crossing into the box, independent of hover zones. #[test] fn rotate_drag_keeps_the_arrow_when_the_pointer_enters_the_box() { let mut app = app(QUAD, 1.0); From b5ff372c456c330f0bb99fedcd953f321e2ba154 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Thu, 5 Nov 2026 15:22:12 -0800 Subject: [PATCH 049/219] Check reference point ownership through the actual drag --- .../ui-egui/src/transform_hit_zone_tests.rs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/transform_hit_zone_tests.rs b/crates/ui-egui/src/transform_hit_zone_tests.rs index 72715bdcf2..86f16ac72e 100644 --- a/crates/ui-egui/src/transform_hit_zone_tests.rs +++ b/crates/ui-egui/src/transform_hit_zone_tests.rs @@ -216,12 +216,17 @@ fn outside_rotation_has_eight_edge_and_corner_orientations() { #[test] fn outside_reference_point_owns_the_pointer_only_when_shown() { let p = [80.0, 40.0]; + let pivot = [76.0, 40.0]; + let to = [80.0, 48.0]; for shown in [false, true] { let mut app = app(QUAD, 1.0); app.ui.transform = None; + app.ui.extras.snap = false; app.session.execute("shape.create", serde_json::json!({"kind": "rect", "rect": [4, 4, 16, 16], "fill": "#ff0000"})).unwrap(); crate::transform_tool::begin(&mut app, &egui::Context::default()).unwrap(); - app.ui.transform.as_mut().unwrap().pivot = p; + let t = app.ui.transform.as_mut().unwrap(); + t.pivot = pivot; + let quad = t.quad; app.ui.tool_options.transform_reference_point = shown; assert_eq!(rotate_cursor_dir(&app, p).is_some(), !shown); let icon = if shown { CursorIcon::Crosshair } else { CursorIcon::None }; @@ -229,8 +234,16 @@ fn outside_reference_point_owns_the_pointer_only_when_shown() { let down = crate::canvas::ToolEvent::Down { x: p[0], y: p[1], pressure: 1.0 }; crate::transform_tool::pointer(&mut app, down, Modifiers::NONE); - let gesture = app.transform_preview.as_ref().unwrap().gesture.unwrap(); - assert_eq!(gesture.hit, if shown { Hit::Pivot } else { Hit::Outside }); + let drag = crate::canvas::ToolEvent::Move { x: to[0], y: to[1], pressure: 1.0 }; + crate::transform_tool::pointer(&mut app, drag, Modifiers::NONE); + let t = app.ui.transform.as_ref().unwrap(); + if shown { + assert_eq!(t.quad, quad, "the reference-point drag leaves the box in place"); + assert_eq!(t.pivot, to); + } else { + assert_ne!(t.quad, quad, "the outside drag turns the box"); + assert_eq!(t.pivot, pivot); + } } } From 2037705698ed9e0001f2f19530cfeb58b7131285 Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 6 Nov 2026 01:17:36 +0100 Subject: [PATCH 050/219] Address review: pin crop's 0 origin, numeric strings, partly-outside rect and Reveal All x/y of 0 count as given, numeric strings are refused on every side, a rectangle partly past the canvas grows it as passed, and Reveal All (crop_keeping_background) still crops to all four sides with a selection active. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/image_cmds/tests.rs | 45 +++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/crates/engine/src/cmd/image_cmds/tests.rs b/crates/engine/src/cmd/image_cmds/tests.rs index bf414dc189..b35040cb5d 100644 --- a/crates/engine/src/cmd/image_cmds/tests.rs +++ b/crates/engine/src/cmd/image_cmds/tests.rs @@ -437,6 +437,51 @@ fn crop_refuses_a_bad_explicit_rect_instead_of_cropping_to_the_selection() { assert_eq!(doc(&s).size, Size::new(21, 10)); } +/// The explicit rectangle of #9279's edge cases, each with a selection elsewhere that a fallback +/// would crop to: `0` is a given origin, numeric strings are not numbers (refused, as `x`/`y` +/// already were), and a rectangle partly past the canvas is used as passed, never clamped. +#[test] +fn crop_rect_edge_cases_never_fall_back_to_the_selection() { + let with_selection = || { + let mut s = session(); + s.execute("select.rect", json!({"x": 5, "y": 2, "width": 20, "height": 10})).unwrap(); + s + }; + // `x`/`y` of 0 count as given: the red square (10, 5)..(20, 15) stays where it was, where a + // crop to the selection would have moved it to (5, 3). + let mut s = with_selection(); + s.execute("image.crop", json!({"x": 0, "y": 0, "width": 15, "height": 10})).unwrap(); + assert_eq!(doc(&s).size, Size::new(15, 10)); + assert_eq!(doc(&s).layers[1].surface().unwrap().content_bounds(), Rect::new(10, 5, 15, 10)); + // Numeric strings are refused on every side, with or without a selection. + for p in [ + json!({"x": "0", "y": "0", "width": "15", "height": "10"}), + json!({"x": "0", "y": 0, "width": 15, "height": 10}), + json!({"x": 0, "y": 0, "width": 15, "height": "10"}), + ] { + for mut s in [with_selection(), session()] { + let err = s.execute("image.crop", p.clone()).unwrap_err(); + assert!(matches!(err, EngineError::BadParams { .. }), "{p}: {err:?}"); + assert_eq!(doc(&s).size, Size::new(40, 20), "{p}"); + } + } + // Partly outside the canvas: the canvas grows to the rectangle, not to its overlap with the + // canvas or the selection. + let mut s = with_selection(); + s.execute("image.crop", json!({"x": -10, "y": -5, "width": 30, "height": 40})).unwrap(); + assert_eq!(doc(&s).size, Size::new(30, 40)); + assert_eq!(doc(&s).layers[1].surface().unwrap().pixel(20, 10), vec![1.0, 0.0, 0.0, 1.0]); + // Reveal All passes all four sides through the same check (here x = y = 0: the hidden pixels + // are past the right and bottom edges), so a selection doesn't change what it reveals. + let mut s = with_selection(); + s.execute("image.canvasSize", json!({"width": 12, "height": 8, "anchor": "topLeft"})).unwrap(); + s.execute("select.rect", json!({"x": 2, "y": 2, "width": 4, "height": 4})).unwrap(); + assert_eq!(s.execute("image.revealAll", json!({})).unwrap()["changed"], true); + let size = doc(&s).size; + assert!(size.width >= 20 && size.height >= 15, "{size:?}: the red square is revealed"); + assert_eq!(doc(&s).layers[1].surface().unwrap().pixel(19, 14), vec![1.0, 0.0, 0.0, 1.0]); +} + #[test] fn crop_extension_fills_the_background_with_the_background_colour() { let mut shrink = Session::new(); From 608cedc5eb3e164148a347fa80eb3bb02163b7c2 Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 6 Nov 2026 01:24:20 +0100 Subject: [PATCH 051/219] Address review: keep scaled pattern scales within 1..1000 % scale_effects (Image Size's Scale Styles, Layer Style > Scale Effects, the Free Transform style preview) now clamps the Pattern Overlay, Bevel & Emboss Texture and pattern Stroke scales to the 1..1000 % the Layer Style dialog allows, through one shared photocraft_doc::effects::PATTERN_SCALE_RANGE that the layer style param parser now uses too. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/effects.rs | 4 ++ crates/engine/src/cmd/image_cmds.rs | 16 ++++- .../engine/src/cmd/layer_menu_cmds/tests.rs | 66 +++++++++++++++++++ crates/engine/src/layer_style.rs | 5 +- 4 files changed, 86 insertions(+), 5 deletions(-) diff --git a/crates/doc/src/effects.rs b/crates/doc/src/effects.rs index 4e3f32a97f..a71aa883dd 100644 --- a/crates/doc/src/effects.rs +++ b/crates/doc/src/effects.rs @@ -10,6 +10,10 @@ use serde::{Deserialize, Serialize}; use crate::adjust::CurvePoint; +/// The range of a style's pattern scale as a fraction (1 = 100 %): Pattern Overlay, a pattern +/// Stroke and the Bevel & Emboss Texture all take 1..1000 % in the Layer Style dialog. +pub const PATTERN_SCALE_RANGE: (f32, f32) = (0.01, 10.0); + /// Gradient geometry (fill layers, gradient overlays, gradient strokes). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum GradientStyle { diff --git a/crates/engine/src/cmd/image_cmds.rs b/crates/engine/src/cmd/image_cmds.rs index 6b4f06c3ee..e41fbf09bb 100644 --- a/crates/engine/src/cmd/image_cmds.rs +++ b/crates/engine/src/cmd/image_cmds.rs @@ -151,16 +151,16 @@ pub fn scale_effects(fx: &mut Effects, k: f32) { b.size *= k; b.soften *= k; if let Some(t) = &mut b.texture { - t.scale *= k; + scale_pattern(&mut t.scale, k); } } - Effect::PatternOverlay { scale, .. } => *scale *= k, + Effect::PatternOverlay { scale, .. } => scale_pattern(scale, k), Effect::GradientOverlay { .. } | Effect::ColorOverlay { .. } => {} } if let Effect::Stroke(s) = e && let FxPaint::Pattern { scale, .. } = &mut s.paint { - *scale *= k; + scale_pattern(scale, k); } } // Effects changed: the preserved PSD block no longer applies. @@ -169,6 +169,16 @@ pub fn scale_effects(fx: &mut Effects, k: f32) { } } +/// Scale a style's pattern scale, kept within the 1..1000 % the Layer Style dialog and the +/// reference application allow (a 10 % pattern reduced to 5 % stays at 1 %). +fn scale_pattern(scale: &mut f32, k: f32) { + let (min, max) = photocraft_doc::effects::PATTERN_SCALE_RANGE; + let v = *scale * k; + if !v.is_nan() { + *scale = v.clamp(min, max); + } +} + /// Image Size's resampling methods; `None` changes the resolution only. #[derive(Clone, Copy)] enum Method { diff --git a/crates/engine/src/cmd/layer_menu_cmds/tests.rs b/crates/engine/src/cmd/layer_menu_cmds/tests.rs index 758ba4c4ba..b60e984bd0 100644 --- a/crates/engine/src/cmd/layer_menu_cmds/tests.rs +++ b/crates/engine/src/cmd/layer_menu_cmds/tests.rs @@ -160,6 +160,72 @@ fn scale_effects_scales_the_bevel_texture_with_the_pattern_overlay() { assert_eq!((overlay, texture), (Some(0.5), Some(0.5))); } +/// The pattern scales of Pattern Overlay, the Bevel & Emboss Texture and a pattern Stroke, in +/// that order. +fn pattern_scales(s: &Session) -> (Option, Option, Option) { + let fx = &active(s).effects.items; + let overlay = fx.iter().find_map(|e| match e { + Effect::PatternOverlay { scale, .. } => Some(*scale), + _ => None, + }); + let texture = fx.iter().find_map(|e| match e { + Effect::BevelEmboss(b) => b.texture.as_ref().map(|t| t.scale), + _ => None, + }); + let stroke = fx.iter().find_map(|e| match e { + Effect::Stroke(st) => match &st.paint { + photocraft_doc::FxPaint::Pattern { scale, .. } => Some(*scale), + _ => None, + }, + _ => None, + }); + (overlay, texture, stroke) +} + +/// A layer with a Pattern Overlay, a Bevel & Emboss Texture and a pattern Stroke, all at `pct` %. +fn pattern_styled(pct: f64) -> Session { + let mut s = session(8, "rgb"); + paint(&mut s, disc); + s.execute("layer.layerStyle.patternOverlay", json!({"scale": pct})).unwrap(); + s.execute("layer.layerStyle.bevelEmboss", json!({"texture": "Bubbles", "textureScale": pct})).unwrap(); + s.execute("layer.layerStyle.stroke", json!({"size": 3})).unwrap(); + s.edit("pattern stroke", |doc, active| { + let layer = doc.layer_mut(active.unwrap()).unwrap(); + for e in &mut layer.effects.items { + if let Effect::Stroke(st) = e { + st.paint = photocraft_doc::FxPaint::Pattern { name: "Bubbles".into(), id: String::new(), scale: pct as f32 / 100.0 }; + } + } + Ok(()) + }) + .unwrap(); + s +} + +#[test] +fn scale_effects_keeps_pattern_scales_within_the_dialog_range() { + // 10 % reduced to 1 % would be 0.1 %: the patterns stop at the dialog's 1 %. + let mut s = pattern_styled(10.0); + s.execute("layer.layerStyle.scaleEffects", json!({"scale": 1})).unwrap(); + assert_eq!(pattern_scales(&s), (Some(0.01), Some(0.01), Some(0.01))); + // 500 % enlarged to 1000 % would be 5000 %: they stop at 1000 %. + let mut s = pattern_styled(500.0); + s.execute("layer.layerStyle.scaleEffects", json!({"scale": 1000})).unwrap(); + assert_eq!(pattern_scales(&s), (Some(10.0), Some(10.0), Some(10.0))); + // Inside the range nothing is clamped: 200 % halved is 100 %. + let mut s = pattern_styled(200.0); + s.execute("layer.layerStyle.scaleEffects", json!({"scale": 50})).unwrap(); + assert_eq!(pattern_scales(&s), (Some(1.0), Some(1.0), Some(1.0))); +} + +#[test] +fn image_size_scale_styles_keeps_pattern_scales_within_the_dialog_range() { + // 40x30 -> 400x300 with Scale Styles multiplies by 10: 500 % would become 5000 %. + let mut s = pattern_styled(500.0); + s.execute("image.imageSize", json!({"width": 400, "height": 300, "scaleStyles": true})).unwrap(); + assert_eq!(pattern_scales(&s), (Some(10.0), Some(10.0), Some(10.0))); +} + #[test] fn global_light_rejects_numbers_beyond_f32() { let mut s = session(8, "rgb"); diff --git a/crates/engine/src/layer_style.rs b/crates/engine/src/layer_style.rs index 176f08b264..af7180cc8a 100644 --- a/crates/engine/src/layer_style.rs +++ b/crates/engine/src/layer_style.rs @@ -2,6 +2,7 @@ use photocraft_color::{BlendMode, Color}; use photocraft_doc::adjust::CurvePoint; +use photocraft_doc::effects::PATTERN_SCALE_RANGE; use photocraft_doc::{ Bevel, BevelStyle, BevelTechnique, Contour, Effect, FxCommon, FxPaint, Glow, GlowSource, GlowTechnique, Gradient, GradientStyle, Satin, Shadow, StrokeFx, StrokePosition, @@ -795,7 +796,7 @@ fn overlay_pattern(fx: &mut Effect, p: &Value) { *id = String::new(); } if let Some(x) = f_of(p, "scale") { - *scale = (x / 100.0).clamp(0.01, 10.0); + *scale = (x / 100.0).clamp(PATTERN_SCALE_RANGE.0, PATTERN_SCALE_RANGE.1); } if let Some(x) = f_of(p, "angle") { *angle = x; @@ -903,7 +904,7 @@ fn overlay_bevel(bv: &mut Bevel, p: &Value) { bv.texture = Some(photocraft_doc::BevelTexture { name: t.to_string(), id: t.to_string(), - scale: (f(p, "textureScale", 100.0) / 100.0).clamp(0.01, 10.0), + scale: (f(p, "textureScale", 100.0) / 100.0).clamp(PATTERN_SCALE_RANGE.0, PATTERN_SCALE_RANGE.1), depth: (f(p, "textureDepth", 100.0) / 100.0).clamp(-10.0, 10.0), invert: b(p, "textureInvert", false), link: b(p, "textureLink", true), From e5e2ef36d0d6cce6c4e63f760e8e41f4c60e8114 Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 6 Nov 2026 01:33:34 +0100 Subject: [PATCH 052/219] Address review: crop key tests reach keys() past the default-frame guard After #9855 crop_shield::keys returns early over the untouched default frame, so the Shift+H/Shift+P and Alt-letter tests on crop_app() passed without the fix. They now start a crop in progress (frame changed, default_frame false); the Shift tests also cover the untouched default frame (and, for Shift+I, no frame), where the letters must still reach the tool keys. Plain P with a crop in progress still toggles Classic Mode, so the setup really reaches the key checks. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/tools/crop_shield.rs | 54 ++++++++++++++++++------- crates/ui-egui/src/tools/crop_size.rs | 33 ++++++++++----- 2 files changed, 62 insertions(+), 25 deletions(-) diff --git a/crates/ui-egui/src/tools/crop_shield.rs b/crates/ui-egui/src/tools/crop_shield.rs index e71e86e8c5..ffc9113ad0 100644 --- a/crates/ui-egui/src/tools/crop_shield.rs +++ b/crates/ui-egui/src/tools/crop_shield.rs @@ -389,25 +389,51 @@ mod tests { assert!(app.ui.tool_options.crop_shield.show_cropped_area); } - /// ⇧H and ⇧P are the Hand and Pen tool keys even with a crop box: egui's `consume_key` ignores - /// an extra ⇧, so the plain-key checks must refuse it. + /// A crop in progress: a frame changed from the default one, so [`keys`] gets past its + /// early returns (#9731). + fn crop_in_progress_app() -> PhotocraftApp { + let mut app = crop_app(); + app.ui.crop_rect = Some([20.0, 10.0, 120.0, 90.0]); + app.crop.default_frame = false; + app + } + + /// ⇧H and ⇧P are the Hand and Pen tool keys with a crop in progress, where plain H and P are + /// the crop box's: egui's `consume_key` ignores an extra ⇧, so the plain-key checks must refuse + /// it. Over the untouched default frame they are the tool keys too. #[test] fn shift_h_and_shift_p_switch_tools_instead_of_crop_options() { - for (key, tool) in [(Key::H, Tool::Hand), (Key::P, Tool::Pen)] { - let mut app = crop_app(); - assert!(app.session.prefs().tools.use_shift_key_for_tool_switch); - let before = app.ui.tool_options.crop_shield.clone(); - let ctx = egui::Context::default(); - ctx.input_mut(|i| { - // Only the event holds ⇧, as with a control-channel `ui.key` (the frame's state lags). - i.events.push(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::SHIFT }); - }); - crate::shortcuts::handle(&mut app, &ctx); - assert_eq!(app.ui.tool, tool, "{key:?}"); - assert_eq!(app.ui.tool_options.crop_shield, before, "{key:?} leaves Show Cropped Area and Classic Mode alone"); + for in_progress in [true, false] { + for (key, tool) in [(Key::H, Tool::Hand), (Key::P, Tool::Pen)] { + let mut app = if in_progress { crop_in_progress_app() } else { crop_app() }; + assert_eq!(app.crop.default_frame, !in_progress); + assert!(app.session.prefs().tools.use_shift_key_for_tool_switch); + let before = app.ui.tool_options.crop_shield.clone(); + let ctx = egui::Context::default(); + ctx.input_mut(|i| { + // Only the event holds ⇧, as with a control-channel `ui.key` (the frame's state lags). + i.events.push(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::SHIFT }); + }); + crate::shortcuts::handle(&mut app, &ctx); + assert_eq!(app.ui.tool, tool, "{key:?}, crop in progress: {in_progress}"); + assert_eq!(app.ui.tool_options.crop_shield, before, "{key:?} leaves Show Cropped Area and Classic Mode alone"); + } } } + /// The same plain H and P with a crop in progress are still the crop box's keys, so the test + /// above really reaches the key checks. + #[test] + fn plain_p_toggles_classic_mode_while_a_crop_is_in_progress() { + let mut app = crop_in_progress_app(); + let classic = app.ui.tool_options.crop_shield.classic_mode; + let ctx = egui::Context::default(); + ctx.input_mut(|i| i.events.push(egui::Event::Key { key: Key::P, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE })); + crate::shortcuts::handle(&mut app, &ctx); + assert_eq!(app.ui.tool, Tool::Crop, "P doesn't switch to the Pen tool"); + assert_eq!(app.ui.tool_options.crop_shield.classic_mode, !classic); + } + /// Alt+H, Alt+P and Alt+I are not the crop box's H, P and I (Show Cropped Area, Classic Mode, /// Front Image): egui's `consume_key` ignores an extra ⌥, so the plain-key checks must refuse /// it and leave the press for later handlers. diff --git a/crates/ui-egui/src/tools/crop_size.rs b/crates/ui-egui/src/tools/crop_size.rs index 6320700780..68b0714190 100644 --- a/crates/ui-egui/src/tools/crop_size.rs +++ b/crates/ui-egui/src/tools/crop_size.rs @@ -537,19 +537,30 @@ mod tests { assert!(!keys(&mut app2, &ctx)); } - /// ⇧I is the Eyedropper group's key even with the Crop tool, not Front Image. + /// ⇧I is the Eyedropper group's key even with the Crop tool, not Front Image: with no frame + /// yet, over the untouched default frame and with a crop in progress. #[test] fn shift_i_with_the_crop_tool_switches_to_the_eyedropper() { - let mut app = app(SampleType::U8); - let ratio = app.ui.tool_options.crop_ratio.clone(); - let ctx = egui::Context::default(); - ctx.input_mut(|i| { - // Only the event holds ⇧, as with a control-channel `ui.key` (the frame's state lags). - i.events.push(egui::Event::Key { key: egui::Key::I, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::SHIFT }); - }); - crate::shortcuts::handle(&mut app, &ctx); - assert_eq!(app.ui.tool, Tool::Eyedropper); - assert_eq!(app.ui.tool_options.crop_ratio, ratio); + for frame in ["none", "default", "in progress"] { + let mut app = app(SampleType::U8); + if frame != "none" { + crate::crop_ui::ensure_frame(&mut app); + assert!(app.ui.crop_rect.is_some() && app.crop.default_frame, "{frame}"); + } + if frame == "in progress" { + app.ui.crop_rect = Some([20.0, 10.0, 120.0, 90.0]); + app.crop.default_frame = false; + } + let ratio = app.ui.tool_options.crop_ratio.clone(); + let ctx = egui::Context::default(); + ctx.input_mut(|i| { + // Only the event holds ⇧, as with a control-channel `ui.key` (the frame's state lags). + i.events.push(egui::Event::Key { key: egui::Key::I, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::SHIFT }); + }); + crate::shortcuts::handle(&mut app, &ctx); + assert_eq!(app.ui.tool, Tool::Eyedropper, "{frame}"); + assert_eq!(app.ui.tool_options.crop_ratio, ratio, "{frame}"); + } } /// Committing a W x H x Resolution crop resamples the frame's area to exactly W x H px and sets From a79db76e3045582fb8f56c2309567ef474ad17e3 Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 6 Nov 2026 01:48:29 +0100 Subject: [PATCH 053/219] Address review: Alt-letter test also starts a crop in progress Over the untouched default frame crop_shield::keys returns early, so the Alt+H/Alt+P cases checked nothing; only Alt+I reached its key check. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/tools/crop_shield.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/tools/crop_shield.rs b/crates/ui-egui/src/tools/crop_shield.rs index ffc9113ad0..d8b3fba7e1 100644 --- a/crates/ui-egui/src/tools/crop_shield.rs +++ b/crates/ui-egui/src/tools/crop_shield.rs @@ -440,7 +440,7 @@ mod tests { #[test] fn alt_letters_are_not_the_crop_box_keys() { for key in [Key::H, Key::P, Key::I] { - let mut app = crop_app(); + let mut app = crop_in_progress_app(); let (shield, ratio) = (app.ui.tool_options.crop_shield.clone(), app.ui.tool_options.crop_ratio.clone()); let ctx = egui::Context::default(); let alt = egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::ALT }; From 51806959efaaf29e47ec56859252b90b06408d27 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Thu, 5 Nov 2026 18:00:58 -0800 Subject: [PATCH 054/219] Expect the default cursor inside Free Transform --- crates/ui-egui/src/canvas/snap_ui/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas/snap_ui/tests.rs b/crates/ui-egui/src/canvas/snap_ui/tests.rs index 17ba7ca349..dfb239cc0f 100644 --- a/crates/ui-egui/src/canvas/snap_ui/tests.rs +++ b/crates/ui-egui/src/canvas/snap_ui/tests.rs @@ -876,7 +876,7 @@ fn alt_over_the_box_shows_the_reference_point_pointer() { for p in [[55.0, 52.0], [30.0, 30.0], [200.0, 200.0]] { assert_eq!(crate::transform_tool::cursor(&app, &xf, p, alt), Some(egui::CursorIcon::Crosshair), "{p:?}"); } - assert_eq!(crate::transform_tool::cursor(&app, &xf, [55.0, 52.0], egui::Modifiers::NONE), Some(egui::CursorIcon::Move)); + assert_eq!(crate::transform_tool::cursor(&app, &xf, [55.0, 52.0], egui::Modifiers::NONE), Some(egui::CursorIcon::Default)); assert_ne!(crate::transform_tool::cursor(&app, &xf, [40.0, 40.0], alt), Some(egui::CursorIcon::Crosshair), "a corner handle stays a handle"); } From 1f1f42182b9be8d8c7ab7d6620cb65056588cc7c Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 6 Nov 2026 14:20:22 +0100 Subject: [PATCH 055/219] Address CodeRabbit review: keep the pattern-scale clamp out of previews transform_fx::work_layer scales a style to the preview's work resolution with scale_effects, so the 1..1000 % clamp turned a 2 % pattern at a quarter of the size into 1 % instead of 0.5 %, and an imported 2000 % pattern into 1000 % at full size. scale_effects_preview scales patterns exactly; Scale Effects and Image Size's Scale Styles keep the clamp. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/image_cmds.rs | 30 +++++++++++++------ .../engine/src/cmd/layer_menu_cmds/tests.rs | 27 +++++++++++++++++ crates/ui-egui/src/tools/transform_fx.rs | 23 +++++++++++++- 3 files changed, 70 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/image_cmds.rs b/crates/engine/src/cmd/image_cmds.rs index 0cac8be4b2..03683f1d00 100644 --- a/crates/engine/src/cmd/image_cmds.rs +++ b/crates/engine/src/cmd/image_cmds.rs @@ -134,9 +134,21 @@ pub(crate) fn reidentify_copied_layers(doc: &mut Document) { } } -/// Scale every size and distance of a style by `k` (Image Size's Scale Styles, Layer Style › -/// Scale Effects, and previews that draw a style at a reduced resolution). +/// Scale every size and distance of a style by `k` (Image Size's Scale Styles and Layer Style › +/// Scale Effects), with pattern scales kept within the dialog's 1..1000 %. pub fn scale_effects(fx: &mut Effects, k: f32) { + scale_effects_with(fx, k, true); +} + +/// [`scale_effects`] for a preview that draws a style at a reduced resolution: pattern scales +/// follow `k` exactly, so the preview matches the style it stands for (a 2 % pattern drawn at a +/// quarter of the size is 0.5 %, not the dialog's 1 % minimum). +pub fn scale_effects_preview(fx: &mut Effects, k: f32) { + scale_effects_with(fx, k, false); +} + +fn scale_effects_with(fx: &mut Effects, k: f32, clamp: bool) { + let pattern = |scale: &mut f32| scale_pattern(scale, k, clamp); for e in &mut fx.items { match e { Effect::DropShadow(s) | Effect::InnerShadow(s) => { @@ -153,16 +165,16 @@ pub fn scale_effects(fx: &mut Effects, k: f32) { b.size *= k; b.soften *= k; if let Some(t) = &mut b.texture { - scale_pattern(&mut t.scale, k); + pattern(&mut t.scale); } } - Effect::PatternOverlay { scale, .. } => scale_pattern(scale, k), + Effect::PatternOverlay { scale, .. } => pattern(scale), Effect::GradientOverlay { .. } | Effect::ColorOverlay { .. } => {} } if let Effect::Stroke(s) = e && let FxPaint::Pattern { scale, .. } = &mut s.paint { - scale_pattern(scale, k); + pattern(scale); } } // Effects changed: the preserved PSD block no longer applies. @@ -171,13 +183,13 @@ pub fn scale_effects(fx: &mut Effects, k: f32) { } } -/// Scale a style's pattern scale, kept within the 1..1000 % the Layer Style dialog and the -/// reference application allow (a 10 % pattern reduced to 5 % stays at 1 %). -fn scale_pattern(scale: &mut f32, k: f32) { +/// Scale a style's pattern scale; with `clamp`, kept within the 1..1000 % the Layer Style dialog +/// and the reference application allow (a 10 % pattern reduced to 5 % stays at 1 %). +fn scale_pattern(scale: &mut f32, k: f32, clamp: bool) { let (min, max) = photocraft_doc::effects::PATTERN_SCALE_RANGE; let v = *scale * k; if !v.is_nan() { - *scale = v.clamp(min, max); + *scale = if clamp { v.clamp(min, max) } else { v }; } } diff --git a/crates/engine/src/cmd/layer_menu_cmds/tests.rs b/crates/engine/src/cmd/layer_menu_cmds/tests.rs index b60e984bd0..0dcd7c2333 100644 --- a/crates/engine/src/cmd/layer_menu_cmds/tests.rs +++ b/crates/engine/src/cmd/layer_menu_cmds/tests.rs @@ -218,6 +218,33 @@ fn scale_effects_keeps_pattern_scales_within_the_dialog_range() { assert_eq!(pattern_scales(&s), (Some(1.0), Some(1.0), Some(1.0))); } +/// Previews at a reduced resolution scale the patterns exactly, past the dialog's range: only +/// commands that store the style clamp them. +#[test] +fn preview_scaling_leaves_pattern_scales_unclamped() { + let s = pattern_styled(2.0); + let mut fx = active(&s).effects.clone(); + crate::image_cmds::scale_effects_preview(&mut fx, 0.25); + let mut clamped = active(&s).effects.clone(); + crate::image_cmds::scale_effects(&mut clamped, 0.25); + let scales = |fx: &photocraft_doc::Effects| { + fx.items + .iter() + .filter_map(|e| match e { + Effect::PatternOverlay { scale, .. } => Some(*scale), + Effect::BevelEmboss(b) => b.texture.as_ref().map(|t| t.scale), + Effect::Stroke(st) => match &st.paint { + photocraft_doc::FxPaint::Pattern { scale, .. } => Some(*scale), + _ => None, + }, + _ => None, + }) + .collect::>() + }; + assert_eq!(scales(&fx), vec![0.005; 3]); + assert_eq!(scales(&clamped), vec![0.01; 3]); +} + #[test] fn image_size_scale_styles_keeps_pattern_scales_within_the_dialog_range() { // 40x30 -> 400x300 with Scale Styles multiplies by 10: 500 % would become 5000 %. diff --git a/crates/ui-egui/src/tools/transform_fx.rs b/crates/ui-egui/src/tools/transform_fx.rs index 0ad888536d..5be2428c85 100644 --- a/crates/ui-egui/src/tools/transform_fx.rs +++ b/crates/ui-egui/src/tools/transform_fx.rs @@ -106,7 +106,7 @@ pub fn work_layer(layer: &Layer, scale: f64) -> Layer { l.vector_mask = None; l.fill_cache = None; l.blend = BlendMode::Normal; - photocraft_engine::image_cmds::scale_effects(&mut l.effects, scale as f32); + photocraft_engine::image_cmds::scale_effects_preview(&mut l.effects, scale as f32); l.effects.reference = l.effects.reference.map(|(x, y)| (x * scale, y * scale)); l } @@ -468,6 +468,27 @@ mod tests { l } + fn pattern_stroke_scale(l: &Layer) -> Option { + l.effects.items.iter().find_map(|e| match e { + Effect::Stroke(StrokeFx { paint: FxPaint::Pattern { scale, .. }, .. }) => Some(*scale), + _ => None, + }) + } + + /// The preview draws a pattern at the work scale exactly: the dialog's 1..1000 % range is for + /// stored styles, so a 2 % pattern at a quarter of the size is 0.5 %, and an imported 2000 % + /// one stays 2000 % at full size, as the committed style draws them. + #[test] + fn work_layer_scales_patterns_past_the_dialog_range() { + let with_pattern = |scale: f32| { + let Effect::Stroke(mut st) = stroke(3.0) else { panic!("a stroke") }; + st.paint = FxPaint::Pattern { name: "Bubbles".into(), id: String::new(), scale }; + styled(vec![Effect::Stroke(st)]) + }; + assert_eq!(pattern_stroke_scale(&work_layer(&with_pattern(0.02), 0.25)), Some(0.005)); + assert_eq!(pattern_stroke_scale(&work_layer(&with_pattern(20.0), 1.0)), Some(20.0)); + } + fn doc() -> Document { Document::new("t", photocraft_geom::Size::new(200, 200), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8) } From c3f825eed5641f1e1e0fa9c81f94c07e2e2f74e6 Mon Sep 17 00:00:00 2001 From: AethelisDEV Date: Fri, 30 Oct 2026 02:09:50 +0300 Subject: [PATCH 056/219] ui-egui: support native file drag and drop on Wayland --- apps/photocraft/src/cursor.rs | 2 +- crates/ui-egui/src/app.rs | 1 - crates/ui-egui/src/app/frame.rs | 2 +- crates/ui-egui/src/canvas/start_screen.rs | 10 +-- crates/ui-egui/src/canvas/tabs.rs | 2 +- crates/ui-egui/src/canvas/tests.rs | 7 +- crates/ui-egui/src/file_open.rs | 10 ++- crates/ui-egui/src/notices.rs | 97 +---------------------- 8 files changed, 20 insertions(+), 111 deletions(-) diff --git a/apps/photocraft/src/cursor.rs b/apps/photocraft/src/cursor.rs index 719e8239c9..72548ca638 100644 --- a/apps/photocraft/src/cursor.rs +++ b/apps/photocraft/src/cursor.rs @@ -6,7 +6,7 @@ //! - **macOS**: `NSEvent.mouseLocation`, converted into the content view of our window under it. //! - **X11**: `QueryPointer` on our window (a second, pure-Rust X connection). //! - **Windows**: `GetCursorPos`, relative to the window's client area. -//! - **Wayland**: winit 0.30 delivers no file drops there, so there's nothing to place. +//! - **Wayland**: winit delivers file drops, and egui's last pointer position is used as fallback. //! //! No `unsafe`: every OS call goes through a safe binding. //! diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 0a77bbdf3b..5d5d79c7a8 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -222,7 +222,6 @@ impl PhotocraftApp { histogram_panel::restore_options(&mut app); // After the saved preferences, which say whether the set was already offered. kys_import::offer_import(&mut app); - notices::wayland_file_drop_guidance(&mut app); // File › Scripts › Script Events Manager: "Start Application". photocraft_engine::automate_cmds::fire_event(&mut app.session, "startApplication"); app diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index eec74bfcc4..08774a1b7d 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -121,7 +121,7 @@ impl PhotocraftApp { } let dropped = ctx.input(|i| i.raw.dropped_files.clone()); if !dropped.is_empty() { - let at = self.services.cursor_pos.as_mut().and_then(|f| f(ctx)); + let at = self.cursor_pos(ctx); self.open_dropped(ctx, dropped, at); // Files dragged in from another app's window: PhotoCraft comes to the front and takes // the keyboard, as Photoshop does, instead of staying behind the source (#5687). diff --git a/crates/ui-egui/src/canvas/start_screen.rs b/crates/ui-egui/src/canvas/start_screen.rs index 64fbe541e5..b033500d03 100644 --- a/crates/ui-egui/src/canvas/start_screen.rs +++ b/crates/ui-egui/src/canvas/start_screen.rs @@ -274,13 +274,9 @@ fn render_first_run_content(app: &mut PhotocraftApp, ui: &mut egui::Ui, content_ }); } -/// Wayland has no native file drops (winit 0.30, #386), so the hint offers File › Open and paste. -pub(crate) fn start_screen_drop_hint(app: &PhotocraftApp) -> std::borrow::Cow<'static, str> { - if app.services.is_wayland { - crate::i18n::fmt(tl!("Use File › Open, or paste a copied image with {paste}."), &[("paste", &crate::notices::paste_hint(app))]).into() - } else { - tl!("Drop an image or PSD anywhere to open it.").into() - } +/// Hint displayed on the start screen for opening files. +pub(crate) fn start_screen_drop_hint(_app: &PhotocraftApp) -> std::borrow::Cow<'static, str> { + tl!("Drop an image or PSD anywhere to open it.").into() } /// Recent files listed on the Home screen. diff --git a/crates/ui-egui/src/canvas/tabs.rs b/crates/ui-egui/src/canvas/tabs.rs index 7389104f6c..27341e8224 100644 --- a/crates/ui-egui/src/canvas/tabs.rs +++ b/crates/ui-egui/src/canvas/tabs.rs @@ -697,7 +697,7 @@ fn drop_slot_line(app: &mut PhotocraftApp, ui: &egui::Ui) { if ui.input(|i| i.raw.hovered_files.is_empty()) { return; } - let at = app.services.cursor_pos.as_mut().and_then(|f| f(ui.ctx())); + let at = app.cursor_pos(ui.ctx()); let crate::file_open::DropTarget::Tabs(slot) = app.drop_target(ui.ctx(), at) else { return }; let Some(tabs) = app.tab_strip.as_ref().map(|s| &s.tabs) else { return }; let Some((r, after)) = tabs.iter().find(|(i, _)| *i == slot).map(|&(_, r)| (r, false)).or_else(|| tabs.last().map(|&(_, r)| (r, true))) else { diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 661da5d277..9b532d0055 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -1409,14 +1409,11 @@ fn clone_overlay_is_clipped_to_a_shaped_tip() { } #[test] -fn wayland_start_screen_hint_does_not_claim_file_drop_works() { +fn wayland_start_screen_hint_offers_file_drop() { let x11 = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); let wayland = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services { is_wayland: true, ..Default::default() }); assert!(start_screen_drop_hint(&x11).starts_with("Drop")); - // Pasting a copied image file works on Wayland (#338): the hint says how. - let hint = start_screen_drop_hint(&wayland); - assert!(!hint.contains("Drop"), "{hint}"); - assert!(hint.ends_with(&format!("paste a copied image with {}.", crate::notices::paste_hint(&wayland))), "{hint}"); + assert!(start_screen_drop_hint(&wayland).starts_with("Drop")); } /// A tiling window manager can hand PhotoCraft a canvas narrower than the Home card (460 diff --git a/crates/ui-egui/src/file_open.rs b/crates/ui-egui/src/file_open.rs index 865f65d7ef..901629eb02 100644 --- a/crates/ui-egui/src/file_open.rs +++ b/crates/ui-egui/src/file_open.rs @@ -334,16 +334,22 @@ impl PhotocraftApp { } } + /// The pointer position, from the OS cursor service when available, falling back to egui's + /// last-known pointer position (e.g. on Wayland). + pub(crate) fn cursor_pos(&mut self, ctx: &egui::Context) -> Option { + self.services.cursor_pos.as_mut().and_then(|f| f(ctx)).or_else(|| ctx.input(|i| i.pointer.latest_pos())) + } + /// Which of the files called `names`, dropped onto the window just now, are placed on the /// active document's canvas rather than opened, for a shell that reads dropped files itself /// (the browser reads them asynchronously, so the web shell can't hand them to /// [`open_dropped`](Self::open_dropped)). It delivers those through /// [`Services::place_inbox`](crate::Services::place_inbox) and the rest through /// [`Services::inbox`](crate::Services::inbox). The pointer comes from - /// [`Services::cursor_pos`](crate::Services::cursor_pos). As with a desktop canvas drop, a Free + /// [`cursor_pos`](Self::cursor_pos). As with a desktop canvas drop, a Free /// Transform still open is applied first (#4833). pub fn dropped_on_canvas(&mut self, ctx: &egui::Context, names: &[String]) -> Vec { - let at = self.services.cursor_pos.as_mut().and_then(|f| f(ctx)); + let at = self.cursor_pos(ctx); let canvas = self.drop_target(ctx, at) == DropTarget::Canvas; let places: Vec = names.iter().map(|n| canvas && !crate::preset_files_ui::is_preset_file(n)).collect(); if places.contains(&true) { diff --git a/crates/ui-egui/src/notices.rs b/crates/ui-egui/src/notices.rs index f2025a0d78..899d4cd63f 100644 --- a/crates/ui-egui/src/notices.rs +++ b/crates/ui-egui/src/notices.rs @@ -7,7 +7,6 @@ use serde::{Deserialize, Serialize}; use crate::PhotocraftApp; -const WAYLAND_FILE_DROP_DISMISSED: &str = "ui.waylandFileDropGuidanceDismissed"; const CLIPBOARD_UNREADABLE: &str = "Pasting from other apps is unavailable"; /// At most this many notices are kept; older ones drop off. pub const MAX_NOTICES: usize = 3; @@ -74,37 +73,11 @@ fn cap_notices(app: &mut PhotocraftApp) { } /// How to paste in hints: Edit › Paste's effective shortcut (`Ctrl+V`), else the menu path. +#[cfg(test)] pub(crate) fn paste_hint(app: &PhotocraftApp) -> String { crate::shortcuts::shortcut_label(app, "edit.paste").unwrap_or_else(|| tl!("Edit › Paste").to_string()) } -/// Show the Wayland-specific fallback guidance unless the user dismissed it in preferences: winit -/// 0.30 delivers no file drops on Wayland, so point at File › Open, pasting a copied file, and the -/// command that starts this install under XWayland (where drops work), when there is one. -pub fn wayland_file_drop_guidance(app: &mut PhotocraftApp) { - if !app.services.is_wayland - || app.session.prefs().dialogs.get(WAYLAND_FILE_DROP_DISMISSED).and_then(serde_json::Value::as_bool) == Some(true) - || app.ui.notices.iter().any(|notice| notice.dismiss_pref.as_deref() == Some(WAYLAND_FILE_DROP_DISMISSED)) - { - return; - } - // English templates, translated and filled in when drawn, so the notice follows a language change. - let mut lines = vec![ - "Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or copy the image in your file manager and paste it with {paste}." - .to_owned(), - ]; - let mut args = vec![("paste".to_owned(), paste_hint(app))]; - if let Some(command) = &app.services.xwayland_command { - lines.push("To drop files, start PhotoCraft under XWayland: `{command}`".to_owned()); - args.push(("command".to_owned(), command.clone())); - lines.push("Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland.".to_owned()); - } - let id = post(app, "Native file drag-and-drop is unavailable", lines, false, Some(WAYLAND_FILE_DROP_DISMISSED)); - if let Some(notice) = app.ui.notices.iter_mut().find(|notice| notice.id == id) { - notice.args = args; - } -} - /// A paste found no clipboard to read (#5031). In a Flatpak on Wayland the sandbox hides the /// data-control protocol that other apps' images are read through, and there is no X server to /// fall back to, so the clipboard may hold an image all the same. Say so, and give the command @@ -303,16 +276,11 @@ mod tests { use super::*; use crate::{PhotocraftApp, Services}; use photocraft_engine::Session; - use std::sync::{Arc, Mutex}; #[test] - fn wayland_guidance_follows_language_changes_after_startup() { - let command = Some("WAYLAND_DISPLAY= photocraft".to_string()); - let app = PhotocraftApp::new(Session::new(), Services { is_wayland: true, xwayland_command: command, ..Default::default() }); - let notice = &app.ui.notices[0]; - // the notice keeps its English source text and is translated when drawn (`i18n::t` with - // the current language) - assert_eq!(notice.title, "Native file drag-and-drop is unavailable"); + fn wayland_session_does_not_post_unavailable_notice() { + let app = PhotocraftApp::new(Session::new(), Services { is_wayland: true, ..Default::default() }); + assert!(app.ui.notices.is_empty()); } /// #5031: a paste that can't reach the clipboard (a Flatpak on Wayland) says why and how to @@ -350,39 +318,7 @@ mod tests { notice.lines.iter().map(|line| crate::i18n::fmt(line, &args)).collect::>().join(" ") } - #[test] - fn wayland_guidance_is_only_shown_in_wayland_sessions() { - let command = Some("WAYLAND_DISPLAY= '/apps/Photo Craft.AppImage'".to_string()); - let mut app = PhotocraftApp::new(Session::new(), Services { is_wayland: true, xwayland_command: command, ..Default::default() }); - assert_eq!(app.ui.notices.len(), 1); - assert_eq!(app.ui.notices[0].title, "Native file drag-and-drop is unavailable"); - let guidance = english(&app.ui.notices[0]); - assert!(guidance.contains("not supported on Wayland yet")); - assert!(guidance.contains("File › Open")); - // Pasting a copied file works on Wayland (#338), so the notice offers it. - let paste = paste_hint(&app); - assert!(guidance.contains(&format!("paste it with {paste}")), "{guidance}"); - // The relaunch command is the one for this install (an AppImage here), not a guess. - assert!(guidance.contains("under XWayland: `WAYLAND_DISPLAY= '/apps/Photo Craft.AppImage'`"), "{guidance}"); - assert!(guidance.contains("Linux display server to X11"), "{guidance}"); - assert_eq!(app.ui.notices[0].dismiss_pref.as_deref(), Some(WAYLAND_FILE_DROP_DISMISSED)); - for i in 0..MAX_NOTICES { - post(&mut app, format!("Transient {i}"), Vec::new(), false, None); - } - assert_eq!(app.ui.notices.len(), MAX_NOTICES); - assert!(app.ui.notices.iter().any(|notice| notice.dismiss_pref.is_some())); - let app = PhotocraftApp::new(Session::new(), Services::default()); - assert!(app.ui.notices.is_empty()); - } - - #[test] - fn wayland_guidance_without_an_x_server_does_not_suggest_xwayland() { - let app = PhotocraftApp::new(Session::new(), Services { is_wayland: true, ..Default::default() }); - let guidance = english(&app.ui.notices[0]); - assert!(guidance.contains("File › Open")); - assert!(!guidance.contains("XWayland"), "{guidance}"); - } #[test] fn paste_hint_follows_the_shortcut_and_falls_back_to_the_menu() { @@ -394,31 +330,6 @@ mod tests { assert_eq!(paste_hint(&app), "Edit › Paste"); } - #[test] - fn dismissing_wayland_guidance_persists_across_launches() { - let saved = Arc::new(Mutex::new(None::)); - let load_store = saved.clone(); - let save_store = saved.clone(); - let services = Services { - is_wayland: true, - load_prefs: Some(Box::new(move || load_store.lock().unwrap_or_else(|e| e.into_inner()).clone())), - save_prefs: Some(Box::new(move |text| { - *save_store.lock().unwrap_or_else(|e| e.into_inner()) = Some(text.to_owned()); - Ok(()) - })), - ..Default::default() - }; - let mut app = PhotocraftApp::new(Session::new(), services); - let id = app.ui.notices[0].id; - dismiss(&mut app, id); - crate::prefs_ui::tick(&mut app, &egui::Context::default()); - - let services = - Services { is_wayland: true, load_prefs: Some(Box::new(move || saved.lock().unwrap_or_else(|e| e.into_inner()).clone())), ..Default::default() }; - let app = PhotocraftApp::new(Session::new(), services); - assert!(app.ui.notices.is_empty()); - } - #[test] fn preparing_compositor_does_not_warn_about_large_documents() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); From 3a7a0aaa1eb40437efcd84e6a93b2dbe364606a5 Mon Sep 17 00:00:00 2001 From: Firstbober Date: Tue, 27 Oct 2026 19:26:55 +0100 Subject: [PATCH 057/219] feat: add Indexed Color matte control --- crates/engine/src/cmd/mode_cmds.rs | 45 ++++++++++++++- crates/engine/src/cmd/mode_cmds/tests.rs | 55 +++++++++++++++++++ crates/ui-egui/src/dialogs/filter_dialog.rs | 42 ++++++++++++++ .../src/dialogs/filter_dialog/tests.rs | 44 +++++++++++++++ docs/development.md | 9 +++ 5 files changed, 193 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/mode_cmds.rs b/crates/engine/src/cmd/mode_cmds.rs index ea6504f7a8..58b959c3f7 100644 --- a/crates/engine/src/cmd/mode_cmds.rs +++ b/crates/engine/src/cmd/mode_cmds.rs @@ -255,6 +255,32 @@ fn parse_dither(s: &str) -> Option { }) } +fn indexed_matte(s: &Session, p: &Value) -> Result> { + const CMD: &str = "image.mode.indexedColor"; + let rgba = |color: [f32; 4]| [color[0], color[1], color[2]]; + let matte = match p.get("matte") { + None | Some(Value::Null) => return Ok(None), + Some(Value::String(value)) => match value.as_str() { + "none" => return Ok(None), + "white" => [1.0; 3], + "black" => [0.0; 3], + "foreground" => rgba(s.tools.foreground), + "background" => rgba(s.tools.background), + value if value.len() == 7 && value.starts_with('#') => p + .get("matte") + .and_then(parse_color) + .ok_or_else(|| bad(CMD, "matte must be none, foreground, background, white, black or #rrggbb"))? + .map(|v| f32::from(v) / 255.0), + _ => return Err(bad(CMD, "matte must be none, foreground, background, white, black or #rrggbb")), + }, + _ => return Err(bad(CMD, "matte must be a colour string")), + }; + if matte.iter().any(|v| !v.is_finite() || !(0.0..=1.0).contains(v)) { + return Err(bad(CMD, "matte channels must be finite and in 0..=1")); + } + Ok(Some(matte)) +} + 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")) @@ -273,8 +299,23 @@ fn indexed_color(s: &mut Session, p: &Value) -> Result { }; 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 matte = indexed_matte(s, p)?; let (pal_len, transparent) = s.edit("Indexed Color", |doc, active| { - let mut px = composite(doc, !transparency); + let mut px = composite(doc, !transparency && matte.is_none()); + if let Some(matte) = matte { + for pixel in &mut px { + let alpha = pixel[3].clamp(0.0, 1.0); + if alpha == 1.0 { + continue; + } + if !transparency || alpha > 0.0 { + for (channel, background) in pixel[..3].iter_mut().zip(matte) { + *channel = *channel * alpha + background * (1.0 - alpha); + } + pixel[3] = 1.0; + } + } + } 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 transparent = if has_clear && pal.len() < 256 { @@ -689,7 +730,7 @@ pub fn specs() -> Vec { "image.mode.indexedColor", "Indexed Color…", ["Image", "Mode"], - 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)"##, + r##"{"palette":"selective|perceptual|adaptive|exact|systemMac|systemWindows|web|uniform"="selective","colors":2..256=256,"forced":"blackWhite|none|primaries|web"="blackWhite","transparency":bool=true,"matte":color,"dither":"diffusion|none|pattern|noise"="diffusion","amount":0..100=75} (flattens; matte: none|foreground|background|white|black|#rrggbb)"##, |s| mode_is( s, &[ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Indexed, ColorMode::Duotone, ColorMode::Cmyk, ColorMode::Lab], diff --git a/crates/engine/src/cmd/mode_cmds/tests.rs b/crates/engine/src/cmd/mode_cmds/tests.rs index 8f190f445c..0cf1d38bf9 100644 --- a/crates/engine/src/cmd/mode_cmds/tests.rs +++ b/crates/engine/src/cmd/mode_cmds/tests.rs @@ -247,6 +247,61 @@ fn indexed_color_palettes_and_dithers() { } } +#[test] +fn indexed_matte_blends_edges_without_replacing_clear_pixels() { + for depth in [8, 16, 32] { + for (matte, color) in [ + ("black", [0.0, 0.0, 0.0]), + ("white", [1.0, 1.0, 1.0]), + ("#0000ff", [0.0, 0.0, 1.0]), + ("foreground", [0.0, 0.0, 1.0]), + ("background", [0.0, 1.0, 0.0]), + ] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 4, "height": 1, "depth": depth, "background": "transparent"})).unwrap(); + s.execute("tools.setColors", json!({"foreground": "#0000ff", "background": "#00ff00"})).unwrap(); + paint(&mut s, |x, _| match x { + 0 => [0.0, 1.0, 0.0, 1.0], + 1 => [1.0, 0.0, 0.0, 0.75], + 2 => [1.0, 0.0, 0.0, 0.25], + _ => [0.0; 4], + }); + let source = [1, 2].map(|x| doc(&s).layers[0].surface().unwrap().rgba(x, 0)); + let expected = source.map(|pixel| [0, 1, 2].map(|i| pixel[i] * pixel[3] + color[i] * (1.0 - pixel[3]))); + s.execute("image.mode.indexedColor", json!({"palette": "exact", "forced": "none", "dither": "none", "matte": matte})).unwrap(); + let surface = doc(&s).layers[0].surface().unwrap(); + let edges = [1, 2].map(|x| surface.rgba(x, 0)); + for (edge, expected) in edges.iter().zip(expected) { + for i in 0..3 { + assert!((edge[i] - expected[i]).abs() <= 1.0 / 255.0, "{depth}-bit {matte}: {edge:?} vs {expected:?}"); + } + assert_eq!(edge[3], 1.0); + } + assert_eq!(surface.rgba(3, 0)[3], 0.0, "{matte}: fully transparent pixels stay clear"); + assert_eq!(surface.rgba(0, 0), [0.0, 1.0, 0.0, 1.0]); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!([1, 2].map(|x| doc(&s).layers[0].surface().unwrap().rgba(x, 0)), source); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!([1, 2].map(|x| doc(&s).layers[0].surface().unwrap().rgba(x, 0)), edges); + } + } +} + +#[test] +fn indexed_matte_without_transparency_fills_clear_pixels_and_rejects_bad_input_atomically() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 2, "height": 1, "background": "transparent"})).unwrap(); + let before = s.active().unwrap().revision; + for matte in [json!(false), json!(12), json!("#gg0000"), json!("#000000ff"), json!("unknown")] { + assert!(s.execute("image.mode.indexedColor", json!({"matte": matte})).is_err(), "{matte}"); + assert_eq!(s.active().unwrap().revision, before); + assert_eq!(doc(&s).mode, ColorMode::Rgb); + } + s.execute("image.mode.indexedColor", json!({"palette": "exact", "forced": "none", "dither": "none", "transparency": false, "matte": "#0000ff"})).unwrap(); + assert_eq!(doc(&s).layers[0].surface().unwrap().rgba(0, 0), [0.0, 0.0, 1.0, 1.0]); + assert_eq!(doc(&s).color_table.as_ref().unwrap().transparent, None); +} + #[test] fn indexed_exact_transparency_and_sources() { // Two colours + transparency: exact palette with a transparent entry. diff --git a/crates/ui-egui/src/dialogs/filter_dialog.rs b/crates/ui-egui/src/dialogs/filter_dialog.rs index be53034183..47eec78cbf 100644 --- a/crates/ui-egui/src/dialogs/filter_dialog.rs +++ b/crates/ui-egui/src/dialogs/filter_dialog.rs @@ -840,6 +840,44 @@ fn replace_color_result(ui: &mut egui::Ui, f: &mut Map) { } } +fn indexed_matte_row(ui: &mut egui::Ui, fields: &mut Map) { + let value = fields.get("matte").and_then(Value::as_str).unwrap_or("none"); + let mut choice = if value.starts_with('#') { "custom" } else { value }; + let color = crate::color_picker_ui::parse_hex(value).unwrap_or([1.0; 3]); + let mut changed = false; + let mut pick = false; + ui.horizontal(|ui| { + ui.label(egui::RichText::new(tl!("Matte")).color(Tokens::get(ui.ctx()).text_dim)); + changed = crate::widgets::dropdown( + ui, + "indexed-matte", + "Matte", + &mut choice, + &[ + ("none", "None"), + ("foreground", "Foreground Color"), + ("background", "Background Color"), + ("white", "White"), + ("black", "Black"), + ("custom", "Other…"), + ], + 170.0, + ); + if choice == "custom" { + let bytes = color.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8); + pick = crate::widgets::color_swatch_button(ui, egui::Color32::from_rgb(bytes[0], bytes[1], bytes[2]), tl!("Matte")).clicked(); + } + }); + let custom = choice == "custom"; + if changed { + let next = if custom { crate::color_picker_ui::hex(color) } else { choice.to_owned() }; + fields.insert("matte".into(), json!(next)); + } + if custom && (changed || pick) { + crate::color_picker_ui::request_field(fields, "matte", &crate::color_picker_ui::title_for("Matte")); + } +} + /// Dialog body for filter commands. pub fn body(ui: &mut egui::Ui, f: &mut Map) { let t = Tokens::get(ui.ctx()); @@ -856,6 +894,10 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { crate::select_mask_view::controls(ui, f); } for p in parse_spec(&spec) { + if cmd == "image.mode.indexedColor" && p.key == "matte" { + indexed_matte_row(ui, f); + continue; + } if zoom_center::owns(&cmd, f) && matches!(p.key.as_str(), "centerX" | "centerY") { if p.key == "centerX" { zoom_center::row(ui, f); diff --git a/crates/ui-egui/src/dialogs/filter_dialog/tests.rs b/crates/ui-egui/src/dialogs/filter_dialog/tests.rs index 036b085882..4225bdedf1 100644 --- a/crates/ui-egui/src/dialogs/filter_dialog/tests.rs +++ b/crates/ui-egui/src/dialogs/filter_dialog/tests.rs @@ -1642,3 +1642,47 @@ fn replace_color_localized_color_clusters() { assert!(px(2)[1] > 0.5, "near the point: shifted to green {:?}", px(2)); assert!(px(38)[0] > 0.9 && px(38)[1] < 0.1, "far away: still red {:?}", px(38)); } + +#[test] +fn indexed_matte_dropdown_and_picker_change_the_applied_edge_color() { + use egui::accesskit::Role; + use egui_kittest::{Harness, kittest::Queryable}; + + for (choice, blue) in [("Black", 0.0), ("Other…", 0.25)] { + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"width": 2, "height": 1, "depth": 32, "background": "transparent"})).unwrap(); + session + .edit("fixture", |doc, active| { + let bounds = doc.bounds(); + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().write_region(bounds, &[1.0, 0.0, 0.0, 0.75, 0.0, 0.0, 0.0, 0.0]); + Ok(()) + }) + .unwrap(); + let mut app = PhotocraftApp::new(session, crate::Services::default()); + let mut harness = Harness::builder().with_size(egui::vec2(900.0, 1000.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), &mut app); + PhotocraftApp::setup_context(&harness.ctx, crate::theme::ThemeKind::ALL[0]); + let id = open(harness.state_mut(), "image.mode.indexedColor").unwrap(); + harness.state_mut().ui.dialog_mut(id).unwrap().fields.insert("palette".into(), json!("exact")); + harness.run_steps(3); + harness.query_all_by_role(Role::ComboBox).find(|node| node.value().as_deref() == Some("None")).expect("Matte dropdown").click(); + harness.run_steps(2); + harness.get_by_label(choice).click(); + harness.run_steps(3); + if choice == "Other…" { + let picker = harness.state().ui.dialogs.last().unwrap().id; + assert_ne!(picker, id); + harness.state_mut().ui.dialog_mut(picker).unwrap().fields.insert("color".into(), json!("#0000ff")); + crate::dialogs::confirm(harness.state_mut(), picker).unwrap(); + harness.state_mut().ui.dialogs.retain(|dialog| dialog.id != picker); + harness.run_steps(3); + } + harness.get_by_label("OK").click(); + harness.run_steps(3); + let doc = &harness.state().session.active().unwrap().doc; + assert_eq!(doc.mode, photocraft_color::ColorMode::Indexed); + let surface = doc.layers[0].surface().unwrap(); + let edge = surface.rgba(0, 0); + assert!((edge[0] - 0.75).abs() <= 1.0 / 255.0 && edge[1] == 0.0 && (edge[2] - blue).abs() <= 1.0 / 255.0 && edge[3] == 1.0, "{choice}: {edge:?}"); + assert_eq!(surface.rgba(1, 0)[3], 0.0); + } +} diff --git a/docs/development.md b/docs/development.md index 1fa912575f..0d907fb97e 100644 --- a/docs/development.md +++ b/docs/development.md @@ -661,6 +661,15 @@ except Place, which is a job that `poll_jobs` steps on the UI thread (a *coopera ## Indexed Color timings +The stock Indexed Color dialog exposes Matte: None, Foreground Color, Background Color, +White, Black and Other… (the shared Color Picker). Automation accepts `matte` as `none`, +`foreground`, `background`, `white`, `black` or `#rrggbb`. A selected matte blends all +partially transparent edges; with Transparency on, fully clear pixels stay transparent. +With Transparency off, the selected matte also fills clear pixels. None retains the existing +hard-edged transparency/default flattening behavior. Indexed conversion retains the document's +ICC profile and encoded RGB sample representation; matte colours are interpreted in the document +RGB coordinate space. This follows Adobe's documented +[Indexed Color conversion options](https://helpx.adobe.com/photoshop/desktop/adjust-color/color-modes/conversion-options-for-indexed-color-images.html). `cargo run --release -p photocraft-engine --example indexed_color_perf -- image.png` measures palette construction, Floyd–Steinberg diffusion, and the complete full-resolution CPU preview path (proxy copy, engine command, result composition; excludes GPU upload/presentation). From b57ec9f729ae55722dc44ae7cd2801ada7821729 Mon Sep 17 00:00:00 2001 From: Firstbober Date: Wed, 28 Oct 2026 11:48:58 +0100 Subject: [PATCH 058/219] fix: preserve indexed matte when picker is canceled --- crates/ui-egui/src/dialogs/color_picker_ui.rs | 10 +++++- crates/ui-egui/src/dialogs/filter_dialog.rs | 9 ++++-- .../src/dialogs/filter_dialog/tests.rs | 31 +++++++++++++++++++ docs/development.md | 8 +++-- 4 files changed, 52 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/dialogs/color_picker_ui.rs b/crates/ui-egui/src/dialogs/color_picker_ui.rs index 1c0ce157e8..d713fbf9d7 100644 --- a/crates/ui-egui/src/dialogs/color_picker_ui.rs +++ b/crates/ui-egui/src/dialogs/color_picker_ui.rs @@ -271,6 +271,10 @@ fn set_field(f: &mut Map, field: &str, value: Value) -> Result<() /// Opens the Color Picker dialog `id`'s body asked for ([`request`], [`request_field`]), on its /// colour: OK sets that field of the dialog (not the foreground), Cancel leaves it as it was. +/// +/// A dialog can provide `__pickColorDefault` when its field is a non-colour choice that has not +/// yet been replaced by the picked colour. The temporary default seeds the picker without +/// changing the parent field; it is removed before the picker opens. pub fn open_requested(app: &mut PhotocraftApp, id: u64) { let Some(d) = app.ui.dialog_mut(id) else { return }; let (title, field) = match d.fields.remove(REQUEST) { @@ -282,7 +286,11 @@ pub fn open_requested(app: &mut PhotocraftApp, id: u64) { } _ => return, }; - let rgb = field_value(&d.fields, &field).and_then(parse_hex).unwrap_or([0.0; 3]); + let rgb = field_value(&d.fields, &field) + .and_then(parse_hex) + .or_else(|| d.fields.get("__pickColorDefault").and_then(Value::as_str).and_then(parse_hex)) + .unwrap_or([0.0; 3]); + d.fields.remove("__pickColorDefault"); open_for_field(app, &title, rgb, id, &field); } diff --git a/crates/ui-egui/src/dialogs/filter_dialog.rs b/crates/ui-egui/src/dialogs/filter_dialog.rs index 47eec78cbf..4a91bdf81b 100644 --- a/crates/ui-egui/src/dialogs/filter_dialog.rs +++ b/crates/ui-egui/src/dialogs/filter_dialog.rs @@ -870,8 +870,13 @@ fn indexed_matte_row(ui: &mut egui::Ui, fields: &mut Map) { }); let custom = choice == "custom"; if changed { - let next = if custom { crate::color_picker_ui::hex(color) } else { choice.to_owned() }; - fields.insert("matte".into(), json!(next)); + if custom { + // Keep the previous matte until the picker confirms; the picker uses this only as + // its initial colour when switching from a named matte or None. + fields.insert("__pickColorDefault".into(), json!(crate::color_picker_ui::hex(color))); + } else { + fields.insert("matte".into(), json!(choice.to_owned())); + } } if custom && (changed || pick) { crate::color_picker_ui::request_field(fields, "matte", &crate::color_picker_ui::title_for("Matte")); diff --git a/crates/ui-egui/src/dialogs/filter_dialog/tests.rs b/crates/ui-egui/src/dialogs/filter_dialog/tests.rs index 4225bdedf1..db0f606677 100644 --- a/crates/ui-egui/src/dialogs/filter_dialog/tests.rs +++ b/crates/ui-egui/src/dialogs/filter_dialog/tests.rs @@ -1686,3 +1686,34 @@ fn indexed_matte_dropdown_and_picker_change_the_applied_edge_color() { assert_eq!(surface.rgba(1, 0)[3], 0.0); } } + +/// Selecting Other… must not replace None until the custom colour picker is confirmed. +#[test] +fn indexed_matte_cancel_keeps_the_previous_choice() { + use egui::accesskit::Role; + use egui_kittest::{Harness, kittest::Queryable}; + + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"width": 2, "height": 1, "depth": 32, "background": "transparent"})).unwrap(); + let mut app = PhotocraftApp::new(session, crate::Services::default()); + let mut harness = Harness::builder().with_size(egui::vec2(900.0, 1000.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), &mut app); + PhotocraftApp::setup_context(&harness.ctx, crate::theme::ThemeKind::ALL[0]); + let id = open(harness.state_mut(), "image.mode.indexedColor").unwrap(); + harness.run_steps(3); + harness.query_all_by_role(Role::ComboBox).find(|node| node.value().as_deref() == Some("None")).expect("Matte dropdown").click(); + harness.run_steps(2); + harness.get_by_label("Other…").click(); + harness.run_steps(3); + + let picker = harness.state().ui.dialogs.last().expect("custom matte picker").id; + assert_ne!(picker, id); + let parent = harness.state().ui.dialogs.iter().find(|dialog| dialog.id == id).expect("Indexed Color dialog"); + assert_eq!(parent.fields.get("matte").and_then(Value::as_str), None, "opening Other… does not commit a matte"); + assert_eq!(harness.state().ui.dialogs.last().unwrap().fields["color"], "#ffffff", "the previous default still seeds the picker"); + + crate::dialogs::cancel(harness.state_mut(), picker).unwrap(); + harness.run_steps(3); + let parent = harness.state().ui.dialogs.iter().find(|dialog| dialog.id == id).expect("Indexed Color dialog"); + assert_eq!(parent.fields.get("matte").and_then(Value::as_str), None, "cancel restores the previous None choice"); + assert!(!parent.fields.contains_key("__pickColorDefault")); +} diff --git a/docs/development.md b/docs/development.md index 0d907fb97e..836b68f85f 100644 --- a/docs/development.md +++ b/docs/development.md @@ -662,9 +662,11 @@ except Place, which is a job that `poll_jobs` steps on the UI thread (a *coopera ## Indexed Color timings The stock Indexed Color dialog exposes Matte: None, Foreground Color, Background Color, -White, Black and Other… (the shared Color Picker). Automation accepts `matte` as `none`, -`foreground`, `background`, `white`, `black` or `#rrggbb`. A selected matte blends all -partially transparent edges; with Transparency on, fully clear pixels stay transparent. +White, Black and Other… (the shared Color Picker). Choosing Other… opens the picker without +replacing the current matte; Cancel leaves that choice unchanged, and OK commits the picked +`#rrggbb`. Automation accepts `matte` as `none`, `foreground`, `background`, +`white`, `black` or `#rrggbb`. A selected matte blends all partially transparent +edges; with Transparency on, fully clear pixels stay transparent. With Transparency off, the selected matte also fills clear pixels. None retains the existing hard-edged transparency/default flattening behavior. Indexed conversion retains the document's ICC profile and encoded RGB sample representation; matte colours are interpreted in the document From 2fbec8b533eb018518c4e1c1d680f3b2ead7540f Mon Sep 17 00:00:00 2001 From: Firstbober Date: Sat, 31 Oct 2026 13:51:34 +0100 Subject: [PATCH 059/219] fix: preserve document colours for indexed mattes --- crates/engine/src/cmd/mode_cmds.rs | 37 +++++++++-- crates/engine/src/cmd/mode_cmds/tests.rs | 61 +++++++++++++++++++ crates/ui-egui/src/dialogs/filter_dialog.rs | 59 +++++++++++++++++- .../src/dialogs/filter_dialog/tests.rs | 35 ++++++++++- docs/development.md | 13 ++-- 5 files changed, 190 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/cmd/mode_cmds.rs b/crates/engine/src/cmd/mode_cmds.rs index 58b959c3f7..df14c063cc 100644 --- a/crates/engine/src/cmd/mode_cmds.rs +++ b/crates/engine/src/cmd/mode_cmds.rs @@ -9,6 +9,7 @@ use photocraft_algo::quantize::{self, BitmapMethod, Dither, Forced, HalftoneShape, PaletteKind}; use photocraft_algo::transform::{Homography, Interp}; +use photocraft_cms::{ColorSpace, Transform}; use photocraft_color::{ColorMode, PixelFormat, SampleType}; use photocraft_doc::adjust::CurvePoint; use photocraft_doc::{ColorTable, Document, Duotone, DuotoneInk, Layer, LayerContent, LayerId, Size}; @@ -255,23 +256,47 @@ fn parse_dither(s: &str) -> Option { }) } +/// Convert a tool colour from the working RGB space into the samples used by the +/// flattened document. RGB and Indexed documents keep their own document profile; +/// grayscale output is a scalar sample replicated into the flattened RGB view. +fn working_matte_color(s: &Session, doc: &Document, color: [f32; 4]) -> Result<[f32; 3]> { + let destination = match crate::color_cmds::mode_space(doc.mode) { + ColorSpace::Rgb | ColorSpace::Gray => crate::color_cmds::document_profile(doc), + // CMYK and Lab flatten through the compositor's sRGB view on the pinned main path. + _ => crate::color_cmds::composite_profile(doc), + }; + let source = s.color.working(ColorMode::Rgb); + let transform = Transform::new(&source, &destination, s.color.settings.intent(), s.color.settings.bpc) + .map_err(|e| bad("image.mode.indexedColor", format!("cannot convert matte colour: {e}")))?; + let mut output = [0.0; 16]; + transform.eval(&color[..3], &mut output); + let matte = if destination.color_space == ColorSpace::Gray { + [output[0]; 3] + } else { + [output[0], output[1], output[2]] + }; + Ok(matte) +} + fn indexed_matte(s: &Session, p: &Value) -> Result> { const CMD: &str = "image.mode.indexedColor"; - let rgba = |color: [f32; 4]| [color[0], color[1], color[2]]; + let doc = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); let matte = match p.get("matte") { None | Some(Value::Null) => return Ok(None), Some(Value::String(value)) => match value.as_str() { "none" => return Ok(None), "white" => [1.0; 3], "black" => [0.0; 3], - "foreground" => rgba(s.tools.foreground), - "background" => rgba(s.tools.background), + "gray" => [128.0 / 255.0; 3], + "netscapeGray" => [192.0 / 255.0; 3], + "foreground" => working_matte_color(s, &doc, s.tools.foreground)?, + "background" => working_matte_color(s, &doc, s.tools.background)?, value if value.len() == 7 && value.starts_with('#') => p .get("matte") .and_then(parse_color) - .ok_or_else(|| bad(CMD, "matte must be none, foreground, background, white, black or #rrggbb"))? + .ok_or_else(|| bad(CMD, "matte must be none, foreground, background, white, black, gray, netscapeGray or #rrggbb"))? .map(|v| f32::from(v) / 255.0), - _ => return Err(bad(CMD, "matte must be none, foreground, background, white, black or #rrggbb")), + _ => return Err(bad(CMD, "matte must be none, foreground, background, white, black, gray, netscapeGray or #rrggbb")), }, _ => return Err(bad(CMD, "matte must be a colour string")), }; @@ -730,7 +755,7 @@ pub fn specs() -> Vec { "image.mode.indexedColor", "Indexed Color…", ["Image", "Mode"], - r##"{"palette":"selective|perceptual|adaptive|exact|systemMac|systemWindows|web|uniform"="selective","colors":2..256=256,"forced":"blackWhite|none|primaries|web"="blackWhite","transparency":bool=true,"matte":color,"dither":"diffusion|none|pattern|noise"="diffusion","amount":0..100=75} (flattens; matte: none|foreground|background|white|black|#rrggbb)"##, +r##"{"palette":"selective|perceptual|adaptive|exact|systemMac|systemWindows|web|uniform"="selective","colors":2..256=256,"forced":"blackWhite|none|primaries|web"="blackWhite","transparency":bool=true,"matte":color,"dither":"diffusion|none|pattern|noise"="diffusion","amount":0..100=75} (flattens; matte: none|foreground|background|white|black|gray|netscapeGray|#rrggbb; gray=50% Gray (#808080), netscapeGray=Netscape Gray (#c0c0c0))"##, |s| mode_is( s, &[ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Indexed, ColorMode::Duotone, ColorMode::Cmyk, ColorMode::Lab], diff --git a/crates/engine/src/cmd/mode_cmds/tests.rs b/crates/engine/src/cmd/mode_cmds/tests.rs index 0cf1d38bf9..f249f87bca 100644 --- a/crates/engine/src/cmd/mode_cmds/tests.rs +++ b/crates/engine/src/cmd/mode_cmds/tests.rs @@ -287,6 +287,67 @@ fn indexed_matte_blends_edges_without_replacing_clear_pixels() { } } +#[test] +fn indexed_matte_converts_working_rgb_to_document_profile_at_every_depth() { + use photocraft_cms::Transform; + + for depth in [8, 16, 32] { + let mut s = Session::new(); + s.color.settings.working_rgb = "display-p3".into(); + s.execute("file.new", json!({"width": 2, "height": 1, "depth": depth, "background": "transparent"})).unwrap(); + s.execute("edit.assignProfile", json!({"profile": "adobe-rgb-compat"})).unwrap(); + s.execute("tools.setColors", json!({"foreground": "#3366cc"})).unwrap(); + paint(&mut s, |x, _| if x == 0 { [1.0, 0.0, 0.0, 0.5] } else { [0.0; 4] }); + + let source = s.color.working(ColorMode::Rgb); + let destination = crate::color_cmds::document_profile(doc(&s)); + let transform = Transform::new(&source, &destination, s.color.settings.intent(), s.color.settings.bpc).unwrap(); + let mut converted = [0.0; 16]; + transform.eval(&[0.2, 0.4, 0.8], &mut converted); + let expected = [0.5 + 0.5 * converted[0], 0.5 * converted[1], 0.5 * converted[2]]; + + s.execute("image.mode.indexedColor", json!({"palette": "exact", "forced": "none", "dither": "none", "matte": "foreground"})).unwrap(); + let edge = doc(&s).layers[0].surface().unwrap().rgba(0, 0); + for (got, want) in edge[..3].iter().zip(expected) { + assert!((got - want).abs() <= 2.0 / 255.0, "{depth}-bit: {edge:?} vs {expected:?}"); + } + assert_eq!(edge[3], 1.0); + } +} + +#[test] +fn indexed_matte_hex_stays_in_document_rgb_coordinates() { + for depth in [8, 16, 32] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 1, "height": 1, "depth": depth, "background": "transparent"})).unwrap(); + s.execute("edit.assignProfile", json!({"profile": "adobe-rgb-compat"})).unwrap(); + paint(&mut s, |_| [1.0, 0.0, 0.0, 0.5]); + s.execute("image.mode.indexedColor", json!({"palette": "exact", "forced": "none", "dither": "none", "matte": "#3366cc"})).unwrap(); + let edge = doc(&s).layers[0].surface().unwrap().rgba(0, 0); + let expected = [0.5 + 0.5 * 0.2, 0.5 * 0.4, 0.5 * 0.8]; + for (got, want) in edge[..3].iter().zip(expected) { + assert!((got - want).abs() <= 2.0 / 255.0, "{depth}-bit: {edge:?} vs {expected:?}"); + } + assert_eq!(edge[3], 1.0); + } +} + +#[test] +fn indexed_matte_named_gray_choices_use_their_document_samples() { + for (matte, channel) in [("gray", 128.0 / 255.0), ("netscapeGray", 192.0 / 255.0)] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 1, "height": 1, "depth": 32, "background": "transparent"})).unwrap(); + paint(&mut s, |_| [1.0, 0.0, 0.0, 0.5]); + s.execute("image.mode.indexedColor", json!({"palette": "exact", "forced": "none", "dither": "none", "matte": matte})).unwrap(); + let edge = doc(&s).layers[0].surface().unwrap().rgba(0, 0); + assert!((edge[0] - 0.5).abs() <= 2.0 / 255.0, "{matte}: {edge:?}"); + for value in edge[1..3].iter() { + assert!((*value - channel * 0.5).abs() <= 2.0 / 255.0, "{matte}: {edge:?}"); + } + assert_eq!(edge[3], 1.0); + } +} + #[test] fn indexed_matte_without_transparency_fills_clear_pixels_and_rejects_bad_input_atomically() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/dialogs/filter_dialog.rs b/crates/ui-egui/src/dialogs/filter_dialog.rs index 4a91bdf81b..31a7bbc03d 100644 --- a/crates/ui-egui/src/dialogs/filter_dialog.rs +++ b/crates/ui-egui/src/dialogs/filter_dialog.rs @@ -6,6 +6,8 @@ use std::sync::Arc; +use photocraft_cms::{ColorSpace, Transform}; +use photocraft_color::ColorMode; use photocraft_doc::Document; use photocraft_engine::color_cmds::ColorSettings; use photocraft_engine::installed_profiles::InstalledProfile; @@ -429,6 +431,9 @@ fn default_value(app: &PhotocraftApp, command: &str, p: &Param) -> Option /// Command-specific open defaults (ruler straighten, warp from layer, …), not remembered prefs. fn apply_command_defaults(app: &PhotocraftApp, command: &str, fields: &mut Map) { + if command == "image.mode.indexedColor" { + indexed_matte_defaults(app, fields); + } if command == "image.rotation.arbitrary" { straighten_defaults(app, fields); } @@ -840,10 +845,58 @@ fn replace_color_result(ui: &mut egui::Ui, f: &mut Map) { } } +const INDEXED_MATTE_FOREGROUND: &str = "__indexedMatteForeground"; +const INDEXED_MATTE_BACKGROUND: &str = "__indexedMatteBackground"; + +/// Resolve a tool colour into the encoded samples used by Indexed Color's matte. +/// +/// The picker and command fields carry document RGB coordinates. Tool swatches are +/// working-RGB coordinates, so only the named tool choices need this conversion here. +fn indexed_matte_document_color(app: &PhotocraftApp, doc: &Document, color: [f32; 4]) -> [f32; 3] { + let destination = match photocraft_engine::color_cmds::mode_space(doc.mode) { + ColorSpace::Rgb | ColorSpace::Gray => photocraft_engine::color_cmds::document_profile(doc), + _ => photocraft_engine::color_cmds::composite_profile(doc), + }; + let source = app.session.color.working(ColorMode::Rgb); + let Ok(transform) = Transform::new(&source, &destination, app.session.color.settings.intent(), app.session.color.settings.bpc) else { + return [color[0], color[1], color[2]]; + }; + let mut output = [0.0; 16]; + transform.eval(&color[..3], &mut output); + if destination.color_space == ColorSpace::Gray { [output[0]; 3] } else { [output[0], output[1], output[2]] } +} + +fn indexed_matte_defaults(app: &PhotocraftApp, fields: &mut Map) { + let Some(doc) = app.session.active().map(|state| state.doc.clone()) else { return }; + let foreground = indexed_matte_document_color(app, &doc, app.session.tools.foreground); + let background = indexed_matte_document_color(app, &doc, app.session.tools.background); + fields.insert(INDEXED_MATTE_FOREGROUND.into(), json!(crate::color_picker_ui::hex(foreground))); + fields.insert(INDEXED_MATTE_BACKGROUND.into(), json!(crate::color_picker_ui::hex(background))); +} + +fn indexed_matte_seed(fields: &Map, value: &str) -> [f32; 3] { + match value { + "foreground" => fields + .get(INDEXED_MATTE_FOREGROUND) + .and_then(Value::as_str) + .and_then(crate::color_picker_ui::parse_hex) + .unwrap_or([0.0; 3]), + "background" => fields + .get(INDEXED_MATTE_BACKGROUND) + .and_then(Value::as_str) + .and_then(crate::color_picker_ui::parse_hex) + .unwrap_or([1.0; 3]), + "black" => [0.0; 3], + "gray" => [128.0 / 255.0; 3], + "netscapeGray" => [192.0 / 255.0; 3], + _ => [1.0; 3], + } +} + fn indexed_matte_row(ui: &mut egui::Ui, fields: &mut Map) { let value = fields.get("matte").and_then(Value::as_str).unwrap_or("none"); let mut choice = if value.starts_with('#') { "custom" } else { value }; - let color = crate::color_picker_ui::parse_hex(value).unwrap_or([1.0; 3]); + let color = crate::color_picker_ui::parse_hex(value).unwrap_or_else(|| indexed_matte_seed(fields, value)); let mut changed = false; let mut pick = false; ui.horizontal(|ui| { @@ -859,6 +912,8 @@ fn indexed_matte_row(ui: &mut egui::Ui, fields: &mut Map) { ("background", "Background Color"), ("white", "White"), ("black", "Black"), + ("gray", "50% Gray"), + ("netscapeGray", "Netscape Gray"), ("custom", "Other…"), ], 170.0, @@ -872,7 +927,7 @@ fn indexed_matte_row(ui: &mut egui::Ui, fields: &mut Map) { if changed { if custom { // Keep the previous matte until the picker confirms; the picker uses this only as - // its initial colour when switching from a named matte or None. + // its initial document-encoded colour when switching from a named matte or None. fields.insert("__pickColorDefault".into(), json!(crate::color_picker_ui::hex(color))); } else { fields.insert("matte".into(), json!(choice.to_owned())); diff --git a/crates/ui-egui/src/dialogs/filter_dialog/tests.rs b/crates/ui-egui/src/dialogs/filter_dialog/tests.rs index db0f606677..31786411b7 100644 --- a/crates/ui-egui/src/dialogs/filter_dialog/tests.rs +++ b/crates/ui-egui/src/dialogs/filter_dialog/tests.rs @@ -90,6 +90,31 @@ fn display_names_are_not_split_like_camel_case_keys() { assert_eq!(source_label("centerX"), "Center X"); } +#[test] +fn indexed_matte_tool_seeds_use_document_encoded_rgb() { + use photocraft_cms::Transform; + + let mut session = photocraft_engine::Session::new(); + session.color.settings.working_rgb = "display-p3".into(); + session.execute("file.new", json!({"width": 2, "height": 2, "background": "transparent"})).unwrap(); + session.execute("edit.assignProfile", json!({"profile": "adobe-rgb-compat"})).unwrap(); + session.execute("tools.setColors", json!({"foreground": "#3366cc", "background": "#cc6633"})).unwrap(); + let mut app = PhotocraftApp::new(session, crate::Services::default()); + let mut fields = Map::new(); + indexed_matte_defaults(&app, &mut fields); + + let doc = app.session.active().unwrap().doc.clone(); + let source = app.session.color.working(ColorMode::Rgb); + let destination = photocraft_engine::color_cmds::document_profile(&doc); + let transform = Transform::new(&source, &destination, app.session.color.settings.intent(), app.session.color.settings.bpc).unwrap(); + for (key, input) in [(INDEXED_MATTE_FOREGROUND, [0.2, 0.4, 0.8]), (INDEXED_MATTE_BACKGROUND, [0.8, 0.4, 0.2])] { + let mut output = [0.0; 16]; + transform.eval(&input, &mut output); + let expected = crate::color_picker_ui::hex([output[0], output[1], output[2]]); + assert_eq!(fields.get(key).and_then(Value::as_str), Some(expected.as_str()), "{key}"); + assert_eq!(indexed_matte_seed(&fields, if key == INDEXED_MATTE_FOREGROUND { "foreground" } else { "background" }), crate::color_picker_ui::parse_hex(&expected).unwrap()); + } +} /// Convert to Profile's and Proof Setup's `bpc` read "Bpc": spelled out. #[test] fn black_point_compensation_is_spelled_out() { @@ -1648,7 +1673,12 @@ fn indexed_matte_dropdown_and_picker_change_the_applied_edge_color() { use egui::accesskit::Role; use egui_kittest::{Harness, kittest::Queryable}; - for (choice, blue) in [("Black", 0.0), ("Other…", 0.25)] { + for (choice, matte) in [ + ("Black", [0.0, 0.0, 0.0]), + ("50% Gray", [128.0 / 255.0; 3]), + ("Netscape Gray", [192.0 / 255.0; 3]), + ("Other…", [0.0, 0.0, 1.0]), + ] { let mut session = photocraft_engine::Session::new(); session.execute("file.new", json!({"width": 2, "height": 1, "depth": 32, "background": "transparent"})).unwrap(); session @@ -1682,7 +1712,8 @@ fn indexed_matte_dropdown_and_picker_change_the_applied_edge_color() { assert_eq!(doc.mode, photocraft_color::ColorMode::Indexed); let surface = doc.layers[0].surface().unwrap(); let edge = surface.rgba(0, 0); - assert!((edge[0] - 0.75).abs() <= 1.0 / 255.0 && edge[1] == 0.0 && (edge[2] - blue).abs() <= 1.0 / 255.0 && edge[3] == 1.0, "{choice}: {edge:?}"); + let expected = [0.75 + 0.25 * matte[0], 0.25 * matte[1], 0.25 * matte[2]]; + assert!(edge.iter().zip(expected).all(|(got, want)| (got - want).abs() <= 1.0 / 255.0) && edge[3] == 1.0, "{choice}: {edge:?} vs {expected:?}"); assert_eq!(surface.rgba(1, 0)[3], 0.0); } } diff --git a/docs/development.md b/docs/development.md index 836b68f85f..79ca11f377 100644 --- a/docs/development.md +++ b/docs/development.md @@ -662,11 +662,14 @@ except Place, which is a job that `poll_jobs` steps on the UI thread (a *coopera ## Indexed Color timings The stock Indexed Color dialog exposes Matte: None, Foreground Color, Background Color, -White, Black and Other… (the shared Color Picker). Choosing Other… opens the picker without -replacing the current matte; Cancel leaves that choice unchanged, and OK commits the picked -`#rrggbb`. Automation accepts `matte` as `none`, `foreground`, `background`, -`white`, `black` or `#rrggbb`. A selected matte blends all partially transparent -edges; with Transparency on, fully clear pixels stay transparent. +White, Black, 50% Gray, Netscape Gray and Other… (the shared Color Picker). Choosing Other… +opens the picker without replacing the current matte; Cancel leaves that choice unchanged, and +OK commits the picked `#rrggbb` in document RGB coordinates. Automation accepts `matte` as +`none`, `foreground`, `background`, `white`, `black`, `gray` (50% Gray, encoded +`#808080`), `netscapeGray` (Netscape Gray, encoded `#c0c0c0`) or `#rrggbb`. Foreground/background +tool colours are converted from the +working RGB profile into the document profile before blending. A selected matte blends all +partially transparent edges; with Transparency on, fully clear pixels stay transparent. With Transparency off, the selected matte also fills clear pixels. None retains the existing hard-edged transparency/default flattening behavior. Indexed conversion retains the document's ICC profile and encoded RGB sample representation; matte colours are interpreted in the document From 95c9d3c9eb018452af8c2caa2ba205b0612196db Mon Sep 17 00:00:00 2001 From: Triwarr <170976175+Triwarr@users.noreply.github.com> Date: Fri, 6 Nov 2026 03:59:22 +0100 Subject: [PATCH 060/219] PDF import: open the first page with PDFium File > Open, drag-and-drop and photocraft-cli now accept PDF files. The first page is rendered with PDFium at 300 dpi (ImportOptions.pdf_dpi), built through the flat importer, and the document resolution keeps the page's print size. The page count is reported; multi-page import is the next step (#2541 / FILE-215-10). - crates/io: pdf_import module, native-only pdfium-render + image deps, import and preflight dispatch (.ai keeps its existing path). - PDFium loads from the executable's folder or PDFIUM_DIR. --- Cargo.lock | 78 +++++++++++++++++++++++++++++++++++++ crates/io/Cargo.toml | 3 ++ crates/io/src/lib.rs | 20 +++++++++- crates/io/src/pdf_import.rs | 76 ++++++++++++++++++++++++++++++++++++ 4 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 crates/io/src/pdf_import.rs diff --git a/Cargo.lock b/Cargo.lock index 201ca61db0..d3221556bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -874,6 +874,12 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "byteorder-lite" version = "0.1.0" @@ -1278,6 +1284,26 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "console_log" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86919cef3e37b9356ccf54d4421208c17ecfda01beae61393e7ffd72916c0ef1" +dependencies = [ + "log", + "web-sys", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -5008,6 +5034,32 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" +[[package]] +name = "pdfium-render" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "296b1146652b486ea6eea0d316e85ab6837ff69e8d2b1568e6fa7bc38bd4c00e" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "bytes", + "chrono", + "console_error_panic_hook", + "console_log", + "image", + "itertools 0.15.0", + "js-sys", + "libloading", + "log", + "maybe-owned", + "once_cell", + "utf16string", + "vecmath", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "pem" version = "3.0.6" @@ -5412,9 +5464,11 @@ dependencies = [ "craft-docpath", "crc32fast", "flate2", + "image", "imagesize", "lopdf", "miniz_oxide 0.9.1", + "pdfium-render", "photocraft-affinity", "photocraft-algo", "photocraft-cms", @@ -5723,6 +5777,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "piston-float" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad78bf43dcf80e8f950c92b84f938a0fc7590b7f6866fbcbeca781609c115590" + [[package]] name = "pkg-config" version = "0.3.34" @@ -8205,6 +8265,15 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" +[[package]] +name = "utf16string" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b62a1e85e12d5d712bf47a85f426b73d303e2d00a90de5f3004df3596e9d216" +dependencies = [ + "byteorder", +] + [[package]] name = "utf8-zero" version = "0.8.1" @@ -8246,6 +8315,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vecmath" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "956ae1e0d85bca567dee1dcf87fb1ca2e792792f66f87dced8381f99cd91156a" +dependencies = [ + "piston-float", +] + [[package]] name = "vello_common" version = "0.2.0" diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index dcd86482db..ef151b80cf 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -51,6 +51,9 @@ imagesize = "0.13" resvg = { version = "0.45", default-features = false, features = ["text", "system-fonts", "raster-images"] } # The SVG fallback font search keeps each font file's character map (already in the tree through usvg). ttf-parser = { version = "0.25", default-features = false } +# PDF import (pdf_import.rs): PDFium renders the pages; the direct surfaces build the document. +pdfium-render = "0.9" +image = { workspace = true, features = ["png"] } [target.'cfg(target_arch = "wasm32")'.dependencies] resvg = { version = "0.45", default-features = false, features = ["text", "raster-images"] } diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 68b1754e61..a828b5ff12 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -65,6 +65,7 @@ pub use ora::losses as ora_losses; pub mod pattern_map; mod pcraft_cache; pub mod pdf; +mod pdf_import; mod pdn; pub use pcraft_cache::PcraftExportCache; pub mod pixels; @@ -513,8 +514,8 @@ pub enum ImportPreflight { /// A flat raster decoded into one image, bounded by /// [`ImportOptions::flat_max_pixel_bytes`]. Flat, - /// Affinity, OpenRaster, Krita, XCF, PDN, SVG, camera raw and `.pcraft` files: this build has - /// no header-only estimate of their pixel memory before decoding. + /// Affinity, OpenRaster, Krita, XCF, PDN, SVG, camera raw, PDF and `.pcraft` files: this + /// build has no header-only estimate of their pixel memory before decoding. Unbounded, } @@ -555,6 +556,10 @@ pub fn import_preflight(name: &str, bytes: &[u8]) -> ImportPreflight { if is_psd_input(name, bytes) { return ImportPreflight::Psd; } + // PDF (PDFium, native only); `.ai` files are PDFs but keep their existing path. + if has_extension(name, "pdf") || (pdf_import::is_pdf(bytes) && !has_extension(name, "ai")) { + return ImportPreflight::Unbounded; + } if photocraft_codecs::detect(bytes) == Some(photocraft_codecs::Format::Tiff) { ImportPreflight::Tiff } else { ImportPreflight::Flat } } @@ -601,6 +606,9 @@ pub struct ImportOptions { /// Decoded-pixel allowance for a flat raster (the buffer a codec decodes into). May lower /// the codec's default cap; 0 uses it. PSD/PSB and layered TIFF use `psd_max_pixel_bytes`. pub flat_max_pixel_bytes: u64, + /// Resolution (dots per inch) PDF pages are rendered at on import; the document keeps it so + /// the page's print size is preserved. Defaults to 300. + pub pdf_dpi: f32, /// Explicit one-shot authorization for imports that exceed the soft budget but fit within the hard budget. pub psd_high_memory_approved: bool, /// Optional estimate limit for high-memory approval. When set, approval only applies if @@ -615,6 +623,7 @@ impl Default for ImportOptions { demosaic: raw::Demosaic::default(), psd_max_pixel_bytes: 0, flat_max_pixel_bytes: 0, + pdf_dpi: 300.0, psd_high_memory_approved: false, psd_approved_estimate: None, } @@ -975,6 +984,13 @@ pub(crate) fn route(name: &str, bytes: &[u8]) -> Route { /// Dispatches format-specific importers and propagates a structured PSD damage /// flag alongside the ordinary best-effort import result. fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, opts: &ImportOptions, damaged: &mut bool) -> Result { + // PDF (PDFium, native only). `.ai` files are PDFs but keep their existing path; a recognized + // raster signature wins over a misleading `.pdf` suffix. + if pdf_import::is_pdf(bytes) + || (has_extension(name, "pdf") && !has_extension(name, "ai") && photocraft_codecs::detect(bytes).is_none()) + { + return pdf_import::import(name, bytes, opts); + } match route(name, bytes) { Route::Pcraft => { let document = photocraft_format::load_from_bytes(bytes)?; diff --git a/crates/io/src/pdf_import.rs b/crates/io/src/pdf_import.rs new file mode 100644 index 0000000000..ca9d984993 --- /dev/null +++ b/crates/io/src/pdf_import.rs @@ -0,0 +1,76 @@ +//! PDF import: PDFium renders the first page to a PNG and the flat importer builds the document. +//! +//! The page's print size is preserved by setting the document resolution to the import DPI: +//! pixels ÷ DPI × 72 pt is the PDF page's size again. Multi-page import, vector/text extraction +//! and page management are the next steps (see PLAN.md in the local PDF project). + +use crate::{ImportOptions, ImportResult, IoError}; + +/// `%PDF-` magic. `.ai` files are PDFs too, but the dispatch keeps them out of this importer. +pub fn is_pdf(bytes: &[u8]) -> bool { + bytes.starts_with(b"%PDF-") +} + +/// Native PDF import is not available in the web build. +#[cfg(target_arch = "wasm32")] +pub fn import(_name: &str, _bytes: &[u8], _opts: &ImportOptions) -> Result { + Err(IoError::Unsupported("PDF import needs the desktop build".into())) +} + +/// Renders the PDF's first page at [`ImportOptions::pdf_dpi`] and opens it as a flat document. +#[cfg(not(target_arch = "wasm32"))] +pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions) -> Result { + use image::ImageEncoder; + use pdfium_render::prelude::*; + + let dpi = if opts.pdf_dpi.is_finite() && opts.pdf_dpi > 0.0 { opts.pdf_dpi } else { 300.0 }; + let pdfium = bind()?; + let document = pdfium.load_pdf_from_byte_slice(bytes, None).map_err(|e| IoError::Unsupported(format!("PDF: {e}")))?; + let page_count = document.pages().len(); + let page = document.pages().get(0).map_err(|e| IoError::Unsupported(format!("PDF: {e}")))?; + let width_pt = f64::from(page.width().value); + // A page can be arbitrarily large; cap the raster so a hostile PDF cannot request a huge + // allocation. 20,000 px is far above a poster at 300 dpi. + let target_width = (width_pt * f64::from(dpi) / 72.0).round().clamp(1.0, 20_000.0) as i32; + let config = PdfRenderConfig::new().set_target_width(target_width); + let bitmap = page.render_with_config(&config).map_err(|e| IoError::Unsupported(format!("PDF render: {e}")))?; + let (width, height) = (bitmap.width().max(0) as u32, bitmap.height().max(0) as u32); + let rgba = bitmap.as_rgba_bytes(); + let image = image::RgbaImage::from_raw(width, height, rgba) + .ok_or_else(|| IoError::Unsupported("PDF: the rendered bitmap does not match its buffer".into()))?; + // Reuse the well-tested flat importer (the PNG still carries the alpha and colour exactly). + let mut png = Vec::new(); + image::codecs::png::PngEncoder::new(&mut png) + .write_image(image.as_raw(), width, height, image::ExtendedColorType::Rgba8) + .map_err(|e| IoError::Unsupported(format!("PDF: encoding the rendered page: {e}")))?; + let mut result = crate::flat::import_flat(name, &png)?; + result.document.resolution_dpi = dpi; + result.document.name = name.to_string(); + if page_count > 1 { + result + .warnings + .push(format!("PDF has {page_count} pages; this build imports page 1 (multi-page import is the next step)")); + } + Ok(result) +} + +/// Loads the PDFium shared library from the executable's folder (`PDFIUM_DIR` overrides it). +#[cfg(not(target_arch = "wasm32"))] +fn bind() -> Result { + use pdfium_render::prelude::Pdfium; + + let dir = std::env::var_os("PDFIUM_DIR").map(std::path::PathBuf::from).unwrap_or_else(|| { + std::env::current_exe() + .ok() + .and_then(|exe| exe.parent().map(std::path::Path::to_path_buf)) + .unwrap_or_default() + }); + let library = Pdfium::pdfium_platform_library_name_at_path(&dir); + let bindings = Pdfium::bind_to_library(&library).map_err(|e| { + IoError::Unsupported(format!( + "PDF import: cannot load PDFium from {} ({e}); put pdfium.dll next to the executable or set PDFIUM_DIR", + library.display() + )) + })?; + Ok(Pdfium::new(bindings)) +} From 399cd6ea4de6febaedff5a67f4e009496f233bd6 Mon Sep 17 00:00:00 2001 From: Triwarr <170976175+Triwarr@users.noreply.github.com> Date: Fri, 6 Nov 2026 13:44:49 +0100 Subject: [PATCH 061/219] PDF: import every page as an artboard; export a page per artboard The PDF importer renders each page with PDFium at the import DPI (default 300), lays the pages out side by side and wraps each in an artboard, so a 2-page PDF opens as 2 artboards and saves back as a 2-page PDF (one page per artboard, page-sized, labelled). The import caps at 16 pages for now. The PDF writer now takes a list of pages: shared font/ICC/selection objects, one image per page, and the existing one-page export is the single-page case. Artboards with vector/text layers are rasterised in this path. Tested: artboards_export_as_one_page_each; convert sample.pdf app-multi.pdf (2 pages), rendered back with the probe. --- crates/io/src/pdf.rs | 169 ++++++++++++++++++++++++------------ crates/io/src/pdf/tests.rs | 26 ++++++ crates/io/src/pdf_import.rs | 96 +++++++++++++------- 3 files changed, 204 insertions(+), 87 deletions(-) diff --git a/crates/io/src/pdf.rs b/crates/io/src/pdf.rs index d693ee941b..71c2818b97 100644 --- a/crates/io/src/pdf.rs +++ b/crates/io/src/pdf.rs @@ -109,10 +109,12 @@ fn marks_ops(m: Marks, (x, y, w, h): (f64, f64, f64, f64)) -> String { /// A one-page PDF. pub fn print_pdf(page: &PrintPage) -> Vec { - print_pdf_with_vectors(page, "", &selection::SelectionFont::default()) + print_pdf_pages(std::slice::from_ref(page), &[String::new()], &selection::SelectionFont::default()) } -fn print_pdf_with_vectors(page: &PrintPage, vectors: &str, selection_font: &selection::SelectionFont) -> Vec { +/// A PDF with one page per entry; `vectors[i]` (when present) is drawn over page `i`. +fn print_pdf_pages(pages: &[PrintPage], vectors: &[String], selection_font: &selection::SelectionFont) -> Vec { + let Some(first) = pages.first() else { return Vec::new() }; let mut out: Vec = b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n".to_vec(); let mut offsets: Vec = Vec::new(); let mut obj = |out: &mut Vec, body: &[u8]| { @@ -121,58 +123,78 @@ fn print_pdf_with_vectors(page: &PrintPage, vectors: &str, selection_font: &sele out.extend_from_slice(body); out.extend_from_slice(b"\nendobj\n"); }; - let img = &page.image; - let (pw, ph) = page.paper; - let (x, y, w, h) = page.rect; - let selection_base = if img.icc.is_some() { 8 } else { 7 }; - // 1 catalog, 2 pages, 3 page, 4 contents, 5 image, 6 font, 7 ICC (optional). + let n = pages.len(); + // 1 catalog, 2 pages, then per page: page, contents, image; then the shared font/ICC/selection. + let font_obj = 3 + n * 3; + let icc = first.image.icc.as_ref(); + let icc_obj = icc.map(|_| font_obj + 1); + let selection_base = font_obj + if icc.is_some() { 2 } else { 1 }; 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 /FSelect {selection_base} 0 R >> >> /Contents 4 0 R >>" + let kids = (0..n).map(|i| format!("{} 0 R", 3 + i * 3)).collect::>().join(" "); + obj(&mut out, format!("<< /Type /Pages /Kids [{kids}] /Count {n} >>").as_bytes()); + for (i, page) in pages.iter().enumerate() { + let page_obj = 3 + i * 3; + let content_obj = page_obj + 1; + let image_obj = page_obj + 2; + let (pw, ph) = page.paper; + let (x, y, w, h) = page.rect; + obj( + &mut out, + format!( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {pw:.2} {ph:.2}] /Resources << /XObject << /Im0 {image_obj} 0 R >> /Font << /F1 {font_obj} 0 R /FSelect {selection_base} 0 R >> >> /Contents {content_obj} 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"); + if let Some(v) = vectors.get(i) { + content.push_str(v); + } + content.push_str(&marks_ops(page.marks, page.rect)); + let any_marks = page.marks.corner_crop || page.marks.center_crop || page.marks.registration; + let pad = if any_marks { 30.0 } else { 6.0 }; + if let Some(l) = page.label.as_deref().filter(|l| !l.is_empty()) { + content.push_str(&format!("BT /F1 8 Tf 0 g {:.2} {:.2} Td {} Tj ET\n", x, y + h + pad, pdf_string(l))); + } + if let Some(d) = page.description.as_deref().filter(|d| !d.is_empty()) { + content.push_str(&format!("BT /F1 8 Tf 0 g {:.2} {:.2} Td {} Tj ET\n", x, y - pad - 8.0, pdf_string(d))); + } + let c = deflate(content.as_bytes()); + let mut body = format!("<< /Length {} /Filter /FlateDecode >>\nstream\n", c.len()).into_bytes(); + body.extend_from_slice(&c); + body.extend_from_slice(b"\nendstream"); + obj(&mut out, &body); + let img = &page.image; + let device = match img.channels { + 1 => "/DeviceGray", + 4 => "/DeviceCMYK", + _ => "/DeviceRGB", + }; + let cs = match icc_obj { + Some(obj) => format!("[/ICCBased {obj} 0 R]"), + None => 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() ) - .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(vectors); - content.push_str(&marks_ops(page.marks, page.rect)); - let any_marks = page.marks.corner_crop || page.marks.center_crop || page.marks.registration; - let pad = if any_marks { 30.0 } else { 6.0 }; - if let Some(l) = page.label.as_deref().filter(|l| !l.is_empty()) { - content.push_str(&format!("BT /F1 8 Tf 0 g {:.2} {:.2} Td {} Tj ET\n", x, y + h + pad, pdf_string(l))); + .into_bytes(); + body.extend_from_slice(&z); + body.extend_from_slice(b"\nendstream"); + obj(&mut out, &body); } - if let Some(d) = page.description.as_deref().filter(|d| !d.is_empty()) { - content.push_str(&format!("BT /F1 8 Tf 0 g {:.2} {:.2} Td {} Tj ET\n", x, y - pad - 8.0, pdf_string(d))); - } - let c = deflate(content.as_bytes()); - let mut body = format!("<< /Length {} /Filter /FlateDecode >>\nstream\n", c.len()).into_bytes(); - body.extend_from_slice(&c); - body.extend_from_slice(b"\nendstream"); - obj(&mut out, &body); - let device = match img.channels { - 1 => "/DeviceGray", - 4 => "/DeviceCMYK", - _ => "/DeviceRGB", - }; - 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(); - body.extend_from_slice(&z); - body.extend_from_slice(b"\nendstream"); - obj(&mut out, &body); obj(&mut out, b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>"); - if let Some(icc) = &img.icc { + if let Some(icc) = icc { + let device = match first.image.channels { + 1 => "/DeviceGray", + 4 => "/DeviceCMYK", + _ => "/DeviceRGB", + }; let z = deflate(icc); - let mut body = format!("<< /N {} /Alternate {device} /Filter /FlateDecode /Length {} >>\nstream\n", img.channels, z.len()).into_bytes(); + let mut body = format!("<< /N {} /Alternate {device} /Filter /FlateDecode /Length {} >>\nstream\n", first.image.channels, z.len()).into_bytes(); body.extend_from_slice(&z); body.extend_from_slice(b"\nendstream"); obj(&mut out, &body); @@ -261,9 +283,10 @@ fn collect_vectors(doc: &Document) -> (Vec<(usize, String)>, selection::Selectio (layers, selection_font) } -/// File › Save As / Save a Copy › PDF (#3401): the flattened document, at its print size -/// (pixels ÷ resolution × 72 pt), as a one-page PDF. Layers and transparency are dropped — the -/// warning says so — and the embedded profile travels with the image. +/// File › Save As / Save a Copy › PDF (#3401): the flattened document at its print size +/// (pixels ÷ resolution × 72 pt). One page per artboard when the document has artboards, else one +/// page for the whole document. Layers and transparency are dropped — the warning says so — and +/// the embedded profile travels with the images. pub fn export(doc: &Document) -> std::result::Result { let (w, h) = (usize::try_from(doc.size.width).unwrap_or(0), usize::try_from(doc.size.height).unwrap_or(0)); if w == 0 || h == 0 { @@ -277,6 +300,41 @@ pub fn export(doc: &Document) -> std::result::Result { }; let fmt = doc.pixel_format(); let n = fmt.channels().max(1); + let dpi = f64::from(doc.resolution_dpi.max(1.0)); + let paper = if doc.mode == ColorMode::Cmyk { 0.0 } else { 1.0 }; + let to8 = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + let icc = doc.icc_profile.as_ref().map(|p| p.to_vec()); + let artboards = doc.artboards(); + if !artboards.is_empty() { + // One flattened pass of the whole document; each artboard is a crop of it. + let surface = photocraft_compose::flatten_to_surface(doc, PixelFormat::new(fmt.mode, fmt.sample, true), None); + let mut pages = Vec::with_capacity(artboards.len()); + for (_, name, artboard) in &artboards { + let rect = artboard.rect; + let (aw, ah) = (rect.width(), rect.height()); + if aw == 0 || ah == 0 { + continue; + } + let values = surface.read_region(rect); + let mut data = Vec::with_capacity((aw as usize * ah as usize).saturating_mul(device)); + for px in values.chunks(n) { + let a = px.last().copied().unwrap_or(1.0).clamp(0.0, 1.0); + for v in px.iter().take(device) { + data.push(to8(v * a + paper * (1.0 - a))); + } + } + let image = PrintImage { width: aw, height: ah, channels: device, data, icc: icc.clone() }; + let (pw, ph) = (f64::from(aw) * 72.0 / dpi, f64::from(ah) * 72.0 / dpi); + pages.push(PrintPage { paper: (pw, ph), image, rect: (0.0, 0.0, pw, ph), marks: Marks::default(), description: None, label: Some((*name).to_string()) }); + } + if pages.is_empty() { + return Err(IoError::Unsupported("PDF: the document's artboards have no area".into())); + } + let mut warnings = vec!["PDF was saved flattened: layers, masks-as-editable and transparency are not preserved (#3401)".into()]; + warnings.push(format!("PDF exported {} artboards as {} pages; vector/text layers on artboards are rasterised", artboards.len(), pages.len())); + let vectors = vec![String::new(); pages.len()]; + return Ok(ExportResult { bytes: print_pdf_pages(&pages, &vectors, &selection::SelectionFont::default()), warnings }); + } let canvas = photocraft_geom::Rect::new(0, 0, w as i32, h as i32); // Simple topmost shapes and text layers can be drawn over the flattened backdrop without changing // compositing. Stop at the first unsupported visible layer, since it may depend on the @@ -290,10 +348,8 @@ pub fn export(doc: &Document) -> std::result::Result { } let surface = photocraft_compose::flatten_to_surface(&backdrop, PixelFormat::new(fmt.mode, fmt.sample, true), None); let values = surface.read_region(canvas); - let to8 = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; // Straight alpha, composited here over paper: white in RGB/Gray (1 = full light), zero ink // in CMYK (0 = paper). - let paper = if doc.mode == ColorMode::Cmyk { 0.0 } else { 1.0 }; let mut data = Vec::with_capacity((w * h).saturating_mul(device)); for px in values.chunks(n) { let a = px.last().copied().unwrap_or(1.0).clamp(0.0, 1.0); @@ -301,8 +357,7 @@ pub fn export(doc: &Document) -> std::result::Result { data.push(to8(v * a + paper * (1.0 - a))); } } - let image = PrintImage { width: doc.size.width, height: doc.size.height, channels: device, data, icc: doc.icc_profile.as_ref().map(|p| p.to_vec()) }; - let dpi = f64::from(doc.resolution_dpi.max(1.0)); + let image = PrintImage { width: doc.size.width, height: doc.size.height, channels: device, data, icc: icc.clone() }; let (pw, ph) = (f64::from(doc.size.width) * 72.0 / dpi, f64::from(doc.size.height) * 72.0 / dpi); let page = PrintPage { paper: (pw, ph), image, rect: (0.0, 0.0, pw, ph), marks: Marks::default(), description: None, label: None }; let mut warnings = vec!["PDF was saved flattened: layers, masks-as-editable and transparency are not preserved (#3401)".into()]; @@ -315,7 +370,7 @@ pub fn export(doc: &Document) -> std::result::Result { vectors.push_str(ops); } vectors.push_str("Q\n"); - Ok(ExportResult { bytes: print_pdf_with_vectors(&page, &vectors, &selection_font), warnings }) + Ok(ExportResult { bytes: print_pdf_pages(&[page], &[vectors], &selection_font), warnings }) } #[cfg(test)] diff --git a/crates/io/src/pdf/tests.rs b/crates/io/src/pdf/tests.rs index e745623e41..0364fa6e1a 100644 --- a/crates/io/src/pdf/tests.rs +++ b/crates/io/src/pdf/tests.rs @@ -393,3 +393,29 @@ fn leading_newline_stays_in_the_first_selection_span() { let content = String::from_utf8(stream_after(&pdf, "/Contents 4 0 R")).unwrap(); assert!(content.contains("/ActualText "), "the first span retains the leading newline:\n{content}"); } + +#[test] +fn artboards_export_as_one_page_each() { + use photocraft_doc::{Artboard, Group}; + let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::F32, true); + let mut d = Document::new("pages.pdf", Size { width: 40, height: 30 }, ColorMode::Rgb, SampleType::F32); + d.resolution_dpi = 72.0; + for (rect, px) in [(Rect::new(0, 0, 20, 30), [1.0, 0.0, 0.0, 1.0]), (Rect::new(20, 0, 40, 30), [0.0, 0.0, 1.0, 1.0])] { + let layer = Layer::new("Pixels", LayerContent::Raster(solid(fmt, rect.x0, rect.y0, rect.x1, rect.y1, &px))); + let group = Group { children: vec![layer], expanded: true, artboard: Some(Artboard::new(rect)), frame_stroke: None }; + d.layers.push(Layer::new("Page", LayerContent::Group(group))); + } + let out = export(&d).unwrap(); + check_structure(&out.bytes); + let parsed = lopdf::Document::load_mem(&out.bytes).unwrap(); + let pages = parsed.get_pages(); + assert_eq!(pages.len(), 2); + for id in pages.values() { + let page = parsed.get_dictionary(*id).unwrap(); + let media = page.get(b"MediaBox").unwrap().as_array().unwrap(); + let w = media[2].as_float().or_else(|_| media[2].as_i64().map(|v| v as f32)).unwrap(); + let h = media[3].as_float().or_else(|_| media[3].as_i64().map(|v| v as f32)).unwrap(); + assert!((w - 20.0).abs() < 0.01 && (h - 30.0).abs() < 0.01, "page {w}x{h}"); + } + assert_eq!(out.bytes.windows(15).filter(|w| *w == b"/Subtype /Image").count(), 2); +} diff --git a/crates/io/src/pdf_import.rs b/crates/io/src/pdf_import.rs index ca9d984993..b35a224dd7 100644 --- a/crates/io/src/pdf_import.rs +++ b/crates/io/src/pdf_import.rs @@ -1,10 +1,12 @@ -//! PDF import: PDFium renders the first page to a PNG and the flat importer builds the document. +//! PDF import: PDFium renders every page and each page becomes an artboard. //! -//! The page's print size is preserved by setting the document resolution to the import DPI: -//! pixels ÷ DPI × 72 pt is the PDF page's size again. Multi-page import, vector/text extraction -//! and page management are the next steps (see PLAN.md in the local PDF project). +//! The document resolution is the import DPI, so pixels ÷ DPI × 72 pt is the PDF page's size +//! again. Pages are laid out side by side with a small gap; more than [`MAX_PAGES`] pages are +//! capped so the union canvas stays bounded. use crate::{ImportOptions, ImportResult, IoError}; +use photocraft_doc::{Artboard, Document, Group, Layer, LayerContent}; +use photocraft_geom::Rect; /// `%PDF-` magic. `.ai` files are PDFs too, but the dispatch keeps them out of this importer. pub fn is_pdf(bytes: &[u8]) -> bool { @@ -17,41 +19,75 @@ pub fn import(_name: &str, _bytes: &[u8], _opts: &ImportOptions) -> Result Result { use image::ImageEncoder; use pdfium_render::prelude::*; + /// The most pages one import renders; more would make the union canvas enormous. + const MAX_PAGES: usize = 16; + /// Gap between the artboards, in document pixels. + const GAP: u32 = 80; + let dpi = if opts.pdf_dpi.is_finite() && opts.pdf_dpi > 0.0 { opts.pdf_dpi } else { 300.0 }; let pdfium = bind()?; let document = pdfium.load_pdf_from_byte_slice(bytes, None).map_err(|e| IoError::Unsupported(format!("PDF: {e}")))?; - let page_count = document.pages().len(); - let page = document.pages().get(0).map_err(|e| IoError::Unsupported(format!("PDF: {e}")))?; - let width_pt = f64::from(page.width().value); - // A page can be arbitrarily large; cap the raster so a hostile PDF cannot request a huge - // allocation. 20,000 px is far above a poster at 300 dpi. - let target_width = (width_pt * f64::from(dpi) / 72.0).round().clamp(1.0, 20_000.0) as i32; - let config = PdfRenderConfig::new().set_target_width(target_width); - let bitmap = page.render_with_config(&config).map_err(|e| IoError::Unsupported(format!("PDF render: {e}")))?; - let (width, height) = (bitmap.width().max(0) as u32, bitmap.height().max(0) as u32); - let rgba = bitmap.as_rgba_bytes(); - let image = image::RgbaImage::from_raw(width, height, rgba) - .ok_or_else(|| IoError::Unsupported("PDF: the rendered bitmap does not match its buffer".into()))?; - // Reuse the well-tested flat importer (the PNG still carries the alpha and colour exactly). - let mut png = Vec::new(); - image::codecs::png::PngEncoder::new(&mut png) - .write_image(image.as_raw(), width, height, image::ExtendedColorType::Rgba8) - .map_err(|e| IoError::Unsupported(format!("PDF: encoding the rendered page: {e}")))?; - let mut result = crate::flat::import_flat(name, &png)?; - result.document.resolution_dpi = dpi; - result.document.name = name.to_string(); - if page_count > 1 { - result - .warnings - .push(format!("PDF has {page_count} pages; this build imports page 1 (multi-page import is the next step)")); + let total = document.pages().len().max(0) as usize; + let count = total.min(MAX_PAGES); + if count == 0 { + return Err(IoError::Unsupported("PDF has no pages".into())); } - Ok(result) + // Render first, so a malformed later page fails before any document is built. + let mut rendered = Vec::with_capacity(count); + for index in 0..count { + let page = document.pages().get(index as i32).map_err(|e| IoError::Unsupported(format!("PDF: {e}")))?; + let width_pt = f64::from(page.width().value); + // A page can be arbitrarily large; cap the raster so a hostile PDF cannot request a huge + // allocation. 20,000 px is far above a poster at 300 dpi. + let target_width = (width_pt * f64::from(dpi) / 72.0).round().clamp(1.0, 20_000.0) as i32; + let config = PdfRenderConfig::new().set_target_width(target_width); + let bitmap = page.render_with_config(&config).map_err(|e| IoError::Unsupported(format!("PDF render: {e}")))?; + let (width, height) = (bitmap.width().max(0) as u32, bitmap.height().max(0) as u32); + rendered.push((width, height, bitmap.as_rgba_bytes())); + } + let doc_width = rendered.iter().map(|(w, _, _)| *w).sum::() + GAP * (count as u32 - 1); + let doc_height = rendered.iter().map(|(_, h, _)| *h).max().unwrap_or(1); + let mut doc: Option = None; + let mut x = 0u32; + for (index, (width, height, rgba)) in rendered.into_iter().enumerate() { + // Reuse the well-tested flat importer: the page is composited into the union canvas at its + // artboard position, encoded as PNG, and decoded into a document-wide raster layer. + let mut canvas = image::RgbaImage::new(doc_width, doc_height); + let page = image::RgbaImage::from_raw(width, height, rgba) + .ok_or_else(|| IoError::Unsupported("PDF: the rendered bitmap does not match its buffer".into()))?; + image::imageops::overlay(&mut canvas, &page, i64::from(x), 0); + let mut png = Vec::new(); + image::codecs::png::PngEncoder::new(&mut png) + .write_image(canvas.as_raw(), doc_width, doc_height, image::ExtendedColorType::Rgba8) + .map_err(|e| IoError::Unsupported(format!("PDF: encoding the rendered page: {e}")))?; + let mut result = crate::flat::import_flat(name, &png)?; + let layer = result.document.layers.pop().ok_or_else(|| IoError::Unsupported("PDF: the page decoded to an empty layer".into()))?; + let rect = Rect::new(x as i32, 0, (x + width) as i32, height as i32); + let group = Group { children: vec![layer], expanded: true, artboard: Some(Artboard::new(rect)), frame_stroke: None }; + let artboard = Layer::new(format!("Page {}", index + 1), LayerContent::Group(group)); + match &mut doc { + None => { + result.document.layers = vec![artboard]; + doc = Some(result.document); + } + Some(d) => d.layers.push(artboard), + } + x += width + GAP; + } + let mut doc = doc.ok_or_else(|| IoError::Unsupported("PDF has no pages".into()))?; + doc.resolution_dpi = dpi; + doc.name = name.to_string(); + let mut warnings = Vec::new(); + if total > count { + warnings.push(format!("PDF has {total} pages; imported the first {count} (the limit is {MAX_PAGES})")); + } + Ok(ImportResult { warnings, document: doc, source_read_only: false, preview_only: false }) } /// Loads the PDFium shared library from the executable's folder (`PDFIUM_DIR` overrides it). From c1662d49116d73cf1760d37f7089a232a5159c6e Mon Sep 17 00:00:00 2001 From: Triwarr <170976175+Triwarr@users.noreply.github.com> Date: Fri, 6 Nov 2026 13:45:00 +0100 Subject: [PATCH 062/219] Export: split nested groups into separate layers/files file.export.layersToFiles gains groups: layers (walk every visible leaf at any depth, not only top-level layers) and naming: layer (bare .png names; duplicates get -2). This is the recursive folder export from the local Program Files build, brought in as the second fix on this branch. Tested: layers_to_files_exports_group_children_as_loose_files (nested subgroup + sibling). --- crates/engine/src/cmd/file_cmds/export.rs | 138 ++++++++++++++++------ crates/engine/src/cmd/file_cmds/tests.rs | 75 ++++++++++++ 2 files changed, 175 insertions(+), 38 deletions(-) diff --git a/crates/engine/src/cmd/file_cmds/export.rs b/crates/engine/src/cmd/file_cmds/export.rs index 1f7f9677ff..ef947a5f21 100644 --- a/crates/engine/src/cmd/file_cmds/export.rs +++ b/crates/engine/src/cmd/file_cmds/export.rs @@ -20,7 +20,7 @@ pub fn export_layers_with(s: &Session, p: &Value, write: impl FnMut(&str, &[u8]) export_layers_report_with(s, p, write).map(|report| report.files) } -/// A top-level layer omitted from a successful Layers to Files export. +/// A layer omitted from a successful Layers to Files export. #[derive(Debug, serde::Serialize)] pub struct SkippedExportLayer { /// Session layer id (names need not be unique). @@ -38,14 +38,59 @@ pub struct LayerExportReport { pub skipped: Vec, } -/// The same export as [`export_layers_with`], with an explanation for every omitted top-level -/// layer. Groups export merged, so their children are not separate exports or skips. Encoding -/// and write failures remain errors, never successful skips. Existing numbering is preserved. +/// One layer the export will write or explain, in top-to-bottom order. +enum Planned<'a> { + Export(&'a Layer), + Skip(&'a Layer, &'static str), + Hidden(&'a Layer), +} + +/// Flattens a layer (and, with `split_groups`, its group descendants) into export entries. +fn plan_layer<'a>(layer: &'a Layer, split_groups: bool, visible_only: bool, plan: &mut Vec>) { + if !layer.visible && visible_only { + plan.push(Planned::Hidden(layer)); + return; + } + if let LayerContent::Group(group) = &layer.content + && split_groups + { + for child in group.children.iter().rev() { + plan_layer(child, split_groups, visible_only, plan); + } + return; + } + if matches!(layer.content, LayerContent::Adjustment(_)) { + plan.push(Planned::Skip(layer, "adjustment layer has no pixels of its own")); + } else { + plan.push(Planned::Export(layer)); + } +} + +/// The same export as [`export_layers_with`], with an explanation for every omitted layer. +/// +/// `groups` chooses what a group produces: `"merged"` (the default) exports the group as one +/// file, as before; `"layers"` walks into the group and exports each visible layer inside it as +/// its own file (top to bottom), so no group is a file of its own. Encoding and write failures +/// remain errors, never successful skips. Existing numbering is preserved. pub fn export_layers_report_with(s: &Session, p: &Value, mut write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result { let cmd = "file.export.layersToFiles"; let format = p.get("format").and_then(Value::as_str).unwrap_or("png").trim_start_matches('.').to_ascii_lowercase(); let trim = p.get("trim").and_then(Value::as_bool).unwrap_or(false); let visible_only = p.get("visibleOnly").and_then(Value::as_bool).unwrap_or(true); + let split_groups = match p.get("groups").and_then(Value::as_str).unwrap_or("merged") { + "merged" => false, + "layers" => true, + other => { + return Err(EngineError::BadParams { cmd: cmd.into(), msg: format!("groups must be \"merged\" or \"layers\", not {other:?}") }); + } + }; + let layer_names = match p.get("naming").and_then(Value::as_str).map(|s| s.to_ascii_lowercase()).as_deref() { + None | Some("numbered") => false, + Some("layer") => true, + Some(other) => { + return Err(EngineError::BadParams { cmd: cmd.into(), msg: format!("naming must be \"numbered\" or \"layer\", not {other:?}") }); + } + }; // Callers outside `Session::execute` (headless workspace exports) carry the session's gate // into `encode`, so a PSD embeds no file a linked smart object names. let _authorization = crate::jobs::render_authorization_scope(s.authorize); @@ -53,48 +98,65 @@ pub fn export_layers_report_with(s: &Session, p: &Value, mut write: impl FnMut(& let d = s.active().ok_or(EngineError::NoDocument)?; let prefix = p.get("prefix").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| stem(&d.doc.name)); let doc = d.doc.clone(); + let mut plan = Vec::new(); + for l in doc.layers.iter().rev() { + plan_layer(l, split_groups, visible_only, &mut plan); + } let mut report = LayerExportReport::default(); + let mut used = std::collections::HashSet::new(); // Hidden layers never consumed a filename index; adjustments that pass the visibility // filter did. Keep those filenames stable while reporting both kinds of omission. let mut i = 0usize; - for l in doc.layers.iter().rev() { - if !l.visible && visible_only { - report.skipped.push(SkippedExportLayer { layer: l.id.0, name: l.name.clone(), reason: "hidden by visibleOnly" }); - continue; - } - let index = i; - i += 1; - if matches!(l.content, LayerContent::Adjustment(_)) { - report.skipped.push(SkippedExportLayer { layer: l.id.0, name: l.name.clone(), reason: "adjustment layer has no pixels of its own" }); - continue; - } - let mut one = (*doc).clone(); - let mut only = l.clone(); - only.visible = true; - only.clipped = false; - one.layers = vec![only]; - if trim { - let mut bounds = Rect::EMPTY; - let _ = photocraft_compose::render_bands(&one, one.bounds(), 0, |band| -> std::result::Result<(), ()> { - let width = band.rect.width() as usize; - for (i, rgba) in band.px.iter().enumerate() { - if rgba[3] > 0.0 && width > 0 { - let x = band.rect.x0 + (i % width) as i32; - let y = band.rect.y0 + (i / width) as i32; - bounds = bounds.union(&Rect::new(x, y, x + 1, y + 1)); + for entry in plan { + match entry { + Planned::Hidden(l) => report.skipped.push(SkippedExportLayer { layer: l.id.0, name: l.name.clone(), reason: "hidden by visibleOnly" }), + Planned::Skip(l, reason) => { + i += 1; + report.skipped.push(SkippedExportLayer { layer: l.id.0, name: l.name.clone(), reason }); + } + Planned::Export(l) => { + let index = i; + i += 1; + let mut one = (*doc).clone(); + let mut only = l.clone(); + only.visible = true; + only.clipped = false; + one.layers = vec![only]; + if trim { + let mut bounds = Rect::EMPTY; + let _ = photocraft_compose::render_bands(&one, one.bounds(), 0, |band| -> std::result::Result<(), ()> { + let width = band.rect.width() as usize; + for (i, rgba) in band.px.iter().enumerate() { + if rgba[3] > 0.0 && width > 0 { + let x = band.rect.x0 + (i % width) as i32; + let y = band.rect.y0 + (i / width) as i32; + bounds = bounds.union(&Rect::new(x, y, x + 1, y + 1)); + } + } + Ok(()) + }); + if !bounds.is_empty() && bounds != one.bounds() { + crate::image_cmds::crop_doc(&mut one, cmd, bounds, true, [0.0; 4])?; } } - Ok(()) - }); - if !bounds.is_empty() && bounds != one.bounds() { - crate::image_cmds::crop_doc(&mut one, cmd, bounds, true, [0.0; 4])?; + + let name = if layer_names { + let base = sanitize(&l.name); + let mut name = format!("{base}.{format}"); + let mut n = 2; + while !used.insert(name.clone()) { + name = format!("{base}-{n}.{format}"); + n += 1; + } + name + } else { + format!("{}_{:04}_{}.{format}", sanitize(&prefix), index, sanitize(&l.name)) + }; + let (bytes, _warnings) = encode(&one, &name, save)?; + write(&name, &bytes)?; + report.files.push(name); } } - - let name = format!("{}_{:04}_{}.{format}", sanitize(&prefix), index, sanitize(&l.name)); - let (bytes, _warnings) = encode(&one, &name, save)?; - write(&name, &bytes)?; - report.files.push(name); } Ok(report) } diff --git a/crates/engine/src/cmd/file_cmds/tests.rs b/crates/engine/src/cmd/file_cmds/tests.rs index 26e172ffd6..7c98907983 100644 --- a/crates/engine/src/cmd/file_cmds/tests.rs +++ b/crates/engine/src/cmd/file_cmds/tests.rs @@ -2538,6 +2538,81 @@ fn layers_to_files_reports_skipped_layers_without_changing_files() { } } +#[test] +fn layers_to_files_exports_group_children_as_loose_files() { + let dir = tmp("layers-groups"); + let mut s = session(8, 8, 8); + s.edit("Group", |doc, _| { + let fmt = doc.pixel_format(); + let child = |name: &str| photocraft_doc::Layer::new(name, photocraft_doc::LayerContent::Raster(photocraft_doc::Surface::new(fmt))); + let sub = photocraft_doc::Layer::new( + "sub", + photocraft_doc::LayerContent::Group(photocraft_doc::Group { + children: vec![child("c")], + expanded: true, + artboard: None, + frame_stroke: None, + }), + ); + doc.layers.push(photocraft_doc::Layer::new( + "expanded", + photocraft_doc::LayerContent::Group(photocraft_doc::Group { + children: vec![child("a"), child("b"), sub], + expanded: true, + artboard: None, + frame_stroke: None, + }), + )); + Ok(()) + }) + .unwrap(); + let files = |r: &Value| -> Vec { r["files"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect() }; + + let r = s.execute("file.export.layersToFiles", json!({"dir": dir, "prefix": "loose", "groups": "layers"})).unwrap(); + assert_eq!( + files(&r), + vec![ + join(&dir, "loose_0000_c.png"), + join(&dir, "loose_0001_b.png"), + join(&dir, "loose_0002_a.png"), + join(&dir, "loose_0003_Background.png") + ] + ); + assert!(files(&r).iter().all(|f| std::path::Path::new(f).is_file())); + + // The default still merges the group into one file. + let r = s.execute("file.export.layersToFiles", json!({"dir": dir, "prefix": "merged"})).unwrap(); + assert_eq!(files(&r), vec![join(&dir, "merged_0000_expanded.png"), join(&dir, "merged_0001_Background.png")]); + + // naming: "layer" writes the bare layer names instead of prefix_index_name. + let r = s.execute("file.export.layersToFiles", json!({"dir": dir, "groups": "layers", "naming": "layer"})).unwrap(); + assert_eq!(files(&r), vec![join(&dir, "c.png"), join(&dir, "b.png"), join(&dir, "a.png"), join(&dir, "Background.png")]); + + // A hidden child is skipped with visibleOnly and exported without it. + s.edit("Hide b", |doc, _| { + let group = doc.layers.last_mut().ok_or_else(|| EngineError::Other("no group".into()))?; + let LayerContent::Group(g) = &mut group.content else { return Err(EngineError::Other("not a group".into())) }; + g.children[1].visible = false; + Ok(()) + }) + .unwrap(); + let r = s.execute("file.export.layersToFiles", json!({"dir": dir, "prefix": "visible", "groups": "layers"})).unwrap(); + assert_eq!(files(&r), vec![join(&dir, "visible_0000_c.png"), join(&dir, "visible_0001_a.png"), join(&dir, "visible_0002_Background.png")]); + let r = s.execute("file.export.layersToFiles", json!({"dir": dir, "prefix": "all", "groups": "layers", "visibleOnly": false})).unwrap(); + assert_eq!( + files(&r), + vec![ + join(&dir, "all_0000_c.png"), + join(&dir, "all_0001_b.png"), + join(&dir, "all_0002_a.png"), + join(&dir, "all_0003_Background.png") + ] + ); + + // An unknown mode is rejected instead of silently merging. + assert!(s.execute("file.export.layersToFiles", json!({"dir": dir, "groups": "folders"})).is_err()); +} + #[test] fn layers_to_files_reports_groups_as_exports_and_failures_as_errors() { let mut s = session(4, 4, 8); From 3a1b41f0c65873c845e9a190f05d9dd9f02f2666 Mon Sep 17 00:00:00 2001 From: Triwarr <170976175+Triwarr@users.noreply.github.com> Date: Fri, 6 Nov 2026 13:55:45 +0100 Subject: [PATCH 063/219] PDF: bound the import canvas The importer budgets the per-page raster (100 M px) and the artboard strip (200 M px): an oversized page is scaled down, and pages that would exceed the strip budget are skipped with a warning, instead of allocating a multi-gigabyte union canvas. The page-count cap stays 16. Also applies cargo fmt to the new PDF code and the export-recursion test. --- crates/engine/src/cmd/file_cmds/tests.rs | 21 ++-------- crates/io/src/pdf.rs | 9 +++- crates/io/src/pdf_import.rs | 52 ++++++++++++++++++------ 3 files changed, 50 insertions(+), 32 deletions(-) diff --git a/crates/engine/src/cmd/file_cmds/tests.rs b/crates/engine/src/cmd/file_cmds/tests.rs index 7c98907983..6a14a7542b 100644 --- a/crates/engine/src/cmd/file_cmds/tests.rs +++ b/crates/engine/src/cmd/file_cmds/tests.rs @@ -2547,12 +2547,7 @@ fn layers_to_files_exports_group_children_as_loose_files() { let child = |name: &str| photocraft_doc::Layer::new(name, photocraft_doc::LayerContent::Raster(photocraft_doc::Surface::new(fmt))); let sub = photocraft_doc::Layer::new( "sub", - photocraft_doc::LayerContent::Group(photocraft_doc::Group { - children: vec![child("c")], - expanded: true, - artboard: None, - frame_stroke: None, - }), + photocraft_doc::LayerContent::Group(photocraft_doc::Group { children: vec![child("c")], expanded: true, artboard: None, frame_stroke: None }), ); doc.layers.push(photocraft_doc::Layer::new( "expanded", @@ -2571,12 +2566,7 @@ fn layers_to_files_exports_group_children_as_loose_files() { let r = s.execute("file.export.layersToFiles", json!({"dir": dir, "prefix": "loose", "groups": "layers"})).unwrap(); assert_eq!( files(&r), - vec![ - join(&dir, "loose_0000_c.png"), - join(&dir, "loose_0001_b.png"), - join(&dir, "loose_0002_a.png"), - join(&dir, "loose_0003_Background.png") - ] + vec![join(&dir, "loose_0000_c.png"), join(&dir, "loose_0001_b.png"), join(&dir, "loose_0002_a.png"), join(&dir, "loose_0003_Background.png")] ); assert!(files(&r).iter().all(|f| std::path::Path::new(f).is_file())); @@ -2601,12 +2591,7 @@ fn layers_to_files_exports_group_children_as_loose_files() { let r = s.execute("file.export.layersToFiles", json!({"dir": dir, "prefix": "all", "groups": "layers", "visibleOnly": false})).unwrap(); assert_eq!( files(&r), - vec![ - join(&dir, "all_0000_c.png"), - join(&dir, "all_0001_b.png"), - join(&dir, "all_0002_a.png"), - join(&dir, "all_0003_Background.png") - ] + vec![join(&dir, "all_0000_c.png"), join(&dir, "all_0001_b.png"), join(&dir, "all_0002_a.png"), join(&dir, "all_0003_Background.png")] ); // An unknown mode is rejected instead of silently merging. diff --git a/crates/io/src/pdf.rs b/crates/io/src/pdf.rs index 71c2818b97..55e70d0318 100644 --- a/crates/io/src/pdf.rs +++ b/crates/io/src/pdf.rs @@ -325,7 +325,14 @@ pub fn export(doc: &Document) -> std::result::Result { } let image = PrintImage { width: aw, height: ah, channels: device, data, icc: icc.clone() }; let (pw, ph) = (f64::from(aw) * 72.0 / dpi, f64::from(ah) * 72.0 / dpi); - pages.push(PrintPage { paper: (pw, ph), image, rect: (0.0, 0.0, pw, ph), marks: Marks::default(), description: None, label: Some((*name).to_string()) }); + pages.push(PrintPage { + paper: (pw, ph), + image, + rect: (0.0, 0.0, pw, ph), + marks: Marks::default(), + description: None, + label: Some((*name).to_string()), + }); } if pages.is_empty() { return Err(IoError::Unsupported("PDF: the document's artboards have no area".into())); diff --git a/crates/io/src/pdf_import.rs b/crates/io/src/pdf_import.rs index b35a224dd7..7bd5f76df9 100644 --- a/crates/io/src/pdf_import.rs +++ b/crates/io/src/pdf_import.rs @@ -38,19 +38,47 @@ pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions) -> Result MAX_PAGE_PIXELS { + let scale = (MAX_PAGE_PIXELS / area).sqrt(); + target_width = (target_width * scale).max(1.0); + } + let config = PdfRenderConfig::new().set_target_width(target_width as i32); let bitmap = page.render_with_config(&config).map_err(|e| IoError::Unsupported(format!("PDF render: {e}")))?; let (width, height) = (bitmap.width().max(0) as u32, bitmap.height().max(0) as u32); + // Stop before the strip exceeds the union budget; the pages rendered so far stay. + let next_width = union_width + u64::from(width) + if rendered.is_empty() { 0 } else { u64::from(GAP) }; + let next_height = union_height.max(u64::from(height)); + if next_width.saturating_mul(next_height) > MAX_UNION_PIXELS { + break; + } + union_width = next_width; + union_height = next_height; rendered.push((width, height, bitmap.as_rgba_bytes())); } + let count = rendered.len(); + if count == 0 { + return Err(IoError::Unsupported("PDF pages are too large to import; reduce the DPI".into())); + } let doc_width = rendered.iter().map(|(w, _, _)| *w).sum::() + GAP * (count as u32 - 1); let doc_height = rendered.iter().map(|(_, h, _)| *h).max().unwrap_or(1); let mut doc: Option = None; @@ -59,8 +87,8 @@ pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions) -> Result Result count { - warnings.push(format!("PDF has {total} pages; imported the first {count} (the limit is {MAX_PAGES})")); + let reason = if total > MAX_PAGES { "the page limit is 16" } else { "the rest would make the import canvas too large" }; + warnings.push(format!("PDF has {total} pages; imported the first {count} ({reason})")); } Ok(ImportResult { warnings, document: doc, source_read_only: false, preview_only: false }) } @@ -95,12 +124,9 @@ pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions) -> Result Result { use pdfium_render::prelude::Pdfium; - let dir = std::env::var_os("PDFIUM_DIR").map(std::path::PathBuf::from).unwrap_or_else(|| { - std::env::current_exe() - .ok() - .and_then(|exe| exe.parent().map(std::path::Path::to_path_buf)) - .unwrap_or_default() - }); + let dir = std::env::var_os("PDFIUM_DIR") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::env::current_exe().ok().and_then(|exe| exe.parent().map(std::path::Path::to_path_buf)).unwrap_or_default()); let library = Pdfium::pdfium_platform_library_name_at_path(&dir); let bindings = Pdfium::bind_to_library(&library).map_err(|e| { IoError::Unsupported(format!( From 94a4998208f9b5528c4f038010d5bcbcf3a3299a Mon Sep 17 00:00:00 2001 From: Triwarr <170976175+Triwarr@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:53:46 +0100 Subject: [PATCH 064/219] PDF: address CodeRabbit review (cancellation, budgets, labels, export edges) - Cancellation: the importer and the PDF writer take the Interrupt and check between pages. - Physical size: the effective DPI keeps each page print size when budgets cap the raster. - Oversized pages: final dimensions are checked before rendering. - Memory: pages go straight into layer surfaces; the aggregate pixel estimate is admitted. - Labels: /PageLabels (UTF-16BE) carry artboard names; no label is drawn outside the media box. - Metadata: the artboard export warns when free-form text metadata is dropped. - Export: hidden groups report their children; non-string modes are rejected; case-insensitive filename collisions are suffixed. - Dispatch: a recognised raster renamed .pdf goes to the flat importer; .ai keeps its path. - Platform: the PDFium recovery message names the actual library. --- crates/engine/src/cmd/file_cmds/export.rs | 50 +++++-- crates/io/src/lib.rs | 14 +- crates/io/src/pdf.rs | 37 ++++- crates/io/src/pdf/tests.rs | 36 ++--- crates/io/src/pdf_import.rs | 167 ++++++++++++---------- 5 files changed, 192 insertions(+), 112 deletions(-) diff --git a/crates/engine/src/cmd/file_cmds/export.rs b/crates/engine/src/cmd/file_cmds/export.rs index ef947a5f21..629b7aad82 100644 --- a/crates/engine/src/cmd/file_cmds/export.rs +++ b/crates/engine/src/cmd/file_cmds/export.rs @@ -49,6 +49,12 @@ enum Planned<'a> { fn plan_layer<'a>(layer: &'a Layer, split_groups: bool, visible_only: bool, plan: &mut Vec>) { if !layer.visible && visible_only { plan.push(Planned::Hidden(layer)); + // A hidden group takes its descendants with it; report each so the omission is complete. + if split_groups && let LayerContent::Group(group) = &layer.content { + for child in group.children.iter().rev() { + plan_hidden(child, plan); + } + } return; } if let LayerContent::Group(group) = &layer.content @@ -66,6 +72,16 @@ fn plan_layer<'a>(layer: &'a Layer, split_groups: bool, visible_only: bool, plan } } +/// Marks `layer` and all of its descendants as hidden, top to bottom. +fn plan_hidden<'a>(layer: &'a Layer, plan: &mut Vec>) { + plan.push(Planned::Hidden(layer)); + if let LayerContent::Group(group) = &layer.content { + for child in group.children.iter().rev() { + plan_hidden(child, plan); + } + } +} + /// The same export as [`export_layers_with`], with an explanation for every omitted layer. /// /// `groups` chooses what a group produces: `"merged"` (the default) exports the group as one @@ -77,19 +93,27 @@ pub fn export_layers_report_with(s: &Session, p: &Value, mut write: impl FnMut(& let format = p.get("format").and_then(Value::as_str).unwrap_or("png").trim_start_matches('.').to_ascii_lowercase(); let trim = p.get("trim").and_then(Value::as_bool).unwrap_or(false); let visible_only = p.get("visibleOnly").and_then(Value::as_bool).unwrap_or(true); - let split_groups = match p.get("groups").and_then(Value::as_str).unwrap_or("merged") { - "merged" => false, - "layers" => true, - other => { - return Err(EngineError::BadParams { cmd: cmd.into(), msg: format!("groups must be \"merged\" or \"layers\", not {other:?}") }); - } + let split_groups = match p.get("groups") { + None => false, + Some(Value::String(s)) => match s.to_ascii_lowercase().as_str() { + "merged" => false, + "layers" => true, + other => { + return Err(EngineError::BadParams { cmd: cmd.into(), msg: format!("groups must be \"merged\" or \"layers\", not {other:?}") }); + } + }, + Some(_) => return Err(EngineError::BadParams { cmd: cmd.into(), msg: "groups must be a string: \"merged\" or \"layers\"".into() }), }; - let layer_names = match p.get("naming").and_then(Value::as_str).map(|s| s.to_ascii_lowercase()).as_deref() { - None | Some("numbered") => false, - Some("layer") => true, - Some(other) => { - return Err(EngineError::BadParams { cmd: cmd.into(), msg: format!("naming must be \"numbered\" or \"layer\", not {other:?}") }); - } + let layer_names = match p.get("naming") { + None => false, + Some(Value::String(s)) => match s.to_ascii_lowercase().as_str() { + "numbered" => false, + "layer" => true, + other => { + return Err(EngineError::BadParams { cmd: cmd.into(), msg: format!("naming must be \"numbered\" or \"layer\", not {other:?}") }); + } + }, + Some(_) => return Err(EngineError::BadParams { cmd: cmd.into(), msg: "naming must be a string: \"numbered\" or \"layer\"".into() }), }; // Callers outside `Session::execute` (headless workspace exports) carry the session's gate // into `encode`, so a PSD embeds no file a linked smart object names. @@ -144,7 +168,7 @@ pub fn export_layers_report_with(s: &Session, p: &Value, mut write: impl FnMut(& let base = sanitize(&l.name); let mut name = format!("{base}.{format}"); let mut n = 2; - while !used.insert(name.clone()) { + while !used.insert(name.to_ascii_lowercase()) { name = format!("{base}-{n}.{format}"); n += 1; } diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index a828b5ff12..1f69b40ea4 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -556,8 +556,9 @@ pub fn import_preflight(name: &str, bytes: &[u8]) -> ImportPreflight { if is_psd_input(name, bytes) { return ImportPreflight::Psd; } - // PDF (PDFium, native only); `.ai` files are PDFs but keep their existing path. - if has_extension(name, "pdf") || (pdf_import::is_pdf(bytes) && !has_extension(name, "ai")) { + // PDF (PDFium, native only). `.ai` files are PDFs but keep their existing path; a recognized + // raster signature wins over a misleading `.pdf` suffix. + if pdf_import::is_pdf(bytes) || (has_extension(name, "pdf") && !has_extension(name, "ai") && photocraft_codecs::detect(bytes).is_none()) { return ImportPreflight::Unbounded; } if photocraft_codecs::detect(bytes) == Some(photocraft_codecs::Format::Tiff) { ImportPreflight::Tiff } else { ImportPreflight::Flat } @@ -978,6 +979,7 @@ pub(crate) fn route(name: &str, bytes: &[u8]) -> Route { if is_psd_input(name, bytes) { return Route::NamedPsd; } +<<<<<<< HEAD Route::Flat } @@ -990,6 +992,12 @@ fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, o || (has_extension(name, "pdf") && !has_extension(name, "ai") && photocraft_codecs::detect(bytes).is_none()) { return pdf_import::import(name, bytes, opts); +======= + // PDF (PDFium, native only). `.ai` files are PDFs but keep their existing path; a recognized + // raster signature wins over a misleading `.pdf` suffix. + if pdf_import::is_pdf(bytes) || (has_extension(name, "pdf") && !has_extension(name, "ai") && photocraft_codecs::detect(bytes).is_none()) { + return pdf_import::import(name, bytes, opts, ctl); +>>>>>>> 806c53fd2 (PDF: address CodeRabbit review (cancellation, budgets, labels, export edges)) } match route(name, bytes) { Route::Pcraft => { @@ -1170,7 +1178,7 @@ fn export_stages_with_renderer( return Ok(res.into()); } if ext == "pdf" { - let mut res = pdf::export(doc)?; + let mut res = pdf::export(doc, ctl)?; if let Some(r) = renderer { res.warnings.push("PDF export uses CPU compositor; GPU rendering applies to flat formats".into()); res.warnings.extend(r.drain_diagnostics()); diff --git a/crates/io/src/pdf.rs b/crates/io/src/pdf.rs index 55e70d0318..4317784e50 100644 --- a/crates/io/src/pdf.rs +++ b/crates/io/src/pdf.rs @@ -44,6 +44,8 @@ pub struct PrintPage { pub marks: Marks, pub description: Option, pub label: Option, + /// The page's name in the PDF `/PageLabels` tree (Unicode-capable; not drawn on the page). + pub page_label: Option, } pub(super) fn pdf_string(s: &str) -> String { @@ -62,6 +64,16 @@ pub(super) fn pdf_string(s: &str) -> String { o } +/// A PDF text string with UTF-16BE hex (the Unicode-capable form, byte-order mark included). +fn pdf_unicode_string(s: &str) -> String { + let mut o = String::from("'); + o +} + fn deflate(data: &[u8]) -> Vec { // Fast: print PDFs are transient and large. Level 3, not 1: zlib-rs level 1 writes static // Huffman blocks only (13-30% bigger on photos); level 3 is the lowest that is never bigger @@ -124,12 +136,14 @@ fn print_pdf_pages(pages: &[PrintPage], vectors: &[String], selection_font: &sel out.extend_from_slice(b"\nendobj\n"); }; let n = pages.len(); + let selections_on = pages.iter().any(|p| p.page_label.is_some()); // 1 catalog, 2 pages, then per page: page, contents, image; then the shared font/ICC/selection. let font_obj = 3 + n * 3; let icc = first.image.icc.as_ref(); let icc_obj = icc.map(|_| font_obj + 1); let selection_base = font_obj + if icc.is_some() { 2 } else { 1 }; - obj(&mut out, b"<< /Type /Catalog /Pages 2 0 R >>"); + let labels_obj = selections_on.then_some(selection_base + 1); + obj(&mut out, format!("<< /Type /Catalog /Pages 2 0 R{} >>", labels_obj.map(|o| format!(" /PageLabels {o} 0 R")).unwrap_or_default()).as_bytes()); let kids = (0..n).map(|i| format!("{} 0 R", 3 + i * 3)).collect::>().join(" "); obj(&mut out, format!("<< /Type /Pages /Kids [{kids}] /Count {n} >>").as_bytes()); for (i, page) in pages.iter().enumerate() { @@ -202,6 +216,15 @@ fn print_pdf_pages(pages: &[PrintPage], vectors: &[String], selection_font: &sel for body in selection_font.objects(selection_base) { obj(&mut out, &body); } + if labels_obj.is_some() { + let nums = pages + .iter() + .enumerate() + .filter_map(|(i, p)| p.page_label.as_deref().filter(|l| !l.is_empty()).map(|l| format!("{i} << /P {} >>", pdf_unicode_string(l)))) + .collect::>() + .join(" "); + obj(&mut out, format!("<< /Nums [{nums}] >>").as_bytes()); + } let xref = out.len(); out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", offsets.len() + 1).as_bytes()); for o in &offsets { @@ -287,7 +310,8 @@ fn collect_vectors(doc: &Document) -> (Vec<(usize, String)>, selection::Selectio /// (pixels ÷ resolution × 72 pt). One page per artboard when the document has artboards, else one /// page for the whole document. Layers and transparency are dropped — the warning says so — and /// the embedded profile travels with the images. -pub fn export(doc: &Document) -> std::result::Result { +pub fn export(doc: &Document, ctl: &photocraft_raster::Interrupt) -> std::result::Result { + ctl.check().map_err(|_| IoError::Cancelled)?; let (w, h) = (usize::try_from(doc.size.width).unwrap_or(0), usize::try_from(doc.size.height).unwrap_or(0)); if w == 0 || h == 0 { return Err(IoError::Unsupported("PDF needs a document with non-zero width and height; nothing was written".into())); @@ -310,6 +334,7 @@ pub fn export(doc: &Document) -> std::result::Result { let surface = photocraft_compose::flatten_to_surface(doc, PixelFormat::new(fmt.mode, fmt.sample, true), None); let mut pages = Vec::with_capacity(artboards.len()); for (_, name, artboard) in &artboards { + ctl.check().map_err(|_| IoError::Cancelled)?; let rect = artboard.rect; let (aw, ah) = (rect.width(), rect.height()); if aw == 0 || ah == 0 { @@ -331,7 +356,8 @@ pub fn export(doc: &Document) -> std::result::Result { rect: (0.0, 0.0, pw, ph), marks: Marks::default(), description: None, - label: Some((*name).to_string()), + label: None, + page_label: Some((*name).to_string()), }); } if pages.is_empty() { @@ -339,6 +365,9 @@ pub fn export(doc: &Document) -> std::result::Result { } let mut warnings = vec!["PDF was saved flattened: layers, masks-as-editable and transparency are not preserved (#3401)".into()]; warnings.push(format!("PDF exported {} artboards as {} pages; vector/text layers on artboards are rasterised", artboards.len(), pages.len())); + if !doc.metadata.text.is_empty() { + warnings.push("free-form text metadata is not supported by PDF; it will be dropped".into()); + } let vectors = vec![String::new(); pages.len()]; return Ok(ExportResult { bytes: print_pdf_pages(&pages, &vectors, &selection::SelectionFont::default()), warnings }); } @@ -366,7 +395,7 @@ pub fn export(doc: &Document) -> std::result::Result { } let image = PrintImage { width: doc.size.width, height: doc.size.height, channels: device, data, icc: icc.clone() }; let (pw, ph) = (f64::from(doc.size.width) * 72.0 / dpi, f64::from(doc.size.height) * 72.0 / dpi); - let page = PrintPage { paper: (pw, ph), image, rect: (0.0, 0.0, pw, ph), marks: Marks::default(), description: None, label: None }; + let page = PrintPage { paper: (pw, ph), image, rect: (0.0, 0.0, pw, ph), marks: Marks::default(), description: None, label: None, page_label: None }; let mut warnings = vec!["PDF was saved flattened: layers, masks-as-editable and transparency are not preserved (#3401)".into()]; if !doc.metadata.text.is_empty() { warnings.push("free-form text metadata is not supported by PDF; it will be dropped".into()); diff --git a/crates/io/src/pdf/tests.rs b/crates/io/src/pdf/tests.rs index 0364fa6e1a..30c38a2698 100644 --- a/crates/io/src/pdf/tests.rs +++ b/crates/io/src/pdf/tests.rs @@ -4,7 +4,7 @@ use super::*; use photocraft_color::{PixelFormat, SampleType}; use photocraft_doc::{Layer, LayerContent, Size}; use photocraft_geom::Rect; -use photocraft_raster::Surface; +use photocraft_raster::{Interrupt, Surface}; fn solid(fmt: PixelFormat, x0: i32, y0: i32, x1: i32, y1: i32, px: &[f32]) -> Surface { let mut s = Surface::new(fmt); @@ -68,7 +68,7 @@ fn check_structure(pdf: &[u8]) { #[test] fn a_flat_rgb_document_saves_as_one_page_pdf() { let d = doc(ColorMode::Rgb); - let out = export(&d).unwrap(); + let out = export(&d, &Interrupt::NONE).unwrap(); let pdf = &out.bytes; check_structure(pdf); assert!(has(pdf, "/MediaBox [0 0 4.00 2.00]"), "page = image size at 72 pt/in"); @@ -85,7 +85,7 @@ fn layers_composite_over_white_before_the_page() { let mut d = doc(ColorMode::Rgb); let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::F32, true); d.layers.push(Layer::new("Half red", LayerContent::Raster(solid(fmt, 1, 0, 3, 2, &[1.0, 0.0, 0.0, 0.5])))); - let out = export(&d).unwrap(); + let out = export(&d, &Interrupt::NONE).unwrap(); let img = stream_after(&out.bytes, "/Subtype /Image"); // Pixels 1-2 of row 0: 0.5 red over white = (1.0, 0.5, 0.5); elsewhere plain white. assert_eq!(&img[3..6], &[255, 128, 128], "the blend lands: {:?}", &img[3..6]); @@ -102,7 +102,7 @@ fn topmost_solid_shape_is_exported_as_a_vector_path() { ..Default::default() }; d.layers.push(Layer::new("Shape", LayerContent::Shape(shape))); - let out = export(&d).unwrap(); + let out = export(&d, &Interrupt::NONE).unwrap(); check_structure(&out.bytes); let content = stream_after(&out.bytes, "/Contents 4 0 R"); let content = String::from_utf8(content).unwrap(); @@ -128,7 +128,7 @@ fn topmost_text_layer_is_exported_with_selectable_text_and_vector_outlines() { let baseline = layout.lines.get(cluster.line).expect("a line").baseline; let origin = text.transform.apply(photocraft_geom::Point::new(f64::from(cluster.x), f64::from(baseline))); d.layers.push(Layer::new("Text", LayerContent::Text(text))); - let out = export(&d).unwrap(); + let out = export(&d, &Interrupt::NONE).unwrap(); check_structure(&out.bytes); let content = stream_after(&out.bytes, "/Contents 4 0 R"); let content = String::from_utf8(content).unwrap(); @@ -159,7 +159,7 @@ fn topmost_shape_and_text_layers_are_both_exported_as_vectors() { d.layers.push(Layer::new("Shape", LayerContent::Shape(shape))); let text = TextLayer { text: "Hi".into(), size_pt: 12.0, color: photocraft_color::Color::rgb(0.0, 1.0, 0.0), ..Default::default() }; d.layers.push(Layer::new("Text", LayerContent::Text(text))); - let out = export(&d).unwrap(); + let out = export(&d, &Interrupt::NONE).unwrap(); check_structure(&out.bytes); let content = stream_after(&out.bytes, "/Contents 4 0 R"); let content = String::from_utf8(content).unwrap(); @@ -173,10 +173,10 @@ fn topmost_shape_and_text_layers_are_both_exported_as_vectors() { #[test] fn greyscale_and_cmyk_pages_keep_their_channels() { - let g = export(&doc(ColorMode::Grayscale)).unwrap(); + let g = export(&doc(ColorMode::Grayscale), &Interrupt::NONE).unwrap(); assert!(has(&g.bytes, "/ColorSpace /DeviceGray"), "gray is DeviceGray"); assert_eq!(stream_after(&g.bytes, "/Subtype /Image"), vec![255; 4 * 2], "one sample per pixel"); - let c = export(&doc(ColorMode::Cmyk)).unwrap(); + let c = export(&doc(ColorMode::Cmyk), &Interrupt::NONE).unwrap(); assert!(has(&c.bytes, "/ColorSpace /DeviceCMYK"), "cmyk is DeviceCMYK"); assert_eq!(stream_after(&c.bytes, "/Subtype /Image"), vec![0; 4 * 2 * 4], "zero ink over the whole page"); } @@ -188,7 +188,7 @@ fn the_page_measures_at_the_document_resolution() { let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::F32, true); d.layers = vec![Layer::new("Paper", LayerContent::Raster(solid(fmt, 0, 0, 300, 150, &[1.0, 1.0, 1.0, 1.0])))]; d.resolution_dpi = 300.0; - let out = export(&d).unwrap(); + let out = export(&d, &Interrupt::NONE).unwrap(); assert!(has(&out.bytes, "/MediaBox [0 0 72.00 36.00]"), "300 px at 300 dpi is one inch"); } @@ -196,7 +196,7 @@ fn the_page_measures_at_the_document_resolution() { fn an_embedded_profile_travels_as_iccbased() { let mut d = doc(ColorMode::Rgb); d.icc_profile = Some(std::sync::Arc::new(vec![1, 2, 3, 4, 5, 6, 7, 8])); - let out = export(&d).unwrap(); + let out = export(&d, &Interrupt::NONE).unwrap(); assert!(has(&out.bytes, "[/ICCBased 7 0 R]"), "the image refers to the profile"); assert_eq!(stream_after(&out.bytes, "/N 3 /Alternate"), vec![1, 2, 3, 4, 5, 6, 7, 8], "the profile bytes survive"); } @@ -204,7 +204,7 @@ fn an_embedded_profile_travels_as_iccbased() { #[test] fn other_modes_are_refused_with_an_actionable_error() { let d = doc(ColorMode::Lab); - let e = export(&d).unwrap_err().to_string(); + let e = export(&d, &Interrupt::NONE).unwrap_err().to_string(); assert!(e.contains("RGB") && e.contains("Mode"), "{e}"); assert!(crate::export(&d, "x.pdf", &crate::ExportOptions::default()).is_err()); } @@ -226,7 +226,7 @@ fn unicode_text_over_a_photo_extracts_through_a_pdf_reader() { ..Default::default() }), )); - let pdf = export(&d).unwrap().bytes; + let pdf = export(&d, &Interrupt::NONE).unwrap().bytes; let parsed = lopdf::Document::load_mem(&pdf).unwrap(); assert_eq!(parsed.extract_text(&[1]).unwrap().trim(), text); assert!(has(&pdf, "/Subtype /Image")); @@ -266,7 +266,7 @@ fn text_with_compositing_or_a_profile_stays_in_the_raster_backdrop() { _ => d.icc_profile = Some(std::sync::Arc::new(vec![1, 2, 3, 4])), } d.layers.push(layer); - let pdf = export(&d).unwrap().bytes; + let pdf = export(&d, &Interrupt::NONE).unwrap().bytes; let content = String::from_utf8(stream_after(&pdf, "/Contents 4 0 R")).unwrap(); assert!(!content.contains("/ActualText"), "case {case} must retain the raster composite"); assert!(stream_after(&pdf, "/Subtype /Image").iter().any(|v| *v != 255), "case {case} must retain visible text"); @@ -304,7 +304,7 @@ fn text_outside_the_vector_prefix_does_not_consume_the_selection_font() { "Top text", LayerContent::Text(TextLayer { text: "Hi".into(), size_pt: 12.0, color: photocraft_color::Color::rgb(0.0, 1.0, 0.0), ..Default::default() }), )); - let pdf = export(&d).unwrap().bytes; + let pdf = export(&d, &Interrupt::NONE).unwrap().bytes; let parsed = lopdf::Document::load_mem(&pdf).unwrap(); assert_eq!(parsed.extract_text(&[1]).unwrap().trim(), "Hi", "case {case}: only the top text is selectable"); let content = String::from_utf8(stream_after(&pdf, "/Contents 4 0 R")).unwrap(); @@ -355,7 +355,7 @@ fn a_text_layer_refused_at_emission_keeps_the_upper_vectors_and_stays_raster() { ..Default::default() }), )); - let pdf = export(&d).unwrap().bytes; + let pdf = export(&d, &Interrupt::NONE).unwrap().bytes; check_structure(&pdf); let content = String::from_utf8(stream_after(&pdf, "/Contents 4 0 R")).unwrap(); assert!(content.contains("/ActualText "), "the first span retains the leading newline:\n{content}"); } @@ -405,7 +405,7 @@ fn artboards_export_as_one_page_each() { let group = Group { children: vec![layer], expanded: true, artboard: Some(Artboard::new(rect)), frame_stroke: None }; d.layers.push(Layer::new("Page", LayerContent::Group(group))); } - let out = export(&d).unwrap(); + let out = export(&d, &Interrupt::NONE).unwrap(); check_structure(&out.bytes); let parsed = lopdf::Document::load_mem(&out.bytes).unwrap(); let pages = parsed.get_pages(); diff --git a/crates/io/src/pdf_import.rs b/crates/io/src/pdf_import.rs index 7bd5f76df9..58ec5fa70b 100644 --- a/crates/io/src/pdf_import.rs +++ b/crates/io/src/pdf_import.rs @@ -1,12 +1,14 @@ //! PDF import: PDFium renders every page and each page becomes an artboard. //! -//! The document resolution is the import DPI, so pixels ÷ DPI × 72 pt is the PDF page's size -//! again. Pages are laid out side by side with a small gap; more than [`MAX_PAGES`] pages are -//! capped so the union canvas stays bounded. +//! The document resolution is chosen so the page's physical size is preserved even when the +//! raster is capped: pixels ÷ resolution × 72 pt is the PDF page's size. Pages are laid out side +//! by side; page count, per-page pixels, union pixels and the aggregate pixel budget are bounded. use crate::{ImportOptions, ImportResult, IoError}; +use photocraft_color::{ColorMode, PixelFormat, SampleType}; use photocraft_doc::{Artboard, Document, Group, Layer, LayerContent}; -use photocraft_geom::Rect; +use photocraft_geom::{Rect, Size}; +use photocraft_raster::{Interrupt, Surface}; /// `%PDF-` magic. `.ai` files are PDFs too, but the dispatch keeps them out of this importer. pub fn is_pdf(bytes: &[u8]) -> bool { @@ -15,22 +17,24 @@ pub fn is_pdf(bytes: &[u8]) -> bool { /// Native PDF import is not available in the web build. #[cfg(target_arch = "wasm32")] -pub fn import(_name: &str, _bytes: &[u8], _opts: &ImportOptions) -> Result { +pub fn import(_name: &str, _bytes: &[u8], _opts: &ImportOptions, _ctl: &Interrupt) -> Result { Err(IoError::Unsupported("PDF import needs the desktop build".into())) } -/// Renders every PDF page at [`ImportOptions::pdf_dpi`] and opens it as one artboard per page. +/// Renders every PDF page and opens it as one artboard per page. #[cfg(not(target_arch = "wasm32"))] -pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions) -> Result { - use image::ImageEncoder; +pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) -> Result { use pdfium_render::prelude::*; - /// The most pages one import renders; more would make the union canvas enormous. + /// The most pages one import renders. const MAX_PAGES: usize = 16; /// Gap between the artboards, in document pixels. const GAP: u32 = 80; + /// Per-page and per-artboard-strip pixel budgets; a poster at 300 dpi is far below these. + const MAX_PAGE_PIXELS: f64 = 100_000_000.0; + const MAX_UNION_PIXELS: u64 = 200_000_000; - let dpi = if opts.pdf_dpi.is_finite() && opts.pdf_dpi > 0.0 { opts.pdf_dpi } else { 300.0 }; + let requested = if opts.pdf_dpi.is_finite() && opts.pdf_dpi > 0.0 { f64::from(opts.pdf_dpi) } else { 300.0 }; let pdfium = bind()?; let document = pdfium.load_pdf_from_byte_slice(bytes, None).map_err(|e| IoError::Unsupported(format!("PDF: {e}")))?; let total = document.pages().len().max(0) as usize; @@ -38,87 +42,101 @@ pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions) -> Result MAX_PAGE_PIXELS { - let scale = (MAX_PAGE_PIXELS / area).sqrt(); - target_width = (target_width * scale).max(1.0); - } - let config = PdfRenderConfig::new().set_target_width(target_width as i32); - let bitmap = page.render_with_config(&config).map_err(|e| IoError::Unsupported(format!("PDF render: {e}")))?; - let (width, height) = (bitmap.width().max(0) as u32, bitmap.height().max(0) as u32); - // Stop before the strip exceeds the union budget; the pages rendered so far stay. - let next_width = union_width + u64::from(width) + if rendered.is_empty() { 0 } else { u64::from(GAP) }; - let next_height = union_height.max(u64::from(height)); - if next_width.saturating_mul(next_height) > MAX_UNION_PIXELS { - break; - } - union_width = next_width; - union_height = next_height; - rendered.push((width, height, bitmap.as_rgba_bytes())); + sizes.push((w, h)); } - let count = rendered.len(); - if count == 0 { - return Err(IoError::Unsupported("PDF pages are too large to import; reduce the DPI".into())); + let dpi = effective_dpi(requested, &sizes, GAP, MAX_PAGE_PIXELS, MAX_UNION_PIXELS); + let doc_width = sizes.iter().map(|(w, _)| (w * dpi / 72.0).round() as u64).sum::() + u64::from(GAP) * (count as u64 - 1); + let doc_height = sizes.iter().map(|(_, h)| (h * dpi / 72.0).round() as u64).max().unwrap_or(1); + if doc_width == 0 || doc_height == 0 { + return Err(IoError::Unsupported("PDF pages have no area".into())); } - let doc_width = rendered.iter().map(|(w, _, _)| *w).sum::() + GAP * (count as u32 - 1); - let doc_height = rendered.iter().map(|(_, h, _)| *h).max().unwrap_or(1); - let mut doc: Option = None; + // The aggregate budget: the rendered pages and the document's raster tiles both hold pixels. + let page_pixels = sizes.iter().map(|(w, h)| (w * dpi / 72.0).round().max(1.0) * (h * dpi / 72.0).round().max(1.0)).sum::(); + let estimate = (page_pixels * 4.0 * 2.0) as u64; + if opts.flat_max_pixel_bytes > 0 && estimate > opts.flat_max_pixel_bytes { + return Err(IoError::Unsupported(format!( + "PDF import needs about {} MB of pixel memory; the limit is {} MB (lower the import DPI)", + estimate >> 20, + opts.flat_max_pixel_bytes >> 20 + ))); + } + let mut doc = Document::new(name, Size::new(doc_width as u32, doc_height as u32), ColorMode::Rgb, SampleType::U8); + doc.resolution_dpi = dpi as f32; let mut x = 0u32; - for (index, (width, height, rgba)) in rendered.into_iter().enumerate() { - // Reuse the well-tested flat importer: the page is composited into the union canvas at its - // artboard position, encoded as PNG, and decoded into a document-wide raster layer. - let mut canvas = image::RgbaImage::new(doc_width, doc_height); - let page = - image::RgbaImage::from_raw(width, height, rgba).ok_or_else(|| IoError::Unsupported("PDF: the rendered bitmap does not match its buffer".into()))?; - image::imageops::overlay(&mut canvas, &page, i64::from(x), 0); - let mut png = Vec::new(); - image::codecs::png::PngEncoder::new(&mut png) - .write_image(canvas.as_raw(), doc_width, doc_height, image::ExtendedColorType::Rgba8) - .map_err(|e| IoError::Unsupported(format!("PDF: encoding the rendered page: {e}")))?; - let mut result = crate::flat::import_flat(name, &png)?; - let layer = result.document.layers.pop().ok_or_else(|| IoError::Unsupported("PDF: the page decoded to an empty layer".into()))?; + for (index, (w_pt, h_pt)) in sizes.iter().enumerate() { + ctl.check().map_err(|_| IoError::Cancelled)?; + let target_width = (w_pt * dpi / 72.0).round().max(1.0) as i32; + let target_height = (h_pt * dpi / 72.0).round().max(1.0) as i32; + // A one-pixel-wide page would otherwise produce an arbitrarily tall bitmap before the + // union check runs; reject the final dimensions up front. + if f64::from(target_width) * f64::from(target_height) > MAX_PAGE_PIXELS { + return Err(IoError::Unsupported(format!( + "PDF page {} is too large at {dpi} dpi ({} x {} px); lower the import DPI", + index + 1, + target_width, + target_height + ))); + } + let page = document.pages().get(index as i32).map_err(|e| IoError::Unsupported(format!("PDF: {e}")))?; + let bitmap = + page.render_with_config(&PdfRenderConfig::new().set_target_width(target_width)).map_err(|e| IoError::Unsupported(format!("PDF render: {e}")))?; + let (width, height) = (bitmap.width().max(0) as u32, bitmap.height().max(0) as u32); + // The page's pixels go straight into the document-wide layer surface: no full-strip + // canvas and no PNG round-trip, so the peak is the page bitmap plus its tiles. + let mut surface = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::U8, true)); + let rgba = bitmap.as_rgba_bytes(); + let mut data = Vec::with_capacity(rgba.len()); + for chunk in rgba.as_chunks::<4>().0 { + for &v in &chunk[..3] { + data.push(f32::from(v) / 255.0); + } + data.push(f32::from(chunk[3]) / 255.0); + } + surface.write_region(Rect::from_xywh(x as i32, 0, width, height), &data); + let layer = Layer::new(format!("Page {}", index + 1), LayerContent::Raster(surface)); let rect = Rect::new(x as i32, 0, (x + width) as i32, height as i32); let group = Group { children: vec![layer], expanded: true, artboard: Some(Artboard::new(rect)), frame_stroke: None }; - let artboard = Layer::new(format!("Page {}", index + 1), LayerContent::Group(group)); - match &mut doc { - None => { - result.document.layers = vec![artboard]; - doc = Some(result.document); - } - Some(d) => d.layers.push(artboard), - } + doc.layers.push(Layer::new(format!("Page {}", index + 1), LayerContent::Group(group))); x += width + GAP; + ctl.progress((index + 1) as f32 / count as f32); } - let mut doc = doc.ok_or_else(|| IoError::Unsupported("PDF has no pages".into()))?; - doc.resolution_dpi = dpi; - doc.name = name.to_string(); let mut warnings = Vec::new(); if total > count { - let reason = if total > MAX_PAGES { "the page limit is 16" } else { "the rest would make the import canvas too large" }; + let reason = if count < total.min(MAX_PAGES) { "the rest would make the import canvas too large" } else { "the page limit is 16" }; warnings.push(format!("PDF has {total} pages; imported the first {count} ({reason})")); } Ok(ImportResult { warnings, document: doc, source_read_only: false, preview_only: false }) } +/// The highest DPI not above `requested` that keeps every page and the artboard strip within +/// their pixel budgets. Rendering at that DPI keeps each page's physical size unchanged. +#[cfg(not(target_arch = "wasm32"))] +fn effective_dpi(requested: f64, sizes: &[(f64, f64)], gap: u32, max_page: f64, max_union: u64) -> f64 { + let mut dpi = requested; + for (w, h) in sizes { + dpi = dpi.min(72.0 * (max_page / (w * h)).sqrt()); + } + let n = sizes.len() as f64; + let sum_w: f64 = sizes.iter().map(|(w, _)| w).sum(); + let max_h = sizes.iter().map(|(_, h)| h).fold(0.0_f64, |a, &h| a.max(h)); + // (sum_w * k + gaps) * (max_h * k) <= max_union, where k = dpi / 72: solve a k² + b k - c = 0. + let gaps = f64::from(gap) * (n - 1.0); + let a = sum_w * max_h; + let b = gaps * max_h; + let k = if a > 0.0 { (-b + (b * b + 4.0 * a * max_union as f64).sqrt()) / (2.0 * a) } else { f64::INFINITY }; + dpi.min(72.0 * k).max(1.0) +} + /// Loads the PDFium shared library from the executable's folder (`PDFIUM_DIR` overrides it). #[cfg(not(target_arch = "wasm32"))] fn bind() -> Result { @@ -128,9 +146,10 @@ fn bind() -> Result { .map(std::path::PathBuf::from) .unwrap_or_else(|| std::env::current_exe().ok().and_then(|exe| exe.parent().map(std::path::Path::to_path_buf)).unwrap_or_default()); let library = Pdfium::pdfium_platform_library_name_at_path(&dir); + let platform = Pdfium::pdfium_platform_library_name().to_string_lossy().into_owned(); let bindings = Pdfium::bind_to_library(&library).map_err(|e| { IoError::Unsupported(format!( - "PDF import: cannot load PDFium from {} ({e}); put pdfium.dll next to the executable or set PDFIUM_DIR", + "PDF import: cannot load PDFium from {} ({e}); put {platform} next to the executable or set PDFIUM_DIR", library.display() )) })?; From b1870f1950d5d4b1a9f69fbe4c04a48d9ae61c8f Mon Sep 17 00:00:00 2001 From: Triwarr <170976175+Triwarr@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:15:57 +0100 Subject: [PATCH 065/219] PDF: second CodeRabbit round (render size, buffer estimate, label tree) --- crates/io/src/lib.rs | 9 --------- crates/io/src/pdf.rs | 7 ++++--- crates/io/src/pdf_import.rs | 12 +++++++++++- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 1f69b40ea4..285e0d8602 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -979,25 +979,16 @@ pub(crate) fn route(name: &str, bytes: &[u8]) -> Route { if is_psd_input(name, bytes) { return Route::NamedPsd; } -<<<<<<< HEAD Route::Flat } /// Dispatches format-specific importers and propagates a structured PSD damage /// flag alongside the ordinary best-effort import result. fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, opts: &ImportOptions, damaged: &mut bool) -> Result { - // PDF (PDFium, native only). `.ai` files are PDFs but keep their existing path; a recognized - // raster signature wins over a misleading `.pdf` suffix. - if pdf_import::is_pdf(bytes) - || (has_extension(name, "pdf") && !has_extension(name, "ai") && photocraft_codecs::detect(bytes).is_none()) - { - return pdf_import::import(name, bytes, opts); -======= // PDF (PDFium, native only). `.ai` files are PDFs but keep their existing path; a recognized // raster signature wins over a misleading `.pdf` suffix. if pdf_import::is_pdf(bytes) || (has_extension(name, "pdf") && !has_extension(name, "ai") && photocraft_codecs::detect(bytes).is_none()) { return pdf_import::import(name, bytes, opts, ctl); ->>>>>>> 806c53fd2 (PDF: address CodeRabbit review (cancellation, budgets, labels, export edges)) } match route(name, bytes) { Route::Pcraft => { diff --git a/crates/io/src/pdf.rs b/crates/io/src/pdf.rs index 4317784e50..35c0fdecf5 100644 --- a/crates/io/src/pdf.rs +++ b/crates/io/src/pdf.rs @@ -142,7 +142,8 @@ fn print_pdf_pages(pages: &[PrintPage], vectors: &[String], selection_font: &sel let icc = first.image.icc.as_ref(); let icc_obj = icc.map(|_| font_obj + 1); let selection_base = font_obj + if icc.is_some() { 2 } else { 1 }; - let labels_obj = selections_on.then_some(selection_base + 1); + let selection_objects = selection_font.objects(selection_base); + let labels_obj = selections_on.then_some((selection_base + selection_objects.len()) as u32); obj(&mut out, format!("<< /Type /Catalog /Pages 2 0 R{} >>", labels_obj.map(|o| format!(" /PageLabels {o} 0 R")).unwrap_or_default()).as_bytes()); let kids = (0..n).map(|i| format!("{} 0 R", 3 + i * 3)).collect::>().join(" "); obj(&mut out, format!("<< /Type /Pages /Kids [{kids}] /Count {n} >>").as_bytes()); @@ -213,8 +214,8 @@ fn print_pdf_pages(pages: &[PrintPage], vectors: &[String], selection_font: &sel body.extend_from_slice(b"\nendstream"); obj(&mut out, &body); } - for body in selection_font.objects(selection_base) { - obj(&mut out, &body); + for body in &selection_objects { + obj(&mut out, body); } if labels_obj.is_some() { let nums = pages diff --git a/crates/io/src/pdf_import.rs b/crates/io/src/pdf_import.rs index 58ec5fa70b..ec58153ad4 100644 --- a/crates/io/src/pdf_import.rs +++ b/crates/io/src/pdf_import.rs @@ -62,7 +62,8 @@ pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) - } // The aggregate budget: the rendered pages and the document's raster tiles both hold pixels. let page_pixels = sizes.iter().map(|(w, h)| (w * dpi / 72.0).round().max(1.0) * (h * dpi / 72.0).round().max(1.0)).sum::(); - let estimate = (page_pixels * 4.0 * 2.0) as u64; + // The RGBA bitmap, the f32 conversion buffer (16 bytes/px) and the document tiles. + let estimate = (page_pixels * 24.0) as u64; if opts.flat_max_pixel_bytes > 0 && estimate > opts.flat_max_pixel_bytes { return Err(IoError::Unsupported(format!( "PDF import needs about {} MB of pixel memory; the limit is {} MB (lower the import DPI)", @@ -91,6 +92,15 @@ pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) - let bitmap = page.render_with_config(&PdfRenderConfig::new().set_target_width(target_width)).map_err(|e| IoError::Unsupported(format!("PDF render: {e}")))?; let (width, height) = (bitmap.width().max(0) as u32, bitmap.height().max(0) as u32); + // The rounded target width can render a taller bitmap than the pre-check predicted. + if f64::from(width) * f64::from(height) > MAX_PAGE_PIXELS { + return Err(IoError::Unsupported(format!( + "PDF page {} rendered larger than the {MAX_PAGE_PIXELS} px budget ({} x {}); lower the import DPI", + index + 1, + width, + height + ))); + } // The page's pixels go straight into the document-wide layer surface: no full-strip // canvas and no PNG round-trip, so the peak is the page bitmap plus its tiles. let mut surface = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::U8, true)); From ef7699ae9a6074ab51fb32b085289c87907cda01 Mon Sep 17 00:00:00 2001 From: Triwarr <170976175+Triwarr@users.noreply.github.com> Date: Fri, 6 Nov 2026 18:46:00 +0100 Subject: [PATCH 066/219] PDF: render each artboard rect instead of a whole-document crop --- crates/io/src/pdf.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/io/src/pdf.rs b/crates/io/src/pdf.rs index 35c0fdecf5..a6aa68cd33 100644 --- a/crates/io/src/pdf.rs +++ b/crates/io/src/pdf.rs @@ -331,8 +331,6 @@ pub fn export(doc: &Document, ctl: &photocraft_raster::Interrupt) -> std::result let icc = doc.icc_profile.as_ref().map(|p| p.to_vec()); let artboards = doc.artboards(); if !artboards.is_empty() { - // One flattened pass of the whole document; each artboard is a crop of it. - let surface = photocraft_compose::flatten_to_surface(doc, PixelFormat::new(fmt.mode, fmt.sample, true), None); let mut pages = Vec::with_capacity(artboards.len()); for (_, name, artboard) in &artboards { ctl.check().map_err(|_| IoError::Cancelled)?; @@ -341,6 +339,8 @@ pub fn export(doc: &Document, ctl: &photocraft_raster::Interrupt) -> std::result if aw == 0 || ah == 0 { continue; } + // Render only this artboard's rect; the surface never holds the whole document. + let surface = photocraft_compose::composite_to_surface(doc, rect, PixelFormat::new(fmt.mode, fmt.sample, true), None); let values = surface.read_region(rect); let mut data = Vec::with_capacity((aw as usize * ah as usize).saturating_mul(device)); for px in values.chunks(n) { From 06a6442076055b094824a0c4d1868eb37febcc96 Mon Sep 17 00:00:00 2001 From: Triwarr <170976175+Triwarr@users.noreply.github.com> Date: Fri, 6 Nov 2026 18:57:00 +0100 Subject: [PATCH 067/219] PDF: SVG precedence, hidden artboards, export budget, writer cancellation --- crates/io/src/lib.rs | 5 +++++ crates/io/src/pdf.rs | 46 +++++++++++++++++++++++++++++++++++++------- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 285e0d8602..ebceff3642 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -985,6 +985,11 @@ pub(crate) fn route(name: &str, bytes: &[u8]) -> Route { /// Dispatches format-specific importers and propagates a structured PSD damage /// flag alongside the ordinary best-effort import result. fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, opts: &ImportOptions, damaged: &mut bool) -> Result { + // SVG content wins over a misleading `.pdf` suffix, matching the preflight (which checks SVG + // before PDF). + if is_svg_input(name, bytes) { + return svg::import_svg_with_group_depth(name, bytes, opts.max_group_depth); + } // PDF (PDFium, native only). `.ai` files are PDFs but keep their existing path; a recognized // raster signature wins over a misleading `.pdf` suffix. if pdf_import::is_pdf(bytes) || (has_extension(name, "pdf") && !has_extension(name, "ai") && photocraft_codecs::detect(bytes).is_none()) { diff --git a/crates/io/src/pdf.rs b/crates/io/src/pdf.rs index a6aa68cd33..7bf228320f 100644 --- a/crates/io/src/pdf.rs +++ b/crates/io/src/pdf.rs @@ -121,12 +121,20 @@ fn marks_ops(m: Marks, (x, y, w, h): (f64, f64, f64, f64)) -> String { /// A one-page PDF. pub fn print_pdf(page: &PrintPage) -> Vec { - print_pdf_pages(std::slice::from_ref(page), &[String::new()], &selection::SelectionFont::default()) + print_pdf_pages(std::slice::from_ref(page), &[String::new()], &selection::SelectionFont::default(), &photocraft_raster::Interrupt::NONE).unwrap_or_default() } /// A PDF with one page per entry; `vectors[i]` (when present) is drawn over page `i`. -fn print_pdf_pages(pages: &[PrintPage], vectors: &[String], selection_font: &selection::SelectionFont) -> Vec { - let Some(first) = pages.first() else { return Vec::new() }; +fn print_pdf_pages( + pages: &[PrintPage], + vectors: &[String], + selection_font: &selection::SelectionFont, + ctl: &photocraft_raster::Interrupt, +) -> std::result::Result, IoError> { + if ctl.check().is_err() { + return Err(IoError::Cancelled); + } + let Some(first) = pages.first() else { return Ok(Vec::new()) }; let mut out: Vec = b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n".to_vec(); let mut offsets: Vec = Vec::new(); let mut obj = |out: &mut Vec, body: &[u8]| { @@ -232,7 +240,7 @@ fn print_pdf_pages(pages: &[PrintPage], vectors: &[String], selection_font: &sel out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); } out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", offsets.len() + 1).as_bytes()); - out + Ok(out) } /// The vector operators for the contiguous visible top prefix of `doc`, top to bottom, with the @@ -331,14 +339,38 @@ pub fn export(doc: &Document, ctl: &photocraft_raster::Interrupt) -> std::result let icc = doc.icc_profile.as_ref().map(|p| p.to_vec()); let artboards = doc.artboards(); if !artboards.is_empty() { + // Every page's encoded pixels are kept until the PDF is assembled; bound that peak. + const MAX_PDF_EXPORT_BYTES: u64 = 1 << 30; + let pixels: u64 = artboards.iter().map(|(_, _, a)| u64::from(a.rect.width()) * u64::from(a.rect.height())).sum(); + let estimate = pixels.saturating_mul(device as u64) + w as u64 * h as u64 * n as u64 * 4; + if estimate > MAX_PDF_EXPORT_BYTES { + return Err(IoError::Unsupported(format!( + "PDF export would need about {} MB for {} artboards; export them in smaller batches", + estimate >> 20, + artboards.len() + ))); + } let mut pages = Vec::with_capacity(artboards.len()); - for (_, name, artboard) in &artboards { + for (id, name, artboard) in &artboards { ctl.check().map_err(|_| IoError::Cancelled)?; let rect = artboard.rect; let (aw, ah) = (rect.width(), rect.height()); if aw == 0 || ah == 0 { continue; } + // A hidden artboard still gets its page, rendered from its own content rather than + // left blank by the group's visibility. + let shown; + let doc = if doc.layer(*id).is_some_and(|l| !l.visible) { + let mut copy = doc.clone(); + if let Some(layer) = copy.layer_mut(*id) { + layer.visible = true; + } + shown = copy; + &shown + } else { + doc + }; // Render only this artboard's rect; the surface never holds the whole document. let surface = photocraft_compose::composite_to_surface(doc, rect, PixelFormat::new(fmt.mode, fmt.sample, true), None); let values = surface.read_region(rect); @@ -370,7 +402,7 @@ pub fn export(doc: &Document, ctl: &photocraft_raster::Interrupt) -> std::result warnings.push("free-form text metadata is not supported by PDF; it will be dropped".into()); } let vectors = vec![String::new(); pages.len()]; - return Ok(ExportResult { bytes: print_pdf_pages(&pages, &vectors, &selection::SelectionFont::default()), warnings }); + return Ok(ExportResult { bytes: print_pdf_pages(&pages, &vectors, &selection::SelectionFont::default(), ctl)?, warnings }); } let canvas = photocraft_geom::Rect::new(0, 0, w as i32, h as i32); // Simple topmost shapes and text layers can be drawn over the flattened backdrop without changing @@ -407,7 +439,7 @@ pub fn export(doc: &Document, ctl: &photocraft_raster::Interrupt) -> std::result vectors.push_str(ops); } vectors.push_str("Q\n"); - Ok(ExportResult { bytes: print_pdf_pages(&[page], &[vectors], &selection_font), warnings }) + Ok(ExportResult { bytes: print_pdf_pages(&[page], &[vectors], &selection_font, ctl)?, warnings }) } #[cfg(test)] From 244a6287ab8f504ad52089f2027385b78321f6c3 Mon Sep 17 00:00:00 2001 From: Triwarr <170976175+Triwarr@users.noreply.github.com> Date: Fri, 6 Nov 2026 19:14:21 +0100 Subject: [PATCH 068/219] PDF: import options (page range, DPI, editable text/vector) and DLL packaging - ImportOptions gains pdf_pages (1-based inclusive), pdf_text and pdf_vectors; the importer maps PDF text objects to editable text layers and vector paths to shape layers through the effective DPI. - photocraft-cli convert gains --pdf-dpi and --pdf-pages. - Preferences gain pdf_dpi/pdf_pages, wired through jobs::import_options. - apps/photocraft/build.rs copies PDFIUM_DLL beside the exe; the MSI installs it. --- apps/photocraft-cli/src/args.rs | 6 ++ apps/photocraft-cli/src/document.rs | 19 +++- apps/photocraft/build.rs | 13 +++ crates/engine/src/jobs.rs | 2 + crates/engine/src/prefs.rs | 6 ++ crates/io/src/lib.rs | 9 ++ crates/io/src/pdf_import.rs | 145 ++++++++++++++++++++++++---- packaging/windows/photocraft.wxs | 2 + 8 files changed, 183 insertions(+), 19 deletions(-) diff --git a/apps/photocraft-cli/src/args.rs b/apps/photocraft-cli/src/args.rs index 3f29309ad5..e806085418 100644 --- a/apps/photocraft-cli/src/args.rs +++ b/apps/photocraft-cli/src/args.rs @@ -107,6 +107,12 @@ pub(crate) struct ConvertArgs { /// Explicitly approve high-memory PSD/PSB imports within the hard memory budget #[arg(long)] pub(crate) approve_high_memory: bool, + /// PDF import resolution in DPI (default 300) + #[arg(long)] + pub(crate) pdf_dpi: Option, + /// PDF pages to import, 1-based: "3" or "2-7" (default all) + #[arg(long)] + pub(crate) pdf_pages: Option, #[command(flatten)] pub(crate) gpu: GpuArgs, #[command(flatten)] diff --git a/apps/photocraft-cli/src/document.rs b/apps/photocraft-cli/src/document.rs index 95e8d4d8e2..ab9e3cfd35 100644 --- a/apps/photocraft-cli/src/document.rs +++ b/apps/photocraft-cli/src/document.rs @@ -26,11 +26,28 @@ fn refuse_overwriting_input(in_place: bool, input: &Path, output: &Path) -> Resu Ok(()) } +/// `N` or `N-M`, 1-based and inclusive, into a normalized `(first, last)`. +fn parse_pdf_pages(s: &str) -> Result<(u32, u32)> { + let (a, b) = s.split_once('-').unwrap_or((s, s)); + let bad = || CliError::Other(format!("invalid --pdf-pages {s:?}; use N or N-M")); + let a = a.trim().parse::().map_err(|_| bad())?; + let b = b.trim().parse::().map_err(|_| bad())?; + if a == 0 || b == 0 { + return Err(CliError::Other(format!("--pdf-pages is 1-based; {s:?} starts at 0"))); + } + Ok((a.min(b), a.max(b))) +} + pub(crate) fn convert(a: &ConvertArgs, _out: &mut dyn Write, err: &mut dyn Write) -> Result { let (input, output) = (&a.input, &a.output); let opts = ExportOptions::from(&a.export); refuse_overwriting_input(a.in_place, input, output)?; - let import_opts = photocraft_io::ImportOptions { psd_high_memory_approved: a.approve_high_memory, ..Default::default() }; + let import_opts = photocraft_io::ImportOptions { + psd_high_memory_approved: a.approve_high_memory, + pdf_dpi: a.pdf_dpi.unwrap_or(300.0), + pdf_pages: a.pdf_pages.as_deref().map(parse_pdf_pages).transpose()?, + ..Default::default() + }; let mut o = files::open_with_options(input, &import_opts)?; // Preferences › Raw Defaults › Resolution at its default, as `run` and `info` open a raw. photocraft_engine::file_cmds::raw_defaults(&photocraft_engine::Session::new(), &o.warnings, &mut o.document); diff --git a/apps/photocraft/build.rs b/apps/photocraft/build.rs index 48abc9f1a5..e750904018 100644 --- a/apps/photocraft/build.rs +++ b/apps/photocraft/build.rs @@ -11,6 +11,19 @@ fn main() { if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { return; } + // PDF import loads PDFium at runtime. When the build provides the shared library + // (`PDFIUM_DLL`, set by the release packaging step), copy it beside the executable so the + // installer's pdfium.dll and the running app agree. + println!("cargo:rerun-if-env-changed=PDFIUM_DLL"); + if let (Some(dll), Ok(out)) = (std::env::var_os("PDFIUM_DLL"), std::env::var("OUT_DIR")) { + let dll = std::path::PathBuf::from(dll); + if let Some(profile_dir) = std::path::Path::new(&out).ancestors().nth(3) { + let dest = profile_dir.join(dll.file_name().unwrap_or_default()); + if let Err(e) = std::fs::copy(&dll, &dest) { + println!("cargo:warning=could not copy PDFium to {}: {e}", dest.display()); + } + } + } // Printing to PDF (winprint's `PdfCreateRenderer`) imports Windows.Data.Pdf.dll, which // stripped Windows images (NTLite, some N/LTSC builds) lack. A load-time import stops the exe // from starting at all ("windows.data.pdf.dll was not found", #5884), so load it on first use; diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index ba82bd9028..a68606eeba 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -956,6 +956,8 @@ pub fn import_options(prefs: &crate::prefs::Preferences) -> photocraft_io::Impor photocraft_io::ImportOptions { max_group_depth: prefs.file_handling.rasterize_svg_groups_deeper_than as usize, demosaic: prefs.raw_defaults.demosaic.method(), + pdf_dpi: prefs.file_handling.pdf_dpi, + pdf_pages: prefs.file_handling.pdf_pages, ..Default::default() } } diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index ee61af791c..1750310d26 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -493,6 +493,10 @@ pub struct FileHandling { pub recover_on_launch: bool, /// Open a file with no ICC profile as untagged even when its EXIF ColorSpace tag says sRGB. pub ignore_exif_profile_tag: bool, + /// PDF import resolution in DPI; the document keeps it so the page's print size is preserved. + pub pdf_dpi: f32, + /// PDF import page range, 1-based and inclusive; `None` imports every page. + pub pdf_pages: Option<(u32, u32)>, /// Most renderers write ACEScg EXRs without stating the colour space. With this on, an EXR /// that would otherwise open as linear sRGB opens as ACEScg (AP1, linear) instead. pub exr_aces_cg: bool, @@ -530,6 +534,8 @@ impl Default for FileHandling { autosave_minutes: 10, recover_on_launch: true, ignore_exif_profile_tag: false, + pdf_dpi: 300.0, + pdf_pages: None, exr_aces_cg: false, ask_before_saving_layered_tiff: true, maximize_psd_compatibility: Ask::Always, diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index ebceff3642..599f110410 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -610,6 +610,12 @@ pub struct ImportOptions { /// Resolution (dots per inch) PDF pages are rendered at on import; the document keeps it so /// the page's print size is preserved. Defaults to 300. pub pdf_dpi: f32, + /// The 1-based, inclusive page range to import; `None` imports every page (up to the cap). + pub pdf_pages: Option<(u32, u32)>, + /// Import PDF text objects as editable text layers (default on). + pub pdf_text: bool, + /// Import PDF vector paths as editable shape layers (default on). + pub pdf_vectors: bool, /// Explicit one-shot authorization for imports that exceed the soft budget but fit within the hard budget. pub psd_high_memory_approved: bool, /// Optional estimate limit for high-memory approval. When set, approval only applies if @@ -625,6 +631,9 @@ impl Default for ImportOptions { psd_max_pixel_bytes: 0, flat_max_pixel_bytes: 0, pdf_dpi: 300.0, + pdf_pages: None, + pdf_text: true, + pdf_vectors: true, psd_high_memory_approved: false, psd_approved_estimate: None, } diff --git a/crates/io/src/pdf_import.rs b/crates/io/src/pdf_import.rs index ec58153ad4..ab77a78b3e 100644 --- a/crates/io/src/pdf_import.rs +++ b/crates/io/src/pdf_import.rs @@ -1,13 +1,16 @@ -//! PDF import: PDFium renders every page and each page becomes an artboard. +//! PDF import: PDFium renders the selected pages and each page becomes an artboard. //! //! The document resolution is chosen so the page's physical size is preserved even when the //! raster is capped: pixels ÷ resolution × 72 pt is the PDF page's size. Pages are laid out side //! by side; page count, per-page pixels, union pixels and the aggregate pixel budget are bounded. +//! +//! Text and vector page objects can be imported as editable layers (`ImportOptions::pdf_text` / +//! `pdf_vectors`); their page-space points are mapped to document pixels through the effective DPI. use crate::{ImportOptions, ImportResult, IoError}; use photocraft_color::{ColorMode, PixelFormat, SampleType}; -use photocraft_doc::{Artboard, Document, Group, Layer, LayerContent}; -use photocraft_geom::{Rect, Size}; +use photocraft_doc::{Artboard, Color, Document, Fill, Group, Knot, Layer, LayerContent, Path, PathOp, ShapeLayer, ShapeStroke, Subpath, TextLayer}; +use photocraft_geom::{Affine, Point, Rect, Size}; use photocraft_raster::{Interrupt, Surface}; /// `%PDF-` magic. `.ai` files are PDFs too, but the dispatch keeps them out of this importer. @@ -21,7 +24,7 @@ pub fn import(_name: &str, _bytes: &[u8], _opts: &ImportOptions, _ctl: &Interrup Err(IoError::Unsupported("PDF import needs the desktop build".into())) } -/// Renders every PDF page and opens it as one artboard per page. +/// Renders the selected PDF pages and opens each as one artboard. #[cfg(not(target_arch = "wasm32"))] pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) -> Result { use pdfium_render::prelude::*; @@ -38,19 +41,29 @@ pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) - let pdfium = bind()?; let document = pdfium.load_pdf_from_byte_slice(bytes, None).map_err(|e| IoError::Unsupported(format!("PDF: {e}")))?; let total = document.pages().len().max(0) as usize; - let count = total.min(MAX_PAGES); - if count == 0 { - return Err(IoError::Unsupported("PDF has no pages".into())); + let (first, last) = match opts.pdf_pages { + Some((a, b)) => { + let a = (a.max(1) as usize).min(total.max(1)); + let b = (b.max(1) as usize).min(total.max(1)); + (a.min(b), a.max(b)) + } + None => (1, total), + }; + if total == 0 || first > total { + return Err(IoError::Unsupported("PDF has no pages in the requested range".into())); } + let page_count = last - first + 1; + let count = page_count.min(MAX_PAGES); // The page sizes in points first: the effective DPI must fit the budgets, and capping the // raster must not change the page's physical size. let mut sizes = Vec::with_capacity(count); for index in 0..count { ctl.check().map_err(|_| IoError::Cancelled)?; - let page = document.pages().get(index as i32).map_err(|e| IoError::Unsupported(format!("PDF: {e}")))?; + let page_index = first - 1 + index; + let page = document.pages().get(page_index as i32).map_err(|e| IoError::Unsupported(format!("PDF: {e}")))?; let (w, h) = (f64::from(page.width().value), f64::from(page.height().value)); if w <= 0.0 || h <= 0.0 { - return Err(IoError::Unsupported(format!("PDF page {} has no area", index + 1))); + return Err(IoError::Unsupported(format!("PDF page {} has no area", page_index + 1))); } sizes.push((w, h)); } @@ -76,6 +89,7 @@ pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) - let mut x = 0u32; for (index, (w_pt, h_pt)) in sizes.iter().enumerate() { ctl.check().map_err(|_| IoError::Cancelled)?; + let page_index = first - 1 + index; let target_width = (w_pt * dpi / 72.0).round().max(1.0) as i32; let target_height = (h_pt * dpi / 72.0).round().max(1.0) as i32; // A one-pixel-wide page would otherwise produce an arbitrarily tall bitmap before the @@ -83,12 +97,12 @@ pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) - if f64::from(target_width) * f64::from(target_height) > MAX_PAGE_PIXELS { return Err(IoError::Unsupported(format!( "PDF page {} is too large at {dpi} dpi ({} x {} px); lower the import DPI", - index + 1, + page_index + 1, target_width, target_height ))); } - let page = document.pages().get(index as i32).map_err(|e| IoError::Unsupported(format!("PDF: {e}")))?; + let page = document.pages().get(page_index as i32).map_err(|e| IoError::Unsupported(format!("PDF: {e}")))?; let bitmap = page.render_with_config(&PdfRenderConfig::new().set_target_width(target_width)).map_err(|e| IoError::Unsupported(format!("PDF render: {e}")))?; let (width, height) = (bitmap.width().max(0) as u32, bitmap.height().max(0) as u32); @@ -96,7 +110,7 @@ pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) - if f64::from(width) * f64::from(height) > MAX_PAGE_PIXELS { return Err(IoError::Unsupported(format!( "PDF page {} rendered larger than the {MAX_PAGE_PIXELS} px budget ({} x {}); lower the import DPI", - index + 1, + page_index + 1, width, height ))); @@ -113,21 +127,116 @@ pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) - data.push(f32::from(chunk[3]) / 255.0); } surface.write_region(Rect::from_xywh(x as i32, 0, width, height), &data); - let layer = Layer::new(format!("Page {}", index + 1), LayerContent::Raster(surface)); + let layer = Layer::new(format!("Page {}", page_index + 1), LayerContent::Raster(surface)); let rect = Rect::new(x as i32, 0, (x + width) as i32, height as i32); - let group = Group { children: vec![layer], expanded: true, artboard: Some(Artboard::new(rect)), frame_stroke: None }; - doc.layers.push(Layer::new(format!("Page {}", index + 1), LayerContent::Group(group))); + let mut children = vec![layer]; + if opts.pdf_text || opts.pdf_vectors { + children.extend(object_layers(&page, *h_pt, dpi, x, opts)); + } + let group = Group { children, expanded: true, artboard: Some(Artboard::new(rect)), frame_stroke: None }; + doc.layers.push(Layer::new(format!("Page {}", page_index + 1), LayerContent::Group(group))); x += width + GAP; ctl.progress((index + 1) as f32 / count as f32); } let mut warnings = Vec::new(); - if total > count { - let reason = if count < total.min(MAX_PAGES) { "the rest would make the import canvas too large" } else { "the page limit is 16" }; - warnings.push(format!("PDF has {total} pages; imported the first {count} ({reason})")); + if first > 1 || last < total || count < page_count { + let shown_last = first + count - 1; + let reason = if count < page_count { " (the page limit is 16)" } else { "" }; + warnings.push(format!("PDF has {total} pages; imported pages {first}-{shown_last}{reason}")); } Ok(ImportResult { warnings, document: doc, source_read_only: false, preview_only: false }) } +/// Editable text and shape layers for one rendered page, in document pixels. +#[cfg(not(target_arch = "wasm32"))] +fn object_layers(page: &pdfium_render::prelude::PdfPage, height_pt: f64, dpi: f64, x: u32, opts: &ImportOptions) -> Vec { + use pdfium_render::prelude::*; + + let to_x = |v: f32| f64::from(v) * dpi / 72.0; + let objects = page.objects(); + let mut layers = Vec::new(); + for object in objects.iter() { + match object { + PdfPageObject::Text(text) if opts.pdf_text => { + let content = text.text(); + if content.trim().is_empty() { + continue; + } + let Ok(bounds) = text.bounds() else { continue }; + let x_px = to_x(bounds.left().value).round() + f64::from(x); + let y_px = (height_pt - to_x(bounds.top().value)).round(); + // The text layer's size is in points; the transform is in document pixels. + let size_pt = f64::from(text.scaled_font_size().value).max(1.0); + layers.push(Layer::new( + "Text", + LayerContent::Text(TextLayer { + text: content, + font_family: text.font().family(), + size_pt: size_pt as f32, + color: Color::BLACK, + transform: Affine::translate(x_px, y_px), + ..Default::default() + }), + )); + } + PdfPageObject::Path(path) if opts.pdf_vectors => { + let mut subpaths: Vec = Vec::new(); + let mut knots: Vec = Vec::new(); + let mut closed = false; + let mut bezier: Vec<(f64, f64)> = Vec::new(); + for segment in path.segments().iter() { + let point = || (to_x(segment.x().value).round(), (height_pt - to_x(segment.y().value)).round()); + if segment.is_close() { + closed = true; + continue; + } + match segment.segment_type() { + PdfPathSegmentType::MoveTo => { + if !knots.is_empty() { + subpaths.push(Subpath { closed, knots: std::mem::take(&mut knots), op: PathOp::Combine }); + closed = false; + } + let (px, py) = point(); + knots.push(Knot::corner(px, py)); + } + PdfPathSegmentType::LineTo => { + let (px, py) = point(); + knots.push(Knot::corner(px, py)); + } + PdfPathSegmentType::BezierTo => { + // PDF draws a cubic Bézier as three consecutive BezierTo points: the + // two control points, then the end point. + bezier.push(point()); + if bezier.len() == 3 { + let ((c1x, c1y), (c2x, c2y), (ex, ey)) = (bezier[0], bezier[1], bezier[2]); + if let Some(prev) = knots.last_mut() { + prev.out_ctrl = Point::new(c1x, c1y); + } + knots.push(Knot::smooth(Point::new(ex, ey), Point::new(c2x, c2y), Point::new(ex, ey))); + bezier.clear(); + } + } + PdfPathSegmentType::Unknown => {} + } + } + if !knots.is_empty() { + subpaths.push(Subpath { closed, knots, op: PathOp::Combine }); + } + if subpaths.is_empty() { + continue; + } + let stroke = path.is_stroked().unwrap_or(false).then(|| ShapeStroke { paint: Fill::Solid(Color::BLACK), width: 1.0, ..Default::default() }); + layers.push(Layer::new( + "Path", + LayerContent::Shape(ShapeLayer { path: Path::new(subpaths), fill: Some(Fill::Solid(Color::BLACK)), stroke, ..Default::default() }), + )); + } + _ => {} + } + } + layers +} + /// The highest DPI not above `requested` that keeps every page and the artboard strip within /// their pixel budgets. Rendering at that DPI keeps each page's physical size unchanged. #[cfg(not(target_arch = "wasm32"))] diff --git a/packaging/windows/photocraft.wxs b/packaging/windows/photocraft.wxs index 6ff961fd99..020f7aa89c 100644 --- a/packaging/windows/photocraft.wxs +++ b/packaging/windows/photocraft.wxs @@ -71,6 +71,8 @@ + + From 7f96ce53433816fd12aec6c5ff822703c6082952 Mon Sep 17 00:00:00 2001 From: Firstbober Date: Sat, 31 Oct 2026 14:04:54 +0100 Subject: [PATCH 069/219] Preserve Indexed profiles and preview current tool colors --- crates/engine/src/cmd/color_cmds.rs | 20 ++- crates/engine/src/cmd/image_cmds.rs | 2 +- crates/engine/src/cmd/mode_cmds.rs | 24 +-- crates/engine/src/cmd/mode_cmds/tests.rs | 82 ++++++--- crates/ui-egui/src/canvas/filter_preview.rs | 3 + crates/ui-egui/src/canvas/tests.rs | 167 ++++++++++++++++++ crates/ui-egui/src/dialogs/filter_dialog.rs | 86 +++++---- .../src/dialogs/filter_dialog/tests.rs | 21 ++- .../ui-egui/src/dialogs/filter_preview_ui.rs | 1 + .../src/dialogs/filter_preview_worker.rs | 1 + crates/ui-egui/src/dialogs/tests.rs | 1 + crates/ui-egui/src/i18n/ar.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nb.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sr.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + docs/development.md | 11 +- 38 files changed, 364 insertions(+), 81 deletions(-) diff --git a/crates/engine/src/cmd/color_cmds.rs b/crates/engine/src/cmd/color_cmds.rs index c7c7942d0b..9fd1762c45 100644 --- a/crates/engine/src/cmd/color_cmds.rs +++ b/crates/engine/src/cmd/color_cmds.rs @@ -899,7 +899,9 @@ fn ink_surface(s: &Surface, d: &Duotone) -> Surface { } /// Image › Mode › RGB/Grayscale/CMYK/Lab through the CMS. Params: `profile` (destination, -/// default the mode's working profile), `intent` (default relative), `bpc` (default true). +/// default the mode's working profile; Indexed→RGB without an explicit profile instead expands +/// the already-RGB storage without changing its encoded samples or ICC bytes), `intent` +/// (default relative), `bpc` (default true). pub fn convert_mode(s: &mut Session, mode: ColorMode, p: &Value) -> Result { let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; if doc.mode == ColorMode::Multichannel && mode != ColorMode::Multichannel { @@ -908,8 +910,13 @@ pub fn convert_mode(s: &mut Session, mode: ColorMode, p: &Value) -> Result s.color.resolve(spec, Some(doc), Some(mode))?, + None if preserve_indexed_rgb => document_profile(doc), None => s.color.working(mode), }; if space_mode(dst.color_space) @@ -923,7 +930,16 @@ pub fn convert_mode(s: &mut Session, mode: ColorMode, p: &Value) -> Result Vec { "image.mode.rgb", "RGB Color", ["Image", "Mode"], - r##"{"profile":"|working|/path/to/profile.icc"=working,"intent":"perceptual|relative|saturation|absolute"="relative","bpc":bool=true}"##, + r##"{"profile":"|working|/path/to/profile.icc"=working (Indexed→RGB preserves the document profile unless explicit),"intent":"perceptual|relative|saturation|absolute"="relative","bpc":bool=true}"##, has_doc, |s, p| convert_mode(s, ColorMode::Rgb, p) ), diff --git a/crates/engine/src/cmd/mode_cmds.rs b/crates/engine/src/cmd/mode_cmds.rs index df14c063cc..2adde91685 100644 --- a/crates/engine/src/cmd/mode_cmds.rs +++ b/crates/engine/src/cmd/mode_cmds.rs @@ -9,7 +9,7 @@ use photocraft_algo::quantize::{self, BitmapMethod, Dither, Forced, HalftoneShape, PaletteKind}; use photocraft_algo::transform::{Homography, Interp}; -use photocraft_cms::{ColorSpace, Transform}; +use photocraft_cms::{ColorSpace, TransformOptions}; use photocraft_color::{ColorMode, PixelFormat, SampleType}; use photocraft_doc::adjust::CurvePoint; use photocraft_doc::{ColorTable, Document, Duotone, DuotoneInk, Layer, LayerContent, LayerId, Size}; @@ -266,21 +266,21 @@ fn working_matte_color(s: &Session, doc: &Document, color: [f32; 4]) -> Result<[ _ => crate::color_cmds::composite_profile(doc), }; let source = s.color.working(ColorMode::Rgb); - let transform = Transform::new(&source, &destination, s.color.settings.intent(), s.color.settings.bpc) + let options = TransformOptions { intent: s.color.settings.intent(), bpc: s.color.settings.bpc, ..Default::default() }; + let transform = photocraft_cms::transform::cached(&source, &destination, options) .map_err(|e| bad("image.mode.indexedColor", format!("cannot convert matte colour: {e}")))?; let mut output = [0.0; 16]; transform.eval(&color[..3], &mut output); - let matte = if destination.color_space == ColorSpace::Gray { - [output[0]; 3] - } else { - [output[0], output[1], output[2]] - }; - Ok(matte) + let matte = if destination.color_space == ColorSpace::Gray { [output[0]; 3] } else { [output[0], output[1], output[2]] }; + if matte.iter().any(|v| !v.is_finite()) { + return Err(bad("image.mode.indexedColor", "cannot convert matte colour to finite document samples")); + } + Ok(matte.map(|v| v.clamp(0.0, 1.0))) } fn indexed_matte(s: &Session, p: &Value) -> Result> { const CMD: &str = "image.mode.indexedColor"; - let doc = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); + let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; let matte = match p.get("matte") { None | Some(Value::Null) => return Ok(None), Some(Value::String(value)) => match value.as_str() { @@ -289,8 +289,8 @@ fn indexed_matte(s: &Session, p: &Value) -> Result> { "black" => [0.0; 3], "gray" => [128.0 / 255.0; 3], "netscapeGray" => [192.0 / 255.0; 3], - "foreground" => working_matte_color(s, &doc, s.tools.foreground)?, - "background" => working_matte_color(s, &doc, s.tools.background)?, + "foreground" => working_matte_color(s, doc, s.tools.foreground)?, + "background" => working_matte_color(s, doc, s.tools.background)?, value if value.len() == 7 && value.starts_with('#') => p .get("matte") .and_then(parse_color) @@ -755,7 +755,7 @@ pub fn specs() -> Vec { "image.mode.indexedColor", "Indexed Color…", ["Image", "Mode"], -r##"{"palette":"selective|perceptual|adaptive|exact|systemMac|systemWindows|web|uniform"="selective","colors":2..256=256,"forced":"blackWhite|none|primaries|web"="blackWhite","transparency":bool=true,"matte":color,"dither":"diffusion|none|pattern|noise"="diffusion","amount":0..100=75} (flattens; matte: none|foreground|background|white|black|gray|netscapeGray|#rrggbb; gray=50% Gray (#808080), netscapeGray=Netscape Gray (#c0c0c0))"##, + r##"{"palette":"selective|perceptual|adaptive|exact|systemMac|systemWindows|web|uniform"="selective","colors":2..256=256,"forced":"blackWhite|none|primaries|web"="blackWhite","transparency":bool=true,"matte":color,"dither":"diffusion|none|pattern|noise"="diffusion","amount":0..100=75} (flattens; matte: none|foreground|background|white|black|gray|netscapeGray|#rrggbb; gray=50% Gray (#808080), netscapeGray=Netscape Gray (#c0c0c0))"##, |s| mode_is( s, &[ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Indexed, ColorMode::Duotone, ColorMode::Cmyk, ColorMode::Lab], diff --git a/crates/engine/src/cmd/mode_cmds/tests.rs b/crates/engine/src/cmd/mode_cmds/tests.rs index f249f87bca..e5dc8aac33 100644 --- a/crates/engine/src/cmd/mode_cmds/tests.rs +++ b/crates/engine/src/cmd/mode_cmds/tests.rs @@ -247,6 +247,40 @@ fn indexed_color_palettes_and_dithers() { } } +#[test] +fn indexed_exact_preserves_profile_and_samples_through_default_rgb() { + for depth in [8, 16, 32] { + for profile in ["adobe-rgb-compat", "display-p3", "prophoto-compat", "none"] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 1, "height": 1, "depth": depth, "background": "transparent"})).unwrap(); + s.execute("edit.assignProfile", json!({"profile": profile})).unwrap(); + paint(&mut s, |_, _| [100.0 / 255.0, 150.0 / 255.0, 200.0 / 255.0, 1.0]); + let source_profile = doc(&s).icc_profile.clone(); + s.execute("image.mode.indexedColor", json!({"palette": "exact", "forced": "none", "dither": "none", "transparency": false})).unwrap(); + assert_eq!(doc(&s).icc_profile, source_profile, "{profile} at {depth}-bit: Indexed profile"); + assert_eq!(doc(&s).color_table.as_ref().unwrap().colors, vec![[100, 150, 200]]); + let indexed_pixel = doc(&s).layers[0].surface().unwrap().rgba(0, 0); + s.execute("image.mode.rgb", json!({})).unwrap(); + assert_eq!(doc(&s).mode, ColorMode::Rgb); + assert_eq!(doc(&s).icc_profile, source_profile, "{profile} at {depth}-bit: RGB profile"); + assert_eq!(doc(&s).layers[0].surface().unwrap().rgba(0, 0), indexed_pixel); + assert!(doc(&s).color_table.is_none()); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(doc(&s).mode, ColorMode::Indexed); + assert_eq!(doc(&s).icc_profile, source_profile); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(doc(&s).mode, ColorMode::Rgb); + assert_eq!(doc(&s).icc_profile, source_profile); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("image.mode.rgb", json!({"profile": "srgb"})).unwrap(); + assert_eq!(doc(&s).icc_profile, Some(photocraft_cms::Builtin::Srgb.profile().to_bytes()), "an explicit target still converts"); + if profile != "none" { + assert_ne!(doc(&s).layers[0].surface().unwrap().rgba(0, 0), indexed_pixel); + } + } + } +} + #[test] fn indexed_matte_blends_edges_without_replacing_clear_pixels() { for depth in [8, 16, 32] { @@ -292,26 +326,30 @@ fn indexed_matte_converts_working_rgb_to_document_profile_at_every_depth() { use photocraft_cms::Transform; for depth in [8, 16, 32] { - let mut s = Session::new(); - s.color.settings.working_rgb = "display-p3".into(); - s.execute("file.new", json!({"width": 2, "height": 1, "depth": depth, "background": "transparent"})).unwrap(); - s.execute("edit.assignProfile", json!({"profile": "adobe-rgb-compat"})).unwrap(); - s.execute("tools.setColors", json!({"foreground": "#3366cc"})).unwrap(); - paint(&mut s, |x, _| if x == 0 { [1.0, 0.0, 0.0, 0.5] } else { [0.0; 4] }); - - let source = s.color.working(ColorMode::Rgb); - let destination = crate::color_cmds::document_profile(doc(&s)); - let transform = Transform::new(&source, &destination, s.color.settings.intent(), s.color.settings.bpc).unwrap(); - let mut converted = [0.0; 16]; - transform.eval(&[0.2, 0.4, 0.8], &mut converted); - let expected = [0.5 + 0.5 * converted[0], 0.5 * converted[1], 0.5 * converted[2]]; - - s.execute("image.mode.indexedColor", json!({"palette": "exact", "forced": "none", "dither": "none", "matte": "foreground"})).unwrap(); - let edge = doc(&s).layers[0].surface().unwrap().rgba(0, 0); - for (got, want) in edge[..3].iter().zip(expected) { - assert!((got - want).abs() <= 2.0 / 255.0, "{depth}-bit: {edge:?} vs {expected:?}"); + for (matte, color) in [("foreground", [0.0, 1.0, 0.0]), ("background", [1.0, 0.0, 0.0])] { + for (intent, bpc) in [("relative", false), ("absolute", true)] { + let mut s = Session::new(); + s.execute("edit.colorSettings", json!({"workingRgb": "display-p3", "intent": intent, "bpc": bpc})).unwrap(); + s.execute("file.new", json!({"width": 2, "height": 1, "depth": depth, "background": "transparent"})).unwrap(); + s.execute("edit.assignProfile", json!({"profile": "adobe-rgb-compat"})).unwrap(); + s.execute("tools.setColors", json!({"foreground": "#00ff00", "background": "#ff0000"})).unwrap(); + paint(&mut s, |x, _| if x == 0 { [1.0, 0.0, 0.0, 0.5] } else { [0.0; 4] }); + let pixel = doc(&s).layers[0].surface().unwrap().rgba(0, 0); + let profile = doc(&s).icc_profile.clone(); + let source = s.color.working(ColorMode::Rgb); + let destination = crate::color_cmds::document_profile(doc(&s)); + let transform = Transform::new(&source, &destination, s.color.settings.intent(), bpc).unwrap(); + let mut converted = [0.0; 16]; + transform.eval(&color, &mut converted); + let expected = [0, 1, 2].map(|i| ((pixel[i] * pixel[3] + converted[i].clamp(0.0, 1.0) * (1.0 - pixel[3])) * 255.0).round() as u8); + s.execute("image.mode.indexedColor", json!({"palette": "exact", "forced": "none", "dither": "none", "matte": matte})).unwrap(); + let edge = doc(&s).layers[0].surface().unwrap().rgba(0, 0); + assert_eq!([edge[0], edge[1], edge[2]].map(|v| (v * 255.0).round() as u8), expected, "{depth}-bit {matte} {intent}, bpc={bpc}"); + assert_eq!(edge[3], 1.0); + assert_eq!(doc(&s).icc_profile, profile); + assert_eq!(doc(&s).layers[0].surface().unwrap().rgba(1, 0)[3], 0.0); + } } - assert_eq!(edge[3], 1.0); } } @@ -321,7 +359,7 @@ fn indexed_matte_hex_stays_in_document_rgb_coordinates() { let mut s = Session::new(); s.execute("file.new", json!({"width": 1, "height": 1, "depth": depth, "background": "transparent"})).unwrap(); s.execute("edit.assignProfile", json!({"profile": "adobe-rgb-compat"})).unwrap(); - paint(&mut s, |_| [1.0, 0.0, 0.0, 0.5]); + paint(&mut s, |_, _| [1.0, 0.0, 0.0, 0.5]); s.execute("image.mode.indexedColor", json!({"palette": "exact", "forced": "none", "dither": "none", "matte": "#3366cc"})).unwrap(); let edge = doc(&s).layers[0].surface().unwrap().rgba(0, 0); let expected = [0.5 + 0.5 * 0.2, 0.5 * 0.4, 0.5 * 0.8]; @@ -337,10 +375,10 @@ fn indexed_matte_named_gray_choices_use_their_document_samples() { for (matte, channel) in [("gray", 128.0 / 255.0), ("netscapeGray", 192.0 / 255.0)] { let mut s = Session::new(); s.execute("file.new", json!({"width": 1, "height": 1, "depth": 32, "background": "transparent"})).unwrap(); - paint(&mut s, |_| [1.0, 0.0, 0.0, 0.5]); + paint(&mut s, |_, _| [1.0, 0.0, 0.0, 0.5]); s.execute("image.mode.indexedColor", json!({"palette": "exact", "forced": "none", "dither": "none", "matte": matte})).unwrap(); let edge = doc(&s).layers[0].surface().unwrap().rgba(0, 0); - assert!((edge[0] - 0.5).abs() <= 2.0 / 255.0, "{matte}: {edge:?}"); + assert!((edge[0] - (0.5 + channel * 0.5)).abs() <= 1.0 / 255.0, "{matte}: {edge:?}"); for value in edge[1..3].iter() { assert!((*value - channel * 0.5).abs() <= 2.0 / 255.0, "{matte}: {edge:?}"); } diff --git a/crates/ui-egui/src/canvas/filter_preview.rs b/crates/ui-egui/src/canvas/filter_preview.rs index 3a6ee54d97..7eff66f7df 100644 --- a/crates/ui-egui/src/canvas/filter_preview.rs +++ b/crates/ui-egui/src/canvas/filter_preview.rs @@ -69,6 +69,7 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e command: cmd, params, k, + tool_colors: (app.session.tools.foreground, app.session.tools.background), }; let key = request.doc.0 ^ (1u64 << 61); // Every filter's preview computes off the UI thread in the desktop app: Gaussian Blur at @@ -123,6 +124,7 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e task.target, &documents, &color, + Some(task.tool_colors), ) .map(|doc| { let buffer = photocraft_compose::flatten(&doc); @@ -164,6 +166,7 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e request.target, &documents, &app.session.color.settings, + Some(request.tool_colors), ) .map(|doc| { let buffer = photocraft_compose::flatten(&doc); diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 661da5d277..eba0bf3e8b 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -276,6 +276,7 @@ fn every_filter_previews_off_the_ui_thread_and_a_newer_value_cancels_the_old() { command: "filter.blur.gaussianBlur".into(), params: json!({"radius": 999.0}), k: 1, + tool_colors: (app.session.tools.foreground, app.session.tools.background), }; let (told, cancelled) = std::sync::mpsc::channel(); app.filter_preview_worker @@ -329,6 +330,7 @@ fn gated_blur_preview(app: &mut PhotocraftApp, ctx: &egui::Context) -> (std::syn command: command.into(), params, k: 1, + tool_colors: (app.session.tools.foreground, app.session.tools.background), }; let doc = app.session.documents()[0].doc.clone(); let documents: Vec<_> = app.session.documents().iter().map(|d| d.doc.clone()).collect(); @@ -347,6 +349,7 @@ fn gated_blur_preview(app: &mut PhotocraftApp, ctx: &egui::Context) -> (std::syn key.target, &documents, &Default::default(), + Some(key.tool_colors), ) .map(|d| { let buffer = photocraft_compose::flatten(&d); @@ -396,6 +399,169 @@ fn a_held_preview_frame_stays_on_the_canvas_when_the_proxy_factor_changes() { assert_eq!((k, size), (1, [2400, 2200])); } +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn indexed_matte_preview_matches_commit_and_refreshes_tool_colors_inline_and_on_worker() { + for background in [false, true] { + for depth in [8, 16, 32] { + for matte in ["foreground", "background"] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.background_jobs = background; + app.run("file.new", json!({"width": 64, "height": 48, "depth": depth, "background": "transparent"})).unwrap(); + app.run("edit.assignProfile", json!({"profile": "adobe-rgb-compat"})).unwrap(); + app.run("edit.colorSettings", json!({"workingRgb": "display-p3"})).unwrap(); + app.session + .edit("Translucent edge", |doc, active| { + let format = doc.pixel_format(); + doc.layer_mut(active.unwrap()) + .unwrap() + .surface_mut() + .unwrap() + .fill_rect(photocraft_geom::Rect::new(0, 0, 32, 48), &photocraft_raster::from_rgba(&format, [1.0, 0.0, 0.0, 0.5])); + Ok(()) + }) + .unwrap(); + let source = app.session.active().unwrap().doc.clone(); + let params = json!({"palette": "exact", "forced": "none", "dither": "none", "matte": matte}); + crate::filter_dialog::open(&mut app, "image.mode.indexedColor").unwrap(); + app.ui.dialogs.last_mut().unwrap().fields.extend(params.as_object().unwrap().clone()); + let ctx = egui::Context::default(); + let mut previous = None; + for (foreground, background_color) in [("#3366cc", "#cc6633"), ("#26bf59", "#bf26a6")] { + app.session.execute("tools.setColors", json!({"foreground": foreground, "background": background_color})).unwrap(); + let colors = (app.session.tools.foreground, app.session.tools.background); + let shown = ensure_filter_preview(&mut app, 0, &ctx); + if background { + assert!(shown.is_none(), "old tool colors must not remain visible"); + } + drive_until(&mut app, &ctx, "the current matte colors", |app| { + app.filter_preview.as_ref().is_some_and(|p| p.key.tool_colors == colors && p.result.is_some()) + }); + let preview = app.filter_preview.as_ref().unwrap().result.as_ref().unwrap(); + let mut applied = photocraft_engine::Session::new(); + applied.color.settings = app.session.color.settings.clone(); + applied.tools.foreground = colors.0; + applied.tools.background = colors.1; + applied.add_document((*source).clone(), None); + applied.execute("image.mode.indexedColor", params.clone()).unwrap(); + let committed = &applied.active().unwrap().doc; + assert_eq!(preview.icc_profile, source.icc_profile); + assert_eq!(preview.color_table, committed.color_table); + let actual = preview.layers[0].surface().unwrap().read_region(preview.bounds()); + let expected = committed.layers[0].surface().unwrap().read_region(committed.bounds()); + assert_eq!(actual, expected, "{matte}, {depth}-bit, worker={background}"); + if let Some(previous) = previous { + assert_ne!(actual, previous, "changing the selected tool color refreshes the pixels"); + } + previous = Some(actual); + assert_eq!(app.session.active().unwrap().doc.mode, photocraft_doc::ColorMode::Rgb, "preview does not commit"); + } + } + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn cross_scale_indexed_matte_preview_rejects_old_tool_colors_from_cached_and_worker_results() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.background_jobs = true; + app.run("file.new", json!({"width": 2400, "height": 2200, "depth": 8, "background": "transparent"})).unwrap(); + app.run("edit.assignProfile", json!({"profile": "adobe-rgb-compat"})).unwrap(); + app.run("edit.colorSettings", json!({"workingRgb": "display-p3"})).unwrap(); + app.session + .edit("Translucent edge", |doc, active| { + let format = doc.pixel_format(); + doc.layer_mut(active.unwrap()) + .unwrap() + .surface_mut() + .unwrap() + .fill_rect(photocraft_geom::Rect::new(0, 0, 32, 48), &photocraft_raster::from_rgba(&format, [1.0, 0.0, 0.0, 0.5])); + Ok(()) + }) + .unwrap(); + app.ui.views.get_mut(0).expect("the new document's view").zoom = 0.25; + let params = json!({"palette": "exact", "forced": "none", "dither": "none", "matte": "foreground"}); + crate::filter_dialog::open(&mut app, "image.mode.indexedColor").unwrap(); + app.ui.dialogs.last_mut().unwrap().fields.extend(params.as_object().unwrap().clone()); + app.session.execute("tools.setColors", json!({"foreground": "#3366cc", "background": "#cc6633"})).unwrap(); + let ctx = egui::Context::default(); + drive_until(&mut app, &ctx, "the first indexed preview", |a| a.filter_preview.as_ref().is_some_and(|p| p.result.is_some())); + let old = app.filter_preview.as_ref().expect("the first preview").key.clone(); + assert_eq!(old.k, 2, "the zoomed-out large document uses the reduced preview"); + + app.session.execute("tools.setColors", json!({"foreground": "#26bf59", "background": "#bf26a6"})).unwrap(); + app.ui.views.get_mut(0).expect("the new document's view").zoom = 1.0; + let new_colors = (app.session.tools.foreground, app.session.tools.background); + let new = crate::filter_dialog::FilterPreviewKey { k: 1, tool_colors: new_colors, ..old.clone() }; + assert_ne!(old.tool_colors, new.tool_colors); + assert_ne!(old.k, new.k, "the cache and request use different preview factors"); + + // Keep the current worker pending so the only possible frame is the cached old-color one. + let (release_new, wait_new) = std::sync::mpsc::channel(); + app.filter_preview_worker + .start(new.clone(), ctx.clone(), move |_| { + wait_new.recv_timeout(std::time::Duration::from_secs(60)).expect("the test releases the job"); + crate::dialogs::filter_preview_worker::Computed { result: None, ms: 0.0 } + }) + .expect("the worker starts"); + assert!(ensure_filter_preview(&mut app, 0, &ctx).is_none(), "the cached old-color frame is not painted while the new worker runs"); + release_new.send(()).expect("released"); + drive_until(&mut app, &ctx, "the new worker to finish", |a| !a.filter_preview_worker.busy()); + assert_eq!(app.filter_preview.as_ref().expect("the current request").key, new); + assert!(app.filter_preview.as_ref().unwrap().result.is_none()); + + // Start an old-color worker while its request is current, then move the dialog to a new + // cross-scale request before the old result lands. Its real preview must not replace the + // current request through the loose worker-result gate. + app.session.execute("tools.setColors", json!({"foreground": "#3366cc", "background": "#cc6633"})).unwrap(); + app.ui.views.get_mut(0).expect("the new document's view").zoom = 0.25; + let old_worker = old.clone(); + let doc = app.session.documents()[0].doc.clone(); + let documents: Vec<_> = app.session.documents().iter().map(|d| d.doc.clone()).collect(); + let color = app.session.color.settings.clone(); + let (release_old, wait_old) = std::sync::mpsc::channel(); + app.filter_preview_worker + .start(old_worker.clone(), ctx.clone(), move |_| { + wait_old.recv_timeout(std::time::Duration::from_secs(60)).expect("the test releases the job"); + let result = crate::filter_dialog::preview_document_for_target( + &doc, + old_worker.active, + &old_worker.command, + &old_worker.params, + old_worker.k, + None, + old_worker.target, + &documents, + &color, + Some(old_worker.tool_colors), + ) + .map(|doc| { + let buffer = photocraft_compose::flatten(&doc); + (std::sync::Arc::new(doc), buffer) + }); + crate::dialogs::filter_preview_worker::Computed { result, ms: 0.0 } + }) + .expect("the worker starts"); + assert!(ensure_filter_preview(&mut app, 0, &ctx).is_none(), "the old cached request is not current"); + + app.session.execute("tools.setColors", json!({"foreground": "#26bf59", "background": "#bf26a6"})).unwrap(); + app.ui.views.get_mut(0).expect("the new document's view").zoom = 1.0; + app.ui.dialogs[0].fields.insert("matte".into(), json!("background")); + let mut new_late = new.clone(); + new_late.params.as_object_mut().unwrap().insert("matte".into(), json!("background")); + assert!(ensure_filter_preview(&mut app, 0, &ctx).is_none(), "the current request has no result while the old worker runs"); + release_old.send(()).expect("released"); + drive_until(&mut app, &ctx, "the stale old-color worker to finish", |a| { + if let Some(shown) = &a.filter_preview { + assert_eq!(shown.key.tool_colors, new.tool_colors, "the stale old-color worker result was not painted"); + assert_ne!(shown.key.k, old.k, "the stale old-scale worker result was not painted"); + } + a.filter_preview.as_ref().is_some_and(|p| p.key == new_late && p.result.is_some()) && !a.filter_preview_worker.busy() + }); + assert_eq!(app.filter_preview.as_ref().expect("the current request").key, new_late); +} + #[cfg(not(target_arch = "wasm32"))] #[test] fn a_preview_computed_mid_drag_reaches_the_canvas_and_then_converges() { @@ -502,6 +668,7 @@ fn a_mid_drag_empty_result_leaves_the_preview_alone() { command: "filter.blur.gaussianBlur".into(), params: json!({"radius": moved}), k: 1, + tool_colors: (app.session.tools.foreground, app.session.tools.background), }; let (release_empty, wait) = std::sync::mpsc::channel::<()>(); app.filter_preview_worker diff --git a/crates/ui-egui/src/dialogs/filter_dialog.rs b/crates/ui-egui/src/dialogs/filter_dialog.rs index 31a7bbc03d..c8cedf7d73 100644 --- a/crates/ui-egui/src/dialogs/filter_dialog.rs +++ b/crates/ui-egui/src/dialogs/filter_dialog.rs @@ -6,7 +6,7 @@ use std::sync::Arc; -use photocraft_cms::{ColorSpace, Transform}; +use photocraft_cms::{ColorSpace, TransformOptions}; use photocraft_color::ColorMode; use photocraft_doc::Document; use photocraft_engine::color_cmds::ColorSettings; @@ -847,56 +847,59 @@ fn replace_color_result(ui: &mut egui::Ui, f: &mut Map) { const INDEXED_MATTE_FOREGROUND: &str = "__indexedMatteForeground"; const INDEXED_MATTE_BACKGROUND: &str = "__indexedMatteBackground"; +const INDEXED_MATTE_COLOR_ERROR: &str = "__indexedMatteColorError"; /// Resolve a tool colour into the encoded samples used by Indexed Color's matte. /// /// The picker and command fields carry document RGB coordinates. Tool swatches are /// working-RGB coordinates, so only the named tool choices need this conversion here. -fn indexed_matte_document_color(app: &PhotocraftApp, doc: &Document, color: [f32; 4]) -> [f32; 3] { +fn indexed_matte_document_color(app: &PhotocraftApp, doc: &Document, color: [f32; 4]) -> Result<[f32; 3], String> { let destination = match photocraft_engine::color_cmds::mode_space(doc.mode) { ColorSpace::Rgb | ColorSpace::Gray => photocraft_engine::color_cmds::document_profile(doc), _ => photocraft_engine::color_cmds::composite_profile(doc), }; let source = app.session.color.working(ColorMode::Rgb); - let Ok(transform) = Transform::new(&source, &destination, app.session.color.settings.intent(), app.session.color.settings.bpc) else { - return [color[0], color[1], color[2]]; - }; + let options = TransformOptions { intent: app.session.color.settings.intent(), bpc: app.session.color.settings.bpc, ..Default::default() }; + let transform = photocraft_cms::transform::cached(&source, &destination, options).map_err(|e| e.to_string())?; let mut output = [0.0; 16]; transform.eval(&color[..3], &mut output); - if destination.color_space == ColorSpace::Gray { [output[0]; 3] } else { [output[0], output[1], output[2]] } + let rgb = if destination.color_space == ColorSpace::Gray { [output[0]; 3] } else { [output[0], output[1], output[2]] }; + if rgb.iter().any(|v| !v.is_finite()) { + return Err("cannot convert matte colour to finite document samples".into()); + } + Ok(rgb.map(|v| v.clamp(0.0, 1.0))) } fn indexed_matte_defaults(app: &PhotocraftApp, fields: &mut Map) { - let Some(doc) = app.session.active().map(|state| state.doc.clone()) else { return }; - let foreground = indexed_matte_document_color(app, &doc, app.session.tools.foreground); - let background = indexed_matte_document_color(app, &doc, app.session.tools.background); - fields.insert(INDEXED_MATTE_FOREGROUND.into(), json!(crate::color_picker_ui::hex(foreground))); - fields.insert(INDEXED_MATTE_BACKGROUND.into(), json!(crate::color_picker_ui::hex(background))); + let Some(doc) = app.session.active().map(|state| state.doc.as_ref()) else { return }; + for (key, color) in [(INDEXED_MATTE_FOREGROUND, app.session.tools.foreground), (INDEXED_MATTE_BACKGROUND, app.session.tools.background)] { + match indexed_matte_document_color(app, doc, color) { + Ok(rgb) => { + fields.insert(key.into(), json!(crate::color_picker_ui::hex(rgb))); + } + Err(error) => { + fields.insert(INDEXED_MATTE_COLOR_ERROR.into(), json!(error)); + } + } + } } -fn indexed_matte_seed(fields: &Map, value: &str) -> [f32; 3] { +fn indexed_matte_seed(fields: &Map, value: &str) -> Option<[f32; 3]> { match value { - "foreground" => fields - .get(INDEXED_MATTE_FOREGROUND) - .and_then(Value::as_str) - .and_then(crate::color_picker_ui::parse_hex) - .unwrap_or([0.0; 3]), - "background" => fields - .get(INDEXED_MATTE_BACKGROUND) - .and_then(Value::as_str) - .and_then(crate::color_picker_ui::parse_hex) - .unwrap_or([1.0; 3]), - "black" => [0.0; 3], - "gray" => [128.0 / 255.0; 3], - "netscapeGray" => [192.0 / 255.0; 3], - _ => [1.0; 3], + "foreground" => fields.get(INDEXED_MATTE_FOREGROUND).and_then(Value::as_str).and_then(crate::color_picker_ui::parse_hex), + "background" => fields.get(INDEXED_MATTE_BACKGROUND).and_then(Value::as_str).and_then(crate::color_picker_ui::parse_hex), + "black" => Some([0.0; 3]), + "gray" => Some([128.0 / 255.0; 3]), + "netscapeGray" => Some([192.0 / 255.0; 3]), + "none" | "white" => Some([1.0; 3]), + _ => crate::color_picker_ui::parse_hex(value), } } fn indexed_matte_row(ui: &mut egui::Ui, fields: &mut Map) { let value = fields.get("matte").and_then(Value::as_str).unwrap_or("none"); let mut choice = if value.starts_with('#') { "custom" } else { value }; - let color = crate::color_picker_ui::parse_hex(value).unwrap_or_else(|| indexed_matte_seed(fields, value)); + let color = indexed_matte_seed(fields, value); let mut changed = false; let mut pick = false; ui.horizontal(|ui| { @@ -913,27 +916,34 @@ fn indexed_matte_row(ui: &mut egui::Ui, fields: &mut Map) { ("white", "White"), ("black", "Black"), ("gray", "50% Gray"), - ("netscapeGray", "Netscape Gray"), + ("netscapeGray", tl!("Netscape Gray")), ("custom", "Other…"), ], 170.0, ); - if choice == "custom" { + if choice == "custom" + && let Some(color) = color + { let bytes = color.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8); pick = crate::widgets::color_swatch_button(ui, egui::Color32::from_rgb(bytes[0], bytes[1], bytes[2]), tl!("Matte")).clicked(); } }); + if color.is_none() + && let Some(error) = fields.get(INDEXED_MATTE_COLOR_ERROR).and_then(Value::as_str) + { + ui.colored_label(Tokens::get(ui.ctx()).danger, error); + } let custom = choice == "custom"; if changed { - if custom { + if custom && let Some(color) = color { // Keep the previous matte until the picker confirms; the picker uses this only as // its initial document-encoded colour when switching from a named matte or None. fields.insert("__pickColorDefault".into(), json!(crate::color_picker_ui::hex(color))); - } else { + } else if !custom { fields.insert("matte".into(), json!(choice.to_owned())); } } - if custom && (changed || pick) { + if custom && color.is_some() && (changed || pick) { crate::color_picker_ui::request_field(fields, "matte", &crate::color_picker_ui::title_for("Matte")); } } @@ -1264,6 +1274,7 @@ pub fn preview_document_with( photocraft_engine::channel_cmds::ChannelTarget::Composite, &[], &ColorSettings::default(), + None, ) } @@ -1271,6 +1282,8 @@ pub fn preview_document_with( /// `documents` are the open documents in session order: document params (Match Color's Source, /// Displace's map) index into them. `color` is the app's Edit › Color Settings: Assign and Convert /// to Profile's `working` is the working space chosen there, not the built-in default. +/// `tool_colors` carries the caller's working-space foreground/background values; standalone +/// previews without a session use the engine's default tool colors. #[allow(clippy::too_many_arguments)] pub(crate) fn preview_document_for_target( doc: &Document, @@ -1282,6 +1295,7 @@ pub(crate) fn preview_document_for_target( target: photocraft_engine::channel_cmds::ChannelTarget, documents: &[Arc], color: &ColorSettings, + tool_colors: Option<([f32; 4], [f32; 4])>, ) -> Option { if command == crate::select_mask_view::COMMAND { return crate::select_mask_view::preview_document(doc, active, params, k, cancel); @@ -1317,6 +1331,10 @@ pub(crate) fn preview_document_for_target( } let mut s = photocraft_engine::Session::new(); s.color.settings = color.clone(); + if let Some((foreground, background)) = tool_colors { + s.tools.foreground = foreground; + s.tools.background = background; + } s.set_inline_job_ctx(cancel.cloned()); let doc_id = proxy.id; s.add_document(proxy, None); @@ -1459,11 +1477,12 @@ pub struct FilterPreviewKey { pub command: String, pub params: Value, pub k: u32, + pub tool_colors: ([f32; 4], [f32; 4]), } impl FilterPreviewKey { /// Everything except the parameters: same dialog, document, revision, layer, channel target, - /// command and proxy factor. + /// command, proxy factor and tool colors. /// /// The canvas shows whatever this preview was computed for, so a result whose parameters are /// a few frames old still belongs on screen while a slider is moving; matching on everything @@ -1485,6 +1504,7 @@ impl FilterPreviewKey { && self.active == other.active && self.target == other.target && self.command == other.command + && self.tool_colors == other.tool_colors } } diff --git a/crates/ui-egui/src/dialogs/filter_dialog/tests.rs b/crates/ui-egui/src/dialogs/filter_dialog/tests.rs index 31786411b7..a45e30b656 100644 --- a/crates/ui-egui/src/dialogs/filter_dialog/tests.rs +++ b/crates/ui-egui/src/dialogs/filter_dialog/tests.rs @@ -99,7 +99,7 @@ fn indexed_matte_tool_seeds_use_document_encoded_rgb() { session.execute("file.new", json!({"width": 2, "height": 2, "background": "transparent"})).unwrap(); session.execute("edit.assignProfile", json!({"profile": "adobe-rgb-compat"})).unwrap(); session.execute("tools.setColors", json!({"foreground": "#3366cc", "background": "#cc6633"})).unwrap(); - let mut app = PhotocraftApp::new(session, crate::Services::default()); + let app = PhotocraftApp::new(session, crate::Services::default()); let mut fields = Map::new(); indexed_matte_defaults(&app, &mut fields); @@ -112,7 +112,10 @@ fn indexed_matte_tool_seeds_use_document_encoded_rgb() { transform.eval(&input, &mut output); let expected = crate::color_picker_ui::hex([output[0], output[1], output[2]]); assert_eq!(fields.get(key).and_then(Value::as_str), Some(expected.as_str()), "{key}"); - assert_eq!(indexed_matte_seed(&fields, if key == INDEXED_MATTE_FOREGROUND { "foreground" } else { "background" }), crate::color_picker_ui::parse_hex(&expected).unwrap()); + assert_eq!( + indexed_matte_seed(&fields, if key == INDEXED_MATTE_FOREGROUND { "foreground" } else { "background" }), + crate::color_picker_ui::parse_hex(&expected) + ); } } /// Convert to Profile's and Proof Setup's `bpc` read "Bpc": spelled out. @@ -131,6 +134,7 @@ fn key() -> FilterPreviewKey { command: "filter.blur.gaussianBlur".into(), params: json!({"radius": 4.0}), k: 1, + tool_colors: ([0.0, 0.0, 0.0, 1.0], [1.0; 4]), } } @@ -153,6 +157,8 @@ fn same_target_ignores_parameters_but_nothing_else() { |k: &mut FilterPreviewKey| k.target = photocraft_engine::channel_cmds::ChannelTarget::LayerMask, |k: &mut FilterPreviewKey| k.command = "filter.blur.motionBlur".into(), |k: &mut FilterPreviewKey| k.k = 2, + |k: &mut FilterPreviewKey| k.tool_colors.0 = [0.2, 0.4, 0.8, 1.0], + |k: &mut FilterPreviewKey| k.tool_colors.1 = [0.8, 0.4, 0.2, 1.0], ] { let mut other = base.clone(); break_it(&mut other); @@ -678,6 +684,7 @@ fn smart_displace_preview_uses_the_map_document() { photocraft_engine::channel_cmds::ChannelTarget::Composite, &documents, &ColorSettings::default(), + None, ) .unwrap_or_else(|| panic!("map {map}: no preview")); assert!(photocraft_compose::render(&preview, doc.bounds()).px == want, "map {map}: the preview differs from what OK applies"); @@ -720,6 +727,7 @@ fn match_color_preview_uses_the_source_document() { photocraft_engine::channel_cmds::ChannelTarget::Composite, &documents, &ColorSettings::default(), + None, ) .unwrap_or_else(|| panic!("{label}: no preview")); let got = preview.layers[0].surface().unwrap().pixel(24, 16); @@ -736,6 +744,7 @@ fn match_color_preview_uses_the_source_document() { photocraft_engine::channel_cmds::ChannelTarget::Composite, &documents, &ColorSettings::default(), + None, ) .unwrap(); let px = preview.layers[0].surface().unwrap().pixel(12, 8); @@ -1673,12 +1682,8 @@ fn indexed_matte_dropdown_and_picker_change_the_applied_edge_color() { use egui::accesskit::Role; use egui_kittest::{Harness, kittest::Queryable}; - for (choice, matte) in [ - ("Black", [0.0, 0.0, 0.0]), - ("50% Gray", [128.0 / 255.0; 3]), - ("Netscape Gray", [192.0 / 255.0; 3]), - ("Other…", [0.0, 0.0, 1.0]), - ] { + for (choice, matte) in [("Black", [0.0, 0.0, 0.0]), ("50% Gray", [128.0 / 255.0; 3]), ("Netscape Gray", [192.0 / 255.0; 3]), ("Other…", [0.0, 0.0, 1.0])] + { let mut session = photocraft_engine::Session::new(); session.execute("file.new", json!({"width": 2, "height": 1, "depth": 32, "background": "transparent"})).unwrap(); session diff --git a/crates/ui-egui/src/dialogs/filter_preview_ui.rs b/crates/ui-egui/src/dialogs/filter_preview_ui.rs index f83a260b0d..7378370984 100644 --- a/crates/ui-egui/src/dialogs/filter_preview_ui.rs +++ b/crates/ui-egui/src/dialogs/filter_preview_ui.rs @@ -334,6 +334,7 @@ mod tests { ChannelTarget::Alpha(0), &[], &Default::default(), + Some((app.session.tools.foreground, app.session.tools.background)), ) .unwrap(); let ctx = egui::Context::default(); diff --git a/crates/ui-egui/src/dialogs/filter_preview_worker.rs b/crates/ui-egui/src/dialogs/filter_preview_worker.rs index 0950e67939..ad02315a8c 100644 --- a/crates/ui-egui/src/dialogs/filter_preview_worker.rs +++ b/crates/ui-egui/src/dialogs/filter_preview_worker.rs @@ -123,6 +123,7 @@ mod tests { command: "image.mode.indexedColor".into(), params: json!({"colors": colors}), k: 1, + tool_colors: ([0.0, 0.0, 0.0, 1.0], [1.0; 4]), } } diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 829e59e99e..00dc506886 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -17,6 +17,7 @@ fn filter_dialog_with_preview(background: bool) -> (PhotocraftApp, u64) { command: "filter.blur.gaussianBlur".into(), params: serde_json::json!({"radius": 4.0}), k: 1, + tool_colors: (app.session.tools.foreground, app.session.tools.background), }; app.filter_preview = Some(crate::filter_dialog::FilterPreview { key, result: result.map(std::sync::Arc::new), committing: false }); (app, id) diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 1f79f7889c..56e946e7ce 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -3595,3 +3595,4 @@ curves Smooth تنعيم Pick a different document for each channel. اختر مستندًا مختلفًا لكل قناة. A picked document is no longer open. أحد المستندات المختارة لم يعد مفتوحًا. A picked document is no longer a grayscale document of the same size. أحد المستندات المختارة لم يعد مستندًا بتدرج الرمادي بالحجم نفسه. + Netscape Gray رمادي Netscape diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index d82218a2b9..718fc73324 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4338,3 +4338,4 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Pick a different document for each channel. Pro každý kanál vyberte jiný dokument. A picked document is no longer open. Jeden z vybraných dokumentů už není otevřený. A picked document is no longer a grayscale document of the same size. Jeden z vybraných dokumentů už není dokument ve stupních šedi stejné velikosti. + Netscape Gray Šedá Netscape diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 2110c49ab8..87ee2025a1 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -4808,3 +4808,4 @@ cameraRaw White Balance: As Shot Hvidbalance: Som fotograferet Pick a different document for each channel. Vælg et andet dokument til hver kanal. A picked document is no longer open. Et af de valgte dokumenter er ikke længere åbent. A picked document is no longer a grayscale document of the same size. Et af de valgte dokumenter er ikke længere et dokument i gråtoner i samme størrelse. + Netscape Gray Netscape-grå diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 6bfdefb09d..ab81180236 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3633,3 +3633,4 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Pick a different document for each channel. Wählen Sie für jeden Kanal ein anderes Dokument. A picked document is no longer open. Ein ausgewähltes Dokument ist nicht mehr geöffnet. A picked document is no longer a grayscale document of the same size. Ein ausgewähltes Dokument ist kein Graustufen-Dokument derselben Größe mehr. + Netscape Gray Netscape-Grau diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 3a76328295..1a8220e34f 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4298,3 +4298,4 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Pick a different document for each channel. Επιλέξτε διαφορετικό έγγραφο για κάθε κανάλι. A picked document is no longer open. Ένα από τα επιλεγμένα έγγραφα δεν είναι πλέον ανοιχτό. A picked document is no longer a grayscale document of the same size. Ένα από τα επιλεγμένα έγγραφα δεν είναι πλέον έγγραφο σε κλίμακα του γκρι ίδιου μεγέθους. + Netscape Gray Γκρι Netscape diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index fd166bb321..213bcebd62 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4498,3 +4498,4 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Pick a different document for each channel. Elija un documento distinto para cada canal. A picked document is no longer open. Uno de los documentos elegidos ya no está abierto. A picked document is no longer a grayscale document of the same size. Uno de los documentos elegidos ya no es un documento en escala de grises del mismo tamaño. + Netscape Gray Gris Netscape diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 7f3413ceda..aa04ad8424 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -4809,3 +4809,4 @@ cameraRaw White Balance: As Shot Valkotasapaino: kuvauksen mukainen Pick a different document for each channel. Valitse jokaiselle kanavalle eri asiakirja. A picked document is no longer open. Yksi valituista asiakirjoista ei ole enää auki. A picked document is no longer a grayscale document of the same size. Yksi valituista asiakirjoista ei ole enää samankokoinen harmaasävyasiakirja. + Netscape Gray Netscape-harmaa diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 0b4394288d..601390a34a 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4621,3 +4621,4 @@ shape Appearance Apparence Pick a different document for each channel. Choisissez un document différent pour chaque canal. A picked document is no longer open. Un des documents choisis n'est plus ouvert. A picked document is no longer a grayscale document of the same size. Un des documents choisis n'est plus un document en niveaux de gris de même taille. + Netscape Gray Gris Netscape diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index aa79f03063..35cdbf8138 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -666,3 +666,4 @@ cameraRaw Yellow पीला Pick a different document for each channel. हर चैनल के लिए अलग डॉक्यूमेंट चुनें। A picked document is no longer open. चुना गया एक डॉक्यूमेंट अब खुला नहीं है। A picked document is no longer a grayscale document of the same size. चुना गया एक डॉक्यूमेंट अब उसी आकार का ग्रेस्केल डॉक्यूमेंट नहीं है। + Netscape Gray Netscape धूसर diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index bb919700eb..d990d9b002 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4505,3 +4505,4 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Pick a different document for each channel. Minden csatornához másik dokumentumot válasszon. A picked document is no longer open. Az egyik kiválasztott dokumentum már nincs megnyitva. A picked document is no longer a grayscale document of the same size. Az egyik kiválasztott dokumentum már nem azonos méretű szürkeárnyalatos dokumentum. + Netscape Gray Netscape-szürke diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index c62378e593..40082c16b9 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3621,3 +3621,4 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Pick a different document for each channel. Pilih dokumen yang berbeda untuk setiap channel. A picked document is no longer open. Salah satu dokumen yang dipilih sudah tidak terbuka. A picked document is no longer a grayscale document of the same size. Salah satu dokumen yang dipilih bukan lagi dokumen grayscale dengan ukuran yang sama. + Netscape Gray Abu-abu Netscape diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 384e18f2b8..0e63a65417 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4697,3 +4697,4 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Pick a different document for each channel. Scegli un documento diverso per ogni canale. A picked document is no longer open. Uno dei documenti scelti non è più aperto. A picked document is no longer a grayscale document of the same size. Uno dei documenti scelti non è più un documento in scala di grigio delle stesse dimensioni. + Netscape Gray Grigio Netscape diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 06f61dcdd0..3df1cdd7e2 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4269,3 +4269,4 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Pick a different document for each channel. チャンネルごとに別のドキュメントを選択してください。 A picked document is no longer open. 選択したドキュメントの一つが開かれていません。 A picked document is no longer a grayscale document of the same size. 選択したドキュメントの一つが、同じサイズのグレースケールのドキュメントではなくなりました。 + Netscape Gray Netscape グレー diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index c1bd0d2ca0..acb85fad65 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3824,3 +3824,4 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Pick a different document for each channel. 채널마다 다른 문서를 선택하십시오. A picked document is no longer open. 선택한 문서 중 하나가 더 이상 열려 있지 않습니다. A picked document is no longer a grayscale document of the same size. 선택한 문서 중 하나가 더 이상 같은 크기의 회색 음영 문서가 아닙니다. + Netscape Gray Netscape 회색 diff --git a/crates/ui-egui/src/i18n/nb.tsv b/crates/ui-egui/src/i18n/nb.tsv index 3732fa7247..c0361d33d7 100644 --- a/crates/ui-egui/src/i18n/nb.tsv +++ b/crates/ui-egui/src/i18n/nb.tsv @@ -4808,3 +4808,4 @@ cameraRaw White Balance: As Shot Hvitbalanse: som fotografert Pick a different document for each channel. Velg et annet dokument for hver kanal. A picked document is no longer open. Et av de valgte dokumentene er ikke lenger åpent. A picked document is no longer a grayscale document of the same size. Et av de valgte dokumentene er ikke lenger et dokument i gråtoner av samme størrelse. + Netscape Gray Netscape-grå diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 246214818e..f48c5d85eb 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -4352,3 +4352,4 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Pick a different document for each channel. Kies voor elk kanaal een ander document. A picked document is no longer open. Een gekozen document is niet meer geopend. A picked document is no longer a grayscale document of the same size. Een gekozen document is geen grijswaardendocument van hetzelfde formaat meer. + Netscape Gray Netscape-grijs diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index e40dece48a..2f1ff7c078 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4216,3 +4216,4 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Pick a different document for each channel. Wybierz inny dokument dla każdego kanału. A picked document is no longer open. Jeden z wybranych dokumentów nie jest już otwarty. A picked document is no longer a grayscale document of the same size. Jeden z wybranych dokumentów nie jest już dokumentem w skali szarości o tym samym rozmiarze. + Netscape Gray Szarość Netscape diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 122ab72265..98953712b7 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4512,3 +4512,4 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Pick a different document for each channel. Escolha um documento diferente para cada canal. A picked document is no longer open. Um dos documentos escolhidos não está mais aberto. A picked document is no longer a grayscale document of the same size. Um dos documentos escolhidos não é mais um documento em tons de cinza do mesmo tamanho. + Netscape Gray Cinza Netscape diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index e66c3f98f3..1fda6074ad 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4783,3 +4783,4 @@ shape Appearance Оформление Pick a different document for each channel. Выберите отдельный документ для каждого канала. A picked document is no longer open. Один из выбранных документов больше не открыт. A picked document is no longer a grayscale document of the same size. Один из выбранных документов больше не является документом в градациях серого того же размера. + Netscape Gray Серый Netscape diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 3e12107087..f4e581e047 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4664,3 +4664,4 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Pick a different document for each channel. Izaberite drugi dokument za svaki kanal. A picked document is no longer open. Jedan izabrani dokument više nije otvoren. A picked document is no longer a grayscale document of the same size. Jedan izabrani dokument više nije dokument u nijansama sive iste veličine. + Netscape Gray Netscape siva diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 41c77bea6b..fed35faa1b 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4806,3 +4806,4 @@ cameraRaw White Balance: As Shot Vitbalans: som tagen Pick a different document for each channel. Välj ett annat dokument för varje kanal. A picked document is no longer open. Ett av de valda dokumenten är inte längre öppet. A picked document is no longer a grayscale document of the same size. Ett av de valda dokumenten är inte längre ett gråskaledokument i samma storlek. + Netscape Gray Netscape-grå diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 6d40778728..7c1f8c13e3 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4329,3 +4329,4 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Pick a different document for each channel. Her kanal için farklı bir belge seçin. A picked document is no longer open. Seçilen belgelerden biri artık açık değil. A picked document is no longer a grayscale document of the same size. Seçilen belgelerden biri artık aynı boyutta gri tonlamalı bir belge değil. + Netscape Gray Netscape Grisi diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index bca4b9619b..dbcb296b33 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4309,3 +4309,4 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Pick a different document for each channel. Виберіть окремий документ для кожного каналу. A picked document is no longer open. Один із вибраних документів більше не відкрито. A picked document is no longer a grayscale document of the same size. Один із вибраних документів більше не є документом у градаціях сірого того самого розміру. + Netscape Gray Сірий Netscape diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index a2f7bd141f..1397fbc39a 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4102,3 +4102,4 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Pick a different document for each channel. Chọn một tài liệu khác cho mỗi kênh. A picked document is no longer open. Một tài liệu đã chọn không còn mở. A picked document is no longer a grayscale document of the same size. Một tài liệu đã chọn không còn là tài liệu thang xám cùng kích thước. + Netscape Gray Xám Netscape diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d8493b38b1..41a3bedab5 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3290,3 +3290,4 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Pick a different document for each channel. 请为每个通道选择不同的文档。 A picked document is no longer open. 所选的一个文档已不再打开。 A picked document is no longer a grayscale document of the same size. 所选的一个文档已不再是同样大小的灰度文档。 + Netscape Gray Netscape 灰色 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 170787a915..0acf072c75 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3246,3 +3246,4 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Pick a different document for each channel. 請為每個色版選擇不同的文件。 A picked document is no longer open. 所選的一個文件已不再開啟。 A picked document is no longer a grayscale document of the same size. 所選的一個文件已不再是相同大小的灰階文件。 + Netscape Gray Netscape 灰色 diff --git a/docs/development.md b/docs/development.md index 79ca11f377..d062dcba34 100644 --- a/docs/development.md +++ b/docs/development.md @@ -667,13 +667,18 @@ opens the picker without replacing the current matte; Cancel leaves that choice OK commits the picked `#rrggbb` in document RGB coordinates. Automation accepts `matte` as `none`, `foreground`, `background`, `white`, `black`, `gray` (50% Gray, encoded `#808080`), `netscapeGray` (Netscape Gray, encoded `#c0c0c0`) or `#rrggbb`. Foreground/background -tool colours are converted from the -working RGB profile into the document profile before blending. A selected matte blends all +tool colours are converted from the working RGB profile into the document profile and clipped +to its encodable RGB range before blending. A selected matte blends all partially transparent edges; with Transparency on, fully clear pixels stay transparent. With Transparency off, the selected matte also fills clear pixels. None retains the existing hard-edged transparency/default flattening behavior. Indexed conversion retains the document's ICC profile and encoded RGB sample representation; matte colours are interpreted in the document -RGB coordinate space. This follows Adobe's documented +RGB coordinate space. Default Indexed → RGB expands the stored RGB samples without converting +them to the working profile; an explicitly supplied destination profile still requests a CMS +conversion. Inline and worker dialog previews inherit the current foreground/background tool +colors, and changing those colors invalidates both cached frames and pending worker results, +including when the preview proxy factor changes. A same-color frame at the old factor can +remain visible during a drag until the new result arrives. This follows Adobe's documented [Indexed Color conversion options](https://helpx.adobe.com/photoshop/desktop/adjust-color/color-modes/conversion-options-for-indexed-color-images.html). `cargo run --release -p photocraft-engine --example indexed_color_perf -- image.png` measures palette construction, Floyd–Steinberg diffusion, and the complete full-resolution CPU preview From fb66c11df6aafe5127b159e897348ab1c3fc73cf Mon Sep 17 00:00:00 2001 From: Triwarr <170976175+Triwarr@users.noreply.github.com> Date: Fri, 6 Nov 2026 19:35:02 +0100 Subject: [PATCH 070/219] PDF: CodeRabbit SVG signature fix, sampled text colour, Preferences page fields - A PDF named .svg now keeps the PDF importer; other signatures beat the .svg suffix fallback (regression test). - Text layers sample their fill colour from the rendered page and use the PDF font family/name. - File Handling preferences expose pdf_dpi, pdf_first_page and pdf_last_page; the page range maps to ImportOptions.pdf_pages. --- crates/io/src/lib.rs | 35 ++++++++++++++++++++++++++++++++--- crates/io/src/pdf_import.rs | 32 ++++++++++++++++++++++++++++---- 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 599f110410..54a4d1577e 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -1028,10 +1028,28 @@ fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, o pub fn is_svg_input(name: &str, bytes: &[u8]) -> bool { // Keep the SVG extension as a fallback for malformed files so the SVG parser can return an // actionable SVG error. A recognized raster signature still wins over a misleading `.svg` - // suffix (for example, a WebP downloaded as Firefox_logo.svg). + // suffix (for example, a WebP downloaded as Firefox_logo.svg), and so does every other + // format's signature: a PDF named `.svg` must reach the PDF importer. svg::is_svg(bytes) - || (has_extension(name, "svg") && photocraft_codecs::detect(bytes).is_none()) - || (has_extension(name, "svgz") && bytes.starts_with(&[0x1f, 0x8b])) + || (has_extension(name, "svg") && photocraft_codecs::detect(bytes).is_none() && !has_format_signature(name, bytes)) + || (has_extension(name, "svgz") && bytes.starts_with(&[0x1f, 0x8b]) && !has_format_signature(name, bytes)) +} + +/// A non-raster format's signature or declared extension, checked before the `.svg` suffix +/// fallback so a misnamed file keeps its own importer. +fn has_format_signature(name: &str, bytes: &[u8]) -> bool { + is_psd(bytes) + || pdf_import::is_pdf(bytes) + || is_psd_input(name, bytes) + || raw::is_raw(bytes) + || affinity::is_affinity(bytes) + || ora::is_ora(bytes) + || kra::is_kra(bytes) + || xcf::is_xcf(bytes) + || has_extension(name, "pdn") + || bytes.starts_with(b"PDN3") + || photocraft_format::is_pcraft(bytes) + || has_extension(name, photocraft_format::EXTENSION) } fn extension(name_or_ext: &str) -> String { @@ -1599,4 +1617,15 @@ mod tests { assert_eq!(d.bounds().height(), 8, "{name}"); } } + + /// A PDF named `.svg` (a save-as mistake) must reach the PDF importer, not the SVG parser; + /// SVG content still wins over a `.pdf` suffix. + #[test] + fn a_pdf_named_svg_stays_a_pdf() { + let pdf = b"%PDF-1.4\n1 0 obj\n<< /Type /Catalog >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n"; + assert!(!is_svg_input("drawing.svg", pdf)); + assert!(!is_svg_input("drawing.svgz", pdf)); + assert!(is_svg_input("drawing.svg", b"")); + assert_eq!(crate::route("drawing.svg", pdf), crate::Route::Flat); + } } diff --git a/crates/io/src/pdf_import.rs b/crates/io/src/pdf_import.rs index ab77a78b3e..e82bc20c84 100644 --- a/crates/io/src/pdf_import.rs +++ b/crates/io/src/pdf_import.rs @@ -131,7 +131,7 @@ pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) - let rect = Rect::new(x as i32, 0, (x + width) as i32, height as i32); let mut children = vec![layer]; if opts.pdf_text || opts.pdf_vectors { - children.extend(object_layers(&page, *h_pt, dpi, x, opts)); + children.extend(object_layers(&page, opts, &PageFrame { height_pt: *h_pt, dpi, x, rgba: &bitmap.as_rgba_bytes(), width, height })); } let group = Group { children, expanded: true, artboard: Some(Artboard::new(rect)), frame_stroke: None }; doc.layers.push(Layer::new(format!("Page {}", page_index + 1), LayerContent::Group(group))); @@ -147,11 +147,23 @@ pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) - Ok(ImportResult { warnings, document: doc, source_read_only: false, preview_only: false }) } +/// One rendered page's frame: its point height, DPI, document x, and the RGBA pixels. +struct PageFrame<'a> { + height_pt: f64, + dpi: f64, + x: u32, + rgba: &'a [u8], + width: u32, + height: u32, +} + /// Editable text and shape layers for one rendered page, in document pixels. #[cfg(not(target_arch = "wasm32"))] -fn object_layers(page: &pdfium_render::prelude::PdfPage, height_pt: f64, dpi: f64, x: u32, opts: &ImportOptions) -> Vec { +fn object_layers(page: &pdfium_render::prelude::PdfPage, opts: &ImportOptions, frame: &PageFrame) -> Vec { use pdfium_render::prelude::*; + let PageFrame { height_pt, dpi, x, rgba, width: page_width, height: page_height } = *frame; + let to_x = |v: f32| f64::from(v) * dpi / 72.0; let objects = page.objects(); let mut layers = Vec::new(); @@ -166,14 +178,26 @@ fn object_layers(page: &pdfium_render::prelude::PdfPage, height_pt: f64, dpi: f6 let x_px = to_x(bounds.left().value).round() + f64::from(x); let y_px = (height_pt - to_x(bounds.top().value)).round(); // The text layer's size is in points; the transform is in document pixels. + // The text object does not expose its fill colour; sample the rendered page at the + // text's centre so the editable layer starts with the right colour. + let cx = (to_x(bounds.left().value).round().max(0.0) as u32).min(page_width.saturating_sub(1)); + let cy = ((height_pt - to_x(bounds.top().value)).round().max(0.0) as u32).min(page_height.saturating_sub(1)); + let i = (cy as usize * page_width as usize + cx as usize) * 4; + let color = if i + 3 < rgba.len() && rgba[i + 3] > 0 { + Color::rgb(f32::from(rgba[i]) / 255.0, f32::from(rgba[i + 1]) / 255.0, f32::from(rgba[i + 2]) / 255.0) + } else { + Color::BLACK + }; let size_pt = f64::from(text.scaled_font_size().value).max(1.0); + let font = text.font(); + let family = if font.family().is_empty() { font.name() } else { font.family() }; layers.push(Layer::new( "Text", LayerContent::Text(TextLayer { text: content, - font_family: text.font().family(), + font_family: family, size_pt: size_pt as f32, - color: Color::BLACK, + color, transform: Affine::translate(x_px, y_px), ..Default::default() }), From 84644de10225b17c39bae4e5b595abf224854639 Mon Sep 17 00:00:00 2001 From: Triwarr <170976175+Triwarr@users.noreply.github.com> Date: Fri, 6 Nov 2026 19:36:49 +0100 Subject: [PATCH 071/219] PDF: expose the page range as two Preferences numbers --- crates/engine/src/jobs.rs | 5 ++++- crates/engine/src/prefs.rs | 9 ++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index a68606eeba..ddd9ac1f68 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -957,7 +957,10 @@ pub fn import_options(prefs: &crate::prefs::Preferences) -> photocraft_io::Impor max_group_depth: prefs.file_handling.rasterize_svg_groups_deeper_than as usize, demosaic: prefs.raw_defaults.demosaic.method(), pdf_dpi: prefs.file_handling.pdf_dpi, - pdf_pages: prefs.file_handling.pdf_pages, + pdf_pages: (prefs.file_handling.pdf_first_page > 0).then(|| { + let first = prefs.file_handling.pdf_first_page; + (first, prefs.file_handling.pdf_last_page.max(first)) + }), ..Default::default() } } diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 1750310d26..19c114fa29 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -495,8 +495,10 @@ pub struct FileHandling { pub ignore_exif_profile_tag: bool, /// PDF import resolution in DPI; the document keeps it so the page's print size is preserved. pub pdf_dpi: f32, - /// PDF import page range, 1-based and inclusive; `None` imports every page. - pub pdf_pages: Option<(u32, u32)>, + /// The first PDF page to import (1-based; 0 imports from the first page). + pub pdf_first_page: u32, + /// The last PDF page to import (1-based; 0 imports to the last page). + pub pdf_last_page: u32, /// Most renderers write ACEScg EXRs without stating the colour space. With this on, an EXR /// that would otherwise open as linear sRGB opens as ACEScg (AP1, linear) instead. pub exr_aces_cg: bool, @@ -535,7 +537,8 @@ impl Default for FileHandling { recover_on_launch: true, ignore_exif_profile_tag: false, pdf_dpi: 300.0, - pdf_pages: None, + pdf_first_page: 0, + pdf_last_page: 0, exr_aces_cg: false, ask_before_saving_layered_tiff: true, maximize_psd_compatibility: Ask::Always, From adbe906eaf0fb2c5d669ca6a7330274c3e3e55c0 Mon Sep 17 00:00:00 2001 From: Triwarr <170976175+Triwarr@users.noreply.github.com> Date: Fri, 6 Nov 2026 20:11:03 +0100 Subject: [PATCH 072/219] PDF: fix CI packaging and CodeRabbit findings - Stage pdfium.dll for the MSI fixtures (install-policy payload) and for the real package stage/portable zip; the package script downloads it before cargo. - Regenerate docs/scorecard.md for the new Preferences fields. - Text y positions convert the page height to pixels; invisible PDF text stays hidden. - Page ranges that start past the PDF are refused; zero first/last bounds are independent. --- crates/engine/src/jobs.rs | 11 +++++++---- crates/io/src/pdf_import.rs | 22 +++++++++++++++------- docs/scorecard.md | 4 ++-- packaging/windows/install-policy.json | 3 ++- packaging/windows/package.ps1 | 19 +++++++++++++++++++ 5 files changed, 45 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index ddd9ac1f68..13c36a82c1 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -957,10 +957,13 @@ pub fn import_options(prefs: &crate::prefs::Preferences) -> photocraft_io::Impor max_group_depth: prefs.file_handling.rasterize_svg_groups_deeper_than as usize, demosaic: prefs.raw_defaults.demosaic.method(), pdf_dpi: prefs.file_handling.pdf_dpi, - pdf_pages: (prefs.file_handling.pdf_first_page > 0).then(|| { - let first = prefs.file_handling.pdf_first_page; - (first, prefs.file_handling.pdf_last_page.max(first)) - }), + pdf_pages: match (prefs.file_handling.pdf_first_page, prefs.file_handling.pdf_last_page) { + (0, 0) => None, + (first, last) => { + let first = first.max(1); + Some((first, if last == 0 { u32::MAX } else { last.max(first) })) + } + }, ..Default::default() } } diff --git a/crates/io/src/pdf_import.rs b/crates/io/src/pdf_import.rs index e82bc20c84..48b8d51858 100644 --- a/crates/io/src/pdf_import.rs +++ b/crates/io/src/pdf_import.rs @@ -43,9 +43,12 @@ pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) - let total = document.pages().len().max(0) as usize; let (first, last) = match opts.pdf_pages { Some((a, b)) => { - let a = (a.max(1) as usize).min(total.max(1)); - let b = (b.max(1) as usize).min(total.max(1)); - (a.min(b), a.max(b)) + let a = a.max(1); + let b = if b == 0 { u32::MAX } else { b.max(a) }; + if u64::from(a) > total as u64 { + return Err(IoError::Unsupported(format!("PDF page range starts at {a}, but the PDF has {total} pages"))); + } + ((a as usize).min(total), (b as usize).min(total)) } None => (1, total), }; @@ -163,6 +166,7 @@ fn object_layers(page: &pdfium_render::prelude::PdfPage, opts: &ImportOptions, f use pdfium_render::prelude::*; let PageFrame { height_pt, dpi, x, rgba, width: page_width, height: page_height } = *frame; + let height_px = height_pt * dpi / 72.0; let to_x = |v: f32| f64::from(v) * dpi / 72.0; let objects = page.objects(); @@ -176,12 +180,12 @@ fn object_layers(page: &pdfium_render::prelude::PdfPage, opts: &ImportOptions, f } let Ok(bounds) = text.bounds() else { continue }; let x_px = to_x(bounds.left().value).round() + f64::from(x); - let y_px = (height_pt - to_x(bounds.top().value)).round(); + let y_px = (height_px - to_x(bounds.top().value)).round(); // The text layer's size is in points; the transform is in document pixels. // The text object does not expose its fill colour; sample the rendered page at the // text's centre so the editable layer starts with the right colour. let cx = (to_x(bounds.left().value).round().max(0.0) as u32).min(page_width.saturating_sub(1)); - let cy = ((height_pt - to_x(bounds.top().value)).round().max(0.0) as u32).min(page_height.saturating_sub(1)); + let cy = ((height_px - to_x(bounds.top().value)).round().max(0.0) as u32).min(page_height.saturating_sub(1)); let i = (cy as usize * page_width as usize + cx as usize) * 4; let color = if i + 3 < rgba.len() && rgba[i + 3] > 0 { Color::rgb(f32::from(rgba[i]) / 255.0, f32::from(rgba[i + 1]) / 255.0, f32::from(rgba[i + 2]) / 255.0) @@ -191,7 +195,7 @@ fn object_layers(page: &pdfium_render::prelude::PdfPage, opts: &ImportOptions, f let size_pt = f64::from(text.scaled_font_size().value).max(1.0); let font = text.font(); let family = if font.family().is_empty() { font.name() } else { font.family() }; - layers.push(Layer::new( + let mut layer = Layer::new( "Text", LayerContent::Text(TextLayer { text: content, @@ -201,7 +205,11 @@ fn object_layers(page: &pdfium_render::prelude::PdfPage, opts: &ImportOptions, f transform: Affine::translate(x_px, y_px), ..Default::default() }), - )); + ); + if !text.is_visible() { + layer.visible = false; + } + layers.push(layer); } PdfPageObject::Path(path) if opts.pdf_vectors => { let mut subpaths: Vec = Vec::new(); diff --git a/docs/scorecard.md b/docs/scorecard.md index a3ecd984cb..ece2924bed 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Performance](#performance) | 3 | 22 | 51 | 85 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 47 not run, 4 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 11 | 14 | 2 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 190/258, round trip ≥ 258/258 | | [Tools](#tools) | 36 | 9 | 0 | | -| [Workspace and UI](#workspace-and-ui) | 15 | 6 | 2 | settings that do nothing: 10 of 192 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 15 | 6 | 2 | settings that do nothing: 10 of 195 (target 0) | | [Type](#type) | 5 | 5 | 0 | | | [Automation](#automation) | 5 | 3 | 3 | 660 of 660 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 30 of 30 crates; panic_hunt present | @@ -209,7 +209,7 @@ Corpus floors (`crates/io/tests/it/corpus.rs`): the enforced minimums; the corpu ## Workspace and UI -**Settings that do nothing: 10 of 192** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. +**Settings that do nothing: 10 of 195** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. Unread: `general.color_picker`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `performance.legacy_compositing`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `type_.text_engine`, `enhanced_controls.scrubby_slider_acceleration`. diff --git a/packaging/windows/install-policy.json b/packaging/windows/install-policy.json index fcba1850ae..8d01fab4ac 100644 --- a/packaging/windows/install-policy.json +++ b/packaging/windows/install-policy.json @@ -15,7 +15,8 @@ ], "payloads": [ "photocraft-cli.exe", - "photocraft.exe" + "photocraft.exe", + "pdfium.dll" ], "registry": [ [ diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index d984e3930a..1f072fedb5 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -55,6 +55,23 @@ New-Item -ItemType Directory -Force -Path $Dist | Out-Null if (-not $env:PHOTOCRAFT_BUILD_SHA) { $env:PHOTOCRAFT_BUILD_SHA = (git -C $Root rev-parse HEAD 2>$null) } if (-not $env:PHOTOCRAFT_BUILD_DATE) { $env:PHOTOCRAFT_BUILD_DATE = (Get-Date).ToUniversalTime().ToString('yyyy-MM-dd') } +# PDF import loads PDFium at runtime. Stage the Windows build's DLL before cargo, so the app's +# build script copies it beside the exe and both the MSI and the portable ZIP ship it. +$PdfiumDir = Join-Path $Root "target\pdfium\$Arch" +$PdfiumDll = Join-Path $PdfiumDir 'pdfium.dll' +if (-not (Test-Path -LiteralPath $PdfiumDll)) { + $tgz = Join-Path $env:TEMP "pdfium-win-$Arch.tgz" + Invoke-Native "download PDFium ($Arch)" { + Invoke-WebRequest -Uri "https://github.com/bblanchon/pdfium-binaries/releases/latest/download/pdfium-win-$Arch.tgz" -OutFile $tgz + New-Item -ItemType Directory -Force -Path $PdfiumDir | Out-Null + tar -xzf $tgz -C $PdfiumDir + $extracted = Join-Path $PdfiumDir 'bin\pdfium.dll' + if (-not (Test-Path -LiteralPath $extracted)) { throw 'PDFium archive had no bin/pdfium.dll' } + Move-Item -Force $extracted $PdfiumDll + } +} +$env:PDFIUM_DLL = $PdfiumDll + Write-Output "PhotoCraft $Version for Windows $Arch ($Target)" if (-not $SkipBuild) { @@ -89,6 +106,7 @@ $Stage = Join-Path $TargetDir "windows-package\$Arch" Remove-Item -Recurse -Force $Stage -ErrorAction SilentlyContinue New-Item -ItemType Directory -Force -Path $Stage | Out-Null Copy-Item (Join-Path $Bin 'photocraft.exe'), (Join-Path $Bin 'photocraft-cli.exe') $Stage +Copy-Item -LiteralPath $PdfiumDll -Destination $Stage & (Join-Path $PSScriptRoot 'sign.ps1') (Join-Path $Stage 'photocraft.exe') (Join-Path $Stage 'photocraft-cli.exe') @@ -171,6 +189,7 @@ $Portable = Join-Path $TargetDir "windows-package\photocraft-$Version-windows-$A Remove-Item -Recurse -Force $Portable -ErrorAction SilentlyContinue New-Item -ItemType Directory -Force -Path $Portable | Out-Null Copy-Item (Join-Path $Stage '*.exe') $Portable +Copy-Item -LiteralPath $PdfiumDll -Destination $Portable foreach ($f in 'README.md', 'LICENSE', 'LICENSE-MIT', 'LICENSE-APACHE', 'NOTICE', 'ATTRIBUTION.md') { $p = Join-Path $Root $f if (Test-Path $p) { Copy-Item $p $Portable } From 393ccdc124ba97e529e673bca1b9c800897d8d00 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Fri, 6 Nov 2026 11:25:47 -0800 Subject: [PATCH 073/219] Match measured Free Transform edge handle zones --- .../src/canvas/transform_controls_tests.rs | 13 ++-- .../src/tools/transform_tool/pointer.rs | 65 +++++++++++-------- .../ui-egui/src/transform_hit_zone_tests.rs | 43 ++++++++++-- 3 files changed, 78 insertions(+), 43 deletions(-) diff --git a/crates/ui-egui/src/canvas/transform_controls_tests.rs b/crates/ui-egui/src/canvas/transform_controls_tests.rs index c977a84d6d..ef5e5668d3 100644 --- a/crates/ui-egui/src/canvas/transform_controls_tests.rs +++ b/crates/ui-egui/src/canvas/transform_controls_tests.rs @@ -323,18 +323,14 @@ fn move_transform_controls_show_resize_and_rotate_cursors_before_first_interacti (r.center_bottom(), egui::CursorIcon::ResizeVertical), (r.right_center(), egui::CursorIcon::ResizeHorizontal), (r.left_center(), egui::CursorIcon::ResizeHorizontal), - // An outward edge band resizes between handles. Beyond it the curved rotate arrow - // replaces the pointer (#4658). - (pos2(r.left() + r.width() * 0.25, r.top() - 11.0), egui::CursorIcon::ResizeVertical), + // Just beyond the outward edge band, the curved rotate arrow replaces the pointer (#4658). (pos2(r.left() + r.width() * 0.25, r.top() - 12.0), egui::CursorIcon::None), ] { assert_eq!(transform_controls_cursor(r, p), Some(expected), "hover at {p:?}"); assert!(transform_controls_hit(r, p), "same region must start a transform"); } assert_eq!(transform_controls_cursor(r, r.center()), Some(egui::CursorIcon::Default), "interior stays Move"); - let far = pos2(r.center().x, r.top() - 30.0); - assert_eq!(transform_controls_cursor(r, far), Some(egui::CursorIcon::None)); - assert!(!transform_controls_hit(r, far), "far hover must not consume a Move press"); + assert_eq!(transform_controls_cursor(r, pos2(r.center().x, r.top() - 30.0)), Some(egui::CursorIcon::None)); // The rotate arrow points into the nearest edge or corner. Handles and the interior have none. let into = |p: Pos2| transform_controls_turn(r, p); assert_eq!(into(pos2(r.left() + r.width() * 0.25, r.top() - 12.0)), Some(vec2(0.0, 16.0)), "top edge"); @@ -353,11 +349,10 @@ fn move_transform_controls_show_resize_and_rotate_cursors_before_first_interacti } #[test] -fn transform_control_hit_resizes_or_rotates_within_the_press_zones() { +fn transform_control_hit_keeps_move_interior_free_and_has_rotation_band() { let r = Rect::from_min_max(pos2(20.0, 30.0), pos2(100.0, 70.0)); assert!(transform_controls_hit(r, r.left_top())); assert!(transform_controls_hit(r, pos2(60.0, 18.0))); assert!(!transform_controls_hit(r, r.center())); - assert!(transform_controls_hit(r, pos2(60.0, 12.0)), "the 18-point rotation band still starts a transform"); - assert!(!transform_controls_hit(r, pos2(60.0, 11.0)), "beyond the band a press stays with Move"); + assert!(!transform_controls_hit(r, pos2(60.0, 4.0))); } diff --git a/crates/ui-egui/src/tools/transform_tool/pointer.rs b/crates/ui-egui/src/tools/transform_tool/pointer.rs index e1457bf208..04ae5ad522 100644 --- a/crates/ui-egui/src/tools/transform_tool/pointer.rs +++ b/crates/ui-egui/src/tools/transform_tool/pointer.rs @@ -19,8 +19,8 @@ pub(crate) enum Hit { /// Screen-sized corner and outward edge bands, shared with Move's unopened controls. impl TransformSession { /// Classify a document point with screen-sized corner and outward edge zones. - /// Corners outrank free edge bands; overlapping painted handles keep nearest-handle priority. - /// Only projection-scale rounding is treated as an edge on the interior side. + /// On and outward from an edge, the nearest corner or midpoint wins, with the corner + /// reach capped along the edge. Inside and beyond the edge ends use the corner radius. pub(crate) fn box_hit(quad: &[[f64; 2]; 4], p: [f64; 2], tol: f64) -> Hit { // `tol` is the existing 12 pt handle tolerance, divided by the view zoom. let corner_radius = tol * (8.0 / HANDLE_PX); @@ -30,32 +30,40 @@ impl TransformSession { let line_epsilon = tol.abs() * (1e-3 / HANDLE_PX); let distance = |q: [f64; 2]| (p[0] - q[0]).hypot(p[1] - q[1]); let interior = inside(quad, p); - let edge_distance = |i| { - let (_, q) = edge_projection(quad, i, p)?; - let d = distance(q); - (d <= edge_width && (!interior || d <= line_epsilon)).then_some(d) - }; - if let Some((i, corner_distance)) = + let edge = (0..4) + .filter_map(|i| { + let (u, q) = edge_projection(quad, i, p)?; + let d = distance(q); + (d <= edge_width && (!interior || d <= line_epsilon)).then_some((i, u, d)) + }) + .min_by(|a, b| a.2.total_cmp(&b.2)); + if let Some((i, u, _)) = edge + && (0.0..=1.0).contains(&u) + { + let next = (i + 1) % 4; + let (a, b) = (quad[i], quad[next]); + let length = (b[0] - a[0]).hypot(b[1] - a[1]); + let quarter = length * 0.25; + let tie_epsilon = line_epsilon.min(quarter * 0.5); + // Projected distances rank the handles along this edge. At the measured + // quarter-point ties, prefer the earlier handle in edge order. + let from_start = u * length; + let from_end = (1.0 - u) * length; + if from_start <= quarter + tie_epsilon && from_start <= edge_width + line_epsilon { + return Hit::Corner(i); + } + if from_end < quarter - tie_epsilon && from_end <= edge_width + line_epsilon { + return Hit::Corner(next); + } + return Hit::Edge(i); + } + if let Some((i, _)) = quad.iter().enumerate().map(|(i, &q)| (i, distance(q))).filter(|(_, d)| *d <= corner_radius).min_by(|a, b| a.1.total_cmp(&b.1)) { - // Preserve nearest-handle priority only when the midpoint itself falls in the - // corner zone. A separate midpoint must not take over a nearby corner press. - let painted_edge = (0..4) - .filter_map(|k| { - edge_distance(k)?; - let (a, b) = (quad[k], quad[(k + 1) % 4]); - let midpoint = [(a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0]; - let corner = quad[i]; - let d = distance(midpoint); - let overlaps_corner = (midpoint[0] - corner[0]).hypot(midpoint[1] - corner[1]) <= corner_radius; - (overlaps_corner && d < corner_distance).then_some((k, d)) - }) - .min_by(|a, b| a.1.total_cmp(&b.1)); - return painted_edge.map_or(Hit::Corner(i), |(k, _)| Hit::Edge(k)); + return Hit::Corner(i); } - let edge = (0..4).filter_map(|i| edge_distance(i).map(|d| (i, d))).min_by(|a, b| a.1.total_cmp(&b.1)); match edge { - Some((i, _)) => Hit::Edge(i), + Some((i, _, _)) => Hit::Edge(i), None if interior => Hit::Inside, None => Hit::Outside, } @@ -95,8 +103,8 @@ impl TransformSession { } } -/// The nearest point on an edge segment and its clamped parameter. Degenerate edges have no -/// band or normal; the other edges and corner handles remain usable. +/// The unclamped projection parameter and the nearest point on the segment. Degenerate +/// edges have no band or normal; the other edges and corner handles remain usable. fn edge_projection(quad: &[[f64; 2]; 4], i: usize, p: [f64; 2]) -> Option<(f64, [f64; 2])> { let (a, b) = (quad[i], quad[(i + 1) % 4]); let d = [b[0] - a[0], b[1] - a[1]]; @@ -104,8 +112,9 @@ fn edge_projection(quad: &[[f64; 2]; 4], i: usize, p: [f64; 2]) -> Option<(f64, if !len2.is_finite() || len2 <= 1e-18 { return None; } - let u = (((p[0] - a[0]) * d[0] + (p[1] - a[1]) * d[1]) / len2).clamp(0.0, 1.0); - Some((u, [a[0] + u * d[0], a[1] + u * d[1]])) + let u = ((p[0] - a[0]) * d[0] + (p[1] - a[1]) * d[1]) / len2; + let on_segment = u.clamp(0.0, 1.0); + Some((u, [a[0] + on_segment * d[0], a[1] + on_segment * d[1]])) } /// The shared box zones, retaining the nearest pivot's existing grab priority on small boxes. diff --git a/crates/ui-egui/src/transform_hit_zone_tests.rs b/crates/ui-egui/src/transform_hit_zone_tests.rs index 394e32a278..9cd109b9e5 100644 --- a/crates/ui-egui/src/transform_hit_zone_tests.rs +++ b/crates/ui-egui/src/transform_hit_zone_tests.rs @@ -94,13 +94,39 @@ fn small_boxes_keep_their_painted_mid_edge_handles() { } } +/// Match the measured #11097 boundaries and ties on the top edge and one point outside it. #[test] -fn a_midpoint_outside_the_corner_zone_cannot_steal_its_press() { - for zoom in [1.0, 2.0] { - let quad = [[0.0, 0.0], [20.0, 0.0], [20.0, 20.0], [0.0, 20.0]].map(|p| p.map(|v| v / zoom)); - let tol = 12.0 / zoom; - assert_eq!(TransformSession::box_hit(&quad, [7.0 / zoom, -1.0 / zoom], tol), Hit::Corner(0)); - assert_eq!(TransformSession::box_hit(&quad, [9.0 / zoom, 0.0], tol), Hit::Edge(0)); +fn measured_edge_corner_and_midpoint_zones_at_100_and_200_percent() { + use Hit::{Corner, Edge}; + let matrix: &[(f64, &[(f64, Hit)])] = &[ + (12.0, &[(3.0, Corner(0)), (4.0, Edge(0)), (5.0, Edge(0)), (6.0, Edge(0)), (9.0, Edge(0)), (10.0, Corner(1))]), + (24.0, &[(6.0, Corner(0)), (7.0, Edge(0)), (18.0, Edge(0)), (19.0, Corner(1))]), + (40.0, &[(10.0, Corner(0)), (11.0, Edge(0)), (30.0, Edge(0)), (31.0, Corner(1))]), + (100.0, &[(11.0, Corner(0)), (12.0, Edge(0))]), + ]; + for zoom in [1.0_f32, 2.0] { + let scale = |p: [f64; 2]| p.map(|v| v / f64::from(zoom)); + let xf = view(zoom); + for &(side, samples) in matrix { + let quad = [[0.0, 0.0], [side, 0.0], [side, side], [0.0, side]].map(scale); + let mut app = app(quad, zoom); + app.ui.tool_options.transform_reference_point = false; + let t = app.ui.transform.as_ref().unwrap(); + let rect = Rect::from_min_size(Pos2::ZERO, vec2(side as f32, side as f32)); + for y in [0.0, -1.0] { + for &(x, expected) in samples { + let p = scale([x, y]); + let icon = match expected { + Corner(0) => CursorIcon::ResizeNwSe, + Corner(1) => CursorIcon::ResizeNeSw, + _ => CursorIcon::ResizeVertical, + }; + assert_eq!(crate::transform_tool::box_hit(&app, t, p, 12.0 / f64::from(zoom)), expected, "{side}, {zoom}, {x}, {y}"); + assert_eq!(cursor(&app, &xf, p, Modifiers::NONE), Some(icon)); + assert_eq!(transform_controls_cursor(rect, pos2(x as f32, y as f32)), Some(icon)); + } + } + } } } @@ -169,6 +195,8 @@ fn turned_box_zones_and_resize_cursors_follow_the_quad() { let t = app.ui.transform.as_ref().unwrap(); for (p, expected, icon) in [ ([500.0, 100.0], Hit::Corner(1), CursorIcon::ResizeHorizontal), + ([111.0, 99.0], Hit::Corner(0), CursorIcon::ResizeVertical), + ([112.0, 99.0], Hit::Edge(0), CursorIcon::ResizeNeSw), ([180.0, 95.0], Hit::Edge(0), CursorIcon::ResizeNeSw), ([505.0, 150.0], Hit::Edge(1), CursorIcon::ResizeNwSe), ([180.0, 101.0], Hit::Inside, CursorIcon::Default), @@ -268,8 +296,11 @@ fn unopened_move_controls_share_hover_but_keep_the_move_press_band() { for (p, starts_transform) in [ (pos2(100.0, 100.0), true), (pos2(180.0, 95.0), true), + (pos2(180.0, 89.0), true), (pos2(505.0, 150.0), true), (pos2(518.0, 150.0), true), + (pos2(519.0, 150.0), false), + (pos2(300.0, 70.0), false), (pos2(180.0, 101.0), false), (pos2(620.0, 200.0), false), (pos2(-20.0, -20.0), false), From 90c03eba3988f69834a830318fa4e2af86bff222 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Fri, 6 Nov 2026 11:30:26 -0800 Subject: [PATCH 074/219] Format Free Transform corner fallback --- crates/ui-egui/src/tools/transform_tool/pointer.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/ui-egui/src/tools/transform_tool/pointer.rs b/crates/ui-egui/src/tools/transform_tool/pointer.rs index 04ae5ad522..49f0c3a06b 100644 --- a/crates/ui-egui/src/tools/transform_tool/pointer.rs +++ b/crates/ui-egui/src/tools/transform_tool/pointer.rs @@ -57,9 +57,7 @@ impl TransformSession { } return Hit::Edge(i); } - if let Some((i, _)) = - quad.iter().enumerate().map(|(i, &q)| (i, distance(q))).filter(|(_, d)| *d <= corner_radius).min_by(|a, b| a.1.total_cmp(&b.1)) - { + if let Some((i, _)) = quad.iter().enumerate().map(|(i, &q)| (i, distance(q))).filter(|(_, d)| *d <= corner_radius).min_by(|a, b| a.1.total_cmp(&b.1)) { return Hit::Corner(i); } match edge { From 36e7c50178460e2b45a9245c7d0079c9f3169e54 Mon Sep 17 00:00:00 2001 From: Triwarr <170976175+Triwarr@users.noreply.github.com> Date: Fri, 6 Nov 2026 20:34:01 +0100 Subject: [PATCH 075/219] PDF: transform imported paths by their object matrix - Apply PdfPageObject::matrix() to path segments: PDFium returns raw object-space points, so scaled or rotated paths landed in the wrong place. - Flip path y with the page height in pixels, not points; paths were off-canvas at any DPI other than 72. - Take fill and stroke presence from the path's draw mode: stroke-only paths no longer get a black fill, and paths that are neither filled nor stroked are skipped. - Estimate import pixel memory as retained tiles (4 B/px over all pages) plus the converting page's peak (24 B/px), defaulting to a 2 GiB budget when the caller sets none. Reuse the RGBA copy and reserve the f32 buffer fallibly. --- crates/io/src/pdf_import.rs | 49 ++++++++++++++++++++++++++++--------- 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/crates/io/src/pdf_import.rs b/crates/io/src/pdf_import.rs index 48b8d51858..34db0767d8 100644 --- a/crates/io/src/pdf_import.rs +++ b/crates/io/src/pdf_import.rs @@ -36,6 +36,9 @@ pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) - /// Per-page and per-artboard-strip pixel budgets; a poster at 300 dpi is far below these. const MAX_PAGE_PIXELS: f64 = 100_000_000.0; const MAX_UNION_PIXELS: u64 = 200_000_000; + /// The default pixel-memory budget when the caller sets none: a zero + /// [`ImportOptions::flat_max_pixel_bytes`] still bounds the import. + const DEFAULT_MAX_PIXEL_BYTES: u64 = 2 << 30; let requested = if opts.pdf_dpi.is_finite() && opts.pdf_dpi > 0.0 { f64::from(opts.pdf_dpi) } else { 300.0 }; let pdfium = bind()?; @@ -76,15 +79,20 @@ pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) - if doc_width == 0 || doc_height == 0 { return Err(IoError::Unsupported("PDF pages have no area".into())); } - // The aggregate budget: the rendered pages and the document's raster tiles both hold pixels. - let page_pixels = sizes.iter().map(|(w, h)| (w * dpi / 72.0).round().max(1.0) * (h * dpi / 72.0).round().max(1.0)).sum::(); - // The RGBA bitmap, the f32 conversion buffer (16 bytes/px) and the document tiles. - let estimate = (page_pixels * 24.0) as u64; - if opts.flat_max_pixel_bytes > 0 && estimate > opts.flat_max_pixel_bytes { + // The aggregate budget: the document keeps every page's raster tiles, while one page at a + // time is converted through the PDFium bitmap, its RGBA copy and the f32 buffer. + let page_px = |w: f64, h: f64| (w * dpi / 72.0).round().max(1.0) * (h * dpi / 72.0).round().max(1.0); + let page_pixels = sizes.iter().map(|&(w, h)| page_px(w, h)).sum::(); + let max_page_pixels = sizes.iter().map(|&(w, h)| page_px(w, h)).fold(0.0_f64, f64::max); + // 4 bytes/px of retained RGBA tiles for every page, plus 24 bytes/px (bitmap, RGBA copy, + // f32 conversion buffer) for the page being converted. + let estimate = (page_pixels * 4.0 + max_page_pixels * 24.0) as u64; + let budget = if opts.flat_max_pixel_bytes > 0 { opts.flat_max_pixel_bytes } else { DEFAULT_MAX_PIXEL_BYTES }; + if estimate > budget { return Err(IoError::Unsupported(format!( "PDF import needs about {} MB of pixel memory; the limit is {} MB (lower the import DPI)", estimate >> 20, - opts.flat_max_pixel_bytes >> 20 + budget >> 20 ))); } let mut doc = Document::new(name, Size::new(doc_width as u32, doc_height as u32), ColorMode::Rgb, SampleType::U8); @@ -122,7 +130,10 @@ pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) - // canvas and no PNG round-trip, so the peak is the page bitmap plus its tiles. let mut surface = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::U8, true)); let rgba = bitmap.as_rgba_bytes(); - let mut data = Vec::with_capacity(rgba.len()); + let mut data = Vec::new(); + if data.try_reserve_exact(rgba.len()).is_err() { + return Err(IoError::Unsupported(format!("PDF page {} needs more memory than is available", page_index + 1))); + } for chunk in rgba.as_chunks::<4>().0 { for &v in &chunk[..3] { data.push(f32::from(v) / 255.0); @@ -134,7 +145,7 @@ pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) - let rect = Rect::new(x as i32, 0, (x + width) as i32, height as i32); let mut children = vec![layer]; if opts.pdf_text || opts.pdf_vectors { - children.extend(object_layers(&page, opts, &PageFrame { height_pt: *h_pt, dpi, x, rgba: &bitmap.as_rgba_bytes(), width, height })); + children.extend(object_layers(&page, opts, &PageFrame { height_pt: *h_pt, dpi, x, rgba: &rgba, width, height })); } let group = Group { children, expanded: true, artboard: Some(Artboard::new(rect)), frame_stroke: None }; doc.layers.push(Layer::new(format!("Page {}", page_index + 1), LayerContent::Group(group))); @@ -212,12 +223,21 @@ fn object_layers(page: &pdfium_render::prelude::PdfPage, opts: &ImportOptions, f layers.push(layer); } PdfPageObject::Path(path) if opts.pdf_vectors => { + // PDFium reports the segments in object space; the object's matrix places them on + // the page. Without it, scaled or rotated paths land in the wrong place. + let matrix = path.matrix().unwrap_or(PdfMatrix::IDENTITY); + // The fill and stroke modes are exposed; the colours and dash pattern are not. + let filled = !matches!(path.fill_mode(), Ok(PdfPathFillMode::None)); + let stroked = path.is_stroked().unwrap_or(false); + if !filled && !stroked { + continue; + } let mut subpaths: Vec = Vec::new(); let mut knots: Vec = Vec::new(); let mut closed = false; let mut bezier: Vec<(f64, f64)> = Vec::new(); - for segment in path.segments().iter() { - let point = || (to_x(segment.x().value).round(), (height_pt - to_x(segment.y().value)).round()); + for segment in path.segments().transform(matrix).iter() { + let point = || (to_x(segment.x().value).round(), (height_px - to_x(segment.y().value)).round()); if segment.is_close() { closed = true; continue; @@ -257,10 +277,15 @@ fn object_layers(page: &pdfium_render::prelude::PdfPage, opts: &ImportOptions, f if subpaths.is_empty() { continue; } - let stroke = path.is_stroked().unwrap_or(false).then(|| ShapeStroke { paint: Fill::Solid(Color::BLACK), width: 1.0, ..Default::default() }); + let stroke = stroked.then(|| ShapeStroke { paint: Fill::Solid(Color::BLACK), width: 1.0, ..Default::default() }); layers.push(Layer::new( "Path", - LayerContent::Shape(ShapeLayer { path: Path::new(subpaths), fill: Some(Fill::Solid(Color::BLACK)), stroke, ..Default::default() }), + LayerContent::Shape(ShapeLayer { + path: Path::new(subpaths), + fill: filled.then_some(Fill::Solid(Color::BLACK)), + stroke, + ..Default::default() + }), )); } _ => {} From 192b075bfacb1b805c0fdcb83e0534eb0c033299 Mon Sep 17 00:00:00 2001 From: Triwarr <170976175+Triwarr@users.noreply.github.com> Date: Fri, 6 Nov 2026 21:29:06 +0100 Subject: [PATCH 076/219] PDF: map editable objects through one page-to-document transform - Paths on page 2+ were placed without the page's document X offset; text and paths now share PageFrame::to_doc, which applies the DPI scale, the Y flip and the page offset in one place. - Flip Y in pixels and round once, so text placement, text colour sampling and path points all stay in the same coordinate system. - Add unit tests for 72/300 dpi, the page-2 offset and the sampled colour; they need no PDFium. --- crates/io/src/pdf_import.rs | 71 ++++++++++++++++++++++++++++--------- 1 file changed, 55 insertions(+), 16 deletions(-) diff --git a/crates/io/src/pdf_import.rs b/crates/io/src/pdf_import.rs index 34db0767d8..0597354826 100644 --- a/crates/io/src/pdf_import.rs +++ b/crates/io/src/pdf_import.rs @@ -171,15 +171,32 @@ struct PageFrame<'a> { height: u32, } +impl PageFrame<'_> { + /// Maps a point in PDF page space (points, origin bottom-left) to document pixels: the Y flip + /// and the page's X offset both belong to this mapping. + fn to_doc(&self, x_pt: f32, y_pt: f32) -> (f64, f64) { + let x_px = f64::from(x_pt) * self.dpi / 72.0 + f64::from(self.x); + let y_px = (self.height_pt - f64::from(y_pt)) * self.dpi / 72.0; + (x_px.round(), y_px.round()) + } + + /// The rendered page's colour at local pixel (`x`, `y`), or black outside it. + fn color_at(&self, x: u32, y: u32) -> Color { + let i = (y as usize * self.width as usize + x as usize) * 4; + if i + 3 < self.rgba.len() && self.rgba[i + 3] > 0 { + Color::rgb(f32::from(self.rgba[i]) / 255.0, f32::from(self.rgba[i + 1]) / 255.0, f32::from(self.rgba[i + 2]) / 255.0) + } else { + Color::BLACK + } + } +} + /// Editable text and shape layers for one rendered page, in document pixels. #[cfg(not(target_arch = "wasm32"))] fn object_layers(page: &pdfium_render::prelude::PdfPage, opts: &ImportOptions, frame: &PageFrame) -> Vec { use pdfium_render::prelude::*; - let PageFrame { height_pt, dpi, x, rgba, width: page_width, height: page_height } = *frame; - let height_px = height_pt * dpi / 72.0; - - let to_x = |v: f32| f64::from(v) * dpi / 72.0; + let PageFrame { x, width: page_width, height: page_height, .. } = *frame; let objects = page.objects(); let mut layers = Vec::new(); for object in objects.iter() { @@ -190,19 +207,13 @@ fn object_layers(page: &pdfium_render::prelude::PdfPage, opts: &ImportOptions, f continue; } let Ok(bounds) = text.bounds() else { continue }; - let x_px = to_x(bounds.left().value).round() + f64::from(x); - let y_px = (height_px - to_x(bounds.top().value)).round(); + let (x_px, y_px) = frame.to_doc(bounds.left().value, bounds.top().value); // The text layer's size is in points; the transform is in document pixels. // The text object does not expose its fill colour; sample the rendered page at the - // text's centre so the editable layer starts with the right colour. - let cx = (to_x(bounds.left().value).round().max(0.0) as u32).min(page_width.saturating_sub(1)); - let cy = ((height_px - to_x(bounds.top().value)).round().max(0.0) as u32).min(page_height.saturating_sub(1)); - let i = (cy as usize * page_width as usize + cx as usize) * 4; - let color = if i + 3 < rgba.len() && rgba[i + 3] > 0 { - Color::rgb(f32::from(rgba[i]) / 255.0, f32::from(rgba[i + 1]) / 255.0, f32::from(rgba[i + 2]) / 255.0) - } else { - Color::BLACK - }; + // text's top-left corner so the editable layer starts with the right colour. + let cx = ((x_px - f64::from(x)).max(0.0) as u32).min(page_width.saturating_sub(1)); + let cy = (y_px.max(0.0) as u32).min(page_height.saturating_sub(1)); + let color = frame.color_at(cx, cy); let size_pt = f64::from(text.scaled_font_size().value).max(1.0); let font = text.font(); let family = if font.family().is_empty() { font.name() } else { font.family() }; @@ -237,7 +248,7 @@ fn object_layers(page: &pdfium_render::prelude::PdfPage, opts: &ImportOptions, f let mut closed = false; let mut bezier: Vec<(f64, f64)> = Vec::new(); for segment in path.segments().transform(matrix).iter() { - let point = || (to_x(segment.x().value).round(), (height_px - to_x(segment.y().value)).round()); + let point = || frame.to_doc(segment.x().value, segment.y().value); if segment.is_close() { closed = true; continue; @@ -331,3 +342,31 @@ fn bind() -> Result { })?; Ok(Pdfium::new(bindings)) } + +#[cfg(test)] +mod tests { + use super::PageFrame; + + fn frame(dpi: f64, x: u32) -> PageFrame<'static> { + PageFrame { height_pt: 842.0, dpi, x, rgba: &[], width: 595, height: 842 } + } + + #[test] + fn page_points_map_to_document_pixels() { + assert_eq!(frame(72.0, 0).to_doc(60.0, 420.0), (60.0, 422.0)); + // The same point at four times the resolution. + assert_eq!(frame(300.0, 0).to_doc(60.0, 420.0), (250.0, 1758.0)); + // Page 2 starts after page 1's width: the document X offset is part of the mapping. + assert_eq!(frame(300.0, 2480).to_doc(60.0, 420.0), (2730.0, 1758.0)); + } + + #[test] + fn samples_the_rendered_page() { + // Two pixels: white then black. + let rgba = [255u8, 255, 255, 255, 0, 0, 0, 255]; + let page = PageFrame { height_pt: 842.0, dpi: 72.0, x: 0, rgba: &rgba, width: 2, height: 1 }; + assert_eq!(page.color_at(0, 0), photocraft_doc::Color::rgb(1.0, 1.0, 1.0)); + assert_eq!(page.color_at(1, 0), photocraft_doc::Color::BLACK); + assert_eq!(page.color_at(2, 0), photocraft_doc::Color::BLACK); + } +} From fabfbb5d5a2f7a727d1658244a736874752ca19b Mon Sep 17 00:00:00 2001 From: Triwarr <170976175+Triwarr@users.noreply.github.com> Date: Fri, 6 Nov 2026 22:15:50 +0100 Subject: [PATCH 077/219] PDF: fix segment closing, admission, glyph colour and PDFium reuse - Process a closing segment's own geometry before marking the subpath closed, so a closing Bezier endpoint cannot leak control points into the next curve. - Estimate retained memory from tile-aligned page bounds (including the page's X offset) instead of raw pixels: a 1 x 10,000 px page admits its 40 tiles, not 10,000 pixels. - Sample text colour from the glyph ink (the pixel farthest from the box's dominant colour) instead of the bounds' top-left corner, which was usually background or antialiasing. - Cache the PDFium bind: pdfium-render binds once per process, so a second PDF import in one session failed with PdfiumLibraryBindingsAlreadyInitialized. - Add a PDFium-backed two-page end-to-end test with a transformed path on page 2 and non-origin red/green text at 72 and 300 dpi; it prints an explicit SKIPPED line when PDFium is unavailable. - package.ps1: pin pdfium-binaries chromium/8138, verify the archive and any cached DLL against SHA-256, drop a stale bin/pdfium.dll before extraction and check tar's exit status. --- crates/io/src/pdf_import.rs | 137 +++++++++++++++++++++++------ crates/io/tests/it/main.rs | 1 + crates/io/tests/it/pdf_import.rs | 143 +++++++++++++++++++++++++++++++ packaging/windows/package.ps1 | 40 ++++++++- 4 files changed, 291 insertions(+), 30 deletions(-) create mode 100644 crates/io/tests/it/pdf_import.rs diff --git a/crates/io/src/pdf_import.rs b/crates/io/src/pdf_import.rs index 0597354826..679198ff8b 100644 --- a/crates/io/src/pdf_import.rs +++ b/crates/io/src/pdf_import.rs @@ -82,11 +82,21 @@ pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) - // The aggregate budget: the document keeps every page's raster tiles, while one page at a // time is converted through the PDFium bitmap, its RGBA copy and the f32 buffer. let page_px = |w: f64, h: f64| (w * dpi / 72.0).round().max(1.0) * (h * dpi / 72.0).round().max(1.0); - let page_pixels = sizes.iter().map(|&(w, h)| page_px(w, h)).sum::(); let max_page_pixels = sizes.iter().map(|&(w, h)| page_px(w, h)).fold(0.0_f64, f64::max); - // 4 bytes/px of retained RGBA tiles for every page, plus 24 bytes/px (bitmap, RGBA copy, - // f32 conversion buffer) for the page being converted. - let estimate = (page_pixels * 4.0 + max_page_pixels * 24.0) as u64; + // Retained backing: a page occupies whole 256 px tiles, including the ones its document X + // offset steps into. A 1 x 10,000 px page is 40 tiles (10 MiB), not 10,000 pixels. + const TILE: u64 = 256; + let mut retained: u64 = 0; + let mut page_x: u64 = 0; + for &(w, h) in &sizes { + let px = (w * dpi / 72.0).round().max(1.0) as u64; + let py = (h * dpi / 72.0).round().max(1.0) as u64; + let tiles_x = (page_x + px).div_ceil(TILE) - page_x / TILE; + retained = retained.saturating_add(tiles_x.saturating_mul(py.div_ceil(TILE)).saturating_mul(TILE * TILE * 4)); + page_x += px + u64::from(GAP); + } + // Plus 24 bytes/px (bitmap, RGBA copy, f32 conversion buffer) for the page being converted. + let estimate = retained.saturating_add((max_page_pixels * 24.0) as u64); let budget = if opts.flat_max_pixel_bytes > 0 { opts.flat_max_pixel_bytes } else { DEFAULT_MAX_PIXEL_BYTES }; if estimate > budget { return Err(IoError::Unsupported(format!( @@ -189,6 +199,56 @@ impl PageFrame<'_> { Color::BLACK } } + + /// The ink colour of the text in `bounds` (page points). + /// + /// The box holds both glyphs and the page behind them, so sampling one corner usually finds + /// background or an antialiased edge. Take the pixel farthest from the box's most common + /// colour instead: for dark text on a light page that is a glyph core, and for light text on a + /// dark page it is the glyph rather than the background. + fn text_color(&self, left: f32, top: f32, right: f32, bottom: f32) -> Color { + let x0 = (f64::from(left) * self.dpi / 72.0).floor().max(0.0) as u32; + let x1 = (f64::from(right) * self.dpi / 72.0).ceil().clamp(0.0, f64::from(self.width)) as u32; + // PDF's Y grows up; the rendered page's Y grows down. + let y0 = ((self.height_pt - f64::from(top)) * self.dpi / 72.0).floor().max(0.0) as u32; + let y1 = ((self.height_pt - f64::from(bottom)) * self.dpi / 72.0).ceil().clamp(0.0, f64::from(self.height)) as u32; + if x1 <= x0 || y1 <= y0 { + return Color::BLACK; + } + // At most ~4096 samples, so a page-sized text box stays cheap. + let step = ((f64::from(x1 - x0) * f64::from(y1 - y0) / 4096.0).sqrt().ceil() as u32).max(1); + let mut samples: Vec<[f32; 3]> = Vec::new(); + let mut buckets: std::collections::HashMap = std::collections::HashMap::new(); + let mut y = y0; + while y < y1 { + let mut x = x0; + while x < x1 { + let c = self.color_at(x, y); + let rgb = [c.c[0], c.c[1], c.c[2]]; + let key = (rgb[0] * 31.0) as u32 * 1024 + (rgb[1] * 31.0) as u32 * 32 + (rgb[2] * 31.0) as u32; + let entry = buckets.entry(key).or_insert((0, [0.0; 3])); + entry.0 += 1; + for (sum, v) in entry.1.iter_mut().zip(rgb) { + *sum += v; + } + samples.push(rgb); + x += step; + } + y += step; + } + let Some((_, (count, sum))) = buckets.iter().max_by_key(|(_, (count, _))| *count) else { return Color::BLACK }; + let mode = [sum[0] / *count as f32, sum[1] / *count as f32, sum[2] / *count as f32]; + let mut best = mode; + let mut best_distance = -1.0f32; + for rgb in samples { + let distance = (rgb[0] - mode[0]).powi(2) + (rgb[1] - mode[1]).powi(2) + (rgb[2] - mode[2]).powi(2); + if distance > best_distance { + best_distance = distance; + best = rgb; + } + } + Color::rgb(best[0], best[1], best[2]) + } } /// Editable text and shape layers for one rendered page, in document pixels. @@ -196,7 +256,6 @@ impl PageFrame<'_> { fn object_layers(page: &pdfium_render::prelude::PdfPage, opts: &ImportOptions, frame: &PageFrame) -> Vec { use pdfium_render::prelude::*; - let PageFrame { x, width: page_width, height: page_height, .. } = *frame; let objects = page.objects(); let mut layers = Vec::new(); for object in objects.iter() { @@ -208,12 +267,9 @@ fn object_layers(page: &pdfium_render::prelude::PdfPage, opts: &ImportOptions, f } let Ok(bounds) = text.bounds() else { continue }; let (x_px, y_px) = frame.to_doc(bounds.left().value, bounds.top().value); - // The text layer's size is in points; the transform is in document pixels. - // The text object does not expose its fill colour; sample the rendered page at the - // text's top-left corner so the editable layer starts with the right colour. - let cx = ((x_px - f64::from(x)).max(0.0) as u32).min(page_width.saturating_sub(1)); - let cy = (y_px.max(0.0) as u32).min(page_height.saturating_sub(1)); - let color = frame.color_at(cx, cy); + // The text layer's size is in points; the transform is in document pixels. The + // text object does not expose its fill colour; sample the glyph ink from the page. + let color = frame.text_color(bounds.left().value, bounds.top().value, bounds.right().value, bounds.bottom().value); let size_pt = f64::from(text.scaled_font_size().value).max(1.0); let font = text.font(); let family = if font.family().is_empty() { font.name() } else { font.family() }; @@ -249,10 +305,6 @@ fn object_layers(page: &pdfium_render::prelude::PdfPage, opts: &ImportOptions, f let mut bezier: Vec<(f64, f64)> = Vec::new(); for segment in path.segments().transform(matrix).iter() { let point = || frame.to_doc(segment.x().value, segment.y().value); - if segment.is_close() { - closed = true; - continue; - } match segment.segment_type() { PdfPathSegmentType::MoveTo => { if !knots.is_empty() { @@ -281,6 +333,11 @@ fn object_layers(page: &pdfium_render::prelude::PdfPage, opts: &ImportOptions, f } PdfPathSegmentType::Unknown => {} } + // A closing segment can also be the endpoint that closes a Bézier; process it + // above before marking the subpath closed so its control points are consumed. + if segment.is_close() { + closed = true; + } } if !knots.is_empty() { subpaths.push(Subpath { closed, knots, op: PathOp::Combine }); @@ -325,22 +382,35 @@ fn effective_dpi(requested: f64, sizes: &[(f64, f64)], gap: u32, max_page: f64, } /// Loads the PDFium shared library from the executable's folder (`PDFIUM_DIR` overrides it). +/// +/// pdfium-render keeps the bindings in a process-wide static: the first import initializes them +/// and later `bind_to_library` calls fail with `PdfiumLibraryBindingsAlreadyInitialized`. Remember +/// the outcome and reuse the initialized bindings, so opening a second PDF in one session works. #[cfg(not(target_arch = "wasm32"))] fn bind() -> Result { use pdfium_render::prelude::Pdfium; - let dir = std::env::var_os("PDFIUM_DIR") - .map(std::path::PathBuf::from) - .unwrap_or_else(|| std::env::current_exe().ok().and_then(|exe| exe.parent().map(std::path::Path::to_path_buf)).unwrap_or_default()); - let library = Pdfium::pdfium_platform_library_name_at_path(&dir); - let platform = Pdfium::pdfium_platform_library_name().to_string_lossy().into_owned(); - let bindings = Pdfium::bind_to_library(&library).map_err(|e| { - IoError::Unsupported(format!( - "PDF import: cannot load PDFium from {} ({e}); put {platform} next to the executable or set PDFIUM_DIR", - library.display() - )) - })?; - Ok(Pdfium::new(bindings)) + static INIT: std::sync::OnceLock> = std::sync::OnceLock::new(); + let init = INIT.get_or_init(|| { + let dir = std::env::var_os("PDFIUM_DIR") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::env::current_exe().ok().and_then(|exe| exe.parent().map(std::path::Path::to_path_buf)).unwrap_or_default()); + let library = Pdfium::pdfium_platform_library_name_at_path(&dir); + let platform = Pdfium::pdfium_platform_library_name().to_string_lossy().into_owned(); + match Pdfium::bind_to_library(&library) { + Ok(bindings) => { + // Initializes the library inside pdfium-render's process-wide bindings. + drop(Pdfium::new(bindings)); + Ok(()) + } + Err(e) => Err(format!("PDF import: cannot load PDFium from {} ({e}); put {platform} next to the executable or set PDFIUM_DIR", library.display())), + } + }); + match init { + // The bindings are already initialized; `default()` re-uses them. + Ok(()) => Ok(Pdfium::default()), + Err(message) => Err(IoError::Unsupported(message.clone())), + } } #[cfg(test)] @@ -369,4 +439,17 @@ mod tests { assert_eq!(page.color_at(1, 0), photocraft_doc::Color::BLACK); assert_eq!(page.color_at(2, 0), photocraft_doc::Color::BLACK); } + + #[test] + fn text_colour_samples_the_glyph_ink() { + // An 8 x 4 white page with one black glyph pixel at (2, 1). + let mut rgba = vec![255u8; 8 * 4 * 4]; + let glyph = (8 + 2) * 4; + rgba[glyph..glyph + 3].fill(0); + let page = PageFrame { height_pt: 4.0, dpi: 72.0, x: 0, rgba: &rgba, width: 8, height: 4 }; + // The whole page box contains the glyph: the ink wins over the white background. + assert_eq!(page.text_color(0.0, 4.0, 8.0, 0.0), photocraft_doc::Color::BLACK); + // A box that contains only background keeps the background colour. + assert_eq!(page.text_color(4.0, 4.0, 8.0, 2.0), photocraft_doc::Color::rgb(1.0, 1.0, 1.0)); + } } diff --git a/crates/io/tests/it/main.rs b/crates/io/tests/it/main.rs index db6fc6cdf3..476818fa44 100644 --- a/crates/io/tests/it/main.rs +++ b/crates/io/tests/it/main.rs @@ -44,6 +44,7 @@ mod orientation; mod pattern_fill_psd; mod pcraft_full_composite; mod pcraft_manifest_name; +mod pdf_import; mod pdn_corpus; mod png_steps; mod png_text_metadata; diff --git a/crates/io/tests/it/pdf_import.rs b/crates/io/tests/it/pdf_import.rs new file mode 100644 index 0000000000..0e1c36eba2 --- /dev/null +++ b/crates/io/tests/it/pdf_import.rs @@ -0,0 +1,143 @@ +//! PDF import end-to-end through PDFium. +//! +//! A synthetic two-page PDF with non-origin red text, a transformed blue rectangle and green text +//! is imported at 72 and 300 DPI, and the resulting editable layers are checked against the +//! fixture's geometry and pixels. PDFium is loaded at run time: when it is unavailable (no +//! `PDFIUM_DIR`, no DLL beside the test binary) the test prints an explicit SKIPPED line and +//! returns instead of pretending the end-to-end wiring passed. + +use lopdf::{Document as PdfDocument, Object, Stream, dictionary}; +use photocraft_doc::{Color, Document, Layer, LayerContent, ShapeLayer, TextLayer}; +use photocraft_io::{ImportOptions, IoError, import_with_options_and_interrupt}; + +const PAGE_W: f64 = 200.0; +const PAGE_H: f64 = 100.0; +const GAP: f64 = 80.0; + +#[test] +fn imports_text_and_a_transformed_path_on_two_pages() { + let pdf = two_page_pdf(); + for dpi in [72.0f32, 300.0] { + let options = ImportOptions { pdf_dpi: dpi, ..Default::default() }; + let result = match import_with_options_and_interrupt("fixture.pdf", &pdf, &options, &photocraft_raster::Interrupt::NONE) { + Ok(result) => result, + Err(IoError::Unsupported(message)) if message.contains("cannot load PDFium") => { + eprintln!("SKIPPED the PDF import end-to-end test: PDFium is unavailable ({message})"); + return; + } + Err(error) => panic!("PDF import failed at {dpi} dpi: {error}"), + }; + check(&result.document, dpi); + } +} + +/// Page 1: red 48 pt text at (20, 50). Page 2: a 20 x 10 pt rectangle scaled 2x and moved by +/// (10, 5) — corners (30, 25)..(70, 45) — plus green 48 pt text at (20, 20). +fn two_page_pdf() -> Vec { + let mut pdf = PdfDocument::with_version("1.5"); + let pages_id = pdf.new_object_id(); + let font_id = pdf.add_object(dictionary! { + "Type" => "Font", + "Subtype" => "Type1", + "BaseFont" => "Helvetica", + }); + let resources = pdf.add_object(dictionary! { + "Font" => dictionary! { "F1" => font_id }, + }); + let contents = ["1 0 0 rg BT /F1 48 Tf 20 50 Td (Red) Tj ET", "0 0 1 rg q 2 0 0 2 10 5 cm 10 10 20 10 re f Q 0 1 0 rg BT /F1 48 Tf 20 20 Td (Green) Tj ET"]; + let mut kids = Vec::new(); + for content in contents { + let content_id = pdf.add_object(Stream::new(dictionary! {}, content.as_bytes().to_vec())); + let page_id = pdf.add_object(dictionary! { + "Type" => "Page", + "Parent" => pages_id, + "MediaBox" => Object::Array(vec![0.into(), 0.into(), Object::Real(PAGE_W as f32), Object::Real(PAGE_H as f32)]), + "Contents" => content_id, + "Resources" => resources, + }); + kids.push(Object::Reference(page_id)); + } + let count = kids.len() as i64; + pdf.objects.insert( + pages_id, + Object::Dictionary(dictionary! { + "Type" => "Pages", + "Kids" => Object::Array(kids), + "Count" => count, + }), + ); + let catalog = pdf.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id }); + pdf.trailer.set("Root", catalog); + let mut bytes = Vec::new(); + pdf.save_to(&mut bytes).expect("writing the PDF fixture"); + bytes +} + +fn check(document: &Document, dpi: f32) { + let scale = f64::from(dpi) / 72.0; + let page_two_x = (PAGE_W * scale).round() + GAP; + + // Page 1: the editable text keeps its position and takes the red ink from the page. + let text = text_layer(page_children(document, 1)); + assert_red(&text.color, "page 1 text"); + let x = text.transform.m[4]; + let y = text.transform.m[5]; + assert!((x - 20.0 * scale).abs() < 8.0 * scale, "page 1 text x is {x} at {dpi} dpi"); + // 48 pt Helvetica's bounds top is about 35 pt above the baseline at y = 50. + assert!((y - 15.0 * scale).abs() < 20.0 * scale, "page 1 text y is {y} at {dpi} dpi"); + + // Page 2: the transformed rectangle lands at (30, 25)..(70, 45) plus the page's document X. + let shape = shape_layer(page_children(document, 2)); + let (mut min_x, mut min_y, mut max_x, mut max_y) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); + for knot in shape.path.subpaths.iter().flat_map(|subpath| &subpath.knots) { + min_x = min_x.min(knot.anchor.x); + max_x = max_x.max(knot.anchor.x); + min_y = min_y.min(knot.anchor.y); + max_y = max_y.max(knot.anchor.y); + } + assert!((min_x - (page_two_x + 30.0 * scale)).abs() < 4.0, "path min x is {min_x}, page 2 starts at {page_two_x} ({dpi} dpi)"); + assert!((max_x - (page_two_x + 70.0 * scale)).abs() < 4.0, "path max x is {max_x} ({dpi} dpi)"); + assert!((min_y - (PAGE_H - 45.0) * scale).abs() < 4.0, "path min y is {min_y} ({dpi} dpi)"); + assert!((max_y - (PAGE_H - 25.0) * scale).abs() < 4.0, "path max y is {max_y} ({dpi} dpi)"); + + let text = text_layer(page_children(document, 2)); + assert_green(&text.color, "page 2 text"); + assert!(text.transform.m[4] > page_two_x + 10.0 * scale, "page 2 text x misses the page offset: {} <= {page_two_x}", text.transform.m[4]); +} + +fn page_children(document: &Document, page: usize) -> &[Layer] { + let name = format!("Page {page}"); + let layer = document.layers.iter().find(|layer| layer.name == name).unwrap_or_else(|| panic!("no {name} layer")); + match &layer.content { + LayerContent::Group(group) => &group.children, + _ => panic!("{name} is not a group"), + } +} + +fn text_layer(children: &[Layer]) -> &TextLayer { + children + .iter() + .find_map(|layer| match &layer.content { + LayerContent::Text(text) => Some(text), + _ => None, + }) + .expect("a text layer") +} + +fn shape_layer(children: &[Layer]) -> &ShapeLayer { + children + .iter() + .find_map(|layer| match &layer.content { + LayerContent::Shape(shape) => Some(shape), + _ => None, + }) + .expect("a shape layer") +} + +fn assert_red(color: &Color, what: &str) { + assert!(color.c[0] > 0.8 && color.c[1] < 0.2 && color.c[2] < 0.2, "{what} should be red, got {:?}", color.c); +} + +fn assert_green(color: &Color, what: &str) { + assert!(color.c[1] > 0.8 && color.c[0] < 0.2 && color.c[2] < 0.2, "{what} should be green, got {:?}", color.c); +} diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index 1f072fedb5..6efb64e770 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -57,17 +57,51 @@ if (-not $env:PHOTOCRAFT_BUILD_DATE) { $env:PHOTOCRAFT_BUILD_DATE = (Get-Date).T # PDF import loads PDFium at runtime. Stage the Windows build's DLL before cargo, so the app's # build script copies it beside the exe and both the MSI and the portable ZIP ship it. +# +# Pinned to a reviewed pdfium-binaries release: the archive is verified before extraction and a +# cached DLL is verified before reuse, so a moved or compromised "latest" cannot be packaged. +# Bump the release and both hashes together after re-checking the published build. +$PdfiumRelease = 'chromium/8138' +$PdfiumArchiveSha256 = @{ + x64 = '8d48973e3eb472dda0f5f2435e595ac043f9f2219dd71883392f549445afa8a5' + x86 = '5c32d44711a73ed8ae86b85322078da8444fc696b9326527eed2d9fcd620fc84' + arm64 = '5e04abdb36ba9b4b5c8630123eaf042036f8a62abda6e5529708fee3d35fbf4f' +} +$PdfiumDllSha256 = @{ + x64 = '71a3e63e6ab8f5746a0fc9de9bc3d0c0dc07eb60990dfd37d9db7fc891821943' + x86 = 'ee592bd01eaef454aada95ed483ceca1c4f6ce0f0b9568608f40c3861fbda035' + arm64 = '0b1b9a52b3297448cbe461ffbca1455603d85161d5e96090e1a89bd7c4a62a33' +} $PdfiumDir = Join-Path $Root "target\pdfium\$Arch" $PdfiumDll = Join-Path $PdfiumDir 'pdfium.dll' + +function Get-Sha256([string] $Path) { (Get-FileHash -Algorithm SHA256 -LiteralPath $Path).Hash.ToLowerInvariant() } + +if (Test-Path -LiteralPath $PdfiumDll) { + $actualDll = Get-Sha256 $PdfiumDll + if ($actualDll -ne $PdfiumDllSha256[$Arch]) { + Write-Warning "cached PDFium DLL is not the pinned $PdfiumRelease build ($actualDll); re-downloading" + Remove-Item -LiteralPath $PdfiumDll -Force + } +} if (-not (Test-Path -LiteralPath $PdfiumDll)) { + $expectedArchive = $PdfiumArchiveSha256[$Arch] + if (-not $expectedArchive) { throw "no pinned PDFium checksum for $Arch" } $tgz = Join-Path $env:TEMP "pdfium-win-$Arch.tgz" - Invoke-Native "download PDFium ($Arch)" { - Invoke-WebRequest -Uri "https://github.com/bblanchon/pdfium-binaries/releases/latest/download/pdfium-win-$Arch.tgz" -OutFile $tgz + Invoke-Native "download PDFium ($Arch, $PdfiumRelease)" { + Remove-Item -LiteralPath $tgz -Force -ErrorAction SilentlyContinue + Invoke-WebRequest -Uri "https://github.com/bblanchon/pdfium-binaries/releases/download/$PdfiumRelease/pdfium-win-$Arch.tgz" -OutFile $tgz + $actualArchive = Get-Sha256 $tgz + if ($actualArchive -ne $expectedArchive) { throw "PDFium $Arch archive checksum $actualArchive does not match the pinned $expectedArchive" } New-Item -ItemType Directory -Force -Path $PdfiumDir | Out-Null - tar -xzf $tgz -C $PdfiumDir + # A DLL left in bin\ by an earlier run must not be mistaken for this archive's extraction. $extracted = Join-Path $PdfiumDir 'bin\pdfium.dll' + Remove-Item -LiteralPath $extracted -Force -ErrorAction SilentlyContinue + tar -xzf $tgz -C $PdfiumDir + if ($LASTEXITCODE -ne 0) { throw "PDFium $Arch archive extraction failed with exit code $LASTEXITCODE" } if (-not (Test-Path -LiteralPath $extracted)) { throw 'PDFium archive had no bin/pdfium.dll' } Move-Item -Force $extracted $PdfiumDll + Remove-Item -LiteralPath $tgz -Force } } $env:PDFIUM_DLL = $PdfiumDll From 95908e2b92476733f25efbb922e072fa5d91a582 Mon Sep 17 00:00:00 2001 From: Triwarr <170976175+Triwarr@users.noreply.github.com> Date: Fri, 6 Nov 2026 22:16:32 +0100 Subject: [PATCH 078/219] Pin pdfium-render exactly to the verified wrapper Same reasoning as the wintab-sys pin in #11331: PDFium is FFI-loaded at run time, so a lockfile refresh must not move the wrapper under the pinned DLL release. Bump deliberately and re-check both the wrapper and the DLL release pin in packaging/windows/package.ps1. --- crates/io/Cargo.toml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index ef151b80cf..c8885d5e9b 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -52,7 +52,10 @@ resvg = { version = "0.45", default-features = false, features = ["text", "syste # The SVG fallback font search keeps each font file's character map (already in the tree through usvg). ttf-parser = { version = "0.25", default-features = false } # PDF import (pdf_import.rs): PDFium renders the pages; the direct surfaces build the document. -pdfium-render = "0.9" +# Pinned exactly, like the WinTab wrappers in photocraft-tablet: PDFium is FFI-loaded at run time +# and the DLL release is pinned separately in packaging/windows/package.ps1, so a lockfile refresh +# must not move the wrapper under it. Bump deliberately and re-check both. +pdfium-render = "=0.9.5" image = { workspace = true, features = ["png"] } [target.'cfg(target_arch = "wasm32")'.dependencies] From 4f585f704b781f9241311eaaa6db4c4b96766ed7 Mon Sep 17 00:00:00 2001 From: Triwarr <170976175+Triwarr@users.noreply.github.com> Date: Fri, 6 Nov 2026 22:48:30 +0100 Subject: [PATCH 079/219] PDF: rotate/offset-aware objects, cancellation, licences, CI PDFium - Map editable text and paths through the page's crop box and /Rotate, matching the rendered raster: box offsets, 90/180/270 rotation, rotated text transforms and rotation-aware colour sampling. - Check cancellation while traversing page objects and before returning the import; cap text colour samples at 4096. - The cached PDFium failure now says to restart the application after fixing the library path. - package.ps1 stages PDFium's LICENSE and bundled licence files flat for the MSI and the portable ZIP, tags the cache with the pinned release, and verifies freshly extracted DLLs. - ci.yml provisions the pinned PDFium for the Windows integration leg and requires it there; local runs still skip explicitly. --- .github/workflows/ci.yml | 18 +++ crates/io/src/pdf_import.rs | 193 ++++++++++++++++++++++++++----- crates/io/tests/it/pdf_import.rs | 52 +++++++-- packaging/windows/package.ps1 | 24 +++- packaging/windows/photocraft.wxs | 5 + 5 files changed, 251 insertions(+), 41 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c3597f5ee..4422f41a22 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -251,6 +251,24 @@ jobs: with: tool: cargo-nextest@0.9.150 + # The PDF import integration test loads PDFium at run time. Provision the pinned build for + # the integration leg and make an unavailable library fail there; local runs still skip. + - name: PDFium for the PDF import test + if: matrix.part == 'integration' + shell: pwsh + run: | + $dir = Join-Path $env:RUNNER_TEMP 'pdfium' + New-Item -ItemType Directory -Force $dir | Out-Null + $tgz = Join-Path $env:RUNNER_TEMP 'pdfium-win-x64.tgz' + Invoke-WebRequest -Uri 'https://github.com/bblanchon/pdfium-binaries/releases/download/chromium/8138/pdfium-win-x64.tgz' -OutFile $tgz + $hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $tgz).Hash.ToLowerInvariant() + if ($hash -ne '8d48973e3eb472dda0f5f2435e595ac043f9f2219dd71883392f549445afa8a5') { + throw "PDFium archive checksum $hash does not match the pinned chromium/8138 build" + } + tar -xzf $tgz -C $dir + "PDFIUM_DIR=$dir\bin" >> $env:GITHUB_ENV + "PHOTOCRAFT_REQUIRE_PDFIUM=1" >> $env:GITHUB_ENV + # The unit leg runs `--lib --bins` and the integration leg `--test '*'` (every # integration-test target); the feature selection stays the same, so both legs share the # dependency cache. diff --git a/crates/io/src/pdf_import.rs b/crates/io/src/pdf_import.rs index 679198ff8b..82233b2abc 100644 --- a/crates/io/src/pdf_import.rs +++ b/crates/io/src/pdf_import.rs @@ -60,19 +60,17 @@ pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) - } let page_count = last - first + 1; let count = page_count.min(MAX_PAGES); - // The page sizes in points first: the effective DPI must fit the budgets, and capping the - // raster must not change the page's physical size. - let mut sizes = Vec::with_capacity(count); + // The page geometry first: the effective DPI must fit the budgets, capping the raster must + // not change the page's physical size, and editable objects are mapped through the same page + // box and rotation the raster uses. + let mut geoms = Vec::with_capacity(count); for index in 0..count { ctl.check().map_err(|_| IoError::Cancelled)?; let page_index = first - 1 + index; let page = document.pages().get(page_index as i32).map_err(|e| IoError::Unsupported(format!("PDF: {e}")))?; - let (w, h) = (f64::from(page.width().value), f64::from(page.height().value)); - if w <= 0.0 || h <= 0.0 { - return Err(IoError::Unsupported(format!("PDF page {} has no area", page_index + 1))); - } - sizes.push((w, h)); + geoms.push(page_geometry(&page, page_index + 1)?); } + let sizes: Vec<(f64, f64)> = geoms.iter().map(|g| (g.width_pt, g.height_pt)).collect(); let dpi = effective_dpi(requested, &sizes, GAP, MAX_PAGE_PIXELS, MAX_UNION_PIXELS); let doc_width = sizes.iter().map(|(w, _)| (w * dpi / 72.0).round() as u64).sum::() + u64::from(GAP) * (count as u64 - 1); let doc_height = sizes.iter().map(|(_, h)| (h * dpi / 72.0).round() as u64).max().unwrap_or(1); @@ -108,7 +106,8 @@ pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) - let mut doc = Document::new(name, Size::new(doc_width as u32, doc_height as u32), ColorMode::Rgb, SampleType::U8); doc.resolution_dpi = dpi as f32; let mut x = 0u32; - for (index, (w_pt, h_pt)) in sizes.iter().enumerate() { + for (index, geometry) in geoms.iter().enumerate() { + let (w_pt, h_pt) = (geometry.width_pt, geometry.height_pt); ctl.check().map_err(|_| IoError::Cancelled)?; let page_index = first - 1 + index; let target_width = (w_pt * dpi / 72.0).round().max(1.0) as i32; @@ -155,7 +154,8 @@ pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) - let rect = Rect::new(x as i32, 0, (x + width) as i32, height as i32); let mut children = vec![layer]; if opts.pdf_text || opts.pdf_vectors { - children.extend(object_layers(&page, opts, &PageFrame { height_pt: *h_pt, dpi, x, rgba: &rgba, width, height })); + let frame = PageFrame { geometry: *geometry, dpi, x, rgba: &rgba, width, height }; + children.extend(object_layers(&page, opts, &frame, ctl)?); } let group = Group { children, expanded: true, artboard: Some(Artboard::new(rect)), frame_stroke: None }; doc.layers.push(Layer::new(format!("Page {}", page_index + 1), LayerContent::Group(group))); @@ -171,9 +171,54 @@ pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) - Ok(ImportResult { warnings, document: doc, source_read_only: false, preview_only: false }) } -/// One rendered page's frame: its point height, DPI, document x, and the RGBA pixels. -struct PageFrame<'a> { +/// A page's visible area and rotation, in PDF points. +#[derive(Clone, Copy)] +struct PageGeometry { + /// Displayed size (rotation applied): the size the raster is rendered into. + width_pt: f64, height_pt: f64, + /// The crop box origin in unrotated PDF user space. + origin_x: f64, + origin_y: f64, + /// The crop box size in unrotated PDF user space. + crop_w: f64, + crop_h: f64, + /// Clockwise rotation the renderer applies: 0, 90, 180 or 270. + rotation: u16, +} + +/// The page's visible box and rotation. PDF page objects stay in unrotated user space, while the +/// raster is rendered through the crop box and the page's `/Rotate`, so editable layers must use +/// the same transform. +#[cfg(not(target_arch = "wasm32"))] +fn page_geometry(page: &pdfium_render::prelude::PdfPage, page_number: usize) -> Result { + let width_pt = f64::from(page.width().value); + let height_pt = f64::from(page.height().value); + if width_pt <= 0.0 || height_pt <= 0.0 { + return Err(IoError::Unsupported(format!("PDF page {page_number} has no area"))); + } + let rotation = page.rotation().map(|r| r.as_degrees() as u16).unwrap_or(0); + let (origin_x, origin_y, crop_w, crop_h) = match page.boundaries().crop().or_else(|_| page.boundaries().media()) { + Ok(boundary) => { + let r = boundary.bounds; + (f64::from(r.left().value), f64::from(r.bottom().value), f64::from(r.right().value - r.left().value), f64::from(r.top().value - r.bottom().value)) + } + Err(_) => (0.0, 0.0, width_pt, height_pt), + }; + // A malformed box falls back to the displayed size; 90 and 270 swap the axes. + let (crop_w, crop_h) = if crop_w > 0.0 && crop_h > 0.0 { + (crop_w, crop_h) + } else if rotation == 90 || rotation == 270 { + (height_pt, width_pt) + } else { + (width_pt, height_pt) + }; + Ok(PageGeometry { width_pt, height_pt, origin_x, origin_y, crop_w, crop_h, rotation }) +} + +/// One rendered page's frame: its geometry, DPI, document x, and the RGBA pixels. +struct PageFrame<'a> { + geometry: PageGeometry, dpi: f64, x: u32, rgba: &'a [u8], @@ -182,12 +227,38 @@ struct PageFrame<'a> { } impl PageFrame<'_> { - /// Maps a point in PDF page space (points, origin bottom-left) to document pixels: the Y flip - /// and the page's X offset both belong to this mapping. + /// Maps a point in PDF page space to the page's own pixels: crop box, rotation and scale. + fn to_page_px(&self, x_pt: f32, y_pt: f32) -> (f64, f64) { + let g = &self.geometry; + let u = f64::from(x_pt) - g.origin_x; + let v = f64::from(y_pt) - g.origin_y; + let (dx, dy) = match g.rotation { + 90 => (v, u), + 180 => (g.crop_w - u, v), + 270 => (g.crop_h - v, g.crop_w - u), + _ => (u, g.crop_h - v), + }; + let scale = self.dpi / 72.0; + (dx * scale, dy * scale) + } + + /// Maps a point in PDF page space to document pixels: the page's own pixels plus its X offset. fn to_doc(&self, x_pt: f32, y_pt: f32) -> (f64, f64) { - let x_px = f64::from(x_pt) * self.dpi / 72.0 + f64::from(self.x); - let y_px = (self.height_pt - f64::from(y_pt)) * self.dpi / 72.0; - (x_px.round(), y_px.round()) + let (px, py) = self.to_page_px(x_pt, y_pt); + ((px + f64::from(self.x)).round(), py.round()) + } + + /// The text layer's transform at document pixel (`x`, `y`): the page rotation turns the text + /// layout too, so rotated pages get rotated editable text rather than upright text at a + /// rotated position. + fn text_transform(&self, x: f64, y: f64) -> Affine { + let (a, b, c, d) = match self.geometry.rotation { + 90 => (0.0, 1.0, -1.0, 0.0), + 180 => (-1.0, 0.0, 0.0, -1.0), + 270 => (0.0, -1.0, 1.0, 0.0), + _ => (1.0, 0.0, 0.0, 1.0), + }; + Affine { m: [a, b, c, d, x, y] } } /// The rendered page's colour at local pixel (`x`, `y`), or black outside it. @@ -207,22 +278,33 @@ impl PageFrame<'_> { /// colour instead: for dark text on a light page that is a glyph core, and for light text on a /// dark page it is the glyph rather than the background. fn text_color(&self, left: f32, top: f32, right: f32, bottom: f32) -> Color { - let x0 = (f64::from(left) * self.dpi / 72.0).floor().max(0.0) as u32; - let x1 = (f64::from(right) * self.dpi / 72.0).ceil().clamp(0.0, f64::from(self.width)) as u32; - // PDF's Y grows up; the rendered page's Y grows down. - let y0 = ((self.height_pt - f64::from(top)) * self.dpi / 72.0).floor().max(0.0) as u32; - let y1 = ((self.height_pt - f64::from(bottom)) * self.dpi / 72.0).ceil().clamp(0.0, f64::from(self.height)) as u32; + // The box's four corners through the same rotation-aware mapping as the glyphs. + let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); + for (x, y) in [(left, bottom), (left, top), (right, top), (right, bottom)] { + let (px, py) = self.to_page_px(x, y); + x0 = x0.min(px); + x1 = x1.max(px); + y0 = y0.min(py); + y1 = y1.max(py); + } + let x0 = x0.floor().max(0.0) as u32; + let x1 = x1.ceil().clamp(0.0, f64::from(self.width)) as u32; + let y0 = y0.floor().max(0.0) as u32; + let y1 = y1.ceil().clamp(0.0, f64::from(self.height)) as u32; if x1 <= x0 || y1 <= y0 { return Color::BLACK; } - // At most ~4096 samples, so a page-sized text box stays cheap. + // At most 4096 samples, so a page-sized text box stays cheap. let step = ((f64::from(x1 - x0) * f64::from(y1 - y0) / 4096.0).sqrt().ceil() as u32).max(1); let mut samples: Vec<[f32; 3]> = Vec::new(); let mut buckets: std::collections::HashMap = std::collections::HashMap::new(); let mut y = y0; - while y < y1 { + 'rows: while y < y1 { let mut x = x0; while x < x1 { + if samples.len() >= 4096 { + break 'rows; + } let c = self.color_at(x, y); let rgb = [c.c[0], c.c[1], c.c[2]]; let key = (rgb[0] * 31.0) as u32 * 1024 + (rgb[1] * 31.0) as u32 * 32 + (rgb[2] * 31.0) as u32; @@ -253,12 +335,15 @@ impl PageFrame<'_> { /// Editable text and shape layers for one rendered page, in document pixels. #[cfg(not(target_arch = "wasm32"))] -fn object_layers(page: &pdfium_render::prelude::PdfPage, opts: &ImportOptions, frame: &PageFrame) -> Vec { +fn object_layers(page: &pdfium_render::prelude::PdfPage, opts: &ImportOptions, frame: &PageFrame, ctl: &Interrupt) -> Result, IoError> { use pdfium_render::prelude::*; let objects = page.objects(); let mut layers = Vec::new(); for object in objects.iter() { + if ctl.check().is_err() { + return Err(IoError::Cancelled); + } match object { PdfPageObject::Text(text) if opts.pdf_text => { let content = text.text(); @@ -280,7 +365,7 @@ fn object_layers(page: &pdfium_render::prelude::PdfPage, opts: &ImportOptions, f font_family: family, size_pt: size_pt as f32, color, - transform: Affine::translate(x_px, y_px), + transform: frame.text_transform(x_px, y_px), ..Default::default() }), ); @@ -359,7 +444,7 @@ fn object_layers(page: &pdfium_render::prelude::PdfPage, opts: &ImportOptions, f _ => {} } } - layers + Ok(layers) } /// The highest DPI not above `requested` that keeps every page and the artboard strip within @@ -403,7 +488,10 @@ fn bind() -> Result { drop(Pdfium::new(bindings)); Ok(()) } - Err(e) => Err(format!("PDF import: cannot load PDFium from {} ({e}); put {platform} next to the executable or set PDFIUM_DIR", library.display())), + Err(e) => Err(format!( + "PDF import: cannot load PDFium from {} ({e}); put {platform} next to the executable or set PDFIUM_DIR, then restart the application", + library.display() + )), } }); match init { @@ -415,10 +503,14 @@ fn bind() -> Result { #[cfg(test)] mod tests { - use super::PageFrame; + use super::{PageFrame, PageGeometry}; + + fn geometry(rotation: u16) -> PageGeometry { + PageGeometry { width_pt: 595.0, height_pt: 842.0, origin_x: 0.0, origin_y: 0.0, crop_w: 595.0, crop_h: 842.0, rotation } + } fn frame(dpi: f64, x: u32) -> PageFrame<'static> { - PageFrame { height_pt: 842.0, dpi, x, rgba: &[], width: 595, height: 842 } + PageFrame { geometry: geometry(0), dpi, x, rgba: &[], width: 595, height: 842 } } #[test] @@ -430,11 +522,41 @@ mod tests { assert_eq!(frame(300.0, 2480).to_doc(60.0, 420.0), (2730.0, 1758.0)); } + #[test] + fn crop_box_offsets_and_rotations_map_like_the_raster() { + // A 200 x 100 pt crop box at (10, 20), displayed rotated 90 degrees clockwise: 100 x 200. + let rotated = |rotation| PageFrame { + geometry: PageGeometry { width_pt: 100.0, height_pt: 200.0, origin_x: 10.0, origin_y: 20.0, crop_w: 200.0, crop_h: 100.0, rotation }, + dpi: 72.0, + x: 0, + rgba: &[], + width: 100, + height: 200, + }; + // At 72 dpi the rectangle (20, 30)..(60, 70) lands at (10, 10)..(50, 50). + assert_eq!(rotated(90).to_doc(20.0, 30.0), (10.0, 10.0)); + assert_eq!(rotated(90).to_doc(60.0, 70.0), (50.0, 50.0)); + // 180 mirrors X; 270 swaps the axes back. + assert_eq!(rotated(180).to_doc(20.0, 30.0), (190.0, 10.0)); + assert_eq!(rotated(270).to_doc(20.0, 30.0), (90.0, 190.0)); + // The page's document X offset is added after the page-space mapping. + let mut offset = rotated(90); + offset.x = 500; + assert_eq!(offset.to_doc(20.0, 30.0), (510.0, 10.0)); + } + #[test] fn samples_the_rendered_page() { // Two pixels: white then black. let rgba = [255u8, 255, 255, 255, 0, 0, 0, 255]; - let page = PageFrame { height_pt: 842.0, dpi: 72.0, x: 0, rgba: &rgba, width: 2, height: 1 }; + let page = PageFrame { + geometry: PageGeometry { width_pt: 2.0, height_pt: 1.0, origin_x: 0.0, origin_y: 0.0, crop_w: 2.0, crop_h: 1.0, rotation: 0 }, + dpi: 72.0, + x: 0, + rgba: &rgba, + width: 2, + height: 1, + }; assert_eq!(page.color_at(0, 0), photocraft_doc::Color::rgb(1.0, 1.0, 1.0)); assert_eq!(page.color_at(1, 0), photocraft_doc::Color::BLACK); assert_eq!(page.color_at(2, 0), photocraft_doc::Color::BLACK); @@ -446,7 +568,14 @@ mod tests { let mut rgba = vec![255u8; 8 * 4 * 4]; let glyph = (8 + 2) * 4; rgba[glyph..glyph + 3].fill(0); - let page = PageFrame { height_pt: 4.0, dpi: 72.0, x: 0, rgba: &rgba, width: 8, height: 4 }; + let page = PageFrame { + geometry: PageGeometry { width_pt: 8.0, height_pt: 4.0, origin_x: 0.0, origin_y: 0.0, crop_w: 8.0, crop_h: 4.0, rotation: 0 }, + dpi: 72.0, + x: 0, + rgba: &rgba, + width: 8, + height: 4, + }; // The whole page box contains the glyph: the ink wins over the white background. assert_eq!(page.text_color(0.0, 4.0, 8.0, 0.0), photocraft_doc::Color::BLACK); // A box that contains only background keeps the background colour. diff --git a/crates/io/tests/it/pdf_import.rs b/crates/io/tests/it/pdf_import.rs index 0e1c36eba2..9bee105b0c 100644 --- a/crates/io/tests/it/pdf_import.rs +++ b/crates/io/tests/it/pdf_import.rs @@ -16,24 +16,30 @@ const GAP: f64 = 80.0; #[test] fn imports_text_and_a_transformed_path_on_two_pages() { - let pdf = two_page_pdf(); + let pdf = three_page_pdf(); for dpi in [72.0f32, 300.0] { let options = ImportOptions { pdf_dpi: dpi, ..Default::default() }; let result = match import_with_options_and_interrupt("fixture.pdf", &pdf, &options, &photocraft_raster::Interrupt::NONE) { Ok(result) => result, Err(IoError::Unsupported(message)) if message.contains("cannot load PDFium") => { eprintln!("SKIPPED the PDF import end-to-end test: PDFium is unavailable ({message})"); + // The Windows integration leg provisions PDFium and sets this: there a missing + // library must fail rather than let the runner report an unreached pass. + assert!(std::env::var_os("PHOTOCRAFT_REQUIRE_PDFIUM").is_none(), "PDFium is required for this test but could not load: {message}"); return; } Err(error) => panic!("PDF import failed at {dpi} dpi: {error}"), }; check(&result.document, dpi); + eprintln!("PDF import executed at {dpi} dpi: {} page groups, layer checks passed", result.document.layers.len()); } } /// Page 1: red 48 pt text at (20, 50). Page 2: a 20 x 10 pt rectangle scaled 2x and moved by -/// (10, 5) — corners (30, 25)..(70, 45) — plus green 48 pt text at (20, 20). -fn two_page_pdf() -> Vec { +/// (10, 5) - corners (30, 25)..(70, 45) - plus green 48 pt text at (20, 20). Page 3: a crop box +/// offset to (10, 20, 210, 120) and rotated 90 degrees clockwise, with a 40 x 40 pt rectangle at +/// (20, 30)..(60, 70) that renders at (10, 10)..(50, 50) on a 100 x 200 page. +fn three_page_pdf() -> Vec { let mut pdf = PdfDocument::with_version("1.5"); let pages_id = pdf.new_object_id(); let font_id = pdf.add_object(dictionary! { @@ -44,17 +50,25 @@ fn two_page_pdf() -> Vec { let resources = pdf.add_object(dictionary! { "Font" => dictionary! { "F1" => font_id }, }); - let contents = ["1 0 0 rg BT /F1 48 Tf 20 50 Td (Red) Tj ET", "0 0 1 rg q 2 0 0 2 10 5 cm 10 10 20 10 re f Q 0 1 0 rg BT /F1 48 Tf 20 20 Td (Green) Tj ET"]; + let pages = [ + ("1 0 0 rg BT /F1 48 Tf 20 50 Td (Red) Tj ET", [0.0f32, 0.0, PAGE_W as f32, PAGE_H as f32], None), + ("0 0 1 rg q 2 0 0 2 10 5 cm 10 10 20 10 re f Q 0 1 0 rg BT /F1 48 Tf 20 20 Td (Green) Tj ET", [0.0, 0.0, PAGE_W as f32, PAGE_H as f32], None), + ("0 0 1 rg 20 30 40 40 re f 0 0.5 0 rg BT /F1 24 Tf 40 60 Td (Rot) Tj ET", [10.0, 20.0, 210.0, 120.0], Some(90i64)), + ]; let mut kids = Vec::new(); - for content in contents { + for (content, [left, bottom, right, top], rotate) in pages { let content_id = pdf.add_object(Stream::new(dictionary! {}, content.as_bytes().to_vec())); - let page_id = pdf.add_object(dictionary! { + let mut page = dictionary! { "Type" => "Page", "Parent" => pages_id, - "MediaBox" => Object::Array(vec![0.into(), 0.into(), Object::Real(PAGE_W as f32), Object::Real(PAGE_H as f32)]), + "MediaBox" => Object::Array(vec![Object::Real(left), Object::Real(bottom), Object::Real(right), Object::Real(top)]), "Contents" => content_id, "Resources" => resources, - }); + }; + if let Some(rotate) = rotate { + page.set("Rotate", rotate); + } + let page_id = pdf.add_object(page); kids.push(Object::Reference(page_id)); } let count = kids.len() as i64; @@ -103,6 +117,28 @@ fn check(document: &Document, dpi: f32) { let text = text_layer(page_children(document, 2)); assert_green(&text.color, "page 2 text"); assert!(text.transform.m[4] > page_two_x + 10.0 * scale, "page 2 text x misses the page offset: {} <= {page_two_x}", text.transform.m[4]); + + // Page 3: the crop box (10, 20, 210, 120) and /Rotate 90 put the rectangle at (10..50, 10..50). + let shape = shape_layer(page_children(document, 3)); + let (mut min_x, mut min_y, mut max_x, mut max_y) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); + for knot in shape.path.subpaths.iter().flat_map(|subpath| &subpath.knots) { + min_x = min_x.min(knot.anchor.x); + max_x = max_x.max(knot.anchor.x); + min_y = min_y.min(knot.anchor.y); + max_y = max_y.max(knot.anchor.y); + } + let page_three_x = 2.0 * (PAGE_W * scale).round() + 2.0 * GAP; + assert!((min_x - (page_three_x + 10.0 * scale)).abs() < 3.0, "rotated path min x is {min_x}, page 3 starts at {page_three_x} ({dpi} dpi)"); + assert!((max_x - (page_three_x + 50.0 * scale)).abs() < 3.0, "rotated path max x is {max_x} ({dpi} dpi)"); + assert!((min_y - 10.0 * scale).abs() < 3.0, "rotated path min y is {min_y} ({dpi} dpi)"); + assert!((max_y - 50.0 * scale).abs() < 3.0, "rotated path max y is {max_y} ({dpi} dpi)"); + + // Rotated text: the layer's transform carries the 90 degree turn, not just its position. + let text = text_layer(page_children(document, 3)); + let m = text.transform.m; + assert!(m[0].abs() < 0.01 && (m[1] - 1.0).abs() < 0.01 && (m[2] + 1.0).abs() < 0.01 && m[3].abs() < 0.01, "page 3 text rotation is {m:?}"); + assert!(m[4] > page_three_x, "page 3 text x misses the page offset: {} <= {page_three_x}", m[4]); + assert!(text.color.c[1] > 0.3 && text.color.c[0] < 0.2 && text.color.c[2] < 0.2, "page 3 text should be green, got {:?}", text.color.c); } fn page_children(document: &Document, page: usize) -> &[Layer] { diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index 6efb64e770..8b4e8d0fea 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -74,9 +74,17 @@ $PdfiumDllSha256 = @{ } $PdfiumDir = Join-Path $Root "target\pdfium\$Arch" $PdfiumDll = Join-Path $PdfiumDir 'pdfium.dll' +$PdfiumLicense = Join-Path $PdfiumDir 'LICENSE' +$PdfiumLicenses = Join-Path $PdfiumDir 'licenses' +$PdfiumTag = Join-Path $PdfiumDir 'release.txt' function Get-Sha256([string] $Path) { (Get-FileHash -Algorithm SHA256 -LiteralPath $Path).Hash.ToLowerInvariant() } +# A cache from another pinned release (or missing its licence set) is discarded whole. +$cachedRelease = if (Test-Path -LiteralPath $PdfiumTag) { (Get-Content -Raw -LiteralPath $PdfiumTag).Trim() } else { '' } +if ($cachedRelease -ne $PdfiumRelease) { + Remove-Item -LiteralPath $PdfiumDir -Recurse -Force -ErrorAction SilentlyContinue +} if (Test-Path -LiteralPath $PdfiumDll) { $actualDll = Get-Sha256 $PdfiumDll if ($actualDll -ne $PdfiumDllSha256[$Arch]) { @@ -84,7 +92,7 @@ if (Test-Path -LiteralPath $PdfiumDll) { Remove-Item -LiteralPath $PdfiumDll -Force } } -if (-not (Test-Path -LiteralPath $PdfiumDll)) { +if (-not ((Test-Path -LiteralPath $PdfiumDll) -and (Test-Path -LiteralPath $PdfiumLicense) -and (Test-Path -LiteralPath $PdfiumLicenses))) { $expectedArchive = $PdfiumArchiveSha256[$Arch] if (-not $expectedArchive) { throw "no pinned PDFium checksum for $Arch" } $tgz = Join-Path $env:TEMP "pdfium-win-$Arch.tgz" @@ -101,11 +109,23 @@ if (-not (Test-Path -LiteralPath $PdfiumDll)) { if ($LASTEXITCODE -ne 0) { throw "PDFium $Arch archive extraction failed with exit code $LASTEXITCODE" } if (-not (Test-Path -LiteralPath $extracted)) { throw 'PDFium archive had no bin/pdfium.dll' } Move-Item -Force $extracted $PdfiumDll + Set-Content -LiteralPath $PdfiumTag -Value $PdfiumRelease -NoNewline + if ((Get-Sha256 $PdfiumDll) -ne $PdfiumDllSha256[$Arch]) { throw "extracted PDFium $Arch DLL checksum does not match the pinned build" } Remove-Item -LiteralPath $tgz -Force } } $env:PDFIUM_DLL = $PdfiumDll +# PDFium's BSD licence and the licences of the libraries bundled into its DLL ship beside it. They +# are staged flat with a `pdfium-` prefix, so both the MSI's INSTALLFOLDER and the portable root +# carry them whether the DLL came from this download or the cache. +function Stage-PdfiumLicences([string] $Dest) { + Copy-Item -LiteralPath $PdfiumLicense -Destination (Join-Path $Dest 'pdfium-LICENSE.txt') -Force + Get-ChildItem -LiteralPath $PdfiumLicenses -File | ForEach-Object { + Copy-Item -LiteralPath $_.FullName -Destination (Join-Path $Dest ("pdfium-license-" + $_.Name)) -Force + } +} + Write-Output "PhotoCraft $Version for Windows $Arch ($Target)" if (-not $SkipBuild) { @@ -141,6 +161,7 @@ Remove-Item -Recurse -Force $Stage -ErrorAction SilentlyContinue New-Item -ItemType Directory -Force -Path $Stage | Out-Null Copy-Item (Join-Path $Bin 'photocraft.exe'), (Join-Path $Bin 'photocraft-cli.exe') $Stage Copy-Item -LiteralPath $PdfiumDll -Destination $Stage +Stage-PdfiumLicences $Stage & (Join-Path $PSScriptRoot 'sign.ps1') (Join-Path $Stage 'photocraft.exe') (Join-Path $Stage 'photocraft-cli.exe') @@ -224,6 +245,7 @@ Remove-Item -Recurse -Force $Portable -ErrorAction SilentlyContinue New-Item -ItemType Directory -Force -Path $Portable | Out-Null Copy-Item (Join-Path $Stage '*.exe') $Portable Copy-Item -LiteralPath $PdfiumDll -Destination $Portable +Stage-PdfiumLicences $Portable foreach ($f in 'README.md', 'LICENSE', 'LICENSE-MIT', 'LICENSE-APACHE', 'NOTICE', 'ATTRIBUTION.md') { $p = Join-Path $Root $f if (Test-Path $p) { Copy-Item $p $Portable } diff --git a/packaging/windows/photocraft.wxs b/packaging/windows/photocraft.wxs index 020f7aa89c..f727880a5a 100644 --- a/packaging/windows/photocraft.wxs +++ b/packaging/windows/photocraft.wxs @@ -33,6 +33,7 @@ + + + + From 20142544b02e3a4488e3fff18b3729cc9e2116e5 Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 6 Nov 2026 23:03:17 +0100 Subject: [PATCH 080/219] Regenerate docs/scorecard.md after merging main Co-Authored-By: Claude Opus 5.5 --- docs/scorecard.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/scorecard.md b/docs/scorecard.md index a3158616b2..dee4b48c6d 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -11,7 +11,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Tools](#tools) | 36 | 9 | 0 | | | [Workspace and UI](#workspace-and-ui) | 15 | 6 | 2 | settings that do nothing: 10 of 192 (target 0) | | [Type](#type) | 5 | 5 | 0 | | -| [Automation](#automation) | 5 | 3 | 3 | 661 of 661 menu items live (100.0%) | +| [Automation](#automation) | 5 | 4 | 2 | 661 of 661 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 30 of 30 crates; panic_hunt present | | [Distribution](#distribution) | 5 | 5 | 1 | | | [Open bugs](#open-bugs) | 31 | 1 | 0 | | @@ -268,7 +268,7 @@ Menu wiring (`docs/parity-checklist.md`): 661 of 661 menu items live (100.0%). T | AUTO-219-5 | Batch: subfolders, open documents, naming template, progress/cancel, error log, stop | partial | [#219](https://github.com/storytold/photocraft/issues/219) | file_cmds/batch_run.rs and batch_naming.rs: flat subfolder output and last-result-wins names measured in Photoshop 2026; live open-document source; file naming components and serials; UTF-16 LE error log; stop-on-error; CLI and Batch dialog, #4579. Desktop Esc asks Continue/Stop, retaining one plan and error log; a read-only canvas preview shows each scratch-session input without a progress bar. Inputs are not live tabs with Layers panel content, so full window parity remains open. | | AUTO-219-6 | Plug-in ABI v2: panel kind, progress/cancel calls, read other layers, Last Filter repeats | missing | [#219](https://github.com/storytold/photocraft/issues/219) | runtime.rs:15 ABI_VERSION = 1; manifest.rs:18 Kind::Filter only | | AUTO-219-7 | Local ONNX models behind a flag, managed in Preferences; subject select, background removal | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No ONNX runtime dependency | -| AUTO-219-8 | xtask check that every panel button routes through a command id | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No such xtask check | +| AUTO-219-8 | xtask check that every panel button routes through a command id | partial | [#219](https://github.com/storytold/photocraft/issues/219) | cargo xtask panel-commands (run by xtask ci and CI) fails on a shell click handler that changes the document or session directly; 1 allowlisted function left (panels/color.rs color_field assigns the foreground/background colour instead of tools.setColors) | | AUTO-219-9 | Headless commands for in-canvas type editing, Liquify/Puppet strokes, transform handles | done | [#219](https://github.com/storytold/photocraft/issues/219) | type.hitTest, type.caret, type.navigate; filter.liquify strokes, edit.puppetWarp pins, type.edit, edit.transform | | AUTO-219-10 | Agent acceptance suite grown to 30-50 workflows | partial | [#219](https://github.com/storytold/photocraft/issues/219) | automation/tests/it/agent_tasks.rs runs 10 workflows over MCP | From ed0b2a726b617f8f17d57e34ecbd62d6c9f63034 Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 6 Nov 2026 23:10:58 +0100 Subject: [PATCH 081/219] Address CodeRabbit review: draw pattern scales below 0.1 % (#9256) A preview at a reduced work scale (4 %) takes a stored 2 % pattern to 0.08 %, which Placement::anchored and the GPU planner's placement treated as invalid and drew at 100 %. Any positive finite scale now draws, floored at 1e-6 to keep tile coordinates finite. Co-Authored-By: Claude Opus 5.5 --- crates/compose/src/pattern.rs | 32 +++++++++++++++++++++++++++++++- crates/gpu/src/plan/lower.rs | 2 +- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/crates/compose/src/pattern.rs b/crates/compose/src/pattern.rs index b351bdef06..30c64c7bdf 100644 --- a/crates/compose/src/pattern.rs +++ b/crates/compose/src/pattern.rs @@ -344,6 +344,9 @@ impl Tile { /// Most taps per axis of the minification filter (the WGSL mirror uses the same cap). const MAX_TAPS: usize = 8; +/// The smallest pattern scale a placement draws at (a fraction): keeps tile coordinates finite. +pub const MIN_PLACEMENT_SCALE: f64 = 1e-6; + /// Taps per axis for a placement whose inverse scale is `inv_scale`: one pixel covers /// `inv_scale` tile pixels, so that many taps (capped) box-filter it. 1 at 100 % and above. fn pixel_taps(inv_scale: f64) -> usize { @@ -387,7 +390,9 @@ impl Placement { let base = if link { anchor } else { (0.0, 0.0) }; let origin = (base.0 + f64::from(phase.0), base.1 + f64::from(phase.1)); let s = f64::from(scale); - let inv_scale = if s.is_finite() && s > 1e-3 { 1.0 / s } else { 1.0 }; + // Any positive scale draws: a preview at a reduced work scale takes a stored 2 % pattern + // well below 0.1 %. Only 0, negatives and non-finite values fall back to 100 %. + let inv_scale = if s.is_finite() && s > 0.0 { 1.0 / s.max(MIN_PLACEMENT_SCALE) } else { 1.0 }; let a = f64::from(angle).to_radians(); Placement { origin, cs: (a.cos(), a.sin()), inv_scale } } @@ -608,6 +613,31 @@ mod tests { assert_eq!((row[0][0], row[1][0]), (0.0, 1.0)); } + /// Review of #9256: a preview at 4 % work scale draws a stored 2 % pattern at 0.08 %, which + /// used to fall back to 100 % and show the stripes full size. + #[test] + fn placement_keeps_scales_below_a_tenth_of_a_percent() { + let place = Placement::new(Rect::EMPTY, false, (0.0, 0.0), 0.0008, 0.0); + let (u, v) = place.map(1.0, 2.0); + assert!((u - 1250.0).abs() < 1e-3 && (v - 2500.0).abs() < 1e-3, "{u},{v}"); + // 1-px dark / light stripes: at 100 % the pixels alternate 0 / 1; at 0.08 % each pixel + // covers 1250 of them, so every pixel is their mean. + let mut s = Surface::new(PixelFormat::RGBA8); + s.fill_rect(Rect::new(0, 0, 8, 8), &[1.0, 1.0, 1.0, 1.0]); + for x in (0..8).step_by(2) { + s.fill_rect(Rect::new(x, 0, x + 1, 8), &[0.0, 0.0, 0.0, 1.0]); + } + let t = Tile::new(&Pattern::new("stripes", s, 8, 8)).unwrap(); + for p in render(&t, &place, Rect::new(0, 0, 8, 1)) { + assert!((p[0] - 0.5).abs() < 0.1, "{p:?}"); + } + // 0, negative and non-finite scales still draw at 100 %. + for bad in [0.0, -0.5, f32::NAN, f32::INFINITY] { + let p = Placement::new(Rect::EMPTY, false, (0.0, 0.0), bad, 0.0); + assert_eq!(p.map(3.0, 0.0).0, 3.0, "{bad}"); + } + } + #[test] fn sampling_at_hostile_phases_does_not_overflow() { let patterns = [checker()]; diff --git a/crates/gpu/src/plan/lower.rs b/crates/gpu/src/plan/lower.rs index 202f0216e1..4bda862af1 100644 --- a/crates/gpu/src/plan/lower.rs +++ b/crates/gpu/src/plan/lower.rs @@ -24,7 +24,7 @@ pub(super) fn placement(anchor: (f64, f64), link: bool, phase: (f32, f32), scale let base = if link { anchor } else { (0.0, 0.0) }; let origin = (base.0 + f64::from(phase.0), base.1 + f64::from(phase.1)); let s = f64::from(scale); - let inv_scale = if s.is_finite() && s > 1e-3 { 1.0 / s } else { 1.0 }; + let inv_scale = if s.is_finite() && s > 0.0 { 1.0 / s.max(photocraft_compose::pattern::MIN_PLACEMENT_SCALE) } else { 1.0 }; let a = f64::from(angle).to_radians(); Placement { origin, cs: (a.cos(), a.sin()), inv_scale } } From 00a1b1cd3ebedfc090d5bc361b406adf5160c9a1 Mon Sep 17 00:00:00 2001 From: Triwarr <170976175+Triwarr@users.noreply.github.com> Date: Fri, 6 Nov 2026 23:12:14 +0100 Subject: [PATCH 082/219] PDF: use PDFium's page bounding box; bound editable objects - Map editable objects through the page's effective bounding box (media box clipped to the crop box) rather than the raw CropBox: an oversized CropBox no longer offsets text or paths from the rendered page. Covered by a fourth fixture page with CropBox (-50, -50, 250, 150) around a 200 x 100 MediaBox. - Add a document-wide editable-object budget (20,000 layers, 1,000,000 path knots). A page that exceeds it keeps its raster, warns that its editable objects were omitted, and the unit test covers the accounting. --- crates/io/src/pdf_import.rs | 98 +++++++++++++++++++++++++++++--- crates/io/tests/it/pdf_import.rs | 30 ++++++++-- 2 files changed, 117 insertions(+), 11 deletions(-) diff --git a/crates/io/src/pdf_import.rs b/crates/io/src/pdf_import.rs index 82233b2abc..10d88beaf8 100644 --- a/crates/io/src/pdf_import.rs +++ b/crates/io/src/pdf_import.rs @@ -103,6 +103,8 @@ pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) - budget >> 20 ))); } + let mut object_budget = ObjectBudget::default(); + let mut object_warnings: Vec = Vec::new(); let mut doc = Document::new(name, Size::new(doc_width as u32, doc_height as u32), ColorMode::Rgb, SampleType::U8); doc.resolution_dpi = dpi as f32; let mut x = 0u32; @@ -155,14 +157,18 @@ pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) - let mut children = vec![layer]; if opts.pdf_text || opts.pdf_vectors { let frame = PageFrame { geometry: *geometry, dpi, x, rgba: &rgba, width, height }; - children.extend(object_layers(&page, opts, &frame, ctl)?); + let (layers, truncated) = object_layers(&page, opts, &frame, ctl, &mut object_budget)?; + children.extend(layers); + if truncated { + object_warnings.push(format!("PDF page {}: editable text/vector layers omitted at the import limit (the raster page is kept)", page_index + 1)); + } } let group = Group { children, expanded: true, artboard: Some(Artboard::new(rect)), frame_stroke: None }; doc.layers.push(Layer::new(format!("Page {}", page_index + 1), LayerContent::Group(group))); x += width + GAP; ctl.progress((index + 1) as f32 / count as f32); } - let mut warnings = Vec::new(); + let mut warnings = object_warnings; if first > 1 || last < total || count < page_count { let shown_last = first + count - 1; let reason = if count < page_count { " (the page limit is 16)" } else { "" }; @@ -171,6 +177,39 @@ pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) - Ok(ImportResult { warnings, document: doc, source_read_only: false, preview_only: false }) } +/// Document-wide bound on editable objects. Text layers and path knots are allocated outside the +/// pixel-memory estimate, so a pathological PDF could otherwise exhaust memory. +const MAX_OBJECT_LAYERS: usize = 20_000; +const MAX_PATH_KNOTS: usize = 1_000_000; + +/// Tracks the editable objects created so far, document-wide. +#[derive(Default)] +struct ObjectBudget { + layers: usize, + knots: usize, +} + +impl ObjectBudget { + /// Claims one editable layer; false once the document-wide limit is reached. + fn claim_layer(&mut self) -> bool { + if self.layers >= MAX_OBJECT_LAYERS { + false + } else { + self.layers += 1; + true + } + } + + /// Claims `count` path knots; false if that would pass the document-wide limit. + fn claim_knots(&mut self, count: usize) -> bool { + if self.knots.saturating_add(count) > MAX_PATH_KNOTS { + false + } else { + self.knots += count; + true + } + } +} /// A page's visible area and rotation, in PDF points. #[derive(Clone, Copy)] struct PageGeometry { @@ -198,7 +237,9 @@ fn page_geometry(page: &pdfium_render::prelude::PdfPage, page_number: usize) -> return Err(IoError::Unsupported(format!("PDF page {page_number} has no area"))); } let rotation = page.rotation().map(|r| r.as_degrees() as u16).unwrap_or(0); - let (origin_x, origin_y, crop_w, crop_h) = match page.boundaries().crop().or_else(|_| page.boundaries().media()) { + // The page's effective box, as PDFium renders it: the bounding box (media box clipped to the + // crop box), not the raw crop box, which may extend past the media box. + let (origin_x, origin_y, crop_w, crop_h) = match page.boundaries().bounding().or_else(|_| page.boundaries().crop()).or_else(|_| page.boundaries().media()) { Ok(boundary) => { let r = boundary.bounds; (f64::from(r.left().value), f64::from(r.bottom().value), f64::from(r.right().value - r.left().value), f64::from(r.top().value - r.bottom().value)) @@ -335,12 +376,23 @@ impl PageFrame<'_> { /// Editable text and shape layers for one rendered page, in document pixels. #[cfg(not(target_arch = "wasm32"))] -fn object_layers(page: &pdfium_render::prelude::PdfPage, opts: &ImportOptions, frame: &PageFrame, ctl: &Interrupt) -> Result, IoError> { +fn object_layers( + page: &pdfium_render::prelude::PdfPage, + opts: &ImportOptions, + frame: &PageFrame, + ctl: &Interrupt, + budget: &mut ObjectBudget, +) -> Result<(Vec, bool), IoError> { use pdfium_render::prelude::*; let objects = page.objects(); let mut layers = Vec::new(); - for object in objects.iter() { + let mut truncated = false; + 'objects: for object in objects.iter() { + if budget.layers >= MAX_OBJECT_LAYERS { + truncated = true; + break; + } if ctl.check().is_err() { return Err(IoError::Cancelled); } @@ -372,6 +424,10 @@ fn object_layers(page: &pdfium_render::prelude::PdfPage, opts: &ImportOptions, f if !text.is_visible() { layer.visible = false; } + if !budget.claim_layer() { + truncated = true; + break 'objects; + } layers.push(layer); } PdfPageObject::Path(path) if opts.pdf_vectors => { @@ -396,10 +452,18 @@ fn object_layers(page: &pdfium_render::prelude::PdfPage, opts: &ImportOptions, f subpaths.push(Subpath { closed, knots: std::mem::take(&mut knots), op: PathOp::Combine }); closed = false; } + if !budget.claim_knots(1) { + truncated = true; + break 'objects; + } let (px, py) = point(); knots.push(Knot::corner(px, py)); } PdfPathSegmentType::LineTo => { + if !budget.claim_knots(1) { + truncated = true; + break 'objects; + } let (px, py) = point(); knots.push(Knot::corner(px, py)); } @@ -412,6 +476,10 @@ fn object_layers(page: &pdfium_render::prelude::PdfPage, opts: &ImportOptions, f if let Some(prev) = knots.last_mut() { prev.out_ctrl = Point::new(c1x, c1y); } + if !budget.claim_knots(1) { + truncated = true; + break 'objects; + } knots.push(Knot::smooth(Point::new(ex, ey), Point::new(c2x, c2y), Point::new(ex, ey))); bezier.clear(); } @@ -431,6 +499,10 @@ fn object_layers(page: &pdfium_render::prelude::PdfPage, opts: &ImportOptions, f continue; } let stroke = stroked.then(|| ShapeStroke { paint: Fill::Solid(Color::BLACK), width: 1.0, ..Default::default() }); + if !budget.claim_layer() { + truncated = true; + break 'objects; + } layers.push(Layer::new( "Path", LayerContent::Shape(ShapeLayer { @@ -444,7 +516,7 @@ fn object_layers(page: &pdfium_render::prelude::PdfPage, opts: &ImportOptions, f _ => {} } } - Ok(layers) + Ok((layers, truncated)) } /// The highest DPI not above `requested` that keeps every page and the artboard strip within @@ -503,7 +575,7 @@ fn bind() -> Result { #[cfg(test)] mod tests { - use super::{PageFrame, PageGeometry}; + use super::{MAX_OBJECT_LAYERS, MAX_PATH_KNOTS, ObjectBudget, PageFrame, PageGeometry}; fn geometry(rotation: u16) -> PageGeometry { PageGeometry { width_pt: 595.0, height_pt: 842.0, origin_x: 0.0, origin_y: 0.0, crop_w: 595.0, crop_h: 842.0, rotation } @@ -522,6 +594,18 @@ mod tests { assert_eq!(frame(300.0, 2480).to_doc(60.0, 420.0), (2730.0, 1758.0)); } + #[test] + fn object_budget_stops_at_its_limits() { + let mut budget = ObjectBudget::default(); + for _ in 0..MAX_OBJECT_LAYERS { + assert!(budget.claim_layer()); + } + assert!(!budget.claim_layer()); + let mut knots = ObjectBudget::default(); + assert!(knots.claim_knots(MAX_PATH_KNOTS)); + assert!(!knots.claim_knots(1)); + assert!(!knots.claim_knots(usize::MAX)); + } #[test] fn crop_box_offsets_and_rotations_map_like_the_raster() { // A 200 x 100 pt crop box at (10, 20), displayed rotated 90 degrees clockwise: 100 x 200. diff --git a/crates/io/tests/it/pdf_import.rs b/crates/io/tests/it/pdf_import.rs index 9bee105b0c..f659eb5166 100644 --- a/crates/io/tests/it/pdf_import.rs +++ b/crates/io/tests/it/pdf_import.rs @@ -51,12 +51,15 @@ fn three_page_pdf() -> Vec { "Font" => dictionary! { "F1" => font_id }, }); let pages = [ - ("1 0 0 rg BT /F1 48 Tf 20 50 Td (Red) Tj ET", [0.0f32, 0.0, PAGE_W as f32, PAGE_H as f32], None), - ("0 0 1 rg q 2 0 0 2 10 5 cm 10 10 20 10 re f Q 0 1 0 rg BT /F1 48 Tf 20 20 Td (Green) Tj ET", [0.0, 0.0, PAGE_W as f32, PAGE_H as f32], None), - ("0 0 1 rg 20 30 40 40 re f 0 0.5 0 rg BT /F1 24 Tf 40 60 Td (Rot) Tj ET", [10.0, 20.0, 210.0, 120.0], Some(90i64)), + ("1 0 0 rg BT /F1 48 Tf 20 50 Td (Red) Tj ET", [0.0f32, 0.0, PAGE_W as f32, PAGE_H as f32], None, None), + ("0 0 1 rg q 2 0 0 2 10 5 cm 10 10 20 10 re f Q 0 1 0 rg BT /F1 48 Tf 20 20 Td (Green) Tj ET", [0.0, 0.0, PAGE_W as f32, PAGE_H as f32], None, None), + ("0 0 1 rg 20 30 40 40 re f 0 0.5 0 rg BT /F1 24 Tf 40 60 Td (Rot) Tj ET", [10.0, 20.0, 210.0, 120.0], Some(90i64), None), + // A CropBox larger than the MediaBox: the rendered page and the mapping use the + // intersection, so the rectangle must not be offset by the crop box's negative origin. + ("0 0.5 0 rg 20 30 40 40 re f", [0.0, 0.0, PAGE_W as f32, PAGE_H as f32], None, Some([-50.0f32, -50.0, 250.0, 150.0])), ]; let mut kids = Vec::new(); - for (content, [left, bottom, right, top], rotate) in pages { + for (content, [left, bottom, right, top], rotate, crop) in pages { let content_id = pdf.add_object(Stream::new(dictionary! {}, content.as_bytes().to_vec())); let mut page = dictionary! { "Type" => "Page", @@ -68,6 +71,9 @@ fn three_page_pdf() -> Vec { if let Some(rotate) = rotate { page.set("Rotate", rotate); } + if let Some([left, bottom, right, top]) = crop { + page.set("CropBox", Object::Array(vec![Object::Real(left), Object::Real(bottom), Object::Real(right), Object::Real(top)])); + } let page_id = pdf.add_object(page); kids.push(Object::Reference(page_id)); } @@ -139,6 +145,22 @@ fn check(document: &Document, dpi: f32) { assert!(m[0].abs() < 0.01 && (m[1] - 1.0).abs() < 0.01 && (m[2] + 1.0).abs() < 0.01 && m[3].abs() < 0.01, "page 3 text rotation is {m:?}"); assert!(m[4] > page_three_x, "page 3 text x misses the page offset: {} <= {page_three_x}", m[4]); assert!(text.color.c[1] > 0.3 && text.color.c[0] < 0.2 && text.color.c[2] < 0.2, "page 3 text should be green, got {:?}", text.color.c); + + // Page 4: a CropBox (-50, -50, 250, 150) around a 200 x 100 MediaBox must not offset the rect. + let shape = shape_layer(page_children(document, 4)); + let (mut min_x, mut min_y, mut max_x, mut max_y) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); + for knot in shape.path.subpaths.iter().flat_map(|subpath| &subpath.knots) { + min_x = min_x.min(knot.anchor.x); + max_x = max_x.max(knot.anchor.x); + min_y = min_y.min(knot.anchor.y); + max_y = max_y.max(knot.anchor.y); + } + // Page 3 is rotated 90 degrees, so it is 100 pt wide on the strip, not 200. + let page_four_x = 2.0 * (PAGE_W * scale).round() + (100.0 * scale).round() + 3.0 * GAP; + assert!((min_x - (page_four_x + 20.0 * scale)).abs() < 3.0, "oversized-crop path min x is {min_x}, page 4 starts at {page_four_x} ({dpi} dpi)"); + assert!((max_x - (page_four_x + 60.0 * scale)).abs() < 3.0, "oversized-crop path max x is {max_x} ({dpi} dpi)"); + assert!((min_y - 30.0 * scale).abs() < 3.0, "oversized-crop path min y is {min_y} ({dpi} dpi)"); + assert!((max_y - 70.0 * scale).abs() < 3.0, "oversized-crop path max y is {max_y} ({dpi} dpi)"); } fn page_children(document: &Document, page: usize) -> &[Layer] { From eda8759d118bf6cf2cb6be965cec5f0fa05ad085 Mon Sep 17 00:00:00 2001 From: Triwarr <170976175+Triwarr@users.noreply.github.com> Date: Fri, 6 Nov 2026 23:35:28 +0100 Subject: [PATCH 083/219] PDF: document the import test helpers Raise docstring coverage on the functions this PR adds: the fixture/assertion helpers in the end-to-end test and the importer's test frame builders now have doc comments. No behaviour change. --- crates/io/src/pdf_import.rs | 2 ++ crates/io/tests/it/pdf_import.rs | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/crates/io/src/pdf_import.rs b/crates/io/src/pdf_import.rs index 10d88beaf8..5a4908c108 100644 --- a/crates/io/src/pdf_import.rs +++ b/crates/io/src/pdf_import.rs @@ -577,10 +577,12 @@ fn bind() -> Result { mod tests { use super::{MAX_OBJECT_LAYERS, MAX_PATH_KNOTS, ObjectBudget, PageFrame, PageGeometry}; + /// A 595 x 842 pt page with no offset, rotated by `rotation` degrees. fn geometry(rotation: u16) -> PageGeometry { PageGeometry { width_pt: 595.0, height_pt: 842.0, origin_x: 0.0, origin_y: 0.0, crop_w: 595.0, crop_h: 842.0, rotation } } + /// A full-page frame at `dpi`, starting at document x `x`. fn frame(dpi: f64, x: u32) -> PageFrame<'static> { PageFrame { geometry: geometry(0), dpi, x, rgba: &[], width: 595, height: 842 } } diff --git a/crates/io/tests/it/pdf_import.rs b/crates/io/tests/it/pdf_import.rs index f659eb5166..d84fb03230 100644 --- a/crates/io/tests/it/pdf_import.rs +++ b/crates/io/tests/it/pdf_import.rs @@ -14,6 +14,7 @@ const PAGE_W: f64 = 200.0; const PAGE_H: f64 = 100.0; const GAP: f64 = 80.0; +/// Imports the four-page fixture at 72 and 300 DPI and checks every layer it produces. #[test] fn imports_text_and_a_transformed_path_on_two_pages() { let pdf = three_page_pdf(); @@ -93,6 +94,7 @@ fn three_page_pdf() -> Vec { bytes } +/// Asserts the imported text and path layers at one DPI against the fixture geometry. fn check(document: &Document, dpi: f32) { let scale = f64::from(dpi) / 72.0; let page_two_x = (PAGE_W * scale).round() + GAP; @@ -163,6 +165,7 @@ fn check(document: &Document, dpi: f32) { assert!((max_y - 70.0 * scale).abs() < 3.0, "oversized-crop path max y is {max_y} ({dpi} dpi)"); } +/// The layer children of the imported `Page N` group. fn page_children(document: &Document, page: usize) -> &[Layer] { let name = format!("Page {page}"); let layer = document.layers.iter().find(|layer| layer.name == name).unwrap_or_else(|| panic!("no {name} layer")); @@ -172,6 +175,7 @@ fn page_children(document: &Document, page: usize) -> &[Layer] { } } +/// The first text layer among `children`. fn text_layer(children: &[Layer]) -> &TextLayer { children .iter() @@ -182,6 +186,7 @@ fn text_layer(children: &[Layer]) -> &TextLayer { .expect("a text layer") } +/// The first shape layer among `children`. fn shape_layer(children: &[Layer]) -> &ShapeLayer { children .iter() @@ -192,10 +197,12 @@ fn shape_layer(children: &[Layer]) -> &ShapeLayer { .expect("a shape layer") } +/// Asserts `color` is the fixture's red ink. fn assert_red(color: &Color, what: &str) { assert!(color.c[0] > 0.8 && color.c[1] < 0.2 && color.c[2] < 0.2, "{what} should be red, got {:?}", color.c); } +/// Asserts `color` is the fixture's green ink. fn assert_green(color: &Color, what: &str) { assert!(color.c[1] > 0.8 && color.c[0] < 0.2 && color.c[2] < 0.2, "{what} should be green, got {:?}", color.c); } From 2f95eac75b2f6bb1201c5570be512ba45bb4a83d Mon Sep 17 00:00:00 2001 From: Triwarr <170976175+Triwarr@users.noreply.github.com> Date: Fri, 6 Nov 2026 23:41:53 +0100 Subject: [PATCH 084/219] PDF: document the importer's known limits Module docs now state that pdfium-render 0.9.5 exposes path fill/stroke presence but not paint, width or dash (black paints, 1 pt stroke); that text colour is sampled and fonts are matched by family/name; and that only package.ps1 provisions PDFium, not the shared release workflow. The text and path extraction comments say the same where the code relies on it. --- crates/io/src/pdf_import.rs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/crates/io/src/pdf_import.rs b/crates/io/src/pdf_import.rs index 5a4908c108..cdb56c9d62 100644 --- a/crates/io/src/pdf_import.rs +++ b/crates/io/src/pdf_import.rs @@ -6,6 +6,15 @@ //! //! Text and vector page objects can be imported as editable layers (`ImportOptions::pdf_text` / //! `pdf_vectors`); their page-space points are mapped to document pixels through the effective DPI. +//! +//! # Known limits +//! +//! - pdfium-render 0.9.5 exposes a path's fill/stroke *presence* but not its paint colour, stroke +//! width or dash pattern, so imported paths are painted black (1 pt centre stroke when stroked). +//! - A text object exposes no fill colour: the colour is sampled from the rendered page's glyph +//! ink, and the font is matched by family/name rather than embedded or loaded from the PDF. +//! - PDFium is provisioned by `packaging/windows/package.ps1` (pinned release, verified DLL); the +//! shared craft-common-actions release workflow does not stage it. use crate::{ImportOptions, ImportResult, IoError}; use photocraft_color::{ColorMode, PixelFormat, SampleType}; @@ -404,8 +413,10 @@ fn object_layers( } let Ok(bounds) = text.bounds() else { continue }; let (x_px, y_px) = frame.to_doc(bounds.left().value, bounds.top().value); - // The text layer's size is in points; the transform is in document pixels. The - // text object does not expose its fill colour; sample the glyph ink from the page. + // The text layer's size is in points; the transform is in document pixels. + // The text object does not expose its fill colour; sample the glyph ink from the + // page. The font is matched by family/name, not embedded: a document font that is + // not installed renders with the closest family the text engine has. let color = frame.text_color(bounds.left().value, bounds.top().value, bounds.right().value, bounds.bottom().value); let size_pt = f64::from(text.scaled_font_size().value).max(1.0); let font = text.font(); @@ -434,7 +445,8 @@ fn object_layers( // PDFium reports the segments in object space; the object's matrix places them on // the page. Without it, scaled or rotated paths land in the wrong place. let matrix = path.matrix().unwrap_or(PdfMatrix::IDENTITY); - // The fill and stroke modes are exposed; the colours and dash pattern are not. + // The fill and stroke modes are exposed; the colours, stroke width and dash + // pattern are not, so both paints are black and a stroke is 1 pt wide. let filled = !matches!(path.fill_mode(), Ok(PdfPathFillMode::None)); let stroked = path.is_stroked().unwrap_or(false); if !filled && !stroked { From 7e1de84bceae3903b92d11b1b3d4e97235b8a54e Mon Sep 17 00:00:00 2001 From: Triwarr <170976175+Triwarr@users.noreply.github.com> Date: Fri, 6 Nov 2026 23:44:59 +0100 Subject: [PATCH 085/219] PDF: document the added test functions Every function this PR adds now has a doc comment, including the test functions in the importer, PDF export and layers_to_files suites. No behaviour change. --- crates/engine/src/cmd/file_cmds/tests.rs | 1 + crates/io/src/pdf/tests.rs | 1 + crates/io/src/pdf_import.rs | 5 +++++ 3 files changed, 7 insertions(+) diff --git a/crates/engine/src/cmd/file_cmds/tests.rs b/crates/engine/src/cmd/file_cmds/tests.rs index 6a14a7542b..afb74faa5b 100644 --- a/crates/engine/src/cmd/file_cmds/tests.rs +++ b/crates/engine/src/cmd/file_cmds/tests.rs @@ -2539,6 +2539,7 @@ fn layers_to_files_reports_skipped_layers_without_changing_files() { } #[test] +/// `groups: "layers"` walks into a group and writes each visible leaf as its own file. fn layers_to_files_exports_group_children_as_loose_files() { let dir = tmp("layers-groups"); let mut s = session(8, 8, 8); diff --git a/crates/io/src/pdf/tests.rs b/crates/io/src/pdf/tests.rs index 30c38a2698..6741fea346 100644 --- a/crates/io/src/pdf/tests.rs +++ b/crates/io/src/pdf/tests.rs @@ -395,6 +395,7 @@ fn leading_newline_stays_in_the_first_selection_span() { } #[test] +/// Each artboard becomes one labelled page, in document order. fn artboards_export_as_one_page_each() { use photocraft_doc::{Artboard, Group}; let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::F32, true); diff --git a/crates/io/src/pdf_import.rs b/crates/io/src/pdf_import.rs index cdb56c9d62..7cb1da50c2 100644 --- a/crates/io/src/pdf_import.rs +++ b/crates/io/src/pdf_import.rs @@ -600,6 +600,7 @@ mod tests { } #[test] + /// Page points map through the DPI scale, the Y flip and the page's document X offset. fn page_points_map_to_document_pixels() { assert_eq!(frame(72.0, 0).to_doc(60.0, 420.0), (60.0, 422.0)); // The same point at four times the resolution. @@ -609,6 +610,7 @@ mod tests { } #[test] + /// The document-wide budget refuses layers and knots once its limits are reached. fn object_budget_stops_at_its_limits() { let mut budget = ObjectBudget::default(); for _ in 0..MAX_OBJECT_LAYERS { @@ -621,6 +623,7 @@ mod tests { assert!(!knots.claim_knots(usize::MAX)); } #[test] + /// Crop-box offsets and 90/180/270 rotations land where the raster renders them. fn crop_box_offsets_and_rotations_map_like_the_raster() { // A 200 x 100 pt crop box at (10, 20), displayed rotated 90 degrees clockwise: 100 x 200. let rotated = |rotation| PageFrame { @@ -644,6 +647,7 @@ mod tests { } #[test] + /// The page sampler reads the requested pixel and reports black outside the page. fn samples_the_rendered_page() { // Two pixels: white then black. let rgba = [255u8, 255, 255, 255, 0, 0, 0, 255]; @@ -661,6 +665,7 @@ mod tests { } #[test] + /// Glyph ink wins over the background inside a text box, while an empty box keeps background. fn text_colour_samples_the_glyph_ink() { // An 8 x 4 white page with one black glyph pixel at (2, 1). let mut rgba = vec![255u8; 8 * 4 * 4]; From b57603708e737c92ff7212cd48b4b722f3242f27 Mon Sep 17 00:00:00 2001 From: Triwarr <170976175+Triwarr@users.noreply.github.com> Date: Fri, 6 Nov 2026 23:47:23 +0100 Subject: [PATCH 086/219] PDF: drop the unused image dependency from photocraft-io The PDF export codec goes through photocraft-codecs; nothing in the crate used the image crate, so the dependency and its lockfile edge are removed. pdfium-render stays pinned exactly. --- Cargo.lock | 1 - crates/io/Cargo.toml | 1 - 2 files changed, 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d3221556bc..1a3f307c2a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5464,7 +5464,6 @@ dependencies = [ "craft-docpath", "crc32fast", "flate2", - "image", "imagesize", "lopdf", "miniz_oxide 0.9.1", diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index c8885d5e9b..dd5a232f32 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -56,7 +56,6 @@ ttf-parser = { version = "0.25", default-features = false } # and the DLL release is pinned separately in packaging/windows/package.ps1, so a lockfile refresh # must not move the wrapper under it. Bump deliberately and re-check both. pdfium-render = "=0.9.5" -image = { workspace = true, features = ["png"] } [target.'cfg(target_arch = "wasm32")'.dependencies] resvg = { version = "0.45", default-features = false, features = ["text", "raster-images"] } From f7dfe7a017f92453500a35339aabe108b6d0e2cf Mon Sep 17 00:00:00 2001 From: Triwarr <170976175+Triwarr@users.noreply.github.com> Date: Sat, 7 Nov 2026 02:34:55 +0100 Subject: [PATCH 087/219] PDF: test last-object cancellation and raster fidelity - A complete import makes a fixed number of cancellation checks; cancelling on the last per-object check and on the public boundary check must both return Cancelled and never a document. - Six page variants (plain, CropBox offset, rotations 90/180/270, oversized CropBox) compare each stored page raster with a direct PDFium render at 72, 150 and 300 DPI: 18/18 byte-identical, and the sizes prove the crop intersection (190x80) and oversized CropBox (200x100) cases. - Both tests skip without PDFium and fail under PHOTOCRAFT_REQUIRE_PDFIUM, like the existing end-to-end test. --- crates/io/tests/it/pdf_import.rs | 155 ++++++++++++++++++++++++++++++- 1 file changed, 154 insertions(+), 1 deletion(-) diff --git a/crates/io/tests/it/pdf_import.rs b/crates/io/tests/it/pdf_import.rs index d84fb03230..ef02111309 100644 --- a/crates/io/tests/it/pdf_import.rs +++ b/crates/io/tests/it/pdf_import.rs @@ -6,9 +6,15 @@ //! `PDFIUM_DIR`, no DLL beside the test binary) the test prints an explicit SKIPPED line and //! returns instead of pretending the end-to-end wiring passed. +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{Duration, Instant}; + use lopdf::{Document as PdfDocument, Object, Stream, dictionary}; +use pdfium_render::prelude::{PdfRenderConfig, Pdfium}; use photocraft_doc::{Color, Document, Layer, LayerContent, ShapeLayer, TextLayer}; -use photocraft_io::{ImportOptions, IoError, import_with_options_and_interrupt}; +use photocraft_geom::Rect; +use photocraft_io::{ImportOptions, ImportResult, IoError, import_with_options_and_interrupt}; +use photocraft_raster::Interrupt; const PAGE_W: f64 = 200.0; const PAGE_H: f64 = 100.0; @@ -206,3 +212,150 @@ fn assert_red(color: &Color, what: &str) { fn assert_green(color: &Color, what: &str) { assert!(color.c[1] > 0.8 && color.c[0] < 0.2 && color.c[2] < 0.2, "{what} should be green, got {:?}", color.c); } + +/// Imports `bytes`, or prints SKIPPED and returns `None` when PDFium cannot load. The Windows +/// integration leg sets `PHOTOCRAFT_REQUIRE_PDFIUM`, where a missing library must fail. +fn imported_or_skipped(bytes: &[u8], options: &ImportOptions, ctl: &Interrupt<'_>, what: &str) -> Option { + match import_with_options_and_interrupt("probe.pdf", bytes, options, ctl) { + Ok(result) => Some(result), + Err(IoError::Unsupported(message)) if message.contains("cannot load PDFium") => { + eprintln!("SKIPPED {what}: PDFium is unavailable ({message})"); + assert!(std::env::var_os("PHOTOCRAFT_REQUIRE_PDFIUM").is_none(), "PDFium is required for {what} but could not load: {message}"); + None + } + Err(error) => panic!("{what} failed: {error}"), + } +} + +/// Six one-page variants for the cancellation and fidelity probes: plain, a CropBox offset to +/// (10, 20, 210, 120), rotations of 90, 180 and 270 degrees, and an oversized CropBox +/// (-50, -50, 250, 150) around the 200 x 100 MediaBox. Each page draws a 40 x 40 blue rectangle +/// and a 10 x 10 red one at (20, 30). +fn variant_pdf() -> Vec { + let mut pdf = PdfDocument::with_version("1.5"); + let pages_id = pdf.new_object_id(); + let pages: [(Option, Option<[f32; 4]>); 6] = [ + (None, None), + (None, Some([10.0, 20.0, 210.0, 120.0])), + (Some(90), None), + (Some(180), None), + (Some(270), None), + (None, Some([-50.0, -50.0, 250.0, 150.0])), + ]; + let mut kids = Vec::new(); + for (rotate, crop) in pages { + let content = b"0 0 1 rg 20 30 40 40 re f 1 0 0 rg 20 30 10 10 re f"; + let content_id = pdf.add_object(Stream::new(dictionary! {}, content.to_vec())); + let mut page = dictionary! { + "Type" => "Page", + "Parent" => pages_id, + "MediaBox" => Object::Array(vec![Object::Real(0.0), Object::Real(0.0), Object::Real(PAGE_W as f32), Object::Real(PAGE_H as f32)]), + "Contents" => content_id, + "Resources" => dictionary! {}, + }; + if let Some(rotate) = rotate { + page.set("Rotate", rotate); + } + if let Some([left, bottom, right, top]) = crop { + page.set("CropBox", Object::Array(vec![Object::Real(left), Object::Real(bottom), Object::Real(right), Object::Real(top)])); + } + kids.push(Object::Reference(pdf.add_object(page))); + } + let count = kids.len() as i64; + pdf.objects.insert( + pages_id, + Object::Dictionary(dictionary! { + "Type" => "Pages", + "Kids" => Object::Array(kids), + "Count" => count, + }), + ); + let catalog = pdf.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id }); + pdf.trailer.set("Root", catalog); + let mut bytes = Vec::new(); + pdf.save_to(&mut bytes).expect("writing the variant PDF"); + bytes +} + +/// A cancel that lands on the last object's check, or on the public boundary after +/// `import_stages`, must be reported as `Cancelled` rather than returning a document. +#[test] +fn cancelling_on_the_last_checks_is_reported() { + let pdf = variant_pdf(); + let options = ImportOptions { pdf_dpi: 72.0, ..Default::default() }; + let count = AtomicUsize::new(0); + let cancel = || { + count.fetch_add(1, Ordering::SeqCst); + false + }; + let ctl = Interrupt::cancel_only(&cancel); + if imported_or_skipped(&pdf, &options, &ctl, "the last-check cancellation test").is_none() { + return; + } + let total = count.load(Ordering::SeqCst); + assert!(total >= 3, "a complete import should check cancellation at least three times, got {total}"); + for (label, threshold) in [("the last per-object check", total - 1), ("the public boundary check", total)] { + let seen = AtomicUsize::new(0); + let cancel = || seen.fetch_add(1, Ordering::SeqCst) + 1 >= threshold; + let ctl = Interrupt::cancel_only(&cancel); + let started = Instant::now(); + let result = import_with_options_and_interrupt("probe.pdf", &pdf, &options, &ctl); + let elapsed = started.elapsed(); + assert_eq!(seen.load(Ordering::SeqCst), threshold, "cancelling on {label} should stop at check {threshold} of {total}"); + match result { + Err(IoError::Cancelled) => {} + Ok(_) => panic!("cancelling on {label} returned a document"), + Err(error) => panic!("cancelling on {label} returned {error:?}"), + } + assert!(elapsed < Duration::from_secs(10), "cancelling on {label} took {elapsed:?}"); + } +} + +/// Every imported page's stored raster must equal a direct PDFium render of the same page at the +/// same target width: crop intersection, rotation and the strip placement may not move or resample +/// a pixel. At 72, 150 and 300 DPI over the six variants that is 18 page renders. +#[test] +fn raster_matches_a_direct_pdfium_render() { + let pdf = variant_pdf(); + let mut compared = 0; + for dpi in [72.0f32, 150.0, 300.0] { + let options = ImportOptions { pdf_dpi: dpi, ..Default::default() }; + let Some(result) = imported_or_skipped(&pdf, &options, &Interrupt::NONE, "the raster fidelity matrix") else { + return; + }; + let pdfium = Pdfium::default(); + let reference = pdfium.load_pdf_from_byte_slice(&pdf, None).expect("reference PDF"); + assert_eq!(reference.pages().len() as usize, result.document.layers.len(), "page count at {dpi} dpi"); + for (index, layer) in result.document.layers.iter().enumerate() { + let group = match &layer.content { + LayerContent::Group(group) => group, + _ => panic!("page layer {} is not a group", layer.name), + }; + let artboard = group.artboard.as_ref().expect("page artboard").rect; + let raster = group.children.first().and_then(|layer| layer.surface()).expect("page raster layer"); + let (width, height) = (artboard.width(), artboard.height()); + let mut got = vec![[0u8; 4]; (width as usize) * (height as usize)]; + raster.read_rgba8_into(Rect::from_xywh(artboard.x0, artboard.y0, width, height), &mut got); + + let page = reference.pages().get(index as i32).expect("reference page"); + let bitmap = page.render_with_config(&PdfRenderConfig::new().set_target_width(width as i32)).expect("reference render"); + assert_eq!((bitmap.width() as u32, bitmap.height() as u32), (width, height), "page {} size at {dpi} dpi", index + 1); + let want = bitmap.as_rgba_bytes(); + + let mut max_diff = 0u32; + let mut sum = 0u64; + for (pixel, reference) in got.iter().zip(want.as_chunks::<4>().0.iter()) { + for channel in 0..4 { + let diff = (pixel[channel] as i32 - reference[channel] as i32).unsigned_abs(); + sum += u64::from(diff); + max_diff = max_diff.max(diff); + } + } + let channels = u64::from(width) * u64::from(height) * 4; + let mean = sum as f64 / channels as f64; + assert!(max_diff <= 1, "page {} at {dpi} dpi differs from the direct render: max channel diff {max_diff}, mean {mean:.4}", index + 1); + compared += 1; + } + } + assert_eq!(compared, 18, "six pages at three DPIs should all be compared"); +} From 2cb4a88dd6e54217e95780d445c79883d382c485 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 7 Nov 2026 02:39:16 +0100 Subject: [PATCH 088/219] Brush: opacity minimum 1 % and "00" sets 100 % (#11812) The Brush, Pencil and Eraser Opacity fields take whole numbers from 1 to 100, as in the reference application (0 and -10 become 1, 150 becomes 100, 33.7 a whole number). The digit keys "00" set the Brush to 100 % instead of 0 %, and tools.setBrush floors opacity at 0.01, so a brush can no longer be set to paint nothing. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/brush_cmds.rs | 4 +- .../src/brush_opacity_minimum_tests.rs | 119 ++++++++++++++++++ crates/ui-egui/src/input/opacity_keys.rs | 18 +-- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels/options_bar.rs | 16 ++- 5 files changed, 146 insertions(+), 13 deletions(-) create mode 100644 crates/ui-egui/src/brush_opacity_minimum_tests.rs diff --git a/crates/engine/src/cmd/brush_cmds.rs b/crates/engine/src/cmd/brush_cmds.rs index e09ff2098c..9f371bcd17 100644 --- a/crates/engine/src/cmd/brush_cmds.rs +++ b/crates/engine/src/cmd/brush_cmds.rs @@ -1105,6 +1105,8 @@ pub(crate) fn set_brush(s: &mut Session, p: &Value) -> Result { } b = merge_brush(&b, &patch, cmd)?; validate_brush(&b, cmd)?; + // The painting tools' Opacity takes 1-100 %, as in Photoshop: a 0 % brush paints nothing (#11812). + b.opacity = b.opacity.max(0.01); // Loading a stored tip can fail. Finish preparing the brush before changing its identity or // foreground colour, so a failed pick leaves the entire current brush state intact. s.load_brush_tips(&mut b).map_err(|e| bad(cmd, e))?; @@ -1365,7 +1367,7 @@ pub fn specs() -> Vec { spec!( "tools.setBrush", "Set Brush", - r##"{"preset":name?,"reset":bool?,…BrushSettings fields (camelCase, deep-merged; pose.overrideTiltX/Y:bool; pose.overrideTilt:legacy combined alias)}"##, + r##"{"preset":name?,"reset":bool?,…BrushSettings fields (camelCase, deep-merged; pose.overrideTiltX/Y:bool; pose.overrideTilt:legacy combined alias; opacity floors at 0.01)}"##, always, set_brush, true diff --git a/crates/ui-egui/src/brush_opacity_minimum_tests.rs b/crates/ui-egui/src/brush_opacity_minimum_tests.rs new file mode 100644 index 0000000000..cbceaef52c --- /dev/null +++ b/crates/ui-egui/src/brush_opacity_minimum_tests.rs @@ -0,0 +1,119 @@ +//! The Brush's Opacity starts at 1 %, as in Photoshop (#11812): its options-bar field takes whole +//! numbers from 1 to 100, the digit keys `00` set 100 %, and `tools.setBrush` floors opacity at 1 %. + +use egui::{Event, Key, Modifiers, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::opacity_keys::press; +use crate::state::Tool; + +fn app(tool: Tool) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.ui.tool = tool; + app +} + +fn options_bar(tool: Tool, theme: crate::theme::ThemeKind) -> Harness<'static, PhotocraftApp> { + let mut ready = false; + let mut h = Harness::builder().with_size(vec2(1400.0, 60.0)).build_ui_state( + move |ui, app: &mut PhotocraftApp| { + if !ready { + crate::theme::install_fonts(ui.ctx()); + crate::theme::apply(ui.ctx(), theme); + ready = true; + return; + } + crate::panels::options_bar(app, ui); + }, + app(tool), + ); + h.run_steps(4); + h +} + +/// Types `text` into the options bar's Opacity field and presses Enter. +fn type_opacity(h: &mut Harness<'_, PhotocraftApp>, text: &str) -> f32 { + // "Opacity:" in the Photoshop-look themes, "Opacity" in the others. + let field = h + .query_all(egui_kittest::kittest::by().role(egui::accesskit::Role::SpinButton)) + .find(|n| n.accesskit_node().label().is_some_and(|l| l.trim_end_matches(':') == "Opacity")) + .unwrap_or_else(|| panic!("the {:?} Opacity field", h.state().ui.tool)) + .rect(); + h.hover_at(field.center()); + h.run(); + h.drag_at(field.center()); + h.run(); + h.drop_at(field.center()); + h.run_steps(2); + h.event(Event::Key { key: Key::A, physical_key: Some(Key::A), pressed: true, repeat: false, modifiers: Modifiers::COMMAND }); + h.run(); + h.event(Event::Text(text.into())); + h.run_steps(2); + h.event(Event::Key { key: Key::Enter, physical_key: Some(Key::Enter), pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + h.state().session.tools.brush.opacity +} + +/// In the Photoshop-look themes and the others (their options bars lay the Brush out apart); the +/// Pencil and Eraser fields take 1-100 whole numbers in Photoshop too. +#[test] +fn the_brush_opacity_field_takes_whole_numbers_from_1_to_100() { + use crate::theme::ThemeKind; + for theme in [ThemeKind::ProMedium, ThemeKind::Studio] { + for tool in [Tool::Brush, Tool::Pencil, Tool::Eraser] { + let mut h = options_bar(tool, theme); + for (typed, want) in [("0", 0.01), ("-10", 0.01), ("150", 1.0), ("45", 0.45), ("33.7", 0.34)] { + let got = type_opacity(&mut h, typed); + assert!((got - want).abs() < 1e-6, "{theme:?} {tool:?} typed {typed}: {got}, want {want}"); + } + } + } +} + +#[test] +fn double_zero_sets_the_brush_to_100_percent() { + let mut a = app(Tool::Brush); + a.run("tools.setBrush", json!({"brush": {"opacity": 0.3}})).unwrap(); + press(&mut a, 0, false, 0.0); + press(&mut a, 0, false, 100.0); + assert_eq!(a.session.tools.brush.opacity, 1.0, "00 = 100%"); + // Other pairs are the exact value; single digits are tens; 0 alone is 100%. + for (digits, want) in [([0, 7], 0.07), ([4, 5], 0.45)] { + press(&mut a, digits[0], false, 10_000.0); + press(&mut a, digits[1], false, 10_100.0); + assert!((a.session.tools.brush.opacity - want).abs() < 1e-6, "{digits:?}"); + } + for (d, want) in [(3, 0.3), (9, 0.9), (0, 1.0), (1, 0.1)] { + press(&mut a, d, false, 20_000.0 + f64::from(d) * 10_000.0); + assert!((a.session.tools.brush.opacity - want).abs() < 1e-6, "{d}"); + } + // With build-up on, ⇧ digits set the opacity: `00` is 100% there too. + a.session.tools.brush.build_up = true; + press(&mut a, 2, true, 200_000.0); + press(&mut a, 0, true, 300_000.0); + press(&mut a, 0, true, 300_100.0); + assert_eq!(a.session.tools.brush.opacity, 1.0); +} + +#[test] +fn double_zero_on_flow_keeps_its_one_percent() { + let mut a = app(Tool::Brush); + press(&mut a, 0, true, 0.0); + press(&mut a, 0, true, 100.0); + assert_eq!(a.session.tools.brush.flow, 0.01, "⇧00 is Flow's 1%, unchanged"); +} + +#[test] +fn set_brush_floors_opacity_at_one_percent() { + let mut a = app(Tool::Brush); + for params in [json!({"brush": {"opacity": 0}}), json!({"opacity": 0.0}), json!({"brush": {"opacity": 0.004}})] { + a.run("tools.setBrush", json!({"brush": {"opacity": 0.5}})).unwrap(); + a.run("tools.setBrush", params.clone()).unwrap(); + assert_eq!(a.session.tools.brush.opacity, 0.01, "{params}"); + } + a.run("tools.setBrush", json!({"brush": {"opacity": 0.37}})).unwrap(); + assert!((a.session.tools.brush.opacity - 0.37).abs() < 1e-6, "values above the floor are kept"); +} diff --git a/crates/ui-egui/src/input/opacity_keys.rs b/crates/ui-egui/src/input/opacity_keys.rs index 7f02c0c77b..6c1312a7fb 100644 --- a/crates/ui-egui/src/input/opacity_keys.rs +++ b/crates/ui-egui/src/input/opacity_keys.rs @@ -1,5 +1,5 @@ //! Number keys set opacity (#352): 1 = 10% … 9 = 90%, 0 = 100%; two digits -//! typed quickly set an exact value (4 then 5 = 45%, 0 then 0 = 0%). With the Brush, Pencil or +//! typed quickly set an exact value (4 then 5 = 45%; 0 then 0 = 100% for the Brush, #11812). With the Brush, Pencil or //! Eraser they set the brush's Opacity (⇧: Flow; swapped while airbrush build-up is on); with the Gradient or Paint Bucket their //! Opacity; with tools that have no opacity of their own (Move, the selection tools, Type…) the //! active layer's Opacity (⇧: Fill). Tools whose number is a strength or exposure are left alone. @@ -107,9 +107,13 @@ fn apply(app: &mut PhotocraftApp, percent: f32, shift: bool) { let shift = shift ^ (build_up && matches!(target(tool), Target::Brush { flow: true })); match (target(app.ui.tool), shift) { (Target::Brush { .. }, false) => { - // Clone Stamp's and History Brush's Opacity fields (and their strokes) start at 1%, so - // `00` sets the 1% they actually paint with rather than showing a 0% they can't do. - let v = if matches!(tool, Tool::CloneStamp | Tool::HistoryBrush) { v.max(0.01) } else { v }; + // The painting tools' Opacity fields (and `tools.setBrush`) start at 1%, so `00` sets the + // 1% they actually paint with rather than a 0% they can't do. The Brush's `00` is 100%, + // as in Photoshop (#11812); only `00` gives 0 here. + let v = match tool { + Tool::Brush if v <= 0.0 => 1.0, + _ => v.max(0.01), + }; let _ = app.run("tools.setBrush", json!({ "brush": { "opacity": v } })); } // The options bar's Flow field starts at 1%. @@ -163,7 +167,7 @@ mod tests { press(&mut a, 0, false, 9000.0); assert_eq!(a.session.tools.brush.opacity, 1.0, "0 = 100%"); press(&mut a, 0, false, 9100.0); - assert_eq!(a.session.tools.brush.opacity, 0.0, "00 = 0%"); + assert_eq!(a.session.tools.brush.opacity, 1.0, "00 = 100%"); press(&mut a, 2, true, 20000.0); assert!((a.session.tools.brush.flow - 0.2).abs() < 1e-6); press(&mut a, 0, true, 30000.0); @@ -224,10 +228,6 @@ mod tests { press(&mut a, 0, false, 100.0); assert!((a.session.tools.brush.opacity - 0.01).abs() < 1e-6, "{tool:?}"); } - let mut a = app(Tool::Brush); - press(&mut a, 0, false, 0.0); - press(&mut a, 0, false, 100.0); - assert!(a.session.tools.brush.opacity.abs() < 1e-6, "the Brush itself still goes to 0%"); } #[test] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 7fd35442cc..17b99b7460 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -978,6 +978,8 @@ mod menu_item_labels_tests; #[cfg(test)] mod pen_path_ownership_tests; +#[cfg(test)] +mod brush_opacity_minimum_tests; #[cfg(test)] mod doc_tab_menu_tests; #[cfg(test)] diff --git a/crates/ui-egui/src/panels/options_bar.rs b/crates/ui-egui/src/panels/options_bar.rs index 15c602ae99..6f0d2718c7 100644 --- a/crates/ui-egui/src/panels/options_bar.rs +++ b/crates/ui-egui/src/panels/options_bar.rs @@ -172,7 +172,8 @@ fn brush_specific_options(app: &mut PhotocraftApp, ui: &mut egui::Ui, t: Tokens, paint_mode_dropdown(ui, "brush-mode", b, &mut app.ui.tool_options.brush_clear, held); } ui.add_enabled_ui(!block, |ui| { - percent_field(ui, tl!("Opacity"), &mut b.opacity, 0.0..=100.0, 62.0); + // A whole number from 1 to 100, as in Photoshop: 0 paints nothing (#11812). + whole_percent_field(ui, tl!("Opacity"), &mut b.opacity, 1.0..=100.0, 62.0); if icons::button(ui, "circle-dot", 24.0, b.pressure_opacity, tl!("Always use pressure for opacity")).clicked() { b.pressure_opacity = !b.pressure_opacity; } @@ -206,7 +207,7 @@ fn brush_specific_options(app: &mut PhotocraftApp, ui: &mut egui::Ui, t: Tokens, opt_label(ui, tl!("Mode")); let held = app.held_erase; paint_mode_dropdown(ui, "pencil-mode", b, &mut app.ui.tool_options.pencil_clear, held); - percent_field(ui, tl!("Opacity"), &mut b.opacity, 0.0..=100.0, if t.pro { 62.0 } else { 66.0 }); + whole_percent_field(ui, tl!("Opacity"), &mut b.opacity, 1.0..=100.0, if t.pro { 62.0 } else { 66.0 }); opt_label(ui, tl!("Smoothing")); smoothing_field(ui, b, if t.pro { 58.0 } else { 66.0 }); widgets::vline(ui, 22.0); @@ -233,7 +234,7 @@ fn brush_specific_options(app: &mut PhotocraftApp, ui: &mut egui::Ui, t: Tokens, // A Pencil-mode Eraser is always hard and at full flow (Photoshop). let fixed = block || crate::eraser_ui::pencil_mode(app, app.ui.tool); ui.add_enabled_ui(!fixed, |ui| percent_field(ui, tl!("Hardness"), &mut b.hardness, 0.0..=100.0, 66.0)); - ui.add_enabled_ui(!block, |ui| percent_field(ui, tl!("Opacity"), &mut b.opacity, 0.0..=100.0, 66.0)); + ui.add_enabled_ui(!block, |ui| whole_percent_field(ui, tl!("Opacity"), &mut b.opacity, 1.0..=100.0, 66.0)); ui.add_enabled_ui(!fixed, |ui| percent_field(ui, tl!("Flow"), &mut b.flow, 1.0..=100.0, 66.0)); opt_label(ui, tl!("Smoothing")); smoothing_field(ui, b, 66.0); @@ -803,6 +804,15 @@ fn percent_field(ui: &mut egui::Ui, label: &str, value: &mut f32, range: std::op } } +/// [`percent_field`] that keeps whole percents (`33.7` becomes 34 %). +fn whole_percent_field(ui: &mut egui::Ui, label: &str, value: &mut f32, range: std::ops::RangeInclusive, width: f32) { + opt_label(ui, label); + let mut percent = *value * 100.0; + if widgets::value_field_named(ui, label, &mut percent, range.clone(), "%", width).changed() { + *value = percent.round().max(*range.start()).min(*range.end()) / 100.0; + } +} + fn hint(ui: &mut egui::Ui, s: &str) { let t = Tokens::get(ui.ctx()); ui.label(RichText::new(s).color(t.text_faint)); From 2eb4e8441180a114da40cf0f3e498d75f45e6d66 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 6 Nov 2026 21:23:30 -0800 Subject: [PATCH 089/219] Use toolbar order for fresh tool shortcuts --- crates/ui-egui/src/input/shortcuts.rs | 11 ++- .../src/input/tool_key_default_tests.rs | 85 +++++++++++++++++++ 2 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 crates/ui-egui/src/input/tool_key_default_tests.rs diff --git a/crates/ui-egui/src/input/shortcuts.rs b/crates/ui-egui/src/input/shortcuts.rs index 0abd096530..63444f628a 100644 --- a/crates/ui-egui/src/input/shortcuts.rs +++ b/crates/ui-egui/src/input/shortcuts.rs @@ -916,15 +916,16 @@ fn select_tool_by_key(app: &mut PhotocraftApp, ctx: &egui::Context, pressed: &im if cycle_shift || pressed(k) { let before = app.ui.tool; let mut group: Vec = Tool::ALL.iter().copied().filter(|x| x.key() == t.key() && crate::tool_shortcuts::uses_default(app, *x)).collect(); - // Shift+key (the cycle and the fallback to the group's first tool) uses the toolbar - // flyout's order; plain keys keep the `Tool::ALL` order. + // Shift+key cycles in toolbar flyout order. if cycle_shift { group.sort_by_key(|x| crate::panels::toolbar::toolbar_position(*x)); } app.ui.tool = match group.iter().position(|x| *x == app.ui.tool) { Some(i) if shift_switch && !cycle_shift => group[i], Some(i) => group[(i + 1) % group.len()], - None => app.ui.group_tool(t.key()).filter(|last| group.contains(last)).unwrap_or(group[0]), + None => app.ui.group_tool(t.key()).filter(|last| group.contains(last)).unwrap_or_else(|| { + group.iter().copied().min_by_key(|tool| crate::panels::toolbar::toolbar_position(*tool)).unwrap_or(t) + }), }; crate::tool_memory::ended_by_tool(app); // `consume_key` ignores extra ⇧ and ⌥: a ⇧/⌥ switch is never spring-loaded. @@ -942,3 +943,7 @@ fn select_tool_by_key(app: &mut PhotocraftApp, ctx: &egui::Context, pressed: &im #[cfg(test)] mod tests; + +#[cfg(test)] +#[path = "tool_key_default_tests.rs"] +mod tool_key_default_tests; diff --git a/crates/ui-egui/src/input/tool_key_default_tests.rs b/crates/ui-egui/src/input/tool_key_default_tests.rs new file mode 100644 index 0000000000..6e5347ed17 --- /dev/null +++ b/crates/ui-egui/src/input/tool_key_default_tests.rs @@ -0,0 +1,85 @@ +use super::{Key, Modifiers, PhotocraftApp, Tool, handle}; +use crate::panels::toolbar::TOOL_SECTIONS; +use crate::spring_tools::key_of; +use std::collections::HashSet; + +fn app() -> PhotocraftApp { + PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()) +} + +fn press(app: &mut PhotocraftApp, ctx: &egui::Context, key: Key, modifiers: Modifiers) { + let raw = egui::RawInput { + events: vec![ + egui::Event::ModifiersChanged(modifiers), + egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }, + egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }, + ], + ..Default::default() + }; + ctx.begin_pass(raw); + handle(app, ctx); + ctx.end_pass().textures_delta.clear(); +} + +#[test] +fn fresh_tool_letters_pick_the_first_tool_in_toolbar_order() { + for shift_switch in [true, false] { + let mut checked = HashSet::new(); + for tool in TOOL_SECTIONS.iter().flat_map(|section| section.iter()).flat_map(|slot| slot.iter()).copied() { + let Some(key) = key_of(tool.key()) else { continue }; + if !checked.insert(key) { + continue; + } + + let mut app = app(); + app.session.edit_prefs(|prefs| prefs.tools.use_shift_key_for_tool_switch = shift_switch); + if app.ui.tool.key() == tool.key() { + app.ui.tool = Tool::Brush; + } + press(&mut app, &egui::Context::default(), key, Modifiers::NONE); + assert_eq!(app.ui.tool, tool, "{key:?}, Shift switch: {shift_switch}"); + } + assert_eq!(checked, Tool::ALL.into_iter().filter_map(|tool| key_of(tool.key())).collect::>()); + } +} + +#[test] +fn plain_w_returns_to_the_remembered_magic_wand() { + for hidden in [false, true] { + let mut app = app(); + let ctx = egui::Context::default(); + if hidden { + app.session.edit_prefs(|prefs| prefs.toolbar.hidden = vec!["MagicWand".to_owned()]); + } + app.ui.tool = Tool::MagicWand; + press(&mut app, &ctx, Key::B, Modifiers::NONE); + assert_eq!(app.ui.tool, Tool::Brush); + press(&mut app, &ctx, Key::W, Modifiers::NONE); + assert_eq!(app.ui.tool, Tool::MagicWand, "hidden: {hidden}"); + } +} + +#[test] +fn shift_w_cycles_in_toolbar_order() { + let mut app = app(); + let ctx = egui::Context::default(); + app.ui.tool = Tool::ObjectSelection; + + for expected in [Tool::QuickSelection, Tool::MagicWand, Tool::ObjectSelection] { + press(&mut app, &ctx, Key::W, Modifiers::SHIFT); + assert_eq!(app.ui.tool, expected); + } + press(&mut app, &ctx, Key::W, Modifiers::NONE); + assert_eq!(app.ui.tool, Tool::ObjectSelection); +} + +#[test] +fn remapped_first_tool_is_skipped_by_its_default_letter() { + let mut app = app(); + let ctx = egui::Context::default(); + app.run("prefs.set", serde_json::json!({"path": "shortcuts.tools.select.objectSelection", "value": "F6"})).unwrap(); + press(&mut app, &ctx, Key::W, Modifiers::NONE); + assert_eq!(app.ui.tool, Tool::QuickSelection); + press(&mut app, &ctx, Key::F6, Modifiers::NONE); + assert_eq!(app.ui.tool, Tool::ObjectSelection); +} From 26a8215043ece846add6b03b745cb2bd55c7d799 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 6 Nov 2026 21:58:06 -0800 Subject: [PATCH 090/219] Format the fresh tool shortcut fallback --- crates/ui-egui/src/input/shortcuts.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/input/shortcuts.rs b/crates/ui-egui/src/input/shortcuts.rs index 63444f628a..212d16b63d 100644 --- a/crates/ui-egui/src/input/shortcuts.rs +++ b/crates/ui-egui/src/input/shortcuts.rs @@ -923,9 +923,11 @@ fn select_tool_by_key(app: &mut PhotocraftApp, ctx: &egui::Context, pressed: &im app.ui.tool = match group.iter().position(|x| *x == app.ui.tool) { Some(i) if shift_switch && !cycle_shift => group[i], Some(i) => group[(i + 1) % group.len()], - None => app.ui.group_tool(t.key()).filter(|last| group.contains(last)).unwrap_or_else(|| { - group.iter().copied().min_by_key(|tool| crate::panels::toolbar::toolbar_position(*tool)).unwrap_or(t) - }), + None => app + .ui + .group_tool(t.key()) + .filter(|last| group.contains(last)) + .unwrap_or_else(|| group.iter().copied().min_by_key(|tool| crate::panels::toolbar::toolbar_position(*tool)).unwrap_or(t)), }; crate::tool_memory::ended_by_tool(app); // `consume_key` ignores extra ⇧ and ⌥: a ⇧/⌥ switch is never spring-loaded. From 5b6f3c18373d90f6af4000437db8cd579d4b1c61 Mon Sep 17 00:00:00 2001 From: mzuniga37 <275811311+mzuniga37@users.noreply.github.com> Date: Sat, 7 Nov 2026 00:16:02 -0600 Subject: [PATCH 091/219] Count tool: adding a marker turns View > Show > Count back on With Show > Count off, a click added the marker but left it invisible, so the click looked like it did nothing. It now switches the flag on, without adding a history state. Alt+click and hidden groups are unchanged. Fixes #11770 --- crates/ui-egui/src/tools.rs | 2 + crates/ui-egui/src/tools/analysis_ui.rs | 5 +- .../src/tools/count_show_extras_tests.rs | 70 +++++++++++++++++++ 3 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/src/tools/count_show_extras_tests.rs diff --git a/crates/ui-egui/src/tools.rs b/crates/ui-egui/src/tools.rs index 9878bf41a4..ccddd07b6d 100644 --- a/crates/ui-egui/src/tools.rs +++ b/crates/ui-egui/src/tools.rs @@ -56,5 +56,7 @@ pub mod vector_ui; pub mod warp_preview; pub mod zoom_tool; +#[cfg(test)] +mod count_show_extras_tests; #[cfg(test)] mod crop_options_labels_tests; diff --git a/crates/ui-egui/src/tools/analysis_ui.rs b/crates/ui-egui/src/tools/analysis_ui.rs index 6cad2b2158..5229fa1b5b 100644 --- a/crates/ui-egui/src/tools/analysis_ui.rs +++ b/crates/ui-egui/src/tools/analysis_ui.rs @@ -294,7 +294,10 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> } Some((g, i)) => app.ui.analysis.drag = Some(Drag::Count(g, i, [x, y])), None if !mods.alt => { - let _ = app.run("count.add", json!({"x": x, "y": y})); + // A marker that stays invisible looks like the click did nothing, so adding one + // brings View > Show > Count back. It is view state, not a history step. + let added = app.run("count.add", json!({"x": x, "y": y})).is_ok(); + app.ui.view.show.count |= added; } None => {} } diff --git a/crates/ui-egui/src/tools/count_show_extras_tests.rs b/crates/ui-egui/src/tools/count_show_extras_tests.rs new file mode 100644 index 0000000000..773ce74546 --- /dev/null +++ b/crates/ui-egui/src/tools/count_show_extras_tests.rs @@ -0,0 +1,70 @@ +//! #11770: adding a count marker while View > Show > Count is off switches it back on. + +use egui::Modifiers; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::canvas::{ToolEvent, tool_event}; +use crate::state::Tool; + +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.ui.tool = Tool::Count; + app +} + +fn click(app: &mut PhotocraftApp, x: f64, y: f64, m: Modifiers) { + tool_event(app, ToolEvent::Down { x, y, pressure: 1.0 }, m); + tool_event(app, ToolEvent::Up { x, y }, m); +} + +fn points(app: &PhotocraftApp) -> usize { + app.session.active().unwrap().doc.measurement.count_groups.iter().map(|g| g.points.len()).sum() +} + +fn past(app: &PhotocraftApp) -> usize { + app.session.active().unwrap().history.past_len() +} + +#[test] +fn a_click_with_show_count_off_adds_the_marker_and_turns_it_on() { + let mut app = app(); + app.ui.view.show.count = false; + let before = past(&app); + click(&mut app, 40.0, 30.0, Modifiers::NONE); + assert_eq!(points(&app), 1); + assert!(app.ui.view.show.count, "the new marker is visible"); + assert_eq!(past(&app), before + 1, "only the marker's own history state"); +} + +#[test] +fn a_click_with_show_count_on_behaves_as_before() { + let mut app = app(); + app.ui.view.show.count = true; + click(&mut app, 40.0, 30.0, Modifiers::NONE); + assert_eq!(points(&app), 1); + assert!(app.ui.view.show.count); +} + +#[test] +fn a_click_into_a_hidden_group_leaves_the_group_hidden() { + let mut app = app(); + click(&mut app, 10.0, 10.0, Modifiers::NONE); + app.run("count.setGroup", json!({"group": 0, "visible": false})).unwrap(); + app.ui.view.show.count = false; + click(&mut app, 90.0, 60.0, Modifiers::NONE); + assert_eq!(points(&app), 2); + assert!(app.ui.view.show.count); + assert!(!app.session.active().unwrap().doc.measurement.count_groups[0].visible); +} + +#[test] +fn removing_a_marker_does_not_turn_show_count_on() { + let mut app = app(); + click(&mut app, 10.0, 10.0, Modifiers::NONE); + app.ui.view.show.count = false; + click(&mut app, 10.0, 10.0, Modifiers::ALT); + assert_eq!(points(&app), 0); + assert!(!app.ui.view.show.count); +} From 0e0d99d0b2b31cdc7809d2ffaf50927f09b6b853 Mon Sep 17 00:00:00 2001 From: DuBearte <130190089+DuBearte@users.noreply.github.com> Date: Sat, 7 Nov 2026 01:29:32 -0500 Subject: [PATCH 092/219] fix(engine): Magic Wand Subtract and Intersect grow from the seed through the selection (#11779) Alt-click (Subtract) and Shift+Alt-click (Intersect) with the Magic Wand took the seed's whole region, wherever the seed was, so a click outside the selection still cut into it. With Contiguous on, the region now grows from the seed through selected pixels only: a seed outside the selection subtracts nothing and intersects to nothing, and a seed inside gives the same results as before. Contiguous off is unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/algo/src/selection.rs | 27 +++++++- crates/engine/src/cmd/selection_cmds.rs | 10 ++- .../selection_cmds/wand_seed_outside_tests.rs | 68 +++++++++++++++++++ 3 files changed, 100 insertions(+), 5 deletions(-) create mode 100644 crates/engine/src/cmd/selection_cmds/wand_seed_outside_tests.rs diff --git a/crates/algo/src/selection.rs b/crates/algo/src/selection.rs index f740687c1a..8ceba64047 100644 --- a/crates/algo/src/selection.rs +++ b/crates/algo/src/selection.rs @@ -270,6 +270,22 @@ impl Region { /// byte mark per pixel, then anti-aliases just the edges inside the touched bounding box. /// Matches [`magic_wand`] for 8-bit sources. `None` if the seed is outside `area`. pub fn wand_region(img: &[[u8; 4]], area: Rect, seed: (i32, i32), tolerance: f32, contiguous: bool, anti_alias: bool) -> Option { + wand_region_within(img, area, seed, tolerance, contiguous, anti_alias, None) +} + +/// [`wand_region`] whose contiguous fill only passes through pixels at least half selected in +/// `within` (the current selection's coverage over `area`, row-major): Subtract and Intersect +/// grow the region from the seed through the selection only (#11779). A seed outside it finds +/// nothing. A global match (`contiguous` off) ignores `within`. +pub fn wand_region_within( + img: &[[u8; 4]], + area: Rect, + seed: (i32, i32), + tolerance: f32, + contiguous: bool, + anti_alias: bool, + within: Option<&[f32]>, +) -> Option { if !area.contains(seed.0, seed.1) { return None; } @@ -277,6 +293,11 @@ pub fn wand_region(img: &[[u8; 4]], area: Rect, seed: (i32, i32), tolerance: f32 if w.checked_mul(h).is_none_or(|n| n > img.len()) { return None; } + let within = within.filter(|_| contiguous).filter(|m| m.len() >= w * h); + let inside = |i: usize| within.is_none_or(|m| m.get(i).is_some_and(|v| *v >= 0.5)); + if !inside((seed.1 - area.y0) as usize * w + (seed.0 - area.x0) as usize) { + return None; + } let target = img[(seed.1 - area.y0) as usize * w + (seed.0 - area.x0) as usize]; let tol = (tolerance + 1e-3).floor().max(0.0) as i32; // Copying a selection can leave RGB under zero alpha. Those invisible colours must not @@ -293,10 +314,10 @@ pub fn wand_region(img: &[[u8; 4]], area: Rect, seed: (i32, i32), tolerance: f32 continue; } let (mut l, mut r) = (x, x); - while l > 0 && marks[row + l - 1] == 0 && similar(img[row + l - 1]) { + while l > 0 && marks[row + l - 1] == 0 && similar(img[row + l - 1]) && inside(row + l - 1) { l -= 1; } - while r + 1 < w && marks[row + r + 1] == 0 && similar(img[row + r + 1]) { + while r + 1 < w && marks[row + r + 1] == 0 && similar(img[row + r + 1]) && inside(row + r + 1) { r += 1; } marks[row + l..=row + r].fill(255); @@ -308,7 +329,7 @@ pub fn wand_region(img: &[[u8; 4]], area: Rect, seed: (i32, i32), tolerance: f32 let nrow = ny * w; let mut in_run = false; for nx in l..=r { - let ok = marks[nrow + nx] == 0 && similar(img[nrow + nx]); + let ok = marks[nrow + nx] == 0 && similar(img[nrow + nx]) && inside(nrow + nx); if ok && !in_run { stack.push((nx, ny)); } diff --git a/crates/engine/src/cmd/selection_cmds.rs b/crates/engine/src/cmd/selection_cmds.rs index 13737e70bd..c1c665e111 100644 --- a/crates/engine/src/cmd/selection_cmds.rs +++ b/crates/engine/src/cmd/selection_cmds.rs @@ -17,6 +17,8 @@ mod modify_window; mod color_range_performance; #[cfg(test)] mod color_range_window_tests; +#[cfg(test)] +mod wand_seed_outside_tests; fn has_doc(s: &Session) -> std::result::Result<(), String> { s.active().map(|_| ()).ok_or_else(|| "no document open".into()) @@ -139,9 +141,13 @@ fn magic_wand(s: &mut Session, p: &Value) -> Result { Some(plane) => plane, None => sample_rgba8(s, b(p, "sampleAllLayers", false))?, }; - let region = sel::wand_region(&img, area, (x, y), f(p, "tolerance", 32.0), b(p, "contiguous", true), b(p, "antiAlias", true)); - drop(img); let m = mode(p); + // Subtract and Intersect grow the region from the seed through the selection only (#11779). + let within = matches!(m, SelectionMode::Subtract | SelectionMode::Intersect) + .then(|| s.active().and_then(|st| st.doc.selection.as_ref()).map(|sel| sel::mask_from_surface(Some(sel), area))) + .flatten(); + let region = sel::wand_region_within(&img, area, (x, y), f(p, "tolerance", 32.0), b(p, "contiguous", true), b(p, "antiAlias", true), within.as_deref()); + drop(img); let selected = s.edit("Magic Wand", |doc, _| { doc.selection = sel::combine_region(doc.selection.as_ref(), region.as_ref(), m); Ok(doc.selection.is_some()) diff --git a/crates/engine/src/cmd/selection_cmds/wand_seed_outside_tests.rs b/crates/engine/src/cmd/selection_cmds/wand_seed_outside_tests.rs new file mode 100644 index 0000000000..13165d591f --- /dev/null +++ b/crates/engine/src/cmd/selection_cmds/wand_seed_outside_tests.rs @@ -0,0 +1,68 @@ +//! #11779: with Contiguous on, the Magic Wand's Subtract and Intersect grow the region from the +//! seed through the current selection only. A seed outside the selection subtracts nothing and +//! intersects to nothing. + +use serde_json::json; + +use crate::Session; + +/// 800 × 600, white, with a grey ramp of ten 40 px blocks from x 100 (100, 105, …, 145). +fn ramp() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 800, "height": 600, "background": "white"})).unwrap(); + for k in 0..10 { + let v = 100 + 5 * k; + s.execute("select.rect", json!({"x": 100 + 40 * k, "y": 350, "width": 40, "height": 100})).unwrap(); + s.execute("edit.fill", json!({"color": format!("#{v:02x}{v:02x}{v:02x}")})).unwrap(); + } + s.execute("select.deselect", json!({})).unwrap(); + s +} + +fn wand(s: &mut Session, x: i32, tolerance: f64, mode: &str) { + s.execute("select.magicWand", json!({"x": x, "y": 400, "tolerance": tolerance, "mode": mode})).unwrap(); +} + +/// The columns of row 400 that are at least half selected: (first, end), or `None`. +fn selected(s: &Session) -> Option<(i32, i32)> { + let doc = &s.active().unwrap().doc; + let row = photocraft_geom::Rect::new(0, 400, 800, 401); + let m = photocraft_algo::selection::mask_from_surface(doc.selection.as_ref(), row); + let first = m.iter().position(|v| *v >= 0.5)?; + let last = m.iter().rposition(|v| *v >= 0.5)?; + Some((first as i32, last as i32 + 1)) +} + +/// Greys 100 to 130 (x 100–380) selected, as tolerance 32 on the first block selects them. +fn ramp_selected() -> Session { + let mut s = ramp(); + wand(&mut s, 110, 32.0, "replace"); + assert_eq!(selected(&s), Some((100, 380))); + s +} + +#[test] +fn a_seed_outside_the_selection_subtracts_nothing() { + let mut s = ramp_selected(); + // Grey 145 (x 460–500) is outside the selection, though its own region reaches into it. + wand(&mut s, 480, 32.0, "subtract"); + assert_eq!(selected(&s), Some((100, 380)), "unchanged"); +} + +#[test] +fn a_seed_outside_the_selection_intersects_to_nothing() { + let mut s = ramp_selected(); + wand(&mut s, 480, 32.0, "intersect"); + assert_eq!(selected(&s), None); +} + +#[test] +fn a_seed_inside_the_selection_works_as_before() { + // Grey 125 with tolerance 10 reaches greys 115–135; inside the selection that is x 220–380. + let mut s = ramp_selected(); + wand(&mut s, 320, 10.0, "subtract"); + assert_eq!(selected(&s), Some((100, 220))); + let mut s = ramp_selected(); + wand(&mut s, 320, 10.0, "intersect"); + assert_eq!(selected(&s), Some((220, 380))); +} From 4551b2a2007bd378629654089074391e7e47c2cf Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 6 Nov 2026 23:13:48 -0800 Subject: [PATCH 093/219] Document tool shortcut selection behavior --- crates/ui-egui/src/input/shortcuts.rs | 5 +++-- crates/ui-egui/src/input/tool_key_default_tests.rs | 4 ++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/input/shortcuts.rs b/crates/ui-egui/src/input/shortcuts.rs index 212d16b63d..cf23556c35 100644 --- a/crates/ui-egui/src/input/shortcuts.rs +++ b/crates/ui-egui/src/input/shortcuts.rs @@ -903,8 +903,9 @@ fn handle_escape_and_view_keys(app: &mut PhotocraftApp, ctx: &egui::Context) { crate::opacity_keys::handle(app, ctx); } -/// Selects a tool from its letter key. Pressing the key of the current group cycles within it; with -/// the Shift-switch preference only ⇧+key cycles. Returns true when a tool key was handled. +/// Selects a tool from its letter key. Entering a group uses its remembered eligible tool or the +/// first eligible tool in toolbar order. Pressing the key of the current group cycles within it. +/// With the Shift-switch preference, only ⇧+key cycles. Returns true when a tool key was handled. fn select_tool_by_key(app: &mut PhotocraftApp, ctx: &egui::Context, pressed: &impl Fn(Key) -> bool) -> bool { let shift_switch = app.session.prefs().tools.use_shift_key_for_tool_switch; for t in Tool::ALL { diff --git a/crates/ui-egui/src/input/tool_key_default_tests.rs b/crates/ui-egui/src/input/tool_key_default_tests.rs index 6e5347ed17..5b846d5e7f 100644 --- a/crates/ui-egui/src/input/tool_key_default_tests.rs +++ b/crates/ui-egui/src/input/tool_key_default_tests.rs @@ -21,6 +21,7 @@ fn press(app: &mut PhotocraftApp, ctx: &egui::Context, key: Key, modifiers: Modi ctx.end_pass().textures_delta.clear(); } +/// A fresh app enters each default letter group at its first toolbar tool under either preference. #[test] fn fresh_tool_letters_pick_the_first_tool_in_toolbar_order() { for shift_switch in [true, false] { @@ -43,6 +44,7 @@ fn fresh_tool_letters_pick_the_first_tool_in_toolbar_order() { } } +/// A remembered Magic Wand takes precedence over the fresh W default, even when hidden. #[test] fn plain_w_returns_to_the_remembered_magic_wand() { for hidden in [false, true] { @@ -59,6 +61,8 @@ fn plain_w_returns_to_the_remembered_magic_wand() { } } +/// Shift+W follows the flyout order and wraps. With the default preference, plain W keeps +/// the current tool when it already belongs to the group. #[test] fn shift_w_cycles_in_toolbar_order() { let mut app = app(); From f52206293613436e518a7c964ce30c2e415c35fe Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 7 Nov 2026 02:45:20 -0800 Subject: [PATCH 094/219] Expect Object Selection on the first W press --- crates/ui-egui/src/tool_shift_cycle_order_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/tool_shift_cycle_order_tests.rs b/crates/ui-egui/src/tool_shift_cycle_order_tests.rs index 83c3ca33cc..e49077c4ff 100644 --- a/crates/ui-egui/src/tool_shift_cycle_order_tests.rs +++ b/crates/ui-egui/src/tool_shift_cycle_order_tests.rs @@ -160,5 +160,5 @@ fn a_plain_w_from_outside_the_group_keeps_selecting_what_it_selected_before() { ctx.begin_pass(raw); crate::shortcuts::handle(&mut app, &ctx); ctx.end_pass().textures_delta.clear(); - assert_eq!(app.ui.tool, Tool::MagicWand, "the first W tool in Tool::ALL, not the first in the flyout"); + assert_eq!(app.ui.tool, Tool::ObjectSelection, "fresh W selects the first tool in the flyout"); } From 5fa91a09395b9d4c205558a185baef47e37baaf4 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 7 Nov 2026 12:05:58 +0100 Subject: [PATCH 095/219] Address CodeRabbit review: whole-percent Opacity display, Pencil "00" = 100 % (#11812) The Opacity field now shows a value set elsewhere (a tools.setBrush fraction, an older saved 0) as the whole percent in 1-100 it can hold, without writing it back. "00" sets the Pencil to 100 % like the Brush, as measured in the reference application. Co-Authored-By: Claude Opus 5.5 --- .../src/brush_opacity_minimum_tests.rs | 27 +++++++++++++++++++ crates/ui-egui/src/input/opacity_keys.rs | 8 +++--- crates/ui-egui/src/panels/options_bar.rs | 5 ++-- 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/brush_opacity_minimum_tests.rs b/crates/ui-egui/src/brush_opacity_minimum_tests.rs index cbceaef52c..77f84a7b41 100644 --- a/crates/ui-egui/src/brush_opacity_minimum_tests.rs +++ b/crates/ui-egui/src/brush_opacity_minimum_tests.rs @@ -117,3 +117,30 @@ fn set_brush_floors_opacity_at_one_percent() { a.run("tools.setBrush", json!({"brush": {"opacity": 0.37}})).unwrap(); assert!((a.session.tools.brush.opacity - 0.37).abs() < 1e-6, "values above the floor are kept"); } + +/// The Pencil's `00` is 100 % too (the Pencil has no Flow, so ⇧ digits are not involved). +#[test] +fn double_zero_sets_the_pencil_to_100_percent() { + let mut a = app(Tool::Pencil); + a.run("tools.setBrush", json!({"brush": {"opacity": 0.3}})).unwrap(); + press(&mut a, 0, false, 0.0); + press(&mut a, 0, false, 100.0); + assert_eq!(a.session.tools.brush.opacity, 1.0, "00 = 100%"); +} + +/// An opacity set outside the field (a `tools.setBrush` fraction, an older saved 0) shows as the +/// whole percent in 1-100 the field can hold. +#[test] +fn the_opacity_field_shows_outside_values_as_whole_percents() { + let mut h = options_bar(Tool::Brush, crate::theme::ThemeKind::ProMedium); + for (stored, shown) in [(0.337, 34.0), (0.0, 1.0), (0.004, 1.0)] { + h.state_mut().session.tools.brush.opacity = stored; + h.run_steps(2); + let value = h + .query_all(egui_kittest::kittest::by().role(egui::accesskit::Role::SpinButton)) + .find(|n| n.accesskit_node().label().is_some_and(|l| l.trim_end_matches(':') == "Opacity")) + .and_then(|n| n.accesskit_node().numeric_value()); + assert_eq!(value, Some(shown), "stored {stored}"); + assert_eq!(h.state().session.tools.brush.opacity, stored, "showing it does not change it"); + } +} diff --git a/crates/ui-egui/src/input/opacity_keys.rs b/crates/ui-egui/src/input/opacity_keys.rs index 6c1312a7fb..167261e417 100644 --- a/crates/ui-egui/src/input/opacity_keys.rs +++ b/crates/ui-egui/src/input/opacity_keys.rs @@ -1,5 +1,5 @@ //! Number keys set opacity (#352): 1 = 10% … 9 = 90%, 0 = 100%; two digits -//! typed quickly set an exact value (4 then 5 = 45%; 0 then 0 = 100% for the Brush, #11812). With the Brush, Pencil or +//! typed quickly set an exact value (4 then 5 = 45%; 0 then 0 = 100% for the Brush and the Pencil, #11812). With the Brush, Pencil or //! Eraser they set the brush's Opacity (⇧: Flow; swapped while airbrush build-up is on); with the Gradient or Paint Bucket their //! Opacity; with tools that have no opacity of their own (Move, the selection tools, Type…) the //! active layer's Opacity (⇧: Fill). Tools whose number is a strength or exposure are left alone. @@ -108,10 +108,10 @@ fn apply(app: &mut PhotocraftApp, percent: f32, shift: bool) { match (target(app.ui.tool), shift) { (Target::Brush { .. }, false) => { // The painting tools' Opacity fields (and `tools.setBrush`) start at 1%, so `00` sets the - // 1% they actually paint with rather than a 0% they can't do. The Brush's `00` is 100%, - // as in Photoshop (#11812); only `00` gives 0 here. + // 1% they actually paint with rather than a 0% they can't do. The Brush's and the + // Pencil's `00` is 100%, as in Photoshop (#11812); only `00` gives 0 here. let v = match tool { - Tool::Brush if v <= 0.0 => 1.0, + Tool::Brush | Tool::Pencil if v <= 0.0 => 1.0, _ => v.max(0.01), }; let _ = app.run("tools.setBrush", json!({ "brush": { "opacity": v } })); diff --git a/crates/ui-egui/src/panels/options_bar.rs b/crates/ui-egui/src/panels/options_bar.rs index 6f0d2718c7..aa39becba5 100644 --- a/crates/ui-egui/src/panels/options_bar.rs +++ b/crates/ui-egui/src/panels/options_bar.rs @@ -804,10 +804,11 @@ fn percent_field(ui: &mut egui::Ui, label: &str, value: &mut f32, range: std::op } } -/// [`percent_field`] that keeps whole percents (`33.7` becomes 34 %). +/// [`percent_field`] that keeps whole percents (`33.7` becomes 34 %). A value set elsewhere (a +/// `tools.setBrush` fraction, an older saved 0) shows as the whole percent in range. fn whole_percent_field(ui: &mut egui::Ui, label: &str, value: &mut f32, range: std::ops::RangeInclusive, width: f32) { opt_label(ui, label); - let mut percent = *value * 100.0; + let mut percent = (*value * 100.0).round().max(*range.start()).min(*range.end()); if widgets::value_field_named(ui, label, &mut percent, range.clone(), "%", width).changed() { *value = percent.round().max(*range.start()).min(*range.end()) / 100.0; } From 6d3e9e71bf1aefa1db0ec8e7623506889d1525c5 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 7 Nov 2026 13:00:55 +0100 Subject: [PATCH 096/219] Color Sampler: right-click menu on the canvas (#11733) A right-click with the Color Sampler opens its menu in the reference application's order: Delete, then the readout modes. Delete removes the sampler under the pointer (view.colorSamplers.delete) and the rest are renumbered. Off a sampler every row is greyed. The readout rows stay greyed until samplers have their own readout mode (#11735). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/canvas_tool_menu.rs | 62 +++++- crates/ui-egui/src/tools/analysis_ui.rs | 12 ++ .../tools/analysis_ui/sampler_menu_tests.rs | 190 ++++++++++++++++++ crates/ui-egui/src/transform_type_tests.rs | 1 + 4 files changed, 264 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/src/tools/analysis_ui/sampler_menu_tests.rs diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu.rs b/crates/ui-egui/src/canvas/canvas_tool_menu.rs index b2e377e0ec..098304d147 100644 --- a/crates/ui-egui/src/canvas/canvas_tool_menu.rs +++ b/crates/ui-egui/src/canvas/canvas_tool_menu.rs @@ -46,6 +46,10 @@ pub struct CanvasToolMenu { /// had when it opened. #[serde(default)] pub crop: Option>, + /// The Color Sampler menu's sampler: the one under the pointer when it opened (its index), + /// `None` off a sampler, where every row is greyed. + #[serde(default)] + pub sampler: Option, } /// A misspelled word in the type layer being edited (`edit.checkSpelling`'s `list` entry). @@ -303,6 +307,7 @@ pub fn open_crop(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; guide: false, spelling: None, crop: Some(users), + sampler: None, }); crate::canvas_popup::mark_open(ctx, menu_id()); true @@ -445,7 +450,42 @@ pub const PEN_MENU: &[Row] = &[ Some(("Disable Symmetry Path", "paint.symmetryDisable")), ]; -/// Tools whose plain canvas right-click offers selection actions. +/// The Color Sampler menu's Delete row: removes the sampler the menu opened on. +pub const SAMPLER_DELETE: &str = "view.colorSamplers.delete"; + +/// The Color Sampler's canvas menu (#11733), in the reference application's order: Delete, then +/// the readout modes for the sampler under the pointer. The readout rows are this menu's own ids +/// and stay greyed until samplers have their own readout mode. +pub const SAMPLER_MENU: &[Row] = &[ + Some(("Delete", SAMPLER_DELETE)), + None, + Some(("Actual Color", "colorSampler.readout.actual")), + Some(("Proof Color", "colorSampler.readout.proof")), + None, + Some(("Grayscale", "colorSampler.readout.gray")), + Some(("RGB Color", "colorSampler.readout.rgb")), + Some(("Web Color", "colorSampler.readout.web")), + Some(("HSB Color", "colorSampler.readout.hsb")), + Some(("HSL Color", "colorSampler.readout.hsl")), + Some(("CMYK Color", "colorSampler.readout.cmyk")), + Some(("Lab Color", "colorSampler.readout.lab")), + None, + Some(("Grayscale 16 bit", "colorSampler.readout.gray16")), + Some(("RGB 16 bit", "colorSampler.readout.rgb16")), + Some(("CMYK 16 bit", "colorSampler.readout.cmyk16")), + Some(("Lab 16 bit", "colorSampler.readout.lab16")), + None, + Some(("Total Ink", "colorSampler.readout.totalInk")), +]; + +/// Is `menu` the Color Sampler's own menu (not the pasteboard, guide or transform menu opened +/// with it)? +fn sampler_menu(menu: &CanvasToolMenu) -> bool { + menu.tool == Tool::ColorSampler && !menu.transform && !menu.pasteboard && !menu.guide && menu.crop.is_none() && menu.anchor.is_none() +} + +/// Tools whose plain canvas right-click opens a menu: the selection tools' actions, the path +/// menu and the Color Sampler's. pub fn applies(tool: Tool) -> bool { matches!( tool, @@ -461,6 +501,7 @@ pub fn applies(tool: Tool) -> bool { // Its brush is set from the options bar's chip; the canvas right-click is the // selection menu, as in Photoshop (#7268). | Tool::QuickSelection + | Tool::ColorSampler ) || path_menu(tool) } @@ -510,6 +551,8 @@ pub fn rows(menu: &CanvasToolMenu) -> &'static [Row] { TYPE_LAYER_MENU } else if path_menu(menu.tool) { PEN_MENU + } else if sampler_menu(menu) { + SAMPLER_MENU } else { selection_rows(menu.has_selection) } @@ -649,6 +692,7 @@ pub fn open_transform(app: &mut PhotocraftApp, ctx: &Context, pos: [f32; 2]) -> guide: false, spelling: None, crop: None, + sampler: None, }); crate::canvas_popup::mark_open(ctx, menu_id()); true @@ -674,6 +718,9 @@ pub fn engine_call(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) -> _ => (command.into(), json!({})), }; } + if sampler_menu(menu) { + return (command.into(), menu.sampler.map_or(json!({}), |i| json!({"index": i}))); + } match pasteboard_choice(command) { Some(c) if menu.pasteboard => ("prefs.set".into(), json!({"values": {"interface.canvasColor": c.name()}})), _ if menu.pasteboard => ("prefs.set".into(), json!({})), @@ -727,6 +774,7 @@ pub fn open_pasteboard(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: guide: false, spelling: None, crop: None, + sampler: None, }); crate::canvas_popup::mark_open(ctx, menu_id()); true @@ -758,6 +806,7 @@ pub fn open_guide(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, mods: egui guide: true, spelling: None, crop: None, + sampler: None, }); crate::canvas_popup::mark_open(ctx, menu_id()); true @@ -810,6 +859,11 @@ pub fn entry_enabled(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) if menu.type_layer { return TYPE_LAYER_MENU.iter().flatten().any(|(_, id)| *id == command) && crate::menus::is_enabled(app, command); } + // Off a sampler every row is greyed. The readout rows wait for per-sampler readout modes. + if sampler_menu(menu) { + let count = app.session.active().map_or(0, |d| d.color_samplers.len()); + return command == SAMPLER_DELETE && menu.sampler.is_some_and(|i| i < count); + } if menu.transform || !path_menu(menu.tool) { return crate::menus::is_enabled(app, command); } @@ -870,6 +924,7 @@ pub fn open_at(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; 2] let has_path = crate::vector_ui::active_path_name(app).is_some() || app.ui.pen.as_ref().is_some_and(|p| p.knots.len() >= 2); let path_name = crate::vector_ui::active_path_name(app); let spelling = if text { doc.and_then(|d| spelling_at(app, d)) } else { None }; + let sampler = if tool == Tool::ColorSampler { doc.and_then(|d| crate::analysis_ui::sampler_at(app, d)) } else { None }; app.ui.canvas_tool_menu = Some(CanvasToolMenu { pos, tool, @@ -884,6 +939,7 @@ pub fn open_at(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; 2] guide: false, spelling, crop: None, + sampler, }); crate::canvas_popup::mark_open(ctx, menu_id()); true @@ -911,6 +967,7 @@ pub fn open_view(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; guide: false, spelling: None, crop: None, + sampler: None, }); crate::canvas_popup::mark_open(ctx, menu_id()); true @@ -964,6 +1021,9 @@ pub fn choose(app: &mut PhotocraftApp, ctx: &Context, command: &str) { choose_type(app, ctx, command) } else if path_menu(menu.tool) && !menu.transform { choose_pen(app, ctx, &menu, command) + } else if sampler_menu(&menu) { + let (id, params) = engine_call(app, &menu, command); + app.run(&id, params).map(|_| ()) } else if command == "select.toWorkPath" { // Photoshop's Make Work Path… asks for the tolerance first. spec_dialog(app, command, "Make Work Path", r##"{"tolerance":0.5..10=2}"##, serde_json::Map::new()); diff --git a/crates/ui-egui/src/tools/analysis_ui.rs b/crates/ui-egui/src/tools/analysis_ui.rs index ed113909a6..4f6f49eae2 100644 --- a/crates/ui-egui/src/tools/analysis_ui.rs +++ b/crates/ui-egui/src/tools/analysis_ui.rs @@ -392,6 +392,18 @@ fn nearest_sampler(points: &[[f64; 2]], at: [f64; 2], tol: f64) -> Option points.iter().enumerate().filter(|(_, q)| dist(**q, at) <= tol).min_by(|a, b| dist(*a.1, at).total_cmp(&dist(*b.1, at))).map(|(i, _)| i) } +/// The Color Sampler point under document point `at` on the active document, by the same hit test +/// a click uses, so the right-click menu acts on the sampler a click would grab. +pub(crate) fn sampler_at(app: &PhotocraftApp, at: [f64; 2]) -> Option { + if !at.iter().all(|v| v.is_finite()) { + return None; + } + nearest_sampler(&app.session.active()?.color_samplers, at, tolerance(app)) +} + +#[cfg(test)] +mod sampler_menu_tests; + /// Show a command error in the status bar without treating the sample-limit message as a fault. fn status_on_err(app: &mut PhotocraftApp, r: Result) { if let Err(e) = r { diff --git a/crates/ui-egui/src/tools/analysis_ui/sampler_menu_tests.rs b/crates/ui-egui/src/tools/analysis_ui/sampler_menu_tests.rs new file mode 100644 index 0000000000..feef455427 --- /dev/null +++ b/crates/ui-egui/src/tools/analysis_ui/sampler_menu_tests.rs @@ -0,0 +1,190 @@ +//! #11733: the Color Sampler's right-click menu on the canvas. + +use egui::{Event, Modifiers, PointerButton, vec2}; +use egui_kittest::Harness; +use serde_json::{Value, json}; + +use crate::canvas_tool_menu::{SAMPLER_DELETE, SAMPLER_MENU}; +use crate::control::{ControlRequest, Outcome, handle}; +use crate::{PhotocraftApp, Tool}; + +/// A 200 x 100 document with the Color Sampler and samplers at (20, 20) and (120, 60). +fn app() -> (PhotocraftApp, egui::Context) { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("view.colorSamplers.add", json!({"x": 20, "y": 20})).unwrap(); + app.run("view.colorSamplers.add", json!({"x": 120, "y": 60})).unwrap(); + app.ui.tool = Tool::ColorSampler; + (app, egui::Context::default()) +} + +fn control(app: &mut PhotocraftApp, ctx: &egui::Context, method: &str, params: Value) -> Value { + let (req, _rx) = ControlRequest::new(method, params); + let Outcome::Done(v) = handle(app, ctx, &req) else { panic!("{method} replies at once") }; + v +} + +/// An agent's right-click at document point (x, y), as `ui.pointer` sends it. +fn right_click(app: &mut PhotocraftApp, ctx: &egui::Context, x: f64, y: f64) { + let r = control(app, ctx, "ui.pointer", json!({"button": "secondary", "events": [{"kind": "down", "x": x, "y": y}, {"kind": "up", "x": x, "y": y}]})); + assert_eq!(r["ok"], json!(true), "{r}"); +} + +fn entries(app: &mut PhotocraftApp, ctx: &egui::Context) -> Vec { + let r = control(app, ctx, "ui.inspect", json!({})); + r["result"]["canvasToolMenu"]["entries"].as_array().cloned().unwrap_or_else(|| panic!("no canvasToolMenu: {r}")) +} + +fn samplers(app: &PhotocraftApp) -> Vec<[f64; 2]> { + app.session.active().unwrap().color_samplers.clone() +} + +/// The reference application's rows, in order, with `|` for a separator. +const EXPECTED: &[&str] = &[ + "Delete", + "|", + "Actual Color", + "Proof Color", + "|", + "Grayscale", + "RGB Color", + "Web Color", + "HSB Color", + "HSL Color", + "CMYK Color", + "Lab Color", + "|", + "Grayscale 16 bit", + "RGB 16 bit", + "CMYK 16 bit", + "Lab 16 bit", + "|", + "Total Ink", +]; + +fn labels(entries: &[Value]) -> Vec { + entries.iter().map(|e| if e["separator"] == json!(true) { "|".to_string() } else { e["label"].as_str().unwrap().to_string() }).collect() +} + +#[test] +fn right_click_on_a_sampler_lists_the_menu_in_order_with_delete_enabled() { + let (mut app, ctx) = app(); + right_click(&mut app, &ctx, 20.5, 20.5); + let menu = app.ui.canvas_tool_menu.clone().expect("the Color Sampler menu opens"); + assert_eq!(menu.tool, Tool::ColorSampler); + assert_eq!(menu.sampler, Some(0)); + let entries = entries(&mut app, &ctx); + assert_eq!(labels(&entries), EXPECTED); + let enabled: Vec<&str> = entries.iter().filter(|e| e["enabled"] == json!(true)).filter_map(|e| e["id"].as_str()).collect(); + // The readout rows wait for per-sampler readout modes (#11735) and stay greyed until then. + assert_eq!(enabled, [SAMPLER_DELETE]); + for (_, id) in SAMPLER_MENU.iter().flatten() { + assert!(!crate::canvas_tool_menu::mode_checked(&app, &menu, id), "{id} shows no checkmark"); + } + assert_eq!(samplers(&app).len(), 2, "the right-click places no sampler"); +} + +#[test] +fn right_click_off_a_sampler_greys_every_row() { + let (mut app, ctx) = app(); + right_click(&mut app, &ctx, 70.0, 40.0); + let menu = app.ui.canvas_tool_menu.clone().expect("the menu opens off a sampler too"); + assert_eq!(menu.sampler, None); + let entries = entries(&mut app, &ctx); + assert_eq!(labels(&entries), EXPECTED); + assert!(entries.iter().all(|e| e["enabled"] != json!(true)), "every row greyed: {entries:?}"); + let r = control(&mut app, &ctx, "ui.context.choose", json!({"id": SAMPLER_DELETE})); + assert_eq!(r["ok"], json!(false), "a greyed Delete does nothing: {r}"); + assert_eq!(samplers(&app).len(), 2, "nothing deleted, no sampler placed"); +} + +#[test] +fn delete_removes_the_sampler_under_the_pointer_and_renumbers_the_rest() { + let (mut app, ctx) = app(); + app.run("view.colorSamplers.add", json!({"x": 180, "y": 90})).unwrap(); + right_click(&mut app, &ctx, 120.0, 60.0); + assert_eq!(app.ui.canvas_tool_menu.as_ref().and_then(|m| m.sampler), Some(1)); + let r = control(&mut app, &ctx, "ui.context.choose", json!({"id": SAMPLER_DELETE})); + assert_eq!(r["ok"], json!(true), "{r}"); + assert_eq!(samplers(&app), [[20.0, 20.0], [180.0, 90.0]], "#3 became #2"); + assert!(app.ui.canvas_tool_menu.is_none(), "choosing closes the menu"); + let listed = app.run("view.colorSamplers.list", json!({})).unwrap(); + assert_eq!(listed["samplers"][1]["number"], json!(2)); +} + +#[test] +fn readout_rows_do_nothing_until_samplers_have_readout_modes() { + let (mut app, ctx) = app(); + right_click(&mut app, &ctx, 20.0, 20.0); + let before = app.session.active().unwrap().revision; + let r = control(&mut app, &ctx, "ui.context.choose", json!({"id": "colorSampler.readout.hsl"})); + assert_eq!(r["ok"], json!(false), "{r}"); + assert_eq!(app.session.active().unwrap().revision, before); + assert_eq!(samplers(&app).len(), 2); +} + +#[test] +fn a_stale_sampler_or_odd_point_fails_gracefully() { + let (mut app, ctx) = app(); + right_click(&mut app, &ctx, 120.0, 60.0); + // The sampler went away while the menu was open: Delete greys out instead of removing another. + app.run("view.colorSamplers.clear", json!({})).unwrap(); + app.run("view.colorSamplers.add", json!({"x": 5, "y": 5})).unwrap(); + let menu = app.ui.canvas_tool_menu.clone().unwrap(); + assert!(!crate::canvas_tool_menu::entry_enabled(&app, &menu, SAMPLER_DELETE)); + crate::canvas_tool_menu::choose(&mut app, &ctx, SAMPLER_DELETE); + assert_eq!(samplers(&app), [[5.0, 5.0]]); + for p in [[f64::NAN, 5.0], [f64::INFINITY, 5.0], [5.0, f64::NEG_INFINITY]] { + assert_eq!(super::sampler_at(&app, p), None); + } + // No document: the menu opens greyed, nothing panics. + let mut empty = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + empty.ui.tool = Tool::ColorSampler; + assert_eq!(super::sampler_at(&empty, [1.0, 1.0]), None); + if crate::canvas_tool_menu::open_at(&mut empty, &ctx, Tool::ColorSampler, [10.0, 10.0], Some([1.0, 1.0])) { + let menu = empty.ui.canvas_tool_menu.clone().unwrap(); + assert!(SAMPLER_MENU.iter().flatten().all(|(_, id)| !crate::canvas_tool_menu::entry_enabled(&empty, &menu, id))); + crate::canvas_tool_menu::choose(&mut empty, &ctx, SAMPLER_DELETE); + } +} + +#[test] +fn other_tools_keep_their_menus() { + let (mut app, ctx) = app(); + app.ui.tool = Tool::RectMarquee; + right_click(&mut app, &ctx, 20.0, 20.0); + let menu = app.ui.canvas_tool_menu.clone().expect("the selection menu"); + assert_eq!(crate::canvas_tool_menu::rows(&menu), crate::canvas_tool_menu::NO_SELECTION_MENU); + app.ui.canvas_tool_menu = None; + app.ui.tool = Tool::Pen; + right_click(&mut app, &ctx, 20.0, 20.0); + let menu = app.ui.canvas_tool_menu.clone().expect("the path menu"); + assert_eq!(crate::canvas_tool_menu::rows(&menu), crate::canvas_tool_menu::PEN_MENU); + app.ui.canvas_tool_menu = None; + // Outside the image the Color Sampler keeps the pasteboard menu. + app.ui.tool = Tool::ColorSampler; + right_click(&mut app, &ctx, 250.0, 40.0); + assert!(app.ui.canvas_tool_menu.as_ref().is_some_and(|m| m.pasteboard)); +} + +/// A real right-click on the canvas over sampler #2 opens the menu on it. +#[test] +fn canvas_right_click_opens_the_menu_on_the_sampler() { + let mut h = Harness::builder().with_size(vec2(1200.0, 800.0)).with_step_dt(1.0 / 60.0).build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + app().0 + }); + h.run_steps(5); + let xf = crate::canvas::ViewXform::active(h.state()).unwrap(); + let at = xf.to_screen(120.5, 60.5); + h.event(Event::PointerMoved(at)); + h.run_steps(2); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); + h.run_steps(1); + } + h.run_steps(3); + let menu = h.state().ui.canvas_tool_menu.clone().expect("the Color Sampler menu opens"); + assert_eq!((menu.tool, menu.sampler), (Tool::ColorSampler, Some(1))); + assert_eq!(h.state().session.active().unwrap().color_samplers.len(), 2, "no sampler was placed or moved"); +} diff --git a/crates/ui-egui/src/transform_type_tests.rs b/crates/ui-egui/src/transform_type_tests.rs index eeb91f44b9..c62e7cf87d 100644 --- a/crates/ui-egui/src/transform_type_tests.rs +++ b/crates/ui-egui/src/transform_type_tests.rs @@ -56,6 +56,7 @@ fn type_layers_offer_free_transform_but_not_distort_or_perspective() { guide: false, spelling: None, crop: None, + sampler: None, }; assert!(crate::canvas_tool_menu::available(&app, &menu, "edit.freeTransform")); assert!(!crate::canvas_tool_menu::available(&app, &menu, "edit.transform.distort")); From 9db78c5d317235a2618e2f7a7bf2347d811722c8 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 7 Nov 2026 13:02:04 +0100 Subject: [PATCH 097/219] Background Eraser: number keys set the Tolerance (#11907) With the Background Eraser active, 1-9 set its Tolerance to 10-90 %, 0 to 100 %, and two digits typed quickly to that exact value, as the number keys set Opacity for the painting tools. Shift+digit and "00" keep today's behaviour. Other tools' digits are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../src/background_eraser_digit_keys_tests.rs | 125 ++++++++++++++++++ crates/ui-egui/src/input/opacity_keys.rs | 8 +- crates/ui-egui/src/lib.rs | 3 + 3 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/src/background_eraser_digit_keys_tests.rs diff --git a/crates/ui-egui/src/background_eraser_digit_keys_tests.rs b/crates/ui-egui/src/background_eraser_digit_keys_tests.rs new file mode 100644 index 0000000000..9cddb5a7eb --- /dev/null +++ b/crates/ui-egui/src/background_eraser_digit_keys_tests.rs @@ -0,0 +1,125 @@ +//! #11907: with the Background Eraser active, the number keys set its Tolerance as they set Opacity +//! for the painting tools (3 = 30%, 0 = 100%, 5 then 5 typed quickly = 55%), as in the reference +//! application. Other tools' digits are unchanged. + +use egui::{Event, Key, Modifiers}; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::opacity_keys::press; +use crate::state::Tool; + +fn app(tool: Tool) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 32, "height": 32})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.ui.tool = tool; + app +} + +fn tolerance(app: &PhotocraftApp) -> f32 { + app.ui.tool_options.bg_tolerance +} + +fn layer(app: &PhotocraftApp) -> (f32, f32) { + let st = app.session.active().unwrap(); + let l = st.doc.layer(st.active_layer.unwrap()).unwrap(); + (l.opacity, l.fill_opacity) +} + +#[test] +fn single_digits_set_the_tolerance() { + let mut a = app(Tool::BackgroundEraser); + assert_eq!(tolerance(&a), 50.0, "default"); + press(&mut a, 3, false, 0.0); + assert_eq!(tolerance(&a), 30.0); + press(&mut a, 0, false, 5000.0); + assert_eq!(tolerance(&a), 100.0, "0 = 100%"); + press(&mut a, 1, false, 10000.0); + assert_eq!(tolerance(&a), 10.0); +} + +#[test] +fn two_quick_digits_set_an_exact_tolerance() { + let mut a = app(Tool::BackgroundEraser); + press(&mut a, 5, false, 0.0); + press(&mut a, 5, false, 300.0); + assert_eq!(tolerance(&a), 55.0); + // A slow second digit starts over. + press(&mut a, 4, false, 5000.0); + press(&mut a, 2, false, 9000.0); + assert_eq!(tolerance(&a), 20.0); +} + +#[test] +fn the_tolerance_is_shown_in_ui_inspect() { + let mut a = app(Tool::BackgroundEraser); + press(&mut a, 3, false, 0.0); + let options = serde_json::to_value(&a.ui.tool_options).unwrap(); + assert_eq!(options["bg_tolerance"], json!(30.0)); +} + +#[test] +fn background_eraser_digits_leave_the_brush_and_the_layer_alone() { + let mut a = app(Tool::BackgroundEraser); + let brush = a.session.tools.brush.clone(); + press(&mut a, 7, false, 0.0); + assert_eq!(a.session.tools.brush, brush); + assert_eq!(layer(&a), (1.0, 1.0)); + assert_eq!(a.ui.tool_options.fill_opacity, 100.0); +} + +#[test] +fn unmeasured_shift_digits_and_double_zero_keep_the_tolerance() { + let mut a = app(Tool::BackgroundEraser); + press(&mut a, 3, true, 0.0); + assert_eq!(tolerance(&a), 50.0, "⇧3 changes nothing"); + press(&mut a, 0, false, 5000.0); + press(&mut a, 0, false, 5100.0); + assert_eq!(tolerance(&a), 100.0, "00 keeps the 100% of the first 0"); +} + +#[test] +fn other_tools_digits_are_unchanged() { + for tool in [Tool::Brush, Tool::Pencil, Tool::Eraser] { + let mut a = app(tool); + press(&mut a, 3, false, 0.0); + assert!((a.session.tools.brush.opacity - 0.3).abs() < 1e-6, "{tool:?}"); + assert_eq!(tolerance(&a), 50.0, "{tool:?}"); + } + for tool in [Tool::Gradient, Tool::PaintBucket] { + let mut a = app(tool); + press(&mut a, 3, false, 0.0); + assert_eq!(a.ui.tool_options.fill_opacity, 30.0, "{tool:?}"); + assert_eq!(tolerance(&a), 50.0, "{tool:?}"); + } + let mut a = app(Tool::Move); + press(&mut a, 3, false, 0.0); + assert!((layer(&a).0 - 0.3).abs() < 1e-6); + assert_eq!(tolerance(&a), 50.0); + // The tools whose number is a strength, exposure or tolerance of their own still ignore the digits. + for tool in [Tool::ColorReplacement, Tool::Dodge, Tool::Smudge, Tool::MagicEraser] { + let mut a = app(tool); + let before = (a.session.tools.brush.clone(), layer(&a), a.ui.tool_options.clone()); + press(&mut a, 3, false, 0.0); + assert_eq!((a.session.tools.brush.clone(), layer(&a), a.ui.tool_options.clone()), before, "{tool:?}"); + } +} + +/// Through the whole app: number keys typed over the canvas (no widget focused). +#[test] +fn number_keys_set_the_tolerance_in_the_app() { + use egui_kittest::Harness; + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_max_steps(64).build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + app(Tool::BackgroundEraser) + }); + h.run_steps(4); + let key = |h: &mut Harness<'_, PhotocraftApp>, k: Key| { + h.event(Event::Key { key: k, physical_key: Some(k), pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.event(Event::Key { key: k, physical_key: Some(k), pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + }; + key(&mut h, Key::Num3); + assert_eq!(tolerance(h.state()), 30.0); +} diff --git a/crates/ui-egui/src/input/opacity_keys.rs b/crates/ui-egui/src/input/opacity_keys.rs index 7f02c0c77b..2874efeba9 100644 --- a/crates/ui-egui/src/input/opacity_keys.rs +++ b/crates/ui-egui/src/input/opacity_keys.rs @@ -2,7 +2,8 @@ //! typed quickly set an exact value (4 then 5 = 45%, 0 then 0 = 0%). With the Brush, Pencil or //! Eraser they set the brush's Opacity (⇧: Flow; swapped while airbrush build-up is on); with the Gradient or Paint Bucket their //! Opacity; with tools that have no opacity of their own (Move, the selection tools, Type…) the -//! active layer's Opacity (⇧: Fill). Tools whose number is a strength or exposure are left alone. +//! active layer's Opacity (⇧: Fill); with the Background Eraser its Tolerance (#11907). Tools whose +//! number is a strength or exposure are left alone. use egui::{Event, Key}; use serde_json::json; @@ -25,6 +26,8 @@ enum Target { }, /// The Gradient / Paint Bucket opacity option. ToolOpacity, + /// The Background Eraser's Tolerance option. + BgTolerance, /// The active layer's opacity (⇧: fill). Layer, None, @@ -35,6 +38,7 @@ fn target(tool: Tool) -> Target { Tool::Brush | Tool::Eraser | Tool::CloneStamp | Tool::HistoryBrush => Target::Brush { flow: true }, Tool::Pencil => Target::Brush { flow: false }, Tool::Gradient | Tool::PaintBucket => Target::ToolOpacity, + Tool::BackgroundEraser => Target::BgTolerance, // Their options-bar number is a strength, exposure or tolerance, not this opacity. t if t.is_brushlike() || t == Tool::MagicEraser => Target::None, _ => Target::Layer, @@ -117,6 +121,8 @@ fn apply(app: &mut PhotocraftApp, percent: f32, shift: bool) { let _ = app.run("tools.setBrush", json!({ "brush": { "flow": v.max(0.01) } })); } (Target::ToolOpacity, false) => app.ui.tool_options.fill_opacity = percent, + // The Tolerance field starts at 1%; `00` (0%) keeps today's value rather than guessing. + (Target::BgTolerance, false) if percent >= 1.0 => app.ui.tool_options.bg_tolerance = percent, (Target::Layer, _) => { // The locked Background has no opacity or fill. let Some(st) = app.session.active() else { return }; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 89d83237e5..3ecdc3daf2 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -996,3 +996,6 @@ mod preferences_submenu_tests; mod selection_move_step_tests; #[cfg(test)] mod view_guides_submenu_tests; + +#[cfg(test)] +mod background_eraser_digit_keys_tests; From 372d864d3e5b9f5ad265cd862cbfddb9eafe55b0 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 7 Nov 2026 15:22:16 +0100 Subject: [PATCH 098/219] Brush Settings: name the Spacing percentage field for accessibility (#5224) After merging main, the newer egui_kittest accessibility check failed the PR's spacing tests: the percentage field next to the Spacing checkbox had no accessible name. Name it "Spacing" and query the checkbox by role in the tests, since the field now shares the label. Co-Authored-By: augrclk Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/brush_panel/tests.rs | 6 +++--- crates/ui-egui/src/panels/brush_sections.rs | 5 ++++- crates/ui-egui/src/panels/brush_sections/tests.rs | 7 ++++--- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/panels/brush_panel/tests.rs b/crates/ui-egui/src/panels/brush_panel/tests.rs index ca2ab47d58..a11fe034c3 100644 --- a/crates/ui-egui/src/panels/brush_panel/tests.rs +++ b/crates/ui-egui/src/panels/brush_panel/tests.rs @@ -1886,7 +1886,7 @@ fn spacing_row_edits_route_only_the_changed_fields_through_the_brush_command() { h.run_steps(2); let journal = h.state().session.journal.len(); let spacing = h.state().session.tools.brush.spacing; - let checkbox = h.query_all_by_label("Spacing").min_by(|a, b| a.rect().top().total_cmp(&b.rect().top())).unwrap(); + let checkbox = h.get_by_role_and_label(egui::accesskit::Role::CheckBox, "Spacing"); checkbox.click(); h.run_steps(2); assert!(h.state().session.tools.brush.spacing_enabled); @@ -1900,7 +1900,7 @@ fn spacing_row_edits_route_only_the_changed_fields_through_the_brush_command() { assert_eq!(h.state().session.tools.brush.spacing, 0.44); assert_eq!(last_journal(h.state()).unwrap().0, "tools.setBrush"); assert_eq!(last_journal(h.state()).unwrap().1["brush"], json!({"spacing": 0.44_f32})); - let checkbox = h.query_all_by_label("Spacing").min_by(|a, b| a.rect().top().total_cmp(&b.rect().top())).unwrap(); + let checkbox = h.get_by_role_and_label(egui::accesskit::Role::CheckBox, "Spacing"); checkbox.click(); h.run_steps(2); assert_eq!(last_journal(h.state()).unwrap().1["brush"], json!({"spacingEnabled": false})); @@ -1911,7 +1911,7 @@ fn spacing_row_edits_route_only_the_changed_fields_through_the_brush_command() { h.run_steps(2); assert_eq!(h.state().session.tools.brush.spacing, 0.44); assert_eq!(h.state().session.journal.len(), journal + 3, "disabled input adds no command"); - let checkbox = h.query_all_by_label("Spacing").min_by(|a, b| a.rect().top().total_cmp(&b.rect().top())).unwrap(); + let checkbox = h.get_by_role_and_label(egui::accesskit::Role::CheckBox, "Spacing"); checkbox.click(); h.run_steps(2); assert_eq!(h.state().session.tools.brush.spacing, 0.44); diff --git a/crates/ui-egui/src/panels/brush_sections.rs b/crates/ui-egui/src/panels/brush_sections.rs index c74a75b452..976766abe9 100644 --- a/crates/ui-egui/src/panels/brush_sections.rs +++ b/crates/ui-egui/src/panels/brush_sections.rs @@ -61,7 +61,10 @@ fn spacing_row(ui: &mut egui::Ui, b: &mut BrushSettings) { ui.horizontal(|ui| { widgets::checkbox(ui, &mut b.spacing_enabled, tl!("Spacing")).on_hover_text(tl!("Off: the speed of the pointer sets the spacing")); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - changed = ui.add_enabled_ui(b.spacing_enabled, |ui| widgets::value_field(ui, &mut percent, 1.0..=1000.0, "%", 74.0)).inner.changed(); + changed = ui + .add_enabled_ui(b.spacing_enabled, |ui| widgets::value_field(ui, &mut percent, 1.0..=1000.0, "%", 74.0).accessible_name(tl!("Spacing"))) + .inner + .changed(); }); }); changed |= ui.add_enabled_ui(b.spacing_enabled, |ui| widgets::slider(ui, &mut percent, 1.0..=1000.0, None)).inner.changed(); diff --git a/crates/ui-egui/src/panels/brush_sections/tests.rs b/crates/ui-egui/src/panels/brush_sections/tests.rs index 823c477992..66cb16d43f 100644 --- a/crates/ui-egui/src/panels/brush_sections/tests.rs +++ b/crates/ui-egui/src/panels/brush_sections/tests.rs @@ -344,8 +344,9 @@ fn spacing_row_has_one_label_and_a_shared_header_in_every_theme() { let mut h = section_harness(0, b.clone()); crate::PhotocraftApp::setup_context(&h.ctx, kind); h.run_steps(3); - assert_eq!(h.query_all_by_label("Spacing").count(), 1, "{kind:?}: the checkbox is the single label"); - let checkbox = h.get_by_label("Spacing").rect(); + assert_eq!(h.query_all_by_role_and_label(Role::Label, "Spacing").count(), 0, "{kind:?}: the checkbox is the single label"); + assert_eq!(h.query_all_by_role_and_label(Role::CheckBox, "Spacing").count(), 1); + let checkbox = h.get_by_role_and_label(Role::CheckBox, "Spacing").rect(); let field = h.query_all_by_role(Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(43.0)).unwrap(); assert!((checkbox.center().y - field.rect().center().y).abs() < 2.0, "{kind:?}: checkbox and percentage share a row"); assert_eq!(field.accesskit_node().is_disabled(), !enabled); @@ -368,7 +369,7 @@ fn spacing_row_retains_disabled_values_and_percentage_editing_limits() { h.run_steps(2); drag(&mut h, from, to); assert_eq!(h.state(), &before, "Off ignores both field input and a track drag"); - let checkbox = h.query_all_by_label("Spacing").min_by(|a, b| a.rect().top().total_cmp(&b.rect().top())).unwrap(); + let checkbox = h.get_by_role_and_label(Role::CheckBox, "Spacing"); checkbox.click(); h.run_steps(2); assert!(h.state().spacing_enabled); From be428bf90ef0bdfc96dc5222a81241990be090dd Mon Sep 17 00:00:00 2001 From: DuBearte <130190089+DuBearte@users.noreply.github.com> Date: Sat, 7 Nov 2026 09:59:39 -0500 Subject: [PATCH 099/219] Brush: Clear on the Background paints as Normal and the Mode reads Normal Fixes #11897. Co-Authored-By: Claude Opus 5.5 --- .../src/brush_clear_background_tests.rs | 59 +++++++++++++++++++ crates/engine/src/cmd/brush_cmds.rs | 18 +++++- crates/engine/src/lib.rs | 2 + .../ui-egui/src/canvas/paint_mouse/tests.rs | 21 +++++++ crates/ui-egui/src/canvas/tool_input.rs | 10 ++++ 5 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 crates/engine/src/brush_clear_background_tests.rs diff --git a/crates/engine/src/brush_clear_background_tests.rs b/crates/engine/src/brush_clear_background_tests.rs new file mode 100644 index 0000000000..e786af9dd7 --- /dev/null +++ b/crates/engine/src/brush_clear_background_tests.rs @@ -0,0 +1,59 @@ +//! #11897: the Brush's Clear mode on the Background paints as Normal with the foreground colour, +//! not the background colour; on a pixel layer Clear still erases, and the Eraser on the +//! Background still paints the background colour. + +use crate::Session; +use serde_json::{Value, json}; + +const BLACK: [f32; 4] = [0.0, 0.0, 0.0, 1.0]; +const GREEN: [f32; 4] = [0.0, 1.0, 0.0, 1.0]; +const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0]; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 64, "height": 32, "background": "white"})).unwrap(); + s.tools.foreground = BLACK; + s.tools.background = GREEN; + s +} + +fn pixel(s: &mut Session, x: i32, y: i32) -> [f32; 4] { + let v = s.execute("document.pixel", json!({"x": x, "y": y})).unwrap(); + let a: Vec = v.as_array().unwrap().iter().map(|c| c.as_f64().unwrap() as f32).collect(); + [a[0], a[1], a[2], a[3]] +} + +fn close(a: [f32; 4], b: [f32; 4]) -> bool { + a.iter().zip(&b).all(|(x, y)| (x - y).abs() <= 0.02) +} + +/// A hard stroke across (10..30, 10), as the canvas sends it with Clear chosen. +fn clear_stroke() -> Value { + json!({"points": [[10, 10], [30, 10]], "size": 8, "hardness": 1.0, "erase": true, "mode": "clear"}) +} + +#[test] +fn clear_on_the_background_paints_the_foreground() { + let mut s = session(); + s.execute("paint.stroke", clear_stroke()).unwrap(); + let p = pixel(&mut s, 20, 10); + assert!(close(p, BLACK), "painted {p:?}, not the background colour"); +} + +#[test] +fn clear_on_a_pixel_layer_still_erases() { + let mut s = session(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": [1.0, 0.0, 0.0, 1.0]})).unwrap(); + s.execute("paint.stroke", clear_stroke()).unwrap(); + let p = pixel(&mut s, 20, 10); + assert!(close(p, WHITE), "erased down to the white Background: {p:?}"); +} + +#[test] +fn the_eraser_on_the_background_still_paints_the_background_colour() { + let mut s = session(); + s.execute("paint.stroke", json!({"points": [[10, 10], [30, 10]], "size": 8, "hardness": 1.0, "erase": true})).unwrap(); + let p = pixel(&mut s, 20, 10); + assert!(close(p, GREEN), "{p:?}"); +} diff --git a/crates/engine/src/cmd/brush_cmds.rs b/crates/engine/src/cmd/brush_cmds.rs index e09ff2098c..ecdeeab4da 100644 --- a/crates/engine/src/cmd/brush_cmds.rs +++ b/crates/engine/src/cmd/brush_cmds.rs @@ -424,10 +424,24 @@ fn with_blend_mode(mut b: BrushSettings, p: &Value) -> BrushSettings { b } +/// The Brush's Clear on the Background paints as Normal with its own colour, as in Photoshop +/// (#11897): the Background has no transparency to clear. Masks, channels, other layers (even +/// with locked transparency) and the Pencil keep Clear. +fn clear_on_background(s: &Session, p: &Value, mut brush: BrushSettings) -> Result { + if !clear_mode(p) || is_mask_target(p) || crate::channel_cmds::is_channel_target(p) || crate::color_channels::target(s, p)?.is_some() { + return Ok(brush); + } + let id = layer_id(s, p)?; + if s.active().is_some_and(|st| st.doc.is_background(id)) { + brush.erase = false; + } + Ok(brush) +} + /// `paint.stroke`: the Brush (and Eraser) tool. pub fn paint_stroke(s: &mut Session, p: &Value) -> Result { let pts = parse_points(p, "paint.stroke")?; - let brush = with_blend_mode(resolve_brush(s, p, "paint.stroke")?, p); + let brush = clear_on_background(s, p, with_blend_mode(resolve_brush(s, p, "paint.stroke")?, p))?; let label = if brush.erase && !clear_mode(p) { "Eraser" } else { "Brush Tool" }; if flag(p, "fill", false) { return fill_region(s, p, label, brush, pts); @@ -605,7 +619,7 @@ impl LiveStroke { "paint.pencil" => true, other => return Err(bad(other, "live strokes are `paint.stroke` or `paint.pencil`")), }; - let brush = if pencil { pencil_brush(s, p)? } else { with_blend_mode(resolve_brush(s, p, cmd)?, p) }; + let brush = if pencil { pencil_brush(s, p)? } else { clear_on_background(s, p, with_blend_mode(resolve_brush(s, p, cmd)?, p))? }; let (seed, fg) = (brush.seed, brush.color); let (layer, mut brush, zoom) = stroke_target(s, p, brush)?; let st = s.active().ok_or(EngineError::NoDocument)?; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8f28147217..f9273cebad 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -355,4 +355,6 @@ mod tests; #[cfg(test)] mod fx_mode_tests; +#[cfg(test)] +mod brush_clear_background_tests; pub mod command_hints; diff --git a/crates/ui-egui/src/canvas/paint_mouse/tests.rs b/crates/ui-egui/src/canvas/paint_mouse/tests.rs index c31e137e82..cdc95c6158 100644 --- a/crates/ui-egui/src/canvas/paint_mouse/tests.rs +++ b/crates/ui-egui/src/canvas/paint_mouse/tests.rs @@ -1023,3 +1023,24 @@ fn a_right_click_moved_and_released_in_one_frame_still_opens_its_menu() { assert_eq!(h.state().ui.brush_picker, None); assert_eq!(h.state().ui.canvas_tool_menu, None); } + +/// #11897: Clear chosen on a pixel layer, then the Background activated: a Brush stroke there +/// paints the foreground, and the Mode reads Normal afterwards. +#[test] +fn chosen_clear_paints_normally_on_the_background_and_the_mode_reads_normal() { + let mut h = harness(None); + h.state_mut().ui.tool_options.brush_clear = true; + let bg = h.state().session.active().unwrap().doc.background().unwrap().id; + h.state_mut().run("layer.select", json!({"layer": bg.0})).unwrap(); + h.run_steps(2); + let (a, b) = drag(&mut h, PointerButton::Primary); + assert!(!h.state().ui.tool_options.brush_clear, "the Mode reads Normal"); + let last = strokes(&h).last().cloned().unwrap(); + assert!(last.get("mode").is_none() && !last["erase"].as_bool().unwrap(), "{last}"); + let app = h.state(); + let v = app.ui.views[0].clone(); + let d = ((a + (b - a) * 0.5) - app.last_canvas_rect.center()) / v.zoom; + let st = app.session.active().unwrap(); + let px = st.doc.layer(bg).unwrap().surface().unwrap().rgba((d.x + v.center[0]) as i32, (d.y + v.center[1]) as i32); + assert!(px[2] > 0.9 && px[0] < 0.1, "the foreground blue, not the background colour: {px:?}"); +} diff --git a/crates/ui-egui/src/canvas/tool_input.rs b/crates/ui-egui/src/canvas/tool_input.rs index f0a6b57506..949b84591f 100644 --- a/crates/ui-egui/src/canvas/tool_input.rs +++ b/crates/ui-egui/src/canvas/tool_input.rs @@ -360,6 +360,15 @@ fn handle_eyedropper_event(app: &mut PhotocraftApp, tool: Tool, ev: ToolEvent, m } /// Tool state machine. Shared by mouse input and automation. +/// The Brush's chosen Clear paints as Normal on the Background, and its Mode then reads Normal +/// (#11897). A held `~` keeps today's behaviour. +fn clear_off_on_background(app: &mut PhotocraftApp, tool: Tool) { + let on_background = app.session.active().is_some_and(|st| st.active_layer.is_some_and(|id| st.doc.is_background(id))); + if tool == Tool::Brush && app.ui.tool_options.brush_clear && !app.held_erase && on_background && paint_target(app) == serde_json::json!("pixels") { + app.ui.tool_options.brush_clear = false; + } +} + pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) { if consumes_cancelled_vector_event(app, ev) { return; @@ -524,6 +533,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) _ => {} } crate::paint_mouse::sync_tool_brush(app); + clear_off_on_background(app, tool); // Clear mode (`~` held, or chosen in the options bar) erases with the tool's own brush. let clear = crate::paint_mouse::clears(app, tool); let erase = std::mem::take(&mut app.secondary_erase) || tool == Tool::Eraser || clear; From 41d6490ef2c40723401717659e36099cd1dcdbe8 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 7 Nov 2026 16:34:06 +0100 Subject: [PATCH 100/219] test(compose): CMYK reference keeps the source ink over nothing (#12079) The reference's source-over computed s * sa / sa over an empty backdrop, one ulp off the stored ink. Behind a Paint.NET Color Burn of a Posterize that lands exactly on 73.5/255, the ulp flipped the 8-bit rounding and random_documents_match_the_reference failed on main. Keep the source ink when nothing shows through or both inks agree, as cmyk::normal does. Co-Authored-By: Claude Opus 5.5 --- crates/compose/src/cmyk/tests.rs | 37 +++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/crates/compose/src/cmyk/tests.rs b/crates/compose/src/cmyk/tests.rs index 8deaa68de3..952d29be63 100644 --- a/crates/compose/src/cmyk/tests.rs +++ b/crates/compose/src/cmyk/tests.rs @@ -60,7 +60,11 @@ fn over(b: Cmyka, s: Cmyka, opacity: f32) -> Cmyka { } let mut o = [0.0; 5]; for i in 0..4 { - o[i] = (s[i] * sa + b[i] * b[4] * (1.0 - sa)) / a; + // Over nothing, under a covering source, or where both inks agree, the result is the + // source's ink exactly: `s * sa / sa` can be one ulp off, which a later mode or + // rounding step can turn into a whole level. + let shows_through = b[4] > 0.0 && sa < 1.0; + o[i] = if !shows_through || b[i] == s[i] { s[i] } else { (s[i] * sa + b[i] * b[4] * (1.0 - sa)) / a }; } o[4] = a; o @@ -762,6 +766,37 @@ fn clips_onto_pass_through(layers: &[LayerSpec]) -> bool { }) || layers.iter().any(|l| matches!(l, LayerSpec::Group { children, .. } if clips_onto_pass_through(children))) } +/// A proptest case from main's CI: a masked shape below 100% Fill, then Posterize in Color +/// Burn (Paint.NET) inside an isolated group. The burned brightness is exactly 73.5/255. +#[test] +fn posterize_in_paint_net_color_burn_on_a_rounding_tie_matches_the_reference() { + let shape = LayerSpec::Pixels { + kind: Kind::Shape, + blend: BlendMode::Normal, + fills: vec![(Rect::new(0, 0, 1, 1), [0.47422728, 0.0, 0.0, 0.0, 1.0])], + opacity: 1.0, + fill: 0.876696, + clipped: false, + visible: true, + mask: Some(MaskSpec { rects: vec![], default: 0.06425183, density: 1.0, enabled: true }), + }; + let posterize = LayerSpec::Pixels { + kind: Kind::Adjust(AdjSpec::Posterize(4)), + blend: BlendMode::PaintNetColorBurn, + fills: vec![(Rect::new(0, 0, 1, 1), [0.0; 5])], + opacity: 1.0, + fill: 1.0, + clipped: false, + visible: true, + mask: None, + }; + let group = LayerSpec::Group { children: vec![shape, posterize], pass_through: false, blend: BlendMode::Normal, opacity: 1.0, fill: 1.0, mask: None }; + let s = Spec { w: 8, h: 6, layers: vec![group] }; + let mut d = doc(s.w as u32, s.h as u32); + d.layers = s.layers.iter().map(build).collect(); + assert_matches_reference(&d); +} + proptest! { #![proptest_config(ProptestConfig { cases: 400, ..ProptestConfig::default() })] From b509031d8820a6cd16eb671952e402cf6088c384 Mon Sep 17 00:00:00 2001 From: Dan Date: Sat, 7 Nov 2026 16:02:20 +0000 Subject: [PATCH 101/219] test(ui): reproduce Zoom menu centre drift (#12085) --- .../src/canvas/canvas_tool_menu/tests.rs | 3 ++ .../zoom_menu_anchor_tests.rs | 35 +++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 crates/ui-egui/src/canvas/canvas_tool_menu/zoom_menu_anchor_tests.rs diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs b/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs index e5c46b42ae..1d7c67ff31 100644 --- a/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs +++ b/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs @@ -2,6 +2,9 @@ use super::*; use egui::{Modifiers, PointerButton, Pos2, vec2}; use egui_kittest::{Harness, kittest::Queryable}; +#[path = "zoom_menu_anchor_tests.rs"] +mod zoom_menu_anchor_tests; + fn app() -> PhotocraftApp { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); app.run("file.new", json!({"width": 32, "height": 32})).unwrap(); diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu/zoom_menu_anchor_tests.rs b/crates/ui-egui/src/canvas/canvas_tool_menu/zoom_menu_anchor_tests.rs new file mode 100644 index 0000000000..72a7d0c24b --- /dev/null +++ b/crates/ui-egui/src/canvas/canvas_tool_menu/zoom_menu_anchor_tests.rs @@ -0,0 +1,35 @@ +use super::*; + +#[test] +fn zoom_menu_steps_keep_the_view_centre_and_history() { + for animated in [false, true] { + for offset in [vec2(-160.0, -100.0), vec2(140.0, 80.0)] { + let mut app = zoom_app(2400, 1800); + app.ui.tool = Tool::Zoom; + app.run("prefs.set", json!({"values": {"general.animatedZoom": animated, "tools.zoomClickedPointToCenter": false}})).unwrap(); + let mut h = harness(app); + let v = &mut h.state_mut().ui.views[0]; + v.zoom = 1.0; + v.center = [928.0, 686.0]; + v.request_zoom_animation(false); + h.step(); + let center = h.state().ui.views[0].center; + let st = h.state().session.active().unwrap(); + let history = (st.history.past_len(), st.history.can_redo(), st.revision); + for (label, zoom) in [("Zoom In", 2.0), ("Zoom Out", 1.0), ("Zoom Out", 2.0 / 3.0)] { + let p = crate::canvas::ViewXform::active(h.state()).unwrap().rect.center() + offset; + right_click(&mut h, p, Modifiers::NONE); + h.get_by_label(label).click(); + h.run_steps(3); + let v = &h.state().ui.views[0]; + assert_eq!(v.zoom, zoom, "{label}: one ladder stop"); + assert_eq!(v.center, center, "{label}: the right-click must not move the view centre"); + let xf = crate::canvas::ViewXform::active(h.state()).unwrap(); + assert!(xf.to_screen(center[0], center[1]).distance(xf.rect.center()) < 1.0); + assert!(h.state().ui.canvas_tool_menu.is_none()); + let st = h.state().session.active().unwrap(); + assert_eq!((st.history.past_len(), st.history.can_redo(), st.revision), history); + } + } + } +} From 593c7d487b5df2164b73d369a699f424f7cfb2c2 Mon Sep 17 00:00:00 2001 From: Dan Date: Sat, 7 Nov 2026 16:09:14 +0000 Subject: [PATCH 102/219] fix(ui): keep Zoom menu steps centred on the view --- crates/ui-egui/src/canvas/canvas_tool_menu.rs | 7 +- .../src/canvas/canvas_tool_menu/tests.rs | 16 ++-- .../zoom_menu_anchor_tests.rs | 77 +++++++++++++++++++ docs/control-protocol.md | 2 +- 4 files changed, 92 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu.rs b/crates/ui-egui/src/canvas/canvas_tool_menu.rs index b2e377e0ec..044f040c72 100644 --- a/crates/ui-egui/src/canvas/canvas_tool_menu.rs +++ b/crates/ui-egui/src/canvas/canvas_tool_menu.rs @@ -916,8 +916,8 @@ pub fn open_view(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; true } -/// A view menu row. Zoom In and Zoom Out step about the right-clicked point, as a Zoom tool click -/// does; the other rows run their View menu command. +/// A view menu row. The selected Zoom tool's steps keep the view centre unless clicked-point +/// centring is enabled. Other view-tool cases retain their pressed-point anchoring. fn choose_view(app: &mut PhotocraftApp, ctx: &Context, menu: &CanvasToolMenu, command: &str) -> Result<(), String> { let i = app.session.active_index().ok_or("no document")?; if command == FILL_SCREEN { @@ -935,6 +935,9 @@ fn choose_view(app: &mut PhotocraftApp, ctx: &Context, menu: &CanvasToolMenu, co } let (Some((zoom0, c)), Some(d)) = (before, menu.anchor) else { return Ok(()) }; let center_on_point = app.session.prefs().tools.zoom_clicked_point_to_center; + if menu.tool == Tool::Zoom && app.ui.tool == Tool::Zoom && !center_on_point { + return Ok(()); + } let Some(v) = app.ui.views.get_mut(i) else { return Ok(()) }; let d = [d[0] as f32, d[1] as f32]; // The view scales about the centre, so the point keeps its screen position when its offset diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs b/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs index 1d7c67ff31..5228ffd6c9 100644 --- a/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs +++ b/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs @@ -550,11 +550,12 @@ fn zoom_and_hand_right_click_opens_the_view_menu() { } #[test] -fn view_menu_zoom_in_keeps_the_clicked_point_under_the_pointer() { +fn view_menu_zoom_in_keeps_the_view_centre() { let mut app = zoom_app(400, 300); app.ui.tool = Tool::Zoom; let mut h = harness(app); let zoom = h.state().ui.views[0].zoom; + let center = h.state().ui.views[0].center; let doc = [300.0, 100.0]; let p = screen_of(h.state(), doc); right_click(&mut h, p, Modifiers::NONE); @@ -564,8 +565,7 @@ fn view_menu_zoom_in_keeps_the_clicked_point_under_the_pointer() { assert!(h.state().ui.canvas_tool_menu.is_none()); let after = h.state().ui.views[0].zoom; assert_eq!(after, crate::zoom_levels::step(zoom, 1, [400, 300]), "one Zoom In step"); - let s = screen_of(h.state(), doc); - assert!((s - p).length() < 1.0, "the right-clicked point stays put: {s:?} vs {p:?}"); + assert_eq!(h.state().ui.views[0].center, center); assert_eq!(h.state().session.journal.last().map(|(id, _)| id.as_str()), Some("view.zoomIn")); // Zoom Out returns to where it was. right_click(&mut h, p, Modifiers::NONE); @@ -576,12 +576,13 @@ fn view_menu_zoom_in_keeps_the_clicked_point_under_the_pointer() { } #[test] -fn animated_zoom_off_view_menu_keeps_the_anchor_without_a_transition() { +fn animated_zoom_off_view_menu_keeps_the_centre_without_a_transition() { for command in ["view.zoomIn", "view.zoomOut", FILL_SCREEN] { let mut app = zoom_app(400, 300); app.ui.tool = Tool::Zoom; app.run("prefs.set", json!({"values": {"general.animatedZoom": false, "tools.zoomClickedPointToCenter": false}})).unwrap(); let mut h = harness(app); + let center = h.state().ui.views[0].center; let doc = [300.0, 100.0]; let p = screen_of(h.state(), doc); right_click(&mut h, p, Modifiers::NONE); @@ -594,17 +595,18 @@ fn animated_zoom_off_view_menu_keeps_the_anchor_without_a_transition() { assert_eq!((xf.zoom, xf.center), (v.zoom, v.center), "{command}"); assert!(v.zoom_anchor.is_none() && v.zoom_transition.is_none(), "{command}"); if command != FILL_SCREEN { - assert!(screen_of(h.state(), doc).distance(p) < 1.0, "{command}: preserve the clicked point"); + assert_eq!(v.center, center, "{command}: preserve the view centre"); } } } #[test] -fn view_menu_zoom_keeps_its_anchor_during_the_animation() { +fn view_menu_zoom_keeps_its_centre_during_the_animation() { let mut app = zoom_app(400, 300); app.ui.tool = Tool::Zoom; let mut h = harness(app); let zoom = h.state().ui.views[0].zoom; + let center = h.state().ui.views[0].center; let doc = [300.0, 100.0]; let p = screen_of(h.state(), doc); right_click(&mut h, p, Modifiers::NONE); @@ -616,7 +618,7 @@ fn view_menu_zoom_keeps_its_anchor_during_the_animation() { h.step(); let displayed = crate::canvas::ViewXform::active(h.state()).unwrap(); assert!(displayed.zoom > zoom && displayed.zoom < h.state().ui.views[0].zoom); - assert!(displayed.to_screen(doc[0], doc[1]).distance(p) < 1.0); + assert!(displayed.to_screen(center[0], center[1]).distance(displayed.rect.center()) < 1.0); h.input_mut().time = Some(ctx.input(|i| i.time) + 0.3); h.step(); assert!(!h.state().ui.views[0].zoom_animating); diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu/zoom_menu_anchor_tests.rs b/crates/ui-egui/src/canvas/canvas_tool_menu/zoom_menu_anchor_tests.rs index 72a7d0c24b..f0baa53b8a 100644 --- a/crates/ui-egui/src/canvas/canvas_tool_menu/zoom_menu_anchor_tests.rs +++ b/crates/ui-egui/src/canvas/canvas_tool_menu/zoom_menu_anchor_tests.rs @@ -33,3 +33,80 @@ fn zoom_menu_steps_keep_the_view_centre_and_history() { } } } + +#[test] +fn clicked_point_preference_and_hand_menu_keep_their_existing_anchors() { + for (tool, center_on_point) in [(Tool::Zoom, true), (Tool::Hand, true), (Tool::Hand, false)] { + for (command, direction) in [("view.zoomIn", 1), ("view.zoomOut", -1)] { + let mut app = zoom_app(400, 300); + app.ui.tool = tool; + app.run("prefs.set", json!({"values": {"tools.zoomClickedPointToCenter": center_on_point, "tools.overscroll": true}})).unwrap(); + let mut h = harness(app); + let before = h.state().ui.views[0].clone(); + let p = screen_of(h.state(), [300.0, 100.0]); + right_click(&mut h, p, Modifiers::NONE); + let doc = h.state().ui.canvas_tool_menu.as_ref().unwrap().anchor.unwrap().map(|n| n as f32); + let ctx = h.ctx.clone(); + choose(h.state_mut(), &ctx, command); + let v = &h.state().ui.views[0]; + assert_eq!(v.zoom, crate::zoom_levels::step(before.zoom, direction, [400, 300])); + let expected = if center_on_point { doc } else { std::array::from_fn(|i| doc[i] - (doc[i] - before.center[i]) * before.zoom / v.zoom) }; + assert_eq!(v.center, expected, "{tool:?}: {command}"); + assert!(v.zoom_animating); + assert_eq!(v.zoom_anchor.is_some(), !center_on_point); + } + } +} + +#[test] +fn zoom_click_and_alt_click_still_anchor_at_the_pointer() { + for animated in [false, true] { + for (modifiers, direction) in [(Modifiers::NONE, 1), (Modifiers::ALT, -1)] { + let mut app = zoom_app(400, 300); + app.ui.tool = Tool::Zoom; + app.run("prefs.set", json!({"values": {"general.animatedZoom": animated, "tools.zoomClickedPointToCenter": false, "tools.overscroll": true}})) + .unwrap(); + let mut h = harness(app); + let zoom = h.state().ui.views[0].zoom; + let doc = [300.0, 100.0]; + let p = screen_of(h.state(), doc); + h.event(egui::Event::ModifiersChanged(modifiers)); + h.event(egui::Event::PointerMoved(p)); + h.step(); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers }); + h.step(); + } + h.input_mut().time = Some(h.ctx.input(|i| i.time) + 0.3); + h.step(); + assert_eq!(h.state().ui.views[0].zoom, crate::zoom_levels::step(zoom, direction, [400, 300])); + assert!(screen_of(h.state(), doc).distance(p) < 1.0); + assert!(h.state().ui.canvas_tool_menu.is_none()); + assert_eq!(h.state().session.active().unwrap().history.past_len(), 0); + } + } +} + +#[test] +fn held_zoom_keys_still_open_no_view_menu() { + for tool in [Tool::Brush, Tool::Hand] { + for modifiers in [Modifiers::COMMAND, Modifiers::ALT] { + let mut app = zoom_app(400, 300); + app.ui.tool = tool; + let mut h = harness(app); + let modifiers = + Modifiers { mac_cmd: cfg!(target_os = "macos") && modifiers.command, ctrl: !cfg!(target_os = "macos") && modifiers.command, ..modifiers }; + h.event(egui::Event::ModifiersChanged(modifiers)); + h.event(egui::Event::Key { key: egui::Key::Space, physical_key: None, pressed: true, repeat: false, modifiers }); + h.step(); + assert!(matches!(crate::hold_keys::held_tool(h.state(), &h.ctx), Some(crate::hold_keys::Temporary::ZoomIn | crate::hold_keys::Temporary::ZoomOut))); + let before = h.state().ui.views[0].clone(); + let p = screen_of(h.state(), [300.0, 100.0]); + right_click(&mut h, p, modifiers); + assert!(h.state().ui.canvas_tool_menu.is_none()); + assert_eq!(h.state().ui.tool, tool); + let v = &h.state().ui.views[0]; + assert_eq!((v.zoom, v.center), (before.zoom, before.center)); + } + } +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index dc4f36dddb..3ffcc17d12 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -85,7 +85,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - A pixel tool pressed on a type, shape, Smart Object or fill layer (e.g. through `ui.pointer`) opens the "Rasterize?" prompt instead of painting: a dialog with `__rasterize` (`type|shape|smartObject|fill`), `message`, `layer`, `tool` and `at`. `ui.dialog.confirm` runs the `layer.rasterize.*` command and then paints at `at` (two history states, `{"rasterized", "painted"}`); `ui.dialog.cancel` does nothing. - Edit › Transform › Warp on editable type (`ui.menu.invoke {"id":"edit.transform.warp"}`, or the warp toggle of an open transform box) opens Warp mode with no prompt, as in Photoshop: the mesh can't be dragged or split, only the options bar's warp style, Bend, H and V apply, and the commit sets the layer's Warp Text in one step, so the type stays editable. Directly: `engine.execute {"command":"edit.transform.warp","params":{"style":"flag","bend":40}}` on a type layer. Distort and Perspective stay greyed for type. - `ui.window.open {document?}` / `ui.window.close {window}`: extra document windows -- `ui.pointer {events: [{kind: down|move|up, x, y, pressure?, tiltX?, tiltY?, rotation?}], tool?, modifiers?, button?}`: drive the active tool in document coordinates (pressure 0..1, tilt in degrees -90..90, barrel rotation 0..360: a simulated pen). Modifier flags (`shift`, `alt`, `command`, `ctrl`, `space`) go either at the top level or grouped under `modifiers`; when `modifiers` is present, top-level flags are ignored. `space: true` holds Space (the Crop frame, marquee, lasso or shape being drawn then moves instead of growing). With the Crop tool in its default mode (Use Classic Mode off) a drag inside the box moves the image and one outside turns it, the view panning and turning with it; a drag's `move`/`up` points are read as the view showed the document at its `down`, so an `up` where the last `move` was changes nothing more. `command: true` with a painting, retouching, eraser, gradient, eyedropper or other non-selection tool makes the gesture the Move tool's, as holding ⌘ (Ctrl off the Mac) does in the app: a drag moves the active layer (the selected pixels when there is a selection), with `alt: true` as well it duplicates the layer first, and a click picks the layer under the point; the tool is unchanged afterwards. The Hand, Rotate View, Zoom, Crop, Slice, Path Selection, Pen, shape, Type and selection tools keep `command` for themselves. `ctrl: true` with `alt: true` and a brush tool is the on-canvas brush resize instead (in the app, Ctrl+Alt-drag off the Mac). `button: "right"` (or `"secondary"`) is the right button: with the Move tool (or `command: true` with any tool) it opens the canvas layer menu (`layerMenu` in `ui.inspect` lists the layers there, topmost first; select one with `layer.select`); with Marquee, Lasso, Magic Wand, Object Selection, or Pen, or with a Type tool on text being edited or on committed type, it opens a tool context menu (`canvasToolMenu` in `ui.inspect`, with ordered command ids, separators, and enabled states); with Zoom or Hand it opens the view menu there (Zoom In and Zoom Out step about the pressed point; `view.fillScreen` is its Fill Screen row); with a painting tool it opens the Brush Preset picker, or erases with Preferences › Tools › Right-click with painting tools = erase. For a painting tool's Shift+right-click, see the Mode menu exception below. The menus open at the screen position of the pressed document point. Use `ui.context.choose {id}` for enabled tool-context actions. The Pen menu is documented in `docs/context-menu-parity.md`. `button` is `left` (default), `right`, `secondary` or `middle`, and an event's `kind` is `down`, `move` (default) or `up`. With a Color Picker as the top dialog, `down` and `move` sample the image into its new colour instead (its eyedropper, like a click on the canvas) and the tool is not driven. A `down`/`up` at the same point with the Rectangle, Ellipse, Triangle or Polygon tool opens Create Rectangle / Ellipse / Triangle / Polygon (Photoshop's click-to-size dialog); set its `width` and `height` (document pixels), `fromCenter`, `radii` [tl, tr, br, bl] (Rectangle) or `sides` and `starRatio` (percent, Polygon) with `ui.dialog.set`, then `ui.dialog.confirm` runs `shape.create` at the clicked point. +- `ui.pointer {events: [{kind: down|move|up, x, y, pressure?, tiltX?, tiltY?, rotation?}], tool?, modifiers?, button?}`: drive the active tool in document coordinates (pressure 0..1, tilt in degrees -90..90, barrel rotation 0..360: a simulated pen). Modifier flags (`shift`, `alt`, `command`, `ctrl`, `space`) go either at the top level or grouped under `modifiers`; when `modifiers` is present, top-level flags are ignored. `space: true` holds Space (the Crop frame, marquee, lasso or shape being drawn then moves instead of growing). With the Crop tool in its default mode (Use Classic Mode off) a drag inside the box moves the image and one outside turns it, the view panning and turning with it; a drag's `move`/`up` points are read as the view showed the document at its `down`, so an `up` where the last `move` was changes nothing more. `command: true` with a painting, retouching, eraser, gradient, eyedropper or other non-selection tool makes the gesture the Move tool's, as holding ⌘ (Ctrl off the Mac) does in the app: a drag moves the active layer (the selected pixels when there is a selection), with `alt: true` as well it duplicates the layer first, and a click picks the layer under the point; the tool is unchanged afterwards. The Hand, Rotate View, Zoom, Crop, Slice, Path Selection, Pen, shape, Type and selection tools keep `command` for themselves. `ctrl: true` with `alt: true` and a brush tool is the on-canvas brush resize instead (in the app, Ctrl+Alt-drag off the Mac). `button: "right"` (or `"secondary"`) is the right button: with the Move tool (or `command: true` with any tool) it opens the canvas layer menu (`layerMenu` in `ui.inspect` lists the layers there, topmost first; select one with `layer.select`); with Marquee, Lasso, Magic Wand, Object Selection, or Pen, or with a Type tool on text being edited or on committed type, it opens a tool context menu (`canvasToolMenu` in `ui.inspect`, with ordered command ids, separators, and enabled states); with Zoom or Hand it opens the view menu there (Zoom In and Zoom Out keep the view centre with the selected Zoom tool and Zoom Clicked Point to Center off; other cases retain their pressed-point behaviour; `view.fillScreen` is its Fill Screen row); with a painting tool it opens the Brush Preset picker, or erases with Preferences › Tools › Right-click with painting tools = erase. For a painting tool's Shift+right-click, see the Mode menu exception below. The menus open at the screen position of the pressed document point. Use `ui.context.choose {id}` for enabled tool-context actions. The Pen menu is documented in `docs/context-menu-parity.md`. `button` is `left` (default), `right`, `secondary` or `middle`, and an event's `kind` is `down`, `move` (default) or `up`. With a Color Picker as the top dialog, `down` and `move` sample the image into its new colour instead (its eyedropper, like a click on the canvas) and the tool is not driven. A `down`/`up` at the same point with the Rectangle, Ellipse, Triangle or Polygon tool opens Create Rectangle / Ellipse / Triangle / Polygon (Photoshop's click-to-size dialog); set its `width` and `height` (document pixels), `fromCenter`, `radii` [tl, tr, br, bl] (Rectangle) or `sides` and `starRatio` (percent, Polygon) with `ui.dialog.set`, then `ui.dialog.confirm` runs `shape.create` at the clicked point. - Magnetic Lasso (`tool: "magneticLasso"`): a `down`/`up` pair is a click. The first sets the first fastening point (its modifiers set the selection mode); later clicks fasten a point, or close the border on the first point. `move` events trace the border with or without a button held, fastening points by themselves; with `alt: true` a click draws a straight segment and a drag a freehand one. `ui.inspect` shows the border in progress under `magnetic` (`path`, `anchors`, `live`, `mode`). `ui.key` Enter closes it along the edges, Backspace removes the last fastening point, Escape cancels. To select along edges in one call instead, use the `select.magneticLasso` command with rough points around the shape. - Image › Adjustments › Curves on top, no eyedropper armed (#7325): a `down` with `command: true` adds a point on the curve the dialog shows (its Channel popup), at the pixel's tone (RGB: Rec. 709 luma; Gray: the mean) and at the curve's current output there, so the preview does not change; with `shift: true` as well, one point on each of Red, Green and Blue at that channel's value. It samples the merged image like the eyedroppers, never moves an existing point (a point already within 2 levels is selected instead, a 16-point curve takes no more), and the tool is not driven. Nothing is added with a pencil-drawn curve, in Lab or CMYK, or with a mask, saved channel or Quick Mask targeted. - Levels or Curves adjustment layer, Properties eyedropper armed (`layerPicker` in `ui.inspect`: `{layer, point, tool}`, set by the panel's Set Black/Neutral Gray/White Point buttons): a `down` sets that point from the image below the layer as one `layer.setAdjustment {"layer", "eyedropper": {"point", "at": [x, y], "size"}}` (send that command directly to skip the UI) and the tool is not driven; Esc, another tool or another layer disarms it. From 4c46db6206dfb6fd0db77fef1f0d187de3bc1fed Mon Sep 17 00:00:00 2001 From: Dan Date: Sat, 7 Nov 2026 16:14:56 +0000 Subject: [PATCH 103/219] fix(ui): retain held-Z menu anchoring --- crates/ui-egui/src/canvas/canvas_tool_menu.rs | 4 +-- .../zoom_menu_anchor_tests.rs | 34 +++++++++++++++++++ docs/control-protocol.md | 2 +- 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu.rs b/crates/ui-egui/src/canvas/canvas_tool_menu.rs index 044f040c72..ff714d0f80 100644 --- a/crates/ui-egui/src/canvas/canvas_tool_menu.rs +++ b/crates/ui-egui/src/canvas/canvas_tool_menu.rs @@ -916,7 +916,7 @@ pub fn open_view(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; true } -/// A view menu row. The selected Zoom tool's steps keep the view centre unless clicked-point +/// A view menu row. The non-temporary Zoom tool's steps keep the view centre unless clicked-point /// centring is enabled. Other view-tool cases retain their pressed-point anchoring. fn choose_view(app: &mut PhotocraftApp, ctx: &Context, menu: &CanvasToolMenu, command: &str) -> Result<(), String> { let i = app.session.active_index().ok_or("no document")?; @@ -935,7 +935,7 @@ fn choose_view(app: &mut PhotocraftApp, ctx: &Context, menu: &CanvasToolMenu, co } let (Some((zoom0, c)), Some(d)) = (before, menu.anchor) else { return Ok(()) }; let center_on_point = app.session.prefs().tools.zoom_clicked_point_to_center; - if menu.tool == Tool::Zoom && app.ui.tool == Tool::Zoom && !center_on_point { + if menu.tool == Tool::Zoom && app.ui.tool == Tool::Zoom && app.ui.spring.is_none() && !center_on_point { return Ok(()); } let Some(v) = app.ui.views.get_mut(i) else { return Ok(()) }; diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu/zoom_menu_anchor_tests.rs b/crates/ui-egui/src/canvas/canvas_tool_menu/zoom_menu_anchor_tests.rs index f0baa53b8a..3aa3d052f0 100644 --- a/crates/ui-egui/src/canvas/canvas_tool_menu/zoom_menu_anchor_tests.rs +++ b/crates/ui-egui/src/canvas/canvas_tool_menu/zoom_menu_anchor_tests.rs @@ -110,3 +110,37 @@ fn held_zoom_keys_still_open_no_view_menu() { } } } + +#[test] +fn held_z_menu_keeps_its_existing_pressed_point_anchor() { + for label in ["Zoom In", "Zoom Out"] { + let mut app = zoom_app(400, 300); + app.ui.tool = Tool::Brush; + let mut h = Harness::builder().with_size(vec2(1000.0, 700.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + crate::shortcuts::handle(app, &ctx); + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::default()); + h.run_steps(4); + h.event(egui::Event::Key { key: egui::Key::Z, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.step(); + assert_eq!(h.state().ui.tool, Tool::Zoom); + assert!(h.state().ui.spring.as_ref().is_some_and(|s| s.previous == Tool::Brush)); + let doc = [300.0, 100.0]; + let p = screen_of(h.state(), doc); + right_click(&mut h, p, Modifiers::NONE); + h.get_by_label(label).click(); + h.run_steps(3); + assert!(screen_of(h.state(), doc).distance(p) < 1.0, "{label}: a held Z keeps today's anchor"); + h.event(egui::Event::Key { key: egui::Key::Z, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert_eq!(h.state().ui.tool, Tool::Brush); + } +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 3ffcc17d12..598ccd47c6 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -85,7 +85,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - A pixel tool pressed on a type, shape, Smart Object or fill layer (e.g. through `ui.pointer`) opens the "Rasterize?" prompt instead of painting: a dialog with `__rasterize` (`type|shape|smartObject|fill`), `message`, `layer`, `tool` and `at`. `ui.dialog.confirm` runs the `layer.rasterize.*` command and then paints at `at` (two history states, `{"rasterized", "painted"}`); `ui.dialog.cancel` does nothing. - Edit › Transform › Warp on editable type (`ui.menu.invoke {"id":"edit.transform.warp"}`, or the warp toggle of an open transform box) opens Warp mode with no prompt, as in Photoshop: the mesh can't be dragged or split, only the options bar's warp style, Bend, H and V apply, and the commit sets the layer's Warp Text in one step, so the type stays editable. Directly: `engine.execute {"command":"edit.transform.warp","params":{"style":"flag","bend":40}}` on a type layer. Distort and Perspective stay greyed for type. - `ui.window.open {document?}` / `ui.window.close {window}`: extra document windows -- `ui.pointer {events: [{kind: down|move|up, x, y, pressure?, tiltX?, tiltY?, rotation?}], tool?, modifiers?, button?}`: drive the active tool in document coordinates (pressure 0..1, tilt in degrees -90..90, barrel rotation 0..360: a simulated pen). Modifier flags (`shift`, `alt`, `command`, `ctrl`, `space`) go either at the top level or grouped under `modifiers`; when `modifiers` is present, top-level flags are ignored. `space: true` holds Space (the Crop frame, marquee, lasso or shape being drawn then moves instead of growing). With the Crop tool in its default mode (Use Classic Mode off) a drag inside the box moves the image and one outside turns it, the view panning and turning with it; a drag's `move`/`up` points are read as the view showed the document at its `down`, so an `up` where the last `move` was changes nothing more. `command: true` with a painting, retouching, eraser, gradient, eyedropper or other non-selection tool makes the gesture the Move tool's, as holding ⌘ (Ctrl off the Mac) does in the app: a drag moves the active layer (the selected pixels when there is a selection), with `alt: true` as well it duplicates the layer first, and a click picks the layer under the point; the tool is unchanged afterwards. The Hand, Rotate View, Zoom, Crop, Slice, Path Selection, Pen, shape, Type and selection tools keep `command` for themselves. `ctrl: true` with `alt: true` and a brush tool is the on-canvas brush resize instead (in the app, Ctrl+Alt-drag off the Mac). `button: "right"` (or `"secondary"`) is the right button: with the Move tool (or `command: true` with any tool) it opens the canvas layer menu (`layerMenu` in `ui.inspect` lists the layers there, topmost first; select one with `layer.select`); with Marquee, Lasso, Magic Wand, Object Selection, or Pen, or with a Type tool on text being edited or on committed type, it opens a tool context menu (`canvasToolMenu` in `ui.inspect`, with ordered command ids, separators, and enabled states); with Zoom or Hand it opens the view menu there (Zoom In and Zoom Out keep the view centre with the selected Zoom tool and Zoom Clicked Point to Center off; other cases retain their pressed-point behaviour; `view.fillScreen` is its Fill Screen row); with a painting tool it opens the Brush Preset picker, or erases with Preferences › Tools › Right-click with painting tools = erase. For a painting tool's Shift+right-click, see the Mode menu exception below. The menus open at the screen position of the pressed document point. Use `ui.context.choose {id}` for enabled tool-context actions. The Pen menu is documented in `docs/context-menu-parity.md`. `button` is `left` (default), `right`, `secondary` or `middle`, and an event's `kind` is `down`, `move` (default) or `up`. With a Color Picker as the top dialog, `down` and `move` sample the image into its new colour instead (its eyedropper, like a click on the canvas) and the tool is not driven. A `down`/`up` at the same point with the Rectangle, Ellipse, Triangle or Polygon tool opens Create Rectangle / Ellipse / Triangle / Polygon (Photoshop's click-to-size dialog); set its `width` and `height` (document pixels), `fromCenter`, `radii` [tl, tr, br, bl] (Rectangle) or `sides` and `starRatio` (percent, Polygon) with `ui.dialog.set`, then `ui.dialog.confirm` runs `shape.create` at the clicked point. +- `ui.pointer {events: [{kind: down|move|up, x, y, pressure?, tiltX?, tiltY?, rotation?}], tool?, modifiers?, button?}`: drive the active tool in document coordinates (pressure 0..1, tilt in degrees -90..90, barrel rotation 0..360: a simulated pen). Modifier flags (`shift`, `alt`, `command`, `ctrl`, `space`) go either at the top level or grouped under `modifiers`; when `modifiers` is present, top-level flags are ignored. `space: true` holds Space (the Crop frame, marquee, lasso or shape being drawn then moves instead of growing). With the Crop tool in its default mode (Use Classic Mode off) a drag inside the box moves the image and one outside turns it, the view panning and turning with it; a drag's `move`/`up` points are read as the view showed the document at its `down`, so an `up` where the last `move` was changes nothing more. `command: true` with a painting, retouching, eraser, gradient, eyedropper or other non-selection tool makes the gesture the Move tool's, as holding ⌘ (Ctrl off the Mac) does in the app: a drag moves the active layer (the selected pixels when there is a selection), with `alt: true` as well it duplicates the layer first, and a click picks the layer under the point; the tool is unchanged afterwards. The Hand, Rotate View, Zoom, Crop, Slice, Path Selection, Pen, shape, Type and selection tools keep `command` for themselves. `ctrl: true` with `alt: true` and a brush tool is the on-canvas brush resize instead (in the app, Ctrl+Alt-drag off the Mac). `button: "right"` (or `"secondary"`) is the right button: with the Move tool (or `command: true` with any tool) it opens the canvas layer menu (`layerMenu` in `ui.inspect` lists the layers there, topmost first; select one with `layer.select`); with Marquee, Lasso, Magic Wand, Object Selection, or Pen, or with a Type tool on text being edited or on committed type, it opens a tool context menu (`canvasToolMenu` in `ui.inspect`, with ordered command ids, separators, and enabled states); with Zoom or Hand it opens the view menu there (Zoom In and Zoom Out keep the view centre with the non-temporary Zoom tool and Zoom Clicked Point to Center off; other cases retain their pressed-point behaviour; `view.fillScreen` is its Fill Screen row); with a painting tool it opens the Brush Preset picker, or erases with Preferences › Tools › Right-click with painting tools = erase. For a painting tool's Shift+right-click, see the Mode menu exception below. The menus open at the screen position of the pressed document point. Use `ui.context.choose {id}` for enabled tool-context actions. The Pen menu is documented in `docs/context-menu-parity.md`. `button` is `left` (default), `right`, `secondary` or `middle`, and an event's `kind` is `down`, `move` (default) or `up`. With a Color Picker as the top dialog, `down` and `move` sample the image into its new colour instead (its eyedropper, like a click on the canvas) and the tool is not driven. A `down`/`up` at the same point with the Rectangle, Ellipse, Triangle or Polygon tool opens Create Rectangle / Ellipse / Triangle / Polygon (Photoshop's click-to-size dialog); set its `width` and `height` (document pixels), `fromCenter`, `radii` [tl, tr, br, bl] (Rectangle) or `sides` and `starRatio` (percent, Polygon) with `ui.dialog.set`, then `ui.dialog.confirm` runs `shape.create` at the clicked point. - Magnetic Lasso (`tool: "magneticLasso"`): a `down`/`up` pair is a click. The first sets the first fastening point (its modifiers set the selection mode); later clicks fasten a point, or close the border on the first point. `move` events trace the border with or without a button held, fastening points by themselves; with `alt: true` a click draws a straight segment and a drag a freehand one. `ui.inspect` shows the border in progress under `magnetic` (`path`, `anchors`, `live`, `mode`). `ui.key` Enter closes it along the edges, Backspace removes the last fastening point, Escape cancels. To select along edges in one call instead, use the `select.magneticLasso` command with rough points around the shape. - Image › Adjustments › Curves on top, no eyedropper armed (#7325): a `down` with `command: true` adds a point on the curve the dialog shows (its Channel popup), at the pixel's tone (RGB: Rec. 709 luma; Gray: the mean) and at the curve's current output there, so the preview does not change; with `shift: true` as well, one point on each of Red, Green and Blue at that channel's value. It samples the merged image like the eyedroppers, never moves an existing point (a point already within 2 levels is selected instead, a 16-point curve takes no more), and the tool is not driven. Nothing is added with a pencil-drawn curve, in Lab or CMYK, or with a mask, saved channel or Quick Mask targeted. - Levels or Curves adjustment layer, Properties eyedropper armed (`layerPicker` in `ui.inspect`: `{layer, point, tool}`, set by the panel's Set Black/Neutral Gray/White Point buttons): a `down` sets that point from the image below the layer as one `layer.setAdjustment {"layer", "eyedropper": {"point", "at": [x, y], "size"}}` (send that command directly to skip the UI) and the tool is not driven; Esc, another tool or another layer disarms it. From 2ce48d4b6ab7fd8e7c710a7105cda96cee3f9541 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 7 Nov 2026 17:30:24 +0100 Subject: [PATCH 104/219] PR #5250: name the Scatter slider by its caption and find captions after the merge Main's slider rows now name their slider after the caption and link the caption to the value field, so get_by_label("Scatter") found the field instead of the caption. The tests use caption_rect, and scatter_row names its slider like slider_row does, with a test that fails without it. Co-Authored-By: augrclk Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/brush_sections.rs | 24 ++++++++++++------- .../src/panels/brush_sections/tests.rs | 9 +++++-- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/panels/brush_sections.rs b/crates/ui-egui/src/panels/brush_sections.rs index 8affedef37..5a5507db1b 100644 --- a/crates/ui-egui/src/panels/brush_sections.rs +++ b/crates/ui-egui/src/panels/brush_sections.rs @@ -383,15 +383,21 @@ fn scatter_row(ui: &mut egui::Ui, amount: &mut f32, both_axes: &mut bool) { let t = Tokens::get(ui.ctx()); let mut percent = (*amount * 100.0).round(); let mut changed = false; - ui.horizontal(|ui| { - let label = ui.label(RichText::new(tl!("Scatter")).color(t.text_dim)); - widgets::scrub_label_within(ui, &label, f32::INFINITY); - widgets::checkbox(ui, both_axes, tl!("Both Axes")); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - changed = widgets::value_field(ui, &mut percent, 0.0..=1000.0, "%", 74.0).changed(); - }); - }); - changed |= widgets::slider(ui, &mut percent, 0.0..=1000.0, None).changed(); + let label = ui + .horizontal(|ui| { + let label = ui.label(RichText::new(tl!("Scatter")).color(t.text_dim)); + widgets::scrub_label_within(ui, &label, f32::INFINITY); + widgets::checkbox(ui, both_axes, tl!("Both Axes")); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + changed = widgets::value_field(ui, &mut percent, 0.0..=1000.0, "%", 74.0).changed(); + }); + label.id + }) + .inner; + // Named by its caption, like every `slider_row` slider. + let slider = widgets::slider(ui, &mut percent, 0.0..=1000.0, None); + slider.widget_info(|| egui::WidgetInfo::slider(ui.is_enabled(), f64::from(percent), tl!("Scatter"))); + changed |= slider.labelled_by(label).changed(); if changed { *amount = (percent.round() / 100.0).clamp(0.0, 10.0); } diff --git a/crates/ui-egui/src/panels/brush_sections/tests.rs b/crates/ui-egui/src/panels/brush_sections/tests.rs index aed7d0657a..830708a6db 100644 --- a/crates/ui-egui/src/panels/brush_sections/tests.rs +++ b/crates/ui-egui/src/panels/brush_sections/tests.rs @@ -347,12 +347,17 @@ fn check_scatter_header(section: usize) { let mut h = section_harness(section, b.clone()); crate::PhotocraftApp::setup_context(&h.ctx, kind); h.run_steps(3); - let label = h.get_by_label("Scatter").rect(); + let label = caption_rect(&h, "Scatter"); let axes = h.get_by_label("Both Axes").rect(); let field = h.query_all_by_role(Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(214.0)).unwrap().rect(); assert!((label.center().y - axes.center().y).abs() < 2.0, "{kind:?}, section {section}: axes belongs on the Scatter header"); assert!((axes.center().y - field.center().y).abs() < 2.0, "{kind:?}, section {section}: axes and percentage share a row"); assert!(label.right() < axes.left() && axes.right() < field.left(), "{kind:?}, section {section}: label, checkbox, percentage order"); + assert_eq!( + h.query_all_by_role_and_label(Role::Slider, "Scatter").count(), + 1, + "{kind:?}, section {section}: the slider is named by its caption, like other slider rows" + ); assert_eq!(h.state(), &b, "rendering retains fractional scatter and other settings"); } } @@ -396,7 +401,7 @@ fn scatter_header_preserves_values_percentage_limits_and_label_scrubbing() { b.dual_brush.scatter = 2.15; } assert_eq!(h.state(), &b, "percentage edits preserve the other brush block"); - let label = h.get_by_label("Scatter").rect().center(); + let label = caption_rect(&h, "Scatter").center(); drag(&mut h, label, label + vec2(18.0, 0.0)); let amount = if section == 2 { h.state().scattering.scatter.jitter } else { h.state().dual_brush.scatter }; assert!(amount > 2.15, "the label still scrubs the percentage across the checkbox"); From a97555e273923390240990c90ccf6e3f2fe49114 Mon Sep 17 00:00:00 2001 From: Dan Date: Sat, 7 Nov 2026 16:43:00 +0000 Subject: [PATCH 105/219] fix(ui): preserve menu anchoring for held Zoom bindings --- crates/ui-egui/src/canvas/canvas_tool_menu.rs | 2 +- .../zoom_menu_anchor_tests.rs | 42 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu.rs b/crates/ui-egui/src/canvas/canvas_tool_menu.rs index ff714d0f80..e174e93fa1 100644 --- a/crates/ui-egui/src/canvas/canvas_tool_menu.rs +++ b/crates/ui-egui/src/canvas/canvas_tool_menu.rs @@ -935,7 +935,7 @@ fn choose_view(app: &mut PhotocraftApp, ctx: &Context, menu: &CanvasToolMenu, co } let (Some((zoom0, c)), Some(d)) = (before, menu.anchor) else { return Ok(()) }; let center_on_point = app.session.prefs().tools.zoom_clicked_point_to_center; - if menu.tool == Tool::Zoom && app.ui.tool == Tool::Zoom && app.ui.spring.is_none() && !center_on_point { + if menu.tool == Tool::Zoom && app.ui.tool == Tool::Zoom && app.ui.spring.is_none() && crate::hold_keys::held_tool(app, ctx).is_none() && !center_on_point { return Ok(()); } let Some(v) = app.ui.views.get_mut(i) else { return Ok(()) }; diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu/zoom_menu_anchor_tests.rs b/crates/ui-egui/src/canvas/canvas_tool_menu/zoom_menu_anchor_tests.rs index 3aa3d052f0..5cb270385c 100644 --- a/crates/ui-egui/src/canvas/canvas_tool_menu/zoom_menu_anchor_tests.rs +++ b/crates/ui-egui/src/canvas/canvas_tool_menu/zoom_menu_anchor_tests.rs @@ -111,6 +111,48 @@ fn held_zoom_keys_still_open_no_view_menu() { } } +#[test] +fn held_zoom_with_zoom_selected_keeps_the_menu_pressed_point_anchor() { + for animated in [false, true] { + for (command, direction) in [("view.zoomIn", 1), ("view.zoomOut", -1)] { + let mut app = zoom_app(400, 300); + app.ui.tool = Tool::Zoom; + app.run("prefs.set", json!({"values": {"general.animatedZoom": animated, "tools.zoomClickedPointToCenter": false, "tools.overscroll": true}})) + .unwrap(); + let mut h = harness(app); + h.event(egui::Event::ModifiersChanged(Modifiers::ALT)); + h.event(egui::Event::Key { key: egui::Key::Space, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::ALT }); + h.step(); + assert_eq!(crate::hold_keys::held_tool(h.state(), &h.ctx), Some(crate::hold_keys::Temporary::ZoomOut)); + let zoom = h.state().ui.views[0].zoom; + let center = h.state().ui.views[0].center; + let doc = [300.0, 100.0]; + let p = screen_of(h.state(), doc); + h.event(egui::Event::PointerMoved(p)); + h.step(); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed, modifiers: Modifiers::ALT }); + h.step(); + } + assert!(h.state().ui.canvas_tool_menu.as_ref().is_some_and(|m| m.tool == Tool::Zoom)); + assert_eq!(crate::hold_keys::held_tool(h.state(), &h.ctx), Some(crate::hold_keys::Temporary::ZoomOut)); + let ctx = h.ctx.clone(); + choose(h.state_mut(), &ctx, command); + assert_eq!(h.state().ui.views[0].zoom, crate::zoom_levels::step(zoom, direction, [400, 300])); + assert_ne!(h.state().ui.views[0].center, center, "{command}: preserve the held tool's pressed-point behavior"); + h.step(); + h.input_mut().time = Some(ctx.input(|i| i.time) + 0.11); + h.step(); + assert!(screen_of(h.state(), doc).distance(p) < 1.0, "{command}: anchor during animation"); + h.input_mut().time = Some(ctx.input(|i| i.time) + 0.3); + h.step(); + assert!(screen_of(h.state(), doc).distance(p) < 1.0, "{command}: settled anchor"); + assert_eq!(h.state().ui.tool, Tool::Zoom); + assert_eq!(h.state().session.active().unwrap().history.past_len(), 0); + } + } +} + #[test] fn held_z_menu_keeps_its_existing_pressed_point_anchor() { for label in ["Zoom In", "Zoom Out"] { From 3d2d1870c9d5441b7f348ebc9b121d78519c0b80 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sat, 7 Nov 2026 09:34:55 -0800 Subject: [PATCH 106/219] Correct Free Transform interior cursor test diagnostic --- crates/ui-egui/src/canvas/transform_controls_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas/transform_controls_tests.rs b/crates/ui-egui/src/canvas/transform_controls_tests.rs index 229f0b8965..2bb92e0d8b 100644 --- a/crates/ui-egui/src/canvas/transform_controls_tests.rs +++ b/crates/ui-egui/src/canvas/transform_controls_tests.rs @@ -334,7 +334,7 @@ fn move_transform_controls_show_resize_and_rotate_cursors_before_first_interacti assert_eq!(transform_controls_cursor(r, p), Some(expected), "hover at {p:?}"); assert!(transform_controls_hit(r, p), "same region must start a transform"); } - assert_eq!(transform_controls_cursor(r, r.center()), Some(egui::CursorIcon::Default), "interior stays Move"); + assert_eq!(transform_controls_cursor(r, r.center()), Some(egui::CursorIcon::Default), "interior uses the default arrow"); assert_eq!(transform_controls_cursor(r, pos2(r.center().x, r.top() - 30.0)), Some(egui::CursorIcon::None)); // The rotate arrow points into the nearest edge or corner. Handles and the interior have none. let into = |p: Pos2| transform_controls_turn(r, p); From 024c7c0310fd40e71add799e0f5ffd3b6c7e1567 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 7 Nov 2026 18:50:40 +0100 Subject: [PATCH 107/219] Regenerate docs/scorecard.md for #10448's export.lastFolder setting #10448 adds the export.lastFolder preference, so the prefs audit now counts 195 settings instead of 194 and the gate failed on a stale scorecard. Co-Authored-By: qvest-ssels Co-Authored-By: Claude Opus 5.5 --- docs/scorecard.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/scorecard.md b/docs/scorecard.md index 7e92243bf8..4a60cdc4ac 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Performance](#performance) | 3 | 22 | 51 | 85 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 47 not run, 4 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 11 | 14 | 2 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 190/258, round trip ≥ 258/258 | | [Tools](#tools) | 36 | 9 | 0 | | -| [Workspace and UI](#workspace-and-ui) | 15 | 6 | 2 | settings that do nothing: 10 of 194 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 15 | 6 | 2 | settings that do nothing: 10 of 195 (target 0) | | [Type](#type) | 5 | 5 | 0 | | | [Automation](#automation) | 5 | 3 | 3 | 661 of 661 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 30 of 30 crates; panic_hunt present | @@ -209,7 +209,7 @@ Corpus floors (`crates/io/tests/it/corpus.rs`): the enforced minimums; the corpu ## Workspace and UI -**Settings that do nothing: 10 of 194** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. +**Settings that do nothing: 10 of 195** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. Unread: `general.color_picker`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `performance.legacy_compositing`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `type_.text_engine`, `enhanced_controls.scrubby_slider_acceleration`. From e25be5e77bfc440cd21e7c8577a2a0a264d54b2a Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 7 Nov 2026 11:57:16 -0600 Subject: [PATCH 108/219] Magic Wand: Anti-alias keeps the region whole and softens only near-matching neighbours (#11776) --- crates/algo/src/selection.rs | 89 ++++++------ crates/algo/src/selection/tests.rs | 4 +- .../src/selection/wand_antialias_tests.rs | 128 ++++++++++++++++++ 3 files changed, 174 insertions(+), 47 deletions(-) create mode 100644 crates/algo/src/selection/wand_antialias_tests.rs diff --git a/crates/algo/src/selection.rs b/crates/algo/src/selection.rs index daf7a2526a..24126cf9f2 100644 --- a/crates/algo/src/selection.rs +++ b/crates/algo/src/selection.rs @@ -219,11 +219,44 @@ pub fn magic_wand(px: &[[f32; 4]], area: Rect, seed: (i32, i32), tolerance: f32, } } if anti_alias { - antialias(&mut m, w, h); + let tol = (tolerance + 1e-3).floor().max(0.0); + let src = m.clone(); + for (i, v) in m.iter_mut().enumerate() { + if src[i] == 0.0 && touches_region(i % w, i / w, w, h, |x, y| src[y * w + x] > 0.0) { + let p = px[i]; + let over = (0..3).map(|c| (p[c] - target[c]).abs() * 255.0).fold(0.0f32, f32::max) - tol; + *v = f32::from(fringe_coverage(over, p[3].min(target[3]) * 255.0)) / 255.0; + } + } } m } +/// How many levels past the tolerance the anti-aliased fringe fades over, from fully selected to +/// nothing. Measured in the reference application (#11776): 3 levels past gives 251, 5 gives +/// 249 and 123 gives 110; a straight fade to zero at 216 levels gives all three. +const FRINGE_LEVELS: f32 = 216.0; + +/// Coverage (0–255) of an anti-aliased fringe pixel: a neighbour of the region whose most +/// different colour channel is `over` levels past the tolerance, and `alpha` (0–255) the lower +/// of its and the clicked pixel's opacity. Photoshop keeps the region itself fully selected and +/// softens only the pixels just outside it, fading with the distance past the tolerance; a +/// neighbour without colour (transparent, next to a shape on its own layer) gets none. +fn fringe_coverage(over: f32, alpha: f32) -> u8 { + let k = (1.0 - over.max(0.0) / FRINGE_LEVELS).clamp(0.0, 1.0) * (alpha / 255.0).clamp(0.0, 1.0); + (k * 255.0).round() as u8 +} + +/// True when one of the 8 neighbours of `(x, y)` (inside a `w` x `h` area) is in the region. +fn touches_region(x: usize, y: usize, w: usize, h: usize, selected: impl Fn(usize, usize) -> bool) -> bool { + (-1i32..=1).any(|dy| { + (-1i32..=1).any(|dx| { + let (nx, ny) = (x as i32 + dx, y as i32 + dy); + (dx, dy) != (0, 0) && nx >= 0 && ny >= 0 && (nx as usize) < w && (ny as usize) < h && selected(nx as usize, ny as usize) + }) + }) +} + /// Straight RGBA8 copy of `area` of a surface, read in parallel bands on native. pub fn rgba8_image(s: &Surface, area: Rect) -> Vec<[u8; 4]> { let (w, h) = (area.width() as usize, area.height() as usize); @@ -356,25 +389,12 @@ pub fn wand_region(img: &[[u8; 4]], area: Rect, seed: (i32, i32), tolerance: f32 let x = x0 + xx; let v = at(x, y); *o = v; - if !anti_alias { - continue; - } - // Same rule as `antialias`: edge pixels blend with their 3×3 neighbourhood; neighbours - // outside the area count as the pixel itself. - let mut sum = 0u32; - let mut edge = false; - for dy in -1i32..=1 { - for dx in -1i32..=1 { - let (nx, ny) = (x as i32 + dx, y as i32 + dy); - let n = if nx < 0 || ny < 0 || nx >= w as i32 || ny >= h as i32 { v } else { at(nx as usize, ny as usize) }; - edge |= n != v; - sum += n as u32; - } - } - if edge { - let avg = sum as f32 / (9.0 * 255.0); - let f = if v > 127 { 0.5 + 0.5 * avg } else { 0.5 * avg }; - *o = (f * 255.0).round() as u8; + // Same rule as `magic_wand`: the region stays fully selected; a pixel just outside + // it gets a fringe by how far its colour is past the tolerance. + if anti_alias && v == 0 && touches_region(x, y, w, h, |nx, ny| at(nx, ny) != 0) { + let p = img[y * w + x]; + let d = (0..3).map(|c| (p[c] as i32 - target[c] as i32).abs()).max().unwrap_or(0); + *o = fringe_coverage((d - tol) as f32, f32::from(p[3].min(target[3]))); } } }; @@ -435,32 +455,6 @@ pub fn combine_region(old: Option<&Surface>, new: Option<&Region>, mode: Selecti } } -/// Softens hard mask edges: edge pixels become the 3×3 average. -fn antialias(m: &mut [f32], w: usize, h: usize) { - let src = m.to_vec(); - for y in 0..h { - for x in 0..w { - let i = y * w + x; - let mut sum = 0.0; - let mut cnt = 0.0; - let mut edge = false; - for dy in -1i32..=1 { - for dx in -1i32..=1 { - let (xx, yy) = (x as i32 + dx, y as i32 + dy); - let v = if xx < 0 || yy < 0 || xx >= w as i32 || yy >= h as i32 { src[i] } else { src[yy as usize * w + xx as usize] }; - edge |= v != src[i]; - sum += v; - cnt += 1.0; - } - } - if edge { - // Keep selected pixels mostly selected, add a soft fringe. - m[i] = if src[i] > 0.5 { 0.5 + 0.5 * sum / cnt } else { 0.5 * sum / cnt }; - } - } - } -} - /// Colour Range: coverage falls off linearly with the per-channel colour /// distance; `fuzziness` in levels (0–200). pub fn color_range(px: &[[f32; 4]], color: [f32; 3], fuzziness: f32) -> Vec { @@ -780,10 +774,13 @@ pub fn tone_range(px: &[[f32; 4]], lo: f32, hi: f32, falloff: f32) -> Vec { mod distance; #[cfg(test)] mod ellipse_aa_edge_tests; + mod inverse; mod lasso; mod marquee; mod morphology; +#[cfg(test)] +mod wand_antialias_tests; pub use distance::edt; pub use inverse::inverse_surface; diff --git a/crates/algo/src/selection/tests.rs b/crates/algo/src/selection/tests.rs index 4e7bdcbe06..5f02b53a58 100644 --- a/crates/algo/src/selection/tests.rs +++ b/crates/algo/src/selection/tests.rs @@ -106,7 +106,9 @@ fn wand_contiguous_vs_global() { let g = magic_wand(&px, a, (3, 3), 10.0, false, false); assert_eq!(g.iter().filter(|v| **v > 0.0).count(), 80); let aa = magic_wand(&px, a, (3, 3), 10.0, true, true); - assert!(aa[3 * 20 + 6] > 0.0 && aa[3 * 20 + 6] < 1.0); + // #11776: anti-aliasing keeps the region whole and adds no fringe next to a colour this far + // past the tolerance (white is 245 levels from red, beyond the fringe's fade). + assert_eq!((aa[3 * 20 + 5], aa[3 * 20 + 6]), (1.0, 0.0)); assert!(magic_wand(&px, a, (50, 3), 10.0, true, false).iter().all(|v| *v == 0.0)); } diff --git a/crates/algo/src/selection/wand_antialias_tests.rs b/crates/algo/src/selection/wand_antialias_tests.rs new file mode 100644 index 0000000000..6d435e12cf --- /dev/null +++ b/crates/algo/src/selection/wand_antialias_tests.rs @@ -0,0 +1,128 @@ +//! The Magic Wand's Anti-alias against the reference measurement (#11776): the region itself +//! stays fully selected, and a one-pixel soft fringe lies only outside it, next to colours just +//! past the tolerance. Document 800 x 600: a solid red rectangle 100,100–300,250 on a transparent +//! layer, and a grey ramp of ten 40 px blocks (grey 100, 105, …, 145) from x 100 at y 350–450, +//! on its own transparent layer or composited on white (Sample All Layers). + +use photocraft_geom::Rect; + +use super::{Region, magic_wand, wand_region}; + +const AREA: Rect = Rect { x0: 0, y0: 0, x1: 800, y1: 600 }; +const W: usize = 800; +const H: usize = 600; + +fn red_rectangle() -> Vec<[u8; 4]> { + let mut img = vec![[0u8; 4]; W * H]; + for y in 100..250 { + for x in 100..300 { + img[y * W + x] = [220, 30, 30, 255]; + } + } + img +} + +/// The grey ramp at y 350–450 on a transparent layer, or on white when `on_white`. +fn ramp(on_white: bool) -> Vec<[u8; 4]> { + let mut img = vec![if on_white { [255u8; 4] } else { [0u8; 4] }; W * H]; + for y in 350..450 { + for b in 0..10u8 { + let g = 100 + 5 * b; + for x in 100 + 40 * b as usize..140 + 40 * b as usize { + img[y * W + x] = [g, g, g, 255]; + } + } + } + img +} + +/// (bounds of every touched pixel, fully selected count, partly selected count). +fn stats(r: &Region) -> (Rect, usize, usize) { + let (mut x0, mut y0, mut x1, mut y1) = (i32::MAX, i32::MAX, i32::MIN, i32::MIN); + let (mut full, mut part) = (0, 0); + for y in r.bbox.y0..r.bbox.y1 { + for x in r.bbox.x0..r.bbox.x1 { + let v = (r.at(x, y) * 255.0).round() as u8; + if v == 0 { + continue; + } + (x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x + 1), y1.max(y + 1)); + if v == 255 { full += 1 } else { part += 1 } + } + } + (Rect::new(x0, y0, x1, y1), full, part) +} + +fn coverage(r: &Region, x: i32, y: i32) -> u8 { + (r.at(x, y) * 255.0).round() as u8 +} + +#[test] +fn a_shape_next_to_a_clearly_different_colour_gets_no_fringe() { + let img = red_rectangle(); + for aa in [true, false] { + let r = wand_region(&img, AREA, (150, 150), 32.0, true, aa).unwrap(); + assert_eq!(stats(&r), (Rect::new(100, 100, 300, 250), 30000, 0), "anti-alias {aa}"); + } +} + +#[test] +fn the_grey_ramp_gets_one_nearly_selected_column_past_the_tolerance() { + let img = ramp(false); + // Tolerance 32 from grey 100: blocks 100 to 130 (x 100–380) fully selected; the 135 block + // is 3 levels past it, so its first column is nearly selected. + let r = wand_region(&img, AREA, (110, 400), 32.0, true, true).unwrap(); + let (bounds, full, part) = stats(&r); + assert_eq!((full, part), (280 * 100, 100), "{bounds:?}"); + assert_eq!(bounds, Rect::new(100, 350, 381, 450)); + for y in [350, 400, 449] { + assert_eq!(coverage(&r, 379, y), 255, "the region's last column stays fully selected"); + assert_eq!(coverage(&r, 380, y), 251, "one fringe column of the next block, as measured"); + assert_eq!(coverage(&r, 381, y), 0, "only one column"); + } + // Transparent pixels above and below the ramp have no colour: no fringe there. + assert_eq!(coverage(&r, 200, 349), 0); + assert_eq!(coverage(&r, 200, 450), 0); + // Tolerance 10: x 100–220 plus one partly selected column of the 115 block. + let r = wand_region(&img, AREA, (110, 400), 10.0, true, true).unwrap(); + let (bounds, full, part) = stats(&r); + assert_eq!((bounds, full, part), (Rect::new(100, 350, 221, 450), 120 * 100, 100)); + assert_eq!(coverage(&r, 220, 400), 249, "5 levels past tolerance 10, as measured"); +} + +#[test] +fn anti_alias_off_has_no_partly_selected_pixels() { + let img = ramp(false); + let r = wand_region(&img, AREA, (110, 400), 32.0, true, false).unwrap(); + assert_eq!(stats(&r), (Rect::new(100, 350, 380, 450), 28000, 0)); +} + +#[test] +fn the_8_bit_and_f32_wands_draw_the_same_fringe() { + let img = ramp(false); + let f: Vec<[f32; 4]> = img.iter().map(|p| p.map(|v| f32::from(v) / 255.0)).collect(); + for (tol, contiguous) in [(32.0, true), (10.0, true), (32.0, false)] { + let r = wand_region(&img, AREA, (110, 400), tol, contiguous, true).unwrap(); + let m = magic_wand(&f, AREA, (110, 400), tol, contiguous, true); + for y in 340..460 { + for x in 90..520 { + let want = (m[y as usize * W + x as usize] * 255.0).round() as u8; + assert_eq!(coverage(&r, x, y), want, "tolerance {tol}, contiguous {contiguous} at {x},{y}"); + } + } + } +} + +#[test] +fn on_the_composite_the_white_around_the_ramp_gets_the_measured_fringe() { + // Sample All Layers: the ramp on white. White is 155 levels from grey 100, 123 past + // tolerance 32; the reference application's fringe there reads 110 (Photoshop 2025, the + // same points; the issue reports 131–177 around the ramp). + let img = ramp(true); + let r = wand_region(&img, AREA, (110, 400), 32.0, true, true).unwrap(); + for (x, y) in [(200, 349), (200, 450), (99, 400), (99, 349)] { + assert_eq!(coverage(&r, x, y), 110, "{x},{y}"); + } + assert_eq!(coverage(&r, 380, 400), 251, "the near-miss block still gets the stronger fringe"); + assert_eq!(coverage(&r, 200, 400), 255, "inside stays fully selected"); +} From 16b842a67fb6f56f549fd8919a48603b50400d3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Sat, 7 Nov 2026 19:06:33 +0100 Subject: [PATCH 109/219] fix(engine): the Type tool leaves a selection in Indexed Color In an Indexed Color or a Bitmap document Photoshop keeps no type layer: the Type tool still lets you click and type and shows the text while editing, but committing it leaves a selection in the shape of the glyphs and no layer, as one history state named "New Type Layer", and paints nothing. commit_here already made that result for the Type Mask tools, with type.createSelection and removeLayer in the typing session's one step, so the commit now runs it for a new Type tool layer in those two modes as well, and names the step back after type.create's own label. The mode check is the engine's (type_cmds::commits_as_selection) and not the type.create command's: that command is the primitive the tools make their working layer with, on the first click already with text in it while the placeholder preference is on, so a mode branch there would leave nothing to type into. Tests: cmd::type_indexed_mode_tests pins the commit in both modes (no type layer, the selection the Type Mask of the same text makes, one state named "New Type Layer", every pixel unchanged, one undo back), RGB, Grayscale, CMYK and Lab at 8, 16 and 32 bit, the command's own role, and a refused conversion; editing.rs pins the tool's commit and its cancel through the canvas' own pointer_up, insert and commit. Fixes #12041 --- crates/engine/src/cmd.rs | 2 + crates/engine/src/cmd/type_cmds.rs | 36 ++- .../engine/src/cmd/type_indexed_mode_tests.rs | 220 ++++++++++++++++++ crates/ui-egui/src/tools/type_tool/editing.rs | 137 +++++++++++ 4 files changed, 393 insertions(+), 2 deletions(-) create mode 100644 crates/engine/src/cmd/type_indexed_mode_tests.rs diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index c4addf4c22..a140b5db5d 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -150,6 +150,8 @@ pub mod type_area_cmds; pub mod type_caret_cmds; pub mod type_cmds; pub mod type_extra_cmds; +#[cfg(test)] +mod type_indexed_mode_tests; pub mod type_mask_cmds; pub mod type_spell_cmds; pub mod type_styles_cmds; diff --git a/crates/engine/src/cmd/type_cmds.rs b/crates/engine/src/cmd/type_cmds.rs index d05c05f46a..16d3d29a91 100644 --- a/crates/engine/src/cmd/type_cmds.rs +++ b/crates/engine/src/cmd/type_cmds.rs @@ -8,7 +8,7 @@ use std::sync::Arc; -use photocraft_color::{Color, PixelFormat}; +use photocraft_color::{Color, ColorMode, PixelFormat}; use photocraft_doc::text::{ AntiAlias, Caps, CharStyle, FontBaseline, FontFeature, FontVariation, Kerning, Orientation, ParagraphRun, ParagraphStyle, TextAlign, TextDirection, TextRun, TextShape, @@ -937,6 +937,38 @@ pub(crate) fn new_text_layer(s: &Session, p: &Value, cmd: &str) -> Result Type > "Fill new type layers with placeholder +/// text" (on by default) already gives that first call non-empty text. Branching there would make +/// the click produce a selection and no layer, so there would be nothing to type into - while the +/// reference application still lets you click and type in these modes and shows the text while +/// editing. Only its commit differs. +/// - `type.create` is also what automation, actions and a script's new text layer go through, and +/// the measured difference is the tool's. +pub fn commits_as_selection(mode: ColorMode) -> bool { + matches!(mode, ColorMode::Indexed | ColorMode::Bitmap) +} + +/// Name the step the Type tool's commit made in such a document (#12041). The commit runs the Type +/// Mask tools' `type.createSelection` with `removeLayer` - the same conversion, coalesced into the +/// one step of the typing session - and that command names the step "Type Mask", while Photoshop's +/// history state here is the type layer's own name. +pub fn name_selection_commit(s: &mut Session) { + if let Some(st) = s.active_mut() { + st.history.set_current_label(NEW_TYPE_LAYER_LABEL); + } +} + const CHAR_PARAMS: &str = r##""font":str,"fontStyle":str,"weight":100..900,"italic":bool,"size":0.1..=1296 pt,"color":"#rrggbb"|[r,g,b,a],"tracking":-1000..=10000 (1/1000 em),"leading":pt|"auto","baselineShift":pt,"horizontalScale":%,"verticalScale":%,"underline":bool,"strikethrough":bool,"fauxBold":bool,"fauxItalic":bool,"kerning":1/1000em (manual, after each character)|"metrics"|"optical"|"off","caps":"normal|small|all","baseline":"normal|superscript|subscript","ligatures":bool,"discretionaryLigatures":bool,"features":{"ss01":1},"variations":{"wght":650},"language":str"##; pub fn specs() -> Vec { @@ -953,7 +985,7 @@ pub fn specs() -> Vec { let mut t = new_text_layer(s, p, "type.create")?; let given = p.get("name").and_then(Value::as_str).map(str::to_string); let name = given.clone().unwrap_or_else(|| layer_name(&t.text)); - let id = s.edit("New Type Layer", |doc, active| { + let id = s.edit(NEW_TYPE_LAYER_LABEL, |doc, active| { refresh(doc, &mut t); let id = crate::layer_place::insert_new(doc, *active, Layer::new(name, LayerContent::Text(t)), crate::layer_place::Place::AutoEditable); *active = Some(id); diff --git a/crates/engine/src/cmd/type_indexed_mode_tests.rs b/crates/engine/src/cmd/type_indexed_mode_tests.rs new file mode 100644 index 0000000000..d60899c96f --- /dev/null +++ b/crates/engine/src/cmd/type_indexed_mode_tests.rs @@ -0,0 +1,220 @@ +//! #12041: committing Type-tool text in an Indexed Color or a Bitmap document. +//! +//! The reference application still lets you click and type there and shows the text while editing; +//! the commit leaves **no type layer** and a selection in the shape of the glyphs, as one history +//! state named "New Type Layer", and paints nothing. [`super::type_cmds::commits_as_selection`] is +//! that mode check and [`super::type_cmds::name_selection_commit`] the step's name; the conversion +//! is the Type Mask tools' `type.createSelection` with `removeLayer`, coalesced into the typing +//! session, which is what [`commit`] below runs and what `ui-egui`'s `commit_here` now does for the +//! Type tool in these modes. +//! +//! `type.create` itself stays mode-agnostic on purpose, and +//! [`the_type_layer_command_keeps_making_the_tools_working_layer`] pins that: the tools make the +//! layer they edit and show on canvas with that command, on the first click already with text in it +//! when Preferences > Type > "Fill new type layers with placeholder text" is on (the default), so a +//! mode branch there would take away the layer the session types into. + +use super::type_cmds::{commits_as_selection, name_selection_commit}; +use super::*; + +/// The typing session's coalesce key, as `ui-egui`'s `session_key` gives one. +const SESSION: &str = "type-session-12041"; + +/// The text the tool commits, at a baseline inside every document used here. `coalesce` for the +/// tool's own calls (one step for the session), without it for a script's or the pin's. +fn typed(coalesce: bool) -> Value { + let mut p = json!({ "text": "Hi", "x": 20, "y": 80, "size": 48 }); + if coalesce { + p["coalesce"] = json!(SESSION); + } + p +} + +/// A blank RGB document, to be converted to the mode under test. +fn blank() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 240, "height": 120, "background": "transparent"})).unwrap(); + s +} + +fn indexed() -> Session { + let mut s = blank(); + s.execute("image.mode.indexedColor", json!({"palette": "exact", "dither": "none", "forced": "none"})).unwrap(); + assert_eq!(mode(&s), ColorMode::Indexed, "the setup made an Indexed Color document"); + s +} + +fn bitmap() -> Session { + let mut s = blank(); + s.execute("image.mode.grayscale", json!({})).unwrap(); + s.execute("image.mode.bitmap", json!({"method": "threshold", "frequency": 12, "shape": "diamond"})).unwrap(); + assert_eq!(mode(&s), ColorMode::Bitmap, "the setup made a Bitmap document"); + s +} + +fn mode(s: &Session) -> ColorMode { + s.active().unwrap().doc.mode +} + +/// The Type tool's commit of the session's working layer in a document [`commits_as_selection`] +/// names: the Type Mask tools' conversion, in the session's one step, which keeps the name the step +/// was opened with. +fn commit(s: &mut Session, layer: u64) { + s.execute("type.createSelection", json!({"layer": layer, "removeLayer": true, "operation": "new", "coalesce": SESSION})).unwrap(); + name_selection_commit(s); +} + +fn text_layers(s: &Session) -> usize { + s.active().unwrap().doc.walk().into_iter().filter(|(_, _, l)| matches!(l.content, LayerContent::Text(_))).count() +} + +fn layers(s: &Session) -> usize { + s.active().unwrap().doc.walk().len() +} + +/// The selection's coverage over the whole canvas, in document pixels. +fn coverage(s: &Session) -> Vec { + let d = s.active().unwrap().doc.clone(); + let b = d.bounds(); + let mut out = Vec::new(); + for y in b.y0..b.y1 { + for x in b.x0..b.x1 { + out.push(d.selection.as_ref().map_or(0.0, |m| m.sample_channel(x, y, 0))); + } + } + out +} + +/// The selection's bounds, as its corners. +fn selection_corners(s: &Session) -> Option<[i32; 4]> { + s.active().unwrap().doc.selection.as_ref().map(|m| m.content_bounds()).map(|r| [r.x0, r.y0, r.x1, r.y1]) +} + +/// Every layer's pixels, quantised: what the commit must leave exactly as it was. +fn pixels(s: &Session) -> Vec { + let d = s.active().unwrap().doc.clone(); + let b = d.bounds(); + let mut out = Vec::new(); + for (_, _, l) in d.walk() { + let Some(surface) = l.surface() else { continue }; + for y in b.y0..b.y1 { + for x in b.x0..b.x1 { + out.extend(surface.rgba(x, y).map(|v| (v * 255.0).round() as u8)); + } + } + } + out +} + +fn history(s: &Session) -> Vec { + s.active().unwrap().history.entries() +} + +#[test] +fn committing_in_indexed_color_or_bitmap_leaves_the_glyphs_selected_and_no_layer() { + for (name, make) in [("Indexed Color", indexed as fn() -> Session), ("Bitmap", bitmap as fn() -> Session)] { + let mut s = make(); + let (steps, layers_before, pixels_before) = (history(&s).len(), layers(&s), pixels(&s)); + + // The tool's session: a type layer to edit and show while typing, as measured. + let layer = s.execute("type.create", typed(true)).unwrap()["layer"].as_u64().unwrap(); + assert_eq!(text_layers(&s), 1, "{name}: typing happens on a layer the tool can show"); + + commit(&mut s, layer); + + assert_eq!(text_layers(&s), 0, "{name}: committing leaves no type layer"); + assert_eq!(layers(&s), layers_before, "{name}: the working layer went with it"); + assert!(s.active().unwrap().doc.selection.is_some(), "{name}: the glyphs are selected"); + let selected = s.active().unwrap().doc.selection.as_ref().unwrap(); + assert!(!selected.content_bounds().is_empty(), "{name}: the selection has a shape"); + assert!(coverage(&s).iter().any(|v| *v > 0.0), "{name}: the selected pixels are the glyphs'"); + assert_eq!(pixels(&s), pixels_before, "{name}: the commit paints nothing"); + + // One step for the whole session, named as Photoshop names it. + let h = history(&s); + assert_eq!(h.len(), steps + 1, "{name}: the session is one history state: {h:?}"); + assert_eq!(h.last().map(String::as_str), Some("New Type Layer"), "{name}: its name"); + + // And undoing that one state gives the document back as it was. + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.active().unwrap().doc.selection.is_none(), "{name}: undo takes the selection"); + assert_eq!(layers(&s), layers_before, "{name}: undo takes the whole session"); + assert_eq!(pixels(&s), pixels_before, "{name}: nothing else was changed"); + } +} + +#[test] +fn the_selection_is_the_glyph_shape_the_type_mask_of_the_same_text_makes() { + for (name, make) in [("Indexed Color", indexed as fn() -> Session), ("Bitmap", bitmap as fn() -> Session)] { + let mut s = make(); + let layer = s.execute("type.create", typed(true)).unwrap()["layer"].as_u64().unwrap(); + commit(&mut s, layer); + let (got, got_bounds) = (coverage(&s), selection_corners(&s)); + + // The pin: the same text straight through the Type Mask command. + let mut pin = make(); + pin.execute("type.createSelection", typed(false)).unwrap(); + assert_eq!(got_bounds, selection_corners(&pin), "{name}: same selection bounds"); + assert_eq!(got, coverage(&pin), "{name}: same selection coverage"); + } +} + +#[test] +fn rgb_grayscale_cmyk_and_lab_keep_making_type_layers_at_every_depth() { + for (id, want) in [("rgb", ColorMode::Rgb), ("gray", ColorMode::Grayscale), ("cmyk", ColorMode::Cmyk), ("lab", ColorMode::Lab)] { + for depth in [8, 16, 32] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 240, "height": 120, "mode": id, "depth": depth, "background": "transparent"})).unwrap(); + assert_eq!(mode(&s), want, "{id} at {depth} bit: the document really is that mode"); + assert!(!commits_as_selection(want), "{id} keeps type layers"); + + let layer = s.execute("type.create", typed(true)).unwrap()["layer"].as_u64().unwrap(); + // Today's commit: rename the session's layer in the same step, select nothing. + let name = super::type_cmds::layer_name("Hi"); + s.execute("type.edit", json!({"layer": layer, "name": name, "coalesce": SESSION})).unwrap(); + assert_eq!(text_layers(&s), 1, "{id} at {depth} bit keeps its type layer"); + assert!(s.active().unwrap().doc.selection.is_none(), "{id} at {depth} bit selects nothing"); + let h = history(&s); + assert_eq!(h.len(), 2, "{id} at {depth} bit: open plus one step for the session: {h:?}"); + assert_eq!(h.last().map(String::as_str), Some("New Type Layer"), "{id} at {depth} bit keeps the step's name"); + } + } +} + +#[test] +fn the_type_layer_command_keeps_making_the_tools_working_layer_in_these_modes() { + // `type.create` is the primitive the Type tools create the layer they edit with (and scripts and + // actions their text layers with), so the colour-mode branch is the commit's, not its own. + for (name, make) in [("Indexed Color", indexed as fn() -> Session), ("Bitmap", bitmap as fn() -> Session)] { + let mut s = make(); + let id = s.execute("type.create", typed(false)).unwrap()["layer"].as_u64().unwrap(); + assert!(matches!(s.active().unwrap().doc.layer(LayerId(id)).unwrap().content, LayerContent::Text(_)), "{name}: the command still makes the layer"); + assert!(s.active().unwrap().doc.selection.is_none(), "{name}: and selects nothing itself"); + assert_eq!(history(&s).last().map(String::as_str), Some("New Type Layer"), "{name}: its own step name"); + } +} + +#[test] +fn a_refused_conversion_leaves_the_typed_text_and_the_document_alone() { + // The commit's fallback in `ui-egui` keeps today's type layer when the conversion is refused, + // so the typed text is not lost with a selection that was never made. Here the document would + // have to keep no layer at all, which is what `type.createSelection` with `removeLayer` refuses. + for (name, make) in [("Indexed Color", indexed as fn() -> Session), ("Bitmap", bitmap as fn() -> Session)] { + let mut s = make(); + let only = s.active().unwrap().doc.layers[0].id.0; + let layer = s.execute("type.create", typed(true)).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.delete", json!({"layer": only})).unwrap(); + let (pixels_before, steps, label) = (pixels(&s), history(&s).len(), history(&s).last().cloned()); + + assert!( + s.execute("type.createSelection", json!({"layer": layer, "removeLayer": true, "operation": "new", "coalesce": SESSION})).is_err(), + "{name}: the conversion is refused" + ); + // `commit_here` stops there and keeps the layer, so the step is never renamed either. + assert_eq!(text_layers(&s), 1, "{name}: the typed text stays as a type layer"); + assert!(s.active().unwrap().doc.selection.is_none(), "{name}: nothing was selected"); + assert_eq!(pixels(&s), pixels_before, "{name}: the refused step painted nothing"); + assert_eq!(history(&s).len(), steps, "{name}: the refused step is no new state"); + assert_eq!(history(&s).last().cloned(), label, "{name}: the refused conversion renames nothing"); + } +} diff --git a/crates/ui-egui/src/tools/type_tool/editing.rs b/crates/ui-egui/src/tools/type_tool/editing.rs index f93826c1d1..20eccbce41 100644 --- a/crates/ui-egui/src/tools/type_tool/editing.rs +++ b/crates/ui-egui/src/tools/type_tool/editing.rs @@ -473,6 +473,27 @@ fn commit_here(app: &mut PhotocraftApp, mods: egui::Modifiers) { } return; } + // #12041: an Indexed Color or Bitmap document has no type layer to keep. The session's working + // layer is still edited and shown while typing, as the reference application shows it, and the + // commit makes what a Type Mask tool's commit makes: the glyphs become the selection and the + // layer goes, in the typing session's one history step - which keeps the "New Type Layer" name + // it was opened with, as Photoshop's state there is named. + // `photocraft_engine::type_cmds::commits_as_selection` says why the check belongs to the commit + // and not to the `type.create` command. Two cases are not measured in these modes and keep + // today's behaviour: an edit of a layer the session did not create (`ed.created`), and a commit + // with no text in it, which is the empty-layer clean-up below. + let leaves_a_selection = ed.created && app.session.active().is_some_and(|st| photocraft_engine::type_cmds::commits_as_selection(st.doc.mode)); + if leaves_a_selection && !text.trim().is_empty() { + if let Err(e) = app.run("type.createSelection", json!({"layer": ed.layer, "removeLayer": true, "operation": "new", "coalesce": ed.session})) { + // The refused step left the document alone: keep today's type layer, so what was typed + // is not lost with a selection that was never made. + app.ui.status = e; + app.ui.status_error = true; + return; + } + photocraft_engine::type_cmds::name_selection_commit(&mut app.session); + return; + } if text.trim().is_empty() && ed.created { let _ = app.run("layer.delete", json!({"layer": ed.layer})); } else if ed.created { @@ -526,3 +547,119 @@ fn undo_session(app: &mut PhotocraftApp, ed: &TextEdit) { let _ = app.run("edit.undo", json!({})); } } + +/// #12041: what the Type tool's commit does in a document whose colour mode has no type layers to +/// keep. The engine side - the mode check, the conversion it reuses and the step's name - is pinned +/// in `photocraft-engine`'s `cmd::type_indexed_mode_tests`; this is the tool's own commit, which is +/// where the difference is measured. +#[cfg(test)] +mod indexed_commit_tests { + use super::{cancel, commit, insert}; + use crate::PhotocraftApp; + use crate::state::Tool; + use crate::tools::type_tool::pointer_up; + use photocraft_doc::{ColorMode, LayerContent}; + use serde_json::json; + + /// A new transparent document in `mode`: `rgb` keeps today's behaviour, `indexed` and `bitmap` + /// are the two the issue is about. + fn app(mode: &str) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 200, "background": "transparent"})).unwrap(); + match mode { + "indexed" => { + app.run("image.mode.indexedColor", json!({"palette": "exact", "dither": "none", "forced": "none"})).unwrap(); + assert_eq!(doc_mode(&app), ColorMode::Indexed, "the document is Indexed Color"); + } + "bitmap" => { + app.run("image.mode.grayscale", json!({})).unwrap(); + app.run("image.mode.bitmap", json!({"method": "threshold", "frequency": 12, "shape": "diamond"})).unwrap(); + assert_eq!(doc_mode(&app), ColorMode::Bitmap, "the document is Bitmap"); + } + _ => assert_eq!(doc_mode(&app), ColorMode::Rgb, "the document is RGB"), + } + app.sync_views(); + app.ui.tool = Tool::Type; + app + } + + fn doc_mode(app: &PhotocraftApp) -> ColorMode { + app.session.active().unwrap().doc.mode + } + + fn text_layers(app: &PhotocraftApp) -> usize { + app.session.active().unwrap().doc.walk().into_iter().filter(|(_, _, l)| matches!(l.content, LayerContent::Text(_))).count() + } + + fn layers(app: &PhotocraftApp) -> usize { + app.session.active().unwrap().doc.walk().len() + } + + fn states(app: &PhotocraftApp) -> usize { + app.session.active().unwrap().history.entries().len() + } + + fn last_state(app: &PhotocraftApp) -> Option { + app.session.active().unwrap().history.entries().last().cloned() + } + + fn selection(app: &PhotocraftApp) -> Option { + app.session.active().unwrap().doc.selection.as_ref().map(|m| m.content_bounds()) + } + + /// The reference application, in an Indexed Color and in a Bitmap document: clicking and typing + /// works and the text is shown while editing, and the commit leaves no type layer but a + /// selection in the shape of the glyphs, as the one history state named "New Type Layer". + #[test] + fn committing_in_indexed_color_or_bitmap_leaves_the_glyphs_selected() { + // The bounds the same text covers where it does become a layer. + let mut rgb = app("rgb"); + pointer_up(&mut rgb, [80.0, 120.0], [80.0, 120.0]); + insert(&mut rgb, "Hi"); + commit(&mut rgb); + let glyphs = rgb.session.active().unwrap().doc.layers.last().unwrap().surface().unwrap().content_bounds(); + assert_eq!(text_layers(&rgb), 1, "an RGB document keeps its type layer"); + assert!(selection(&rgb).is_none(), "and selects nothing"); + assert_eq!(last_state(&rgb).as_deref(), Some("New Type Layer"), "as the session's one state"); + + for mode in ["indexed", "bitmap"] { + let mut app = app(mode); + let (layers_before, states_before) = (layers(&app), states(&app)); + + // Typing: the working layer is there, and shown, as measured. + pointer_up(&mut app, [80.0, 120.0], [80.0, 120.0]); + insert(&mut app, "Hi"); + assert!(app.ui.text_edit.is_some(), "{mode}: the session is open"); + assert_eq!(text_layers(&app), 1, "{mode}: the text is edited on a layer"); + + commit(&mut app); + assert!(app.ui.text_edit.is_none(), "{mode}: the commit ends the session"); + assert_eq!(text_layers(&app), 0, "{mode}: committing leaves no type layer"); + assert_eq!(layers(&app), layers_before, "{mode}: the working layer went with it"); + assert_eq!(selection(&app), Some(glyphs), "{mode}: the typed glyphs are the selection"); + assert_eq!(states(&app), states_before + 1, "{mode}: one state for the session"); + assert_eq!(last_state(&app).as_deref(), Some("New Type Layer"), "{mode}: and its name"); + + app.run("edit.undo", json!({})).unwrap(); + assert!(selection(&app).is_none(), "{mode}: one undo takes the selection"); + assert_eq!(layers(&app), layers_before, "{mode}: and the whole session"); + } + } + + /// Cancelling such a session is unchanged: no layer and no selection are left, as elsewhere. + #[test] + fn cancelling_in_indexed_color_or_bitmap_leaves_nothing() { + for mode in ["rgb", "indexed", "bitmap"] { + let mut app = app(mode); + let (layers_before, states_before) = (layers(&app), states(&app)); + pointer_up(&mut app, [80.0, 120.0], [80.0, 120.0]); + insert(&mut app, "Hi"); + cancel(&mut app); + assert!(app.ui.text_edit.is_none(), "{mode}: the session ended"); + assert_eq!(text_layers(&app), 0, "{mode}: no layer is left"); + assert_eq!(layers(&app), layers_before, "{mode}: no layer is added"); + assert!(selection(&app).is_none(), "{mode}: nothing is selected"); + assert_eq!(states(&app), states_before, "{mode}: and no history state"); + } + } +} From 5c0bec3036f408adf2670f4197215ebb62ff286f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Sat, 7 Nov 2026 19:36:53 +0100 Subject: [PATCH 110/219] fix(engine): grey out Straighten Layer where the reference does Straighten Layer was offered wherever a ruler line existed, so its button was live for every target and the command turned whatever it was pointed at: it rotated and cropped the canvas on the Background, turned several selected layers or a group, failed on a locked layer with `layer "..." is locked`, and rotated pixels nobody could see on a hidden layer. The reference application (#12033) keeps the button off for the Background, a multi-layer selection, a group, Lock All and Lock Position, and answers a hidden target layer with its alert and no change at all. The command's own `enabled` check now says the first set, so the options bar, the CLI, the control channel and an action step all agree about what is offered; the hidden layer stays enabled and is refused by the command, before it reads anything for the edit, so a refused click leaves the document, its History and the ruler line exactly as they were. The states the ticket marks *not measured* keep today's behaviour. --- crates/engine/src/cmd/actions_cmds/tests.rs | 19 +- crates/engine/src/cmd/analysis_cmds.rs | 65 ++++- crates/engine/src/cmd/analysis_cmds/tests.rs | 7 +- .../cmd/analysis_straighten_enabled_tests.rs | 259 ++++++++++++++++++ .../src/cmd/transform_cmds/multi_tests.rs | 20 +- crates/ui-egui/src/tools/analysis_ui.rs | 21 +- crates/ui-egui/src/tools/analysis_ui/tests.rs | 117 ++++++++ 7 files changed, 486 insertions(+), 22 deletions(-) create mode 100644 crates/engine/src/cmd/analysis_straighten_enabled_tests.rs diff --git a/crates/engine/src/cmd/actions_cmds/tests.rs b/crates/engine/src/cmd/actions_cmds/tests.rs index 6a18a871f1..ed5da88e7d 100644 --- a/crates/engine/src/cmd/actions_cmds/tests.rs +++ b/crates/engine/src/cmd/actions_cmds/tests.rs @@ -334,11 +334,11 @@ fn record_drops_the_steps_of_a_command_that_failed_and_was_taken_back() { assert_eq!(s.execute("actions.stop", json!({})).unwrap()["steps"], 0, "no Duplicate step for a copy that was taken back"); // Straighten rolls back when its crop is refused after the rotation ran. - ruled_background(&mut s, 8); + ruled_layer(&mut s, 8); s.execute("image.analysis.rulerTool", json!({"start": [10, 30], "end": [70, 25]})).unwrap(); s.authorize = Some(|id, _| if id == "image.crop" { Err(EngineError::Other("refused".into())) } else { Ok(()) }); s.execute("actions.record", json!({"name": "Refused"})).unwrap(); - assert!(s.execute("image.analysis.straightenLayer", json!({})).is_err()); + assert!(s.execute("image.analysis.straightenLayer", json!({"crop": true})).is_err()); assert_eq!(s.active().unwrap().doc.size.width, 80, "the rotation was taken back"); assert_eq!(s.execute("actions.stop", json!({})).unwrap()["steps"], 0, "no rotation step for a rotation that was taken back"); } @@ -500,23 +500,32 @@ fn ruled_background(s: &mut Session, depth: u32) { .unwrap(); } +/// The same, with a pixel layer on top: since #12033 the Background alone greys Straighten Layer +/// out, so the tests that run the command target a layer and pass `crop` explicitly. +fn ruled_layer(s: &mut Session, depth: u32) { + ruled_background(s, depth); + s.execute("layer.new.layer", json!({"name": "Paint"})).unwrap(); +} + #[test] fn record_command_that_runs_commands_replays_it_once() { // Straighten Layer runs Image Rotation and Crop itself: only its own call is recorded, // so playback rotates once, not three times (rotate, crop, then straighten again). for depth in [8, 16] { let mut s = Session::new(); - ruled_background(&mut s, depth); + ruled_layer(&mut s, depth); s.execute("actions.record", json!({"name": "Level"})).unwrap(); s.execute("image.analysis.rulerTool", json!({"start": [10, 30], "end": [70, 25]})).unwrap(); - s.execute("image.analysis.straightenLayer", json!({})).unwrap(); + // #12033: the crop is asked for, as automation does; the Background it used to default to + // now greys the command out. + s.execute("image.analysis.straightenLayer", json!({"crop": true})).unwrap(); s.execute("actions.stop", json!({})).unwrap(); let got = s.execute("actions.get", json!({"action": "Level"})).unwrap(); let ids: Vec<&str> = got["steps"].as_array().unwrap().iter().map(|st| st[0].as_str().unwrap()).collect(); assert_eq!(ids, ["image.analysis.rulerTool", "image.analysis.straightenLayer"], "depth {depth}"); let direct = s.active().unwrap().doc.clone(); - ruled_background(&mut s, depth); + ruled_layer(&mut s, depth); let before = s.active().unwrap().history.past_len(); let played = s.execute("actions.play", json!({"action": "Level"})).unwrap(); assert!(played.get("failed").is_none(), "depth {depth}: {played}"); diff --git a/crates/engine/src/cmd/analysis_cmds.rs b/crates/engine/src/cmd/analysis_cmds.rs index d9d7d1510c..76744c37fc 100644 --- a/crates/engine/src/cmd/analysis_cmds.rs +++ b/crates/engine/src/cmd/analysis_cmds.rs @@ -13,6 +13,11 @@ //! values the luminosity (0.30 R + 0.59 G + 0.11 B) of the composite in the document's value //! range (0–255 at 8 bits, 0–32768 at 16 bits, 0–1 at 32 bits), integrated density area × mean. //! Lengths and areas are scaled by the measurement scale. +//! +//! Straighten Layer is offered where the reference application offers it: the ruler line is needed, +//! and the target must be a single layer that is not the Background, not a group and not locked in +//! place ([`straighten_disabled`], #12033). A hidden target layer keeps the button and is refused +//! with [`STRAIGHTEN_HIDDEN_ERROR`] before anything changes. use std::sync::Arc; @@ -138,6 +143,53 @@ fn has_ruler(s: &Session) -> std::result::Result<(), String> { s.active().and_then(|d| d.doc.measurement.ruler).map(|_| ()).ok_or_else(|| "no ruler line".into()) } +/// The reference's alert for Straighten Layer on a hidden target layer (#12033), measured: one +/// alert window with an OK button, and the document and its History stay as they were. The button +/// is *enabled* for a hidden layer, so this is the command's refusal and not a greyed state; the +/// options bar shows the same text through `tl!` (English is the catalog source). +pub const STRAIGHTEN_HIDDEN_ERROR: &str = "Could not straighten the layer because the target layer is hidden."; + +/// The enabled check of `image.analysis.straightenLayer`: a ruler line, and a target the reference +/// straightens ([`straighten_disabled`], #12033). +fn has_straightenable_layer(s: &Session) -> std::result::Result<(), String> { + has_ruler(s)?; + straighten_disabled(s).map_or(Ok(()), |why| Err(why.into())) +} + +/// Why Straighten Layer is greyed out for the current target, `None` when its button is live +/// (#12033). Measured with a ruler line drawn: the Background, two or more selected layers, a +/// group, a layer with Lock All and a layer with Lock Position all grey it out. +/// +/// Deliberately not here: a hidden target layer keeps the button enabled and is refused by +/// [`straighten`] with [`STRAIGHTEN_HIDDEN_ERROR`], and the states the ticket marks *not measured* +/// (a layer with a mask, a shape, vector or adjustment layer, artboards, and the lock kinds other +/// than Lock All and Lock Position) keep today's behaviour. A document with no active layer keeps +/// turning the whole canvas as it does now. +fn straighten_disabled(s: &Session) -> Option<&'static str> { + let st = s.active()?; + if st.selected_layers().len() > 1 { + return Some("several layers selected"); + } + // The active layer is the one the Layers panel targets; with none, the command turns the + // canvas, which the reference was not measured for. + let id = st.active_layer?; + if st.doc.is_background(id) { + return Some("the Background"); + } + if st.doc.layer(id).is_some_and(|l| l.is_group()) { + return Some("a group"); + } + // Locks inherited from a group count, as they do for the Move tool. + let locks = st.doc.effective_locks(id); + if locks.all { + return Some("the layer is locked"); + } + if locks.position { + return Some("the layer's position is locked"); + } + None +} + fn has_log(s: &Session) -> std::result::Result<(), String> { if s.analysis.log.is_empty() { Err("the Measurement Log is empty".into()) } else { Ok(()) } } @@ -718,6 +770,12 @@ pub fn straighten_angle(r: &Ruler) -> f64 { fn straighten(s: &mut Session, p: &Value) -> Result { let d = doc(s)?; let r = d.measurement.ruler.ok_or_else(|| EngineError::Other("no ruler line".into()))?; + // A hidden target layer keeps its button and gets this alert instead of a rotation (#12033). + // Asked before anything is read for the edit, so the document, its History and the ruler line + // stay exactly as they were; a layer inside a hidden group is hidden too (#571). + if s.active().and_then(|st| st.active_layer).is_some_and(|id| d.path_of(id).is_some_and(|path| !d.shown_at(&path))) { + return Err(EngineError::Other(STRAIGHTEN_HIDDEN_ERROR.into())); + } let rot = straighten_angle(&r); if rot.abs() < 1e-9 { return Ok(json!({"angle": 0.0})); @@ -1297,8 +1355,8 @@ pub fn specs() -> Vec { "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, + r##"{"crop":bool=(active layer is the Background)} (rotates so the ruler line is level; crop = rotate the canvas and crop to the image; off for the Background, two or more selected layers, a group, Lock All and Lock Position, and a hidden target layer answers "Could not straighten the layer because the target layer is hidden." with nothing changed (#12033))"##, + has_straightenable_layer, straighten, true ), @@ -1332,5 +1390,8 @@ pub fn specs() -> Vec { ] } +#[cfg(test)] +#[path = "analysis_straighten_enabled_tests.rs"] +mod analysis_straighten_enabled_tests; #[cfg(test)] mod tests; diff --git a/crates/engine/src/cmd/analysis_cmds/tests.rs b/crates/engine/src/cmd/analysis_cmds/tests.rs index dc76470bc9..6978b4d059 100644 --- a/crates/engine/src/cmd/analysis_cmds/tests.rs +++ b/crates/engine/src/cmd/analysis_cmds/tests.rs @@ -231,10 +231,13 @@ fn straighten_layer_and_image() { assert_eq!(s.active().unwrap().history.past_len(), before + 1, "one history step"); assert_eq!(d(&s).size.width, 80); assert!(d(&s).measurement.ruler.is_none()); - // The Background: rotate the canvas and crop to the image. + // The Background used to rotate the canvas and crop it to the image; #12033 greys that state + // out, so the same crop now happens on a layer and only when it is asked for. let mut s = session(80, 60, 16); s.execute("image.analysis.rulerTool", json!({"start": [0, 0], "end": [40, 40]})).unwrap(); - let r = s.execute("image.analysis.straightenLayer", json!({})).unwrap(); + assert!(!s.is_enabled("image.analysis.straightenLayer"), "the Background is greyed out (#12033)"); + s.execute("layer.new.layer", json!({})).unwrap(); + let r = s.execute("image.analysis.straightenLayer", json!({"crop": true})).unwrap(); assert_eq!(r["cropped"], true); assert_eq!(r["angle"].as_f64().unwrap().abs(), 45.0); let nd = d(&s); diff --git a/crates/engine/src/cmd/analysis_straighten_enabled_tests.rs b/crates/engine/src/cmd/analysis_straighten_enabled_tests.rs new file mode 100644 index 0000000000..9e27c2ad8d --- /dev/null +++ b/crates/engine/src/cmd/analysis_straighten_enabled_tests.rs @@ -0,0 +1,259 @@ +//! Straighten Layer's enabled states and its hidden-layer refusal, measured against the reference +//! application (#12033). +//! +//! With a ruler line drawn the reference greys the button out for the Background, two or more +//! selected layers, a group, a layer with Lock All and a layer with Lock Position. A hidden target +//! layer keeps the button enabled: the click answers an alert ("Could not straighten the layer +//! because the target layer is hidden.") and nothing changes. Pixel, text and smart object layers +//! stay enabled and are rotated. +//! +//! A layer with a mask, a shape or vector layer, an adjustment layer, artboards and the other lock +//! kinds are *not measured* there: they keep today's behaviour, so the tests below only pin that +//! the new check does not reach them. + +use photocraft_doc::LayerId; + +use super::*; + +const CMD: &str = "image.analysis.straightenLayer"; + +/// A white document with a Background, as File > New makes one. +fn session(w: u32, h: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": w, "height": h, "background": "white"})).unwrap(); + s +} + +/// The same document in CMYK at 16 bits, which the reference also straightens. +fn cmyk_16(w: u32, h: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": w, "height": h, "mode": "cmyk", "depth": 16, "background": "white"})).unwrap(); + s +} + +/// A ruler line to straighten by: without one the command is off for every target. +fn rule(s: &mut Session) { + s.execute("image.analysis.rulerTool", json!({"start": [10, 30], "end": [70, 25]})).unwrap(); +} + +/// A block of opaque pixels on the layer: an empty layer has nothing to transform, which would +/// hide the state under test behind "nothing to transform". The colour has the layer's own channel +/// count, so it works for RGB, Grayscale and CMYK at 8 and 16 bits alike. +fn paint(s: &mut Session, l: LayerId) { + s.edit("paint", |doc, _| { + let surf = doc.layer_mut(l).unwrap().surface_mut().unwrap(); + let mut px = vec![0.0; surf.channels()]; + if let Some(first) = px.first_mut() { + *first = 1.0; + } + if let Some(last) = px.last_mut() { + *last = 1.0; + } + surf.fill_rect(Rect::new(10, 10, 50, 20), &px); + Ok(()) + }) + .unwrap(); +} + +/// A new pixel layer with something on it, which `layer.new.layer` makes the active one. +fn pixel_layer(s: &mut Session, name: &str) -> LayerId { + let l = LayerId(s.execute("layer.new.layer", json!({"name": name})).unwrap()["layer"].as_u64().unwrap()); + paint(s, l); + l +} + +/// A type layer over the canvas. +fn text_layer(s: &mut Session) -> LayerId { + LayerId(s.execute("type.create", json!({"x": 40, "y": 50, "text": "Hi", "size": 20})).unwrap()["layer"].as_u64().unwrap()) +} + +/// A pixel layer turned into a smart object. +fn smart_layer(s: &mut Session) -> LayerId { + let l = pixel_layer(s, "Smart"); + s.execute("layer.smartObjects.convertToSmartObject", json!({"layer": l.0})).unwrap(); + l +} + +fn doc_of(s: &Session) -> Arc { + s.active().unwrap().doc.clone() +} + +fn steps(s: &Session) -> usize { + s.active().unwrap().history.past_len() +} + +/// A state the reference greys the button out for: the engine says the command is off, so the menu +/// item and the options-bar button follow, and a call answered anyway (the CLI, the control channel, +/// an action step) is refused as a disabled command rather than run. Nothing is touched: the +/// document is the same `Arc`, no History step is added and the ruler line stays. +fn assert_greyed(s: &mut Session, what: &str) { + let (before, history) = (doc_of(s), steps(s)); + assert!(!s.is_enabled(CMD), "{what}: the button is greyed out"); + let e = s.execute(CMD, json!({})).unwrap_err(); + let disabled = matches!(&e, EngineError::Disabled(id, _) if id == CMD); + assert!(disabled, "{what}: refused as a disabled command, got {e}"); + assert!(Arc::ptr_eq(&before, &doc_of(s)), "{what}: the document was not touched"); + assert_eq!(steps(s), history, "{what}: no History step"); + assert!(doc_of(s).measurement.ruler.is_some(), "{what}: the ruler line is still there"); +} + +/// The angle the ruler line of [`rule`] straightens by. +const ROT: f64 = 4.7636; + +#[test] +fn the_background_greys_straighten_layer_out() { + // Today it rotates the canvas and crops it (800 x 600 becomes 500 x 375); the reference has no + // button for it at all. + let mut s = session(100, 80); + assert!(doc_of(&s).is_background(s.active().unwrap().active_layer.unwrap()), "the Background is the target"); + rule(&mut s); + assert_greyed(&mut s, "the Background"); +} + +#[test] +fn two_selected_layers_grey_straighten_layer_out() { + let mut s = session(100, 80); + let a = pixel_layer(&mut s, "One"); + let b = pixel_layer(&mut s, "Two"); + s.execute("layer.select", json!({"layer": a.0})).unwrap(); + s.execute("layer.select", json!({"layer": b.0, "mode": "add"})).unwrap(); + assert_eq!(s.active().unwrap().selected_layers().len(), 2, "both selected"); + rule(&mut s); + assert_greyed(&mut s, "two selected layers"); + // Back to one layer, the button is live again. + s.execute("layer.select", json!({"layer": b.0})).unwrap(); + assert!(s.is_enabled(CMD), "one selected layer"); +} + +#[test] +fn a_group_greys_straighten_layer_out() { + let mut s = session(100, 80); + pixel_layer(&mut s, "Inner"); + s.execute("layer.groupLayers", json!({})).unwrap(); + let g = s.active().unwrap().active_layer.unwrap(); + assert!(doc_of(&s).layer(g).unwrap().is_group(), "the new group is the target"); + rule(&mut s); + assert_greyed(&mut s, "a group"); + // A layer inside the group is a layer, so the button is on for it. + let inner = doc_of(&s).layer(g).unwrap().children().unwrap()[0].id; + s.execute("layer.select", json!({"layer": inner.0})).unwrap(); + assert!(s.is_enabled(CMD), "a layer in a group"); +} + +#[test] +fn lock_all_and_lock_position_grey_straighten_layer_out() { + // Measured: both grey the button. Today it is enabled and the command fails with + // `layer "Colors" is locked`, which is what a greyed button cannot do any more. + for key in ["all", "position"] { + let mut s = session(100, 80); + let l = pixel_layer(&mut s, "Colors"); + s.execute("layer.setProps", json!({"layer": l.0, "locks": {key: true}})).unwrap(); + rule(&mut s); + assert_greyed(&mut s, &format!("Lock {}", if key == "all" { "All" } else { "Position" })); + } +} + +#[test] +fn the_other_lock_kinds_are_not_reached_by_the_new_check() { + // Not measured in the reference, so they keep today's behaviour: the button stays on and the + // command answers as it does now. + for key in ["transparency", "pixels", "artboard"] { + let mut s = session(100, 80); + let l = pixel_layer(&mut s, "Colors"); + s.execute("layer.setProps", json!({"layer": l.0, "locks": {key: true}})).unwrap(); + rule(&mut s); + assert!(s.is_enabled(CMD), "Lock {key}: not measured, still enabled"); + } +} + +#[test] +fn a_hidden_layer_stays_enabled_and_is_refused_with_the_alert() { + // The one measured refusal: the button is on, the click shows the alert and nothing changes. + let mut s = session(100, 80); + let l = pixel_layer(&mut s, "Gone"); + s.execute("layer.setProps", json!({"layer": l.0, "visible": false})).unwrap(); + rule(&mut s); + let (before, history) = (doc_of(&s), steps(&s)); + assert!(s.is_enabled(CMD), "a hidden layer keeps the button on"); + let e = s.execute(CMD, json!({})).unwrap_err(); + assert_eq!(e.to_string(), STRAIGHTEN_HIDDEN_ERROR, "the reference's alert text"); + assert!(matches!(e, EngineError::Other(_)), "an alert, not a greyed state: {e}"); + assert!(Arc::ptr_eq(&before, &doc_of(&s)), "nothing changed"); + assert_eq!(steps(&s), history, "no History step"); + assert!(doc_of(&s).measurement.ruler.is_some(), "a straighten that never ran leaves the ruler line"); +} + +#[test] +fn a_layer_hidden_with_its_group_is_refused_the_same_way() { + // A layer inside a hidden group is hidden, as for the painting tools (#571). + let mut s = session(100, 80); + let l = pixel_layer(&mut s, "Inner"); + s.execute("layer.groupLayers", json!({})).unwrap(); + let g = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.setProps", json!({"layer": g.0, "visible": false})).unwrap(); + s.execute("layer.select", json!({"layer": l.0})).unwrap(); + rule(&mut s); + let (before, history) = (doc_of(&s), steps(&s)); + assert!(s.is_enabled(CMD), "the target is a layer, not the group"); + assert_eq!(s.execute(CMD, json!({})).unwrap_err().to_string(), STRAIGHTEN_HIDDEN_ERROR); + assert!(Arc::ptr_eq(&before, &doc_of(&s))); + assert_eq!(steps(&s), history); +} + +#[test] +fn without_a_ruler_line_the_button_stays_greyed_for_every_layer() { + let mut s = session(100, 80); + pixel_layer(&mut s, "Paint"); + assert!(!s.is_enabled(CMD), "no ruler line"); + s.execute("layer.setProps", json!({"layer": s.active().unwrap().active_layer.unwrap().0, "visible": false})).unwrap(); + assert!(!s.is_enabled(CMD), "a hidden layer without a ruler line stays greyed, not enabled"); +} + +#[test] +fn a_single_visible_layer_still_rotates_without_cropping() { + // The pin for the state that stays: the layer turns about the canvas centre, the canvas does not. + let mut s = session(100, 80); + pixel_layer(&mut s, "Paint"); + rule(&mut s); + let (history, size) = (steps(&s), (doc_of(&s).size.width, doc_of(&s).size.height)); + assert!(s.is_enabled(CMD), "a layer is what the button is for"); + let r = s.execute(CMD, json!({})).unwrap(); + assert_eq!(r["cropped"], false, "{r}"); + assert!((r["angle"].as_f64().unwrap() - ROT).abs() < 1e-3, "{r}"); + assert_eq!((doc_of(&s).size.width, doc_of(&s).size.height), size, "the canvas keeps its size"); + assert_eq!(steps(&s), history + 1, "one History step"); + assert!(doc_of(&s).measurement.ruler.is_none(), "the ruler line is cleared"); + assert!(s.undo()); + assert_eq!((doc_of(&s).size.width, doc_of(&s).size.height), size, "and one Undo takes it back"); +} + +#[test] +fn pixel_text_and_smart_object_layers_stay_enabled_and_rotate() { + // Measured, also in a CMYK 16-bit document. + type Maker = (&'static str, fn(&mut Session) -> LayerId); + let make: [Maker; 3] = [("pixel", |s| pixel_layer(s, "Paint")), ("type", text_layer), ("smart object", smart_layer)]; + for (what, make_layer) in make { + for new in [session as fn(u32, u32) -> Session, cmyk_16] { + let mut s = new(100, 80); + make_layer(&mut s); + rule(&mut s); + assert!(s.is_enabled(CMD), "{what}: enabled"); + let r = s.execute(CMD, json!({})); + let r = r.unwrap_or_else(|e| panic!("{what}: {e}")); + assert_eq!(r["cropped"], false, "{what}: rotated, not cropped"); + assert!((r["angle"].as_f64().unwrap() - ROT).abs() < 1e-3, "{what}: {r}"); + } + } +} + +#[test] +fn an_explicit_crop_still_crops_for_automation() { + // The `crop` param stays available to automation where the command is enabled. + let mut s = session(100, 80); + pixel_layer(&mut s, "Paint"); + rule(&mut s); + let r = s.execute(CMD, json!({"crop": true})).unwrap(); + assert_eq!(r["cropped"], true, "{r}"); + let d = doc_of(&s); + assert!(d.size.width < 100 && d.size.height < 80, "the canvas is rotated and cropped to {:?}", [d.size.width, d.size.height]); +} diff --git a/crates/engine/src/cmd/transform_cmds/multi_tests.rs b/crates/engine/src/cmd/transform_cmds/multi_tests.rs index a50e4239fd..ec0c817b12 100644 --- a/crates/engine/src/cmd/transform_cmds/multi_tests.rs +++ b/crates/engine/src/cmd/transform_cmds/multi_tests.rs @@ -223,24 +223,24 @@ fn bad_layers_params_are_errors_not_panics() { } #[test] -fn straighten_layer_rotates_every_selected_layer() { - // Photoshop's Straighten Layer turns all the selected layers, not just the active one; it - // goes through `edit.transform` without a layer, so it takes the selection like Free Transform. +fn straighten_layer_wants_one_selected_layer() { + // #12033: the reference greys Straighten Layer out while two layers are selected, so it no + // longer turns the whole selection the way Free Transform does + // (`free_transform_moves_every_selected_layer_in_one_step` covers that). let (mut s, paint, text) = two_layers(); let (p0, m0) = (bounds(&s, paint), text_matrix(&s, text)); s.execute("image.analysis.rulerTool", json!({"start": [10, 30], "end": [70, 25]})).unwrap(); let steps = s.active().unwrap().history.past_len(); - let r = s.execute("image.analysis.straightenLayer", json!({})).unwrap(); - assert_eq!(r["cropped"], false); - assert!(bounds(&s, paint) != p0, "the selected pixel layer turned too"); - assert!(text_matrix(&s, text)[1].abs() > 0.05 && m0[1].abs() < 1e-9, "the active type layer turned"); - assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "one history step"); - assert!(s.undo()); - assert_eq!((bounds(&s, paint), text_matrix(&s, text)), (p0, m0), "and one Undo takes both back"); + assert_eq!(s.active().unwrap().selected_layers().len(), 2, "both selected, the type layer active"); + assert!(!s.is_enabled("image.analysis.straightenLayer"), "two selected layers"); + assert!(s.execute("image.analysis.straightenLayer", json!({})).is_err()); + assert_eq!((bounds(&s, paint), text_matrix(&s, text)), (p0, m0), "nothing turned"); + assert_eq!(s.active().unwrap().history.past_len(), steps, "and no history step"); // With one layer selected, only that layer turns. s.execute("layer.select", json!({"layer": paint.0})).unwrap(); s.execute("image.analysis.rulerTool", json!({"start": [10, 30], "end": [70, 25]})).unwrap(); + assert!(s.is_enabled("image.analysis.straightenLayer"), "a single layer"); s.execute("image.analysis.straightenLayer", json!({})).unwrap(); assert!(bounds(&s, paint) != p0); assert_eq!(text_matrix(&s, text), m0, "an unselected layer stays put"); diff --git a/crates/ui-egui/src/tools/analysis_ui.rs b/crates/ui-egui/src/tools/analysis_ui.rs index ed113909a6..39c8dc1e51 100644 --- a/crates/ui-egui/src/tools/analysis_ui.rs +++ b/crates/ui-egui/src/tools/analysis_ui.rs @@ -7,6 +7,10 @@ //! part of the serialisable UI state so the control channel can read and drive it). The Color //! Sampler points themselves live in the engine (`DocState.color_samplers`) so every front end //! shares them; this module only maps pointer input and draws the numbered markers. +//! +//! The Ruler options bar's Straighten Layer button is on when the engine's check says so, and a +//! hidden target layer answers its click with the reference's alert rather than the status line +//! (#12033). use egui::{Align2, Color32, CornerRadius, FontId, Pos2, RichText, Stroke, pos2, vec2}; use photocraft_doc::{Document, Ruler}; @@ -15,7 +19,7 @@ use serde_json::{Map, Value, json}; use crate::PhotocraftApp; use crate::canvas::{ToolEvent, ViewXform}; -use crate::state::Tool; +use crate::state::{DialogKind, Tool}; use crate::theme::Tokens; /// What a pointer drag is doing (not serialised). @@ -526,8 +530,19 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo crate::widgets::vline(ui, 22.0); crate::widgets::checkbox(ui, &mut app.ui.analysis.use_measurement_scale, tl!("Use Measurement Scale")); let has = doc.measurement.ruler.is_some(); - if ui.add_enabled_ui(has, |ui| crate::widgets::secondary_button(ui, tl!("Straighten Layer"), 0.0)).inner.clicked() { - let _ = app.run("image.analysis.straightenLayer", json!({})); + // The engine decides this button's state, so it goes grey for exactly the targets + // Straighten Layer is off for: the Background, several selected layers, a group and a + // layer locked in place (#12033). + let straighten = app.session.is_enabled("image.analysis.straightenLayer"); + if ui.add_enabled_ui(straighten, |ui| crate::widgets::secondary_button(ui, tl!("Straighten Layer"), 0.0)).inner.clicked() { + // A hidden target layer keeps the button on and answers the click with the + // reference's alert (its message and an OK button), changing nothing (#12033). + if let Err(why) = app.run("image.analysis.straightenLayer", json!({})) + && why == photocraft_engine::analysis_cmds::STRAIGHTEN_HIDDEN_ERROR + { + let message = photocraft_engine::analysis_cmds::STRAIGHTEN_HIDDEN_ERROR; + app.ui.open_dialog(DialogKind::Error, json!({"message": tl!(message)}).as_object().cloned().unwrap_or_default()); + } } if ui.add_enabled_ui(has, |ui| crate::widgets::secondary_button(ui, tl!("Clear"), 0.0)).inner.clicked() { let _ = app.run("image.analysis.rulerTool", json!({"clear": true})); diff --git a/crates/ui-egui/src/tools/analysis_ui/tests.rs b/crates/ui-egui/src/tools/analysis_ui/tests.rs index 3ed65a3ffd..502d83851a 100644 --- a/crates/ui-egui/src/tools/analysis_ui/tests.rs +++ b/crates/ui-egui/src/tools/analysis_ui/tests.rs @@ -296,3 +296,120 @@ fn count_size_fields_send_whole_numbers_the_engine_applies() { let g = &app.session.active().unwrap().doc.measurement.count_groups[0]; assert_eq!((g.marker_size, g.label_size), (8, 20)); } + +// ---------------------------------------------------------------- Straighten Layer button (#12033) + +/// The Ruler options bar, drawn wide enough for its buttons to show. +fn ruler_bar(mut app: PhotocraftApp) -> egui_kittest::Harness<'static, PhotocraftApp> { + use egui::vec2; + app.ui.tool = Tool::Ruler; + let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 60.0)).build_ui_state( + |ui: &mut egui::Ui, app: &mut PhotocraftApp| { + if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + crate::panels::options_bar(app, ui); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Pro); + h.run_steps(4); + h +} + +/// A ruler line to straighten by, without one the button is off for every target. +fn rule(app: &mut PhotocraftApp) { + app.run("image.analysis.rulerTool", json!({"start": [10, 30], "end": [150, 25]})).unwrap(); +} + +/// The message of every open error dialog, in the order they were opened. +fn alerts(app: &PhotocraftApp) -> Vec<&str> { + app.ui.dialogs.iter().filter(|d| d.kind == DialogKind::Error).filter_map(|d| d.fields.get("message").and_then(|m| m.as_str())).collect() +} + +/// Each case opens one of the targets #12033 measured, on top of the plain document `app()` makes. +fn add_layer(app: &mut PhotocraftApp, name: &str) -> u64 { + app.run("layer.new.layer", json!({"name": name})).unwrap()["layer"].as_u64().unwrap() +} + +fn on_a_layer(app: &mut PhotocraftApp) { + add_layer(app, "Paint"); +} + +fn on_the_background(_: &mut PhotocraftApp) {} + +fn on_two_layers(app: &mut PhotocraftApp) { + let a = add_layer(app, "One"); + let b = add_layer(app, "Two"); + app.run("layer.select", json!({"layer": a})).unwrap(); + app.run("layer.select", json!({"layer": b, "mode": "add"})).unwrap(); +} + +fn on_a_group(app: &mut PhotocraftApp) { + add_layer(app, "Inner"); + app.run("layer.groupLayers", json!({})).unwrap(); +} + +fn locked(app: &mut PhotocraftApp, key: &str) { + let l = add_layer(app, "Colors"); + app.run("layer.setProps", json!({"layer": l, "locks": {key: true}})).unwrap(); +} + +fn on_lock_all(app: &mut PhotocraftApp) { + locked(app, "all"); +} + +fn on_lock_position(app: &mut PhotocraftApp) { + locked(app, "position"); +} + +/// #12033: the options bar asks the engine, so the button is greyed out for exactly the targets +/// `image.analysis.straightenLayer` is off for - the Background, two or more selected layers, a +/// group, Lock All and Lock Position - and live for a layer. +#[test] +fn the_straighten_layer_button_greys_out_where_the_command_is_off() { + use egui_kittest::kittest::{NodeT, Queryable}; + type Case = (&'static str, fn(&mut PhotocraftApp), bool); + let cases: [Case; 6] = [ + ("a pixel layer", on_a_layer, true), + ("the Background", on_the_background, false), + ("two selected layers", on_two_layers, false), + ("a group", on_a_group, false), + ("Lock All", on_lock_all, false), + ("Lock Position", on_lock_position, false), + ]; + for (what, prepare, live) in cases { + let (mut app, _) = app(); + prepare(&mut app); + rule(&mut app); + let h = ruler_bar(app); + assert_eq!(h.state().session.is_enabled("image.analysis.straightenLayer"), live, "{what}: the command's own check"); + let button = h.get_by_label("Straighten Layer").accesskit_node(); + assert_eq!(!button.is_disabled(), live, "{what}: the options-bar button follows the command"); + // The other Ruler button is unaffected: a ruler line is there to clear in every state. + assert!(!h.get_by_label("Clear").accesskit_node().is_disabled(), "{what}: Clear stays on"); + } +} + +/// #12033: a hidden target layer is the one state that keeps its button. The click answers with the +/// reference's alert - its measured message and an OK button - instead of the status line, and the +/// document, its History and the ruler line stay as they were. +#[test] +fn a_hidden_layer_keeps_the_button_and_the_click_shows_the_alert() { + use egui_kittest::kittest::{NodeT, Queryable}; + let (mut app, _) = app(); + let l = app.run("layer.new.layer", json!({"name": "Gone"})).unwrap()["layer"].as_u64().unwrap(); + app.run("layer.setProps", json!({"layer": l, "visible": false})).unwrap(); + rule(&mut app); + let mut h = ruler_bar(app); + assert!(!h.get_by_label("Straighten Layer").accesskit_node().is_disabled(), "a hidden layer keeps the button on"); + let (before, steps) = (h.state().session.active().unwrap().doc.clone(), h.state().session.active().unwrap().history.past_len()); + h.get_by_label("Straighten Layer").click(); + h.run_steps(2); + let st = h.state(); + assert_eq!(alerts(st), [photocraft_engine::analysis_cmds::STRAIGHTEN_HIDDEN_ERROR], "one alert with the reference's text"); + let now = st.session.active().unwrap(); + assert!(std::sync::Arc::ptr_eq(&before, &now.doc), "the document was not touched"); + assert_eq!(now.history.past_len(), steps, "no History step"); + assert!(now.doc.measurement.ruler.is_some(), "the ruler line is still there"); +} From ca92d936c00d4724916f4e57ae8937f78aa691d5 Mon Sep 17 00:00:00 2001 From: Triwarr <170976175+Triwarr@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:41:22 +0100 Subject: [PATCH 111/219] PDF: import raster pages through craft-pdf instead of PDFium - craft-pdf (the shared Rust/Hayro reader in craft-libs, rev c5a1cbf) renders each page; no shared library is loaded or shipped, and every desktop platform works. - Raster pages only: editable text/vector import and the pdf_text/pdf_vectors options are removed (they can return later through craft-pdf's interpret device). Page geometry, artboards, page range, budgets, cancellation and warnings are kept; the effective DPI now respects craft-pdf's 8192 px side and 64M px page limits. - Drops pdfium-render, the PDFium download in CI, the build-script copy and the packaging/licence staging; records the Hayro/fonts/CMaps/ICC assets in ATTRIBUTION.md and NOTICE. - \.ai\ files starting with %PDF- now take the PDF importer, matching the comment. - Attribution, architecture and file-format-parity docs updated. --- .github/workflows/ci.yml | 18 - ATTRIBUTION.md | 13 + Cargo.lock | 385 +++++++++---- Cargo.toml | 6 + NOTICE | 11 + apps/photocraft/build.rs | 13 - crates/io/Cargo.toml | 7 +- crates/io/src/lib.rs | 52 +- crates/io/src/pdf_import.rs | 764 ++++++-------------------- crates/io/tests/it/pdf_import.rs | 393 ++++--------- docs/architecture.md | 2 +- docs/file-format-parity.md | 3 +- packaging/windows/install-policy.json | 3 +- packaging/windows/package.ps1 | 75 --- packaging/windows/photocraft.wxs | 7 - 15 files changed, 640 insertions(+), 1112 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4422f41a22..2c3597f5ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -251,24 +251,6 @@ jobs: with: tool: cargo-nextest@0.9.150 - # The PDF import integration test loads PDFium at run time. Provision the pinned build for - # the integration leg and make an unavailable library fail there; local runs still skip. - - name: PDFium for the PDF import test - if: matrix.part == 'integration' - shell: pwsh - run: | - $dir = Join-Path $env:RUNNER_TEMP 'pdfium' - New-Item -ItemType Directory -Force $dir | Out-Null - $tgz = Join-Path $env:RUNNER_TEMP 'pdfium-win-x64.tgz' - Invoke-WebRequest -Uri 'https://github.com/bblanchon/pdfium-binaries/releases/download/chromium/8138/pdfium-win-x64.tgz' -OutFile $tgz - $hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $tgz).Hash.ToLowerInvariant() - if ($hash -ne '8d48973e3eb472dda0f5f2435e595ac043f9f2219dd71883392f549445afa8a5') { - throw "PDFium archive checksum $hash does not match the pinned chromium/8138 build" - } - tar -xzf $tgz -C $dir - "PDFIUM_DIR=$dir\bin" >> $env:GITHUB_ENV - "PHOTOCRAFT_REQUIRE_PDFIUM=1" >> $env:GITHUB_ENV - # The unit leg runs `--lib --bins` and the integration leg `--test '*'` (every # integration-test target); the feature selection stays the same, so both legs share the # dependency cache. diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index f11c7f30e0..bac9633320 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -166,3 +166,16 @@ are adapted from Skyfuller1303’s MIT OR Apache-2.0 implementation in `897f20072762fdf0eb688adcdb8d8cda7b38ce66`. Don Omondi’s embedded AI/PDF/EPS extension and current-main integration build on that source. Clipboard and unrelated Shape Tools changes from that branch are not included. + +## Assets bundled by the PDF reader dependency (craft-pdf) + +PhotoCraft imports PDF pages through `craft-pdf`, pinned to the craft-libs revision +`c5a1cbfb5f9049f9270955caffb5bee5997aebf7`. The renderer vendors Hayro and the assets below; +they ship inside the dependency rather than being copied into this repository. PDFium is no +longer used or shipped. + +| Crate / file | Author | Source | Licence | Use | +| --- | --- | --- | --- | --- | +| `hayro-interpret 0.7.0 / assets/Foxit*.pfb` (13 standard-14 font files) | Foxit Software Inc. / PDFium Authors | | BSD-3-Clause | Renders non-embedded standard-14 PDF fonts | +| `hayro-interpret 0.7.0 / assets/CGATS001Compat-v2-micro.icc` | Clinton Ingram (saucecontrol/Compact-ICC-Profiles) | | CC0-1.0 | Default CMYK colour conversion | +| `hayro-cmap 0.1.0 / assets/cmaps.brotli` | Adobe | | BSD-3-Clause | Character-code mapping for CJK PDFs | diff --git a/Cargo.lock b/Cargo.lock index 049da1acef..5e2a1dfca1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22,7 +22,7 @@ dependencies = [ "accesskit", "accesskit_consumer", "atspi-common", - "phf", + "phf 0.14.0", "serde", "zvariant", ] @@ -826,6 +826,17 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + [[package]] name = "brotli-decompressor" version = "5.0.3" @@ -874,12 +885,6 @@ dependencies = [ "syn 3.0.6", ] -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - [[package]] name = "byteorder-lite" version = "0.1.0" @@ -1284,26 +1289,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "console_error_panic_hook" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" -dependencies = [ - "cfg-if", - "wasm-bindgen", -] - -[[package]] -name = "console_log" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86919cef3e37b9356ccf54d4421208c17ecfda01beae61393e7ffd72916c0ef1" -dependencies = [ - "log", - "web-sys", -] - [[package]] name = "const-oid" version = "0.9.6" @@ -1469,6 +1454,18 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "craft-pdf" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +dependencies = [ + "bytemuck", + "hayro", + "hayro-interpret", + "hayro-syntax", + "thiserror 2.0.21", +] + [[package]] name = "craft-print" version = "0.1.0" @@ -1498,7 +1495,7 @@ dependencies = [ "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "serde", - "skrifa", + "skrifa 0.44.0", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", @@ -2133,7 +2130,7 @@ dependencies = [ "ecolor", "emath", "epaint_default_fonts", - "font-types", + "font-types 0.12.5", "harfrust", "log", "nohash-hasher", @@ -2141,13 +2138,13 @@ dependencies = [ "profiling", "self_cell", "serde", - "skrifa", + "skrifa 0.44.0", "smallvec", "unicode-bidi", "unicode-general-category", "unicode-properties", "unicode-segmentation", - "vello_cpu", + "vello_cpu 0.2.0", ] [[package]] @@ -2403,6 +2400,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "font-types" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7" +dependencies = [ + "bytemuck", +] + [[package]] name = "font-types" version = "0.12.5" @@ -2449,7 +2455,7 @@ dependencies = [ "objc2-core-text", "objc2-foundation 0.3.2", "parlance", - "read-fonts", + "read-fonts 0.41.0", "roxmltree 0.21.1", "smallvec", "windows", @@ -2743,6 +2749,23 @@ dependencies = [ "xml-rs", ] +[[package]] +name = "glifo" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e" +dependencies = [ + "bytemuck", + "foldhash 0.2.0", + "hashbrown 0.17.1", + "log", + "peniko", + "png 0.18.1", + "skrifa 0.42.1", + "smallvec", + "vello_common 0.0.9", +] + [[package]] name = "glifo" version = "0.3.0" @@ -2754,9 +2777,9 @@ dependencies = [ "hashbrown 0.17.1", "log", "peniko", - "skrifa", + "skrifa 0.44.0", "smallvec", - "vello_common", + "vello_common 0.2.0", ] [[package]] @@ -2905,7 +2928,7 @@ checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9" dependencies = [ "bitflags 2.13.2", "bytemuck", - "read-fonts", + "read-fonts 0.41.0", "smallvec", ] @@ -2938,6 +2961,94 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hayro" +version = "0.7.1" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +dependencies = [ + "bytemuck", + "hayro-interpret", + "image", + "kurbo 0.13.1", + "log", + "pic-scale", + "vello_cpu 0.0.8", +] + +[[package]] +name = "hayro-ccitt" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a" + +[[package]] +name = "hayro-cmap" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a14afde3344be69225fec4f6f44f6a219a3a492f9d8301eb25ef608fe4b7014c" +dependencies = [ + "brotli", + "hayro-postscript", +] + +[[package]] +name = "hayro-interpret" +version = "0.7.0" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +dependencies = [ + "bitflags 2.13.2", + "hayro-cmap", + "hayro-syntax", + "kurbo 0.13.1", + "moxcms", + "phf 0.13.1", + "rustc-hash 2.1.3", + "siphasher", + "skrifa 0.47.0", + "smallvec", + "yoke", +] + +[[package]] +name = "hayro-jbig2" +version = "0.3.0" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +dependencies = [ + "fearless_simd", + "hayro-ccitt", +] + +[[package]] +name = "hayro-jpeg2000" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1" +dependencies = [ + "fearless_simd", +] + +[[package]] +name = "hayro-postscript" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc2d80da85f31c3fdc0366431f44cfd645a9da1e242ed1f4a61f213d9765660f" + +[[package]] +name = "hayro-syntax" +version = "0.7.2" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +dependencies = [ + "flate2", + "hayro-ccitt", + "hayro-jbig2", + "hayro-jpeg2000", + "memchr", + "rustc-hash 2.1.3", + "smallvec", + "stringprep", + "zune-jpeg 0.5.15", +] + [[package]] name = "heck" version = "0.5.0" @@ -5027,7 +5138,7 @@ dependencies = [ "linebender_resource_handle", "parlance", "parley_data", - "skrifa", + "skrifa 0.44.0", ] [[package]] @@ -5057,32 +5168,6 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" -[[package]] -name = "pdfium-render" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "296b1146652b486ea6eea0d316e85ab6837ff69e8d2b1568e6fa7bc38bd4c00e" -dependencies = [ - "bitflags 2.13.2", - "bytemuck", - "bytes", - "chrono", - "console_error_panic_hook", - "console_log", - "image", - "itertools 0.15.0", - "js-sys", - "libloading", - "log", - "maybe-owned", - "once_cell", - "utf16string", - "vecmath", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - [[package]] name = "pem" version = "3.0.6" @@ -5132,17 +5217,37 @@ dependencies = [ "indexmap", ] +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros 0.13.1", + "phf_shared 0.13.1", +] + [[package]] name = "phf" version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" dependencies = [ - "phf_macros", - "phf_shared", + "phf_macros 0.14.0", + "phf_shared 0.14.0", "serde", ] +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", +] + [[package]] name = "phf_generator" version = "0.14.0" @@ -5150,7 +5255,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" dependencies = [ "fastrand", - "phf_shared", + "phf_shared 0.14.0", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -5159,13 +5277,22 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.14.0", + "phf_shared 0.14.0", "proc-macro2", "quote", "syn 2.0.119", ] +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + [[package]] name = "phf_shared" version = "0.14.0" @@ -5486,12 +5613,12 @@ version = "0.20.1" dependencies = [ "blake3", "craft-docpath", + "craft-pdf", "crc32fast", "flate2", "imagesize", "lopdf", "miniz_oxide 0.9.1", - "pdfium-render", "photocraft-affinity", "photocraft-algo", "photocraft-cms", @@ -5638,7 +5765,7 @@ dependencies = [ "photocraft-psd", "photocraft-raster", "rayon", - "skrifa", + "skrifa 0.44.0", ] [[package]] @@ -5734,6 +5861,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "pic-scale" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb0ff645d35accaec252de081d55984a0d6c952cb1e5dc2b9afa7c78f07288" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "pico-args" version = "0.5.0" @@ -5802,12 +5939,6 @@ dependencies = [ "system-deps", ] -[[package]] -name = "piston-float" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad78bf43dcf80e8f950c92b84f938a0fc7590b7f6866fbcbeca781609c115590" - [[package]] name = "pkg-config" version = "0.3.34" @@ -6365,6 +6496,16 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "read-fonts" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81" +dependencies = [ + "bytemuck", + "font-types 0.11.3", +] + [[package]] name = "read-fonts" version = "0.41.0" @@ -6372,7 +6513,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" dependencies = [ "bytemuck", - "font-types", + "font-types 0.12.5", + "once_cell", +] + +[[package]] +name = "read-fonts" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5a0cf4bb6cbce4c29756270d189a5e400ac05e91192161d83a0bcb6e68492e8" +dependencies = [ + "bytemuck", + "font-types 0.12.5", "once_cell", ] @@ -7319,6 +7471,16 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" +[[package]] +name = "skrifa" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564" +dependencies = [ + "bytemuck", + "read-fonts 0.39.2", +] + [[package]] name = "skrifa" version = "0.44.0" @@ -7326,7 +7488,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" dependencies = [ "bytemuck", - "read-fonts", + "read-fonts 0.41.0", +] + +[[package]] +name = "skrifa" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "526e654b303ff4ad73a5a40b25de2b3ce3d039c5e3b6da5f89ae6e57004cb40f" +dependencies = [ + "bytemuck", + "read-fonts 0.44.0", ] [[package]] @@ -8290,15 +8462,6 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" -[[package]] -name = "utf16string" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b62a1e85e12d5d712bf47a85f426b73d303e2d00a90de5f3004df3596e9d216" -dependencies = [ - "byteorder", -] - [[package]] name = "utf8-zero" version = "0.8.1" @@ -8341,12 +8504,37 @@ dependencies = [ ] [[package]] -name = "vecmath" -version = "1.0.0" +name = "vello_common" +version = "0.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "956ae1e0d85bca567dee1dcf87fb1ca2e792792f66f87dced8381f99cd91156a" +checksum = "3361bff7f7d82c0c496b92048db83846691f0e844cc28dee92b1c824291b55ee" dependencies = [ - "piston-float", + "bytemuck", + "fearless_simd", + "guillotiere", + "hashbrown 0.17.1", + "log", + "peniko", + "png 0.18.1", + "smallvec", + "thiserror 2.0.21", +] + +[[package]] +name = "vello_common" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d672facaa2d697285a786cd9d44d614cd2ce54cdc022504bf339f8fff3b750" +dependencies = [ + "bytemuck", + "fearless_simd", + "guillotiere", + "hashbrown 0.17.1", + "log", + "peniko", + "png 0.18.1", + "smallvec", + "thiserror 2.0.21", ] [[package]] @@ -8364,6 +8552,19 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "vello_cpu" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d8ded630e8316bb94a55881256506d1f3b9947b5f66db8a7d32ca7ba02decd0" +dependencies = [ + "bytemuck", + "glifo 0.1.1", + "hashbrown 0.17.1", + "png 0.18.1", + "vello_common 0.0.8", +] + [[package]] name = "vello_cpu" version = "0.2.0" @@ -8371,9 +8572,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9" dependencies = [ "bytemuck", - "glifo", + "glifo 0.3.0", "hashbrown 0.17.1", - "vello_common", + "vello_common 0.2.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index e01bcf175a..f5fafa868d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ photocraft-ipp = { path = "crates/ipp" } # external craft-print = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } +craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["raster"] } craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } craft-jpeg = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } craft-pcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "b8f7d93b00dfb8f172938f3d2d796ae62dcfe123" } @@ -203,3 +204,8 @@ eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc79968157 egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" } +# craft-pdf's vendored Hayro trees: consumers patch the same craft-libs revision (crates/pdf README). +hayro = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } +hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } +hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } +hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } diff --git a/NOTICE b/NOTICE index 9ab6b0a1cf..826dea7490 100644 --- a/NOTICE +++ b/NOTICE @@ -70,3 +70,14 @@ Permission is hereby granted, free of charge, to any person obtaining a copy of The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Optional PDF reader (craft-pdf) + +PhotoCraft imports PDF pages through craft-pdf (https://github.com/storytold/craft-libs, +MIT OR Apache-2.0), pinned to revision c5a1cbfb5f9049f9270955caffb5bee5997aebf7. It vendors +Hayro 0.7.1, hayro-interpret 0.7.0, hayro-syntax 0.7.2, hayro-jbig2 0.3.0 and +hayro-cmap 0.1.0 (https://github.com/LaurenzV/hayro, MIT OR Apache-2.0). The reader bundles +the standard-14 Foxit fonts (BSD-3-Clause), Adobe CMaps (BSD-3-Clause) and a Compact ICC +profile by Clinton Ingram (CC0-1.0); per-file sources and hashes are in craft-libs +ATTRIBUTION.md. No PDF assets are copied into this repository, and PDFium is no longer used or +shipped. diff --git a/apps/photocraft/build.rs b/apps/photocraft/build.rs index e750904018..48abc9f1a5 100644 --- a/apps/photocraft/build.rs +++ b/apps/photocraft/build.rs @@ -11,19 +11,6 @@ fn main() { if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { return; } - // PDF import loads PDFium at runtime. When the build provides the shared library - // (`PDFIUM_DLL`, set by the release packaging step), copy it beside the executable so the - // installer's pdfium.dll and the running app agree. - println!("cargo:rerun-if-env-changed=PDFIUM_DLL"); - if let (Some(dll), Ok(out)) = (std::env::var_os("PDFIUM_DLL"), std::env::var("OUT_DIR")) { - let dll = std::path::PathBuf::from(dll); - if let Some(profile_dir) = std::path::Path::new(&out).ancestors().nth(3) { - let dest = profile_dir.join(dll.file_name().unwrap_or_default()); - if let Err(e) = std::fs::copy(&dll, &dest) { - println!("cargo:warning=could not copy PDFium to {}: {e}", dest.display()); - } - } - } // Printing to PDF (winprint's `PdfCreateRenderer`) imports Windows.Data.Pdf.dll, which // stripped Windows images (NTLite, some N/LTSC builds) lack. A load-time import stops the exe // from starting at all ("windows.data.pdf.dll was not found", #5884), so load it on first use; diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index dd5a232f32..36975e57eb 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -51,11 +51,8 @@ imagesize = "0.13" resvg = { version = "0.45", default-features = false, features = ["text", "system-fonts", "raster-images"] } # The SVG fallback font search keeps each font file's character map (already in the tree through usvg). ttf-parser = { version = "0.25", default-features = false } -# PDF import (pdf_import.rs): PDFium renders the pages; the direct surfaces build the document. -# Pinned exactly, like the WinTab wrappers in photocraft-tablet: PDFium is FFI-loaded at run time -# and the DLL release is pinned separately in packaging/windows/package.ps1, so a lockfile refresh -# must not move the wrapper under it. Bump deliberately and re-check both. -pdfium-render = "=0.9.5" +# PDF import (pdf_import.rs): craft-pdf rasterizes pages in pure Rust; no shared library ships. +craft-pdf = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] resvg = { version = "0.45", default-features = false, features = ["text", "raster-images"] } diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 54a4d1577e..7519449224 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -556,9 +556,9 @@ pub fn import_preflight(name: &str, bytes: &[u8]) -> ImportPreflight { if is_psd_input(name, bytes) { return ImportPreflight::Psd; } - // PDF (PDFium, native only). `.ai` files are PDFs but keep their existing path; a recognized - // raster signature wins over a misleading `.pdf` suffix. - if pdf_import::is_pdf(bytes) || (has_extension(name, "pdf") && !has_extension(name, "ai") && photocraft_codecs::detect(bytes).is_none()) { + // PDF (craft-pdf, pure Rust). `.ai` files that start with `%PDF-` are PDFs and take this + // importer too; a recognized raster signature wins over a misleading `.pdf` suffix. + if pdf_import::is_pdf(bytes) || (has_extension(name, "pdf") && photocraft_codecs::detect(bytes).is_none()) { return ImportPreflight::Unbounded; } if photocraft_codecs::detect(bytes) == Some(photocraft_codecs::Format::Tiff) { ImportPreflight::Tiff } else { ImportPreflight::Flat } @@ -612,10 +612,6 @@ pub struct ImportOptions { pub pdf_dpi: f32, /// The 1-based, inclusive page range to import; `None` imports every page (up to the cap). pub pdf_pages: Option<(u32, u32)>, - /// Import PDF text objects as editable text layers (default on). - pub pdf_text: bool, - /// Import PDF vector paths as editable shape layers (default on). - pub pdf_vectors: bool, /// Explicit one-shot authorization for imports that exceed the soft budget but fit within the hard budget. pub psd_high_memory_approved: bool, /// Optional estimate limit for high-memory approval. When set, approval only applies if @@ -632,8 +628,6 @@ impl Default for ImportOptions { flat_max_pixel_bytes: 0, pdf_dpi: 300.0, pdf_pages: None, - pdf_text: true, - pdf_vectors: true, psd_high_memory_approved: false, psd_approved_estimate: None, } @@ -999,9 +993,9 @@ fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, o if is_svg_input(name, bytes) { return svg::import_svg_with_group_depth(name, bytes, opts.max_group_depth); } - // PDF (PDFium, native only). `.ai` files are PDFs but keep their existing path; a recognized - // raster signature wins over a misleading `.pdf` suffix. - if pdf_import::is_pdf(bytes) || (has_extension(name, "pdf") && !has_extension(name, "ai") && photocraft_codecs::detect(bytes).is_none()) { + // PDF (craft-pdf, pure Rust). `.ai` files that start with `%PDF-` are PDFs and take this + // importer too; a recognized raster signature wins over a misleading `.pdf` suffix. + if pdf_import::is_pdf(bytes) || (has_extension(name, "pdf") && photocraft_codecs::detect(bytes).is_none()) { return pdf_import::import(name, bytes, opts, ctl); } match route(name, bytes) { @@ -1628,4 +1622,38 @@ mod tests { assert!(is_svg_input("drawing.svg", b"")); assert_eq!(crate::route("drawing.svg", pdf), crate::Route::Flat); } + + /// `.ai` files that start with `%PDF-` are PDFs: both preflight and dispatch send them to the + /// PDF importer rather than the flat decoder. + #[test] + fn an_ai_with_pdf_content_takes_the_pdf_importer() { + use lopdf::{Document as PdfDocument, Object, Stream, dictionary}; + let mut pdf = PdfDocument::with_version("1.5"); + let pages_id = pdf.new_object_id(); + let content = b"0 0 1 rg 10 10 20 20 re f"; + let content_id = pdf.add_object(Stream::new(dictionary! {}, content.to_vec())); + let page_id = pdf.add_object(dictionary! { + "Type" => "Page", + "Parent" => pages_id, + "MediaBox" => Object::Array(vec![Object::Real(0.0), Object::Real(0.0), Object::Real(100.0), Object::Real(100.0)]), + "Contents" => content_id, + "Resources" => dictionary! {}, + }); + pdf.objects.insert( + pages_id, + Object::Dictionary(dictionary! { + "Type" => "Pages", + "Kids" => Object::Array(vec![Object::Reference(page_id)]), + "Count" => 1, + }), + ); + let catalog = pdf.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id }); + pdf.trailer.set("Root", catalog); + let mut bytes = Vec::new(); + pdf.save_to(&mut bytes).expect("writing the .ai fixture"); + assert!(pdf_import::is_pdf(&bytes)); + assert_eq!(import_preflight("illustration.ai", &bytes), ImportPreflight::Unbounded); + let result = import("illustration.ai", &bytes).expect("an .ai starting with %PDF- takes the PDF importer"); + assert_eq!(result.document.artboards().len(), 1); + } } diff --git a/crates/io/src/pdf_import.rs b/crates/io/src/pdf_import.rs index 7cb1da50c2..0fcc540749 100644 --- a/crates/io/src/pdf_import.rs +++ b/crates/io/src/pdf_import.rs @@ -1,33 +1,28 @@ -//! PDF import: PDFium renders the selected pages and each page becomes an artboard. +//! PDF import: craft-pdf (a Rust PDF reader and renderer built on Hayro) rasterizes the +//! selected pages, and each page becomes one artboard. //! -//! The document resolution is chosen so the page's physical size is preserved even when the -//! raster is capped: pixels ÷ resolution × 72 pt is the PDF page's size. Pages are laid out side -//! by side; page count, per-page pixels, union pixels and the aggregate pixel budget are bounded. +//! Raster pages only: editable text and vector import would need craft-pdf's `interpret` +//! device and is not part of this importer. The renderer's hard output bounds (8192 px per +//! side and 64M px per page) and a document-wide strip budget are respected by lowering the +//! effective DPI, so each page keeps its physical size: pixels / resolution * 72 pt. //! -//! Text and vector page objects can be imported as editable layers (`ImportOptions::pdf_text` / -//! `pdf_vectors`); their page-space points are mapped to document pixels through the effective DPI. -//! -//! # Known limits -//! -//! - pdfium-render 0.9.5 exposes a path's fill/stroke *presence* but not its paint colour, stroke -//! width or dash pattern, so imported paths are painted black (1 pt centre stroke when stroked). -//! - A text object exposes no fill colour: the colour is sampled from the rendered page's glyph -//! ink, and the font is matched by family/name rather than embedded or loaded from the PDF. -//! - PDFium is provisioned by `packaging/windows/package.ps1` (pinned release, verified DLL); the -//! shared craft-common-actions release workflow does not stage it. +//! The renderer is pure Rust and runs on every desktop platform: no shared library is loaded +//! or shipped, and a malformed file is reported as an error rather than taking the process down. use crate::{ImportOptions, ImportResult, IoError}; +use craft_pdf::interpret::InterpreterSettings; +use craft_pdf::raster::{self, RenderCache}; use photocraft_color::{ColorMode, PixelFormat, SampleType}; -use photocraft_doc::{Artboard, Color, Document, Fill, Group, Knot, Layer, LayerContent, Path, PathOp, ShapeLayer, ShapeStroke, Subpath, TextLayer}; -use photocraft_geom::{Affine, Point, Rect, Size}; +use photocraft_doc::{Artboard, Document, Group, Layer, LayerContent}; +use photocraft_geom::{Rect, Size}; use photocraft_raster::{Interrupt, Surface}; -/// `%PDF-` magic. `.ai` files are PDFs too, but the dispatch keeps them out of this importer. +/// `%PDF-` magic. `.ai` files that start with it are PDFs and render through this importer. pub fn is_pdf(bytes: &[u8]) -> bool { bytes.starts_with(b"%PDF-") } -/// Native PDF import is not available in the web build. +/// PDF import is not available in the web build. #[cfg(target_arch = "wasm32")] pub fn import(_name: &str, _bytes: &[u8], _opts: &ImportOptions, _ctl: &Interrupt) -> Result { Err(IoError::Unsupported("PDF import needs the desktop build".into())) @@ -36,23 +31,23 @@ pub fn import(_name: &str, _bytes: &[u8], _opts: &ImportOptions, _ctl: &Interrup /// Renders the selected PDF pages and opens each as one artboard. #[cfg(not(target_arch = "wasm32"))] pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) -> Result { - use pdfium_render::prelude::*; - /// The most pages one import renders. const MAX_PAGES: usize = 16; /// Gap between the artboards, in document pixels. const GAP: u32 = 80; - /// Per-page and per-artboard-strip pixel budgets; a poster at 300 dpi is far below these. - const MAX_PAGE_PIXELS: f64 = 100_000_000.0; + /// The document-wide raster budget across every imported page, in pixels. const MAX_UNION_PIXELS: u64 = 200_000_000; /// The default pixel-memory budget when the caller sets none: a zero /// [`ImportOptions::flat_max_pixel_bytes`] still bounds the import. const DEFAULT_MAX_PIXEL_BYTES: u64 = 2 << 30; + ctl.check().map_err(|_| IoError::Cancelled)?; let requested = if opts.pdf_dpi.is_finite() && opts.pdf_dpi > 0.0 { f64::from(opts.pdf_dpi) } else { 300.0 }; - let pdfium = bind()?; - let document = pdfium.load_pdf_from_byte_slice(bytes, None).map_err(|e| IoError::Unsupported(format!("PDF: {e}")))?; - let total = document.pages().len().max(0) as usize; + let pdf = craft_pdf::open_slice_with_options(bytes, None, &craft_pdf::OpenOptions::default()).map_err(open_error)?; + let total = pdf.pages().len(); + if total == 0 { + return Err(IoError::Unsupported("PDF has no pages".into())); + } let (first, last) = match opts.pdf_pages { Some((a, b)) => { let a = a.max(1); @@ -64,46 +59,45 @@ pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) - } None => (1, total), }; - if total == 0 || first > total { - return Err(IoError::Unsupported("PDF has no pages in the requested range".into())); - } let page_count = last - first + 1; let count = page_count.min(MAX_PAGES); - // The page geometry first: the effective DPI must fit the budgets, capping the raster must - // not change the page's physical size, and editable objects are mapped through the same page - // box and rotation the raster uses. - let mut geoms = Vec::with_capacity(count); + + // Page sizes in points first: the effective DPI must fit the renderer's bounds without + // changing any page's physical size. + let mut sizes = Vec::with_capacity(count); for index in 0..count { + let page = &pdf.pages()[first - 1 + index]; + let (width, height) = page.render_dimensions(); + if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 { + return Err(IoError::Unsupported(format!("PDF page {} has no area", first + index))); + } + sizes.push((f64::from(width), f64::from(height))); ctl.check().map_err(|_| IoError::Cancelled)?; - let page_index = first - 1 + index; - let page = document.pages().get(page_index as i32).map_err(|e| IoError::Unsupported(format!("PDF: {e}")))?; - geoms.push(page_geometry(&page, page_index + 1)?); } - let sizes: Vec<(f64, f64)> = geoms.iter().map(|g| (g.width_pt, g.height_pt)).collect(); - let dpi = effective_dpi(requested, &sizes, GAP, MAX_PAGE_PIXELS, MAX_UNION_PIXELS); - let doc_width = sizes.iter().map(|(w, _)| (w * dpi / 72.0).round() as u64).sum::() + u64::from(GAP) * (count as u64 - 1); - let doc_height = sizes.iter().map(|(_, h)| (h * dpi / 72.0).round() as u64).max().unwrap_or(1); + let dpi = effective_dpi(requested, &sizes, u64::from(raster::MAX_SIDE), raster::MAX_PIXELS, MAX_UNION_PIXELS); + + let mut doc_width: u64 = 0; + let mut doc_height: u64 = 0; + let mut retained: u64 = 0; + for (index, &(width_pt, height_pt)) in sizes.iter().enumerate() { + let width = page_pixels(width_pt, dpi); + let height = page_pixels(height_pt, dpi); + doc_width = doc_width.saturating_add(u64::from(width)).saturating_add(if index + 1 < count { u64::from(GAP) } else { 0 }); + doc_height = doc_height.max(u64::from(height)); + let tiles_x = (u64::from(width)).div_ceil(256); + let tiles_y = (u64::from(height)).div_ceil(256); + retained = retained.saturating_add(tiles_x.saturating_mul(tiles_y).saturating_mul(256 * 256 * 4)); + } if doc_width == 0 || doc_height == 0 { return Err(IoError::Unsupported("PDF pages have no area".into())); } - // The aggregate budget: the document keeps every page's raster tiles, while one page at a - // time is converted through the PDFium bitmap, its RGBA copy and the f32 buffer. - let page_px = |w: f64, h: f64| (w * dpi / 72.0).round().max(1.0) * (h * dpi / 72.0).round().max(1.0); - let max_page_pixels = sizes.iter().map(|&(w, h)| page_px(w, h)).fold(0.0_f64, f64::max); - // Retained backing: a page occupies whole 256 px tiles, including the ones its document X - // offset steps into. A 1 x 10,000 px page is 40 tiles (10 MiB), not 10,000 pixels. - const TILE: u64 = 256; - let mut retained: u64 = 0; - let mut page_x: u64 = 0; - for &(w, h) in &sizes { - let px = (w * dpi / 72.0).round().max(1.0) as u64; - let py = (h * dpi / 72.0).round().max(1.0) as u64; - let tiles_x = (page_x + px).div_ceil(TILE) - page_x / TILE; - retained = retained.saturating_add(tiles_x.saturating_mul(py.div_ceil(TILE)).saturating_mul(TILE * TILE * 4)); - page_x += px + u64::from(GAP); + if doc_width > u64::from(u32::MAX) || doc_height > u64::from(u32::MAX) { + return Err(IoError::Unsupported("PDF import is too large at this DPI; lower the import DPI".into())); } - // Plus 24 bytes/px (bitmap, RGBA copy, f32 conversion buffer) for the page being converted. - let estimate = retained.saturating_add((max_page_pixels * 24.0) as u64); + // Retained tiles plus the converting page's peak: craft-pdf's RGBA8 raster (4 B/px) and the + // f32 document buffer (16 B/px). + let max_page = sizes.iter().map(|&(w, h)| u64::from(page_pixels(w, dpi)) * u64::from(page_pixels(h, dpi))).max().unwrap_or(0); + let estimate = retained.saturating_add(max_page.saturating_mul(20)); let budget = if opts.flat_max_pixel_bytes > 0 { opts.flat_max_pixel_bytes } else { DEFAULT_MAX_PIXEL_BYTES }; if estimate > budget { return Err(IoError::Unsupported(format!( @@ -112,576 +106,134 @@ pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) - budget >> 20 ))); } - let mut object_budget = ObjectBudget::default(); - let mut object_warnings: Vec = Vec::new(); - let mut doc = Document::new(name, Size::new(doc_width as u32, doc_height as u32), ColorMode::Rgb, SampleType::U8); - doc.resolution_dpi = dpi as f32; - let mut x = 0u32; - for (index, geometry) in geoms.iter().enumerate() { - let (w_pt, h_pt) = (geometry.width_pt, geometry.height_pt); - ctl.check().map_err(|_| IoError::Cancelled)?; - let page_index = first - 1 + index; - let target_width = (w_pt * dpi / 72.0).round().max(1.0) as i32; - let target_height = (h_pt * dpi / 72.0).round().max(1.0) as i32; - // A one-pixel-wide page would otherwise produce an arbitrarily tall bitmap before the - // union check runs; reject the final dimensions up front. - if f64::from(target_width) * f64::from(target_height) > MAX_PAGE_PIXELS { - return Err(IoError::Unsupported(format!( - "PDF page {} is too large at {dpi} dpi ({} x {} px); lower the import DPI", - page_index + 1, - target_width, - target_height - ))); - } - let page = document.pages().get(page_index as i32).map_err(|e| IoError::Unsupported(format!("PDF: {e}")))?; - let bitmap = - page.render_with_config(&PdfRenderConfig::new().set_target_width(target_width)).map_err(|e| IoError::Unsupported(format!("PDF render: {e}")))?; - let (width, height) = (bitmap.width().max(0) as u32, bitmap.height().max(0) as u32); - // The rounded target width can render a taller bitmap than the pre-check predicted. - if f64::from(width) * f64::from(height) > MAX_PAGE_PIXELS { - return Err(IoError::Unsupported(format!( - "PDF page {} rendered larger than the {MAX_PAGE_PIXELS} px budget ({} x {}); lower the import DPI", - page_index + 1, - width, - height - ))); - } - // The page's pixels go straight into the document-wide layer surface: no full-strip - // canvas and no PNG round-trip, so the peak is the page bitmap plus its tiles. - let mut surface = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::U8, true)); - let rgba = bitmap.as_rgba_bytes(); - let mut data = Vec::new(); - if data.try_reserve_exact(rgba.len()).is_err() { - return Err(IoError::Unsupported(format!("PDF page {} needs more memory than is available", page_index + 1))); - } - for chunk in rgba.as_chunks::<4>().0 { - for &v in &chunk[..3] { - data.push(f32::from(v) / 255.0); - } - data.push(f32::from(chunk[3]) / 255.0); - } - surface.write_region(Rect::from_xywh(x as i32, 0, width, height), &data); - let layer = Layer::new(format!("Page {}", page_index + 1), LayerContent::Raster(surface)); - let rect = Rect::new(x as i32, 0, (x + width) as i32, height as i32); - let mut children = vec![layer]; - if opts.pdf_text || opts.pdf_vectors { - let frame = PageFrame { geometry: *geometry, dpi, x, rgba: &rgba, width, height }; - let (layers, truncated) = object_layers(&page, opts, &frame, ctl, &mut object_budget)?; - children.extend(layers); - if truncated { - object_warnings.push(format!("PDF page {}: editable text/vector layers omitted at the import limit (the raster page is kept)", page_index + 1)); - } - } - let group = Group { children, expanded: true, artboard: Some(Artboard::new(rect)), frame_stroke: None }; - doc.layers.push(Layer::new(format!("Page {}", page_index + 1), LayerContent::Group(group))); - x += width + GAP; - ctl.progress((index + 1) as f32 / count as f32); - } - let mut warnings = object_warnings; + + let mut warnings = Vec::new(); if first > 1 || last < total || count < page_count { let shown_last = first + count - 1; let reason = if count < page_count { " (the page limit is 16)" } else { "" }; warnings.push(format!("PDF has {total} pages; imported pages {first}-{shown_last}{reason}")); } + if dpi + 0.5 < requested { + warnings.push(format!("PDF was rendered at {:.0} dpi to fit the import limits", dpi.max(1.0))); + } + + let mut doc = Document::new(name, Size::new(doc_width as u32, doc_height as u32), ColorMode::Rgb, SampleType::U8); + doc.resolution_dpi = dpi as f32; + let mut x: u32 = 0; + for index in 0..count { + ctl.check().map_err(|_| IoError::Cancelled)?; + let page_number = first + index; + let page = &pdf.pages()[page_number - 1]; + let (width_pt, height_pt) = sizes[index]; + let width = page_pixels(width_pt, dpi); + let height = page_pixels(height_pt, dpi); + // The DPI solve keeps these inside the renderer's bounds; re-check rather than panic if + // rounding on an edge moves one pixel past them. + if width > raster::MAX_SIDE || height > raster::MAX_SIDE || u64::from(width) * u64::from(height) > raster::MAX_PIXELS { + return Err(IoError::Unsupported(format!("PDF page {page_number} is too large at {dpi:.0} dpi; lower the import DPI"))); + } + let cache = RenderCache::new(); + let settings = InterpreterSettings::default(); + let options = raster::Options { + scale: [(dpi / 72.0) as f32; 2], + size: Some([width, height]), + offset: [0.0, 0.0], + // PDF pages composite onto white, as every PDF viewer and the previous importer did. + background: [255, 255, 255, 255], + color_replacement: None, + }; + let rendered = raster::render(page, &cache, &settings, &options).map_err(|error| render_error(page_number, dpi, error))?; + if rendered.diagnostics.image_decode_failed { + warnings.push(format!("PDF page {page_number}: an image could not be decoded and is missing")); + } + if rendered.diagnostics.content_truncated { + warnings.push(format!("PDF page {page_number}: some content was omitted (a decode or memory limit)")); + } + ctl.check().map_err(|_| IoError::Cancelled)?; + + let mut surface = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::U8, true)); + let bytes = rendered.bytes(); + let mut data = Vec::new(); + if data.try_reserve_exact(bytes.len()).is_err() { + return Err(IoError::Unsupported(format!("PDF page {page_number} needs more memory than is available"))); + } + for chunk in bytes.as_chunks::<4>().0 { + for &value in &chunk[..3] { + data.push(f32::from(value) / 255.0); + } + data.push(f32::from(chunk[3]) / 255.0); + } + surface.write_region(Rect::from_xywh(x as i32, 0, width, height), &data); + let rect = Rect::new(x as i32, 0, (x + width) as i32, height as i32); + let layer = Layer::new(format!("Page {page_number}"), LayerContent::Raster(surface)); + let group = Group { children: vec![layer], expanded: true, artboard: Some(Artboard::new(rect)), frame_stroke: None }; + doc.layers.push(Layer::new(format!("Page {page_number}"), LayerContent::Group(group))); + x = x.saturating_add(width).saturating_add(GAP); + ctl.progress((index + 1) as f32 / count as f32); + } + Ok(ImportResult { warnings, document: doc, source_read_only: false, preview_only: false }) } -/// Document-wide bound on editable objects. Text layers and path knots are allocated outside the -/// pixel-memory estimate, so a pathological PDF could otherwise exhaust memory. -const MAX_OBJECT_LAYERS: usize = 20_000; -const MAX_PATH_KNOTS: usize = 1_000_000; - -/// Tracks the editable objects created so far, document-wide. -#[derive(Default)] -struct ObjectBudget { - layers: usize, - knots: usize, -} - -impl ObjectBudget { - /// Claims one editable layer; false once the document-wide limit is reached. - fn claim_layer(&mut self) -> bool { - if self.layers >= MAX_OBJECT_LAYERS { - false - } else { - self.layers += 1; - true - } - } - - /// Claims `count` path knots; false if that would pass the document-wide limit. - fn claim_knots(&mut self, count: usize) -> bool { - if self.knots.saturating_add(count) > MAX_PATH_KNOTS { - false - } else { - self.knots += count; - true - } - } -} -/// A page's visible area and rotation, in PDF points. -#[derive(Clone, Copy)] -struct PageGeometry { - /// Displayed size (rotation applied): the size the raster is rendered into. - width_pt: f64, - height_pt: f64, - /// The crop box origin in unrotated PDF user space. - origin_x: f64, - origin_y: f64, - /// The crop box size in unrotated PDF user space. - crop_w: f64, - crop_h: f64, - /// Clockwise rotation the renderer applies: 0, 90, 180 or 270. - rotation: u16, -} - -/// The page's visible box and rotation. PDF page objects stay in unrotated user space, while the -/// raster is rendered through the crop box and the page's `/Rotate`, so editable layers must use -/// the same transform. +/// One page's raster width or height in pixels at `dpi`, at least one. #[cfg(not(target_arch = "wasm32"))] -fn page_geometry(page: &pdfium_render::prelude::PdfPage, page_number: usize) -> Result { - let width_pt = f64::from(page.width().value); - let height_pt = f64::from(page.height().value); - if width_pt <= 0.0 || height_pt <= 0.0 { - return Err(IoError::Unsupported(format!("PDF page {page_number} has no area"))); - } - let rotation = page.rotation().map(|r| r.as_degrees() as u16).unwrap_or(0); - // The page's effective box, as PDFium renders it: the bounding box (media box clipped to the - // crop box), not the raw crop box, which may extend past the media box. - let (origin_x, origin_y, crop_w, crop_h) = match page.boundaries().bounding().or_else(|_| page.boundaries().crop()).or_else(|_| page.boundaries().media()) { - Ok(boundary) => { - let r = boundary.bounds; - (f64::from(r.left().value), f64::from(r.bottom().value), f64::from(r.right().value - r.left().value), f64::from(r.top().value - r.bottom().value)) - } - Err(_) => (0.0, 0.0, width_pt, height_pt), - }; - // A malformed box falls back to the displayed size; 90 and 270 swap the axes. - let (crop_w, crop_h) = if crop_w > 0.0 && crop_h > 0.0 { - (crop_w, crop_h) - } else if rotation == 90 || rotation == 270 { - (height_pt, width_pt) - } else { - (width_pt, height_pt) - }; - Ok(PageGeometry { width_pt, height_pt, origin_x, origin_y, crop_w, crop_h, rotation }) +fn page_pixels(points: f64, dpi: f64) -> u32 { + (points * dpi / 72.0).round().max(1.0) as u32 } -/// One rendered page's frame: its geometry, DPI, document x, and the RGBA pixels. -struct PageFrame<'a> { - geometry: PageGeometry, - dpi: f64, - x: u32, - rgba: &'a [u8], - width: u32, - height: u32, -} - -impl PageFrame<'_> { - /// Maps a point in PDF page space to the page's own pixels: crop box, rotation and scale. - fn to_page_px(&self, x_pt: f32, y_pt: f32) -> (f64, f64) { - let g = &self.geometry; - let u = f64::from(x_pt) - g.origin_x; - let v = f64::from(y_pt) - g.origin_y; - let (dx, dy) = match g.rotation { - 90 => (v, u), - 180 => (g.crop_w - u, v), - 270 => (g.crop_h - v, g.crop_w - u), - _ => (u, g.crop_h - v), - }; - let scale = self.dpi / 72.0; - (dx * scale, dy * scale) - } - - /// Maps a point in PDF page space to document pixels: the page's own pixels plus its X offset. - fn to_doc(&self, x_pt: f32, y_pt: f32) -> (f64, f64) { - let (px, py) = self.to_page_px(x_pt, y_pt); - ((px + f64::from(self.x)).round(), py.round()) - } - - /// The text layer's transform at document pixel (`x`, `y`): the page rotation turns the text - /// layout too, so rotated pages get rotated editable text rather than upright text at a - /// rotated position. - fn text_transform(&self, x: f64, y: f64) -> Affine { - let (a, b, c, d) = match self.geometry.rotation { - 90 => (0.0, 1.0, -1.0, 0.0), - 180 => (-1.0, 0.0, 0.0, -1.0), - 270 => (0.0, -1.0, 1.0, 0.0), - _ => (1.0, 0.0, 0.0, 1.0), - }; - Affine { m: [a, b, c, d, x, y] } - } - - /// The rendered page's colour at local pixel (`x`, `y`), or black outside it. - fn color_at(&self, x: u32, y: u32) -> Color { - let i = (y as usize * self.width as usize + x as usize) * 4; - if i + 3 < self.rgba.len() && self.rgba[i + 3] > 0 { - Color::rgb(f32::from(self.rgba[i]) / 255.0, f32::from(self.rgba[i + 1]) / 255.0, f32::from(self.rgba[i + 2]) / 255.0) - } else { - Color::BLACK - } - } - - /// The ink colour of the text in `bounds` (page points). - /// - /// The box holds both glyphs and the page behind them, so sampling one corner usually finds - /// background or an antialiased edge. Take the pixel farthest from the box's most common - /// colour instead: for dark text on a light page that is a glyph core, and for light text on a - /// dark page it is the glyph rather than the background. - fn text_color(&self, left: f32, top: f32, right: f32, bottom: f32) -> Color { - // The box's four corners through the same rotation-aware mapping as the glyphs. - let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); - for (x, y) in [(left, bottom), (left, top), (right, top), (right, bottom)] { - let (px, py) = self.to_page_px(x, y); - x0 = x0.min(px); - x1 = x1.max(px); - y0 = y0.min(py); - y1 = y1.max(py); - } - let x0 = x0.floor().max(0.0) as u32; - let x1 = x1.ceil().clamp(0.0, f64::from(self.width)) as u32; - let y0 = y0.floor().max(0.0) as u32; - let y1 = y1.ceil().clamp(0.0, f64::from(self.height)) as u32; - if x1 <= x0 || y1 <= y0 { - return Color::BLACK; - } - // At most 4096 samples, so a page-sized text box stays cheap. - let step = ((f64::from(x1 - x0) * f64::from(y1 - y0) / 4096.0).sqrt().ceil() as u32).max(1); - let mut samples: Vec<[f32; 3]> = Vec::new(); - let mut buckets: std::collections::HashMap = std::collections::HashMap::new(); - let mut y = y0; - 'rows: while y < y1 { - let mut x = x0; - while x < x1 { - if samples.len() >= 4096 { - break 'rows; - } - let c = self.color_at(x, y); - let rgb = [c.c[0], c.c[1], c.c[2]]; - let key = (rgb[0] * 31.0) as u32 * 1024 + (rgb[1] * 31.0) as u32 * 32 + (rgb[2] * 31.0) as u32; - let entry = buckets.entry(key).or_insert((0, [0.0; 3])); - entry.0 += 1; - for (sum, v) in entry.1.iter_mut().zip(rgb) { - *sum += v; - } - samples.push(rgb); - x += step; - } - y += step; - } - let Some((_, (count, sum))) = buckets.iter().max_by_key(|(_, (count, _))| *count) else { return Color::BLACK }; - let mode = [sum[0] / *count as f32, sum[1] / *count as f32, sum[2] / *count as f32]; - let mut best = mode; - let mut best_distance = -1.0f32; - for rgb in samples { - let distance = (rgb[0] - mode[0]).powi(2) + (rgb[1] - mode[1]).powi(2) + (rgb[2] - mode[2]).powi(2); - if distance > best_distance { - best_distance = distance; - best = rgb; - } - } - Color::rgb(best[0], best[1], best[2]) - } -} - -/// Editable text and shape layers for one rendered page, in document pixels. +/// The highest DPI not above `requested` that keeps every page inside the renderer's side and +/// pixel bounds and the whole strip inside `max_union_pixels`. Rendering at that DPI keeps each +/// page's physical size unchanged; unreachable bounds report 1 DPI and the render call then fails +/// with a clear message. #[cfg(not(target_arch = "wasm32"))] -fn object_layers( - page: &pdfium_render::prelude::PdfPage, - opts: &ImportOptions, - frame: &PageFrame, - ctl: &Interrupt, - budget: &mut ObjectBudget, -) -> Result<(Vec, bool), IoError> { - use pdfium_render::prelude::*; - - let objects = page.objects(); - let mut layers = Vec::new(); - let mut truncated = false; - 'objects: for object in objects.iter() { - if budget.layers >= MAX_OBJECT_LAYERS { - truncated = true; - break; - } - if ctl.check().is_err() { - return Err(IoError::Cancelled); - } - match object { - PdfPageObject::Text(text) if opts.pdf_text => { - let content = text.text(); - if content.trim().is_empty() { - continue; - } - let Ok(bounds) = text.bounds() else { continue }; - let (x_px, y_px) = frame.to_doc(bounds.left().value, bounds.top().value); - // The text layer's size is in points; the transform is in document pixels. - // The text object does not expose its fill colour; sample the glyph ink from the - // page. The font is matched by family/name, not embedded: a document font that is - // not installed renders with the closest family the text engine has. - let color = frame.text_color(bounds.left().value, bounds.top().value, bounds.right().value, bounds.bottom().value); - let size_pt = f64::from(text.scaled_font_size().value).max(1.0); - let font = text.font(); - let family = if font.family().is_empty() { font.name() } else { font.family() }; - let mut layer = Layer::new( - "Text", - LayerContent::Text(TextLayer { - text: content, - font_family: family, - size_pt: size_pt as f32, - color, - transform: frame.text_transform(x_px, y_px), - ..Default::default() - }), - ); - if !text.is_visible() { - layer.visible = false; - } - if !budget.claim_layer() { - truncated = true; - break 'objects; - } - layers.push(layer); +fn effective_dpi(requested: f64, sizes: &[(f64, f64)], max_side: u64, max_page_pixels: u64, max_union_pixels: u64) -> f64 { + let mut dpi = requested.clamp(1.0, 2400.0); + loop { + let mut union = 0u64; + let mut fits = true; + for &(width_pt, height_pt) in sizes { + let width = u64::from(page_pixels(width_pt, dpi)); + let height = u64::from(page_pixels(height_pt, dpi)); + if width > max_side || height > max_side || width.saturating_mul(height) > max_page_pixels { + fits = false; + break; } - PdfPageObject::Path(path) if opts.pdf_vectors => { - // PDFium reports the segments in object space; the object's matrix places them on - // the page. Without it, scaled or rotated paths land in the wrong place. - let matrix = path.matrix().unwrap_or(PdfMatrix::IDENTITY); - // The fill and stroke modes are exposed; the colours, stroke width and dash - // pattern are not, so both paints are black and a stroke is 1 pt wide. - let filled = !matches!(path.fill_mode(), Ok(PdfPathFillMode::None)); - let stroked = path.is_stroked().unwrap_or(false); - if !filled && !stroked { - continue; - } - let mut subpaths: Vec = Vec::new(); - let mut knots: Vec = Vec::new(); - let mut closed = false; - let mut bezier: Vec<(f64, f64)> = Vec::new(); - for segment in path.segments().transform(matrix).iter() { - let point = || frame.to_doc(segment.x().value, segment.y().value); - match segment.segment_type() { - PdfPathSegmentType::MoveTo => { - if !knots.is_empty() { - subpaths.push(Subpath { closed, knots: std::mem::take(&mut knots), op: PathOp::Combine }); - closed = false; - } - if !budget.claim_knots(1) { - truncated = true; - break 'objects; - } - let (px, py) = point(); - knots.push(Knot::corner(px, py)); - } - PdfPathSegmentType::LineTo => { - if !budget.claim_knots(1) { - truncated = true; - break 'objects; - } - let (px, py) = point(); - knots.push(Knot::corner(px, py)); - } - PdfPathSegmentType::BezierTo => { - // PDF draws a cubic Bézier as three consecutive BezierTo points: the - // two control points, then the end point. - bezier.push(point()); - if bezier.len() == 3 { - let ((c1x, c1y), (c2x, c2y), (ex, ey)) = (bezier[0], bezier[1], bezier[2]); - if let Some(prev) = knots.last_mut() { - prev.out_ctrl = Point::new(c1x, c1y); - } - if !budget.claim_knots(1) { - truncated = true; - break 'objects; - } - knots.push(Knot::smooth(Point::new(ex, ey), Point::new(c2x, c2y), Point::new(ex, ey))); - bezier.clear(); - } - } - PdfPathSegmentType::Unknown => {} - } - // A closing segment can also be the endpoint that closes a Bézier; process it - // above before marking the subpath closed so its control points are consumed. - if segment.is_close() { - closed = true; - } - } - if !knots.is_empty() { - subpaths.push(Subpath { closed, knots, op: PathOp::Combine }); - } - if subpaths.is_empty() { - continue; - } - let stroke = stroked.then(|| ShapeStroke { paint: Fill::Solid(Color::BLACK), width: 1.0, ..Default::default() }); - if !budget.claim_layer() { - truncated = true; - break 'objects; - } - layers.push(Layer::new( - "Path", - LayerContent::Shape(ShapeLayer { - path: Path::new(subpaths), - fill: filled.then_some(Fill::Solid(Color::BLACK)), - stroke, - ..Default::default() - }), - )); - } - _ => {} + union = union.saturating_add(width.saturating_mul(height)); } + if fits && union <= max_union_pixels { + return dpi; + } + if dpi <= 1.0 { + return 1.0; + } + // The strip's gaps contribute a constant width, so a geometric step converges without + // needing a closed form per page. + dpi = (dpi * 0.9).max(1.0); } - Ok((layers, truncated)) } -/// The highest DPI not above `requested` that keeps every page and the artboard strip within -/// their pixel budgets. Rendering at that DPI keeps each page's physical size unchanged. +/// Maps a craft-pdf open failure to the importer's error. #[cfg(not(target_arch = "wasm32"))] -fn effective_dpi(requested: f64, sizes: &[(f64, f64)], gap: u32, max_page: f64, max_union: u64) -> f64 { - let mut dpi = requested; - for (w, h) in sizes { - dpi = dpi.min(72.0 * (max_page / (w * h)).sqrt()); +fn open_error(error: craft_pdf::OpenError) -> IoError { + use craft_pdf::OpenError; + match error { + OpenError::InputLimit { limit } => IoError::Unsupported(format!("PDF is larger than the {limit}-byte input limit")), + OpenError::Allocation => IoError::Unsupported("PDF needs more memory than is available".into()), + OpenError::BackendPanic => IoError::Unsupported("PDF reader failed on this file; it may be damaged".into()), + OpenError::Load(error) => IoError::Unsupported(format!("PDF: {error:?}")), } - let n = sizes.len() as f64; - let sum_w: f64 = sizes.iter().map(|(w, _)| w).sum(); - let max_h = sizes.iter().map(|(_, h)| h).fold(0.0_f64, |a, &h| a.max(h)); - // (sum_w * k + gaps) * (max_h * k) <= max_union, where k = dpi / 72: solve a k² + b k - c = 0. - let gaps = f64::from(gap) * (n - 1.0); - let a = sum_w * max_h; - let b = gaps * max_h; - let k = if a > 0.0 { (-b + (b * b + 4.0 * a * max_union as f64).sqrt()) / (2.0 * a) } else { f64::INFINITY }; - dpi.min(72.0 * k).max(1.0) } -/// Loads the PDFium shared library from the executable's folder (`PDFIUM_DIR` overrides it). -/// -/// pdfium-render keeps the bindings in a process-wide static: the first import initializes them -/// and later `bind_to_library` calls fail with `PdfiumLibraryBindingsAlreadyInitialized`. Remember -/// the outcome and reuse the initialized bindings, so opening a second PDF in one session works. +/// Maps a craft-pdf render failure to the importer's error. #[cfg(not(target_arch = "wasm32"))] -fn bind() -> Result { - use pdfium_render::prelude::Pdfium; - - static INIT: std::sync::OnceLock> = std::sync::OnceLock::new(); - let init = INIT.get_or_init(|| { - let dir = std::env::var_os("PDFIUM_DIR") - .map(std::path::PathBuf::from) - .unwrap_or_else(|| std::env::current_exe().ok().and_then(|exe| exe.parent().map(std::path::Path::to_path_buf)).unwrap_or_default()); - let library = Pdfium::pdfium_platform_library_name_at_path(&dir); - let platform = Pdfium::pdfium_platform_library_name().to_string_lossy().into_owned(); - match Pdfium::bind_to_library(&library) { - Ok(bindings) => { - // Initializes the library inside pdfium-render's process-wide bindings. - drop(Pdfium::new(bindings)); - Ok(()) - } - Err(e) => Err(format!( - "PDF import: cannot load PDFium from {} ({e}); put {platform} next to the executable or set PDFIUM_DIR, then restart the application", - library.display() - )), - } - }); - match init { - // The bindings are already initialized; `default()` re-uses them. - Ok(()) => Ok(Pdfium::default()), - Err(message) => Err(IoError::Unsupported(message.clone())), - } -} - -#[cfg(test)] -mod tests { - use super::{MAX_OBJECT_LAYERS, MAX_PATH_KNOTS, ObjectBudget, PageFrame, PageGeometry}; - - /// A 595 x 842 pt page with no offset, rotated by `rotation` degrees. - fn geometry(rotation: u16) -> PageGeometry { - PageGeometry { width_pt: 595.0, height_pt: 842.0, origin_x: 0.0, origin_y: 0.0, crop_w: 595.0, crop_h: 842.0, rotation } - } - - /// A full-page frame at `dpi`, starting at document x `x`. - fn frame(dpi: f64, x: u32) -> PageFrame<'static> { - PageFrame { geometry: geometry(0), dpi, x, rgba: &[], width: 595, height: 842 } - } - - #[test] - /// Page points map through the DPI scale, the Y flip and the page's document X offset. - fn page_points_map_to_document_pixels() { - assert_eq!(frame(72.0, 0).to_doc(60.0, 420.0), (60.0, 422.0)); - // The same point at four times the resolution. - assert_eq!(frame(300.0, 0).to_doc(60.0, 420.0), (250.0, 1758.0)); - // Page 2 starts after page 1's width: the document X offset is part of the mapping. - assert_eq!(frame(300.0, 2480).to_doc(60.0, 420.0), (2730.0, 1758.0)); - } - - #[test] - /// The document-wide budget refuses layers and knots once its limits are reached. - fn object_budget_stops_at_its_limits() { - let mut budget = ObjectBudget::default(); - for _ in 0..MAX_OBJECT_LAYERS { - assert!(budget.claim_layer()); - } - assert!(!budget.claim_layer()); - let mut knots = ObjectBudget::default(); - assert!(knots.claim_knots(MAX_PATH_KNOTS)); - assert!(!knots.claim_knots(1)); - assert!(!knots.claim_knots(usize::MAX)); - } - #[test] - /// Crop-box offsets and 90/180/270 rotations land where the raster renders them. - fn crop_box_offsets_and_rotations_map_like_the_raster() { - // A 200 x 100 pt crop box at (10, 20), displayed rotated 90 degrees clockwise: 100 x 200. - let rotated = |rotation| PageFrame { - geometry: PageGeometry { width_pt: 100.0, height_pt: 200.0, origin_x: 10.0, origin_y: 20.0, crop_w: 200.0, crop_h: 100.0, rotation }, - dpi: 72.0, - x: 0, - rgba: &[], - width: 100, - height: 200, - }; - // At 72 dpi the rectangle (20, 30)..(60, 70) lands at (10, 10)..(50, 50). - assert_eq!(rotated(90).to_doc(20.0, 30.0), (10.0, 10.0)); - assert_eq!(rotated(90).to_doc(60.0, 70.0), (50.0, 50.0)); - // 180 mirrors X; 270 swaps the axes back. - assert_eq!(rotated(180).to_doc(20.0, 30.0), (190.0, 10.0)); - assert_eq!(rotated(270).to_doc(20.0, 30.0), (90.0, 190.0)); - // The page's document X offset is added after the page-space mapping. - let mut offset = rotated(90); - offset.x = 500; - assert_eq!(offset.to_doc(20.0, 30.0), (510.0, 10.0)); - } - - #[test] - /// The page sampler reads the requested pixel and reports black outside the page. - fn samples_the_rendered_page() { - // Two pixels: white then black. - let rgba = [255u8, 255, 255, 255, 0, 0, 0, 255]; - let page = PageFrame { - geometry: PageGeometry { width_pt: 2.0, height_pt: 1.0, origin_x: 0.0, origin_y: 0.0, crop_w: 2.0, crop_h: 1.0, rotation: 0 }, - dpi: 72.0, - x: 0, - rgba: &rgba, - width: 2, - height: 1, - }; - assert_eq!(page.color_at(0, 0), photocraft_doc::Color::rgb(1.0, 1.0, 1.0)); - assert_eq!(page.color_at(1, 0), photocraft_doc::Color::BLACK); - assert_eq!(page.color_at(2, 0), photocraft_doc::Color::BLACK); - } - - #[test] - /// Glyph ink wins over the background inside a text box, while an empty box keeps background. - fn text_colour_samples_the_glyph_ink() { - // An 8 x 4 white page with one black glyph pixel at (2, 1). - let mut rgba = vec![255u8; 8 * 4 * 4]; - let glyph = (8 + 2) * 4; - rgba[glyph..glyph + 3].fill(0); - let page = PageFrame { - geometry: PageGeometry { width_pt: 8.0, height_pt: 4.0, origin_x: 0.0, origin_y: 0.0, crop_w: 8.0, crop_h: 4.0, rotation: 0 }, - dpi: 72.0, - x: 0, - rgba: &rgba, - width: 8, - height: 4, - }; - // The whole page box contains the glyph: the ink wins over the white background. - assert_eq!(page.text_color(0.0, 4.0, 8.0, 0.0), photocraft_doc::Color::BLACK); - // A box that contains only background keeps the background colour. - assert_eq!(page.text_color(4.0, 4.0, 8.0, 2.0), photocraft_doc::Color::rgb(1.0, 1.0, 1.0)); +fn render_error(page: usize, dpi: f64, error: raster::Error) -> IoError { + use raster::Error; + match error { + Error::Cancelled => IoError::Cancelled, + Error::OutputLimit => IoError::Unsupported(format!("PDF page {page} is too large at {dpi:.0} dpi; lower the import DPI")), + Error::InvalidPage => IoError::Unsupported(format!("PDF page {page} has an empty or invalid page box")), + Error::Allocation => IoError::Unsupported(format!("PDF page {page} needs more memory than is available")), + Error::BackendPanic => IoError::Unsupported(format!("PDF renderer failed on page {page}; it may be damaged")), + Error::InvalidOptions | Error::BufferLength => IoError::Unsupported(format!("PDF page {page} could not be rendered")), } } diff --git a/crates/io/tests/it/pdf_import.rs b/crates/io/tests/it/pdf_import.rs index ef02111309..f63ff19482 100644 --- a/crates/io/tests/it/pdf_import.rs +++ b/crates/io/tests/it/pdf_import.rs @@ -1,239 +1,26 @@ -//! PDF import end-to-end through PDFium. +//! PDF import end-to-end through craft-pdf (pure Rust, no shared library). //! -//! A synthetic two-page PDF with non-origin red text, a transformed blue rectangle and green text -//! is imported at 72 and 300 DPI, and the resulting editable layers are checked against the -//! fixture's geometry and pixels. PDFium is loaded at run time: when it is unavailable (no -//! `PDFIUM_DIR`, no DLL beside the test binary) the test prints an explicit SKIPPED line and -//! returns instead of pretending the end-to-end wiring passed. +//! A six-page synthetic PDF (plain, CropBox offset, rotations 90/180/270 and an oversized +//! CropBox) imports at 72 and 300 DPI as one artboard per page, with the page's print size kept, +//! a blue rectangle sampled from the raster, and the renderer's side/pixel limits respected by +//! lowering the effective DPI on a huge page. use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::{Duration, Instant}; use lopdf::{Document as PdfDocument, Object, Stream, dictionary}; -use pdfium_render::prelude::{PdfRenderConfig, Pdfium}; -use photocraft_doc::{Color, Document, Layer, LayerContent, ShapeLayer, TextLayer}; +use photocraft_doc::LayerContent; use photocraft_geom::Rect; -use photocraft_io::{ImportOptions, ImportResult, IoError, import_with_options_and_interrupt}; +use photocraft_io::{ImportOptions, IoError, import_with_options_and_interrupt}; use photocraft_raster::Interrupt; const PAGE_W: f64 = 200.0; const PAGE_H: f64 = 100.0; -const GAP: f64 = 80.0; -/// Imports the four-page fixture at 72 and 300 DPI and checks every layer it produces. -#[test] -fn imports_text_and_a_transformed_path_on_two_pages() { - let pdf = three_page_pdf(); - for dpi in [72.0f32, 300.0] { - let options = ImportOptions { pdf_dpi: dpi, ..Default::default() }; - let result = match import_with_options_and_interrupt("fixture.pdf", &pdf, &options, &photocraft_raster::Interrupt::NONE) { - Ok(result) => result, - Err(IoError::Unsupported(message)) if message.contains("cannot load PDFium") => { - eprintln!("SKIPPED the PDF import end-to-end test: PDFium is unavailable ({message})"); - // The Windows integration leg provisions PDFium and sets this: there a missing - // library must fail rather than let the runner report an unreached pass. - assert!(std::env::var_os("PHOTOCRAFT_REQUIRE_PDFIUM").is_none(), "PDFium is required for this test but could not load: {message}"); - return; - } - Err(error) => panic!("PDF import failed at {dpi} dpi: {error}"), - }; - check(&result.document, dpi); - eprintln!("PDF import executed at {dpi} dpi: {} page groups, layer checks passed", result.document.layers.len()); - } -} - -/// Page 1: red 48 pt text at (20, 50). Page 2: a 20 x 10 pt rectangle scaled 2x and moved by -/// (10, 5) - corners (30, 25)..(70, 45) - plus green 48 pt text at (20, 20). Page 3: a crop box -/// offset to (10, 20, 210, 120) and rotated 90 degrees clockwise, with a 40 x 40 pt rectangle at -/// (20, 30)..(60, 70) that renders at (10, 10)..(50, 50) on a 100 x 200 page. -fn three_page_pdf() -> Vec { - let mut pdf = PdfDocument::with_version("1.5"); - let pages_id = pdf.new_object_id(); - let font_id = pdf.add_object(dictionary! { - "Type" => "Font", - "Subtype" => "Type1", - "BaseFont" => "Helvetica", - }); - let resources = pdf.add_object(dictionary! { - "Font" => dictionary! { "F1" => font_id }, - }); - let pages = [ - ("1 0 0 rg BT /F1 48 Tf 20 50 Td (Red) Tj ET", [0.0f32, 0.0, PAGE_W as f32, PAGE_H as f32], None, None), - ("0 0 1 rg q 2 0 0 2 10 5 cm 10 10 20 10 re f Q 0 1 0 rg BT /F1 48 Tf 20 20 Td (Green) Tj ET", [0.0, 0.0, PAGE_W as f32, PAGE_H as f32], None, None), - ("0 0 1 rg 20 30 40 40 re f 0 0.5 0 rg BT /F1 24 Tf 40 60 Td (Rot) Tj ET", [10.0, 20.0, 210.0, 120.0], Some(90i64), None), - // A CropBox larger than the MediaBox: the rendered page and the mapping use the - // intersection, so the rectangle must not be offset by the crop box's negative origin. - ("0 0.5 0 rg 20 30 40 40 re f", [0.0, 0.0, PAGE_W as f32, PAGE_H as f32], None, Some([-50.0f32, -50.0, 250.0, 150.0])), - ]; - let mut kids = Vec::new(); - for (content, [left, bottom, right, top], rotate, crop) in pages { - let content_id = pdf.add_object(Stream::new(dictionary! {}, content.as_bytes().to_vec())); - let mut page = dictionary! { - "Type" => "Page", - "Parent" => pages_id, - "MediaBox" => Object::Array(vec![Object::Real(left), Object::Real(bottom), Object::Real(right), Object::Real(top)]), - "Contents" => content_id, - "Resources" => resources, - }; - if let Some(rotate) = rotate { - page.set("Rotate", rotate); - } - if let Some([left, bottom, right, top]) = crop { - page.set("CropBox", Object::Array(vec![Object::Real(left), Object::Real(bottom), Object::Real(right), Object::Real(top)])); - } - let page_id = pdf.add_object(page); - kids.push(Object::Reference(page_id)); - } - let count = kids.len() as i64; - pdf.objects.insert( - pages_id, - Object::Dictionary(dictionary! { - "Type" => "Pages", - "Kids" => Object::Array(kids), - "Count" => count, - }), - ); - let catalog = pdf.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id }); - pdf.trailer.set("Root", catalog); - let mut bytes = Vec::new(); - pdf.save_to(&mut bytes).expect("writing the PDF fixture"); - bytes -} - -/// Asserts the imported text and path layers at one DPI against the fixture geometry. -fn check(document: &Document, dpi: f32) { - let scale = f64::from(dpi) / 72.0; - let page_two_x = (PAGE_W * scale).round() + GAP; - - // Page 1: the editable text keeps its position and takes the red ink from the page. - let text = text_layer(page_children(document, 1)); - assert_red(&text.color, "page 1 text"); - let x = text.transform.m[4]; - let y = text.transform.m[5]; - assert!((x - 20.0 * scale).abs() < 8.0 * scale, "page 1 text x is {x} at {dpi} dpi"); - // 48 pt Helvetica's bounds top is about 35 pt above the baseline at y = 50. - assert!((y - 15.0 * scale).abs() < 20.0 * scale, "page 1 text y is {y} at {dpi} dpi"); - - // Page 2: the transformed rectangle lands at (30, 25)..(70, 45) plus the page's document X. - let shape = shape_layer(page_children(document, 2)); - let (mut min_x, mut min_y, mut max_x, mut max_y) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); - for knot in shape.path.subpaths.iter().flat_map(|subpath| &subpath.knots) { - min_x = min_x.min(knot.anchor.x); - max_x = max_x.max(knot.anchor.x); - min_y = min_y.min(knot.anchor.y); - max_y = max_y.max(knot.anchor.y); - } - assert!((min_x - (page_two_x + 30.0 * scale)).abs() < 4.0, "path min x is {min_x}, page 2 starts at {page_two_x} ({dpi} dpi)"); - assert!((max_x - (page_two_x + 70.0 * scale)).abs() < 4.0, "path max x is {max_x} ({dpi} dpi)"); - assert!((min_y - (PAGE_H - 45.0) * scale).abs() < 4.0, "path min y is {min_y} ({dpi} dpi)"); - assert!((max_y - (PAGE_H - 25.0) * scale).abs() < 4.0, "path max y is {max_y} ({dpi} dpi)"); - - let text = text_layer(page_children(document, 2)); - assert_green(&text.color, "page 2 text"); - assert!(text.transform.m[4] > page_two_x + 10.0 * scale, "page 2 text x misses the page offset: {} <= {page_two_x}", text.transform.m[4]); - - // Page 3: the crop box (10, 20, 210, 120) and /Rotate 90 put the rectangle at (10..50, 10..50). - let shape = shape_layer(page_children(document, 3)); - let (mut min_x, mut min_y, mut max_x, mut max_y) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); - for knot in shape.path.subpaths.iter().flat_map(|subpath| &subpath.knots) { - min_x = min_x.min(knot.anchor.x); - max_x = max_x.max(knot.anchor.x); - min_y = min_y.min(knot.anchor.y); - max_y = max_y.max(knot.anchor.y); - } - let page_three_x = 2.0 * (PAGE_W * scale).round() + 2.0 * GAP; - assert!((min_x - (page_three_x + 10.0 * scale)).abs() < 3.0, "rotated path min x is {min_x}, page 3 starts at {page_three_x} ({dpi} dpi)"); - assert!((max_x - (page_three_x + 50.0 * scale)).abs() < 3.0, "rotated path max x is {max_x} ({dpi} dpi)"); - assert!((min_y - 10.0 * scale).abs() < 3.0, "rotated path min y is {min_y} ({dpi} dpi)"); - assert!((max_y - 50.0 * scale).abs() < 3.0, "rotated path max y is {max_y} ({dpi} dpi)"); - - // Rotated text: the layer's transform carries the 90 degree turn, not just its position. - let text = text_layer(page_children(document, 3)); - let m = text.transform.m; - assert!(m[0].abs() < 0.01 && (m[1] - 1.0).abs() < 0.01 && (m[2] + 1.0).abs() < 0.01 && m[3].abs() < 0.01, "page 3 text rotation is {m:?}"); - assert!(m[4] > page_three_x, "page 3 text x misses the page offset: {} <= {page_three_x}", m[4]); - assert!(text.color.c[1] > 0.3 && text.color.c[0] < 0.2 && text.color.c[2] < 0.2, "page 3 text should be green, got {:?}", text.color.c); - - // Page 4: a CropBox (-50, -50, 250, 150) around a 200 x 100 MediaBox must not offset the rect. - let shape = shape_layer(page_children(document, 4)); - let (mut min_x, mut min_y, mut max_x, mut max_y) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); - for knot in shape.path.subpaths.iter().flat_map(|subpath| &subpath.knots) { - min_x = min_x.min(knot.anchor.x); - max_x = max_x.max(knot.anchor.x); - min_y = min_y.min(knot.anchor.y); - max_y = max_y.max(knot.anchor.y); - } - // Page 3 is rotated 90 degrees, so it is 100 pt wide on the strip, not 200. - let page_four_x = 2.0 * (PAGE_W * scale).round() + (100.0 * scale).round() + 3.0 * GAP; - assert!((min_x - (page_four_x + 20.0 * scale)).abs() < 3.0, "oversized-crop path min x is {min_x}, page 4 starts at {page_four_x} ({dpi} dpi)"); - assert!((max_x - (page_four_x + 60.0 * scale)).abs() < 3.0, "oversized-crop path max x is {max_x} ({dpi} dpi)"); - assert!((min_y - 30.0 * scale).abs() < 3.0, "oversized-crop path min y is {min_y} ({dpi} dpi)"); - assert!((max_y - 70.0 * scale).abs() < 3.0, "oversized-crop path max y is {max_y} ({dpi} dpi)"); -} - -/// The layer children of the imported `Page N` group. -fn page_children(document: &Document, page: usize) -> &[Layer] { - let name = format!("Page {page}"); - let layer = document.layers.iter().find(|layer| layer.name == name).unwrap_or_else(|| panic!("no {name} layer")); - match &layer.content { - LayerContent::Group(group) => &group.children, - _ => panic!("{name} is not a group"), - } -} - -/// The first text layer among `children`. -fn text_layer(children: &[Layer]) -> &TextLayer { - children - .iter() - .find_map(|layer| match &layer.content { - LayerContent::Text(text) => Some(text), - _ => None, - }) - .expect("a text layer") -} - -/// The first shape layer among `children`. -fn shape_layer(children: &[Layer]) -> &ShapeLayer { - children - .iter() - .find_map(|layer| match &layer.content { - LayerContent::Shape(shape) => Some(shape), - _ => None, - }) - .expect("a shape layer") -} - -/// Asserts `color` is the fixture's red ink. -fn assert_red(color: &Color, what: &str) { - assert!(color.c[0] > 0.8 && color.c[1] < 0.2 && color.c[2] < 0.2, "{what} should be red, got {:?}", color.c); -} - -/// Asserts `color` is the fixture's green ink. -fn assert_green(color: &Color, what: &str) { - assert!(color.c[1] > 0.8 && color.c[0] < 0.2 && color.c[2] < 0.2, "{what} should be green, got {:?}", color.c); -} - -/// Imports `bytes`, or prints SKIPPED and returns `None` when PDFium cannot load. The Windows -/// integration leg sets `PHOTOCRAFT_REQUIRE_PDFIUM`, where a missing library must fail. -fn imported_or_skipped(bytes: &[u8], options: &ImportOptions, ctl: &Interrupt<'_>, what: &str) -> Option { - match import_with_options_and_interrupt("probe.pdf", bytes, options, ctl) { - Ok(result) => Some(result), - Err(IoError::Unsupported(message)) if message.contains("cannot load PDFium") => { - eprintln!("SKIPPED {what}: PDFium is unavailable ({message})"); - assert!(std::env::var_os("PHOTOCRAFT_REQUIRE_PDFIUM").is_none(), "PDFium is required for {what} but could not load: {message}"); - None - } - Err(error) => panic!("{what} failed: {error}"), - } -} - -/// Six one-page variants for the cancellation and fidelity probes: plain, a CropBox offset to -/// (10, 20, 210, 120), rotations of 90, 180 and 270 degrees, and an oversized CropBox -/// (-50, -50, 250, 150) around the 200 x 100 MediaBox. Each page draws a 40 x 40 blue rectangle -/// and a 10 x 10 red one at (20, 30). +/// Six one-page variants: plain, CropBox offset to (10, 20, 210, 120), rotations of 90/180/270 +/// degrees and an oversized CropBox (-50, -50, 250, 150) around the 200 x 100 MediaBox. Each page +/// draws a 40 x 40 blue rectangle at (20, 30) in PDF space. fn variant_pdf() -> Vec { - let mut pdf = PdfDocument::with_version("1.5"); - let pages_id = pdf.new_object_id(); let pages: [(Option, Option<[f32; 4]>); 6] = [ (None, None), (None, Some([10.0, 20.0, 210.0, 120.0])), @@ -242,9 +29,11 @@ fn variant_pdf() -> Vec { (Some(270), None), (None, Some([-50.0, -50.0, 250.0, 150.0])), ]; + let mut pdf = PdfDocument::with_version("1.5"); + let pages_id = pdf.new_object_id(); let mut kids = Vec::new(); for (rotate, crop) in pages { - let content = b"0 0 1 rg 20 30 40 40 re f 1 0 0 rg 20 30 10 10 re f"; + let content = b"0 0 1 rg 20 30 40 40 re f"; let content_id = pdf.add_object(Stream::new(dictionary! {}, content.to_vec())); let mut page = dictionary! { "Type" => "Page", @@ -277,8 +66,102 @@ fn variant_pdf() -> Vec { bytes } -/// A cancel that lands on the last object's check, or on the public boundary after -/// `import_stages`, must be reported as `Cancelled` rather than returning a document. +/// One page of `width` x `height` points with the same blue rectangle. +fn one_page_pdf(width: f32, height: f32) -> Vec { + let mut pdf = PdfDocument::with_version("1.5"); + let pages_id = pdf.new_object_id(); + let content_id = pdf.add_object(Stream::new(dictionary! {}, b"0 0 1 rg 20 30 40 40 re f".to_vec())); + let page_id = pdf.add_object(dictionary! { + "Type" => "Page", + "Parent" => pages_id, + "MediaBox" => Object::Array(vec![Object::Real(0.0), Object::Real(0.0), Object::Real(width), Object::Real(height)]), + "Contents" => content_id, + "Resources" => dictionary! {}, + }); + pdf.objects.insert( + pages_id, + Object::Dictionary(dictionary! { + "Type" => "Pages", + "Kids" => Object::Array(vec![Object::Reference(page_id)]), + "Count" => 1, + }), + ); + let catalog = pdf.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id }); + pdf.trailer.set("Root", catalog); + let mut bytes = Vec::new(); + pdf.save_to(&mut bytes).expect("writing the page PDF"); + bytes +} + +/// The first artboard rectangle of `document`. +fn first_artboard(document: &photocraft_doc::Document) -> Rect { + let layer = document.layers.first().expect("an imported page group"); + let group = match &layer.content { + LayerContent::Group(group) => group, + _ => panic!("{} is not a group", layer.name), + }; + group.artboard.as_ref().expect("page artboard").rect +} + +#[test] +fn imports_every_page_as_an_artboard_with_its_print_size() { + let pdf = variant_pdf(); + for dpi in [72.0f32, 300.0] { + let options = ImportOptions { pdf_dpi: dpi, ..Default::default() }; + let result = import_with_options_and_interrupt("variants.pdf", &pdf, &options, &Interrupt::NONE).expect("PDF import"); + assert_eq!(result.document.layers.len(), 6, "one artboard per page at {dpi} dpi"); + let mut top_left = 0i32; + for (index, layer) in result.document.layers.iter().enumerate() { + let group = match &layer.content { + LayerContent::Group(group) => group, + _ => panic!("{} is not a page group", layer.name), + }; + let artboard = group.artboard.as_ref().expect("page artboard").rect; + let expected = match index { + // CropBox [10, 20, 210, 120] intersected with the MediaBox is 190 x 80 points. + 1 => (190.0, 80.0), + // Rotations 90 and 270 swap the axes. + 2 | 4 => (100.0, 200.0), + _ => (200.0, 100.0), + }; + let print_width = f64::from(artboard.width()) * 72.0 / f64::from(dpi); + let print_height = f64::from(artboard.height()) * 72.0 / f64::from(dpi); + assert!((print_width - expected.0).abs() < 1.0, "page {} width is {print_width:.1} pt, expected {} at {dpi} dpi", index + 1, expected.0); + assert!((print_height - expected.1).abs() < 1.0, "page {} height is {print_height:.1} pt, expected {} at {dpi} dpi", index + 1, expected.1); + assert_eq!(artboard.x0, top_left, "pages lay out side by side from x=0"); + top_left = artboard.x1 + 80; + let surface = group.children.first().and_then(|layer| layer.surface()).expect("page raster layer"); + if index == 0 { + // The rectangle's centre in PDF space is (40, 50); at the document's top-left + // origin that is y = PAGE_H - 50. + let scale = f64::from(dpi) / 72.0; + let x = artboard.x0 + (40.0 * scale).round() as i32; + let y = ((PAGE_H - 50.0) * scale).round() as i32; + let mut pixel = [[0u8; 4]; 1]; + surface.read_rgba8_into(Rect::from_xywh(x, y, 1, 1), &mut pixel); + assert!(pixel[0][2] > 120 && pixel[0][0] < 120, "page 1 should be blue inside the rectangle, got {:?}", pixel[0]); + let mut background = [[0u8; 4]; 1]; + surface.read_rgba8_into(Rect::from_xywh(artboard.x0 + 2, 2, 1, 1), &mut background); + assert!(background[0][0] > 240 && background[0][1] > 240 && background[0][2] > 240, "page background should be white, got {:?}", background[0]); + } + } + } +} + +#[test] +fn huge_pages_lower_the_effective_dpi_within_the_renderer_bounds() { + // 3000 pt at 300 dpi would be 12500 px wide, over craft-pdf's 8192 px side limit. + let pdf = one_page_pdf(3000.0, 100.0); + let options = ImportOptions { pdf_dpi: 300.0, ..Default::default() }; + let result = import_with_options_and_interrupt("wide.pdf", &pdf, &options, &Interrupt::NONE).expect("PDF import"); + assert!(result.warnings.iter().any(|warning| warning.contains("rendered at")), "warnings: {:?}", result.warnings); + let artboard = first_artboard(&result.document); + assert!(artboard.width() <= 8192, "width {} is over the renderer's side limit", artboard.width()); + assert!(result.document.resolution_dpi < 300.0, "the effective DPI should be lowered"); + let print_width = f64::from(artboard.width()) * 72.0 / f64::from(result.document.resolution_dpi); + assert!((print_width - 3000.0).abs() < 1.0, "print width {print_width:.1} pt should stay 3000 pt"); +} + #[test] fn cancelling_on_the_last_checks_is_reported() { let pdf = variant_pdf(); @@ -289,17 +172,16 @@ fn cancelling_on_the_last_checks_is_reported() { false }; let ctl = Interrupt::cancel_only(&cancel); - if imported_or_skipped(&pdf, &options, &ctl, "the last-check cancellation test").is_none() { - return; - } + let result = import_with_options_and_interrupt("variants.pdf", &pdf, &options, &ctl); + assert!(result.is_ok(), "uncancelled import failed: {:?}", result.err()); let total = count.load(Ordering::SeqCst); assert!(total >= 3, "a complete import should check cancellation at least three times, got {total}"); - for (label, threshold) in [("the last per-object check", total - 1), ("the public boundary check", total)] { + for (label, threshold) in [("the last page check", total - 1), ("the public boundary check", total)] { let seen = AtomicUsize::new(0); let cancel = || seen.fetch_add(1, Ordering::SeqCst) + 1 >= threshold; let ctl = Interrupt::cancel_only(&cancel); let started = Instant::now(); - let result = import_with_options_and_interrupt("probe.pdf", &pdf, &options, &ctl); + let result = import_with_options_and_interrupt("variants.pdf", &pdf, &options, &ctl); let elapsed = started.elapsed(); assert_eq!(seen.load(Ordering::SeqCst), threshold, "cancelling on {label} should stop at check {threshold} of {total}"); match result { @@ -310,52 +192,3 @@ fn cancelling_on_the_last_checks_is_reported() { assert!(elapsed < Duration::from_secs(10), "cancelling on {label} took {elapsed:?}"); } } - -/// Every imported page's stored raster must equal a direct PDFium render of the same page at the -/// same target width: crop intersection, rotation and the strip placement may not move or resample -/// a pixel. At 72, 150 and 300 DPI over the six variants that is 18 page renders. -#[test] -fn raster_matches_a_direct_pdfium_render() { - let pdf = variant_pdf(); - let mut compared = 0; - for dpi in [72.0f32, 150.0, 300.0] { - let options = ImportOptions { pdf_dpi: dpi, ..Default::default() }; - let Some(result) = imported_or_skipped(&pdf, &options, &Interrupt::NONE, "the raster fidelity matrix") else { - return; - }; - let pdfium = Pdfium::default(); - let reference = pdfium.load_pdf_from_byte_slice(&pdf, None).expect("reference PDF"); - assert_eq!(reference.pages().len() as usize, result.document.layers.len(), "page count at {dpi} dpi"); - for (index, layer) in result.document.layers.iter().enumerate() { - let group = match &layer.content { - LayerContent::Group(group) => group, - _ => panic!("page layer {} is not a group", layer.name), - }; - let artboard = group.artboard.as_ref().expect("page artboard").rect; - let raster = group.children.first().and_then(|layer| layer.surface()).expect("page raster layer"); - let (width, height) = (artboard.width(), artboard.height()); - let mut got = vec![[0u8; 4]; (width as usize) * (height as usize)]; - raster.read_rgba8_into(Rect::from_xywh(artboard.x0, artboard.y0, width, height), &mut got); - - let page = reference.pages().get(index as i32).expect("reference page"); - let bitmap = page.render_with_config(&PdfRenderConfig::new().set_target_width(width as i32)).expect("reference render"); - assert_eq!((bitmap.width() as u32, bitmap.height() as u32), (width, height), "page {} size at {dpi} dpi", index + 1); - let want = bitmap.as_rgba_bytes(); - - let mut max_diff = 0u32; - let mut sum = 0u64; - for (pixel, reference) in got.iter().zip(want.as_chunks::<4>().0.iter()) { - for channel in 0..4 { - let diff = (pixel[channel] as i32 - reference[channel] as i32).unsigned_abs(); - sum += u64::from(diff); - max_diff = max_diff.max(diff); - } - } - let channels = u64::from(width) * u64::from(height) * 4; - let mean = sum as f64 / channels as f64; - assert!(max_diff <= 1, "page {} at {dpi} dpi differs from the direct render: max channel diff {max_diff}, mean {mean:.4}", index + 1); - compared += 1; - } - } - assert_eq!(compared, 18, "six pages at three DPIs should all be compared"); -} diff --git a/docs/architecture.md b/docs/architecture.md index 0125f26693..ee9650cb10 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -183,7 +183,7 @@ photocraft/ 3. **`photocraft-psd` depends on no workspace crate.** The doc ⇄ PSD mapping lives in `io`. This keeps the PSD crate publishable and reusable by other projects. The same holds for every standalone crate, with one documented exception: `codecs` → `heif` (both standalone and publishable; `STANDALONE_EXCEPTIONS` in `xtask/src/layers.rs`). 4. **wasm gate:** every crate in L0–L5 (except feature-gated native backends) must build for `wasm32-unknown-unknown`. CI runs `cargo build -p photocraft-engine --target wasm32-unknown-unknown --no-default-features --features web`. 5. `gpu` is optional for `engine`. Engine features are `gpu` (default on) and `cpu-only`, and `cpu-only` builds are what the headless CLI and CI tests use. -6. **C dependencies** (optional LibRaw, pdfium) only behind features, only in `codecs`, `raw` or `io`, and never on by default for the web target. +6. **C dependencies** (optional LibRaw) only behind features, only in `codecs`, `raw` or `io`, and never on by default for the web target. --- diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 468365c6f6..02a2da7f79 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -40,7 +40,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | Portable Bit Map (PBM/PGM/PPM/PNM/PFM/PAM) | R/W | yes | yes | 8/16/32f | | FITS | R/W (FITS Liberator plug-in) | yes | yes | 8/16/32f gray or RGB, header cards kept; no fpack, no debayer | | Targa | R/W | yes | yes | 8-bit | -| Photoshop PDF | R/W | no (#2541) | yes, one flattened page (Save As / Save a Copy / CLI, #3401; also print and artboards-to-PDF) | No vector/text PDF, no multi-page save, no PDF presentation, layers are not preserved (FILE-215-10); read still missing (#2541) | +| Photoshop PDF | R/W | yes, raster pages (craft-pdf; up to 16 pages, 8192 px per side, 64M px per page) | yes, one flattened page (Save As / Save a Copy / CLI, #3401; also print and artboards-to-PDF) | Read renders each page as a raster artboard: no editable text/vector layers yet (craft-pdf `interpret` is the follow-up), no PDF forms or annotations; write is still one flattened page, no multi-page save, no PDF presentation, layers are not preserved (FILE-215-10) | | Photoshop EPS, DCS 1.0/2.0, EPS TIFF/PICT preview | R/W | no | no | | | Generic EPS / AI | R (rasterize) | no | no | | | SVG | R (rasterize) | yes, as editable shape layers or a vector smart object | no | Better than Photoshop for editing; gradients approximated (FILE-215-9) | @@ -145,6 +145,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. Embedded SVG and PDF-compatible AI/PDF/EPS payloads can use an explicit VectorCraft editor choice, including nested PSB contents. AI/PDF/EPS working copies retain their format on save-back; a separate source-bound SVG display preview does not replace the native payload. Placement, shared instances, masks and Undo/Redo use existing smart-object transactions, with native bytes and previews persisted through .pcraft and PSD/PSB saves. Unsupported editor features may still change; standalone EPS/DCS import and lossless Adobe round-trip compatibility remain unverified. See [workflow, safety and validation limits](embedded-vector-editing.md). No corpus floor or parity percentage is changed. ## Revision history +| 2026-11-07 | minor | Photoshop PDF read: pages import as raster artboards through the shared craft-pdf (Hayro) renderer, up to 16 pages, 8192 px per side and 64M px per page; editable text/vectors and PDFium are gone. Write is unchanged. | The original reference row retains its source date and historical totals; current totals are in the status and Actions row above. The original reference entry retains its source date and historical totals; current totals are in the status above. diff --git a/packaging/windows/install-policy.json b/packaging/windows/install-policy.json index 8d01fab4ac..fcba1850ae 100644 --- a/packaging/windows/install-policy.json +++ b/packaging/windows/install-policy.json @@ -15,8 +15,7 @@ ], "payloads": [ "photocraft-cli.exe", - "photocraft.exe", - "pdfium.dll" + "photocraft.exe" ], "registry": [ [ diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index 8b4e8d0fea..d984e3930a 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -55,77 +55,6 @@ New-Item -ItemType Directory -Force -Path $Dist | Out-Null if (-not $env:PHOTOCRAFT_BUILD_SHA) { $env:PHOTOCRAFT_BUILD_SHA = (git -C $Root rev-parse HEAD 2>$null) } if (-not $env:PHOTOCRAFT_BUILD_DATE) { $env:PHOTOCRAFT_BUILD_DATE = (Get-Date).ToUniversalTime().ToString('yyyy-MM-dd') } -# PDF import loads PDFium at runtime. Stage the Windows build's DLL before cargo, so the app's -# build script copies it beside the exe and both the MSI and the portable ZIP ship it. -# -# Pinned to a reviewed pdfium-binaries release: the archive is verified before extraction and a -# cached DLL is verified before reuse, so a moved or compromised "latest" cannot be packaged. -# Bump the release and both hashes together after re-checking the published build. -$PdfiumRelease = 'chromium/8138' -$PdfiumArchiveSha256 = @{ - x64 = '8d48973e3eb472dda0f5f2435e595ac043f9f2219dd71883392f549445afa8a5' - x86 = '5c32d44711a73ed8ae86b85322078da8444fc696b9326527eed2d9fcd620fc84' - arm64 = '5e04abdb36ba9b4b5c8630123eaf042036f8a62abda6e5529708fee3d35fbf4f' -} -$PdfiumDllSha256 = @{ - x64 = '71a3e63e6ab8f5746a0fc9de9bc3d0c0dc07eb60990dfd37d9db7fc891821943' - x86 = 'ee592bd01eaef454aada95ed483ceca1c4f6ce0f0b9568608f40c3861fbda035' - arm64 = '0b1b9a52b3297448cbe461ffbca1455603d85161d5e96090e1a89bd7c4a62a33' -} -$PdfiumDir = Join-Path $Root "target\pdfium\$Arch" -$PdfiumDll = Join-Path $PdfiumDir 'pdfium.dll' -$PdfiumLicense = Join-Path $PdfiumDir 'LICENSE' -$PdfiumLicenses = Join-Path $PdfiumDir 'licenses' -$PdfiumTag = Join-Path $PdfiumDir 'release.txt' - -function Get-Sha256([string] $Path) { (Get-FileHash -Algorithm SHA256 -LiteralPath $Path).Hash.ToLowerInvariant() } - -# A cache from another pinned release (or missing its licence set) is discarded whole. -$cachedRelease = if (Test-Path -LiteralPath $PdfiumTag) { (Get-Content -Raw -LiteralPath $PdfiumTag).Trim() } else { '' } -if ($cachedRelease -ne $PdfiumRelease) { - Remove-Item -LiteralPath $PdfiumDir -Recurse -Force -ErrorAction SilentlyContinue -} -if (Test-Path -LiteralPath $PdfiumDll) { - $actualDll = Get-Sha256 $PdfiumDll - if ($actualDll -ne $PdfiumDllSha256[$Arch]) { - Write-Warning "cached PDFium DLL is not the pinned $PdfiumRelease build ($actualDll); re-downloading" - Remove-Item -LiteralPath $PdfiumDll -Force - } -} -if (-not ((Test-Path -LiteralPath $PdfiumDll) -and (Test-Path -LiteralPath $PdfiumLicense) -and (Test-Path -LiteralPath $PdfiumLicenses))) { - $expectedArchive = $PdfiumArchiveSha256[$Arch] - if (-not $expectedArchive) { throw "no pinned PDFium checksum for $Arch" } - $tgz = Join-Path $env:TEMP "pdfium-win-$Arch.tgz" - Invoke-Native "download PDFium ($Arch, $PdfiumRelease)" { - Remove-Item -LiteralPath $tgz -Force -ErrorAction SilentlyContinue - Invoke-WebRequest -Uri "https://github.com/bblanchon/pdfium-binaries/releases/download/$PdfiumRelease/pdfium-win-$Arch.tgz" -OutFile $tgz - $actualArchive = Get-Sha256 $tgz - if ($actualArchive -ne $expectedArchive) { throw "PDFium $Arch archive checksum $actualArchive does not match the pinned $expectedArchive" } - New-Item -ItemType Directory -Force -Path $PdfiumDir | Out-Null - # A DLL left in bin\ by an earlier run must not be mistaken for this archive's extraction. - $extracted = Join-Path $PdfiumDir 'bin\pdfium.dll' - Remove-Item -LiteralPath $extracted -Force -ErrorAction SilentlyContinue - tar -xzf $tgz -C $PdfiumDir - if ($LASTEXITCODE -ne 0) { throw "PDFium $Arch archive extraction failed with exit code $LASTEXITCODE" } - if (-not (Test-Path -LiteralPath $extracted)) { throw 'PDFium archive had no bin/pdfium.dll' } - Move-Item -Force $extracted $PdfiumDll - Set-Content -LiteralPath $PdfiumTag -Value $PdfiumRelease -NoNewline - if ((Get-Sha256 $PdfiumDll) -ne $PdfiumDllSha256[$Arch]) { throw "extracted PDFium $Arch DLL checksum does not match the pinned build" } - Remove-Item -LiteralPath $tgz -Force - } -} -$env:PDFIUM_DLL = $PdfiumDll - -# PDFium's BSD licence and the licences of the libraries bundled into its DLL ship beside it. They -# are staged flat with a `pdfium-` prefix, so both the MSI's INSTALLFOLDER and the portable root -# carry them whether the DLL came from this download or the cache. -function Stage-PdfiumLicences([string] $Dest) { - Copy-Item -LiteralPath $PdfiumLicense -Destination (Join-Path $Dest 'pdfium-LICENSE.txt') -Force - Get-ChildItem -LiteralPath $PdfiumLicenses -File | ForEach-Object { - Copy-Item -LiteralPath $_.FullName -Destination (Join-Path $Dest ("pdfium-license-" + $_.Name)) -Force - } -} - Write-Output "PhotoCraft $Version for Windows $Arch ($Target)" if (-not $SkipBuild) { @@ -160,8 +89,6 @@ $Stage = Join-Path $TargetDir "windows-package\$Arch" Remove-Item -Recurse -Force $Stage -ErrorAction SilentlyContinue New-Item -ItemType Directory -Force -Path $Stage | Out-Null Copy-Item (Join-Path $Bin 'photocraft.exe'), (Join-Path $Bin 'photocraft-cli.exe') $Stage -Copy-Item -LiteralPath $PdfiumDll -Destination $Stage -Stage-PdfiumLicences $Stage & (Join-Path $PSScriptRoot 'sign.ps1') (Join-Path $Stage 'photocraft.exe') (Join-Path $Stage 'photocraft-cli.exe') @@ -244,8 +171,6 @@ $Portable = Join-Path $TargetDir "windows-package\photocraft-$Version-windows-$A Remove-Item -Recurse -Force $Portable -ErrorAction SilentlyContinue New-Item -ItemType Directory -Force -Path $Portable | Out-Null Copy-Item (Join-Path $Stage '*.exe') $Portable -Copy-Item -LiteralPath $PdfiumDll -Destination $Portable -Stage-PdfiumLicences $Portable foreach ($f in 'README.md', 'LICENSE', 'LICENSE-MIT', 'LICENSE-APACHE', 'NOTICE', 'ATTRIBUTION.md') { $p = Join-Path $Root $f if (Test-Path $p) { Copy-Item $p $Portable } diff --git a/packaging/windows/photocraft.wxs b/packaging/windows/photocraft.wxs index f727880a5a..6ff961fd99 100644 --- a/packaging/windows/photocraft.wxs +++ b/packaging/windows/photocraft.wxs @@ -33,7 +33,6 @@ - - - - - - From 2de7a9c042d2572a9a166971d11c07446d3a9b90 Mon Sep 17 00:00:00 2001 From: Triwarr <170976175+Triwarr@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:41:28 +0100 Subject: [PATCH 112/219] Revert "Export: split nested groups into separate layers/files" This reverts commit c1662d49116d73cf1760d37f7089a232a5159c6e. --- crates/engine/src/cmd/file_cmds/export.rs | 162 +++++----------------- crates/engine/src/cmd/file_cmds/tests.rs | 61 -------- 2 files changed, 38 insertions(+), 185 deletions(-) diff --git a/crates/engine/src/cmd/file_cmds/export.rs b/crates/engine/src/cmd/file_cmds/export.rs index 629b7aad82..1f7f9677ff 100644 --- a/crates/engine/src/cmd/file_cmds/export.rs +++ b/crates/engine/src/cmd/file_cmds/export.rs @@ -20,7 +20,7 @@ pub fn export_layers_with(s: &Session, p: &Value, write: impl FnMut(&str, &[u8]) export_layers_report_with(s, p, write).map(|report| report.files) } -/// A layer omitted from a successful Layers to Files export. +/// A top-level layer omitted from a successful Layers to Files export. #[derive(Debug, serde::Serialize)] pub struct SkippedExportLayer { /// Session layer id (names need not be unique). @@ -38,83 +38,14 @@ pub struct LayerExportReport { pub skipped: Vec, } -/// One layer the export will write or explain, in top-to-bottom order. -enum Planned<'a> { - Export(&'a Layer), - Skip(&'a Layer, &'static str), - Hidden(&'a Layer), -} - -/// Flattens a layer (and, with `split_groups`, its group descendants) into export entries. -fn plan_layer<'a>(layer: &'a Layer, split_groups: bool, visible_only: bool, plan: &mut Vec>) { - if !layer.visible && visible_only { - plan.push(Planned::Hidden(layer)); - // A hidden group takes its descendants with it; report each so the omission is complete. - if split_groups && let LayerContent::Group(group) = &layer.content { - for child in group.children.iter().rev() { - plan_hidden(child, plan); - } - } - return; - } - if let LayerContent::Group(group) = &layer.content - && split_groups - { - for child in group.children.iter().rev() { - plan_layer(child, split_groups, visible_only, plan); - } - return; - } - if matches!(layer.content, LayerContent::Adjustment(_)) { - plan.push(Planned::Skip(layer, "adjustment layer has no pixels of its own")); - } else { - plan.push(Planned::Export(layer)); - } -} - -/// Marks `layer` and all of its descendants as hidden, top to bottom. -fn plan_hidden<'a>(layer: &'a Layer, plan: &mut Vec>) { - plan.push(Planned::Hidden(layer)); - if let LayerContent::Group(group) = &layer.content { - for child in group.children.iter().rev() { - plan_hidden(child, plan); - } - } -} - -/// The same export as [`export_layers_with`], with an explanation for every omitted layer. -/// -/// `groups` chooses what a group produces: `"merged"` (the default) exports the group as one -/// file, as before; `"layers"` walks into the group and exports each visible layer inside it as -/// its own file (top to bottom), so no group is a file of its own. Encoding and write failures -/// remain errors, never successful skips. Existing numbering is preserved. +/// The same export as [`export_layers_with`], with an explanation for every omitted top-level +/// layer. Groups export merged, so their children are not separate exports or skips. Encoding +/// and write failures remain errors, never successful skips. Existing numbering is preserved. pub fn export_layers_report_with(s: &Session, p: &Value, mut write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result { let cmd = "file.export.layersToFiles"; let format = p.get("format").and_then(Value::as_str).unwrap_or("png").trim_start_matches('.').to_ascii_lowercase(); let trim = p.get("trim").and_then(Value::as_bool).unwrap_or(false); let visible_only = p.get("visibleOnly").and_then(Value::as_bool).unwrap_or(true); - let split_groups = match p.get("groups") { - None => false, - Some(Value::String(s)) => match s.to_ascii_lowercase().as_str() { - "merged" => false, - "layers" => true, - other => { - return Err(EngineError::BadParams { cmd: cmd.into(), msg: format!("groups must be \"merged\" or \"layers\", not {other:?}") }); - } - }, - Some(_) => return Err(EngineError::BadParams { cmd: cmd.into(), msg: "groups must be a string: \"merged\" or \"layers\"".into() }), - }; - let layer_names = match p.get("naming") { - None => false, - Some(Value::String(s)) => match s.to_ascii_lowercase().as_str() { - "numbered" => false, - "layer" => true, - other => { - return Err(EngineError::BadParams { cmd: cmd.into(), msg: format!("naming must be \"numbered\" or \"layer\", not {other:?}") }); - } - }, - Some(_) => return Err(EngineError::BadParams { cmd: cmd.into(), msg: "naming must be a string: \"numbered\" or \"layer\"".into() }), - }; // Callers outside `Session::execute` (headless workspace exports) carry the session's gate // into `encode`, so a PSD embeds no file a linked smart object names. let _authorization = crate::jobs::render_authorization_scope(s.authorize); @@ -122,65 +53,48 @@ pub fn export_layers_report_with(s: &Session, p: &Value, mut write: impl FnMut(& let d = s.active().ok_or(EngineError::NoDocument)?; let prefix = p.get("prefix").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| stem(&d.doc.name)); let doc = d.doc.clone(); - let mut plan = Vec::new(); - for l in doc.layers.iter().rev() { - plan_layer(l, split_groups, visible_only, &mut plan); - } let mut report = LayerExportReport::default(); - let mut used = std::collections::HashSet::new(); // Hidden layers never consumed a filename index; adjustments that pass the visibility // filter did. Keep those filenames stable while reporting both kinds of omission. let mut i = 0usize; - for entry in plan { - match entry { - Planned::Hidden(l) => report.skipped.push(SkippedExportLayer { layer: l.id.0, name: l.name.clone(), reason: "hidden by visibleOnly" }), - Planned::Skip(l, reason) => { - i += 1; - report.skipped.push(SkippedExportLayer { layer: l.id.0, name: l.name.clone(), reason }); - } - Planned::Export(l) => { - let index = i; - i += 1; - let mut one = (*doc).clone(); - let mut only = l.clone(); - only.visible = true; - only.clipped = false; - one.layers = vec![only]; - if trim { - let mut bounds = Rect::EMPTY; - let _ = photocraft_compose::render_bands(&one, one.bounds(), 0, |band| -> std::result::Result<(), ()> { - let width = band.rect.width() as usize; - for (i, rgba) in band.px.iter().enumerate() { - if rgba[3] > 0.0 && width > 0 { - let x = band.rect.x0 + (i % width) as i32; - let y = band.rect.y0 + (i / width) as i32; - bounds = bounds.union(&Rect::new(x, y, x + 1, y + 1)); - } - } - Ok(()) - }); - if !bounds.is_empty() && bounds != one.bounds() { - crate::image_cmds::crop_doc(&mut one, cmd, bounds, true, [0.0; 4])?; + for l in doc.layers.iter().rev() { + if !l.visible && visible_only { + report.skipped.push(SkippedExportLayer { layer: l.id.0, name: l.name.clone(), reason: "hidden by visibleOnly" }); + continue; + } + let index = i; + i += 1; + if matches!(l.content, LayerContent::Adjustment(_)) { + report.skipped.push(SkippedExportLayer { layer: l.id.0, name: l.name.clone(), reason: "adjustment layer has no pixels of its own" }); + continue; + } + let mut one = (*doc).clone(); + let mut only = l.clone(); + only.visible = true; + only.clipped = false; + one.layers = vec![only]; + if trim { + let mut bounds = Rect::EMPTY; + let _ = photocraft_compose::render_bands(&one, one.bounds(), 0, |band| -> std::result::Result<(), ()> { + let width = band.rect.width() as usize; + for (i, rgba) in band.px.iter().enumerate() { + if rgba[3] > 0.0 && width > 0 { + let x = band.rect.x0 + (i % width) as i32; + let y = band.rect.y0 + (i / width) as i32; + bounds = bounds.union(&Rect::new(x, y, x + 1, y + 1)); } } - - let name = if layer_names { - let base = sanitize(&l.name); - let mut name = format!("{base}.{format}"); - let mut n = 2; - while !used.insert(name.to_ascii_lowercase()) { - name = format!("{base}-{n}.{format}"); - n += 1; - } - name - } else { - format!("{}_{:04}_{}.{format}", sanitize(&prefix), index, sanitize(&l.name)) - }; - let (bytes, _warnings) = encode(&one, &name, save)?; - write(&name, &bytes)?; - report.files.push(name); + Ok(()) + }); + if !bounds.is_empty() && bounds != one.bounds() { + crate::image_cmds::crop_doc(&mut one, cmd, bounds, true, [0.0; 4])?; } } + + let name = format!("{}_{:04}_{}.{format}", sanitize(&prefix), index, sanitize(&l.name)); + let (bytes, _warnings) = encode(&one, &name, save)?; + write(&name, &bytes)?; + report.files.push(name); } Ok(report) } diff --git a/crates/engine/src/cmd/file_cmds/tests.rs b/crates/engine/src/cmd/file_cmds/tests.rs index 0703cf914e..d42d333128 100644 --- a/crates/engine/src/cmd/file_cmds/tests.rs +++ b/crates/engine/src/cmd/file_cmds/tests.rs @@ -2573,67 +2573,6 @@ fn layers_to_files_reports_skipped_layers_without_changing_files() { } } -#[test] -/// `groups: "layers"` walks into a group and writes each visible leaf as its own file. -fn layers_to_files_exports_group_children_as_loose_files() { - let dir = tmp("layers-groups"); - let mut s = session(8, 8, 8); - s.edit("Group", |doc, _| { - let fmt = doc.pixel_format(); - let child = |name: &str| photocraft_doc::Layer::new(name, photocraft_doc::LayerContent::Raster(photocraft_doc::Surface::new(fmt))); - let sub = photocraft_doc::Layer::new( - "sub", - photocraft_doc::LayerContent::Group(photocraft_doc::Group { children: vec![child("c")], expanded: true, artboard: None, frame_stroke: None }), - ); - doc.layers.push(photocraft_doc::Layer::new( - "expanded", - photocraft_doc::LayerContent::Group(photocraft_doc::Group { - children: vec![child("a"), child("b"), sub], - expanded: true, - artboard: None, - frame_stroke: None, - }), - )); - Ok(()) - }) - .unwrap(); - let files = |r: &Value| -> Vec { r["files"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect() }; - - let r = s.execute("file.export.layersToFiles", json!({"dir": dir, "prefix": "loose", "groups": "layers"})).unwrap(); - assert_eq!( - files(&r), - vec![join(&dir, "loose_0000_c.png"), join(&dir, "loose_0001_b.png"), join(&dir, "loose_0002_a.png"), join(&dir, "loose_0003_Background.png")] - ); - assert!(files(&r).iter().all(|f| std::path::Path::new(f).is_file())); - - // The default still merges the group into one file. - let r = s.execute("file.export.layersToFiles", json!({"dir": dir, "prefix": "merged"})).unwrap(); - assert_eq!(files(&r), vec![join(&dir, "merged_0000_expanded.png"), join(&dir, "merged_0001_Background.png")]); - - // naming: "layer" writes the bare layer names instead of prefix_index_name. - let r = s.execute("file.export.layersToFiles", json!({"dir": dir, "groups": "layers", "naming": "layer"})).unwrap(); - assert_eq!(files(&r), vec![join(&dir, "c.png"), join(&dir, "b.png"), join(&dir, "a.png"), join(&dir, "Background.png")]); - - // A hidden child is skipped with visibleOnly and exported without it. - s.edit("Hide b", |doc, _| { - let group = doc.layers.last_mut().ok_or_else(|| EngineError::Other("no group".into()))?; - let LayerContent::Group(g) = &mut group.content else { return Err(EngineError::Other("not a group".into())) }; - g.children[1].visible = false; - Ok(()) - }) - .unwrap(); - let r = s.execute("file.export.layersToFiles", json!({"dir": dir, "prefix": "visible", "groups": "layers"})).unwrap(); - assert_eq!(files(&r), vec![join(&dir, "visible_0000_c.png"), join(&dir, "visible_0001_a.png"), join(&dir, "visible_0002_Background.png")]); - let r = s.execute("file.export.layersToFiles", json!({"dir": dir, "prefix": "all", "groups": "layers", "visibleOnly": false})).unwrap(); - assert_eq!( - files(&r), - vec![join(&dir, "all_0000_c.png"), join(&dir, "all_0001_b.png"), join(&dir, "all_0002_a.png"), join(&dir, "all_0003_Background.png")] - ); - - // An unknown mode is rejected instead of silently merging. - assert!(s.execute("file.export.layersToFiles", json!({"dir": dir, "groups": "folders"})).is_err()); -} - #[test] fn layers_to_files_reports_groups_as_exports_and_failures_as_errors() { let mut s = session(4, 4, 8); From 060d35265a1a65a9f25f555b5f2d924208425209 Mon Sep 17 00:00:00 2001 From: Triwarr <170976175+Triwarr@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:46:18 +0100 Subject: [PATCH 113/219] PDF: keep the raster importer clippy-clean and refresh the scorecard - Iterate the page sizes with their index instead of indexing the vector (clippy::needless_range_loop). - Fix the .ai dispatch test's module path. - Regenerate docs/scorecard.md after the merge (10 of 197 settings). --- crates/io/src/lib.rs | 2 +- crates/io/src/pdf_import.rs | 3 +-- docs/scorecard.md | 4 ++-- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 7519449224..195a58b01a 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -1651,7 +1651,7 @@ mod tests { pdf.trailer.set("Root", catalog); let mut bytes = Vec::new(); pdf.save_to(&mut bytes).expect("writing the .ai fixture"); - assert!(pdf_import::is_pdf(&bytes)); + assert!(crate::pdf_import::is_pdf(&bytes)); assert_eq!(import_preflight("illustration.ai", &bytes), ImportPreflight::Unbounded); let result = import("illustration.ai", &bytes).expect("an .ai starting with %PDF- takes the PDF importer"); assert_eq!(result.document.artboards().len(), 1); diff --git a/crates/io/src/pdf_import.rs b/crates/io/src/pdf_import.rs index 0fcc540749..fc4733908a 100644 --- a/crates/io/src/pdf_import.rs +++ b/crates/io/src/pdf_import.rs @@ -120,11 +120,10 @@ pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) - let mut doc = Document::new(name, Size::new(doc_width as u32, doc_height as u32), ColorMode::Rgb, SampleType::U8); doc.resolution_dpi = dpi as f32; let mut x: u32 = 0; - for index in 0..count { + for (index, &(width_pt, height_pt)) in sizes.iter().enumerate() { ctl.check().map_err(|_| IoError::Cancelled)?; let page_number = first + index; let page = &pdf.pages()[page_number - 1]; - let (width_pt, height_pt) = sizes[index]; let width = page_pixels(width_pt, dpi); let height = page_pixels(height_pt, dpi); // The DPI solve keeps these inside the renderer's bounds; re-check rather than panic if diff --git a/docs/scorecard.md b/docs/scorecard.md index cb666664b9..4ff5f86cd6 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Performance](#performance) | 3 | 22 | 51 | 85 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 47 not run, 4 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 11 | 14 | 2 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 190/258, round trip ≥ 258/258 | | [Tools](#tools) | 36 | 9 | 0 | | -| [Workspace and UI](#workspace-and-ui) | 15 | 6 | 2 | settings that do nothing: 10 of 194 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 15 | 6 | 2 | settings that do nothing: 10 of 197 (target 0) | | [Type](#type) | 5 | 5 | 0 | | | [Automation](#automation) | 5 | 3 | 3 | 662 of 662 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 30 of 30 crates; panic_hunt present | @@ -209,7 +209,7 @@ Corpus floors (`crates/io/tests/it/corpus.rs`): the enforced minimums; the corpu ## Workspace and UI -**Settings that do nothing: 10 of 194** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. +**Settings that do nothing: 10 of 197** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. Unread: `general.color_picker`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `performance.legacy_compositing`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `type_.text_engine`, `enhanced_controls.scrubby_slider_acceleration`. From c5a9288c4b6fe326922e3f3c9bfe32a599c0dfc5 Mon Sep 17 00:00:00 2001 From: Triwarr <170976175+Triwarr@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:52:35 +0100 Subject: [PATCH 114/219] PDF: keep the craft-pdf imports off the wasm build The module compiles for wasm (only its import entry point is stubbed), so the renderer imports and the native document types are gated; the web build no longer sees a dependency it does not have. --- crates/io/src/pdf_import.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/io/src/pdf_import.rs b/crates/io/src/pdf_import.rs index fc4733908a..c78d92018c 100644 --- a/crates/io/src/pdf_import.rs +++ b/crates/io/src/pdf_import.rs @@ -10,12 +10,19 @@ //! or shipped, and a malformed file is reported as an error rather than taking the process down. use crate::{ImportOptions, ImportResult, IoError}; +#[cfg(not(target_arch = "wasm32"))] use craft_pdf::interpret::InterpreterSettings; +#[cfg(not(target_arch = "wasm32"))] use craft_pdf::raster::{self, RenderCache}; +#[cfg(not(target_arch = "wasm32"))] use photocraft_color::{ColorMode, PixelFormat, SampleType}; +#[cfg(not(target_arch = "wasm32"))] use photocraft_doc::{Artboard, Document, Group, Layer, LayerContent}; +#[cfg(not(target_arch = "wasm32"))] use photocraft_geom::{Rect, Size}; -use photocraft_raster::{Interrupt, Surface}; +use photocraft_raster::Interrupt; +#[cfg(not(target_arch = "wasm32"))] +use photocraft_raster::Surface; /// `%PDF-` magic. `.ai` files that start with it are PDFs and render through this importer. pub fn is_pdf(bytes: &[u8]) -> bool { From d297dd3a2d068a664fa87dc5e22d56cd7bb323c3 Mon Sep 17 00:00:00 2001 From: Triwarr <170976175+Triwarr@users.noreply.github.com> Date: Sat, 7 Nov 2026 20:12:46 +0100 Subject: [PATCH 115/219] PDF: validate --pdf-dpi, band the raster conversion, complete the notices - --pdf-dpi now rejects zero, negative, NaN and infinite values at parse time (unit-tested) instead of silently falling back to 300 DPI. - The RGBA8 raster is converted and written in 256-row bands with a cancellation check per band, so the f32 buffer no longer duplicates a whole page; the pixel-memory estimate charges the retained raster instead of that buffer. - docs/file-format-parity.md: the 2026-11-07 revision row is inside the revision table. - NOTICE now reproduces the Hayro MIT notice, the Foxit/PDFium BSD-3-Clause text, Adobe's CMaps BSD-3-Clause text and the CC0 statement for the ICC profile; package.ps1 ships NOTICE and ATTRIBUTION.md in both installers. --- NOTICE | 37 ++++++++++++++++++++++++++++++++ apps/photocraft-cli/src/args.rs | 26 +++++++++++++++++++++- crates/io/src/pdf_import.rs | 28 ++++++++++++++---------- crates/io/tests/it/pdf_import.rs | 3 +++ docs/file-format-parity.md | 2 +- 5 files changed, 83 insertions(+), 13 deletions(-) diff --git a/NOTICE b/NOTICE index 826dea7490..d51db72c91 100644 --- a/NOTICE +++ b/NOTICE @@ -81,3 +81,40 @@ the standard-14 Foxit fonts (BSD-3-Clause), Adobe CMaps (BSD-3-Clause) and a Com profile by Clinton Ingram (CC0-1.0); per-file sources and hashes are in craft-libs ATTRIBUTION.md. No PDF assets are copied into this repository, and PDFium is no longer used or shipped. + +Licence texts for the optional PDF reader + +Hayro (MIT): +Copyright (c) The Hayro Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Standard-14 Foxit fonts (BSD-3-Clause): +Copyright 2014 PDFium Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. +* Neither the name of Google Inc. nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Adobe CMaps (BSD-3-Clause): +Copyright 1990-2023 Adobe. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +Neither the name of Adobe nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Compact ICC profile: CC0 1.0 Universal (public domain dedication), by Clinton Ingram (saucecontrol/Compact-ICC-Profiles). diff --git a/apps/photocraft-cli/src/args.rs b/apps/photocraft-cli/src/args.rs index 84cf7c0a88..a15019e666 100644 --- a/apps/photocraft-cli/src/args.rs +++ b/apps/photocraft-cli/src/args.rs @@ -114,7 +114,7 @@ pub(crate) struct ConvertArgs { #[arg(long)] pub(crate) approve_high_memory: bool, /// PDF import resolution in DPI (default 300) - #[arg(long)] + #[arg(long, value_name = "DPI", value_parser = pdf_dpi)] pub(crate) pdf_dpi: Option, /// PDF pages to import, 1-based: "3" or "2-7" (default all) #[arg(long)] @@ -320,6 +320,8 @@ enum ValueError { JpegFormat, #[error("expected on or off")] OnOff, + #[error("expected a positive number")] + PdfDpi, /// Why a `--bridge` value, or a PHOTOCRAFT_CONTROL_PORT value for `mcp`, is refused. #[error("expected a port, 0-65535 (--bridge also takes 127.0.0.1:)")] Bridge, @@ -388,6 +390,12 @@ fn on_off(s: &str) -> Result { } } +/// A PDF import DPI: a finite number greater than zero. The importer's fallback to 300 DPI is +/// for absent preferences, not for a mistyped flag. +fn pdf_dpi(s: &str) -> Result { + s.parse::().ok().filter(|dpi| dpi.is_finite() && *dpi > 0.0).ok_or(ValueError::PdfDpi) +} + fn jpeg_format(s: &str) -> Result { JpegFormat::parse(s).ok_or(ValueError::JpegFormat) } @@ -538,3 +546,19 @@ fn check_server(sub: &mut clap::Command, m: &ArgMatches, has_transport: bool, tr } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + /// `--pdf-dpi` only takes a positive finite number: a mistyped value is refused at parse time + /// instead of silently becoming the importer's 300 DPI fallback. + #[test] + fn pdf_dpi_rejects_non_positive_and_non_finite_values() { + assert!(pdf_dpi("300").is_ok()); + assert!(pdf_dpi("0.5").is_ok()); + for bad in ["0", "-1", "nan", "inf", "-inf", "abc"] { + assert!(pdf_dpi(bad).is_err(), "{bad} should be refused"); + } + } +} diff --git a/crates/io/src/pdf_import.rs b/crates/io/src/pdf_import.rs index c78d92018c..79db591088 100644 --- a/crates/io/src/pdf_import.rs +++ b/crates/io/src/pdf_import.rs @@ -101,10 +101,10 @@ pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) - if doc_width > u64::from(u32::MAX) || doc_height > u64::from(u32::MAX) { return Err(IoError::Unsupported("PDF import is too large at this DPI; lower the import DPI".into())); } - // Retained tiles plus the converting page's peak: craft-pdf's RGBA8 raster (4 B/px) and the - // f32 document buffer (16 B/px). + // Retained tiles plus the converting page's raster (4 B/px); the f32 conversion runs in + // 256-row bands, so it adds only a constant-sized buffer rather than a second full page. let max_page = sizes.iter().map(|&(w, h)| u64::from(page_pixels(w, dpi)) * u64::from(page_pixels(h, dpi))).max().unwrap_or(0); - let estimate = retained.saturating_add(max_page.saturating_mul(20)); + let estimate = retained.saturating_add(max_page.saturating_mul(4)); let budget = if opts.flat_max_pixel_bytes > 0 { opts.flat_max_pixel_bytes } else { DEFAULT_MAX_PIXEL_BYTES }; if estimate > budget { return Err(IoError::Unsupported(format!( @@ -159,17 +159,23 @@ pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) - let mut surface = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::U8, true)); let bytes = rendered.bytes(); - let mut data = Vec::new(); - if data.try_reserve_exact(bytes.len()).is_err() { + // Convert and write in row bands: the f32 buffer stays bounded instead of a second + // 16-bytes-per-pixel copy of the whole page, and cancellation is polled per band. + const BAND_ROWS: usize = 256; + let row_bytes = width as usize * 4; + let band_bytes = row_bytes.saturating_mul(BAND_ROWS); + let mut data: Vec = Vec::new(); + if data.try_reserve_exact(band_bytes).is_err() { return Err(IoError::Unsupported(format!("PDF page {page_number} needs more memory than is available"))); } - for chunk in bytes.as_chunks::<4>().0 { - for &value in &chunk[..3] { - data.push(f32::from(value) / 255.0); - } - data.push(f32::from(chunk[3]) / 255.0); + for (band, rows) in bytes.chunks(band_bytes).enumerate() { + ctl.check().map_err(|_| IoError::Cancelled)?; + data.clear(); + data.extend(rows.iter().map(|&value| f32::from(value) / 255.0)); + let top = (band * BAND_ROWS) as i32; + let band_height = (rows.len() / row_bytes) as u32; + surface.write_region(Rect::from_xywh(x as i32, top, width, band_height), &data); } - surface.write_region(Rect::from_xywh(x as i32, 0, width, height), &data); let rect = Rect::new(x as i32, 0, (x + width) as i32, height as i32); let layer = Layer::new(format!("Page {page_number}"), LayerContent::Raster(surface)); let group = Group { children: vec![layer], expanded: true, artboard: Some(Artboard::new(rect)), frame_stroke: None }; diff --git a/crates/io/tests/it/pdf_import.rs b/crates/io/tests/it/pdf_import.rs index f63ff19482..95475ea200 100644 --- a/crates/io/tests/it/pdf_import.rs +++ b/crates/io/tests/it/pdf_import.rs @@ -103,6 +103,7 @@ fn first_artboard(document: &photocraft_doc::Document) -> Rect { group.artboard.as_ref().expect("page artboard").rect } +/// Each page becomes its own artboard at 72 and 300 DPI, with the print size preserved. #[test] fn imports_every_page_as_an_artboard_with_its_print_size() { let pdf = variant_pdf(); @@ -148,6 +149,7 @@ fn imports_every_page_as_an_artboard_with_its_print_size() { } } +/// A page over the renderer's side limit lowers the effective DPI instead of failing. #[test] fn huge_pages_lower_the_effective_dpi_within_the_renderer_bounds() { // 3000 pt at 300 dpi would be 12500 px wide, over craft-pdf's 8192 px side limit. @@ -162,6 +164,7 @@ fn huge_pages_lower_the_effective_dpi_within_the_renderer_bounds() { assert!((print_width - 3000.0).abs() < 1.0, "print width {print_width:.1} pt should stay 3000 pt"); } +/// Cancelling on the last page check or the public boundary returns Cancelled, never a document. #[test] fn cancelling_on_the_last_checks_is_reported() { let pdf = variant_pdf(); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 02a2da7f79..237e69921b 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -145,13 +145,13 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. Embedded SVG and PDF-compatible AI/PDF/EPS payloads can use an explicit VectorCraft editor choice, including nested PSB contents. AI/PDF/EPS working copies retain their format on save-back; a separate source-bound SVG display preview does not replace the native payload. Placement, shared instances, masks and Undo/Redo use existing smart-object transactions, with native bytes and previews persisted through .pcraft and PSD/PSB saves. Unsupported editor features may still change; standalone EPS/DCS import and lossless Adobe round-trip compatibility remain unverified. See [workflow, safety and validation limits](embedded-vector-editing.md). No corpus floor or parity percentage is changed. ## Revision history -| 2026-11-07 | minor | Photoshop PDF read: pages import as raster artboards through the shared craft-pdf (Hayro) renderer, up to 16 pages, 8192 px per side and 64M px per page; editable text/vectors and PDFium are gone. Write is unchanged. | The original reference row retains its source date and historical totals; current totals are in the status and Actions row above. The original reference entry retains its source date and historical totals; current totals are in the status above. | Date | Change | Summary | |---|---|---| +| 2026-11-07 | minor | Photoshop PDF read: pages import as raster artboards through the shared craft-pdf (Hayro) renderer, up to 16 pages, 8192 px per side and 64M px per page; editable text/vectors and PDFium are gone. Write is unchanged. | | 2026-11-06 | minor | Scripted PSD/PSB saves take `maximizeCompatibility` (Save, Save As, Save a Copy, `app.save`, serve `doc.save`, MCP `doc_save`/`doc_export`, Batch, Image Processor, Layers to Files) over Preferences › File Handling for one save, and the CLI `--no-maximize-compatibility` (#8064). Layer channels and the merged image were already RLE (ZIP with prediction at 32-bit), as Photoshop writes them (#8645) | | 2026-11-02 | minor | Actions Shear (`Shr `) loads, plays and saves the measured curve points and edge mode; unsafe subranges and unrepresentable forms remain kept steps (#10605). | | 2026-11-01 | minor | `.atn`: 23 newer measured forms mapped, including explicit Link/Unlink, Smart Object file operations, Make Type to Shape, radius-only Shape Blur, Artboard from Group and the Crop Photos parent; supported in batch 13; Link Layers keeps the measured toggle. Artboard forms with unmeasured settings and generated Crop helper steps remain kept. | From d4f27c60fa449688be2ae59744f01c01058c2b54 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 7 Nov 2026 20:40:29 +0100 Subject: [PATCH 116/219] #9348: only Clone Stamp and Healing Brush wrap in Pattern Preview Measured in the reference application: in View > Pattern Preview the Clone Stamp and Healing Brush carry a stroke on across the canvas edges. Blur, Sharpen, Smudge, Dodge, Burn, Sponge, Color Replacement, Background Eraser, Pattern Stamp and History Brush show "Symmetry and Pattern Preview are not supported with this tool ... does not perform wrap-around" and stop at the edge; Spot Healing stops at the edge too. Keep the Healing Brush wrap (commit and live preview, through periodic::stroke) and drop the wrap from the other tools: their commands, WorkTiles' wrap tile, photocraft_paint::wrap and the canvas params go back to main's code. Tests: the tiling checks stay for Clone Stamp and Healing Brush; new tests check that the other tools' strokes, live and committed, stop at the edge and that the canvas sends `wrap` only for the two tools that wrap. Co-Authored-By: VoiceLessQ <44067308+VoiceLessQ@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/brush_cmds.rs | 13 +- crates/engine/src/cmd/eraser_cmds.rs | 31 +- crates/engine/src/cmd/retouch_cmds.rs | 39 +-- crates/engine/src/cmd/retouch_cmds/clone.rs | 21 +- crates/engine/src/cmd/retouch_cmds/dab.rs | 209 ++++++------ crates/engine/src/cmd/retouch_cmds/heal.rs | 78 +---- .../src/cmd/retouch_cmds/pattern_stamp.rs | 14 +- .../engine/src/cmd/retouch_cmds/sampling.rs | 49 +-- crates/engine/src/cmd/retouch_cmds/tests.rs | 312 ++++-------------- crates/paint/src/bg_erase.rs | 19 +- crates/paint/src/lib.rs | 4 +- crates/paint/src/replace.rs | 42 +-- crates/paint/src/retouch.rs | 264 --------------- crates/paint/src/wrap.rs | 166 ---------- crates/ui-egui/src/canvas.rs | 4 +- crates/ui-egui/src/canvas/strokes.rs | 8 - crates/ui-egui/src/stroke_timing_tests.rs | 54 --- crates/ui-egui/src/tools/eraser_ui.rs | 6 +- crates/ui-egui/src/tools/retouch_ui.rs | 23 +- crates/ui-egui/src/tools/retouch_ui/tests.rs | 36 +- 20 files changed, 287 insertions(+), 1105 deletions(-) delete mode 100644 crates/paint/src/wrap.rs diff --git a/crates/engine/src/cmd/brush_cmds.rs b/crates/engine/src/cmd/brush_cmds.rs index 9142cbf6ff..e09ff2098c 100644 --- a/crates/engine/src/cmd/brush_cmds.rs +++ b/crates/engine/src/cmd/brush_cmds.rs @@ -9,7 +9,7 @@ use photocraft_doc::LayerContent; use photocraft_geom::Rect; use photocraft_paint::mixer::{MixerSettings, MixerState}; -use photocraft_paint::replace::{Limits, ReplaceMode, ReplaceSettings, Sampling, apply_color_replacement_wrapped, color_replacement_effect}; +use photocraft_paint::replace::{Limits, ReplaceMode, ReplaceSettings, Sampling, apply_color_replacement, color_replacement_effect}; use photocraft_paint::tile::PREVIEW_SIDE; use photocraft_paint::{ BrushPreset, BrushSettings, GrayTile, Pattern, PresetCaptures, StoredTile, Stroke, StrokePoint, StrokeRenderer, TipShape, render_stroke, @@ -860,9 +860,8 @@ fn color_replacement(s: &mut Session, p: &Value) -> Result { let dmg = s.edit("Color Replacement", |doc, _| { let sel = doc.selection.clone(); let lock = doc.effective_locks(id).transparency; - let wrap = flag(p, "wrap", false).then(|| doc.bounds()); let surf = paint_surface(doc, id, &json!({}))?; - Ok(apply_color_replacement_wrapped(surf, &stroke, &rs, sel.as_ref(), lock, wrap)) + Ok(apply_color_replacement(surf, &stroke, &rs, sel.as_ref(), lock)) })?; Ok(damage_json(s, dmg)) } @@ -871,10 +870,8 @@ fn color_replacement(s: &mut Session, p: &Value) -> Result { /// recolouring per dab as the commit, so the preview at the last point is the committed result. pub fn live_color_replacement(s: &Session, p: &Value) -> Result { let (stroke, rs, id) = color_replacement_setup(s, p)?; - crate::retouch_cmds::LiveDab::with_effect(s, "paint.colorReplacement", p, stroke, Some(id), 0, |pre, wrap| { - Box::new(color_replacement_effect(pre.clone(), rs, wrap)) - })? - .with_tail_preview() + crate::retouch_cmds::LiveDab::with_effect(s, "paint.colorReplacement", p, stroke, Some(id), 0, |pre| Box::new(color_replacement_effect(pre.clone(), rs)))? + .with_tail_preview() } fn brush_json(b: &BrushSettings) -> Value { @@ -1339,7 +1336,7 @@ pub fn specs() -> Vec { 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,"target":"pixels"="pixels","seed":u64?,"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges)}"##, + 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,"target":"pixels"="pixels","seed":u64?}"##, crate::commands::has_paintable, color_replacement, true diff --git a/crates/engine/src/cmd/eraser_cmds.rs b/crates/engine/src/cmd/eraser_cmds.rs index d33f1afae0..1433498078 100644 --- a/crates/engine/src/cmd/eraser_cmds.rs +++ b/crates/engine/src/cmd/eraser_cmds.rs @@ -11,7 +11,7 @@ use photocraft_algo::selection as sel; use photocraft_doc::{Document, Layer, LayerContent, LayerId}; use photocraft_geom::Rect; use photocraft_paint::Stroke; -use photocraft_paint::bg_erase::{BgEraseSettings, apply_background_eraser_wrapped, background_eraser_effect, background_eraser_stroke}; +use photocraft_paint::bg_erase::{BgEraseSettings, apply_background_eraser, background_eraser_effect, background_eraser_stroke}; use photocraft_paint::replace::{Limits, Sampling}; use serde_json::{Value, json}; @@ -165,18 +165,16 @@ fn background_eraser(s: &mut Session, p: &Value) -> Result { let bg = s.tools.background; let dmg = s.edit("Background Eraser", |doc, _| { let selection = doc.selection.clone(); - // View › Pattern Preview: the stroke carries on across the canvas edges. - let wrap = flag(p, "wrap", false).then(|| doc.bounds()); if colors.is_none() { crate::extra_cmds::background_to_layer(doc, id); } let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; let surf = l.surface_mut().ok_or_else(|| bad(CMD, "not a pixel layer"))?; if let Some(colors) = colors { - let effect = selected_background_effect(surf, &bs, colors, bg, wrap); - Ok(photocraft_paint::retouch::apply_dab_stroke_wrapped(surf, &background_eraser_stroke(&stroke), selection.as_ref(), false, 0, wrap, effect)) + let effect = selected_background_effect(surf, &bs, colors, bg); + Ok(photocraft_paint::retouch::apply_dab_stroke(surf, &background_eraser_stroke(&stroke), selection.as_ref(), false, 0, effect)) } else { - Ok(apply_background_eraser_wrapped(surf, &stroke, &bs, selection.as_ref(), wrap)) + Ok(apply_background_eraser(surf, &stroke, &bs, selection.as_ref())) } })?; Ok(damage(s, dmg, true)) @@ -187,7 +185,6 @@ fn selected_background_effect( bs: &BgEraseSettings, colors: crate::color_channels::ColorChannels, bg: [f32; 4], - wrap: Option, ) -> crate::retouch_cmds::DabEffect { let fmt = pre.format(); let alpha = fmt.mode.color_channels(); @@ -195,7 +192,7 @@ fn selected_background_effect( // Opaque Backgrounds have no alpha sample. The matching algorithm uses a temporary opaque // alpha to calculate erased coverage; the real layer keeps its original format and identity. let opaque = pre.convert(photocraft_color::PixelFormat::new(fmt.mode, fmt.sample, true)); - let Some(mut effect) = background_eraser_effect(&opaque, bs, wrap) else { + let Some(mut effect) = background_eraser_effect(&opaque, bs) else { return Box::new(|_, _| {}); }; Box::new(move |region, footprint| { @@ -238,20 +235,16 @@ pub fn live_background_eraser(s: &Session, p: &Value) -> Result Box::new(effect), - // No alpha: the commit erases nothing either. - None => Box::new(|_: &mut photocraft_paint::retouch::Region, _: &photocraft_paint::retouch::Footprint| {}), - } + crate::retouch_cmds::LiveDab::with_effect_on(doc, CMD, &json!({}), stroke, Some(id), 0, |pre| match background_eraser_effect(pre, &bs) { + Some(effect) => Box::new(effect), + // No alpha: the commit erases nothing either. + None => Box::new(|_: &mut photocraft_paint::retouch::Region, _: &photocraft_paint::retouch::Footprint| {}), })? .with_tail_preview() } @@ -274,7 +267,7 @@ pub fn specs() -> Vec { label: "Background Eraser", menu: &[], shortcut: None, - params: r##"{"points":[[x,y,pressure?],…],"size":px?,"hardness":0..1?,"brush":{…}?,"preset":name?,"sampling":"continuous|once|backgroundSwatch"="continuous","limits":"discontiguous|contiguous|findEdges"="contiguous","tolerance":0..100=50,"protectForegroundColor":bool=false,"seed":u64?,"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges)}"##, + params: r##"{"points":[[x,y,pressure?],…],"size":px?,"hardness":0..1?,"brush":{…}?,"preset":name?,"sampling":"continuous|once|backgroundSwatch"="continuous","limits":"discontiguous|contiguous|findEdges"="contiguous","tolerance":0..100=50,"protectForegroundColor":bool=false,"seed":u64?}"##, enabled: has_pixel_layer, run: background_eraser, journal: true, diff --git a/crates/engine/src/cmd/retouch_cmds.rs b/crates/engine/src/cmd/retouch_cmds.rs index e6cc4eb3d4..bf05a7e879 100644 --- a/crates/engine/src/cmd/retouch_cmds.rs +++ b/crates/engine/src/cmd/retouch_cmds.rs @@ -16,8 +16,7 @@ use photocraft_algo::retouch::{ToneRange, dodge_burn, local_blur, local_sharpen, use photocraft_color::{BlendMode, PixelFormat, SampleType}; use photocraft_doc::{Document, LayerContent, LayerId}; use photocraft_geom::Rect; -use photocraft_paint::retouch::{Footprint, Region, Smudge, WorkTiles, alpha_index, apply_coverage, apply_dab_stroke_wrapped}; -use photocraft_paint::wrap::wrap_coord; +use photocraft_paint::retouch::{Footprint, Region, Smudge, alpha_index, apply_coverage, apply_dab_stroke}; use photocraft_paint::{BrushSettings, Stroke, StrokePoint}; use photocraft_raster::{Surface, from_rgba_into, to_rgba}; use rayon::prelude::*; @@ -123,10 +122,6 @@ fn num(p: &Value, k: &str, d: f32) -> f32 { fn flag(p: &Value, k: &str, d: bool) -> bool { p.get(k).and_then(Value::as_bool).unwrap_or(d) } -/// The canvas a stroke with `"wrap": true` repeats across (View › Pattern Preview). -fn wrap_tile(pre: &Document, p: &Value) -> Option { - flag(p, "wrap", false).then(|| pre.bounds()).filter(|t| !t.is_empty()) -} fn string<'a>(p: &'a Value, k: &str, d: &'a str) -> &'a str { p.get(k).and_then(Value::as_str).unwrap_or(d) } @@ -253,7 +248,7 @@ pub fn specs() -> Vec { menu: &[], shortcut: None, params: brush_params!( - r#","pattern":id|name?=Patterns panel current,"scale":1..1000 %=100,"angle":deg=0,"aligned":bool=true,"impressionist":bool=false,"mode":"normal|multiply|…"="normal","phase":[px,py]?,"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges) → {"damage","phase","aligned","pattern"}"# + r#","pattern":id|name?=Patterns panel current,"scale":1..1000 %=100,"angle":deg=0,"aligned":bool=true,"impressionist":bool=false,"mode":"normal|multiply|…"="normal","phase":[px,py]? → {"damage","phase","aligned","pattern"}"# ), enabled: pattern_stamp::enabled, run: pattern_stamp::run, @@ -276,9 +271,7 @@ pub fn specs() -> Vec { label: "Spot Healing Brush", menu: &[], shortcut: None, - params: brush_params!( - r#","type":"contentAware|createTexture|proximityMatch"="contentAware","sampleAllLayers":bool=false,"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges)"# - ), + params: brush_params!(r#","type":"contentAware|createTexture|proximityMatch"="contentAware","sampleAllLayers":bool=false"#), enabled: has_pixel_layer, run: spot_healing, journal: true, @@ -320,9 +313,7 @@ pub fn specs() -> Vec { label: "Dodge", menu: &[], shortcut: None, - params: brush_params!( - r#","range":"shadows|midtones|highlights"="midtones","exposure":1..100=50,"protectTones":bool=true,"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges)"# - ), + params: brush_params!(r#","range":"shadows|midtones|highlights"="midtones","exposure":1..100=50,"protectTones":bool=true"#), enabled: has_pixel_layer, run: |s, p| dab_cmd(s, p, "paint.dodge"), journal: true, @@ -332,9 +323,7 @@ pub fn specs() -> Vec { label: "Burn", menu: &[], shortcut: None, - params: brush_params!( - r#","range":"shadows|midtones|highlights"="midtones","exposure":1..100=50,"protectTones":bool=true,"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges)"# - ), + params: brush_params!(r#","range":"shadows|midtones|highlights"="midtones","exposure":1..100=50,"protectTones":bool=true"#), enabled: has_pixel_layer, run: |s, p| dab_cmd(s, p, "paint.burn"), journal: true, @@ -344,9 +333,7 @@ pub fn specs() -> Vec { label: "Sponge", menu: &[], shortcut: None, - params: brush_params!( - r#","mode":"desaturate|saturate"="desaturate","vibrance":bool=true (flow = sponge strength),"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges)"# - ), + params: brush_params!(r#","mode":"desaturate|saturate"="desaturate","vibrance":bool=true (flow = sponge strength)"#), enabled: has_pixel_layer, run: |s, p| dab_cmd(s, p, "paint.sponge"), journal: true, @@ -356,9 +343,7 @@ pub fn specs() -> Vec { label: "Blur", menu: &[], shortcut: None, - params: brush_params!( - r#","strength":1..100=50,"sampleAllLayers":bool=false,"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges)"# - ), + params: brush_params!(r#","strength":1..100=50,"sampleAllLayers":bool=false"#), enabled: has_pixel_layer, run: |s, p| dab_cmd(s, p, "paint.blur"), journal: true, @@ -368,9 +353,7 @@ pub fn specs() -> Vec { label: "Sharpen", menu: &[], shortcut: None, - params: brush_params!( - r#","strength":1..100=50,"protectDetail":bool=true,"sampleAllLayers":bool=false,"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges)"# - ), + params: brush_params!(r#","strength":1..100=50,"protectDetail":bool=true,"sampleAllLayers":bool=false"#), enabled: has_pixel_layer, run: |s, p| dab_cmd(s, p, "paint.sharpen"), journal: true, @@ -380,9 +363,7 @@ pub fn specs() -> Vec { label: "Smudge", menu: &[], shortcut: None, - params: brush_params!( - r#","strength":1..100=50,"fingerPainting":bool=false (starts with the foreground colour),"sampleAllLayers":bool=false,"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges)"# - ), + params: brush_params!(r#","strength":1..100=50,"fingerPainting":bool=false (starts with the foreground colour),"sampleAllLayers":bool=false"#), enabled: has_pixel_layer, run: |s, p| dab_cmd(s, p, "paint.smudge"), journal: true, @@ -393,7 +374,7 @@ pub fn specs() -> Vec { menu: &[], shortcut: None, params: brush_params!( - r#","state":row (a History panel row, as history.setSource: 0 = oldest state, then the current one, then the greyed redo states),"snapshot":id (a history.newSnapshot id; default: the History panel's source, else the oldest held state; a source of another canvas size is an error),"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges)"# + r#","state":row (a History panel row, as history.setSource: 0 = oldest state, then the current one, then the greyed redo states),"snapshot":id (a history.newSnapshot id; default: the History panel's source, else the oldest held state; a source of another canvas size is an error)"# ), enabled: has_pixel_layer, run: history_brush, diff --git a/crates/engine/src/cmd/retouch_cmds/clone.rs b/crates/engine/src/cmd/retouch_cmds/clone.rs index 9e79894a44..66cf276332 100644 --- a/crates/engine/src/cmd/retouch_cmds/clone.rs +++ b/crates/engine/src/cmd/retouch_cmds/clone.rs @@ -265,7 +265,14 @@ impl LiveRetouch { /// Everything the stroke has touched so far. pub fn bounds(&self) -> Rect { let bounds = self.renderer.bounds().union(&self.tail); - if flag(&self.params, "wrap", false) { periodic::project(bounds, self.pre.bounds()).into_iter().fold(Rect::EMPTY, |a, b| a.union(&b)) } else { bounds } + if self.wraps() { periodic::project(bounds, self.pre.bounds()).into_iter().fold(Rect::EMPTY, |a, b| a.union(&b)) } else { bounds } + } + + /// Whether the stroke repeats across the canvas edges (View › Pattern Preview). Only the Clone + /// Stamp and the Healing Brush do: the reference application's other retouching tools stop + /// at the edge. + fn wraps(&self) -> bool { + matches!(self.cmd.as_str(), "paint.cloneStamp" | "paint.healingBrush") && flag(&self.params, "wrap", false) } /// Every point rendered so far, in order. @@ -317,7 +324,7 @@ impl LiveRetouch { let pts: Vec<_> = pts.iter().map(|q| StrokePoint::new(q.x, q.y, q.pressure)).collect(); self.points.extend_from_slice(&pts); self.renderer.push(&pts); - if flag(&self.params, "wrap", false) { + if self.wraps() { return self.push_periodic(); } let old = std::mem::replace(&mut self.tail, Rect::EMPTY); @@ -346,13 +353,11 @@ impl LiveRetouch { let mut tail = self.renderer.tail_preview(); self.tail = tail.as_mut().map_or(Rect::EMPTY, |t| t.take_dirty_rect()); dirty = dirty.union(&self.tail); - let (pre, id, pre_surf, live_paint, live_cmd) = (&self.pre, self.id, &self.pre_surf, &self.paint, self.cmd.as_str()); - let fmt = pre_surf.format(); - let paint = |r| match live_paint { - LivePaint::Clone { which, map, cmd } => clone_sample(pre, id, pre_surf, *which, r, map, true, cmd), - LivePaint::History(src) => periodic::sample(pre, id, src, SampleLayers::Current, r, live_cmd), - LivePaint::Pattern { tile, place } => Ok(pattern_stamp::paint_region(tile, place, &fmt, r)), + let LivePaint::Clone { which, map, cmd } = &self.paint else { + return Err(bad(&self.cmd, "periodic painting requires a clone source")); }; + let (pre, id, pre_surf, live_cmd) = (&self.pre, self.id, &self.pre_surf, self.cmd.as_str()); + let paint = |r| clone_sample(pre, id, pre_surf, *which, r, map, true, cmd); let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.id, &self.params)?; periodic::Composite { target: surf, diff --git a/crates/engine/src/cmd/retouch_cmds/dab.rs b/crates/engine/src/cmd/retouch_cmds/dab.rs index 597a1a5d74..e51ac10021 100644 --- a/crates/engine/src/cmd/retouch_cmds/dab.rs +++ b/crates/engine/src/cmd/retouch_cmds/dab.rs @@ -1,10 +1,9 @@ use super::sampling::{AllLayers, sample_all_layers}; use super::{ BrushSettings, Document, EngineError, Footprint, IndexedParallelIterator, LayerId, ParallelIterator, ParallelSliceMut, PixelFormat, Rect, Region, Result, - Session, Smudge, Stroke, StrokePoint, Surface, ToneRange, Value, WorkTiles, alpha_index, apply_dab_stroke_wrapped, bad, damage_json, dodge_burn, flag, - from_rgba_into, has_pixel_layer, json, local_blur, local_sharpen, num, parse_brush, run_stroke, sponge, string, to_rgba, wrap_tile, + Session, Smudge, Stroke, StrokePoint, Surface, ToneRange, Value, alpha_index, apply_dab_stroke, bad, damage_json, dodge_burn, flag, from_rgba_into, + has_pixel_layer, json, local_blur, local_sharpen, num, parse_brush, run_stroke, sponge, string, to_rgba, }; -use photocraft_paint::wrap::{fits, fold_area, pieces, wrap_coord}; // --------------------------------------------------------------------------------------------- // Toning (Dodge / Burn / Sponge) and focus (Blur / Sharpen), Smudge @@ -47,9 +46,8 @@ fn color_dab(fmt: &PixelFormat, work: &mut Region, fp: &Footprint, strength: f32 /// stroke's coverage there (`toned` is the full-exposure colour). Dabs build up coverage as in a /// brush stroke, capped at full coverage, so Exposure caps a stroke the way Opacity caps a paint /// stroke: overlapping dabs and passes within one stroke don't compound the tone curve, and a -/// new stroke builds on the last. Wrapped around `wrap` (View › Pattern Preview), the coverage is -/// kept per canvas pixel, so a stroke coming back to a pixel one canvas away tones it once. -fn tone_stroke(fmt: PixelFormat, wrap: Option, toned: impl Fn([f32; 3]) -> [f32; 3] + 'static) -> DabEffect { +/// new stroke builds on the last. +fn tone_stroke(fmt: PixelFormat, toned: impl Fn([f32; 3]) -> [f32; 3] + 'static) -> DabEffect { /// Side of the cells that remember, per touched pixel, the coverage applied so far and the /// original pixel (copied from the working pixels the first time a dab reaches it). Dense /// cells rather than a map per pixel: a map lookup per covered pixel made long strokes with @@ -62,8 +60,11 @@ fn tone_stroke(fmt: PixelFormat, wrap: Option, toned: impl Fn([f32; 3]) -> let mut cells: std::collections::HashMap<(i32, i32), Cell> = std::collections::HashMap::new(); let a = alpha_index(&fmt); let n = fmt.channels(); - // Tone the pixels of `px` at `r` moved by `(dx, dy)`, remembered at `r`. - let mut tone = move |px: &mut Region, fp: &Footprint, r: Rect, (dx, dy): (i32, i32)| { + Box::new(move |work, fp| { + let r = fp.rect.intersect(&work.rect); + if r.is_empty() { + return; + } let mut enc = [0.0f32; 8]; for cy in r.y0.div_euclid(CELL)..=(r.y1 - 1).div_euclid(CELL) { for cx in r.x0.div_euclid(CELL)..=(r.x1 - 1).div_euclid(CELL) { @@ -77,7 +78,7 @@ fn tone_stroke(fmt: PixelFormat, wrap: Option, toned: impl Fn([f32; 3]) -> let cell = cells.entry((cx, cy)).or_insert_with(|| Cell { cov: vec![-1.0; len], orig: vec![0.0; len * n] }); for y in part.y0..part.y1 { for x in part.x0..part.x1 { - let k = fp.at(x + dx, y + dy).clamp(0.0, 1.0); + let k = fp.at(x, y).clamp(0.0, 1.0); if k <= 0.0 { continue; } @@ -85,7 +86,7 @@ fn tone_stroke(fmt: PixelFormat, wrap: Option, toned: impl Fn([f32; 3]) -> let (Some(prev), Some(src)) = (cell.cov.get_mut(i), cell.orig.get_mut(i * n..i * n + n)) else { continue }; if *prev < 0.0 { // First touch: the working pixel is still the original. - src.copy_from_slice(px.px(x + dx, y + dy)); + src.copy_from_slice(work.px(x, y)); *prev = 0.0; } // Dabs build up the stroke's coverage as a brush stroke's do (flow), up @@ -99,55 +100,16 @@ fn tone_stroke(fmt: PixelFormat, wrap: Option, toned: impl Fn([f32; 3]) -> let t = toned([o[0], o[1], o[2]]); let m = [o[0] + (t[0] - o[0]) * k, o[1] + (t[1] - o[1]) * k, o[2] + (t[2] - o[2]) * k]; from_rgba_into(&fmt, [m[0], m[1], m[2], o[3]], &mut enc); - let out = px.px_mut(x + dx, y + dy); + let px = work.px_mut(x, y); for c in 0..n { if Some(c) != a { - out[c] = enc[c]; + px[c] = enc[c]; } } } } } } - }; - Box::new(move |work, fp| { - let r = fp.rect.intersect(&work.rect); - if r.is_empty() { - return; - } - match wrap { - None => tone(work, fp, r, (0, 0)), - Some(tile) if fits(r, tile) => { - for (part, off) in pieces(r, tile) { - tone(work, fp, part, off); - } - } - // A dab bigger than the canvas covers its pixels more than once: tone each at the - // most coverage any copy gets (the copy `WorkTiles` keeps), and give every copy that. - Some(tile) => { - let area = fold_area(r, tile); - let mut on = Region::new(area, work.ch); - let mut most = Footprint { dab: fp.dab, index: fp.index, rect: area, cov: vec![0.0; area.width() as usize * area.height() as usize] }; - let at = |x, y| (wrap_coord(x, tile.x0, tile.x1), wrap_coord(y, tile.y0, tile.y1)); - for y in r.y0..r.y1 { - for x in r.x0..r.x1 { - let (tx, ty) = at(x, y); - let i = on.index(tx, ty); - on.px_mut(tx, ty).copy_from_slice(work.px(x, y)); - if let Some(m) = most.cov.get_mut(i) { - *m = m.max(fp.at(x, y)); - } - } - } - tone(&mut on, &most, area, (0, 0)); - for y in r.y0..r.y1 { - for x in r.x0..r.x1 { - let (tx, ty) = at(x, y); - work.px_mut(x, y).copy_from_slice(on.px(tx, ty)); - } - } - } - } }) } @@ -253,12 +215,11 @@ impl DabTool { } } - /// The per-dab effect on a working copy in `fmt`, for a brush of `spacing`, on a stroke - /// wrapped around `wrap`. - pub(super) fn effect(&self, fmt: PixelFormat, spacing: f32, wrap: Option) -> DabEffect { + /// The per-dab effect on a working copy in `fmt`, for a brush of `spacing`. + pub(super) fn effect(&self, fmt: PixelFormat, spacing: f32) -> DabEffect { let sp = spacing; match self.clone() { - Self::Tone { burn, range, exposure, protect } => tone_stroke(fmt, wrap, move |c| dodge_burn(c, exposure, range, burn, protect)), + Self::Tone { burn, range, exposure, protect } => tone_stroke(fmt, move |c| dodge_burn(c, exposure, range, burn, protect)), // Flow is already in each dab's coverage; a full-flow pass moves colours half-way. Self::Sponge { saturate, vibrance } => Box::new(move |work, fp| color_dab(&fmt, work, fp, 0.5, sp, |c, k| sponge(c, k, saturate, vibrance))), Self::Focus { sharpen, strength, protect, sigma } => { @@ -320,10 +281,9 @@ pub(super) fn dab_cmd(s: &mut Session, p: &Value, cmd: &str) -> Result { return Ok(l.bounds); } let fmt = surf.format(); - let (halo, wrap) = (tool.neighborhood().halo(), wrap_tile(pre, p)); - let mut effect = tool.effect(fmt, stroke.brush.spacing, wrap); - let mut sampler = all.then(|| AllLayers::new(pre, fmt).wrapped(wrap, &stroke, halo)); - Ok(apply_dab_stroke_wrapped(surf, &stroke, sel, lock, halo, wrap, |work, fp| match &mut sampler { + let mut effect = tool.effect(fmt, stroke.brush.spacing); + let mut sampler = all.then(|| AllLayers::new(pre, fmt)); + Ok(apply_dab_stroke(surf, &stroke, sel, lock, tool.neighborhood().halo(), |work, fp| match &mut sampler { Some(all) => all.dab(work, fp, |w, f| effect(w, f)), None => effect(work, fp), })) @@ -335,6 +295,81 @@ pub(super) fn dab_cmd(s: &mut Session, p: &Value, cmd: &str) -> Result { Ok(r) } +/// Side of the tiles a live sequential-dab stroke keeps its working pixels in ([`WorkTiles`]). +const WORK_TILE: i32 = 128; + +/// The working copy of a live sequential-dab stroke: the target's native channels as `f32`, in +/// tiles copied from the pre-stroke surface the first time a dab writes to them. Unlike a +/// `Surface`, it isn't rounded to the document's depth between dabs, so each dab sees what the +/// commit's working copy (`apply_dab_stroke`) holds and the preview is the commit's pixels. +struct WorkTiles { + ch: usize, + tiles: std::collections::HashMap<(i32, i32), Vec>, +} + +impl WorkTiles { + fn new(ch: usize) -> Self { + Self { ch, tiles: std::collections::HashMap::new() } + } + + fn tile_rect((tx, ty): (i32, i32)) -> Rect { + Rect::new(tx * WORK_TILE, ty * WORK_TILE, tx * WORK_TILE + WORK_TILE, ty * WORK_TILE + WORK_TILE) + } + + /// The tiles `r` overlaps. + fn coords(r: Rect) -> impl Iterator { + let (x0, x1) = (r.x0.div_euclid(WORK_TILE), (r.x1 - 1).div_euclid(WORK_TILE)); + let (y0, y1) = (r.y0.div_euclid(WORK_TILE), (r.y1 - 1).div_euclid(WORK_TILE)); + let empty = r.is_empty(); + (y0..=y1).filter(move |_| !empty).flat_map(move |ty| (x0..=x1).map(move |tx| (tx, ty))) + } + + /// The working pixels of `r`: the pre-stroke pixels `pre` where no dab has written yet. + fn read(&self, pre: &Surface, r: Rect) -> Region { + let mut out = Region::new(r, self.ch); + let n = self.ch; + let mut buf = Vec::new(); + for c in Self::coords(r) { + let tr = Self::tile_rect(c); + let part = r.intersect(&tr); + let w = part.width() as usize * n; + let tile = self.tiles.get(&c); + if tile.is_none() { + pre.read_region_into(part, &mut buf); + } + for y in part.y0..part.y1 { + let src = match tile { + Some(t) => t.get(((y - tr.y0) * WORK_TILE + (part.x0 - tr.x0)) as usize * n..).and_then(|s| s.get(..w)), + None => buf.get((y - part.y0) as usize * w..).and_then(|s| s.get(..w)), + }; + let dst = out.index(part.x0, y) * n; + if let (Some(s), Some(d)) = (src, out.data.get_mut(dst..dst + w)) { + d.copy_from_slice(s); + } + } + } + out + } + + /// Store `region` as the working pixels of its rectangle. + fn write(&mut self, pre: &Surface, region: &Region) { + let (r, n) = (region.rect, self.ch); + for c in Self::coords(r) { + let tr = Self::tile_rect(c); + let tile = self.tiles.entry(c).or_insert_with(|| pre.read_region(tr)); + let part = r.intersect(&tr); + let w = part.width() as usize * n; + for y in part.y0..part.y1 { + let dst = ((y - tr.y0) * WORK_TILE + (part.x0 - tr.x0)) as usize * n; + let src = region.index(part.x0, y) * n; + if let (Some(s), Some(d)) = (region.data.get(src..src + w), tile.get_mut(dst..dst + w)) { + d.copy_from_slice(s); + } + } + } + } +} + /// A Dodge, Burn, Sponge, Blur, Sharpen or Smudge stroke shown while it is drawn: each new dab /// runs the tool's effect on a working copy of the target, in order, and the changed area is /// mixed back over the original at the brush opacity, as `apply_dab_stroke` does on release. @@ -362,8 +397,6 @@ pub struct LiveDab { sel: Option, lock: bool, bounds: Rect, - /// The canvas a wrapped stroke repeats across (View › Pattern Preview, `"wrap": true`). - wrap: Option, /// Show the dabs finishing the stroke would add, so the preview at the last point is the /// commit. Only for effects that a repeated dab doesn't change (Color Replacement): the /// tail runs on a scratch copy at every push, so an effect that carries paint from dab to @@ -393,14 +426,13 @@ impl LiveDab { let (tool, _) = DabTool::parse(s, cmd, p, &stroke)?; let (neighborhood, spacing, brush) = (tool.neighborhood(), stroke.brush.spacing, Box::new(stroke.brush.clone())); let effect = tool.clone(); - let mut live = Self::with_neighborhood(s, cmd, p, stroke, id, neighborhood, |pre, wrap| effect.effect(pre.format(), spacing, wrap))?; + let mut live = Self::with_neighborhood(s, cmd, p, stroke, id, neighborhood, |pre| effect.effect(pre.format(), spacing))?; live.tool = Some((tool, brush)); Ok(live) } - /// Start a live stroke of `cmd` that runs `effect` (made from the target before the stroke - /// and the canvas a wrapped stroke repeats across) at each dab of `stroke`, reading `halo` - /// pixels around it. `p` gives the target and `wrap`. + /// Start a live stroke of `cmd` that runs `effect` (made from the target before the stroke) + /// at each dab of `stroke`, reading `halo` pixels around it. `p` gives the target. pub(crate) fn with_effect( s: &Session, cmd: &str, @@ -408,7 +440,7 @@ impl LiveDab { stroke: Stroke, id: Option, halo: i32, - effect: impl FnOnce(&Surface, Option) -> DabEffect, + effect: impl FnOnce(&Surface) -> DabEffect, ) -> Result { Self::with_neighborhood(s, cmd, p, stroke, id, DabNeighborhood::Halo(halo), effect) } @@ -420,7 +452,7 @@ impl LiveDab { stroke: Stroke, id: Option, neighborhood: DabNeighborhood, - effect: impl FnOnce(&Surface, Option) -> DabEffect, + effect: impl FnOnce(&Surface) -> DabEffect, ) -> Result { let captured = crate::channel_cmds::inject_target(s, cmd, p.clone()); let p = &captured; @@ -446,7 +478,7 @@ impl LiveDab { stroke: Stroke, id: Option, halo: i32, - effect: impl FnOnce(&Surface, Option) -> DabEffect, + effect: impl FnOnce(&Surface) -> DabEffect, ) -> Result { Self::with_neighborhood_on(doc, cmd, p, stroke, id, DabNeighborhood::Halo(halo), effect) } @@ -458,14 +490,13 @@ impl LiveDab { stroke: Stroke, id: Option, neighborhood: DabNeighborhood, - effect: impl FnOnce(&Surface, Option) -> DabEffect, + effect: impl FnOnce(&Surface) -> DabEffect, ) -> Result { - let wrap = wrap_tile(&doc, p); let colors = crate::color_channels::explicit(p, crate::channel_cmds::color_count(&doc))?; let sel = doc.selection.clone(); let (surf, lock) = crate::channel_cmds::target_surface(&mut doc, id, p)?; let pre_surf = surf.clone(); - let effect = effect(&pre_surf, wrap); + let effect = effect(&pre_surf); // Generate the same dabs as `apply_dab_stroke` (zoom 1), without a StrokeRenderer: // its accumulated coverage isn't used here, and each effect rasterizes its own footprint. let ctx = photocraft_paint::BrushContext::new(&stroke.brush); @@ -480,7 +511,7 @@ impl LiveDab { done: 0, effect, neighborhood, - work: WorkTiles::new(pre_surf.channels()).wrapped(wrap), + work: WorkTiles::new(pre_surf.channels()), pre_surf, colors, id, @@ -489,7 +520,6 @@ impl LiveDab { sel, lock, bounds: Rect::EMPTY, - wrap, preview_tail: false, tail: Rect::EMPTY, tool: None, @@ -525,11 +555,10 @@ impl LiveDab { if r.is_empty() { continue; } - let fp = ctx.footprint(d, self.done.saturating_add(k)); let mut region = self.work.read(&self.pre_surf, self.neighborhood.read_rect(r, d)); - (self.effect)(&mut region, &fp); - self.work.write_dab(&self.pre_surf, ®ion, &fp); - dmg = dmg.union(&self.on_canvas(r)); + (self.effect)(&mut region, &ctx.footprint(d, self.done.saturating_add(k))); + self.work.write(&self.pre_surf, ®ion.crop(r)); + dmg = dmg.union(&r); } self.done = self.done.saturating_add(new.len()); new.clear(); @@ -537,24 +566,6 @@ impl LiveDab { dmg } - /// Where the stroke's rectangle `r` lands on the document: `r`, or in a wrapped stroke the - /// part of the canvas it covers. - fn on_canvas(&self, r: Rect) -> Rect { - self.wrap.map_or(r, |t| photocraft_paint::wrap::fold_area(r, t)) - } - - /// [`Self::show`] working pixels laid out in the stroke's own coordinates: a wrapped stroke - /// shows each part where it lies on the canvas. - fn show_unwrapped(&mut self, work: &Region) -> Result<()> { - let Some(tile) = self.wrap else { return self.show(work.rect, work) }; - for (part, (dx, dy)) in photocraft_paint::wrap::pieces(work.rect, tile) { - let mut piece = work.crop(part.translate(dx, dy)); - piece.rect = part; - self.show(part, &piece)?; - } - Ok(()) - } - /// Mix the working pixels of `r` back over the original into the shown document. fn show(&mut self, r: Rect, work: &Region) -> Result<()> { let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.id, &self.params)?; @@ -599,7 +610,7 @@ impl LiveDab { // Back to the stroke without the previous tail. let old = std::mem::replace(&mut self.tail, Rect::EMPTY); let shown = dmg.union(&old); - let tail_rect = tail.as_ref().map_or(Rect::EMPTY, |(r, _)| self.on_canvas(*r)); + let tail_rect = tail.as_ref().map_or(Rect::EMPTY, |(r, _)| *r); if shown.is_empty() && tail_rect.is_empty() { return Ok(Rect::EMPTY); } @@ -608,9 +619,9 @@ impl LiveDab { let work = self.work.read(&self.pre_surf, shown); self.show(shown, &work)?; } - if let Some((_, work)) = tail { - self.show_unwrapped(&work)?; - self.tail = tail_rect; + if let Some((r, work)) = tail { + self.show(r, &work)?; + self.tail = r; } Ok(shown.union(&tail_rect)) } diff --git a/crates/engine/src/cmd/retouch_cmds/heal.rs b/crates/engine/src/cmd/retouch_cmds/heal.rs index c68789e2c3..b26decfd69 100644 --- a/crates/engine/src/cmd/retouch_cmds/heal.rs +++ b/crates/engine/src/cmd/retouch_cmds/heal.rs @@ -3,7 +3,7 @@ use super::periodic; use super::sampling::{SampleLayers, composite_region, sample_all_layers, sample_layers}; use super::{ BlendMode, CompleteParams, Document, EngineError, LayerId, PixelFormat, Rect, Region, Result, Session, Stroke, Surface, Value, apply_coverage, bad, - coverage, damage_json, flag, inpaint, json, parse_brush, poisson, reject_huge_stroke, run_stroke, string, wrap_coord, wrap_tile, + coverage, damage_json, flag, inpaint, json, parse_brush, poisson, reject_huge_stroke, run_stroke, string, }; /// Grow a stroke's coverage map by `m` pixels of zero coverage (a Dirichlet boundary for the solve). @@ -105,17 +105,13 @@ pub(super) fn history_brush(s: &mut Session, p: &Value) -> Result { let live = s.live_commit.take().filter(|l| l.matches(s, CMD, p, &stroke.points)); let from_live = live.is_some(); let src_surface = history_source(s, id, p, CMD)?; - let dmg = run_stroke(s, "History Brush", id, p, |pre, surf, sel, lock| { + let dmg = run_stroke(s, "History Brush", id, p, |_, surf, sel, lock| { if let Some(l) = live { *surf = l.surface; return Ok(l.bounds); } let fmt = surf.format(); let src = if src_surface.format() == fmt { src_surface } else { src_surface.convert(fmt) }; - if flag(p, "wrap", false) { - // Pattern Preview: the history state repeats with the canvas, as the stroke does. - return periodic::stroke(pre, surf, &stroke, sel, lock, mode, CMD, |_, r| periodic::sample(pre, id, &src, SampleLayers::Current, r, CMD)); - } let (bounds, cov) = coverage(CMD, &stroke)?; let paint = Region::read(&src, bounds); Ok(apply_coverage(surf, bounds, &cov, stroke.brush.opacity, sel, lock, &paint, mode)) @@ -193,68 +189,28 @@ enum SpotType { /// Photoshop 25.4 (measured): a dab changes exactly the brush's footprint, and Content-Aware takes /// its texture from around it, as Edit › Fill does from its window /// ([`crate::fill_cmds::sampling_window`] of the stroke's bounds), which is what this samples. -/// -/// Wrapped around the canvas `wrap` (View › Pattern Preview), the window is centred on the stroke -/// whatever the canvas edges and at most one canvas across, so it holds no second copy of the -/// stroke; its pixels repeat across the edges, and the healed pixels go where they lie on the -/// canvas. -#[allow(clippy::too_many_arguments)] -fn spot_heal_surface( - surf: &mut Surface, - pre: &Document, - stroke: &Stroke, - kind: SpotType, - all: bool, - sel: Option<&Surface>, - lock: bool, - wrap: Option, -) -> Result { - const CMD: &str = "paint.spotHealing"; - let (bounds, cov) = coverage(CMD, stroke)?; +fn spot_heal_surface(surf: &mut Surface, pre: &Document, stroke: &Stroke, kind: SpotType, all: bool, sel: Option<&Surface>, lock: bool) -> Result { + let (bounds, cov) = coverage("paint.spotHealing", stroke)?; let size = stroke.brush.size; - let region_rect = match wrap { - Some(tile) => { - let (tw, th) = (i64::from(tile.width()), i64::from(tile.height())); - let x0 = (i64::from(bounds.x0) + i64::from(bounds.x1) - tw) / 2; - let y0 = (i64::from(bounds.y0) + i64::from(bounds.y1) - th) / 2; - let around = Rect::new(x0 as i32, y0 as i32, (x0 + tw) as i32, (y0 + th) as i32); - crate::fill_cmds::sampling_window(bounds, around) - } - None => crate::fill_cmds::sampling_window(bounds, pre.bounds()), - }; + let region_rect = crate::fill_cmds::sampling_window(bounds, pre.bounds()); if region_rect.is_empty() { return Ok(Rect::EMPTY); } // Proximity Match looks for its source anywhere in that window. let margin = (region_rect.width().max(region_rect.height()) / 2) as i32; let fmt = surf.format(); - let img = match wrap { - Some(_) => periodic::sample(pre, None, surf, if all { SampleLayers::All } else { SampleLayers::Current }, region_rect, CMD)?, - None if all => composite_region(pre, None, SampleLayers::All, region_rect, fmt), - None => Region::read(surf, region_rect), - }; + let img = if all { composite_region(pre, None, SampleLayers::All, region_rect, fmt) } else { Region::read(surf, region_rect) }; let (w, h, ch) = (img.width(), img.height(), img.ch); let bw = bounds.width() as usize; let mut stroke_cov = vec![0.0f32; w * h]; for (i, c) in cov.iter().enumerate() { let (x, y) = (bounds.x0 + (i % bw) as i32, bounds.y0 + (i / bw) as i32); - // A wrapped stroke longer than the window (one canvas) folds onto it: the strongest - // coverage of each pixel heals it once. - let (x, y) = match wrap { - Some(t) => { - let wrap_into = |v: i32, lo: i32, period: u32| wrap_coord(v, lo, lo.saturating_add(period as i32)); - (wrap_into(x, region_rect.x0, t.width()), wrap_into(y, region_rect.y0, t.height())) - } - None => (x, y), - }; if region_rect.contains(x, y) { - let px = &mut stroke_cov[img.index(x, y)]; - *px = px.max(*c); + stroke_cov[img.index(x, y)] = *c; } } let hole: Vec = stroke_cov.iter().map(|c| *c > 0.0).collect(); - // Nothing to heal, or (a wrapped dab covering its whole window) nothing to heal from. - if !hole.iter().any(|h| *h) || hole.iter().all(|h| *h) { + if !hole.iter().any(|h| *h) { return Ok(Rect::EMPTY); } // Synthesise over a slightly larger domain than the Poisson region, so the solve's boundary pixels @@ -278,19 +234,8 @@ fn spot_heal_surface( let src = Region { rect: region_rect, ch, data: filled }; let healed = heal_region(&fmt, &src, &img, &heal_mask); let dmg = bounds.intersect(®ion_rect); - let cov_of = |r: Rect, (dx, dy): (i32, i32)| -> Vec { - (r.y0..r.y1).flat_map(|y| (r.x0..r.x1).map(move |x| (x, y))).map(|(x, y)| stroke_cov[img.index(x + dx, y + dy)]).collect() - }; - let Some(tile) = wrap else { - return Ok(apply_coverage(surf, dmg, &cov_of(dmg, (0, 0)), stroke.brush.opacity, sel, lock, &healed, BlendMode::Normal)); - }; - let mut done = Rect::EMPTY; - for (part, off) in photocraft_paint::wrap::pieces(dmg, tile) { - let mut paint = healed.crop(part.translate(off.0, off.1)); - paint.rect = part; - done = done.union(&apply_coverage(surf, part, &cov_of(part, off), stroke.brush.opacity, sel, lock, &paint, BlendMode::Normal)); - } - Ok(done) + let dmg_cov: Vec = (dmg.y0..dmg.y1).flat_map(|y| (dmg.x0..dmg.x1).map(move |x| (x, y))).map(|(x, y)| stroke_cov[img.index(x, y)]).collect(); + Ok(apply_coverage(surf, dmg, &dmg_cov, stroke.brush.opacity, sel, lock, &healed, BlendMode::Normal)) } pub(super) fn spot_healing(s: &mut Session, p: &Value) -> Result { @@ -304,7 +249,6 @@ pub(super) fn spot_healing(s: &mut Session, p: &Value) -> Result { o => return Err(bad(CMD, format!("unknown type `{o}` (contentAware|createTexture|proximityMatch)"))), }; let all = sample_all_layers(p); - let dmg = - run_stroke(s, "Spot Healing Brush", id, p, |pre, surf, sel, lock| spot_heal_surface(surf, pre, &stroke, kind, all, sel, lock, wrap_tile(pre, p)))?; + let dmg = run_stroke(s, "Spot Healing Brush", id, p, |pre, surf, sel, lock| spot_heal_surface(surf, pre, &stroke, kind, all, sel, lock))?; Ok(json!({ "damage": damage_json(dmg) })) } diff --git a/crates/engine/src/cmd/retouch_cmds/pattern_stamp.rs b/crates/engine/src/cmd/retouch_cmds/pattern_stamp.rs index 6b5dae73b5..06b5bd17e7 100644 --- a/crates/engine/src/cmd/retouch_cmds/pattern_stamp.rs +++ b/crates/engine/src/cmd/retouch_cmds/pattern_stamp.rs @@ -8,12 +8,12 @@ use photocraft_color::{BlendMode, PixelFormat}; use photocraft_compose::pattern::{Placement, Tile, render}; use photocraft_doc::{LayerId, Pattern}; use photocraft_geom::Rect; -use photocraft_paint::retouch::{Footprint, Region, apply_coverage, apply_dab_stroke_wrapped, over_native}; +use photocraft_paint::retouch::{Footprint, Region, apply_coverage, apply_dab_stroke, over_native}; use photocraft_paint::{Stroke, StrokePoint}; use photocraft_raster::{Surface, from_rgba_into, to_rgba}; use serde_json::{Value, json}; -use super::{bad, blend_param, damage_json, flag, has_pixel_layer, parse_brush, periodic, reject_huge_stroke, wrap_tile}; +use super::{bad, blend_param, damage_json, flag, has_pixel_layer, parse_brush, reject_huge_stroke}; use crate::{Result, Session}; const STREAM_PHASE_X: u64 = 101; @@ -88,16 +88,10 @@ pub(super) fn run(s: &mut Session, p: &Value) -> Result { let pre = doc.clone(); let (surf, lock) = crate::channel_cmds::target_surface(doc, st.id, p)?; let sel = pre.selection.as_ref(); - // View › Pattern Preview: the stroke carries on across the canvas edges, the pattern - // following it as it goes. - let wrap = wrap_tile(&pre, p); if st.impressionist { // Halo is the dab footprint only: Impressionist jitters phase, it does not blur. let effect = st.impressionist_effect(surf.format()); - Ok(apply_dab_stroke_wrapped(surf, &st.stroke, sel, lock, 0, wrap, effect)) - } else if wrap.is_some() { - let (place, fmt) = (st.placement(), surf.format()); - periodic::stroke(&pre, surf, &st.stroke, sel, lock, st.mode, CMD, |_, r| Ok(paint_region(&st.tile, &place, &fmt, r))) + Ok(apply_dab_stroke(surf, &st.stroke, sel, lock, 0, effect)) } else { paint_aligned(surf, &st.stroke, sel, lock, &st.tile, st.placement(), st.mode, CMD) } @@ -123,7 +117,7 @@ pub fn live_impressionist(s: &Session, p: &Value) -> Result { } let (stroke, id) = (st.stroke.clone(), st.id); let doc = (*s.active().ok_or(crate::EngineError::NoDocument)?.doc).clone(); - super::LiveDab::with_effect_on(doc, CMD, p, stroke, id, 0, |pre, _| st.impressionist_effect(pre.format()))?.with_tail_preview() + super::LiveDab::with_effect_on(doc, CMD, p, stroke, id, 0, |pre| st.impressionist_effect(pre.format()))?.with_tail_preview() } /// The pattern under destination `rect` in `fmt` (native channels): what an aligned stroke paints diff --git a/crates/engine/src/cmd/retouch_cmds/sampling.rs b/crates/engine/src/cmd/retouch_cmds/sampling.rs index 8719e931bc..253f6648ad 100644 --- a/crates/engine/src/cmd/retouch_cmds/sampling.rs +++ b/crates/engine/src/cmd/retouch_cmds/sampling.rs @@ -1,7 +1,4 @@ -use super::{ - Document, Footprint, LayerId, PixelFormat, Rect, Region, Result, SampleType, Stroke, Surface, Value, WorkTiles, bad, flag, from_rgba_into, string, - targets_pixels, -}; +use super::{Document, Footprint, LayerId, PixelFormat, Rect, Region, Result, Surface, Value, bad, flag, from_rgba_into, string, targets_pixels}; // --------------------------------------------------------------------------------------------- // Sampling sources @@ -74,56 +71,26 @@ pub(super) struct AllLayers<'a> { pre: &'a Document, fmt: PixelFormat, visible: Option, - /// A stroke wrapped around the canvas (View › Pattern Preview): the composite at full - /// precision under everything the stroke reads, and the dabs' edits of it, kept once per - /// pixel of the canvas as the layer's working pixels are. - wrapped: Option<(Surface, WorkTiles)>, } impl<'a> AllLayers<'a> { pub(super) fn new(pre: &'a Document, fmt: PixelFormat) -> Self { - Self { pre, fmt, visible: None, wrapped: None } - } - - /// Sample for `stroke` wrapped around `wrap`, whose dabs read `halo` pixels around them. - pub(super) fn wrapped(mut self, wrap: Option, stroke: &Stroke, halo: i32) -> Self { - let Some(tile) = wrap else { return self }; - let ctx = photocraft_paint::BrushContext::new(&stroke.brush); - let reach = photocraft_paint::dabs(stroke).iter().fold(Rect::EMPTY, |r, d| r.union(&ctx.dab_rect(d, false))).inflate(halo); - let area = photocraft_paint::wrap::fold_area(reach, tile); - let mut visible = Surface::new(PixelFormat { sample: SampleType::F32, ..self.fmt }); - if !area.is_empty() { - visible.write_region(area, &composite_region(self.pre, None, SampleLayers::All, area, self.fmt).data); - } - self.wrapped = Some((visible, WorkTiles::new(self.fmt.channels()).wrapped(Some(tile)))); - self + Self { pre, fmt, visible: None } } pub(super) fn dab(&mut self, work: &mut Region, fp: &Footprint, effect: impl FnOnce(&mut Region, &Footprint)) { - if let Some((visible, edits)) = self.wrapped.as_mut() { - let mut shown = edits.read(visible, work.rect); - effect(&mut shown, fp); - lay(work, &shown, fp); - edits.write_dab(visible, &shown, fp); - return; - } let (pre, fmt) = (self.pre, self.fmt); let visible = self.visible.get_or_insert_with(|| composite_region(pre, None, SampleLayers::All, work.rect, fmt)); if visible.rect != work.rect || visible.ch != work.ch { return effect(work, fp); } effect(visible, fp); - lay(work, visible, fp); - } -} - -/// Lay what a dab did to the visible pixels onto the layer's working pixels under it. -fn lay(work: &mut Region, visible: &Region, fp: &Footprint) { - let r = fp.rect.intersect(&work.rect); - for y in r.y0..r.y1 { - for x in r.x0..r.x1 { - if fp.at(x, y) > 0.0 { - work.px_mut(x, y).copy_from_slice(visible.px(x, y)); + let r = fp.rect.intersect(&work.rect); + for y in r.y0..r.y1 { + for x in r.x0..r.x1 { + if fp.at(x, y) > 0.0 { + work.px_mut(x, y).copy_from_slice(visible.px(x, y)); + } } } } diff --git a/crates/engine/src/cmd/retouch_cmds/tests.rs b/crates/engine/src/cmd/retouch_cmds/tests.rs index ede2cb9348..b5730f5e34 100644 --- a/crates/engine/src/cmd/retouch_cmds/tests.rs +++ b/crates/engine/src/cmd/retouch_cmds/tests.rs @@ -1912,7 +1912,7 @@ fn live_dab_batches_match_the_accumulating_renderer() { p["flow"] = json!(63); let (stroke, _) = parse_brush(&s, &p, cmd).unwrap(); let (tool, _) = DabTool::parse(&s, cmd, &p, &stroke).unwrap(); - let mut effect = tool.effect(pre.format(), stroke.brush.spacing, None); + let mut effect = tool.effect(pre.format(), stroke.brush.spacing); let mut reference = StrokeRenderer::new(&stroke.brush, None, 1.0).record_dabs(); // A fixed region containing all dabs plus their read neighbourhoods. let orig = Region::read(&pre, Rect::new(-128, -128, 384, 256)); @@ -2534,7 +2534,7 @@ fn periodic_clone_interior_matches_ordinary_brush_masks_and_transparency() { } // --------------------------------------------------------------------------------------------- -// View › Pattern Preview: every retouching stroke wraps across the canvas edges (#8883) +// View › Pattern Preview: Clone Stamp and Healing Brush wrap across the canvas edges (#8883) // --------------------------------------------------------------------------------------------- /// The canvas the Pattern Preview tests wrap around. @@ -2555,48 +2555,11 @@ fn corner_stroke(extra: &Value) -> Value { p } -/// The texture on the layer. -fn textured(s: &mut Session) { - paint_layer(s, tiled_texture); -} - -/// The texture under an empty layer, which is the target (Sample All Layers). -fn textured_below(s: &mut Session) { - paint_layer(s, tiled_texture); - s.execute("layer.new.layer", json!({})).unwrap(); -} - -/// The texture in history state 1, painted over since. -fn textured_in_history(s: &mut Session) { - paint_layer(s, tiled_texture); - paint_layer(s, |_, _| [0.15, 0.2, 0.7, 1.0]); -} - -type Setup = fn(&mut Session); - -/// Every stroke tool that wraps, with options and a document for it. `true` marks the tools -/// that composite a paint image through the stroke's coverage (`periodic::Composite`), which -/// matches the ordinary composite only within rounding. -fn wrap_cases() -> Vec<(&'static str, Value, Setup, bool)> { - vec![ - ("paint.dodge", json!({"exposure": 60}), textured, false), - ("paint.burn", json!({"exposure": 40, "range": "shadows", "protectTones": false}), textured, false), - ("paint.sponge", json!({"mode": "saturate"}), textured, false), - ("paint.blur", json!({"strength": 80}), textured, false), - ("paint.sharpen", json!({"strength": 60}), textured, false), - ("paint.smudge", json!({"strength": 70}), textured, false), - ("paint.blur", json!({"strength": 80, "sampleAllLayers": true}), textured_below, false), - ("paint.smudge", json!({"strength": 70, "sampleAllLayers": true}), textured_below, false), - ("paint.colorReplacement", json!({"color": "#ff8000", "tolerance": 60}), textured, false), - ("paint.backgroundEraser", json!({"tolerance": 40}), textured, false), - ("paint.colorReplacement", json!({"color": "#ff8000", "tolerance": 60, "target": {"colors": [0, 2]}}), textured, false), - ("paint.backgroundEraser", json!({"tolerance": 40, "target": {"colors": [0, 2]}}), textured, false), - ("paint.patternStamp", json!({"pattern": "Bricks", "impressionist": true}), textured, false), - ("paint.patternStamp", json!({"pattern": "Checkerboard"}), textured, true), - ("paint.historyBrush", json!({"state": 1}), textured_in_history, true), - ("paint.healingBrush", json!({"offset": [-20, -14]}), textured, true), - ("paint.cloneStamp", json!({"offset": [-20, -14]}), textured, true), - ] +/// The stroke tools that wrap, with their options. Both composite a paint image through the +/// stroke's coverage (`periodic::Composite`), which matches the ordinary composite only within +/// rounding. +fn wrap_cases() -> [(&'static str, Value); 2] { + [("paint.healingBrush", json!({"offset": [-20, -14]})), ("paint.cloneStamp", json!({"offset": [-20, -14]}))] } /// The native-channel difference `periodic::Composite` may leave against the ordinary composite. @@ -2614,11 +2577,11 @@ fn every_wrapping_stroke_paints_the_canvas_as_on_the_canvas_tiled_three_by_three // copy of that pixel it goes over, on a document holding the canvas tiled three by three. let (w, h) = (WRAP.0 as usize, WRAP.1 as usize); for depth in DEPTHS { - for (cmd, extra, setup, composite) in wrap_cases() { + for (cmd, extra) in wrap_cases() { let mut canvas = session(WRAP.0, WRAP.1, depth, "rgb"); - setup(&mut canvas); + paint_layer(&mut canvas, tiled_texture); let mut tiled = session(3 * WRAP.0, 3 * WRAP.1, depth, "rgb"); - setup(&mut tiled); + paint_layer(&mut tiled, tiled_texture); let (mut wrapped, plain) = (corner_stroke(&extra), corner_stroke(&extra)); wrapped["wrap"] = json!(true); canvas.execute(cmd, wrapped).unwrap_or_else(|e| panic!("{cmd} at {depth}: {e}")); @@ -2627,7 +2590,7 @@ fn every_wrapping_stroke_paints_the_canvas_as_on_the_canvas_tiled_three_by_three let (got, want) = (layer_pixels(&canvas), layer_pixels(&tiled)); let n = got.len() / (w * h); let at = |x: usize, y: usize| (y * 3 * w + x) * n..(y * 3 * w + x + 1) * n; - let tolerance = if composite { composite_tolerance(depth) } else { 0.0 }; + let tolerance = composite_tolerance(depth); let (mut moved, mut edges) = (0, [false; 4]); for y in 0..h { for x in 0..w { @@ -2657,66 +2620,31 @@ fn every_wrapping_stroke_paints_the_canvas_as_on_the_canvas_tiled_three_by_three #[test] fn a_wrapped_stroke_inside_the_canvas_paints_as_an_ordinary_one() { let p = json!({"points": [[130, 110], [150, 118], [168, 112]], "size": 14, "hardness": 60}); - let mut cases: Vec<(&str, Value, Setup, bool)> = wrap_cases(); - cases.push(("paint.spotHealing", json!({"type": "contentAware"}), textured, false)); - cases.push(("paint.spotHealing", json!({"type": "proximityMatch"}), textured, false)); for depth in [8, 32] { - for (cmd, extra, setup, composite) in &cases { + for (cmd, extra) in wrap_cases() { let run = |wrap: bool| { - // Big enough that Spot Healing's window stays clear of the edges either way. let mut s = session(300, 230, depth, "rgb"); - setup(&mut s); + paint_layer(&mut s, tiled_texture); let mut q = p.clone(); for (k, v) in extra.as_object().unwrap() { q[k] = v.clone(); } q["wrap"] = json!(wrap); - let r = s.execute(cmd, q).unwrap(); - (layer_pixels(&s), r["damage"].clone()) + s.execute(cmd, q).unwrap(); + layer_pixels(&s) }; - let ((plain, d0), (wrapped, d1)) = (run(false), run(true)); - let tolerance = if *composite { composite_tolerance(depth) } else { 0.0 }; + let (plain, wrapped) = (run(false), run(true)); let error = plain.iter().zip(&wrapped).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max); - assert!(error <= tolerance, "{cmd} {extra} at {depth}-bit: off by {error}"); - if !*composite { - assert_eq!(d0, d1, "{cmd} {extra} at {depth}-bit: the same damage"); - } - } - } -} - -#[test] -fn spot_healing_across_the_canvas_corner_heals_every_copy_of_the_stroke_and_nothing_else() { - // The window is centred on the stroke whatever the edges and one canvas across, so the - // corner heals from what surrounds it in the copies, the same as when the stroke stays inside. - for kind in ["contentAware", "createTexture", "proximityMatch"] { - let mut s = session(WRAP.0, WRAP.1, 8, "rgb"); - paint_layer(&mut s, |x, y| if (x - 2).abs() < 3 && (y - 2).abs() < 3 { [0.0, 0.0, 0.0, 1.0] } else { tiled_texture(x, y) }); - let before = layer_pixels(&s); - // The dark blemish straddles the corner (0, 0); the stroke is drawn on the copy to its - // lower right, around (48, 36). - s.execute("paint.spotHealing", json!({"points": [[48, 36], [50, 38]], "size": 14, "hardness": 100, "type": kind, "wrap": true})).unwrap(); - let after = layer_pixels(&s); - let n = after.len() / (WRAP.0 * WRAP.1) as usize; - let px = |v: &[f32], x: usize, y: usize| v[(y * WRAP.0 as usize + x) * n..][..n].to_vec(); - assert!(px(&after, 1, 1)[0] > 0.1, "{kind}: the blemish healed: {:?}", px(&after, 1, 1)); - for (x, y) in [(46, 1), (1, 34), (46, 34)] { - assert!(px(&after, x, y)[0] > 0.1, "{kind}: ({x}, {y}) healed: {:?}", px(&after, x, y)); - assert_ne!(px(&after, x, y), px(&before, x, y), "{kind}: ({x}, {y}) is under the stroke"); - } - for (x, y) in [(24, 18), (10, 18), (24, 4), (38, 28)] { - assert_eq!(px(&after, x, y), px(&before, x, y), "{kind}: ({x}, {y}) is far from the stroke"); + assert!(error <= composite_tolerance(depth), "{cmd} {extra} at {depth}-bit: off by {error}"); } } } #[test] fn a_wrapped_dab_bigger_than_the_canvas_stays_on_it() { - let mut cases = wrap_cases(); - cases.push(("paint.spotHealing", json!({"type": "contentAware"}), textured, false)); - for (cmd, extra, setup, _) in cases { + for (cmd, extra) in wrap_cases() { let mut s = session(16, 12, 8, "rgb"); - setup(&mut s); + paint_layer(&mut s, tiled_texture); let before = layer_pixels(&s); let mut p = json!({"points": [[8, 6], [30, 20]], "size": 60, "hardness": 0, "wrap": true}); for (k, v) in extra.as_object().unwrap() { @@ -2728,168 +2656,68 @@ fn a_wrapped_dab_bigger_than_the_canvas_stays_on_it() { let st = s.active().unwrap(); let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); assert!([(-1, 5), (16, 5), (5, -1), (5, 12), (40, 30)].iter().all(|&(x, y)| surf.rgba(x, y)[3] == 0.0), "{cmd}: nothing lands off the canvas"); - // Spot Healing has nothing left to sample here (the next test). - if cmd != "paint.spotHealing" { - assert!(layer_pixels(&s) != before, "{cmd} {extra}: the stroke changed the canvas"); - } + assert!(layer_pixels(&s) != before, "{cmd} {extra}: the stroke changed the canvas"); } } #[test] -fn spot_healing_with_nothing_left_to_sample_leaves_the_layer_alone() { - // Wrapped, the dab covers all of a canvas smaller than it, so its window has no pixel left - // to heal from. - for depth in DEPTHS { - for kind in ["contentAware", "createTexture", "proximityMatch"] { - let mut s = session(48, 36, depth, "rgb"); - textured(&mut s); +fn the_tools_the_reference_application_does_not_wrap_stop_at_the_canvas_edge() { + // Measured in the reference application: in Pattern Preview the Clone Stamp and Healing + // Brush carry a stroke on across the canvas edges, but Blur … Sponge, Color Replacement, + // Background Eraser, Pattern Stamp and History Brush warn that they don't wrap, and Spot + // Healing stops at the edge too. Their `wrap` stroke over the corner paints as the same + // stroke without it. + let points = [[36.0, 24.0], [58.0, 39.0]]; + let cases = [ + ("paint.blur", json!({"strength": 80})), + ("paint.sharpen", json!({"strength": 60})), + ("paint.smudge", json!({"strength": 70})), + ("paint.dodge", json!({"exposure": 60})), + ("paint.burn", json!({"exposure": 40})), + ("paint.sponge", json!({"mode": "saturate"})), + ("paint.colorReplacement", json!({"color": "#ff8000", "tolerance": 60})), + ("paint.backgroundEraser", json!({"tolerance": 40})), + ("paint.patternStamp", json!({"pattern": "Checkerboard"})), + ("paint.patternStamp", json!({"pattern": "Bricks", "impressionist": true, "seed": 7})), + ("paint.historyBrush", json!({"state": 1})), + ("paint.spotHealing", json!({"type": "contentAware"})), + ]; + for (cmd, extra) in cases { + let run = |wrap: bool| { + let mut s = session(WRAP.0, WRAP.1, 8, "rgb"); + paint_layer(&mut s, tiled_texture); + // Painted over since history state 1, so the History Brush has something to bring back. + paint_layer(&mut s, |x, y| tiled_texture(x + 7, y + 5)); + let mut p = dab_opts(&points, 100, &extra); + p["size"] = json!(12); + p["wrap"] = json!(wrap); let before = layer_pixels(&s); - let r = s.execute("paint.spotHealing", json!({"points": [[48, 36]], "size": 64, "hardness": 100, "type": kind, "wrap": true})).unwrap(); - assert_eq!(layer_pixels(&s), before, "{kind} at {depth}-bit"); - assert_eq!(r["damage"][2].as_i64().unwrap() * r["damage"][3].as_i64().unwrap(), 0, "{kind} at {depth}-bit: no damage, got {}", r["damage"]); - } + s.execute(cmd, p).unwrap_or_else(|e| panic!("{cmd}: {e}")); + (before, layer_pixels(&s)) + }; + let ((before, plain), (_, wrapped)) = (run(false), run(true)); + assert!(plain != before, "{cmd} {extra}: the stroke paints inside the canvas"); + assert!(wrapped == plain, "{cmd} {extra}: nothing comes back at the opposite edges"); } } #[test] -fn a_wrapped_dodge_or_burn_stroke_tones_each_pixel_once() { - // A stroke round the canvas, or a dab bigger than it, comes back to pixels one canvas away; - // the stroke still tones each of them once, as a single dab does. - for depth in DEPTHS { - for cmd in ["paint.dodge", "paint.burn"] { - let run = |w: u32, h: u32, mut p: Value| { - let mut s = session(w, h, depth, "rgb"); - paint_layer(&mut s, |_, _| [0.5, 0.5, 0.5, 1.0]); - p["exposure"] = json!(60); - s.execute(cmd, p).unwrap(); - layer_pixels(&s) - }; - let once = run(WRAP.0, WRAP.1, json!({"points": [[24, 18]], "size": 10, "hardness": 100})); - let n = once.len() / (WRAP.0 * WRAP.1) as usize; - let toned = &once[(18 * WRAP.0 as usize + 24) * n..][..n]; - assert_ne!(toned, &once[..n], "{cmd} at {depth}-bit: the dab tones"); - let round = run(WRAP.0, WRAP.1, json!({"points": [[2, 18], [122, 18]], "size": 10, "hardness": 100, "wrap": true})); - for x in 0..WRAP.0 as usize { - assert_eq!(&round[(18 * WRAP.0 as usize + x) * n..][..n], toned, "{cmd} at {depth}-bit: ({x}, 18)"); - } - let big = run(16, 12, json!({"points": [[8, 6], [30, 20]], "size": 60, "hardness": 100, "wrap": true})); - for (i, px) in big.chunks(n).enumerate() { - assert_eq!(px, toned, "{cmd} at {depth}-bit: pixel {i} under the big dab"); - } - } - } -} - -#[test] -fn wrapped_live_strokes_show_what_their_commits_paint() { - // Across the left and bottom edges, as the canvas draws them: Dodge … Smudge commit the - // pixels they drew; the strokes that preview their finishing dabs show their commit's. - let points = [[30.0, 20.0], [10.0, 30.0], [-6.0, 40.0], [-12.0, 34.0]]; - for depth in DEPTHS { - for (cmd, extra) in dab_cases() { - let mut s = session(WRAP.0, WRAP.1, depth, "rgb"); - paint_layer(&mut s, tiled_texture); - let mut extra = extra.clone(); - extra["wrap"] = json!(true); - let rendered = s.execute(cmd, dab_opts(&points, 50, &extra)).unwrap(); - let want = layer_pixels(&s); - s.execute("edit.undo", json!({})).unwrap(); - offer_live(&mut s, cmd, &points, 50, &extra); - let taken = s.execute(cmd, dab_opts(&points, 50, &extra)).unwrap(); - assert_eq!(taken["fromLive"], json!(true), "{cmd} at {depth}-bit"); - assert_eq!(taken["damage"], rendered["damage"], "{cmd} at {depth}-bit"); - assert!(layer_pixels(&s) == want, "{cmd} at {depth}-bit: the live pixels are the command's"); - } - let pts: Vec<_> = points.iter().map(|q| StrokePoint::new(q[0], q[1], 1.0)).collect(); - type Begin = fn(&Session, &Value) -> Result; - let shown: [(&str, Value, Begin); 5] = [ - ("paint.colorReplacement", json!({"color": "#ff8000", "tolerance": 60}), crate::brush_cmds::live_color_replacement), - ("paint.colorReplacement", json!({"color": "#ff8000", "tolerance": 60, "target": {"colors": [0, 2]}}), crate::brush_cmds::live_color_replacement), - ("paint.backgroundEraser", json!({"tolerance": 40}), crate::eraser_cmds::live_background_eraser), - ("paint.backgroundEraser", json!({"tolerance": 40, "target": {"colors": [0, 2]}}), crate::eraser_cmds::live_background_eraser), - // A seed, as the canvas sends: without one the jitter hashes all the points. - ("paint.patternStamp", json!({"pattern": "Bricks", "impressionist": true, "seed": 7}), live_pattern_impressionist), - ]; - for (cmd, extra, begin) in shown { - let mut s = session(WRAP.0, WRAP.1, depth, "rgb"); - paint_layer(&mut s, tiled_texture); - let mut p = dab_opts(&points[..1], 50, &extra); - p["wrap"] = json!(true); - let mut live = begin(&s, &p).unwrap(); - live.push(&pts[1..]).unwrap(); - let shown = live.doc.clone(); - let pre = s.active().unwrap().doc.clone(); - p["points"] = json!(points); - s.execute(cmd, p).unwrap(); - let st = s.active().unwrap(); - let id = st.active_layer.unwrap(); - let (a, b) = (shown.layer(id).unwrap().surface().unwrap(), st.doc.layer(id).unwrap().surface().unwrap()); - let before = pre.layer(id).unwrap().surface().unwrap(); - let worst = (0..WRAP.1 as i32) - .flat_map(|y| (0..WRAP.0 as i32).map(move |x| (x, y))) - .map(|(x, y)| (0..4).map(|c| (a.rgba(x, y)[c] - b.rgba(x, y)[c]).abs()).fold(0.0f32, f32::max)) - .fold(0.0f32, f32::max); - assert!(worst <= 2.0 / 255.0 + 1e-6, "{cmd} at {depth}-bit: live and commit differ by {worst}"); - let right = WRAP.0 as i32 - 3; - assert!((0..WRAP.1 as i32).any(|y| b.rgba(right, y) != before.rgba(right, y)), "{cmd} at {depth}-bit: the stroke came back at the right edge"); - } - } -} - -#[test] -fn wrapped_live_retouch_strokes_show_what_their_commits_paint() { - // History Brush commits the pixels its live stroke drew across the edges; the aligned Pattern - // Stamp's preview is its commit. - let points = [[30.0, 20.0], [10.0, 30.0], [-6.0, 40.0], [-12.0, 34.0]]; +fn live_history_and_pattern_strokes_stop_at_the_canvas_edge() { + // The History Brush and aligned Pattern Stamp previews stop at the edge as their commits do. + let points = [[36.0, 24.0], [58.0, 39.0]]; let pts: Vec<_> = points.iter().map(|q| StrokePoint::new(q[0], q[1], 1.0)).collect(); - for depth in DEPTHS { - for (cmd, extra, setup) in [ - ("paint.historyBrush", json!({"state": 1}), textured_in_history as Setup), - ("paint.patternStamp", json!({"pattern": "Checkerboard"}), textured as Setup), - ] { - let mut s = session(WRAP.0, WRAP.1, depth, "rgb"); - setup(&mut s); - let mut p = dab_opts(&points[..1], 50, &extra); - p["wrap"] = json!(true); + for (cmd, extra) in [("paint.historyBrush", json!({"state": 1})), ("paint.patternStamp", json!({"pattern": "Checkerboard"}))] { + let bounds = |wrap: bool| { + let mut s = session(WRAP.0, WRAP.1, 8, "rgb"); + paint_layer(&mut s, tiled_texture); + paint_layer(&mut s, |_, _| [0.15, 0.2, 0.7, 1.0]); + let mut p = dab_opts(&points[..1], 100, &extra); + p["size"] = json!(12); + p["wrap"] = json!(wrap); let mut live = LiveRetouch::begin(&s, cmd, &p).unwrap(); live.push(&pts[1..]).unwrap(); - p["points"] = json!(points); - let shown = live.doc.layer(s.active().unwrap().active_layer.unwrap()).unwrap().surface().unwrap().read_region(Rect::new(0, 0, 48, 36)); - let plain = s.execute(cmd, p.clone()).unwrap(); - let want = layer_pixels(&s); - let error = shown.iter().zip(&want).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max); - assert!(error <= composite_tolerance(depth), "{cmd} at {depth}-bit: the preview is off by {error}"); - if cmd == "paint.historyBrush" { - // A stroke drawn on the document as it is after the undo. - s.execute("edit.undo", json!({})).unwrap(); - let mut first = p.clone(); - first["points"] = json!(points[..1]); - let mut live = LiveRetouch::begin(&s, cmd, &first).unwrap(); - live.push(&pts[1..]).unwrap(); - s.offer_live_retouch(live).unwrap(); - let taken = s.execute(cmd, p).unwrap(); - assert_eq!(taken["fromLive"], json!(true), "{depth}-bit"); - assert_eq!(taken["damage"], plain["damage"], "{depth}-bit"); - assert!(layer_pixels(&s) == want, "{depth}-bit: the committed live pixels are the command's"); - } - } - } -} - -#[test] -fn a_wrapped_composite_stroke_over_its_work_budget_names_its_own_command() { - // Big, tightly spaced dabs pass the stroke footprint check but not the wrapped-work budget. - for (cmd, extra, setup) in [ - ("paint.historyBrush", json!({"state": 1}), textured_in_history as Setup), - ("paint.patternStamp", json!({"pattern": "Checkerboard"}), textured as Setup), - ] { - let mut s = session(WRAP.0, WRAP.1, 8, "rgb"); - setup(&mut s); - let mut p = json!({"points": [[0, 0], [700, 0]], "size": 1000, "hardness": 100, "spacing": 1, "wrap": true}); - for (k, v) in extra.as_object().unwrap() { - p[k] = v.clone(); - } - let err = s.execute(cmd, p).unwrap_err().to_string(); - assert!(err.contains(cmd) && err.contains("coverage budget"), "{cmd}: {err}"); + live.bounds() + }; + assert_eq!(bounds(true), bounds(false), "{cmd}: the live stroke stays where it was drawn"); } } diff --git a/crates/paint/src/bg_erase.rs b/crates/paint/src/bg_erase.rs index e41308e76a..323a90036a 100644 --- a/crates/paint/src/bg_erase.rs +++ b/crates/paint/src/bg_erase.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use crate::Stroke; use crate::replace::{Limits, Sampling}; -use crate::retouch::{Footprint, Region, alpha_index, apply_dab_stroke_wrapped}; +use crate::retouch::{Footprint, Region, alpha_index, apply_dab_stroke}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -49,14 +49,8 @@ fn dist(a: &[f32], b: &[f32]) -> f32 { /// the selection limits the effect. Returns the damaged rectangle; empty when the surface has no /// alpha channel. pub fn apply_background_eraser(target: &mut Surface, stroke: &Stroke, bs: &BgEraseSettings, selection: Option<&Surface>) -> Rect { - apply_background_eraser_wrapped(target, stroke, bs, selection, None) -} - -/// [`apply_background_eraser`] wrapped around `wrap` (View › Pattern Preview): the stroke samples -/// and erases where its pixels lie on that canvas, so it carries on across the edges. -pub fn apply_background_eraser_wrapped(target: &mut Surface, stroke: &Stroke, bs: &BgEraseSettings, selection: Option<&Surface>, wrap: Option) -> Rect { - let Some(effect) = background_eraser_effect(target, bs, wrap) else { return Rect::EMPTY }; - apply_dab_stroke_wrapped(target, &background_eraser_stroke(stroke), selection, false, 0, wrap, effect) + let Some(effect) = background_eraser_effect(target, bs) else { return Rect::EMPTY }; + apply_dab_stroke(target, &background_eraser_stroke(stroke), selection, false, 0, effect) } /// The stroke [`apply_background_eraser`] runs its dabs along: full opacity (the tool has none), @@ -70,9 +64,8 @@ pub fn background_eraser_stroke(stroke: &Stroke) -> Stroke { /// One Background Eraser dab's edit of a working copy of `pre` (the target before the stroke): /// shared by [`apply_background_eraser`] and live previews that run the dabs as they come, so both -/// erase the same pixels. `None` when `pre` has no alpha channel (nothing can be erased). In a -/// stroke wrapped around `wrap`, `pre` is read where the working pixels lie on that canvas. -pub fn background_eraser_effect(pre: &Surface, bs: &BgEraseSettings, wrap: Option) -> Option> { +/// erase the same pixels. `None` when `pre` has no alpha channel (nothing can be erased). +pub fn background_eraser_effect(pre: &Surface, bs: &BgEraseSettings) -> Option> { let fmt = pre.format(); let a = alpha_index(&fmt)?; let nc = a; @@ -104,7 +97,7 @@ pub fn background_eraser_effect(pre: &Surface, bs: &BgEraseSettings, wrap: Optio return; } if orig.as_ref().is_none_or(|o| o.rect != work.rect) { - orig = Some(Region::read_wrapped(&pre, work.rect, wrap)); + orig = Some(Region::read(&pre, work.rect)); } let Some(orig) = orig.as_ref() else { return }; let (cx, cy) = (fp.dab.center.x.floor() as i32, fp.dab.center.y.floor() as i32); diff --git a/crates/paint/src/lib.rs b/crates/paint/src/lib.rs index fc356ff5cb..7054e11492 100644 --- a/crates/paint/src/lib.rs +++ b/crates/paint/src/lib.rs @@ -10,8 +10,7 @@ //! //! Module map: [`brush`] (the serde model), [`dynamics`] (points → dabs), [`render`] (dabs → //! stroke buffer → pixels), [`presets`] (built-ins), [`mixer`] (Mixer Brush), [`replace`] (Color -//! Replacement), [`retouch`] (sequential/accumulating helpers for the retouch tools), [`wrap`] -//! (View › Pattern Preview: strokes repeating across the canvas edges). +//! Replacement), [`retouch`] (sequential/accumulating helpers for the retouch tools). #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -35,7 +34,6 @@ pub mod retouch; pub mod rng; pub mod symmetry; pub mod tile; -pub mod wrap; pub use brush::{ BrushPreset, BrushSettings, ColorDynamics, Control, DualBrush, Dynamic, MAX_BRUSH_SIZE, MAX_SCATTER, MaskMode, Pattern, PatternStyle, Pose, PresetCaptures, diff --git a/crates/paint/src/replace.rs b/crates/paint/src/replace.rs index 957037a0bd..20619cd48a 100644 --- a/crates/paint/src/replace.rs +++ b/crates/paint/src/replace.rs @@ -8,8 +8,7 @@ use photocraft_raster::{Surface, from_rgba_into, to_rgba}; use serde::{Deserialize, Serialize}; use crate::Stroke; -use crate::retouch::{Footprint, Region, apply_dab_stroke_wrapped}; -use crate::wrap::wrap_coord; +use crate::retouch::{Footprint, Region, apply_dab_stroke}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -80,29 +79,14 @@ fn dist(a: &[f32; 4], b: [f32; 3]) -> f32 { /// Replace colours along a stroke. Returns the damaged rectangle. pub fn apply_color_replacement(target: &mut Surface, stroke: &Stroke, rs: &ReplaceSettings, selection: Option<&Surface>, lock_transparency: bool) -> Rect { - apply_color_replacement_wrapped(target, stroke, rs, selection, lock_transparency, None) -} - -/// [`apply_color_replacement`] wrapped around `wrap` (View › Pattern Preview): the stroke samples -/// and recolours where its pixels lie on that canvas, so it carries on across the edges. -pub fn apply_color_replacement_wrapped( - target: &mut Surface, - stroke: &Stroke, - rs: &ReplaceSettings, - selection: Option<&Surface>, - lock_transparency: bool, - wrap: Option, -) -> Rect { - let effect = color_replacement_effect(target.clone(), rs.clone(), wrap); - apply_dab_stroke_wrapped(target, stroke, selection, lock_transparency, 0, wrap, effect) + let effect = color_replacement_effect(target.clone(), rs.clone()); + apply_dab_stroke(target, stroke, selection, lock_transparency, 0, effect) } /// One Color Replacement dab's edit of a working copy, matching against `pre` (the target before /// the stroke). Shared by [`apply_color_replacement`] and live previews that run the dabs as they -/// come, so both recolour the same pixels. In a stroke wrapped around `wrap` (View › Pattern -/// Preview), `pre` is read where the dab's pixels lie on that canvas. -pub fn color_replacement_effect(pre: Surface, rs: ReplaceSettings, wrap: Option) -> impl FnMut(&mut Region, &Footprint) { - let wrap = wrap.filter(|t| !t.is_empty()); +/// come, so both recolour the same pixels. +pub fn color_replacement_effect(pre: Surface, rs: ReplaceSettings) -> impl FnMut(&mut Region, &Footprint) { let fmt = pre.format(); let n = fmt.channels(); let mode = match rs.mode { @@ -127,14 +111,13 @@ pub fn color_replacement_effect(pre: Surface, rs: ReplaceSettings, wrap: Option< }; move |work: &mut Region, fp: &Footprint| { let (cx, cy) = (fp.dab.center.x.floor() as i32, fp.dab.center.y.floor() as i32); - let (sx, sy) = wrap.map_or((cx, cy), |t| (wrap_coord(cx, t.x0, t.x1), wrap_coord(cy, t.y0, t.y1))); let sampled = match rs.sampling { Sampling::Continuous => { - let c = pre.rgba(sx, sy); + let c = pre.rgba(cx, cy); [c[0], c[1], c[2]] } Sampling::Once => *once.get_or_insert_with(|| { - let c = pre.rgba(sx, sy); + let c = pre.rgba(cx, cy); [c[0], c[1], c[2]] }), Sampling::BackgroundSwatch => [rs.background[0], rs.background[1], rs.background[2]], @@ -144,15 +127,8 @@ pub fn color_replacement_effect(pre: Surface, rs: ReplaceSettings, wrap: Option< return; } let (w, h) = (r.width() as usize, r.height() as usize); - let read = |q: Rect| { - let mut px = vec![[0.0f32; 4]; q.width() as usize * q.height() as usize]; - pre.read_rgba_into(q, &mut px); - px - }; - let pre_px = match wrap { - Some(t) => crate::wrap::read(r, t, 1, read), - None => read(r), - }; + let mut pre_px = vec![[0.0f32; 4]; w * h]; + pre.read_rgba_into(r, &mut pre_px); // Per-pixel match weight. let mut weight = vec![0.0f32; w * h]; for y in r.y0..r.y1 { diff --git a/crates/paint/src/retouch.rs b/crates/paint/src/retouch.rs index 53ef5b9a01..ec66e32f84 100644 --- a/crates/paint/src/retouch.rs +++ b/crates/paint/src/retouch.rs @@ -14,13 +14,10 @@ //! All pixel data here is the surface's *native* channels as normalised `f32` (any depth, any colour //! model); colour-space-specific work happens in the callbacks. -use std::collections::HashMap; - use photocraft_color::{BlendMode, PixelFormat}; use photocraft_geom::Rect; use photocraft_raster::{Surface, from_rgba_into, to_rgba}; -use crate::wrap::{fits, fold_area, wrap_coord}; use crate::{Dab, Stroke, dab_coverage, dabs}; /// A rectangle of native-channel pixels (`rect.width() × rect.height() × ch` floats, row-major). @@ -78,14 +75,6 @@ impl Region { pub fn write(&self, s: &mut Surface) { s.write_region(self.rect, &self.data); } - /// Read `rect` from a surface that repeats `wrap` apart (View › Pattern Preview): `rect` may - /// cross its edges. `None` (or an empty tile) is [`Region::read`]. - pub fn read_wrapped(s: &Surface, rect: Rect, wrap: Option) -> Self { - match wrap.filter(|t| !t.is_empty()) { - Some(tile) => Self { rect, ch: s.channels(), data: crate::wrap::read(rect, tile, s.channels(), |r| s.read_region(r)) }, - None => Self::read(s, rect), - } - } } /// One dab's footprint: coverage (hardness falloff × dab alpha) over its bounding rectangle. @@ -289,174 +278,6 @@ pub fn apply_dab_stroke( bounds } -/// [`apply_dab_stroke`] wrapped around `wrap` (View › Pattern Preview): each dab reads and edits -/// the pixels where it repeats on that tile, so a stroke crossing an edge carries on at the -/// opposite one. The dabs run one by one on a [`WorkTiles`] working copy, as live previews run -/// them. `None` (or an empty tile) is [`apply_dab_stroke`]. Returns the damaged rectangle. -pub fn apply_dab_stroke_wrapped( - target: &mut Surface, - stroke: &Stroke, - selection: Option<&Surface>, - lock_transparency: bool, - halo: i32, - wrap: Option, - mut effect: impl FnMut(&mut Region, &Footprint), -) -> Rect { - let Some(tile) = wrap.filter(|t| !t.is_empty()) else { - return apply_dab_stroke(target, stroke, selection, lock_transparency, halo, effect); - }; - let ctx = crate::BrushContext::new(&stroke.brush); - let mut work = WorkTiles::new(target.channels()).wrapped(Some(tile)); - let mut damage = Rect::EMPTY; - for (i, d) in dabs(stroke).iter().enumerate() { - let fp = ctx.footprint(d, i); - if fp.rect.is_empty() { - continue; - } - let mut region = work.read(target, fp.rect.inflate(halo)); - effect(&mut region, &fp); - work.write_dab(target, ®ion, &fp); - damage = damage.union(&fold_area(fp.rect, tile)); - } - let written: Vec = work.areas().map(|r| r.intersect(&tile)).filter(|r| !r.is_empty()).collect(); - for r in written { - let edited = work.read(target, r); - mix_back(target, &Region::read(target, r), &edited, r, stroke.brush.opacity, selection, lock_transparency); - } - damage -} - -/// Side of the tiles a [`WorkTiles`] working copy keeps its pixels in. -const WORK_TILE: i32 = 128; - -/// The working copy of a sequential-dab stroke run dab by dab (live previews, -/// [`apply_dab_stroke_wrapped`]): the target's native channels as `f32`, in tiles copied from the -/// pre-stroke surface the first time a dab writes to them. Unlike a `Surface`, it isn't rounded to -/// the document's depth between dabs, so each dab sees what [`apply_dab_stroke`]'s working copy -/// holds. Wrapped around a tile ([`Self::wrapped`]), it keeps each pixel once, where it lies on -/// the tile, and reads and writes it wherever it repeats. -pub struct WorkTiles { - ch: usize, - tiles: HashMap<(i32, i32), Vec>, - wrap: Option, -} - -impl WorkTiles { - pub fn new(ch: usize) -> Self { - Self { ch, tiles: HashMap::new(), wrap: None } - } - - /// Keep the pixels of a stroke wrapped around `tile` (View › Pattern Preview). `None` or an - /// empty tile doesn't wrap. - pub fn wrapped(mut self, tile: Option) -> Self { - self.wrap = tile.filter(|t| !t.is_empty()); - self - } - - fn tile_rect((tx, ty): (i32, i32)) -> Rect { - Rect::new(tx * WORK_TILE, ty * WORK_TILE, tx * WORK_TILE + WORK_TILE, ty * WORK_TILE + WORK_TILE) - } - - /// The tiles `r` overlaps. - fn coords(r: Rect) -> impl Iterator { - let (x0, x1) = (r.x0.div_euclid(WORK_TILE), (r.x1 - 1).div_euclid(WORK_TILE)); - let (y0, y1) = (r.y0.div_euclid(WORK_TILE), (r.y1 - 1).div_euclid(WORK_TILE)); - let empty = r.is_empty(); - (y0..=y1).filter(move |_| !empty).flat_map(move |ty| (x0..=x1).map(move |tx| (tx, ty))) - } - - /// The working pixels of `r`: the pre-stroke pixels `pre` where no dab has written yet. - /// Wrapped, `r` may cross the tile's edges or be bigger than it. - pub fn read(&self, pre: &Surface, r: Rect) -> Region { - match self.wrap { - Some(tile) => Region { rect: r, ch: self.ch, data: crate::wrap::read(r, tile, self.ch, |part| self.read_at(pre, part).data) }, - None => self.read_at(pre, r), - } - } - - fn read_at(&self, pre: &Surface, r: Rect) -> Region { - let mut out = Region::new(r, self.ch); - let n = self.ch; - let mut buf = Vec::new(); - for c in Self::coords(r) { - let tr = Self::tile_rect(c); - let part = r.intersect(&tr); - let w = part.width() as usize * n; - let tile = self.tiles.get(&c); - if tile.is_none() { - pre.read_region_into(part, &mut buf); - } - for y in part.y0..part.y1 { - let src = match tile { - Some(t) => t.get(((y - tr.y0) * WORK_TILE + (part.x0 - tr.x0)) as usize * n..).and_then(|s| s.get(..w)), - None => buf.get((y - part.y0) as usize * w..).and_then(|s| s.get(..w)), - }; - let dst = out.index(part.x0, y) * n; - if let (Some(s), Some(d)) = (src, out.data.get_mut(dst..dst + w)) { - d.copy_from_slice(s); - } - } - } - out - } - - /// Store a dab's edit: the pixels of `region` (which covers the dab) over the dab's rectangle - /// `fp.rect`. Wrapped, they land where they lie on the tile; a dab bigger than the tile covers - /// some pixels more than once, and each keeps the copy the dab covers most. - pub fn write_dab(&mut self, pre: &Surface, region: &Region, fp: &Footprint) { - let r = fp.rect.intersect(®ion.rect); - let Some(tile) = self.wrap else { - return self.write_at(pre, ®ion.crop(r)); - }; - if fits(r, tile) { - for (part, (dx, dy)) in crate::wrap::pieces(r, tile) { - let mut piece = region.crop(part.translate(dx, dy)); - piece.rect = part; - self.write_at(pre, &piece); - } - return; - } - let area = fold_area(r, tile); - let mut folded = self.read_at(pre, area); - let mut most = vec![0.0f32; area.width() as usize * area.height() as usize]; - for y in r.y0..r.y1 { - for x in r.x0..r.x1 { - let (c, tx, ty) = (fp.at(x, y), wrap_coord(x, tile.x0, tile.x1), wrap_coord(y, tile.y0, tile.y1)); - if let Some(m) = most.get_mut(folded.index(tx, ty)) - && c > *m - { - *m = c; - folded.px_mut(tx, ty).copy_from_slice(region.px(x, y)); - } - } - } - self.write_at(pre, &folded); - } - - /// Store `region` as the working pixels of its rectangle. - fn write_at(&mut self, pre: &Surface, region: &Region) { - let (r, n) = (region.rect, self.ch); - for c in Self::coords(r) { - let tr = Self::tile_rect(c); - let tile = self.tiles.entry(c).or_insert_with(|| pre.read_region(tr)); - let part = r.intersect(&tr); - let w = part.width() as usize * n; - for y in part.y0..part.y1 { - let dst = ((y - tr.y0) * WORK_TILE + (part.x0 - tr.x0)) as usize * n; - let src = region.index(part.x0, y) * n; - if let (Some(s), Some(d)) = (region.data.get(src..src + w), tile.get_mut(dst..dst + w)) { - d.copy_from_slice(s); - } - } - } - } - - /// The rectangles of the tiles dabs have written to. - pub fn areas(&self) -> impl Iterator + '_ { - self.tiles.keys().map(|&c| Self::tile_rect(c)) - } -} - /// Mix a sequential-dab stroke's working pixels `work` back over the original `orig` at /// `opacity × selection` inside `rect`, and write that area to `target`: the last step of /// [`apply_dab_stroke`], shared with live previews that run the dabs as they come. @@ -1066,89 +887,4 @@ mod tests { } } } - - /// The canvas the Pattern Preview strokes below wrap around. - const TILE: Rect = Rect::new(0, 0, 40, 30); - - /// `area` of a surface showing the canvas repeated: every pixel of the canvas has its own colour. - fn tiled(fmt: PixelFormat, area: Rect) -> Surface { - let mut s = Surface::new(fmt); - let px = (area.y0..area.y1).flat_map(|y| (area.x0..area.x1).map(move |x| (wrap_coord(x, 0, 40), wrap_coord(y, 0, 30)))); - let data: Vec = px.flat_map(|(x, y)| [x as f32 / 40.0, y as f32 / 30.0, ((x * 7 + y * 3) % 13) as f32 / 13.0, 1.0]).collect(); - s.write_region(area, &data); - s - } - - fn smudge(size: f32) -> impl FnMut(&mut Region, &Footprint) { - let mut sm = Smudge::new(0.8, None, size).with_alpha(Some(3)); - move |work, fp| sm.dab(work, fp) - } - - #[test] - fn a_wrapped_sequential_stroke_paints_the_canvas_as_its_tiled_copies_would() { - // View › Pattern Preview: a smudge across a corner of the canvas copy it is drawn on gives - // the canvas exactly what it gives the canvas tiled three by three, where it runs into the - // next copies and carries their colours along. (The same points both times: moved points - // would place the dabs a rounding apart.) - let fmt = PixelFormat::RGBA32F; - let far = stroke(&[(68.0, 48.0), (92.0, 70.0)], 12.0, 0.5); - let halo = Smudge::sampling_radius(12.0); - let mut canvas = tiled(fmt, TILE); - let dmg = apply_dab_stroke_wrapped(&mut canvas, &far, None, false, halo, Some(TILE), smudge(12.0)); - let three = Rect::new(0, 0, 120, 90); - let (before, mut big) = (tiled(fmt, three), tiled(fmt, three)); - apply_dab_stroke(&mut big, &far, None, false, halo, smudge(12.0)); - let mut changed = 0; - for y in 0..30 { - for x in 0..40 { - let copies = [(40, 30), (80, 30), (40, 60), (80, 60)].map(|(dx, dy)| (x + dx, y + dy)); - let moved: Vec<_> = copies.into_iter().filter(|&(u, v)| big.pixel(u, v) != before.pixel(u, v)).collect(); - let want = match moved.as_slice() { - [] => before.pixel(x, y), - [(u, v)] => big.pixel(*u, *v), - _ => panic!("({x}, {y}): two copies under the stroke"), - }; - assert_eq!(canvas.pixel(x, y), want, "({x}, {y})"); - changed += usize::from(!moved.is_empty()); - } - } - assert!(changed > 300, "the smudge crossed both edges ({changed} px)"); - assert!(dmg.x0 >= 0 && dmg.y0 >= 0 && dmg.x1 <= 40 && dmg.y1 <= 30, "{dmg:?}"); - assert!((-12..0).all(|x| canvas.pixel(x, 20)[3] == 0.0) && (40..52).all(|x| canvas.pixel(x, 20)[3] == 0.0), "nothing off the canvas"); - } - - #[test] - fn a_wrapped_sequential_stroke_inside_the_canvas_paints_as_before() { - for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA32F] { - let st = stroke(&[(16.0, 12.0), (24.0, 18.0)], 6.0, 0.8); - let run = |wrap| { - let mut s = tiled(fmt, TILE); - let dmg = apply_dab_stroke_wrapped(&mut s, &st, None, false, Smudge::sampling_radius(6.0), wrap, smudge(6.0)); - (s, dmg) - }; - let ((plain, d0), (wrapped, d1)) = (run(None), run(Some(TILE))); - assert_eq!(d0, d1, "{fmt:?}"); - assert!((0..30).all(|y| (0..40).all(|x| plain.pixel(x, y) == wrapped.pixel(x, y))), "{fmt:?}"); - assert!(plain.pixel(20, 15) != tiled(fmt, TILE).pixel(20, 15), "{fmt:?}: the stroke smudged"); - } - } - - #[test] - fn a_wrapped_dab_bigger_than_the_canvas_edits_all_of_it_and_nothing_else() { - let small = Rect::new(0, 0, 10, 8); - let mut s = Surface::new(PixelFormat::RGBA32F); - s.fill_rect(small, &[1.0, 0.0, 0.0, 1.0]); - let darken = |work: &mut Region, fp: &Footprint| { - let r = fp.rect.intersect(&work.rect); - for y in r.y0..r.y1 { - for x in r.x0..r.x1 { - work.px_mut(x, y)[0] *= 1.0 - 0.5 * fp.at(x, y); - } - } - }; - let dmg = apply_dab_stroke_wrapped(&mut s, &stroke(&[(5.0, 4.0), (9.0, 6.0)], 30.0, 0.0), None, false, 0, Some(small), darken); - assert_eq!(dmg, small); - assert!((0..8).all(|y| (0..10).all(|x| s.pixel(x, y)[0] < 1.0)), "every pixel under the dab's copies darkens"); - assert!((-5..15).all(|x| s.pixel(x, -1)[3] == 0.0 && s.pixel(x, 8)[3] == 0.0), "nothing off the canvas"); - } } diff --git a/crates/paint/src/wrap.rs b/crates/paint/src/wrap.rs deleted file mode 100644 index efad49d3ca..0000000000 --- a/crates/paint/src/wrap.rs +++ /dev/null @@ -1,166 +0,0 @@ -//! View › Pattern Preview geometry: the document repeats its canvas (the *tile*) in every -//! direction, so a stroke past one edge carries on at the opposite one. Coordinates outside the -//! tile name the pixel a whole number of tile sizes away that lies on it. - -use photocraft_geom::Rect; - -/// `v` moved a whole number of periods into `lo..hi` (`v` itself when that is empty). -#[inline] -pub fn wrap_coord(v: i32, lo: i32, hi: i32) -> i32 { - let period = i64::from(hi) - i64::from(lo); - if period <= 0 { - return v; - } - (i64::from(lo) + (i64::from(v) - i64::from(lo)).rem_euclid(period)) as i32 -} - -/// Whether `rect` fits in `tile`, so it covers no pixel of the tile twice. -pub fn fits(rect: Rect, tile: Rect) -> bool { - rect.width() <= tile.width() && rect.height() <= tile.height() -} - -/// The part of `tile` that `rect` covers once moved onto it: per axis, the rectangle's span moved -/// a whole number of periods, or the whole tile where the span is longer or crosses an edge. -pub fn fold_area(rect: Rect, tile: Rect) -> Rect { - if rect.is_empty() || tile.is_empty() { - return Rect::EMPTY; - } - let span = |a0: i32, a1: i32, lo: i32, hi: i32| { - let start = wrap_coord(a0, lo, hi); - let len = i64::from(a1) - i64::from(a0); - if i64::from(start) + len > i64::from(hi) { (lo, hi) } else { (start, start + len as i32) } - }; - let (x0, x1) = span(rect.x0, rect.x1, tile.x0, tile.x1); - let (y0, y1) = span(rect.y0, rect.y1, tile.y0, tile.y1); - Rect::new(x0, y0, x1, y1) -} - -/// One axis of `a0..a1` cut where it crosses the edges of `lo..hi`: each run's start on the tile, -/// its start in `a0..a1`, and its length. -fn runs(a0: i32, a1: i32, lo: i32, hi: i32) -> Vec<(i32, i32, i32)> { - let period = i64::from(hi) - i64::from(lo); - let mut out = Vec::new(); - let mut a = i64::from(a0); - while a < i64::from(a1) { - let k = (a - i64::from(lo)).div_euclid(period) * period; - let end = (i64::from(a1) - k).min(i64::from(hi)); - let start = a - k; - out.push((start as i32, a as i32, (end - start) as i32)); - a = end + k; - } - out -} - -/// `r` cut where it crosses the edges of `tile`: each part moved onto the tile, with the offset -/// that moves it back (`part.translate(dx, dy)` is that part of `r`). A rectangle bigger than the -/// tile gives parts that overlap on it. -pub fn pieces(r: Rect, tile: Rect) -> Vec<(Rect, (i32, i32))> { - if r.is_empty() || tile.is_empty() { - return Vec::new(); - } - let xs = runs(r.x0, r.x1, tile.x0, tile.x1); - runs(r.y0, r.y1, tile.y0, tile.y1) - .into_iter() - .flat_map(|(ty, uy, h)| xs.iter().map(move |&(tx, ux, w)| (Rect::new(tx, ty, tx + w, ty + h), (ux - tx, uy - ty)))) - .collect() -} - -/// The values over `r` of an image repeating `tile` apart, given `read`, which returns a -/// rectangle of the tile (`n` values per pixel, row-major). A rectangle crossing at most one edge -/// per axis is read in its parts; a bigger one reads the tile area it covers once. -pub fn read(r: Rect, tile: Rect, n: usize, mut read: impl FnMut(Rect) -> Vec) -> Vec { - let w = r.width() as usize; - let mut out = vec![T::default(); w * r.height() as usize * n]; - if r.is_empty() || tile.is_empty() { - return out; - } - let (xs, ys) = (runs(r.x0, r.x1, tile.x0, tile.x1), runs(r.y0, r.y1, tile.y0, tile.y1)); - let mut copy = |src: &[T], at: usize, x: i32, y: i32, len: i32| { - let o = ((y - r.y0) as usize * w + (x - r.x0) as usize) * n; - let len = len as usize * n; - if let (Some(d), Some(s)) = (out.get_mut(o..o + len), src.get(at..at + len)) { - d.copy_from_slice(s); - } - }; - if xs.len() * ys.len() <= 4 { - for &(ty, uy, h) in &ys { - for &(tx, ux, len) in &xs { - let part = read(Rect::new(tx, ty, tx + len, ty + h)); - for row in 0..h { - copy(&part, row as usize * len as usize * n, ux, uy + row, len); - } - } - } - return out; - } - let area = fold_area(r, tile); - let src = read(area); - let aw = area.width() as usize; - for &(ty, uy, h) in &ys { - for row in 0..h { - for &(tx, ux, len) in &xs { - copy(&src, ((ty + row - area.y0) as usize * aw + (tx - area.x0) as usize) * n, ux, uy + row, len); - } - } - } - out -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn coordinates_wrap_onto_the_tile_from_any_distance() { - assert_eq!([-17, -1, 0, 15, 16, 33, 1_000_000].map(|v| wrap_coord(v, 0, 16)), [15, 15, 0, 15, 0, 1, 0]); - assert_eq!(wrap_coord(-3, 10, 20), 17); - } - - #[test] - fn pieces_cover_the_rectangle_once_and_land_on_the_tile() { - let tile = Rect::new(0, 0, 10, 8); - for r in [Rect::new(7, 6, 13, 11), Rect::new(-25, 3, 4, 5), Rect::new(2, 2, 5, 5), Rect::new(-3, -3, 30, 20)] { - let mut seen = std::collections::HashMap::new(); - for (part, (dx, dy)) in pieces(r, tile) { - assert!(tile.contains(part.x0, part.y0) && part.x1 <= tile.x1 && part.y1 <= tile.y1, "{part:?} on the tile"); - for y in part.y0..part.y1 { - for x in part.x0..part.x1 { - *seen.entry((x + dx, y + dy)).or_insert(0) += 1; - assert_eq!((wrap_coord(x + dx, 0, 10), wrap_coord(y + dy, 0, 8)), (x, y)); - } - } - } - assert_eq!(seen.len(), (r.width() * r.height()) as usize, "{r:?}"); - assert!(seen.values().all(|&c| c == 1) && seen.keys().all(|&(x, y)| r.contains(x, y)), "{r:?}"); - } - } - - #[test] - fn a_read_repeats_the_tile_whatever_the_rectangle() { - let tile = Rect::new(4, 2, 14, 10); - let value = |x: i32, y: i32| (wrap_coord(x, 4, 14) * 100 + wrap_coord(y, 2, 10)) as f32; - let mut reads = 0; - // Two values per pixel; `read` only ever asks for rectangles of the tile. - let mut from_tile = |q: Rect| { - reads += 1; - assert!(q.x0 >= tile.x0 && q.y0 >= tile.y0 && q.x1 <= tile.x1 && q.y1 <= tile.y1, "{q:?}"); - (q.y0..q.y1).flat_map(|y| (q.x0..q.x1).flat_map(move |x| [value(x, y), -value(x, y)])).collect::>() - }; - for r in [Rect::new(10, 7, 17, 12), Rect::new(5, 3, 9, 6), Rect::new(-31, -5, 40, 30), Rect::new(0, 0, 0, 5)] { - let got = read(r, tile, 2, &mut from_tile); - let want: Vec = (r.y0..r.y1).flat_map(|y| (r.x0..r.x1).flat_map(move |x| [value(x, y), -value(x, y)])).collect(); - assert_eq!(got, want, "{r:?}"); - } - assert!(reads <= 4 + 1 + 1, "small rectangles read in parts, big ones once ({reads})"); - } - - #[test] - fn the_fold_area_is_where_a_rectangle_lands() { - let tile = Rect::new(0, 0, 10, 8); - assert_eq!(fold_area(Rect::new(12, 9, 15, 12), tile), Rect::new(2, 1, 5, 4)); - assert_eq!(fold_area(Rect::new(8, 2, 12, 4), tile), Rect::new(0, 2, 10, 4)); - assert_eq!(fold_area(Rect::new(-50, -50, 50, 50), tile), tile); - assert_eq!(fold_area(Rect::EMPTY, tile), Rect::EMPTY); - assert!(fits(Rect::new(3, 3, 13, 11), tile) && !fits(Rect::new(0, 0, 11, 2), tile)); - } -} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index c0efd7ae37..ac471a9269 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -97,9 +97,7 @@ pub(crate) use selection::{floating_outline, selection_drag_delta}; pub(crate) use start_screen::start_screen_drop_hint; pub(crate) use strokes::multiple_color_target; pub use strokes::paint_target; -pub(crate) use strokes::{ - ClonePreviewCache, CommittedStroke, LiveStroke, cancel_mixer_on_context_change, freehand_tool, pattern_wrap, spread_ms, strokes_live, -}; +pub(crate) use strokes::{ClonePreviewCache, CommittedStroke, LiveStroke, cancel_mixer_on_context_change, freehand_tool, spread_ms, strokes_live}; use strokes::{HOLD_REPEAT_MS, draw_clone_preview, feed_live_stroke, pointer_button_modifiers, pointer_moves, stroke_wants_time, tap_at_peak_pressure}; #[cfg(test)] pub(crate) use strokes::{SPOT_HEALING_TRAIL, live_retouch_points}; diff --git a/crates/ui-egui/src/canvas/strokes.rs b/crates/ui-egui/src/canvas/strokes.rs index 404a46b9fa..c7c6d4b643 100644 --- a/crates/ui-egui/src/canvas/strokes.rs +++ b/crates/ui-egui/src/canvas/strokes.rs @@ -310,13 +310,6 @@ pub(super) fn stroke_params(app: &PhotocraftApp, d: &Drag, points: &[Vec]) p } -/// View › Pattern Preview: a painting stroke goes on across the canvas edges (#8883). -pub(crate) fn pattern_wrap(app: &PhotocraftApp, p: &mut serde_json::Value) { - if app.ui.view.pattern_preview { - p["wrap"] = json!(true); - } -} - /// Tools whose strokes the engine renders while they are drawn (`LiveStroke`). pub(crate) fn strokes_live(tool: Tool) -> bool { matches!( @@ -394,7 +387,6 @@ pub(super) fn begin_live_stroke(app: &PhotocraftApp) -> Option { } else if let Some(cmd) = live_retouch_command(d.tool) { // The params `retouch_ui::finish_stroke` commits. let mut p = if d.tool == Tool::HistoryBrush { crate::retouch_ui::history_brush_params(app) } else { crate::retouch_ui::clone_params(app)? }; - pattern_wrap(app, &mut p); p["points"] = json!(d.points); p["target"] = paint_target(app); EngineStroke::Retouch(Box::new(photocraft_engine::retouch_cmds::LiveRetouch::begin(&app.session, cmd, &p).ok()?)) diff --git a/crates/ui-egui/src/stroke_timing_tests.rs b/crates/ui-egui/src/stroke_timing_tests.rs index 5e44707c28..ed59943fae 100644 --- a/crates/ui-egui/src/stroke_timing_tests.rs +++ b/crates/ui-egui/src/stroke_timing_tests.rs @@ -499,60 +499,6 @@ fn releasing_a_sequential_dab_stroke_commits_what_a_replay_paints() { } } -/// View › Pattern Preview (#8883): a Blur, Smudge or Dodge stroke dragged past the right edge -/// carries on at the left one, and the release still commits the pixels its live stroke drew. -#[test] -fn a_sequential_dab_stroke_past_the_edge_in_pattern_preview_commits_its_live_pixels() { - for (tool, cmd) in [(Tool::Blur, "paint.blur"), (Tool::Smudge, "paint.smudge"), (Tool::Dodge, "paint.dodge")] { - let setup = |run: &mut dyn FnMut(&str, Value)| { - run("tools.setBrush", json!({"brush": {"size": 24, "hardness": 0.2}})); - run("paint.stroke", json!({"points": [[0, 50], [200, 50]], "size": 30, "hardness": 0.5, "color": "#c04020"})); - run("paint.stroke", json!({"points": [[0, 35], [200, 65]], "size": 8, "hardness": 1.0, "color": "#2060c0"})); - }; - let mut h = harness(); - setup(&mut |id, p| { - h.state_mut().run(id, p).unwrap(); - }); - let before = h.state().session.active().unwrap().doc.clone(); - { - let app = h.state_mut(); - app.ui.tool = tool; - app.ui.view.pattern_preview = true; - app.ui.views[0].zoom = 2.0; - app.ui.views[0].center = [100.0, 50.0]; - } - h.run_steps(1); - move_to(&mut h, 150.0, 50.0); - button(&mut h, 150.0, 50.0, true); - for (x, y) in [(175.0, 52.0), (200.0, 50.0), (225.0, 47.0), (245.0, 50.0)] { - move_to(&mut h, x, y); - } - assert!(h.state().live_stroke.is_some(), "{cmd}: a live stroke"); - h.state_mut().retouch_from_live = false; - button(&mut h, 245.0, 50.0, false); - h.run_steps(2); - let app = h.state(); - assert!(app.retouch_from_live, "{cmd}: the release took the live pixels instead of rendering the stroke again"); - let (_, p) = app.session.journal.iter().rev().find(|(id, _)| id == cmd).cloned().expect("a committed stroke"); - assert_eq!(p["wrap"], json!(true), "{cmd}: {p}"); - let mut fresh = photocraft_engine::Session::new(); - fresh.execute("file.new", json!({"width": 200, "height": 100, "background": "white"})).unwrap(); - setup(&mut |id, p| { - fresh.execute(id, p).unwrap(); - }); - fresh.execute(cmd, p).unwrap(); - let st = app.session.active().unwrap(); - let (a, b, pre) = ( - photocraft_compose::render(&st.doc, st.doc.bounds()), - photocraft_compose::render(&fresh.active().unwrap().doc, st.doc.bounds()), - photocraft_compose::render(&before, st.doc.bounds()), - ); - assert!(a.px.iter().zip(&b.px).all(|(x, y)| x == y), "{cmd}: live commit and replay differ"); - let left = |buf: &photocraft_compose::Buffer| (30..70).flat_map(|y| (0..30).map(move |x| (x, y))).map(|(x, y)| buf.px[y * 200 + x]).collect::>(); - assert_ne!(left(&a), left(&pre), "{cmd}: the stroke came back at the left edge"); - } -} - /// Pressure changes Smudge's painted footprint, but not the square of colour it carries. /// Exercise the actual pen → live preview → release handoff → journal replay, then undo/redo. #[test] diff --git a/crates/ui-egui/src/tools/eraser_ui.rs b/crates/ui-egui/src/tools/eraser_ui.rs index 8782c29863..ac896eeb0b 100644 --- a/crates/ui-egui/src/tools/eraser_ui.rs +++ b/crates/ui-egui/src/tools/eraser_ui.rs @@ -49,16 +49,14 @@ pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]]) - /// the commit, with the jitter seed from the first point (`retouch_ui::stroke_seed`). pub(crate) fn background_eraser_params(app: &PhotocraftApp, points: &[[f64; 3]]) -> Value { let o = &app.ui.tool_options; - let mut p = json!({ + json!({ "points": points, "sampling": o.bg_sampling, "limits": o.bg_limits, "tolerance": o.bg_tolerance, "protectForegroundColor": o.bg_protect_fg, "seed": crate::retouch_ui::stroke_seed(points), - }); - crate::canvas::pattern_wrap(app, &mut p); - p + }) } /// Whether `tool` is the Eraser in Pencil mode: it erases hard, aliased pixels through diff --git a/crates/ui-egui/src/tools/retouch_ui.rs b/crates/ui-egui/src/tools/retouch_ui.rs index c694679202..6b6d0f07e2 100644 --- a/crates/ui-egui/src/tools/retouch_ui.rs +++ b/crates/ui-egui/src/tools/retouch_ui.rs @@ -29,13 +29,11 @@ pub(crate) fn clone_params(app: &PhotocraftApp) -> Option { let o = &app.ui.tool_options; // The options bar's Mode is the tool's own brush's. let mode = crate::blend_keys::brush_tool_mode(app.ui.tool, app.session.tools.brush.mode); - let mut p = json!({"aligned": o.clone_aligned, "sampleLayer": o.clone_sample, "mode": mode.label()}); + // View › Pattern Preview: both tools carry a stroke on across the canvas edges (#8883). + let mut p = json!({"aligned": o.clone_aligned, "sampleLayer": o.clone_sample, "mode": mode.label(), "wrap": app.ui.view.pattern_preview}); if app.ui.tool == Tool::CloneStamp { - p["wrap"] = json!(app.ui.view.pattern_preview); p["opacity"] = json!(app.session.tools.brush.opacity * 100.0); p["flow"] = json!(app.session.tools.brush.flow * 100.0); - } else { - crate::canvas::pattern_wrap(app, &mut p); } // The Clone Source panel's active slot (set by ⌥-click) drives the stroke: the engine keeps // the aligned pairing and applies the slot's scale/rotation/flip. @@ -63,7 +61,7 @@ pub(crate) fn history_brush_params(app: &PhotocraftApp) -> Value { /// Blur, Sharpen, Smudge, Dodge, Burn, Sponge. Shared by the live preview and the commit. pub(crate) fn dab_params(app: &PhotocraftApp, tool: Tool) -> Option<(&'static str, Value)> { let o = &app.ui.tool_options; - let (cmd, mut p) = match tool { + Some(match tool { Tool::Blur => ("paint.blur", json!({"strength": o.strength, "sampleAllLayers": o.sample_all.blur})), Tool::Sharpen => ("paint.sharpen", json!({"strength": o.strength, "protectDetail": o.protect_detail, "sampleAllLayers": o.sample_all.sharpen})), Tool::Smudge => ("paint.smudge", json!({"strength": o.strength, "fingerPainting": o.finger_painting, "sampleAllLayers": o.sample_all.smudge})), @@ -73,9 +71,7 @@ pub(crate) fn dab_params(app: &PhotocraftApp, tool: Tool) -> Option<(&'static st ), Tool::Sponge => ("paint.sponge", json!({"mode": o.sponge_mode, "vibrance": o.vibrance})), _ => return None, - }; - crate::canvas::pattern_wrap(app, &mut p); - Some((cmd, p)) + }) } /// `paint.colorReplacement` params from the options bar (without `points`): shared by the live @@ -83,7 +79,7 @@ pub(crate) fn dab_params(app: &PhotocraftApp, tool: Tool) -> Option<(&'static st /// stroke paints the same colour; Background Swatch sampling matches the background colour. pub(crate) fn color_replacement_params(app: &PhotocraftApp) -> Value { let o = &app.ui.tool_options; - let mut p = json!({ + json!({ "color": app.session.tools.foreground, "mode": o.cr_mode, "sampling": o.cr_sampling, @@ -92,9 +88,7 @@ pub(crate) fn color_replacement_params(app: &PhotocraftApp) -> Value { "antiAlias": o.cr_anti_alias, // A mask or channel target is refused: the tool recolours layer pixels only. "target": crate::canvas::paint_target(app), - }); - crate::canvas::pattern_wrap(app, &mut p); - p + }) } /// `paint.patternStamp` params from the options bar and the Patterns panel (without `points` and @@ -119,7 +113,6 @@ pub(crate) fn pattern_stamp_params(app: &PhotocraftApp, points: &[[f64; 3]]) -> if let Some(id) = photocraft_engine::presets::patterns::current(&app.session).or(app.session.patterns.items.first()).map(|x| x.id.clone()) { p["pattern"] = json!(id); } - crate::canvas::pattern_wrap(app, &mut p); p } @@ -188,10 +181,6 @@ pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]], m _ => return false, }; p["points"] = pts; - // Spot Healing and History Brush; the others' params have it already. - if !matches!(tool, Tool::Remove | Tool::MixerBrush) { - crate::canvas::pattern_wrap(app, &mut p); - } // The layer mask, an alpha channel or the Quick Mask when targeted, as the Brush paints. p["target"] = crate::canvas::paint_target(app); let result = app.run(cmd, p); diff --git a/crates/ui-egui/src/tools/retouch_ui/tests.rs b/crates/ui-egui/src/tools/retouch_ui/tests.rs index bfe9672331..816c9ca12e 100644 --- a/crates/ui-egui/src/tools/retouch_ui/tests.rs +++ b/crates/ui-egui/src/tools/retouch_ui/tests.rs @@ -29,24 +29,26 @@ fn stripes(app: &mut PhotocraftApp, step: usize, target: &str) { } #[test] -fn every_retouching_stroke_wraps_in_pattern_preview() { - // View › Pattern Preview (#8883): a stroke across the right edge asks its command to go - // on at the opposite one, and only then. +fn only_clone_stamp_and_healing_brush_wrap_in_pattern_preview() { + // View › Pattern Preview (#8883): a Clone Stamp or Healing Brush stroke across the right edge + // asks its command to go on at the opposite one, and only then. The reference application's + // other retouching tools stop at the edge. let tools = [ - (Tool::Blur, "paint.blur"), - (Tool::Sharpen, "paint.sharpen"), - (Tool::Smudge, "paint.smudge"), - (Tool::Dodge, "paint.dodge"), - (Tool::Burn, "paint.burn"), - (Tool::Sponge, "paint.sponge"), - (Tool::ColorReplacement, "paint.colorReplacement"), - (Tool::PatternStamp, "paint.patternStamp"), - (Tool::Healing, "paint.healingBrush"), - (Tool::HistoryBrush, "paint.historyBrush"), - (Tool::SpotHealing, "paint.spotHealing"), - (Tool::BackgroundEraser, "paint.backgroundEraser"), + (Tool::CloneStamp, "paint.cloneStamp", true), + (Tool::Healing, "paint.healingBrush", true), + (Tool::Blur, "paint.blur", false), + (Tool::Sharpen, "paint.sharpen", false), + (Tool::Smudge, "paint.smudge", false), + (Tool::Dodge, "paint.dodge", false), + (Tool::Burn, "paint.burn", false), + (Tool::Sponge, "paint.sponge", false), + (Tool::ColorReplacement, "paint.colorReplacement", false), + (Tool::PatternStamp, "paint.patternStamp", false), + (Tool::HistoryBrush, "paint.historyBrush", false), + (Tool::SpotHealing, "paint.spotHealing", false), + (Tool::BackgroundEraser, "paint.backgroundEraser", false), ]; - for (tool, cmd) in tools { + for (tool, cmd, wraps) in tools { for pattern_preview in [false, true] { let mut app = app(); stripes(&mut app, 8, "pixels"); @@ -59,7 +61,7 @@ fn every_retouching_stroke_wraps_in_pattern_preview() { tool_event(&mut app, ToolEvent::Up { x: 112.0, y: 30.0 }, m); assert!(!app.ui.status_error, "{tool:?}: {}", app.ui.status); let (_, p) = app.session.journal.iter().rev().find(|(id, _)| id == cmd).cloned().unwrap_or_else(|| panic!("{tool:?}: no {cmd}")); - assert_eq!(p.get("wrap").and_then(Value::as_bool).unwrap_or(false), pattern_preview, "{tool:?}: {p}"); + assert_eq!(p.get("wrap").and_then(Value::as_bool).unwrap_or(false), wraps && pattern_preview, "{tool:?}: {p}"); } } } From c5ebcd9dc77c62d5d498ae14005e4e31a2b03430 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 7 Nov 2026 21:12:31 +0100 Subject: [PATCH 117/219] Regenerate docs/scorecard.md Co-Authored-By: Claude Opus 5.5 --- docs/scorecard.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/scorecard.md b/docs/scorecard.md index cb666664b9..679d8b6cc5 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -11,7 +11,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Tools](#tools) | 36 | 9 | 0 | | | [Workspace and UI](#workspace-and-ui) | 15 | 6 | 2 | settings that do nothing: 10 of 194 (target 0) | | [Type](#type) | 5 | 5 | 0 | | -| [Automation](#automation) | 5 | 3 | 3 | 662 of 662 menu items live (100.0%) | +| [Automation](#automation) | 5 | 4 | 2 | 662 of 662 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 30 of 30 crates; panic_hunt present | | [Distribution](#distribution) | 5 | 5 | 1 | | | [Open bugs](#open-bugs) | 31 | 1 | 0 | | @@ -268,7 +268,7 @@ Menu wiring (`docs/parity-checklist.md`): 662 of 662 menu items live (100.0%). T | AUTO-219-5 | Batch: subfolders, open documents, naming template, progress/cancel, error log, stop | partial | [#219](https://github.com/storytold/photocraft/issues/219) | file_cmds/batch_run.rs and batch_naming.rs: flat subfolder output and last-result-wins names measured in Photoshop 2026; live open-document source; file naming components and serials; UTF-16 LE error log; stop-on-error; CLI and Batch dialog, #4579. Desktop Esc asks Continue/Stop, retaining one plan and error log; a read-only canvas preview shows each scratch-session input without a progress bar. Inputs are not live tabs with Layers panel content, so full window parity remains open. | | AUTO-219-6 | Plug-in ABI v2: panel kind, progress/cancel calls, read other layers, Last Filter repeats | missing | [#219](https://github.com/storytold/photocraft/issues/219) | runtime.rs:15 ABI_VERSION = 1; manifest.rs:18 Kind::Filter only | | AUTO-219-7 | Local ONNX models behind a flag, managed in Preferences; subject select, background removal | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No ONNX runtime dependency | -| AUTO-219-8 | xtask check that every panel button routes through a command id | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No such xtask check | +| AUTO-219-8 | xtask check that every panel button routes through a command id | partial | [#219](https://github.com/storytold/photocraft/issues/219) | cargo xtask panel-commands (run by xtask ci and CI) fails on a shell click handler that changes the document or session directly; 1 allowlisted function left (panels/color.rs color_field assigns the foreground/background colour instead of tools.setColors) | | AUTO-219-9 | Headless commands for in-canvas type editing, Liquify/Puppet strokes, transform handles | done | [#219](https://github.com/storytold/photocraft/issues/219) | type.hitTest, type.caret, type.navigate; filter.liquify strokes, edit.puppetWarp pins, type.edit, edit.transform | | AUTO-219-10 | Agent acceptance suite grown to 30-50 workflows | partial | [#219](https://github.com/storytold/photocraft/issues/219) | automation/tests/it/agent_tasks.rs runs 10 workflows over MCP | From a91bc05a3df92266d3f1398f1f473d9933a4fea2 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 7 Nov 2026 21:26:04 +0100 Subject: [PATCH 118/219] Format the merged test module list Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 753f90fe55..8a810bd7ca 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -994,11 +994,11 @@ mod menu_item_labels_tests; #[cfg(test)] mod pen_path_ownership_tests; -#[cfg(test)] -mod brush_opacity_minimum_tests; #[cfg(test)] mod artboard_options_bar_tests; #[cfg(test)] +mod brush_opacity_minimum_tests; +#[cfg(test)] mod doc_tab_menu_tests; #[cfg(test)] mod layers_empty_area_menu_tests; From 920bf1b6a8810912aa0b73842b608ea20e2758d9 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 7 Nov 2026 14:50:09 -0600 Subject: [PATCH 119/219] Count tool: History names Add/Drag/Delete Count Object, Add Count Group and one name per group setting (#11763) --- crates/engine/src/cmd.rs | 2 + crates/engine/src/cmd/analysis_cmds.rs | 28 +++-- .../src/cmd/count_history_names_tests.rs | 100 ++++++++++++++++++ 3 files changed, 124 insertions(+), 6 deletions(-) create mode 100644 crates/engine/src/cmd/count_history_names_tests.rs diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index c4addf4c22..47672f82b4 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -69,6 +69,8 @@ pub mod comps_cmds; pub mod contour_lib_cmds; #[cfg(test)] mod count_delete_group_tests; +#[cfg(test)] +mod count_history_names_tests; pub mod cutout_cmds; pub mod distort_cmds; pub mod document_preset_cmds; diff --git a/crates/engine/src/cmd/analysis_cmds.rs b/crates/engine/src/cmd/analysis_cmds.rs index d9d7d1510c..18dad6c9f8 100644 --- a/crates/engine/src/cmd/analysis_cmds.rs +++ b/crates/engine/src/cmd/analysis_cmds.rs @@ -796,7 +796,7 @@ fn count_add(s: &mut Session, p: &Value) -> Result { if gi.is_some_and(|i| i >= group_count) { return Err(bad("count.add", "no such group")); } - let n = s.edit("Count", |d, _| { + let n = s.edit("Add Count Object", |d, _| { if d.measurement.count_groups.is_empty() { let g = new_group(d, None, None); d.measurement.count_groups.push(g); @@ -846,7 +846,7 @@ fn locate(s: &Session, p: &Value, cmd: &str) -> Result<(usize, usize)> { fn count_remove(s: &mut Session, p: &Value) -> Result { let (g, i) = locate(s, p, "count.remove")?; - s.edit("Count", |d, _| { + s.edit("Delete Count Object", |d, _| { d.measurement.count_groups[g].points.remove(i); Ok(()) })?; @@ -856,7 +856,7 @@ fn count_remove(s: &mut Session, p: &Value) -> Result { fn count_move(s: &mut Session, p: &Value) -> Result { let to = point(p.get("to")).ok_or_else(|| bad("count.move", "pass `to`: [x, y]"))?; let (g, i) = locate(s, p, "count.move")?; - s.edit("Count", |d, _| { + s.edit("Drag Count Object", |d, _| { d.measurement.count_groups[g].points[i] = to; Ok(()) })?; @@ -881,7 +881,7 @@ fn count_clear(s: &mut Session, p: &Value) -> Result { fn count_new_group(s: &mut Session, p: &Value) -> Result { let name = p.get("name").and_then(Value::as_str).map(str::to_string); let color = color_of("count.newGroup", p, "color")?; - s.edit("New Count Group", |d, _| { + s.edit("Add Count Group", |d, _| { let g = new_group(d, name, color); d.measurement.count_groups.push(g); d.measurement.active_count_group = d.measurement.count_groups.len() - 1; @@ -949,8 +949,24 @@ fn count_set_group(s: &mut Session, p: &Value) -> Result { } let activate = p.get("active").and_then(Value::as_bool) == Some(true) || p.as_object().is_some_and(|o| o.len() == 1 && o.contains_key("group")); let changed = grp != d.measurement.count_groups[g]; - if changed || (activate && d.measurement.active_count_group != g) { - s.edit("Count Group Options", |d, _| { + let switches = activate && d.measurement.active_count_group != g; + if changed || switches { + // One field changed: Photoshop's name for it (#11763). Several fields, or a switch of the + // current group (not measured), keep "Count Group Options". + let old = &d.measurement.count_groups[g]; + let fields = [ + (grp.name != old.name, "Set Count Group Name"), + (grp.visible != old.visible, "Set Count Group Visibility"), + (grp.color != old.color, "Set Count Draw Color"), + (grp.marker_size != old.marker_size, "Set Count Marker Size"), + (grp.label_size != old.label_size, "Set Count Font Size"), + ]; + let mut one = fields.iter().filter(|(c, _)| *c).map(|(_, l)| *l); + let label = match (one.next(), one.next()) { + (Some(l), None) if !switches => l, + _ => "Count Group Options", + }; + s.edit(label, |d, _| { d.measurement.count_groups[g] = grp; if activate { d.measurement.active_count_group = g; diff --git a/crates/engine/src/cmd/count_history_names_tests.rs b/crates/engine/src/cmd/count_history_names_tests.rs new file mode 100644 index 0000000000..2cfa357ab4 --- /dev/null +++ b/crates/engine/src/cmd/count_history_names_tests.rs @@ -0,0 +1,100 @@ +//! #11763: each Count tool edit gets the reference application's History name. A `count.setGroup` +//! that changes exactly one field takes that field's name; several fields at once, or a switch of +//! the current group, keep "Count Group Options". + +use serde_json::{Value, json}; + +use crate::Session; + +/// A 100 × 100 document with one count group holding one marker at 10,10 (its first state is +/// "Add Count Object"). +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 100, "height": 100})).unwrap(); + s.execute("count.add", json!({"x": 10, "y": 10})).unwrap(); + s +} + +fn steps(s: &Session) -> usize { + s.active().unwrap().history.past_len() +} + +/// Run `id` and return the History name of the one step it added. +fn named(s: &mut Session, id: &str, p: Value) -> String { + let before = steps(s); + s.execute(id, p.clone()).unwrap_or_else(|e| panic!("{id} {p}: {e}")); + assert_eq!(steps(s), before + 1, "{id} {p} adds exactly one step"); + s.active().unwrap().history.undo_label().unwrap_or_default().to_string() +} + +#[test] +fn marker_edits_are_add_drag_and_delete_count_object() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 100, "height": 100})).unwrap(); + // The first click makes "Count Group 1" too, still one "Add Count Object" step. + assert_eq!(named(&mut s, "count.add", json!({"x": 10, "y": 10})), "Add Count Object"); + assert_eq!(named(&mut s, "count.add", json!({"x": 40, "y": 40})), "Add Count Object"); + assert_eq!(named(&mut s, "count.move", json!({"x": 10, "y": 10, "to": [20, 20]})), "Drag Count Object"); + assert_eq!(named(&mut s, "count.remove", json!({"x": 40, "y": 40})), "Delete Count Object"); +} + +#[test] +fn group_edits_keep_or_take_their_names() { + let mut s = session(); + assert_eq!(named(&mut s, "count.newGroup", json!({"name": "Leaves"})), "Add Count Group"); + assert_eq!(named(&mut s, "count.clear", json!({})), "Clear Count"); + assert_eq!(named(&mut s, "count.deleteGroup", json!({})), "Delete Count Group"); +} + +#[test] +fn a_single_field_change_uses_that_fields_name() { + for (p, want) in [ + (json!({"name": "Seeds"}), "Set Count Group Name"), + (json!({"visible": false}), "Set Count Group Visibility"), + (json!({"color": "#204080"}), "Set Count Draw Color"), + (json!({"markerSize": 5}), "Set Count Marker Size"), + (json!({"labelSize": 20}), "Set Count Font Size"), + ] { + let mut s = session(); + assert_eq!(named(&mut s, "count.setGroup", p.clone()), want, "{p}"); + } +} + +#[test] +fn several_fields_at_once_keep_count_group_options() { + let mut s = session(); + assert_eq!(named(&mut s, "count.setGroup", json!({"markerSize": 5, "labelSize": 20})), "Count Group Options"); + // A field sent with its current value doesn't count as a change. + let mut s = session(); + let name = s.execute("image.analysis.countTool", json!({})).unwrap()["groups"][0]["name"].clone(); + assert_eq!(named(&mut s, "count.setGroup", json!({"name": name, "markerSize": 6})), "Set Count Marker Size"); +} + +#[test] +fn switching_the_current_group_keeps_todays_name() { + let mut s = session(); + s.execute("count.newGroup", json!({"name": "Two"})).unwrap(); + assert_eq!(named(&mut s, "count.setGroup", json!({"group": 0})), "Count Group Options", "only `group`"); + assert_eq!(named(&mut s, "count.setGroup", json!({"group": 1, "active": true})), "Count Group Options", "`active: true`, no field change"); + // A field change together with a switch is two changes: today's name too. + assert_eq!(named(&mut s, "count.setGroup", json!({"group": 0, "active": true, "visible": false})), "Count Group Options"); +} + +#[test] +fn no_change_adds_no_step_and_bad_params_stay_errors() { + let mut s = session(); + let before = steps(&s); + // The current group's own values: nothing changes, no step. + s.execute("count.setGroup", json!({"group": 0})).unwrap(); + assert_eq!(steps(&s), before); + for (id, p) in [ + ("count.setGroup", json!({"group": 9, "name": "x"})), + ("count.setGroup", json!({"markerSize": 2.5})), + ("count.move", json!({"x": 90, "y": 90, "to": [1, 1]})), + ("count.remove", json!({"x": 90, "y": 90})), + ("count.add", json!({"x": 1, "y": 1, "group": 7})), + ] { + assert!(s.execute(id, p.clone()).is_err(), "{id} {p}"); + assert_eq!(steps(&s), before, "{id} {p} adds no step"); + } +} From c7a4f6258e63b4b9bc564fd50452393c49b66b5b Mon Sep 17 00:00:00 2001 From: AethelisDEV Date: Sun, 8 Nov 2026 00:00:22 +0300 Subject: [PATCH 120/219] notices: format leftover blank lines --- crates/ui-egui/src/notices.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/crates/ui-egui/src/notices.rs b/crates/ui-egui/src/notices.rs index 899d4cd63f..0e9a3035eb 100644 --- a/crates/ui-egui/src/notices.rs +++ b/crates/ui-egui/src/notices.rs @@ -318,8 +318,6 @@ mod tests { notice.lines.iter().map(|line| crate::i18n::fmt(line, &args)).collect::>().join(" ") } - - #[test] fn paste_hint_follows_the_shortcut_and_falls_back_to_the_menu() { let mut app = PhotocraftApp::new(Session::new(), Services::default()); From 38b7064daa7b5927bfe83fc9e8cff9c3884bcfd2 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 7 Nov 2026 22:17:47 +0100 Subject: [PATCH 121/219] Gradient stops: a stop dragged the other way off the bar comes off too (#10823) The reference application removes a colour stop dragged up past the opacity stops and an opacity stop dragged down past the colour stops, not only colour-down / opacity-up. The test that kept them now checks that they are removed. Co-Authored-By: ljkx Co-Authored-By: Claude Opus 5.5 --- .../ui-egui/src/tools/gradient_ui/editor_tests.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/tools/gradient_ui/editor_tests.rs b/crates/ui-egui/src/tools/gradient_ui/editor_tests.rs index 34636bd376..0882a50ed2 100644 --- a/crates/ui-egui/src/tools/gradient_ui/editor_tests.rs +++ b/crates/ui-egui/src/tools/gradient_ui/editor_tests.rs @@ -278,17 +278,19 @@ fn an_opacity_stop_dragged_up_off_the_bar_is_removed() { } } -/// Only the stop's own direction takes it off: an opacity stop dragged down over the ramp and a -/// colour stop dragged up over it are kept, however far they go. +/// Either way off the bar takes a stop off, as in the reference application: an opacity stop +/// dragged down past the colour stops and a colour stop dragged up past the opacity stops are +/// removed too. #[test] -fn a_stop_dragged_the_other_way_is_kept() { +fn a_stop_dragged_the_other_way_comes_off_too() { for layer in [true, false] { - let (mut h, sink) = editor(layer); for opacity in [true, false] { + let (mut h, sink) = editor(layer); let from = marker(&h, 0.5, opacity); let to = from + vec2(0.0, if opacity { 70.0 } else { -70.0 }); - drag_through(&mut h, &[from, to], |h| assert_eq!(shown(h, sink), (3, 3), "layer {layer} opacity {opacity}: still shown")); - assert_eq!(counts(&h, sink), (3, 3), "layer {layer} opacity {opacity}: the stop is kept"); + let gone = if opacity { (3, 2) } else { (2, 3) }; + drag_through(&mut h, &[from, to], |h| assert_eq!(shown(h, sink), gone, "layer {layer} opacity {opacity}: gone from the preview")); + assert_eq!(counts(&h, sink), gone, "layer {layer} opacity {opacity}: the stop is removed"); } } } From 8f40ec918128b8e1a3d1f65a97b14f05a2c3a85f Mon Sep 17 00:00:00 2001 From: DuBearte <130190089+DuBearte@users.noreply.github.com> Date: Sat, 7 Nov 2026 16:30:48 -0500 Subject: [PATCH 122/219] Pencil: the Auto Erase checkbox shows its tooltip Fixes #11968. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 2 ++ crates/ui-egui/src/panels/options_bar.rs | 3 +- .../src/pencil_auto_erase_tooltip_tests.rs | 34 +++++++++++++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/src/pencil_auto_erase_tooltip_tests.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f19ec41da9..2e6702120d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1001,6 +1001,8 @@ mod doc_tab_menu_tests; #[cfg(test)] mod layers_empty_area_menu_tests; #[cfg(test)] +mod pencil_auto_erase_tooltip_tests; +#[cfg(test)] mod preferences_submenu_tests; #[cfg(test)] mod selection_move_step_tests; diff --git a/crates/ui-egui/src/panels/options_bar.rs b/crates/ui-egui/src/panels/options_bar.rs index 34e4042f48..9a106a8c70 100644 --- a/crates/ui-egui/src/panels/options_bar.rs +++ b/crates/ui-egui/src/panels/options_bar.rs @@ -219,7 +219,8 @@ fn brush_specific_options(app: &mut PhotocraftApp, ui: &mut egui::Ui, t: Tokens, opt_label(ui, tl!("Smoothing")); smoothing_field(ui, b, if t.pro { 58.0 } else { 66.0 }); widgets::vline(ui, 22.0); - widgets::checkbox(ui, &mut app.ui.tool_options.pencil_auto_erase, tl!("Auto Erase")); + widgets::checkbox(ui, &mut app.ui.tool_options.pencil_auto_erase, tl!("Auto Erase")) + .on_hover_text(tl!("Draws background color over foreground colors")); } Tool::Brush | Tool::Eraser => { brush_preset_chip(ui, b, &mut app.ui); diff --git a/crates/ui-egui/src/pencil_auto_erase_tooltip_tests.rs b/crates/ui-egui/src/pencil_auto_erase_tooltip_tests.rs new file mode 100644 index 0000000000..7ae125d24a --- /dev/null +++ b/crates/ui-egui/src/pencil_auto_erase_tooltip_tests.rs @@ -0,0 +1,34 @@ +//! #11968: hovering the Pencil's Auto Erase checkbox shows "Draws background color over +//! foreground colors", in the Pro and the other themes. + +use egui::vec2; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::state::Tool; +use crate::theme::ThemeKind; + +const TIP: &str = "Draws background color over foreground colors"; + +fn bar(theme: ThemeKind) -> Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 80, "height": 80})).unwrap(); + app.ui.tool = Tool::Pencil; + let mut h = Harness::builder().with_size(vec2(1600.0, 300.0)).with_step_dt(1.0 / 60.0).build_ui_state(|ui, app| crate::panels::options_bar(app, ui), app); + PhotocraftApp::setup_context(&h.ctx, theme); + h.run_steps(4); + h +} + +#[test] +fn auto_erase_has_the_reference_tooltip() { + for theme in [ThemeKind::ProMedium, ThemeKind::Classic] { + let mut h = bar(theme); + // Hovering the checkbox's label shows it. + h.get_by_label("Auto Erase").hover(); + h.run_steps(60); + assert!(h.query_by_label(TIP).is_some(), "{theme:?}: no tooltip on hover"); + } +} From b697d52ca0677b41c737188c4f56ad4f765c319b Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 7 Nov 2026 22:42:13 +0100 Subject: [PATCH 123/219] Background Eraser: Tolerance takes whole numbers 1-100 with an alert, bar tooltips (#11909) The options bar's Tolerance field stores whole numbers in 1..100. A value typed past the range becomes the closest one and, once the field is left, an alert with OK says "An integer between 1% and 100% is required. Closest value inserted." paint.backgroundEraser clamps tolerance to 1..100 and its params doc says so. The brush chip, Limits, Tolerance and Protect Foreground Color get the reference application's tooltips. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/eraser_cmds.rs | 4 +- ...background_eraser_tolerance_field_tests.rs | 163 ++++++++++++++++++ crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels/brush_controls.rs | 8 +- crates/ui-egui/src/tools/eraser_ui.rs | 41 ++++- 5 files changed, 212 insertions(+), 6 deletions(-) create mode 100644 crates/ui-egui/src/background_eraser_tolerance_field_tests.rs diff --git a/crates/engine/src/cmd/eraser_cmds.rs b/crates/engine/src/cmd/eraser_cmds.rs index 1433498078..bf08c18ac2 100644 --- a/crates/engine/src/cmd/eraser_cmds.rs +++ b/crates/engine/src/cmd/eraser_cmds.rs @@ -140,7 +140,7 @@ fn background_eraser_setup(s: &Session, p: &Value) -> Result<(Stroke, BgEraseSet let bs = BgEraseSettings { sampling: enum_param(p, CMD, "sampling", Sampling::Continuous, "continuous|once|backgroundSwatch")?, limits: enum_param(p, CMD, "limits", Limits::Contiguous, "discontiguous|contiguous|findEdges")?, - tolerance: number(p, CMD, "tolerance", 50.0, 0.0..=100.0)? as f32 / 100.0, + tolerance: number(p, CMD, "tolerance", 50.0, 1.0..=100.0)? as f32 / 100.0, protect_foreground: flag(p, "protectForegroundColor", false), foreground: s.tools.foreground, background: s.tools.background, @@ -267,7 +267,7 @@ pub fn specs() -> Vec { label: "Background Eraser", menu: &[], shortcut: None, - params: r##"{"points":[[x,y,pressure?],…],"size":px?,"hardness":0..1?,"brush":{…}?,"preset":name?,"sampling":"continuous|once|backgroundSwatch"="continuous","limits":"discontiguous|contiguous|findEdges"="contiguous","tolerance":0..100=50,"protectForegroundColor":bool=false,"seed":u64?}"##, + params: r##"{"points":[[x,y,pressure?],…],"size":px?,"hardness":0..1?,"brush":{…}?,"preset":name?,"sampling":"continuous|once|backgroundSwatch"="continuous","limits":"discontiguous|contiguous|findEdges"="contiguous","tolerance":1..100=50,"protectForegroundColor":bool=false,"seed":u64?}"##, enabled: has_pixel_layer, run: background_eraser, journal: true, diff --git a/crates/ui-egui/src/background_eraser_tolerance_field_tests.rs b/crates/ui-egui/src/background_eraser_tolerance_field_tests.rs new file mode 100644 index 0000000000..537fd02694 --- /dev/null +++ b/crates/ui-egui/src/background_eraser_tolerance_field_tests.rs @@ -0,0 +1,163 @@ +//! #11909: the Background Eraser's Tolerance takes whole numbers 1..100; a value typed past the +//! range becomes the closest one and, once the field is left, an alert with OK says so. +//! `paint.backgroundEraser` clamps `tolerance` to 1..100 too, and the options bar's controls carry +//! the reference application's tooltips. + +use egui::vec2; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::eraser_ui::TOLERANCE_RANGE_ALERT; +use crate::state::{DialogKind, Tool}; + +fn app_harness() -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(vec2(1600.0, 900.0)).with_step_dt(1.0 / 60.0).build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 150})).unwrap(); + app.ui.tool = Tool::BackgroundEraser; + app + }); + h.run_steps(5); + h +} + +fn bar(tool: Tool) -> Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 80, "height": 80})).unwrap(); + app.ui.tool = tool; + let mut h = Harness::builder().with_size(vec2(1600.0, 300.0)).build_ui_state(|ui, app| crate::panels::options_bar(app, ui), app); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(4); + h +} + +/// Types `text` into the Tolerance field, then Tabs out of it. +fn type_tolerance(h: &mut Harness<'static, PhotocraftApp>, text: &str) { + h.query_all_by_role(egui::accesskit::Role::SpinButton) + .find(|n| n.accesskit_node().label().as_deref() == Some("Tolerance:")) + .expect("the Tolerance field") + .click(); + h.run_steps(1); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text(text.into())); + h.run_steps(1); + h.key_press(egui::Key::Tab); + h.run_steps(3); +} + +fn alert(h: &Harness<'static, PhotocraftApp>) -> Option { + h.state().ui.dialogs.iter().find(|d| d.kind == DialogKind::Error).and_then(|d| d.fields.get("message")).and_then(|m| m.as_str()).map(str::to_owned) +} + +fn tolerance(h: &Harness<'static, PhotocraftApp>) -> f32 { + h.state().ui.tool_options.bg_tolerance +} + +#[test] +fn tolerance_past_the_range_takes_the_closest_value_and_alerts() { + for (typed, want) in [("0", 1.0), ("150", 100.0), ("-5", 1.0)] { + let mut h = app_harness(); + type_tolerance(&mut h, typed); + assert_eq!(tolerance(&h), want, "{typed}"); + assert_eq!(alert(&h).as_deref(), Some(TOLERANCE_RANGE_ALERT), "{typed}"); + h.get_by_label("OK").click(); + h.run_steps(4); + assert_eq!(alert(&h), None, "{typed}: OK closes the alert"); + assert_eq!(tolerance(&h), want, "{typed}: the clamped value is kept"); + } +} + +#[test] +fn the_alert_is_a_modal_the_control_channel_sees() { + let mut h = app_harness(); + type_tolerance(&mut h, "150"); + let dialogs = serde_json::to_value(&h.state().ui.dialogs).unwrap(); + assert!(dialogs.to_string().contains(TOLERANCE_RANGE_ALERT), "{dialogs}"); + assert!(h.query_by_label("OK").is_some(), "the alert has an OK button"); +} + +#[test] +fn tolerance_in_range_stores_a_whole_number_without_an_alert() { + let mut h = app_harness(); + type_tolerance(&mut h, "33.6"); + assert_eq!(tolerance(&h), 34.0); + assert_eq!(alert(&h), None); + type_tolerance(&mut h, "100"); + assert_eq!(tolerance(&h), 100.0); + assert_eq!(alert(&h), None, "the end of the range is in it"); + type_tolerance(&mut h, "abc"); + assert_eq!(tolerance(&h), 100.0, "not a number: the value stays"); + assert_eq!(alert(&h), None); +} + +/// The accessible descriptions (tooltips) of the widgets of `role` named `label`. +fn descriptions(h: &Harness<'static, PhotocraftApp>, role: Option, label: &str) -> Vec { + let of = |n: egui_kittest::Node<'_>| n.accesskit_node().description().map(|d| d.to_string()); + match role { + Some(role) => h.query_all_by_role(role).filter(|n| n.accesskit_node().label().as_deref() == Some(label)).filter_map(of).collect(), + None => h.query_all_by_label(label).filter_map(of).collect(), + } +} + +#[test] +fn the_bar_has_the_reference_tooltips() { + use egui::accesskit::Role; + for (role, label, tip) in [ + (Role::Button, "Brush Preset picker", "Click to open the Brush Preset picker"), + (Role::ComboBox, "Limits:", "How far to let the erasing spread"), + (Role::SpinButton, "Tolerance:", "Erase range of similar colors"), + (Role::CheckBox, "Protect Foreground Color", "Do not erase the foreground swatch color"), + ] { + let mut h = bar(Tool::BackgroundEraser); + assert!(h.query_by_label(tip).is_none(), "{label}: no tooltip before the hover"); + let at = h.query_all_by_role(role).find(|n| n.accesskit_node().label().as_deref() == Some(label)).expect(label).rect().center(); + h.hover_at(at); + h.run_steps(8); + assert!(h.query_by_label(tip).is_some(), "{label}: resting on it shows {tip:?}"); + } +} + +#[test] +fn the_other_tools_keep_their_brush_chip_tooltip() { + for tool in [Tool::Eraser, Tool::Pencil] { + let h = bar(tool); + let found = descriptions(&h, None, "Brush Preset picker"); + assert!(found.iter().any(|d| d == "Brush Preset picker"), "{tool:?}: {found:?}"); + } +} + +/// The alpha at `(x, y)` after a Background Eraser stroke over white and a square one level off +/// white, with `tolerance`. +fn erased_alpha(tolerance: serde_json::Value, (x, y): (i32, i32)) -> f32 { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 40})).unwrap(); + s.execute("select.rect", json!({"x": 30, "y": 0, "width": 34, "height": 40, "antiAlias": false})).unwrap(); + s.execute("edit.fill", json!({"color": "#feffff"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.execute( + "paint.backgroundEraser", + json!({"points": [[4, 20], [56, 20]], "size": 16, "hardness": 1, "sampling": "once", "limits": "discontiguous", "tolerance": tolerance}), + ) + .unwrap(); + let d = s.active().unwrap(); + d.doc.layer(d.active_layer.unwrap()).unwrap().surface().unwrap().rgba(x, y)[3] +} + +#[test] +fn the_command_clamps_tolerance_to_1_through_100() { + // Sampled white; the square is 1/255 off it: Tolerance 1 % reaches it, 0 % would not. + let near = (45, 20); + assert!(erased_alpha(json!(1), near) < 1.0, "1 % erases the near-white square"); + assert_eq!(erased_alpha(json!(0), near), erased_alpha(json!(1), near), "0 is taken as 1"); + assert_eq!(erased_alpha(json!(-20), near), erased_alpha(json!(1), near), "below the range is taken as 1"); + assert_eq!(erased_alpha(json!(150), (6, 20)), erased_alpha(json!(100), (6, 20)), "150 is taken as 100"); +} + +#[test] +fn the_params_doc_says_1_through_100() { + let spec = photocraft_engine::commands::find("paint.backgroundEraser").expect("registered"); + assert!(spec.params.contains(r#""tolerance":1..100=50"#), "{}", spec.params); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f19ec41da9..420b030e60 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -771,6 +771,8 @@ impl SyntheticTarget { #[cfg(test)] mod artboard_add_mode_tests; #[cfg(test)] +mod background_eraser_tolerance_field_tests; +#[cfg(test)] mod brush_options_tooltips_tests; #[cfg(test)] mod brush_picker_escape_tests; diff --git a/crates/ui-egui/src/panels/brush_controls.rs b/crates/ui-egui/src/panels/brush_controls.rs index 9c07f56255..ddd45f38ba 100644 --- a/crates/ui-egui/src/panels/brush_controls.rs +++ b/crates/ui-egui/src/panels/brush_controls.rs @@ -125,8 +125,12 @@ pub(super) fn brush_preset_chip(ui: &mut egui::Ui, b: &photocraft_engine::BrushS ui.painter().text(pos2(c.x, r.bottom() - 5.0), Align2::CENTER_CENTER, format!("{}", b.size.round() as i64), egui::FontId::proportional(9.5), t.text_dim); icons::paint(ui, Rect::from_center_size(pos2(r.right() - 9.0, c.y), vec2(10.0, 10.0)), "chevron-down", 9.0, t.text_faint); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Brush Preset picker"))); - // The Brush's own text (#11813); the other tools keep theirs. - let tip = if state.tool == crate::state::Tool::Brush { tl!("Click to open the Brush Preset picker") } else { tl!("Brush Preset picker") }; + // The Brush's and the Background Eraser's own text (#11813, #11909); the other tools keep theirs. + let tip = if matches!(state.tool, crate::state::Tool::Brush | crate::state::Tool::BackgroundEraser) { + tl!("Click to open the Brush Preset picker") + } else { + tl!("Brush Preset picker") + }; if resp.on_hover_text(tip).clicked() { if state.brush_picker.is_some() { crate::brush_picker::close(state); diff --git a/crates/ui-egui/src/tools/eraser_ui.rs b/crates/ui-egui/src/tools/eraser_ui.rs index ac896eeb0b..a5a0fdb1cf 100644 --- a/crates/ui-egui/src/tools/eraser_ui.rs +++ b/crates/ui-egui/src/tools/eraser_ui.rs @@ -125,11 +125,48 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo ("findEdges".to_string(), tl!("Find Edges")), ]; widgets::dropdown(ui, "bg-eraser-limits", tl!("Limits:"), &mut o.bg_limits, &limits, 110.0); + // The dropdown's button, by the id its combo box takes from "bg-eraser-limits". + if let Some(limits) = ui.ctx().read_response(ui.make_persistent_id(egui::IdSalt::new("bg-eraser-limits"))) { + limits.on_hover_text(tl!("How far to let the erasing spread")); + } opt(ui, tl!("Tolerance:")); - widgets::value_field(ui, &mut o.bg_tolerance, 0.0..=100.0, "%", 62.0); - widgets::checkbox(ui, &mut o.bg_protect_fg, tl!("Protect Foreground Color")); + if tolerance_field(ui, &mut o.bg_tolerance) { + open_range_alert(app); + } + let o = &mut app.ui.tool_options; + widgets::checkbox(ui, &mut o.bg_protect_fg, tl!("Protect Foreground Color")).on_hover_text(tl!("Do not erase the foreground swatch color")); } _ => return false, } true } + +/// The alert for a Background Eraser Tolerance typed out of range (#11909). +pub const TOLERANCE_RANGE_ALERT: &str = "An integer between 1% and 100% is required. Closest value inserted."; + +/// The Background Eraser's Tolerance: whole numbers in 1..=100, as the reference application takes +/// them (#11909). A typed fraction becomes the nearest whole number, a value past the range the +/// closest one. Returns true when the field is left with a value typed out of range, so the +/// caller shows [`TOLERANCE_RANGE_ALERT`]. +fn tolerance_field(ui: &mut egui::Ui, value: &mut f32) -> bool { + // The field's own range is wide, so the typed value reaches here unclamped. + let mut typed = *value; + let r = widgets::value_field(ui, &mut typed, -1.0e9..=1.0e9, "%", 62.0).on_hover_text(tl!("Erase range of similar colors")); + let out_id = r.id.with("out-of-range"); + if r.changed() && typed.is_finite() { + *value = typed.round().clamp(1.0, 100.0); + // Only what was typed alerts; a scrub past the ends just stops there. + let out = r.has_focus() && !(1.0..=100.0).contains(&typed); + ui.ctx().data_mut(|d| d.insert_temp(out_id, out)); + } + r.lost_focus() && ui.ctx().data_mut(|d| d.remove_temp::(out_id)).unwrap_or(false) +} + +/// Shows [`TOLERANCE_RANGE_ALERT`]: a modal with OK, once even if more than one pass asks. +fn open_range_alert(app: &mut PhotocraftApp) { + let message = tl!(TOLERANCE_RANGE_ALERT).to_owned(); + let shown = app.ui.dialogs.iter().any(|d| d.kind == crate::state::DialogKind::Error && d.fields.get("message").and_then(|m| m.as_str()) == Some(&message)); + if !shown { + app.ui.open_dialog(crate::state::DialogKind::Error, json!({ "message": message }).as_object().cloned().unwrap_or_default()); + } +} From c1f630791a89a06cf7ac801073074fae72d3acbd Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sat, 7 Nov 2026 16:49:36 -0500 Subject: [PATCH 124/219] Open as Smart Object: tests (#11678) Co-Authored-By: Claude Fable 5.1 --- crates/engine/src/cmd/file_cmds.rs | 4 + .../file_cmds/open_as_smart_object_tests.rs | 196 ++++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 crates/engine/src/cmd/file_cmds/open_as_smart_object_tests.rs diff --git a/crates/engine/src/cmd/file_cmds.rs b/crates/engine/src/cmd/file_cmds.rs index 495f9d1892..cce669ec45 100644 --- a/crates/engine/src/cmd/file_cmds.rs +++ b/crates/engine/src/cmd/file_cmds.rs @@ -421,6 +421,10 @@ mod tests; #[path = "file_cmds/place_job_tests.rs"] mod place_job_tests; +#[cfg(test)] +#[path = "file_cmds/open_as_smart_object_tests.rs"] +mod open_as_smart_object_tests; + #[cfg(test)] mod aces_exr_tests { use super::*; diff --git a/crates/engine/src/cmd/file_cmds/open_as_smart_object_tests.rs b/crates/engine/src/cmd/file_cmds/open_as_smart_object_tests.rs new file mode 100644 index 0000000000..6378c47353 --- /dev/null +++ b/crates/engine/src/cmd/file_cmds/open_as_smart_object_tests.rs @@ -0,0 +1,196 @@ +//! File › Open as Smart Object… (#11678): a file opens as a new untitled document holding one +//! embedded smart object layer. + +use super::*; + +fn tmp(name: &str) -> String { + static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let id = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let d = std::env::temp_dir().join(format!("photocraft-open-as-so-{}-{name}-{id}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d.to_string_lossy().into_owned() +} + +/// A small PNG with a gradient, so the pixels differ from place to place. +fn write_png(dir: &str, name: &str, w: u32, h: u32) -> String { + let px: Vec = (0..h).flat_map(|y| (0..w).flat_map(move |x| [(x * 255 / w) as u8, (y * 255 / h) as u8, ((x + y) % 256) as u8])).collect(); + let img = photocraft_codecs::Image::from_u8(w, h, photocraft_codecs::ChannelLayout::Rgb, px).unwrap(); + let bytes = photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap(); + let path = join(dir, name); + std::fs::write(&path, bytes).unwrap(); + path +} + +/// A two-layer 16-bit PSD at 300 ppi tagged with Display P3, made with the engine's own save. +fn write_psd16(dir: &str, name: &str, w: u32, h: u32) -> String { + let mut s = Session::new(); + s.execute("file.new", json!({"width": w, "height": h, "depth": 16})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("image.imageSize", json!({"resolution": 300, "resample": "none"})).unwrap(); + s.execute("edit.assignProfile", json!({"profile": "display-p3"})).unwrap(); + let path = join(dir, name); + save_doc(&s.active().unwrap().doc, &path, None).unwrap(); + path +} + +fn imported(path: &str) -> Document { + photocraft_io::import(&file_name(path), &std::fs::read(path).unwrap()).unwrap().document +} + +fn active(s: &Session) -> &Document { + &s.active().unwrap().doc +} + +fn names(s: &Session) -> Vec { + s.documents().iter().map(|d| d.doc.name.clone()).collect() +} + +/// The one layer of the active document, which must be an embedded smart object. +fn embedded(s: &Session) -> (String, String, Arc>) { + let d = active(s); + assert_eq!(d.layers.len(), 1, "one layer, not a Background plus a layer"); + let layer = &d.layers[0]; + match &layer.content { + LayerContent::Smart(so) => match &so.source { + SmartSource::Embedded { file_name, bytes } => (layer.name.clone(), file_name.clone(), Arc::clone(bytes)), + other => panic!("expected an embedded smart object, got {other:?}"), + }, + _ => panic!("the layer is not a smart object"), + } +} + +#[test] +fn a_png_opens_as_an_untitled_document_with_one_embedded_smart_object() { + let dir = tmp("png"); + let path = write_png(&dir, "a_test.png", 64, 48); + let mut s = Session::new(); + let r = s.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + assert_eq!(r["name"], "a_test-1", "{r}"); + assert_eq!(s.documents().len(), 1); + let st = s.active().unwrap(); + assert_eq!(st.doc.name, "a_test-1"); + assert_eq!(st.path, None, "untitled: Save asks where to save"); + + let file = imported(&path); + let d = &st.doc; + assert_eq!((d.size.width, d.size.height), (64, 48)); + assert_eq!(d.resolution_dpi, file.resolution_dpi); + assert_eq!((d.mode, d.depth), (file.mode, file.depth)); + assert_eq!(d.icc_profile, file.icc_profile); + + let (layer, original, bytes) = embedded(&s); + assert_eq!(layer, "a_test", "named after the file without its extension"); + assert_eq!(original, "a_test.png", "Edit Contents opens the original file name"); + assert_eq!(*bytes, std::fs::read(&path).unwrap(), "the file itself is embedded"); +} + +#[test] +fn the_smart_object_shows_the_file() { + let dir = tmp("pixels"); + let path = write_png(&dir, "grad.png", 32, 16); + let mut s = Session::new(); + s.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + let want = photocraft_compose::render(&imported(&path), Rect::new(0, 0, 32, 16)).px.clone(); + let got = photocraft_compose::render(active(&s), Rect::new(0, 0, 32, 16)).px.clone(); + assert_eq!(got, want); +} + +#[test] +fn a_16_bit_psd_keeps_size_resolution_mode_depth_and_profile() { + let dir = tmp("psd"); + let path = write_psd16(&dir, "c.layered.psd", 40, 30); + let mut s = Session::new(); + s.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + let file = imported(&path); + assert_eq!(file.resolution_dpi, 300.0); + assert!(file.icc_profile.is_some()); + let d = active(&s); + assert_eq!(d.name, "c.layered-1", "only the last extension is dropped"); + assert_eq!((d.size.width, d.size.height), (40, 30)); + assert_eq!(d.resolution_dpi, 300.0); + assert_eq!((d.mode, d.depth), (file.mode, file.depth)); + assert_eq!(d.depth, photocraft_doc::SampleType::U16); + assert_eq!(d.icc_profile, file.icc_profile); + let (layer, original, _) = embedded(&s); + assert_eq!((layer.as_str(), original.as_str()), ("c.layered", "c.layered.psd")); +} + +#[test] +fn history_is_the_opening_snapshot_then_open_as_smart_object() { + let dir = tmp("history"); + let path = write_png(&dir, "a_test.png", 16, 16); + let mut s = Session::new(); + s.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + let st = s.active().unwrap(); + assert_eq!(st.history.entries().last().map(String::as_str), Some("Open As Smart Object")); + assert_eq!(st.history.entries().len(), 2, "the opening state, then one step"); + assert_eq!(st.snapshots.first().map(|snap| snap.name.as_str()), Some("a_test"), "the opening snapshot is named after the layer"); +} + +#[test] +fn the_number_is_the_next_free_one_and_starts_again_after_they_close() { + let dir = tmp("numbers"); + let path = write_png(&dir, "a_test.png", 8, 8); + let mut s = Session::new(); + for _ in 0..3 { + s.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + } + assert_eq!(names(&s), ["a_test-1", "a_test-2", "a_test-3"]); + while !s.documents().is_empty() { + s.close(0); + } + s.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + assert_eq!(names(&s), ["a_test-1"]); +} + +#[test] +fn a_plain_open_of_the_same_file_is_unchanged() { + let dir = tmp("plain"); + let path = write_png(&dir, "a_test.png", 8, 8); + let mut s = Session::new(); + s.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + open_bytes_as(&mut s, &path, &std::fs::read(&path).unwrap(), None, Some(path.clone())).unwrap(); + assert_eq!(names(&s), ["a_test-1", "a_test.png"]); + assert!(matches!(active(&s).layers[0].content, LayerContent::Raster(_))); +} + +#[test] +fn several_files_open_one_document_each() { + let dir = tmp("several"); + let a = write_png(&dir, "a_test.png", 8, 8); + let b = write_psd16(&dir, "d_other.psd", 12, 10); + let mut s = Session::new(); + let r = s.execute("file.openAsSmartObject", json!({"paths": [a, b]})).unwrap(); + assert_eq!(r["documents"].as_array().map(Vec::len), Some(2), "{r}"); + assert_eq!(names(&s), ["a_test-1", "d_other-1"]); + for st in s.documents() { + assert_eq!(st.doc.layers.len(), 1); + assert!(matches!(st.doc.layers[0].content, LayerContent::Smart(_))); + } +} + +#[test] +fn a_missing_or_unreadable_file_is_an_error_not_a_crash() { + let dir = tmp("bad"); + let junk = join(&dir, "notes.png"); + std::fs::write(&junk, b"this is not an image").unwrap(); + let text = join(&dir, "readme.txt"); + std::fs::write(&text, b"hello").unwrap(); + let mut s = Session::new(); + for path in [join(&dir, "missing.png"), junk, text] { + let r = s.execute("file.openAsSmartObject", json!({"path": path})); + assert!(r.is_err(), "{path}: {r:?}"); + } + assert!(s.documents().is_empty(), "a failed open leaves no document behind"); +} + +#[test] +fn bad_params_are_errors() { + let mut s = Session::new(); + for params in [json!({}), json!({"path": 7}), json!({"path": null}), json!({"paths": "a.png"}), json!({"paths": []}), json!({"paths": [1]})] { + let r = s.execute("file.openAsSmartObject", params.clone()); + assert!(r.is_err(), "{params}: {r:?}"); + } + assert!(s.documents().is_empty()); +} From 8f9643c932b0deafb5ac04a8761c5b4cdcdd430b Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 7 Nov 2026 22:54:47 +0100 Subject: [PATCH 125/219] Background Eraser: alert for an expression committed past the range, normalize a restored Tolerance (#11909) An expression such as 50*4 is applied as the field is left, so the out-of-range check now also counts the frame the field loses focus. A restored setting (an older 0, a fraction) shows and erases as the field would store it: a whole number in 1..100. Co-Authored-By: Claude Opus 5.5 --- ...background_eraser_tolerance_field_tests.rs | 28 +++++++++++++++++++ crates/ui-egui/src/tools/eraser_ui.rs | 7 +++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/background_eraser_tolerance_field_tests.rs b/crates/ui-egui/src/background_eraser_tolerance_field_tests.rs index 537fd02694..012c16c73e 100644 --- a/crates/ui-egui/src/background_eraser_tolerance_field_tests.rs +++ b/crates/ui-egui/src/background_eraser_tolerance_field_tests.rs @@ -93,6 +93,34 @@ fn tolerance_in_range_stores_a_whole_number_without_an_alert() { assert_eq!(alert(&h), None); } +#[test] +fn an_expression_committed_with_enter_past_the_range_alerts() { + let mut h = app_harness(); + h.query_all_by_role(egui::accesskit::Role::SpinButton) + .find(|n| n.accesskit_node().label().as_deref() == Some("Tolerance:")) + .expect("the Tolerance field") + .click(); + h.run_steps(1); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text("50*4".into())); + h.run_steps(1); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(tolerance(&h), 100.0); + assert_eq!(alert(&h).as_deref(), Some(TOLERANCE_RANGE_ALERT)); +} + +#[test] +fn a_restored_tolerance_out_of_range_shows_as_the_field_stores_it() { + for (saved, want) in [(0.0, 1.0), (33.6, 34.0), (f32::NAN, 50.0)] { + let mut h = app_harness(); + h.state_mut().ui.tool_options.bg_tolerance = saved; + h.run_steps(3); + assert_eq!(tolerance(&h), want, "{saved}"); + assert_eq!(alert(&h), None, "{saved}: no alert for a restored value"); + } +} + /// The accessible descriptions (tooltips) of the widgets of `role` named `label`. fn descriptions(h: &Harness<'static, PhotocraftApp>, role: Option, label: &str) -> Vec { let of = |n: egui_kittest::Node<'_>| n.accesskit_node().description().map(|d| d.to_string()); diff --git a/crates/ui-egui/src/tools/eraser_ui.rs b/crates/ui-egui/src/tools/eraser_ui.rs index a5a0fdb1cf..0d6ba3134e 100644 --- a/crates/ui-egui/src/tools/eraser_ui.rs +++ b/crates/ui-egui/src/tools/eraser_ui.rs @@ -149,14 +149,17 @@ pub const TOLERANCE_RANGE_ALERT: &str = "An integer between 1% and 100% is requi /// closest one. Returns true when the field is left with a value typed out of range, so the /// caller shows [`TOLERANCE_RANGE_ALERT`]. fn tolerance_field(ui: &mut egui::Ui, value: &mut f32) -> bool { + // A restored setting (an older 0, a fraction) shows and erases as the field would store it. + *value = if value.is_finite() { value.round().clamp(1.0, 100.0) } else { 50.0 }; // The field's own range is wide, so the typed value reaches here unclamped. let mut typed = *value; let r = widgets::value_field(ui, &mut typed, -1.0e9..=1.0e9, "%", 62.0).on_hover_text(tl!("Erase range of similar colors")); let out_id = r.id.with("out-of-range"); if r.changed() && typed.is_finite() { *value = typed.round().clamp(1.0, 100.0); - // Only what was typed alerts; a scrub past the ends just stops there. - let out = r.has_focus() && !(1.0..=100.0).contains(&typed); + // Only what was typed alerts, also an expression committed as the field is left + // (`50*4` and Enter); a scrub past the ends just stops there. + let out = (r.has_focus() || r.lost_focus()) && !(1.0..=100.0).contains(&typed); ui.ctx().data_mut(|d| d.insert_temp(out_id, out)); } r.lost_focus() && ui.ctx().data_mut(|d| d.remove_temp::(out_id)).unwrap_or(false) From 5e583a3f9fb2aa164f3a76f68be3b573ad7bd598 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sat, 7 Nov 2026 17:04:06 -0500 Subject: [PATCH 126/219] File: Open as Smart Object... opens a file as a new document holding one embedded smart object (#11678) New file.openAsSmartObject command (path or paths) and a File menu item after Open As. Co-Authored-By: Claude Fable 5.1 --- crates/engine/src/cmd/file_cmds.rs | 11 +++ .../src/cmd/file_cmds/open_as_smart_object.rs | 88 +++++++++++++++++++ crates/ui-egui/src/menu_catalog.rs | 1 + 3 files changed, 100 insertions(+) create mode 100644 crates/engine/src/cmd/file_cmds/open_as_smart_object.rs diff --git a/crates/engine/src/cmd/file_cmds.rs b/crates/engine/src/cmd/file_cmds.rs index cce669ec45..00efb839e1 100644 --- a/crates/engine/src/cmd/file_cmds.rs +++ b/crates/engine/src/cmd/file_cmds.rs @@ -24,6 +24,7 @@ mod batch_run; mod export; mod info; mod open; +mod open_as_smart_object; mod paths; mod place_steps; mod processing; @@ -50,6 +51,7 @@ pub(crate) use open::{BatchOpen, batch_open_notice, batch_open_params, remember_ pub use open::{PLACE_EMBEDDED, open_bytes_as, open_bytes_as_with_options, place_bytes, raw_defaults}; pub(crate) use open::{PlaceInput, apply_place, import, import_with_options, tag_exr_colorspace}; use open::{approve_memory, close_all, close_others, open_as, place, revert}; +use open_as_smart_object::open_as_smart_object; use paths::is_file; #[cfg(test)] use paths::openable_name; @@ -285,6 +287,15 @@ pub fn specs() -> Vec { native, open_as ), + spec!( + "file.openAsSmartObject", + "Open as Smart Object…", + &["File"], + None, + r##"{"path":str (or "paths":[str], one document each)} (opens the file as a new untitled document named "-N" with the file's size, resolution, colour mode, bit depth and profile, holding one embedded smart object layer named after the file) → {document, name, layer} ({documents:[…]} for paths)"##, + native, + open_as_smart_object + ), // One-shot permission for one file, not something to replay later: never journaled or // recorded in an action, since replaying it in another session/on another file would // grant memory use nobody approved (#10897). It also releases the queued source on both diff --git a/crates/engine/src/cmd/file_cmds/open_as_smart_object.rs b/crates/engine/src/cmd/file_cmds/open_as_smart_object.rs new file mode 100644 index 0000000000..0d162b5238 --- /dev/null +++ b/crates/engine/src/cmd/file_cmds/open_as_smart_object.rs @@ -0,0 +1,88 @@ +//! File › Open as Smart Object… (#11678): a file opens as a new untitled document with the file's +//! size, resolution, colour mode, bit depth and profile, holding one embedded smart object layer. +//! +//! The layer is built as File › Place Embedded builds one (the same [`Prepared`] layer, inserted +//! by [`apply_place`]), at the file's own size and position `0, 0`. + +use super::open::{Prepared, apply_place, import_opened}; +use super::paths::{file_name, read_file, stem, untitled_name}; +use super::{Affine, Arc, Document, EngineError, LayerContent, Result, Session, SmartObject, SmartSource, Value, flattened_checked, json, str_param}; + +const CMD: &str = "file.openAsSmartObject"; + +/// The history step's name. +const LABEL: &str = "Open As Smart Object"; + +fn bad(msg: impl Into) -> EngineError { + EngineError::BadParams { cmd: CMD.into(), msg: msg.into() } +} + +/// The files to open: `paths` (several selected files) or `path` (one); whether it was one. +fn paths_param(p: &Value) -> Result<(Vec, bool)> { + match p.get("paths") { + None | Some(Value::Null) => Ok((vec![str_param(p, "path", CMD)?.to_string()], true)), + Some(Value::Array(list)) if p.get("path").is_none_or(Value::is_null) => { + let paths: Option> = list.iter().map(|v| v.as_str().filter(|v| !v.is_empty()).map(str::to_string)).collect(); + match paths { + Some(paths) if !paths.is_empty() => Ok((paths, false)), + _ => Err(bad("\"paths\" must be a non-empty list of file paths")), + } + } + Some(Value::Array(_)) => Err(bad("give \"path\" or \"paths\", not both")), + Some(_) => Err(bad("\"paths\" must be a list of file paths")), + } +} + +/// Opens each file as its own document, in order. A file that cannot be read stops the command +/// with an error; the documents opened before it stay open. +pub(super) fn open_as_smart_object(s: &mut Session, p: &Value) -> Result { + let (paths, single) = paths_param(p)?; + let mut opened = Vec::new(); + for path in &paths { + opened.push(open_one(s, path)?); + } + Ok(if single { opened.swap_remove(0) } else { json!({"documents": opened}) }) +} + +fn open_one(s: &mut Session, path: &str) -> Result { + let bytes = read_file(path)?; + let src = import_opened(s, path, &bytes)?; + // The file as one image: the smart object's rendered contents. + let pixels = flattened_checked(&src, src.pixel_format(), CMD)?; + let (w, h) = (f64::from(src.size.width), f64::from(src.size.height)); + let layer_name = stem(path); + // "-1", the next free number while a document with that name is open. + let name = untitled_name(&layer_name, |n| s.documents().iter().any(|d| d.doc.name == n)); + + let mut doc = Document::new(name.clone(), src.size, src.mode, src.depth); + doc.resolution_dpi = src.resolution_dpi; + doc.icc_profile = src.icc_profile.clone(); + doc.color_table = src.color_table.clone(); + doc.duotone = src.duotone.clone(); + + // Embedded (not linked): the original file name keeps its extension, so Edit Contents opens it. + let source = SmartSource::Embedded { file_name: file_name(path), bytes: Arc::new(bytes) }; + let so = SmartObject::new(source, Affine { m: [1.0, 0.0, 0.0, 1.0, 0.0, 0.0] }, Some(pixels)); + let prepared = Prepared { + content: LayerContent::Smart(so), + layer_name: layer_name.clone(), + label: LABEL, + scale: 1.0, + bounds: [0.0, 0.0, w, h], + smart: true, + frame: None, + }; + // Untitled: no path, so Save asks where to save. + let index = s.add_document(doc, None); + // The opening snapshot is named after the layer, as the reference names it. + if let Some(first) = s.active_mut().and_then(|st| st.snapshots.iter_mut().find(|snap| snap.id == 0)) { + first.name = layer_name; + } + match apply_place(s, prepared) { + Ok(placed) => Ok(json!({"document": index, "name": name, "layer": placed["layer"]})), + Err(e) => { + s.close(index); + Err(e) + } + } +} diff --git a/crates/ui-egui/src/menu_catalog.rs b/crates/ui-egui/src/menu_catalog.rs index 3838a70bcf..3bba8bca22 100644 --- a/crates/ui-egui/src/menu_catalog.rs +++ b/crates/ui-egui/src/menu_catalog.rs @@ -7,6 +7,7 @@ pub static CATALOG: &[(&[&str], &str, Option<&str>, &str)] = &[ (&["File"], "New…", Some("Cmd+N"), "file.new"), (&["File"], "Open…", Some("Cmd+O"), "file.open"), (&["File"], "Open As…", Some("Cmd+Alt+Shift+O"), "file.openAs"), + (&["File"], "Open as Smart Object…", None, "file.openAsSmartObject"), (&["File"], "Open EXR Parts…", None, "file.openExrParts"), (&["File"], "---", None, "---"), (&["File"], "Close", Some("Cmd+W"), "file.close"), From e2471998de0d283f7bfb8d905e254d60a63da7ec Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sat, 7 Nov 2026 17:08:24 -0500 Subject: [PATCH 127/219] File: Open as Smart Object... opens the same file dialog as Open and takes several files (#11678) The menu item with no path asks with File > Open's dialog (multi-select) and opens each chosen file as its own document; failures are reported as File > Open reports them. Co-Authored-By: Claude Fable 5.1 --- crates/ui-egui/src/file_dialog.rs | 18 ++++++++++++++++++ crates/ui-egui/src/file_open/tests.rs | 18 ++++++++++++++++++ crates/ui-egui/src/menus/view_cmds/dialogs.rs | 1 + crates/ui-egui/src/menus/view_cmds/dispatch.rs | 1 + 4 files changed, 38 insertions(+) diff --git a/crates/ui-egui/src/file_dialog.rs b/crates/ui-egui/src/file_dialog.rs index bddc6796d3..a8edcdcf9f 100644 --- a/crates/ui-egui/src/file_dialog.rs +++ b/crates/ui-egui/src/file_dialog.rs @@ -257,6 +257,24 @@ impl PhotocraftApp { }) } + /// File › Open as Smart Object…: the File › Open dialog (same filters, several files); each + /// chosen file opens as its own document holding one embedded smart object, in name order, + /// reporting each failure as File › Open does. + pub fn open_dialog_as_smart_object(&mut self) -> Result { + let initial_dir = last_used_dir(&self.ui.recent_files); + self.ask_file(FileDialogRequest::Open { multiple: true, initial_dir, extensions: None }, |app, answer| { + let FileDialogAnswer::Paths(mut paths) = answer else { return Err(UNEXPECTED.into()) }; + paths.sort_by(|a, b| crate::file_open::natural_cmp(a, b)); + for path in &paths { + if let Err(e) = crate::jobs_ui::run(app, "file.openAsSmartObject", json!({"path": path})) { + app.open_failed(&crate::file_open::display_name(path), &e); + } + } + app.sync_views(); + Ok(Value::Null) + }) + } + /// Opens each picked file's contents as its own document, in name order (like /// [`Self::open_paths`]), reporting each failure. fn open_contents(&mut self, mut files: Vec<(String, Vec)>) { diff --git a/crates/ui-egui/src/file_open/tests.rs b/crates/ui-egui/src/file_open/tests.rs index a617332bd1..3fdf448099 100644 --- a/crates/ui-egui/src/file_open/tests.rs +++ b/crates/ui-egui/src/file_open/tests.rs @@ -1694,3 +1694,21 @@ fn queued_drop_waits_for_an_external_document_job() { assert_eq!(app.ui.transform.as_ref().and_then(|t| t.made), Some(crate::state::MadeLayer::Place)); assert!(app.drop_places.is_empty()); } + +/// File › Open as Smart Object… (#11678) asks with File › Open's dialog and opens each chosen +/// file as its own untitled document holding one embedded smart object. +#[test] +fn open_as_smart_object_dialog_opens_one_document_per_selected_file() { + let (dir, paths) = files("open-as-so", &["one.png", "two.png", "bad.png"], &png_bytes(8, 6)); + std::fs::write(&paths[2], b"not an image").unwrap(); + let (mut app, _) = app_with(vec![Some(FileDialogAnswer::Paths(paths.clone()))]); + + menus::invoke(&mut app, &egui::Context::default(), "file.openAsSmartObject", json!({})).unwrap(); + assert!(app.file_dialog_open(), "the menu item asks for files first"); + answer(&mut app); + + assert_eq!(app.session.documents().iter().map(|d| d.doc.name.as_str()).collect::>(), ["one-1", "two-1"]); + assert!(app.session.documents().iter().all(|d| d.path.is_none() && d.doc.layers.len() == 1)); + assert!(app.ui.status_error, "the unreadable file is reported"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/menus/view_cmds/dialogs.rs b/crates/ui-egui/src/menus/view_cmds/dialogs.rs index 16cefbd87b..b2b7d56158 100644 --- a/crates/ui-egui/src/menus/view_cmds/dialogs.rs +++ b/crates/ui-egui/src/menus/view_cmds/dialogs.rs @@ -345,6 +345,7 @@ pub(super) fn front(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, para let dialog = |app: &mut PhotocraftApp, fields: Value, choices: Value| Some(Ok(json!({"dialog": form(app, id, label, fields, choices)}))); match id { "file.openAs" => Some(app.open_dialog_file()), + "file.openAsSmartObject" => Some(app.open_dialog_as_smart_object()), "file.openExrParts" => open_exr_parts_dialog(app), "file.saveACopy" => Some(save_a_copy(app)), "file.placeEmbedded" | "file.placeLinked" => { diff --git a/crates/ui-egui/src/menus/view_cmds/dispatch.rs b/crates/ui-egui/src/menus/view_cmds/dispatch.rs index f1e9b2770f..aef68d2dd6 100644 --- a/crates/ui-egui/src/menus/view_cmds/dispatch.rs +++ b/crates/ui-egui/src/menus/view_cmds/dispatch.rs @@ -223,6 +223,7 @@ fn wraps(id: &str) -> bool { matches!( id, "file.openAs" + | "file.openAsSmartObject" | "file.openExrParts" | "file.saveACopy" | "file.placeEmbedded" From 321e306a8f00be0dd4e6ed7989d23302bb0e1027 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 7 Nov 2026 16:14:42 -0600 Subject: [PATCH 128/219] Perspective Crop: the options bar's Cancel and Commit show only during a session (#11978) --- .../perspective_crop_bar_buttons_tests.rs | 72 +++++++++++++++++++ .../ui-egui/src/tools/perspective_crop_ui.rs | 8 +++ 2 files changed, 80 insertions(+) create mode 100644 crates/ui-egui/src/tools/perspective_crop_bar_buttons_tests.rs diff --git a/crates/ui-egui/src/tools/perspective_crop_bar_buttons_tests.rs b/crates/ui-egui/src/tools/perspective_crop_bar_buttons_tests.rs new file mode 100644 index 0000000000..01d4043b5e --- /dev/null +++ b/crates/ui-egui/src/tools/perspective_crop_bar_buttons_tests.rs @@ -0,0 +1,72 @@ +//! #11978: the Perspective Crop options bar shows its Cancel and Commit buttons only during a +//! session (a quad, or corners being clicked), and they still commit and cancel. + +use egui::vec2; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use super::*; + +const COMMIT: &str = "Commit current crop operation"; +const CANCEL: &str = "Cancel current crop operation"; + +/// A 200 × 150 document with the Perspective Crop tool chosen and no session. +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 150})).unwrap(); + app.ui.tool = Tool::PerspectiveCrop; + app +} + +fn harness(app: PhotocraftApp) -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(vec2(1400.0, 80.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + crate::panels::options_bar(app, ui); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio); + h.run_steps(4); + h +} + +fn shows(h: &Harness<'static, PhotocraftApp>, label: &str) -> bool { + h.query_by_label_contains(label).is_some() +} + +#[test] +fn without_a_session_neither_button_shows() { + let h = harness(app()); + assert!(!shows(&h, COMMIT) && !shows(&h, CANCEL)); + assert!(h.query_by_label("Show Grid").is_some(), "the bar still ends with Show Grid"); +} + +#[test] +fn a_pending_quad_shows_both_and_commit_commits() { + let mut app = app(); + app.ui.perspective_crop = Some([[20.0, 20.0], [180.0, 20.0], [180.0, 130.0], [20.0, 130.0]]); + let mut h = harness(app); + assert!(shows(&h, COMMIT) && shows(&h, CANCEL)); + h.get_by_label_contains(COMMIT).click(); + h.run_steps(3); + let size = h.state().session.active().unwrap().doc.size; + assert_eq!((size.width, size.height), (160, 110), "Commit cropped to the quad"); + assert!(!shows(&h, COMMIT) && !shows(&h, CANCEL), "gone after commit"); +} + +#[test] +fn clicked_corners_show_both_and_cancel_cancels() { + let mut app = app(); + app.pcrop.clicks = vec![[20.0, 20.0], [180.0, 25.0]]; + let mut h = harness(app); + assert!(shows(&h, COMMIT) && shows(&h, CANCEL), "with clicked corners"); + h.get_by_label_contains(CANCEL).click(); + h.run_steps(3); + assert!(h.state().pcrop.clicks.is_empty(), "Cancel dropped the corners"); + assert_eq!(h.state().session.active().unwrap().doc.size.width, 200, "nothing cropped"); + assert!(!shows(&h, COMMIT) && !shows(&h, CANCEL), "gone after cancel"); +} diff --git a/crates/ui-egui/src/tools/perspective_crop_ui.rs b/crates/ui-egui/src/tools/perspective_crop_ui.rs index ed60c90d41..9875916d72 100644 --- a/crates/ui-egui/src/tools/perspective_crop_ui.rs +++ b/crates/ui-egui/src/tools/perspective_crop_ui.rs @@ -305,6 +305,10 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } crate::widgets::vline(ui, 22.0); crate::widgets::checkbox(ui, &mut app.ui.tool_options.perspective_crop_grid, tl!("Show Grid")); + // Commit and Cancel show only during a session: a quad, clicked corners, or a drag (#11978). + if !pending(app) { + return; + } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let commit_tip = crate::i18n::fmt(tl!("Commit current crop operation ({key})"), &[("key", &crate::shortcuts::pretty("Enter"))]); if crate::icons::button(ui, "check", 26.0, false, &commit_tip).clicked() { @@ -316,6 +320,10 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { }); } +#[cfg(test)] +#[path = "perspective_crop_bar_buttons_tests.rs"] +mod perspective_crop_bar_buttons_tests; + #[cfg(test)] mod tests { use super::*; From 2a989225f6211aafd97550a377fa4fdab649ffdd Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 7 Nov 2026 23:33:56 +0100 Subject: [PATCH 129/219] Background Eraser: no alert for a caption scrub, strokes use the stored Tolerance (#11909) A scrub of the Tolerance caption while the field has the keyboard stops at the range's end without the alert. Strokes send the Tolerance as the field stores it, so a restored fraction erases the same whether or not the options bar has drawn. Co-Authored-By: Claude Opus 5.5 --- ...background_eraser_tolerance_field_tests.rs | 42 +++++++++++++++++++ crates/ui-egui/src/tools/eraser_ui.rs | 17 ++++++-- 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/background_eraser_tolerance_field_tests.rs b/crates/ui-egui/src/background_eraser_tolerance_field_tests.rs index 012c16c73e..9d5a1e8c22 100644 --- a/crates/ui-egui/src/background_eraser_tolerance_field_tests.rs +++ b/crates/ui-egui/src/background_eraser_tolerance_field_tests.rs @@ -121,6 +121,48 @@ fn a_restored_tolerance_out_of_range_shows_as_the_field_stores_it() { } } +#[test] +fn a_restored_fraction_reaches_the_stroke_as_a_whole_number() { + // The options bar need not have drawn: the stroke's params round it themselves. + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + for (saved, want) in [(23.6, 24.0), (0.0, 1.0), (f32::NAN, 50.0)] { + app.ui.tool_options.bg_tolerance = saved; + let p = crate::eraser_ui::background_eraser_params(&app, &[[1.0, 1.0, 1.0]]); + assert_eq!(p["tolerance"].as_f64(), Some(want), "{saved}"); + } +} + +#[test] +fn a_label_scrub_past_100_does_not_alert() { + let mut h = app_harness(); + type_tolerance(&mut h, "100"); + // The field keeps the keyboard while the caption is scrubbed past the end. + let field = h + .query_all_by_role(egui::accesskit::Role::SpinButton) + .find(|n| n.accesskit_node().label().as_deref() == Some("Tolerance:")) + .expect("the Tolerance field"); + let rect = field.rect(); + field.click(); + h.run_steps(1); + // The caption sits just left of the field. + let from = egui::pos2(rect.left() - 25.0, rect.center().y); + h.event(egui::Event::PointerMoved(from)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.run_steps(1); + for k in 1..=6 { + h.event(egui::Event::PointerMoved(from + vec2(50.0 * k as f32, 0.0))); + h.run_steps(1); + } + let to = from + vec2(300.0, 0.0); + h.event(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(3); + h.key_press(egui::Key::Tab); + h.run_steps(3); + assert_eq!(tolerance(&h), 100.0); + assert_eq!(alert(&h), None, "a scrub stops at 100 without an alert"); +} + /// The accessible descriptions (tooltips) of the widgets of `role` named `label`. fn descriptions(h: &Harness<'static, PhotocraftApp>, role: Option, label: &str) -> Vec { let of = |n: egui_kittest::Node<'_>| n.accesskit_node().description().map(|d| d.to_string()); diff --git a/crates/ui-egui/src/tools/eraser_ui.rs b/crates/ui-egui/src/tools/eraser_ui.rs index 0d6ba3134e..779f56d3fd 100644 --- a/crates/ui-egui/src/tools/eraser_ui.rs +++ b/crates/ui-egui/src/tools/eraser_ui.rs @@ -53,7 +53,7 @@ pub(crate) fn background_eraser_params(app: &PhotocraftApp, points: &[[f64; 3]]) "points": points, "sampling": o.bg_sampling, "limits": o.bg_limits, - "tolerance": o.bg_tolerance, + "tolerance": stored_tolerance(o.bg_tolerance), "protectForegroundColor": o.bg_protect_fg, "seed": crate::retouch_ui::stroke_seed(points), }) @@ -150,7 +150,7 @@ pub const TOLERANCE_RANGE_ALERT: &str = "An integer between 1% and 100% is requi /// caller shows [`TOLERANCE_RANGE_ALERT`]. fn tolerance_field(ui: &mut egui::Ui, value: &mut f32) -> bool { // A restored setting (an older 0, a fraction) shows and erases as the field would store it. - *value = if value.is_finite() { value.round().clamp(1.0, 100.0) } else { 50.0 }; + *value = stored_tolerance(*value); // The field's own range is wide, so the typed value reaches here unclamped. let mut typed = *value; let r = widgets::value_field(ui, &mut typed, -1.0e9..=1.0e9, "%", 62.0).on_hover_text(tl!("Erase range of similar colors")); @@ -158,13 +158,22 @@ fn tolerance_field(ui: &mut egui::Ui, value: &mut f32) -> bool { if r.changed() && typed.is_finite() { *value = typed.round().clamp(1.0, 100.0); // Only what was typed alerts, also an expression committed as the field is left - // (`50*4` and Enter); a scrub past the ends just stops there. - let out = (r.has_focus() || r.lost_focus()) && !(1.0..=100.0).contains(&typed); + // (`50*4` and Enter); a scrub past the ends (the field's or its caption's, even while + // the field has the keyboard) just stops there. + let typing = (r.has_focus() || r.lost_focus()) && ui.ctx().dragged_id().is_none(); + let out = typing && !(1.0..=100.0).contains(&typed); ui.ctx().data_mut(|d| d.insert_temp(out_id, out)); } r.lost_focus() && ui.ctx().data_mut(|d| d.remove_temp::(out_id)).unwrap_or(false) } +/// A Tolerance as the field stores it: a whole number in 1..=100, the default 50 for a value +/// that is no number. Strokes use it too, so a restored setting erases the same before and after +/// the options bar shows it. +fn stored_tolerance(v: f32) -> f32 { + if v.is_finite() { v.round().clamp(1.0, 100.0) } else { 50.0 } +} + /// Shows [`TOLERANCE_RANGE_ALERT`]: a modal with OK, once even if more than one pass asks. fn open_range_alert(app: &mut PhotocraftApp) { let message = tl!(TOLERANCE_RANGE_ALERT).to_owned(); From 8b3e769173101cf96f40bf9922ed1d73c73f9f13 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 7 Nov 2026 23:44:07 +0100 Subject: [PATCH 130/219] Background Eraser: drop a pending Tolerance alert when the field is entered again (#11909) An out-of-range edit cut off by hiding the options bar no longer shows its alert later, when the field is entered and left again without a change. The caption scrub test now starts at 90 and checks that it reaches 100. Co-Authored-By: Claude Opus 5.5 --- ...background_eraser_tolerance_field_tests.rs | 35 +++++++++++++++++-- crates/ui-egui/src/tools/eraser_ui.rs | 5 +++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/background_eraser_tolerance_field_tests.rs b/crates/ui-egui/src/background_eraser_tolerance_field_tests.rs index 9d5a1e8c22..a976444c5d 100644 --- a/crates/ui-egui/src/background_eraser_tolerance_field_tests.rs +++ b/crates/ui-egui/src/background_eraser_tolerance_field_tests.rs @@ -135,8 +135,8 @@ fn a_restored_fraction_reaches_the_stroke_as_a_whole_number() { #[test] fn a_label_scrub_past_100_does_not_alert() { let mut h = app_harness(); - type_tolerance(&mut h, "100"); - // The field keeps the keyboard while the caption is scrubbed past the end. + type_tolerance(&mut h, "90"); + // The field keeps the keyboard while the caption is scrubbed from 90 past the end. let field = h .query_all_by_role(egui::accesskit::Role::SpinButton) .find(|n| n.accesskit_node().label().as_deref() == Some("Tolerance:")) @@ -159,10 +159,39 @@ fn a_label_scrub_past_100_does_not_alert() { h.run_steps(3); h.key_press(egui::Key::Tab); h.run_steps(3); - assert_eq!(tolerance(&h), 100.0); + assert_eq!(tolerance(&h), 100.0, "the scrub reached the end"); assert_eq!(alert(&h), None, "a scrub stops at 100 without an alert"); } +#[test] +fn an_edit_cut_off_by_hiding_the_bar_leaves_no_stale_alert() { + let mut h = app_harness(); + let field = |h: &Harness<'static, PhotocraftApp>| { + h.query_all_by_role(egui::accesskit::Role::SpinButton) + .find(|n| n.accesskit_node().label().as_deref() == Some("Tolerance:")) + .expect("the Tolerance field") + .rect() + .center() + }; + // 150 typed, then the bar goes away before the field is left. + let at = field(&h); + h.event(egui::Event::PointerMoved(at)); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(1); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text("150".into())); + h.run_steps(1); + h.state_mut().ui.panels.options_bar = false; + h.run_steps(3); + h.state_mut().ui.panels.options_bar = true; + h.run_steps(3); + assert_eq!(tolerance(&h), 100.0); + // Back in the field and out again without a change: nothing to say. + type_tolerance(&mut h, "100"); + assert_eq!(alert(&h), None, "no alert for an edit that was already cut off"); +} + /// The accessible descriptions (tooltips) of the widgets of `role` named `label`. fn descriptions(h: &Harness<'static, PhotocraftApp>, role: Option, label: &str) -> Vec { let of = |n: egui_kittest::Node<'_>| n.accesskit_node().description().map(|d| d.to_string()); diff --git a/crates/ui-egui/src/tools/eraser_ui.rs b/crates/ui-egui/src/tools/eraser_ui.rs index 779f56d3fd..36451da695 100644 --- a/crates/ui-egui/src/tools/eraser_ui.rs +++ b/crates/ui-egui/src/tools/eraser_ui.rs @@ -155,6 +155,11 @@ fn tolerance_field(ui: &mut egui::Ui, value: &mut f32) -> bool { let mut typed = *value; let r = widgets::value_field(ui, &mut typed, -1.0e9..=1.0e9, "%", 62.0).on_hover_text(tl!("Erase range of similar colors")); let out_id = r.id.with("out-of-range"); + // An edit the field never got to finish (the options bar was hidden while it had the + // keyboard) has nothing left to say once the field is entered again. + if r.gained_focus() { + ui.ctx().data_mut(|d| d.remove_temp::(out_id)); + } if r.changed() && typed.is_finite() { *value = typed.round().clamp(1.0, 100.0); // Only what was typed alerts, also an expression committed as the field is left From 324489a66fccb8e20dd09727856854dc4bae962f Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 7 Nov 2026 16:06:44 -0600 Subject: [PATCH 131/219] Notes: default colour 255,255,131, popup at icon + (8,10) 240x140, local-time date; the popup keeps its size under transforms (#11795) --- crates/engine/src/canvas_geom.rs | 7 +- crates/engine/src/cmd.rs | 2 + crates/engine/src/cmd/notes_cmds.rs | 42 ++++++--- .../src/cmd/notes_stored_values_tests.rs | 87 +++++++++++++++++++ crates/ui-egui/src/tools/analysis_ui.rs | 2 +- 5 files changed, 128 insertions(+), 12 deletions(-) create mode 100644 crates/engine/src/cmd/notes_stored_values_tests.rs diff --git a/crates/engine/src/canvas_geom.rs b/crates/engine/src/canvas_geom.rs index 93bc4654b9..38cddc6ef5 100644 --- a/crates/engine/src/canvas_geom.rs +++ b/crates/engine/src/canvas_geom.rs @@ -242,9 +242,14 @@ pub(crate) fn transform_doc_marks(doc: &mut Document, a: &Affine) { if let Some(wp) = &mut doc.work_path { *wp = wp.transform(a); } + // A note's icon point maps with the pixels; its popup moves with it and keeps its size and its + // offset from the icon, as Photoshop's does under Image Size, Canvas Size and turns (#11795). for n in &mut doc.notes { + let [px, py] = n.position; + let (dx, dy, w, h) = (n.popup[0] - px, n.popup[1] - py, n.popup[2] - n.popup[0], n.popup[3] - n.popup[1]); n.position = map_pt(a, n.position); - n.popup = map_box(a, n.popup); + let [x, y] = n.position; + n.popup = [x + dx, y + dy, x + dx + w, y + dy + h]; } for g in &mut doc.measurement.count_groups { for p in &mut g.points { diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index c4addf4c22..6a741c521c 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -114,6 +114,8 @@ pub(super) mod migrate_cmds; pub mod mode_cmds; pub mod multichannel_cmds; pub mod notes_cmds; +#[cfg(test)] +mod notes_stored_values_tests; mod ops; pub mod paint_cmds; mod params; diff --git a/crates/engine/src/cmd/notes_cmds.rs b/crates/engine/src/cmd/notes_cmds.rs index 2f589f3390..133417649d 100644 --- a/crates/engine/src/cmd/notes_cmds.rs +++ b/crates/engine/src/cmd/notes_cmds.rs @@ -22,14 +22,33 @@ fn has_notes(s: &Session) -> std::result::Result<(), String> { if s.active().is_some_and(|d| !d.doc.notes.is_empty()) { Ok(()) } else { Err("the document has no notes".into()) } } -/// PDF-style date (`D:YYYYMMDDhhmmssZ`) as Photoshop stores in notes; empty without a clock. +/// Photoshop's default note colour (#11795): RGB 255, 255, 131 (stored as 16-bit 0x8383 blue). +pub const DEFAULT_NOTE_COLOR: [f32; 3] = [1.0, 1.0, 131.0 / 255.0]; + +/// The PDF date (`D:YYYYMMDDHHMMSS+HH'mm'`) Photoshop stores in a note: local time with its +/// offset from UTC (#11795), through chrono. Empty without a clock (the web build). fn pdf_date() -> String { - let iso = crate::analysis_cmds::now_iso(); - if iso.len() < 19 { - return String::new(); + #[cfg(not(target_arch = "wasm32"))] + { + let now = chrono::Local::now(); + pdf_date_at(&now.with_timezone(now.offset())) } - let digits: String = iso[..19].chars().filter(char::is_ascii_digit).collect(); - format!("D:{digits}Z") + #[cfg(target_arch = "wasm32")] + { + String::new() + } +} + +/// [`pdf_date`] for the moment `at`: its own wall-clock time and UTC offset. +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +pub(crate) fn pdf_date_at(at: &chrono::DateTime) -> String +where + Tz::Offset: std::fmt::Display, +{ + use chrono::Offset; + let off = at.offset().fix().local_minus_utc(); + let (sign, off) = if off < 0 { ('-', -off) } else { ('+', off) }; + format!("D:{}{sign}{:02}'{:02}'", at.format("%Y%m%d%H%M%S"), off / 3600, off % 3600 / 60) } pub fn note_json(i: usize, n: &Note) -> Value { @@ -44,7 +63,8 @@ fn color_of(cmd: &str, p: &Value) -> Result> { if p.get("color").is_none() { return Ok(None); } - let c = crate::commands::color_param(cmd, p, "color", [1.0, 1.0, 0.51, 1.0])?; + let [r, g, b] = DEFAULT_NOTE_COLOR; + let c = crate::commands::color_param(cmd, p, "color", [r, g, b, 1.0])?; Ok(Some(Color::rgb(c[0], c[1], c[2]))) } @@ -65,10 +85,11 @@ fn add(s: &mut Session, p: &Value) -> Result { author: p.get("author").and_then(Value::as_str).unwrap_or("").to_string(), text: p.get("text").and_then(Value::as_str).unwrap_or("").to_string(), position: [x, y], - popup: [x + 20.0, y + 20.0, x + 260.0, y + 160.0], + // Photoshop's popup: 240 × 140, at the icon's top-left + (8, 10) (#11795). + popup: [x + 8.0, y + 10.0, x + 248.0, y + 150.0], + color: Color::rgb(DEFAULT_NOTE_COLOR[0], DEFAULT_NOTE_COLOR[1], DEFAULT_NOTE_COLOR[2]), open: p.get("open").and_then(Value::as_bool).unwrap_or(true), modified: pdf_date(), - ..Default::default() }; if let Some(c) = color_of(CMD, p)? { n.color = c; @@ -270,7 +291,8 @@ mod tests { let d = s.active().unwrap().doc.clone(); assert_eq!(d.notes[0].text, "fixed"); assert_eq!(d.notes[0].position, [20.0, 12.0]); - assert_eq!(d.notes[0].popup[0], 40.0); + // The popup travels with its icon: 20 + 8 (#11795). + assert_eq!(d.notes[0].popup[0], 28.0); // Unchanged edits don't add history. let before = s.active().unwrap().history.past_len(); assert_eq!(s.execute("notes.set", json!({"index": 0, "text": "fixed"})).unwrap()["changed"], false); diff --git a/crates/engine/src/cmd/notes_stored_values_tests.rs b/crates/engine/src/cmd/notes_stored_values_tests.rs new file mode 100644 index 0000000000..b0920139d6 --- /dev/null +++ b/crates/engine/src/cmd/notes_stored_values_tests.rs @@ -0,0 +1,87 @@ +//! #11795: what a new note stores, as in Photoshop: colour 255, 255, 131, a 240 × 140 popup at the +//! icon's top-left + (8, 10), and a local-time PDF date with its offset. Document operations move +//! the popup with its icon and keep its size. + +use chrono::{FixedOffset, TimeZone}; +use serde_json::json; + +use super::notes_cmds::{DEFAULT_NOTE_COLOR, pdf_date_at}; +use crate::Session; + +fn session(w: u32, h: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": w, "height": h, "background": "white"})).unwrap(); + s +} + +fn note(s: &Session, i: usize) -> photocraft_doc::Note { + s.active().unwrap().doc.notes[i].clone() +} + +#[test] +fn a_new_note_has_the_default_colour_and_popup() { + let mut s = session(600, 400); + s.execute("notes.add", json!({"x": 100, "y": 100})).unwrap(); + let n = note(&s, 0); + let rgb = n.color.to_rgb(); + assert_eq!(rgb.map(|v| (v * 255.0).round() as i32), [255, 255, 131], "{rgb:?}"); + assert_eq!(DEFAULT_NOTE_COLOR.map(|v| (v * 65535.0).round() as u32), [0xffff, 0xffff, 0x8383], "the 16-bit components Photoshop saves"); + assert_eq!(n.popup, [108.0, 110.0, 348.0, 250.0], "icon + (8, 10), 240 × 140"); + // A note on the pasteboard keeps its popup there too. + s.execute("notes.add", json!({"x": -178, "y": 300})).unwrap(); + assert_eq!(note(&s, 1).popup, [-170.0, 310.0, 70.0, 450.0]); + // An explicit colour still wins. + s.execute("notes.add", json!({"x": 10, "y": 10, "color": [0.25, 0.5, 1.0]})).unwrap(); + assert_eq!(note(&s, 2).color.to_rgb(), [0.25, 0.5, 1.0]); +} + +#[test] +fn the_date_is_local_time_with_its_offset() { + let at = |offset: i32| FixedOffset::east_opt(offset).unwrap().with_ymd_and_hms(2026, 11, 6, 20, 14, 55).unwrap(); + assert_eq!(pdf_date_at(&at(3600)), "D:20261106201455+01'00'"); + assert_eq!(pdf_date_at(&at(0)), "D:20261106201455+00'00'"); + assert_eq!(pdf_date_at(&at(-(5 * 3600 + 30 * 60))), "D:20261106201455-05'30'"); + // A new note's own date has that shape. + let mut s = session(100, 100); + s.execute("notes.add", json!({"x": 10, "y": 10})).unwrap(); + let d = note(&s, 0).modified; + let b = d.as_bytes(); + assert!(d.len() == 23 && d.starts_with("D:") && matches!(b[16], b'+' | b'-') && b[19] == b'\'' && b[22] == b'\'', "{d}"); + assert!(d[2..16].chars().all(|c| c.is_ascii_digit()), "{d}"); +} + +#[test] +fn image_size_moves_the_icon_and_keeps_the_popup_size() { + let mut s = session(600, 400); + s.execute("notes.add", json!({"x": 100, "y": 99})).unwrap(); + let before = note(&s, 0); + s.execute("image.imageSize", json!({"width": 300, "height": 200})).unwrap(); + let n = note(&s, 0); + assert_eq!(n.position, [50.0, 49.5], "the icon point scales as today"); + let size = |p: [f64; 4]| [p[2] - p[0], p[3] - p[1]]; + assert_eq!(size(n.popup), size(before.popup), "240 × 140 still"); + assert_eq!([n.popup[0] - n.position[0], n.popup[1] - n.position[1]], [8.0, 10.0], "still at icon + (8, 10)"); +} + +#[test] +fn canvas_size_and_a_quarter_turn_keep_the_popup_size() { + let mut s = session(600, 400); + s.execute("notes.add", json!({"x": 100, "y": 100})).unwrap(); + s.execute("image.canvasSize", json!({"width": 400, "height": 300, "anchor": "center"})).unwrap(); + let n = note(&s, 0); + assert_eq!(n.position, [0.0, 50.0], "moved by the canvas offset"); + assert_eq!(n.popup, [8.0, 60.0, 248.0, 200.0]); + s.execute("image.imageRotation.90cw", json!({})).unwrap(); + let r = note(&s, 0); + assert_eq!([r.popup[2] - r.popup[0], r.popup[3] - r.popup[1]], [240.0, 140.0], "a turn doesn't swap the popup's sides"); + assert_eq!([r.popup[0] - r.position[0], r.popup[1] - r.position[1]], [8.0, 10.0], "the popup moves with its icon"); +} + +#[test] +fn a_mode_change_leaves_notes_alone() { + let mut s = session(100, 100); + s.execute("notes.add", json!({"x": 10, "y": 20})).unwrap(); + let before = note(&s, 0); + s.execute("image.mode.cmyk", json!({})).unwrap(); + assert_eq!(note(&s, 0), before); +} diff --git a/crates/ui-egui/src/tools/analysis_ui.rs b/crates/ui-egui/src/tools/analysis_ui.rs index ed113909a6..a0c1feecde 100644 --- a/crates/ui-egui/src/tools/analysis_ui.rs +++ b/crates/ui-egui/src/tools/analysis_ui.rs @@ -64,7 +64,7 @@ impl Default for AnalysisUi { log_selected: Vec::new(), note_selected: None, note_author: String::new(), - note_color: [1.0, 1.0, 0.51], + note_color: photocraft_engine::notes_cmds::DEFAULT_NOTE_COLOR, use_measurement_scale: false, dialog: None, drag: None, From 3ad1695d3e14a62f9380221fbad8e95925de02e9 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 8 Nov 2026 00:01:49 +0100 Subject: [PATCH 132/219] Background Eraser: keep the Tolerance text while it is typed (#11909) While the field has the keyboard it gets back the value typed so far instead of the clamped one, so a leading 0 on the way to 05 no longer turns into 1 (and 15). The stored value is still clamped at once. Co-Authored-By: Claude Opus 5.5 --- ...background_eraser_tolerance_field_tests.rs | 19 +++++++++++++++++++ crates/ui-egui/src/tools/eraser_ui.rs | 13 ++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/background_eraser_tolerance_field_tests.rs b/crates/ui-egui/src/background_eraser_tolerance_field_tests.rs index a976444c5d..84160eea96 100644 --- a/crates/ui-egui/src/background_eraser_tolerance_field_tests.rs +++ b/crates/ui-egui/src/background_eraser_tolerance_field_tests.rs @@ -93,6 +93,25 @@ fn tolerance_in_range_stores_a_whole_number_without_an_alert() { assert_eq!(alert(&h), None); } +#[test] +fn a_leading_zero_typed_key_by_key_keeps_the_text() { + let mut h = app_harness(); + h.query_all_by_role(egui::accesskit::Role::SpinButton) + .find(|n| n.accesskit_node().label().as_deref() == Some("Tolerance:")) + .expect("the Tolerance field") + .click(); + h.run_steps(1); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + for c in ["0", "5"] { + h.event(egui::Event::Text(c.into())); + h.run_steps(2); + } + h.key_press(egui::Key::Tab); + h.run_steps(3); + assert_eq!(tolerance(&h), 5.0, "0 then 5 is 05, not 15"); + assert_eq!(alert(&h), None, "05 is in range"); +} + #[test] fn an_expression_committed_with_enter_past_the_range_alerts() { let mut h = app_harness(); diff --git a/crates/ui-egui/src/tools/eraser_ui.rs b/crates/ui-egui/src/tools/eraser_ui.rs index 36451da695..1310057628 100644 --- a/crates/ui-egui/src/tools/eraser_ui.rs +++ b/crates/ui-egui/src/tools/eraser_ui.rs @@ -151,8 +151,11 @@ pub const TOLERANCE_RANGE_ALERT: &str = "An integer between 1% and 100% is requi fn tolerance_field(ui: &mut egui::Ui, value: &mut f32) -> bool { // A restored setting (an older 0, a fraction) shows and erases as the field would store it. *value = stored_tolerance(*value); + // While the field has the keyboard it gets back what was typed, not the stored value, so + // its text is left alone mid-entry (`0` on the way to `05` must not turn into `1`). + let draft_id = ui.make_persistent_id(egui::IdSalt::new("bg-eraser-tolerance-typed")); // The field's own range is wide, so the typed value reaches here unclamped. - let mut typed = *value; + let mut typed = ui.ctx().data(|d| d.get_temp::(draft_id)).unwrap_or(*value); let r = widgets::value_field(ui, &mut typed, -1.0e9..=1.0e9, "%", 62.0).on_hover_text(tl!("Erase range of similar colors")); let out_id = r.id.with("out-of-range"); // An edit the field never got to finish (the options bar was hidden while it had the @@ -168,6 +171,14 @@ fn tolerance_field(ui: &mut egui::Ui, value: &mut f32) -> bool { let typing = (r.has_focus() || r.lost_focus()) && ui.ctx().dragged_id().is_none(); let out = typing && !(1.0..=100.0).contains(&typed); ui.ctx().data_mut(|d| d.insert_temp(out_id, out)); + if typing && r.has_focus() { + ui.ctx().data_mut(|d| d.insert_temp(draft_id, typed)); + } else { + ui.ctx().data_mut(|d| d.remove_temp::(draft_id)); + } + } + if !r.has_focus() { + ui.ctx().data_mut(|d| d.remove_temp::(draft_id)); } r.lost_focus() && ui.ctx().data_mut(|d| d.remove_temp::(out_id)).unwrap_or(false) } From e6992a855d41cfda79483b5656a888213385ca8d Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 8 Nov 2026 00:17:09 +0100 Subject: [PATCH 133/219] Background Eraser: a Tolerance set elsewhere wins over an interrupted edit (#11909) The typed-text draft remembers the setting it was stored with and is only used while that setting is unchanged, so a value set meanwhile (a tool preset while the options bar was hidden) shows from the first frame. Co-Authored-By: Claude Opus 5.5 --- ...background_eraser_tolerance_field_tests.rs | 29 +++++++++++++++++++ crates/ui-egui/src/tools/eraser_ui.rs | 11 ++++--- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/background_eraser_tolerance_field_tests.rs b/crates/ui-egui/src/background_eraser_tolerance_field_tests.rs index 84160eea96..acf9d3de15 100644 --- a/crates/ui-egui/src/background_eraser_tolerance_field_tests.rs +++ b/crates/ui-egui/src/background_eraser_tolerance_field_tests.rs @@ -112,6 +112,35 @@ fn a_leading_zero_typed_key_by_key_keeps_the_text() { assert_eq!(alert(&h), None, "05 is in range"); } +#[test] +fn a_tolerance_set_elsewhere_during_an_edit_wins_over_the_typed_text() { + let mut h = app_harness(); + h.query_all_by_role(egui::accesskit::Role::SpinButton) + .find(|n| n.accesskit_node().label().as_deref() == Some("Tolerance:")) + .expect("the Tolerance field") + .click(); + h.run_steps(1); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text("150".into())); + h.run_steps(1); + // The bar goes away mid-edit and a preset sets the Tolerance meanwhile. + h.state_mut().ui.panels.options_bar = false; + h.run_steps(2); + h.state_mut().ui.tool_options.bg_tolerance = 30.0; + h.state_mut().ui.panels.options_bar = true; + h.run_steps(1); + // From the first frame back, the field shows the preset's value. + let shown = h + .query_all_by_role(egui::accesskit::Role::SpinButton) + .find(|n| n.accesskit_node().label().as_deref() == Some("Tolerance:")) + .expect("the Tolerance field") + .accesskit_node() + .numeric_value(); + assert_eq!(shown, Some(30.0), "the field shows the preset's value, not the typed 150"); + h.run_steps(2); + assert_eq!(tolerance(&h), 30.0, "the preset's value, not the typed 150"); +} + #[test] fn an_expression_committed_with_enter_past_the_range_alerts() { let mut h = app_harness(); diff --git a/crates/ui-egui/src/tools/eraser_ui.rs b/crates/ui-egui/src/tools/eraser_ui.rs index 1310057628..3c5b997cb3 100644 --- a/crates/ui-egui/src/tools/eraser_ui.rs +++ b/crates/ui-egui/src/tools/eraser_ui.rs @@ -155,7 +155,10 @@ fn tolerance_field(ui: &mut egui::Ui, value: &mut f32) -> bool { // its text is left alone mid-entry (`0` on the way to `05` must not turn into `1`). let draft_id = ui.make_persistent_id(egui::IdSalt::new("bg-eraser-tolerance-typed")); // The field's own range is wide, so the typed value reaches here unclamped. - let mut typed = ui.ctx().data(|d| d.get_temp::(draft_id)).unwrap_or(*value); + // A draft only stands while the setting is still the one it stored: a value set elsewhere + // meanwhile (a tool preset while the bar was hidden) wins. + let draft = ui.ctx().data(|d| d.get_temp::<(f32, f32)>(draft_id)); + let mut typed = draft.filter(|(_, stored)| *stored == *value).map_or(*value, |(typed, _)| typed); let r = widgets::value_field(ui, &mut typed, -1.0e9..=1.0e9, "%", 62.0).on_hover_text(tl!("Erase range of similar colors")); let out_id = r.id.with("out-of-range"); // An edit the field never got to finish (the options bar was hidden while it had the @@ -172,13 +175,13 @@ fn tolerance_field(ui: &mut egui::Ui, value: &mut f32) -> bool { let out = typing && !(1.0..=100.0).contains(&typed); ui.ctx().data_mut(|d| d.insert_temp(out_id, out)); if typing && r.has_focus() { - ui.ctx().data_mut(|d| d.insert_temp(draft_id, typed)); + ui.ctx().data_mut(|d| d.insert_temp(draft_id, (typed, *value))); } else { - ui.ctx().data_mut(|d| d.remove_temp::(draft_id)); + ui.ctx().data_mut(|d| d.remove_temp::<(f32, f32)>(draft_id)); } } if !r.has_focus() { - ui.ctx().data_mut(|d| d.remove_temp::(draft_id)); + ui.ctx().data_mut(|d| d.remove_temp::<(f32, f32)>(draft_id)); } r.lost_focus() && ui.ctx().data_mut(|d| d.remove_temp::(out_id)).unwrap_or(false) } From 47e14b5da298a00e2eb2e5a3f62e0a343eec35aa Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 8 Nov 2026 00:28:37 +0100 Subject: [PATCH 134/219] Photo Filter keeps only Preserve Luminosity from its last OK (#9970) #9970 reopened Image > Adjustments > Photo Filter... at the filter, colour, density and Preserve Luminosity of its last OK. Measured in the reference application: after OK at 60, 70 and 74 %, with a preset or with Color and a custom colour, it reopens every time at Warming Filter (85), the orange swatch and 25 %; only Preserve Luminosity carries over (off after an OK with it off, on after an OK with it on). Opening now starts from the neutral settings and takes just that one value from the last OK; Alt still reopens at all the last settings (#8900). Tests and the ui-parity row follow the measured behaviour. Co-Authored-By: ljkx <177671075+ljkx@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/adjust_dialog.rs | 28 ++++++-- .../src/dialogs/adjust_dialog/tests.rs | 66 ++++++++++++------- docs/ui-parity.md | 6 +- 3 files changed, 68 insertions(+), 32 deletions(-) diff --git a/crates/ui-egui/src/dialogs/adjust_dialog.rs b/crates/ui-egui/src/dialogs/adjust_dialog.rs index fccaffbc24..f435bde1b4 100644 --- a/crates/ui-egui/src/dialogs/adjust_dialog.rs +++ b/crates/ui-egui/src/dialogs/adjust_dialog.rs @@ -284,15 +284,29 @@ pub fn reset(app: &PhotocraftApp, ctx: &egui::Context, fields: &mut Map Option { - if command.strip_prefix(PREFIX).is_some_and(|kind| REMEMBERS_LAST.contains(&kind)) { open_last(app, command) } else { open_neutral(app, command) } + let id = open_neutral(app, command)?; + let kind = command.strip_prefix(PREFIX).unwrap_or_default(); + let kept: Vec<(String, Value)> = match app.session.prefs().dialogs.get(command) { + Some(last) => KEEPS_LAST + .iter() + .filter(|(k, _)| *k == kind) + .filter_map(|(_, key)| last.get(*key).filter(|v| v.is_boolean()).map(|v| ((*key).to_owned(), v.clone()))) + .collect(), + None => Vec::new(), + }; + if let Some(d) = app.ui.dialog_mut(id) { + d.fields.extend(kept); + } + Some(id) } /// Opens the dialog for `command` with the kind's neutral settings. diff --git a/crates/ui-egui/src/dialogs/adjust_dialog/tests.rs b/crates/ui-egui/src/dialogs/adjust_dialog/tests.rs index a259987d42..c769f76c32 100644 --- a/crates/ui-egui/src/dialogs/adjust_dialog/tests.rs +++ b/crates/ui-egui/src/dialogs/adjust_dialog/tests.rs @@ -939,36 +939,50 @@ fn curves_on_a_targeted_filter_mask_is_a_gray_plane() { assert_eq!(curve_mode(&app, "curves"), photocraft_doc::ColorMode::Grayscale); } -/// #9879: Photo Filter opens at the filter and density last confirmed in it, without ⌥; Levels -/// still opens at its neutral settings, and a smart filter's dialog never takes the last ones. +/// #9879: the reference application's Photo Filter reopens at Warming Filter (85) and 25 % after +/// any OK, but keeps Preserve Luminosity as last confirmed (measured 2026-11-08); ⌥ reopens at all +/// the last settings; Levels still opens at its neutral settings, and a smart filter's dialog +/// never takes the last ones. #[test] -fn photo_filter_opens_at_its_last_settings() { +fn photo_filter_keeps_only_preserve_luminosity_from_its_last_ok() { let mut app = app_with_image(); let cmd = "image.adjustments.photoFilter"; let first = open_neutral(&mut app, cmd).unwrap(); let neutral = crate::filter_dialog::params_of(&app.ui.dialog_mut(first).unwrap().fields.clone()); app.ui.dialogs.clear(); let id = open(&mut app, cmd).unwrap(); - let sample = photocraft_engine::adjust_params::to_params( + let mut sample = photocraft_engine::adjust_params::to_params( &photocraft_engine::adjust_params::from_params("photoFilter", &sample_params("photoFilter"), None, photocraft_doc::ColorMode::Rgb).unwrap(), ); - assert_ne!(sample, neutral, "the sample differs from the neutral settings"); + // Preserve Luminosity is on by default; the sample turns it off with the other settings. + assert_eq!(neutral["preserveLuminosity"], json!(true)); + sample["preserveLuminosity"] = json!(false); + assert_ne!(sample["color"], neutral["color"], "the sample differs from the neutral settings"); + assert_ne!(sample["density"], neutral["density"]); if let Value::Object(p) = sample.clone() { app.ui.dialog_mut(id).unwrap().fields.extend(p); } crate::dialogs::confirm(&mut app, id).unwrap(); let again = open(&mut app, cmd).unwrap(); - assert_eq!(crate::filter_dialog::params_of(&app.ui.dialog_mut(again).unwrap().fields.clone()), sample, "reopened at the last settings"); - // Preserve Luminosity is on by default; turned off and confirmed, it reopens off. - assert_eq!(neutral["preserveLuminosity"], json!(true)); - app.ui.dialog_mut(again).unwrap().fields.insert("preserveLuminosity".into(), json!(false)); + let reopened = crate::filter_dialog::params_of(&app.ui.dialog_mut(again).unwrap().fields.clone()); + assert_eq!(reopened["preserveLuminosity"], json!(false), "Preserve Luminosity off is kept"); + assert_eq!(reopened["color"], neutral["color"], "the colour is the neutral one again"); + assert_eq!(reopened["density"], neutral["density"], "and so is the density"); + // Turned on again and confirmed, it reopens on. + app.ui.dialog_mut(again).unwrap().fields.insert("preserveLuminosity".into(), json!(true)); crate::dialogs::confirm(&mut app, again).unwrap(); let again = open(&mut app, cmd).unwrap(); - let reopened = crate::filter_dialog::params_of(&app.ui.dialog_mut(again).unwrap().fields.clone()); - assert_eq!(reopened["preserveLuminosity"], json!(false), "Preserve Luminosity off is remembered"); - assert_eq!(reopened["color"], sample["color"], "with the rest of the last settings"); - assert_eq!(reopened["density"], sample["density"]); + assert_eq!(crate::filter_dialog::params_of(&app.ui.dialog_mut(again).unwrap().fields.clone()), neutral, "all neutral again"); + app.ui.dialogs.clear(); + // ⌥ reopens at every last setting (#8900). + let id = open(&mut app, cmd).unwrap(); + if let Value::Object(p) = sample.clone() { + app.ui.dialog_mut(id).unwrap().fields.extend(p); + } + crate::dialogs::confirm(&mut app, id).unwrap(); + let alt = open_last(&mut app, cmd).unwrap(); + assert_eq!(crate::filter_dialog::params_of(&app.ui.dialog_mut(alt).unwrap().fields.clone()), sample, "⌥ reopens at the last settings"); app.ui.dialogs.clear(); let smart = open_neutral(&mut app, cmd).unwrap(); assert_eq!(crate::filter_dialog::params_of(&app.ui.dialog_mut(smart).unwrap().fields.clone()), neutral, "open_neutral ignores them"); @@ -984,26 +998,34 @@ fn photo_filter_opens_at_its_last_settings() { assert_eq!(app.ui.dialog_mut(again).unwrap().fields["inBlack"], defaults["inBlack"], "Levels opens neutral without ⌥"); } -/// #9879 review: a custom colour confirmed in Photo Filter reopens with Color chosen, even after an -/// opening that switched to Filter and was cancelled (the choice lives in the editor memory). +/// #9879: Photo Filter reopens with Filter chosen for its neutral preset colour, even after an OK +/// with Color chosen (the choice lives in the editor memory), as the reference application does; +/// ⌥ reopens a custom colour with Color chosen, also after an opening that chose Filter and was +/// cancelled. #[test] fn photo_filter_reopens_with_the_choice_its_colour_implies() { let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app_with_image()); PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); let cmd = "image.adjustments.photoFilter"; - let id = open(h.state_mut(), cmd).unwrap(); - h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("color".into(), json!("#123456")); - h.run_steps(2); - crate::dialogs::confirm(h.state_mut(), id).unwrap(); - // The next opening chooses Filter (what clicking its radio stores), then is cancelled. let mode = dialog_mem("photoFilter").with(crate::adjust_editors::PHOTO_FILTER_MODE); let id = open(h.state_mut(), cmd).unwrap(); h.run_steps(2); - h.ctx.data_mut(|d| d.insert_temp(mode, false)); - h.state_mut().ui.close_dialog(id); + let neutral = h.state_mut().ui.dialog_mut(id).unwrap().fields["color"].clone(); + // Color chosen (what clicking its radio stores), a custom colour, OK. + h.ctx.data_mut(|d| d.insert_temp(mode, true)); + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("color".into(), json!("#123456")); + h.run_steps(2); + crate::dialogs::confirm(h.state_mut(), id).unwrap(); h.run_steps(1); let id = open(h.state_mut(), cmd).unwrap(); h.run_steps(2); + assert_eq!(h.state_mut().ui.dialog_mut(id).unwrap().fields["color"], neutral, "the neutral colour again"); + assert_eq!(h.ctx.data(|d| d.get_temp::(mode)), Some(false), "Filter is chosen for the preset colour"); + // That opening is cancelled with Filter chosen; ⌥ brings the custom colour back with Color. + h.state_mut().ui.close_dialog(id); + h.run_steps(1); + let id = open_last(h.state_mut(), cmd).unwrap(); + h.run_steps(2); assert_eq!(h.state_mut().ui.dialog_mut(id).unwrap().fields["color"], json!("#123456")); assert_eq!(h.ctx.data(|d| d.get_temp::(mode)), Some(true), "Color is chosen for the custom colour"); } diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e69b1f6b76..8840897f38 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity: tools, handles, modifiers and feel -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (Image › Adjustments › Photo Filter… opens at the filter and density of its last OK, #9879; foreground/background swatches and exact CMYK picks restored across launches, #8485; Layer ▸ Smart Objects ▸ Reset Transform and the Layers panel row-menu item for a smart object layer: 100 % again in place with the Warp cleared and centred on the corners' average, #11662; each toolbar slot and tool key keeps its last tool across launches, #3569; Image › Adjustments › Selective Color keeps each colour's own values and applies all nine, #11463; History Options snapshot options, #3634; Channels panel menu: New Channel…, Duplicate Channel, Delete Channel, New Spot Channel…, Merge Spot Channel, Channel Options…, Split Channels, Merge Channels…, #11451; a Color Picker for another dialog's colour or the Crop shield shows the picked colour there before OK, #9886; Edit › Transform › Distort side handles move their edge, Perspective side handles skew, #10085; Ctrl + Alt + wheel zooms the canvas like ⌥ + wheel on Windows and Linux, where winit reports a held Ctrl as both ctrl and command, #10488; Layers panel filter by Color, #5898; 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; Shape Fill/Stroke popover as Photoshop's, every colour swatch opens the full Color Picker with a live preview; the text and gradient stop Color Pickers show the picked colour on the canvas before OK, #5530; selected colour sets edit drawing, tone and supported filters; geometry remains open; a paragraph box that can't hold its text enlarges its lower-right handle and shows a black + in it, as the reference application; Free Transform of several selected layers, #6501; Path Operations fixes: the first component keeps combining, a hidden component selection is left alone, a dragged live shape stays live, a refused Pen draft ends once; Pen, Freeform Pen, Curvature Pen and Path Selection Path Operations menu, which changes selected components, `+` / `-` keys, #1397; the Pen carries on an open shape or vector mask path; selection-aware font controls, pending caret fonts and focused commit, plus direct font-name editing with a compact no-match message, #9338; New Group dialog and button modifiers; new layers go into an open active group and join a clipping mask, except that type, shapes, pastes and placed files go above a group whose lock would refuse them; Color panel Hue and Brightness Cube show the out-of-gamut ⚠ and nearest printable colour when the field is at least 53 pt tall, #8672; Art History Brush in the History group of the Y flyout, partial, TOOL-213-20, #1610; Histogram panel: Compact / Expanded / All Channels views, Luminosity and Colors channels, Source, range statistics, Uncached Refresh, live adjustment overlay, Cache Levels preference; the font menu opens on the current family, marked in the recent group when it is a recent one, #9160; Type options bar alignment and direction follow the target paragraph; a swatch click recolours selected type and shape layers with any tool, #9238; UI scale presets done: 105–120% steps, #3222; ⇧⌥ blend mode letters; painting tool Mode menus; ⌥ + an adjustment's shortcut reopens it at its last settings, ⌥⌘L / ⌥⌘M, #8900; Crop default mode: moving the image under the box snaps the frame's edges and centre, #8580; View › Extras hides the Move transform controls, #6699; adjustment clipping stays visible in compact Properties, #5231; Color panel modes from its panel menu, the same in every theme, UI-217-17, #6000; a picked colour recolours the selected type and shapes; Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests; Properties › Transform shows a shape's path geometry, without its stroke) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** minor (Image › Adjustments › Photo Filter… keeps Preserve Luminosity from its last OK and opens at Warming Filter (85), 25 %, as the reference application does, #9879; foreground/background swatches and exact CMYK picks restored across launches, #8485; Layer ▸ Smart Objects ▸ Reset Transform and the Layers panel row-menu item for a smart object layer: 100 % again in place with the Warp cleared and centred on the corners' average, #11662; each toolbar slot and tool key keeps its last tool across launches, #3569; Image › Adjustments › Selective Color keeps each colour's own values and applies all nine, #11463; History Options snapshot options, #3634; Channels panel menu: New Channel…, Duplicate Channel, Delete Channel, New Spot Channel…, Merge Spot Channel, Channel Options…, Split Channels, Merge Channels…, #11451; a Color Picker for another dialog's colour or the Crop shield shows the picked colour there before OK, #9886; Edit › Transform › Distort side handles move their edge, Perspective side handles skew, #10085; Ctrl + Alt + wheel zooms the canvas like ⌥ + wheel on Windows and Linux, where winit reports a held Ctrl as both ctrl and command, #10488; Layers panel filter by Color, #5898; 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; Shape Fill/Stroke popover as Photoshop's, every colour swatch opens the full Color Picker with a live preview; the text and gradient stop Color Pickers show the picked colour on the canvas before OK, #5530; selected colour sets edit drawing, tone and supported filters; geometry remains open; a paragraph box that can't hold its text enlarges its lower-right handle and shows a black + in it, as the reference application; Free Transform of several selected layers, #6501; Path Operations fixes: the first component keeps combining, a hidden component selection is left alone, a dragged live shape stays live, a refused Pen draft ends once; Pen, Freeform Pen, Curvature Pen and Path Selection Path Operations menu, which changes selected components, `+` / `-` keys, #1397; the Pen carries on an open shape or vector mask path; selection-aware font controls, pending caret fonts and focused commit, plus direct font-name editing with a compact no-match message, #9338; New Group dialog and button modifiers; new layers go into an open active group and join a clipping mask, except that type, shapes, pastes and placed files go above a group whose lock would refuse them; Color panel Hue and Brightness Cube show the out-of-gamut ⚠ and nearest printable colour when the field is at least 53 pt tall, #8672; Art History Brush in the History group of the Y flyout, partial, TOOL-213-20, #1610; Histogram panel: Compact / Expanded / All Channels views, Luminosity and Colors channels, Source, range statistics, Uncached Refresh, live adjustment overlay, Cache Levels preference; the font menu opens on the current family, marked in the recent group when it is a recent one, #9160; Type options bar alignment and direction follow the target paragraph; a swatch click recolours selected type and shape layers with any tool, #9238; UI scale presets done: 105–120% steps, #3222; ⇧⌥ blend mode letters; painting tool Mode menus; ⌥ + an adjustment's shortcut reopens it at its last settings, ⌥⌘L / ⌥⌘M, #8900; Crop default mode: moving the image under the box snaps the frame's edges and centre, #8580; View › Extras hides the Move transform controls, #6699; adjustment clipping stays visible in compact Properties, #5231; Color panel modes from its panel menu, the same in every theme, UI-217-17, #6000; a picked colour recolours the selected type and shapes; Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests; Properties › Transform shows a shape's path geometry, without its stroke) · **Target:** Adobe Photoshop 2026 How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation, @@ -224,7 +224,7 @@ for partial sets; [scope, tests and visual evidence](multi-channel-editing.md). | Paragraph box overflow mark: text past the box shows a + in its lower-right handle | done (2026-10-29): text a box can't hold (dropped layout lines) enlarges the box's lower-right handle and shows a black + in it, as the reference application does; agents read `typeBoxOverflow` in `ui.inspect` (false once the edit session ends). Differs: shown while the inline edit session is on the box, and not while its transform handles are up | `text::TextLayout::overflows`, `canvas_tests.rs` `an_overflowing_box_shows_a_plus_in_its_enlarged_lower_right_handle` | | Image › Adjustments › Selective Color: each of the nine colours keeps its own Cyan, Magenta, Yellow and Black; choosing another colour shows its values; OK and Preview apply all nine | done (#11463): one editor shared with the Properties panel (`adjust_editors/selective_color.rs`, `adjust_dialog/tests.rs`, 2026-11-05). Preset menu and `.asv` files not yet | | | ⌥ + an adjustment's shortcut opens it at its last settings (⌥⌘L Levels, ⌥⌘M Curves, and so ⌥⌘U, ⌥⌘B) | done (#8900): the settings of the last OK, kept in the preferences; Cancel and smart filter edits keep nothing; a key assigned to another command stays that command's. ⌥ while choosing the item from Image › Adjustments does the same | `shortcut_dispatch/tests.rs` `alt_adjustment_shortcut_reopens_the_last_confirmed_settings`, `alt_choosing_an_adjustment_from_the_menu_reopens_the_last_settings` | -| Photo Filter remembers its last settings: Image › Adjustments › Photo Filter… opens at the filter, colour, density and Preserve Luminosity of its last OK | done (#9879): the other adjustments still open at their neutral settings and take the last ones with ⌥; a smart filter's dialog shows that filter's own settings | `adjust_dialog/tests.rs` `photo_filter_opens_at_its_last_settings` | +| Photo Filter keeps Preserve Luminosity: Image › Adjustments › Photo Filter… opens at Warming Filter (85) and 25 % after any OK, with Preserve Luminosity as last confirmed | done (#9879): measured in the reference application 2026-11-08 (filter, colour and density are not kept); ⌥ reopens at all the last settings; a smart filter's dialog shows that filter's own settings | `adjust_dialog/tests.rs` `photo_filter_keeps_only_preserve_luminosity_from_its_last_ok`, `photo_filter_reopens_with_the_choice_its_colour_implies` | | Guides: click selects, Delete deletes, right-click menu, ⌥-click turns a guide, double-click opens Guides, Grid & Slices | done (2026-10-12) | a guide let go over either ruler is removed, not only off the canvas (#6286) | | Blend mode keys: ⇧⌥ + letter picks a mode (N Normal, I Dissolve, R Clear, P Pass Through, K M B A darken, G S D W lighten, O F H V J Z L contrast, E X difference, U T C Y component), ⇧+ / ⇧− step through them | done (2026-10-14): Brush, Pencil and Gradient take them as their own mode (Clear on Brush, Pencil and Paint Bucket only), any non-painting tool sets every selected layer in one undo step (Pass Through on groups only; the Background is skipped); the typed letter wins, the physical key is the fallback for ⌥ characters on a Mac. ⇧⌥ letters no longer switch tools. While a Free Transform box is open they, and the number keys, still reach the tool (or the layers) without closing it; Puppet Warp and Perspective Warp keep them to themselves. Clone Stamp, Pattern Stamp, History Brush, Healing (its own short list) and Paint Bucket (with Clear) take them as their own mode; tools without a blend mode (the Erasers, Mixer Brush) set the layers. Differs: Behind (⇧⌥Q) is not implemented; Spot Healing, Color Replacement, Blur, Sharpen and Smudge ignore the keys until their options bar has a Mode, and Dodge, Burn and Sponge ignore them until their Range and mode keys (⇧⌥S/M/H, ⇧⌥D/S) exist | `blend_keys.rs` | @@ -273,13 +273,13 @@ Paragraph; the rest open as in-app floating windows. | Date | Change | Summary | |---|---|---| +| 2026-11-08 | minor | Image › Adjustments › Photo Filter… keeps Preserve Luminosity from its last OK; filter, colour and density reopen at Warming Filter (85) and 25 %, measured in the reference application (#9879); no percentage changed | | 2026-11-07 | minor | Foreground/background colours restored before the first frame (`Preferences.toolColors`, #8485), with separate pixel/mask pairs, HDR/alpha, exact CMYK ink picks and a final normal-exit flush; malformed values are ignored one by one, and first saves from older preferences merge against the default swatches so separate edits in two instances survive. No percentage changed | | 2026-11-07 | minor | Reset Transform on a smart object also clears the **Warp**, and centres on the average of the placement's four corners rather than the point its box centre maps to: measured in the reference application 2026-11-06 (a smart object warped with Arc 50, rotated 20° and scaled to 80 % came back with no warp at all, at its contents' own size, unrotated), which corrects the row below (#11662, discussion in #11675). The corner average is the box centre for an affine placement, so only Distort and Perspective placements move; the Smart Filter mask and linked masks keep following the placement, as under Free Transform. The step also requires its re-render: a visible filter PhotoCraft doesn't implement, or contents that can't be read, refuses it whole (no history state, placement and warp untouched) rather than recording the old warped pixels at the reset placement. No percentage changed | | 2026-11-06 | minor | Layer ▸ Smart Objects ▸ Reset Transform, and the same item in the Layers panel row menu for a smart object layer (#11662): 100 % of the current contents' own size again with rotation, skew, Distort and Perspective cleared and the object left centred where it is, in one undo step; the Warp and the smart filters are untouched, as Photoshop resets the transform and not the warp. No percentage changed | | 2026-11-06 | minor | Channels panel menu (☰): the channel items, not only the rows' context menu; New Channel…, New Spot Channel…, Channel Options… and Merge Channels… open their dialogs (New Channel and Merge Channels are new), and Channel Options… in Quick Mask mode opens a targeted alpha channel's options (#11451). No percentage changed | | 2026-11-05 | minor | Image › Adjustments › Selective Color keeps each colour's own Cyan, Magenta, Yellow and Black, and OK and Preview apply all nine colours; the dialog and the Properties panel share one editor (#11463) | | 2026-11-02 | minor | Edit › Transform › Distort: the side handles move their edge freely, both its corners by the drag, stopping before the box folds as a corner does; Perspective's side handles skew along their edge, as in Photoshop (#10085). Not a new command, so no percentage changed | -| 2026-11-02 | minor | Image › Adjustments › Photo Filter… opens at the filter and density of its last OK, without ⌥, as in Photoshop (#9879); no percentage changed | | 2026-11-01 | minor | Every preference takes effect: 11 of 183 do nothing (was 12); Graphics Adapter always worked, but startup read it by raw JSON key, which the scorecard audit can't see | | 2026-10-31 | minor | Layers panel filter-type dropdown: Color filters by the layers' label colour (#5898); Kind, Name, Mode, Color, Smart Object and Artboard, the other three missing; no percentage changed | | 2026-10-29 | minor | Paragraph box overflow: text a box can't hold enlarges the box's lower-right handle and shows a black + in it, as the reference application does (#8311); agents read `typeBoxOverflow` in `ui.inspect`. Not a new command, so no percentage changed | From de9e11df9b345391f712d61d2ed7eed15d99f354 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 7 Nov 2026 11:33:24 -0600 Subject: [PATCH 135/219] Toolbar: Star Tool in the shape slot between Polygon and Line (#11377) --- README.md | 4 +- crates/engine/src/shell_commands.rs | 1 + crates/ui-egui/src/canvas/snap_ui.rs | 6 +- crates/ui-egui/src/icons.rs | 1 + crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/panels/toolbar.rs | 2 +- crates/ui-egui/src/star_tool_tests.rs | 103 +++++++++++++++++++ crates/ui-egui/src/state.rs | 2 +- crates/ui-egui/src/state/tool.rs | 8 +- crates/ui-egui/src/tools/corner_radius.rs | 2 +- crates/ui-egui/src/tools/shape_bounds_box.rs | 2 +- crates/ui-egui/src/tools/tool_reset.rs | 2 +- crates/ui-egui/src/tools/vector_ui/shapes.rs | 4 +- 13 files changed, 129 insertions(+), 11 deletions(-) create mode 100644 crates/ui-egui/src/star_tool_tests.rs diff --git a/README.md b/README.md index b1b453a318..c1789ab7d2 100644 --- a/README.md +++ b/README.md @@ -185,8 +185,8 @@ Every screenshot here is the real app at work on public-domain art, rendered off
-

🧰 68 tools

- Move · Artboard · Rectangular and Elliptical Marquee · Single Row Marquee · Single Column Marquee · Lasso · Polygonal Lasso · Magnetic Lasso · Object Selection · Quick Selection · Magic Wand · Crop · Perspective Crop · Slice · Slice Select · Frame · Eyedropper · Color Sampler · Ruler · Note · Count · Remove · Spot Healing Brush · Healing Brush · Patch · Content-Aware Move · Red Eye · Brush · Pencil · Mixer Brush · Color Replacement · Clone Stamp · Pattern Stamp · History Brush · Art History Brush · Eraser · Background Eraser · Magic Eraser · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Freeform Pen · Curvature Pen · Add Anchor Point · Delete Anchor Point · Convert Point · Horizontal and Vertical Type · Horizontal and Vertical Type Mask · Path Selection · Direct Selection · Rectangle · Ellipse · Triangle · Polygon · Line · Custom Shape · Hand · Rotate View · Zoom +

🧰 69 tools

+ Move · Artboard · Rectangular and Elliptical Marquee · Single Row Marquee · Single Column Marquee · Lasso · Polygonal Lasso · Magnetic Lasso · Object Selection · Quick Selection · Magic Wand · Crop · Perspective Crop · Slice · Slice Select · Frame · Eyedropper · Color Sampler · Ruler · Note · Count · Remove · Spot Healing Brush · Healing Brush · Patch · Content-Aware Move · Red Eye · Brush · Pencil · Mixer Brush · Color Replacement · Clone Stamp · Pattern Stamp · History Brush · Art History Brush · Eraser · Background Eraser · Magic Eraser · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Freeform Pen · Curvature Pen · Add Anchor Point · Delete Anchor Point · Convert Point · Horizontal and Vertical Type · Horizontal and Vertical Type Mask · Path Selection · Direct Selection · Rectangle · Ellipse · Triangle · Polygon · Star · Line · Custom Shape · Hand · Rotate View · Zoom

🖌️ A real brush engine

diff --git a/crates/engine/src/shell_commands.rs b/crates/engine/src/shell_commands.rs index 49db9509e5..7aaf58ffcd 100644 --- a/crates/engine/src/shell_commands.rs +++ b/crates/engine/src/shell_commands.rs @@ -169,6 +169,7 @@ pub const TOOLS: &[(&str, &str, Option<&str>)] = &[ ("tools.select.ellipseShape", "Ellipse Tool", Some("U")), ("tools.select.triangle", "Triangle Tool", Some("U")), ("tools.select.polygon", "Polygon Tool", Some("U")), + ("tools.select.star", "Star Tool", Some("U")), ("tools.select.line", "Line Tool", Some("U")), ("tools.select.customShape", "Custom Shape Tool", Some("U")), ("tools.select.slice", "Slice Tool", Some("C")), diff --git a/crates/ui-egui/src/canvas/snap_ui.rs b/crates/ui-egui/src/canvas/snap_ui.rs index 989af47e1c..f1a8917b63 100644 --- a/crates/ui-egui/src/canvas/snap_ui.rs +++ b/crates/ui-egui/src/canvas/snap_ui.rs @@ -146,6 +146,7 @@ fn is_point_tool(t: Tool) -> bool { | Tool::EllipseShape | Tool::Triangle | Tool::Polygon + | Tool::Star | Tool::Line | Tool::Type | Tool::VerticalType @@ -334,7 +335,10 @@ pub(crate) fn snap_click(app: &PhotocraftApp, p: [f64; 2], mods: egui::Modifiers /// 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::CurvaturePen | Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::Line) + && matches!( + app.ui.tool, + Tool::Pen | Tool::CurvaturePen | Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::Star | Tool::Line + ) { [p[0].round(), p[1].round()] } else { diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index c102064a7f..0a40f7b034 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -179,6 +179,7 @@ pub fn tool_icon(t: Tool) -> &'static str { Tool::EllipseShape => "circle", Tool::Triangle => "triangle", Tool::Polygon => "pentagon", + Tool::Star => "star", Tool::Line => "slash", Tool::CustomShape => "cloud", } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 7d5d2a90e8..2388c9d29d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -865,6 +865,9 @@ mod transform_reference_point_tests; #[cfg(test)] mod transform_relative_position_tests; +#[cfg(test)] +mod star_tool_tests; + #[cfg(test)] mod transform_several_tests; diff --git a/crates/ui-egui/src/panels/toolbar.rs b/crates/ui-egui/src/panels/toolbar.rs index 12f85de2c1..da8500f535 100644 --- a/crates/ui-egui/src/panels/toolbar.rs +++ b/crates/ui-egui/src/panels/toolbar.rs @@ -30,7 +30,7 @@ pub(crate) const TOOL_SECTIONS: &[&[&[Tool]]] = &[ &[Tool::Pen, Tool::FreeformPen, Tool::CurvaturePen, Tool::AddAnchorPoint, Tool::DeleteAnchorPoint, Tool::ConvertPoint], &[Tool::Type, Tool::VerticalType, Tool::VerticalTypeMask, Tool::HorizontalTypeMask], &[Tool::PathSelection, Tool::DirectSelection], - &[Tool::Rectangle, Tool::EllipseShape, Tool::Triangle, Tool::Polygon, Tool::Line, Tool::CustomShape], + &[Tool::Rectangle, Tool::EllipseShape, Tool::Triangle, Tool::Polygon, Tool::Star, Tool::Line, Tool::CustomShape], ], &[&[Tool::Hand, Tool::RotateView], &[Tool::Zoom]], ]; diff --git a/crates/ui-egui/src/star_tool_tests.rs b/crates/ui-egui/src/star_tool_tests.rs new file mode 100644 index 0000000000..e79fa9f4c3 --- /dev/null +++ b/crates/ui-egui/src/star_tool_tests.rs @@ -0,0 +1,103 @@ +//! The Star Tool (#11377): in the shape slot between Polygon and Line, on U, and a drag makes a +//! live star shape layer with PhotoCraft's star defaults (5 points, star ratio 0.5). + +use egui::{Key, Modifiers}; +use photocraft_doc::{LayerContent, LiveShape}; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::canvas::{ToolEvent, tool_event}; +use crate::panels::toolbar::TOOL_SECTIONS; +use crate::state::Tool; + +const SHAPES: [Tool; 7] = [Tool::Rectangle, Tool::EllipseShape, Tool::Triangle, Tool::Polygon, Tool::Star, Tool::Line, Tool::CustomShape]; + +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.sync_views(); + app +} + +fn press(app: &mut PhotocraftApp, key: Key, modifiers: Modifiers) { + let ctx = egui::Context::default(); + let raw = egui::RawInput { + events: vec![egui::Event::ModifiersChanged(modifiers), egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }], + ..Default::default() + }; + ctx.begin_pass(raw); + crate::shortcuts::handle(app, &ctx); + ctx.end_pass().textures_delta.clear(); +} + +fn drag(app: &mut PhotocraftApp, from: [f64; 2], to: [f64; 2], mods: Modifiers) { + tool_event(app, ToolEvent::Down { x: from[0], y: from[1], pressure: 1.0 }, mods); + tool_event(app, ToolEvent::Move { x: (from[0] + to[0]) / 2.0, y: (from[1] + to[1]) / 2.0, pressure: 1.0 }, mods); + tool_event(app, ToolEvent::Move { x: to[0], y: to[1], pressure: 1.0 }, mods); + tool_event(app, ToolEvent::Up { x: to[0], y: to[1] }, mods); +} + +fn live_shape(app: &PhotocraftApp) -> Option { + let st = app.session.active()?; + match &st.doc.layer(st.active_layer?)?.content { + LayerContent::Shape(s) => s.live.clone(), + _ => None, + } +} + +#[test] +fn star_sits_in_the_shape_slot_after_polygon() { + let slot = TOOL_SECTIONS.iter().flat_map(|s| s.iter()).find(|slot| slot.contains(&Tool::Star)).copied(); + assert_eq!(slot, Some(&SHAPES[..])); + let pos = |t: Tool| Tool::ALL.iter().position(|x| *x == t); + assert_eq!(pos(Tool::Star), pos(Tool::Polygon).map(|p| p + 1), "Tool::ALL keeps the slot order"); +} + +#[test] +fn star_is_a_shape_tool_on_u_with_its_label_icon_and_name() { + assert!(crate::tools::vector_ui::is_shape_tool(Tool::Star)); + assert_eq!(Tool::Star.key(), 'U'); + assert_eq!(Tool::Star.label(), "Star Tool"); + assert_eq!(Tool::from_name("Star Tool"), Some(Tool::Star)); + assert_eq!(Tool::from_name("star"), Some(Tool::Star)); + assert_eq!(crate::icons::tool_icon(Tool::Star), "star"); +} + +#[test] +fn shift_u_steps_from_polygon_to_star_to_line() { + let mut app = app(); + app.ui.tool = Tool::Polygon; + press(&mut app, Key::U, Modifiers::SHIFT); + assert_eq!(app.ui.tool, Tool::Star); + press(&mut app, Key::U, Modifiers::SHIFT); + assert_eq!(app.ui.tool, Tool::Line); +} + +#[test] +fn a_drag_makes_a_live_star_shape_layer_with_one_undo_step() { + let mut app = app(); + app.ui.tool = Tool::Star; + let before = app.session.active().unwrap().history.past_len(); + drag(&mut app, [20.0, 30.0], [120.0, 90.0], Modifiers::NONE); + match live_shape(&app) { + Some(LiveShape::Polygon { rect, sides, star_ratio, .. }) => { + assert_eq!((sides, star_ratio), (5, 0.5)); + assert_eq!(rect, [20.0, 30.0, 100.0, 60.0]); + } + other => panic!("a live star shape, got {other:?}"), + } + assert_eq!(app.session.active().unwrap().history.past_len(), before + 1, "one history step"); + app.run("edit.undo", json!({})).unwrap(); + assert!(live_shape(&app).is_none(), "undo takes the star away"); +} + +#[test] +fn shift_and_alt_constrain_and_centre_the_star_like_the_polygon_tool() { + let mut app = app(); + app.ui.tool = Tool::Star; + drag(&mut app, [100.0, 100.0], [140.0, 120.0], Modifiers::SHIFT | Modifiers::ALT); + match live_shape(&app) { + Some(LiveShape::Polygon { rect, .. }) => assert_eq!(rect, [60.0, 60.0, 80.0, 80.0], "a square box drawn from the centre"), + other => panic!("a live star shape, got {other:?}"), + } +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 256a88bb38..5b79331819 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -769,7 +769,7 @@ mod tests { assert!(!Tool::RedEye.is_brushlike()); assert_eq!(Tool::from_name("patternStamp"), Some(Tool::PatternStamp)); assert_eq!(Tool::from_name("Pattern Stamp Tool"), Some(Tool::PatternStamp)); - assert_eq!(Tool::ALL.len(), 68); + assert_eq!(Tool::ALL.len(), 69); } fn vector_tool_names_parse() { diff --git a/crates/ui-egui/src/state/tool.rs b/crates/ui-egui/src/state/tool.rs index 8dda7ce231..66aa7d53f6 100644 --- a/crates/ui-egui/src/state/tool.rs +++ b/crates/ui-egui/src/state/tool.rs @@ -71,6 +71,8 @@ pub enum Tool { EllipseShape, Triangle, Polygon, + /// The shape flyout's Star Tool (#11377): a live polygon shape with a star ratio. + Star, Line, CustomShape, Slice, @@ -79,7 +81,7 @@ pub enum Tool { } impl Tool { - pub const ALL: [Tool; 68] = [ + pub const ALL: [Tool; 69] = [ Tool::Move, Tool::Artboard, Tool::RectMarquee, @@ -143,6 +145,7 @@ impl Tool { Tool::EllipseShape, Tool::Triangle, Tool::Polygon, + Tool::Star, Tool::Line, Tool::CustomShape, Tool::Slice, @@ -218,6 +221,7 @@ impl Tool { Tool::EllipseShape => "Ellipse Tool", Tool::Triangle => "Triangle Tool", Tool::Polygon => "Polygon Tool", + Tool::Star => "Star Tool", Tool::Line => "Line Tool", Tool::CustomShape => "Custom Shape Tool", } @@ -288,7 +292,7 @@ impl Tool { // In the Pen flyout, but P never reaches them. Tool::AddAnchorPoint | Tool::DeleteAnchorPoint | Tool::ConvertPoint => '', Tool::PathSelection | Tool::DirectSelection => 'A', - Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::Line | Tool::CustomShape => 'U', + Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::Star | Tool::Line | Tool::CustomShape => 'U', } } /// Glyph drawn in the toolbar (vector icons come later). diff --git a/crates/ui-egui/src/tools/corner_radius.rs b/crates/ui-egui/src/tools/corner_radius.rs index 6358ffd139..f5ec3192e9 100644 --- a/crates/ui-egui/src/tools/corner_radius.rs +++ b/crates/ui-egui/src/tools/corner_radius.rs @@ -150,7 +150,7 @@ pub struct Drag { fn shows(app: &PhotocraftApp) -> bool { app.ui.transform.is_none() && match app.ui.tool { - Tool::PathSelection | Tool::Rectangle | Tool::Triangle | Tool::Polygon => true, + Tool::PathSelection | Tool::Rectangle | Tool::Triangle | Tool::Polygon | Tool::Star => true, Tool::Move => app.ui.tool_options.move_show_transform, _ => false, } diff --git a/crates/ui-egui/src/tools/shape_bounds_box.rs b/crates/ui-egui/src/tools/shape_bounds_box.rs index ee137bc541..d56508bf96 100644 --- a/crates/ui-egui/src/tools/shape_bounds_box.rs +++ b/crates/ui-egui/src/tools/shape_bounds_box.rs @@ -62,7 +62,7 @@ pub struct Drag { fn shows(app: &PhotocraftApp) -> bool { app.ui.transform.is_none() && app.ui.view.shows(app.ui.view.show.target_path) - && matches!(app.ui.tool, Tool::PathSelection | Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::Line) + && matches!(app.ui.tool, Tool::PathSelection | Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::Star | Tool::Line) } /// The selected shape layers and the box round their paths. `None` unless at least one layer is diff --git a/crates/ui-egui/src/tools/tool_reset.rs b/crates/ui-egui/src/tools/tool_reset.rs index 5ae380a4be..6c19b9e42b 100644 --- a/crates/ui-egui/src/tools/tool_reset.rs +++ b/crates/ui-egui/src/tools/tool_reset.rs @@ -257,7 +257,7 @@ pub fn option_keys(tool: Tool) -> &'static [&'static str] { arrow_length, arrow_concavity, ), - Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::CustomShape => { + Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::Star | Tool::CustomShape => { reset!(shape_mode, shape_fill, stroke_width, shape_stroke, shape_fill_gradient, shape_stroke_gradient, corner_radius, polygon_sides, line_weight) } Tool::ColorSampler diff --git a/crates/ui-egui/src/tools/vector_ui/shapes.rs b/crates/ui-egui/src/tools/vector_ui/shapes.rs index 7b67544721..562e700bc3 100644 --- a/crates/ui-egui/src/tools/vector_ui/shapes.rs +++ b/crates/ui-egui/src/tools/vector_ui/shapes.rs @@ -3,7 +3,7 @@ use super::overlay::path_lines; use super::{Affine, Color32, PhotocraftApp, Tokens, Tool, ToolOptions, Value, ViewXform, json, paint_param}; pub fn is_shape_tool(t: Tool) -> bool { - matches!(t, Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::Line | Tool::CustomShape) + matches!(t, Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::Star | Tool::Line | Tool::CustomShape) } fn rgb32(c: [f32; 4]) -> Color32 { @@ -80,6 +80,8 @@ pub(super) fn shape_geometry(o: &ToolOptions, tool: Tool, start: [f64; 2], end: Tool::Rectangle | Tool::CustomShape => json!({"kind": "rect", "rect": rect}), Tool::EllipseShape => json!({"kind": "ellipse", "rect": rect}), Tool::Triangle => json!({"kind": "polygon", "rect": rect, "sides": 3}), + // PhotoCraft's star defaults; the Star Tool's own options are not measured yet (#11377). + Tool::Star => json!({"kind": "star", "rect": rect, "sides": 5, "starRatio": 0.5}), _ => json!({"kind": "polygon", "rect": rect, "sides": o.polygon_sides.max(3)}), } }) From fe13a8acc376189883d316ca369072145abefe62 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 7 Nov 2026 17:41:12 -0600 Subject: [PATCH 136/219] Star Tool test: the star layer keeps the tool's fill and stroke (maintainer suggestion) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/star_tool_tests.rs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/star_tool_tests.rs b/crates/ui-egui/src/star_tool_tests.rs index e79fa9f4c3..b83bb15d83 100644 --- a/crates/ui-egui/src/star_tool_tests.rs +++ b/crates/ui-egui/src/star_tool_tests.rs @@ -2,7 +2,7 @@ //! live star shape layer with PhotoCraft's star defaults (5 points, star ratio 0.5). use egui::{Key, Modifiers}; -use photocraft_doc::{LayerContent, LiveShape}; +use photocraft_doc::{Color, Fill, LayerContent, LiveShape, ShapeLayer}; use serde_json::json; use crate::PhotocraftApp; @@ -37,14 +37,18 @@ fn drag(app: &mut PhotocraftApp, from: [f64; 2], to: [f64; 2], mods: Modifiers) tool_event(app, ToolEvent::Up { x: to[0], y: to[1] }, mods); } -fn live_shape(app: &PhotocraftApp) -> Option { +fn shape_layer(app: &PhotocraftApp) -> Option { let st = app.session.active()?; match &st.doc.layer(st.active_layer?)?.content { - LayerContent::Shape(s) => s.live.clone(), + LayerContent::Shape(s) => Some(s.clone()), _ => None, } } +fn live_shape(app: &PhotocraftApp) -> Option { + shape_layer(app)?.live +} + #[test] fn star_sits_in_the_shape_slot_after_polygon() { let slot = TOOL_SECTIONS.iter().flat_map(|s| s.iter()).find(|slot| slot.contains(&Tool::Star)).copied(); @@ -77,6 +81,9 @@ fn shift_u_steps_from_polygon_to_star_to_line() { fn a_drag_makes_a_live_star_shape_layer_with_one_undo_step() { let mut app = app(); app.ui.tool = Tool::Star; + app.run("tools.setColors", json!({"foreground": "#ff0000", "background": "#0000ff"})).unwrap(); + app.ui.tool_options.shape_fill = true; + app.ui.tool_options.stroke_width = 5.0; let before = app.session.active().unwrap().history.past_len(); drag(&mut app, [20.0, 30.0], [120.0, 90.0], Modifiers::NONE); match live_shape(&app) { @@ -86,6 +93,11 @@ fn a_drag_makes_a_live_star_shape_layer_with_one_undo_step() { } other => panic!("a live star shape, got {other:?}"), } + let shape = shape_layer(&app).expect("a star shape layer"); + let solid = |r, g, b| Fill::Solid(Color::rgba(r, g, b, 1.0)); + assert_eq!(shape.fill, Some(solid(1.0, 0.0, 0.0)), "filled with the foreground colour"); + let stroke = shape.stroke.expect("the star keeps the tool's stroke"); + assert_eq!((stroke.paint, stroke.width), (solid(0.0, 0.0, 1.0), 5.0), "stroked with the background colour at the tool's width"); assert_eq!(app.session.active().unwrap().history.past_len(), before + 1, "one history step"); app.run("edit.undo", json!({})).unwrap(); assert!(live_shape(&app).is_none(), "undo takes the star away"); From 6547fadccd87ca2f8ffb4f7a73a970a6121865ff Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 8 Nov 2026 01:24:18 +0000 Subject: [PATCH 137/219] Floor a restored brush's opacity at 1 % A brush remembered at 0 % opacity (saved before the 1 % floor) read back at 0: the options bar showed 1 % but strokes painted nothing. Restoring now floors opacity at 1 %, like tools.setBrush (#11812). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/brush_cmds.rs | 7 +++++-- crates/engine/src/cmd/brush_cmds/tests.rs | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/brush_cmds.rs b/crates/engine/src/cmd/brush_cmds.rs index 9f371bcd17..61a35e5150 100644 --- a/crates/engine/src/cmd/brush_cmds.rs +++ b/crates/engine/src/cmd/brush_cmds.rs @@ -1172,8 +1172,8 @@ pub fn remembered_settings(b: &BrushSettings) -> Option { /// A brush remembered by [`remembered_settings`], read back at launch: `None` when the value is /// not a brush or fails the checks `tools.setBrush` makes (a size or scatter out of range). The -/// options-bar values are clamped to the ranges their controls allow (size 1..max px; hardness, -/// opacity, flow and smoothing 0..100 %), and stored tips load when the store is attached (else +/// options-bar values are clamped to the ranges their controls allow (size 1..max px; opacity +/// 1..100 %; hardness, flow and smoothing 0..100 %), and stored tips load when the store is attached (else /// they paint with their previews until `Session::attach_preset_store`). pub fn restored_settings(s: &Session, v: &Value) -> Option { if !v.is_object() { @@ -1185,6 +1185,9 @@ pub fn restored_settings(s: &Session, v: &Value) -> Option { for f in [&mut b.hardness, &mut b.opacity, &mut b.flow, &mut b.smoothing.amount] { *f = if f.is_nan() { 1.0 } else { f.clamp(0.0, 1.0) }; } + // Opacity takes 1-100 %, as `tools.setBrush` floors it: a brush remembered at 0 % (saved + // before that floor) would show 1 % but paint nothing (#11812). + b.opacity = b.opacity.max(0.01); let _ = s.load_brush_tips(&mut b); Some(b) } diff --git a/crates/engine/src/cmd/brush_cmds/tests.rs b/crates/engine/src/cmd/brush_cmds/tests.rs index f72a0e207b..3abf932823 100644 --- a/crates/engine/src/cmd/brush_cmds/tests.rs +++ b/crates/engine/src/cmd/brush_cmds/tests.rs @@ -1380,6 +1380,22 @@ fn restored_brush_settings_are_clamped_or_rejected() { } } +/// #11812: a brush remembered at 0 % opacity (saved before the 1 % floor) reads back at 1 %, the +/// value its options-bar field shows, so it paints instead of laying down nothing. +#[test] +fn restored_brush_opacity_floors_at_one_percent() { + let s = Session::new(); + for opacity in [0.0, -1.0, 0.004] { + let b = restored_settings(&s, &json!({"opacity": opacity})).unwrap(); + assert_eq!(b.opacity, 0.01, "{opacity}"); + } + assert_eq!(restored_settings(&s, &json!({"opacity": 0.37})).unwrap().opacity, 0.37, "values above the floor are kept"); + let mut t = Session::new(); + t.prefs.edit(|p| p.last_brush = json!({"brush": {"opacity": 0.0}})); + restore_brush(&mut t); + assert_eq!(t.tools.brush.opacity, 0.01, "the remembered session brush"); +} + #[test] fn remembered_brushes_never_embed_large_bitmaps() { use std::sync::Arc; From 4750447f23c011b3294cea98da4b70b8c767d038 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 8 Nov 2026 01:03:17 +0100 Subject: [PATCH 138/219] Floor an imported brush's opacity at 1 % when it is selected Importing an .abr with select picked its first brush without the 1 % opacity floor, so a preset saved at 0 % showed 1 % but painted nothing (#11812). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/preset_import_cmds.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/crates/engine/src/cmd/preset_import_cmds.rs b/crates/engine/src/cmd/preset_import_cmds.rs index 3adbee6f06..4430f4d1d2 100644 --- a/crates/engine/src/cmd/preset_import_cmds.rs +++ b/crates/engine/src/cmd/preset_import_cmds.rs @@ -176,6 +176,9 @@ fn add_abr_presets(s: &mut Session, p: &Value, group: String, imp: photocraft_io && let Some(pr) = photocraft_paint::presets::find(&s.tools.presets, first) { let mut b = pr.brush.clone().picked_over(&s.tools.brush); + // Opacity takes 1-100 %, as `tools.setBrush` floors it: a preset saved at 0 % would show + // 1 % but paint nothing (#11812). + b.opacity = b.opacity.max(0.01); s.load_brush_tips(&mut b).map_err(|e| bad("brush.presets.importAbr", e))?; s.tools.brush = b; s.tools.brush_changed(); @@ -477,6 +480,24 @@ mod tests { .unwrap() } + /// #11812: selecting an imported brush whose tool options hold 0 % opacity floors it at 1 %, + /// the value its options-bar field shows, so it paints instead of laying down nothing. + #[test] + fn selecting_an_imported_zero_opacity_brush_floors_it_at_one_percent() { + use photocraft_psd::descriptor::{Descriptor, UnicodeString, Value as D}; + let preset = |opacity: i32| { + Descriptor::new("brushPreset") + .with("Nm ", D::Text(UnicodeString::new_nul("Faint"))) + .with("toolOptions", D::Descriptor(Descriptor::new("currentToolOptions").with("Opct", D::Integer(opacity)))) + }; + for (opacity, expected) in [(0, 0.01), (37, 0.37)] { + let abr = photocraft_psd::abr::write_v6_folders(2, &[], &[], &[preset(opacity)], &[vec![]], true).unwrap(); + let mut s = Session::new(); + s.execute("brush.presets.importAbr", json!({"data": photocraft_paint::tile::b64_encode(&abr), "select": true})).unwrap(); + assert!((s.tools.brush.opacity - expected).abs() < 1e-6, "{opacity}: {}", s.tools.brush.opacity); + } + } + #[test] fn import_adds_a_group_and_paints() { let mut s = Session::new(); From 8f527110be6456fc5df44cea07ffc2a875936137 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 8 Nov 2026 01:20:55 +0100 Subject: [PATCH 139/219] #9396: the continue-path press no longer closes a short path, and hover stops cloning it CodeRabbit on #9396: with both ends of a short open path in reach (as when zoomed out), the canvas showed the continue-path pointer, but the press resumed the path and then hit its first anchor, so the release closed it. The press that resumes a path now never closes it, and the nearer end is the one carried on. The path a Pen path carries on is now borrowed instead of cloned, so the hover check no longer copies the whole path each frame. Co-Authored-By: Leel Wasanatha <260480244+kavishkashehan726-sudo@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/pen_variants_tests.rs | 33 ++++++++++++++++++++ crates/ui-egui/src/tools/vector_ui/pen.rs | 38 ++++++++++++++--------- 2 files changed, 57 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/pen_variants_tests.rs b/crates/ui-egui/src/pen_variants_tests.rs index 78143ffaff..71c272f793 100644 --- a/crates/ui-egui/src/pen_variants_tests.rs +++ b/crates/ui-egui/src/pen_variants_tests.rs @@ -556,3 +556,36 @@ fn a_targeted_vector_mask_carries_on_its_open_path() { crate::vector_ui::pen_commit(&mut app, false); assert_eq!(mask(&app), [3], "the same subpath, extended"); } + +/// #9396 review: on a short open path whose two ends are both within reach of the pointer (as +/// when zoomed out), the press under the continue-path pointer carries the nearer end on and never +/// closes the path, which it did when the other end was its first anchor. +#[test] +fn a_press_in_reach_of_both_ends_carries_the_nearer_one_on_and_does_not_close() { + let mut app = app(Tool::Pen); + assert_eq!(app.point_zoom(), 1.0, "a 6 px reach in document pixels"); + // The ends sit beside the first segment, which Auto Add/Delete would put an anchor on. + app.ui.tool_options.pen_auto_add_delete = false; + for (x, y) in [(20.0, 20.0), (60.0, 20.0), (60.0, 40.0), (26.0, 22.0)] { + click(&mut app, x, y); + } + crate::vector_ui::pen_commit(&mut app, false); + let ends = |app: &PhotocraftApp| { + let pen = app.ui.pen.as_ref().expect("a resumed draft"); + assert!(pen.resumes.is_some(), "{pen:?}"); + pen.knots.last().map(|k| k[0]) + }; + // (24, 22): 2 px from the last anchor, 4.5 px from the first. + assert!(crate::vector_ui::continues_path_at(&app, Tool::Pen, 24.0, 22.0)); + click(&mut app, 24.0, 22.0); + assert!(!work_path(&app).subpaths[0].closed, "the press carried the path on, it did not close it"); + assert_eq!(ends(&app), Some([26.0, 22.0]), "from the nearer, last anchor"); + click(&mut app, 100.0, 100.0); + crate::vector_ui::pen_commit(&mut app, false); + let sub = &work_path(&app).subpaths[0]; + assert!(!sub.closed && sub.knots.len() == 5 && sub.knots[4].anchor.x == 100.0, "{sub:?}"); + // (21, 20): 1 px from the first anchor, 5.4 px from the last: carried on from the first. + click(&mut app, 21.0, 20.0); + assert!(!work_path(&app).subpaths[0].closed); + assert_eq!(ends(&app), Some([20.0, 20.0]), "from the nearer, first anchor"); +} diff --git a/crates/ui-egui/src/tools/vector_ui/pen.rs b/crates/ui-egui/src/tools/vector_ui/pen.rs index bc534f3cf4..83e99f983f 100644 --- a/crates/ui-egui/src/tools/vector_ui/pen.rs +++ b/crates/ui-egui/src/tools/vector_ui/pen.rs @@ -1,6 +1,6 @@ use super::curvature::{curvature_hit, curvature_refresh}; use super::options::selected_shape; -use super::path_ops::{active_shape_path, targeted_vector_mask, work_path_shown}; +use super::path_ops::{targeted_vector_mask, targeted_vector_mask_id, work_path_shown}; use super::shapes::{fill_param, stroke_param}; use super::{Document, LayerContent, Path, PenPath, PhotocraftApp, Resumed, Tokens, Tool, ToolOptions, Value, json, vec2}; @@ -53,10 +53,12 @@ pub fn pen_down(app: &mut PhotocraftApp, x: f64, y: f64, shift: bool) { } // A press on an endpoint of an open path carries on drawing it, as in Photoshop (#6593): the // press then acts on its last anchor, so a drag pulls out the next segment's handle. + let mut resuming = false; if app.ui.pen.as_ref().is_none_or(|p| p.knots.is_empty()) && let Some(resumed) = resume_at(app, x, y, tol) { app.ui.pen = Some(resumed); + resuming = true; } let Some(owner) = capture_pen_owner(app) else { return }; let pen = app.ui.pen.get_or_insert_with(PenPath::default); @@ -67,7 +69,10 @@ pub fn pen_down(app: &mut PhotocraftApp, x: f64, y: f64, shift: bool) { pen.adjusting_in = false; pen.last_pointer = Some([x, y]); pen.reposition = false; + // The press that carries a path on never closes it, even when its other end is in reach too: + // the canvas showed the continue-path pointer there (`continues_path_at`). if let Some(first) = pen.knots.first().map(|k| k[0]) + && !resuming && on_closing_anchor(pen, x, y, tol) { // Pressing the first anchor closes the path on release. Until then a stand-in for it @@ -518,21 +523,24 @@ fn doc_path<'a>(doc: &'a Document, name: &str) -> Option<&'a Path> { /// The path a Pen path carries on (#6593): a targeted vector mask, else in Shape mode the /// selected shape layer's, else the path a new Pen path would join (the one selected in the Paths /// panel, else the work path). Its Paths-panel name ("layer" for a layer's), layer and path. -fn resume_target(app: &PhotocraftApp) -> Option<(String, Option, Path)> { +fn resume_target(app: &PhotocraftApp) -> Option<(String, Option, &Path)> { let st = app.session.active()?; - if let Some((id, p)) = targeted_vector_mask(app) { - return Some(("layer".to_string(), Some(id), p)); + // Borrowed, not cloned: the canvas asks on every hover (`continues_path_at`). + if let Some(id) = targeted_vector_mask_id(app) { + let mask = st.doc.layer(photocraft_doc::LayerId(id))?.vector_mask.as_ref()?; + return Some(("layer".to_string(), Some(id), &mask.path)); } if app.ui.tool_options.vector_mode == "shape" { - let (id, p) = active_shape_path(app)?; - return Some(("layer".to_string(), Some(id), p)); + let l = st.active_layer.and_then(|id| st.doc.layer(id))?; + let LayerContent::Shape(sh) = &l.content else { return None }; + return Some(("layer".to_string(), Some(l.id.0), &sh.path)); } let name = app.ui.selected_path.clone().filter(|n| path_exists(&st.doc, n)).unwrap_or_else(|| "work".into()); // A Work Path hidden by Make Selection isn't carried on: a new path replaces it (#6079). if name == "work" && !work_path_shown(app) { return None; } - let p = doc_path(&st.doc, &name)?.clone(); + let p = doc_path(&st.doc, &name)?; Some((name, None, p)) } @@ -556,14 +564,16 @@ fn resumed(doc: u64, name: String, layer: Option, index: usize, sub: &photo pub(super) fn resume_at(app: &PhotocraftApp, x: f64, y: f64, tol: f64) -> Option { let doc = app.session.active()?.doc.id.0; let (name, layer, path) = resume_target(app)?; - let near = |k: &photocraft_doc::Knot| (k.anchor.x - x).hypot(k.anchor.y - y) < tol; + let dist = |k: &photocraft_doc::Knot| (k.anchor.x - x).hypot(k.anchor.y - y); path.subpaths.iter().enumerate().filter(|(_, s)| !s.closed).find_map(|(i, s)| { - let reversed = if s.knots.last().is_some_and(near) { - false - } else if s.knots.first().is_some_and(near) { - true - } else { - return None; + let last = s.knots.last().map(dist).filter(|d| *d < tol); + let first = s.knots.first().map(dist).filter(|d| *d < tol); + // With both ends in reach (a short path, zoomed out) the nearer one is carried on. + let reversed = match (last, first) { + (Some(l), Some(f)) => f < l, + (Some(_), None) => false, + (None, Some(_)) => true, + (None, None) => return None, }; Some(resumed(doc, name.clone(), layer, i, s, reversed)) }) From b7e71b9ed525a1d2eee68a0a5407ee8744d4f275 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 7 Nov 2026 18:23:00 -0600 Subject: [PATCH 140/219] Notes: an unset (all-zero) popup stays unset under Image Size, Canvas Size and turns (CodeRabbit review on crates/engine/src/canvas_geom.rs:252) PSD export writes its default popup for an all-zero one; moving it with the icon turned it into a 0 x 0 rectangle elsewhere, which export then wrote as is. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/canvas_geom.rs | 5 +++++ crates/engine/src/cmd/notes_stored_values_tests.rs | 13 +++++++++++++ 2 files changed, 18 insertions(+) diff --git a/crates/engine/src/canvas_geom.rs b/crates/engine/src/canvas_geom.rs index 38cddc6ef5..43fecf84de 100644 --- a/crates/engine/src/canvas_geom.rs +++ b/crates/engine/src/canvas_geom.rs @@ -245,6 +245,11 @@ pub(crate) fn transform_doc_marks(doc: &mut Document, a: &Affine) { // A note's icon point maps with the pixels; its popup moves with it and keeps its size and its // offset from the icon, as Photoshop's does under Image Size, Canvas Size and turns (#11795). for n in &mut doc.notes { + // An all-zero popup means none is stored (PSD export writes its default one): keep it. + if n.popup == [0.0; 4] { + n.position = map_pt(a, n.position); + continue; + } let [px, py] = n.position; let (dx, dy, w, h) = (n.popup[0] - px, n.popup[1] - py, n.popup[2] - n.popup[0], n.popup[3] - n.popup[1]); n.position = map_pt(a, n.position); diff --git a/crates/engine/src/cmd/notes_stored_values_tests.rs b/crates/engine/src/cmd/notes_stored_values_tests.rs index b0920139d6..cb1897f121 100644 --- a/crates/engine/src/cmd/notes_stored_values_tests.rs +++ b/crates/engine/src/cmd/notes_stored_values_tests.rs @@ -85,3 +85,16 @@ fn a_mode_change_leaves_notes_alone() { s.execute("image.mode.cmyk", json!({})).unwrap(); assert_eq!(note(&s, 0), before); } + +#[test] +fn an_unset_popup_stays_unset_under_image_size() { + // An all-zero popup means "none stored": PSD export writes its default popup for it, so Image + // Size must not turn it into a 0 × 0 rectangle somewhere else. + let mut s = session(600, 400); + let doc = std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc); + doc.notes.push(photocraft_doc::Note { position: [100.0, 100.0], ..Default::default() }); + s.execute("image.imageSize", json!({"width": 300, "height": 200})).unwrap(); + let n = note(&s, 0); + assert_eq!(n.position, [50.0, 50.0]); + assert_eq!(n.popup, [0.0; 4]); +} From 4a8e26a11a79843c31cb4cf31b5931e4e5d75cb4 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 7 Nov 2026 18:25:41 -0600 Subject: [PATCH 141/219] Notes: after a turn or flip the popup keeps today's origin, with its size kept (CodeRabbit review on crates/engine/src/canvas_geom.rs:252) #11795 didn't measure where the popup sits after a rotation, so that case keeps today's placement (the top-left of the mapped rectangle) and only the size rule applies. Image Size and Canvas Size still keep the popup's offset from the icon. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/canvas_geom.rs | 16 ++++++++++++---- .../src/cmd/notes_stored_values_tests.rs | 18 +++++++++++++++++- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/canvas_geom.rs b/crates/engine/src/canvas_geom.rs index 43fecf84de..ff40ea8a8b 100644 --- a/crates/engine/src/canvas_geom.rs +++ b/crates/engine/src/canvas_geom.rs @@ -242,8 +242,10 @@ pub(crate) fn transform_doc_marks(doc: &mut Document, a: &Affine) { if let Some(wp) = &mut doc.work_path { *wp = wp.transform(a); } - // A note's icon point maps with the pixels; its popup moves with it and keeps its size and its - // offset from the icon, as Photoshop's does under Image Size, Canvas Size and turns (#11795). + // A note's icon point maps with the pixels; its popup keeps its size (#11795). Under Image Size + // and Canvas Size it also keeps its offset from the icon, as Photoshop's does. Where it sits + // after a turn or flip wasn't measured, so it keeps today's origin there: the top-left of the + // mapped rectangle. for n in &mut doc.notes { // An all-zero popup means none is stored (PSD export writes its default one): keep it. if n.popup == [0.0; 4] { @@ -253,8 +255,14 @@ pub(crate) fn transform_doc_marks(doc: &mut Document, a: &Affine) { let [px, py] = n.position; let (dx, dy, w, h) = (n.popup[0] - px, n.popup[1] - py, n.popup[2] - n.popup[0], n.popup[3] - n.popup[1]); n.position = map_pt(a, n.position); - let [x, y] = n.position; - n.popup = [x + dx, y + dy, x + dx + w, y + dy + h]; + let [m0, m1, m2, m3, ..] = a.m; + let [x, y] = if m1.abs() < EPS && m2.abs() < EPS && m0 > 0.0 && m3 > 0.0 { + [n.position[0] + dx, n.position[1] + dy] + } else { + let [l, t, ..] = map_box(a, n.popup); + [l, t] + }; + n.popup = [x, y, x + w, y + h]; } for g in &mut doc.measurement.count_groups { for p in &mut g.points { diff --git a/crates/engine/src/cmd/notes_stored_values_tests.rs b/crates/engine/src/cmd/notes_stored_values_tests.rs index cb1897f121..1db8e5681b 100644 --- a/crates/engine/src/cmd/notes_stored_values_tests.rs +++ b/crates/engine/src/cmd/notes_stored_values_tests.rs @@ -74,7 +74,6 @@ fn canvas_size_and_a_quarter_turn_keep_the_popup_size() { s.execute("image.imageRotation.90cw", json!({})).unwrap(); let r = note(&s, 0); assert_eq!([r.popup[2] - r.popup[0], r.popup[3] - r.popup[1]], [240.0, 140.0], "a turn doesn't swap the popup's sides"); - assert_eq!([r.popup[0] - r.position[0], r.popup[1] - r.position[1]], [8.0, 10.0], "the popup moves with its icon"); } #[test] @@ -86,6 +85,23 @@ fn a_mode_change_leaves_notes_alone() { assert_eq!(note(&s, 0), before); } +#[test] +fn a_turn_keeps_todays_popup_origin_and_the_popup_size() { + // Where the popup sits after a turn wasn't measured (#11795), so it keeps today's origin (the + // top-left of the turned rectangle) and only the size rule applies. + let mut s = session(600, 400); + s.execute("notes.add", json!({"x": 100, "y": 100})).unwrap(); + // The popup [108, 110, 348, 250] turned 90° clockwise on a 400 px tall canvas spans x 150..290, + // y 108..348; it starts there and stays 240 × 140. + s.execute("image.imageRotation.90cw", json!({})).unwrap(); + assert_eq!(note(&s, 0).popup, [150.0, 108.0, 390.0, 248.0]); + // A flip keeps today's mirrored popup, which already has its size. + let mut s = session(600, 400); + s.execute("notes.add", json!({"x": 100, "y": 100})).unwrap(); + s.execute("image.imageRotation.flipCanvasHorizontal", json!({})).unwrap(); + assert_eq!(note(&s, 0).popup, [252.0, 110.0, 492.0, 250.0]); +} + #[test] fn an_unset_popup_stays_unset_under_image_size() { // An all-zero popup means "none stored": PSD export writes its default popup for it, so Image From 45edc58f8914fbbba43edacd380ce96a39df23d5 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 8 Nov 2026 01:36:26 +0100 Subject: [PATCH 142/219] PR #9991: give filmstrip raws their sidecar crop The filmstrip that main added while #9991 was open holds raws opened together. Only the shown raw read its sidecar crop, so a raw that was never shown, or was shown and left, opened uncropped. The crop is now read when the raw is queued. Co-Authored-By: ljkx <177671075+ljkx@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- .../ui-egui/src/dialogs/camera_raw_ui/open.rs | 3 ++- .../src/dialogs/camera_raw_ui/tests.rs | 27 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs/camera_raw_ui/open.rs b/crates/ui-egui/src/dialogs/camera_raw_ui/open.rs index 1e543c359d..95da88ae1d 100644 --- a/crates/ui-egui/src/dialogs/camera_raw_ui/open.rs +++ b/crates/ui-egui/src/dialogs/camera_raw_ui/open.rs @@ -75,7 +75,8 @@ pub fn queue_open_dialog( bytes: bytes.map(|b| std::sync::Arc::new(b.to_vec())), developed_with: method, demosaic: method, - crop: None, + // Read now: a filmstrip raw that is never shown still opens with its sidecar's crop. + crop: path.and_then(read_sidecar).and_then(|(_, report, _)| report.crop), }; let held = super::filmstrip::HeldRaw::new(app, raw); app.raw_shelf.push(held); diff --git a/crates/ui-egui/src/dialogs/camera_raw_ui/tests.rs b/crates/ui-egui/src/dialogs/camera_raw_ui/tests.rs index 87f7111d2d..76591f3df1 100644 --- a/crates/ui-egui/src/dialogs/camera_raw_ui/tests.rs +++ b/crates/ui-egui/src/dialogs/camera_raw_ui/tests.rs @@ -1030,6 +1030,33 @@ fn a_sidecar_crop_crops_the_opened_raw() { let _ = std::fs::remove_dir_all(&dir); } +/// #9985 with the filmstrip: raws opened together each open with their own sidecar's crop, also +/// the one that is never shown and the one that was shown and left. +#[test] +fn filmstrip_raws_open_with_their_sidecar_crops() { + let lightroom = r#""#; + let (mut app, dng, dir, raw_path, _) = raw_with_sidecar("filmcrop", Some(lightroom)); + let paths: Vec = (1..3).map(|i| dir.join(format!("IMG_010{i}.cr2")).to_string_lossy().into_owned()).collect(); + for p in &paths { + std::fs::write(p, &dng).unwrap(); + std::fs::write(sidecar_path(p), lightroom).unwrap(); + } + let ctx = egui::Context::default(); + app.open_file(&raw_path, &dng).unwrap(); + for p in &paths { + app.open_file(p, &dng).unwrap(); + } + assert_eq!(app.raw_shelf.len(), 3); + filmstrip::open_held(&mut app, &ctx, 0).unwrap(); + filmstrip::show_raw(&mut app, &ctx, 1).unwrap(); + filmstrip::show_raw(&mut app, &ctx, 0).unwrap(); + let r = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"commit": true}})).unwrap().unwrap(); + assert_eq!(r["opened"].as_array().map(Vec::len), Some(3), "{r}"); + let sizes: Vec<_> = app.session.documents().iter().map(|st| (st.doc.size.width, st.doc.size.height)).collect(); + assert_eq!(sizes, vec![(24, 16); 3], "shown, shown and left, and never shown: all cropped"); + let _ = std::fs::remove_dir_all(&dir); +} + /// With Open in Camera Raw off, opening a raw develops it with its sidecar and writes nothing /// back: nothing changed. #[test] From 1b0a3e13367aa08e7b47e0efd79e49e5576662b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Sun, 8 Nov 2026 01:40:36 +0100 Subject: [PATCH 143/219] fix(engine): refuse Straighten Layer for a target that is gone `Session::edit` keeps the selected layers valid after an edit but not the active id, so an edit that drops the active layer leaves one behind that the document does not hold. The new enabled check read that id and answered "live", and with an explicit `crop` the command then rotated and cropped the whole canvas for a layer that is not there - the one outcome of this state that changes pixels. Refusing it is what `select_layer` and `restore_layer_selection` already do with a gone id, and it costs the resolve check the target lookup was doing anyway. No command shipped today drops the active layer without re-targeting, so no real document changes behaviour: this is the guard the review asked for, and the test reaches it through `Session::edit` directly. --- crates/engine/src/cmd/analysis_cmds.rs | 11 +++++++-- .../cmd/analysis_straighten_enabled_tests.rs | 24 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/analysis_cmds.rs b/crates/engine/src/cmd/analysis_cmds.rs index 76744c37fc..c095bcd81d 100644 --- a/crates/engine/src/cmd/analysis_cmds.rs +++ b/crates/engine/src/cmd/analysis_cmds.rs @@ -164,7 +164,7 @@ fn has_straightenable_layer(s: &Session) -> std::result::Result<(), String> { /// [`straighten`] with [`STRAIGHTEN_HIDDEN_ERROR`], and the states the ticket marks *not measured* /// (a layer with a mask, a shape, vector or adjustment layer, artboards, and the lock kinds other /// than Lock All and Lock Position) keep today's behaviour. A document with no active layer keeps -/// turning the whole canvas as it does now. +/// turning the whole canvas as it does now; an active id the document no longer holds is refused. fn straighten_disabled(s: &Session) -> Option<&'static str> { let st = s.active()?; if st.selected_layers().len() > 1 { @@ -173,10 +173,17 @@ fn straighten_disabled(s: &Session) -> Option<&'static str> { // The active layer is the one the Layers panel targets; with none, the command turns the // canvas, which the reference was not measured for. let id = st.active_layer?; + // An id the document no longer holds: `Session::edit` keeps the *selected* layers valid but + // not the active one, so an edit that drops the active layer can leave a stale id behind. + // Turning and cropping the whole canvas for a layer that is not there is the worse answer, and + // `select_layer` and `restore_layer_selection` both refuse such an id. + let Some(layer) = st.doc.layer(id) else { + return Some("no active layer"); + }; if st.doc.is_background(id) { return Some("the Background"); } - if st.doc.layer(id).is_some_and(|l| l.is_group()) { + if layer.is_group() { return Some("a group"); } // Locks inherited from a group count, as they do for the Move tool. diff --git a/crates/engine/src/cmd/analysis_straighten_enabled_tests.rs b/crates/engine/src/cmd/analysis_straighten_enabled_tests.rs index 9e27c2ad8d..b2d107d7bb 100644 --- a/crates/engine/src/cmd/analysis_straighten_enabled_tests.rs +++ b/crates/engine/src/cmd/analysis_straighten_enabled_tests.rs @@ -209,6 +209,30 @@ fn without_a_ruler_line_the_button_stays_greyed_for_every_layer() { assert!(!s.is_enabled(CMD), "a hidden layer without a ruler line stays greyed, not enabled"); } +#[test] +fn an_active_layer_the_document_no_longer_holds_is_refused() { + // `Session::edit` prunes the selected layers but not the active id, so an edit that drops the + // active layer can leave one behind (`select_layer` and `restore_layer_selection` both refuse + // such an id). The command must then say it is off, not rotate and crop the whole canvas for a + // layer that is not there. + let mut s = session(100, 80); + let l = pixel_layer(&mut s, "Gone"); + s.edit("drop", |doc, _| { + doc.layers.retain(|layer| layer.id != l); + Ok(()) + }) + .unwrap(); + assert_eq!(s.active().unwrap().active_layer, Some(l), "the target still points at the dropped layer"); + assert!(doc_of(&s).layer(l).is_none(), "and the document does not hold it"); + rule(&mut s); + assert_greyed(&mut s, "an active layer that is gone"); + // The destructive case this closes: `crop` asks for the canvas, which would turn and shrink for + // a target that no longer exists. + let size = (doc_of(&s).size.width, doc_of(&s).size.height); + assert!(s.execute(CMD, json!({"crop": true})).is_err()); + assert_eq!((doc_of(&s).size.width, doc_of(&s).size.height), size, "the canvas kept its size"); +} + #[test] fn a_single_visible_layer_still_rotates_without_cropping() { // The pin for the state that stays: the layer turns about the canvas centre, the canvas does not. From c517e295b57ed74aab48309dc91c13eec4326da1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Sun, 8 Nov 2026 01:40:36 +0100 Subject: [PATCH 144/219] fix(ui): say why a Straighten Layer click was refused `PhotocraftApp::run` puts what a command answered into the status line, but a refusal raised before the command runs - the automation gate a control or MCP-driven click passes through - returns from `run_command_at_depth` ahead of that report, and the button handler only reacted to the hidden-layer alert. A denied synthetic click therefore did nothing at all, which reads as a dead button rather than a refused one. It now reports any error it gets, the way `eraser_ui` and the toolbar flyout do, and keeps opening the reference's alert for the hidden layer alone. --- crates/ui-egui/src/tools/analysis_ui.rs | 21 ++++++++++----- crates/ui-egui/src/tools/analysis_ui/tests.rs | 27 +++++++++++++++++++ 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/tools/analysis_ui.rs b/crates/ui-egui/src/tools/analysis_ui.rs index 39c8dc1e51..3a0b879e50 100644 --- a/crates/ui-egui/src/tools/analysis_ui.rs +++ b/crates/ui-egui/src/tools/analysis_ui.rs @@ -535,13 +535,20 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo // layer locked in place (#12033). let straighten = app.session.is_enabled("image.analysis.straightenLayer"); if ui.add_enabled_ui(straighten, |ui| crate::widgets::secondary_button(ui, tl!("Straighten Layer"), 0.0)).inner.clicked() { - // A hidden target layer keeps the button on and answers the click with the - // reference's alert (its message and an OK button), changing nothing (#12033). - if let Err(why) = app.run("image.analysis.straightenLayer", json!({})) - && why == photocraft_engine::analysis_cmds::STRAIGHTEN_HIDDEN_ERROR - { - let message = photocraft_engine::analysis_cmds::STRAIGHTEN_HIDDEN_ERROR; - app.ui.open_dialog(DialogKind::Error, json!({"message": tl!(message)}).as_object().cloned().unwrap_or_default()); + let hidden = photocraft_engine::analysis_cmds::STRAIGHTEN_HIDDEN_ERROR; + if let Err(why) = app.run("image.analysis.straightenLayer", json!({})) { + // A hidden target layer keeps the button on and answers the click with the + // reference's alert (its message and an OK button), changing nothing (#12033). + if why == hidden { + app.ui.open_dialog(DialogKind::Error, json!({"message": tl!(hidden)}).as_object().cloned().unwrap_or_default()); + } + // Every other refusal goes to the status line, as the eraser tools do. What the + // command itself answered is reported there by `PhotocraftApp::run`, but a + // refusal raised before it runs - the automation gate a control or MCP-driven + // click passes - returns early, and a click that says nothing reads as a dead + // button (#12148). + app.ui.status = why; + app.ui.status_error = true; } } if ui.add_enabled_ui(has, |ui| crate::widgets::secondary_button(ui, tl!("Clear"), 0.0)).inner.clicked() { diff --git a/crates/ui-egui/src/tools/analysis_ui/tests.rs b/crates/ui-egui/src/tools/analysis_ui/tests.rs index 502d83851a..517e7df649 100644 --- a/crates/ui-egui/src/tools/analysis_ui/tests.rs +++ b/crates/ui-egui/src/tools/analysis_ui/tests.rs @@ -391,6 +391,33 @@ fn the_straighten_layer_button_greys_out_where_the_command_is_off() { } } +/// A refused click says so. `PhotocraftApp::run` reports what the *command* answered in the status +/// line, but a refusal raised before it runs - the automation gate a control/MCP-driven click goes +/// through - returns early, so the button has to report it itself. As the toolbar flyout does +/// (`a_failed_edit_toolbar_from_the_flyout_reports_its_error_in_the_status_bar`). +#[test] +fn a_denied_synthetic_click_reports_the_refusal_instead_of_saying_nothing() { + use egui_kittest::kittest::{NodeT, Queryable}; + let (mut app, _) = app(); + on_a_layer(&mut app); + rule(&mut app); + app.automation_input = true; + app.services.automation_command = + Some(Box::new(|id, _| if id == "image.analysis.straightenLayer" { Err("straightening is denied".into()) } else { Ok(()) })); + let mut h = ruler_bar(app); + assert!(!h.get_by_label("Straighten Layer").accesskit_node().is_disabled(), "the layer is a valid target"); + let steps = h.state().session.active().unwrap().history.past_len(); + h.get_by_label("Straighten Layer").click(); + h.run_steps(2); + let st = h.state(); + assert_eq!(st.ui.status, "straightening is denied", "the refusal reaches the status line"); + assert!(st.ui.status_error, "as an error"); + assert!(alerts(st).is_empty(), "the alert stays the hidden layer's own"); + let now = st.session.active().unwrap(); + assert!(now.doc.measurement.ruler.is_some(), "nothing ran: the ruler line is still up"); + assert_eq!(now.history.past_len(), steps, "and nothing was recorded"); +} + /// #12033: a hidden target layer is the one state that keeps its button. The click answers with the /// reference's alert - its measured message and an OK button - instead of the status line, and the /// document, its History and the ruler line stay as they were. From 599baaa26e385eee471df5eb17c80f6f883cecaf Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 7 Nov 2026 18:42:08 -0600 Subject: [PATCH 145/219] Brush Preset picker: the tip's angle/roundness widget at its top left (#11821) The picker shows the Brush Settings panel's angle/roundness widget left of its Size and Hardness block, the same widget made reachable from the picker. Its drags go through tools.setBrush as one journaled change each, and Escape reverts an angle or roundness edit as it does a Size or Hardness edit. A pointer position that isn't finite leaves the tip as it was. --- .../src/brush_picker_angle_widget_tests.rs | 167 ++++++++++++++++++ crates/ui-egui/src/panels/brush_sections.rs | 5 +- crates/ui-egui/src/tools/brush_picker.rs | 103 +++++++---- 3 files changed, 235 insertions(+), 40 deletions(-) create mode 100644 crates/ui-egui/src/brush_picker_angle_widget_tests.rs diff --git a/crates/ui-egui/src/brush_picker_angle_widget_tests.rs b/crates/ui-egui/src/brush_picker_angle_widget_tests.rs new file mode 100644 index 0000000000..caed875a5b --- /dev/null +++ b/crates/ui-egui/src/brush_picker_angle_widget_tests.rs @@ -0,0 +1,167 @@ +//! #11821: the Brush Preset picker shows the tip's angle/roundness widget at its top left, the +//! Brush Settings panel's own widget, and its drags go through `tools.setBrush` as one journaled +//! change each; Escape reverts them as it does a Size or Hardness edit. + +use egui::{Event, Modifiers, PointerButton, Pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::brush_sections::{EllipseGrab, ellipse_drag, ellipse_grab, roundness_handles}; +use crate::state::Tool; + +fn harness() -> Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("tools.setBrush", json!({"brush": {"size": 20, "hardness": 1.0, "angle": 0.0, "roundness": 1.0}})).unwrap(); + app.ui.tool = Tool::Brush; + app.sync_views(); + let mut h = Harness::builder().with_size(vec2(1200.0, 800.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + let c = h.state().last_canvas_rect.center(); + let ctx = h.ctx.clone(); + crate::brush_picker::open(&mut h.state_mut().ui, &ctx, [c.x, c.y]); + h.run_steps(3); + h +} + +/// The widget's centre on screen. It is the top block's first item, 84 × 84, and the Size column +/// starts one item spacing right of it, its first row (24 pt tall, the label centred in it) level +/// with the widget's top. +fn widget_centre(h: &Harness<'static, PhotocraftApp>) -> Pos2 { + use egui_kittest::kittest::Queryable; + let picker = h.ctx.memory(|m| m.area_rect(crate::brush_picker::picker_id())).expect("the picker is open"); + let label = h.query_all_by_label(crate::i18n::t("Size")).find(|n| picker.contains(n.rect().center())).expect("the picker's Size label").rect(); + let spacing = h.ctx.global_style().spacing.item_spacing.x; + Pos2::new(label.left() - spacing - 42.0, label.center().y - 12.0 + 42.0) +} + +fn brush(h: &Harness<'static, PhotocraftApp>) -> (f32, f32) { + let b = &h.state().session.tools.brush; + (b.angle, b.roundness) +} + +/// A press at `from`, moves through `path` and the release at its end, a frame each. +fn drag(h: &mut Harness<'static, PhotocraftApp>, from: Pos2, path: &[Pos2]) { + h.event(Event::PointerMoved(from)); + h.run_steps(1); + h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + for p in path { + h.event(Event::PointerMoved(*p)); + h.run_steps(1); + } + let end = path.last().copied().unwrap_or(from); + h.event(Event::PointerButton { pos: end, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); +} + +#[test] +fn the_widget_sits_at_the_pickers_top_left_left_of_size() { + use egui_kittest::kittest::Queryable; + let h = harness(); + let c = widget_centre(&h); + // Its top left is the picker's content corner: inside the popup frame's margin and stroke. + let picker = h.ctx.memory(|m| m.area_rect(crate::brush_picker::picker_id())).expect("the picker is open"); + let style = h.ctx.global_style(); + let inset = + vec2(f32::from(style.spacing.menu_margin.left), f32::from(style.spacing.menu_margin.top)) + egui::Vec2::splat(style.visuals.window_stroke().width); + let corner = c - vec2(42.0, 42.0); + assert!((corner - (picker.min + inset)).length() <= 2.0, "the widget at the top left: {corner:?} vs {:?}", picker.min + inset); + // The Size field is right of it, in the same top block. + let size = h.get_all_by_role(egui::accesskit::Role::SpinButton).next().expect("the Size field").rect(); + assert!(size.left() > c.x + 42.0 && size.top() < c.y + 42.0, "{size:?} vs centre {c:?}"); +} + +#[test] +fn dragging_the_arrow_sets_the_angle_as_the_panel_widget_does() { + let mut h = harness(); + let c = widget_centre(&h); + let (angle, roundness) = brush(&h); + // On the arrow (angle 0 points right), away from the roundness handles. + let from = c + vec2(28.0, 0.0); + assert_eq!(ellipse_grab(from - c, angle, roundness), EllipseGrab::Angle); + let to = c + vec2(-20.0, -20.0); + let journal = h.state().session.journal.len(); + drag(&mut h, from, &[c + vec2(10.0, -25.0), to]); + let want = ellipse_drag(EllipseGrab::Angle, to - c, angle, roundness); + assert_eq!(brush(&h), want, "the panel widget's values for the same drag"); + assert_eq!(want.0, 135.0); + let added: Vec = h.state().session.journal[journal..].iter().map(|(id, _)| id.clone()).collect(); + assert_eq!(added, ["tools.setBrush"], "one journaled change for the drag"); +} + +#[test] +fn dragging_a_handle_sets_the_roundness_as_the_panel_widget_does() { + let mut h = harness(); + let c = widget_centre(&h); + let (angle, roundness) = brush(&h); + let handle = c + roundness_handles(angle, roundness)[0]; + assert_eq!(ellipse_grab(handle - c, angle, roundness), EllipseGrab::Roundness); + let to = c + vec2(3.0, -12.0); + // The grab is judged where the drag starts: the first move stays within the handle's reach. + drag(&mut h, handle, &[handle + vec2(0.0, 6.5), c + vec2(2.0, -20.0), to]); + let want = ellipse_drag(EllipseGrab::Roundness, to - c, angle, roundness); + assert_eq!(brush(&h), want); + assert!((want.1 - 12.0 / 34.0).abs() < 1e-4, "{want:?}"); + // The Brush Settings panel reads the same brush. + assert_eq!(h.state().session.tools.brush.roundness, want.1); +} + +#[test] +fn escape_reverts_an_angle_edit_as_it_does_a_size_edit() { + let mut h = harness(); + let c = widget_centre(&h); + let opened = brush(&h); + drag(&mut h, c + vec2(28.0, 0.0), &[c + vec2(20.0, -15.0), c + vec2(0.0, -30.0)]); + assert_eq!(brush(&h).0, 90.0); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert_eq!(brush(&h), opened, "Escape goes back to the values the picker opened with"); + assert_eq!(h.state().ui.brush_picker, None, "and closes it"); +} + +#[test] +fn enter_keeps_an_angle_edit_and_closes() { + let mut h = harness(); + let c = widget_centre(&h); + drag(&mut h, c + vec2(28.0, 0.0), &[c + vec2(20.0, 15.0), c + vec2(0.0, 30.0)]); + assert_eq!(brush(&h).0, -90.0); + h.key_press(egui::Key::Enter); + h.run_steps(2); + assert_eq!(brush(&h).0, -90.0); + assert_eq!(h.state().ui.brush_picker, None); +} + +#[test] +fn non_finite_pointer_positions_leave_the_brush_unchanged() { + let mut h = harness(); + let c = widget_centre(&h); + let before = brush(&h); + let handle = c + roundness_handles(before.0, before.1)[0]; + h.event(Event::PointerMoved(handle)); + h.run_steps(1); + h.event(Event::PointerButton { pos: handle, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + // The pointer reports non-finite positions while the handle is held. (egui's own debug checks + // panic once a drag is under way at such a position, or on a release there, so the test stops + // here.) + for p in [Pos2::new(f32::NAN, c.y), Pos2::new(c.x, f32::INFINITY)] { + h.event(Event::PointerMoved(p)); + h.run_steps(1); + let now = brush(&h); + assert!(now.0.is_finite() && now.1.is_finite(), "{p:?}: {now:?}"); + assert_eq!(now, before, "{p:?}"); + } +} diff --git a/crates/ui-egui/src/panels/brush_sections.rs b/crates/ui-egui/src/panels/brush_sections.rs index 7026f83352..38026cb94c 100644 --- a/crates/ui-egui/src/panels/brush_sections.rs +++ b/crates/ui-egui/src/panels/brush_sections.rs @@ -186,8 +186,9 @@ pub fn ellipse_drag(grab: EllipseGrab, rel: egui::Vec2, angle: f32, roundness: f } } -/// Photoshop's angle/roundness widget: drag the arrow to rotate, the dots to squash. -fn ellipse_widget(ui: &mut egui::Ui, angle: &mut f32, roundness: &mut f32) -> bool { +/// Photoshop's angle/roundness widget: drag the arrow to rotate, the dots to squash. Also at the +/// Brush Preset picker's top left (#11821). +pub(crate) fn ellipse_widget(ui: &mut egui::Ui, angle: &mut f32, roundness: &mut f32) -> bool { let t = Tokens::get(ui.ctx()); let (rect, resp) = ui.allocate_exact_size(vec2(84.0, 84.0), Sense::click_and_drag()); let c = rect.center(); diff --git a/crates/ui-egui/src/tools/brush_picker.rs b/crates/ui-egui/src/tools/brush_picker.rs index 3e59a90444..a09179944e 100644 --- a/crates/ui-egui/src/tools/brush_picker.rs +++ b/crates/ui-egui/src/tools/brush_picker.rs @@ -91,31 +91,44 @@ pub(crate) fn opened_id() -> egui::Id { picker_id().with("opened-with") } -/// What Escape in the picker goes back to (#11816): the Size and Hardness it opened with (or a -/// picked preset brought), and the values its own fields last typed. Escape reverts a value only -/// while it is still the typed one, so a change from elsewhere (`]`, the options bar) stays. +/// What Escape in the picker goes back to (#11816): the Size, Hardness, Angle and Roundness it +/// opened with (or a picked preset brought), and the values its own controls last set. Escape +/// reverts a value only while it is still the picker's, so a change from elsewhere (`]`, the +/// options bar) stays. Angle and Roundness come from the tip widget (#11821). #[derive(Clone, Debug, Default, PartialEq)] pub(crate) struct Opened { - opened: [f32; 2], - typed: [f32; 2], + opened: [f32; 4], + typed: [f32; 4], +} + +fn picker_values(b: &photocraft_engine::BrushSettings) -> [f32; 4] { + [b.size, b.hardness, b.angle, b.roundness] } impl Opened { pub(crate) fn new(b: &photocraft_engine::BrushSettings) -> Self { - Self { opened: [b.size, b.hardness], typed: [b.size, b.hardness] } + Self { opened: picker_values(b), typed: picker_values(b) } } pub(crate) fn typed(&mut self, b: &photocraft_engine::BrushSettings) { - self.typed = [b.size, b.hardness]; + self.typed = picker_values(b); } pub(crate) fn revert(&self, now: &photocraft_engine::BrushSettings) -> photocraft_engine::BrushSettings { let mut b = now.clone(); - if b.size == self.typed[0] { - b.size = self.opened[0]; + let [size, hardness, angle, roundness] = self.opened; + let [typed_size, typed_hardness, typed_angle, typed_roundness] = self.typed; + if b.size == typed_size { + b.size = size; } - if b.hardness == self.typed[1] { - b.hardness = self.opened[1]; + if b.hardness == typed_hardness { + b.hardness = hardness; + } + if b.angle == typed_angle { + b.angle = angle; + } + if b.roundness == typed_roundness { + b.roundness = roundness; } b } @@ -260,35 +273,45 @@ pub fn body( ui.set_width(size.x); ui.set_min_height(size.y); let top = ui.cursor().min.y; - // The Size and Hardness controls live in one block flush with the left edge, at most - // [`HEAD_MAX_W`] wide: a picker dragged wider leaves the space past the block empty instead of - // stretching the rows across it. - ui.scope(|ui| { - ui.set_max_width(ui.available_width().min(HEAD_MAX_W)); - // Size: value field plus a logarithmic slider (small sizes get most of the travel). - ui.horizontal(|ui| { - ui.label(RichText::new(tl!("Size")).color(t.text_dim)); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let mut size = b.size; - if widgets::value_field(ui, &mut size, 1.0..=MAX_BRUSH_SIZE, "px", 72.0).accessible_name(tl!("Size")).changed() { - b.size = size.round().clamp(1.0, MAX_BRUSH_SIZE); - } + // The tip's angle/roundness widget at the top left (#11821), then the Size and Hardness + // controls in one block, at most [`HEAD_MAX_W`] wide: a picker dragged wider leaves the space + // past the block empty instead of stretching the rows across it. + ui.horizontal_top(|ui| { + let (angle, roundness) = (b.angle, b.roundness); + crate::brush_sections::ellipse_widget(ui, &mut b.angle, &mut b.roundness); + // A pointer position that isn't finite leaves the tip as it was. + let finite = |p: egui::Pos2| p.x.is_finite() && p.y.is_finite(); + let pointer_ok = ui.input(|i| i.pointer.interact_pos().is_none_or(finite) && i.pointer.latest_pos().is_none_or(finite)); + if !(pointer_ok && b.angle.is_finite() && b.roundness.is_finite()) { + (b.angle, b.roundness) = (angle, roundness); + } + ui.vertical(|ui| { + ui.set_max_width(ui.available_width().min(HEAD_MAX_W)); + // Size: value field plus a logarithmic slider (small sizes get most of the travel). + ui.horizontal(|ui| { + ui.label(RichText::new(tl!("Size")).color(t.text_dim)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let mut size = b.size; + if widgets::value_field(ui, &mut size, 1.0..=MAX_BRUSH_SIZE, "px", 72.0).accessible_name(tl!("Size")).changed() { + b.size = size.round().clamp(1.0, MAX_BRUSH_SIZE); + } + }); }); - }); - let mut lv = b.size.max(1.0).ln(); - let size_slider = widgets::slider(ui, &mut lv, 0.0..=MAX_BRUSH_SIZE.ln(), None); - if size_slider.changed() { - b.size = lv.exp().round().clamp(1.0, MAX_BRUSH_SIZE); - } - dismiss_popup(&size_slider); - if b.tip == TipShape::Round { - let mut hard = b.hardness * 100.0; - let hardness = widgets::slider_row(ui, "Hardness", &mut hard, 0.0..=100.0, "%", None); - if hardness.changed() { - b.hardness = (hard / 100.0).clamp(0.0, 1.0); + let mut lv = b.size.max(1.0).ln(); + let size_slider = widgets::slider(ui, &mut lv, 0.0..=MAX_BRUSH_SIZE.ln(), None); + if size_slider.changed() { + b.size = lv.exp().round().clamp(1.0, MAX_BRUSH_SIZE); } - dismiss_popup(&hardness); - } + dismiss_popup(&size_slider); + if b.tip == TipShape::Round { + let mut hard = b.hardness * 100.0; + let hardness = widgets::slider_row(ui, "Hardness", &mut hard, 0.0..=100.0, "%", None); + if hardness.changed() { + b.hardness = (hard / 100.0).clamp(0.0, 1.0); + } + dismiss_popup(&hardness); + } + }); }); ui.add_space(6.0); widgets::hairline(ui); @@ -437,3 +460,7 @@ fn gear_menu(ui: &mut egui::Ui, current: Option<&str>, presets: &[BrushPreset], } }); } + +#[cfg(test)] +#[path = "../brush_picker_angle_widget_tests.rs"] +mod brush_picker_angle_widget_tests; From 8da11e891af338d2a7006b39c4c47e018fb7194d Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 8 Nov 2026 01:54:33 +0100 Subject: [PATCH 146/219] PR #9991: Batch's quiet RAW opening applies the sidecar crop Main added a non-interactive RAW opener for Batch while #9991 was open. It refused a sidecar whose crop keys were unsupported. #9991 moves those keys from the ignored list into LoadReport::crop, so after the merge Batch would drop the crop without saying so. It now passes the crop into RawTuning, as the interactive open does. Co-Authored-By: ljkx <177671075+ljkx@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/file_cmds/raw_open.rs | 13 ++++++++++--- crates/engine/src/cmd/file_cmds/tests.rs | 21 +++++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/file_cmds/raw_open.rs b/crates/engine/src/cmd/file_cmds/raw_open.rs index 905f359aaa..c252acc1de 100644 --- a/crates/engine/src/cmd/file_cmds/raw_open.rs +++ b/crates/engine/src/cmd/file_cmds/raw_open.rs @@ -5,9 +5,12 @@ use photocraft_io::{ImportResult, raw::RawTuning}; use super::{EngineError, Result, Session, Value, json}; +/// The prior settings of a sidecar, and its crop (in the raw's stored orientation). +type Prior = (CameraRaw, Option); + /// Read only a bounded regular XMP file. No ambient sidecar reads are granted to gated sessions. #[cfg(not(target_arch = "wasm32"))] -fn prior_settings(path: Option<&str>) -> Result> { +fn prior_settings(path: Option<&str>) -> Result> { use std::io::Read; let Some(path) = path else { return Ok(None) }; let sidecar = match path.rsplit_once('.') { @@ -31,11 +34,11 @@ fn prior_settings(path: Option<&str>) -> Result> { if !report.ignored.is_empty() { return Err(EngineError::Other(format!("{sidecar}: unsupported prior RAW settings: {}", report.ignored.join(", ")))); } - Ok(Some(settings)) + Ok(Some((settings, report.crop))) } #[cfg(target_arch = "wasm32")] -fn prior_settings(path: Option<&str>) -> Result> { +fn prior_settings(path: Option<&str>) -> Result> { if path.is_some() { Err(EngineError::Other("cannot read RAW sidecars on the web".into())) } else { Ok(None) } } @@ -67,6 +70,8 @@ pub(super) fn prepare(s: &Session, name: &str, path: Option<&str>, bytes: &[u8]) return Err(EngineError::Other("RAW sidecar opening requires a trusted session; no implicit read capability is available".into())); } let prior = prior_settings(path)?; + let crop = prior.as_ref().and_then(|(_, crop)| *crop); + let prior = prior.map(|(settings, _)| settings); let mut settings = prior.clone().unwrap_or_default(); let opts = crate::jobs::import_options(s.prefs()); let mut imported = photocraft_io::import_with_options(name, bytes, &opts).map_err(|error| EngineError::Other(format!("{name}: {error}")))?; @@ -87,6 +92,8 @@ pub(super) fn prepare(s: &Session, name: &str, path: Option<&str>, bytes: &[u8]) clarity: settings.clarity, texture: settings.texture, dehaze: settings.dehaze, + // The sidecar's crop, as the interactive open applies it: before the Camera Raw step. + crop, }; if tuning != (RawTuning { demosaic: opts.demosaic, ..Default::default() }) { super::open::raw_defaults(s, &imported.warnings, &mut imported.document); diff --git a/crates/engine/src/cmd/file_cmds/tests.rs b/crates/engine/src/cmd/file_cmds/tests.rs index d42d333128..33b1f60875 100644 --- a/crates/engine/src/cmd/file_cmds/tests.rs +++ b/crates/engine/src/cmd/file_cmds/tests.rs @@ -734,6 +734,27 @@ fn batch_quiet_raw_applies_prior_settings_and_workflow_without_touching_inputs() assert!(s.batch_open_notices.is_none()); } +/// #9985: quiet RAW opening crops to the sidecar's crop, as the interactive open does. +#[test] +fn batch_quiet_raw_applies_the_sidecar_crop() { + use photocraft_raw::testgen::{DngSpec, mosaic, scene}; + let dir = tmp("batch-quiet-raw-crop"); + let path = join(&dir, "raw.dng"); + let (w, h) = (24, 16); + let bytes = DngSpec::cfa(w, h, mosaic(&scene(w, h), w, [0, 1, 1, 2], 0, 65535)).build(); + std::fs::write(&path, &bytes).unwrap(); + let crop = photocraft_io::camera_raw_xmp::Crop { top: 0.25, left: 0.125, bottom: 0.75, right: 0.625 }; + let xmp = photocraft_io::camera_raw_xmp::save_with_crop(&photocraft_algo::camera_raw::CameraRaw::default(), Some(crop)); + std::fs::write(join(&dir, "raw.xmp"), &xmp).unwrap(); + let mut s = Session::new(); + let output = join(&dir, "out"); + let p = json!({"input":[path],"output":output,"format":"psd","suppressOpenOptions":true,"suppressWarnings":true,"steps":[]}); + let result = s.execute("file.automate.batch", p).unwrap(); + assert!(result["errors"].as_array().unwrap().is_empty(), "{result}"); + let actual = photocraft_io::import("raw.psd", &std::fs::read(join(&output, "raw.psd")).unwrap()).unwrap().document; + assert_eq!((actual.size.width, actual.size.height), (12, 8), "the 24x16 frame cropped to x 3..15, y 4..12"); +} + fn tmp(name: &str) -> String { static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); let id = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); From b170944c3e1af64309fd691a3ea94c8bf534563f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Sun, 8 Nov 2026 02:03:17 +0100 Subject: [PATCH 147/219] fix(engine): the Type tool converts only the text #12041 measured Paragraph (box) text and vertical type are on the issue's *Not measured* list, whose rule is that an unmeasured case keeps today's behaviour - and a coverage mask could not give back a box that reflows or a column that stacks its lines, so their editable type layer stays. The commit converts the measured case only: horizontal point text in an Indexed Color or a Bitmap document. The Type Mask tools keep turning both into the selection, as they do today; there the user asked for one. Also pin what happens when an edit that carries no coalesce key - the deliberately per-press Alt+arrow kerning step - comes between the typing and the commit: the commit is a state of its own, named for the type layer, exactly as the Type Mask commit and the plain layer's rename are in that situation today. Nothing is mis-named; folding those states together would delete history the user can undo today and belongs with #12039. Tests: commits_as_selection across shape and orientation; the tool's commit of box and vertical text in both modes; the split states and what each undo takes. --- crates/engine/src/cmd/type_cmds.rs | 28 ++++++-- .../engine/src/cmd/type_indexed_mode_tests.rs | 71 ++++++++++++++++++- crates/ui-egui/src/tools/type_tool/editing.rs | 58 +++++++++++++-- 3 files changed, 143 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/cmd/type_cmds.rs b/crates/engine/src/cmd/type_cmds.rs index 16d3d29a91..f1f8aa53c4 100644 --- a/crates/engine/src/cmd/type_cmds.rs +++ b/crates/engine/src/cmd/type_cmds.rs @@ -941,10 +941,21 @@ pub(crate) fn new_text_layer(s: &Session, p: &Value, cmd: &str) -> Result bool { - matches!(mode, ColorMode::Indexed | ColorMode::Bitmap) +pub fn commits_as_selection(mode: ColorMode, t: &TextLayer) -> bool { + let measured = matches!(t.shape, TextShape::Point) && t.orientation == Orientation::Horizontal; + measured && matches!(mode, ColorMode::Indexed | ColorMode::Bitmap) } /// Name the step the Type tool's commit made in such a document (#12041). The commit runs the Type /// Mask tools' `type.createSelection` with `removeLayer` - the same conversion, coalesced into the /// one step of the typing session - and that command names the step "Type Mask", while Photoshop's /// history state here is the type layer's own name. +/// +/// An edit that does not carry the session's `coalesce` key (the Alt+arrow kerning step, which is +/// deliberately one state per press) ends the session's step, exactly as it does for the Type Mask +/// tools and for the plain type layer's rename on commit; the conversion is then a state of its own +/// and takes this name too. Photoshop repeats a state name whenever the same thing happens again, +/// so the two states read as the two type-layer steps they are. pub fn name_selection_commit(s: &mut Session) { if let Some(st) = s.active_mut() { st.history.set_current_label(NEW_TYPE_LAYER_LABEL); diff --git a/crates/engine/src/cmd/type_indexed_mode_tests.rs b/crates/engine/src/cmd/type_indexed_mode_tests.rs index d60899c96f..8f6a8058fb 100644 --- a/crates/engine/src/cmd/type_indexed_mode_tests.rs +++ b/crates/engine/src/cmd/type_indexed_mode_tests.rs @@ -13,9 +13,16 @@ //! layer they edit and show on canvas with that command, on the first click already with text in it //! when Preferences > Type > "Fill new type layers with placeholder text" is on (the default), so a //! mode branch there would take away the layer the session types into. +//! +//! The issue measures the horizontal point text you click and type, and lists paragraph text and +//! vertical type under *Not measured*, where its rule is that today's behaviour stays: +//! [`only_the_measured_horizontal_point_text_converts_in_these_modes`] pins that half of the check, +//! and [`an_uncoalesced_edit_between_typing_and_the_commit_splits_the_states`] what happens to the +//! states when an edit outside the session's coalesce group comes between the typing and the commit. -use super::type_cmds::{commits_as_selection, name_selection_commit}; +use super::type_cmds::{commits_as_selection, name_selection_commit, new_text_layer}; use super::*; +use photocraft_doc::text::{Orientation, TextShape}; /// The typing session's coalesce key, as `ui-egui`'s `session_key` gives one. const SESSION: &str = "type-session-12041"; @@ -166,7 +173,8 @@ fn rgb_grayscale_cmyk_and_lab_keep_making_type_layers_at_every_depth() { let mut s = Session::new(); s.execute("file.new", json!({"width": 240, "height": 120, "mode": id, "depth": depth, "background": "transparent"})).unwrap(); assert_eq!(mode(&s), want, "{id} at {depth} bit: the document really is that mode"); - assert!(!commits_as_selection(want), "{id} keeps type layers"); + let point = new_text_layer(&s, &typed(false), "type.create").unwrap(); + assert!(!commits_as_selection(want, &point), "{id} keeps type layers"); let layer = s.execute("type.create", typed(true)).unwrap()["layer"].as_u64().unwrap(); // Today's commit: rename the session's layer in the same step, select nothing. @@ -218,3 +226,62 @@ fn a_refused_conversion_leaves_the_typed_text_and_the_document_alone() { assert_eq!(history(&s).last().cloned(), label, "{name}: the refused conversion renames nothing"); } } + +/// The measurement covers horizontal point text. Paragraph (box) text and vertical type are on the +/// issue's *Not measured* list, where its rule is that today's behaviour stays - and a coverage mask +/// could not give a reflowing box or a stacked column back anyway, so their editable layer stays. +#[test] +fn only_the_measured_horizontal_point_text_converts_in_these_modes() { + let s = indexed(); + let point = new_text_layer(&s, &typed(false), "type.create").unwrap(); + assert!(matches!(point.shape, TextShape::Point) && point.orientation == Orientation::Horizontal, "the fixture is horizontal point text"); + + let mut box_params = typed(false); + box_params["box"] = json!([20.0, 20.0, 180.0, 80.0]); + let paragraph = new_text_layer(&s, &box_params, "type.create").unwrap(); + assert!(matches!(paragraph.shape, TextShape::Box { .. }), "the fixture is paragraph text"); + + let mut vertical_params = typed(false); + vertical_params["orientation"] = json!("vertical"); + let vertical = new_text_layer(&s, &vertical_params, "type.create").unwrap(); + assert_eq!(vertical.orientation, Orientation::Vertical, "the fixture is vertical text"); + + for mode in [ColorMode::Indexed, ColorMode::Bitmap] { + assert!(commits_as_selection(mode, &point), "{mode:?}: the measured text becomes the selection"); + assert!(!commits_as_selection(mode, ¶graph), "{mode:?}: paragraph text keeps its type layer"); + assert!(!commits_as_selection(mode, &vertical), "{mode:?}: vertical text keeps its type layer"); + } +} + +/// An edit that does not carry the session's `coalesce` key ends its coalesce group - in the tool +/// that is the deliberately per-press Alt+arrow kerning step. The commit then makes a state of its +/// own, which is what the Type Mask tools' commit and the plain layer's rename do in the same +/// situation today; nothing is mis-named and nothing else moves. +#[test] +fn an_uncoalesced_edit_between_typing_and_the_commit_splits_the_states() { + for (name, make) in [("Indexed Color", indexed as fn() -> Session), ("Bitmap", bitmap as fn() -> Session)] { + let mut s = make(); + let layer = s.execute("type.create", typed(true)).unwrap()["layer"].as_u64().unwrap(); + // The stand-in for that kerning press: a real edit under no coalesce key. + s.execute("type.edit", json!({"layer": layer, "antialias": "sharp"})).unwrap(); + let before = history(&s); + assert_eq!(before.last().map(String::as_str), Some("Edit Type"), "{name}: the unkeyed edit is a state of its own"); + + commit(&mut s, layer); + + let h = history(&s); + assert_eq!(h.len(), before.len() + 1, "{name}: the commit is a further state once the group ended: {h:?}"); + assert_eq!(h.last().map(String::as_str), Some("New Type Layer"), "{name}: named for the type layer either way"); + assert_eq!(h[h.len() - 2].as_str(), "Edit Type", "{name}: the edit between them keeps its own name"); + assert_eq!(text_layers(&s), 0, "{name}: still no type layer is left"); + assert!(s.active().unwrap().doc.selection.is_some(), "{name}: still the glyphs are selected"); + + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.active().unwrap().doc.selection.is_none(), "{name}: one undo takes the conversion"); + assert_eq!(text_layers(&s), 1, "{name}: which brings the working layer back, as the mask tools' undo does"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(text_layers(&s), 1, "{name}: the next undo takes the unkeyed edit, not the session"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(text_layers(&s), 0, "{name}: and the third one takes the typing session"); + } +} diff --git a/crates/ui-egui/src/tools/type_tool/editing.rs b/crates/ui-egui/src/tools/type_tool/editing.rs index 20eccbce41..adb70c5574 100644 --- a/crates/ui-egui/src/tools/type_tool/editing.rs +++ b/crates/ui-egui/src/tools/type_tool/editing.rs @@ -478,12 +478,19 @@ fn commit_here(app: &mut PhotocraftApp, mods: egui::Modifiers) { // commit makes what a Type Mask tool's commit makes: the glyphs become the selection and the // layer goes, in the typing session's one history step - which keeps the "New Type Layer" name // it was opened with, as Photoshop's state there is named. - // `photocraft_engine::type_cmds::commits_as_selection` says why the check belongs to the commit - // and not to the `type.create` command. Two cases are not measured in these modes and keep - // today's behaviour: an edit of a layer the session did not create (`ed.created`), and a commit - // with no text in it, which is the empty-layer clean-up below. - let leaves_a_selection = ed.created && app.session.active().is_some_and(|st| photocraft_engine::type_cmds::commits_as_selection(st.doc.mode)); - if leaves_a_selection && !text.trim().is_empty() { + // `photocraft_engine::type_cmds::commits_as_selection` holds the ticket's scope - the two modes + // and the measured horizontal point text - and why the check belongs to the commit and not to + // the `type.create` command. Three cases are not measured in these modes and keep today's + // behaviour: an edit of a layer the session did not create (`ed.created`), paragraph (box) and + // vertical text, whose editability a coverage mask could not give back, and a commit with no + // text in it, which is the empty-layer clean-up below. + let session_layer = LayerId(ed.layer); + let converts = app + .session + .active() + .is_some_and(|st| text_layer(&st.doc, session_layer).is_some_and(|t| photocraft_engine::type_cmds::commits_as_selection(st.doc.mode, t))); + let leaves_a_selection = ed.created && !text.trim().is_empty() && converts; + if leaves_a_selection { if let Err(e) = app.run("type.createSelection", json!({"layer": ed.layer, "removeLayer": true, "operation": "new", "coalesce": ed.session})) { // The refused step left the document alone: keep today's type layer, so what was typed // is not lost with a selection that was never made. @@ -558,7 +565,7 @@ mod indexed_commit_tests { use crate::PhotocraftApp; use crate::state::Tool; use crate::tools::type_tool::pointer_up; - use photocraft_doc::{ColorMode, LayerContent}; + use photocraft_doc::{ColorMode, LayerContent, LayerId, TextLayer}; use serde_json::json; /// A new transparent document in `mode`: `rgb` keeps today's behaviour, `indexed` and `bitmap` @@ -587,6 +594,11 @@ mod indexed_commit_tests { app.session.active().unwrap().doc.mode } + fn text_of(app: &PhotocraftApp, id: LayerId) -> Option { + let doc = app.session.active().unwrap().doc.clone(); + super::text_layer(&doc, id).cloned() + } + fn text_layers(app: &PhotocraftApp) -> usize { app.session.active().unwrap().doc.walk().into_iter().filter(|(_, _, l)| matches!(l.content, LayerContent::Text(_))).count() } @@ -662,4 +674,36 @@ mod indexed_commit_tests { assert_eq!(states(&app), states_before, "{mode}: and no history state"); } } + + /// #12041 measures the horizontal point text you click and type. Paragraph (box) text and + /// vertical type are on its *Not measured* list, whose rule is that they keep today's + /// behaviour - and today's behaviour is the right one here anyway: a coverage mask cannot bring + /// back a box that reflows or a column that runs down the page, so their editable layer stays. + #[test] + fn paragraph_and_vertical_text_keep_their_type_layer_in_indexed_and_bitmap() { + use photocraft_doc::text::{Orientation, TextShape}; + + for mode in ["indexed", "bitmap"] { + // A dragged paragraph box. + let mut boxed = app(mode); + pointer_up(&mut boxed, [40.0, 60.0], [320.0, 180.0]); + insert(&mut boxed, "Hi"); + let box_layer = LayerId(boxed.ui.text_edit.as_ref().unwrap().layer); + assert!(matches!(text_of(&boxed, box_layer).map(|t| t.shape), Some(TextShape::Box { .. })), "{mode}: the setup made paragraph text"); + commit(&mut boxed); + assert_eq!(text_layers(&boxed), 1, "{mode}: paragraph text keeps its editable layer"); + assert!(selection(&boxed).is_none(), "{mode}: and is not turned into a selection"); + + // Vertical point text. + let mut column_app = app(mode); + column_app.ui.tool = Tool::VerticalType; + pointer_up(&mut column_app, [80.0, 120.0], [80.0, 120.0]); + insert(&mut column_app, "Hi"); + let column = LayerId(column_app.ui.text_edit.as_ref().unwrap().layer); + assert!(text_of(&column_app, column).is_some_and(|t| t.orientation == Orientation::Vertical), "{mode}: the setup made vertical text"); + commit(&mut column_app); + assert_eq!(text_layers(&column_app), 1, "{mode}: vertical text keeps its editable layer"); + assert!(selection(&column_app).is_none(), "{mode}: and is not turned into a selection"); + } + } } From b29fdc5a295f2a63245d6cb2523b38e93e4f7f09 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sat, 7 Nov 2026 20:05:43 -0500 Subject: [PATCH 148/219] Open as Smart Object: the opening snapshot shows the placed smart object (#11678) The smart object layer is built into the document before admission, so snapshot 0 holds it and the history is the single state "Open As Smart Object", as File > Open's is one state. Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove; Fable 5.1 briefed; Sonnet 5.5 wrote at medium --- crates/engine/src/cmd/file_cmds.rs | 1 + .../src/cmd/file_cmds/open_as_smart_object.rs | 46 +++++++++---------- .../file_cmds/open_as_smart_object_tests.rs | 20 ++++++-- 3 files changed, 41 insertions(+), 26 deletions(-) diff --git a/crates/engine/src/cmd/file_cmds.rs b/crates/engine/src/cmd/file_cmds.rs index 00efb839e1..d2cdf92d32 100644 --- a/crates/engine/src/cmd/file_cmds.rs +++ b/crates/engine/src/cmd/file_cmds.rs @@ -51,6 +51,7 @@ pub(crate) use open::{BatchOpen, batch_open_notice, batch_open_params, remember_ pub use open::{PLACE_EMBEDDED, open_bytes_as, open_bytes_as_with_options, place_bytes, raw_defaults}; pub(crate) use open::{PlaceInput, apply_place, import, import_with_options, tag_exr_colorspace}; use open::{approve_memory, close_all, close_others, open_as, place, revert}; +pub use open_as_smart_object::admit_as_smart_object; use open_as_smart_object::open_as_smart_object; use paths::is_file; #[cfg(test)] diff --git a/crates/engine/src/cmd/file_cmds/open_as_smart_object.rs b/crates/engine/src/cmd/file_cmds/open_as_smart_object.rs index 0d162b5238..f365170f34 100644 --- a/crates/engine/src/cmd/file_cmds/open_as_smart_object.rs +++ b/crates/engine/src/cmd/file_cmds/open_as_smart_object.rs @@ -1,12 +1,14 @@ //! File › Open as Smart Object… (#11678): a file opens as a new untitled document with the file's //! size, resolution, colour mode, bit depth and profile, holding one embedded smart object layer. //! -//! The layer is built as File › Place Embedded builds one (the same [`Prepared`] layer, inserted -//! by [`apply_place`]), at the file's own size and position `0, 0`. +//! The layer is built as File › Place Embedded builds one (an embedded smart object at the file's own +//! size and position `0, 0`) and put into the document before it is admitted, so the opening +//! snapshot already shows it. -use super::open::{Prepared, apply_place, import_opened}; +use super::open::import_opened; use super::paths::{file_name, read_file, stem, untitled_name}; -use super::{Affine, Arc, Document, EngineError, LayerContent, Result, Session, SmartObject, SmartSource, Value, flattened_checked, json, str_param}; +use super::{Affine, Arc, Document, EngineError, Layer, LayerContent, Result, Session, SmartObject, SmartSource, Value, flattened_checked, json, str_param}; +use crate::layer_place::Place; const CMD: &str = "file.openAsSmartObject"; @@ -49,7 +51,6 @@ fn open_one(s: &mut Session, path: &str) -> Result { let src = import_opened(s, path, &bytes)?; // The file as one image: the smart object's rendered contents. let pixels = flattened_checked(&src, src.pixel_format(), CMD)?; - let (w, h) = (f64::from(src.size.width), f64::from(src.size.height)); let layer_name = stem(path); // "-1", the next free number while a document with that name is open. let name = untitled_name(&layer_name, |n| s.documents().iter().any(|d| d.doc.name == n)); @@ -63,26 +64,25 @@ fn open_one(s: &mut Session, path: &str) -> Result { // Embedded (not linked): the original file name keeps its extension, so Edit Contents opens it. let source = SmartSource::Embedded { file_name: file_name(path), bytes: Arc::new(bytes) }; let so = SmartObject::new(source, Affine { m: [1.0, 0.0, 0.0, 1.0, 0.0, 0.0] }, Some(pixels)); - let prepared = Prepared { - content: LayerContent::Smart(so), - layer_name: layer_name.clone(), - label: LABEL, - scale: 1.0, - bounds: [0.0, 0.0, w, h], - smart: true, - frame: None, - }; + let layer = Layer::new(layer_name, LayerContent::Smart(so)); + let layer_id = layer.id; + crate::layer_place::insert_new(&mut doc, None, layer, Place::AutoEditable); + let index = admit_as_smart_object(s, doc); + Ok(json!({"document": index, "name": name, "layer": layer_id.0})) +} + +/// Admits a document built by Open as Smart Object (one smart object layer) as an untitled +/// document: the opening snapshot already shows the placed layer, is named after it, and the +/// history is the single state "Open As Smart Object", as File › Open's is one state. +pub fn admit_as_smart_object(s: &mut Session, doc: Document) -> usize { + let layer_name = doc.layers.first().map(|l| l.name.clone()); // Untitled: no path, so Save asks where to save. let index = s.add_document(doc, None); - // The opening snapshot is named after the layer, as the reference names it. - if let Some(first) = s.active_mut().and_then(|st| st.snapshots.iter_mut().find(|snap| snap.id == 0)) { - first.name = layer_name; - } - match apply_place(s, prepared) { - Ok(placed) => Ok(json!({"document": index, "name": name, "layer": placed["layer"]})), - Err(e) => { - s.close(index); - Err(e) + if let Some(st) = s.active_mut() { + if let (Some(first), Some(name)) = (st.snapshots.iter_mut().find(|snap| snap.id == 0), layer_name) { + first.name = name; } + st.history.set_current_label(LABEL); } + index } diff --git a/crates/engine/src/cmd/file_cmds/open_as_smart_object_tests.rs b/crates/engine/src/cmd/file_cmds/open_as_smart_object_tests.rs index 6378c47353..b6dce7b1e5 100644 --- a/crates/engine/src/cmd/file_cmds/open_as_smart_object_tests.rs +++ b/crates/engine/src/cmd/file_cmds/open_as_smart_object_tests.rs @@ -117,17 +117,31 @@ fn a_16_bit_psd_keeps_size_resolution_mode_depth_and_profile() { } #[test] -fn history_is_the_opening_snapshot_then_open_as_smart_object() { +fn history_is_the_single_state_open_as_smart_object() { let dir = tmp("history"); let path = write_png(&dir, "a_test.png", 16, 16); let mut s = Session::new(); s.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); let st = s.active().unwrap(); - assert_eq!(st.history.entries().last().map(String::as_str), Some("Open As Smart Object")); - assert_eq!(st.history.entries().len(), 2, "the opening state, then one step"); + assert_eq!(st.history.entries(), ["Open As Smart Object"], "one state, as File > Open has"); assert_eq!(st.snapshots.first().map(|snap| snap.name.as_str()), Some("a_test"), "the opening snapshot is named after the layer"); } +#[test] +fn the_opening_snapshot_shows_the_placed_smart_object() { + let dir = tmp("snapshot"); + let path = write_png(&dir, "a_test.png", 16, 16); + let mut s = Session::new(); + s.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + let st = s.active().unwrap(); + let first = st.snapshots.first().unwrap(); + assert_eq!(first.doc.layers.len(), 1, "snapshot 0 already holds the smart object"); + assert!(matches!(first.doc.layers[0].content, LayerContent::Smart(_))); + assert_eq!(first.layers.active, Some(first.doc.layers[0].id)); + assert_eq!(st.active_layer, Some(first.doc.layers[0].id)); + assert_eq!(st.history.entries(), ["Open As Smart Object"]); +} + #[test] fn the_number_is_the_next_free_one_and_starts_again_after_they_close() { let dir = tmp("numbers"); From caae1c8a1f29560766a27ca20ef718dd2772cc79 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 8 Nov 2026 02:19:35 +0100 Subject: [PATCH 149/219] Address CodeRabbit review: close the sampler menu when its sampler changes The Color Sampler menu kept only the sampler's index. If a control command deleted an earlier sampler while the menu was open, the index named another sampler and Delete removed that one. The menu now also remembers the active document and the sampler's position; when either no longer matches, Delete greys out and the menu closes. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/canvas_tool_menu.rs | 24 +++++++++++++++-- .../tools/analysis_ui/sampler_menu_tests.rs | 27 +++++++++++++++++++ crates/ui-egui/src/transform_type_tests.rs | 1 + 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu.rs b/crates/ui-egui/src/canvas/canvas_tool_menu.rs index 098304d147..50059007ad 100644 --- a/crates/ui-egui/src/canvas/canvas_tool_menu.rs +++ b/crates/ui-egui/src/canvas/canvas_tool_menu.rs @@ -50,6 +50,11 @@ pub struct CanvasToolMenu { /// `None` off a sampler, where every row is greyed. #[serde(default)] pub sampler: Option, + /// Where that sampler was when the menu opened: the active document's index and the sampler's + /// position. Once a control command deletes, moves or renumbers samplers, or another document + /// becomes active, the index may name a different sampler, so the menu closes and Delete greys. + #[serde(default)] + pub sampler_at: Option<(usize, [f64; 2])>, } /// A misspelled word in the type layer being edited (`edit.checkSpelling`'s `list` entry). @@ -308,6 +313,7 @@ pub fn open_crop(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; spelling: None, crop: Some(users), sampler: None, + sampler_at: None, }); crate::canvas_popup::mark_open(ctx, menu_id()); true @@ -478,6 +484,14 @@ pub const SAMPLER_MENU: &[Row] = &[ Some(("Total Ink", "colorSampler.readout.totalInk")), ]; +/// Does the Color Sampler menu's index still name the sampler it opened on, in the same document? +/// `true` off a sampler, where there is nothing to go stale. +fn sampler_current(app: &PhotocraftApp, menu: &CanvasToolMenu) -> bool { + let Some(i) = menu.sampler else { return true }; + let now = app.session.active_index().zip(app.session.active().and_then(|d| d.color_samplers.get(i).copied())); + now.is_some() && now == menu.sampler_at +} + /// Is `menu` the Color Sampler's own menu (not the pasteboard, guide or transform menu opened /// with it)? fn sampler_menu(menu: &CanvasToolMenu) -> bool { @@ -693,6 +707,7 @@ pub fn open_transform(app: &mut PhotocraftApp, ctx: &Context, pos: [f32; 2]) -> spelling: None, crop: None, sampler: None, + sampler_at: None, }); crate::canvas_popup::mark_open(ctx, menu_id()); true @@ -775,6 +790,7 @@ pub fn open_pasteboard(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: spelling: None, crop: None, sampler: None, + sampler_at: None, }); crate::canvas_popup::mark_open(ctx, menu_id()); true @@ -807,6 +823,7 @@ pub fn open_guide(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, mods: egui spelling: None, crop: None, sampler: None, + sampler_at: None, }); crate::canvas_popup::mark_open(ctx, menu_id()); true @@ -861,8 +878,7 @@ pub fn entry_enabled(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) } // Off a sampler every row is greyed. The readout rows wait for per-sampler readout modes. if sampler_menu(menu) { - let count = app.session.active().map_or(0, |d| d.color_samplers.len()); - return command == SAMPLER_DELETE && menu.sampler.is_some_and(|i| i < count); + return command == SAMPLER_DELETE && menu.sampler.is_some() && sampler_current(app, menu); } if menu.transform || !path_menu(menu.tool) { return crate::menus::is_enabled(app, command); @@ -925,6 +941,7 @@ pub fn open_at(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; 2] let path_name = crate::vector_ui::active_path_name(app); let spelling = if text { doc.and_then(|d| spelling_at(app, d)) } else { None }; let sampler = if tool == Tool::ColorSampler { doc.and_then(|d| crate::analysis_ui::sampler_at(app, d)) } else { None }; + let sampler_at = sampler.and_then(|i| Some((app.session.active_index()?, *app.session.active()?.color_samplers.get(i)?))); app.ui.canvas_tool_menu = Some(CanvasToolMenu { pos, tool, @@ -940,6 +957,7 @@ pub fn open_at(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; 2] spelling, crop: None, sampler, + sampler_at, }); crate::canvas_popup::mark_open(ctx, menu_id()); true @@ -968,6 +986,7 @@ pub fn open_view(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; spelling: None, crop: None, sampler: None, + sampler_at: None, }); crate::canvas_popup::mark_open(ctx, menu_id()); true @@ -1213,6 +1232,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &Context) { || (menu.text && app.ui.text_edit.is_none()) || (menu.type_layer && app.ui.text_edit.is_some()) || (menu.crop.is_some() && !crate::crop_ui::pending(app)) + || (sampler_menu(&menu) && !sampler_current(app, &menu)) { app.ui.canvas_tool_menu = None; return; diff --git a/crates/ui-egui/src/tools/analysis_ui/sampler_menu_tests.rs b/crates/ui-egui/src/tools/analysis_ui/sampler_menu_tests.rs index feef455427..9388a31f68 100644 --- a/crates/ui-egui/src/tools/analysis_ui/sampler_menu_tests.rs +++ b/crates/ui-egui/src/tools/analysis_ui/sampler_menu_tests.rs @@ -112,6 +112,33 @@ fn delete_removes_the_sampler_under_the_pointer_and_renumbers_the_rest() { assert_eq!(listed["samplers"][1]["number"], json!(2)); } +#[test] +fn delete_never_removes_another_sampler_after_the_list_changes_under_the_menu() { + let (mut app, ctx) = app(); + app.run("view.colorSamplers.add", json!({"x": 180, "y": 90})).unwrap(); + right_click(&mut app, &ctx, 120.0, 60.0); + assert_eq!(app.ui.canvas_tool_menu.as_ref().and_then(|m| m.sampler), Some(1)); + // An agent deletes #1 while the menu is open: index 1 now names the old #3. + app.run("view.colorSamplers.delete", json!({"index": 0})).unwrap(); + let r = control(&mut app, &ctx, "ui.context.choose", json!({"id": SAMPLER_DELETE})); + assert_eq!(r["ok"], json!(false), "Delete greys out: {r}"); + assert_eq!(samplers(&app), [[120.0, 60.0], [180.0, 90.0]], "no other sampler is deleted"); + // The menu itself closes on the next frame. + right_click(&mut app, &ctx, 120.0, 60.0); + app.run("view.colorSamplers.move", json!({"index": 0, "x": 30, "y": 30})).unwrap(); + ctx.run_ui(Default::default(), |ui| crate::canvas_tool_menu::show(&mut app, ui.ctx())).textures_delta.clear(); + assert!(app.ui.canvas_tool_menu.is_none(), "a moved sampler closes the menu"); + // Another document with a sampler at the same index and spot: still not this menu's sampler. + right_click(&mut app, &ctx, 180.0, 90.0); + assert_eq!(app.ui.canvas_tool_menu.as_ref().and_then(|m| m.sampler), Some(1)); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + for (x, y) in [(20, 20), (180, 90)] { + app.run("view.colorSamplers.add", json!({"x": x, "y": y})).unwrap(); + } + let menu = app.ui.canvas_tool_menu.clone().expect("the menu is still open"); + assert!(!crate::canvas_tool_menu::entry_enabled(&app, &menu, SAMPLER_DELETE), "another document's sampler"); +} + #[test] fn readout_rows_do_nothing_until_samplers_have_readout_modes() { let (mut app, ctx) = app(); diff --git a/crates/ui-egui/src/transform_type_tests.rs b/crates/ui-egui/src/transform_type_tests.rs index c62e7cf87d..e179e4f6d5 100644 --- a/crates/ui-egui/src/transform_type_tests.rs +++ b/crates/ui-egui/src/transform_type_tests.rs @@ -57,6 +57,7 @@ fn type_layers_offer_free_transform_but_not_distort_or_perspective() { spelling: None, crop: None, sampler: None, + sampler_at: None, }; assert!(crate::canvas_tool_menu::available(&app, &menu, "edit.freeTransform")); assert!(!crate::canvas_tool_menu::available(&app, &menu, "edit.transform.distort")); From 22882f690c1775a9447715e524a7849da17bea67 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 8 Nov 2026 02:24:08 +0100 Subject: [PATCH 150/219] Background Eraser: alpha falls off over the whole Tolerance (#11901) One dab now erases a colour less the further it is from the sampled colour, over the whole Tolerance, instead of fully up to the last quarter of it. The weight is 1 - clamp((d - d0) / (T - d0), 0, 1) with d0 = min(T / 20, 8 levels), measured with single clicks in the reference application. The colour distance is the largest increase plus the largest decrease over the channels, so opposite-sign changes add up. Find Edges keeps its hard cut (#11904). Co-Authored-By: Claude Opus 5.5 --- crates/paint/src/bg_erase.rs | 37 +++- .../paint/src/bg_erase_graded_alpha_tests.rs | 177 ++++++++++++++++++ 2 files changed, 208 insertions(+), 6 deletions(-) create mode 100644 crates/paint/src/bg_erase_graded_alpha_tests.rs diff --git a/crates/paint/src/bg_erase.rs b/crates/paint/src/bg_erase.rs index 323a90036a..751d58519b 100644 --- a/crates/paint/src/bg_erase.rs +++ b/crates/paint/src/bg_erase.rs @@ -40,9 +40,31 @@ impl Default for BgEraseSettings { } } -/// Largest per-channel difference over the colour channels. +/// Colour distance of `a` from `b` over the colour channels: the largest increase plus the largest +/// decrease (#11901). Changes in the same direction count like the largest of them; changes in +/// opposite directions add up, as the reference application measures them (a red-to-green edge is +/// about twice as far as one channel alone). One channel (Gray) gives the absolute difference. fn dist(a: &[f32], b: &[f32]) -> f32 { - a.iter().zip(b).fold(0.0f32, |m, (x, y)| m.max((x - y).abs())) + let (up, down) = a.iter().zip(b).fold((0.0f32, 0.0f32), |(up, down), (x, y)| { + let diff = x - y; + (up.max(diff), down.max(-diff)) + }); + up + down +} + +/// The match weight of one dab for a colour `d` away from the sampled colour (#11901): an exact +/// match is erased fully, then the weight falls off linearly over the whole Tolerance `tol` after +/// a short plateau `d0 = min(tol / 20, 8 levels)`, and colours at or beyond the Tolerance are kept. +/// Measured in the reference application with single clicks (one dab) at Tolerance 10 to 100. +fn graded_weight(d: f32, tol: f32) -> f32 { + if !d.is_finite() || d >= tol { + return if d <= 0.0 { 1.0 } else { 0.0 }; + } + let d0 = (tol / 20.0).min(8.0 / 255.0); + if d <= d0 { + return 1.0; + } + (1.0 - (d - d0) / (tol - d0)).clamp(0.0, 1.0) } /// Erase along `stroke`. The brush opacity is ignored (Photoshop's Background Eraser has none); @@ -77,11 +99,10 @@ pub fn background_eraser_effect(pre: &Surface, bs: &BgEraseSettings) -> Option>> = None; let match_w = move |d: f32| -> f32 { - if d > tol { + if soft { + graded_weight(d, tol) + } else if d > tol { 0.0 - } else if soft && tol > 0.0 { - // Soft falloff over the last quarter of the tolerance. - ((tol - d) / (tol * 0.25)).clamp(0.0, 1.0) } else { 1.0 } @@ -191,6 +212,10 @@ fn keep_connected(weight: &mut [f32], w: usize, h: usize, sx: usize, sy: usize) } } +#[cfg(test)] +#[path = "bg_erase_graded_alpha_tests.rs"] +mod graded_alpha_tests; + #[cfg(test)] mod tests { use super::*; diff --git a/crates/paint/src/bg_erase_graded_alpha_tests.rs b/crates/paint/src/bg_erase_graded_alpha_tests.rs new file mode 100644 index 0000000000..51e1bbc20d --- /dev/null +++ b/crates/paint/src/bg_erase_graded_alpha_tests.rs @@ -0,0 +1,177 @@ +//! Background Eraser graded alpha (#11901): one dab erases a colour less the further it is from the +//! sampled colour, over the whole Tolerance, not fully up to the Tolerance. Expected values are the +//! reference application's single-click measurements from the issue (8-bit RGB, 40 px hard round +//! tip, Sampling Background Swatch with background (200,40,40), alpha at the patch centre). + +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_geom::Rect; +use photocraft_raster::{Surface, from_rgba}; + +use crate::bg_erase::{BgEraseSettings, apply_background_eraser}; +use crate::replace::{Limits, Sampling}; +use crate::{BrushSettings, Stroke, StrokePoint}; + +/// The sampled colour of every measurement: (200,40,40). +const SAMPLE: [i32; 3] = [200, 40, 40]; + +/// Allowed deviation from the measured alpha ("within 8 levels"). +const LEVELS: i32 = 8; + +fn rgb(c: [i32; 3]) -> [f32; 4] { + [c[0] as f32 / 255.0, c[1] as f32 / 255.0, c[2] as f32 / 255.0, 1.0] +} + +fn click(at: (f64, f64)) -> Stroke { + Stroke { + brush: BrushSettings { size: 40.0, hardness: 1.0, spacing: 0.25, pressure_size: false, ..Default::default() }, + points: vec![StrokePoint::new(at.0, at.1, 1.0)], + } +} + +fn swatch(tolerance_percent: f32, background: [f32; 4]) -> BgEraseSettings { + BgEraseSettings { + sampling: Sampling::BackgroundSwatch, + limits: Limits::Discontiguous, + tolerance: tolerance_percent / 100.0, + background, + ..Default::default() + } +} + +/// One click in the centre of a 100 px patch of `colour` (straight RGBA) in `fmt`; alpha 0-255 at +/// the centre. +fn one_click(fmt: PixelFormat, colour: [f32; 4], bs: &BgEraseSettings) -> i32 { + let mut s = Surface::new(fmt); + s.fill_rect(Rect::new(0, 0, 100, 100), &from_rgba(&fmt, colour)); + apply_background_eraser(&mut s, &click((50.0, 50.0)), bs, None); + (s.rgba(50, 50)[3] * 255.0).round() as i32 +} + +/// The measured single-click table: (Tolerance, [(green difference d, alpha)]). +fn single_click_table() -> Vec<(f32, Vec<(i32, i32)>)> { + vec![ + (10.0, vec![(10, 92), (20, 194), (30, 255), (40, 255)]), + (30.0, vec![(10, 21), (20, 56), (30, 91), (40, 126), (50, 161), (60, 196), (80, 255), (100, 255)]), + (50.0, vec![(10, 8), (20, 29), (30, 50), (40, 71), (50, 92), (60, 113), (80, 155), (100, 196), (120, 238), (140, 255)]), + (70.0, vec![(10, 3), (20, 18), (30, 33), (40, 48), (50, 63), (60, 78), (80, 107), (100, 137), (120, 167), (140, 197), (160, 227)]), + (100.0, vec![(10, 2), (20, 12), (30, 23), (40, 33), (50, 43), (60, 54), (80, 74), (100, 95), (120, 116), (140, 136), (160, 157)]), + ] +} + +fn assert_near(got: i32, want: i32, what: &str) { + assert!((got - want).abs() <= LEVELS, "{what}: got alpha {got}, measured {want}"); +} + +#[test] +fn one_click_falls_off_over_the_whole_tolerance() { + let bs = |tol| swatch(tol, rgb(SAMPLE)); + for (tol, row) in single_click_table() { + for (d, want) in row { + let got = one_click(PixelFormat::RGBA8, rgb([SAMPLE[0], SAMPLE[1] + d, SAMPLE[2]]), &bs(tol)); + assert_near(got, want, &format!("Tolerance {tol}, green +{d}")); + } + } +} + +#[test] +fn same_sign_differences_count_once_and_opposite_signs_add_up() { + // Measured at Tolerance 50, single clicks, sample (200,40,40): (difference per channel, alpha). + let rows: [([i32; 3], i32); 15] = [ + ([0, 20, 0], 29), + ([-20, 0, 0], 29), + ([0, 0, 20], 29), + ([0, 20, 20], 29), + ([0, 40, 20], 71), + ([0, 20, 40], 71), + ([0, 60, 20], 113), + ([-20, 20, 0], 71), + ([-40, 40, 0], 155), + ([-60, 60, 0], 238), + ([10, 10, 10], 8), + ([20, 20, 20], 29), + ([40, 40, 40], 71), + ([-10, -10, -10], 8), + ([-40, -40, -40], 71), + ]; + let bs = swatch(50.0, rgb(SAMPLE)); + for (diff, want) in rows { + let colour = [SAMPLE[0] + diff[0], SAMPLE[1] + diff[1], SAMPLE[2] + diff[2]]; + assert_near(one_click(PixelFormat::RGBA8, rgb(colour), &bs), want, &format!("difference {diff:?}")); + } +} + +#[test] +fn a_blurred_edge_keeps_its_transition_graded() { + // Red (220,30,30) | green (30,180,60) at x 200, blurred with a 4 px Gaussian; the measured red + // channel along y 70 from x 185. A Gaussian blur mixes every channel with the same weights, so + // green and blue follow red's mix fraction. + const RED: [i32; 30] = + [220, 220, 220, 220, 219, 219, 217, 214, 210, 203, 194, 182, 168, 152, 134, 116, 98, 82, 68, 56, 47, 40, 36, 33, 31, 31, 30, 30, 30, 30]; + const ALPHA: [i32; 15] = [0, 0, 0, 0, 0, 0, 0, 4, 21, 46, 79, 123, 178, 238, 255]; + let fmt = PixelFormat::RGBA8; + let mut s = Surface::new(fmt); + s.fill_rect(Rect::new(0, 0, 185, 140), &from_rgba(&fmt, rgb([220, 30, 30]))); + s.fill_rect(Rect::new(215, 0, 400, 140), &from_rgba(&fmt, rgb([30, 180, 60]))); + for (i, r) in RED.iter().enumerate() { + let t = (220 - r) as f32 / 190.0; + let c = [*r as f32 / 255.0, (30.0 + 150.0 * t) / 255.0, (30.0 + 30.0 * t) / 255.0, 1.0]; + let x = 185 + i as i32; + s.fill_rect(Rect::new(x, 0, x + 1, 140), &from_rgba(&fmt, c)); + } + for limits in [Limits::Contiguous, Limits::Discontiguous] { + let mut t = s.clone(); + let bs = BgEraseSettings { sampling: Sampling::Once, limits, tolerance: 0.5, ..Default::default() }; + apply_background_eraser(&mut t, &click((190.0, 70.0)), &bs, None); + for (i, want) in ALPHA.iter().enumerate() { + let x = 185 + i as i32; + let got = (t.rgba(x, 70)[3] * 255.0).round() as i32; + assert_near(got, *want, &format!("{limits:?} x {x}")); + } + for x in 200..215 { + assert_eq!(t.rgba(x, 70)[3], 1.0, "{limits:?} x {x} beyond the Tolerance kept"); + } + } +} + +#[test] +fn an_exact_match_is_erased_and_far_colours_are_kept_at_every_tolerance() { + for tol in [0.0, 1.0, 10.0, 30.0, 50.0, 70.0, 100.0] { + let bs = swatch(tol, rgb(SAMPLE)); + assert_eq!(one_click(PixelFormat::RGBA8, rgb(SAMPLE), &bs), 0, "Tolerance {tol}: the sample itself"); + if tol < 100.0 { + // Opposite-sign differences of 255 + 215: beyond every Tolerance below 100 %. + assert_eq!(one_click(PixelFormat::RGBA8, rgb([0, 255, 40]), &bs), 255, "Tolerance {tol}: a far colour"); + } + } + // Tolerance 0 erases nothing but the exact colour. + assert_eq!(one_click(PixelFormat::RGBA8, rgb([200, 41, 40]), &swatch(0.0, rgb(SAMPLE))), 255); +} + +#[test] +fn the_distance_is_measured_in_the_documents_own_channels() { + // 16-bit RGB: the same single-click table as 8-bit. + let rgb16 = PixelFormat::new(ColorMode::Rgb, SampleType::U16, true); + for (tol, row) in single_click_table() { + for (d, want) in row { + let got = one_click(rgb16, rgb([SAMPLE[0], SAMPLE[1] + d, SAMPLE[2]]), &swatch(tol, rgb(SAMPLE))); + assert_near(got, want, &format!("16-bit Tolerance {tol}, green +{d}")); + } + } + // Gray (8 and 16 bit): the distance is the absolute difference of the gray levels, so a gray + // patch d levels from the swatch erases like a one-channel difference of d in RGB, either way. + for sample in [SampleType::U8, SampleType::U16] { + let gray = PixelFormat::new(ColorMode::Grayscale, sample, true); + let level = |v: i32| [v as f32 / 255.0, v as f32 / 255.0, v as f32 / 255.0, 1.0]; + for (tol, row) in single_click_table() { + for (d, want) in row { + let bs = swatch(tol, level(60)); + if 60 + d <= 255 { + assert_near(one_click(gray, level(60 + d), &bs), want, &format!("{sample:?} gray Tolerance {tol}, +{d}")); + } + if d <= 60 { + assert_near(one_click(gray, level(60 - d), &bs), want, &format!("{sample:?} gray Tolerance {tol}, -{d}")); + } + } + } + } +} From 63ec0bef12121727964607451a09f6ad6c82d438 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 7 Nov 2026 19:31:35 -0600 Subject: [PATCH 151/219] Blend mode keys leave Behind when they pick another mode (#11896) Behind is one of the painting modes in the reference application: a Shift+Alt letter or a Shift+minus/plus step that picks another mode (or Clear) now also turns Behind off, in the same one tools.setBrush. With Behind off the keys send what they did before. --- .../src/brush_blend_keys_behind_tests.rs | 122 ++++++++++++++++++ crates/ui-egui/src/input/blend_keys.rs | 15 ++- 2 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/src/brush_blend_keys_behind_tests.rs diff --git a/crates/ui-egui/src/brush_blend_keys_behind_tests.rs b/crates/ui-egui/src/brush_blend_keys_behind_tests.rs new file mode 100644 index 0000000000..372bd5aca9 --- /dev/null +++ b/crates/ui-egui/src/brush_blend_keys_behind_tests.rs @@ -0,0 +1,122 @@ +//! #11896: Behind is one of the painting modes, so after Behind a Shift+Alt letter or a +//! Shift+minus/plus step that sets another mode leaves Behind, in the same one `tools.setBrush`. +//! With Behind off the keys work as before. + +use egui::{Key, Modifiers}; +use serde_json::{Value, json}; + +use super::*; + +const SHIFT_ALT: Modifiers = Modifiers { alt: true, ctrl: false, shift: true, mac_cmd: false, command: false }; + +/// A 64 × 64 document with a layer filled 220,30,30, black foreground, the Brush. +fn app(tool: Tool) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("edit.fill", json!({"color": "#dc1e1e"})).unwrap(); + app.run("tools.setColors", json!({"foreground": "#000000"})).unwrap(); + app.ui.tool = tool; + app +} + +/// One key press with `modifiers` through the app's shortcut handling. +fn key(app: &mut PhotocraftApp, ctx: &egui::Context, key: Key, modifiers: Modifiers) { + let raw = egui::RawInput { + events: vec![egui::Event::ModifiersChanged(modifiers), egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }], + ..Default::default() + }; + ctx.begin_pass(raw); + crate::shortcuts::handle(app, ctx); + ctx.end_pass().textures_delta.clear(); +} + +fn brush(app: &mut PhotocraftApp) -> Value { + app.run("brush.get", json!({})).unwrap() +} + +fn behind_on(app: &mut PhotocraftApp, ctx: &egui::Context) { + key(app, ctx, Key::Q, SHIFT_ALT); + assert_eq!(brush(app)["behind"], true, "Shift+Alt+Q turns Behind on"); +} + +fn journal(app: &PhotocraftApp) -> usize { + app.session.journal.len() +} + +/// The layer's pixel at (x, y) as 8-bit RGB. +fn pixel(app: &PhotocraftApp, x: i32, y: i32) -> [u8; 3] { + let st = app.session.active().unwrap(); + let c = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().rgba(x, y); + [0, 1, 2].map(|i| (c[i] * 255.0).round() as u8) +} + +#[test] +fn shift_alt_m_after_behind_multiplies_over_the_pixels() { + let ctx = egui::Context::default(); + let mut app = app(Tool::Brush); + behind_on(&mut app, &ctx); + let n = journal(&app); + key(&mut app, &ctx, Key::M, SHIFT_ALT); + let b = brush(&mut app); + assert_eq!((b["behind"].clone(), b["mode"].clone()), (json!(false), json!("Multiply")), "{b}"); + assert_eq!(journal(&app), n + 1, "one tools.setBrush"); + // A black stroke across the red layer multiplies it to black, not only under transparency. + app.run("paint.stroke", json!({"points": [[4, 32], [60, 32]], "size": 9, "hardness": 100})).unwrap(); + assert_eq!(pixel(&app, 32, 32), [0, 0, 0]); +} + +#[test] +fn shift_alt_n_l_and_r_after_behind_leave_it() { + let ctx = egui::Context::default(); + for (letter, mode) in [(Key::N, "Normal"), (Key::L, "HardMix")] { + let mut app = app(Tool::Brush); + behind_on(&mut app, &ctx); + let n = journal(&app); + key(&mut app, &ctx, letter, SHIFT_ALT); + let b = brush(&mut app); + assert_eq!(b["behind"], false, "{letter:?}: {b}"); + assert_eq!(b["mode"], json!(mode), "{letter:?}: {b}"); + assert_eq!(journal(&app), n + 1, "{letter:?}: one tools.setBrush"); + } + // Clear: Behind off and Clear on. + let mut app = app(Tool::Brush); + behind_on(&mut app, &ctx); + let n = journal(&app); + key(&mut app, &ctx, Key::R, SHIFT_ALT); + assert_eq!(brush(&mut app)["behind"], false); + assert!(app.ui.tool_options.brush_clear, "Clear on"); + assert_eq!(journal(&app), n + 1, "one tools.setBrush"); + // The Pencil too. + let mut app = self::app(Tool::Pencil); + behind_on(&mut app, &ctx); + key(&mut app, &ctx, Key::M, SHIFT_ALT); + assert_eq!(brush(&mut app)["behind"], false); +} + +#[test] +fn a_shift_step_after_behind_leaves_it() { + let ctx = egui::Context::default(); + for step in [Key::Minus, Key::Plus] { + let mut app = app(Tool::Brush); + behind_on(&mut app, &ctx); + let n = journal(&app); + key(&mut app, &ctx, step, Modifiers::SHIFT); + assert_eq!(brush(&mut app)["behind"], false, "{step:?}"); + assert_eq!(journal(&app), n + 1, "{step:?}: one tools.setBrush"); + } +} + +#[test] +fn with_behind_off_the_keys_send_what_they_did() { + let ctx = egui::Context::default(); + let mut app = app(Tool::Brush); + key(&mut app, &ctx, Key::M, SHIFT_ALT); + let (id, params) = app.session.journal.last().cloned().unwrap(); + assert_eq!((id.as_str(), params), ("tools.setBrush", json!({"brush": {"mode": "Multiply"}})), "only the mode, as before"); + // Clear with Behind off sends nothing, as before (Clear is the options' flag). + let n = journal(&app); + key(&mut app, &ctx, Key::R, SHIFT_ALT); + assert_eq!(journal(&app), n); + assert!(app.ui.tool_options.brush_clear); +} diff --git a/crates/ui-egui/src/input/blend_keys.rs b/crates/ui-egui/src/input/blend_keys.rs index fb3d5d47b5..ebb018b381 100644 --- a/crates/ui-egui/src/input/blend_keys.rs +++ b/crates/ui-egui/src/input/blend_keys.rs @@ -202,10 +202,19 @@ fn set_tool_mode(app: &mut PhotocraftApp, mode: Option) { _ => {} } crate::paint_mouse::sync_tool_brush(app); + // Behind is one of the painting modes in the reference application: picking any other mode + // (Clear too) leaves it, in the same one command (#11896). With Behind off, as before. + let mut brush = serde_json::Map::new(); if let Some(m) = mode && let Ok(m) = serde_json::to_value(m) { - let _ = app.run("tools.setBrush", json!({ "brush": { "mode": m } })); + brush.insert("mode".into(), m); + } + if app.session.tools.brush.behind { + brush.insert("behind".into(), json!(false)); + } + if !brush.is_empty() { + let _ = app.run("tools.setBrush", json!({ "brush": brush })); } } @@ -299,3 +308,7 @@ mod tests; #[cfg(test)] #[path = "../brush_behind_key_tests.rs"] mod brush_behind_key_tests; + +#[cfg(test)] +#[path = "../brush_blend_keys_behind_tests.rs"] +mod brush_blend_keys_behind_tests; From 23f63c685eb3053f6434cdacc4b27afeb32e39c8 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 8 Nov 2026 02:31:23 +0100 Subject: [PATCH 152/219] Background Eraser: Find Edges erases soft edges graded instead of cutting them hard (#11904) Find Edges used to give every pixel within the Tolerance full weight, so on a blurred edge it erased more than Contiguous. It now uses the same graded weight (#11901), which keeps the transition as the reference application does and still gives the Contiguous result on a hard edge. Co-Authored-By: Claude Opus 5.5 --- crates/paint/src/bg_erase.rs | 17 ++- crates/paint/src/bg_erase_find_edges_tests.rs | 101 ++++++++++++++++++ 2 files changed, 108 insertions(+), 10 deletions(-) create mode 100644 crates/paint/src/bg_erase_find_edges_tests.rs diff --git a/crates/paint/src/bg_erase.rs b/crates/paint/src/bg_erase.rs index 751d58519b..2e8da047fc 100644 --- a/crates/paint/src/bg_erase.rs +++ b/crates/paint/src/bg_erase.rs @@ -92,21 +92,14 @@ pub fn background_eraser_effect(pre: &Surface, bs: &BgEraseSettings) -> Option Vec { from_rgba(&fmt, [c[0], c[1], c[2], 1.0])[..nc].to_vec() }; let swatch = native(bs.background); let fg = native(bs.foreground); let (sampling, limits, protect_foreground) = (bs.sampling, bs.limits, bs.protect_foreground); let mut once: Option>> = None; - let match_w = move |d: f32| -> f32 { - if soft { - graded_weight(d, tol) - } else if d > tol { - 0.0 - } else { - 1.0 - } - }; + // Find Edges grades soft edges like Contiguous does instead of cutting them hard (#11904): + // the reference application keeps at least as much of a blurred transition with it. + let match_w = move |d: f32| -> f32 { graded_weight(d, tol) }; let pre = pre.clone(); // The pixels before the stroke under the working copy, read once per working area. Sampling // and matching read these, so a dab landing on pixels earlier dabs already erased still @@ -216,6 +209,10 @@ fn keep_connected(weight: &mut [f32], w: usize, h: usize, sx: usize, sy: usize) #[path = "bg_erase_graded_alpha_tests.rs"] mod graded_alpha_tests; +#[cfg(test)] +#[path = "bg_erase_find_edges_tests.rs"] +mod find_edges_tests; + #[cfg(test)] mod tests { use super::*; diff --git a/crates/paint/src/bg_erase_find_edges_tests.rs b/crates/paint/src/bg_erase_find_edges_tests.rs new file mode 100644 index 0000000000..ee95dd8c2e --- /dev/null +++ b/crates/paint/src/bg_erase_find_edges_tests.rs @@ -0,0 +1,101 @@ +//! Background Eraser Limits Find Edges (#11904): a blurred edge is erased graded, keeping at least +//! as much of the transition as Contiguous does, instead of cut hard at the Tolerance. Expected +//! values are the reference application's measurements from the issue (8-bit RGB, 40 px and 60 px +//! hard round tips, Tolerance 50, Sampling Continuous). + +use photocraft_color::PixelFormat; +use photocraft_geom::Rect; +use photocraft_raster::{Surface, from_rgba}; + +use crate::bg_erase::{BgEraseSettings, apply_background_eraser}; +use crate::replace::{Limits, Sampling}; +use crate::{BrushSettings, Stroke, StrokePoint}; + +/// Measured red channel along y 70 from x 185 across the 4 px Gaussian red|green edge at x 200. +const RED: [i32; 30] = [220, 220, 220, 220, 219, 219, 217, 214, 210, 203, 194, 182, 168, 152, 134, 116, 98, 82, 68, 56, 47, 40, 36, 33, 31, 31, 30, 30, 30, 30]; +/// Alpha along y 70 from x 185 after a click at 190,70. +const FIND_EDGES: [i32; 15] = [8, 8, 8, 4, 4, 0, 0, 0, 17, 42, 76, 121, 176, 238, 255]; +const CONTIGUOUS: [i32; 15] = [0, 0, 0, 0, 0, 0, 0, 4, 21, 46, 79, 123, 178, 238, 255]; + +fn rgb(c: [i32; 3]) -> [f32; 4] { + [c[0] as f32 / 255.0, c[1] as f32 / 255.0, c[2] as f32 / 255.0, 1.0] +} + +/// The issue's test layer: red | green | blue | pink stripes (y 0-300), a white gap at y 140-160 +/// across the red stripe, and the red|green edge blurred over y 0-130. A Gaussian blur mixes every +/// channel with the same weights, so green and blue follow red's mix fraction. +fn layer() -> Surface { + let fmt = PixelFormat::RGBA8; + let mut s = Surface::new(fmt); + for (x0, c) in [(0, [220, 30, 30]), (200, [30, 180, 60]), (400, [40, 60, 220]), (600, [240, 120, 120])] { + s.fill_rect(Rect::new(x0, 0, x0 + 200, 300), &from_rgba(&fmt, rgb(c))); + } + s.fill_rect(Rect::new(0, 140, 200, 160), &from_rgba(&fmt, rgb([255, 255, 255]))); + for (i, r) in RED.iter().enumerate() { + let t = (220 - r) as f32 / 190.0; + let c = [*r as f32 / 255.0, (30.0 + 150.0 * t) / 255.0, (30.0 + 30.0 * t) / 255.0, 1.0]; + let x = 185 + i as i32; + s.fill_rect(Rect::new(x, 0, x + 1, 130), &from_rgba(&fmt, c)); + } + s +} + +fn click(at: (f64, f64), size: f32, limits: Limits) -> Surface { + let mut s = layer(); + let stroke = Stroke { + brush: BrushSettings { size, hardness: 1.0, spacing: 0.25, pressure_size: false, ..Default::default() }, + points: vec![StrokePoint::new(at.0, at.1, 1.0)], + }; + let bs = BgEraseSettings { sampling: Sampling::Continuous, limits, tolerance: 0.5, ..Default::default() }; + apply_background_eraser(&mut s, &stroke, &bs, None); + s +} + +fn alpha(s: &Surface, x: i32, y: i32) -> i32 { + (s.rgba(x, y)[3] * 255.0).round() as i32 +} + +#[test] +fn find_edges_grades_a_blurred_edge_instead_of_cutting_it() { + let fe = click((190.0, 70.0), 40.0, Limits::FindEdges); + let co = click((190.0, 70.0), 40.0, Limits::Contiguous); + for (i, want) in FIND_EDGES.iter().enumerate() { + let x = 185 + i as i32; + let (f, c) = (alpha(&fe, x, 70), alpha(&co, x, 70)); + assert!((f - want).abs() <= 10, "x {x}: Find Edges alpha {f}, measured {want}"); + assert!(f >= c, "x {x}: Find Edges keeps {f}, less than Contiguous's {c}"); + } + // Past the transition nothing is erased (before, Find Edges cut through x 201). + for x in 200..230 { + assert_eq!(alpha(&fe, x, 70), 255, "x {x}"); + } +} + +#[test] +fn find_edges_on_a_hard_edge_gives_the_contiguous_result() { + // 60 px tip at 100,138 on red just above the white gap: column x 100 erased through y 139, the + // gap and the red below it kept. + let fe = click((100.0, 138.0), 60.0, Limits::FindEdges); + let co = click((100.0, 138.0), 60.0, Limits::Contiguous); + for y in 100..176 { + assert_eq!(alpha(&fe, 100, y), alpha(&co, 100, y), "y {y}"); + } + for y in 110..140 { + assert_eq!(alpha(&fe, 100, y), 0, "red above the gap, y {y}"); + } + for y in 140..176 { + assert_eq!(alpha(&fe, 100, y), 255, "the gap and the red below it, y {y}"); + } +} + +#[test] +fn contiguous_and_discontiguous_keep_their_results() { + // This item changes only Find Edges: the other two limits still give the measured row. + for limits in [Limits::Contiguous, Limits::Discontiguous] { + let s = click((190.0, 70.0), 40.0, limits); + for (i, want) in CONTIGUOUS.iter().enumerate() { + let x = 185 + i as i32; + assert!((alpha(&s, x, 70) - want).abs() <= 8, "{limits:?} x {x}: {} against {want}", alpha(&s, x, 70)); + } + } +} From 1df674997b3d10368b4c8237155a8d68d720ea48 Mon Sep 17 00:00:00 2001 From: Triwarr <170976175+Triwarr@users.noreply.github.com> Date: Sun, 8 Nov 2026 02:34:58 +0100 Subject: [PATCH 153/219] PDF: cancel dense pages, complete page labels, isolate artboards - render_page: forward Interrupt to craft-pdf through an owned flag and a scoped 5 ms watcher, so dense vector pages stop mid-render. - Warn when craft-pdf reports a substituted font (the backend does not emit the flag yet). - /PageLabels: emit an entry for every page, start at 0, and write no tree when every artboard name is empty. - Export budget: charge retained page data plus the largest page's two f32 buffers instead of the whole document canvas, saturating on overflow. - Render each artboard from a document holding only its group, so overlapping boards no longer paint on each other's pages. One document shell is cloned and reused. - Tests: dense-page cancellation, labels with an empty middle name and all empty, overlapping artboard pixels, sparse 9000x9000 canvas, seven US Letter pages. --- crates/io/src/pdf.rs | 52 ++++++++----- crates/io/src/pdf/tests.rs | 127 ++++++++++++++++++++++++++++++- crates/io/src/pdf_import.rs | 123 +++++++++++++++++++++++++++++- crates/io/tests/it/pdf_import.rs | 53 +++++++------ 4 files changed, 308 insertions(+), 47 deletions(-) diff --git a/crates/io/src/pdf.rs b/crates/io/src/pdf.rs index 7bf228320f..86b03b565a 100644 --- a/crates/io/src/pdf.rs +++ b/crates/io/src/pdf.rs @@ -144,14 +144,16 @@ fn print_pdf_pages( out.extend_from_slice(b"\nendobj\n"); }; let n = pages.len(); - let selections_on = pages.iter().any(|p| p.page_label.is_some()); + // A label tree is written only for a non-empty name: an all-empty tree would be `/Nums []`, + // and filtering the first empty name away would start the tree at page index 1, not 0. + let labels_on = pages.iter().any(|p| p.page_label.as_deref().is_some_and(|label| !label.is_empty())); // 1 catalog, 2 pages, then per page: page, contents, image; then the shared font/ICC/selection. let font_obj = 3 + n * 3; let icc = first.image.icc.as_ref(); let icc_obj = icc.map(|_| font_obj + 1); let selection_base = font_obj + if icc.is_some() { 2 } else { 1 }; let selection_objects = selection_font.objects(selection_base); - let labels_obj = selections_on.then_some((selection_base + selection_objects.len()) as u32); + let labels_obj = labels_on.then_some((selection_base + selection_objects.len()) as u32); obj(&mut out, format!("<< /Type /Catalog /Pages 2 0 R{} >>", labels_obj.map(|o| format!(" /PageLabels {o} 0 R")).unwrap_or_default()).as_bytes()); let kids = (0..n).map(|i| format!("{} 0 R", 3 + i * 3)).collect::>().join(" "); obj(&mut out, format!("<< /Type /Pages /Kids [{kids}] /Count {n} >>").as_bytes()); @@ -226,10 +228,16 @@ fn print_pdf_pages( obj(&mut out, body); } if labels_obj.is_some() { + // Every page gets an entry: a number-tree range runs until the next key, so omitting an + // empty name would make that page inherit the previous page's label. An empty + // dictionary gives the page the default (decimal) label instead. let nums = pages .iter() .enumerate() - .filter_map(|(i, p)| p.page_label.as_deref().filter(|l| !l.is_empty()).map(|l| format!("{i} << /P {} >>", pdf_unicode_string(l)))) + .map(|(i, p)| match p.page_label.as_deref().filter(|label| !label.is_empty()) { + Some(label) => format!("{i} << /P {} >>", pdf_unicode_string(label)), + None => format!("{i} << >>"), + }) .collect::>() .join(" "); obj(&mut out, format!("<< /Nums [{nums}] >>").as_bytes()); @@ -315,6 +323,11 @@ fn collect_vectors(doc: &Document) -> (Vec<(usize, String)>, selection::Selectio (layers, selection_font) } +/// Pixels in `rect`, saturating on overflow. +fn rect_pixels(rect: photocraft_geom::Rect) -> u64 { + u64::from(rect.width()).saturating_mul(u64::from(rect.height())) +} + /// File › Save As / Save a Copy › PDF (#3401): the flattened document at its print size /// (pixels ÷ resolution × 72 pt). One page per artboard when the document has artboards, else one /// page for the whole document. Layers and transparency are dropped — the warning says so — and @@ -341,8 +354,13 @@ pub fn export(doc: &Document, ctl: &photocraft_raster::Interrupt) -> std::result if !artboards.is_empty() { // Every page's encoded pixels are kept until the PDF is assembled; bound that peak. const MAX_PDF_EXPORT_BYTES: u64 = 1 << 30; - let pixels: u64 = artboards.iter().map(|(_, _, a)| u64::from(a.rect.width()) * u64::from(a.rect.height())).sum(); - let estimate = pixels.saturating_mul(device as u64) + w as u64 * h as u64 * n as u64 * 4; + let page_pixels: u64 = artboards.iter().fold(0u64, |bytes, (_, _, a)| bytes.saturating_add(rect_pixels(a.rect))); + let retained = page_pixels.saturating_mul(device as u64); + // One page renders at a time: its f32 composite surface plus the region read back from + // it, both `n` channels at 4 bytes per pixel. The whole-document canvas is never held. + let largest = artboards.iter().map(|(_, _, a)| rect_pixels(a.rect)).max().unwrap_or(0); + let temporary = largest.saturating_mul(n as u64).saturating_mul(4).saturating_mul(2); + let estimate = retained.saturating_add(temporary); if estimate > MAX_PDF_EXPORT_BYTES { return Err(IoError::Unsupported(format!( "PDF export would need about {} MB for {} artboards; export them in smaller batches", @@ -350,6 +368,12 @@ pub fn export(doc: &Document, ctl: &photocraft_raster::Interrupt) -> std::result artboards.len() ))); } + // One document shell is reused for every page. Each page renders a document holding only + // its artboard group (the same isolation as `artboard_document`), so an overlapping board + // cannot paint on another board's page. Layer pixels are shared with `doc`; this one + // clone and the per-page group clone hold no new pixel buffers, but they are outside the + // 1 GiB estimate because they are not retained page data. + let mut one = doc.clone(); let mut pages = Vec::with_capacity(artboards.len()); for (id, name, artboard) in &artboards { ctl.check().map_err(|_| IoError::Cancelled)?; @@ -360,19 +384,13 @@ pub fn export(doc: &Document, ctl: &photocraft_raster::Interrupt) -> std::result } // A hidden artboard still gets its page, rendered from its own content rather than // left blank by the group's visibility. - let shown; - let doc = if doc.layer(*id).is_some_and(|l| !l.visible) { - let mut copy = doc.clone(); - if let Some(layer) = copy.layer_mut(*id) { - layer.visible = true; - } - shown = copy; - &shown - } else { - doc - }; + let Some(layer) = doc.layer(*id) else { continue }; + let mut group = layer.clone(); + group.visible = true; + one.layers.clear(); + one.layers.push(group); // Render only this artboard's rect; the surface never holds the whole document. - let surface = photocraft_compose::composite_to_surface(doc, rect, PixelFormat::new(fmt.mode, fmt.sample, true), None); + let surface = photocraft_compose::composite_to_surface(&one, rect, PixelFormat::new(fmt.mode, fmt.sample, true), None); let values = surface.read_region(rect); let mut data = Vec::with_capacity((aw as usize * ah as usize).saturating_mul(device)); for px in values.chunks(n) { diff --git a/crates/io/src/pdf/tests.rs b/crates/io/src/pdf/tests.rs index 6741fea346..247ab99192 100644 --- a/crates/io/src/pdf/tests.rs +++ b/crates/io/src/pdf/tests.rs @@ -1,8 +1,9 @@ //! PDF export: structure, pixel oracle, colour modes and refusal paths. use super::*; +use lopdf::{Object, StringFormat}; use photocraft_color::{PixelFormat, SampleType}; -use photocraft_doc::{Layer, LayerContent, Size}; +use photocraft_doc::{Artboard, Group, Layer, LayerContent, Size}; use photocraft_geom::Rect; use photocraft_raster::{Interrupt, Surface}; @@ -420,3 +421,127 @@ fn artboards_export_as_one_page_each() { } assert_eq!(out.bytes.windows(15).filter(|w| *w == b"/Subtype /Image").count(), 2); } + +/// Side-by-side 2x2 artboards at 72 dpi, named by `names` (empty names are kept). +fn artboard_doc(names: &[&str]) -> Document { + let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::F32, true); + let width = (names.len() as u32).saturating_mul(2).max(2); + let mut d = Document::new("labels.pdf", Size { width, height: 2 }, ColorMode::Rgb, SampleType::F32); + d.resolution_dpi = 72.0; + for (index, name) in names.iter().enumerate() { + let x0 = (index as i32) * 2; + let rect = Rect::new(x0, 0, x0 + 2, 2); + let layer = Layer::new("Pixels", LayerContent::Raster(solid(fmt, rect.x0, rect.y0, rect.x1, rect.y1, &[0.5, 0.5, 0.5, 1.0]))); + let group = Group { children: vec![layer], expanded: true, artboard: Some(Artboard::new(rect)), frame_stroke: None }; + d.layers.push(Layer::new(*name, LayerContent::Group(group))); + } + d +} + +/// The decompressed samples of page `id`'s first image XObject (`/Im0`). +fn page_image(pdf: &lopdf::Document, id: lopdf::ObjectId) -> Vec { + let page = pdf.get_dictionary(id).expect("page dictionary"); + let resources = page.get(b"Resources").expect("page resources").as_dict().expect("resources dictionary"); + let xobjects = resources.get(b"XObject").expect("xobjects").as_dict().expect("xobject dictionary"); + let image = xobjects.get(b"Im0").expect("Im0").as_reference().expect("image reference"); + pdf.get_object(image).expect("image object").as_stream().expect("image stream").decompressed_content().expect("image samples") +} + +/// A named artboard gives every page a `/PageLabels` entry, and an empty name gets an empty +/// label dictionary rather than inheriting the previous name. +#[test] +fn page_labels_name_every_page_and_an_empty_name_does_not_inherit() { + let d = artboard_doc(&["A", "", "C"]); + let pdf = export(&d, &Interrupt::NONE).unwrap().bytes; + check_structure(&pdf); + let parsed = lopdf::Document::load_mem(&pdf).unwrap(); + let labels = parsed + .catalog() + .unwrap() + .get(b"PageLabels") + .expect("a named artboard gives the PDF page labels") + .as_reference() + .expect("the label tree is an indirect object"); + let nums = parsed.get_dictionary(labels).unwrap().get(b"Nums").unwrap().as_array().unwrap(); + assert_eq!(nums.len(), 6, "one key and one label dictionary per page"); + for page in 0..3 { + assert_eq!(nums[page * 2].as_i64().unwrap(), page as i64, "the tree starts at page index 0 and covers every page"); + } + let label = |page: usize| nums[page * 2 + 1].as_dict().unwrap(); + assert_eq!(label(0).get(b"P").unwrap(), &Object::String(vec![0xFE, 0xFF, 0x00, 0x41], StringFormat::Hexadecimal), "page 1 is A"); + assert!(label(1).get(b"P").is_err(), "the empty name gets an empty dictionary, not A"); + assert_eq!(label(2).get(b"P").unwrap(), &Object::String(vec![0xFE, 0xFF, 0x00, 0x43], StringFormat::Hexadecimal), "page 3 is C"); + assert_eq!(parsed.get_pages().len(), 3); +} + +/// With no non-empty name there is no label tree at all: an empty `/Nums` would be invalid. +#[test] +fn an_all_empty_artboard_names_document_has_no_page_labels_tree() { + let d = artboard_doc(&["", "", ""]); + let pdf = export(&d, &Interrupt::NONE).unwrap().bytes; + check_structure(&pdf); + let parsed = lopdf::Document::load_mem(&pdf).unwrap(); + assert!(parsed.catalog().unwrap().get(b"PageLabels").is_err(), "no names means no /PageLabels"); + assert!(!has(&pdf, "/PageLabels")); + assert_eq!(parsed.get_pages().len(), 3, "the pages themselves are still written"); +} + +/// Two artboards at the same rectangle: each page must hold only its own board's pixels, not +/// the other board composited over it. +#[test] +fn overlapping_artboards_export_only_their_own_pixels() { + let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::F32, true); + let mut d = Document::new("overlap.pdf", Size { width: 2, height: 2 }, ColorMode::Rgb, SampleType::F32); + d.resolution_dpi = 72.0; + for (name, color) in [("Red", [1.0, 0.0, 0.0, 1.0]), ("Blue", [0.0, 0.0, 1.0, 1.0])] { + let layer = Layer::new("Pixels", LayerContent::Raster(solid(fmt, 0, 0, 2, 2, &color))); + let group = Group { children: vec![layer], expanded: true, artboard: Some(Artboard::new(Rect::new(0, 0, 2, 2))), frame_stroke: None }; + d.layers.push(Layer::new(name, LayerContent::Group(group))); + } + let pdf = export(&d, &Interrupt::NONE).unwrap().bytes; + let parsed = lopdf::Document::load_mem(&pdf).unwrap(); + let pages = parsed.get_pages(); + let images = pages.values().map(|id| page_image(&parsed, *id)).collect::>(); + assert_eq!(images.len(), 2); + assert_eq!(images[0], [255u8, 0, 0].repeat(4), "the bottom board's page is red, not blue over red"); + assert_eq!(images[1], [0u8, 0, 255].repeat(4), "the top board's page is blue"); +} + +/// A large, mostly empty canvas must not be charged as if its whole area were rendered. +#[test] +fn sparse_canvas_artboards_export_without_a_false_memory_limit() { + let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::F32, true); + let mut d = Document::new("sparse.pdf", Size { width: 9000, height: 9000 }, ColorMode::Rgb, SampleType::F32); + d.resolution_dpi = 72.0; + for index in 0..2 { + let x0 = index * 10; + let rect = Rect::new(x0, 0, x0 + 2, 2); + let layer = Layer::new("Pixels", LayerContent::Raster(solid(fmt, rect.x0, 0, rect.x1, 2, &[1.0, 0.0, 0.0, 1.0]))); + let group = Group { children: vec![layer], expanded: true, artboard: Some(Artboard::new(rect)), frame_stroke: None }; + d.layers.push(Layer::new("Page", LayerContent::Group(group))); + } + let out = export(&d, &Interrupt::NONE).expect("two 2x2 boards on a 9000x9000 canvas must fit the export budget"); + let parsed = lopdf::Document::load_mem(&out.bytes).unwrap(); + assert_eq!(parsed.get_pages().len(), 2); +} + +/// Seven US Letter boards at 300 dpi with the importer's gaps: the pages are retained as encoded +/// data, but no whole-canvas buffer exists, so the old whole-canvas charge rejected this. +#[test] +fn seven_us_letter_artboards_export_without_a_false_memory_limit() { + const PAGE_W: u32 = 2550; + const PAGE_H: u32 = 3300; + const GAP: u32 = 80; + const PAGES: u32 = 7; + let mut d = Document::new("letters.pdf", Size { width: PAGES * PAGE_W + (PAGES - 1) * GAP, height: PAGE_H }, ColorMode::Rgb, SampleType::F32); + d.resolution_dpi = 300.0; + for index in 0..PAGES { + let x0 = (index * (PAGE_W + GAP)) as i32; + let rect = Rect::new(x0, 0, x0 + PAGE_W as i32, PAGE_H as i32); + let group = Group { children: Vec::new(), expanded: true, artboard: Some(Artboard::new(rect)), frame_stroke: None }; + d.layers.push(Layer::new(format!("Letter {}", index + 1), LayerContent::Group(group))); + } + let out = export(&d, &Interrupt::NONE).expect("seven US Letter pages at 300 dpi fit the export budget"); + let parsed = lopdf::Document::load_mem(&out.bytes).unwrap(); + assert_eq!(parsed.get_pages().len(), 7); +} diff --git a/crates/io/src/pdf_import.rs b/crates/io/src/pdf_import.rs index 79db591088..d4c8aa4eac 100644 --- a/crates/io/src/pdf_import.rs +++ b/crates/io/src/pdf_import.rs @@ -8,6 +8,9 @@ //! //! The renderer is pure Rust and runs on every desktop platform: no shared library is loaded //! or shipped, and a malformed file is reported as an error rather than taking the process down. +//! +//! Pages are drawn with the interpreter's default annotation settings, so annotations and +//! filled-in form fields are rendered; comments are not hidden. use crate::{ImportOptions, ImportResult, IoError}; #[cfg(not(target_arch = "wasm32"))] @@ -23,6 +26,12 @@ use photocraft_geom::{Rect, Size}; use photocraft_raster::Interrupt; #[cfg(not(target_arch = "wasm32"))] use photocraft_raster::Surface; +#[cfg(not(target_arch = "wasm32"))] +use std::sync::Arc; +#[cfg(not(target_arch = "wasm32"))] +use std::sync::atomic::{AtomicBool, Ordering}; +#[cfg(not(target_arch = "wasm32"))] +use std::time::Duration; /// `%PDF-` magic. `.ai` files that start with it are PDFs and render through this importer. pub fn is_pdf(bytes: &[u8]) -> bool { @@ -139,19 +148,26 @@ pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) - return Err(IoError::Unsupported(format!("PDF page {page_number} is too large at {dpi:.0} dpi; lower the import DPI"))); } let cache = RenderCache::new(); - let settings = InterpreterSettings::default(); let options = raster::Options { scale: [(dpi / 72.0) as f32; 2], size: Some([width, height]), offset: [0.0, 0.0], // PDF pages composite onto white, as every PDF viewer and the previous importer did. + // craft-pdf returns premultiplied RGBA8; an opaque background makes alpha 1, so the + // bytes below can be written as straight alpha. A transparent background would need + // un-premultiplying (dividing each colour by alpha) first. background: [255, 255, 255, 255], color_replacement: None, }; - let rendered = raster::render(page, &cache, &settings, &options).map_err(|error| render_error(page_number, dpi, error))?; + let rendered = render_page(page, &cache, &options, ctl).map_err(|error| render_error(page_number, dpi, error))?; if rendered.diagnostics.image_decode_failed { warnings.push(format!("PDF page {page_number}: an image could not be decoded and is missing")); } + // craft-pdf's hayro backend does not emit this flag yet (its README says so); keep the + // warning ready so a backend that starts reporting font substitution needs no change here. + if rendered.diagnostics.unsupported_font { + warnings.push(format!("PDF page {page_number}: a font could not be used and was substituted, so text may look different")); + } if rendered.diagnostics.content_truncated { warnings.push(format!("PDF page {page_number}: some content was omitted (a decode or memory limit)")); } @@ -187,6 +203,43 @@ pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) - Ok(ImportResult { warnings, document: doc, source_read_only: false, preview_only: false }) } +/// Renders one page, forwarding `ctl`'s cancellation to craft-pdf while it interprets content. +/// +/// craft-pdf checks `InterpreterSettings::cancelled` between content operators, so a dense +/// vector page (CAD, maps) can be stopped part-way through. `Interrupt` is a borrowed callback +/// while the setting needs an owned flag, so a scoped watcher thread copies one into the other +/// every few milliseconds until the render returns. +#[cfg(not(target_arch = "wasm32"))] +fn render_page<'a>( + page: &'a craft_pdf::syntax::page::Page<'a>, + cache: &RenderCache<'a>, + options: &raster::Options, + ctl: &Interrupt, +) -> Result { + if ctl.cancelled() { + return Err(raster::Error::Cancelled); + } + let cancelled = Arc::new(AtomicBool::new(false)); + let finished = Arc::new(AtomicBool::new(false)); + let settings = InterpreterSettings { cancelled: Some(Arc::clone(&cancelled)), ..InterpreterSettings::default() }; + std::thread::scope(|scope| { + let flag = Arc::clone(&cancelled); + let done = Arc::clone(&finished); + let ctl = *ctl; + scope.spawn(move || { + while !done.load(Ordering::Relaxed) { + if ctl.cancelled() { + flag.store(true, Ordering::Relaxed); + } + std::thread::sleep(Duration::from_millis(5)); + } + }); + let rendered = raster::render(page, cache, &settings, options); + finished.store(true, Ordering::Relaxed); + rendered + }) +} + /// One page's raster width or height in pixels at `dpi`, at least one. #[cfg(not(target_arch = "wasm32"))] fn page_pixels(points: f64, dpi: f64) -> u32 { @@ -249,3 +302,69 @@ fn render_error(page: usize, dpi: f64, error: raster::Error) -> IoError { Error::InvalidOptions | Error::BufferLength => IoError::Unsupported(format!("PDF page {page} could not be rendered")), } } + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use std::sync::atomic::AtomicUsize; + + use lopdf::{Document as PdfDocument, Object, Stream, dictionary}; + + use super::*; + + /// A one-page PDF whose content stream is `operators` short strokes: rendering costs time + /// per operator, not per pixel. + fn dense_operator_pdf(operators: usize) -> Result, lopdf::Error> { + let mut pdf = PdfDocument::with_version("1.5"); + let pages_id = pdf.new_object_id(); + let mut content = String::with_capacity(operators.saturating_mul(24)); + content.push_str("0 0 1 RG 0.5 w\n"); + for i in 0..operators { + let x = (i % 200) as f32 * 0.5; + let y = ((i / 200) % 100) as f32 * 0.5; + content.push_str(&format!("{x:.1} {y:.1} m {:.1} {y:.1} l S\n", x + 0.4)); + } + let content_id = pdf.add_object(Stream::new(dictionary! {}, content.into_bytes())); + let page_id = pdf.add_object(dictionary! { + "Type" => "Page", + "Parent" => pages_id, + "MediaBox" => Object::Array(vec![Object::Real(0.0), Object::Real(0.0), Object::Real(200.0), Object::Real(100.0)]), + "Contents" => content_id, + "Resources" => dictionary! {}, + }); + pdf.objects.insert( + pages_id, + Object::Dictionary(dictionary! { + "Type" => "Pages", + "Kids" => Object::Array(vec![Object::Reference(page_id)]), + "Count" => 1, + }), + ); + let catalog = pdf.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id }); + pdf.trailer.set("Root", catalog); + let mut bytes = Vec::new(); + pdf.save_to(&mut bytes)?; + Ok(bytes) + } + + /// Hundreds of thousands of path operators take far longer than the watcher's polling + /// interval, so the interrupt lands mid-render and the page reports Cancelled. + #[test] + fn a_dense_page_stops_when_the_interrupt_fires_mid_render() { + let pdf = dense_operator_pdf(300_000).expect("writing the dense PDF"); + let document = craft_pdf::open_slice_with_options(&pdf, None, &craft_pdf::OpenOptions::default()).expect("the dense PDF opens"); + let cache = RenderCache::new(); + let options = + raster::Options { scale: [1.0, 1.0], size: Some([200, 100]), offset: [0.0, 0.0], background: [255, 255, 255, 255], color_replacement: None }; + let polls = AtomicUsize::new(0); + // `render_page` checks once before spawning; the watcher then polls every 5 ms. Ten + // polls put the cancellation about 45 ms into a render that takes much longer. + let cancel = || polls.fetch_add(1, Ordering::SeqCst) >= 9; + let ctl = Interrupt::cancel_only(&cancel); + let error = match render_page(&document.pages()[0], &cache, &options, &ctl) { + Err(error) => error, + Ok(_) => panic!("the dense page rendered to completion before the interrupt fired"), + }; + assert!(matches!(render_error(1, 72.0, error), IoError::Cancelled), "the render error maps to Cancelled"); + assert!(polls.load(Ordering::SeqCst) >= 10, "the watcher must keep polling the interrupt"); + } +} diff --git a/crates/io/tests/it/pdf_import.rs b/crates/io/tests/it/pdf_import.rs index 95475ea200..b614f54c97 100644 --- a/crates/io/tests/it/pdf_import.rs +++ b/crates/io/tests/it/pdf_import.rs @@ -5,8 +5,7 @@ //! a blue rectangle sampled from the raster, and the renderer's side/pixel limits respected by //! lowering the effective DPI on a huge page. -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::time::{Duration, Instant}; +use std::sync::atomic::{AtomicBool, Ordering}; use lopdf::{Document as PdfDocument, Object, Stream, dictionary}; use photocraft_doc::LayerContent; @@ -164,34 +163,34 @@ fn huge_pages_lower_the_effective_dpi_within_the_renderer_bounds() { assert!((print_width - 3000.0).abs() < 1.0, "print width {print_width:.1} pt should stay 3000 pt"); } -/// Cancelling on the last page check or the public boundary returns Cancelled, never a document. +/// Cancelling at the public boundary or between pages returns Cancelled, never a document. +/// +/// The renderer's watcher polls the interrupt while a page is being interpreted, so the number +/// of checks is not fixed any more; these triggers do not depend on counting them. #[test] -fn cancelling_on_the_last_checks_is_reported() { +fn cancelling_before_and_between_pages_is_reported() { let pdf = variant_pdf(); let options = ImportOptions { pdf_dpi: 72.0, ..Default::default() }; - let count = AtomicUsize::new(0); - let cancel = || { - count.fetch_add(1, Ordering::SeqCst); - false - }; - let ctl = Interrupt::cancel_only(&cancel); - let result = import_with_options_and_interrupt("variants.pdf", &pdf, &options, &ctl); - assert!(result.is_ok(), "uncancelled import failed: {:?}", result.err()); - let total = count.load(Ordering::SeqCst); - assert!(total >= 3, "a complete import should check cancellation at least three times, got {total}"); - for (label, threshold) in [("the last page check", total - 1), ("the public boundary check", total)] { - let seen = AtomicUsize::new(0); - let cancel = || seen.fetch_add(1, Ordering::SeqCst) + 1 >= threshold; - let ctl = Interrupt::cancel_only(&cancel); - let started = Instant::now(); - let result = import_with_options_and_interrupt("variants.pdf", &pdf, &options, &ctl); - let elapsed = started.elapsed(); - assert_eq!(seen.load(Ordering::SeqCst), threshold, "cancelling on {label} should stop at check {threshold} of {total}"); - match result { - Err(IoError::Cancelled) => {} - Ok(_) => panic!("cancelling on {label} returned a document"), - Err(error) => panic!("cancelling on {label} returned {error:?}"), + + // The boundary check before dispatch: already cancelled. + let always = || true; + let ctl = Interrupt::cancel_only(&always); + match import_with_options_and_interrupt("variants.pdf", &pdf, &options, &ctl) { + Err(IoError::Cancelled) => {} + other => panic!("an already-cancelled import returned {:?}", other.map(|_| ())), + } + + // The per-page loop's check: the first page's progress cancels the next iteration. + let cancelled = AtomicBool::new(false); + let cancel = || cancelled.load(Ordering::SeqCst); + let progress = |fraction: f32| { + if fraction > 0.0 { + cancelled.store(true, Ordering::SeqCst); } - assert!(elapsed < Duration::from_secs(10), "cancelling on {label} took {elapsed:?}"); + }; + let ctl = Interrupt::new(&cancel, &progress); + match import_with_options_and_interrupt("variants.pdf", &pdf, &options, &ctl) { + Err(IoError::Cancelled) => {} + other => panic!("cancelling after the first page returned {:?}", other.map(|_| ())), } } From 2341642056e63f2ded1b42ee20ab554249e57a84 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sat, 7 Nov 2026 20:38:18 -0500 Subject: [PATCH 154/219] Open as Smart Object: import on a worker and complete through File > Open's colour policy and prompts (#11678) The command is a job (inline under execute, a worker under start) that returns File > Open's result shape; the UI tracks it as an OpenTab so finish_open gives it the profile prompt (held, admitted with the smart object label) and after_open gives a raw the Camera Raw open dialog and the import warnings. Import options and the Color Settings policy match File > Open. The paths form is removed: the UI calls the command once per file. Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove; Fable 5.1 briefed; Sonnet 5.5 wrote at medium --- crates/engine/src/cmd/file_cmds.rs | 2 +- crates/engine/src/cmd/file_cmds/open.rs | 2 +- .../src/cmd/file_cmds/open_as_smart_object.rs | 83 ++++++++++--------- .../file_cmds/open_as_smart_object_tests.rs | 76 ++++++++++++++++- crates/ui-egui/src/app/files.rs | 1 + crates/ui-egui/src/dialogs/open_prompt.rs | 7 +- crates/ui-egui/src/file_dialog.rs | 12 ++- crates/ui-egui/src/file_open.rs | 4 +- crates/ui-egui/src/file_open/tests.rs | 24 +++++- crates/ui-egui/src/jobs_ui.rs | 39 +++++++-- crates/ui-egui/src/psd_open_tests.rs | 2 + 11 files changed, 196 insertions(+), 56 deletions(-) diff --git a/crates/engine/src/cmd/file_cmds.rs b/crates/engine/src/cmd/file_cmds.rs index d2cdf92d32..b1eed032dd 100644 --- a/crates/engine/src/cmd/file_cmds.rs +++ b/crates/engine/src/cmd/file_cmds.rs @@ -293,7 +293,7 @@ pub fn specs() -> Vec { "Open as Smart Object…", &["File"], None, - r##"{"path":str (or "paths":[str], one document each)} (opens the file as a new untitled document named "-N" with the file's size, resolution, colour mode, bit depth and profile, holding one embedded smart object layer named after the file) → {document, name, layer} ({documents:[…]} for paths)"##, + r##"{"path":str} (opens the file as a new untitled document named "-N" with the file's size, resolution, colour mode, bit depth and profile, holding one embedded smart object layer named after the file) → {document, docId, name, layer, warnings, color} (or {held, name, warnings, color, smartObject} while the profile prompt waits)"##, native, open_as_smart_object ), diff --git a/crates/engine/src/cmd/file_cmds/open.rs b/crates/engine/src/cmd/file_cmds/open.rs index 6121180e22..71b7cfcefb 100644 --- a/crates/engine/src/cmd/file_cmds/open.rs +++ b/crates/engine/src/cmd/file_cmds/open.rs @@ -74,7 +74,7 @@ fn import_result(name: &str, bytes: &[u8]) -> Result Result { +pub(super) fn import_result_with(name: &str, bytes: &[u8], opts: &photocraft_io::ImportOptions) -> Result { crate::allocation::checkpoint("importing document")?; let r = photocraft_io::import_with_options(name, bytes, opts).map_err(|e| crate::error::io_open_error(name, e))?; // Auxiliary imports return only a document and cannot surface the preview's fidelity warning. diff --git a/crates/engine/src/cmd/file_cmds/open_as_smart_object.rs b/crates/engine/src/cmd/file_cmds/open_as_smart_object.rs index f365170f34..b32b6bfbcb 100644 --- a/crates/engine/src/cmd/file_cmds/open_as_smart_object.rs +++ b/crates/engine/src/cmd/file_cmds/open_as_smart_object.rs @@ -5,9 +5,9 @@ //! size and position `0, 0`) and put into the document before it is admitted, so the opening //! snapshot already shows it. -use super::open::import_opened; +use super::open::import_result_with; use super::paths::{file_name, read_file, stem, untitled_name}; -use super::{Affine, Arc, Document, EngineError, Layer, LayerContent, Result, Session, SmartObject, SmartSource, Value, flattened_checked, json, str_param}; +use super::{Affine, Arc, Document, Layer, LayerContent, Result, Session, SmartObject, SmartSource, Value, flattened_checked, json, str_param}; use crate::layer_place::Place; const CMD: &str = "file.openAsSmartObject"; @@ -15,42 +15,40 @@ const CMD: &str = "file.openAsSmartObject"; /// The history step's name. const LABEL: &str = "Open As Smart Object"; -fn bad(msg: impl Into) -> EngineError { - EngineError::BadParams { cmd: CMD.into(), msg: msg.into() } -} - -/// The files to open: `paths` (several selected files) or `path` (one); whether it was one. -fn paths_param(p: &Value) -> Result<(Vec, bool)> { - match p.get("paths") { - None | Some(Value::Null) => Ok((vec![str_param(p, "path", CMD)?.to_string()], true)), - Some(Value::Array(list)) if p.get("path").is_none_or(Value::is_null) => { - let paths: Option> = list.iter().map(|v| v.as_str().filter(|v| !v.is_empty()).map(str::to_string)).collect(); - match paths { - Some(paths) if !paths.is_empty() => Ok((paths, false)), - _ => Err(bad("\"paths\" must be a non-empty list of file paths")), - } - } - Some(Value::Array(_)) => Err(bad("give \"path\" or \"paths\", not both")), - Some(_) => Err(bad("\"paths\" must be a list of file paths")), - } -} - -/// Opens each file as its own document, in order. A file that cannot be read stops the command -/// with an error; the documents opened before it stay open. +/// Opens the file as a document holding one embedded smart object. The read, decode and flatten +/// run on the job's worker (inline under `execute`); the document is built, given File › Open's +/// colour policy and admitted, or held for its profile prompt, when the job is applied. The reply +/// has the shape of File › Open's job: `{document, docId, name, layer, warnings, color}`, or +/// `{held, name, warnings, color, smartObject}` while the profile prompt waits. pub(super) fn open_as_smart_object(s: &mut Session, p: &Value) -> Result { - let (paths, single) = paths_param(p)?; - let mut opened = Vec::new(); - for path in &paths { - opened.push(open_one(s, path)?); - } - Ok(if single { opened.swap_remove(0) } else { json!({"documents": opened}) }) + let path = str_param(p, "path", CMD)?.to_string(); + let name = file_name(&path); + let opts = crate::jobs::import_options(s.prefs()); + crate::jobs::run( + s, + &format!("Opening {name}"), + false, + move |ctx| { + ctx.progress(0.0, "Reading"); + let bytes = read_file(&path)?; + ctx.check()?; + ctx.progress(0.05, "Decoding"); + let r = import_result_with(&name, &bytes, &opts)?; + ctx.check()?; + ctx.progress(0.9, "Flattening"); + // The file as one image: the smart object's rendered contents. + let pixels = flattened_checked(&r.document, r.document.pixel_format(), CMD)?; + Ok((path, name, Arc::new(bytes), r, pixels)) + }, + |s, (path, name, bytes, r, pixels)| apply(s, &path, &name, bytes, r, pixels), + ) } -fn open_one(s: &mut Session, path: &str) -> Result { - let bytes = read_file(path)?; - let src = import_opened(s, path, &bytes)?; - // The file as one image: the smart object's rendered contents. - let pixels = flattened_checked(&src, src.pixel_format(), CMD)?; +fn apply(s: &mut Session, path: &str, file: &str, bytes: Arc>, r: photocraft_io::ImportResult, pixels: photocraft_raster::Surface) -> Result { + let warnings = r.warnings; + let mut src = r.document; + super::tag_exr_colorspace(s, file, &mut src); + super::raw_defaults(s, &warnings, &mut src); let layer_name = stem(path); // "-1", the next free number while a document with that name is open. let name = untitled_name(&layer_name, |n| s.documents().iter().any(|d| d.doc.name == n)); @@ -62,13 +60,24 @@ fn open_one(s: &mut Session, path: &str) -> Result { doc.duotone = src.duotone.clone(); // Embedded (not linked): the original file name keeps its extension, so Edit Contents opens it. - let source = SmartSource::Embedded { file_name: file_name(path), bytes: Arc::new(bytes) }; + let source = SmartSource::Embedded { file_name: file.to_string(), bytes }; let so = SmartObject::new(source, Affine { m: [1.0, 0.0, 0.0, 1.0, 0.0, 0.0] }, Some(pixels)); let layer = Layer::new(layer_name, LayerContent::Smart(so)); let layer_id = layer.id; crate::layer_place::insert_new(&mut doc, None, layer, Place::AutoEditable); + + // File › Open's completion: a raw has no embedded profile to ask about; otherwise the profile + // prompt holds the document, and without a question the Color Settings policy is applied. + let raw = warnings.iter().any(|w| w.contains(photocraft_io::raw::DEVELOPED_NOTE)); + let question = s.color.open_question(&doc).filter(|_| !raw); + if question.is_some() { + let held = s.hold_open(doc, false); + return Ok(json!({"held": held, "name": name, "warnings": warnings, "color": question, "smartObject": true})); + } + let color = s.color.open_policy(&mut doc, s.prefs().file_handling.ignore_exif_profile_tag); let index = admit_as_smart_object(s, doc); - Ok(json!({"document": index, "name": name, "layer": layer_id.0})) + let id = s.documents().get(index).map(|st| st.doc.id.0); + Ok(json!({"document": index, "docId": id, "name": name, "layer": layer_id.0, "warnings": warnings, "color": color})) } /// Admits a document built by Open as Smart Object (one smart object layer) as an untitled diff --git a/crates/engine/src/cmd/file_cmds/open_as_smart_object_tests.rs b/crates/engine/src/cmd/file_cmds/open_as_smart_object_tests.rs index b6dce7b1e5..2117d8e5e1 100644 --- a/crates/engine/src/cmd/file_cmds/open_as_smart_object_tests.rs +++ b/crates/engine/src/cmd/file_cmds/open_as_smart_object_tests.rs @@ -101,6 +101,8 @@ fn a_16_bit_psd_keeps_size_resolution_mode_depth_and_profile() { let dir = tmp("psd"); let path = write_psd16(&dir, "c.layered.psd", 40, 30); let mut s = Session::new(); + // The files were measured in the working spaces, where nothing asks; Preserve keeps Display P3. + s.execute("edit.colorSettings", json!({"askOnMismatch": false})).unwrap(); s.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); let file = imported(&path); assert_eq!(file.resolution_dpi, 300.0); @@ -175,8 +177,9 @@ fn several_files_open_one_document_each() { let a = write_png(&dir, "a_test.png", 8, 8); let b = write_psd16(&dir, "d_other.psd", 12, 10); let mut s = Session::new(); - let r = s.execute("file.openAsSmartObject", json!({"paths": [a, b]})).unwrap(); - assert_eq!(r["documents"].as_array().map(Vec::len), Some(2), "{r}"); + s.execute("edit.colorSettings", json!({"askOnMismatch": false})).unwrap(); + s.execute("file.openAsSmartObject", json!({"path": a})).unwrap(); + s.execute("file.openAsSmartObject", json!({"path": b})).unwrap(); assert_eq!(names(&s), ["a_test-1", "d_other-1"]); for st in s.documents() { assert_eq!(st.doc.layers.len(), 1); @@ -202,9 +205,76 @@ fn a_missing_or_unreadable_file_is_an_error_not_a_crash() { #[test] fn bad_params_are_errors() { let mut s = Session::new(); - for params in [json!({}), json!({"path": 7}), json!({"path": null}), json!({"paths": "a.png"}), json!({"paths": []}), json!({"paths": [1]})] { + for params in [json!({}), json!({"path": 7}), json!({"path": null})] { let r = s.execute("file.openAsSmartObject", params.clone()); assert!(r.is_err(), "{params}: {r:?}"); } assert!(s.documents().is_empty()); } + +fn job(r: crate::jobs::Started) -> crate::jobs::JobId { + match r { + crate::jobs::Started::Job(id) => id, + crate::jobs::Started::Done(v) => panic!("expected a background job, ran inline: {v}"), + } +} + +/// Poll until job `id` ends (or 60 s pass); returns its event. +fn wait_event(s: &mut Session, id: crate::jobs::JobId) -> crate::jobs::JobEvent { + let t = std::time::Instant::now(); + loop { + if let Some(e) = s.poll_jobs().into_iter().find(|e| e.id == id) { + return e; + } + assert!(t.elapsed() < std::time::Duration::from_secs(60), "job {id:?} did not finish"); + std::thread::sleep(std::time::Duration::from_millis(2)); + } +} + +#[test] +fn the_import_runs_as_a_job_when_started() { + let dir = tmp("job"); + let path = write_png(&dir, "a_test.png", 32, 24); + let mut s = Session::new(); + let id = job(s.start("file.openAsSmartObject", json!({"path": path})).unwrap()); + assert!(s.documents().is_empty(), "nothing is admitted before the job is applied"); + let e = wait_event(&mut s, id); + let crate::jobs::JobOutcome::Done(v) = &e.outcome else { panic!("{e:?}") }; + assert!(v["docId"].is_u64(), "{v}"); + assert_eq!(v["name"], "a_test-1", "{v}"); + assert_eq!(s.documents().len(), 1); + embedded(&s); +} + +#[test] +fn the_colour_policy_matches_file_open() { + let dir = tmp("policy"); + let path = write_psd16(&dir, "c.layered.psd", 40, 30); + let settings = json!({"askOnMismatch": false, "policyRgb": "convert"}); + let mut open = Session::new(); + open.execute("edit.colorSettings", settings.clone()).unwrap(); + open.execute("file.openAs", json!({"path": path})).unwrap(); + let mut so = Session::new(); + so.execute("edit.colorSettings", settings).unwrap(); + so.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + let (a, b) = (active(&open), active(&so)); + assert_eq!(a.icc_profile, b.icc_profile, "Convert is applied as File > Open applies it"); + assert_eq!((a.mode, a.depth, a.size), (b.mode, b.depth, b.size)); + assert_eq!(a.resolution_dpi, b.resolution_dpi); +} + +#[test] +fn ask_when_opening_holds_the_document_like_file_open() { + let dir = tmp("ask"); + let path = write_psd16(&dir, "c.layered.psd", 40, 30); + let mut s = Session::new(); + let r = s.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + assert_eq!(r["color"]["ask"], true, "{r}"); + assert_eq!(r["smartObject"], true, "{r}"); + assert!(r["held"].is_u64(), "{r}"); + assert!(s.documents().is_empty(), "held for the profile prompt, not admitted"); + let (doc, _) = s.take_held_open(r["held"].as_u64().unwrap()).unwrap(); + assert_eq!(doc.name, "c.layered-1"); + assert_eq!(doc.layers.len(), 1); + assert!(matches!(doc.layers[0].content, LayerContent::Smart(_))); +} diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 26ac96f722..8264895665 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -137,6 +137,7 @@ impl PhotocraftApp { order, question, policy_applied: false, + smart_object: false, }, ); Ok(warnings) diff --git a/crates/ui-egui/src/dialogs/open_prompt.rs b/crates/ui-egui/src/dialogs/open_prompt.rs index 78691112c0..145ae4ce84 100644 --- a/crates/ui-egui/src/dialogs/open_prompt.rs +++ b/crates/ui-egui/src/dialogs/open_prompt.rs @@ -43,6 +43,9 @@ pub struct Held { /// The Color Settings policy was already applied (a background open added it before it had /// to wait); otherwise opening applies it. pub policy_applied: bool, + /// Held by Open as Smart Object: the document holds the smart object layer already, and is + /// admitted by [`photocraft_engine::file_cmds::admit_as_smart_object`]. + pub smart_object: bool, } /// Dialog field for the high-memory confirmation prompt. @@ -323,7 +326,7 @@ fn open(app: &mut PhotocraftApp, held: Held, action: Option<&str>) -> Result) -> Result Result { let initial_dir = last_used_dir(&self.ui.recent_files); self.ask_file(FileDialogRequest::Open { multiple: true, initial_dir, extensions: None }, |app, answer| { - let FileDialogAnswer::Paths(mut paths) = answer else { return Err(UNEXPECTED.into()) }; + let FileDialogAnswer::Paths(paths) = answer else { return Err(UNEXPECTED.into()) }; + // Each file is a tracked open job like File › Open's, in name order. No `show_if_open`: + // the file opens as another document even when it is already open. + let mut paths: Vec = paths.iter().map(|p| crate::file_open::absolute(p)).collect(); paths.sort_by(|a, b| crate::file_open::natural_cmp(a, b)); - for path in &paths { - if let Err(e) = crate::jobs_ui::run(app, "file.openAsSmartObject", json!({"path": path})) { + let batch = crate::jobs_ui::begin_batch(app); + for (seq, path) in paths.iter().enumerate() { + let order = crate::jobs_ui::OpenOrder { batch, seq }; + if let Err(e) = crate::jobs_ui::start_open_as_smart_object(app, path, Some(order)) { app.open_failed(&crate::file_open::display_name(path), &e); } } + crate::jobs_ui::end_batch(app); app.sync_views(); Ok(Value::Null) }) diff --git a/crates/ui-egui/src/file_open.rs b/crates/ui-egui/src/file_open.rs index 865f65d7ef..8262f6ae9a 100644 --- a/crates/ui-egui/src/file_open.rs +++ b/crates/ui-egui/src/file_open.rs @@ -594,12 +594,12 @@ fn digit_run(it: &mut std::iter::Peekable) -> String { } #[cfg(not(target_arch = "wasm32"))] -fn absolute(p: &str) -> String { +pub(crate) fn absolute(p: &str) -> String { std::path::absolute(p).map(|a| a.to_string_lossy().to_string()).unwrap_or_else(|_| p.to_string()) } #[cfg(target_arch = "wasm32")] -fn absolute(p: &str) -> String { +pub(crate) fn absolute(p: &str) -> String { p.to_string() } diff --git a/crates/ui-egui/src/file_open/tests.rs b/crates/ui-egui/src/file_open/tests.rs index 3fdf448099..38e07dfa85 100644 --- a/crates/ui-egui/src/file_open/tests.rs +++ b/crates/ui-egui/src/file_open/tests.rs @@ -1709,6 +1709,28 @@ fn open_as_smart_object_dialog_opens_one_document_per_selected_file() { assert_eq!(app.session.documents().iter().map(|d| d.doc.name.as_str()).collect::>(), ["one-1", "two-1"]); assert!(app.session.documents().iter().all(|d| d.path.is_none() && d.doc.layers.len() == 1)); - assert!(app.ui.status_error, "the unreadable file is reported"); + assert!(app.ui.notices.iter().any(|n| n.error && n.title.starts_with("Couldn't open bad.png")), "the unreadable file is reported"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// File > Open as Smart Object... runs each file as a tracked background job like File > Open +/// does: a tab per file, the documents landing in name order, untitled, failures reported. +#[test] +fn open_as_smart_object_opens_in_the_background_in_name_order() { + let ctx = egui::Context::default(); + let (dir, paths) = files("open-as-so-bg", &["two.png", "one.png", "bad.png"], &png_bytes(8, 6)); + std::fs::write(&paths[2], b"not an image").unwrap(); + let (mut app, _) = app_with(vec![Some(FileDialogAnswer::Paths(paths.clone()))]); + app.background_jobs = true; + + menus::invoke(&mut app, &ctx, "file.openAsSmartObject", json!({})).unwrap(); + answer(&mut app); + assert_eq!(app.jobs.opens.len(), 3, "a tab for each file while it opens"); + assert!(app.session.documents().is_empty(), "nothing is on the UI thread's critical path"); + + tick_until(&mut app, &ctx, |app| app.jobs.opens.is_empty()); + assert_eq!(app.session.documents().iter().map(|d| d.doc.name.as_str()).collect::>(), ["one-1", "two-1"]); + assert!(app.session.documents().iter().all(|d| d.path.is_none() && d.doc.layers.len() == 1)); + assert!(app.ui.notices.iter().any(|n| n.error && n.title.starts_with("Couldn't open bad.png")), "the unreadable file is reported"); let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/ui-egui/src/jobs_ui.rs b/crates/ui-egui/src/jobs_ui.rs index c4ff5397c2..3900826166 100644 --- a/crates/ui-egui/src/jobs_ui.rs +++ b/crates/ui-egui/src/jobs_ui.rs @@ -286,11 +286,37 @@ pub fn start_open( // once. A preflight keeps the slot and set order in its memory prompt. Started::Done(v) => finish_open(app, name, path.as_deref(), &v, slot, order), Started::Job(job) => { - app.jobs.opens.push(OpenTab { job, name: name.to_string(), path, slot, order }); - app.jobs.focus = Some(job); - app.ui.chrome.home = None; - app.ui.status = crate::i18n::fmt(tl!("Opening {name}…"), &[("name", name)]); - app.ui.status_error = false; + track_open(app, OpenTab { job, name: name.to_string(), path, slot, order }); + Ok(()) + } + } +} + +/// A file opening in the background gets its tab: focused, with the status line naming it. +fn track_open(app: &mut PhotocraftApp, tab: OpenTab) { + app.ui.status = crate::i18n::fmt(tl!("Opening {name}…"), &[("name", &tab.name)]); + app.jobs.focus = Some(tab.job); + app.jobs.opens.push(tab); + app.ui.chrome.home = None; + app.ui.status_error = false; +} + +/// File › Open as Smart Object… for one file: `file.openAsSmartObject` as a tracked open job, so +/// the result goes through File › Open's completion ([`finish_open`]: the profile prompt, the +/// Camera Raw open dialog for a raw, the import warnings). The document is untitled, so +/// Open Recent is untouched. Inline (background jobs off, or the web) it finishes at once. +pub(crate) fn start_open_as_smart_object(app: &mut PhotocraftApp, path: &str, order: Option) -> Result<(), String> { + let name = crate::file_open::display_name(path); + let params = json!({"path": path}); + let started = if app.background_jobs { + app.session.start("file.openAsSmartObject", params).map_err(|e| e.to_string())? + } else { + Started::Done(app.session.execute("file.openAsSmartObject", params).map_err(|e| e.to_string())?) + }; + match started { + Started::Done(v) => finish_open(app, &name, None, &v, None, order), + Started::Job(job) => { + track_open(app, OpenTab { job, name, path: None, slot: None, order }); Ok(()) } } @@ -608,7 +634,7 @@ fn on_event(app: &mut PhotocraftApp, ctx: &egui::Context, e: JobEvent) { finish_save(app, ctx, save, &e.label, e.outcome); return; } - if e.command == OPEN_JOB { + if e.command == OPEN_JOB || app.jobs.opens.iter().any(|o| o.job == e.id) { let Some(i) = app.jobs.opens.iter().position(|o| o.job == e.id) else { return }; let tab = app.jobs.opens.remove(i); match e.outcome { @@ -703,6 +729,7 @@ fn finish_open(app: &mut PhotocraftApp, name: &str, path: Option<&str>, v: &Valu order, question, policy_applied, + smart_object: v.get("smartObject").and_then(Value::as_bool).unwrap_or(false), }; // Held back for the profile prompt (Ask When Opening). if let Some(id) = v.get("held").and_then(Value::as_u64) { diff --git a/crates/ui-egui/src/psd_open_tests.rs b/crates/ui-egui/src/psd_open_tests.rs index 02c18313f5..eff12bedbe 100644 --- a/crates/ui-egui/src/psd_open_tests.rs +++ b/crates/ui-egui/src/psd_open_tests.rs @@ -569,6 +569,7 @@ fn profile_cancel_cancels_whole_queue_including_memory_prompts() { order: None, question: Some(json!({"missing": false, "working": "sRGB", "embedded": "Adobe RGB"})), policy_applied: false, + smart_object: false, }, ); @@ -600,6 +601,7 @@ fn mixed_prompts_follow_arrival_order_fifo() { order: None, question: None, policy_applied: false, + smart_object: false, }, ); From 3ef91ec45af2e29aa1e4ff0e0471494aec88b8ba Mon Sep 17 00:00:00 2001 From: Triwarr <170976175+Triwarr@users.noreply.github.com> Date: Sun, 8 Nov 2026 02:41:37 +0100 Subject: [PATCH 155/219] PDF: widen the dense-page cancellation margin Raise the synthetic page to a million strokes so the 45 ms watcher threshold keeps a wide margin on an optimised test build, and record why the importer keeps one RenderCache per page. --- crates/io/src/pdf_import.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/io/src/pdf_import.rs b/crates/io/src/pdf_import.rs index d4c8aa4eac..d55676ac16 100644 --- a/crates/io/src/pdf_import.rs +++ b/crates/io/src/pdf_import.rs @@ -147,6 +147,9 @@ pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) - if width > raster::MAX_SIDE || height > raster::MAX_SIDE || u64::from(width) * u64::from(height) > raster::MAX_PIXELS { return Err(IoError::Unsupported(format!("PDF page {page_number} is too large at {dpi:.0} dpi; lower the import DPI"))); } + // A cache per page, deliberately: a shared one would keep decoded images and fonts alive + // across up to 16 pages outside the pixel budget, and would hide an image-decode warning + // on a later page after the page that first hit it. let cache = RenderCache::new(); let options = raster::Options { scale: [(dpi / 72.0) as f32; 2], @@ -347,10 +350,11 @@ mod tests { } /// Hundreds of thousands of path operators take far longer than the watcher's polling - /// interval, so the interrupt lands mid-render and the page reports Cancelled. + /// interval, so the interrupt lands mid-render and the page reports Cancelled. A million + /// strokes leaves a wide margin even on a fast, optimised test build. #[test] fn a_dense_page_stops_when_the_interrupt_fires_mid_render() { - let pdf = dense_operator_pdf(300_000).expect("writing the dense PDF"); + let pdf = dense_operator_pdf(1_000_000).expect("writing the dense PDF"); let document = craft_pdf::open_slice_with_options(&pdf, None, &craft_pdf::OpenOptions::default()).expect("the dense PDF opens"); let cache = RenderCache::new(); let options = From 1f30e48673176464cfbeba743bf96e7be5654697 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 7 Nov 2026 19:45:28 -0600 Subject: [PATCH 156/219] Rasterize prompt: the smart object wording as in the reference (#11899) The prompt for painting on a smart object reads "This smart object must be rasterized before proceeding. Edit Contents will no longer be available. Rasterize the smart object?", lower-case "smart object" as measured. The 22 catalogs that translated the old text are re-keyed to the new English text, so those languages keep a translation. The type, shape and fill prompts are unchanged. --- .../ui-egui/src/dialogs/rasterize_prompt.rs | 9 ++++- crates/ui-egui/src/i18n/az.tsv | 2 +- crates/ui-egui/src/i18n/bs.tsv | 2 +- crates/ui-egui/src/i18n/cs.tsv | 2 +- crates/ui-egui/src/i18n/da.tsv | 2 +- crates/ui-egui/src/i18n/es.tsv | 2 +- crates/ui-egui/src/i18n/fi.tsv | 2 +- crates/ui-egui/src/i18n/fr.tsv | 2 +- crates/ui-egui/src/i18n/he.tsv | 2 +- crates/ui-egui/src/i18n/hu.tsv | 2 +- crates/ui-egui/src/i18n/it.tsv | 2 +- crates/ui-egui/src/i18n/ka.tsv | 2 +- crates/ui-egui/src/i18n/ko.tsv | 2 +- crates/ui-egui/src/i18n/nb.tsv | 2 +- crates/ui-egui/src/i18n/pt-br.tsv | 2 +- crates/ui-egui/src/i18n/ro.tsv | 2 +- crates/ui-egui/src/i18n/ru.tsv | 2 +- crates/ui-egui/src/i18n/sr.tsv | 2 +- crates/ui-egui/src/i18n/sv.tsv | 2 +- crates/ui-egui/src/i18n/ta.tsv | 2 +- crates/ui-egui/src/i18n/th.tsv | 2 +- crates/ui-egui/src/i18n/uk.tsv | 2 +- crates/ui-egui/src/i18n/ur.tsv | 2 +- .../src/rasterize_prompt_text_tests.rs | 39 +++++++++++++++++++ 24 files changed, 69 insertions(+), 23 deletions(-) create mode 100644 crates/ui-egui/src/rasterize_prompt_text_tests.rs diff --git a/crates/ui-egui/src/dialogs/rasterize_prompt.rs b/crates/ui-egui/src/dialogs/rasterize_prompt.rs index a6ac9ace68..fc9018516d 100644 --- a/crates/ui-egui/src/dialogs/rasterize_prompt.rs +++ b/crates/ui-egui/src/dialogs/rasterize_prompt.rs @@ -57,7 +57,10 @@ impl Kind { match self { Kind::Type => "This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type?", Kind::Shape => "This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape?", - Kind::SmartObject => "This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object?", + // As measured in the reference application (#11899): lower-case "smart object". + Kind::SmartObject => { + "This smart object must be rasterized before proceeding. Edit Contents will no longer be available. Rasterize the smart object?" + } Kind::Fill => "This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer?", } } @@ -249,3 +252,7 @@ pub fn run_pending(app: &mut PhotocraftApp, ctx: &egui::Context) { #[cfg(test)] mod tests; + +#[cfg(test)] +#[path = "../rasterize_prompt_text_tests.rs"] +mod rasterize_prompt_text_tests; diff --git a/crates/ui-egui/src/i18n/az.tsv b/crates/ui-egui/src/i18n/az.tsv index c228ba73d0..5fa57f8379 100644 --- a/crates/ui-egui/src/i18n/az.tsv +++ b/crates/ui-egui/src/i18n/az.tsv @@ -4262,7 +4262,7 @@ curves Smooth Hamarlama The file could not be saved. Faylı saxlamaq mümkün olmadı. Thickness Qalınlıq This Layer: Bu qat: - This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Bu Ağıllı Obyekt davam etməzdən əvvəl rasterləşdirilməlidir. Onun məzmunu artıq redaktə edilə bilməyəcək. Ağıllı Obyekt rasterləşdirilsin? + This smart object must be rasterized before proceeding. Edit Contents will no longer be available. Rasterize the smart object? Bu Ağıllı Obyekt davam etməzdən əvvəl rasterləşdirilməlidir. Onun məzmunu artıq redaktə edilə bilməyəcək. Ağıllı Obyekt rasterləşdirilsin? This adjustment ({key}) is kept as imported and can't be edited yet. Bu tənzimləmə ({key}) idxal edilmiş kimi saxlanılır və hələ redaktə edilə bilməz. This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Davam etməzdən əvvəl bu doldurma qatsi rasterləşdirilməlidir. Onun doldurma məzmunu artıq redaktə edilə bilməyəcək. Qat rasterləşdirilsin? This layer must be rasterized before proceeding. Davam etməzdən əvvəl bu qat rasterləşdirilməlidir. diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 3a9cdb8137..9a5926f9a0 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -4002,7 +4002,7 @@ layerLabel No Color Bez boje Scroll menu down Pomjeri meni dolje This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? Ovaj tekstualni sloj mora se rasterizirati prije nastavka. Njegov tekst više neće biti moguće uređivati. Rasterizirati tekst? This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? Ovaj sloj oblika mora se rasterizirati prije nastavka. Više se neće moći uređivati kao oblik. Rasterizirati oblik? - This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Ovaj pametni objekat mora se rasterizirati prije nastavka. Njegov sadržaj više neće biti moguće uređivati. Rasterizirati pametni objekat? + This smart object must be rasterized before proceeding. Edit Contents will no longer be available. Rasterize the smart object? Ovaj pametni objekat mora se rasterizirati prije nastavka. Njegov sadržaj više neće biti moguće uređivati. Rasterizirati pametni objekat? This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Ovaj sloj ispune mora se rasterizirati prije nastavka. Njegov sadržaj ispune više neće biti moguće uređivati. Rasterizirati sloj? This layer must be rasterized before proceeding. Ovaj sloj mora se rasterizirati prije nastavka. Color Picker (Background Color) Birač boja (boja pozadine) diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index eb8d5dc546..89cfb5d675 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3807,7 +3807,7 @@ curves Smooth Vyhladit Scroll menu down Posunout nabídku dolů This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? Před pokračováním je nutné tuto textovou vrstvu rastrovat. Její text už nebude možné upravovat. Rastrovat text? This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? Před pokračováním je nutné tuto vrstvu tvaru rastrovat. Už ji nebude možné upravovat jako tvar. Rastrovat tvar? - This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Před pokračováním je nutné tento chytrý objekt rastrovat. Jeho obsah už nebude možné upravovat. Rastrovat chytrý objekt? + This smart object must be rasterized before proceeding. Edit Contents will no longer be available. Rasterize the smart object? Před pokračováním je nutné tento chytrý objekt rastrovat. Jeho obsah už nebude možné upravovat. Rastrovat chytrý objekt? This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Před pokračováním je nutné tuto vrstvu výplně rastrovat. Její výplň už nebude možné upravovat. Rastrovat vrstvu? This layer must be rasterized before proceeding. Před pokračováním je nutné tuto vrstvu rastrovat. Color Picker (Background Color) Výběr barvy (barva pozadí) diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index cf9105b865..c8814738a0 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -4493,7 +4493,7 @@ curves Smooth Udjævn The file could not be saved. Filen kunne ikke gemmes. Thickness Tykkelse This Layer: Dette lag: - This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Dette smartobjekt skal rasteriseres, før du fortsætter. Dets indhold kan ikke længere redigeres. Vil du rasterisere smartobjektet? + This smart object must be rasterized before proceeding. Edit Contents will no longer be available. Rasterize the smart object? Dette smartobjekt skal rasteriseres, før du fortsætter. Dets indhold kan ikke længere redigeres. Vil du rasterisere smartobjektet? This adjustment ({key}) is kept as imported and can't be edited yet. Denne justering ({key}) bevares som importeret og kan ikke redigeres endnu. This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Dette udfyldningslag skal rasteriseres, før du fortsætter. Dets udfyldningsindhold kan ikke længere redigeres. Vil du rasterisere laget? This layer must be rasterized before proceeding. Dette lag skal rasteriseres, før du fortsætter. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index e9b758a917..b299c58089 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4105,7 +4105,7 @@ layerLabel No Color Sin color Scroll menu down Desplazar menú hacia abajo This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? Esta capa de texto debe rasterizarse antes de continuar. Su texto dejará de ser editable. ¿Rasterizar el texto? This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? Esta capa de forma debe rasterizarse antes de continuar. Dejará de ser editable como forma. ¿Rasterizar la forma? - This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Este objeto inteligente debe rasterizarse antes de continuar. Su contenido dejará de ser editable. ¿Rasterizar el objeto inteligente? + This smart object must be rasterized before proceeding. Edit Contents will no longer be available. Rasterize the smart object? Este objeto inteligente debe rasterizarse antes de continuar. Su contenido dejará de ser editable. ¿Rasterizar el objeto inteligente? This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Esta capa de relleno debe rasterizarse antes de continuar. Su contenido de relleno dejará de ser editable. ¿Rasterizar la capa? This layer must be rasterized before proceeding. Esta capa debe rasterizarse antes de continuar. Color Picker (Background Color) Selector de color (color de fondo) diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index eb99627d4f..ddf6948ef7 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -4494,7 +4494,7 @@ curves Smooth Tasoita The file could not be saved. Tiedostoa ei voitu tallentaa. Thickness Paksuus This Layer: Tämä taso: - This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Tämä älykäs objekti on rasteroitava ennen jatkamista. Sen sisältöä ei voi enää muokata. Rasteroidaanko älykäs objekti? + This smart object must be rasterized before proceeding. Edit Contents will no longer be available. Rasterize the smart object? Tämä älykäs objekti on rasteroitava ennen jatkamista. Sen sisältöä ei voi enää muokata. Rasteroidaanko älykäs objekti? This adjustment ({key}) is kept as imported and can't be edited yet. Tämä säätö ({key}) säilytetään tuotuna, eikä sitä voi vielä muokata. This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Tämä täyttötaso on rasteroitava ennen jatkamista. Sen täyttösisältöä ei voi enää muokata. Rasteroidaanko taso? This layer must be rasterized before proceeding. Tämä taso on rasteroitava ennen jatkamista. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index c0a008b3e4..34a1486aed 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4333,7 +4333,7 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded The file could not be saved. Impossible d'enregistrer le fichier. Thickness Épaisseur This Layer: Ce calque : - This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Cet objet intelligent doit être rastérisé pour continuer. Son contenu ne sera plus modifiable. Rastériser l'objet intelligent ? + This smart object must be rasterized before proceeding. Edit Contents will no longer be available. Rasterize the smart object? Cet objet intelligent doit être rastérisé pour continuer. Son contenu ne sera plus modifiable. Rastériser l'objet intelligent ? This adjustment ({key}) is kept as imported and can't be edited yet. Cet ajustement ({key}) est conservé tel qu'il a été importé et ne peut pas encore être modifié. This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Ce calque de remplissage doit être rastérisé pour continuer. Son contenu de remplissage ne sera plus modifiable. Rastériser le calque ? This layer must be rasterized before proceeding. Ce calque doit être rastérisé pour continuer. diff --git a/crates/ui-egui/src/i18n/he.tsv b/crates/ui-egui/src/i18n/he.tsv index d38b62ed3a..62b4c04134 100644 --- a/crates/ui-egui/src/i18n/he.tsv +++ b/crates/ui-egui/src/i18n/he.tsv @@ -4018,7 +4018,7 @@ layerLabel No Color בלי צבע Scroll menu down גלול תפריט למטה This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? שכבת הטקסט הזו חייבת להיות מרוסטרת לפני ההמשך. הטקסט שלה לא יהיה עוד עריך. לרסטר את הטקסט? This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? שכבת הצורה הזו חייבת להיות מרוסטרת לפני ההמשך. היא לא תהיה עוד עריכה כצורה. לרסטר את הצורה? - This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? האובייקט החכם הזה חייב להיות מרוסטר לפני ההמשך. התוכן שלו לא יהיה עוד עריך. לרסטר את האובייקט החכם? + This smart object must be rasterized before proceeding. Edit Contents will no longer be available. Rasterize the smart object? האובייקט החכם הזה חייב להיות מרוסטר לפני ההמשך. התוכן שלו לא יהיה עוד עריך. לרסטר את האובייקט החכם? This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? שכבת המילוי הזו חייבת להיות מרוסטרת לפני ההמשך. תוכן המילוי שלה לא יהיה עוד עריך. לרסטר את השכבה? This layer must be rasterized before proceeding. השכבה הזו חייבת להיות מרוסטרת לפני ההמשך. Color Picker (Background Color) בוחר צבעים (צבע רקע) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index ced7e54f43..abb2e7bfba 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3090,7 +3090,7 @@ cameraRaw Yellow Sárga This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? A folytatáshoz a kitöltőréteget raszterizálni kell. A kitöltés tartalma ezután nem lesz szerkeszthető. Raszterizálod a réteget? This layer must be rasterized before proceeding. A folytatáshoz a réteget raszterizálni kell. This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? A folytatáshoz az alakzatréteget raszterizálni kell. Ezután alakzatként nem lesz szerkeszthető. Raszterizálod az alakzatot? - This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? A folytatáshoz az intelligens objektumot raszterizálni kell. A tartalma ezután nem lesz szerkeszthető. Raszterizálod az intelligens objektumot? + This smart object must be rasterized before proceeding. Edit Contents will no longer be available. Rasterize the smart object? A folytatáshoz az intelligens objektumot raszterizálni kell. A tartalma ezután nem lesz szerkeszthető. Raszterizálod az intelligens objektumot? This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? A folytatáshoz a szövegréteget raszterizálni kell. A szöveg ezután nem lesz szerkeszthető. Raszterizálod a szöveget? TIFF Options TIFF-beállítások Top: Felső: diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 1d671feece..31cb7f3d0e 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4170,7 +4170,7 @@ layerLabel No Color Nessun colore Scroll menu down Scorri il menu in basso This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? Per procedere il livello di testo deve essere rasterizzato. Il testo non sarà più modificabile. Rasterizzare il testo? This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? Per procedere il livello forma deve essere rasterizzato. Non sarà più modificabile come forma. Rasterizzare la forma? - This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Per procedere l'oggetto avanzato deve essere rasterizzato. Il contenuto non sarà più modificabile. Rasterizzare l'oggetto avanzato? + This smart object must be rasterized before proceeding. Edit Contents will no longer be available. Rasterize the smart object? Per procedere l'oggetto avanzato deve essere rasterizzato. Il contenuto non sarà più modificabile. Rasterizzare l'oggetto avanzato? This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Per procedere il livello di riempimento deve essere rasterizzato. Il riempimento non sarà più modificabile. Rasterizzare il livello? This layer must be rasterized before proceeding. Per procedere il livello deve essere rasterizzato. Color Picker (Background Color) Selettore colore (colore sfondo) diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 02e5270e11..bec93b9ce4 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -3949,7 +3949,7 @@ curves Smooth გაგლუვება Scroll menu down მენიუს ქვემოთ გადახვევა This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? გასაგრძელებლად ეს ტექსტის შრე უნდა დარასტრირდეს. მისი ტექსტი აღარ იქნება რედაქტირებადი. დავარასტრირო ტექსტი? This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? გასაგრძელებლად ეს ფიგურის შრე უნდა დარასტრირდეს. ის ფიგურად აღარ იქნება რედაქტირებადი. დავარასტრირო ფიგურა? - This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? გასაგრძელებლად ეს ჭკვიანი ობიექტი უნდა დარასტრირდეს. მისი შიგთავსი აღარ იქნება რედაქტირებადი. დავარასტრირო ჭკვიანი ობიექტი? + This smart object must be rasterized before proceeding. Edit Contents will no longer be available. Rasterize the smart object? გასაგრძელებლად ეს ჭკვიანი ობიექტი უნდა დარასტრირდეს. მისი შიგთავსი აღარ იქნება რედაქტირებადი. დავარასტრირო ჭკვიანი ობიექტი? This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? გასაგრძელებლად ეს შევსების შრე უნდა დარასტრირდეს. მისი შევსება აღარ იქნება რედაქტირებადი. დავარასტრირო შრე? This layer must be rasterized before proceeding. გასაგრძელებლად ეს შრე უნდა დარასტრირდეს. Color Picker (Background Color) ფერის ამრჩევი (ფონის ფერი) diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 4d4815caf2..34b85aabdc 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4024,7 +4024,7 @@ layerLabel No Color 색상 없음 Scroll menu down 메뉴 아래로 스크롤 This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? 계속하려면 이 문자 레이어를 래스터화해야 합니다. 이후에는 텍스트를 편집할 수 없습니다. 문자를 래스터화할까요? This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? 계속하려면 이 도형 레이어를 래스터화해야 합니다. 이후에는 도형으로 편집할 수 없습니다. 도형을 래스터화할까요? - This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? 계속하려면 이 고급 개체를 래스터화해야 합니다. 이후에는 내용을 편집할 수 없습니다. 고급 개체를 래스터화할까요? + This smart object must be rasterized before proceeding. Edit Contents will no longer be available. Rasterize the smart object? 계속하려면 이 고급 개체를 래스터화해야 합니다. 이후에는 내용을 편집할 수 없습니다. 고급 개체를 래스터화할까요? This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? 계속하려면 이 칠 레이어를 래스터화해야 합니다. 이후에는 칠 내용을 편집할 수 없습니다. 레이어를 래스터화할까요? This layer must be rasterized before proceeding. 계속하려면 이 레이어를 래스터화해야 합니다. Color Picker (Background Color) 색상 선택기(배경색) diff --git a/crates/ui-egui/src/i18n/nb.tsv b/crates/ui-egui/src/i18n/nb.tsv index 83643360f3..6012ca0754 100644 --- a/crates/ui-egui/src/i18n/nb.tsv +++ b/crates/ui-egui/src/i18n/nb.tsv @@ -4493,7 +4493,7 @@ curves Smooth Jevn ut The file could not be saved. Filen kunne ikke lagres. Thickness Tykkelse This Layer: Dette laget: - This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Dette smartobjektet må rasteriseres før du fortsetter. Innholdet kan da ikke lenger redigeres. Rasterisere smartobjektet? + This smart object must be rasterized before proceeding. Edit Contents will no longer be available. Rasterize the smart object? Dette smartobjektet må rasteriseres før du fortsetter. Innholdet kan da ikke lenger redigeres. Rasterisere smartobjektet? This adjustment ({key}) is kept as imported and can't be edited yet. Denne justeringen ({key}) beholdes som importert og kan ikke redigeres ennå. This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Dette fyllaget må rasteriseres før du fortsetter. Fyllinnholdet kan da ikke lenger redigeres. Rasterisere laget? This layer must be rasterized before proceeding. Dette laget må rasteriseres før du fortsetter. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 15fdbbdbeb..c76b72f6ed 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4321,7 +4321,7 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded This layer must be rasterized before proceeding. Esta camada precisa ser rasterizada antes de continuar. This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? Esta camada de texto precisa ser rasterizada antes de continuar. O texto não poderá mais ser editado. Rasterizar o texto? This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? Esta camada de forma precisa ser rasterizada antes de continuar. Ela não poderá mais ser editada como forma. Rasterizar a forma? - This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Este objeto inteligente precisa ser rasterizado antes de continuar. O conteúdo não poderá mais ser editado. Rasterizar o objeto inteligente? + This smart object must be rasterized before proceeding. Edit Contents will no longer be available. Rasterize the smart object? Este objeto inteligente precisa ser rasterizado antes de continuar. O conteúdo não poderá mais ser editado. Rasterizar o objeto inteligente? This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Esta camada de preenchimento precisa ser rasterizada antes de continuar. O conteúdo do preenchimento não poderá mais ser editado. Rasterizar a camada? Graphics adapter: {adapter} Adaptador gráfico: {adapter} Backend: {backend} ({type}) Backend: {backend} ({type}) diff --git a/crates/ui-egui/src/i18n/ro.tsv b/crates/ui-egui/src/i18n/ro.tsv index 5bf7d924c8..57812b13f3 100644 --- a/crates/ui-egui/src/i18n/ro.tsv +++ b/crates/ui-egui/src/i18n/ro.tsv @@ -4060,7 +4060,7 @@ layerLabel No Color Fără culoare Scroll menu down Derulare meniu jos This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? Acest strat de text trebuie rasterizat înainte de a continua. Textul său nu va mai fi editabil. Rasterizați textul? This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? Acest strat de formă trebuie rasterizat înainte de a continua. Nu va mai fi editabil ca formă. Rasterizați forma? - This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Acest obiect inteligent trebuie rasterizat înainte de a continua. Conținutul său nu va mai fi editabil. Rasterizați obiectul inteligent? + This smart object must be rasterized before proceeding. Edit Contents will no longer be available. Rasterize the smart object? Acest obiect inteligent trebuie rasterizat înainte de a continua. Conținutul său nu va mai fi editabil. Rasterizați obiectul inteligent? This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Acest strat de umplere trebuie rasterizat înainte de a continua. Conținutul său de umplere nu va mai fi editabil. Rasterizați stratul? This layer must be rasterized before proceeding. Acest strat trebuie rasterizat înainte de a continua. Color Picker (Background Color) Selector de culoare (culoare de fundal) diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 65575eaf9a..8a8d7fb2fc 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4496,7 +4496,7 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded The file could not be saved. Не удалось сохранить файл. Thickness Толщина This Layer: Этот слой: - This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Перед продолжением необходимо растрировать этот смарт-объект. Его содержимое перестанет быть редактируемым. Растрировать смарт-объект? + This smart object must be rasterized before proceeding. Edit Contents will no longer be available. Rasterize the smart object? Перед продолжением необходимо растрировать этот смарт-объект. Его содержимое перестанет быть редактируемым. Растрировать смарт-объект? This adjustment ({key}) is kept as imported and can't be edited yet. Эта коррекция ({key}) сохраняется в импортированном виде и пока не может быть изменена. This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Перед продолжением необходимо растрировать этот слой заливки. Содержимое заливки перестанет быть редактируемым. Растрировать слой? This layer must be rasterized before proceeding. Перед продолжением необходимо растрировать этот слой. diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index b2c1d42cab..9bb54b475e 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4143,7 +4143,7 @@ layerLabel No Color Bez boje Scroll menu down Pomeri meni nadole This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? Ovaj tekstualni sloj mora da se rasterizuje pre nastavka. Njegov tekst više neće moći da se uređuje. Rasterizovati tekst? This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? Ovaj sloj oblika mora da se rasterizuje pre nastavka. Više neće moći da se uređuje kao oblik. Rasterizovati oblik? - This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Ovaj pametni objekat mora da se rasterizuje pre nastavka. Njegov sadržaj više neće moći da se uređuje. Rasterizovati pametni objekat? + This smart object must be rasterized before proceeding. Edit Contents will no longer be available. Rasterize the smart object? Ovaj pametni objekat mora da se rasterizuje pre nastavka. Njegov sadržaj više neće moći da se uređuje. Rasterizovati pametni objekat? This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Ovaj sloj popune mora da se rasterizuje pre nastavka. Njegov sadržaj popune više neće moći da se uređuje. Rasterizovati sloj? This layer must be rasterized before proceeding. Ovaj sloj mora da se rasterizuje pre nastavka. Color Picker (Background Color) Birač boja (boja pozadine) diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 2157f234de..7446563416 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4491,7 +4491,7 @@ curves Smooth Jämna ut The file could not be saved. Det gick inte att spara filen. Thickness Tjocklek This Layer: Det här lagret: - This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Det här smarta objektet måste rasteriseras innan du fortsätter. Innehållet går inte längre att redigera. Vill du rasterisera det smarta objektet? + This smart object must be rasterized before proceeding. Edit Contents will no longer be available. Rasterize the smart object? Det här smarta objektet måste rasteriseras innan du fortsätter. Innehållet går inte längre att redigera. Vill du rasterisera det smarta objektet? This adjustment ({key}) is kept as imported and can't be edited yet. Den här justeringen ({key}) behålls som den importerades och kan inte redigeras ännu. This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Det här fyllningslagret måste rasteriseras innan du fortsätter. Fyllningsinnehållet går inte längre att redigera. Vill du rasterisera lagret? This layer must be rasterized before proceeding. Det här lagret måste rasteriseras innan du fortsätter. diff --git a/crates/ui-egui/src/i18n/ta.tsv b/crates/ui-egui/src/i18n/ta.tsv index 3c45b9a218..c8269c4cbb 100644 --- a/crates/ui-egui/src/i18n/ta.tsv +++ b/crates/ui-egui/src/i18n/ta.tsv @@ -4027,7 +4027,7 @@ layerLabel No Color நிறம் இல்லை Scroll menu down பட்டியைக் கீழே உருட்டு This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? தொடர்வதற்கு முன் இந்த உரை அடுக்கு ராஸ்டராக்கப்பட வேண்டும். அதன் உரையை இனி திருத்த முடியாது. உரையை ராஸ்டராக்கவா? This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? தொடர்வதற்கு முன் இந்த வடிவ அடுக்கு ராஸ்டராக்கப்பட வேண்டும். இதை இனி வடிவமாகத் திருத்த முடியாது. வடிவத்தை ராஸ்டராக்கவா? - This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? தொடர்வதற்கு முன் இந்த திறன்மிகு பொருள் ராஸ்டராக்கப்பட வேண்டும். அதன் உள்ளடக்கத்தை இனி திருத்த முடியாது. திறன்மிகு பொருளை ராஸ்டராக்கவா? + This smart object must be rasterized before proceeding. Edit Contents will no longer be available. Rasterize the smart object? தொடர்வதற்கு முன் இந்த திறன்மிகு பொருள் ராஸ்டராக்கப்பட வேண்டும். அதன் உள்ளடக்கத்தை இனி திருத்த முடியாது. திறன்மிகு பொருளை ராஸ்டராக்கவா? This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? தொடர்வதற்கு முன் இந்த நிரப்பு அடுக்கு ராஸ்டராக்கப்பட வேண்டும். அதன் நிரப்பு உள்ளடக்கத்தை இனி திருத்த முடியாது. அடுக்கை ராஸ்டராக்கவா? This layer must be rasterized before proceeding. தொடர்வதற்கு முன் இந்த அடுக்கு ராஸ்டராக்கப்பட வேண்டும். Color Picker (Background Color) நிறத் தேர்வி (பின்னணி நிறம்) diff --git a/crates/ui-egui/src/i18n/th.tsv b/crates/ui-egui/src/i18n/th.tsv index ef34287b6b..8340165370 100644 --- a/crates/ui-egui/src/i18n/th.tsv +++ b/crates/ui-egui/src/i18n/th.tsv @@ -4472,7 +4472,7 @@ curves Smooth เรียบเนียน The file could not be saved. ไม่สามารถบันทึกไฟล์ได้ Thickness ความหนา This Layer: เลเยอร์นี้: - This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? สมาร์ทออบเจ็กต์นี้ต้องได้รับการแรสเตอร์ก่อนดำเนินการต่อ เนื้อหาจะไม่สามารถแก้ไขได้อีกต่อไป แรสเตอร์วัตถุอัจฉริยะหรือไม่ + This smart object must be rasterized before proceeding. Edit Contents will no longer be available. Rasterize the smart object? สมาร์ทออบเจ็กต์นี้ต้องได้รับการแรสเตอร์ก่อนดำเนินการต่อ เนื้อหาจะไม่สามารถแก้ไขได้อีกต่อไป แรสเตอร์วัตถุอัจฉริยะหรือไม่ This adjustment ({key}) is kept as imported and can't be edited yet. การปรับเปลี่ยนนี้ ({key}) ถูกเก็บไว้เป็นการนำเข้าและยังไม่สามารถแก้ไขได้ This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? เลเยอร์เติมนี้จะต้องถูกแรสเตอร์ก่อนดำเนินการต่อ เนื้อหาที่กรอกจะไม่สามารถแก้ไขได้อีกต่อไป แรสเตอร์เลเยอร์หรือไม่? This layer must be rasterized before proceeding. เลเยอร์นี้จะต้องถูกแรสเตอร์ก่อนดำเนินการต่อ diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 8035a7f224..fc996be47e 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4157,7 +4157,7 @@ curves Smooth Згладити Color Picker (Custom Canvas Color) Вибір кольору (власний колір полотна) This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? Щоб продовжити, потрібно растеризувати цей текстовий шар. Після цього його текст більше не можна буде редагувати. Растеризувати текст? This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? Щоб продовжити, потрібно растеризувати цей шар фігури. Після цього його більше не можна буде редагувати як фігуру. Растеризувати фігуру? - This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Щоб продовжити, потрібно растеризувати цей смартоб’єкт. Після цього його вміст більше не можна буде редагувати. Растеризувати смартоб’єкт? + This smart object must be rasterized before proceeding. Edit Contents will no longer be available. Rasterize the smart object? Щоб продовжити, потрібно растеризувати цей смартоб’єкт. Після цього його вміст більше не можна буде редагувати. Растеризувати смартоб’єкт? This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Щоб продовжити, потрібно растеризувати цей шар заливки. Після цього його заливку більше не можна буде редагувати. Растеризувати шар? Demosaic Демозаїк How the camera's color filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Як відновлюється мозаїка кольорового фільтра камери. Застосовується під час відкриття файлу; попередній перегляд показує метод, з яким файл було відкрито. diff --git a/crates/ui-egui/src/i18n/ur.tsv b/crates/ui-egui/src/i18n/ur.tsv index 4d5eaf7961..5812a8053e 100644 --- a/crates/ui-egui/src/i18n/ur.tsv +++ b/crates/ui-egui/src/i18n/ur.tsv @@ -4018,7 +4018,7 @@ layerLabel No Color بلا رنگ Scroll menu down مینیو نیچے اسکرول کریں This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? آگے بڑھنے سے پہلے اس متن تہہ کو ریسٹرائز کرنا ضروری ہے۔ اس کا متن مزید قابل ترمیم نہیں رہے گا۔ کیا قسم کو ریسٹرائز کریں؟ This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? آگے بڑھنے سے پہلے اس شکل تہہ کو ریسٹرائز کرنا ضروری ہے۔ یہ مزید شکل کے طور پر قابل ترمیم نہیں رہے گی۔ کیا شکل کو ریسٹرائز کریں؟ - This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? آگے بڑھنے سے پہلے اس سمارٹ آبجیکٹ کو ریسٹرائز کرنا ضروری ہے۔ اس کا مواد مزید قابل ترمیم نہیں رہے گا۔ کیا سمارٹ آبجیکٹ کو ریسٹرائز کریں؟ + This smart object must be rasterized before proceeding. Edit Contents will no longer be available. Rasterize the smart object? آگے بڑھنے سے پہلے اس سمارٹ آبجیکٹ کو ریسٹرائز کرنا ضروری ہے۔ اس کا مواد مزید قابل ترمیم نہیں رہے گا۔ کیا سمارٹ آبجیکٹ کو ریسٹرائز کریں؟ This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? آگے بڑھنے سے پہلے اس بھرائی تہہ کو ریسٹرائز کرنا ضروری ہے۔ اس کا بھرائی مواد مزید قابل ترمیم نہیں رہے گا۔ کیا تہہ کو ریسٹرائز کریں؟ This layer must be rasterized before proceeding. آگے بڑھنے سے پہلے اس تہہ کو ریسٹرائز کرنا ضروری ہے۔ Color Picker (Background Color) رنگ چنندہ (پس منظر کا رنگ) diff --git a/crates/ui-egui/src/rasterize_prompt_text_tests.rs b/crates/ui-egui/src/rasterize_prompt_text_tests.rs new file mode 100644 index 0000000000..77a5da88bc --- /dev/null +++ b/crates/ui-egui/src/rasterize_prompt_text_tests.rs @@ -0,0 +1,39 @@ +//! #11899: the rasterize prompt for a smart object uses the reference application's wording; +//! the type, shape and fill prompts are unchanged, and the catalogs that translated the old text +//! translate the new one. + +use super::*; +use crate::i18n::{Lang, tr}; + +const OLD: &str = "This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object?"; + +#[test] +fn the_four_prompts_read_as_measured() { + assert_eq!( + Kind::SmartObject.message(), + "This smart object must be rasterized before proceeding. Edit Contents will no longer be available. Rasterize the smart object?" + ); + assert_eq!(Kind::Type.message(), "This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type?"); + assert_eq!(Kind::Shape.message(), "This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape?"); + assert_eq!(Kind::Fill.message(), "This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer?"); + assert!(!Kind::SmartObject.message().contains(" "), "single spaces between sentences"); +} + +#[test] +fn catalogs_that_had_a_translation_keep_one() { + let new = Kind::SmartObject.message(); + let fr = Lang::from_code("fr").expect("French"); + assert_ne!(tr(fr, new), new, "French still translates the prompt"); + // A language without an entry falls back to English. + let de = Lang::from_code("de").expect("German"); + assert_eq!(tr(de, new), new); + // No catalog keeps the old key. + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/i18n"); + for entry in std::fs::read_dir(&dir).unwrap() { + let path = entry.unwrap().path(); + if path.extension().is_some_and(|e| e == "tsv") { + let text = std::fs::read_to_string(&path).unwrap(); + assert!(!text.contains(OLD), "{} still has the old key", path.display()); + } + } +} From fdbb79e46f9a03aa275c8eb8c093be23b65b17eb Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 7 Nov 2026 19:50:50 -0600 Subject: [PATCH 157/219] Background Eraser: a 13 px hard tip at 25 % spacing after Reset Tool (#11911) fresh_brush gives the Background Eraser the reference application's reset tip, a 13 px hard round at 25 % spacing with Size control off, as it gives the Brush its own (#11810). Reset Tool, Reset All Tools and the tool's first use all start from it; other tools and strokes without a brush keep their tips. --- crates/ui-egui/src/canvas/paint_mouse.rs | 13 +- .../tests/it/background_eraser_reset_tip.rs | 111 ++++++++++++++++++ crates/ui-egui/tests/it/main.rs | 1 + 3 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 crates/ui-egui/tests/it/background_eraser_reset_tip.rs diff --git a/crates/ui-egui/src/canvas/paint_mouse.rs b/crates/ui-egui/src/canvas/paint_mouse.rs index 2c16ce1fa1..723f5e9e19 100644 --- a/crates/ui-egui/src/canvas/paint_mouse.rs +++ b/crates/ui-egui/src/canvas/paint_mouse.rs @@ -41,6 +41,8 @@ pub fn sync_tool_brush(app: &mut PhotocraftApp) { // in Normal mode: each tool's Mode starts at Normal, so a Multiply Brush doesn't make the // first Clone Stamp stroke multiply (or give Healing a mode its menu doesn't have). // The Art History Brush also starts at full opacity (#10263): its Opacity is its own. + // The Background Eraser starts with its own measured tip instead (#11911). + None if tool == Tool::BackgroundEraser => fresh_brush(tool, ¤t), None => { let opacity = if tool == Tool::ArtHistoryBrush { BrushSettings::default().opacity } else { current.opacity }; BrushSettings { smoothing: first_smoothing(), mode: photocraft_color::BlendMode::Normal, opacity, ..current } @@ -70,14 +72,19 @@ fn session_fields(current: &BrushSettings, brush: &BrushSettings) -> BrushSettin /// The Brush tool's tip after Reset Tool, as the reference measures it (#11810): size, hardness /// and spacing of a 13 px soft round at 25 % spacing. const BRUSH_TOOL_TIP: (f32, f32, f32) = (13.0, 0.0, 0.25); +/// The Background Eraser's tip after Reset Tool (#11911): a 13 px hard round at 25 % spacing. +const BACKGROUND_ERASER_TIP: (f32, f32, f32) = (13.0, 1.0, 0.25); /// `tool`'s brush as it starts out (Reset Tool, #2825): the default brush at Photoshop's /// smoothing, with the session's own fields of `current`. The Brush tool's tip is -/// [`BRUSH_TOOL_TIP`]; the other tools keep the default tip. +/// [`BRUSH_TOOL_TIP`], the Background Eraser's [`BACKGROUND_ERASER_TIP`]; the other tools keep the +/// default tip. pub(crate) fn fresh_brush(tool: Tool, current: &BrushSettings) -> BrushSettings { let mut brush = BrushSettings { smoothing: first_smoothing(), ..BrushSettings::default() }; - if tool == Tool::Brush { - (brush.size, brush.hardness, brush.spacing) = BRUSH_TOOL_TIP; + match tool { + Tool::Brush => (brush.size, brush.hardness, brush.spacing) = BRUSH_TOOL_TIP, + Tool::BackgroundEraser => (brush.size, brush.hardness, brush.spacing) = BACKGROUND_ERASER_TIP, + _ => {} } session_fields(current, &brush) } diff --git a/crates/ui-egui/tests/it/background_eraser_reset_tip.rs b/crates/ui-egui/tests/it/background_eraser_reset_tip.rs new file mode 100644 index 0000000000..39fe152c94 --- /dev/null +++ b/crates/ui-egui/tests/it/background_eraser_reset_tip.rs @@ -0,0 +1,111 @@ +//! #11911: Reset Tool gives the Background Eraser the reference application's tip, a 13 px hard +//! round at 25 % spacing with Size control off, not the 20 px default at 10 %; Reset All Tools and +//! a first use do too. The other painting tools and `paint.stroke` without a `brush` keep theirs. + +use photocraft_ui_egui::control::{ControlRequest, handle}; +use photocraft_ui_egui::{PhotocraftApp, Tool}; +use serde_json::{Value, json}; + +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.sync_views(); + app +} + +/// A control request as the control channel sends it, then the frame's sync. +fn control(app: &mut PhotocraftApp, method: &str, params: Value) -> Value { + let ctx = egui::Context::default(); + let (req, rx) = ControlRequest::new(method, params); + handle(app, &ctx, &req); + app.sync_views(); + rx.try_recv().unwrap_or(Value::Null) +} + +/// Pick `tool`, and swap its brush in as a frame does. +fn pick(app: &mut PhotocraftApp, tool: Tool) { + app.ui.tool = tool; + photocraft_ui_egui::paint_mouse::sync_tool_brush(app); + app.sync_views(); +} + +/// Size, hardness, spacing, angle and roundness of the live brush, as `brush.get` reports them. +fn tip(app: &mut PhotocraftApp) -> [f64; 5] { + let b = app.run("brush.get", json!({})).unwrap(); + ["size", "hardness", "spacing", "angle", "roundness"].map(|k| (b[k].as_f64().unwrap() * 1e4).round() / 1e4) +} + +fn pressure_size(app: &mut PhotocraftApp) -> bool { + app.run("brush.get", json!({})).unwrap()["pressureSize"].as_bool().unwrap() +} + +/// A tip that is neither default, so a reset has something to undo. +fn scramble(app: &mut PhotocraftApp) { + let b = &mut app.session.tools.brush; + (b.size, b.hardness, b.spacing, b.angle, b.roundness, b.pressure_size) = (57.0, 0.4, 0.6, 30.0, 0.5, true); +} + +const ERASER_TIP: [f64; 5] = [13.0, 1.0, 0.25, 0.0, 1.0]; +const DEFAULT_TIP: [f64; 5] = [20.0, 1.0, 0.1, 0.0, 1.0]; + +#[test] +fn reset_tool_gives_the_background_eraser_a_13_px_hard_tip() { + let mut app = app(); + pick(&mut app, Tool::BackgroundEraser); + scramble(&mut app); + control(&mut app, "ui.menu.invoke", json!({"id": "tool.reset"})); + assert_eq!(tip(&mut app), ERASER_TIP); + assert!(!pressure_size(&mut app), "Size control Off"); + // The options-bar brush chip shows the brush's size. + assert_eq!(app.session.tools.brush.size.round(), 13.0); +} + +#[test] +fn reset_all_tools_gives_it_the_tip_active_or_picked_later() { + let mut app = app(); + pick(&mut app, Tool::BackgroundEraser); + scramble(&mut app); + control(&mut app, "ui.menu.invoke", json!({"id": "tool.resetAll", "params": {"confirmed": true}})); + assert_eq!(tip(&mut app), ERASER_TIP, "active"); + let mut app = self::app(); + pick(&mut app, Tool::BackgroundEraser); + scramble(&mut app); + pick(&mut app, Tool::Pencil); + control(&mut app, "ui.menu.invoke", json!({"id": "tool.resetAll", "params": {"confirmed": true}})); + pick(&mut app, Tool::BackgroundEraser); + assert_eq!(tip(&mut app), ERASER_TIP, "picked after the reset"); +} + +#[test] +fn a_first_use_starts_with_the_tip() { + // No stored tool brushes: the Background Eraser's first pick, after another tool's tip. + let mut app = app(); + pick(&mut app, Tool::Pencil); + scramble(&mut app); + pick(&mut app, Tool::BackgroundEraser); + assert_eq!(tip(&mut app), ERASER_TIP); + assert!(!pressure_size(&mut app)); + // Its own tip is then kept across tool switches. + app.session.tools.brush.size = 42.0; + pick(&mut app, Tool::Pencil); + pick(&mut app, Tool::BackgroundEraser); + assert_eq!(tip(&mut app)[0], 42.0); +} + +#[test] +fn other_tools_and_session_strokes_keep_their_tips() { + for tool in [Tool::Pencil, Tool::Eraser] { + let mut a = app(); + pick(&mut a, tool); + scramble(&mut a); + control(&mut a, "ui.menu.invoke", json!({"id": "tool.reset"})); + assert_eq!(tip(&mut a), DEFAULT_TIP, "{tool:?}"); + } + let mut app = app(); + assert_eq!(tip(&mut app), DEFAULT_TIP, "what `paint.stroke` / `paint.backgroundEraser` paint with when no `brush` is given"); + // A first use of another tool still takes the current tip, as before. + pick(&mut app, Tool::Pencil); + scramble(&mut app); + pick(&mut app, Tool::Eraser); + assert_eq!(tip(&mut app)[0], 57.0); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 41d3f3e662..9e160488d4 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -5,6 +5,7 @@ mod actions_panel_menu; mod actions_record_again; mod adjust_preview_gpu; mod arbitrary_rotation_preview; +mod background_eraser_reset_tip; mod batch_export_options; mod brush_reset_defaults; mod camera_raw_histogram; From 6d3fe6f96f4ee9bb670bf3903a44fc22de0c64d2 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 8 Nov 2026 02:51:01 +0100 Subject: [PATCH 158/219] Background Eraser: partly erased pixels lose the sampled colour's tint (#11902) A dab that erases a pixel only partly now also removes the sampled colour's share from it: the kept colour becomes (c - sampled x (1 - keep)) / keep per channel, clipped to the channel range, so composited over the sampled colour it gives back the original. Later dabs, in the same stroke and in later strokes, match the pixel's current, decontaminated colour; sampling and the Contiguous fill still read the pixels before the stroke. With the graded alpha of #11901 this gives the reference application's drag results. Co-Authored-By: Claude Opus 5.5 --- crates/paint/src/bg_erase.rs | 57 ++++- .../src/bg_erase_decontamination_tests.rs | 212 ++++++++++++++++++ 2 files changed, 258 insertions(+), 11 deletions(-) create mode 100644 crates/paint/src/bg_erase_decontamination_tests.rs diff --git a/crates/paint/src/bg_erase.rs b/crates/paint/src/bg_erase.rs index 2e8da047fc..1cbe4827dd 100644 --- a/crates/paint/src/bg_erase.rs +++ b/crates/paint/src/bg_erase.rs @@ -4,6 +4,7 @@ //! Colours are compared in the surface's own colour channels (RGB, CMYK, Gray, Lab… at any depth), //! so nothing is converted through RGB; only alpha changes. +use photocraft_color::SampleType; use photocraft_geom::Rect; use photocraft_raster::{Surface, from_rgba}; use serde::{Deserialize, Serialize}; @@ -96,6 +97,7 @@ pub fn background_eraser_effect(pre: &Surface, bs: &BgEraseSettings) -> Option>> = None; // Find Edges grades soft edges like Contiguous does instead of cutting them hard (#11904): // the reference application keeps at least as much of a blurred transition with it. @@ -131,27 +133,40 @@ pub fn background_eraser_effect(pre: &Surface, bs: &BgEraseSettings) -> Option 0.0 && !protected(&c[..nc]) { + weight[i] = match_w(dist(&c[..nc], &sampled)); } - weight[(y - r.y0) as usize * w + (x - r.x0) as usize] = match_w(dist(c, &sampled)); } } if limits != Limits::Discontiguous { - keep_connected(&mut weight, w, h, (cx.clamp(r.x0, r.x1 - 1) - r.x0) as usize, (cy.clamp(r.y0, r.y1 - 1) - r.y0) as usize); + keep_connected(&mut reach, w, h, (cx.clamp(r.x0, r.x1 - 1) - r.x0) as usize, (cy.clamp(r.y0, r.y1 - 1) - r.y0) as usize); + for (wv, rv) in weight.iter_mut().zip(&reach) { + if *rv <= 0.0 { + *wv = 0.0; + } + } } for y in r.y0..r.y1 { for x in r.x0..r.x1 { @@ -159,16 +174,32 @@ pub fn background_eraser_effect(pre: &Surface, bs: &BgEraseSettings) -> Option [f32; 4] { + [200.0 / 255.0, (40.0 + 20.0 * i as f32) / 255.0, 40.0 / 255.0, 1.0] +} + +/// Eight 100 px blocks side by side, 900 x 300; the reference's drag row y 450 is y 150 here. +fn blocks(fmt: PixelFormat) -> Surface { + let mut s = Surface::new(fmt); + for i in 0..8 { + let x = 100 * i as i32; + s.fill_rect(Rect::new(x, 0, x + 100, 300), &from_rgba(&fmt, block(i))); + } + s +} + +fn stroke(points: &[(f64, f64)]) -> Stroke { + Stroke { + brush: BrushSettings { size: 40.0, hardness: 1.0, spacing: 0.25, pressure_size: false, ..Default::default() }, + points: points.iter().map(|&(x, y)| StrokePoint::new(x, y, 1.0)).collect(), + } +} + +fn once(tolerance_percent: f32) -> BgEraseSettings { + BgEraseSettings { sampling: Sampling::Once, limits: Limits::Discontiguous, tolerance: tolerance_percent / 100.0, ..Default::default() } +} + +fn swatch(tolerance_percent: f32) -> BgEraseSettings { + BgEraseSettings { sampling: Sampling::BackgroundSwatch, background: block(0), ..once(tolerance_percent) } +} + +/// RGBA 0-255 at block `i`'s centre. +fn rgba(s: &Surface, i: usize) -> [i32; 4] { + let p = s.rgba(100 * i as i32 + 50, 150); + p.map(|v| (v * 255.0).round() as i32) +} + +/// The reference's drag from block 0's centre to `to_x`, Sampling Once. +fn drag(fmt: PixelFormat, tolerance_percent: f32, to_x: f64) -> Surface { + let mut s = blocks(fmt); + apply_background_eraser(&mut s, &stroke(&[(50.0, 150.0), (to_x, 150.0)]), &once(tolerance_percent), None); + s +} + +/// Separate single-click strokes on every block centre, `clicks` times, Background Swatch. +fn clicks(fmt: PixelFormat, tolerance_percent: f32, clicks: usize) -> Surface { + let mut s = blocks(fmt); + for _ in 0..clicks { + for i in 0..8 { + apply_background_eraser(&mut s, &stroke(&[(100.0 * i as f64 + 50.0, 150.0)]), &swatch(tolerance_percent), None); + } + } + s +} + +fn assert_alpha_row(s: &Surface, want: &[i32], what: &str) { + let got: Vec = (0..want.len()).map(|i| rgba(s, i)[3]).collect(); + for (i, (g, w)) in got.iter().zip(want).enumerate() { + assert!((g - w).abs() <= 8, "{what}: block {i} alpha {g}, measured {w} (row {got:?})"); + } +} + +#[test] +fn the_eight_block_drag_gives_the_measured_rgba() { + const WANT: [[i32; 4]; 8] = [ + [200, 40, 40, 0], + [200, 216, 40, 29], + [200, 184, 40, 71], + [200, 175, 40, 113], + [200, 172, 40, 155], + [200, 170, 40, 196], + [200, 169, 40, 238], + [200, 180, 40, 255], + ]; + let s = drag(PixelFormat::RGBA8, 50.0, 750.0); + for (i, want) in WANT.iter().enumerate() { + let got = rgba(&s, i); + // Block 0 is fully erased: only its alpha is defined. + let channels = if want[3] == 0 { 3..4 } else { 0..4 }; + for c in channels { + assert!((got[c] - want[c]).abs() <= 8, "block {i}: got {got:?}, measured {want:?}"); + } + } +} + +#[test] +fn the_drag_table_of_11901_holds_with_decontamination() { + let rows: [(f32, [i32; 8]); 5] = [ + (10.0, [0, 194, 255, 255, 255, 255, 255, 255]), + (20.0, [0, 90, 197, 255, 255, 255, 255, 255]), + (30.0, [0, 56, 126, 196, 255, 255, 255, 255]), + (50.0, [0, 30, 75, 114, 156, 198, 238, 255]), + (70.0, [0, 18, 48, 78, 107, 137, 167, 197]), + ]; + for (tol, want) in rows { + assert_alpha_row(&drag(PixelFormat::RGBA8, tol, 750.0), &want, &format!("drag at Tolerance {tol}")); + } + // At Tolerance 100 the decontaminated colours stay inside the Tolerance, so every dab erases + // a bit more; the drag on to 790 is the measured row (block 7 is no reference at 750). + assert_alpha_row(&drag(PixelFormat::RGBA8, 100.0, 790.0), &[0, 7, 19, 32, 44, 56, 68, 81], "drag at Tolerance 100"); +} + +#[test] +fn later_strokes_match_the_decontaminated_colour() { + let fmt = PixelFormat::RGBA8; + assert_alpha_row(&clicks(fmt, 100.0, 1), &[0, 12, 33, 54, 74, 95, 116, 136], "Tolerance 100, 1 click"); + assert_alpha_row(&clicks(fmt, 100.0, 2), &[0, 10, 28, 45, 62, 80, 97, 114], "Tolerance 100, 2 clicks"); + assert_alpha_row(&clicks(fmt, 100.0, 3), &[0, 8, 23, 38, 52, 67, 81, 96], "Tolerance 100, 3 clicks"); + // At Tolerance 50 the decontaminated colours are outside the Tolerance: a second click leaves + // the block centres as they were. + let one = clicks(fmt, 50.0, 1); + assert_alpha_row(&one, &[0, 29, 71, 113, 155, 196, 238, 255], "Tolerance 50, 1 click"); + let two = clicks(fmt, 50.0, 2); + for i in 1..8 { + assert_eq!(rgba(&two, i), rgba(&one, i), "Tolerance 50, 2 clicks: block {i}"); + } +} + +#[test] +fn partly_erased_pixels_composite_back_to_their_original_colour() { + // Composited over the sampled colour, each partly erased block centre gives back its original + // colour within 2 levels, after one click and after the drag. + for s in [clicks(PixelFormat::RGBA8, 50.0, 1), drag(PixelFormat::RGBA8, 50.0, 750.0)] { + for i in 1..7 { + let [r, g, b, a] = rgba(&s, i); + assert!(a > 0 && a < 255, "block {i} partly erased, alpha {a}"); + let over = |c: i32, s: i32| (c * a + s * (255 - a)) as f32 / 255.0; + let orig = block(i).map(|v| v * 255.0); + for (k, (got, want)) in [over(r, 200), over(g, 40), over(b, 40)].iter().zip(orig).enumerate() { + assert!((got - want).abs() <= 2.0, "block {i} channel {k}: composite {got}, original {want}"); + } + } + } +} + +#[test] +fn kept_and_erased_pixels_and_the_channel_range() { + let s = drag(PixelFormat::RGBA8, 50.0, 750.0); + // Fully kept: the colour exactly; outside the brush: untouched. + assert_eq!(rgba(&s, 7), [200, 180, 40, 255]); + assert_eq!(s.rgba(350, 10), blocks(PixelFormat::RGBA8).rgba(350, 10)); + // Fully erased: alpha 0. + assert_eq!(rgba(&s, 0)[3], 0); + // At Tolerance 100 the decontaminated green clips at 255 rather than wrapping, and 32-bit + // float results stay finite and within 0..1 for in-range colours. + let t = clicks(PixelFormat::RGBA8, 100.0, 1); + assert_eq!(rgba(&t, 7)[1], 255, "clipped, not wrapped"); + let f = clicks(PixelFormat::RGBA32F, 100.0, 3); + for x in (0..900).step_by(7) { + for v in f.rgba(x, 150) { + assert!(v.is_finite() && (0.0..=1.0).contains(&v), "x {x}: {v}"); + } + } +} + +#[test] +fn it_works_in_the_documents_own_channels() { + // 16-bit RGB: the same RGBA as 8-bit. + let rgb16 = PixelFormat::new(ColorMode::Rgb, SampleType::U16, true); + let a = drag(PixelFormat::RGBA8, 50.0, 750.0); + let b = drag(rgb16, 50.0, 750.0); + for i in 0..8 { + let (x, y) = (rgba(&a, i), rgba(&b, i)); + for c in 0..4 { + assert!((x[c] - y[c]).abs() <= 2 || x[3] == 0, "16-bit block {i}: {y:?} against 8-bit {x:?}"); + } + } + // Gray (8 and 16 bit): the gray level is decontaminated the same way, so the composite over + // the sampled gray gives back the original level, and a second click at Tolerance 50 changes + // nothing at the block centres. + for sample in [SampleType::U8, SampleType::U16] { + let gray = PixelFormat::new(ColorMode::Grayscale, sample, true); + let level = |i: usize| (40.0 + 20.0 * i as f32) / 255.0; + let mut s = Surface::new(gray); + for i in 0..8 { + let x = 100 * i as i32; + s.fill_rect(Rect::new(x, 0, x + 100, 300), &[level(i), 1.0]); + } + let bs = BgEraseSettings { background: [level(0), level(0), level(0), 1.0], ..swatch(50.0) }; + let click_all = |s: &mut Surface| { + for i in 0..8 { + apply_background_eraser(s, &stroke(&[(100.0 * i as f64 + 50.0, 150.0)]), &bs, None); + } + }; + click_all(&mut s); + let first = s.clone(); + for i in 1..7 { + let p = s.pixel(100 * i as i32 + 50, 150); + let (v, a) = (p[0], p[1]); + assert!(a > 0.0 && a < 1.0, "{sample:?} block {i} partly erased, alpha {a}"); + let over = v * a + level(0) * (1.0 - a); + assert!((over - level(i)).abs() <= 2.0 / 255.0, "{sample:?} block {i}: composite {over}, original {}", level(i)); + } + click_all(&mut s); + for i in 1..8 { + let at = (100 * i + 50, 150); + assert_eq!(s.pixel(at.0, at.1), first.pixel(at.0, at.1), "{sample:?} block {i}: a second click at Tolerance 50 changes nothing"); + } + } +} From eb68c21f94ef8a6627b275ba433270e008972ac3 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 7 Nov 2026 19:38:07 -0600 Subject: [PATCH 159/219] Brush: the reference's alert when it can't paint (#11898) A Brush press on a hidden target layer, a layer with Lock All, or a group's pixels opens the reference application's alert with OK instead of a status-bar message, and paints nothing. The texts name the tool from one place (brush_refusal::tool_name); tools whose wording wasn't measured keep today's status bar. A group's targeted mask and Lock transparent pixels still paint. --- .../ui-egui/src/brush_refusal_alert_tests.rs | 106 ++++++++++++++++++ crates/ui-egui/src/canvas/tool_input.rs | 21 +++- crates/ui-egui/src/hidden_layer_tests.rs | 4 +- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/tools.rs | 1 + crates/ui-egui/src/tools/brush_refusal.rs | 55 +++++++++ 6 files changed, 184 insertions(+), 4 deletions(-) create mode 100644 crates/ui-egui/src/brush_refusal_alert_tests.rs create mode 100644 crates/ui-egui/src/tools/brush_refusal.rs diff --git a/crates/ui-egui/src/brush_refusal_alert_tests.rs b/crates/ui-egui/src/brush_refusal_alert_tests.rs new file mode 100644 index 0000000000..7aa6bee9fa --- /dev/null +++ b/crates/ui-egui/src/brush_refusal_alert_tests.rs @@ -0,0 +1,106 @@ +//! #11898: when the Brush can't paint (hidden target layer, Lock All, a group's pixels), a press +//! opens the reference application's alert with OK and paints nothing; other tools keep today's +//! status-bar message, a group's targeted mask paints and Lock transparent pixels paints. + +use egui::Modifiers; +use serde_json::{Value, json}; + +use super::*; +use crate::canvas::{ToolEvent, tool_event}; + +/// A 64 × 64 document with a painted layer active, black foreground, `tool`. +fn app(tool: Tool) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("edit.fill", json!({"color": "#dc1e1e"})).unwrap(); + app.run("tools.setColors", json!({"foreground": "#000000"})).unwrap(); + app.ui.tool = tool; + app.sync_views(); + app +} + +fn active(app: &PhotocraftApp) -> u64 { + app.session.active().unwrap().active_layer.unwrap().0 +} + +fn steps(app: &PhotocraftApp) -> usize { + app.session.active().unwrap().history.past_len() +} + +/// A press, a short drag and the release with the active tool. +fn stroke(app: &mut PhotocraftApp) { + tool_event(app, ToolEvent::Down { x: 20.0, y: 32.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(app, ToolEvent::Move { x: 44.0, y: 32.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(app, ToolEvent::Up { x: 44.0, y: 32.0 }, Modifiers::NONE); +} + +fn alerts(app: &PhotocraftApp) -> Vec { + app.ui.dialogs.iter().filter(|d| d.kind == DialogKind::Error).filter_map(|d| d.fields.get("message").and_then(Value::as_str).map(str::to_string)).collect() +} + +fn refused(app: &mut PhotocraftApp, want: &str) { + let before = steps(app); + stroke(app); + assert_eq!(alerts(app), [want], "the alert, once"); + assert_eq!(steps(app), before, "nothing painted, no History step"); + // Over the control channel it is an ordinary dialog: `ui.dialog.confirm` closes it. + let id = app.ui.dialogs.iter().find(|d| d.kind == DialogKind::Error).map(|d| d.id).unwrap(); + crate::dialogs::confirm(app, id).unwrap(); + assert!(alerts(app).is_empty()); +} + +#[test] +fn a_hidden_target_layer() { + let mut app = app(Tool::Brush); + let id = active(&app); + app.run("layer.setProps", json!({"layer": id, "visible": false})).unwrap(); + refused(&mut app, "Could not use the brush tool because the target layer is hidden."); + assert!(app.ui.status.is_empty() || !app.ui.status_error, "no status-bar error as well: {:?}", app.ui.status); +} + +#[test] +fn lock_all() { + let mut app = app(Tool::Brush); + app.run("layer.lockLayers", json!({"all": true})).unwrap(); + refused(&mut app, "Could not use the brush tool because the layer is locked."); +} + +#[test] +fn a_group_is_active() { + let mut app = app(Tool::Brush); + app.run("layer.new.group", json!({})).unwrap(); + assert!(app.session.active().unwrap().doc.layer(photocraft_doc::LayerId(active(&app))).unwrap().is_group()); + refused(&mut app, "Could not use the brush tool because the pixels in a group cannot be edited without merging the group."); +} + +#[test] +fn a_groups_targeted_mask_and_lock_transparency_still_paint() { + let mut app = app(Tool::Brush); + app.run("layer.new.group", json!({})).unwrap(); + app.run("layer.layerMask.revealAll", json!({})).unwrap(); + assert_eq!(crate::canvas::paint_target(&app), json!("mask"), "the new mask is the target"); + let before = steps(&app); + stroke(&mut app); + assert!(alerts(&app).is_empty(), "no alert on the mask"); + assert_eq!(steps(&app), before + 1, "the mask was painted"); + let mut app = self::app(Tool::Brush); + app.run("layer.lockLayers", json!({"transparency": true})).unwrap(); + let before = steps(&app); + stroke(&mut app); + assert!(alerts(&app).is_empty()); + assert_eq!(steps(&app), before + 1, "paints on the existing pixels"); +} + +#[test] +fn other_tools_keep_the_status_bar() { + // Wording not measured for them: today's message, no dialog. + let mut app = app(Tool::Pencil); + let id = active(&app); + app.run("layer.setProps", json!({"layer": id, "visible": false})).unwrap(); + stroke(&mut app); + assert!(alerts(&app).is_empty()); + assert_eq!(app.ui.status, photocraft_engine::hidden_target::HIDDEN); + assert_eq!(tool_name(Tool::Pencil), None); + assert_eq!(tool_name(Tool::Brush), Some("brush tool"), "the one place that names the tool"); +} diff --git a/crates/ui-egui/src/canvas/tool_input.rs b/crates/ui-egui/src/canvas/tool_input.rs index f148b8dfa5..4b90939984 100644 --- a/crates/ui-egui/src/canvas/tool_input.rs +++ b/crates/ui-egui/src/canvas/tool_input.rs @@ -399,9 +399,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) // (⌥-click with the Clone Stamp or Healing Brush only sets the source). let sets_source = matches!(tool, Tool::CloneStamp | Tool::Healing) && mods.alt; // Painting on or moving a hidden layer is refused at the press, as in Photoshop (#571). - if !sets_source && let Some(why) = hidden_target(app, tool) { - app.ui.status = why.into(); - app.ui.status_error = true; + if !sets_source && press_refused(app, tool) { return; } if !sets_source && crate::rasterize_prompt::intercept(app, tool, x, y, pressure) { @@ -680,6 +678,23 @@ fn hidden_target(app: &PhotocraftApp, tool: Tool) -> Option<&'static str> { photocraft_engine::hidden_target::refusal(&app.session, cmd, &json!({})) } +/// The press-time refusals: a hidden target layer (#571), and for the Brush a locked layer or a +/// group's pixels. The Brush says why in an alert, as in Photoshop (#11898); the other tools say +/// a hidden target in the status bar. True when the press is refused. +fn press_refused(app: &mut PhotocraftApp, tool: Tool) -> bool { + let hidden = hidden_target(app, tool); + if let Some(message) = crate::brush_refusal::alert(app, tool, hidden.is_some()) { + crate::brush_refusal::prompt(app, &message); + return true; + } + if let Some(why) = hidden { + app.ui.status = why.into(); + app.ui.status_error = true; + return true; + } + false +} + /// Is document point `p` inside the active document's selection? fn inside_selection(app: &PhotocraftApp, p: [f64; 2]) -> bool { app.session.active().and_then(|st| st.doc.selection.as_ref()).is_some_and(|sel| covers(sel, p)) diff --git a/crates/ui-egui/src/hidden_layer_tests.rs b/crates/ui-egui/src/hidden_layer_tests.rs index 806aadd8a2..e6c92e619f 100644 --- a/crates/ui-egui/src/hidden_layer_tests.rs +++ b/crates/ui-egui/src/hidden_layer_tests.rs @@ -72,7 +72,9 @@ fn painting_a_hidden_layer_is_refused_at_the_press() { let n = steps(&app); tool_event(&mut app, ToolEvent::Down { x: 30.0, y: 30.0, pressure: 1.0 }, egui::Modifiers::NONE); assert!(app.drag.is_none() && app.live_stroke.is_none(), "{tool:?}: no stroke started"); - assert!(app.ui.status.contains("target layer is hidden"), "{tool:?}: {}", app.ui.status); + // The Brush says so in its alert (#11898), the other tools in the status bar. + let alert = app.ui.dialogs.iter().any(|d| d.fields.get("message").and_then(|m| m.as_str()).is_some_and(|m| m.contains("target layer is hidden"))); + assert!(app.ui.status.contains("target layer is hidden") || alert, "{tool:?}: {}", app.ui.status); tool_event(&mut app, ToolEvent::Up { x: 40.0, y: 40.0 }, egui::Modifiers::NONE); assert_eq!((bounds(&app, id), steps(&app)), (Rect::new(8, 8, 24, 24), n), "{tool:?}"); // Shown again, the same press paints. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 47d6d7740f..ed1a1af6dc 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -278,6 +278,7 @@ pub use tools::anchor_tools; pub use tools::artboard_tool; pub(crate) use tools::brush_cursor; pub use tools::brush_picker; +pub use tools::brush_refusal; pub use tools::brush_resize; use tools::cam_transform; pub use tools::cas_transform; diff --git a/crates/ui-egui/src/tools.rs b/crates/ui-egui/src/tools.rs index 9878bf41a4..a1475d248b 100644 --- a/crates/ui-egui/src/tools.rs +++ b/crates/ui-egui/src/tools.rs @@ -4,6 +4,7 @@ pub mod anchor_tools; pub mod artboard_tool; pub(crate) mod brush_cursor; pub mod brush_picker; +pub mod brush_refusal; pub mod brush_resize; pub(super) mod cam_transform; pub mod cas_transform; diff --git a/crates/ui-egui/src/tools/brush_refusal.rs b/crates/ui-egui/src/tools/brush_refusal.rs new file mode 100644 index 0000000000..c1919c8639 --- /dev/null +++ b/crates/ui-egui/src/tools/brush_refusal.rs @@ -0,0 +1,55 @@ +//! Photoshop's alerts when a painting tool can't paint (#11898): a press on a hidden target +//! layer, on a layer with Lock All, or with a group active (its pixels, not its mask) opens a +//! modal alert with OK instead of a status-bar message, and paints nothing. Only the tools whose +//! wording was measured have a name here ([`tool_name`]); the others keep today's status bar. + +use serde_json::json; + +use crate::PhotocraftApp; +use crate::state::{DialogKind, Tool}; + +/// The alerts; `{tool}` is [`tool_name`]. +pub const HIDDEN: &str = "Could not use the {tool} because the target layer is hidden."; +pub const LOCKED: &str = "Could not use the {tool} because the layer is locked."; +pub const GROUP: &str = "Could not use the {tool} because the pixels in a group cannot be edited without merging the group."; + +/// The tool's name in the alerts, for the tools whose wording was measured: the Brush. +pub fn tool_name(tool: Tool) -> Option<&'static str> { + match tool { + Tool::Brush => Some(tl!("brush tool")), + _ => None, + } +} + +/// The alert a press with `tool` gets, if any. `hidden` is the press-time hidden-target refusal +/// (`canvas::tool_input`), which this only words; the lock and the group are checked here. Lock +/// transparent pixels is no refusal, and a group's targeted mask paints. +pub fn alert(app: &PhotocraftApp, tool: Tool, hidden: bool) -> Option { + let name = tool_name(tool)?; + let template = if hidden { + HIDDEN + } else { + let st = app.session.active()?; + let layer = st.active_layer.and_then(|id| st.doc.layer(id))?; + if crate::canvas::paint_target(app) != json!("pixels") { + return None; + } + if layer.is_group() { + GROUP + } else if st.doc.effective_locks(layer.id).all { + LOCKED + } else { + return None; + } + }; + Some(crate::i18n::fmt(tl!(template), &[("tool", name)])) +} + +/// Opens the alert: ordinary dialog state, listed by `ui.inspect` and closed by `ui.dialog.confirm`. +pub fn prompt(app: &mut PhotocraftApp, message: &str) { + app.ui.open_dialog(DialogKind::Error, json!({ "message": message }).as_object().cloned().unwrap_or_default()); +} + +#[cfg(test)] +#[path = "../brush_refusal_alert_tests.rs"] +mod brush_refusal_alert_tests; From 7a1cd03a5801b5e29da8056733125a9d17641c0c Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 8 Nov 2026 02:56:31 +0100 Subject: [PATCH 160/219] Brush Settings: name the Spacing slider for accessibility (#5224) The standalone Spacing track had no accessible name or slider role, unlike the slider_row it replaced. It is now a slider named "Spacing" with the current percentage, as CodeRabbit noted. Co-Authored-By: augrclk Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/brush_sections.rs | 9 ++++++++- crates/ui-egui/src/panels/brush_sections/tests.rs | 3 +++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/brush_sections.rs b/crates/ui-egui/src/panels/brush_sections.rs index 69a08182ad..44440a32bd 100644 --- a/crates/ui-egui/src/panels/brush_sections.rs +++ b/crates/ui-egui/src/panels/brush_sections.rs @@ -67,7 +67,14 @@ fn spacing_row(ui: &mut egui::Ui, b: &mut BrushSettings) { .changed(); }); }); - changed |= ui.add_enabled_ui(b.spacing_enabled, |ui| widgets::slider(ui, &mut percent, 1.0..=1000.0, None)).inner.changed(); + changed |= ui + .add_enabled_ui(b.spacing_enabled, |ui| { + let s = widgets::slider(ui, &mut percent, 1.0..=1000.0, None); + s.widget_info(|| egui::WidgetInfo::slider(ui.is_enabled(), f64::from(percent), tl!("Spacing"))); + s + }) + .inner + .changed(); if changed { b.spacing = (percent.round() / 100.0).clamp(0.01, 10.0); } diff --git a/crates/ui-egui/src/panels/brush_sections/tests.rs b/crates/ui-egui/src/panels/brush_sections/tests.rs index 94e9c85f4e..9fdaf985f0 100644 --- a/crates/ui-egui/src/panels/brush_sections/tests.rs +++ b/crates/ui-egui/src/panels/brush_sections/tests.rs @@ -354,6 +354,9 @@ fn spacing_row_has_one_label_and_a_shared_header_in_every_theme() { let field = h.query_all_by_role(Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(43.0)).unwrap(); assert!((checkbox.center().y - field.rect().center().y).abs() < 2.0, "{kind:?}: checkbox and percentage share a row"); assert_eq!(field.accesskit_node().is_disabled(), !enabled); + let slider = h.get_by_role_and_label(Role::Slider, "Spacing"); + assert_eq!(slider.accesskit_node().numeric_value(), Some(43.0), "{kind:?}: the track is a named slider"); + assert_eq!(slider.accesskit_node().is_disabled(), !enabled); assert_eq!(h.state(), &b, "rendering does not round the saved spacing"); } } From 974c32ddc693b4c5e71838e8a02efece9942c032 Mon Sep 17 00:00:00 2001 From: SBaksa Date: Sun, 8 Nov 2026 13:09:17 +1100 Subject: [PATCH 161/219] Patch: History names as the tool's (#12117) select.lasso takes an optional historyName; the Patch tool sends "Patch Tool" so its lasso is that step (the Lasso tool keeps "Lasso"). paint.patch records "Patch Selection" in every mode; the content-aware job keeps its progress text. Tests: patch_history_names_tests (engine) and patch_tool_history_names_tests (ui-egui). --- crates/engine/src/cmd/retouch_cmds.rs | 3 + crates/engine/src/cmd/retouch_cmds/patch.rs | 10 ++-- .../retouch_cmds/patch_history_names_tests.rs | 58 +++++++++++++++++++ crates/engine/src/cmd/retouch_cmds/tests.rs | 2 +- crates/engine/src/cmd/selection_cmds.rs | 14 ++++- crates/ui-egui/src/canvas/gesture.rs | 7 ++- crates/ui-egui/src/lib.rs | 2 + .../src/patch_tool_history_names_tests.rs | 33 +++++++++++ 8 files changed, 121 insertions(+), 8 deletions(-) create mode 100644 crates/engine/src/cmd/retouch_cmds/patch_history_names_tests.rs create mode 100644 crates/ui-egui/src/patch_tool_history_names_tests.rs diff --git a/crates/engine/src/cmd/retouch_cmds.rs b/crates/engine/src/cmd/retouch_cmds.rs index 0746ca7440..3cba7c4318 100644 --- a/crates/engine/src/cmd/retouch_cmds.rs +++ b/crates/engine/src/cmd/retouch_cmds.rs @@ -397,3 +397,6 @@ pub fn specs() -> Vec { #[cfg(test)] mod tests; + +#[cfg(test)] +mod patch_history_names_tests; diff --git a/crates/engine/src/cmd/retouch_cmds/patch.rs b/crates/engine/src/cmd/retouch_cmds/patch.rs index 1cfcd3bfca..38655075fd 100644 --- a/crates/engine/src/cmd/retouch_cmds/patch.rs +++ b/crates/engine/src/cmd/retouch_cmds/patch.rs @@ -23,6 +23,8 @@ use super::{ }; const CMD: &str = "paint.patch"; +/// The History step of applying a patch, in every mode. +const HISTORY_NAME: &str = "Patch Selection"; /// Largest patch, in pixels of the selection's bounding box. The solve holds the two regions, the /// result and per-channel float planes of it (on the order of 150 bytes per pixel with four @@ -151,7 +153,6 @@ struct Plan { /// Shift of the repaired area from the selection, and of the texture from the repaired area. dst_shift: (i32, i32), src_shift: (i32, i32), - destination: bool, offset: (i32, i32), } @@ -185,7 +186,7 @@ fn plan(s: &Session, p: &Value) -> Result { PatchMode::Source => ((0, 0), (dx, dy)), PatchMode::Destination => ((dx, dy), (-dx, -dy)), }; - Ok(Plan { id, canvas, area, dst_shift, src_shift, destination: mode == PatchMode::Destination, offset: (dx, dy) }) + Ok(Plan { id, canvas, area, dst_shift, src_shift, offset: (dx, dy) }) } /// Heal `surf` at the selection (shifted by `dst_shift`) with texture read `src_shift` away, the @@ -216,7 +217,7 @@ pub(super) fn patch(s: &mut Session, p: &Value) -> Result { return patch_content_aware(s, p); } let plan = plan(s, p)?; - let label = if plan.destination { "Patch (Destination)" } else { "Patch" }; + let label = HISTORY_NAME; let dmg = run_stroke(s, label, plan.id, p, |pre, surf, _, lock| { let sel = pre.selection.as_ref().ok_or_else(|| bad(CMD, "select the area to patch first"))?; Ok(patch_surface(surf, sel, &plan, lock, 1)) @@ -263,6 +264,7 @@ fn level(p: &Value, k: &str, default: u8, lo: u8, hi: u8) -> Result { /// docs), as a background job. fn patch_content_aware(s: &mut Session, p: &Value) -> Result { use super::content_aware_move::{Target, land, mask, read, window}; + // The job's progress text; its History step is `HISTORY_NAME`. const LABEL: &str = "Patch (Content-Aware)"; let plan = plan(s, p)?; let structure = level(p, "structure", 4, 1, 7)?; @@ -294,7 +296,7 @@ fn patch_content_aware(s: &mut Session, p: &Value) -> Result { } }, move |s, (surf, damage)| { - s.edit(LABEL, |doc, _| { + s.edit(HISTORY_NAME, |doc, _| { *crate::channel_cmds::target_surface(doc, target.id, &p)?.0 = surf; Ok(()) })?; diff --git a/crates/engine/src/cmd/retouch_cmds/patch_history_names_tests.rs b/crates/engine/src/cmd/retouch_cmds/patch_history_names_tests.rs new file mode 100644 index 0000000000..fae53cc0e5 --- /dev/null +++ b/crates/engine/src/cmd/retouch_cmds/patch_history_names_tests.rs @@ -0,0 +1,58 @@ +//! History step names of the Patch Tool (#12117): "Patch Tool" for the lasso it draws with and +//! "Patch Selection" for applying a patch in every mode; the plain Lasso tool keeps "Lasso". + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 128, "height": 64, "depth": 8, "mode": "rgb"})).unwrap(); + s +} + +fn last_step(s: &Session) -> Option { + s.active().unwrap().history.undo_label().map(str::to_string) +} + +#[test] +fn applying_a_patch_is_one_step_named_patch_selection_in_every_mode() { + let modes = [ + json!({"offset": [30, 0]}), + json!({"offset": [30, 0], "mode": "source"}), + json!({"offset": [30, 0], "mode": "destination"}), + json!({"offset": [30, 0], "contentAware": true}), + json!({"offset": [30, 0], "mode": "destination", "contentAware": true}), + ]; + for p in modes { + let mut s = session(); + s.execute("select.rect", json!({"x": 16, "y": 22, "width": 20, "height": 20})).unwrap(); + let steps = s.active().unwrap().history.past_len(); + s.execute("paint.patch", p.clone()).unwrap(); + assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "{p}: one step"); + assert_eq!(last_step(&s).as_deref(), Some("Patch Selection"), "{p}"); + } +} + +const TRIANGLE: [[i32; 2]; 3] = [[16, 22], [36, 22], [16, 42]]; + +#[test] +fn the_lasso_is_named_for_the_tool_that_drew_it() { + let mut s = session(); + s.execute("select.lasso", json!({"points": TRIANGLE})).unwrap(); + assert_eq!(last_step(&s).as_deref(), Some("Lasso"), "the Lasso tool sends no name"); + for mode in ["replace", "add", "subtract", "intersect"] { + s.execute("select.lasso", json!({"points": TRIANGLE, "mode": mode, "historyName": "Patch Tool"})).unwrap(); + assert_eq!(last_step(&s).as_deref(), Some("Patch Tool"), "{mode}"); + } + s.execute("select.lasso", json!({"points": TRIANGLE, "polygonal": true, "historyName": "Patch Tool"})).unwrap(); + assert_eq!(last_step(&s).as_deref(), Some("Polygonal Lasso"), "the Polygonal Lasso keeps its own name"); +} + +#[test] +fn a_hostile_history_name_is_refused_without_a_step() { + let mut s = session(); + let steps = s.active().unwrap().history.past_len(); + for name in [json!(""), json!(" "), json!("x".repeat(65)), json!("a\nb"), json!(7), json!(["Patch Tool"])] { + assert!(s.execute("select.lasso", json!({"points": TRIANGLE, "historyName": name})).is_err(), "{name}"); + } + assert_eq!(s.active().unwrap().history.past_len(), steps); +} diff --git a/crates/engine/src/cmd/retouch_cmds/tests.rs b/crates/engine/src/cmd/retouch_cmds/tests.rs index 25ebcc8a97..636d3c1ce7 100644 --- a/crates/engine/src/cmd/retouch_cmds/tests.rs +++ b/crates/engine/src/cmd/retouch_cmds/tests.rs @@ -1022,7 +1022,7 @@ fn patch_content_aware_copies_the_dragged_to_content_into_the_selection() { let (got, want) = (rgba(&s, x, y), texture(x, y)); assert!((got[0] - want[0]).abs() <= tol(depth), "depth {depth}: untouched {x},{y}: {got:?}"); } - assert_eq!(s.active().unwrap().history.undo_label(), Some("Patch (Content-Aware)")); + assert_eq!(s.active().unwrap().history.undo_label(), Some("Patch Selection")); s.execute("edit.undo", json!({})).unwrap(); assert_eq!(rgba(&s, 44, 24)[1], 0.0, "depth {depth}: undo brings the blemish back"); } diff --git a/crates/engine/src/cmd/selection_cmds.rs b/crates/engine/src/cmd/selection_cmds.rs index 13737e70bd..b74a9d7f9f 100644 --- a/crates/engine/src/cmd/selection_cmds.rs +++ b/crates/engine/src/cmd/selection_cmds.rs @@ -622,7 +622,17 @@ fn lasso(s: &mut Session, p: &Value) -> Result { return Err(EngineError::BadParams { cmd: "select.lasso".into(), msg: "needs at least 3 points".into() }); } // The History step is named after the tool: the Polygonal Lasso sends `polygonal` (Photoshop). - let step = if b(p, "polygonal", false) { "Polygonal Lasso" } else { "Lasso" }; + // The Patch and Content-Aware Move tools draw with this command and send their own name as + // `historyName`, so the plain Lasso tool keeps "Lasso". + let step = if b(p, "polygonal", false) { + "Polygonal Lasso" + } else { + match p.get("historyName") { + None | Some(Value::Null) => "Lasso", + Some(Value::String(n)) if !n.trim().is_empty() && n.chars().count() <= 64 && !n.chars().any(char::is_control) => n.trim(), + Some(_) => return Err(EngineError::BadParams { cmd: "select.lasso".into(), msg: "historyName must be a short text".into() }), + } + }; polygon_selection(s, "select.lasso", step, &pts, p) } @@ -756,7 +766,7 @@ pub fn specs() -> Vec { "select.lasso", "Lasso", [], - r##"{"points":[[x,y],…],"mode":"replace|add|subtract|intersect"="replace","antiAlias":bool=true,"feather":px=0,"polygonal":bool=false}"##, + r##"{"points":[[x,y],…],"mode":"replace|add|subtract|intersect"="replace","antiAlias":bool=true,"feather":px=0,"polygonal":bool=false,"historyName"?:text (1..64 characters, no control characters; the History step name when a tool other than the Lasso draws it, e.g. "Patch Tool"; default "Lasso")}"##, has_doc, lasso ), diff --git a/crates/ui-egui/src/canvas/gesture.rs b/crates/ui-egui/src/canvas/gesture.rs index b67aec4beb..60af838375 100644 --- a/crates/ui-egui/src/canvas/gesture.rs +++ b/crates/ui-egui/src/canvas/gesture.rs @@ -144,7 +144,12 @@ fn finish(app: &mut PhotocraftApp, mut d: Drag, click: Option) { let o = &app.ui.tool_options; // The Patch and Content-Aware Move tools have no Feather in their options bar. let feather = if d.tool == Tool::Lasso { o.feather } else { 0.0 }; - let made = app.run("select.lasso", json!({"points": pts, "mode": mode, "antiAlias": o.anti_alias, "feather": feather})); + let mut params = json!({"points": pts, "mode": mode, "antiAlias": o.anti_alias, "feather": feather}); + // The Patch tool's lasso is a step of that tool in History; the Lasso tool keeps "Lasso". + if d.tool == Tool::Patch { + params["historyName"] = json!("Patch Tool"); + } + let made = app.run("select.lasso", params); // The lasso only outlines the patch; say that it is dragged next (#1715). if made.is_ok() && d.tool != Tool::Lasso { crate::retouch_ui::patch_hint(app); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 7d5d2a90e8..3ee1d27bbe 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -979,6 +979,8 @@ mod status_bar_tests; #[cfg(test)] mod stroke_timing_tests; +#[cfg(test)] +mod patch_tool_history_names_tests; #[cfg(test)] mod polygon_lasso_step_snap_tests; #[cfg(test)] diff --git a/crates/ui-egui/src/patch_tool_history_names_tests.rs b/crates/ui-egui/src/patch_tool_history_names_tests.rs new file mode 100644 index 0000000000..de9054fef1 --- /dev/null +++ b/crates/ui-egui/src/patch_tool_history_names_tests.rs @@ -0,0 +1,33 @@ +//! The Patch tool's lasso is a History step named "Patch Tool" (#12117); the Lasso tool keeps "Lasso". + +use egui::Modifiers; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::canvas::{ToolEvent, tool_event}; +use crate::state::Tool; + +fn app_with(tool: Tool) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 800, "height": 600, "background": "transparent"})).unwrap(); + app.sync_views(); + app.ui.tool = tool; + app +} + +/// Drag a triangle with the active tool and return the last History step. +fn drag_lasso(app: &mut PhotocraftApp) -> Option { + tool_event(app, ToolEvent::Down { x: 100.0, y: 100.0, pressure: 1.0 }, Modifiers::NONE); + for (x, y) in [(200.0, 100.0), (300.0, 100.0), (300.0, 300.0)] { + tool_event(app, ToolEvent::Move { x, y, pressure: 1.0 }, Modifiers::NONE); + } + tool_event(app, ToolEvent::Up { x: 200.0, y: 300.0 }, Modifiers::NONE); + assert!(app.session.active().unwrap().doc.selection.is_some()); + app.session.active().unwrap().history.entries().last().cloned() +} + +#[test] +fn the_patch_tool_lasso_is_named_for_the_tool() { + assert_eq!(drag_lasso(&mut app_with(Tool::Patch)).as_deref(), Some("Patch Tool")); + assert_eq!(drag_lasso(&mut app_with(Tool::Lasso)).as_deref(), Some("Lasso")); +} From bb94bbef7b631010548f3f6ec2bc155943a42559 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 7 Nov 2026 20:11:29 -0600 Subject: [PATCH 162/219] Color Replacement: Alt+click picks the foreground like the Eyedropper (#11914) Holding Alt turns the Color Replacement tool into the Eyedropper, as in the reference application: Alt+click and Alt+drag set the foreground colour with the Eyedropper's settings, add no history state and change no pixel, and the cursor shows the eyedropper. Ctrl+Alt keeps its brush-resize meaning; a plain click still paints. --- crates/ui-egui/src/canvas/cursors.rs | 9 ++- .../src/color_replacement_alt_click_tests.rs | 77 +++++++++++++++++++ 2 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/src/color_replacement_alt_click_tests.rs diff --git a/crates/ui-egui/src/canvas/cursors.rs b/crates/ui-egui/src/canvas/cursors.rs index e4f0336b1d..f5f3b3912c 100644 --- a/crates/ui-egui/src/canvas/cursors.rs +++ b/crates/ui-egui/src/canvas/cursors.rs @@ -112,10 +112,11 @@ pub(super) fn eyedropper_ring(app: &mut PhotocraftApp, painter: &egui::Painter, const RING_GREY: Color32 = Color32::from_gray(127); /// Tools on which holding ⌥ (Alt) switches to the Eyedropper: a click or drag sets the -/// foreground colour, as with the Eyedropper itself (#417, the Background Eraser #11906). +/// foreground colour, as with the Eyedropper itself (#417, the Background Eraser #11906, the Color +/// Replacement tool #11914). pub(crate) fn alt_samples(tool: Tool, mods: egui::Modifiers) -> bool { // Control+Alt is the brush-resize drag (`brush_resize`), not sampling. - mods.alt && !mods.ctrl && matches!(tool, Tool::Brush | Tool::Pencil | Tool::Gradient | Tool::PaintBucket | Tool::BackgroundEraser) + mods.alt && !mods.ctrl && matches!(tool, Tool::Brush | Tool::Pencil | Tool::Gradient | Tool::PaintBucket | Tool::BackgroundEraser | Tool::ColorReplacement) } /// Preferences › Cursors › Other Cursors asks for the precise crosshair, or Caps Lock swaps the @@ -290,6 +291,10 @@ pub(crate) fn alt_picks(tool: Tool, mods: egui::Modifiers) -> bool { #[path = "../eyedropper_mask_sample_tests.rs"] mod eyedropper_mask_sample_tests; +#[cfg(test)] +#[path = "../color_replacement_alt_click_tests.rs"] +mod color_replacement_alt_click_tests; + #[cfg(test)] #[path = "../background_eraser_alt_click_tests.rs"] mod background_eraser_alt_click_tests; diff --git a/crates/ui-egui/src/color_replacement_alt_click_tests.rs b/crates/ui-egui/src/color_replacement_alt_click_tests.rs new file mode 100644 index 0000000000..8fd1533a02 --- /dev/null +++ b/crates/ui-egui/src/color_replacement_alt_click_tests.rs @@ -0,0 +1,77 @@ +//! #11914: holding Alt turns the Color Replacement tool into the Eyedropper. Alt+click and +//! Alt+drag set the foreground colour, add no history state and change no pixel; Ctrl+Alt keeps +//! its brush-resize meaning and a plain click still paints. + +use egui::Modifiers; +use serde_json::json; + +use super::*; +use crate::canvas::{ToolEvent, tool_event}; + +/// A 400 × 300 RGB document: a green layer (30,180,60), blue foreground, the Color Replacement tool. +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("edit.fill", json!({"color": "#1eb43c"})).unwrap(); + app.run("tools.setColors", json!({"foreground": "#0000ff"})).unwrap(); + app.ui.tool = Tool::ColorReplacement; + app.sync_views(); + app +} + +fn steps(app: &PhotocraftApp) -> usize { + app.session.active().unwrap().history.past_len() +} + +fn foreground(app: &PhotocraftApp) -> [u8; 3] { + let c = app.session.tools.foreground; + [0, 1, 2].map(|i| (c[i] * 255.0).round() as u8) +} + +fn pixel(app: &PhotocraftApp, x: i32, y: i32) -> [u8; 3] { + let st = app.session.active().unwrap(); + let c = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().rgba(x, y); + [0, 1, 2].map(|i| (c[i] * 255.0).round() as u8) +} + +fn drag(app: &mut PhotocraftApp, from: [f64; 2], to: [f64; 2], mods: Modifiers) { + tool_event(app, ToolEvent::Down { x: from[0], y: from[1], pressure: 1.0 }, mods); + tool_event(app, ToolEvent::Move { x: to[0], y: to[1], pressure: 1.0 }, mods); + tool_event(app, ToolEvent::Up { x: to[0], y: to[1] }, mods); +} + +#[test] +fn alt_click_and_drag_pick_the_foreground() { + let mut app = app(); + let before = steps(&app); + drag(&mut app, [300.0, 60.0], [300.0, 60.0], Modifiers::ALT); + assert_eq!(foreground(&app), [30, 180, 60], "the green under the click"); + assert_eq!(steps(&app), before, "no history state"); + assert_eq!(pixel(&app, 300, 60), [30, 180, 60], "no pixel changed"); + // A drag samples as it goes, as the Eyedropper does. + app.run("tools.setColors", json!({"foreground": "#0000ff"})).unwrap(); + drag(&mut app, [100.0, 100.0], [200.0, 150.0], Modifiers::ALT); + assert_eq!(foreground(&app), [30, 180, 60]); + assert_eq!(steps(&app), before); +} + +#[test] +fn alt_shows_the_eyedropper_and_ctrl_alt_resizes() { + let alt = Modifiers::ALT; + let ctrl_alt = Modifiers { alt: true, ctrl: true, ..Modifiers::NONE }; + assert!(alt_samples(Tool::ColorReplacement, alt)); + assert!(alt_picks(Tool::ColorReplacement, alt), "the cursor shows the eyedropper while Alt is held"); + assert!(!alt_samples(Tool::ColorReplacement, ctrl_alt), "Ctrl+Alt is the brush-resize drag"); + assert!(!alt_samples(Tool::ColorReplacement, Modifiers::NONE)); +} + +#[test] +fn a_plain_click_still_paints() { + let mut app = app(); + let before = steps(&app); + drag(&mut app, [300.0, 60.0], [310.0, 60.0], Modifiers::NONE); + assert_eq!(steps(&app), before + 1, "a Color Replacement state"); + assert_ne!(pixel(&app, 300, 60), [30, 180, 60], "the green was replaced"); + assert_eq!(foreground(&app), [0, 0, 255]); +} From 479ea9c96b46b6f31ac8db979e390ccecaab0e3b Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 7 Nov 2026 20:06:32 -0600 Subject: [PATCH 163/219] Background Eraser: the compact brush popup from the picker and a right-click (#11910) With the Background Eraser, the options-bar brush picker and a canvas right-click open the reference application's compact popup instead of the Brush Preset picker: Size, Hardness and Spacing with sliders, the angle/roundness widget, Angle and Roundness, then the Size: and Tolerance: pen dropdowns (Off, Pen Pressure, Stylus Wheel), the latter with the tablet warning. No preset list or search field. Tolerance's control is a tool option (Pen Pressure by default); Size's is the tool's brush. Other tools keep the Brush Preset picker. --- .../background_eraser_brush_popup_tests.rs | 138 ++++++++++++++++++ crates/ui-egui/src/canvas/paint_mouse.rs | 11 +- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/panels/brush_sections.rs | 5 +- crates/ui-egui/src/state/tool_options.rs | 4 + crates/ui-egui/src/tools.rs | 1 + crates/ui-egui/src/tools/bg_eraser_popup.rs | 122 ++++++++++++++++ crates/ui-egui/src/tools/tool_reset.rs | 2 +- 8 files changed, 279 insertions(+), 5 deletions(-) create mode 100644 crates/ui-egui/src/background_eraser_brush_popup_tests.rs create mode 100644 crates/ui-egui/src/tools/bg_eraser_popup.rs diff --git a/crates/ui-egui/src/background_eraser_brush_popup_tests.rs b/crates/ui-egui/src/background_eraser_brush_popup_tests.rs new file mode 100644 index 0000000000..3c0dbd2b54 --- /dev/null +++ b/crates/ui-egui/src/background_eraser_brush_popup_tests.rs @@ -0,0 +1,138 @@ +//! #11910: with the Background Eraser, the options-bar brush picker and a canvas right-click open +//! a compact popup (Size, Hardness, Spacing, the angle/roundness widget, Angle, Roundness, the +//! "Size:" and "Tolerance:" pen dropdowns with the tablet warning) and no preset list; other tools +//! keep the Brush Preset picker. + +use egui::accesskit::Role; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; + +use super::*; +use crate::PhotocraftApp; +use crate::state::Tool; + +fn app(tool: Tool) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("edit.fill", json!({"color": "#dc1e1e"})).unwrap(); + app.ui.tool = tool; + app.sync_views(); + app +} + +/// The canvas with the picker open as the brush chip opens it. +fn harness(app: PhotocraftApp) -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + crate::paint_mouse::sync_tool_brush(app); + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + h +} + +fn open(h: &mut Harness<'static, PhotocraftApp>) { + let c = h.state().last_canvas_rect.center(); + let ctx = h.ctx.clone(); + crate::brush_picker::open(&mut h.state_mut().ui, &ctx, [c.x, c.y]); + h.run_steps(3); +} + +fn top(h: &Harness<'static, PhotocraftApp>, label: &str) -> f32 { + h.get_by_label(label).rect().top() +} + +#[test] +fn the_popup_has_the_controls_in_order_and_no_presets() { + let mut h = harness(app(Tool::BackgroundEraser)); + open(&mut h); + let order = ["Size", "Hardness", "Spacing", "Angle", "Roundness", "Size:", "Tolerance:"]; + let tops: Vec = order + .iter() + .map(|l| match *l { + // The Size field, the dropdowns by their combo boxes. + "Size" => h.get_all_by_role(Role::SpinButton).next().expect("the Size field").rect().top(), + "Size:" | "Tolerance:" => h.get_all_by_role(Role::ComboBox).find(|n| n.accesskit_node().label().as_deref() == Some(*l)).expect(l).rect().top(), + "Hardness" | "Spacing" => h.query_all_by_label(l).next().expect(l).rect().top(), + _ => top(&h, l), + }) + .collect(); + assert!(tops.windows(2).all(|w| w[0] <= w[1]), "top to bottom {order:?}: {tops:?}"); + assert!(h.query_by_label(PRESSURE_WARNING).is_some(), "the warning triangle beside Tolerance:"); + assert!(h.query_by_label("Search Brushes").is_none() && h.query_by_label("Brush Preset Options").is_none(), "no presets, search or gear"); +} + +#[test] +fn the_defaults_are_the_references() { + let app = app(Tool::BackgroundEraser); + assert_eq!(app.ui.tool_options.bg_tolerance_control, PEN_PRESSURE, "Tolerance: Pen Pressure by default"); + assert_eq!(size_control(&app.session.tools.brush), OFF, "Size: Off"); + let mut h = harness(app); + open(&mut h); + let value = |l: &str| { + h.get_all_by_role(Role::ComboBox) + .find(|n| n.accesskit_node().label().as_deref() == Some(l)) + .and_then(|n| n.accesskit_node().value()) + .unwrap_or_default() + }; + assert_eq!(value("Size:"), "Off"); + assert_eq!(value("Tolerance:"), "Pen Pressure"); +} + +#[test] +fn tolerance_is_stored_with_the_tool_and_size_with_its_brush() { + let mut h = harness(app(Tool::BackgroundEraser)); + open(&mut h); + h.get_all_by_role(Role::ComboBox).find(|n| n.accesskit_node().label().as_deref() == Some("Tolerance:")).unwrap().click(); + h.run_steps(2); + h.get_all_by_label("Off").last().expect("the Off entry").click(); + h.run_steps(3); + assert_eq!(h.state().ui.tool_options.bg_tolerance_control, OFF); + h.get_all_by_role(Role::ComboBox).find(|n| n.accesskit_node().label().as_deref() == Some("Size:")).unwrap().click(); + h.run_steps(2); + h.get_all_by_label("Pen Pressure").last().expect("the Pen Pressure entry").click(); + h.run_steps(3); + assert!(h.state().session.tools.brush.pressure_size, "Size: Pen Pressure is the brush's pressure for size"); + // The mapping both ways. + let mut b = BrushSettings::default(); + for c in [STYLUS_WHEEL, PEN_PRESSURE, OFF] { + set_size_control(&mut b, c); + assert_eq!(size_control(&b), c); + } +} + +#[test] +fn escape_closes_and_a_right_click_erases_nothing() { + let mut h = harness(app(Tool::BackgroundEraser)); + let steps = h.state().session.active().unwrap().history.past_len(); + let c = h.state().last_canvas_rect.center(); + h.event(egui::Event::PointerMoved(c)); + h.run_steps(1); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: c, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE }); + h.run_steps(2); + } + assert!(h.state().ui.brush_picker.is_some(), "the right-click opens the popup"); + assert!(h.query_all_by_label("Tolerance:").next().is_some(), "the compact popup, not the preset picker"); + assert_eq!(h.state().session.active().unwrap().history.past_len(), steps, "nothing erased"); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert_eq!(h.state().ui.brush_picker, None); +} + +#[test] +fn other_tools_keep_the_brush_preset_picker() { + let mut h = harness(app(Tool::Brush)); + open(&mut h); + assert!(h.query_by_label("Brush Preset Options").is_some(), "the Brush's picker with its gear"); + assert!(h.query_by_label(PRESSURE_WARNING).is_none()); +} diff --git a/crates/ui-egui/src/canvas/paint_mouse.rs b/crates/ui-egui/src/canvas/paint_mouse.rs index 2c16ce1fa1..81b67ad3a9 100644 --- a/crates/ui-egui/src/canvas/paint_mouse.rs +++ b/crates/ui-egui/src/canvas/paint_mouse.rs @@ -481,8 +481,15 @@ pub fn show_picker(app: &mut PhotocraftApp, ctx: &egui::Context) { let before = app.session.tools.brush.clone(); let mut b = before.clone(); let list = &mut app.ui.brush_picker_list; - let frame = egui::Frame::popup(ui.style()) - .show(ui, |ui| crate::brush_picker::body(ui, &mut b, &app.session.tools.presets, app.session.tools.current_preset.as_deref(), list, content)); + let frame = egui::Frame::popup(ui.style()).show(ui, |ui| { + // The Background Eraser has its own compact popup, without presets (#11910). + if app.ui.tool == Tool::BackgroundEraser { + crate::bg_eraser_popup::body(ui, &mut b, &mut app.ui.tool_options.bg_tolerance_control); + Vec::new() + } else { + crate::brush_picker::body(ui, &mut b, &app.session.tools.presets, app.session.tools.current_preset.as_deref(), list, content) + } + }); let picks = frame.inner; // The corner grip resizes the picker, and the size is remembered across opens. if let Some(new) = crate::brush_picker::resize_grip(ui, frame.response.rect, content) { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 47d6d7740f..e5e6e191b1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -276,6 +276,7 @@ use tools::align_bar; pub use tools::analysis_ui; pub use tools::anchor_tools; pub use tools::artboard_tool; +pub use tools::bg_eraser_popup; pub(crate) use tools::brush_cursor; pub use tools::brush_picker; pub use tools::brush_resize; diff --git a/crates/ui-egui/src/panels/brush_sections.rs b/crates/ui-egui/src/panels/brush_sections.rs index 7026f83352..38026cb94c 100644 --- a/crates/ui-egui/src/panels/brush_sections.rs +++ b/crates/ui-egui/src/panels/brush_sections.rs @@ -186,8 +186,9 @@ pub fn ellipse_drag(grab: EllipseGrab, rel: egui::Vec2, angle: f32, roundness: f } } -/// Photoshop's angle/roundness widget: drag the arrow to rotate, the dots to squash. -fn ellipse_widget(ui: &mut egui::Ui, angle: &mut f32, roundness: &mut f32) -> bool { +/// Photoshop's angle/roundness widget: drag the arrow to rotate, the dots to squash. Also at the +/// Brush Preset picker's top left (#11821). +pub(crate) fn ellipse_widget(ui: &mut egui::Ui, angle: &mut f32, roundness: &mut f32) -> bool { let t = Tokens::get(ui.ctx()); let (rect, resp) = ui.allocate_exact_size(vec2(84.0, 84.0), Sense::click_and_drag()); let c = rect.center(); diff --git a/crates/ui-egui/src/state/tool_options.rs b/crates/ui-egui/src/state/tool_options.rs index e5db1af422..184a420126 100644 --- a/crates/ui-egui/src/state/tool_options.rs +++ b/crates/ui-egui/src/state/tool_options.rs @@ -226,6 +226,9 @@ pub struct ToolOptions { pub bg_limits: String, pub bg_tolerance: f32, pub bg_protect_fg: bool, + /// The Background Eraser popup's "Tolerance:" pen control (#11910): `off`, `penPressure` (the + /// reference's default) or `stylusWheel`. + pub bg_tolerance_control: String, /// Color Replacement: mode (hue | saturation | color | luminosity), sampling (continuous | /// once | backgroundSwatch), limits (discontiguous | contiguous | findEdges), tolerance % and /// anti-alias, as `paint.colorReplacement` takes them. @@ -522,6 +525,7 @@ impl Default for ToolOptions { bg_limits: "contiguous".into(), bg_tolerance: 50.0, bg_protect_fg: false, + bg_tolerance_control: "penPressure".into(), cr_mode: "color".into(), cr_sampling: "continuous".into(), cr_limits: "contiguous".into(), diff --git a/crates/ui-egui/src/tools.rs b/crates/ui-egui/src/tools.rs index 9878bf41a4..9be119d2bc 100644 --- a/crates/ui-egui/src/tools.rs +++ b/crates/ui-egui/src/tools.rs @@ -2,6 +2,7 @@ pub(super) mod align_bar; pub mod analysis_ui; pub mod anchor_tools; pub mod artboard_tool; +pub mod bg_eraser_popup; pub(crate) mod brush_cursor; pub mod brush_picker; pub mod brush_resize; diff --git a/crates/ui-egui/src/tools/bg_eraser_popup.rs b/crates/ui-egui/src/tools/bg_eraser_popup.rs new file mode 100644 index 0000000000..d59c55197e --- /dev/null +++ b/crates/ui-egui/src/tools/bg_eraser_popup.rs @@ -0,0 +1,122 @@ +//! The Background Eraser's compact brush popup (#11910): what its options-bar brush picker and a +//! canvas right-click open instead of the Brush Preset picker, as in Photoshop. No preset list and +//! no search field; top to bottom Size, Hardness and Spacing (fields with sliders), the +//! angle/roundness widget, Angle and Roundness fields, then the "Size:" and "Tolerance:" pen +//! dropdowns (Off · Pen Pressure · Stylus Wheel), the latter with the reference's tablet warning. +//! +//! It edits the caller's copy of the brush like the picker does (`paint_mouse` commits it through +//! `tools.setBrush`); the Tolerance control is a tool option (`ToolOptions::bg_tolerance_control`). + +use egui::{RichText, vec2}; +use photocraft_engine::BrushSettings; +use photocraft_engine::paint::{Control, MAX_BRUSH_SIZE, TipShape}; + +use crate::theme::Tokens; +use crate::widgets; + +/// The pen dropdowns' entries, as stored. +pub const OFF: &str = "off"; +pub const PEN_PRESSURE: &str = "penPressure"; +pub const STYLUS_WHEEL: &str = "stylusWheel"; +/// The tooltip of the warning triangle next to "Tolerance:". +pub const PRESSURE_WARNING: &str = "Control by pen pressure requires the use of a pressure sensitive tablet"; + +/// The popup's width. +pub const WIDTH: f32 = 240.0; + +fn pen_options() -> [(String, &'static str); 3] { + [(OFF.into(), tl!("Off")), (PEN_PRESSURE.into(), tl!("Pen Pressure")), (STYLUS_WHEEL.into(), tl!("Stylus Wheel"))] +} + +/// What the brush's "Size:" control is: Pen Pressure is the brush's pressure-for-size switch, +/// Stylus Wheel its Shape Dynamics size control. +pub fn size_control(b: &BrushSettings) -> &'static str { + if b.pressure_size { + PEN_PRESSURE + } else if b.shape_dynamics.enabled && b.shape_dynamics.size.control == Control::StylusWheel { + STYLUS_WHEEL + } else { + OFF + } +} + +/// Set the brush's "Size:" control. +pub fn set_size_control(b: &mut BrushSettings, control: &str) { + b.pressure_size = control == PEN_PRESSURE; + if control == STYLUS_WHEEL { + b.shape_dynamics.enabled = true; + b.shape_dynamics.size.control = Control::StylusWheel; + } else if b.shape_dynamics.size.control == Control::StylusWheel { + b.shape_dynamics.size.control = Control::Off; + } +} + +/// The popup's controls, editing `b` and the tool's Tolerance control. +pub fn body(ui: &mut egui::Ui, b: &mut BrushSettings, tolerance_control: &mut String) { + let t = Tokens::get(ui.ctx()); + ui.set_width(WIDTH); + ui.horizontal(|ui| { + ui.label(RichText::new(tl!("Size")).color(t.text_dim)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let mut size = b.size; + if widgets::value_field(ui, &mut size, 1.0..=MAX_BRUSH_SIZE, "px", 72.0).accessible_name(tl!("Size")).changed() { + b.size = size.round().clamp(1.0, MAX_BRUSH_SIZE); + } + }); + }); + // Logarithmic, as the Brush Preset picker's: small sizes get most of the travel. + let mut lv = b.size.max(1.0).ln(); + if widgets::slider(ui, &mut lv, 0.0..=MAX_BRUSH_SIZE.ln(), None).changed() { + b.size = lv.exp().round().clamp(1.0, MAX_BRUSH_SIZE); + } + if b.tip == TipShape::Round { + let mut hard = b.hardness * 100.0; + if widgets::slider_row(ui, "Hardness", &mut hard, 0.0..=100.0, "%", None).changed() { + b.hardness = (hard / 100.0).clamp(0.0, 1.0); + } + } + let mut spacing = b.spacing * 100.0; + if widgets::slider_row(ui, "Spacing", &mut spacing, 1.0..=1000.0, "%", None).changed() { + b.spacing = (spacing / 100.0).clamp(0.01, 10.0); + } + ui.add_space(4.0); + ui.horizontal(|ui| { + crate::brush_sections::ellipse_widget(ui, &mut b.angle, &mut b.roundness); + ui.vertical(|ui| { + let mut angle = b.angle; + ui.horizontal(|ui| { + ui.label(RichText::new(tl!("Angle:")).color(t.text_dim)); + if widgets::value_field(ui, &mut angle, -180.0..=180.0, "°", 64.0).accessible_name(tl!("Angle")).changed() && angle.is_finite() { + b.angle = angle.round().clamp(-180.0, 180.0); + } + }); + let mut round = b.roundness * 100.0; + ui.horizontal(|ui| { + ui.label(RichText::new(tl!("Roundness:")).color(t.text_dim)); + if widgets::value_field(ui, &mut round, 0.0..=100.0, "%", 64.0).accessible_name(tl!("Roundness")).changed() && round.is_finite() { + b.roundness = (round / 100.0).clamp(0.0, 1.0); + } + }); + }); + }); + ui.add_space(4.0); + let options = pen_options(); + ui.horizontal(|ui| { + ui.label(RichText::new(tl!("Size:")).color(t.text_dim)); + let mut size = size_control(b).to_string(); + if widgets::dropdown(ui, "bg-eraser-size-control", tl!("Size:"), &mut size, &options, 120.0) { + set_size_control(b, &size); + } + }); + ui.horizontal(|ui| { + ui.label(RichText::new(tl!("Tolerance:")).color(t.text_dim)); + widgets::dropdown(ui, "bg-eraser-tolerance-control", tl!("Tolerance:"), tolerance_control, &options, 120.0); + let (r, resp) = ui.allocate_exact_size(vec2(16.0, 16.0), egui::Sense::hover()); + crate::icons::paint(ui, r, "triangle-alert", 14.0, t.warning); + resp.on_hover_text(tl!(PRESSURE_WARNING)).widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, tl!(PRESSURE_WARNING))); + }); +} + +#[cfg(test)] +#[path = "../background_eraser_brush_popup_tests.rs"] +mod background_eraser_brush_popup_tests; diff --git a/crates/ui-egui/src/tools/tool_reset.rs b/crates/ui-egui/src/tools/tool_reset.rs index 148599b50c..9801ff58c1 100644 --- a/crates/ui-egui/src/tools/tool_reset.rs +++ b/crates/ui-egui/src/tools/tool_reset.rs @@ -218,7 +218,7 @@ pub fn option_keys(tool: Tool) -> &'static [&'static str] { Tool::Pencil => reset!(pencil_auto_erase, pencil_clear), Tool::Eraser => reset!(eraser_mode), Tool::Remove => reset!(remove_new_layer, remove_after_each_stroke), - Tool::BackgroundEraser => reset!(bg_sampling, bg_limits, bg_tolerance, bg_protect_fg), + Tool::BackgroundEraser => reset!(bg_sampling, bg_limits, bg_tolerance, bg_protect_fg, bg_tolerance_control), Tool::ColorReplacement => reset!(cr_mode, cr_sampling, cr_limits, cr_tolerance, cr_anti_alias), Tool::MagicEraser => reset!(tolerance, anti_alias, contiguous, magic_eraser_opacity), Tool::Gradient => reset!(gradient_style, gradient_reverse, gradient_dither, gradient_classic, gradient_blend_mode, fill_opacity), From 7efd224081a190521f6634e766df02d75de77ab7 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 7 Nov 2026 20:25:05 -0600 Subject: [PATCH 164/219] Color Replacement: in Lab, Color keeps L and takes the foreground's a and b (#11922) In a Lab document the Color mode no longer blends through RGB (which changed the pixel's L): it keeps the stored L and moves a and b to the foreground's, as in the reference application. Hue, Saturation and Luminosity keep today's RGB blend. --- crates/paint/src/replace.rs | 16 ++++++ crates/paint/src/replace_lab_tests.rs | 77 +++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 crates/paint/src/replace_lab_tests.rs diff --git a/crates/paint/src/replace.rs b/crates/paint/src/replace.rs index 20619cd48a..31bd4ab90b 100644 --- a/crates/paint/src/replace.rs +++ b/crates/paint/src/replace.rs @@ -96,6 +96,13 @@ pub fn color_replacement_effect(pre: Surface, rs: ReplaceSettings) -> impl FnMut ReplaceMode::Luminosity => BlendMode::Luminosity, }; let fg = [rs.color[0], rs.color[1], rs.color[2]]; + // In a Lab document Color mode keeps the pixel's L and takes the foreground's a and b, as in + // the reference application (#11922): the stored a, b channels move straight to its. + let lab_color = fmt.mode == photocraft_color::ColorMode::Lab && rs.mode == ReplaceMode::Color; + let fg_ab = { + let l = photocraft_color::convert::srgb_to_lab(fg); + [(l[1] + 128.0) / 255.0, (l[2] + 128.0) / 255.0] + }; let tol = rs.tolerance.clamp(0.0, 1.0); let soft = rs.anti_alias && rs.limits != Limits::FindEdges; let mut once: Option<[f32; 3]> = None; @@ -189,6 +196,11 @@ pub fn color_replacement_effect(pre: Surface, rs: ReplaceSettings) -> impl FnMut continue; } let px = work.px_mut(x, y); + if lab_color { + px[1] += (fg_ab[0] - px[1]) * k; + px[2] += (fg_ab[1] - px[2]) * k; + continue; + } let c = to_rgba(&fmt, px); let b = blend_rgb(mode, [c[0], c[1], c[2]], fg); let o = [c[0] + (b[0] - c[0]) * k, c[1] + (b[1] - c[1]) * k, c[2] + (b[2] - c[2]) * k, c[3]]; @@ -199,6 +211,10 @@ pub fn color_replacement_effect(pre: Surface, rs: ReplaceSettings) -> impl FnMut } } +#[cfg(test)] +#[path = "replace_lab_tests.rs"] +mod replace_lab_tests; + #[cfg(test)] mod tests { use super::*; diff --git a/crates/paint/src/replace_lab_tests.rs b/crates/paint/src/replace_lab_tests.rs new file mode 100644 index 0000000000..3402ca6a41 --- /dev/null +++ b/crates/paint/src/replace_lab_tests.rs @@ -0,0 +1,77 @@ +//! #11922: in a Lab document, Color mode keeps the pixel's L and takes the foreground's a and b. +//! The setup is the issue's: red, green and pink stripes and the darker block (200,40,40), a blue +//! foreground, a 40 px hard tip, one click on each. Hue, Saturation and Luminosity keep today's RGB +//! blend. + +use photocraft_color::{ColorMode, PixelFormat, SampleType}; + +use super::*; +use crate::{BrushSettings, StrokePoint}; + +const LAB8: PixelFormat = PixelFormat { mode: ColorMode::Lab, sample: SampleType::U8, alpha: true }; +const COLOURS: [[u8; 3]; 4] = [[220, 30, 30], [30, 180, 60], [240, 120, 120], [200, 40, 40]]; +const BLUE: [f32; 4] = [0.0, 0.0, 1.0, 1.0]; + +/// A Lab/8 surface with the four colours in 100 px columns. +fn target() -> Surface { + let mut s = Surface::new(LAB8); + for (i, c) in COLOURS.iter().enumerate() { + let rgba = [f32::from(c[0]) / 255.0, f32::from(c[1]) / 255.0, f32::from(c[2]) / 255.0, 1.0]; + let x0 = i as i32 * 100; + s.fill_rect(Rect::new(x0, 0, x0 + 100, 100), &photocraft_raster::from_rgba(&LAB8, rgba)); + } + s +} + +fn click(s: &mut Surface, mode: ReplaceMode, x: f64) { + let stroke = Stroke { + brush: BrushSettings { size: 40.0, hardness: 1.0, spacing: 0.25, pressure_size: false, ..Default::default() }, + points: vec![StrokePoint::new(x, 50.0, 1.0)], + }; + let rs = ReplaceSettings { mode, color: BLUE, ..Default::default() }; + apply_color_replacement(s, &stroke, &rs, None, false); +} + +/// The stored L (0..100) and a, b (-128..127) at a pixel. +fn lab(s: &Surface, x: i32, y: i32) -> [f32; 3] { + let px = s.pixel(x, y); + [px[0] * 100.0, px[1] * 255.0 - 128.0, px[2] * 255.0 - 128.0] +} + +#[test] +fn color_keeps_l_and_takes_the_foregrounds_a_and_b() { + let fg = photocraft_color::convert::srgb_to_lab([0.0, 0.0, 1.0]); + for i in 0..COLOURS.len() { + let mut s = target(); + let x = i as i32 * 100 + 50; + let before = lab(&s, x, 50); + click(&mut s, ReplaceMode::Color, f64::from(x) + 0.5); + let after = lab(&s, x, 50); + assert!((after[0] - before[0]).abs() <= 1.0, "colour {i}: L {} → {}", before[0], after[0]); + assert!((after[1] - fg[1]).abs() <= 2.0 && (after[2] - fg[2]).abs() <= 2.0, "colour {i}: a, b {:?} vs the foreground's {:?}", &after[1..], &fg[1..]); + // Outside the tip nothing changed. + assert_eq!(lab(&s, x - 40, 50), before, "colour {i} outside the tip"); + } +} + +#[test] +fn hue_saturation_and_luminosity_keep_todays_blend() { + for mode in [ReplaceMode::Hue, ReplaceMode::Saturation, ReplaceMode::Luminosity] { + let mut s = target(); + let pre = s.pixel(150, 50).to_vec(); + click(&mut s, mode, 150.5); + // Today's path: through RGB, blended, and back. + let blend = match mode { + ReplaceMode::Hue => BlendMode::Hue, + ReplaceMode::Saturation => BlendMode::Saturation, + _ => BlendMode::Luminosity, + }; + let c = to_rgba(&LAB8, &pre); + let b = blend_rgb(blend, [c[0], c[1], c[2]], [0.0, 0.0, 1.0]); + let want = photocraft_raster::from_rgba(&LAB8, [b[0], b[1], b[2], c[3]]); + let got = s.pixel(150, 50); + for (g, w) in got.iter().zip(&want) { + assert!((g - w).abs() <= 1.0 / 255.0 + 1e-6, "{mode:?}: {got:?} vs {want:?}"); + } + } +} From fda05f7b02086c8c3b4d09d4bc1e95f3332e4d56 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 8 Nov 2026 03:45:37 +0100 Subject: [PATCH 165/219] Regenerate docs/scorecard.md (merge queue) On main + #10448, so main's gate stays green after the merge. --- docs/scorecard.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/scorecard.md b/docs/scorecard.md index bc6eba171c..7a0d30a8ce 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Performance](#performance) | 3 | 22 | 51 | 85 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 47 not run, 4 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 11 | 14 | 2 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 190/258, round trip ≥ 258/258 | | [Tools](#tools) | 36 | 9 | 0 | | -| [Workspace and UI](#workspace-and-ui) | 15 | 6 | 2 | settings that do nothing: 10 of 195 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 15 | 6 | 2 | settings that do nothing: 10 of 196 (target 0) | | [Type](#type) | 5 | 5 | 0 | | | [Automation](#automation) | 5 | 4 | 2 | 662 of 662 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 30 of 30 crates; panic_hunt present | @@ -209,7 +209,7 @@ Corpus floors (`crates/io/tests/it/corpus.rs`): the enforced minimums; the corpu ## Workspace and UI -**Settings that do nothing: 10 of 195** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. +**Settings that do nothing: 10 of 196** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. Unread: `general.color_picker`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `performance.legacy_compositing`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `type_.text_engine`, `enhanced_controls.scrubby_slider_acceleration`. From 6b9628c92f0d839cb63b63ed1cead5e3a42a9ae1 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 7 Nov 2026 20:47:00 -0600 Subject: [PATCH 166/219] Color Replacement: History names each edit "Color Replacement Tool" (#11933) A Color Replacement click, stroke or Shift+click segment adds one History state named "Color Replacement Tool", as in the reference application. The command's label is the same name, so a recorded action's step reads the same as the History state. --- crates/engine/src/cmd/brush_cmds.rs | 13 +++++- .../color_replacement_history_name_tests.rs | 45 +++++++++++++++++++ crates/ui-egui/src/color_replacement_tests.rs | 2 +- 3 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 crates/engine/src/cmd/color_replacement_history_name_tests.rs diff --git a/crates/engine/src/cmd/brush_cmds.rs b/crates/engine/src/cmd/brush_cmds.rs index 61a35e5150..389d02a08d 100644 --- a/crates/engine/src/cmd/brush_cmds.rs +++ b/crates/engine/src/cmd/brush_cmds.rs @@ -855,9 +855,14 @@ fn color_replacement_setup(s: &Session, p: &Value) -> Result<(Stroke, ReplaceSet Ok((Stroke { brush, points: pts }, rs, id)) } +/// The History state (and recorded step) name of a Color Replacement edit. +pub const COLOR_REPLACEMENT_STEP: &str = "Color Replacement Tool"; + fn color_replacement(s: &mut Session, p: &Value) -> Result { let (stroke, rs, id) = color_replacement_setup(s, p)?; - let dmg = s.edit("Color Replacement", |doc, _| { + // The reference application names every click, stroke and Shift+click segment "Color + // Replacement Tool" (#11933); the command's label is the same, so recorded actions match. + let dmg = s.edit(COLOR_REPLACEMENT_STEP, |doc, _| { let sel = doc.selection.clone(); let lock = doc.effective_locks(id).transparency; let surf = paint_surface(doc, id, &json!({}))?; @@ -1340,7 +1345,7 @@ pub fn specs() -> Vec { ), spec!( "paint.colorReplacement", - "Color Replacement", + "Color Replacement Tool", 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,"target":"pixels"="pixels","seed":u64?}"##, crate::commands::has_paintable, color_replacement, @@ -1383,3 +1388,7 @@ mod tests; #[cfg(test)] mod mixer_live_tests; + +#[cfg(test)] +#[path = "color_replacement_history_name_tests.rs"] +mod color_replacement_history_name_tests; diff --git a/crates/engine/src/cmd/color_replacement_history_name_tests.rs b/crates/engine/src/cmd/color_replacement_history_name_tests.rs new file mode 100644 index 0000000000..4f20d6783e --- /dev/null +++ b/crates/engine/src/cmd/color_replacement_history_name_tests.rs @@ -0,0 +1,45 @@ +//! #11933: a Color Replacement click, a drag and a Shift+click segment each add one History state +//! named "Color Replacement Tool", on a layer and on the Background with Lock transparent pixels; +//! the command's label, which recorded actions show, is the same name. + +use serde_json::json; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 100, "height": 100})).unwrap(); + s.execute("tools.setColors", json!({"foreground": "#0000ff"})).unwrap(); + s +} + +fn last(s: &Session) -> (usize, Option) { + let h = &s.active().unwrap().history; + (h.past_len(), h.entries().last().map(|e| e.to_string())) +} + +#[test] +fn every_click_stroke_and_segment_is_one_color_replacement_tool_state() { + for on_background in [false, true] { + let mut s = session(); + if !on_background { + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"color": "#dc1e1e"})).unwrap(); + } else { + s.execute("edit.fill", json!({"color": "#dc1e1e"})).unwrap(); + s.execute("layer.lockLayers", json!({"transparency": true})).unwrap(); + } + for points in [json!([[20, 20]]), json!([[20, 50], [80, 50]]), json!([[80, 50], [80, 80]])] { + let (n, _) = last(&s); + s.execute("paint.colorReplacement", json!({"points": points, "size": 15})).unwrap(); + assert_eq!(last(&s), (n + 1, Some(COLOR_REPLACEMENT_STEP.to_string())), "{points} (background {on_background})"); + } + } + assert_eq!(COLOR_REPLACEMENT_STEP, "Color Replacement Tool"); +} + +#[test] +fn recorded_steps_use_the_same_name() { + let spec = crate::commands::find("paint.colorReplacement").expect("the command"); + assert_eq!(spec.label, COLOR_REPLACEMENT_STEP); +} diff --git a/crates/ui-egui/src/color_replacement_tests.rs b/crates/ui-egui/src/color_replacement_tests.rs index 64f04f3c73..e374c87a8f 100644 --- a/crates/ui-egui/src/color_replacement_tests.rs +++ b/crates/ui-egui/src/color_replacement_tests.rs @@ -147,7 +147,7 @@ fn live_stroke_at(depth: u32) { } let st = app.session.active().unwrap(); assert_eq!(st.history.past_len(), steps + 1, "one History step"); - assert_eq!(st.history.entries().last().map(|e| e.to_string()).as_deref(), Some("Color Replacement")); + assert_eq!(st.history.entries().last().map(|e| e.to_string()).as_deref(), Some("Color Replacement Tool")); app.run("edit.undo", json!({})).unwrap(); assert_eq!(rgba(&app, 20, 20), before, "one undo restores the pixels"); app.run("edit.redo", json!({})).unwrap(); From 022cf4f125d1b22ec3e7679ce2ffc38980c11e13 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sat, 7 Nov 2026 21:59:52 -0500 Subject: [PATCH 167/219] Open as Smart Object: keep the damage flag, the Smart Object flag and unique names CodeRabbit round 2 on #12212: - 4504550937 (b): a PSD that recovered from damaged data is held for File > Open's confirmation (the app passes confirmDamage; headless callers open it as before). - 4504550942: a document held for its prompt counts when numbering, and the number is re-taken when each document is admitted, so two held files with one stem no longer share a name. - 4504550943 is not changed here (needs colour transforms at regeneration), as are 4504550937 (a), (c) and 4504550953. - 4504550946: the successful reply carries smartObject, so a document queued behind another open prompt is admitted with the Open As Smart Object history label. Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at medium --- crates/engine/src/cmd/file_cmds.rs | 2 +- crates/engine/src/cmd/file_cmds/open.rs | 10 +++- .../src/cmd/file_cmds/open_as_smart_object.rs | 37 ++++++++---- .../file_cmds/open_as_smart_object_tests.rs | 56 +++++++++++++++++++ crates/engine/src/jobs.rs | 2 +- crates/engine/src/session/documents.rs | 5 ++ crates/ui-egui/src/jobs_ui.rs | 3 +- 7 files changed, 100 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/cmd/file_cmds.rs b/crates/engine/src/cmd/file_cmds.rs index b1eed032dd..b1ec4df7d6 100644 --- a/crates/engine/src/cmd/file_cmds.rs +++ b/crates/engine/src/cmd/file_cmds.rs @@ -293,7 +293,7 @@ pub fn specs() -> Vec { "Open as Smart Object…", &["File"], None, - r##"{"path":str} (opens the file as a new untitled document named "-N" with the file's size, resolution, colour mode, bit depth and profile, holding one embedded smart object layer named after the file) → {document, docId, name, layer, warnings, color} (or {held, name, warnings, color, smartObject} while the profile prompt waits)"##, + r##"{"path":str,"confirmDamage":bool=false (a damaged PSD waits for an answer, as File > Open's does)} (opens the file as a new untitled document named "-N" with the file's size, resolution, colour mode, bit depth and profile, holding one embedded smart object layer named after the file) → {document, docId, name, layer, warnings, color, smartObject} (or {held, name, warnings, color, damaged, smartObject} while a prompt waits)"##, native, open_as_smart_object ), diff --git a/crates/engine/src/cmd/file_cmds/open.rs b/crates/engine/src/cmd/file_cmds/open.rs index 71b7cfcefb..282eae0e4e 100644 --- a/crates/engine/src/cmd/file_cmds/open.rs +++ b/crates/engine/src/cmd/file_cmds/open.rs @@ -75,8 +75,14 @@ fn import_result(name: &str, bytes: &[u8]) -> Result Result { + import_result_for_open(name, bytes, opts).map(|(r, _)| r) +} + +/// [`import_result_with`], also telling whether a PSD/PSB had damaged data it recovered from, as +/// File › Open's job learns it (`photocraft_io::import_for_open`), so an interactive caller can ask. +pub(super) fn import_result_for_open(name: &str, bytes: &[u8], opts: &photocraft_io::ImportOptions) -> Result<(photocraft_io::ImportResult, bool)> { crate::allocation::checkpoint("importing document")?; - let r = photocraft_io::import_with_options(name, bytes, opts).map_err(|e| crate::error::io_open_error(name, e))?; + let (r, damaged) = photocraft_io::import_for_open(name, bytes, opts).map_err(|e| crate::error::io_open_error(name, e))?; // Auxiliary imports return only a document and cannot surface the preview's fidelity warning. // Open has its own warning-preserving path; never silently place or process a thumbnail. if r.preview_only { @@ -84,7 +90,7 @@ pub(super) fn import_result_with(name: &str, bytes: &[u8], opts: &photocraft_io: "{name}: only this Affinity file's embedded preview could be read; open it with File › Open to see the warning, or export PSD or PNG from Affinity before using it here" ))); } - Ok(r) + Ok((r, damaged)) } // ---------- close / revert / save a copy / open as ---------- diff --git a/crates/engine/src/cmd/file_cmds/open_as_smart_object.rs b/crates/engine/src/cmd/file_cmds/open_as_smart_object.rs index b32b6bfbcb..517252b25a 100644 --- a/crates/engine/src/cmd/file_cmds/open_as_smart_object.rs +++ b/crates/engine/src/cmd/file_cmds/open_as_smart_object.rs @@ -5,7 +5,7 @@ //! size and position `0, 0`) and put into the document before it is admitted, so the opening //! snapshot already shows it. -use super::open::import_result_with; +use super::open::import_result_for_open; use super::paths::{file_name, read_file, stem, untitled_name}; use super::{Affine, Arc, Document, Layer, LayerContent, Result, Session, SmartObject, SmartSource, Value, flattened_checked, json, str_param}; use crate::layer_place::Place; @@ -24,6 +24,8 @@ pub(super) fn open_as_smart_object(s: &mut Session, p: &Value) -> Result let path = str_param(p, "path", CMD)?.to_string(); let name = file_name(&path); let opts = crate::jobs::import_options(s.prefs()); + // An interactive caller (the app) asks before opening a PSD that had damaged data, as File > Open does. + let confirm_damage = p.get("confirmDamage").and_then(Value::as_bool).unwrap_or(false); crate::jobs::run( s, &format!("Opening {name}"), @@ -33,25 +35,33 @@ pub(super) fn open_as_smart_object(s: &mut Session, p: &Value) -> Result let bytes = read_file(&path)?; ctx.check()?; ctx.progress(0.05, "Decoding"); - let r = import_result_with(&name, &bytes, &opts)?; + let (r, damaged) = import_result_for_open(&name, &bytes, &opts)?; ctx.check()?; ctx.progress(0.9, "Flattening"); // The file as one image: the smart object's rendered contents. let pixels = flattened_checked(&r.document, r.document.pixel_format(), CMD)?; - Ok((path, name, Arc::new(bytes), r, pixels)) + Ok((path, name, Arc::new(bytes), r, pixels, confirm_damage && damaged)) }, - |s, (path, name, bytes, r, pixels)| apply(s, &path, &name, bytes, r, pixels), + |s, (path, name, bytes, r, pixels, damaged)| apply(s, &path, &name, bytes, r, pixels, damaged), ) } -fn apply(s: &mut Session, path: &str, file: &str, bytes: Arc>, r: photocraft_io::ImportResult, pixels: photocraft_raster::Surface) -> Result { +fn apply( + s: &mut Session, + path: &str, + file: &str, + bytes: Arc>, + r: photocraft_io::ImportResult, + pixels: photocraft_raster::Surface, + damaged: bool, +) -> Result { let warnings = r.warnings; let mut src = r.document; super::tag_exr_colorspace(s, file, &mut src); super::raw_defaults(s, &warnings, &mut src); let layer_name = stem(path); // "-1", the next free number while a document with that name is open. - let name = untitled_name(&layer_name, |n| s.documents().iter().any(|d| d.doc.name == n)); + let name = untitled_name(&layer_name, |n| s.documents().iter().any(|d| d.doc.name == n) || s.held_open_named(n)); let mut doc = Document::new(name.clone(), src.size, src.mode, src.depth); doc.resolution_dpi = src.resolution_dpi; @@ -70,21 +80,28 @@ fn apply(s: &mut Session, path: &str, file: &str, bytes: Arc>, r: photoc // prompt holds the document, and without a question the Color Settings policy is applied. let raw = warnings.iter().any(|w| w.contains(photocraft_io::raw::DEVELOPED_NOTE)); let question = s.color.open_question(&doc).filter(|_| !raw); - if question.is_some() { + if damaged || question.is_some() { let held = s.hold_open(doc, false); - return Ok(json!({"held": held, "name": name, "warnings": warnings, "color": question, "smartObject": true})); + return Ok(json!({"held": held, "name": name, "warnings": warnings, "color": question, "damaged": damaged, "smartObject": true})); } let color = s.color.open_policy(&mut doc, s.prefs().file_handling.ignore_exif_profile_tag); let index = admit_as_smart_object(s, doc); let id = s.documents().get(index).map(|st| st.doc.id.0); - Ok(json!({"document": index, "docId": id, "name": name, "layer": layer_id.0, "warnings": warnings, "color": color})) + Ok(json!({"document": index, "docId": id, "name": name, "layer": layer_id.0, "warnings": warnings, "color": color, "smartObject": true})) } /// Admits a document built by Open as Smart Object (one smart object layer) as an untitled /// document: the opening snapshot already shows the placed layer, is named after it, and the /// history is the single state "Open As Smart Object", as File › Open's is one state. -pub fn admit_as_smart_object(s: &mut Session, doc: Document) -> usize { +pub fn admit_as_smart_object(s: &mut Session, mut doc: Document) -> usize { let layer_name = doc.layers.first().map(|l| l.name.clone()); + // The number was free when the file was read, but a document held for its prompt did not count + // then: take the next free one now, as each document is admitted. + if let Some(base) = &layer_name + && s.documents().iter().any(|d| d.doc.name == doc.name) + { + doc.name = untitled_name(base, |n| s.documents().iter().any(|d| d.doc.name == n)); + } // Untitled: no path, so Save asks where to save. let index = s.add_document(doc, None); if let Some(st) = s.active_mut() { diff --git a/crates/engine/src/cmd/file_cmds/open_as_smart_object_tests.rs b/crates/engine/src/cmd/file_cmds/open_as_smart_object_tests.rs index 2117d8e5e1..230c75afe8 100644 --- a/crates/engine/src/cmd/file_cmds/open_as_smart_object_tests.rs +++ b/crates/engine/src/cmd/file_cmds/open_as_smart_object_tests.rs @@ -278,3 +278,59 @@ fn ask_when_opening_holds_the_document_like_file_open() { assert_eq!(doc.layers.len(), 1); assert!(matches!(doc.layers[0].content, LayerContent::Smart(_))); } + +#[test] +fn the_reply_flags_a_smart_object_so_a_queued_document_keeps_its_history_label() { + let dir = tmp("flag"); + let path = write_png(&dir, "a_test.png", 16, 12); + let mut s = Session::new(); + let r = s.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + assert_eq!(r["smartObject"], true, "{r}"); +} + +#[test] +fn a_damaged_psd_is_held_for_confirmation_when_asked_and_opens_otherwise() { + let dir = tmp("damaged"); + let path = join(&dir, "d.psd"); + std::fs::write(&path, crate::jobs::psd_open_fixture::make_psd(true)).unwrap(); + let mut s = Session::new(); + s.execute("edit.colorSettings", json!({"askOnMismatch": false})).unwrap(); + let r = s.execute("file.openAsSmartObject", json!({"path": path, "confirmDamage": true})).unwrap(); + assert_eq!(r["damaged"], true, "{r}"); + assert_eq!(r["smartObject"], true, "{r}"); + assert!(r["held"].is_u64(), "{r}"); + assert!(s.documents().is_empty(), "held for the damage prompt, not admitted"); + let r = s.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + assert!(r["docId"].is_u64(), "a headless caller opens the recovered content: {r}"); +} + +#[test] +fn a_document_held_for_its_prompt_does_not_share_its_name_with_the_next() { + let dir = tmp("held-name"); + let path = write_psd16(&dir, "a_test.psd", 20, 20); + let mut s = Session::new(); + let first = s.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + let second = s.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + assert_eq!(first["name"], "a_test-1"); + assert_eq!(second["name"], "a_test-2", "the held document's name is taken: {second}"); + // Both held docs leave the session for the app's queue; admitting them one by one numbers each. + let (a, _) = s.take_held_open(first["held"].as_u64().unwrap()).unwrap(); + let (b, _) = s.take_held_open(second["held"].as_u64().unwrap()).unwrap(); + admit_as_smart_object(&mut s, a); + admit_as_smart_object(&mut s, b); + assert_eq!(names(&s), ["a_test-1", "a_test-2"]); + let mut t = Session::new(); + let (c, _) = { + let r = t.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + t.take_held_open(r["held"].as_u64().unwrap()).unwrap() + }; + let (d, _) = { + t.execute("edit.colorSettings", json!({"askOnMismatch": true})).unwrap(); + let r = t.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + t.take_held_open(r["held"].as_u64().unwrap()).unwrap() + }; + assert_eq!(c.name, d.name, "taken out of the session, both are named a_test-1 when read"); + admit_as_smart_object(&mut t, c); + admit_as_smart_object(&mut t, d); + assert_eq!(names(&t), ["a_test-1", "a_test-2"], "the second takes the next free number when admitted"); +} diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index 1390e89902..2504bcc3d9 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -2148,7 +2148,7 @@ mod tests; #[cfg(test)] #[path = "../../io/tests/support/recovery_psd.rs"] -mod psd_open_fixture; +pub(crate) mod psd_open_fixture; #[cfg(test)] mod psd_open_confirmation_tests { diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index 49f8205079..b5b82a4753 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -193,6 +193,11 @@ impl Session { self.next_held_open } + /// Whether a document held back for its prompt already has `name`. + pub(crate) fn held_open_named(&self, name: &str) -> bool { + self.held_opens.iter().any(|(_, d, _)| d.name == name) + } + /// Takes a document a background open held back for the profile prompt: the document and /// whether its source is read-only. `None` when there is no such document (already taken). pub fn take_held_open(&mut self, id: u64) -> Option<(Document, bool)> { diff --git a/crates/ui-egui/src/jobs_ui.rs b/crates/ui-egui/src/jobs_ui.rs index 3900826166..7459f54be6 100644 --- a/crates/ui-egui/src/jobs_ui.rs +++ b/crates/ui-egui/src/jobs_ui.rs @@ -307,7 +307,8 @@ fn track_open(app: &mut PhotocraftApp, tab: OpenTab) { /// Open Recent is untouched. Inline (background jobs off, or the web) it finishes at once. pub(crate) fn start_open_as_smart_object(app: &mut PhotocraftApp, path: &str, order: Option) -> Result<(), String> { let name = crate::file_open::display_name(path); - let params = json!({"path": path}); + // `confirmDamage`: a damaged PSD waits for the user's answer, as in File › Open. + let params = json!({"path": path, "confirmDamage": true}); let started = if app.background_jobs { app.session.start("file.openAsSmartObject", params).map_err(|e| e.to_string())? } else { From 60e45bb9263bee84b5b5ea71806f32a0f54458b9 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 7 Nov 2026 20:32:04 -0600 Subject: [PATCH 168/219] Color Replacement: refuse RGB/32, Grayscale, Bitmap and Indexed documents (#11923) paint.colorReplacement and its live stroke refuse the modes and depths the reference application refuses, with its words: 32 bit per channel RGB, Grayscale and Bitmap (only full color modes), and Indexed Color. Nothing is painted and no history is added. RGB/8, RGB/16, CMYK and Lab paint as before; cases that weren't measured keep today's behaviour. --- crates/engine/src/cmd/brush_cmds.rs | 19 +++++ .../src/cmd/color_replacement_modes_tests.rs | 71 +++++++++++++++++++ crates/engine/tests/multi_channel_editing.rs | 18 ++++- crates/ui-egui/src/color_replacement_tests.rs | 3 +- 4 files changed, 107 insertions(+), 4 deletions(-) create mode 100644 crates/engine/src/cmd/color_replacement_modes_tests.rs diff --git a/crates/engine/src/cmd/brush_cmds.rs b/crates/engine/src/cmd/brush_cmds.rs index 61a35e5150..cf09145b0a 100644 --- a/crates/engine/src/cmd/brush_cmds.rs +++ b/crates/engine/src/cmd/brush_cmds.rs @@ -832,6 +832,21 @@ fn mixer_load_pixel(s: &mut Session, p: &Value) -> Result { /// channel or Quick Mask `target` is refused rather than recolouring the layer instead. fn color_replacement_setup(s: &Session, p: &Value) -> Result<(Stroke, ReplaceSettings, photocraft_doc::LayerId)> { let cmd = "paint.colorReplacement"; + // The modes and depths the reference application refuses, with its words (#11923). The live + // stroke runs this setup too, so it previews nothing in them. 32-bit Grayscale, CMYK and Lab, + // Multichannel and Duotone weren't measured and keep painting. + if let Some(st) = s.active() { + use photocraft_color::{ColorMode, SampleType}; + let why = match (st.doc.mode, st.doc.depth) { + (ColorMode::Indexed, _) => Some("it does not work with Indexed Color images (convert image to RGB Color mode to edit)."), + (ColorMode::Bitmap, _) | (ColorMode::Grayscale, SampleType::U8 | SampleType::U16) => Some("it only works in full color modes."), + (ColorMode::Rgb, SampleType::F32) => Some("it does not work with 32 bit per channel images (convert image to 8 or 16 bit per channel to edit)."), + _ => None, + }; + if let Some(why) = why { + return Err(EngineError::Other(format!("Could not use the Color Replacement tool because {why}"))); + } + } if let Some(t) = p.get("target").filter(|t| t.as_str() != Some("pixels")) && crate::color_channels::explicit(p, s.active().map_or(0, |st| crate::channel_cmds::color_count(&st.doc)))?.is_none() { @@ -1381,5 +1396,9 @@ pub fn specs() -> Vec { #[cfg(test)] mod tests; +#[cfg(test)] +#[path = "color_replacement_modes_tests.rs"] +mod color_replacement_modes_tests; + #[cfg(test)] mod mixer_live_tests; diff --git a/crates/engine/src/cmd/color_replacement_modes_tests.rs b/crates/engine/src/cmd/color_replacement_modes_tests.rs new file mode 100644 index 0000000000..89df6a7ce9 --- /dev/null +++ b/crates/engine/src/cmd/color_replacement_modes_tests.rs @@ -0,0 +1,71 @@ +//! #11923: the Color Replacement tool refuses RGB/32, Grayscale, Bitmap and Indexed documents with +//! the reference application's messages, painting nothing and adding no history; the live stroke +//! is refused the same way. RGB/8, RGB/16, CMYK and Lab paint. + +use photocraft_color::{Color, ColorMode, SampleType}; +use photocraft_doc::{Document, Size}; +use serde_json::json; + +use super::*; + +const DEPTH_32: &str = + "Could not use the Color Replacement tool because it does not work with 32 bit per channel images (convert image to 8 or 16 bit per channel to edit)."; +const NOT_FULL_COLOR: &str = "Could not use the Color Replacement tool because it only works in full color modes."; +const INDEXED: &str = "Could not use the Color Replacement tool because it does not work with Indexed Color images (convert image to RGB Color mode to edit)."; + +/// A session with one 64 × 64 document of `mode` and `depth`, filled red. +fn session(mode: ColorMode, depth: SampleType) -> Session { + let mut s = Session::new(); + s.add_document(Document::with_background("t", Size::new(64, 64), mode, depth, Color::rgb(220.0 / 255.0, 30.0 / 255.0, 30.0 / 255.0)), None); + s.tools.foreground = [0.0, 0.0, 1.0, 1.0]; + s +} + +fn stroke() -> Value { + json!({"points": [[20, 32], [44, 32]], "size": 20, "hardness": 100}) +} + +fn steps(s: &Session) -> usize { + s.active().unwrap().history.past_len() +} + +fn pixels(s: &Session) -> Vec { + let st = s.active().unwrap(); + let surf = st.doc.layers[0].surface().unwrap(); + (0..64).flat_map(|x| surf.pixel(x, 32).to_vec()).collect() +} + +#[test] +fn refused_modes_say_why_and_paint_nothing() { + for (mode, depth, want) in [ + (ColorMode::Rgb, SampleType::F32, DEPTH_32), + (ColorMode::Grayscale, SampleType::U8, NOT_FULL_COLOR), + (ColorMode::Grayscale, SampleType::U16, NOT_FULL_COLOR), + (ColorMode::Bitmap, SampleType::U8, NOT_FULL_COLOR), + (ColorMode::Indexed, SampleType::U8, INDEXED), + ] { + let mut s = session(mode, depth); + let (before, n) = (pixels(&s), steps(&s)); + let err = s.execute("paint.colorReplacement", stroke()).unwrap_err().to_string(); + assert_eq!(err, want, "{mode:?}/{depth:?}"); + assert_eq!(pixels(&s), before, "{mode:?}/{depth:?}: nothing painted"); + assert_eq!(steps(&s), n, "{mode:?}/{depth:?}: no history"); + // The live stroke previews nothing either. + let live = live_color_replacement(&s, &stroke()).err().map(|e| e.to_string()); + assert_eq!(live.as_deref(), Some(want), "{mode:?}/{depth:?}: live"); + } +} + +#[test] +fn full_colour_documents_still_paint() { + for (mode, depth) in + [(ColorMode::Rgb, SampleType::U8), (ColorMode::Rgb, SampleType::U16), (ColorMode::Cmyk, SampleType::U8), (ColorMode::Lab, SampleType::U8)] + { + let mut s = session(mode, depth); + let (before, n) = (pixels(&s), steps(&s)); + s.execute("paint.colorReplacement", stroke()).unwrap_or_else(|e| panic!("{mode:?}/{depth:?}: {e}")); + assert_ne!(pixels(&s), before, "{mode:?}/{depth:?}: painted"); + assert_eq!(steps(&s), n + 1, "{mode:?}/{depth:?}"); + assert!(live_color_replacement(&s, &stroke()).is_ok(), "{mode:?}/{depth:?}: live"); + } +} diff --git a/crates/engine/tests/multi_channel_editing.rs b/crates/engine/tests/multi_channel_editing.rs index 5780c1318e..09cf6edc97 100644 --- a/crates/engine/tests/multi_channel_editing.rs +++ b/crates/engine/tests/multi_channel_editing.rs @@ -99,6 +99,12 @@ fn adjustments_filters_and_drawing_preserve_other_encoded_components() { let mut s = setup(mode, depth); subset(&mut s); let before = surface(&s); + // Color Replacement refuses 32-bit RGB, as the reference application does (#11923). + if *cmd == "paint.colorReplacement" && mode == "rgb" && depth == 32 { + assert!(s.execute(cmd, p.clone()).is_err(), "{mode} {depth} {cmd} is refused"); + assert_eq!(surface(&s), before, "{mode} {depth} {cmd}: nothing changed"); + continue; + } s.execute(cmd, p.clone()).unwrap_or_else(|e| panic!("{mode} {depth} {cmd}: {e}")); let after = surface(&s); unchanged(&before, &after); @@ -669,7 +675,8 @@ fn last_filter_rejects_non_object_journal_params_without_panicking() { #[test] fn first_single_channel_retouch_preview_preserves_every_other_sample() { for cmd in ["paint.dodge", "paint.burn", "paint.sponge", "paint.blur", "paint.sharpen", "paint.smudge", "paint.colorReplacement"] { - let mut s = setup("rgb", 32); + // Color Replacement refuses 32-bit RGB (#11923): it is checked at 16 bits. + let mut s = setup("rgb", if cmd == "paint.colorReplacement" { 16 } else { 32 }); s.execute("channel.target", json!({"channel":{"color":0}})).unwrap(); let before = surface(&s); let p = json!({"points":[[6,7,1],[23,15,0.7]],"size":9,"color":"#ffffff","tolerance":100}); @@ -765,7 +772,11 @@ fn lab_single_channel_tone_parameters_round_trip_without_applying_lightness_twic } fn flat_rgb() -> Session { - let mut s = setup("rgb", 32); + flat_rgb_at(32) +} + +fn flat_rgb_at(depth: u32) -> Session { + let mut s = setup("rgb", depth); s.edit("flat", |doc, id| { doc.layer_mut(id.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(0, 0, 32, 24), &[0.25, 0.5, 0.25, 1.0]); Ok(()) @@ -894,7 +905,8 @@ fn rgb_component_exposure_uses_the_document_transfer_at_all_depths() { #[test] fn color_replacement_live_preview_forwards_explicit_component_and_pixel_targets() { for target in [json!({"colors":[1]}), json!("pixels")] { - let mut s = flat_rgb(); + // 16-bit: Color Replacement refuses 32-bit RGB (#11923). + let mut s = flat_rgb_at(16); subset(&mut s); let params = json!({"points":[[8,8],[22,12]],"size":9,"tolerance":100,"mode":"luminosity","color":"#ffffff","seed":42,"target":target}); let live = photocraft_engine::brush_cmds::live_color_replacement(&s, ¶ms).unwrap(); diff --git a/crates/ui-egui/src/color_replacement_tests.rs b/crates/ui-egui/src/color_replacement_tests.rs index 64f04f3c73..19bfe5b0ea 100644 --- a/crates/ui-egui/src/color_replacement_tests.rs +++ b/crates/ui-egui/src/color_replacement_tests.rs @@ -115,7 +115,8 @@ fn contiguous_limits_skip_a_matching_island_that_discontiguous_recolours() { /// 8 (the live stroke keeps its working pixels in the layer's depth between dabs). #[test] fn a_stroke_is_drawn_live_and_is_one_history_step() { - for depth in [8, 32] { + // Color Replacement refuses 32-bit RGB documents, as the reference application does (#11923). + for depth in [8, 16] { live_stroke_at(depth); } } From 3ee54ede0f205cc9f6072ed85bf7f495741d407a Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 7 Nov 2026 21:11:34 -0600 Subject: [PATCH 169/219] Eraser: in Block mode a right-click opens the Brush / Pencil / Block menu (#11937) With the Eraser in Block mode, a plain right-click on the canvas opens the Brush / Pencil / Block menu at the pointer instead of the Brush Preset picker, whose Size and Hardness mean nothing for the Block, as in the reference application. Picking an entry sets the mode without erasing or adding history. Brush and Pencil mode keep the picker, Shift+right-click opens the menu in every mode, and with the Erase preference a right-click still erases. --- crates/ui-egui/src/brush_mode_menu.rs | 15 +++ crates/ui-egui/src/canvas/paint_mouse.rs | 5 +- .../src/eraser_block_right_click_tests.rs | 123 ++++++++++++++++++ 3 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/src/eraser_block_right_click_tests.rs diff --git a/crates/ui-egui/src/brush_mode_menu.rs b/crates/ui-egui/src/brush_mode_menu.rs index 431ad7aea2..71259e9f53 100644 --- a/crates/ui-egui/src/brush_mode_menu.rs +++ b/crates/ui-egui/src/brush_mode_menu.rs @@ -166,6 +166,17 @@ pub(crate) fn is_gesture(tool: Tool, mods: egui::Modifiers) -> bool { mods.shift && !mods.alt && !mods.ctrl && !mods.command && !mods.mac_cmd && !items(tool).is_empty() } +/// Does a right-click with `mods` open the menu? The Shift gesture for every tool that has one, +/// and a plain right-click with the Eraser in Block mode, where the Brush Preset picker's Size and +/// Hardness mean nothing (#11937, as in the reference application). +/// A plain right-click opens it only where it would otherwise open the picker: with the Erase +/// preference it still erases. +pub(crate) fn opens_on(app: &PhotocraftApp, tool: Tool, mods: egui::Modifiers) -> bool { + let plain = !mods.shift && !mods.alt && !mods.ctrl && !mods.command && !mods.mac_cmd; + let block = tool == Tool::Eraser && app.ui.tool_options.eraser_mode == "block"; + is_gesture(tool, mods) || (plain && block && !crate::paint_mouse::right_erases(app, tool)) +} + /// The modifiers that decide a right-button gesture: the ones held when the button went down. /// egui reports `secondary_clicked` on release, so letting go of Shift a moment before the button /// must not turn Shift+right-click into a plain right-click (an erase dab, with the Erase @@ -382,3 +393,7 @@ pub(crate) fn show(app: &mut PhotocraftApp, ctx: &Context) { close(app); } } + +#[cfg(test)] +#[path = "eraser_block_right_click_tests.rs"] +mod eraser_block_right_click_tests; diff --git a/crates/ui-egui/src/canvas/paint_mouse.rs b/crates/ui-egui/src/canvas/paint_mouse.rs index 2c16ce1fa1..12efd0d971 100644 --- a/crates/ui-egui/src/canvas/paint_mouse.rs +++ b/crates/ui-egui/src/canvas/paint_mouse.rs @@ -348,7 +348,8 @@ pub fn canvas_buttons(app: &mut PhotocraftApp, response: &Response, tool: Tool) // ⌘/Ctrl+right-click lists the layers under the pointer instead (layer_pick_ui.rs, #307). let layer_menu = crate::layer_pick_ui::is_gesture(tool, mods); // Decided by the modifiers held when the button went down, not by those of the release frame that reports the click. - let mode_menu = crate::brush_mode_menu::is_gesture(tool, crate::workspace_ui::sticky_mods(app, crate::brush_mode_menu::gesture_mods(&response.ctx, mods))); + let mode_menu = + crate::brush_mode_menu::opens_on(app, tool, crate::workspace_ui::sticky_mods(app, crate::brush_mode_menu::gesture_mods(&response.ctx, mods))); if mode_menu && !resizing && app.drag.is_none() { app.secondary_erase = false; if let Some(p) = right_click_at(response) { @@ -425,7 +426,7 @@ pub fn pointer_secondary(app: &mut PhotocraftApp, ctx: &egui::Context, down: boo app.brush_resize_armed = down && app.drag.is_none(); return true; } - if crate::brush_mode_menu::is_gesture(tool, mods) && app.drag.is_none() && app.brush_resize.is_none() { + if crate::brush_mode_menu::opens_on(app, tool, mods) && app.drag.is_none() && app.brush_resize.is_none() { app.secondary_erase = false; if down { crate::brush_mode_menu::open(app, ctx, at); diff --git a/crates/ui-egui/src/eraser_block_right_click_tests.rs b/crates/ui-egui/src/eraser_block_right_click_tests.rs new file mode 100644 index 0000000000..73be8c8750 --- /dev/null +++ b/crates/ui-egui/src/eraser_block_right_click_tests.rs @@ -0,0 +1,123 @@ +//! #11937: with the Eraser in Block mode a plain right-click on the canvas opens the Brush · Pencil +//! · Block menu at the pointer instead of the Brush Preset picker; picking an entry sets the mode, +//! adds no history and erases nothing. Brush and Pencil mode keep the picker; Shift+right-click +//! opens the menu in every mode; with the Erase preference a right-click still erases. + +use egui_kittest::Harness; +use serde_json::{Value, json}; + +use crate::PhotocraftApp; +use crate::control::{ControlRequest, Outcome, handle}; +use crate::state::Tool; + +fn app(mode: &str) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 64, "background": "white"})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("edit.fill", json!({"color": "#dc1e1e"})).unwrap(); + app.ui.tool = Tool::Eraser; + app.ui.tool_options.eraser_mode = mode.into(); + app.sync_views(); + app +} + +fn call(app: &mut PhotocraftApp, ctx: &egui::Context, method: &str, params: Value) -> Value { + let (req, _rx) = ControlRequest::new(method, params); + match handle(app, ctx, &req) { + Outcome::Done(v) => v, + _ => panic!("{method}: expected an immediate reply"), + } +} + +fn right_click(app: &mut PhotocraftApp, ctx: &egui::Context, modifiers: Value) { + call( + app, + ctx, + "ui.pointer", + json!({"button": "right", "modifiers": modifiers, "events": [{"kind": "down", "x": 20, "y": 20}, {"kind": "up", "x": 20, "y": 20}]}), + ); +} + +fn steps(app: &PhotocraftApp) -> usize { + app.session.active().unwrap().history.past_len() +} + +#[test] +fn block_mode_right_click_opens_the_mode_menu() { + let ctx = egui::Context::default(); + let mut app = app("block"); + let n = steps(&app); + right_click(&mut app, &ctx, json!({})); + assert!(app.ui.brush_mode_menu.is_some(), "the Brush · Pencil · Block menu"); + assert!(app.ui.brush_picker.is_none(), "not the preset picker"); + let inspect = call(&mut app, &ctx, "ui.inspect", json!({})); + assert_eq!(inspect["result"]["brushModeMenu"]["choices"], json!(["Brush", "Pencil", "Block"]), "{inspect}"); + // Picking Pencil sets the mode; nothing is erased and no history is added. + assert_eq!(call(&mut app, &ctx, "ui.context.choose", json!({"id": "Pencil"}))["ok"], true); + assert_eq!(app.ui.tool_options.eraser_mode, "pencil"); + assert_eq!(steps(&app), n); + assert!(app.ui.brush_mode_menu.is_none()); +} + +#[test] +fn brush_and_pencil_mode_keep_the_picker() { + let ctx = egui::Context::default(); + for mode in ["brush", "pencil"] { + let mut app = app(mode); + right_click(&mut app, &ctx, json!({})); + assert!(app.ui.brush_picker.is_some(), "{mode}: the Brush Preset picker"); + assert!(app.ui.brush_mode_menu.is_none(), "{mode}"); + } +} + +#[test] +fn shift_right_click_opens_the_menu_in_every_mode() { + let ctx = egui::Context::default(); + for mode in ["brush", "pencil", "block"] { + let mut app = app(mode); + right_click(&mut app, &ctx, json!({"shift": true})); + assert!(app.ui.brush_mode_menu.is_some(), "{mode}"); + } +} + +#[test] +fn with_the_erase_preference_block_mode_still_erases() { + let ctx = egui::Context::default(); + let mut app = app("block"); + app.run("prefs.set", json!({"path": "tools.rightClickWithPaintingTools", "value": "erase"})).unwrap(); + call(&mut app, &ctx, "ui.pointer", json!({"button": "right", "events": [{"kind": "down", "x": 20, "y": 20}]})); + assert!(app.ui.brush_mode_menu.is_none(), "the preference's erase wins"); + assert!(app.secondary_erase || app.drag.is_some(), "a right-erase started"); +} + +/// The real mouse: a right-click on the canvas in Block mode opens the menu; Escape closes it and +/// nothing is erased. +#[test] +fn a_real_right_click_opens_the_menu_and_escape_closes_it() { + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + }, + app("block"), + ); + crate::PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + let n = steps(h.state()); + let c = h.state().last_canvas_rect.center(); + h.event(egui::Event::PointerMoved(c)); + h.run_steps(1); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: c, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE }); + h.run_steps(2); + } + assert!(h.state().ui.brush_mode_menu.is_some(), "the menu at the pointer"); + assert!(h.state().ui.brush_picker.is_none()); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert!(h.state().ui.brush_mode_menu.is_none(), "Escape closes it"); + assert_eq!(steps(h.state()), n, "nothing erased"); +} From fe210916f9a840711a919f289a4c58d755cb504b Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 7 Nov 2026 21:30:58 -0600 Subject: [PATCH 170/219] Check Size control is off after Reset All Tools (CodeRabbit review on crates/ui-egui/tests/it/background_eraser_reset_tip.rs:76) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/tests/it/background_eraser_reset_tip.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/ui-egui/tests/it/background_eraser_reset_tip.rs b/crates/ui-egui/tests/it/background_eraser_reset_tip.rs index 39fe152c94..f971412bc4 100644 --- a/crates/ui-egui/tests/it/background_eraser_reset_tip.rs +++ b/crates/ui-egui/tests/it/background_eraser_reset_tip.rs @@ -67,6 +67,7 @@ fn reset_all_tools_gives_it_the_tip_active_or_picked_later() { scramble(&mut app); control(&mut app, "ui.menu.invoke", json!({"id": "tool.resetAll", "params": {"confirmed": true}})); assert_eq!(tip(&mut app), ERASER_TIP, "active"); + assert!(!pressure_size(&mut app), "Size control Off, active"); let mut app = self::app(); pick(&mut app, Tool::BackgroundEraser); scramble(&mut app); @@ -74,6 +75,7 @@ fn reset_all_tools_gives_it_the_tip_active_or_picked_later() { control(&mut app, "ui.menu.invoke", json!({"id": "tool.resetAll", "params": {"confirmed": true}})); pick(&mut app, Tool::BackgroundEraser); assert_eq!(tip(&mut app), ERASER_TIP, "picked after the reset"); + assert!(!pressure_size(&mut app), "Size control Off, picked after the reset"); } #[test] From 60d0895c270a9ca85111fbfa3501ab9cc5dcb708 Mon Sep 17 00:00:00 2001 From: SBaksa Date: Sun, 8 Nov 2026 14:32:37 +1100 Subject: [PATCH 171/219] Patch Tool history: refuse a bad name with Polygonal Lasso, give the step its icon historyName is validated before polygonal picks the step name, and the History panel shows the selection icon for a Patch Tool step. --- .../retouch_cmds/patch_history_names_tests.rs | 2 ++ crates/engine/src/cmd/selection_cmds.rs | 14 +++++----- crates/ui-egui/src/panels.rs | 2 ++ crates/ui-egui/src/panels/history.rs | 27 ++++++++++++------- .../src/patch_tool_history_names_tests.rs | 7 +++++ 5 files changed, 34 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/cmd/retouch_cmds/patch_history_names_tests.rs b/crates/engine/src/cmd/retouch_cmds/patch_history_names_tests.rs index fae53cc0e5..6172d5c376 100644 --- a/crates/engine/src/cmd/retouch_cmds/patch_history_names_tests.rs +++ b/crates/engine/src/cmd/retouch_cmds/patch_history_names_tests.rs @@ -53,6 +53,8 @@ fn a_hostile_history_name_is_refused_without_a_step() { let steps = s.active().unwrap().history.past_len(); for name in [json!(""), json!(" "), json!("x".repeat(65)), json!("a\nb"), json!(7), json!(["Patch Tool"])] { assert!(s.execute("select.lasso", json!({"points": TRIANGLE, "historyName": name})).is_err(), "{name}"); + let polygonal = json!({"points": TRIANGLE, "polygonal": true, "historyName": name}); + assert!(s.execute("select.lasso", polygonal).is_err(), "polygonal {name}"); } assert_eq!(s.active().unwrap().history.past_len(), steps); } diff --git a/crates/engine/src/cmd/selection_cmds.rs b/crates/engine/src/cmd/selection_cmds.rs index b74a9d7f9f..90a1c14d88 100644 --- a/crates/engine/src/cmd/selection_cmds.rs +++ b/crates/engine/src/cmd/selection_cmds.rs @@ -624,15 +624,13 @@ fn lasso(s: &mut Session, p: &Value) -> Result { // The History step is named after the tool: the Polygonal Lasso sends `polygonal` (Photoshop). // The Patch and Content-Aware Move tools draw with this command and send their own name as // `historyName`, so the plain Lasso tool keeps "Lasso". - let step = if b(p, "polygonal", false) { - "Polygonal Lasso" - } else { - match p.get("historyName") { - None | Some(Value::Null) => "Lasso", - Some(Value::String(n)) if !n.trim().is_empty() && n.chars().count() <= 64 && !n.chars().any(char::is_control) => n.trim(), - Some(_) => return Err(EngineError::BadParams { cmd: "select.lasso".into(), msg: "historyName must be a short text".into() }), - } + // A bad name is refused even when `polygonal` picks the step's name. + let named = match p.get("historyName") { + None | Some(Value::Null) => None, + Some(Value::String(n)) if !n.trim().is_empty() && n.chars().count() <= 64 && !n.chars().any(char::is_control) => Some(n.trim()), + Some(_) => return Err(EngineError::BadParams { cmd: "select.lasso".into(), msg: "historyName must be a short text".into() }), }; + let step = if b(p, "polygonal", false) { "Polygonal Lasso" } else { named.unwrap_or("Lasso") }; polygon_selection(s, "select.lasso", step, &pts, p) } diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 0d40e2280b..c4a8601769 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -80,6 +80,8 @@ use history::history; pub(crate) use history::select_snapshot; pub(crate) use history::selected_snapshot; #[cfg(test)] +pub(crate) use history::step_icon as history_step_icon; +#[cfg(test)] use info::info_panel; pub(crate) use info::{SamplerReadouts, sampler_readouts}; #[cfg(test)] diff --git a/crates/ui-egui/src/panels/history.rs b/crates/ui-egui/src/panels/history.rs index 38d3797014..1a366876f3 100644 --- a/crates/ui-egui/src/panels/history.rs +++ b/crates/ui-egui/src/panels/history.rs @@ -62,16 +62,7 @@ pub(super) fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } else if resp.hovered() { ui.painter().rect_filled(rect, 0.0, t.hover.gamma_multiply(0.5)); } - let icon = match e.as_str() { - s if s.contains("Brush") || s.contains("Eraser") => "brush", - // The Layers panel's controls (layer.setProps). - "Opacity Change" | "Fill Opacity Change" | "Blending Change" => "layers", - s if s.contains("Layer") => "layers", - s if s.contains("Select") || s.contains("Marquee") || s.contains("Deselect") || s.contains("Lasso") => "square-dashed", - s if s.contains("Fill") => "paint-bucket", - "Open" => "image", - _ => "sliders-horizontal", - }; + let icon = step_icon(&e); let color = if is_redo { t.text_faint } else { t.text }; icons::paint( ui, @@ -197,6 +188,22 @@ pub(super) fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { /// The History panel's source column at the left of a state or snapshot row: the History Brush /// icon on the source; a click makes the row the source (`history.setSource`). It sits above the /// row, so the click doesn't also step to the state or snapshot. +/// The icon of a History step, from its name. +pub(crate) fn step_icon(step: &str) -> &'static str { + match step { + s if s.contains("Brush") || s.contains("Eraser") => "brush", + // The Layers panel's controls (layer.setProps). + "Opacity Change" | "Fill Opacity Change" | "Blending Change" => "layers", + s if s.contains("Layer") => "layers", + s if s.contains("Select") || s.contains("Marquee") || s.contains("Deselect") || s.contains("Lasso") => "square-dashed", + // The selections the Patch tool draws (select.lasso's historyName). + "Patch Tool" => "square-dashed", + s if s.contains("Fill") => "paint-bucket", + "Open" => "image", + _ => "sliders-horizontal", + } +} + fn history_source_column(ui: &mut egui::Ui, row: Rect, salt: egui::Id, is_source: bool) -> bool { let t = Tokens::get(ui.ctx()); let cell = Rect::from_min_size(row.min, vec2(24.0, row.height())); diff --git a/crates/ui-egui/src/patch_tool_history_names_tests.rs b/crates/ui-egui/src/patch_tool_history_names_tests.rs index de9054fef1..ef8fec47b5 100644 --- a/crates/ui-egui/src/patch_tool_history_names_tests.rs +++ b/crates/ui-egui/src/patch_tool_history_names_tests.rs @@ -31,3 +31,10 @@ fn the_patch_tool_lasso_is_named_for_the_tool() { assert_eq!(drag_lasso(&mut app_with(Tool::Patch)).as_deref(), Some("Patch Tool")); assert_eq!(drag_lasso(&mut app_with(Tool::Lasso)).as_deref(), Some("Lasso")); } + +/// A Patch tool lasso keeps the selection icon in the History panel. +#[test] +fn the_patch_tool_step_has_the_selection_icon() { + assert_eq!(crate::panels::history_step_icon("Patch Tool"), crate::panels::history_step_icon("Lasso")); + assert_eq!(crate::panels::history_step_icon("Patch Tool"), "square-dashed"); +} From d17e1f114b1eb71e9dfe7b0fe4d2aafbf38d3991 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sat, 7 Nov 2026 22:39:14 -0500 Subject: [PATCH 172/219] Patch: Shift during the drag keeps the patch horizontal or vertical (#12129) Shift pressed after mouse-down snaps the Patch offset to the dominant axis (0/90 degrees only, no 45); the outline, the live preview and the applied patch use the snapped offset; Shift at mouse-down still adds to the selection. Four tests in patch_shift_constrain_tests.rs cover snapping, the three consumers, the add-at-press case and the no-45-degree case. Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at medium --- crates/ui-egui/src/canvas/gesture.rs | 4 +- crates/ui-egui/src/canvas/paint.rs | 2 +- crates/ui-egui/src/canvas/patch_preview.rs | 4 +- crates/ui-egui/src/lib.rs | 2 + .../src/patch_shift_constrain_tests.rs | 103 ++++++++++++++++++ crates/ui-egui/src/tools/retouch_ui.rs | 11 ++ 6 files changed, 122 insertions(+), 4 deletions(-) create mode 100644 crates/ui-egui/src/patch_shift_constrain_tests.rs diff --git a/crates/ui-egui/src/canvas/gesture.rs b/crates/ui-egui/src/canvas/gesture.rs index b67aec4beb..2ef84f4e0a 100644 --- a/crates/ui-egui/src/canvas/gesture.rs +++ b/crates/ui-egui/src/canvas/gesture.rs @@ -133,7 +133,9 @@ fn finish(app: &mut PhotocraftApp, mut d: Drag, click: Option) { json!({"x": x0, "y": y0, "width": x1 - x0, "height": y1 - y0, "mode": mode, "line": line, "antiAlias": false, "feather": feather}), ); } - Tool::Patch if crate::retouch_ui::patch_drags_selection(app, d.start, d.modifiers) => crate::retouch_ui::finish_patch(app, d.start, [end[0], end[1]]), + Tool::Patch if crate::retouch_ui::patch_drags_selection(app, d.start, d.modifiers) => { + crate::retouch_ui::finish_patch(app, d.start, crate::retouch_ui::patch_end(&d, [end[0], end[1]])) + } Tool::ContentAwareMove if crate::retouch_ui::patch_drags_selection(app, d.start, d.modifiers) => { crate::retouch_ui::finish_content_aware_move(app, d.start, [end[0], end[1]]) } diff --git a/crates/ui-egui/src/canvas/paint.rs b/crates/ui-egui/src/canvas/paint.rs index a091602313..7366dafdd3 100644 --- a/crates/ui-egui/src/canvas/paint.rs +++ b/crates/ui-egui/src/canvas/paint.rs @@ -660,7 +660,7 @@ pub(super) fn draw_drag_preview(app: &mut PhotocraftApp, painter: &egui::Painter // Patch / Content-Aware Move dragging the selection: its outline follows the pointer. Tool::Patch | Tool::ContentAwareMove if crate::retouch_ui::patch_drags_selection(app, d.start, d.modifiers) => { let start = d.start; - let [dx, dy] = crate::retouch_ui::patch_offset(app, start, last); + let [dx, dy] = crate::retouch_ui::patch_offset(app, start, crate::retouch_ui::patch_end(d, last)); if let Some((_, _, segs)) = &app.outline_cache { let moved: Vec = segs.iter().map(|(a, b)| ([a[0] + dx, a[1] + dy], [b[0] + dx, b[1] + dy])).collect(); marching_ants_segments(painter, xf, &moved, painter.ctx().input(|i| i.time)); diff --git a/crates/ui-egui/src/canvas/patch_preview.rs b/crates/ui-egui/src/canvas/patch_preview.rs index b669a60ed3..942acc4da4 100644 --- a/crates/ui-egui/src/canvas/patch_preview.rs +++ b/crates/ui-egui/src/canvas/patch_preview.rs @@ -30,14 +30,14 @@ pub(crate) struct PatchPreview { } /// The offset of the current Patch drag, if one is dragging the patch (not drawing a lasso). -fn drag_offset(app: &mut PhotocraftApp) -> Option<[i32; 2]> { +pub(crate) fn drag_offset(app: &mut PhotocraftApp) -> Option<[i32; 2]> { // Content-Aware patches are background jobs: the canvas shows the result when it is done. if app.ui.tool_options.patch_content_aware { return None; } let d = app.drag.as_ref().filter(|d| d.tool == Tool::Patch)?; let (start, mods) = (d.start, d.modifiers); - let end = d.points.last().map_or(start, |p| [p[0], p[1]]); + let end = crate::retouch_ui::patch_end(d, d.points.last().map_or(start, |p| [p[0], p[1]])); if !crate::retouch_ui::patch_drags_selection(app, start, mods) { return None; } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 47d6d7740f..e79028ee2d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -781,6 +781,8 @@ mod brush_options_tooltips_tests; #[cfg(test)] mod brush_picker_escape_tests; #[cfg(test)] +mod patch_shift_constrain_tests; +#[cfg(test)] mod shape_bounds_box_tests; #[cfg(test)] diff --git a/crates/ui-egui/src/patch_shift_constrain_tests.rs b/crates/ui-egui/src/patch_shift_constrain_tests.rs new file mode 100644 index 0000000000..590d4ed5b5 --- /dev/null +++ b/crates/ui-egui/src/patch_shift_constrain_tests.rs @@ -0,0 +1,103 @@ +//! #12129: ⇧ pressed during a Patch drag (after the press) keeps the drag horizontal or vertical. +//! Only 0 and 90 degrees snap; ⇧ at the press still adds to the selection. + +use egui::Modifiers; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::canvas::{ToolEvent, tool_event}; +use crate::state::Tool; + +/// A 500 x 400 document with 100..200 square selected, the Patch tool active. +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 500, "height": 400, "background": "white"})).unwrap(); + app.run("select.rect", json!({"x": 100, "y": 100, "width": 100, "height": 100, "mode": "replace"})).unwrap(); + app.ui.tool = Tool::Patch; + app.ui.tool_options.patch_content_aware = false; + app +} + +fn mv(app: &mut PhotocraftApp, x: f64, y: f64, m: Modifiers) { + tool_event(app, ToolEvent::Move { x, y, pressure: 1.0 }, m); +} + +/// Press at 140,140 without ⇧, move past the first 60 px, then to `to` with `m` held. +fn drag_to(app: &mut PhotocraftApp, to: [f64; 2], m: Modifiers) { + tool_event(app, ToolEvent::Down { x: 140.0, y: 140.0, pressure: 1.0 }, Modifiers::NONE); + mv(app, 200.0, 145.0, Modifiers::NONE); + mv(app, to[0], to[1], m); +} + +fn patch_offsets(app: &PhotocraftApp) -> Vec { + app.session.journal.iter().filter(|(id, _)| id == "paint.patch").map(|(_, p)| p["offset"].clone()).collect() +} + +fn outline_offset(app: &mut PhotocraftApp) -> [i32; 2] { + let (start, end) = { + let d = app.drag.as_ref().unwrap(); + (d.start, crate::retouch_ui::patch_end(d, d.points.last().map_or(d.start, |p| [p[0], p[1]]))) + }; + crate::retouch_ui::patch_offset(app, start, end) +} + +#[test] +fn shift_during_the_drag_snaps_the_offset_to_the_dominant_axis() { + let mut app = app(); + drag_to(&mut app, [380.0, 170.0], Modifiers::SHIFT); + assert_eq!(crate::patch_preview::drag_offset(&mut app), Some([240, 0])); + + let mut app = self::app(); + drag_to(&mut app, [380.0, 170.0], Modifiers::NONE); + assert_eq!(crate::patch_preview::drag_offset(&mut app), Some([240, 30]), "without ⇧ the offset is free"); + + // The dominant axis can be vertical too. + let mut app = self::app(); + drag_to(&mut app, [150.0, 300.0], Modifiers::SHIFT); + assert_eq!(crate::patch_preview::drag_offset(&mut app), Some([0, 160])); +} + +#[test] +fn the_outline_the_preview_and_the_applied_patch_use_the_snapped_offset() { + let mut app = app(); + drag_to(&mut app, [380.0, 170.0], Modifiers::SHIFT); + assert_eq!(outline_offset(&mut app), [240, 0], "outline"); + assert_eq!(crate::patch_preview::drag_offset(&mut app), Some([240, 0]), "preview"); + tool_event(&mut app, ToolEvent::Up { x: 380.0, y: 170.0 }, Modifiers::SHIFT); + assert!(!app.ui.status_error, "{}", app.ui.status); + assert_eq!(patch_offsets(&app), vec![json!([240, 0])], "applied patch"); + + let mut app = self::app(); + drag_to(&mut app, [380.0, 170.0], Modifiers::NONE); + assert_eq!(outline_offset(&mut app), [240, 30]); + tool_event(&mut app, ToolEvent::Up { x: 380.0, y: 170.0 }, Modifiers::NONE); + assert_eq!(patch_offsets(&app), vec![json!([240, 30])]); +} + +#[test] +fn shift_at_mouse_down_still_adds_to_the_selection() { + let mut app = app(); + let m = Modifiers::SHIFT; + tool_event(&mut app, ToolEvent::Down { x: 140.0, y: 140.0, pressure: 1.0 }, m); + for [x, y] in [[300.0, 140.0], [340.0, 140.0], [340.0, 180.0], [300.0, 180.0]] { + mv(&mut app, x, y, m); + } + tool_event(&mut app, ToolEvent::Up { x: 300.0, y: 180.0 }, m); + assert!(patch_offsets(&app).is_empty(), "no patch was applied"); + let sel = app.session.active().unwrap().doc.selection.clone().unwrap(); + assert!(sel.sample_channel(150, 150, 0) > 0.0, "the old selection is kept"); + assert!(sel.sample_channel(320, 160, 0) > 0.0, "the lasso is added"); +} + +#[test] +fn only_horizontal_and_vertical_snap_never_forty_five_degrees() { + // A perfect diagonal still goes to one axis. + let mut app = app(); + drag_to(&mut app, [240.0, 240.0], Modifiers::SHIFT); + let off = crate::patch_preview::drag_offset(&mut app).unwrap(); + assert!(off[0] == 0 || off[1] == 0, "{off:?} is not on an axis"); + // A 160 x 120 drag is not pulled to 45 degrees (140 x 140). + let mut app = self::app(); + drag_to(&mut app, [300.0, 260.0], Modifiers::SHIFT); + assert_eq!(crate::patch_preview::drag_offset(&mut app), Some([160, 0])); +} diff --git a/crates/ui-egui/src/tools/retouch_ui.rs b/crates/ui-egui/src/tools/retouch_ui.rs index 540cf5879a..884c775e29 100644 --- a/crates/ui-egui/src/tools/retouch_ui.rs +++ b/crates/ui-egui/src/tools/retouch_ui.rs @@ -337,6 +337,17 @@ pub fn cancel_stale(app: &mut PhotocraftApp) { } } +/// Where a Patch drag ends: with ⇧ pressed after the press (⇧ at the press adds to the selection +/// instead, so this never sees it) the end is held to the dominant axis from the start, 0 or 90 +/// degrees only (#12129). Other tools, and a drag without ⇧, end at `end`. +pub fn patch_end(d: &crate::canvas::Drag, end: [f64; 2]) -> [f64; 2] { + if d.tool != Tool::Patch || !d.live.shift { + return end; + } + let (dx, dy) = (end[0] - d.start[0], end[1] - d.start[1]); + if dx.abs() >= dy.abs() { [end[0], d.start[1]] } else { [d.start[0], end[1]] } +} + /// Whole-pixel offset for a drag from `start` to `end`, limited so the dragged outline stays on the /// canvas (the engine rejects a patch or move that leaves it). pub fn patch_offset(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2]) -> [i32; 2] { From 8ff95219abd666b56f57d8792cf5c99bfc6b9b5b Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 7 Nov 2026 21:41:13 -0600 Subject: [PATCH 173/219] Smoothing Options: greyed out at Smoothing 0 % (#11939) At Smoothing 0 % the gear's four Smoothing Options (Pulled String Mode, Stroke Catch-up, Catch-up on Stroke End, Adjust for Zoom) are drawn greyed and can't be toggled, keeping their values, as in the reference application; above 0 % they work again. For the Brush and the Eraser, the tools with this gear. --- crates/ui-egui/src/panels/brush_controls.rs | 17 +++-- .../src/smoothing_options_greyed_tests.rs | 67 +++++++++++++++++++ 2 files changed, 80 insertions(+), 4 deletions(-) create mode 100644 crates/ui-egui/src/smoothing_options_greyed_tests.rs diff --git a/crates/ui-egui/src/panels/brush_controls.rs b/crates/ui-egui/src/panels/brush_controls.rs index ddd45f38ba..a0f009eb90 100644 --- a/crates/ui-egui/src/panels/brush_controls.rs +++ b/crates/ui-egui/src/panels/brush_controls.rs @@ -57,10 +57,15 @@ pub(super) fn smoothing_options(ui: &mut egui::Ui, b: &mut photocraft_engine::Br if brush { ui.label(egui::RichText::new(tl!("Smoothing Options")).strong()); } - widgets::checkbox(ui, &mut s.pulled_string, tl!("Pulled String Mode")); - widgets::checkbox(ui, &mut s.catch_up, tl!("Stroke Catch-up")); - widgets::checkbox(ui, &mut s.catch_up_on_end, tl!("Catch-up on Stroke End")); - widgets::checkbox(ui, &mut s.adjust_for_zoom, tl!("Adjust for Zoom")); + // At 0 % there is no smoothing to tune: the four are greyed and keep their values, as in + // the reference application (#11939). + let on = s.amount > 0.0; + ui.add_enabled_ui(on, |ui| { + widgets::checkbox(ui, &mut s.pulled_string, tl!("Pulled String Mode")); + widgets::checkbox(ui, &mut s.catch_up, tl!("Stroke Catch-up")); + widgets::checkbox(ui, &mut s.catch_up_on_end, tl!("Catch-up on Stroke End")); + widgets::checkbox(ui, &mut s.adjust_for_zoom, tl!("Adjust for Zoom")); + }); }); } @@ -139,3 +144,7 @@ pub(super) fn brush_preset_chip(ui: &mut egui::Ui, b: &photocraft_engine::BrushS } } } + +#[cfg(test)] +#[path = "../smoothing_options_greyed_tests.rs"] +mod smoothing_options_greyed_tests; diff --git a/crates/ui-egui/src/smoothing_options_greyed_tests.rs b/crates/ui-egui/src/smoothing_options_greyed_tests.rs new file mode 100644 index 0000000000..e7e483e03a --- /dev/null +++ b/crates/ui-egui/src/smoothing_options_greyed_tests.rs @@ -0,0 +1,67 @@ +//! #11939: at Smoothing 0 % the gear's four Smoothing Options are greyed and can't be toggled, but +//! keep their values; above 0 % they work again. For the Brush and the Eraser, the tools with this +//! gear. + +use egui::accesskit::Role; +use egui::vec2; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::state::Tool; + +const OPTIONS: [&str; 4] = ["Pulled String Mode", "Stroke Catch-up", "Catch-up on Stroke End", "Adjust for Zoom"]; + +fn bar(tool: Tool, smoothing: f32) -> Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 80, "height": 80})).unwrap(); + app.ui.tool = tool; + crate::paint_mouse::sync_tool_brush(&mut app); + app.session.tools.brush.smoothing.amount = smoothing; + let mut h = Harness::builder().with_size(vec2(1800.0, 400.0)).build_ui_state(|ui, app| crate::panels::options_bar(app, ui), app); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(4); + h.get_by_label("Set additional smoothing options").click(); + h.run_steps(3); + h +} + +fn checkbox<'a>(h: &'a Harness<'static, PhotocraftApp>, label: &str) -> egui_kittest::Node<'a> { + h.query_all_by_role(Role::CheckBox).find(|n| n.accesskit_node().label().as_deref() == Some(label)).unwrap_or_else(|| panic!("{label}")) +} + +fn values(h: &Harness<'static, PhotocraftApp>) -> [bool; 4] { + let s = &h.state().session.tools.brush.smoothing; + [s.pulled_string, s.catch_up, s.catch_up_on_end, s.adjust_for_zoom] +} + +#[test] +fn at_zero_the_four_are_greyed_and_keep_their_values() { + for tool in [Tool::Brush, Tool::Eraser] { + let mut h = bar(tool, 0.0); + let before = values(&h); + for label in OPTIONS { + assert!(checkbox(&h, label).accesskit_node().is_disabled(), "{tool:?}: {label} greyed at 0 %"); + } + for label in OPTIONS { + checkbox(&h, label).click(); + h.run_steps(2); + } + assert_eq!(values(&h), before, "{tool:?}: clicks change nothing at 0 %"); + } +} + +#[test] +fn above_zero_they_work_again() { + for tool in [Tool::Brush, Tool::Eraser] { + let mut h = bar(tool, 0.1); + for label in OPTIONS { + assert!(!checkbox(&h, label).accesskit_node().is_disabled(), "{tool:?}: {label} enabled"); + } + let before = values(&h); + checkbox(&h, "Pulled String Mode").click(); + h.run_steps(2); + assert_ne!(values(&h)[0], before[0], "{tool:?}: Pulled String Mode toggles"); + } +} From 1cf1e15da5684388e43a17ecbca5fe179f248c20 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 7 Nov 2026 21:41:31 -0600 Subject: [PATCH 174/219] Keep Lab/16 Color Replacement on the RGB blend (CodeRabbit review on crates/paint/src/replace.rs:101) Co-Authored-By: Claude Opus 5.5 --- crates/paint/src/replace.rs | 7 ++++--- crates/paint/src/replace_lab_tests.rs | 27 +++++++++++++++++++++++++-- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/crates/paint/src/replace.rs b/crates/paint/src/replace.rs index 31bd4ab90b..c4157fac81 100644 --- a/crates/paint/src/replace.rs +++ b/crates/paint/src/replace.rs @@ -96,9 +96,10 @@ pub fn color_replacement_effect(pre: Surface, rs: ReplaceSettings) -> impl FnMut ReplaceMode::Luminosity => BlendMode::Luminosity, }; let fg = [rs.color[0], rs.color[1], rs.color[2]]; - // In a Lab document Color mode keeps the pixel's L and takes the foreground's a and b, as in - // the reference application (#11922): the stored a, b channels move straight to its. - let lab_color = fmt.mode == photocraft_color::ColorMode::Lab && rs.mode == ReplaceMode::Color; + // In a Lab/8 document Color mode keeps the pixel's L and takes the foreground's a and b, as in + // the reference application (#11922): the stored a, b channels move straight to its. Lab/16 + // wasn't measured and keeps the RGB blend. + let lab_color = fmt.mode == photocraft_color::ColorMode::Lab && fmt.sample == photocraft_color::SampleType::U8 && rs.mode == ReplaceMode::Color; let fg_ab = { let l = photocraft_color::convert::srgb_to_lab(fg); [(l[1] + 128.0) / 255.0, (l[2] + 128.0) / 255.0] diff --git a/crates/paint/src/replace_lab_tests.rs b/crates/paint/src/replace_lab_tests.rs index 3402ca6a41..caf024fb40 100644 --- a/crates/paint/src/replace_lab_tests.rs +++ b/crates/paint/src/replace_lab_tests.rs @@ -14,11 +14,15 @@ const BLUE: [f32; 4] = [0.0, 0.0, 1.0, 1.0]; /// A Lab/8 surface with the four colours in 100 px columns. fn target() -> Surface { - let mut s = Surface::new(LAB8); + target_in(LAB8) +} + +fn target_in(fmt: PixelFormat) -> Surface { + let mut s = Surface::new(fmt); for (i, c) in COLOURS.iter().enumerate() { let rgba = [f32::from(c[0]) / 255.0, f32::from(c[1]) / 255.0, f32::from(c[2]) / 255.0, 1.0]; let x0 = i as i32 * 100; - s.fill_rect(Rect::new(x0, 0, x0 + 100, 100), &photocraft_raster::from_rgba(&LAB8, rgba)); + s.fill_rect(Rect::new(x0, 0, x0 + 100, 100), &photocraft_raster::from_rgba(&fmt, rgba)); } s } @@ -75,3 +79,22 @@ fn hue_saturation_and_luminosity_keep_todays_blend() { } } } + +#[test] +fn lab_16_color_keeps_todays_blend() { + // Lab/16 wasn't measured: its Color mode still blends through RGB. + let lab16 = PixelFormat { sample: SampleType::U16, ..LAB8 }; + for i in 0..COLOURS.len() { + let mut s = target_in(lab16); + let x = i as i32 * 100 + 50; + let pre = s.pixel(x, 50).to_vec(); + click(&mut s, ReplaceMode::Color, f64::from(x) + 0.5); + let c = to_rgba(&lab16, &pre); + let b = blend_rgb(BlendMode::Color, [c[0], c[1], c[2]], [0.0, 0.0, 1.0]); + let want = photocraft_raster::from_rgba(&lab16, [b[0], b[1], b[2], c[3]]); + let got = s.pixel(x, 50); + for (g, w) in got.iter().zip(&want) { + assert!((g - w).abs() <= 1.0 / 65535.0 + 1e-6, "colour {i}: {got:?} vs {want:?}"); + } + } +} From 80a254050b6799bc66be900bbf6e3f644448ab5c Mon Sep 17 00:00:00 2001 From: Triwarr <170976175+Triwarr@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:11:31 +0100 Subject: [PATCH 175/219] PDF: editable import step 1 - paths as shape layers Add an experimental, opt-in PDF path importer on craft-pdf's interpret Device. Fills and strokes become ShapeLayers in document pixels with their real colours, widths and dash patterns; text, images, clipping paths, transparency groups, patterns, soft masks and annotations keep the raster page with a warning, so a page is never half imported. - ImportOptions::pdf_editable (off by default) and --pdf-editable. - pdf-editable Cargo feature in photocraft-io; the CLI enables it. - Shape caches are rendered within a 256 MiB page budget. - Cancellation is forwarded through a 5 ms watcher; hostile pages are caught and fall back instead of panicking. Tests cover fill/stroke colours and dashes, composite parity with the raster page, text fallback and cancellation. --- Cargo.lock | 1 + apps/photocraft-cli/Cargo.toml | 2 +- apps/photocraft-cli/src/args.rs | 3 + apps/photocraft-cli/src/document.rs | 1 + crates/io/Cargo.toml | 6 + crates/io/src/lib.rs | 7 + crates/io/src/pdf_editable.rs | 514 ++++++++++++++++++++++++++++ crates/io/src/pdf_import.rs | 22 +- 8 files changed, 554 insertions(+), 2 deletions(-) create mode 100644 crates/io/src/pdf_editable.rs diff --git a/Cargo.lock b/Cargo.lock index 434e4b6aa8..186be182eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5618,6 +5618,7 @@ dependencies = [ "crc32fast", "flate2", "imagesize", + "kurbo 0.13.1", "lopdf", "miniz_oxide 0.9.1", "photocraft-affinity", diff --git a/apps/photocraft-cli/Cargo.toml b/apps/photocraft-cli/Cargo.toml index 4ddde07142..7640483f44 100644 --- a/apps/photocraft-cli/Cargo.toml +++ b/apps/photocraft-cli/Cargo.toml @@ -33,7 +33,7 @@ required-features = ["gpu"] photocraft-automation = { path = "../../crates/automation" } photocraft-doc = { workspace = true } photocraft-engine = { workspace = true } -photocraft-io = { workspace = true } +photocraft-io = { workspace = true, features = ["pdf-editable"] } photocraft-raster = { workspace = true } photocraft-format = { path = "../../crates/format" } photocraft-codecs = { workspace = true } diff --git a/apps/photocraft-cli/src/args.rs b/apps/photocraft-cli/src/args.rs index a5acb4557b..c450c94442 100644 --- a/apps/photocraft-cli/src/args.rs +++ b/apps/photocraft-cli/src/args.rs @@ -125,6 +125,9 @@ pub(crate) struct ConvertArgs { /// PDF pages to import, 1-based: "3" or "2-7" (default all) #[arg(long)] pub(crate) pdf_pages: Option, + /// Import PDF vector paths as editable shape layers (experimental; text still rasterises) + #[arg(long)] + pub(crate) pdf_editable: bool, #[command(flatten)] pub(crate) gpu: GpuArgs, #[command(flatten)] diff --git a/apps/photocraft-cli/src/document.rs b/apps/photocraft-cli/src/document.rs index ab9e3cfd35..5541da11a6 100644 --- a/apps/photocraft-cli/src/document.rs +++ b/apps/photocraft-cli/src/document.rs @@ -46,6 +46,7 @@ pub(crate) fn convert(a: &ConvertArgs, _out: &mut dyn Write, err: &mut dyn Write psd_high_memory_approved: a.approve_high_memory, pdf_dpi: a.pdf_dpi.unwrap_or(300.0), pdf_pages: a.pdf_pages.as_deref().map(parse_pdf_pages).transpose()?, + pdf_editable: a.pdf_editable, ..Default::default() }; let mut o = files::open_with_options(input, &import_opts)?; diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index 36975e57eb..923d5da891 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -16,6 +16,9 @@ corpus = [] heif = ["photocraft-codecs/heif"] # Test support for synthetic memory injection, estimate charges and verification across crates. testgen = [] +# Editable PDF import (experimental): painted paths become shape layers. Text and the other +# unsupported constructs keep the raster page. +pdf-editable = ["dep:kurbo"] [dependencies] photocraft-geom = { workspace = true } @@ -53,6 +56,9 @@ resvg = { version = "0.45", default-features = false, features = ["text", "syste ttf-parser = { version = "0.25", default-features = false } # PDF import (pdf_import.rs): craft-pdf rasterizes pages in pure Rust; no shared library ships. craft-pdf = { workspace = true } +# Editable PDF import (pdf_editable.rs): craft-pdf's Device trait uses kurbo paths and transforms. +# This is the same kurbo 0.13 that craft-pdf already links; naming it adds no crate to the graph. +kurbo = { version = "0.13", optional = true } [target.'cfg(target_arch = "wasm32")'.dependencies] resvg = { version = "0.45", default-features = false, features = ["text", "raster-images"] } diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 195a58b01a..40dd1c0755 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -65,6 +65,8 @@ pub use ora::losses as ora_losses; pub mod pattern_map; mod pcraft_cache; pub mod pdf; +#[cfg(all(feature = "pdf-editable", not(target_arch = "wasm32")))] +mod pdf_editable; mod pdf_import; mod pdn; pub use pcraft_cache::PcraftExportCache; @@ -612,6 +614,10 @@ pub struct ImportOptions { pub pdf_dpi: f32, /// The 1-based, inclusive page range to import; `None` imports every page (up to the cap). pub pdf_pages: Option<(u32, u32)>, + /// Import PDF painted paths as editable [`photocraft_doc::ShapeLayer`]s instead of a page + /// raster (experimental). Text, images, clipping paths and the other unsupported constructs + /// make that page fall back to the raster importer with a warning. Off by default. + pub pdf_editable: bool, /// Explicit one-shot authorization for imports that exceed the soft budget but fit within the hard budget. pub psd_high_memory_approved: bool, /// Optional estimate limit for high-memory approval. When set, approval only applies if @@ -628,6 +634,7 @@ impl Default for ImportOptions { flat_max_pixel_bytes: 0, pdf_dpi: 300.0, pdf_pages: None, + pdf_editable: false, psd_high_memory_approved: false, psd_approved_estimate: None, } diff --git a/crates/io/src/pdf_editable.rs b/crates/io/src/pdf_editable.rs new file mode 100644 index 0000000000..1344bf0822 --- /dev/null +++ b/crates/io/src/pdf_editable.rs @@ -0,0 +1,514 @@ +//! Experimental editable PDF import: a `craft-pdf` device turns painted paths into shape layers. +//! +//! The first step is deliberately narrow: solid fills and strokes become [`ShapeLayer`]s in +//! document pixels. Text, images, clipping paths, transparency groups, patterns, soft masks and +//! annotations mark the page unsupported, and the caller falls back to the raster importer for +//! that page with a warning. Nothing is ever half imported. +//! +//! This is the app-owned `Device` route: Hayro's interpreter walks the page and reports each +//! paint with its real colour and geometry, so shape layers need no page raster underneath. That +//! is what makes moving or deleting a layer behave, and what avoids the black-path and +//! doubled-text failures of the old PDFium import. + +use std::panic::AssertUnwindSafe; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +use craft_pdf::interpret::color::Color as HayroColor; +use craft_pdf::interpret::font::Glyph; +use craft_pdf::interpret::{ + BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask, TransformExt, + interpret_page, +}; +use craft_pdf::syntax::page::Page; +use kurbo::{Affine as KurboAffine, BezPath, Cap, Join, PathEl}; +use photocraft_color::{Color, ColorMode, PixelFormat, SampleType}; +use photocraft_doc::vector::{FillRule, Knot, LineCap, LineJoin, Path, PathOp, ShapeLayer, ShapeStroke, StrokeAlign, Subpath}; +use photocraft_doc::{Fill, Layer, LayerContent}; +use photocraft_geom::{Affine, Point, Rect}; +use photocraft_raster::Interrupt; + +/// Most layers one editable page may create; past it the page falls back to raster. +const MAX_LAYERS: usize = 20_000; +/// Most path knots one editable page may hold; past it the page falls back to raster. +const MAX_KNOTS: usize = 1_000_000; +/// Pixel bytes of shape caches one editable page may retain before the rest render on demand. +const MAX_CACHE_BYTES: u64 = 256 << 20; +/// How often the watcher copies the interrupt into craft-pdf's cancellation flag. +const POLL: Duration = Duration::from_millis(5); + +/// Why an editable page could not be built. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EditableError { + /// The caller cancelled the import. + Cancelled, + /// The page uses something this first step cannot represent; rasterise it instead. + Unsupported(&'static str), +} + +/// Builds one page's shape layers in document coordinates inside `rect`. +/// +/// `rect` is the page's artboard in document pixels; the page's displayed size comes from +/// `Page::render_dimensions()` (CropBox, rotation and UserUnit already applied), so a point maps +/// to `rect` by the per-axis scale `rect.size / render_dimensions`. Nothing here re-applies the +/// CropBox offset or rotation. +pub fn page(page: &Page<'_>, rect: Rect, ctl: &Interrupt) -> Result, EditableError> { + ctl.check().map_err(|_| EditableError::Cancelled)?; + let (width_pt, height_pt) = page.render_dimensions(); + if !width_pt.is_finite() || !height_pt.is_finite() || width_pt <= 0.0 || height_pt <= 0.0 { + return Err(EditableError::Unsupported("the page has no area")); + } + let sx = f64::from(rect.width()) / f64::from(width_pt); + let sy = f64::from(rect.height()) / f64::from(height_pt); + let doc = Affine::translate(f64::from(rect.x0), f64::from(rect.y0)).mul(&Affine { m: [sx, 0.0, 0.0, sy, 0.0, 0.0] }); + let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::U8, true); + let cancelled = Arc::new(AtomicBool::new(false)); + let finished = Arc::new(AtomicBool::new(false)); + let settings = InterpreterSettings { cancelled: Some(Arc::clone(&cancelled)), ..InterpreterSettings::default() }; + let cache = InterpreterCache::new(); + let mut device = PathDevice { doc, clip: rect, fmt, ctl: *ctl, layers: Vec::new(), knots: 0, cached: 0, unsupported: None, cancelled: false }; + std::thread::scope(|scope| { + let flag = Arc::clone(&cancelled); + let done = Arc::clone(&finished); + let ctl = *ctl; + scope.spawn(move || { + while !done.load(Ordering::Relaxed) { + if ctl.cancelled() { + flag.store(true, Ordering::Relaxed); + } + std::thread::sleep(POLL); + } + }); + // A hostile page must not take the process down; the raster path already guards Hayro. + let result = std::panic::catch_unwind(AssertUnwindSafe(|| { + let bbox = kurbo::Rect::new(0.0, 0.0, f64::from(width_pt), f64::from(height_pt)); + let mut context = Context::new(page.initial_transform(true).to_kurbo(), bbox, &cache, page.xref(), settings); + interpret_page(page, &mut context, &mut device); + })); + if result.is_err() { + device.unsupported = Some("the page could not be interpreted"); + } + finished.store(true, Ordering::Relaxed); + }); + if device.cancelled || ctl.cancelled() { + return Err(EditableError::Cancelled); + } + if let Some(reason) = device.unsupported { + return Err(EditableError::Unsupported(reason)); + } + Ok(device.layers) +} + +/// Collects filled and stroked paths; anything else marks the page unsupported. +struct PathDevice<'a> { + /// Page points to document pixels: translation to the artboard plus the per-axis scale. + doc: Affine, + /// The page's artboard in document pixels; shape caches and strokes are clipped to it. + clip: Rect, + /// Document pixel format the shape caches are rendered in. + fmt: PixelFormat, + ctl: Interrupt<'a>, + layers: Vec, + knots: usize, + /// Bytes of shape caches retained so far. + cached: u64, + unsupported: Option<&'static str>, + cancelled: bool, +} + +impl PathDevice<'_> { + /// Stops collecting at the first thing this step cannot represent. + fn unsupported(&mut self, reason: &'static str) { + if self.unsupported.is_none() { + self.unsupported = Some(reason); + } + } + + /// Adds one shape layer unless a page cap is reached. Renders its pixel cache within the + /// page's cache budget so the layer is visible before the engine refreshes it. + fn push_shape(&mut self, path: Path, fill: Option, stroke: Option) { + if self.ctl.check().is_err() { + self.cancelled = true; + return; + } + if path.is_empty() { + return; + } + if self.layers.len() >= MAX_LAYERS { + self.unsupported("too many objects"); + return; + } + let reach = stroke.as_ref().map_or(0.0, |stroke| f64::from(stroke.width)); + let b = path_bounds(&path); + let area = Rect::new((b.0 - reach).floor() as i32, (b.1 - reach).floor() as i32, (b.2 + reach).ceil() as i32, (b.3 + reach).ceil() as i32) + .intersect(&self.clip); + let bytes = u64::from(area.width()).saturating_mul(u64::from(area.height())).saturating_mul(self.fmt.bytes_per_pixel() as u64); + let mut shape = ShapeLayer { path, fill, stroke, ..ShapeLayer::default() }; + if area.width() > 0 && area.height() > 0 && self.cached.saturating_add(bytes) <= MAX_CACHE_BYTES { + shape.cache = Some(photocraft_vector::render_shape(&shape, self.fmt, self.clip)); + self.cached = self.cached.saturating_add(bytes); + } + self.layers.push(Layer::new(format!("Path {}", self.layers.len() + 1), LayerContent::Shape(shape))); + } +} + +impl<'a> Device<'a> for PathDevice<'_> { + fn set_soft_mask(&mut self, mask: Option>) { + if mask.is_some() { + self.unsupported("soft masks"); + } + } + + fn set_blend_mode(&mut self, blend_mode: BlendMode) { + if blend_mode != BlendMode::Normal { + self.unsupported("blend modes"); + } + } + + fn draw_path(&mut self, path: &BezPath, transform: KurboAffine, paint: &Paint<'a>, draw_mode: &PathDrawMode) { + if self.unsupported.is_some() { + return; + } + if self.ctl.check().is_err() { + self.cancelled = true; + return; + } + let color = match paint { + Paint::Color(color) => to_color(color), + Paint::Pattern(_) => { + self.unsupported("patterns"); + return; + } + }; + let full = self.doc.mul(&knots_affine(&transform)); + let Some(path) = to_path(path, &full, &mut self.knots) else { + self.unsupported("too many path points"); + return; + }; + match draw_mode { + PathDrawMode::Fill(rule) => { + let mut path = path; + path.fill_rule = match rule { + craft_pdf::interpret::FillRule::NonZero => FillRule::NonZero, + craft_pdf::interpret::FillRule::EvenOdd => FillRule::EvenOdd, + }; + self.push_shape(path, Some(Fill::Solid(color)), None); + } + PathDrawMode::Stroke(stroke) => { + let Some(stroke) = to_stroke(stroke, color, full.determinant().abs().sqrt()) else { + self.unsupported("stroke properties"); + return; + }; + self.push_shape(path, None, Some(stroke)); + } + } + } + + fn push_clip_path(&mut self, _clip_path: &ClipPath) { + self.unsupported("clipping paths"); + } + + fn push_transparency_group(&mut self, _opacity: f32, mask: Option>, blend_mode: BlendMode) { + // A group needs its own blend/opacity/mask handling; until that exists the page is + // rasterised rather than flattened into the wrong stacking order. + let _ = (mask, blend_mode); + self.unsupported("transparency groups"); + } + + fn draw_glyph(&mut self, _glyph: &Glyph<'a>, _transform: KurboAffine, _glyph_transform: KurboAffine, _paint: &Paint<'a>, _draw_mode: &GlyphDrawMode) { + self.unsupported("text is not editable yet"); + } + + fn draw_image(&mut self, _image: Image<'a, '_>, _transform: KurboAffine) { + self.unsupported("images"); + } + + fn pop_clip_path(&mut self) {} + + fn pop_transparency_group(&mut self) {} + + fn begin_annotations(&mut self) { + self.unsupported("annotations"); + } +} + +/// A PDF colour as a straight-alpha PhotoCraft colour. +fn to_color(color: &HayroColor) -> Color { + let rgba = color.to_rgba(); + let [r, g, b, a] = rgba.components(); + let a = a.clamp(0.0, 1.0); + if a <= 0.0 { + return Color::rgba(0.0, 0.0, 0.0, 0.0); + } + // `AlphaColor` components are premultiplied; PhotoCraft colours are straight. + Color::rgba((r / a).clamp(0.0, 1.0), (g / a).clamp(0.0, 1.0), (b / a).clamp(0.0, 1.0), a) +} + +/// A kurbo affine as PhotoCraft's affine (same `[a, b, c, d, e, f]` layout). +fn knots_affine(transform: &KurboAffine) -> Affine { + let [a, b, c, d, e, f] = transform.as_coeffs(); + Affine { m: [a, b, c, d, e, f] } +} + +/// The anchor bounds of a path, used only to estimate a shape cache's size. +fn path_bounds(path: &Path) -> (f64, f64, f64, f64) { + let mut b = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); + for knot in path.subpaths.iter().flat_map(|subpath| &subpath.knots) { + b.0 = b.0.min(knot.anchor.x); + b.1 = b.1.min(knot.anchor.y); + b.2 = b.2.max(knot.anchor.x); + b.3 = b.3.max(knot.anchor.y); + } + b +} + +/// A Hayro stroke as PhotoCraft's stroke: width in pixels, dashes in multiples of the width. +fn to_stroke(stroke: &craft_pdf::interpret::StrokeProps, color: Color, scale: f64) -> Option { + if !stroke.line_width.is_finite() || stroke.line_width <= 0.0 || !scale.is_finite() || scale <= 0.0 { + return None; + } + let width = stroke.line_width * scale as f32; + if !width.is_finite() || width <= 0.0 { + return None; + } + let dashes = stroke.dash_array.iter().map(|dash| dash / stroke.line_width).collect(); + Some(ShapeStroke { + width, + paint: Fill::Solid(color), + opacity: 1.0, + align: StrokeAlign::Center, + cap: match stroke.line_cap { + Cap::Butt => LineCap::Butt, + Cap::Round => LineCap::Round, + Cap::Square => LineCap::Square, + }, + join: match stroke.line_join { + Join::Miter => LineJoin::Miter, + Join::Round => LineJoin::Round, + Join::Bevel => LineJoin::Bevel, + }, + miter_limit: stroke.miter_limit, + dashes, + dash_offset: stroke.dash_offset / stroke.line_width, + }) +} + +/// Converts a kurbo path to PhotoCraft's cubic knot model. `None` once `knots` passes the cap. +fn to_path(bez: &BezPath, transform: &Affine, knots: &mut usize) -> Option { + let mut subpaths = Vec::new(); + let mut current: Option = None; + let mut last = Point::new(0.0, 0.0); + for element in bez.elements() { + match *element { + PathEl::MoveTo(p) => { + if let Some(subpath) = current.take() { + subpaths.push(subpath); + } + current = Some(Subpath { closed: false, knots: vec![Knot::corner(p.x, p.y)], op: PathOp::Combine }); + last = Point::new(p.x, p.y); + } + PathEl::LineTo(p) => { + if let Some(subpath) = current.as_mut() { + if let Some(knot) = subpath.knots.last_mut() { + knot.out_ctrl = knot.anchor; + } + subpath.knots.push(Knot::corner(p.x, p.y)); + } + last = Point::new(p.x, p.y); + } + PathEl::QuadTo(control, p) => { + if let Some(subpath) = current.as_mut() { + let start = subpath.knots.last().map_or(last, |knot| knot.anchor); + if let Some(knot) = subpath.knots.last_mut() { + knot.out_ctrl = Point::new(start.x + 2.0 / 3.0 * (control.x - start.x), start.y + 2.0 / 3.0 * (control.y - start.y)); + } + subpath.knots.push(Knot { + anchor: Point::new(p.x, p.y), + in_ctrl: Point::new(p.x + 2.0 / 3.0 * (control.x - p.x), p.y + 2.0 / 3.0 * (control.y - p.y)), + out_ctrl: Point::new(p.x, p.y), + smooth: false, + }); + } + last = Point::new(p.x, p.y); + } + PathEl::CurveTo(c1, c2, p) => { + if let Some(subpath) = current.as_mut() { + if let Some(knot) = subpath.knots.last_mut() { + knot.out_ctrl = Point::new(c1.x, c1.y); + } + subpath.knots.push(Knot { anchor: Point::new(p.x, p.y), in_ctrl: Point::new(c2.x, c2.y), out_ctrl: Point::new(p.x, p.y), smooth: false }); + } + last = Point::new(p.x, p.y); + } + PathEl::ClosePath => { + if let Some(mut subpath) = current.take() { + subpath.closed = true; + if subpath.knots.len() > 1 { + let first = subpath.knots[0].anchor; + let last_anchor = subpath.knots.last().map_or(first, |knot| knot.anchor); + if (first.x - last_anchor.x).abs() < 1e-9 && (first.y - last_anchor.y).abs() < 1e-9 { + if let Some(knot) = subpath.knots.pop() { + subpath.knots[0].in_ctrl = knot.in_ctrl; + } + } else { + subpath.knots[0].in_ctrl = first; + } + } + subpaths.push(subpath); + } + } + } + } + if let Some(subpath) = current { + subpaths.push(subpath); + } + *knots = knots.saturating_add(subpaths.iter().map(|subpath| subpath.knots.len()).sum::()); + if *knots > MAX_KNOTS { + return None; + } + for subpath in &mut subpaths { + for knot in &mut subpath.knots { + *knot = knot.transform(transform); + } + } + Some(Path::new(subpaths)) +} + +#[cfg(test)] +mod tests { + use lopdf::{Document as PdfDocument, Object, Stream, dictionary}; + + use super::*; + use crate::{ImportOptions, IoError, import_with_options_and_interrupt}; + + /// A one-page 200 x 100 pt PDF whose content stream is `content`. + fn pdf(content: &str) -> Vec { + let mut pdf = PdfDocument::with_version("1.5"); + let pages_id = pdf.new_object_id(); + let content_id = pdf.add_object(Stream::new(dictionary! {}, content.as_bytes().to_vec())); + let page_id = pdf.add_object(dictionary! { + "Type" => "Page", + "Parent" => pages_id, + "MediaBox" => Object::Array(vec![Object::Real(0.0), Object::Real(0.0), Object::Real(200.0), Object::Real(100.0)]), + "Contents" => content_id, + "Resources" => dictionary! {}, + }); + pdf.objects.insert( + pages_id, + Object::Dictionary(dictionary! { + "Type" => "Pages", + "Kids" => Object::Array(vec![Object::Reference(page_id)]), + "Count" => 1, + }), + ); + let catalog = pdf.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id }); + pdf.trailer.set("Root", catalog); + let mut bytes = Vec::new(); + pdf.save_to(&mut bytes).expect("writing the fixture"); + bytes + } + + /// The artboard group's children. + fn children(document: &photocraft_doc::Document) -> &[Layer] { + match &document.layers.first().expect("a page group").content { + LayerContent::Group(group) => &group.children, + _ => panic!("the page is not a group"), + } + } + + /// A solid fill or stroke colour. + fn solid(fill: &Fill) -> Color { + match fill { + Fill::Solid(color) => *color, + _ => panic!("not a solid paint"), + } + } + + /// Path bounds in document pixels. + fn bounds(path: &Path) -> (f64, f64, f64, f64) { + let mut b = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); + for knot in path.subpaths.iter().flat_map(|subpath| &subpath.knots) { + b.0 = b.0.min(knot.anchor.x); + b.1 = b.1.min(knot.anchor.y); + b.2 = b.2.max(knot.anchor.x); + b.3 = b.3.max(knot.anchor.y); + } + b + } + + #[test] + fn painted_paths_become_shape_layers_with_their_colours_and_dashes() { + let bytes = pdf("1 0 0 rg 20 20 60 40 re f 0 0 1 RG 3 w [9 4] 0 d 100 30 m 180 30 l S"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("paths.pdf", &bytes, &options, &Interrupt::NONE).expect("an editable import"); + let children = children(&imported.document); + assert_eq!(children.len(), 2, "one fill and one stroke layer"); + let LayerContent::Shape(fill) = &children[0].content else { panic!("the first child is not a shape") }; + assert_eq!(solid(fill.fill.as_ref().expect("a fill")), Color::rgb(1.0, 0.0, 0.0)); + assert!(fill.stroke.is_none()); + let (x0, y0, x1, y1) = bounds(&fill.path); + assert!((x0 - 20.0).abs() < 0.01 && (x1 - 80.0).abs() < 0.01, "x bounds {x0}..{x1}"); + assert!((y0 - 40.0).abs() < 0.01 && (y1 - 80.0).abs() < 0.01, "PDF y up becomes document y down: {y0}..{y1}"); + + let LayerContent::Shape(stroke) = &children[1].content else { panic!("the second child is not a shape") }; + assert!(stroke.fill.is_none()); + let stroke = stroke.stroke.as_ref().expect("a stroke"); + assert_eq!(solid(&stroke.paint), Color::rgb(0.0, 0.0, 1.0)); + assert!((stroke.width - 3.0).abs() < 0.01, "stroke width {}", stroke.width); + assert_eq!(stroke.dashes.len(), 2); + assert!((stroke.dashes[0] - 3.0).abs() < 0.01 && (stroke.dashes[1] - 4.0 / 3.0).abs() < 0.01, "dashes {:?}", stroke.dashes); + } + + #[test] + fn the_editable_composite_matches_the_raster_page() { + let bytes = pdf("1 0 0 rg 20 20 60 40 re f 0 0 1 RG 3 w [9 4] 0 d 100 30 m 180 30 l S"); + let raster = import_with_options_and_interrupt("paths.pdf", &bytes, &ImportOptions { pdf_dpi: 72.0, ..ImportOptions::default() }, &Interrupt::NONE) + .expect("a raster import"); + let editable = import_with_options_and_interrupt( + "paths.pdf", + &bytes, + &ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }, + &Interrupt::NONE, + ) + .expect("an editable import"); + let fmt = editable.document.pixel_format(); + let a = photocraft_compose::flatten_to_surface(&editable.document, fmt, None); + let b = photocraft_compose::flatten_to_surface(&raster.document, fmt, None); + let rect = editable.document.bounds(); + let av = a.read_region(rect); + let bv = b.read_region(rect); + assert_eq!(av.len(), bv.len()); + let mut sum = 0.0f64; + let mut max = 0.0f32; + for (x, y) in av.iter().zip(&bv) { + let d = (x - y).abs(); + sum += f64::from(d); + max = max.max(d); + } + let mean = sum / av.len() as f64; + assert!(mean < 0.01, "mean pixel difference {mean:.4} (max {max:.3})"); + } + + #[test] + fn text_falls_back_to_the_raster_page_with_a_warning() { + let bytes = pdf("BT /F1 24 Tf 10 50 Td (Hi) Tj ET"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("text.pdf", &bytes, &options, &Interrupt::NONE).expect("a fallback import"); + assert!( + imported.warnings.iter().any(|warning| warning.contains("text is not editable yet") && warning.contains("rasterised")), + "{:?}", + imported.warnings + ); + assert!(matches!(children(&imported.document).first().map(|layer| &layer.content), Some(LayerContent::Raster(_)))); + } + + #[test] + fn a_cancelled_editable_import_returns_cancelled() { + let bytes = pdf("1 0 0 rg 20 20 60 40 re f"); + let cancel = || true; + let ctl = Interrupt::cancel_only(&cancel); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + assert!(matches!(import_with_options_and_interrupt("paths.pdf", &bytes, &options, &ctl), Err(IoError::Cancelled))); + } +} diff --git a/crates/io/src/pdf_import.rs b/crates/io/src/pdf_import.rs index d55676ac16..3907751046 100644 --- a/crates/io/src/pdf_import.rs +++ b/crates/io/src/pdf_import.rs @@ -147,6 +147,27 @@ pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) - if width > raster::MAX_SIDE || height > raster::MAX_SIDE || u64::from(width) * u64::from(height) > raster::MAX_PIXELS { return Err(IoError::Unsupported(format!("PDF page {page_number} is too large at {dpi:.0} dpi; lower the import DPI"))); } + let rect = Rect::new(x as i32, 0, (x + width) as i32, height as i32); + #[cfg(feature = "pdf-editable")] + if opts.pdf_editable { + match crate::pdf_editable::page(page, rect, ctl) { + Ok(layers) => { + let group = Group { children: layers, expanded: true, artboard: Some(Artboard::new(rect)), frame_stroke: None }; + doc.layers.push(Layer::new(format!("Page {page_number}"), LayerContent::Group(group))); + x = x.saturating_add(width).saturating_add(GAP); + ctl.progress((index + 1) as f32 / count as f32); + continue; + } + Err(crate::pdf_editable::EditableError::Cancelled) => return Err(IoError::Cancelled), + Err(crate::pdf_editable::EditableError::Unsupported(reason)) => { + warnings.push(format!("PDF page {page_number}: {reason}; the page was rasterised")); + } + } + } + #[cfg(not(feature = "pdf-editable"))] + if opts.pdf_editable { + warnings.push(format!("PDF page {page_number}: editable PDF import is not built into this binary; the page was rasterised")); + } // A cache per page, deliberately: a shared one would keep decoded images and fonts alive // across up to 16 pages outside the pixel budget, and would hide an image-decode warning // on a later page after the page that first hit it. @@ -195,7 +216,6 @@ pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) - let band_height = (rows.len() / row_bytes) as u32; surface.write_region(Rect::from_xywh(x as i32, top, width, band_height), &data); } - let rect = Rect::new(x as i32, 0, (x + width) as i32, height as i32); let layer = Layer::new(format!("Page {page_number}"), LayerContent::Raster(surface)); let group = Group { children: vec![layer], expanded: true, artboard: Some(Artboard::new(rect)), frame_stroke: None }; doc.layers.push(Layer::new(format!("Page {page_number}"), LayerContent::Group(group))); From a18268d0d3da9773e8b80bfc949341abd2e3befc Mon Sep 17 00:00:00 2001 From: Triwarr <170976175+Triwarr@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:40:23 +0100 Subject: [PATCH 176/219] PDF: editable text pass and review fixes - Text: build one TextLayer per line/style run from the Device's glyph draws. Derive the family from the PostScript name (subset tag, style suffix, MT/PS, camel spacing); italic from the font flags/attributes or name; bold from the weight or name; pair fill+stroke glyphs as fake bold (Tr 2/6) instead of a second run. - Require an embedded font with a PostScript name; standard-14/Type 1 substitutes and Type 3 text fall back to the raster page. - Register embedded font bytes in the text engine, use the family the font reports, and render text caches on import. Tests compare the text composite with the raster page. - Review fixes: accept a page-sized no-op clip; draw zero-width strokes as 1 px hairlines and use 1 px as the dash base; reject non-uniform stroke transforms; repeat odd dash arrays and keep zero dashes; ExtGState ca/CA already reach the colour alpha (tested); note that catch_unwind only guards unwinding profiles. --- crates/io/src/pdf_editable.rs | 731 +++++++++++++++++++++++++++++++++- crates/io/src/pdf_import.rs | 5 +- crates/text/src/lib.rs | 11 + 3 files changed, 729 insertions(+), 18 deletions(-) diff --git a/crates/io/src/pdf_editable.rs b/crates/io/src/pdf_editable.rs index 1344bf0822..8d1beab04d 100644 --- a/crates/io/src/pdf_editable.rs +++ b/crates/io/src/pdf_editable.rs @@ -16,7 +16,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; use craft_pdf::interpret::color::Color as HayroColor; -use craft_pdf::interpret::font::Glyph; +use craft_pdf::interpret::font::{FontData, Glyph, OutlineFontData}; use craft_pdf::interpret::{ BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask, TransformExt, interpret_page, @@ -24,8 +24,9 @@ use craft_pdf::interpret::{ use craft_pdf::syntax::page::Page; use kurbo::{Affine as KurboAffine, BezPath, Cap, Join, PathEl}; use photocraft_color::{Color, ColorMode, PixelFormat, SampleType}; +use photocraft_doc::text::{CharStyle, TextRun}; use photocraft_doc::vector::{FillRule, Knot, LineCap, LineJoin, Path, PathOp, ShapeLayer, ShapeStroke, StrokeAlign, Subpath}; -use photocraft_doc::{Fill, Layer, LayerContent}; +use photocraft_doc::{Fill, Layer, LayerContent, TextLayer}; use photocraft_geom::{Affine, Point, Rect}; use photocraft_raster::Interrupt; @@ -37,6 +38,10 @@ const MAX_KNOTS: usize = 1_000_000; const MAX_CACHE_BYTES: u64 = 256 << 20; /// How often the watcher copies the interrupt into craft-pdf's cancellation flag. const POLL: Duration = Duration::from_millis(5); +/// Largest gap (in em) inside one run before a space is inserted. +const WORD_GAP: f64 = 0.2; +/// Largest baseline offset (in em) for glyphs to share one line. +const LINE_TOLERANCE: f64 = 0.35; /// Why an editable page could not be built. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -53,7 +58,7 @@ pub enum EditableError { /// `Page::render_dimensions()` (CropBox, rotation and UserUnit already applied), so a point maps /// to `rect` by the per-axis scale `rect.size / render_dimensions`. Nothing here re-applies the /// CropBox offset or rotation. -pub fn page(page: &Page<'_>, rect: Rect, ctl: &Interrupt) -> Result, EditableError> { +pub fn page(page: &Page<'_>, rect: Rect, ctl: &Interrupt, text: &mut photocraft_text::TextEngine) -> Result, EditableError> { ctl.check().map_err(|_| EditableError::Cancelled)?; let (width_pt, height_pt) = page.render_dimensions(); if !width_pt.is_finite() || !height_pt.is_finite() || width_pt <= 0.0 || height_pt <= 0.0 { @@ -67,7 +72,22 @@ pub fn page(page: &Page<'_>, rect: Rect, ctl: &Interrupt) -> Result, let finished = Arc::new(AtomicBool::new(false)); let settings = InterpreterSettings { cancelled: Some(Arc::clone(&cancelled)), ..InterpreterSettings::default() }; let cache = InterpreterCache::new(); - let mut device = PathDevice { doc, clip: rect, fmt, ctl: *ctl, layers: Vec::new(), knots: 0, cached: 0, unsupported: None, cancelled: false }; + let mut device = PathDevice { + doc, + clip: rect, + fmt, + dpi: 72.0 * sx, + ctl: *ctl, + layers: Vec::new(), + glyphs: Vec::new(), + strokes: Vec::new(), + fonts: Vec::new(), + clips: Vec::new(), + knots: 0, + cached: 0, + unsupported: None, + cancelled: false, + }; std::thread::scope(|scope| { let flag = Arc::clone(&cancelled); let done = Arc::clone(&finished); @@ -80,7 +100,8 @@ pub fn page(page: &Page<'_>, rect: Rect, ctl: &Interrupt) -> Result, std::thread::sleep(POLL); } }); - // A hostile page must not take the process down; the raster path already guards Hayro. + // A hostile page must not take the process down. This only guards profiles that unwind; + // a `panic = "abort"` build aborts here just as the raster path would. let result = std::panic::catch_unwind(AssertUnwindSafe(|| { let bbox = kurbo::Rect::new(0.0, 0.0, f64::from(width_pt), f64::from(height_pt)); let mut context = Context::new(page.initial_transform(true).to_kurbo(), bbox, &cache, page.xref(), settings); @@ -97,10 +118,157 @@ pub fn page(page: &Page<'_>, rect: Rect, ctl: &Interrupt) -> Result, if let Some(reason) = device.unsupported { return Err(EditableError::Unsupported(reason)); } - Ok(device.layers) + if let Err(reason) = pair_fake_bold(&mut device.glyphs, device.strokes) { + return Err(EditableError::Unsupported(reason)); + } + let mut layers = device.layers; + layers.extend(text_layers(&device.glyphs)); + render_text_caches(&mut layers, device.dpi, device.fmt, &device.fonts, text); + Ok(layers) } -/// Collects filled and stroked paths; anything else marks the page unsupported. +/// Removes stroked glyphs that duplicate a fill at the same place, marking that glyph fake bold. +fn pair_fake_bold(glyphs: &mut [GlyphRun], strokes: Vec) -> Result<(), &'static str> { + for stroke in strokes { + let mut matched = false; + for glyph in glyphs.iter_mut() { + let tolerance = 0.01 * glyph.size_px + 0.01; + if glyph.text == stroke.text + && glyph.font.key == stroke.font.key + && (glyph.origin.x - stroke.origin.x).abs() <= tolerance + && (glyph.origin.y - stroke.origin.y).abs() <= tolerance + { + glyph.fake_bold = true; + matched = true; + break; + } + } + if !matched { + return Err("stroked text"); + } + } + Ok(()) +} + +/// Builds one point-text layer per line and style run. +fn text_layers(glyphs: &[GlyphRun]) -> Vec { + let mut out = Vec::new(); + let mut i = 0; + while i < glyphs.len() { + let first = &glyphs[i]; + let (dx, dy) = (first.angle.cos(), first.angle.sin()); + let (nx, ny) = (-dy, dx); + let baseline = first.origin.x * nx + first.origin.y * ny; + let start = i; + let mut text = String::new(); + let mut last = first; + while i < glyphs.len() { + let glyph = &glyphs[i]; + if !same_style(glyph, first) { + break; + } + let line = glyph.origin.x * nx + glyph.origin.y * ny; + if (line - baseline).abs() > LINE_TOLERANCE * first.size_px { + break; + } + if i > start { + let along = (glyph.origin.x - first.origin.x) * dx + (glyph.origin.y - first.origin.y) * dy; + let prev = (last.origin.x - first.origin.x) * dx + (last.origin.y - first.origin.y) * dy; + let gap = along - (prev + last.advance); + if gap > 3.0 * first.size_px { + break; + } + if gap > WORD_GAP * first.size_px && !text.ends_with(' ') { + text.push(' '); + } + } + text.push_str(&glyph.text); + last = glyph; + i += 1; + } + let text = text.trim().to_string(); + if !text.is_empty() { + out.push(text_layer(text, first, out.len())); + } + if i == start { + i += 1; + } + } + out +} + +/// Whether `b` belongs to the same line and style run as `a`. +fn same_style(a: &GlyphRun, b: &GlyphRun) -> bool { + let weight = |glyph: &GlyphRun| if glyph.fake_bold { 700 } else { glyph.font.weight }; + a.font.key == b.font.key + && a.font.family == b.font.family + && a.font.italic == b.font.italic + && weight(a) == weight(b) + && (a.size_pt - b.size_pt).abs() < 0.05 + && (a.angle - b.angle).abs() < 0.02 + && (a.color.c[0] - b.color.c[0]).abs() < 0.004 + && (a.color.c[1] - b.color.c[1]).abs() < 0.004 + && (a.color.c[2] - b.color.c[2]).abs() < 0.004 + && (a.color.alpha - b.color.alpha).abs() < 0.004 +} + +/// One line's point-text layer. +fn text_layer(text: String, run: &GlyphRun, number: usize) -> Layer { + let weight = if run.fake_bold { 700 } else { run.font.weight }; + let font_style = match (weight >= 600, run.font.italic) { + (true, true) => "Bold Italic", + (true, false) => "Bold", + (false, true) => "Italic", + (false, false) => "Regular", + }; + let style = CharStyle { + font_family: run.font.family.clone(), + font_style: font_style.to_string(), + postscript_name: Some(run.font.postscript.clone()), + weight: u16::try_from(weight).unwrap_or(400), + italic: run.font.italic, + size_pt: run.size_pt as f32, + color: run.color, + ..CharStyle::default() + }; + let layer = TextLayer { + text: text.clone(), + font_family: run.font.family.clone(), + size_pt: run.size_pt as f32, + color: run.color, + transform: Affine::translate(run.origin.x, run.origin.y).mul(&Affine::rotate(run.angle)), + runs: vec![TextRun { len: text.len(), style }], + ..TextLayer::default() + }; + Layer::new(format!("Text {}", number + 1), LayerContent::Text(layer)) +} + +/// Registers each embedded font with the text engine, renames the runs to the family the font +/// itself reports, and renders the caches so the page is complete before the engine refreshes it. +fn render_text_caches(layers: &mut [Layer], dpi: f64, fmt: PixelFormat, fonts: &[(u128, String, FontData)], engine: &mut photocraft_text::TextEngine) { + let mut families = std::collections::HashMap::new(); + for (_, postscript, data) in fonts { + let names = engine.register_font_data(data.as_ref().as_ref().to_vec()); + if let Some(name) = names.first() { + families.insert(postscript.clone(), name.clone()); + } + } + for layer in layers { + if let LayerContent::Text(text) = &mut layer.content { + if let Some(family) = text.runs.first().and_then(|run| run.style.postscript_name.as_ref()).and_then(|postscript| families.get(postscript)) { + text.font_family.clone_from(family); + if let Some(run) = text.runs.first_mut() { + run.style.font_family.clone_from(family); + } + } + if !text.text.trim().is_empty() { + let _ = engine.render_layer(text, dpi as f32, fmt); + } + } + } +} + +/// Collects filled and stroked paths and glyph runs; anything else marks the page unsupported. struct PathDevice<'a> { /// Page points to document pixels: translation to the artboard plus the per-axis scale. doc: Affine, @@ -108,8 +276,18 @@ struct PathDevice<'a> { clip: Rect, /// Document pixel format the shape caches are rendered in. fmt: PixelFormat, + /// Document DPI, for text-layer caches. + dpi: f64, ctl: Interrupt<'a>, layers: Vec, + /// Filled glyphs in content order. + glyphs: Vec, + /// Stroked glyphs, paired with a fill at the same place to recognise fake bold. + strokes: Vec, + /// Embedded fonts met on the page: cache key, PostScript name and bytes. + fonts: Vec<(u128, String, FontData)>, + /// One entry per clip pushed by the interpreter: `true` for an accepted no-op clip. + clips: Vec, knots: usize, /// Bytes of shape caches retained so far. cached: u64, @@ -117,6 +295,35 @@ struct PathDevice<'a> { cancelled: bool, } +/// One drawn glyph with the style the text layer needs. +struct GlyphRun { + text: String, + /// Baseline origin in document pixels. + origin: Point, + size_pt: f64, + /// Em size in document pixels. + size_px: f64, + /// Baseline direction in radians (view space, y down). + angle: f64, + /// Advance in document pixels. + advance: f64, + font: FontInfo, + color: Color, + /// Drawn both filled and stroked: word-processor fake bold. + fake_bold: bool, +} + +/// The font style a run carries. +#[derive(Clone)] +struct FontInfo { + /// Hayro's font cache key, to group glyphs of one font. + key: u128, + family: String, + postscript: String, + weight: u32, + italic: bool, +} + impl PathDevice<'_> { /// Stops collecting at the first thing this step cannot represent. fn unsupported(&mut self, reason: &'static str) { @@ -125,6 +332,18 @@ impl PathDevice<'_> { } } + /// Whether `clip` is an axis-aligned rectangle that contains the whole artboard, so it + /// changes nothing in document space and can be ignored. + fn no_op_clip(&self, clip: &ClipPath) -> bool { + let Some((x0, y0, x1, y1)) = axis_aligned_rect(&clip.path) else { return false }; + let a = self.doc.apply(Point::new(x0, y0)); + let b = self.doc.apply(Point::new(x1, y1)); + let (dx0, dx1) = (a.x.min(b.x), a.x.max(b.x)); + let (dy0, dy1) = (a.y.min(b.y), a.y.max(b.y)); + let board = self.clip; + dx0 <= f64::from(board.x0) + 0.5 && dy0 <= f64::from(board.y0) + 0.5 && dx1 >= f64::from(board.x1) - 0.5 && dy1 >= f64::from(board.y1) - 0.5 + } + /// Adds one shape layer unless a page cap is reached. Renders its pixel cache within the /// page's cache budget so the layer is visible before the engine refreshes it. fn push_shape(&mut self, path: Path, fill: Option, stroke: Option) { @@ -196,6 +415,10 @@ impl<'a> Device<'a> for PathDevice<'_> { self.push_shape(path, Some(Fill::Solid(color)), None); } PathDrawMode::Stroke(stroke) => { + if !uniform_transform(&transform) { + self.unsupported("non-uniform stroke transform"); + return; + } let Some(stroke) = to_stroke(stroke, color, full.determinant().abs().sqrt()) else { self.unsupported("stroke properties"); return; @@ -205,7 +428,12 @@ impl<'a> Device<'a> for PathDevice<'_> { } } - fn push_clip_path(&mut self, _clip_path: &ClipPath) { + fn push_clip_path(&mut self, clip_path: &ClipPath) { + if self.no_op_clip(clip_path) { + self.clips.push(true); + return; + } + self.clips.push(false); self.unsupported("clipping paths"); } @@ -216,15 +444,96 @@ impl<'a> Device<'a> for PathDevice<'_> { self.unsupported("transparency groups"); } - fn draw_glyph(&mut self, _glyph: &Glyph<'a>, _transform: KurboAffine, _glyph_transform: KurboAffine, _paint: &Paint<'a>, _draw_mode: &GlyphDrawMode) { - self.unsupported("text is not editable yet"); + fn draw_glyph(&mut self, glyph: &Glyph<'a>, transform: KurboAffine, glyph_transform: KurboAffine, paint: &Paint<'a>, draw_mode: &GlyphDrawMode) { + if self.unsupported.is_some() { + return; + } + if self.ctl.check().is_err() { + self.cancelled = true; + return; + } + if matches!(draw_mode, GlyphDrawMode::Invisible) { + return; + } + if matches!(draw_mode, GlyphDrawMode::Clip) { + self.unsupported("text used as a clipping path"); + return; + } + let text = match glyph.as_unicode() { + Some(craft_pdf::interpret::hayro_cmap::BfString::Char(c)) => c.to_string(), + Some(craft_pdf::interpret::hayro_cmap::BfString::String(s)) => s, + None => return, + }; + if text.is_empty() || text.chars().all(|c| c.is_control()) { + return; + } + let Glyph::Outline(outline) = glyph else { + self.unsupported("Type 3 text"); + return; + }; + let Some(data) = outline.font_data() else { + self.unsupported("text without an embedded font"); + return; + }; + if !data.embedded { + self.unsupported("text in a substituted font"); + return; + } + let Some(postscript) = data.postscript_name.clone().filter(|name| !name.is_empty()) else { + self.unsupported("text without a font name"); + return; + }; + let color = match paint { + Paint::Color(color) => to_color(color), + Paint::Pattern(_) => { + self.unsupported("text with a pattern paint"); + return; + } + }; + let t = transform * glyph_transform; + let [a, b, c, d, e, f] = t.as_coeffs(); + // A glyph space of 1000 units per em, so the determinant's square root is 1/1000 of the + // em size. Upright text has a positive a and a negative d (the view flips y). + let size_pt = (a * d - b * c).abs().sqrt() * 1000.0; + if !size_pt.is_finite() || size_pt <= 0.0 { + self.unsupported("text without a usable size"); + return; + } + // Skewed or mirrored text cannot be a point text layer; leave the page to raster. + if (a.abs() - d.abs()).abs() > 0.01 * a.abs().max(1.0e-9) || (b.abs() - c.abs()).abs() > 0.01 * a.abs().max(1.0e-9) { + self.unsupported("skewed text"); + return; + } + let origin = self.doc.apply(Point::new(e, f)); + let size_px = size_pt * self.doc.determinant().abs().sqrt(); + let angle = b.atan2(a); + if !angle.is_finite() || !size_px.is_finite() || size_px <= 0.0 { + self.unsupported("text without a usable transform"); + return; + } + let advance = f64::from(outline.advance_width().unwrap_or(500.0)) / 1000.0 * size_px; + let weight = font_weight(&data, &postscript); + let italic = data.is_italic || name_has(&postscript, &["italic", "oblique"]); + let key = outline.font_cache_key(); + if !self.fonts.iter().any(|(existing, _, _)| *existing == key) { + self.fonts.push((key, postscript.clone(), data.data.clone())); + } + let font = FontInfo { key, family: family_from_postscript(&postscript), postscript, weight, italic }; + let glyph = GlyphRun { text, origin, size_pt, size_px, angle, advance, font, color, fake_bold: false }; + if matches!(draw_mode, GlyphDrawMode::Stroke(_)) { + self.strokes.push(glyph); + } else { + self.glyphs.push(glyph); + } } fn draw_image(&mut self, _image: Image<'a, '_>, _transform: KurboAffine) { self.unsupported("images"); } - fn pop_clip_path(&mut self) {} + fn pop_clip_path(&mut self) { + let _ = self.clips.pop(); + } fn pop_transparency_group(&mut self) {} @@ -233,6 +542,144 @@ impl<'a> Device<'a> for PathDevice<'_> { } } +/// The weight a font is given to a text layer, from its metadata and name. +fn font_weight(data: &OutlineFontData, postscript: &str) -> u32 { + if name_has(postscript, &["bold", "semibold", "demibold", "extrabold", "ultrabold", "black", "heavy"]) { + return data.weight.unwrap_or(700).max(700); + } + data.weight.unwrap_or(400) +} + +/// Whether `name` contains any of the lowercase style words. +fn name_has(name: &str, words: &[&str]) -> bool { + let lower = name.to_ascii_lowercase(); + words.iter().any(|word| lower.contains(word)) +} + +/// A family name from a `/BaseFont`/PostScript name, per the rules PdfCraft and pdfcer use: +/// drop a six-letter subset tag, a trailing style suffix and `MT`/`PS`, then space camel case. +/// The raw name stays in `CharStyle::postscript_name`, because some systems match that spelling. +fn family_from_postscript(name: &str) -> String { + let mut s = name; + if s.len() > 7 && s.as_bytes()[6] == b'+' && s[..6].bytes().all(|b| b.is_ascii_uppercase()) { + s = &s[7..]; + } + if let Some((head, tail)) = s.rsplit_once('-') + && is_style_word(tail) + { + s = head; + } + loop { + if let Some(rest) = s.strip_suffix("PSMT") { + s = rest; + } else if let Some(rest) = s.strip_suffix("MT") { + s = rest; + } else if let Some(rest) = s.strip_suffix("PS") { + s = rest; + } else { + break; + } + } + let spaced = space_camel(s); + if spaced.is_empty() { name.to_string() } else { spaced } +} + +/// Whether a suffix is a style word (after dropping `MT`/`PS` from it). +fn is_style_word(word: &str) -> bool { + let mut w = word; + loop { + if let Some(rest) = w.strip_suffix("MT") { + w = rest; + } else if let Some(rest) = w.strip_suffix("PS") { + w = rest; + } else { + break; + } + } + matches!( + w.to_ascii_lowercase().as_str(), + "bold" + | "italic" + | "oblique" + | "bolditalic" + | "boldoblique" + | "regular" + | "medium" + | "light" + | "black" + | "semibold" + | "demibold" + | "heavy" + | "thin" + | "book" + | "roman" + | "condensed" + | "narrow" + | "extended" + ) +} + +/// `TimesNewRoman` → `Times New Roman`; acronym runs and hyphens stay as they are. +fn space_camel(name: &str) -> String { + let chars: Vec = name.chars().collect(); + let mut out = String::with_capacity(name.len() + 4); + for (i, ch) in chars.iter().copied().enumerate() { + let prev = i.checked_sub(1).and_then(|j| chars.get(j)).copied(); + let next = chars.get(i + 1).copied(); + let boundary = ch.is_ascii_uppercase() && prev.is_some_and(|p| p.is_ascii_lowercase() || p.is_ascii_digit()) + || ch.is_ascii_uppercase() && prev.is_some_and(|p| p.is_ascii_uppercase()) && next.is_some_and(|n| n.is_ascii_lowercase()); + if boundary && !out.is_empty() && !out.ends_with(' ') && !out.ends_with('-') { + out.push(' '); + } + out.push(ch); + } + out +} + +/// The bounds of a path that is one axis-aligned rectangle, else `None`. +fn axis_aligned_rect(path: &BezPath) -> Option<(f64, f64, f64, f64)> { + let elements = path.elements(); + if elements.len() != 5 { + return None; + } + let mut points = Vec::with_capacity(4); + for element in elements { + match *element { + PathEl::MoveTo(p) | PathEl::LineTo(p) => points.push((p.x, p.y)), + PathEl::ClosePath => {} + PathEl::QuadTo(..) | PathEl::CurveTo(..) => return None, + } + } + if points.len() != 4 { + return None; + } + let x0 = points.iter().map(|p| p.0).fold(f64::INFINITY, f64::min); + let x1 = points.iter().map(|p| p.0).fold(f64::NEG_INFINITY, f64::max); + let y0 = points.iter().map(|p| p.1).fold(f64::INFINITY, f64::min); + let y1 = points.iter().map(|p| p.1).fold(f64::NEG_INFINITY, f64::max); + if !(x1 > x0 && y1 > y0) { + return None; + } + let on_edge = |v: f64, lo: f64, hi: f64| (v - lo).abs() < 1.0e-6 || (v - hi).abs() < 1.0e-6; + if points.iter().any(|(x, y)| !on_edge(*x, x0, x1) || !on_edge(*y, y0, y1)) { + return None; + } + Some((x0, y0, x1, y1)) +} + +/// Whether a path transform keeps circles circular: both axes the same length and perpendicular. +fn uniform_transform(transform: &KurboAffine) -> bool { + let [a, b, c, d, _, _] = transform.as_coeffs(); + let x = (a * a + b * b).sqrt(); + let y = (c * c + d * d).sqrt(); + if !x.is_finite() || !y.is_finite() || x <= 0.0 || y <= 0.0 { + return false; + } + let same = (x - y).abs() <= 0.01 * x.max(y); + let square = (a * c + b * d).abs() <= 0.01 * x * y; + same && square +} + /// A PDF colour as a straight-alpha PhotoCraft colour. fn to_color(color: &HayroColor) -> Color { let rgba = color.to_rgba(); @@ -265,14 +712,21 @@ fn path_bounds(path: &Path) -> (f64, f64, f64, f64) { /// A Hayro stroke as PhotoCraft's stroke: width in pixels, dashes in multiples of the width. fn to_stroke(stroke: &craft_pdf::interpret::StrokeProps, color: Color, scale: f64) -> Option { - if !stroke.line_width.is_finite() || stroke.line_width <= 0.0 || !scale.is_finite() || scale <= 0.0 { + if !stroke.line_width.is_finite() || stroke.line_width < 0.0 || !scale.is_finite() || scale <= 0.0 { return None; } - let width = stroke.line_width * scale as f32; + // A zero line width is the thinnest line the device can draw (ISO 32000-2 §8.4.3.2); the + // raster path draws it one document pixel wide, so do the same and use that as the dash base. + let width = if stroke.line_width > 0.0 { stroke.line_width * scale as f32 } else { 1.0 }; if !width.is_finite() || width <= 0.0 { return None; } - let dashes = stroke.dash_array.iter().map(|dash| dash / stroke.line_width).collect(); + let mut dashes: Vec = stroke.dash_array.iter().map(|dash| dash * scale as f32 / width).collect(); + // An odd PDF dash array repeats, so [3] means 3 on, 3 off rather than a single entry. + if dashes.len() % 2 == 1 { + let copy = dashes.clone(); + dashes.extend(copy); + } Some(ShapeStroke { width, paint: Fill::Solid(color), @@ -290,7 +744,7 @@ fn to_stroke(stroke: &craft_pdf::interpret::StrokeProps, color: Color, scale: f6 }, miter_limit: stroke.miter_limit, dashes, - dash_offset: stroke.dash_offset / stroke.line_width, + dash_offset: stroke.dash_offset * scale as f32 / width, }) } @@ -384,6 +838,11 @@ mod tests { /// A one-page 200 x 100 pt PDF whose content stream is `content`. fn pdf(content: &str) -> Vec { + pdf_with_resources(content, dictionary! {}) + } + + /// [`pdf`] with page resources (for example an ExtGState). + fn pdf_with_resources(content: &str, resources: lopdf::Dictionary) -> Vec { let mut pdf = PdfDocument::with_version("1.5"); let pages_id = pdf.new_object_id(); let content_id = pdf.add_object(Stream::new(dictionary! {}, content.as_bytes().to_vec())); @@ -392,7 +851,7 @@ mod tests { "Parent" => pages_id, "MediaBox" => Object::Array(vec![Object::Real(0.0), Object::Real(0.0), Object::Real(200.0), Object::Real(100.0)]), "Contents" => content_id, - "Resources" => dictionary! {}, + "Resources" => resources, }); pdf.objects.insert( pages_id, @@ -409,6 +868,64 @@ mod tests { bytes } + /// A one-page 200 x 100 pt PDF whose content stream is `content` and whose page uses an + /// embedded TrueType font as `/F1`. + fn text_pdf(content: &str) -> Vec { + text_pdf_named(content, "Inter-Regular") + } + + /// [`text_pdf`] with the font named `name` (the same Inter bytes, so tests can exercise the + /// name-based style rules). + fn text_pdf_named(content: &str, name: &str) -> Vec { + const INTER: &[u8] = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); + let mut pdf = PdfDocument::with_version("1.5"); + let pages_id = pdf.new_object_id(); + let font_file = pdf.add_object(Stream::new(dictionary! { "Length1" => INTER.len() as i64 }, INTER.to_vec())); + let descriptor = pdf.add_object(dictionary! { + "Type" => "FontDescriptor", + "FontName" => Object::Name(name.as_bytes().to_vec()), + "Flags" => 32, + "FontBBox" => Object::Array(vec![Object::Integer(-100), Object::Integer(-200), Object::Integer(1000), Object::Integer(900)]), + "ItalicAngle" => 0, + "Ascent" => 900, + "Descent" => -200, + "CapHeight" => 700, + "StemV" => 80, + "FontFile2" => font_file, + }); + let font = pdf.add_object(dictionary! { + "Type" => "Font", + "Subtype" => "TrueType", + "BaseFont" => Object::Name(name.as_bytes().to_vec()), + "FirstChar" => 32, + "LastChar" => 126, + "Widths" => Object::Array(vec![Object::Integer(600); 95]), + "FontDescriptor" => descriptor, + "Encoding" => "WinAnsiEncoding", + }); + let content_id = pdf.add_object(Stream::new(dictionary! {}, content.as_bytes().to_vec())); + let page_id = pdf.add_object(dictionary! { + "Type" => "Page", + "Parent" => pages_id, + "MediaBox" => Object::Array(vec![Object::Real(0.0), Object::Real(0.0), Object::Real(200.0), Object::Real(100.0)]), + "Contents" => content_id, + "Resources" => dictionary! { "Font" => dictionary! { "F1" => font } }, + }); + pdf.objects.insert( + pages_id, + Object::Dictionary(dictionary! { + "Type" => "Pages", + "Kids" => Object::Array(vec![Object::Reference(page_id)]), + "Count" => 1, + }), + ); + let catalog = pdf.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id }); + pdf.trailer.set("Root", catalog); + let mut bytes = Vec::new(); + pdf.save_to(&mut bytes).expect("writing the text fixture"); + bytes + } + /// The artboard group's children. fn children(document: &photocraft_doc::Document) -> &[Layer] { match &document.layers.first().expect("a page group").content { @@ -417,6 +934,17 @@ mod tests { } } + /// The first text layer of `document`. + fn text_layer(document: &photocraft_doc::Document) -> &TextLayer { + children(document) + .iter() + .find_map(|layer| match &layer.content { + LayerContent::Text(text) => Some(text), + _ => None, + }) + .expect("a text layer") + } + /// A solid fill or stroke colour. fn solid(fill: &Fill) -> Color { match fill { @@ -496,13 +1024,182 @@ mod tests { let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; let imported = import_with_options_and_interrupt("text.pdf", &bytes, &options, &Interrupt::NONE).expect("a fallback import"); assert!( - imported.warnings.iter().any(|warning| warning.contains("text is not editable yet") && warning.contains("rasterised")), + imported.warnings.iter().any(|warning| warning.contains("text without an embedded font") && warning.contains("rasterised")), "{:?}", imported.warnings ); assert!(matches!(children(&imported.document).first().map(|layer| &layer.content), Some(LayerContent::Raster(_)))); } + #[test] + fn embedded_text_becomes_a_text_layer_with_its_style() { + let bytes = text_pdf("BT /F1 24 Tf 1 0 0 rg 20 50 Td (Hi) Tj ET"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("text.pdf", &bytes, &options, &Interrupt::NONE).expect("an editable text import"); + let text = text_layer(&imported.document); + assert_eq!(text.text, "Hi"); + assert_eq!(text.font_family, "Inter"); + assert!((f64::from(text.size_pt) - 24.0).abs() < 0.1, "size {}", text.size_pt); + assert_eq!(text.color, Color::rgb(1.0, 0.0, 0.0)); + let origin = text.transform.apply(Point::new(0.0, 0.0)); + assert!((origin.x - 20.0).abs() < 0.5 && (origin.y - 50.0).abs() < 0.5, "origin {origin:?}"); + assert!(text.cache.is_some(), "the text cache is rendered on import"); + let style = &text.runs[0].style; + assert_eq!(style.postscript_name.as_deref(), Some("Inter-Regular")); + assert_eq!(style.weight, 400); + assert!(!style.italic); + } + + #[test] + fn font_names_set_bold_and_italic() { + for (name, weight, italic) in [("Inter-Bold", 700, false), ("Inter-Italic", 400, true), ("Inter-BoldItalic", 700, true)] { + let bytes = text_pdf_named("BT /F1 24 Tf 20 50 Td (Hi) Tj ET", name); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("text.pdf", &bytes, &options, &Interrupt::NONE).expect("an editable text import"); + let style = &text_layer(&imported.document).runs[0].style; + assert_eq!(style.weight, weight, "{name}"); + assert_eq!(style.italic, italic, "{name}"); + assert_eq!(style.font_family, "Inter", "{name}"); + } + } + + #[test] + fn a_page_sized_clip_does_not_force_a_raster_page() { + let bytes = pdf("q 0 0 200 100 re W n 1 0 0 rg 20 20 60 40 re f Q"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("clip.pdf", &bytes, &options, &Interrupt::NONE).expect("import"); + assert!(!imported.warnings.iter().any(|warning| warning.contains("clipping")), "{:?}", imported.warnings); + assert!(children(&imported.document).iter().any(|layer| matches!(layer.content, LayerContent::Shape(_)))); + } + + #[test] + fn a_zero_width_stroke_is_a_one_pixel_hairline() { + let bytes = pdf("0 0 1 RG 0 w 20 30 m 180 30 l S"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("hairline.pdf", &bytes, &options, &Interrupt::NONE).expect("import"); + let LayerContent::Shape(shape) = &children(&imported.document)[0].content else { panic!("not a shape") }; + let stroke = shape.stroke.as_ref().expect("a stroke"); + assert!((stroke.width - 1.0).abs() < 0.01, "width {}", stroke.width); + } + + #[test] + fn a_non_uniform_stroke_transform_falls_back() { + let bytes = pdf("q 2 0 0 1 0 0 cm 0 0 1 RG 4 w 20 30 m 80 30 l S Q"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("skew.pdf", &bytes, &options, &Interrupt::NONE).expect("import"); + assert!( + imported.warnings.iter().any(|warning| warning.contains("non-uniform stroke transform") && warning.contains("rasterised")), + "{:?}", + imported.warnings + ); + assert!(matches!(children(&imported.document).first().map(|layer| &layer.content), Some(LayerContent::Raster(_)))); + } + + #[test] + fn a_uniform_stroke_transform_scales_the_width() { + let bytes = pdf("q 2 0 0 2 0 0 cm 0 0 1 RG 4 w 20 30 m 80 30 l S Q"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("scaled.pdf", &bytes, &options, &Interrupt::NONE).expect("import"); + let LayerContent::Shape(shape) = &children(&imported.document)[0].content else { panic!("not a shape") }; + let stroke = shape.stroke.as_ref().expect("a stroke"); + assert!((stroke.width - 8.0).abs() < 0.01, "width {}", stroke.width); + } + + #[test] + fn extgstate_fill_and_stroke_opacity_reach_the_layers() { + let resources = dictionary! { + "ExtGState" => dictionary! { + "GS0" => dictionary! { "ca" => 0.5, "CA" => 0.5 }, + }, + }; + let bytes = pdf_with_resources("/GS0 gs 1 0 0 rg 20 20 60 40 re f 0 0 1 RG 4 w 20 80 m 180 80 l S", resources); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("opacity.pdf", &bytes, &options, &Interrupt::NONE).expect("import"); + let layers = children(&imported.document); + let LayerContent::Shape(fill) = &layers[0].content else { panic!("not a shape") }; + assert!((solid(fill.fill.as_ref().expect("a fill")).alpha - 0.5).abs() < 0.01); + let LayerContent::Shape(stroke) = &layers[1].content else { panic!("not a shape") }; + assert!((solid(&stroke.stroke.as_ref().expect("a stroke").paint).alpha - 0.5).abs() < 0.01); + } + + #[test] + fn odd_and_zero_dash_arrays_are_normalised() { + let mut stroke = craft_pdf::interpret::StrokeProps { + line_width: 2.0, + line_cap: Cap::Butt, + line_join: Join::Miter, + miter_limit: 10.0, + dash_array: Default::default(), + dash_offset: 0.0, + }; + stroke.dash_array.push(3.0); + let out = to_stroke(&stroke, Color::BLACK, 1.0).expect("a stroke"); + assert_eq!(out.dashes, vec![1.5, 1.5], "an odd array repeats"); + stroke.dash_array.clear(); + stroke.dash_array.push(0.0); + stroke.dash_array.push(4.0); + let out = to_stroke(&stroke, Color::BLACK, 1.0).expect("a stroke"); + assert_eq!(out.dashes, vec![0.0, 2.0], "zero-length dashes stay"); + } + + #[test] + fn fill_then_stroke_text_is_fake_bold_not_a_second_run() { + let bytes = text_pdf("BT /F1 24 Tf 2 Tr 1 0 0 rg 20 50 Td (Hi) Tj ET"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("text.pdf", &bytes, &options, &Interrupt::NONE).expect("an editable text import"); + let texts: Vec<_> = children(&imported.document) + .iter() + .filter_map(|layer| match &layer.content { + LayerContent::Text(text) => Some(text), + _ => None, + }) + .collect(); + assert_eq!(texts.len(), 1, "one run, not a duplicate stroke run"); + assert_eq!(texts[0].text, "Hi"); + assert_eq!(texts[0].runs[0].style.weight, 700); + assert_eq!(texts[0].color, Color::rgb(1.0, 0.0, 0.0)); + } + + #[test] + fn font_family_rules_follow_the_base_font() { + assert_eq!(family_from_postscript("ABCDEF+TimesNewRomanPSMT"), "Times New Roman"); + assert_eq!(family_from_postscript("Arial-BoldMT"), "Arial"); + assert_eq!(family_from_postscript("Helvetica-Oblique"), "Helvetica"); + assert_eq!(family_from_postscript("MS-Gothic"), "MS-Gothic"); + assert_eq!(family_from_postscript("SimSun"), "Sim Sun"); + assert_eq!(family_from_postscript("Inter-Regular"), "Inter"); + assert_eq!(family_from_postscript("Roboto-Black"), "Roboto"); + } + + #[test] + fn the_editable_text_composite_matches_the_raster_page() { + let bytes = text_pdf("BT /F1 24 Tf 1 0 0 rg 20 50 Td (Hi) Tj ET"); + let raster = import_with_options_and_interrupt("text.pdf", &bytes, &ImportOptions { pdf_dpi: 72.0, ..ImportOptions::default() }, &Interrupt::NONE) + .expect("a raster import"); + let editable = import_with_options_and_interrupt( + "text.pdf", + &bytes, + &ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }, + &Interrupt::NONE, + ) + .expect("an editable import"); + let fmt = editable.document.pixel_format(); + let a = photocraft_compose::flatten_to_surface(&editable.document, fmt, None); + let b = photocraft_compose::flatten_to_surface(&raster.document, fmt, None); + let rect = editable.document.bounds(); + let av = a.read_region(rect); + let bv = b.read_region(rect); + let mut sum = 0.0f64; + let mut max = 0.0f32; + for (x, y) in av.iter().zip(&bv) { + let d = (x - y).abs(); + sum += f64::from(d); + max = max.max(d); + } + let mean = sum / av.len() as f64; + assert!(mean < 0.02, "mean pixel difference {mean:.4} (max {max:.3})"); + } + #[test] fn a_cancelled_editable_import_returns_cancelled() { let bytes = pdf("1 0 0 rg 20 20 60 40 re f"); diff --git a/crates/io/src/pdf_import.rs b/crates/io/src/pdf_import.rs index 3907751046..0191c823a8 100644 --- a/crates/io/src/pdf_import.rs +++ b/crates/io/src/pdf_import.rs @@ -135,6 +135,8 @@ pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) - let mut doc = Document::new(name, Size::new(doc_width as u32, doc_height as u32), ColorMode::Rgb, SampleType::U8); doc.resolution_dpi = dpi as f32; + #[cfg(feature = "pdf-editable")] + let mut text_engine: Option = None; let mut x: u32 = 0; for (index, &(width_pt, height_pt)) in sizes.iter().enumerate() { ctl.check().map_err(|_| IoError::Cancelled)?; @@ -150,7 +152,8 @@ pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) - let rect = Rect::new(x as i32, 0, (x + width) as i32, height as i32); #[cfg(feature = "pdf-editable")] if opts.pdf_editable { - match crate::pdf_editable::page(page, rect, ctl) { + let engine = text_engine.get_or_insert_with(photocraft_text::TextEngine::with_system_fonts); + match crate::pdf_editable::page(page, rect, ctl, engine) { Ok(layers) => { let group = Group { children: layers, expanded: true, artboard: Some(Artboard::new(rect)), frame_stroke: None }; doc.layers.push(Layer::new(format!("Page {page_number}"), LayerContent::Group(group))); diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 4a4219e143..d0ec86f4d1 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -142,6 +142,17 @@ impl TextEngine { layer.cache = Some(r.surface); r.rect } + + /// Registers font bytes (for example a font embedded in an imported PDF) for later layout. + /// Returns the family names the font provides, best first. + pub fn register_font_data(&mut self, bytes: Vec) -> Vec { + self.fonts.register_font_data(bytes) + } + + /// Whether `family` is available to this engine. + pub fn has_family(&mut self, family: &str) -> bool { + self.fonts.has_family(family) + } } static SHARED: warm::Warm> = warm::Warm::new(); From d1e95f57a81aaee5234bae6e3cc96bf1e84d4cc3 Mon Sep 17 00:00:00 2001 From: Triwarr <170976175+Triwarr@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:47:40 +0100 Subject: [PATCH 177/219] PDF: share embedded fonts and tighten text checks - Register imported fonts in photocraft_text::shared() so the app can re-lay text after edits or DPI changes. Prefer an installed full family, and register an embedded face under its PostScript name (key-suffixed when two faces share it) so a subset cannot shadow it. - Warn that text using PDF-embedded fonts may change font when edited or reopened; the document format does not carry the bytes yet. - Reject mirrored text and b == -c matrices; scale the tolerance by the em size so 90-degree text stays editable. Tests for rotated and mirrored text. - Use pdfcer's 0.30 em line tolerance. --- crates/io/src/pdf_editable.rs | 93 ++++++++++++++++++++++++++++------- crates/io/src/pdf_import.rs | 12 +++-- crates/text/src/fonts.rs | 6 +++ crates/text/src/lib.rs | 5 ++ 4 files changed, 94 insertions(+), 22 deletions(-) diff --git a/crates/io/src/pdf_editable.rs b/crates/io/src/pdf_editable.rs index 8d1beab04d..31228be45f 100644 --- a/crates/io/src/pdf_editable.rs +++ b/crates/io/src/pdf_editable.rs @@ -41,7 +41,7 @@ const POLL: Duration = Duration::from_millis(5); /// Largest gap (in em) inside one run before a space is inserted. const WORD_GAP: f64 = 0.2; /// Largest baseline offset (in em) for glyphs to share one line. -const LINE_TOLERANCE: f64 = 0.35; +const LINE_TOLERANCE: f64 = 0.30; /// Why an editable page could not be built. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -58,7 +58,7 @@ pub enum EditableError { /// `Page::render_dimensions()` (CropBox, rotation and UserUnit already applied), so a point maps /// to `rect` by the per-axis scale `rect.size / render_dimensions`. Nothing here re-applies the /// CropBox offset or rotation. -pub fn page(page: &Page<'_>, rect: Rect, ctl: &Interrupt, text: &mut photocraft_text::TextEngine) -> Result, EditableError> { +pub fn page(page: &Page<'_>, rect: Rect, ctl: &Interrupt) -> Result, EditableError> { ctl.check().map_err(|_| EditableError::Cancelled)?; let (width_pt, height_pt) = page.render_dimensions(); if !width_pt.is_finite() || !height_pt.is_finite() || width_pt <= 0.0 || height_pt <= 0.0 { @@ -123,7 +123,7 @@ pub fn page(page: &Page<'_>, rect: Rect, ctl: &Interrupt, text: &mut photocraft_ } let mut layers = device.layers; layers.extend(text_layers(&device.glyphs)); - render_text_caches(&mut layers, device.dpi, device.fmt, &device.fonts, text); + render_text_caches(&mut layers, device.dpi, device.fmt, &device.fonts); Ok(layers) } @@ -245,13 +245,28 @@ fn text_layer(text: String, run: &GlyphRun, number: usize) -> Layer { /// Registers each embedded font with the text engine, renames the runs to the family the font /// itself reports, and renders the caches so the page is complete before the engine refreshes it. -fn render_text_caches(layers: &mut [Layer], dpi: f64, fmt: PixelFormat, fonts: &[(u128, String, FontData)], engine: &mut photocraft_text::TextEngine) { +fn render_text_caches(layers: &mut [Layer], dpi: f64, fmt: PixelFormat, fonts: &[(u128, String, String, FontData)]) { + let shared = photocraft_text::shared(); + let mut engine = shared.lock().unwrap_or_else(|error| error.into_inner()); + let mut counts = std::collections::HashMap::new(); + for (_, postscript, _, _) in fonts { + *counts.entry(postscript.as_str()).or_insert(0usize) += 1; + } let mut families = std::collections::HashMap::new(); - for (_, postscript, data) in fonts { - let names = engine.register_font_data(data.as_ref().as_ref().to_vec()); - if let Some(name) = names.first() { - families.insert(postscript.clone(), name.clone()); - } + for (key, postscript, derived, data) in fonts { + // Prefer an installed full family; a subset only holds the glyphs the page uses. The + // Device has the font program's PostScript name, not the PDF `/BaseFont`, so register + // the embedded face under that name (with a key suffix when two faces share it) rather + // than shadowing the real family. + let family = if engine.has_family(derived) { + derived.clone() + } else { + let alias = + if counts.get(postscript.as_str()).copied().unwrap_or(0) > 1 { format!("{postscript} {:08x}", *key as u32) } else { postscript.clone() }; + let names = engine.register_font_data_as(data.as_ref().as_ref().to_vec(), &alias); + names.first().cloned().unwrap_or(alias) + }; + families.insert(postscript.clone(), family); } for layer in layers { if let LayerContent::Text(text) = &mut layer.content { @@ -284,8 +299,8 @@ struct PathDevice<'a> { glyphs: Vec, /// Stroked glyphs, paired with a fill at the same place to recognise fake bold. strokes: Vec, - /// Embedded fonts met on the page: cache key, PostScript name and bytes. - fonts: Vec<(u128, String, FontData)>, + /// Embedded fonts met on the page: cache key, PostScript name, derived family and bytes. + fonts: Vec<(u128, String, String, FontData)>, /// One entry per clip pushed by the interpreter: `true` for an accepted no-op clip. clips: Vec, knots: usize, @@ -499,9 +514,11 @@ impl<'a> Device<'a> for PathDevice<'_> { self.unsupported("text without a usable size"); return; } - // Skewed or mirrored text cannot be a point text layer; leave the page to raster. - if (a.abs() - d.abs()).abs() > 0.01 * a.abs().max(1.0e-9) || (b.abs() - c.abs()).abs() > 0.01 * a.abs().max(1.0e-9) { - self.unsupported("skewed text"); + // Skewed, mirrored or sheared text cannot be a point text layer; leave the page to raster. + // After the view's y flip an upright or rotated run has a ≈ -d and b ≈ c. + let tolerance = 0.01 * (a * d - b * c).abs().sqrt(); + if (a + d).abs() > tolerance || (b - c).abs() > tolerance { + self.unsupported("skewed or mirrored text"); return; } let origin = self.doc.apply(Point::new(e, f)); @@ -515,10 +532,11 @@ impl<'a> Device<'a> for PathDevice<'_> { let weight = font_weight(&data, &postscript); let italic = data.is_italic || name_has(&postscript, &["italic", "oblique"]); let key = outline.font_cache_key(); - if !self.fonts.iter().any(|(existing, _, _)| *existing == key) { - self.fonts.push((key, postscript.clone(), data.data.clone())); + let family = family_from_postscript(&postscript); + if !self.fonts.iter().any(|(existing, _, _, _)| *existing == key) { + self.fonts.push((key, postscript.clone(), family.clone(), data.data.clone())); } - let font = FontInfo { key, family: family_from_postscript(&postscript), postscript, weight, italic }; + let font = FontInfo { key, family, postscript, weight, italic }; let glyph = GlyphRun { text, origin, size_pt, size_px, angle, advance, font, color, fake_bold: false }; if matches!(draw_mode, GlyphDrawMode::Stroke(_)) { self.strokes.push(glyph); @@ -1038,7 +1056,7 @@ mod tests { let imported = import_with_options_and_interrupt("text.pdf", &bytes, &options, &Interrupt::NONE).expect("an editable text import"); let text = text_layer(&imported.document); assert_eq!(text.text, "Hi"); - assert_eq!(text.font_family, "Inter"); + assert!(text.font_family.starts_with("Inter"), "family {}", text.font_family); assert!((f64::from(text.size_pt) - 24.0).abs() < 0.1, "size {}", text.size_pt); assert_eq!(text.color, Color::rgb(1.0, 0.0, 0.0)); let origin = text.transform.apply(Point::new(0.0, 0.0)); @@ -1048,6 +1066,43 @@ mod tests { assert_eq!(style.postscript_name.as_deref(), Some("Inter-Regular")); assert_eq!(style.weight, 400); assert!(!style.italic); + assert!(imported.warnings.iter().any(|warning| warning.contains("fonts embedded in the PDF")), "{:?}", imported.warnings); + } + + #[test] + fn imported_text_relayouts_with_the_shared_engine() { + let bytes = text_pdf("BT /F1 24 Tf 1 0 0 rg 20 50 Td (Hi) Tj ET"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("text.pdf", &bytes, &options, &Interrupt::NONE).expect("an editable text import"); + let mut text = text_layer(&imported.document).clone(); + text.cache = None; + let shared = photocraft_text::shared(); + let mut engine = shared.lock().unwrap_or_else(|error| error.into_inner()); + assert!(engine.has_family(&text.font_family), "the app engine must know the imported family"); + let rect = engine.render_layer(&mut text, 72.0, PixelFormat::new(ColorMode::Rgb, SampleType::U8, true)); + assert!(rect.width() > 0 && rect.height() > 0, "the layer re-lays out"); + assert!(text.cache.is_some()); + } + + #[test] + fn rotated_text_stays_editable() { + let bytes = text_pdf("BT /F1 24 Tf 0 1 -1 0 100 20 Tm (Hi) Tj ET"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("rotated.pdf", &bytes, &options, &Interrupt::NONE).expect("import"); + assert!(!imported.warnings.iter().any(|warning| warning.contains("skewed or mirrored")), "{:?}", imported.warnings); + assert_eq!(text_layer(&imported.document).text, "Hi"); + } + + #[test] + fn mirrored_text_falls_back() { + let bytes = text_pdf("BT /F1 24 Tf -1 0 0 1 100 50 Tm (Hi) Tj ET"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("mirror.pdf", &bytes, &options, &Interrupt::NONE).expect("import"); + assert!( + imported.warnings.iter().any(|warning| warning.contains("skewed or mirrored text") && warning.contains("rasterised")), + "{:?}", + imported.warnings + ); } #[test] @@ -1059,7 +1114,7 @@ mod tests { let style = &text_layer(&imported.document).runs[0].style; assert_eq!(style.weight, weight, "{name}"); assert_eq!(style.italic, italic, "{name}"); - assert_eq!(style.font_family, "Inter", "{name}"); + assert!(style.font_family.starts_with("Inter"), "{name}: {}", style.font_family); } } diff --git a/crates/io/src/pdf_import.rs b/crates/io/src/pdf_import.rs index 0191c823a8..b55da30bec 100644 --- a/crates/io/src/pdf_import.rs +++ b/crates/io/src/pdf_import.rs @@ -136,7 +136,7 @@ pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) - let mut doc = Document::new(name, Size::new(doc_width as u32, doc_height as u32), ColorMode::Rgb, SampleType::U8); doc.resolution_dpi = dpi as f32; #[cfg(feature = "pdf-editable")] - let mut text_engine: Option = None; + let mut editable_text = false; let mut x: u32 = 0; for (index, &(width_pt, height_pt)) in sizes.iter().enumerate() { ctl.check().map_err(|_| IoError::Cancelled)?; @@ -152,9 +152,11 @@ pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) - let rect = Rect::new(x as i32, 0, (x + width) as i32, height as i32); #[cfg(feature = "pdf-editable")] if opts.pdf_editable { - let engine = text_engine.get_or_insert_with(photocraft_text::TextEngine::with_system_fonts); - match crate::pdf_editable::page(page, rect, ctl, engine) { + match crate::pdf_editable::page(page, rect, ctl) { Ok(layers) => { + if layers.iter().any(|layer| matches!(&layer.content, LayerContent::Text(_))) { + editable_text = true; + } let group = Group { children: layers, expanded: true, artboard: Some(Artboard::new(rect)), frame_stroke: None }; doc.layers.push(Layer::new(format!("Page {page_number}"), LayerContent::Group(group))); x = x.saturating_add(width).saturating_add(GAP); @@ -225,6 +227,10 @@ pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) - x = x.saturating_add(width).saturating_add(GAP); ctl.progress((index + 1) as f32 / count as f32); } + #[cfg(feature = "pdf-editable")] + if editable_text { + warnings.push("PDF text uses fonts embedded in the PDF; it may change font when edited or reopened".into()); + } Ok(ImportResult { warnings, document: doc, source_read_only: false, preview_only: false }) } diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 416ae7834c..489f2eef5e 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -295,6 +295,12 @@ impl FontDb { self.register_blob(Blob::new(Arc::new(bytes)), None) } + /// [`Self::register_font_data`] with the family name overridden, so an embedded subset can be + /// registered under a name that cannot shadow an installed full family. Returns the names added. + pub fn register_font_data_as(&mut self, bytes: Vec, family: &str) -> Vec { + self.register_blob(Blob::new(Arc::new(bytes)), Some(family)) + } + /// Registers embedded font data without copying it. fn register_static_font(&mut self, bytes: &'static [u8]) -> Vec { self.register_blob(Blob::new(Arc::new(bytes)), None) diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index d0ec86f4d1..75f3348b08 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -149,6 +149,11 @@ impl TextEngine { self.fonts.register_font_data(bytes) } + /// [`Self::register_font_data`] under `family`, which cannot shadow an installed family. + pub fn register_font_data_as(&mut self, bytes: Vec, family: &str) -> Vec { + self.fonts.register_font_data_as(bytes, family) + } + /// Whether `family` is available to this engine. pub fn has_family(&mut self, family: &str) -> bool { self.fonts.has_family(family) From d91ea1911e8811bc22924a7981455f059aaca612 Mon Sep 17 00:00:00 2001 From: Triwarr <170976175+Triwarr@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:52:37 +0100 Subject: [PATCH 178/219] PDF: content-key embedded font aliases - Always suffix an embedded face's alias with a hash of its bytes, so the same PostScript name from different documents cannot collide in the process-wide shared engine; a face imported twice reuses its entry. Registration is skipped when the alias or an installed full family is already present. - Release the shared text engine lock between layers so a text-heavy import does not block the UI's layout for the whole cache loop. - Test: two documents with one font name but different bytes get distinct aliases and both families are present in the shared engine. --- crates/io/src/pdf_editable.rs | 101 ++++++++++++++++++++++++++-------- 1 file changed, 79 insertions(+), 22 deletions(-) diff --git a/crates/io/src/pdf_editable.rs b/crates/io/src/pdf_editable.rs index 31228be45f..4f5cafc436 100644 --- a/crates/io/src/pdf_editable.rs +++ b/crates/io/src/pdf_editable.rs @@ -247,26 +247,27 @@ fn text_layer(text: String, run: &GlyphRun, number: usize) -> Layer { /// itself reports, and renders the caches so the page is complete before the engine refreshes it. fn render_text_caches(layers: &mut [Layer], dpi: f64, fmt: PixelFormat, fonts: &[(u128, String, String, FontData)]) { let shared = photocraft_text::shared(); - let mut engine = shared.lock().unwrap_or_else(|error| error.into_inner()); - let mut counts = std::collections::HashMap::new(); - for (_, postscript, _, _) in fonts { - *counts.entry(postscript.as_str()).or_insert(0usize) += 1; - } let mut families = std::collections::HashMap::new(); - for (key, postscript, derived, data) in fonts { - // Prefer an installed full family; a subset only holds the glyphs the page uses. The - // Device has the font program's PostScript name, not the PDF `/BaseFont`, so register - // the embedded face under that name (with a key suffix when two faces share it) rather - // than shadowing the real family. - let family = if engine.has_family(derived) { - derived.clone() - } else { - let alias = - if counts.get(postscript.as_str()).copied().unwrap_or(0) > 1 { format!("{postscript} {:08x}", *key as u32) } else { postscript.clone() }; - let names = engine.register_font_data_as(data.as_ref().as_ref().to_vec(), &alias); - names.first().cloned().unwrap_or(alias) - }; - families.insert(postscript.clone(), family); + { + let mut engine = shared.lock().unwrap_or_else(|error| error.into_inner()); + for (key, postscript, derived, data) in fonts { + // Prefer an installed full family; a subset only holds the glyphs the page uses. The + // Device has the font program's PostScript name, not the PDF `/BaseFont`; a + // content-keyed alias keeps two documents' subsets of one name apart for the life of + // the shared engine, and the same face imported twice reuses its registration. + let family = if engine.has_family(derived) { + derived.clone() + } else { + let alias = font_alias(postscript, *key); + if engine.has_family(&alias) { + alias + } else { + let names = engine.register_font_data_as(data.as_ref().as_ref().to_vec(), &alias); + names.first().cloned().unwrap_or(alias) + } + }; + families.insert(postscript.clone(), family); + } } for layer in layers { if let LayerContent::Text(text) = &mut layer.content { @@ -277,12 +278,29 @@ fn render_text_caches(layers: &mut [Layer], dpi: f64, fmt: PixelFormat, fonts: & } } if !text.text.trim().is_empty() { + // Re-lock per layer so a text-heavy import does not hold the UI's engine for the + // whole cache-render loop. + let mut engine = shared.lock().unwrap_or_else(|error| error.into_inner()); let _ = engine.render_layer(text, dpi as f32, fmt); } } } } +/// A content key for an embedded face, used to group its glyphs and to keep aliases unique. +fn font_content_key(data: &OutlineFontData) -> u128 { + let hash = blake3::hash(data.data.as_ref().as_ref()); + let mut key = [0u8; 16]; + key.copy_from_slice(&hash.as_bytes()[..16]); + u128::from_le_bytes(key) +} + +/// The family alias an embedded face is registered under: its PostScript name plus a content +/// key, so subsets with the same name from different documents cannot replace each other. +fn font_alias(postscript: &str, key: u128) -> String { + format!("{postscript} {key:016x}") +} + /// Collects filled and stroked paths and glyph runs; anything else marks the page unsupported. struct PathDevice<'a> { /// Page points to document pixels: translation to the artboard plus the per-axis scale. @@ -531,7 +549,7 @@ impl<'a> Device<'a> for PathDevice<'_> { let advance = f64::from(outline.advance_width().unwrap_or(500.0)) / 1000.0 * size_px; let weight = font_weight(&data, &postscript); let italic = data.is_italic || name_has(&postscript, &["italic", "oblique"]); - let key = outline.font_cache_key(); + let key = font_content_key(&data); let family = family_from_postscript(&postscript); if !self.fonts.iter().any(|(existing, _, _, _)| *existing == key) { self.fonts.push((key, postscript.clone(), family.clone(), data.data.clone())); @@ -854,6 +872,9 @@ mod tests { use super::*; use crate::{ImportOptions, IoError, import_with_options_and_interrupt}; + /// The embedded test font. + const INTER: &[u8] = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); + /// A one-page 200 x 100 pt PDF whose content stream is `content`. fn pdf(content: &str) -> Vec { pdf_with_resources(content, dictionary! {}) @@ -895,10 +916,14 @@ mod tests { /// [`text_pdf`] with the font named `name` (the same Inter bytes, so tests can exercise the /// name-based style rules). fn text_pdf_named(content: &str, name: &str) -> Vec { - const INTER: &[u8] = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); + text_pdf_bytes(content, name, INTER) + } + + /// [`text_pdf`] with the font named `name` and the given font bytes. + fn text_pdf_bytes(content: &str, name: &str, font_bytes: &[u8]) -> Vec { let mut pdf = PdfDocument::with_version("1.5"); let pages_id = pdf.new_object_id(); - let font_file = pdf.add_object(Stream::new(dictionary! { "Length1" => INTER.len() as i64 }, INTER.to_vec())); + let font_file = pdf.add_object(Stream::new(dictionary! { "Length1" => font_bytes.len() as i64 }, font_bytes.to_vec())); let descriptor = pdf.add_object(dictionary! { "Type" => "FontDescriptor", "FontName" => Object::Name(name.as_bytes().to_vec()), @@ -1093,6 +1118,38 @@ mod tests { assert_eq!(text_layer(&imported.document).text, "Hi"); } + #[test] + fn font_aliases_include_the_content_key() { + assert_ne!(font_alias("Inter-Regular", 1), font_alias("Inter-Regular", 2)); + assert!(font_alias("Inter-Regular", 0x12ab).contains("Inter-Regular")); + } + + #[test] + fn two_documents_with_one_font_name_keep_distinct_aliases() { + // Rename the bundled Inter family to one that is not installed, so the embedded face + // takes the alias path; same PostScript name and family, different bytes (a trailing byte + // changes the content key), as two different subsets of one font would. + let mut base = INTER.to_vec(); + for at in 0..base.len().saturating_sub(5) { + if &base[at..at + 5] == b"Inter" { + base[at..at + 5].copy_from_slice(b"Xntr"); + } + } + let mut other = base.clone(); + other.push(0); + let a = text_pdf_bytes("BT /F1 24 Tf 20 50 Td (Hi) Tj ET", "Xntr-Regular", &base); + let b = text_pdf_bytes("BT /F1 24 Tf 20 50 Td (Hi) Tj ET", "Xntr-Regular", &other); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let first = import_with_options_and_interrupt("a.pdf", &a, &options, &Interrupt::NONE).expect("first import"); + let second = import_with_options_and_interrupt("b.pdf", &b, &options, &Interrupt::NONE).expect("second import"); + let fa = text_layer(&first.document).font_family.clone(); + let fb = text_layer(&second.document).font_family.clone(); + assert_ne!(fa, fb, "the two embedded faces must not share an alias"); + let shared = photocraft_text::shared(); + let mut engine = shared.lock().unwrap_or_else(|error| error.into_inner()); + assert!(engine.has_family(&fa) && engine.has_family(&fb)); + } + #[test] fn mirrored_text_falls_back() { let bytes = text_pdf("BT /F1 24 Tf -1 0 0 1 100 50 Tm (Hi) Tj ET"); From 92ccd8b7d682061082a8cafd1de15c3f7f1d751f Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sat, 7 Nov 2026 23:04:32 -0500 Subject: [PATCH 179/219] Open as Smart Object: a damage-only hold is admitted as a smart object; the name is taken again at admission (#12212 review) Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at medium --- .../src/cmd/file_cmds/open_as_smart_object.rs | 9 +++--- .../file_cmds/open_as_smart_object_tests.rs | 15 +++++++++ crates/ui-egui/src/dialogs/open_prompt.rs | 6 ++++ crates/ui-egui/src/psd_open_tests.rs | 31 +++++++++++++++++++ 4 files changed, 56 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/file_cmds/open_as_smart_object.rs b/crates/engine/src/cmd/file_cmds/open_as_smart_object.rs index 517252b25a..f89cc5aef1 100644 --- a/crates/engine/src/cmd/file_cmds/open_as_smart_object.rs +++ b/crates/engine/src/cmd/file_cmds/open_as_smart_object.rs @@ -95,11 +95,10 @@ fn apply( /// history is the single state "Open As Smart Object", as File › Open's is one state. pub fn admit_as_smart_object(s: &mut Session, mut doc: Document) -> usize { let layer_name = doc.layers.first().map(|l| l.name.clone()); - // The number was free when the file was read, but a document held for its prompt did not count - // then: take the next free one now, as each document is admitted. - if let Some(base) = &layer_name - && s.documents().iter().any(|d| d.doc.name == doc.name) - { + // The number was free when the file was read, but documents held for their prompts did not count + // then, and one of them may have been cancelled since: take the first free one now, as each + // document is admitted. + if let Some(base) = &layer_name { doc.name = untitled_name(base, |n| s.documents().iter().any(|d| d.doc.name == n)); } // Untitled: no path, so Save asks where to save. diff --git a/crates/engine/src/cmd/file_cmds/open_as_smart_object_tests.rs b/crates/engine/src/cmd/file_cmds/open_as_smart_object_tests.rs index 230c75afe8..52493876db 100644 --- a/crates/engine/src/cmd/file_cmds/open_as_smart_object_tests.rs +++ b/crates/engine/src/cmd/file_cmds/open_as_smart_object_tests.rs @@ -334,3 +334,18 @@ fn a_document_held_for_its_prompt_does_not_share_its_name_with_the_next() { admit_as_smart_object(&mut t, d); assert_eq!(names(&t), ["a_test-1", "a_test-2"], "the second takes the next free number when admitted"); } + +#[test] +fn a_cancelled_hold_frees_its_number_for_the_one_admitted_after_it() { + let dir = tmp("cancelled-hold"); + let path = write_psd16(&dir, "photo.psd", 20, 20); + let mut s = Session::new(); + let first = s.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + let second = s.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + assert_eq!((first["name"].as_str(), second["name"].as_str()), (Some("photo-1"), Some("photo-2"))); + // photo-1 is cancelled while photo-2 waits; photo-2 is admitted with nothing open. + s.take_held_open(first["held"].as_u64().unwrap()).unwrap(); + let (b, _) = s.take_held_open(second["held"].as_u64().unwrap()).unwrap(); + admit_as_smart_object(&mut s, b); + assert_eq!(names(&s), ["photo-1"], "the first free number is taken at admission, with no collision"); +} diff --git a/crates/ui-egui/src/dialogs/open_prompt.rs b/crates/ui-egui/src/dialogs/open_prompt.rs index 145ae4ce84..a34eae8ecd 100644 --- a/crates/ui-egui/src/dialogs/open_prompt.rs +++ b/crates/ui-egui/src/dialogs/open_prompt.rs @@ -335,6 +335,12 @@ fn open(app: &mut PhotocraftApp, held: Held, action: Option<&str>) -> Result = app.session.documents().iter().map(|d| d.doc.name.clone()).collect(); + assert_eq!(names, ["dmg-1", "dmg-2"]); + for st in app.session.documents() { + assert_eq!(st.history.entries(), ["Open As Smart Object"]); + } + let _ = std::fs::remove_dir_all(&dir); +} From e49c8426532516d6dc7879e204a9a3b1cb78acfe Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 7 Nov 2026 22:17:14 -0600 Subject: [PATCH 180/219] Color Replacement refusal test compares the whole surface (CodeRabbit review on crates/engine/src/cmd/color_replacement_modes_tests.rs:34) The no-painting check looked at row 32 only; it now snapshots every pixel. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/color_replacement_modes_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/color_replacement_modes_tests.rs b/crates/engine/src/cmd/color_replacement_modes_tests.rs index 89df6a7ce9..b9c454fb33 100644 --- a/crates/engine/src/cmd/color_replacement_modes_tests.rs +++ b/crates/engine/src/cmd/color_replacement_modes_tests.rs @@ -32,7 +32,7 @@ fn steps(s: &Session) -> usize { fn pixels(s: &Session) -> Vec { let st = s.active().unwrap(); let surf = st.doc.layers[0].surface().unwrap(); - (0..64).flat_map(|x| surf.pixel(x, 32).to_vec()).collect() + (0..64).flat_map(|y| (0..64).flat_map(move |x| surf.pixel(x, y))).collect() } #[test] From d22ed9e6925d82e0b7db7920a4b105c9a4c197d0 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 7 Nov 2026 22:18:11 -0600 Subject: [PATCH 181/219] Test each tool's hidden-layer refusal as it is shown (CodeRabbit review on crates/ui-egui/src/hidden_layer_tests.rs:77) The Brush must open its alert; the other tools must keep the status-bar message and open no dialog, as #11898 asks. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/hidden_layer_tests.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/hidden_layer_tests.rs b/crates/ui-egui/src/hidden_layer_tests.rs index e6c92e619f..ce69c21943 100644 --- a/crates/ui-egui/src/hidden_layer_tests.rs +++ b/crates/ui-egui/src/hidden_layer_tests.rs @@ -74,7 +74,11 @@ fn painting_a_hidden_layer_is_refused_at_the_press() { assert!(app.drag.is_none() && app.live_stroke.is_none(), "{tool:?}: no stroke started"); // The Brush says so in its alert (#11898), the other tools in the status bar. let alert = app.ui.dialogs.iter().any(|d| d.fields.get("message").and_then(|m| m.as_str()).is_some_and(|m| m.contains("target layer is hidden"))); - assert!(app.ui.status.contains("target layer is hidden") || alert, "{tool:?}: {}", app.ui.status); + if tool == Tool::Brush { + assert!(alert, "{tool:?}: {:?}", app.ui.dialogs); + } else { + assert!(app.ui.status.contains("target layer is hidden") && app.ui.dialogs.is_empty(), "{tool:?}: {}", app.ui.status); + } tool_event(&mut app, ToolEvent::Up { x: 40.0, y: 40.0 }, egui::Modifiers::NONE); assert_eq!((bounds(&app, id), steps(&app)), (Rect::new(8, 8, 24, 24), n), "{tool:?}"); // Shown again, the same press paints. From 7c36a5c9e935e3960482833312f7d7311aaef814 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sat, 7 Nov 2026 23:48:22 -0500 Subject: [PATCH 182/219] Open as Smart Object: the reply names the document as admitted (#12212 review) When an earlier document is held for its profile prompt and the next open skips the prompt, admission numbers the second document photo-1 again, but the reply reported the name read before admission (photo-2). The reply now reads the name from the admitted document. Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at medium --- .../src/cmd/file_cmds/open_as_smart_object.rs | 2 ++ .../cmd/file_cmds/open_as_smart_object_tests.rs | 16 ++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/crates/engine/src/cmd/file_cmds/open_as_smart_object.rs b/crates/engine/src/cmd/file_cmds/open_as_smart_object.rs index f89cc5aef1..57bd8ce39f 100644 --- a/crates/engine/src/cmd/file_cmds/open_as_smart_object.rs +++ b/crates/engine/src/cmd/file_cmds/open_as_smart_object.rs @@ -87,6 +87,8 @@ fn apply( let color = s.color.open_policy(&mut doc, s.prefs().file_handling.ignore_exif_profile_tag); let index = admit_as_smart_object(s, doc); let id = s.documents().get(index).map(|st| st.doc.id.0); + // Admission numbers the document again, so the reply reads the name it was admitted under. + let name = s.documents().get(index).map_or(name, |st| st.doc.name.clone()); Ok(json!({"document": index, "docId": id, "name": name, "layer": layer_id.0, "warnings": warnings, "color": color, "smartObject": true})) } diff --git a/crates/engine/src/cmd/file_cmds/open_as_smart_object_tests.rs b/crates/engine/src/cmd/file_cmds/open_as_smart_object_tests.rs index 52493876db..c205af3d71 100644 --- a/crates/engine/src/cmd/file_cmds/open_as_smart_object_tests.rs +++ b/crates/engine/src/cmd/file_cmds/open_as_smart_object_tests.rs @@ -349,3 +349,19 @@ fn a_cancelled_hold_frees_its_number_for_the_one_admitted_after_it() { admit_as_smart_object(&mut s, b); assert_eq!(names(&s), ["photo-1"], "the first free number is taken at admission, with no collision"); } + +#[test] +fn the_reply_names_the_document_as_admitted() { + let dir = tmp("reply-name"); + let path = write_psd16(&dir, "photo.psd", 20, 20); + let mut s = Session::new(); + s.execute("edit.colorSettings", json!({"askOnMismatch": true})).unwrap(); + let held = s.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + assert_eq!(held["name"], "photo-1", "{held}"); + assert!(held["held"].is_u64(), "{held}"); + // The next open skips the prompt: photo-1 is held, so the number read was 2, but admission takes the first free one. + s.execute("edit.colorSettings", json!({"askOnMismatch": false})).unwrap(); + let r = s.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + assert_eq!(names(&s), ["photo-1"], "{r}"); + assert_eq!(r["name"], "photo-1", "the reply carries the name the document was admitted under: {r}"); +} From 6abb358b189541cddb5a406160471ec0b83b8d4c Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 7 Nov 2026 22:58:12 -0600 Subject: [PATCH 183/219] Grey the Smoothing options when the field shows 0 % (CodeRabbit review on brush_controls.rs:63) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/brush_controls.rs | 5 +++-- crates/ui-egui/src/smoothing_options_greyed_tests.rs | 11 +++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/brush_controls.rs b/crates/ui-egui/src/panels/brush_controls.rs index a0f009eb90..952823b573 100644 --- a/crates/ui-egui/src/panels/brush_controls.rs +++ b/crates/ui-egui/src/panels/brush_controls.rs @@ -58,8 +58,9 @@ pub(super) fn smoothing_options(ui: &mut egui::Ui, b: &mut photocraft_engine::Br ui.label(egui::RichText::new(tl!("Smoothing Options")).strong()); } // At 0 % there is no smoothing to tune: the four are greyed and keep their values, as in - // the reference application (#11939). - let on = s.amount > 0.0; + // the reference application (#11939). Rounded like the Smoothing field, so a value that + // shows as 0 % greys them too. + let on = (s.amount * 100.0).round() > 0.0; ui.add_enabled_ui(on, |ui| { widgets::checkbox(ui, &mut s.pulled_string, tl!("Pulled String Mode")); widgets::checkbox(ui, &mut s.catch_up, tl!("Stroke Catch-up")); diff --git a/crates/ui-egui/src/smoothing_options_greyed_tests.rs b/crates/ui-egui/src/smoothing_options_greyed_tests.rs index e7e483e03a..2eea989275 100644 --- a/crates/ui-egui/src/smoothing_options_greyed_tests.rs +++ b/crates/ui-egui/src/smoothing_options_greyed_tests.rs @@ -65,3 +65,14 @@ fn above_zero_they_work_again() { assert_ne!(values(&h)[0], before[0], "{tool:?}: Pulled String Mode toggles"); } } + +#[test] +fn a_smoothing_that_shows_as_zero_greys_them_too() { + // 0.4 % (from a preset or a script) reads 0 % in the field, so the four follow the field. + for tool in [Tool::Brush, Tool::Eraser] { + let h = bar(tool, 0.004); + for label in OPTIONS { + assert!(checkbox(&h, label).accesskit_node().is_disabled(), "{tool:?}: {label} greyed at a displayed 0 %"); + } + } +} From 3069a2d071c489b688066f182c62536e6c18b7be Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 7 Nov 2026 22:42:03 -0600 Subject: [PATCH 184/219] Mixer Brush: Useful mixer brush combinations dropdown (#11946) Between the Clean after each stroke toggle and Wet: Custom, then Dry, Moist, Wet and Very Wet with their Light/Heavy variants (separators above each group). Picking an entry sets Wet, Load and Mix in one change and leaves Flow; the picked entry stays shown after a typed value, and Reset Tool shows Custom again. --- .../src/mixer_blend_combination_tests.rs | 171 ++++++++++++++++++ crates/ui-egui/src/panels/brush_controls.rs | 50 +++++ crates/ui-egui/src/panels/options_bar.rs | 7 +- crates/ui-egui/src/state/tool_options.rs | 4 + crates/ui-egui/src/tools/tool_reset.rs | 2 +- 5 files changed, 232 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/src/mixer_blend_combination_tests.rs diff --git a/crates/ui-egui/src/mixer_blend_combination_tests.rs b/crates/ui-egui/src/mixer_blend_combination_tests.rs new file mode 100644 index 0000000000..2b4cd12782 --- /dev/null +++ b/crates/ui-egui/src/mixer_blend_combination_tests.rs @@ -0,0 +1,171 @@ +//! The Mixer Brush's Useful mixer brush combinations dropdown (#11946): between the Clean after +//! each stroke toggle and Wet, Custom then the reference's twelve entries in four groups; each +//! sets Wet, Load and Mix in one `tools.setBrush` change and leaves Flow. Reset Tool shows Custom. + +use egui::vec2; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; + +use super::{MIXER_COMBINATION_GROUPS, MIXER_COMBINATIONS}; +use crate::PhotocraftApp; +use crate::state::Tool; + +const TIP: &str = "Useful mixer brush combinations"; + +fn harness() -> Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); + app.ui.tool = Tool::MixerBrush; + crate::paint_mouse::sync_tool_brush(&mut app); + app.run("tools.setBrush", json!({"brush": {"mixer": {"flow": 0.42}}})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1800.0, 700.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + crate::panels::options_bar(app, ui); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio); + h.run_steps(4); + h +} + +fn has_fonts(h: &Harness<'static, PhotocraftApp>) -> bool { + h.ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) +} + +/// The combinations dropdown, by the entry it shows. +fn dropdown_rect(h: &Harness<'static, PhotocraftApp>, shows: &str) -> egui::Rect { + h.get_all_by_role(egui::accesskit::Role::ComboBox) + .find(|n| n.accesskit_node().value().as_deref() == Some(shows)) + .unwrap_or_else(|| panic!("no dropdown showing {shows}")) + .rect() +} + +fn open_and_pick(h: &mut Harness<'static, PhotocraftApp>, shows: &str, entry: &str) { + h.get_all_by_role(egui::accesskit::Role::ComboBox).find(|n| n.accesskit_node().value().as_deref() == Some(shows)).unwrap().click(); + h.run_steps(3); + entry_node(h, entry).click(); + h.run_steps(3); +} + +/// An entry of the open list (not the Wet field, which shares the "Wet" label). +fn entry_node<'a>(h: &'a Harness<'static, PhotocraftApp>, entry: &'a str) -> egui_kittest::Node<'a> { + h.get_all_by_label(entry).rfind(|n| n.accesskit_node().role() != egui::accesskit::Role::SpinButton).unwrap_or_else(|| panic!("no entry {entry}")) +} + +fn mixer(h: &Harness<'static, PhotocraftApp>) -> (f32, f32, f32, f32) { + let m = &h.state().session.tools.brush.mixer; + (m.wet, m.load, m.mix, m.flow) +} + +fn set_brush_count(h: &Harness<'static, PhotocraftApp>) -> usize { + h.state().session.journal.iter().filter(|(id, _)| id == "tools.setBrush").count() +} + +#[test] +fn the_dropdown_sits_before_wet_with_the_reference_entries_in_order() { + let mut h = harness(); + if !has_fonts(&h) { + return; + } + let combo = dropdown_rect(&h, "Custom"); + let clean = h.get_by_label("Clean the brush after each stroke").rect(); + let wet = h.get_all_by_role(egui::accesskit::Role::SpinButton).find(|n| n.accesskit_node().label().as_deref() == Some("Wet")).unwrap().rect(); + assert!(clean.right() <= combo.left() && combo.right() <= wet.left(), "clean {clean:?}, dropdown {combo:?}, wet {wet:?}"); + assert_eq!( + h.get_all_by_role(egui::accesskit::Role::ComboBox) + .find(|n| n.accesskit_node().value().as_deref() == Some("Custom")) + .unwrap() + .accesskit_node() + .label() + .as_deref(), + Some(TIP) + ); + let labels: Vec<&str> = MIXER_COMBINATIONS.iter().map(|(l, _)| *l).collect(); + assert_eq!( + labels, + [ + "Custom", + "Dry", + "Dry, Light Load", + "Dry, Heavy Load", + "Moist", + "Moist, Light Mix", + "Moist, Heavy Mix", + "Wet", + "Wet, Light Mix", + "Wet, Heavy Mix", + "Very Wet", + "Very Wet, Light Mix", + "Very Wet, Heavy Mix" + ] + ); + // Separators above Dry, Moist, Wet and Very Wet. + let groups: Vec<&str> = MIXER_COMBINATION_GROUPS.iter().map(|&i| MIXER_COMBINATIONS[i].0).collect(); + assert_eq!(groups, ["Dry", "Moist", "Wet", "Very Wet"]); + // The open list shows the entries top to bottom in that order. + h.get_all_by_role(egui::accesskit::Role::ComboBox).find(|n| n.accesskit_node().value().as_deref() == Some("Custom")).unwrap().click(); + h.run_steps(3); + let tops: Vec = labels[1..].iter().map(|l| entry_node(&h, l).rect().top()).collect(); + assert!(tops.windows(2).all(|w| w[0] < w[1]), "entries out of order: {tops:?}"); +} + +#[test] +fn each_entry_sets_wet_load_and_mix_in_one_change_and_keeps_flow() { + let mut h = harness(); + if !has_fonts(&h) { + return; + } + let expected = [ + ("Dry", (0.0, 0.5, 0.0)), + ("Dry, Light Load", (0.0, 0.05, 0.0)), + ("Dry, Heavy Load", (0.0, 1.0, 0.0)), + ("Moist", (0.1, 0.05, 0.5)), + ("Moist, Light Mix", (0.1, 0.05, 0.0)), + ("Moist, Heavy Mix", (0.1, 0.05, 1.0)), + ("Wet", (0.5, 0.5, 0.5)), + ("Wet, Light Mix", (0.5, 0.5, 0.0)), + ("Wet, Heavy Mix", (0.5, 0.5, 1.0)), + ("Very Wet", (1.0, 0.5, 0.5)), + ("Very Wet, Light Mix", (1.0, 0.5, 0.0)), + ("Very Wet, Heavy Mix", (1.0, 0.5, 1.0)), + ]; + let mut shows = "Custom"; + for (entry, (wet, load, mix)) in expected { + let before = set_brush_count(&h); + open_and_pick(&mut h, shows, entry); + assert_eq!(mixer(&h), (wet, load, mix, 0.42), "{entry}"); + assert_eq!(set_brush_count(&h), before + 1, "{entry}: one tools.setBrush change"); + assert_eq!(h.state().ui.tool_options.mixer_combination, MIXER_COMBINATIONS.iter().position(|(l, _)| *l == entry).unwrap()); + dropdown_rect(&h, entry); + shows = entry; + } +} + +#[test] +fn reset_tool_shows_custom_and_a_typed_value_keeps_the_pick() { + let mut h = harness(); + if !has_fonts(&h) { + return; + } + open_and_pick(&mut h, "Custom", "Wet, Heavy Mix"); + // A typed Wet keeps showing the picked entry (not measured; today's behaviour). + h.get_all_by_role(egui::accesskit::Role::SpinButton).find(|n| n.accesskit_node().numeric_value() == Some(50.0)).expect("Wet field").click(); + h.run_steps(1); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text("30".into())); + h.key_press(egui::Key::Tab); + h.run_steps(3); + assert_eq!(mixer(&h).0, 0.3); + dropdown_rect(&h, "Wet, Heavy Mix"); + // Reset Tool: Custom again. + let ctx = h.ctx.clone(); + crate::menus::invoke(h.state_mut(), &ctx, "tool.reset", json!({})).unwrap(); + h.run_steps(3); + assert_eq!(h.state().ui.tool_options.mixer_combination, 0); + dropdown_rect(&h, "Custom"); +} diff --git a/crates/ui-egui/src/panels/brush_controls.rs b/crates/ui-egui/src/panels/brush_controls.rs index ddd45f38ba..0cbf5539e8 100644 --- a/crates/ui-egui/src/panels/brush_controls.rs +++ b/crates/ui-egui/src/panels/brush_controls.rs @@ -64,6 +64,56 @@ pub(super) fn smoothing_options(ui: &mut egui::Ui, b: &mut photocraft_engine::Br }); } +/// A Useful mixer brush combinations entry: its label and the Wet, Load and Mix it sets. +type MixerCombination = (&'static str, Option<(f32, f32, f32)>); + +/// The Mixer Brush's Useful mixer brush combinations, in the reference's order: each entry's label +/// and the Wet, Load and Mix (0..1) it sets. Custom, the first, sets nothing. +pub(crate) const MIXER_COMBINATIONS: [MixerCombination; 13] = [ + ("Custom", None), + ("Dry", Some((0.0, 0.5, 0.0))), + ("Dry, Light Load", Some((0.0, 0.05, 0.0))), + ("Dry, Heavy Load", Some((0.0, 1.0, 0.0))), + ("Moist", Some((0.1, 0.05, 0.5))), + ("Moist, Light Mix", Some((0.1, 0.05, 0.0))), + ("Moist, Heavy Mix", Some((0.1, 0.05, 1.0))), + ("Wet", Some((0.5, 0.5, 0.5))), + ("Wet, Light Mix", Some((0.5, 0.5, 0.0))), + ("Wet, Heavy Mix", Some((0.5, 0.5, 1.0))), + ("Very Wet", Some((1.0, 0.5, 0.5))), + ("Very Wet, Light Mix", Some((1.0, 0.5, 0.0))), + ("Very Wet, Heavy Mix", Some((1.0, 0.5, 1.0))), +]; + +/// The entries of [`MIXER_COMBINATIONS`] a separator line goes above: Dry, Moist, Wet, Very Wet. +const MIXER_COMBINATION_GROUPS: [usize; 4] = [1, 4, 7, 10]; + +/// Mixer Brush: the Useful mixer brush combinations dropdown (#11946). Picking an entry sets Wet, +/// Load and Mix together (Flow stays) and keeps showing it, also after a value is typed; Custom +/// sets nothing. `picked` is the entry shown. +pub(super) fn mixer_combination_dropdown( + ui: &mut egui::Ui, + picked: &mut usize, + m: &mut photocraft_engine::paint::mixer::MixerSettings, +) -> Option { + const ID: &str = "mixer-combination"; + let opts: Vec<(usize, &str)> = MIXER_COMBINATIONS.iter().enumerate().map(|(i, (label, _))| (i, *label)).collect(); + let mut shown = (*picked).min(MIXER_COMBINATIONS.len() - 1); + let chosen = + widgets::dropdown_wheel_grouped(ui, ID, tl!("Useful mixer brush combinations"), &mut shown, &opts, 132.0, |i| MIXER_COMBINATION_GROUPS.contains(i)).0; + if let Some(&i) = chosen.last() { + *picked = i; + if let Some((wet, load, mix)) = MIXER_COMBINATIONS[i].1 { + (m.wet, m.load, m.mix) = (wet, load, mix); + } + } + ui.ctx().read_response(ui.make_persistent_id(egui::IdSalt::new(ID))) +} + +#[cfg(test)] +#[path = "../mixer_blend_combination_tests.rs"] +mod mixer_blend_combination_tests; + /// Mixer Brush: the Current Brush Load swatch shows the paint the next stroke starts with (an /// empty swatch when the brush is clean). Its menu holds Load Brush and Clean Brush. pub(super) fn mixer_load_swatch(app: &mut PhotocraftApp, ui: &mut egui::Ui) { diff --git a/crates/ui-egui/src/panels/options_bar.rs b/crates/ui-egui/src/panels/options_bar.rs index df45e61d01..fa12991295 100644 --- a/crates/ui-egui/src/panels/options_bar.rs +++ b/crates/ui-egui/src/panels/options_bar.rs @@ -1,4 +1,6 @@ -use super::brush_controls::{brush_preset_chip, mixer_load_swatch, paint_mode_dropdown, smoothing_field, smoothing_options, tool_mode_dropdown}; +use super::brush_controls::{ + brush_preset_chip, mixer_combination_dropdown, mixer_load_swatch, paint_mode_dropdown, smoothing_field, smoothing_options, tool_mode_dropdown, +}; use super::properties::{select_and_mask_button, selection_mode_buttons}; use super::{BlendMode, Color32, PhotocraftApp, RichText, Sense, Tokens, Tool, Value, icons, json, starts_blend_group, vec2, widgets}; use photocraft_paint::BrushSettings; @@ -279,6 +281,9 @@ fn brush_specific_options(app: &mut PhotocraftApp, ui: &mut egui::Ui, t: Tokens, *on = !*on; } } + if let Some(r) = mixer_combination_dropdown(ui, &mut app.ui.tool_options.mixer_combination, &mut b.mixer) { + r.on_hover_text(tl!("Useful mixer brush combinations")); + } widgets::vline(ui, 22.0); for (label, value) in [(tl!("Wet"), &mut b.mixer.wet), (tl!("Load"), &mut b.mixer.load), (tl!("Mix"), &mut b.mixer.mix), (tl!("Flow"), &mut b.mixer.flow)] diff --git a/crates/ui-egui/src/state/tool_options.rs b/crates/ui-egui/src/state/tool_options.rs index e5db1af422..51f40e6123 100644 --- a/crates/ui-egui/src/state/tool_options.rs +++ b/crates/ui-egui/src/state/tool_options.rs @@ -251,6 +251,9 @@ pub struct ToolOptions { /// `paint.pencil`), as in Photoshop's options bar (#2662). #[serde(default = "default_eraser_mode")] pub eraser_mode: String, + /// Mixer Brush › Useful mixer brush combinations: the entry last picked, an index into + /// `brush_controls::MIXER_COMBINATIONS` (0 = Custom, #11946). + pub mixer_combination: usize, /// Magnetic Lasso: detection width (px, 1..256), edge contrast (%, 1..100), how often it /// fastens points by itself (0..100), and whether pen pressure narrows the width. pub magnetic_width: f32, @@ -532,6 +535,7 @@ impl Default for ToolOptions { brush_clear: false, pencil_clear: false, eraser_mode: default_eraser_mode(), + mixer_combination: 0, magnetic_width: 10.0, magnetic_contrast: 10.0, magnetic_frequency: 57.0, diff --git a/crates/ui-egui/src/tools/tool_reset.rs b/crates/ui-egui/src/tools/tool_reset.rs index 148599b50c..a16ff53723 100644 --- a/crates/ui-egui/src/tools/tool_reset.rs +++ b/crates/ui-egui/src/tools/tool_reset.rs @@ -262,11 +262,11 @@ pub fn option_keys(tool: Tool) -> &'static [&'static str] { Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::Star | Tool::CustomShape => { reset!(shape_mode, shape_fill, stroke_width, shape_stroke, shape_fill_gradient, shape_stroke_gradient, corner_radius, polygon_sides, line_weight) } + Tool::MixerBrush => reset!(mixer_combination), Tool::ColorSampler | Tool::Ruler | Tool::Note | Tool::Count - | Tool::MixerBrush | Tool::Hand | Tool::RotateView | Tool::HistoryBrush From cac9c70afc5afcdeb4d494188f6074ca893c05ad Mon Sep 17 00:00:00 2001 From: DuBearte <130190089+DuBearte@users.noreply.github.com> Date: Sun, 8 Nov 2026 00:11:58 -0500 Subject: [PATCH 185/219] Zoom tool bar: 100%, Fit Screen and Fill Screen with the reference tooltips (#12068) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/options_bar.rs | 41 ++++++----- .../panels/options_bar/zoom_buttons_tests.rs | 69 +++++++++++++++++++ 2 files changed, 93 insertions(+), 17 deletions(-) create mode 100644 crates/ui-egui/src/panels/options_bar/zoom_buttons_tests.rs diff --git a/crates/ui-egui/src/panels/options_bar.rs b/crates/ui-egui/src/panels/options_bar.rs index df45e61d01..b4bd565c76 100644 --- a/crates/ui-egui/src/panels/options_bar.rs +++ b/crates/ui-egui/src/panels/options_bar.rs @@ -649,25 +649,13 @@ fn tool_specific_options(app: &mut PhotocraftApp, ui: &mut egui::Ui, t: Tokens, // Resize Windows to Fit is Preferences › Zoom Resizes Windows itself, as in // Photoshop: one setting, shown in both places (zoom_window_fit.rs). let mut fit = app.session.prefs().general.zoom_resizes_windows; - if widgets::checkbox(ui, &mut fit, tl!("Resize Windows to Fit")).changed() { + if widgets::checkbox(ui, &mut fit, tl!("Resize Windows to Fit")).on_hover_text(tl!("Resize windows when zooming using the Zoom Tool")).changed() { app.session.prefs.edit(|p| p.general.zoom_resizes_windows = fit); } - widgets::checkbox(ui, &mut app.ui.tool_options.zoom_scrubby, tl!("Scrubby Zoom")); - hint( - ui, - &crate::i18n::fmt( - tl!("Click to zoom in · {key}-click to zoom out · drag right/left to zoom in/out"), - &[("key", &crate::shortcuts::pretty("Alt"))], - ), - ); - if widgets::secondary_button(ui, tl!("Fit Screen"), 0.0).clicked() - && let Some(i) = app.session.active_index() - { - app.ui.views[i].fit_pending = true; - app.ui.views[i].request_zoom_animation(app.session.prefs().general.animated_zoom); - app.ui.views[i].zoom_anchor = None; - } - if widgets::secondary_button(ui, "100%", 0.0).clicked() + widgets::checkbox(ui, &mut app.ui.tool_options.zoom_scrubby, tl!("Scrubby Zoom")) + .on_hover_text(tl!("Click and move left or right to scale using the Zoom Tool (OpenGL only)")); + // The same three buttons as the Hand bar, with the reference tooltips (#12068). + if widgets::secondary_button(ui, "100%", 0.0).on_hover_text(tl!("Zoom current window to 1:1")).clicked() && let Some(i) = app.session.active_index() { app.ui.views[i].zoom = 1.0; @@ -676,6 +664,22 @@ fn tool_specific_options(app: &mut PhotocraftApp, ui: &mut egui::Ui, t: Tokens, app.ui.views[i].fit_pending = false; app.ui.views[i].fill_pending = false; } + if widgets::secondary_button(ui, tl!("Fit Screen"), 0.0).on_hover_text(tl!("Zoom current window to screen size")).clicked() + && let Some(i) = app.session.active_index() + { + app.ui.views[i].fit_pending = true; + app.ui.views[i].request_zoom_animation(app.session.prefs().general.animated_zoom); + app.ui.views[i].zoom_anchor = None; + app.ui.views[i].fill_pending = false; + } + if widgets::secondary_button(ui, tl!("Fill Screen"), 0.0).on_hover_text(tl!("Zoom current window to fill the screen")).clicked() + && let Some(i) = app.session.active_index() + { + app.ui.views[i].fill_pending = true; + app.ui.views[i].request_zoom_animation(app.session.prefs().general.animated_zoom); + app.ui.views[i].zoom_anchor = None; + app.ui.views[i].fit_pending = false; + } } Tool::RotateView => { hint(ui, tl!("Drag around the center to rotate the view · Shift constrains to 15°")); @@ -727,6 +731,9 @@ fn tool_specific_options(app: &mut PhotocraftApp, ui: &mut egui::Ui, t: Tokens, } } +#[cfg(test)] +mod zoom_buttons_tests; + /// Width of the Layers panel's Opacity and Fill fields, with room for their pop-up slider's ▾. pub(super) const LAYER_PCT_W: f32 = 66.0 + widgets::POPUP_ARROW_W; diff --git a/crates/ui-egui/src/panels/options_bar/zoom_buttons_tests.rs b/crates/ui-egui/src/panels/options_bar/zoom_buttons_tests.rs new file mode 100644 index 0000000000..ce00ce0bc7 --- /dev/null +++ b/crates/ui-egui/src/panels/options_bar/zoom_buttons_tests.rs @@ -0,0 +1,69 @@ +//! #12068: the Zoom tool's bar shows 100%, Fit Screen and Fill Screen in that order, with the +//! reference tooltips, and no hint text. + +use egui::vec2; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::state::Tool; + +fn zoom_bar() -> Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 3000, "height": 2000})).unwrap(); + // The bar's fonts are installed after the first frame, so it starts on a tool without "100%". + app.ui.tool = Tool::Move; + let mut h = Harness::builder().with_size(vec2(1600.0, 300.0)).with_step_dt(1.0 / 60.0).build_ui_state(|ui, app| crate::panels::options_bar(app, ui), app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ProMedium); + h.state_mut().ui.tool = Tool::Zoom; + h.run_steps(4); + h +} + +#[test] +fn the_buttons_are_100_fit_and_fill_in_that_order() { + let h = zoom_bar(); + let x = |label: &str| h.get_by_label(label).rect().center().x; + assert!(x("Scrubby Zoom") < x("100%"), "after the checkboxes"); + assert!(x("100%") < x("Fit Screen") && x("Fit Screen") < x("Fill Screen")); +} + +#[test] +fn each_control_has_the_reference_tooltip() { + for (label, tip) in [ + ("100%", "Zoom current window to 1:1"), + ("Fit Screen", "Zoom current window to screen size"), + ("Fill Screen", "Zoom current window to fill the screen"), + ("Resize Windows to Fit", "Resize windows when zooming using the Zoom Tool"), + ("Scrubby Zoom", "Click and move left or right to scale using the Zoom Tool (OpenGL only)"), + ] { + let mut h = zoom_bar(); + h.get_by_label(label).hover(); + h.run_steps(60); + assert!(h.query_by_label(tip).is_some(), "{label}: no tooltip {tip:?}"); + } +} + +#[test] +fn the_bar_shows_no_hint_text() { + let h = zoom_bar(); + assert!(h.query_by_label_contains("to zoom out").is_none()); + assert!(h.query_by_label_contains("Click to zoom in").is_none()); +} + +#[test] +fn fill_screen_fills_as_on_the_hand_bar() { + let mut h = zoom_bar(); + h.get_by_label("Fit Screen").click(); + h.run_steps(2); + assert!(h.state().ui.views[0].fit_pending); + h.get_by_label("Fill Screen").click(); + h.run_steps(2); + let v = &h.state().ui.views[0]; + assert!(v.fill_pending && !v.fit_pending); + h.get_by_label("100%").click(); + h.run_steps(2); + let v = &h.state().ui.views[0]; + assert!(!v.fill_pending && !v.fit_pending && v.zoom == 1.0); +} From a3778b44766aea05e01cd3e3e62e206c0eb16fb0 Mon Sep 17 00:00:00 2001 From: Triwarr <170976175+Triwarr@users.noreply.github.com> Date: Sun, 8 Nov 2026 06:13:29 +0100 Subject: [PATCH 186/219] PDF: address editable-import review findings - Colours: AlphaColor components are straight, not premultiplied; use them directly instead of dividing by alpha (the opacity test now checks RGB, not just alpha). - Text: key families by font content key, not PostScript name, so two faces with one name cannot share a family. Keep text layers at the path index where their glyphs were painted instead of appending them after all paths. - Correctness: reject bow-tie clip paths, start a new subpath after close, fall back on unparseable/unsupported glyph mappings, truncated content, failed font registration, and shape-cache budget exhaustion. - Cancellation: check between text-cache renders as well as between objects. - Include Bezier control points in the cache-size estimate; cache the content hash per Hayro font identity. - Docs: ImportOptions/CLI help now describe editable text and the embedded-font caveat. Tests: text cache is not blank, paint order, bow-tie clip, line after close, cancellation between objects. --- apps/photocraft-cli/src/args.rs | 3 +- crates/io/src/lib.rs | 9 +- crates/io/src/pdf_editable.rs | 287 ++++++++++++++++++++++++-------- 3 files changed, 227 insertions(+), 72 deletions(-) diff --git a/apps/photocraft-cli/src/args.rs b/apps/photocraft-cli/src/args.rs index c450c94442..2baf000039 100644 --- a/apps/photocraft-cli/src/args.rs +++ b/apps/photocraft-cli/src/args.rs @@ -125,7 +125,8 @@ pub(crate) struct ConvertArgs { /// PDF pages to import, 1-based: "3" or "2-7" (default all) #[arg(long)] pub(crate) pdf_pages: Option, - /// Import PDF vector paths as editable shape layers (experimental; text still rasterises) + /// Import PDF paths and embedded-font text as editable layers (experimental; unsupported + /// pages fall back to raster, and embedded fonts may change when edited or reopened) #[arg(long)] pub(crate) pdf_editable: bool, #[command(flatten)] diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 40dd1c0755..c796b7cffa 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -614,9 +614,12 @@ pub struct ImportOptions { pub pdf_dpi: f32, /// The 1-based, inclusive page range to import; `None` imports every page (up to the cap). pub pdf_pages: Option<(u32, u32)>, - /// Import PDF painted paths as editable [`photocraft_doc::ShapeLayer`]s instead of a page - /// raster (experimental). Text, images, clipping paths and the other unsupported constructs - /// make that page fall back to the raster importer with a warning. Off by default. + /// Import PDF painted paths and embedded-font text as editable [`photocraft_doc::ShapeLayer`]s + /// and text layers instead of a page raster (experimental). Images, complex clips/masks, + /// patterns, non-normal blends and unembedded/Type 3 text make that page fall back to the + /// raster importer with a warning. Text that uses fonts embedded in the PDF may change font + /// when edited or reopened, because the document format does not carry the font bytes. Off by + /// default. pub pdf_editable: bool, /// Explicit one-shot authorization for imports that exceed the soft budget but fit within the hard budget. pub psd_high_memory_approved: bool, diff --git a/crates/io/src/pdf_editable.rs b/crates/io/src/pdf_editable.rs index 4f5cafc436..1b91a4ff61 100644 --- a/crates/io/src/pdf_editable.rs +++ b/crates/io/src/pdf_editable.rs @@ -10,16 +10,17 @@ //! is what makes moving or deleting a layer behave, and what avoids the black-path and //! doubled-text failures of the old PDFium import. +use std::collections::HashMap; use std::panic::AssertUnwindSafe; use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; use std::time::Duration; use craft_pdf::interpret::color::Color as HayroColor; use craft_pdf::interpret::font::{FontData, Glyph, OutlineFontData}; use craft_pdf::interpret::{ - BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask, TransformExt, - interpret_page, + BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, InterpreterWarning, Paint, PathDrawMode, SoftMask, + TransformExt, interpret_page, }; use craft_pdf::syntax::page::Page; use kurbo::{Affine as KurboAffine, BezPath, Cap, Join, PathEl}; @@ -70,7 +71,20 @@ pub fn page(page: &Page<'_>, rect: Rect, ctl: &Interrupt) -> Result, let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::U8, true); let cancelled = Arc::new(AtomicBool::new(false)); let finished = Arc::new(AtomicBool::new(false)); - let settings = InterpreterSettings { cancelled: Some(Arc::clone(&cancelled)), ..InterpreterSettings::default() }; + let warned = Arc::new(AtomicU8::new(0)); + let sink = Arc::clone(&warned); + let settings = InterpreterSettings { + cancelled: Some(Arc::clone(&cancelled)), + warning_sink: Arc::new(move |warning| { + let bit = match warning { + InterpreterWarning::ImageDecodeFailure => 1, + InterpreterWarning::UnsupportedFont => 2, + InterpreterWarning::ContentTruncated => 4, + }; + sink.fetch_or(bit, Ordering::Relaxed); + }), + ..InterpreterSettings::default() + }; let cache = InterpreterCache::new(); let mut device = PathDevice { doc, @@ -82,6 +96,7 @@ pub fn page(page: &Page<'_>, rect: Rect, ctl: &Interrupt) -> Result, glyphs: Vec::new(), strokes: Vec::new(), fonts: Vec::new(), + font_keys: HashMap::new(), clips: Vec::new(), knots: 0, cached: 0, @@ -115,15 +130,25 @@ pub fn page(page: &Page<'_>, rect: Rect, ctl: &Interrupt) -> Result, if device.cancelled || ctl.cancelled() { return Err(EditableError::Cancelled); } + let warned = warned.load(Ordering::Relaxed); + if warned & 4 != 0 { + return Err(EditableError::Unsupported("the page content was truncated")); + } + if warned & 2 != 0 { + return Err(EditableError::Unsupported("a font could not be used")); + } + if warned & 1 != 0 { + return Err(EditableError::Unsupported("an image could not be decoded")); + } if let Some(reason) = device.unsupported { return Err(EditableError::Unsupported(reason)); } if let Err(reason) = pair_fake_bold(&mut device.glyphs, device.strokes) { return Err(EditableError::Unsupported(reason)); } - let mut layers = device.layers; - layers.extend(text_layers(&device.glyphs)); - render_text_caches(&mut layers, device.dpi, device.fmt, &device.fonts); + let families = register_fonts(&device.fonts)?; + let mut layers = merge_text_layers(device.layers, text_layers(&device.glyphs, &families)); + render_text_caches(&mut layers, device.dpi, device.fmt, ctl)?; Ok(layers) } @@ -150,12 +175,14 @@ fn pair_fake_bold(glyphs: &mut [GlyphRun], strokes: Vec) -> Result<(), Ok(()) } -/// Builds one point-text layer per line and style run. -fn text_layers(glyphs: &[GlyphRun]) -> Vec { +/// Builds one point-text layer per line and style run, with the layer index it must be inserted +/// at to preserve paint order relative to paths. +fn text_layers(glyphs: &[GlyphRun], families: &std::collections::HashMap) -> Vec<(usize, Layer)> { let mut out = Vec::new(); let mut i = 0; while i < glyphs.len() { let first = &glyphs[i]; + let family = families.get(&first.font.key).cloned().unwrap_or_else(|| first.font.family.clone()); let (dx, dy) = (first.angle.cos(), first.angle.sin()); let (nx, ny) = (-dy, dx); let baseline = first.origin.x * nx + first.origin.y * ny; @@ -164,7 +191,7 @@ fn text_layers(glyphs: &[GlyphRun]) -> Vec { let mut last = first; while i < glyphs.len() { let glyph = &glyphs[i]; - if !same_style(glyph, first) { + if glyph.order != first.order || !same_style(glyph, first) { break; } let line = glyph.origin.x * nx + glyph.origin.y * ny; @@ -188,7 +215,7 @@ fn text_layers(glyphs: &[GlyphRun]) -> Vec { } let text = text.trim().to_string(); if !text.is_empty() { - out.push(text_layer(text, first, out.len())); + out.push((first.order, text_layer(text, first, out.len(), &family))); } if i == start { i += 1; @@ -212,8 +239,8 @@ fn same_style(a: &GlyphRun, b: &GlyphRun) -> bool { && (a.color.alpha - b.color.alpha).abs() < 0.004 } -/// One line's point-text layer. -fn text_layer(text: String, run: &GlyphRun, number: usize) -> Layer { +/// One line's point-text layer, with the family the shared engine registered. +fn text_layer(text: String, run: &GlyphRun, number: usize, family: &str) -> Layer { let weight = if run.fake_bold { 700 } else { run.font.weight }; let font_style = match (weight >= 600, run.font.italic) { (true, true) => "Bold Italic", @@ -222,18 +249,19 @@ fn text_layer(text: String, run: &GlyphRun, number: usize) -> Layer { (false, false) => "Regular", }; let style = CharStyle { - font_family: run.font.family.clone(), + font_family: family.to_string(), font_style: font_style.to_string(), postscript_name: Some(run.font.postscript.clone()), weight: u16::try_from(weight).unwrap_or(400), italic: run.font.italic, + faux_bold: run.fake_bold, size_pt: run.size_pt as f32, color: run.color, ..CharStyle::default() }; let layer = TextLayer { text: text.clone(), - font_family: run.font.family.clone(), + font_family: family.to_string(), size_pt: run.size_pt as f32, color: run.color, transform: Affine::translate(run.origin.x, run.origin.y).mul(&Affine::rotate(run.angle)), @@ -243,48 +271,70 @@ fn text_layer(text: String, run: &GlyphRun, number: usize) -> Layer { Layer::new(format!("Text {}", number + 1), LayerContent::Text(layer)) } -/// Registers each embedded font with the text engine, renames the runs to the family the font -/// itself reports, and renders the caches so the page is complete before the engine refreshes it. -fn render_text_caches(layers: &mut [Layer], dpi: f64, fmt: PixelFormat, fonts: &[(u128, String, String, FontData)]) { +/// Registers each embedded face with the shared engine under its content-keyed family and returns +/// the family each font key must use. +fn register_fonts(fonts: &[(u128, String, String, FontData)]) -> Result, EditableError> { let shared = photocraft_text::shared(); + let mut engine = shared.lock().unwrap_or_else(|error| error.into_inner()); let mut families = std::collections::HashMap::new(); - { - let mut engine = shared.lock().unwrap_or_else(|error| error.into_inner()); - for (key, postscript, derived, data) in fonts { - // Prefer an installed full family; a subset only holds the glyphs the page uses. The - // Device has the font program's PostScript name, not the PDF `/BaseFont`; a - // content-keyed alias keeps two documents' subsets of one name apart for the life of - // the shared engine, and the same face imported twice reuses its registration. - let family = if engine.has_family(derived) { - derived.clone() + for (key, postscript, derived, data) in fonts { + // Prefer an installed full family; a subset only holds the glyphs the page uses. The + // Device has the font program's PostScript name, not the PDF `/BaseFont`; a content-keyed + // alias keeps two documents' subsets of one name apart for the life of the shared engine, + // and the same face imported twice reuses its registration. + let family = if engine.has_family(derived) { + derived.clone() + } else { + let alias = font_alias(postscript, *key); + if engine.has_family(&alias) { + alias } else { - let alias = font_alias(postscript, *key); - if engine.has_family(&alias) { - alias - } else { - let names = engine.register_font_data_as(data.as_ref().as_ref().to_vec(), &alias); - names.first().cloned().unwrap_or(alias) + let names = engine.register_font_data_as(data.as_ref().as_ref().to_vec(), &alias); + if names.is_empty() && !engine.has_family(&alias) { + return Err(EditableError::Unsupported("a font could not be registered")); } - }; - families.insert(postscript.clone(), family); - } + names.first().cloned().unwrap_or(alias) + } + }; + families.insert(*key, family); } + Ok(families) +} + +/// Renders the text caches so the page is complete before the engine refreshes it. +fn render_text_caches(layers: &mut [Layer], dpi: f64, fmt: PixelFormat, ctl: &Interrupt) -> Result<(), EditableError> { + let shared = photocraft_text::shared(); for layer in layers { - if let LayerContent::Text(text) = &mut layer.content { - if let Some(family) = text.runs.first().and_then(|run| run.style.postscript_name.as_ref()).and_then(|postscript| families.get(postscript)) { - text.font_family.clone_from(family); - if let Some(run) = text.runs.first_mut() { - run.style.font_family.clone_from(family); - } - } - if !text.text.trim().is_empty() { - // Re-lock per layer so a text-heavy import does not hold the UI's engine for the - // whole cache-render loop. - let mut engine = shared.lock().unwrap_or_else(|error| error.into_inner()); - let _ = engine.render_layer(text, dpi as f32, fmt); - } + if ctl.check().is_err() { + return Err(EditableError::Cancelled); + } + if let LayerContent::Text(text) = &mut layer.content + && !text.text.trim().is_empty() + { + // Re-lock per layer so a text-heavy import does not hold the UI's engine for the + // whole cache-render loop. + let mut engine = shared.lock().unwrap_or_else(|error| error.into_inner()); + let _ = engine.render_layer(text, dpi as f32, fmt); } } + Ok(()) +} + +/// Puts text layers back at the path index where their glyphs were painted. +fn merge_text_layers(layers: Vec, mut texts: Vec<(usize, Layer)>) -> Vec { + texts.sort_by_key(|(order, _)| *order); + let mut out = Vec::with_capacity(layers.len() + texts.len()); + let mut texts = texts.into_iter().peekable(); + for (index, layer) in layers.into_iter().enumerate() { + while texts.peek().is_some_and(|(order, _)| *order <= index) { + if let Some((_, text)) = texts.next() { + out.push(text); + } + } + out.push(layer); + } + out.extend(texts.map(|(_, layer)| layer)); + out } /// A content key for an embedded face, used to group its glyphs and to keep aliases unique. @@ -319,6 +369,8 @@ struct PathDevice<'a> { strokes: Vec, /// Embedded fonts met on the page: cache key, PostScript name, derived family and bytes. fonts: Vec<(u128, String, String, FontData)>, + /// Hayro's cheap font identity → our content hash, so bytes are hashed once per face. + font_keys: HashMap, /// One entry per clip pushed by the interpreter: `true` for an accepted no-op clip. clips: Vec, knots: usize, @@ -341,6 +393,9 @@ struct GlyphRun { /// Advance in document pixels. advance: f64, font: FontInfo, + /// How many layers had been drawn when this glyph was painted; text runs split at a path + /// between glyphs so paint order is preserved. + order: usize, color: Color, /// Drawn both filled and stroked: word-processor fake bold. fake_bold: bool, @@ -397,7 +452,11 @@ impl PathDevice<'_> { .intersect(&self.clip); let bytes = u64::from(area.width()).saturating_mul(u64::from(area.height())).saturating_mul(self.fmt.bytes_per_pixel() as u64); let mut shape = ShapeLayer { path, fill, stroke, ..ShapeLayer::default() }; - if area.width() > 0 && area.height() > 0 && self.cached.saturating_add(bytes) <= MAX_CACHE_BYTES { + if area.width() > 0 && area.height() > 0 { + if self.cached.saturating_add(bytes) > MAX_CACHE_BYTES { + self.unsupported("shape cache budget"); + return; + } shape.cache = Some(photocraft_vector::render_shape(&shape, self.fmt, self.clip)); self.cached = self.cached.saturating_add(bytes); } @@ -495,7 +554,11 @@ impl<'a> Device<'a> for PathDevice<'_> { let text = match glyph.as_unicode() { Some(craft_pdf::interpret::hayro_cmap::BfString::Char(c)) => c.to_string(), Some(craft_pdf::interpret::hayro_cmap::BfString::String(s)) => s, - None => return, + None => { + // No Unicode means the text layer could not be edited faithfully. + self.unsupported("text without a Unicode mapping"); + return; + } }; if text.is_empty() || text.chars().all(|c| c.is_control()) { return; @@ -549,13 +612,14 @@ impl<'a> Device<'a> for PathDevice<'_> { let advance = f64::from(outline.advance_width().unwrap_or(500.0)) / 1000.0 * size_px; let weight = font_weight(&data, &postscript); let italic = data.is_italic || name_has(&postscript, &["italic", "oblique"]); - let key = font_content_key(&data); + let identity = outline.font_cache_key(); + let key = *self.font_keys.entry(identity).or_insert_with(|| font_content_key(&data)); let family = family_from_postscript(&postscript); if !self.fonts.iter().any(|(existing, _, _, _)| *existing == key) { self.fonts.push((key, postscript.clone(), family.clone(), data.data.clone())); } let font = FontInfo { key, family, postscript, weight, italic }; - let glyph = GlyphRun { text, origin, size_pt, size_px, angle, advance, font, color, fake_bold: false }; + let glyph = GlyphRun { text, origin, size_pt, size_px, angle, advance, font, color, fake_bold: false, order: self.layers.len() }; if matches!(draw_mode, GlyphDrawMode::Stroke(_)) { self.strokes.push(glyph); } else { @@ -700,6 +764,18 @@ fn axis_aligned_rect(path: &BezPath) -> Option<(f64, f64, f64, f64)> { if points.iter().any(|(x, y)| !on_edge(*x, x0, x1) || !on_edge(*y, y0, y1)) { return None; } + // A bow-tie traversal also visits the four corners, so require its shoelace area to match + // the bounding box (a real rectangle covers it exactly). + let mut twice_area = 0.0; + for i in 0..4 { + let (ax, ay) = points[i]; + let (bx, by) = points[(i + 1) % 4]; + twice_area += ax * by - bx * ay; + } + let box_area = (x1 - x0) * (y1 - y0); + if (twice_area.abs() * 0.5 - box_area).abs() > box_area * 1.0e-3 { + return None; + } Some((x0, y0, x1, y1)) } @@ -719,13 +795,10 @@ fn uniform_transform(transform: &KurboAffine) -> bool { /// A PDF colour as a straight-alpha PhotoCraft colour. fn to_color(color: &HayroColor) -> Color { let rgba = color.to_rgba(); + // `AlphaColor` components are straight (not premultiplied), despite the raster path being + // premultiplied: `Color::to_rgba8` reads them as straight too. let [r, g, b, a] = rgba.components(); - let a = a.clamp(0.0, 1.0); - if a <= 0.0 { - return Color::rgba(0.0, 0.0, 0.0, 0.0); - } - // `AlphaColor` components are premultiplied; PhotoCraft colours are straight. - Color::rgba((r / a).clamp(0.0, 1.0), (g / a).clamp(0.0, 1.0), (b / a).clamp(0.0, 1.0), a) + Color::rgba(r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0), a.clamp(0.0, 1.0)) } /// A kurbo affine as PhotoCraft's affine (same `[a, b, c, d, e, f]` layout). @@ -738,10 +811,12 @@ fn knots_affine(transform: &KurboAffine) -> Affine { fn path_bounds(path: &Path) -> (f64, f64, f64, f64) { let mut b = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); for knot in path.subpaths.iter().flat_map(|subpath| &subpath.knots) { - b.0 = b.0.min(knot.anchor.x); - b.1 = b.1.min(knot.anchor.y); - b.2 = b.2.max(knot.anchor.x); - b.3 = b.3.max(knot.anchor.y); + for point in [knot.anchor, knot.in_ctrl, knot.out_ctrl] { + b.0 = b.0.min(point.x); + b.1 = b.1.min(point.y); + b.2 = b.2.max(point.x); + b.3 = b.3.max(point.y); + } } b } @@ -792,7 +867,9 @@ fn to_path(bez: &BezPath, transform: &Affine, knots: &mut usize) -> Option for element in bez.elements() { match *element { PathEl::MoveTo(p) => { - if let Some(subpath) = current.take() { + if let Some(subpath) = current.take() + && subpath.knots.len() > 1 + { subpaths.push(subpath); } current = Some(Subpath { closed: false, knots: vec![Knot::corner(p.x, p.y)], op: PathOp::Combine }); @@ -833,6 +910,7 @@ fn to_path(bez: &BezPath, transform: &Affine, knots: &mut usize) -> Option } PathEl::ClosePath => { if let Some(mut subpath) = current.take() { + let restart = subpath.knots.first().map(|knot| knot.anchor); subpath.closed = true; if subpath.knots.len() > 1 { let first = subpath.knots[0].anchor; @@ -844,13 +922,21 @@ fn to_path(bez: &BezPath, transform: &Affine, knots: &mut usize) -> Option } else { subpath.knots[0].in_ctrl = first; } + subpaths.push(subpath); + } + // A close starts a new subpath at the closed subpath's start point; a + // following line/curve continues from there (PDF 32000-1 §8.5.2.1). + if let Some(start) = restart { + current = Some(Subpath { closed: false, knots: vec![Knot::corner(start.x, start.y)], op: PathOp::Combine }); + last = start; } - subpaths.push(subpath); } } } } - if let Some(subpath) = current { + if let Some(subpath) = current + && subpath.knots.len() > 1 + { subpaths.push(subpath); } *knots = knots.saturating_add(subpaths.iter().map(|subpath| subpath.knots.len()).sum::()); @@ -1224,12 +1310,14 @@ mod tests { "GS0" => dictionary! { "ca" => 0.5, "CA" => 0.5 }, }, }; - let bytes = pdf_with_resources("/GS0 gs 1 0 0 rg 20 20 60 40 re f 0 0 1 RG 4 w 20 80 m 180 80 l S", resources); + let bytes = pdf_with_resources("/GS0 gs 0.2 0.4 0.6 rg 20 20 60 40 re f 0 0 1 RG 4 w 20 80 m 180 80 l S", resources); let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; let imported = import_with_options_and_interrupt("opacity.pdf", &bytes, &options, &Interrupt::NONE).expect("import"); let layers = children(&imported.document); let LayerContent::Shape(fill) = &layers[0].content else { panic!("not a shape") }; - assert!((solid(fill.fill.as_ref().expect("a fill")).alpha - 0.5).abs() < 0.01); + let fill = solid(fill.fill.as_ref().expect("a fill")); + assert!((fill.alpha - 0.5).abs() < 0.01); + assert!((fill.c[0] - 0.2).abs() < 0.01 && (fill.c[1] - 0.4).abs() < 0.01 && (fill.c[2] - 0.6).abs() < 0.01, "{fill:?}"); let LayerContent::Shape(stroke) = &layers[1].content else { panic!("not a shape") }; assert!((solid(&stroke.stroke.as_ref().expect("a stroke").paint).alpha - 0.5).abs() < 0.01); } @@ -1310,6 +1398,69 @@ mod tests { } let mean = sum / av.len() as f64; assert!(mean < 0.02, "mean pixel difference {mean:.4} (max {max:.3})"); + // The cache must actually contain text: a blank layer can still pass a mean comparison. + let channels = fmt.channels(); + let width = rect.width() as usize; + let mut red = 0; + for y in 30..60usize { + for x in 20..45usize { + let at = (y * width + x) * channels; + if av[at] > 0.4 && av[at + 1] < 0.4 && av[at + 2] < 0.4 { + red += 1; + } + } + } + assert!(red > 0, "the editable text cache is blank"); + } + + #[test] + fn text_keeps_paint_order_relative_to_paths() { + let bytes = text_pdf("BT /F1 24 Tf 20 50 Td (Hi) Tj ET 0 0 1 rg 0 0 200 100 re f"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("order.pdf", &bytes, &options, &Interrupt::NONE).expect("import"); + let layers = children(&imported.document); + assert!(matches!(layers[0].content, LayerContent::Text(_)), "text was painted first"); + assert!(matches!(layers[1].content, LayerContent::Shape(_)), "the covering path comes after"); + } + + #[test] + fn a_bow_tie_path_is_not_a_no_op_clip() { + let mut path = BezPath::new(); + path.move_to((0.0, 0.0)); + path.line_to((10.0, 10.0)); + path.line_to((10.0, 0.0)); + path.line_to((0.0, 10.0)); + path.close_path(); + assert!(axis_aligned_rect(&path).is_none()); + } + + #[test] + fn a_line_after_close_starts_a_new_subpath() { + let mut bez = BezPath::new(); + bez.move_to((0.0, 0.0)); + bez.line_to((10.0, 0.0)); + bez.line_to((10.0, 10.0)); + bez.close_path(); + bez.line_to((20.0, 0.0)); + let mut knots = 0; + let path = to_path(&bez, &Affine::IDENTITY, &mut knots).expect("a path"); + assert_eq!(path.subpaths.len(), 2); + assert_eq!(path.subpaths[1].knots[0].anchor, Point::new(0.0, 0.0)); + assert_eq!(path.subpaths[1].knots.last().expect("a knot").anchor, Point::new(20.0, 0.0)); + } + + #[test] + fn cancelling_between_objects_stops_the_editable_page() { + let mut content = String::from("1 0 0 rg\n"); + for i in 0..20 { + content.push_str(&format!("{} 20 20 20 re f\n", i * 8)); + } + let bytes = pdf(&content); + let calls = std::sync::atomic::AtomicUsize::new(0); + let cancel = || calls.fetch_add(1, Ordering::SeqCst) >= 3; + let ctl = Interrupt::cancel_only(&cancel); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + assert!(matches!(import_with_options_and_interrupt("objects.pdf", &bytes, &options, &ctl), Err(IoError::Cancelled))); } #[test] From 79f4899008c8ccfa9960adfaf8092af8830ab37f Mon Sep 17 00:00:00 2001 From: DuBearte <130190089+DuBearte@users.noreply.github.com> Date: Sun, 8 Nov 2026 00:37:01 -0500 Subject: [PATCH 187/219] Hand tool: the canvas right-click menu lists only the fixed zooms (#12022) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/canvas_tool_menu.rs | 17 ++++- .../hand_menu_entries_tests.rs | 63 +++++++++++++++++++ .../src/canvas/canvas_tool_menu/tests.rs | 6 +- 3 files changed, 81 insertions(+), 5 deletions(-) create mode 100644 crates/ui-egui/src/canvas/canvas_tool_menu/hand_menu_entries_tests.rs diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu.rs b/crates/ui-egui/src/canvas/canvas_tool_menu.rs index 50059007ad..90d16e45e8 100644 --- a/crates/ui-egui/src/canvas/canvas_tool_menu.rs +++ b/crates/ui-egui/src/canvas/canvas_tool_menu.rs @@ -365,8 +365,8 @@ pub fn cycle_canvas_color(app: &mut PhotocraftApp, backward: bool) -> Result<(), app.run("prefs.set", json!({"values": {"interface.canvasColor": name}})).map(|_| ()) } -/// The Zoom and Hand tools' context menu: the fixed zooms of the View menu (plus the Hand tool's -/// Fill Screen), then the zoom steps last, in Photoshop's order (#6005). +/// The Zoom tool's context menu: the fixed zooms of the View menu (plus Fill Screen), then the +/// zoom steps last, in Photoshop's order (#6005). pub const VIEW_MENU: &[Row] = &[ Some(("Fit on Screen", "view.fitOnScreen")), Some(("Fill Screen", FILL_SCREEN)), @@ -378,6 +378,14 @@ pub const VIEW_MENU: &[Row] = &[ Some(("Zoom Out", "view.zoomOut")), ]; +/// The Hand tool's context menu: only the fixed zooms, as measured in the reference application (#12022). +pub const HAND_MENU: &[Row] = &[ + Some(("Fit on Screen", "view.fitOnScreen")), + Some(("100%", "view.actualPixels")), + Some(("200%", "view.twoHundredPercent")), + Some(("Print Size", "view.printSize")), +]; + /// The view menu's Fill Screen row. Photoshop has no View menu command for it, so the menu sets /// the view itself. pub const FILL_SCREEN: &str = "view.fillScreen"; @@ -558,7 +566,7 @@ pub fn rows(menu: &CanvasToolMenu) -> &'static [Row] { } else if menu.transform { TRANSFORM_MENU } else if menu.anchor.is_some() { - VIEW_MENU + if menu.tool == Tool::Hand { HAND_MENU } else { VIEW_MENU } } else if menu.text { TYPE_EDIT_MENU } else if menu.type_layer { @@ -1306,3 +1314,6 @@ pub fn show(app: &mut PhotocraftApp, ctx: &Context) { #[cfg(test)] mod tests; + +#[cfg(test)] +mod hand_menu_entries_tests; diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu/hand_menu_entries_tests.rs b/crates/ui-egui/src/canvas/canvas_tool_menu/hand_menu_entries_tests.rs new file mode 100644 index 0000000000..52424544c8 --- /dev/null +++ b/crates/ui-egui/src/canvas/canvas_tool_menu/hand_menu_entries_tests.rs @@ -0,0 +1,63 @@ +//! #12022: the Hand tool's canvas right-click lists only Fit on Screen, 100%, 200% and Print Size; +//! the Zoom tool's menu is unchanged. + +use egui::Context; +use serde_json::json; + +use super::{FILL_SCREEN, HAND_MENU, Row, VIEW_MENU, choose, open_view, rows}; +use crate::PhotocraftApp; +use crate::state::Tool; + +fn labels(rows: &[Row]) -> Vec> { + rows.iter().map(|r| r.map(|(label, _)| label)).collect() +} + +/// A 400 × 300 document, optionally with everything selected, and the view menu of `tool` open. +fn menu_app(tool: Tool, selection: bool) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + if selection { + app.run("select.all", json!({})).unwrap(); + } + app.sync_views(); + assert!(open_view(&mut app, &Context::default(), tool, [100.0, 100.0], [300.0, 100.0])); + app +} + +#[test] +fn the_hand_menu_lists_only_the_fixed_zooms() { + for selection in [false, true] { + let app = menu_app(Tool::Hand, selection); + let menu = app.ui.canvas_tool_menu.clone().unwrap(); + assert_eq!(labels(rows(&menu)), vec![Some("Fit on Screen"), Some("100%"), Some("200%"), Some("Print Size")], "selection {selection}"); + } +} + +#[test] +fn the_zoom_menu_is_unchanged() { + let app = menu_app(Tool::Zoom, false); + let menu = app.ui.canvas_tool_menu.clone().unwrap(); + assert!(rows(&menu) == VIEW_MENU); + assert_eq!( + labels(rows(&menu)), + vec![Some("Fit on Screen"), Some("Fill Screen"), Some("100%"), Some("200%"), Some("Print Size"), None, Some("Zoom In"), Some("Zoom Out")] + ); +} + +#[test] +fn the_hand_entries_zoom_and_add_no_history() { + let mut app = menu_app(Tool::Hand, false); + let before = app.session.active().unwrap().history.past_len(); + choose(&mut app, &Context::default(), "view.twoHundredPercent"); + assert_eq!(app.ui.views[0].zoom, 2.0); + assert_eq!(app.session.active().unwrap().history.past_len(), before); + // Rows that left the Hand's menu can't be run through it. + for gone in [FILL_SCREEN, "view.zoomIn", "view.zoomOut"] { + let mut app = menu_app(Tool::Hand, false); + let zoom = app.ui.views[0].zoom; + choose(&mut app, &Context::default(), gone); + assert_eq!(app.ui.views[0].zoom, zoom, "{gone}"); + assert!(!app.ui.views[0].fill_pending, "{gone}"); + } + assert!(HAND_MENU.iter().all(Option::is_some), "no separator"); +} diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs b/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs index e5c46b42ae..0ca26a96e6 100644 --- a/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs +++ b/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs @@ -535,7 +535,8 @@ fn zoom_and_hand_right_click_opens_the_view_menu() { let inside = screen_of(h.state(), [300.0, 100.0]); right_click(&mut h, inside, Modifiers::NONE); let menu = h.state().ui.canvas_tool_menu.clone().unwrap_or_else(|| panic!("{tool:?}: right-click opens no menu")); - assert!(rows(&menu) == VIEW_MENU, "{tool:?}: the view menu"); + // The Hand's menu has only the fixed zooms (#12022). + assert!(rows(&menu) == if tool == Tool::Hand { HAND_MENU } else { VIEW_MENU }, "{tool:?}: the view menu"); assert_eq!(h.state().ui.views[0].zoom, zoom, "{tool:?}: the right-click alone does not zoom"); assert!(entry_enabled(h.state(), &menu, FILL_SCREEN) && entry_enabled(h.state(), &menu, "view.printSize")); // Outside the image the pasteboard colour menu wins, as for the other tools. @@ -622,7 +623,8 @@ fn view_menu_zoom_keeps_its_anchor_during_the_animation() { #[test] fn view_menu_zoom_in_centres_the_clicked_point_with_the_preference() { let mut app = zoom_app(400, 300); - app.ui.tool = Tool::Hand; + // Zoom In is on the Zoom tool's menu only (#12022). + app.ui.tool = Tool::Zoom; app.run("prefs.set", json!({"path": "tools.zoomClickedPointToCenter", "value": true})).unwrap(); let mut h = harness(app); let doc = [300.0, 100.0]; From 44908ec8315ea5776bc6df89109e89793fdd77d6 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 7 Nov 2026 23:54:54 -0600 Subject: [PATCH 188/219] Test the 1 % boundary, a 0 % round trip and the shown values at 0 % (CodeRabbit review 5816741552 on smoothing_options_greyed_tests.rs) Co-Authored-By: Claude Opus 5.5 --- .../src/smoothing_options_greyed_tests.rs | 66 ++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/smoothing_options_greyed_tests.rs b/crates/ui-egui/src/smoothing_options_greyed_tests.rs index 2eea989275..06cd6d12de 100644 --- a/crates/ui-egui/src/smoothing_options_greyed_tests.rs +++ b/crates/ui-egui/src/smoothing_options_greyed_tests.rs @@ -14,11 +14,20 @@ use crate::state::Tool; const OPTIONS: [&str; 4] = ["Pulled String Mode", "Stroke Catch-up", "Catch-up on Stroke End", "Adjust for Zoom"]; fn bar(tool: Tool, smoothing: f32) -> Harness<'static, PhotocraftApp> { + bar_with(tool, smoothing, None) +} + +/// [`bar`] with the four options set to `options` (in [`OPTIONS`] order) before the first frame. +fn bar_with(tool: Tool, smoothing: f32, options: Option<[bool; 4]>) -> Harness<'static, PhotocraftApp> { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); app.run("file.new", json!({"width": 80, "height": 80})).unwrap(); app.ui.tool = tool; crate::paint_mouse::sync_tool_brush(&mut app); - app.session.tools.brush.smoothing.amount = smoothing; + let s = &mut app.session.tools.brush.smoothing; + s.amount = smoothing; + if let Some([a, b, c, d]) = options { + (s.pulled_string, s.catch_up, s.catch_up_on_end, s.adjust_for_zoom) = (a, b, c, d); + } let mut h = Harness::builder().with_size(vec2(1800.0, 400.0)).build_ui_state(|ui, app| crate::panels::options_bar(app, ui), app); PhotocraftApp::setup_context(&h.ctx, Default::default()); h.run_steps(4); @@ -76,3 +85,58 @@ fn a_smoothing_that_shows_as_zero_greys_them_too() { } } } + +/// The opposite of each option's default (Pulled String off, Stroke Catch-up on, Catch-up on +/// Stroke End off, Adjust for Zoom on). +const SET: [bool; 4] = [true, false, true, false]; + +/// Types `text` into the options bar's Smoothing field, then Tabs out of it. +fn type_smoothing(h: &mut Harness<'static, PhotocraftApp>, text: &str) { + h.query_all_by_role(Role::SpinButton) + .find(|n| n.accesskit_node().label().is_some_and(|l| l.trim_end_matches(':') == "Smoothing")) + .expect("the Smoothing field") + .click(); + h.run_steps(1); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text(text.into())); + h.run_steps(1); + h.key_press(egui::Key::Tab); + h.run_steps(3); +} + +#[test] +fn a_smoothing_that_shows_as_one_enables_them() { + // 0.9 % reads 1 % in the field, so the four are on. + for tool in [Tool::Brush, Tool::Eraser] { + let h = bar(tool, 0.009); + for label in OPTIONS { + assert!(!checkbox(&h, label).accesskit_node().is_disabled(), "{tool:?}: {label} enabled at a displayed 1 %"); + } + } +} + +#[test] +fn smoothing_to_zero_and_back_keeps_the_four_values() { + for tool in [Tool::Brush, Tool::Eraser] { + let mut h = bar_with(tool, 0.1, Some(SET)); + type_smoothing(&mut h, "0"); + assert_eq!(h.state().session.tools.brush.smoothing.amount, 0.0, "{tool:?}: Smoothing 0 %"); + assert_eq!(values(&h), SET, "{tool:?}: kept at 0 %"); + type_smoothing(&mut h, "10"); + assert_eq!(values(&h), SET, "{tool:?}: kept back above 0 %"); + } +} + +#[test] +fn at_zero_each_checkbox_shows_its_value() { + use egui::accesskit::Toggled; + for tool in [Tool::Brush, Tool::Eraser] { + for set in [SET, SET.map(|v| !v)] { + let h = bar_with(tool, 0.0, Some(set)); + for (label, on) in OPTIONS.into_iter().zip(set) { + let shown = checkbox(&h, label).accesskit_node().toggled(); + assert_eq!(shown, Some(if on { Toggled::True } else { Toggled::False }), "{tool:?}: {label} shows {on} at 0 %"); + } + } + } +} From 4f629dc4ddfa9a0b73d2b08f5854ae9ed6846458 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sun, 8 Nov 2026 00:57:48 -0500 Subject: [PATCH 189/219] Patch: in Destination mode the selection moves with the patch (#12119) A plain rectangular selection is translated by the drag offset in the same history state as the pixel change. Source, Content-Aware, feathered and non-rectangular selections keep today's behaviour. Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at medium --- crates/engine/src/cmd/retouch_cmds/patch.rs | 35 +++++++++-- .../patch_destination_selection_tests.rs | 58 +++++++++++++++++++ 2 files changed, 89 insertions(+), 4 deletions(-) create mode 100644 crates/engine/src/cmd/retouch_cmds/patch_destination_selection_tests.rs diff --git a/crates/engine/src/cmd/retouch_cmds/patch.rs b/crates/engine/src/cmd/retouch_cmds/patch.rs index 1cfcd3bfca..b6bf3b18d6 100644 --- a/crates/engine/src/cmd/retouch_cmds/patch.rs +++ b/crates/engine/src/cmd/retouch_cmds/patch.rs @@ -211,16 +211,39 @@ fn patch_surface(surf: &mut Surface, sel: &Surface, plan: &Plan, lock: bool, ste apply_coverage(surf, g, &cov, 1.0, None, lock, &healed, BlendMode::Normal) } +/// Whether `sel` is a hard-edged rectangle exactly filling `area` (the only Destination selection +/// measured to move with the patch). +fn is_hard_rect(sel: &Surface, area: Rect) -> bool { + sel.default_pixel().first().copied().unwrap_or(0.0) <= 0.0 && sel.content_bounds() == area && coverage(sel, area).iter().all(|c| *c >= 1.0) +} + pub(super) fn patch(s: &mut Session, p: &Value) -> Result { if flag(p, "contentAware", false) { return patch_content_aware(s, p); } let plan = plan(s, p)?; let label = if plan.destination { "Patch (Destination)" } else { "Patch" }; - let dmg = run_stroke(s, label, plan.id, p, |pre, surf, _, lock| { - let sel = pre.selection.as_ref().ok_or_else(|| bad(CMD, "select the area to patch first"))?; - Ok(patch_surface(surf, sel, &plan, lock, 1)) - })?; + let dmg = if plan.destination { + // Destination: a plain rectangular selection moves with the patch, in the same history state. + let dmg = s.edit(label, |doc, _| { + let pre = doc.clone(); + let (surf, lock) = crate::channel_cmds::target_surface(doc, plan.id, p)?; + let sel = pre.selection.as_ref().ok_or_else(|| bad(CMD, "select the area to patch first"))?; + let dmg = patch_surface(surf, sel, &plan, lock, 1); + if is_hard_rect(sel, plan.area) { + let (dx, dy) = plan.offset; + doc.selection = Some(sel.translated(dx, dy, sel.content_bounds())); + } + Ok(dmg) + })?; + s.hint_damage(dmg); + dmg + } else { + run_stroke(s, label, plan.id, p, |pre, surf, _, lock| { + let sel = pre.selection.as_ref().ok_or_else(|| bad(CMD, "select the area to patch first"))?; + Ok(patch_surface(surf, sel, &plan, lock, 1)) + })? + }; Ok(json!({ "damage": damage_json(dmg), "offset": [plan.offset.0, plan.offset.1] })) } @@ -303,3 +326,7 @@ fn patch_content_aware(s: &mut Session, p: &Value) -> Result { }, ) } + +#[cfg(test)] +#[path = "patch_destination_selection_tests.rs"] +mod patch_destination_selection_tests; diff --git a/crates/engine/src/cmd/retouch_cmds/patch_destination_selection_tests.rs b/crates/engine/src/cmd/retouch_cmds/patch_destination_selection_tests.rs new file mode 100644 index 0000000000..3aa7879af0 --- /dev/null +++ b/crates/engine/src/cmd/retouch_cmds/patch_destination_selection_tests.rs @@ -0,0 +1,58 @@ +//! Patch in Destination mode moves a plain rectangular selection with the patch (#12119). + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 800, "height": 600, "depth": 8, "mode": "rgb"})).unwrap(); + s.edit("setup", |doc, active| { + let b = doc.bounds(); + 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 { + for x in b.x0..b.x1 { + let v = if (x / 8 + y / 8) % 2 == 0 { 0.2 } else { 0.8 }; + let px = if x >= 400 { [0.1, 0.6, 0.2, 1.0] } else { [v, v, v, 1.0] }; + data.extend(photocraft_raster::from_rgba(&fmt, px)); + } + } + surf.write_region(b, &data); + Ok(()) + }) + .unwrap(); + s +} + +fn bounds(s: &mut Session) -> Value { + s.execute("document.inspect", json!({})).unwrap()["selectionBounds"].clone() +} + +fn px(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) +} + +#[test] +fn destination_moves_the_selection_with_the_patch_and_undo_restores_both() { + let mut s = session(); + s.execute("select.rect", json!({"x": 120, "y": 120, "width": 40, "height": 40})).unwrap(); + let before = px(&s, 600, 420); + s.execute("paint.patch", json!({"mode": "destination", "offset": [460, 280]})).unwrap(); + assert_eq!(bounds(&mut s), json!([580, 400, 40, 40])); + assert_ne!(px(&s, 600, 420), before, "pixels changed too"); + // One history state: a single undo brings back the pixels and the old selection. + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(bounds(&mut s), json!([120, 120, 40, 40])); + assert_eq!(px(&s, 600, 420), before); +} + +#[test] +fn source_and_content_aware_keep_the_selection() { + let mut s = session(); + s.execute("select.rect", json!({"x": 120, "y": 120, "width": 40, "height": 40})).unwrap(); + s.execute("paint.patch", json!({"offset": [260, 0]})).unwrap(); + assert_eq!(bounds(&mut s), json!([120, 120, 40, 40])); + s.execute("paint.patch", json!({"offset": [260, 0], "contentAware": true})).unwrap(); + assert_eq!(bounds(&mut s), json!([120, 120, 40, 40])); +} From 11607cfaade1cda9f4fc6ab39f607b543da4db9c Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sun, 8 Nov 2026 01:10:52 -0500 Subject: [PATCH 190/219] Patch: an arrow badge over the selection shows that a drag moves the patch (#12130) The Patch tool's cursor shows a new single right-pointing arrow badge over the selection (no Shift or Alt), also while a drag that started inside is in progress. Content-Aware Move keeps no badge and the Curvature Pen keeps its four-arrow badge. Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at medium --- crates/ui-egui/src/canvas/tool_feedback.rs | 12 +++- .../ui-egui/src/patch_cursor_badge_tests.rs | 56 +++++++++++++++++++ crates/ui-egui/src/tools/retouch_ui/tests.rs | 3 +- 3 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/src/patch_cursor_badge_tests.rs diff --git a/crates/ui-egui/src/canvas/tool_feedback.rs b/crates/ui-egui/src/canvas/tool_feedback.rs index 6c9c890571..0b26dc5a25 100644 --- a/crates/ui-egui/src/canvas/tool_feedback.rs +++ b/crates/ui-egui/src/canvas/tool_feedback.rs @@ -19,6 +19,8 @@ pub enum Badge { Intersect, /// Four arrows: a press drags the point under the pointer (the Curvature Pen over an anchor). Move, + /// One right-pointing arrow: a press drags the patch (the Patch tool over the selection). + Arrow, } impl Badge { @@ -124,7 +126,7 @@ pub fn badge(app: &PhotocraftApp, tool: Tool, m: egui::Modifiers) -> Option Vec<[Pos2; 2]> { } v } + Badge::Arrow => { + let a = h * 0.6; + vec![[c - vec2(h, 0.0), c + vec2(h, 0.0)], [c + vec2(h, 0.0), c + vec2(h - a, a)], [c + vec2(h, 0.0), c + vec2(h - a, -a)]] + } Badge::Intersect => { let d = h * 0.8; vec![[c + vec2(-d, -d), c + vec2(d, d)], [c + vec2(-d, d), c + vec2(d, -d)]] @@ -319,3 +325,7 @@ mod tests { assert!(e.len() >= 16 && e.iter().all(|p| p.x >= -0.01 && p.x <= 100.01 && p.y >= -0.01 && p.y <= 50.01)); } } + +#[cfg(test)] +#[path = "../patch_cursor_badge_tests.rs"] +mod patch_cursor_badge_tests; diff --git a/crates/ui-egui/src/patch_cursor_badge_tests.rs b/crates/ui-egui/src/patch_cursor_badge_tests.rs new file mode 100644 index 0000000000..9612a51e4c --- /dev/null +++ b/crates/ui-egui/src/patch_cursor_badge_tests.rs @@ -0,0 +1,56 @@ +//! The Patch tool's cursor shows a right-pointing arrow over the selection (#12130). + +use egui::Modifiers; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::canvas::Drag; +use crate::state::Tool; +use crate::tool_feedback::{Badge, badge, badge_glyph}; + +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + app.run("select.rect", json!({"x": 20, "y": 20, "width": 30, "height": 30})).unwrap(); + app +} + +const INSIDE: [f64; 2] = [30.0, 30.0]; +const OUTSIDE: [f64; 2] = [80.0, 80.0]; + +#[test] +fn arrow_over_the_selection_without_modifiers() { + let mut app = app(); + app.hover_doc = Some(INSIDE); + assert_eq!(badge(&app, Tool::Patch, Modifiers::NONE), Some(Badge::Arrow)); + assert_ne!(Badge::Arrow, Badge::Move, "a new single-arrow glyph"); + let glyph = badge_glyph(Badge::Arrow, egui::pos2(0.0, 0.0), 4.0); + let tip = glyph.iter().flatten().map(|p| p.x).fold(f32::MIN, f32::max); + assert!(glyph.iter().flatten().all(|p| p.x <= tip) && glyph.len() == 3, "one shaft and two barbs pointing right"); +} + +#[test] +fn the_arrow_stays_during_a_drag_started_inside() { + let mut app = app(); + app.drag = Some(Drag::new(Tool::Patch, INSIDE, vec![[INSIDE[0], INSIDE[1], 1.0]], Modifiers::NONE, false)); + app.hover_doc = Some(OUTSIDE); + assert_eq!(badge(&app, Tool::Patch, Modifiers::NONE), Some(Badge::Arrow)); +} + +#[test] +fn outside_and_with_shift_or_alt_the_badges_are_as_before() { + let mut app = app(); + app.hover_doc = Some(OUTSIDE); + assert_eq!(badge(&app, Tool::Patch, Modifiers::NONE), None); + app.hover_doc = Some(INSIDE); + assert_eq!(badge(&app, Tool::Patch, Modifiers::SHIFT), Some(Badge::Add)); + assert_eq!(badge(&app, Tool::Patch, Modifiers::ALT), Some(Badge::Subtract)); +} + +#[test] +fn content_aware_move_and_the_curvature_pen_keep_their_cursors() { + let mut app = app(); + app.hover_doc = Some(INSIDE); + assert_eq!(badge(&app, Tool::ContentAwareMove, Modifiers::NONE), None); + assert_eq!(badge_glyph(Badge::Move, egui::pos2(0.0, 0.0), 4.0).len(), 10, "four-arrow glyph unchanged"); +} diff --git a/crates/ui-egui/src/tools/retouch_ui/tests.rs b/crates/ui-egui/src/tools/retouch_ui/tests.rs index 375f8a410a..e68c8b99cf 100644 --- a/crates/ui-egui/src/tools/retouch_ui/tests.rs +++ b/crates/ui-egui/src/tools/retouch_ui/tests.rs @@ -587,7 +587,8 @@ fn patch_cursor_shows_the_selection_mode_badge() { app.ui.selection_mode = 1; assert_eq!(badge(&app, tool, none), Some(Badge::Add), "{tool:?}: the options-bar mode outside the selection"); app.hover_doc = Some([20.0, 30.0]); - assert_eq!(badge(&app, tool, none), None, "{tool:?}: a press over the selection drags the patch"); + let over = (tool == Tool::Patch).then_some(Badge::Arrow); + assert_eq!(badge(&app, tool, none), over, "{tool:?}: a press over the selection drags the patch (Patch shows an arrow, #12130)"); assert_eq!(badge(&app, tool, shift), Some(Badge::Add), "{tool:?}: ⇧ over the selection adds"); } } From b17c98f59539e3b792a79c9c737a16b97c4b3d68 Mon Sep 17 00:00:00 2001 From: Triwarr <170976175+Triwarr@users.noreply.github.com> Date: Sun, 8 Nov 2026 07:17:09 +0100 Subject: [PATCH 191/219] PDF: second review round on editable import - A text cache that renders empty now falls back the page instead of leaving blank text. - Fake-bold strokes pair only with fill glyphs at the same paint order. - Retain single-point closed subpaths (round-cap dots) without emitting duplicate phantom points; test added. - Device methods documented; paint-order test covers text on both sides of a path. --- crates/io/src/pdf_editable.rs | 54 ++++++++++++++++++++++++++++++----- 1 file changed, 47 insertions(+), 7 deletions(-) diff --git a/crates/io/src/pdf_editable.rs b/crates/io/src/pdf_editable.rs index 1b91a4ff61..f718ac7c22 100644 --- a/crates/io/src/pdf_editable.rs +++ b/crates/io/src/pdf_editable.rs @@ -160,6 +160,7 @@ fn pair_fake_bold(glyphs: &mut [GlyphRun], strokes: Vec) -> Result<(), let tolerance = 0.01 * glyph.size_px + 0.01; if glyph.text == stroke.text && glyph.font.key == stroke.font.key + && glyph.order == stroke.order && (glyph.origin.x - stroke.origin.x).abs() <= tolerance && (glyph.origin.y - stroke.origin.y).abs() <= tolerance { @@ -314,7 +315,10 @@ fn render_text_caches(layers: &mut [Layer], dpi: f64, fmt: PixelFormat, ctl: &In // Re-lock per layer so a text-heavy import does not hold the UI's engine for the // whole cache-render loop. let mut engine = shared.lock().unwrap_or_else(|error| error.into_inner()); - let _ = engine.render_layer(text, dpi as f32, fmt); + let rect = engine.render_layer(text, dpi as f32, fmt); + if rect.width() == 0 || rect.height() == 0 { + return Err(EditableError::Unsupported("a text cache could not be rendered")); + } } } Ok(()) @@ -465,18 +469,21 @@ impl PathDevice<'_> { } impl<'a> Device<'a> for PathDevice<'_> { + /// A soft mask changes compositing in ways a plain layer cannot express. fn set_soft_mask(&mut self, mask: Option>) { if mask.is_some() { self.unsupported("soft masks"); } } + /// Only normal blending is editable. fn set_blend_mode(&mut self, blend_mode: BlendMode) { if blend_mode != BlendMode::Normal { self.unsupported("blend modes"); } } + /// Turns a filled/stroked path into a shape layer, rejecting paints and strokes it cannot keep. fn draw_path(&mut self, path: &BezPath, transform: KurboAffine, paint: &Paint<'a>, draw_mode: &PathDrawMode) { if self.unsupported.is_some() { return; @@ -520,6 +527,7 @@ impl<'a> Device<'a> for PathDevice<'_> { } } + /// Accepts an axis-aligned clip that contains the artboard; other clips fall back. fn push_clip_path(&mut self, clip_path: &ClipPath) { if self.no_op_clip(clip_path) { self.clips.push(true); @@ -529,6 +537,7 @@ impl<'a> Device<'a> for PathDevice<'_> { self.unsupported("clipping paths"); } + /// Transparency groups are not represented by the layer model. fn push_transparency_group(&mut self, _opacity: f32, mask: Option>, blend_mode: BlendMode) { // A group needs its own blend/opacity/mask handling; until that exists the page is // rasterised rather than flattened into the wrong stacking order. @@ -536,6 +545,7 @@ impl<'a> Device<'a> for PathDevice<'_> { self.unsupported("transparency groups"); } + /// Records a filled glyph as text, or its stroke copy for fake-bold pairing. fn draw_glyph(&mut self, glyph: &Glyph<'a>, transform: KurboAffine, glyph_transform: KurboAffine, paint: &Paint<'a>, draw_mode: &GlyphDrawMode) { if self.unsupported.is_some() { return; @@ -627,16 +637,20 @@ impl<'a> Device<'a> for PathDevice<'_> { } } + /// Images are not editable at this step. fn draw_image(&mut self, _image: Image<'a, '_>, _transform: KurboAffine) { self.unsupported("images"); } + /// Balances [`Self::push_clip_path`]. fn pop_clip_path(&mut self) { let _ = self.clips.pop(); } + /// Balances [`Self::push_transparency_group`]. fn pop_transparency_group(&mut self) {} + /// Annotations are not part of the page's editable paint. fn begin_annotations(&mut self) { self.unsupported("annotations"); } @@ -863,15 +877,17 @@ fn to_stroke(stroke: &craft_pdf::interpret::StrokeProps, color: Color, scale: f6 fn to_path(bez: &BezPath, transform: &Affine, knots: &mut usize) -> Option { let mut subpaths = Vec::new(); let mut current: Option = None; + let mut restarted = false; let mut last = Point::new(0.0, 0.0); for element in bez.elements() { match *element { PathEl::MoveTo(p) => { if let Some(subpath) = current.take() - && subpath.knots.len() > 1 + && (!restarted || subpath.knots.len() > 1) { subpaths.push(subpath); } + restarted = false; current = Some(Subpath { closed: false, knots: vec![Knot::corner(p.x, p.y)], op: PathOp::Combine }); last = Point::new(p.x, p.y); } @@ -922,6 +938,9 @@ fn to_path(bez: &BezPath, transform: &Affine, knots: &mut usize) -> Option } else { subpath.knots[0].in_ctrl = first; } + } + // A single-point closed subpath still paints a dot with a round cap. + if !restarted || subpath.knots.len() > 1 { subpaths.push(subpath); } // A close starts a new subpath at the closed subpath's start point; a @@ -929,13 +948,16 @@ fn to_path(bez: &BezPath, transform: &Affine, knots: &mut usize) -> Option if let Some(start) = restart { current = Some(Subpath { closed: false, knots: vec![Knot::corner(start.x, start.y)], op: PathOp::Combine }); last = start; + restarted = true; + } else { + restarted = false; } } } } } if let Some(subpath) = current - && subpath.knots.len() > 1 + && (!restarted || subpath.knots.len() > 1) { subpaths.push(subpath); } @@ -1414,13 +1436,31 @@ mod tests { } #[test] - fn text_keeps_paint_order_relative_to_paths() { - let bytes = text_pdf("BT /F1 24 Tf 20 50 Td (Hi) Tj ET 0 0 1 rg 0 0 200 100 re f"); + fn text_keeps_paint_order_on_both_sides_of_a_path() { + let bytes = text_pdf("BT /F1 24 Tf 20 50 Td (A) Tj ET 0 0 1 rg 0 0 200 100 re f BT /F1 24 Tf 20 20 Td (B) Tj ET"); let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; let imported = import_with_options_and_interrupt("order.pdf", &bytes, &options, &Interrupt::NONE).expect("import"); let layers = children(&imported.document); - assert!(matches!(layers[0].content, LayerContent::Text(_)), "text was painted first"); - assert!(matches!(layers[1].content, LayerContent::Shape(_)), "the covering path comes after"); + assert_eq!(layers.len(), 3); + let text = |layer: &Layer| match &layer.content { + LayerContent::Text(text) => text.text.clone(), + _ => String::new(), + }; + assert_eq!(text(&layers[0]), "A"); + assert!(matches!(layers[1].content, LayerContent::Shape(_)), "the path stays between the runs"); + assert_eq!(text(&layers[2]), "B"); + } + + #[test] + fn a_closed_single_point_subpath_is_retained() { + let mut bez = BezPath::new(); + bez.move_to((10.0, 10.0)); + bez.close_path(); + let mut knots = 0; + let path = to_path(&bez, &Affine::IDENTITY, &mut knots).expect("a path"); + assert_eq!(path.subpaths.len(), 1); + assert_eq!(path.subpaths[0].knots.len(), 1); + assert!(path.subpaths[0].closed); } #[test] From 8272e6b9f437ab3f038199f0a242071f5d931b3f Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sun, 8 Nov 2026 00:05:40 -0600 Subject: [PATCH 192/219] Mixer Brush: refuse Indexed Color and Bitmap documents with the alert (#11958) A Mixer Brush stroke in an Indexed Color or Bitmap document now fails before anything is painted, with the reference's alert texts (convert to RGB Color / Grayscale mode to edit); paint.mixerBrush returns that error and leaves the document and History unchanged. A press on the canvas shows it as an Error alert instead of a status-bar message. RGB, CMYK, Lab and Grayscale still paint. --- .../engine/src/cmd/brush_cmds/mixer_live.rs | 18 ++++++ crates/engine/src/lib.rs | 2 + .../engine/src/mixer_indexed_bitmap_tests.rs | 60 +++++++++++++++++++ crates/ui-egui/src/canvas/tool_input.rs | 16 ++++- 4 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 crates/engine/src/mixer_indexed_bitmap_tests.rs diff --git a/crates/engine/src/cmd/brush_cmds/mixer_live.rs b/crates/engine/src/cmd/brush_cmds/mixer_live.rs index bfe409c471..f0abb81995 100644 --- a/crates/engine/src/cmd/brush_cmds/mixer_live.rs +++ b/crates/engine/src/cmd/brush_cmds/mixer_live.rs @@ -151,6 +151,7 @@ impl LiveMixer { /// Validate the params and capture the document and paint load, without rendering a dab. fn prepare(s: &Session, p: &Value, preview_limits: bool) -> Result<(Self, Vec)> { + mode_refusal(s)?; let captured = crate::channel_cmds::inject_target(s, CMD, p.clone()); let p = &captured; has_paintable(s).map_err(EngineError::Other)?; @@ -330,9 +331,26 @@ impl LiveMixer { } } +/// The reference's alert for a Mixer Brush press in an Indexed Color document (#11958). +pub const INDEXED_REFUSAL: &str = + "Could not use the Mixer Brush tool because it does not work with Indexed Color images (convert image to RGB Color mode to edit)."; +/// The reference's alert for a Mixer Brush press in a Bitmap document (#11958). +pub const BITMAP_REFUSAL: &str = "Could not use the Mixer Brush tool because it does not work with Bitmap images (convert image to Grayscale mode to edit)."; + +/// The Mixer Brush refuses Indexed Color and Bitmap documents before anything is painted, with the +/// reference's alert text; other modes (Duotone and Multichannel were not measured) paint as before. +fn mode_refusal(s: &Session) -> Result<()> { + match s.active().map(|st| st.doc.mode) { + Some(photocraft_color::ColorMode::Indexed) => Err(EngineError::Other(INDEXED_REFUSAL.into())), + Some(photocraft_color::ColorMode::Bitmap) => Err(EngineError::Other(BITMAP_REFUSAL.into())), + _ => Ok(()), + } +} + /// Consume a matching prepared result or replay without the preview tile cap, then commit one history step. /// The completed stroke also applies the automatic clean/load settings to the stored paint load. pub(super) fn run(s: &mut Session, p: &Value) -> Result { + mode_refusal(s)?; let points = parse_points(p, CMD)?; let cached = s.live_commit.take().filter(|live| live.matches(s, CMD, p, &points) && live.mixer.as_ref().is_some_and(|(before, _)| *before == s.tools.mixer)); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8f28147217..bb9940c9e5 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -346,6 +346,8 @@ mod embed_all_linked_tests; #[cfg(test)] mod fill_layer_mode_tests; #[cfg(test)] +mod mixer_indexed_bitmap_tests; +#[cfg(test)] mod pattern_mode_tests; #[cfg(test)] mod second_keys_zoom_actions_tests; diff --git a/crates/engine/src/mixer_indexed_bitmap_tests.rs b/crates/engine/src/mixer_indexed_bitmap_tests.rs new file mode 100644 index 0000000000..8f31a41cd5 --- /dev/null +++ b/crates/engine/src/mixer_indexed_bitmap_tests.rs @@ -0,0 +1,60 @@ +//! The Mixer Brush refuses Indexed Color and Bitmap documents with the reference's alert texts +//! (#11958): `paint.mixerBrush` returns the error and leaves the document and History unchanged. +//! RGB, CMYK, Lab and Grayscale documents still paint. + +use crate::Session; +use crate::brush_cmds::mixer_live::{BITMAP_REFUSAL, INDEXED_REFUSAL}; +use serde_json::json; + +fn stroke(s: &mut Session) -> crate::Result { + s.execute("paint.mixerBrush", json!({"points": [[10, 10], [40, 20]], "size": 12, "wet": 0, "mix": 0})) +} + +fn assert_refused(s: &mut Session, text: &str) { + let (doc, steps) = (s.active().unwrap().doc.clone(), s.active().unwrap().history.past_len()); + let err = stroke(s).unwrap_err().to_string(); + assert_eq!(err, text); + assert!(std::sync::Arc::ptr_eq(&doc, &s.active().unwrap().doc), "nothing painted"); + assert_eq!(s.active().unwrap().history.past_len(), steps, "no history step"); +} + +#[test] +fn indexed_color_documents_are_refused_with_the_alert() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 64, "height": 32, "background": "white"})).unwrap(); + s.execute("image.mode.indexedColor", json!({})).unwrap(); + assert_refused(&mut s, INDEXED_REFUSAL); + assert_eq!( + INDEXED_REFUSAL, + "Could not use the Mixer Brush tool because it does not work with Indexed Color images (convert image to RGB Color mode to edit)." + ); +} + +#[test] +fn bitmap_documents_are_refused_with_the_alert() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 64, "height": 32, "background": "white"})).unwrap(); + s.execute("image.mode.grayscale", json!({})).unwrap(); + s.execute("image.mode.bitmap", json!({})).unwrap(); + assert_refused(&mut s, BITMAP_REFUSAL); + assert_eq!(BITMAP_REFUSAL, "Could not use the Mixer Brush tool because it does not work with Bitmap images (convert image to Grayscale mode to edit)."); +} + +#[test] +fn rgb_cmyk_lab_and_grayscale_documents_still_paint() { + let mut painted = 0; + for mode in ["rgb", "cmyk", "lab", "grayscale"] { + for depth in [8, 16, 32] { + let mut s = Session::new(); + // Not every mode has every depth (as in the reference, CMYK and Lab stop at 16 bits). + if s.execute("file.new", json!({"width": 64, "height": 32, "mode": mode, "depth": depth, "background": "white"})).is_err() { + continue; + } + let steps = s.active().unwrap().history.past_len(); + stroke(&mut s).unwrap_or_else(|e| panic!("{mode} {depth}: {e}")); + assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "{mode} {depth}"); + painted += 1; + } + } + assert!(painted >= 8, "only {painted} mode/depth combinations painted"); +} diff --git a/crates/ui-egui/src/canvas/tool_input.rs b/crates/ui-egui/src/canvas/tool_input.rs index f148b8dfa5..0d49329f7c 100644 --- a/crates/ui-egui/src/canvas/tool_input.rs +++ b/crates/ui-egui/src/canvas/tool_input.rs @@ -359,6 +359,19 @@ fn handle_eyedropper_event(app: &mut PhotocraftApp, tool: Tool, ev: ToolEvent, m true } +/// A Mixer Brush press the engine refused: Indexed Color and Bitmap documents answer it with the +/// reference's alert (#11958); anything else goes to the status bar, as before. +fn mixer_press_failed(app: &mut PhotocraftApp, message: String) { + use photocraft_engine::brush_cmds::mixer_live::{BITMAP_REFUSAL, INDEXED_REFUSAL}; + if message == INDEXED_REFUSAL || message == BITMAP_REFUSAL { + let params = json!({ "message": message }).as_object().cloned().unwrap_or_default(); + app.ui.open_dialog(crate::state::DialogKind::Error, params); + } else { + app.ui.status = message; + app.ui.status_error = true; + } +} + /// Tool state machine. Shared by mouse input and automation. pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) { if consumes_cancelled_vector_event(app, ev) { @@ -563,8 +576,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) && !LiveMixer::defers_to_release(&app.session, &p).is_ok_and(|deferred| deferred) { if let Err(e) = LiveMixer::begin(&app.session, &p) { - app.ui.status = e.to_string(); - app.ui.status_error = true; + mixer_press_failed(app, e.to_string()); } app.drag = None; } From 541b972091bbb00dc73db1e56a4c5b73a3213896 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sun, 8 Nov 2026 00:41:16 -0600 Subject: [PATCH 193/219] Ruler: Shift snaps to 45 degree steps without making the ruler longer (#11985) constrain now keeps the pointer's legs instead of its distance: on a diagonal step both legs take min(|dx|, |dy|) with their signs, and on a 0 or 90 degree step the dominant component stays and the other becomes 0. This applies to drawing, to moving either endpoint around the other, and to a protractor's second arm. --- .../src/tools/analysis_ruler_shift_tests.rs | 101 ++++++++++++++++++ crates/ui-egui/src/tools/analysis_ui.rs | 21 +++- 2 files changed, 118 insertions(+), 4 deletions(-) create mode 100644 crates/ui-egui/src/tools/analysis_ruler_shift_tests.rs diff --git a/crates/ui-egui/src/tools/analysis_ruler_shift_tests.rs b/crates/ui-egui/src/tools/analysis_ruler_shift_tests.rs new file mode 100644 index 0000000000..301636845f --- /dev/null +++ b/crates/ui-egui/src/tools/analysis_ruler_shift_tests.rs @@ -0,0 +1,101 @@ +//! Shift constrains the Ruler to 45° steps without making it longer (#11985): on a diagonal step +//! both legs take min(|dx|, |dy|), on a 0° or 90° step the dominant component stays and the other +//! is 0; the same for an endpoint moved around the other one and for a protractor's second arm. + +use egui::Modifiers; +use serde_json::{Value, json}; + +use crate::PhotocraftApp; +use crate::canvas::{ToolEvent, tool_event}; +use crate::state::Tool; +use photocraft_engine::doc::Ruler; + +fn ruler_app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 800, "height": 600})).unwrap(); + app.ui.tool = Tool::Ruler; + app +} + +/// A drag from `from` to `to` with `m` held during the move. +fn drag(app: &mut PhotocraftApp, from: [f64; 2], to: [f64; 2], press: Modifiers, m: Modifiers) { + tool_event(app, ToolEvent::Down { x: from[0], y: from[1], pressure: 1.0 }, press); + tool_event(app, ToolEvent::Move { x: to[0], y: to[1], pressure: 1.0 }, m); + tool_event(app, ToolEvent::Up { x: to[0], y: to[1] }, m); +} + +fn ruler(app: &PhotocraftApp) -> Ruler { + app.session.active().unwrap().doc.measurement.ruler.expect("a ruler") +} + +fn info(app: &PhotocraftApp) -> Value { + photocraft_engine::analysis_cmds::ruler_info(&ruler(app), &Default::default()) +} + +fn near(a: f64, b: f64) -> bool { + (a - b).abs() < 0.01 +} + +#[test] +fn drawing_with_shift_keeps_the_shorter_leg_on_diagonals_and_the_dominant_one_on_axes() { + // (pointer, constrained end, L1 when measured), all from 100,100. + for (to, end, l1) in [ + ([182.0, 200.0], [182.0, 182.0], Some(115.97)), + ([222.0, 220.0], [220.0, 220.0], None), + ([400.0, 130.0], [400.0, 100.0], None), + // Signs are kept: dx −82, dy −100. + ([18.0, 0.0], [18.0, 18.0], None), + ] { + let mut app = ruler_app(); + drag(&mut app, [100.0, 100.0], to, Modifiers::NONE, Modifiers::SHIFT); + let r = ruler(&app); + assert_eq!(r.start, [100.0, 100.0]); + assert!(near(r.end[0], end[0]) && near(r.end[1], end[1]), "to {to:?}: end {:?}, expected {end:?}", r.end); + if let Some(l1) = l1 { + let i = info(&app); + assert!(near(i["w"].as_f64().unwrap().abs(), 82.0) && near(i["h"].as_f64().unwrap().abs(), 82.0), "{i}"); + assert!(near(i["l1"].as_f64().unwrap(), l1), "{i}"); + } + assert_eq!(app.session.active().unwrap().history.past_len(), 0, "no history step"); + } +} + +#[test] +fn moving_an_endpoint_with_shift_snaps_around_the_other_one() { + // The end point, around the start. + let mut app = ruler_app(); + drag(&mut app, [100.0, 100.0], [300.0, 250.0], Modifiers::NONE, Modifiers::NONE); + drag(&mut app, [300.0, 250.0], [382.0, 160.0], Modifiers::NONE, Modifiers::SHIFT); + let r = ruler(&app); + assert_eq!((r.start, r.end), ([100.0, 100.0], [382.0, 100.0]), "W 282, H 0"); + // The start point, around the end. + let mut app = ruler_app(); + drag(&mut app, [100.0, 100.0], [400.0, 300.0], Modifiers::NONE, Modifiers::NONE); + drag(&mut app, [100.0, 100.0], [118.0, 240.0], Modifiers::NONE, Modifiers::SHIFT); + let r = ruler(&app); + assert_eq!((r.start, r.end), ([118.0, 300.0], [400.0, 300.0]), "W 282, H 0"); +} + +#[test] +fn a_protractor_arm_with_shift_keeps_the_shorter_leg() { + let mut app = ruler_app(); + drag(&mut app, [400.0, 300.0], [600.0, 300.0], Modifiers::NONE, Modifiers::NONE); + // Shift+Alt-drag from the vertex: pointer at +62, −80. + drag(&mut app, [400.0, 300.0], [462.0, 220.0], Modifiers::ALT, Modifiers::SHIFT | Modifiers::ALT); + let arm = ruler(&app).protractor.expect("a protractor"); + assert!(near(arm[0], 462.0) && near(arm[1], 238.0), "{arm:?}"); + let i = info(&app); + assert!(near(i["l2"].as_f64().unwrap(), 87.68), "{i}"); + // Shift-drag that arm end to dx 122, dy −170 from the vertex. + drag(&mut app, [462.0, 238.0], [522.0, 130.0], Modifiers::NONE, Modifiers::SHIFT); + let arm = ruler(&app).protractor.unwrap(); + assert!(near(arm[0], 522.0) && near(arm[1], 178.0), "{arm:?}"); + assert!(near(info(&app)["l2"].as_f64().unwrap(), 172.53)); +} + +#[test] +fn without_shift_the_endpoint_follows_the_pointer() { + let mut app = ruler_app(); + drag(&mut app, [100.0, 100.0], [182.0, 200.0], Modifiers::NONE, Modifiers::NONE); + assert_eq!(ruler(&app).end, [182.0, 200.0]); +} diff --git a/crates/ui-egui/src/tools/analysis_ui.rs b/crates/ui-egui/src/tools/analysis_ui.rs index a3b9826acb..8e3377c5b0 100644 --- a/crates/ui-egui/src/tools/analysis_ui.rs +++ b/crates/ui-egui/src/tools/analysis_ui.rs @@ -219,15 +219,24 @@ fn dist(a: [f64; 2], b: [f64; 2]) -> f64 { (a[0] - b[0]).hypot(a[1] - b[1]) } -/// Shift constrains to 45° steps around `from`. +/// Shift constrains to 45° steps around `from`, as the reference does (#11985): on a diagonal step +/// both legs take the smaller of |dx| and |dy| (signs kept), on a 0° or 90° step the dominant +/// component stays and the other becomes 0, so the ruler never gets longer than the pointer's legs. fn constrain(from: [f64; 2], to: [f64; 2], shift: bool) -> [f64; 2] { if !shift { return to; } let (dx, dy) = (to[0] - from[0], to[1] - from[1]); - let a = (dy.atan2(dx) / std::f64::consts::FRAC_PI_4).round() * std::f64::consts::FRAC_PI_4; - let l = dx.hypot(dy); - [from[0] + l * a.cos(), from[1] + l * a.sin()] + let step = (dy.atan2(dx) / std::f64::consts::FRAC_PI_4).round() as i64; + let (cx, cy) = match step.rem_euclid(4) { + 0 => (dx, 0.0), + 2 => (0.0, dy), + _ => { + let leg = dx.abs().min(dy.abs()); + (leg.copysign(dx), leg.copysign(dy)) + } + }; + [from[0] + cx, from[1] + cy] } fn set_ruler(app: &mut PhotocraftApp, r: Ruler) { @@ -1125,3 +1134,7 @@ pub fn note_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { #[cfg(test)] #[path = "note_delete_key_tests.rs"] mod note_delete_key_tests; + +#[cfg(test)] +#[path = "analysis_ruler_shift_tests.rs"] +mod analysis_ruler_shift_tests; From be7ebc078a87eedc8fdc2cb431dadb248062e6b6 Mon Sep 17 00:00:00 2001 From: SBaksa <18648812+SBaksa@users.noreply.github.com> Date: Sun, 8 Nov 2026 17:36:30 +1100 Subject: [PATCH 194/219] Lasso: refuse a history name that shows nothing or hides formatting A historyName of zero-width, filler or BOM characters showed an empty History row, and a bidi control or line separator could make a row read differently from its name. A history name now has no control or line-break character anywhere, loses white space and ZWNJ/ZWJ at its ends, and is then held to the plug-in menu-label rule, now shared as photocraft_plugins::manifest::printable_name (1-64 characters with a visible one, no hidden format characters; plug-in names also refuse U+2028/U+2029). --- .../retouch_cmds/patch_history_names_tests.rs | 33 ++++++++++++++++++- crates/engine/src/cmd/selection_cmds.rs | 20 +++++++++-- crates/plugins/src/manifest.rs | 12 +++++-- 3 files changed, 58 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/retouch_cmds/patch_history_names_tests.rs b/crates/engine/src/cmd/retouch_cmds/patch_history_names_tests.rs index 6172d5c376..d72958fc3a 100644 --- a/crates/engine/src/cmd/retouch_cmds/patch_history_names_tests.rs +++ b/crates/engine/src/cmd/retouch_cmds/patch_history_names_tests.rs @@ -51,10 +51,41 @@ fn the_lasso_is_named_for_the_tool_that_drew_it() { fn a_hostile_history_name_is_refused_without_a_step() { let mut s = session(); let steps = s.active().unwrap().history.past_len(); - for name in [json!(""), json!(" "), json!("x".repeat(65)), json!("a\nb"), json!(7), json!(["Patch Tool"])] { + let invisible = ["\u{200B}", " \u{200B}\u{FEFF} ", "\u{3164}", "\u{FE0F}", "\u{E0020}", "\u{00AD}"]; + let hidden = ["Patch\u{202E}looT", "\u{FEFF}Patch Tool", "Patch Tool\u{200B}", "\u{2066}Patch", "Patch\u{2028}Tool", "a\u{2029}b"]; + let ends = ["Patch Tool\n", "\r\nPatch Tool", "\tPatch", "\u{0085}Patch", "Patch\u{2028}"]; + let others = [json!(""), json!(" "), json!("x".repeat(65)), json!("a\nb"), json!(7), json!(["Patch Tool"])]; + let others = ends.iter().map(|n| json!(n)).chain(others); + for name in invisible.iter().chain(&hidden).map(|n| json!(n)).chain(others) { assert!(s.execute("select.lasso", json!({"points": TRIANGLE, "historyName": name})).is_err(), "{name}"); let polygonal = json!({"points": TRIANGLE, "polygonal": true, "historyName": name}); assert!(s.execute("select.lasso", polygonal).is_err(), "polygonal {name}"); } assert_eq!(s.active().unwrap().history.past_len(), steps); } + +#[test] +fn a_name_joining_its_letters_with_a_zero_width_joiner_is_kept() { + let mut s = session(); + for name in ["\u{0645}\u{200C}\u{06CC}", "\u{1F469}\u{200D}\u{1F4BB}", "Patch Tool \u{2713}"] { + s.execute("select.lasso", json!({"points": TRIANGLE, "historyName": name})).unwrap(); + assert_eq!(last_step(&s).as_deref(), Some(name)); + } +} + +#[test] +fn spaces_and_joiners_at_the_ends_are_trimmed_and_only_what_is_left_counts() { + let mut s = session(); + let padded = format!("{}{}", " ".repeat(10), "x".repeat(60)); + let joined = format!("Patch Tool{}", "\u{200D}".repeat(60)); + let cases = [ + ("Patch Tool\u{200D}", "Patch Tool"), + (" \u{200C}Patch Tool ", "Patch Tool"), + ("Love \u{2764}\u{FE0F}", "Love \u{2764}\u{FE0F}"), + (joined.as_str(), "Patch Tool"), + ]; + for (name, stored) in cases.iter().copied().chain([(padded.as_str(), &padded[10..])]) { + s.execute("select.lasso", json!({"points": TRIANGLE, "historyName": name})).unwrap(); + assert_eq!(last_step(&s).as_deref(), Some(stored), "{name:?}"); + } +} diff --git a/crates/engine/src/cmd/selection_cmds.rs b/crates/engine/src/cmd/selection_cmds.rs index 90a1c14d88..ed4b60cc96 100644 --- a/crates/engine/src/cmd/selection_cmds.rs +++ b/crates/engine/src/cmd/selection_cmds.rs @@ -627,13 +627,27 @@ fn lasso(s: &mut Session, p: &Value) -> Result { // A bad name is refused even when `polygonal` picks the step's name. let named = match p.get("historyName") { None | Some(Value::Null) => None, - Some(Value::String(n)) if !n.trim().is_empty() && n.chars().count() <= 64 && !n.chars().any(char::is_control) => Some(n.trim()), - Some(_) => return Err(EngineError::BadParams { cmd: "select.lasso".into(), msg: "historyName must be a short text".into() }), + Some(Value::String(n)) if history_name(n).is_some() => history_name(n), + Some(_) => { + let msg = "historyName must be 1-64 characters with a visible one, and no control, line-break or hidden format characters"; + return Err(EngineError::BadParams { cmd: "select.lasso".into(), msg: msg.into() }); + } }; let step = if b(p, "polygonal", false) { "Polygonal Lasso" } else { named.unwrap_or("Lasso") }; polygon_selection(s, "select.lasso", step, &pts, p) } +/// The History step name for `historyName` `n`: refused when it has a control or line-break +/// character anywhere; otherwise without white space or zero-width (non-)joiners at its ends, so a +/// name that looks like "Patch Tool" is "Patch Tool", and then held to the plug-in menu-label rule. +fn history_name(n: &str) -> Option<&str> { + if n.chars().any(|c| c.is_control() || matches!(c, '\u{2028}' | '\u{2029}')) { + return None; + } + let t = n.trim_matches(|c: char| c.is_whitespace() || matches!(c, '\u{200C}' | '\u{200D}')); + photocraft_plugins::manifest::printable_name(t).then_some(t) +} + /// Selects the polygon `pts` with the lasso tools' `mode`, `antiAlias` and `feather` params, as /// one history step called `label`. pub(crate) fn polygon_selection(s: &mut Session, cmd: &str, label: &str, pts: &[(f32, f32)], p: &Value) -> Result { @@ -764,7 +778,7 @@ pub fn specs() -> Vec { "select.lasso", "Lasso", [], - r##"{"points":[[x,y],…],"mode":"replace|add|subtract|intersect"="replace","antiAlias":bool=true,"feather":px=0,"polygonal":bool=false,"historyName"?:text (1..64 characters, no control characters; the History step name when a tool other than the Lasso draws it, e.g. "Patch Tool"; default "Lasso")}"##, + r##"{"points":[[x,y],…],"mode":"replace|add|subtract|intersect"="replace","antiAlias":bool=true,"feather":px=0,"polygonal":bool=false,"historyName"?:text (1..64 characters with a visible one, no control, line-break or hidden format characters, spaces and joiners at the ends trimmed; the History step name when a tool other than the Lasso draws it, e.g. "Patch Tool"; default "Lasso")}"##, has_doc, lasso ), diff --git a/crates/plugins/src/manifest.rs b/crates/plugins/src/manifest.rs index 133c0fa843..f8116198fe 100644 --- a/crates/plugins/src/manifest.rs +++ b/crates/plugins/src/manifest.rs @@ -143,9 +143,7 @@ impl Manifest { if !valid_id(&self.id) { return Err(Error::Manifest(format!("invalid id {:?}: use 1-64 characters from A-Z a-z 0-9 . _ -", self.id))); } - let name = self.name.trim(); - let printable = name.chars().all(|c| !c.is_control() && !hidden_format(c)) && name.chars().any(visible); - if !printable || name.chars().count() > 64 { + if !printable_name(self.name.trim()) { return Err(Error::Manifest("`name` must be 1-64 printable characters".into())); } if self.version.len() > 32 || self.description.len() > 1024 || self.author.len() > 128 { @@ -399,6 +397,12 @@ fn hidden_format(c: char) -> bool { !matches!(c, '\u{200C}' | '\u{200D}') && in_ranges(c, FORMAT) } +/// Whether `name` (already trimmed) can be shown as a label: 1-64 characters, at least one of them +/// visible, and no control characters, line or paragraph separators, or hidden format characters. +pub fn printable_name(name: &str) -> bool { + name.chars().count() <= 64 && name.chars().any(visible) && !name.chars().any(|c| c.is_control() || matches!(c, '\u{2028}' | '\u{2029}') || hidden_format(c)) +} + /// A character that draws something on its own. fn visible(c: char) -> bool { !c.is_whitespace() && !c.is_control() && !in_ranges(c, FORMAT) && !in_ranges(c, BLANK) @@ -631,6 +635,8 @@ mod tests { "\u{3164}\u{FE0F}", " \u{3000} ", "Tone\u{7}", + "Tone\u{2028}Pro", + "Tone\u{2029}Pro", ] { assert!(with_name(bad).is_err(), "{bad:?}"); } From 48cdda6a89a587f1f8726ccb09a34eed4b16d379 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sun, 8 Nov 2026 01:08:04 -0600 Subject: [PATCH 195/219] Mixer Brush: open the Indexed/Bitmap refusal alert once, not once per press (CodeRabbit review on crates/ui-egui/src/canvas/tool_input.rs:373) A press that arrives while the same refusal alert is still open (ui.pointer can send one) no longer stacks a second alert, as the Background Eraser's range alert already does. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/tool_input.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/canvas/tool_input.rs b/crates/ui-egui/src/canvas/tool_input.rs index 0d49329f7c..6db30f6654 100644 --- a/crates/ui-egui/src/canvas/tool_input.rs +++ b/crates/ui-egui/src/canvas/tool_input.rs @@ -364,8 +364,13 @@ fn handle_eyedropper_event(app: &mut PhotocraftApp, tool: Tool, ev: ToolEvent, m fn mixer_press_failed(app: &mut PhotocraftApp, message: String) { use photocraft_engine::brush_cmds::mixer_live::{BITMAP_REFUSAL, INDEXED_REFUSAL}; if message == INDEXED_REFUSAL || message == BITMAP_REFUSAL { - let params = json!({ "message": message }).as_object().cloned().unwrap_or_default(); - app.ui.open_dialog(crate::state::DialogKind::Error, params); + // One alert, even if more presses arrive while it is open (`ui.pointer` can send them). + let shown = + app.ui.dialogs.iter().any(|d| d.kind == crate::state::DialogKind::Error && d.fields.get("message").and_then(|m| m.as_str()) == Some(&message)); + if !shown { + let params = json!({ "message": message }).as_object().cloned().unwrap_or_default(); + app.ui.open_dialog(crate::state::DialogKind::Error, params); + } } else { app.ui.status = message; app.ui.status_error = true; From 0692a3cf4767a984205478732624723413a2d926 Mon Sep 17 00:00:00 2001 From: Triwarr <170976175+Triwarr@users.noreply.github.com> Date: Sun, 8 Nov 2026 08:21:19 +0100 Subject: [PATCH 196/219] PDF: third review round on editable import - Bound fake-bold glyph/stroke pairing by comparison count and check cancellation during the scan. - Keep both knots when a closing curve returns to its start; fall back for a dashed single-point subpath. - Skip inkless runs (zero-width/format characters) instead of treating an empty cache as a render failure. - Track soft masks and reject the page only when paint happens while one is active. - Tests: fake-bold mismatch across a path, closing curve loop. --- crates/io/src/pdf_editable.rs | 80 +++++++++++++++++++++++++++++------ 1 file changed, 67 insertions(+), 13 deletions(-) diff --git a/crates/io/src/pdf_editable.rs b/crates/io/src/pdf_editable.rs index f718ac7c22..c4b2bef7eb 100644 --- a/crates/io/src/pdf_editable.rs +++ b/crates/io/src/pdf_editable.rs @@ -37,6 +37,8 @@ const MAX_LAYERS: usize = 20_000; const MAX_KNOTS: usize = 1_000_000; /// Pixel bytes of shape caches one editable page may retain before the rest render on demand. const MAX_CACHE_BYTES: u64 = 256 << 20; +/// Cap on fill/stroke glyph comparisons so hostile pages cannot make pairing quadratic forever. +const MAX_PAIR_CHECKS: usize = 1_000_000; /// How often the watcher copies the interrupt into craft-pdf's cancellation flag. const POLL: Duration = Duration::from_millis(5); /// Largest gap (in em) inside one run before a space is inserted. @@ -97,6 +99,7 @@ pub fn page(page: &Page<'_>, rect: Rect, ctl: &Interrupt) -> Result, strokes: Vec::new(), fonts: Vec::new(), font_keys: HashMap::new(), + soft_mask: false, clips: Vec::new(), knots: 0, cached: 0, @@ -143,9 +146,7 @@ pub fn page(page: &Page<'_>, rect: Rect, ctl: &Interrupt) -> Result, if let Some(reason) = device.unsupported { return Err(EditableError::Unsupported(reason)); } - if let Err(reason) = pair_fake_bold(&mut device.glyphs, device.strokes) { - return Err(EditableError::Unsupported(reason)); - } + pair_fake_bold(&mut device.glyphs, device.strokes, ctl)?; let families = register_fonts(&device.fonts)?; let mut layers = merge_text_layers(device.layers, text_layers(&device.glyphs, &families)); render_text_caches(&mut layers, device.dpi, device.fmt, ctl)?; @@ -153,10 +154,16 @@ pub fn page(page: &Page<'_>, rect: Rect, ctl: &Interrupt) -> Result, } /// Removes stroked glyphs that duplicate a fill at the same place, marking that glyph fake bold. -fn pair_fake_bold(glyphs: &mut [GlyphRun], strokes: Vec) -> Result<(), &'static str> { +fn pair_fake_bold(glyphs: &mut [GlyphRun], strokes: Vec, ctl: &Interrupt) -> Result<(), EditableError> { + if glyphs.len().saturating_mul(strokes.len()) > MAX_PAIR_CHECKS { + return Err(EditableError::Unsupported("too much stroked text")); + } for stroke in strokes { let mut matched = false; - for glyph in glyphs.iter_mut() { + for (index, glyph) in glyphs.iter_mut().enumerate() { + if index % 256 == 0 && ctl.check().is_err() { + return Err(EditableError::Cancelled); + } let tolerance = 0.01 * glyph.size_px + 0.01; if glyph.text == stroke.text && glyph.font.key == stroke.font.key @@ -170,7 +177,7 @@ fn pair_fake_bold(glyphs: &mut [GlyphRun], strokes: Vec) -> Result<(), } } if !matched { - return Err("stroked text"); + return Err(EditableError::Unsupported("stroked text")); } } Ok(()) @@ -215,7 +222,7 @@ fn text_layers(glyphs: &[GlyphRun], families: &std::collections::HashMap u128 { u128::from_le_bytes(key) } +/// Whether text has a visible character (not only spaces, controls or zero-width marks). +fn has_ink(text: &str) -> bool { + text.chars().any(|c| !c.is_whitespace() && !c.is_control() && !matches!(c, '\u{200B}'..='\u{200F}' | '\u{2060}' | '\u{FEFF}')) +} + /// The family alias an embedded face is registered under: its PostScript name plus a content /// key, so subsets with the same name from different documents cannot replace each other. fn font_alias(postscript: &str, key: u128) -> String { @@ -375,6 +387,8 @@ struct PathDevice<'a> { fonts: Vec<(u128, String, String, FontData)>, /// Hayro's cheap font identity → our content hash, so bytes are hashed once per face. font_keys: HashMap, + /// Whether a soft mask is active; the first paint under one falls back the page. + soft_mask: bool, /// One entry per clip pushed by the interpreter: `true` for an accepted no-op clip. clips: Vec, knots: usize, @@ -446,6 +460,11 @@ impl PathDevice<'_> { if path.is_empty() { return; } + if stroke.as_ref().is_some_and(|stroke| !stroke.dashes.is_empty()) && path.subpaths.iter().any(|subpath| subpath.closed && subpath.knots.len() == 1) { + // A dashed zero-length subpath cannot be reproduced as a single point. + self.unsupported("a dashed zero-length subpath"); + return; + } if self.layers.len() >= MAX_LAYERS { self.unsupported("too many objects"); return; @@ -469,11 +488,10 @@ impl PathDevice<'_> { } impl<'a> Device<'a> for PathDevice<'_> { - /// A soft mask changes compositing in ways a plain layer cannot express. + /// A soft mask changes compositing in ways a plain layer cannot express, but it only matters + /// if something is painted while it is active. fn set_soft_mask(&mut self, mask: Option>) { - if mask.is_some() { - self.unsupported("soft masks"); - } + self.soft_mask = mask.is_some(); } /// Only normal blending is editable. @@ -488,6 +506,10 @@ impl<'a> Device<'a> for PathDevice<'_> { if self.unsupported.is_some() { return; } + if self.soft_mask { + self.unsupported("soft masks"); + return; + } if self.ctl.check().is_err() { self.cancelled = true; return; @@ -550,6 +572,10 @@ impl<'a> Device<'a> for PathDevice<'_> { if self.unsupported.is_some() { return; } + if self.soft_mask { + self.unsupported("soft masks"); + return; + } if self.ctl.check().is_err() { self.cancelled = true; return; @@ -932,7 +958,15 @@ fn to_path(bez: &BezPath, transform: &Affine, knots: &mut usize) -> Option let first = subpath.knots[0].anchor; let last_anchor = subpath.knots.last().map_or(first, |knot| knot.anchor); if (first.x - last_anchor.x).abs() < 1e-9 && (first.y - last_anchor.y).abs() < 1e-9 { - if let Some(knot) = subpath.knots.pop() { + // A straight closing segment can merge into the first knot; a curve + // that returns to its start must keep both knots to preserve the loop. + let straight = subpath.knots.last().is_some_and(|knot| { + knot.in_ctrl.x == knot.anchor.x + && knot.in_ctrl.y == knot.anchor.y + && knot.out_ctrl.x == knot.anchor.x + && knot.out_ctrl.y == knot.anchor.y + }); + if straight && let Some(knot) = subpath.knots.pop() { subpath.knots[0].in_ctrl = knot.in_ctrl; } } else { @@ -1451,6 +1485,26 @@ mod tests { assert_eq!(text(&layers[2]), "B"); } + #[test] + fn fake_bold_does_not_pair_across_a_path() { + let bytes = text_pdf("BT /F1 24 Tf 20 50 Td (Hi) Tj ET 0 0 1 rg 0 0 200 100 re f BT /F1 24 Tf 1 Tr 1 w 0 0 1 RG 20 50 Td (Hi) Tj ET"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("pair.pdf", &bytes, &options, &Interrupt::NONE).expect("import"); + assert!(imported.warnings.iter().any(|warning| warning.contains("stroked text") && warning.contains("rasterised")), "{:?}", imported.warnings); + } + + #[test] + fn a_curve_returning_to_its_start_keeps_both_knots() { + let mut bez = BezPath::new(); + bez.move_to((0.0, 0.0)); + bez.curve_to((10.0, 0.0), (10.0, 10.0), (0.0, 0.0)); + bez.close_path(); + let mut knots = 0; + let path = to_path(&bez, &Affine::IDENTITY, &mut knots).expect("a path"); + assert_eq!(path.subpaths.len(), 1); + assert!(path.subpaths[0].knots.len() >= 2); + } + #[test] fn a_closed_single_point_subpath_is_retained() { let mut bez = BezPath::new(); From bf21e370f4a47f9c47c0cc42198701aca1f3dbcd Mon Sep 17 00:00:00 2001 From: Triwarr <170976175+Triwarr@users.noreply.github.com> Date: Sun, 8 Nov 2026 09:54:52 +0100 Subject: [PATCH 197/219] PDF: fourth review round on editable import - Count actual fake-bold comparisons toward the cap instead of the product of glyph and stroke counts. - Fall back for any dashed zero-length subpath (open or closed); test covers a stroked line followed by a closed point. - Handle invisible glyphs before the soft-mask check so hidden text does not rasterise the page. --- crates/io/src/pdf_editable.rs | 33 ++++++++++++++++++++++++++------- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/crates/io/src/pdf_editable.rs b/crates/io/src/pdf_editable.rs index c4b2bef7eb..0d12d6e924 100644 --- a/crates/io/src/pdf_editable.rs +++ b/crates/io/src/pdf_editable.rs @@ -155,15 +155,17 @@ pub fn page(page: &Page<'_>, rect: Rect, ctl: &Interrupt) -> Result, /// Removes stroked glyphs that duplicate a fill at the same place, marking that glyph fake bold. fn pair_fake_bold(glyphs: &mut [GlyphRun], strokes: Vec, ctl: &Interrupt) -> Result<(), EditableError> { - if glyphs.len().saturating_mul(strokes.len()) > MAX_PAIR_CHECKS { - return Err(EditableError::Unsupported("too much stroked text")); - } + let mut checks = 0usize; for stroke in strokes { let mut matched = false; for (index, glyph) in glyphs.iter_mut().enumerate() { if index % 256 == 0 && ctl.check().is_err() { return Err(EditableError::Cancelled); } + if checks == MAX_PAIR_CHECKS { + return Err(EditableError::Unsupported("too much stroked text")); + } + checks += 1; let tolerance = 0.01 * glyph.size_px + 0.01; if glyph.text == stroke.text && glyph.font.key == stroke.font.key @@ -460,7 +462,7 @@ impl PathDevice<'_> { if path.is_empty() { return; } - if stroke.as_ref().is_some_and(|stroke| !stroke.dashes.is_empty()) && path.subpaths.iter().any(|subpath| subpath.closed && subpath.knots.len() == 1) { + if stroke.as_ref().is_some_and(|stroke| !stroke.dashes.is_empty()) && path.subpaths.iter().any(is_zero_length) { // A dashed zero-length subpath cannot be reproduced as a single point. self.unsupported("a dashed zero-length subpath"); return; @@ -572,6 +574,9 @@ impl<'a> Device<'a> for PathDevice<'_> { if self.unsupported.is_some() { return; } + if matches!(draw_mode, GlyphDrawMode::Invisible) { + return; + } if self.soft_mask { self.unsupported("soft masks"); return; @@ -580,9 +585,6 @@ impl<'a> Device<'a> for PathDevice<'_> { self.cancelled = true; return; } - if matches!(draw_mode, GlyphDrawMode::Invisible) { - return; - } if matches!(draw_mode, GlyphDrawMode::Clip) { self.unsupported("text used as a clipping path"); return; @@ -819,6 +821,11 @@ fn axis_aligned_rect(path: &BezPath) -> Option<(f64, f64, f64, f64)> { Some((x0, y0, x1, y1)) } +/// Whether every knot of a subpath shares the first anchor (a zero-length subpath). +fn is_zero_length(subpath: &Subpath) -> bool { + subpath.knots.first().is_some_and(|first| subpath.knots.iter().all(|knot| knot.anchor.x == first.anchor.x && knot.anchor.y == first.anchor.y)) +} + /// Whether a path transform keeps circles circular: both axes the same length and perpendicular. fn uniform_transform(transform: &KurboAffine) -> bool { let [a, b, c, d, _, _] = transform.as_coeffs(); @@ -1505,6 +1512,18 @@ mod tests { assert!(path.subpaths[0].knots.len() >= 2); } + #[test] + fn a_dashed_zero_length_line_falls_back() { + let bytes = pdf("[1 1] 0 d 10 10 m 20 20 l 30 30 m h S"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("dash.pdf", &bytes, &options, &Interrupt::NONE).expect("import"); + assert!( + imported.warnings.iter().any(|warning| warning.contains("dashed zero-length subpath") && warning.contains("rasterised")), + "{:?}", + imported.warnings + ); + } + #[test] fn a_closed_single_point_subpath_is_retained() { let mut bez = BezPath::new(); From c2f85a590f97f29aac46eb865b56bab58dd13429 Mon Sep 17 00:00:00 2001 From: SBaksa <18648812+SBaksa@users.noreply.github.com> Date: Sun, 8 Nov 2026 19:59:43 +1100 Subject: [PATCH 198/219] Plug-in names: store the name trimmed, count U+2800 and unassigned ignorables as blank A manifest name such as "\nTone" was checked trimmed but shown as given, so a line break reached the menu; padding also got past the 64-character limit. The name is now stored trimmed. The blank Braille pattern and the unassigned default-ignorable ranges no longer count as visible, in plug-in names and History names. Applying a patch no longer takes the selection icon, and the History source column has its doc comment back. --- .../retouch_cmds/patch_history_names_tests.rs | 2 +- crates/plugins/src/manifest.rs | 33 +++++++++++++++---- crates/ui-egui/src/panels/history.rs | 8 +++-- .../src/patch_tool_history_names_tests.rs | 2 ++ 4 files changed, 35 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/retouch_cmds/patch_history_names_tests.rs b/crates/engine/src/cmd/retouch_cmds/patch_history_names_tests.rs index d72958fc3a..b3e87b0d6d 100644 --- a/crates/engine/src/cmd/retouch_cmds/patch_history_names_tests.rs +++ b/crates/engine/src/cmd/retouch_cmds/patch_history_names_tests.rs @@ -51,7 +51,7 @@ fn the_lasso_is_named_for_the_tool_that_drew_it() { fn a_hostile_history_name_is_refused_without_a_step() { let mut s = session(); let steps = s.active().unwrap().history.past_len(); - let invisible = ["\u{200B}", " \u{200B}\u{FEFF} ", "\u{3164}", "\u{FE0F}", "\u{E0020}", "\u{00AD}"]; + let invisible = ["\u{200B}", " \u{200B}\u{FEFF} ", "\u{3164}", "\u{FE0F}", "\u{E0020}", "\u{00AD}", "\u{2800}"]; let hidden = ["Patch\u{202E}looT", "\u{FEFF}Patch Tool", "Patch Tool\u{200B}", "\u{2066}Patch", "Patch\u{2028}Tool", "a\u{2029}b"]; let ends = ["Patch Tool\n", "\r\nPatch Tool", "\tPatch", "\u{0085}Patch", "Patch\u{2028}"]; let others = [json!(""), json!(" "), json!("x".repeat(65)), json!("a\nb"), json!(7), json!(["Patch Tool"])]; diff --git a/crates/plugins/src/manifest.rs b/crates/plugins/src/manifest.rs index f8116198fe..1273dfea26 100644 --- a/crates/plugins/src/manifest.rs +++ b/crates/plugins/src/manifest.rs @@ -134,7 +134,9 @@ impl Manifest { return Err(Error::Manifest(format!("manifest is {} bytes (limit {MAX_MANIFEST_BYTES})", bytes.len()))); } let text = std::str::from_utf8(bytes).map_err(|_| Error::Manifest("manifest is not UTF-8".into()))?; - let man: Manifest = serde_json::from_str(text).map_err(|e| Error::Manifest(format!("manifest: {e}")))?; + let mut man: Manifest = serde_json::from_str(text).map_err(|e| Error::Manifest(format!("manifest: {e}")))?; + // The name is shown as stored, so white space and line breaks at its ends are dropped here. + man.name = man.name.trim().to_owned(); man.validate()?; Ok(man) } @@ -143,7 +145,7 @@ impl Manifest { if !valid_id(&self.id) { return Err(Error::Manifest(format!("invalid id {:?}: use 1-64 characters from A-Z a-z 0-9 . _ -", self.id))); } - if !printable_name(self.name.trim()) { + if !printable_name(&self.name) { return Err(Error::Manifest("`name` must be 1-64 printable characters".into())); } if self.version.len() > 32 || self.description.len() > 1024 || self.author.len() > 128 { @@ -372,18 +374,23 @@ const FORMAT: &[(char, char)] = &[ ('\u{E0020}', '\u{E007F}'), ]; -/// Default-ignorable characters outside Cf (fillers, variation selectors): allowed in a name, but they -/// draw nothing on their own. +/// Default-ignorable characters outside Cf (fillers, variation selectors, unassigned ignorables), and +/// the blank Braille pattern U+2800: allowed in a name, but they draw nothing on their own. const BLANK: &[(char, char)] = &[ ('\u{34F}', '\u{34F}'), ('\u{115F}', '\u{1160}'), ('\u{17B4}', '\u{17B5}'), ('\u{180B}', '\u{180D}'), ('\u{180F}', '\u{180F}'), + ('\u{2065}', '\u{2065}'), + ('\u{2800}', '\u{2800}'), ('\u{3164}', '\u{3164}'), ('\u{FE00}', '\u{FE0F}'), ('\u{FFA0}', '\u{FFA0}'), - ('\u{E0100}', '\u{E01EF}'), + ('\u{FFF0}', '\u{FFF8}'), + ('\u{E0000}', '\u{E0000}'), + ('\u{E0002}', '\u{E001F}'), + ('\u{E0080}', '\u{E0FFF}'), ]; fn in_ranges(c: char, ranges: &[(char, char)]) -> bool { @@ -400,7 +407,9 @@ fn hidden_format(c: char) -> bool { /// Whether `name` (already trimmed) can be shown as a label: 1-64 characters, at least one of them /// visible, and no control characters, line or paragraph separators, or hidden format characters. pub fn printable_name(name: &str) -> bool { - name.chars().count() <= 64 && name.chars().any(visible) && !name.chars().any(|c| c.is_control() || matches!(c, '\u{2028}' | '\u{2029}') || hidden_format(c)) + name.chars().nth(64).is_none() + && name.chars().any(visible) + && !name.chars().any(|c| c.is_control() || matches!(c, '\u{2028}' | '\u{2029}') || hidden_format(c)) } /// A character that draws something on its own. @@ -637,6 +646,12 @@ mod tests { "Tone\u{7}", "Tone\u{2028}Pro", "Tone\u{2029}Pro", + "\u{2800}", + " \u{2800}\u{2800} ", + "\u{2065}", + "\u{FFF0}", + "\u{E0000}\u{E0002}", + "\u{E0080}\u{E0FFF}", ] { assert!(with_name(bad).is_err(), "{bad:?}"); } @@ -644,6 +659,12 @@ mod tests { for good in ["Tone…", "Kontrast ±", "می\u{200C}خواهم", "\u{1F469}\u{200D}\u{1F4BB} Code", "\u{2764}\u{FE0F} Love", "色调"] { assert_eq!(with_name(good).map(|m| m.name).ok().as_deref(), Some(good)); } + // White space and line breaks at the ends are dropped from the stored name, so the menu never + // shows them, and padding does not count towards the limit. + for (padded, stored) in [("\nTone", "Tone"), ("Tone\r\n", "Tone"), ("\u{2028}Tone ", "Tone"), (" Tone… ", "Tone…")] { + assert_eq!(with_name(padded).map(|m| m.name).ok().as_deref(), Some(stored), "{padded:?}"); + } + assert_eq!(with_name(&format!("{}{}", "x".repeat(64), " ".repeat(1000))).map(|m| m.name.len()).ok(), Some(64)); // The documented limit counts characters, not bytes. assert!(with_name(&"é".repeat(64)).is_ok()); assert!(with_name(&"é".repeat(65)).is_err()); diff --git a/crates/ui-egui/src/panels/history.rs b/crates/ui-egui/src/panels/history.rs index 1a366876f3..e75dbbb5b9 100644 --- a/crates/ui-egui/src/panels/history.rs +++ b/crates/ui-egui/src/panels/history.rs @@ -185,13 +185,12 @@ pub(super) fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } } -/// The History panel's source column at the left of a state or snapshot row: the History Brush -/// icon on the source; a click makes the row the source (`history.setSource`). It sits above the -/// row, so the click doesn't also step to the state or snapshot. /// The icon of a History step, from its name. pub(crate) fn step_icon(step: &str) -> &'static str { match step { s if s.contains("Brush") || s.contains("Eraser") => "brush", + // Applying a patch edits pixels; only the Patch tool's lasso is a selection step. + "Patch Selection" => "sliders-horizontal", // The Layers panel's controls (layer.setProps). "Opacity Change" | "Fill Opacity Change" | "Blending Change" => "layers", s if s.contains("Layer") => "layers", @@ -204,6 +203,9 @@ pub(crate) fn step_icon(step: &str) -> &'static str { } } +/// The History panel's source column at the left of a state or snapshot row: the History Brush +/// icon on the source; a click makes the row the source (`history.setSource`). It sits above the +/// row, so the click doesn't also step to the state or snapshot. fn history_source_column(ui: &mut egui::Ui, row: Rect, salt: egui::Id, is_source: bool) -> bool { let t = Tokens::get(ui.ctx()); let cell = Rect::from_min_size(row.min, vec2(24.0, row.height())); diff --git a/crates/ui-egui/src/patch_tool_history_names_tests.rs b/crates/ui-egui/src/patch_tool_history_names_tests.rs index ef8fec47b5..d0dc3e771d 100644 --- a/crates/ui-egui/src/patch_tool_history_names_tests.rs +++ b/crates/ui-egui/src/patch_tool_history_names_tests.rs @@ -37,4 +37,6 @@ fn the_patch_tool_lasso_is_named_for_the_tool() { fn the_patch_tool_step_has_the_selection_icon() { assert_eq!(crate::panels::history_step_icon("Patch Tool"), crate::panels::history_step_icon("Lasso")); assert_eq!(crate::panels::history_step_icon("Patch Tool"), "square-dashed"); + // Applying the patch edits pixels, so it does not take the selection icon from "Selection". + assert_ne!(crate::panels::history_step_icon("Patch Selection"), "square-dashed"); } From d39fc3f9cdbcc4b84461dd3acd256cd70c0d32b4 Mon Sep 17 00:00:00 2001 From: Triwarr <170976175+Triwarr@users.noreply.github.com> Date: Sun, 8 Nov 2026 11:29:05 +0100 Subject: [PATCH 199/219] PDF: fifth review round on editable import - Accept explicitly closed page-sized clip rectangles (m l l l l h). - Pair fake-bold strokes through a text/font/order index instead of a full scan, keeping the comparison cap and cancellation checks. - Tests: solid zero-length stroke stays editable, explicit clip rectangle. --- crates/io/src/pdf_editable.rs | 81 +++++++++++++++++++++++++---------- 1 file changed, 58 insertions(+), 23 deletions(-) diff --git a/crates/io/src/pdf_editable.rs b/crates/io/src/pdf_editable.rs index 0d12d6e924..e83b6a2581 100644 --- a/crates/io/src/pdf_editable.rs +++ b/crates/io/src/pdf_editable.rs @@ -155,32 +155,41 @@ pub fn page(page: &Page<'_>, rect: Rect, ctl: &Interrupt) -> Result, /// Removes stroked glyphs that duplicate a fill at the same place, marking that glyph fake bold. fn pair_fake_bold(glyphs: &mut [GlyphRun], strokes: Vec, ctl: &Interrupt) -> Result<(), EditableError> { + // Index fills by the exact attributes a stroke must match, so each stroke only compares + // against candidates at its own paint order/font/text instead of the whole page. + let mut index: HashMap<(usize, u128, String), Vec> = HashMap::new(); + for (at, glyph) in glyphs.iter().enumerate() { + index.entry((glyph.order, glyph.font.key, glyph.text.clone())).or_default().push(at); + } + let mut paired = vec![false; glyphs.len()]; let mut checks = 0usize; for stroke in strokes { - let mut matched = false; - for (index, glyph) in glyphs.iter_mut().enumerate() { - if index % 256 == 0 && ctl.check().is_err() { - return Err(EditableError::Cancelled); - } - if checks == MAX_PAIR_CHECKS { - return Err(EditableError::Unsupported("too much stroked text")); - } - checks += 1; - let tolerance = 0.01 * glyph.size_px + 0.01; - if glyph.text == stroke.text - && glyph.font.key == stroke.font.key - && glyph.order == stroke.order - && (glyph.origin.x - stroke.origin.x).abs() <= tolerance - && (glyph.origin.y - stroke.origin.y).abs() <= tolerance - { - glyph.fake_bold = true; - matched = true; - break; + let mut found = None; + if let Some(candidates) = index.get(&(stroke.order, stroke.font.key, stroke.text.clone())) { + for &at in candidates { + if paired[at] { + continue; + } + if checks == MAX_PAIR_CHECKS { + return Err(EditableError::Unsupported("too much stroked text")); + } + if checks.is_multiple_of(256) && ctl.check().is_err() { + return Err(EditableError::Cancelled); + } + checks += 1; + let glyph = &glyphs[at]; + let tolerance = 0.01 * glyph.size_px + 0.01; + if (glyph.origin.x - stroke.origin.x).abs() <= tolerance && (glyph.origin.y - stroke.origin.y).abs() <= tolerance { + found = Some(at); + break; + } } } - if !matched { + let Some(at) = found else { return Err(EditableError::Unsupported("stroked text")); - } + }; + glyphs[at].fake_bold = true; + paired[at] = true; } Ok(()) } @@ -781,7 +790,7 @@ fn space_camel(name: &str) -> String { /// The bounds of a path that is one axis-aligned rectangle, else `None`. fn axis_aligned_rect(path: &BezPath) -> Option<(f64, f64, f64, f64)> { let elements = path.elements(); - if elements.len() != 5 { + if !(5..=6).contains(&elements.len()) { return None; } let mut points = Vec::with_capacity(4); @@ -793,7 +802,12 @@ fn axis_aligned_rect(path: &BezPath) -> Option<(f64, f64, f64, f64)> { } } if points.len() != 4 { - return None; + // An explicitly closed rectangle uses four LineTo points plus the duplicate start. + if points.len() == 5 && (points[0].0 - points[4].0).abs() < 1.0e-6 && (points[0].1 - points[4].1).abs() < 1.0e-6 { + points.pop(); + } else { + return None; + } } let x0 = points.iter().map(|p| p.0).fold(f64::INFINITY, f64::min); let x1 = points.iter().map(|p| p.0).fold(f64::NEG_INFINITY, f64::max); @@ -1512,6 +1526,18 @@ mod tests { assert!(path.subpaths[0].knots.len() >= 2); } + #[test] + fn an_explicitly_closed_rectangle_is_a_no_op_clip() { + let mut path = BezPath::new(); + path.move_to((0.0, 0.0)); + path.line_to((200.0, 0.0)); + path.line_to((200.0, 100.0)); + path.line_to((0.0, 100.0)); + path.line_to((0.0, 0.0)); + path.close_path(); + assert_eq!(axis_aligned_rect(&path), Some((0.0, 0.0, 200.0, 100.0))); + } + #[test] fn a_dashed_zero_length_line_falls_back() { let bytes = pdf("[1 1] 0 d 10 10 m 20 20 l 30 30 m h S"); @@ -1524,6 +1550,15 @@ mod tests { ); } + #[test] + fn a_solid_zero_length_line_stays_editable() { + let bytes = pdf("10 10 m 20 20 l 30 30 m h S"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("solid.pdf", &bytes, &options, &Interrupt::NONE).expect("import"); + let layers = children(&imported.document); + assert!(layers.iter().any(|layer| matches!(layer.content, LayerContent::Shape(_))), "{:?}", imported.warnings); + } + #[test] fn a_closed_single_point_subpath_is_retained() { let mut bez = BezPath::new(); From e81e813b945519948ef99134a80e5c5ce81e2a57 Mon Sep 17 00:00:00 2001 From: rmsff <17902130+rmsff@users.noreply.github.com> Date: Sun, 8 Nov 2026 14:10:06 +0300 Subject: [PATCH 200/219] fix(ui): the Type options bar gets the reference tooltips (#12057) The font family menu showed no tooltip, the font style menu none, and the font size icon and colour swatch other wording. They now show the reference application's texts: "Search and select fonts", "Set font style", "Set font size" (icon and field) and "Set text color", through the catalogue with the English fallback. The controls keep their accessible names. The family tooltip is added by the options bar only: the Character, style and Glyphs panels share the picker and keep their texts. canvas_tests' T-icon tooltip probe took the tooltip to be the widget's own name; it now takes the expected tooltip text. Credit: own entry in contributors/people.toml. --- contributors/people.toml | 4 + crates/ui-egui/src/tools/type_tool.rs | 2 + .../src/tools/type_tool/canvas_tests.rs | 15 ++- crates/ui-egui/src/tools/type_tool/fonts.rs | 12 ++ .../src/tools/type_tool/options_bar.rs | 13 +- .../tools/type_tool/options_tooltips_tests.rs | 121 ++++++++++++++++++ 6 files changed, 156 insertions(+), 11 deletions(-) create mode 100644 crates/ui-egui/src/tools/type_tool/options_tooltips_tests.rs diff --git a/contributors/people.toml b/contributors/people.toml index a2ead57598..5995994db3 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -315,3 +315,7 @@ sync_github_name = false [people.davidvhk] real_name = "David Vanhoucke" sync_github_name = true + +[people.rmsff] +real_name = "Serikov Roman" +sync_github_name = false diff --git a/crates/ui-egui/src/tools/type_tool.rs b/crates/ui-egui/src/tools/type_tool.rs index 42ad346f61..766aa4220c 100644 --- a/crates/ui-egui/src/tools/type_tool.rs +++ b/crates/ui-egui/src/tools/type_tool.rs @@ -137,6 +137,8 @@ mod canvas_tests; mod font_commit_tests; #[cfg(test)] mod font_picker_tests; +#[cfg(test)] +mod options_tooltips_tests; #[cfg(test)] mod tests; diff --git a/crates/ui-egui/src/tools/type_tool/canvas_tests.rs b/crates/ui-egui/src/tools/type_tool/canvas_tests.rs index 091a0a22cb..0a8fbbd7f1 100644 --- a/crates/ui-egui/src/tools/type_tool/canvas_tests.rs +++ b/crates/ui-egui/src/tools/type_tool/canvas_tests.rs @@ -795,25 +795,26 @@ fn options_bar_t_icon_scrub_stops_at_the_size_limits() { } /// Rests the pointer on the widget named `label` past the tooltip delay; true when its tooltip -/// (a second node with the same name) shows. -fn tooltip_shows(h: &mut Harness<'static, PhotocraftApp>, role: egui::accesskit::Role, label: &str) -> bool { +/// `tip` shows (one more node with that text). +fn tooltip_shows(h: &mut Harness<'static, PhotocraftApp>, role: egui::accesskit::Role, label: &str, tip: &str) -> bool { + let before = h.query_all_by_label(tip).count(); let at = h.get_by_role_and_label(role, label).rect().center(); h.hover_at(at); h.run_steps(8); - h.query_all_by_label(label).count() == 2 + h.query_all_by_label(tip).count() == before + 1 } -/// The scrubby "T" icon keeps its "Font size" tooltip: the size field's drag area, laid over the -/// icon after it, must not take its hover. +/// The scrubby "T" icon keeps its tooltip ("Set font size", #12057): the size field's drag area, +/// laid over the icon after it, must not take its hover. #[test] fn options_bar_t_icon_shows_its_tooltip() { let mut app = new_app(); app.run("type.create", json!({"text": "Hello", "size": 20, "x": 300, "y": 420})).unwrap(); let mut h = options_harness(app); - assert!(tooltip_shows(&mut h, egui::accesskit::Role::Button, "Set the text color"), "the probe sees an ordinary tooltip"); + assert!(tooltip_shows(&mut h, egui::accesskit::Role::Button, "Set the text color", "Set text color"), "the probe sees an ordinary tooltip"); h.hover_at(egui::pos2(890.0, 55.0)); h.run_steps(4); - assert!(tooltip_shows(&mut h, egui::accesskit::Role::Label, "Font size"), "hovering the \"T\" icon shows \"Font size\""); + assert!(tooltip_shows(&mut h, egui::accesskit::Role::Label, "Font size", "Set font size"), "hovering the \"T\" icon shows \"Set font size\""); } /// A point inside glyph `i` of vertical type: `f` of the way down its advance, right of the diff --git a/crates/ui-egui/src/tools/type_tool/fonts.rs b/crates/ui-egui/src/tools/type_tool/fonts.rs index 7c8162e3f4..022b978f8b 100644 --- a/crates/ui-egui/src/tools/type_tool/fonts.rs +++ b/crates/ui-egui/src/tools/type_tool/fonts.rs @@ -338,6 +338,18 @@ pub(super) fn font_picker(app: &mut PhotocraftApp, ui: &mut egui::Ui, current: & Some(style) } +/// The options bar's hover text on the [`font_picker`] just drawn in `ui`: its name field and its +/// arrow, by the ids [`family_picker_in`] gives them. Only the options bar calls it (#12057); the +/// panels sharing the picker keep their texts. +pub(super) fn font_picker_tip(ui: &egui::Ui, tip: &str) { + let field = ui.make_persistent_id(egui::IdSalt::new("type-font")); + for id in [field, field.with("arrow")] { + if let Some(r) = ui.ctx().read_response(id) { + r.on_hover_text(tip); + } + } +} + /// The font list's maximum height: it scrolls inside the menu, under the fixed preview and filters. pub(super) const FONT_LIST_HEIGHT: f32 = 440.0; /// The font list's width: star, style expander, name, format badge and sample. diff --git a/crates/ui-egui/src/tools/type_tool/options_bar.rs b/crates/ui-egui/src/tools/type_tool/options_bar.rs index 33287d1234..25f28093e5 100644 --- a/crates/ui-egui/src/tools/type_tool/options_bar.rs +++ b/crates/ui-egui/src/tools/type_tool/options_bar.rs @@ -1,6 +1,6 @@ use super::defaults::{apply, formatting_params, open_color_picker, shown_scale, target, target_doc, type_unit}; use super::editing::{cancel, commit_with}; -use super::fonts::{font_picker, fonts_ready, selected_style, style_label, styles}; +use super::fonts::{font_picker, font_picker_tip, fonts_ready, selected_style, style_label, styles}; use super::keys::is_vertical; use super::{Color32, LayerId, PhotocraftApp, Stroke, byte_of, insertion_style, json, selected_styles, text_layer}; @@ -43,6 +43,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { None => apply(app, ui.ctx(), json!({"font": fam})), } } + font_picker_tip(ui, tl!("Search and select fonts")); let style_family = if fam.is_empty() { styles_at(app).map(|(c, _)| c.font_family).unwrap_or_default() } else { fam.clone() }; let opts: Vec<(String, String)> = styles(&style_family).into_iter().map(|s| (s.clone(), style_label(&s))).collect(); let opts_ref: Vec<(String, &str)> = opts.iter().map(|(a, b)| (a.clone(), b.as_str())).collect(); @@ -50,15 +51,19 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { app.ui.tool_options.type_style = style.clone(); apply(app, ui.ctx(), json!({"fontStyle": style})); } + // The menu's button, by the id its combo box takes from "type-style"; "Style" stays its name. + if let Some(menu) = ui.ctx().read_response(ui.make_persistent_id(egui::IdSalt::new("type-style"))) { + menu.on_hover_text(tl!("Set font style")); + } } let (r, icon) = ui.allocate_exact_size(egui::vec2(18.0, 22.0), egui::Sense::hover()); crate::icons::paint(ui, r, "type", 13.0, t.icon); icon.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, tl!("Font size"))); // Photoshop's "T" scrubs the size field beside it, like the Character panel's icons (#6437). - crate::widgets::scrub_option_icon(ui, &icon, tl!("Font size")); + crate::widgets::scrub_option_icon(ui, &icon, tl!("Set font size")); let (unit, label) = type_unit(app); let mut display = size * unit; - if crate::widgets::value_field(ui, &mut display, unit..=1296.0 * unit, label, 66.0).changed() { + if crate::widgets::value_field(ui, &mut display, unit..=1296.0 * unit, label, 66.0).on_hover_text(tl!("Set font size")).changed() { let size = display / unit; app.ui.tool_options.type_size = size; apply(app, ui.ctx(), json!({"size": size, "metricsAsShown": true})); @@ -128,7 +133,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { 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]))); ui.painter().rect_stroke(rect, 2.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Outside); - let resp = resp.on_hover_text(tl!("Set the text color")); + let resp = resp.on_hover_text(tl!("Set text color")); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Set the text color"))); if resp.clicked() { open_color_picker(app, [c[0], c[1], c[2]]); diff --git a/crates/ui-egui/src/tools/type_tool/options_tooltips_tests.rs b/crates/ui-egui/src/tools/type_tool/options_tooltips_tests.rs new file mode 100644 index 0000000000..a41a0e2f2c --- /dev/null +++ b/crates/ui-egui/src/tools/type_tool/options_tooltips_tests.rs @@ -0,0 +1,121 @@ +//! #12057: the Type options bar's font family menu, font style menu, font size icon and field, +//! and text colour swatch carry the reference application's tooltips (also their accessible +//! descriptions). The controls keep their names, and the Character panel's font menu, which +//! shares the family picker, keeps its text. + +use egui::accesskit::Role; +use egui::vec2; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; + +use crate::PhotocraftApp; + +const FAMILY_TIP: &str = "Search and select fonts"; + +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("type.create", json!({"text": "Sample", "font": "Inter", "size": 18})).unwrap(); + app +} + +/// `draw` in a row, once the app's fonts are set up (the first pass has none). +fn harness(draw: fn(&mut PhotocraftApp, &mut egui::Ui)) -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(vec2(1400.0, 600.0)).build_ui_state( + move |ui, app| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + ui.horizontal(|ui| draw(app, ui)); + } + }, + app(), + ); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(4); + h +} + +fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + super::options_bar::options_bar(app, ui); +} + +fn character_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + ui.vertical(|ui| super::panel::character_panel(app, ui, false)); +} + +fn description(n: egui_kittest::Node<'_>) -> Option { + n.accesskit_node().description().map(|d| d.to_string()) +} + +fn described_by_label(h: &Harness<'static, PhotocraftApp>, label: &str) -> Vec { + h.query_all_by_label(label).filter_map(description).collect() +} + +fn described_by_role(h: &Harness<'static, PhotocraftApp>, role: Role) -> Vec { + h.query_all_by_role(role).filter_map(description).collect() +} + +/// Every text shape drawn this frame, tooltips included. +fn drawn(h: &Harness<'static, PhotocraftApp>) -> Vec { + fn collect(shape: &egui::Shape, text: &mut Vec) { + match shape { + egui::Shape::Text(shape) => text.push(shape.galley.job.text.clone()), + egui::Shape::Vec(shapes) => shapes.iter().for_each(|shape| collect(shape, text)), + _ => {} + } + } + let mut text = Vec::new(); + for shape in &h.output().shapes { + collect(&shape.shape, &mut text); + } + text +} + +#[test] +fn the_font_family_menu_has_the_reference_tooltip() { + let h = harness(options_bar); + // The name field (the bar's only text input) and its arrow, which keeps the name "Font". + assert_eq!(described_by_role(&h, Role::TextInput), [FAMILY_TIP]); + assert_eq!(described_by_label(&h, "Font"), [FAMILY_TIP]); +} + +#[test] +fn the_font_style_menu_has_the_reference_tooltip_and_keeps_its_name() { + let h = harness(options_bar); + assert_eq!(described_by_label(&h, "Style"), ["Set font style"]); +} + +#[test] +fn the_font_size_field_has_the_reference_tooltip() { + let h = harness(options_bar); + // The bar's only number field. + assert_eq!(described_by_role(&h, Role::SpinButton), ["Set font size"]); +} + +#[test] +fn the_font_size_icon_shows_the_reference_tooltip_and_keeps_its_name() { + let mut h = harness(options_bar); + // The icon itself: the size field beside it is labelled by it, so it is named "Font size" too. + h.get_by_role_and_label(Role::Label, "Font size").hover(); + h.run_steps(8); + let text = drawn(&h); + assert!(text.iter().any(|t| t == "Set font size"), "{text:?}"); + assert!(!text.iter().any(|t| t == "Font size"), "old tooltip: {text:?}"); +} + +#[test] +fn the_text_color_swatch_has_the_reference_tooltip_and_keeps_its_name() { + let h = harness(options_bar); + let found = described_by_label(&h, "Set the text color"); + assert_eq!(found, ["Set text color"]); +} + +/// A guard, so it passes without the fix too: it fails if the tooltip moves into the picker that +/// the panels share. +#[test] +fn the_character_panel_font_menu_keeps_its_text() { + let h = harness(character_panel); + assert!(h.query_by_label("Font").is_some(), "the panel draws its font menu"); + let found: Vec = described_by_label(&h, "Font").into_iter().chain(described_by_role(&h, Role::TextInput)).collect(); + assert!(!found.iter().any(|d| d == FAMILY_TIP), "{found:?}"); +} From 28d2d9095dbbce480b09cff5cd37165993ba128d Mon Sep 17 00:00:00 2001 From: SBaksa <18648812+SBaksa@users.noreply.github.com> Date: Sun, 8 Nov 2026 21:58:21 +1100 Subject: [PATCH 201/219] History and plug-in names: trim the fillers and blank padding at the ends too trim_name drops white space, ZWNJ/ZWJ and the blank characters (Braille blank, Hangul fillers, invisible operators, tag and other default-ignorable blanks) from both ends. A variation or Mongolian free variation selector or CGJ that belongs to a kept character stays, and so do Hangul filler jamo that complete a syllable. History names use the same trim as plug-in names. --- .../retouch_cmds/patch_history_names_tests.rs | 9 ++ crates/engine/src/cmd/selection_cmds.rs | 9 +- crates/plugins/src/manifest.rs | 101 +++++++++++++++++- docs/plugins.md | 3 + 4 files changed, 115 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/retouch_cmds/patch_history_names_tests.rs b/crates/engine/src/cmd/retouch_cmds/patch_history_names_tests.rs index b3e87b0d6d..2fcee6e1d7 100644 --- a/crates/engine/src/cmd/retouch_cmds/patch_history_names_tests.rs +++ b/crates/engine/src/cmd/retouch_cmds/patch_history_names_tests.rs @@ -83,6 +83,15 @@ fn spaces_and_joiners_at_the_ends_are_trimmed_and_only_what_is_left_counts() { (" \u{200C}Patch Tool ", "Patch Tool"), ("Love \u{2764}\u{FE0F}", "Love \u{2764}\u{FE0F}"), (joined.as_str(), "Patch Tool"), + ("Patch Tool\u{2800}", "Patch Tool"), + ("\u{3164}\u{2800}Patch Tool\u{FFA0} \u{115F}", "Patch Tool"), + ("Patch Tool\u{2065}\u{FFF0}", "Patch Tool"), + ("\u{17B4}Patch Tool", "Patch Tool"), + ("Patch Tool \u{FE0F}", "Patch Tool"), + ("\u{FE0F}Patch Tool", "Patch Tool"), + ("Patch \u{2764}\u{FE0F}\u{2800}", "Patch \u{2764}\u{FE0F}"), + ("Patch \u{1100}\u{1160}", "Patch \u{1100}\u{1160}"), + ("\u{115F}\u{1161} Patch", "\u{115F}\u{1161} Patch"), ]; for (name, stored) in cases.iter().copied().chain([(padded.as_str(), &padded[10..])]) { s.execute("select.lasso", json!({"points": TRIANGLE, "historyName": name})).unwrap(); diff --git a/crates/engine/src/cmd/selection_cmds.rs b/crates/engine/src/cmd/selection_cmds.rs index ed4b60cc96..798b4f03a0 100644 --- a/crates/engine/src/cmd/selection_cmds.rs +++ b/crates/engine/src/cmd/selection_cmds.rs @@ -638,13 +638,14 @@ fn lasso(s: &mut Session, p: &Value) -> Result { } /// The History step name for `historyName` `n`: refused when it has a control or line-break -/// character anywhere; otherwise without white space or zero-width (non-)joiners at its ends, so a -/// name that looks like "Patch Tool" is "Patch Tool", and then held to the plug-in menu-label rule. +/// character anywhere; otherwise without its invisible end padding (see +/// [`photocraft_plugins::manifest::trim_name`]), so a name that looks like "Patch Tool" is "Patch Tool", +/// and then held to the plug-in menu-label rule. fn history_name(n: &str) -> Option<&str> { if n.chars().any(|c| c.is_control() || matches!(c, '\u{2028}' | '\u{2029}')) { return None; } - let t = n.trim_matches(|c: char| c.is_whitespace() || matches!(c, '\u{200C}' | '\u{200D}')); + let t = photocraft_plugins::manifest::trim_name(n); photocraft_plugins::manifest::printable_name(t).then_some(t) } @@ -778,7 +779,7 @@ pub fn specs() -> Vec { "select.lasso", "Lasso", [], - r##"{"points":[[x,y],…],"mode":"replace|add|subtract|intersect"="replace","antiAlias":bool=true,"feather":px=0,"polygonal":bool=false,"historyName"?:text (1..64 characters with a visible one, no control, line-break or hidden format characters, spaces and joiners at the ends trimmed; the History step name when a tool other than the Lasso draws it, e.g. "Patch Tool"; default "Lasso")}"##, + r##"{"points":[[x,y],…],"mode":"replace|add|subtract|intersect"="replace","antiAlias":bool=true,"feather":px=0,"polygonal":bool=false,"historyName"?:text (1..64 characters with a visible one, no control, line-break or hidden format characters, invisible padding at the ends (spaces, joiners, fillers, blank Braille and other default-ignorable blanks, and selectors cut off by them) trimmed; the History step name when a tool other than the Lasso draws it, e.g. "Patch Tool"; default "Lasso")}"##, has_doc, lasso ), diff --git a/crates/plugins/src/manifest.rs b/crates/plugins/src/manifest.rs index 1273dfea26..494a4cb6a9 100644 --- a/crates/plugins/src/manifest.rs +++ b/crates/plugins/src/manifest.rs @@ -135,8 +135,9 @@ impl Manifest { } let text = std::str::from_utf8(bytes).map_err(|_| Error::Manifest("manifest is not UTF-8".into()))?; let mut man: Manifest = serde_json::from_str(text).map_err(|e| Error::Manifest(format!("manifest: {e}")))?; - // The name is shown as stored, so white space and line breaks at its ends are dropped here. - man.name = man.name.trim().to_owned(); + // The name is shown as stored, so the invisible padding at its ends (white space, line breaks, + // joiners, fillers) is dropped here. + man.name = trim_name(&man.name).to_owned(); man.validate()?; Ok(man) } @@ -412,6 +413,77 @@ pub fn printable_name(name: &str) -> bool { && !name.chars().any(|c| c.is_control() || matches!(c, '\u{2028}' | '\u{2029}') || hidden_format(c)) } +/// `name` without what pads its ends invisibly: white space, the zero-width (non-)joiners and the +/// characters that draw nothing on their own (fillers, blank Braille, unassigned ignorables, stray +/// selectors), so a name that looks like "Tone" is "Tone". What belongs to a character the name +/// keeps stays: a variation selector after it (the emoji in "Love ❤️"), and a Hangul filler that +/// is part of a syllable block (U+1160 after a leading consonant, U+115F before a vowel). +pub fn trim_name(name: &str) -> &str { + let pad = |c: char| c.is_whitespace() || matches!(c, '\u{200C}' | '\u{200D}') || in_ranges(c, BLANK); + let mut start = name.len(); + let mut chars = name.char_indices().peekable(); + while let Some((i, c)) = chars.next() { + // The choseong filler opens a syllable before a vowel, and before the jungseong filler U+1160 + // when a final consonant follows (U+115F U+1160 + a final consonant is how a lone final + // consonant is written; U+115F U+1160 alone draws nothing). + let opens_syllable = c == '\u{115F}' + && chars.peek().is_some_and(|&(j, n)| { + vowel_jamo(n) || (n == '\u{1160}' && name.get(j + n.len_utf8()..).and_then(|r| r.chars().next()).is_some_and(trailing_jamo)) + }); + if !pad(c) || opens_syllable { + start = i; + break; + } + } + let s = name.get(start..).unwrap_or_default(); + let mut end = s.len(); + for (i, c) in s.char_indices().rev() { + if !pad(c) { + break; + } + end = i; + } + // Take back the mark that belongs to the last character kept: one per character, so a run of + // selectors after it is still padding. + let mut prev = s.get(..end).and_then(|h| h.chars().next_back()); + for c in s.get(end..).unwrap_or_default().chars() { + let belongs = match prev { + Some(p) => attaches(c) || (c == '\u{1160}' && leading_jamo(p)), + None => false, + }; + if !belongs { + break; + } + end += c.len_utf8(); + prev = if attaches(c) { None } else { Some(c) }; + } + s.get(..end).unwrap_or_default() +} + +/// The marks among [`BLANK`] that modify the character before them: the combining grapheme joiner, +/// the Khmer inherent vowels, the Mongolian free variation selectors and the variation selectors. +fn attaches(c: char) -> bool { + matches!( + c, + '\u{34F}' | '\u{17B4}' | '\u{17B5}' | '\u{180B}'..='\u{180D}' | '\u{180F}' | '\u{FE00}'..='\u{FE0F}' | '\u{E0100}'..='\u{E01EF}' + ) +} + +/// A conjoining Hangul leading consonant (choseong), not the filler U+115F. +fn leading_jamo(c: char) -> bool { + matches!(c, '\u{1100}'..='\u{115E}' | '\u{A960}'..='\u{A97C}') +} + +/// A conjoining Hangul final consonant (jongseong). +fn trailing_jamo(c: char) -> bool { + matches!(c, '\u{11A8}'..='\u{11FF}' | '\u{D7CB}'..='\u{D7FB}') +} + +/// A conjoining Hangul vowel (jungseong) other than the vowel filler U+1160. +fn vowel_jamo(c: char) -> bool { + matches!(c, '\u{1161}'..='\u{11A7}' | '\u{D7B0}'..='\u{D7C6}') +} + /// A character that draws something on its own. fn visible(c: char) -> bool { !c.is_whitespace() && !c.is_control() && !in_ranges(c, FORMAT) && !in_ranges(c, BLANK) @@ -661,7 +733,30 @@ mod tests { } // White space and line breaks at the ends are dropped from the stored name, so the menu never // shows them, and padding does not count towards the limit. - for (padded, stored) in [("\nTone", "Tone"), ("Tone\r\n", "Tone"), ("\u{2028}Tone ", "Tone"), (" Tone… ", "Tone…")] { + for (padded, stored) in [ + ("\nTone", "Tone"), + ("Tone\r\n", "Tone"), + ("\u{2028}Tone ", "Tone"), + (" Tone… ", "Tone…"), + ("Tone\u{2800}", "Tone"), + ("\u{3164}\u{2800}Tone\u{FFA0}", "Tone"), + ("\u{2764}\u{FE0F} Love\u{2800}", "\u{2764}\u{FE0F} Love"), + ("Tone\u{2065}\u{FFF0}", "Tone"), + ("\u{17B4}Tone", "Tone"), + // A selector right after the last kept character belongs to it and stays; one cut off by padding goes. + ("Love \u{2764}\u{FE0F}\u{2800}", "Love \u{2764}\u{FE0F}"), + ("Tone \u{FE0F}", "Tone"), + ("\u{FE0F}Tone", "Tone"), + ("Love \u{2764}\u{FE0F}\u{FE0F}\u{FE0F}", "Love \u{2764}\u{FE0F}"), + // Hangul filler jamo that complete a syllable are part of the name; the two fillers alone draw nothing. + ("\u{115F}\u{1160}Tone", "Tone"), + ("Tone \u{115F}\u{1160}", "Tone"), + ("Tone \u{1100}\u{1160}", "Tone \u{1100}\u{1160}"), + ("\u{115F}\u{1161} Tone", "\u{115F}\u{1161} Tone"), + ("\u{115F}\u{1160}\u{11A8} Tone", "\u{115F}\u{1160}\u{11A8} Tone"), + // Khmer inherent vowels belong to the consonant before them. + ("Tone \u{1780}\u{17B5}", "Tone \u{1780}\u{17B5}"), + ] { assert_eq!(with_name(padded).map(|m| m.name).ok().as_deref(), Some(stored), "{padded:?}"); } assert_eq!(with_name(&format!("{}{}", "x".repeat(64), " ".repeat(1000))).map(|m| m.name.len()).ok(), Some(64)); diff --git a/docs/plugins.md b/docs/plugins.md index 4f582ae9e4..bb2859cef8 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -100,6 +100,9 @@ exports. All integers are `i32` unless noted; pointers are offsets into the modu - `name`: the menu label (1–64 printable characters). Any script works, but control characters and Unicode format characters (bidi overrides and marks, zero-width spaces, soft hyphens) are rejected, except the zero-width joiner and non-joiner, and at least one character must be visible. + Invisible padding at the ends (white space, joiners, Hangul fillers, blank Braille and other + default-ignorable blanks, and variation selectors cut off by them) is removed before these checks, + so it does not count towards the limit; marks that belong to a kept character stay. `kind`: `"filter"` (the only kind in v1). - `params`: in dialog order. Names are `[A-Za-z0-9_]`, not starting with `_`, not `id`/`layer`. The host validates the user's values before calling: missing values take the default, numbers are From 9f5af15a194343f34098114fd33dd1d5584c8c4b Mon Sep 17 00:00:00 2001 From: rmsff <17902130+rmsff@users.noreply.github.com> Date: Sun, 8 Nov 2026 14:57:55 +0300 Subject: [PATCH 202/219] fix(ui): Escape commits a Type Mask session as Command+Return does (#12070) Escape while typing with the Horizontal or Vertical Type Mask tool threw the session away. As in the reference application it now commits: the text outline becomes the selection in one history state and no layer is added, whatever Preferences > Type "Use ESC to commit" says. The options bar's Cancel still throws the session away. Escape on an empty mask text, or while an input method is composing, was not measured and still cancels. A Command transform drag keeps its own Escape, and the Type tool's Escape still follows the preference. The #1055 canvas test that pinned the old Escape now goes through Cancel, which keeps what it guarded: the selection and layers unchanged. --- crates/ui-egui/src/tools/type_tool.rs | 2 + .../src/tools/type_tool/canvas_tests.rs | 8 +- crates/ui-egui/src/tools/type_tool/keys.rs | 6 +- .../src/tools/type_tool/mask_escape_tests.rs | 152 ++++++++++++++++++ 4 files changed, 163 insertions(+), 5 deletions(-) create mode 100644 crates/ui-egui/src/tools/type_tool/mask_escape_tests.rs diff --git a/crates/ui-egui/src/tools/type_tool.rs b/crates/ui-egui/src/tools/type_tool.rs index 42ad346f61..1d4ac7e85f 100644 --- a/crates/ui-egui/src/tools/type_tool.rs +++ b/crates/ui-egui/src/tools/type_tool.rs @@ -146,3 +146,5 @@ mod commit_tests; #[cfg(test)] mod ime_tests; +#[cfg(test)] +mod mask_escape_tests; diff --git a/crates/ui-egui/src/tools/type_tool/canvas_tests.rs b/crates/ui-egui/src/tools/type_tool/canvas_tests.rs index 091a0a22cb..42effdb2eb 100644 --- a/crates/ui-egui/src/tools/type_tool/canvas_tests.rs +++ b/crates/ui-egui/src/tools/type_tool/canvas_tests.rs @@ -2728,10 +2728,10 @@ fn type_mask_commits_the_glyphs_as_the_selection_in_one_step() { assert!(v.height() > 2 * v.width(), "{v:?}"); } -/// #1055: Escape cancels a Type Mask session whatever "Use Escape to Commit" says: the selection -/// and the layers stay as they were. +/// #1055: Cancel throws a Type Mask session away: the selection and the layers stay as they were. +/// (Escape commits it, as in the reference application: #12070, `mask_escape_tests`.) #[test] -fn type_mask_escape_leaves_the_selection_unchanged() { +fn type_mask_cancel_leaves_the_selection_unchanged() { let mut app = new_app(); app.run("select.all", json!({})).unwrap(); let (before, layers) = (selection_bounds(&app), app.session.active().unwrap().doc.layers.len()); @@ -2741,7 +2741,7 @@ fn type_mask_escape_leaves_the_selection_unchanged() { click(&mut h, p); assert!(h.state().ui.text_edit.as_ref().is_some_and(|ed| ed.mask), "typing a mask"); super::insert(h.state_mut(), "I"); - h.key_press(egui::Key::Escape); + super::cancel(h.state_mut()); h.run_steps(2); assert!(h.state().ui.text_edit.is_none()); assert_eq!(selection_bounds(h.state()), before); diff --git a/crates/ui-egui/src/tools/type_tool/keys.rs b/crates/ui-egui/src/tools/type_tool/keys.rs index 74c81c2f7d..0da9b40319 100644 --- a/crates/ui-egui/src/tools/type_tool/keys.rs +++ b/crates/ui-egui/src/tools/type_tool/keys.rs @@ -255,7 +255,11 @@ fn handle_pressed_key( insert(app, "\t"); } Key::Escape if crate::type_transform::active(app) => crate::type_transform::cancel_drag(app), - Key::Escape if edit.edit.mask => cancel(app), + // A Type Mask commits on Escape as on ⌘↩, whatever "Use ESC to commit" says; the options bar's + // Cancel still throws it away (#12070). Escape on an empty mask text, or while an input + // method is composing, was not measured and still cancels. + Key::Escape if edit.edit.mask && (edit.text.is_empty() || edit.edit.preedit.is_some()) => cancel(app), + Key::Escape if edit.edit.mask => commit(app), Key::Escape if app.session.prefs().type_.use_esc_to_commit => commit(app), Key::Escape => cancel(app), Key::A if modifiers.command => { diff --git a/crates/ui-egui/src/tools/type_tool/mask_escape_tests.rs b/crates/ui-egui/src/tools/type_tool/mask_escape_tests.rs new file mode 100644 index 0000000000..30566900b0 --- /dev/null +++ b/crates/ui-egui/src/tools/type_tool/mask_escape_tests.rs @@ -0,0 +1,152 @@ +//! #12070: Escape commits a Type Mask session the way ⌘↩ does, whatever "Use Escape to Commit" +//! says: the text outline becomes the selection in one history state and no layer is added. An +//! empty mask text and a composing input method still cancel, and the Type tool's Escape still +//! follows the preference. (A ⌘ transform drag's Escape arm stays first; masks have no transform +//! box yet, #12082.) Keys go through the app's shortcut handling, as on the canvas. + +use egui_kittest::Harness; +use photocraft_doc::LayerId; +use photocraft_raster::Surface; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::state::Tool; + +fn new_app(tool: Tool, use_esc_to_commit: bool) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 200})).unwrap(); + app.run("prefs.set", json!({"path": "type.useEscToCommit", "value": use_esc_to_commit})).unwrap(); + // Away from the text, so New, Add, Subtract and Intersect each leave a different selection. + app.run("select.rect", json!({"x": 300, "y": 150, "width": 60, "height": 30})).unwrap(); + app.sync_views(); + app.ui.tool = tool; + app +} + +/// Start new text with a click and type `s`, as the canvas does. +fn type_new(app: &mut PhotocraftApp, s: &str) -> LayerId { + super::pointer_up(app, [50.0, 100.0], [50.0, 100.0]); + super::insert(app, s); + LayerId(app.ui.text_edit.as_ref().unwrap().layer) +} + +/// One frame of the app's shortcut handling for each of `events`. +fn send(app: PhotocraftApp, events: Vec) -> PhotocraftApp { + let mut h = Harness::builder().build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + // As `canvas_tests`' harness: the shortcuts wait for the app's fonts. + if ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + crate::shortcuts::handle(app, &ctx); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + for event in events { + h.event(event); + h.run_steps(1); + } + h.into_state() +} + +fn key(key: egui::Key, modifiers: egui::Modifiers) -> egui::Event { + egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers } +} + +fn escape(app: PhotocraftApp) -> PhotocraftApp { + send(app, vec![key(egui::Key::Escape, egui::Modifiers::NONE)]) +} + +/// The selection (compared pixel for pixel, shown by its bounds), the history (oldest first) and +/// the layer count. +#[derive(PartialEq)] +struct Snapshot { + selection: Option, + history: Vec, + layers: usize, +} + +impl std::fmt::Debug for Snapshot { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let bounds = self.selection.as_ref().map(Surface::content_bounds); + write!(f, "selection {bounds:?}, history {:?}, {} layers", self.history, self.layers) + } +} + +fn snapshot(app: &PhotocraftApp) -> Snapshot { + let st = app.session.active().unwrap(); + Snapshot { selection: st.doc.selection.clone(), history: st.history.entries(), layers: st.doc.layers.len() } +} + +#[test] +fn escape_commits_a_type_mask_as_command_return_does() { + for tool in [Tool::HorizontalTypeMask, Tool::VerticalTypeMask] { + for use_esc_to_commit in [true, false] { + let what = format!("{tool:?}, useEscToCommit={use_esc_to_commit}"); + let session = || { + let mut app = new_app(tool, use_esc_to_commit); + let before = snapshot(&app); + type_new(&mut app, "AB"); + (app, before) + }; + let (app, before) = session(); + let escaped = escape(app); + let (app, _) = session(); + let returned = send(app, vec![key(egui::Key::Enter, egui::Modifiers::COMMAND)]); + assert!(escaped.ui.text_edit.is_none(), "{what}: the session ends"); + let after = snapshot(&escaped); + let bounds = after.selection.as_ref().map(Surface::content_bounds); + assert!(bounds.is_some_and(|r| r.x1 < 300), "{what}: the text outline alone is the selection: {bounds:?}"); + assert_eq!(after.history.len(), before.history.len() + 1, "{what}: one history state"); + assert_eq!(after.layers, before.layers, "{what}: no layer is added"); + assert_eq!(after, snapshot(&returned), "{what}: the same as ⌘↩"); + } + } +} + +#[test] +fn escape_with_an_empty_mask_text_changes_nothing() { + for tool in [Tool::HorizontalTypeMask, Tool::VerticalTypeMask] { + for use_esc_to_commit in [true, false] { + let what = format!("{tool:?}, useEscToCommit={use_esc_to_commit}"); + let mut app = new_app(tool, use_esc_to_commit); + let before = snapshot(&app); + type_new(&mut app, ""); + let app = escape(app); + assert!(app.ui.text_edit.is_none(), "{what}"); + assert_eq!(snapshot(&app), before, "{what}"); + } + } +} + +#[test] +fn escape_while_an_input_method_composes_still_cancels_a_type_mask() { + let preedit = || egui::Event::Ime(egui::ImeEvent::Preedit { text: "にほ".into(), active_range_chars: None }); + let session = || { + let mut app = new_app(Tool::HorizontalTypeMask, true); + let before = snapshot(&app); + type_new(&mut app, "AB"); + (app, before) + }; + let (app, _) = session(); + let app = send(app, vec![preedit()]); + assert!(app.ui.text_edit.as_ref().is_some_and(|ed| ed.preedit.is_some()), "composing"); + let (app, before) = session(); + let app = send(app, vec![preedit(), key(egui::Key::Escape, egui::Modifiers::NONE)]); + assert!(app.ui.text_edit.is_none()); + assert_eq!(snapshot(&app), before); +} + +#[test] +fn the_type_tools_escape_still_follows_the_preference() { + for (use_esc_to_commit, kept) in [(true, true), (false, false)] { + let mut app = new_app(Tool::Type, use_esc_to_commit); + let id = type_new(&mut app, "AB"); + let app = escape(app); + assert!(app.ui.text_edit.is_none()); + let layer = app.session.active().unwrap().doc.layer(id).is_some(); + assert_eq!(layer, kept, "useEscToCommit={use_esc_to_commit}: committed, or cancelled and the new layer gone"); + } +} From 368eee36793e2bf5662c8b4b4cf6c693e038127b Mon Sep 17 00:00:00 2001 From: rmsff <17902130+rmsff@users.noreply.github.com> Date: Sun, 8 Nov 2026 15:30:49 +0300 Subject: [PATCH 203/219] fix(ui): a Type Mask always replaces the selection when committed (#12073) Committing a Type Mask took the selection mode from the modifiers held at that moment, so Shift added the text outline to the selection and Alt subtracted it. As in the reference application, a commit now always replaces the selection with the outline: a click outside the text, the Commit button and Cmd+Return, with or without Shift or Alt. type.createSelection keeps its "operation" parameter for scripts. commit_with keeps its modifiers parameter so its callers don't change. The #3036 canvas test keeps its check that the working text has no Layers panel row; its Shift and Alt assertions now expect a replace. --- crates/ui-egui/src/tools/type_tool.rs | 3 + .../src/tools/type_tool/canvas_tests.rs | 17 +-- crates/ui-egui/src/tools/type_tool/editing.rs | 24 ++-- .../type_tool/mask_replace_selection_tests.rs | 135 ++++++++++++++++++ crates/ui-egui/src/tools/type_tool/pointer.rs | 2 +- 5 files changed, 157 insertions(+), 24 deletions(-) create mode 100644 crates/ui-egui/src/tools/type_tool/mask_replace_selection_tests.rs diff --git a/crates/ui-egui/src/tools/type_tool.rs b/crates/ui-egui/src/tools/type_tool.rs index 42ad346f61..fd44c046ce 100644 --- a/crates/ui-egui/src/tools/type_tool.rs +++ b/crates/ui-egui/src/tools/type_tool.rs @@ -141,6 +141,9 @@ mod font_picker_tests; #[cfg(test)] mod tests; +#[cfg(test)] +mod mask_replace_selection_tests; + #[cfg(test)] mod commit_tests; diff --git a/crates/ui-egui/src/tools/type_tool/canvas_tests.rs b/crates/ui-egui/src/tools/type_tool/canvas_tests.rs index 091a0a22cb..4650d8fb02 100644 --- a/crates/ui-egui/src/tools/type_tool/canvas_tests.rs +++ b/crates/ui-egui/src/tools/type_tool/canvas_tests.rs @@ -3005,19 +3005,18 @@ fn multi_click_without_a_session_does_nothing() { assert_eq!((ed.anchor, ed.caret), (5, 5)); } -/// #3036: ⇧ on the click that commits a Type Mask adds the glyphs to the selection and ⌥ -/// subtracts them, as in Photoshop; while typing, the working text has no Layers panel row. +/// #3036: while typing, the working text has no Layers panel row. ⇧ or ⌥ on the click that +/// commits a Type Mask no longer combine: the glyphs replace the selection (#12073, +/// `mask_replace_selection_tests`). #[test] -fn type_mask_shift_adds_and_alt_subtracts_without_a_layer_row() { +fn type_mask_shift_and_alt_replace_without_a_layer_row() { use crate::canvas::{ToolEvent, tool_event}; use crate::state::Tool; - use photocraft_geom::Rect; let mut app = new_app(); type_with(&mut app, Tool::HorizontalTypeMask, "I"); let glyphs = selection_bounds(&app).unwrap(); let mut app = new_app(); app.ui.extras.snap = false; // the second text would snap to the first one's selected edge - let rect = Rect::new(1000, 100, 1100, 200); app.run("select.rect", json!({"x": 1000, "y": 100, "width": 100, "height": 100})).unwrap(); // Type, then commit with a click outside the text holding `m`. let type_and_commit = |app: &mut PhotocraftApp, m: Modifiers| { @@ -3032,14 +3031,10 @@ fn type_mask_shift_adds_and_alt_subtracts_without_a_layer_row() { tool_event(app, ToolEvent::Up { x: 1500.0, y: 800.0 }, m); assert!(app.ui.text_edit.is_none()); }; - let at = |app: &PhotocraftApp, x: i32, y: i32| app.session.active().unwrap().doc.selection.as_ref().map_or(0.0, |s| s.sample_channel(x, y, 0)); - let stem = ((glyphs.x0 + glyphs.x1) / 2, (glyphs.y0 + glyphs.y1) / 2); type_and_commit(&mut app, Modifiers::SHIFT); - assert_eq!(selection_bounds(&app), Some(rect.union(&glyphs)), "⇧ adds"); - assert!(at(&app, stem.0, stem.1) > 0.99 && at(&app, 1050, 150) > 0.99); + assert_eq!(selection_bounds(&app), Some(glyphs), "⇧ replaces"); type_and_commit(&mut app, Modifiers::ALT); - assert!(at(&app, stem.0, stem.1) < 0.01, "⌥ subtracts the glyphs"); - assert!(at(&app, 1050, 150) > 0.99, "and keeps the rest"); + assert_eq!(selection_bounds(&app), Some(glyphs), "⌥ replaces"); } /// #7713: justification needs a paragraph box. On point text the Paragraph panel's four justify diff --git a/crates/ui-egui/src/tools/type_tool/editing.rs b/crates/ui-egui/src/tools/type_tool/editing.rs index f93826c1d1..95f2dbbcc1 100644 --- a/crates/ui-egui/src/tools/type_tool/editing.rs +++ b/crates/ui-egui/src/tools/type_tool/editing.rs @@ -405,26 +405,26 @@ pub(crate) fn commit_shortcut(app: &mut PhotocraftApp, ctx: &egui::Context) -> b true } -/// [`commit`] with the modifiers held while committing: a Type Mask adds its glyphs to the -/// selection with ⇧, subtracts them with ⌥ and intersects with ⇧⌥ (Photoshop); with none it -/// replaces the selection. -pub fn commit_with(app: &mut PhotocraftApp, mods: egui::Modifiers) { +/// [`commit`] from a click, button or key that has modifiers held. They no longer change the +/// result, since a Type Mask always replaces the selection (#12073); the parameter stays so its +/// callers don't change. +pub fn commit_with(app: &mut PhotocraftApp, _mods: egui::Modifiers) { app.ui.type_insertion_style = None; crate::type_font_preview::clear(app); let current = app.session.active_index(); let Some(home) = app.ui.text_edit.as_ref().map(|ed| edit_home(app, ed)) else { - return commit_here(app, mods); + return commit_here(app); }; match home { Some(home) if Some(home) != current => { // An edit left in a background document commits there, in its own history (#6483). app.session.set_active(home); - commit_here(app, mods); + commit_here(app); if let Some(i) = current { app.session.set_active(i); } } - Some(_) => commit_here(app, mods), + Some(_) => commit_here(app), // Its document closed: there is nothing to commit into, and the layer id may name a // layer of another document. None => { @@ -445,7 +445,7 @@ pub(super) fn edit_home(app: &PhotocraftApp, ed: &TextEdit) -> Option { } /// [`commit_with`] in the active document, which is the edit's own. -fn commit_here(app: &mut PhotocraftApp, mods: egui::Modifiers) { +fn commit_here(app: &mut PhotocraftApp) { if app.ui.tool.is_type() { crate::type_transform::finish(app); } @@ -459,11 +459,11 @@ fn commit_here(app: &mut PhotocraftApp, mods: egui::Modifiers) { } let Some(text) = current_text(app, LayerId(ed.layer)) else { return }; if ed.mask { - // Type Mask: the text's glyphs become (or combine with) the selection and the working - // layer goes, all in the session's one history step. No text: nothing changes. - let operation = crate::tool_feedback::selection_mode(crate::state::Tool::HorizontalTypeMask, 0, mods); + // Type Mask: the text's glyphs replace the selection, with ⇧ or ⌥ held too, as in the + // reference application (#12073), and the working layer goes, all in the session's one + // history step. No text: nothing changes. if !text.trim().is_empty() - && let Err(e) = app.run("type.createSelection", json!({"layer": ed.layer, "removeLayer": true, "operation": operation, "coalesce": ed.session})) + && let Err(e) = app.run("type.createSelection", json!({"layer": ed.layer, "removeLayer": true, "operation": "new", "coalesce": ed.session})) { app.ui.status = e; app.ui.status_error = true; diff --git a/crates/ui-egui/src/tools/type_tool/mask_replace_selection_tests.rs b/crates/ui-egui/src/tools/type_tool/mask_replace_selection_tests.rs new file mode 100644 index 0000000000..dba036ae85 --- /dev/null +++ b/crates/ui-egui/src/tools/type_tool/mask_replace_selection_tests.rs @@ -0,0 +1,135 @@ +//! #12073: committing a Type Mask always replaces the selection with the text outline, with ⇧, +//! ⌥ or ⇧⌥ held too: a click outside the text, the Commit button and ⌘↩ all go through +//! `commit_with`. `type.createSelection` keeps its `operation` param for scripts. + +use egui::Modifiers; +use egui_kittest::Harness; +use photocraft_raster::Surface; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::canvas::{ToolEvent, tool_event}; +use crate::state::Tool; + +const COMBOS: [Modifiers; 4] = [Modifiers::NONE, Modifiers::SHIFT, Modifiers::ALT, Modifiers { shift: true, alt: true, ..Modifiers::NONE }]; + +/// A document with a selection away from the text, so Add, Subtract and Intersect would each +/// leave something other than the text outline. +fn new_app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 800, "height": 400})).unwrap(); + app.run("select.rect", json!({"x": 600, "y": 250, "width": 100, "height": 100})).unwrap(); + app.sync_views(); + app.ui.extras.snap = false; + app.ui.tool_options.type_size = 48.0; + app.ui.tool = Tool::HorizontalTypeMask; + app +} + +fn click(app: &mut PhotocraftApp, x: f64, y: f64, mods: Modifiers) { + tool_event(app, ToolEvent::Down { x, y, pressure: 1.0 }, mods); + tool_event(app, ToolEvent::Up { x, y }, mods); +} + +/// Start a mask with a click at `at` and type `s`. +fn mask(app: &mut PhotocraftApp, at: (f64, f64), s: &str) { + mask_with(app, at, s, Modifiers::NONE); +} + +/// [`mask`] started by a click holding `mods`. +fn mask_with(app: &mut PhotocraftApp, at: (f64, f64), s: &str, mods: Modifiers) { + click(app, at.0, at.1, mods); + assert!(app.ui.text_edit.as_ref().is_some_and(|ed| ed.mask), "typing a mask"); + super::insert(app, s); +} + +fn selection(app: &PhotocraftApp) -> Option { + app.session.active().unwrap().doc.selection.clone() +} + +fn bounds(s: &Option) -> Option { + s.as_ref().map(Surface::content_bounds) +} + +/// The selection a plain commit of `s` at `at` leaves: the text outline alone. +fn outline(at: (f64, f64), s: &str) -> Option { + let mut app = new_app(); + mask(&mut app, at, s); + super::commit_with(&mut app, Modifiers::NONE); + let outline = selection(&app); + assert!(bounds(&outline).is_some_and(|r| r.x1 < 600), "the text alone: {:?}", bounds(&outline)); + outline +} + +#[test] +fn every_commit_replaces_the_selection_whatever_modifiers_are_held() { + let outline = outline((100.0, 200.0), "AB"); + for mods in COMBOS { + let mut app = new_app(); + mask(&mut app, (100.0, 200.0), "AB"); + super::commit_with(&mut app, mods); + assert!(app.ui.text_edit.is_none(), "{mods:?}"); + assert!(selection(&app) == outline, "{mods:?}: {:?}, not the outline {:?}", bounds(&selection(&app)), bounds(&outline)); + } +} + +#[test] +fn a_modified_click_outside_the_text_replaces_the_selection() { + let outline = outline((100.0, 200.0), "AB"); + for mods in COMBOS { + let mut app = new_app(); + mask(&mut app, (100.0, 200.0), "AB"); + click(&mut app, 450.0, 60.0, mods); + assert!(app.ui.text_edit.is_none(), "{mods:?}: the click commits"); + assert!(selection(&app) == outline, "{mods:?}: {:?}", bounds(&selection(&app))); + } +} + +#[test] +fn shift_command_return_replaces_the_selection() { + let outline = outline((100.0, 200.0), "AB"); + let mut app = new_app(); + mask(&mut app, (100.0, 200.0), "AB"); + let mut h = Harness::builder().build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + // As `canvas_tests`' harness: the shortcuts wait for the app's fonts. + if ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + crate::shortcuts::handle(app, &ctx); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + h.event(egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND | Modifiers::SHIFT }); + h.run_steps(1); + let app = h.into_state(); + assert!(app.ui.text_edit.is_none()); + assert!(selection(&app) == outline, "{:?}", bounds(&selection(&app))); +} + +/// The reference application's "AB", then ⇧-clicks to commit it and to start "C", then "C" +/// committed: only "C" is selected. (Here a click outside the text commits without starting new +/// text, so that is two ⇧-clicks.) +#[test] +fn the_next_mask_after_a_shift_click_commit_replaces_the_first() { + let second = outline((300.0, 200.0), "C"); + let mut app = new_app(); + mask(&mut app, (100.0, 200.0), "AB"); + click(&mut app, 450.0, 60.0, Modifiers::SHIFT); + assert!(app.ui.text_edit.is_none(), "the ⇧-click commits"); + mask_with(&mut app, (300.0, 200.0), "C", Modifiers::SHIFT); + click(&mut app, 450.0, 60.0, Modifiers::SHIFT); + assert!(selection(&app) == second, "{:?}, not only \"C\" {:?}", bounds(&selection(&app)), bounds(&second)); +} + +#[test] +fn type_create_selection_still_combines_for_scripts() { + let mut app = new_app(); + let rect = bounds(&selection(&app)).unwrap(); + app.run("type.createSelection", json!({"text": "AB", "x": 100, "y": 200, "size": 48, "operation": "add"})).unwrap(); + let added = bounds(&selection(&app)).unwrap(); + assert_eq!(added.union(&rect), added, "the rectangle is kept"); + assert!(added.x0 < rect.x0, "and the text is added"); +} diff --git a/crates/ui-egui/src/tools/type_tool/pointer.rs b/crates/ui-egui/src/tools/type_tool/pointer.rs index 19bab778a4..68df59059f 100644 --- a/crates/ui-egui/src/tools/type_tool/pointer.rs +++ b/crates/ui-egui/src/tools/type_tool/pointer.rs @@ -140,7 +140,7 @@ pub fn pointer_down(app: &mut PhotocraftApp, x: f64, y: f64, mods: egui::Modifie } return true; } - // ⇧/⌥ on that click combine a Type Mask with the selection. + // A Type Mask replaces the selection with ⇧ or ⌥ on that click too (#12073). commit_with(app, mods); return true; // Photoshop: a click outside commits without starting new text } From 342355d2dcb116391ac147c831f89c1c26236160 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sun, 8 Nov 2026 07:41:42 -0500 Subject: [PATCH 204/219] Patch badge: a drag keeps its press modifiers; concrete arrow geometry and negative drag tests (#12130) Cause: while a Patch or Content-Aware Move drag was under way, the cursor badge read the current modifiers, but the release and the paint read the modifiers from the press (`drag.modifiers`). Pressing Shift or Alt mid-drag after a plain press inside the selection flipped the arrow to Add or Subtract although the drag still moved the patch, and a drag pressed with Shift showed no badge once the key was let go. Fix: `badge` takes the modifiers of the Patch or Content-Aware Move drag in progress, as the Lasso line below it already does. The tests now pin the arrow glyph's exact geometry (3 strokes, horizontal shaft, both barbs from the tip, mirrored ends), a drag started outside showing no arrow inside, Content-Aware Move staying badge-free, the press modifiers winning mid-drag, Shift+Alt giving Intersect on hover, and the Curvature Pen's real Move badge over an anchor. Verified: `cargo test -p photocraft-ui-egui --lib patch_cursor` 10 passed (9 in patch_cursor_badge_tests plus the retouch_ui one), `retouch` 58 passed, clippy --all-targets -D warnings clean, fmt clean. Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove and briefed; Sonnet 5.5 wrote at medium --- crates/ui-egui/src/canvas/tool_feedback.rs | 5 +- .../ui-egui/src/patch_cursor_badge_tests.rs | 69 ++++++++++++++++++- 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/canvas/tool_feedback.rs b/crates/ui-egui/src/canvas/tool_feedback.rs index 0b26dc5a25..88aec042e5 100644 --- a/crates/ui-egui/src/canvas/tool_feedback.rs +++ b/crates/ui-egui/src/canvas/tool_feedback.rs @@ -124,7 +124,10 @@ pub fn badge(app: &PhotocraftApp, tool: Tool, m: egui::Modifiers) -> Option Date: Sun, 8 Nov 2026 15:51:41 +0300 Subject: [PATCH 205/219] fix(ui): vertical text gets Top / Center / Bottom alignment buttons (#12059) The Type options bar always showed Left / Center / Right. As in the reference application, for vertical text it now shows "Top align text", "Center text" and "Bottom align text" with vertical glyphs: when the edited or selected text layer is vertical, or with no text targeted when the Vertical Type tool is active. The buttons still set left / center / right and the lit one follows the targeted paragraph. The Type Mask tools keep today's buttons (not measured). The orientation is read from the target's own document, as the paragraph style is, so a text edit left in a background document is not judged by a same-id layer of the active one. The three glyphs (vertical-align-{top,middle,bottom}.svg) are original work drawn on Lucide's grid and stroke, with their ATTRIBUTION.md row; they are kept out of the Lucide row's wildcard. --- ATTRIBUTION.md | 3 +- assets/icons/vertical-align-bottom.svg | 17 ++ assets/icons/vertical-align-middle.svg | 17 ++ assets/icons/vertical-align-top.svg | 17 ++ crates/ui-egui/src/icon_data.rs | 3 + crates/ui-egui/src/tools/type_tool.rs | 3 + .../src/tools/type_tool/options_bar.rs | 19 +- .../type_tool/vertical_align_buttons_tests.rs | 178 ++++++++++++++++++ 8 files changed, 254 insertions(+), 3 deletions(-) create mode 100644 assets/icons/vertical-align-bottom.svg create mode 100644 assets/icons/vertical-align-middle.svg create mode 100644 assets/icons/vertical-align-top.svg create mode 100644 crates/ui-egui/src/tools/type_tool/vertical_align_buttons_tests.rs diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index bac9633320..4b38673804 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -42,7 +42,7 @@ next to them. | `epaint_default_fonts::EGUI_ICONS` (build input; deflated into `OUT_DIR/egui_fonts/egui-icons.ttf.deflate`, registered by `crates/ui-egui/src/egui_fonts.rs`) | egui-icons, five-glyph subset of emoji-icon-font | John Slegers | [egui fork at 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [original](https://github.com/jslegers/emoji-icon-font); egui subset, unmodified here | MIT; Copyright (c) 2014 John Slegers; full upstream notice in [NOTICE](NOTICE) | | `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) | | `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) | -| `assets/icons/*.svg` except `slice-knife.svg`, `slice-select.svg`, `font-similar.svg`, `font-variable.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `text-warp.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `history-brush.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg`, `path-op-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg`, `crop-overlay-*.svg` (115 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | +| `assets/icons/*.svg` except `slice-knife.svg`, `slice-select.svg`, `font-similar.svg`, `font-variable.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `text-warp.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `history-brush.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg`, `path-op-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg`, `crop-overlay-*.svg`, `vertical-align-*.svg` (115 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/{check,chevron-down,chevron-left,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/slice-select.svg` | Slice Select tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | @@ -58,6 +58,7 @@ next to them. | `assets/icons/marquee-row.svg`, `assets/icons/marquee-column.svg` | Single Row and Single Column Marquee tool glyphs: a dashed row (column) between two edge bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/clip-below.svg`, `assets/icons/clip-release.svg` | Clipping-mask cursors (⌥ over the line between two layers): a hooked arrow onto the layer below; the same with a small "no" badge to release | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/layer-mask.svg`, `assets/icons/adjustment-layer.svg` | Layers panel footer glyphs: Add a mask (a frame around a solid disc) and New Fill or Adjustment Layer (a circle, one half solid), drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | +| `assets/icons/vertical-align-{top,middle,bottom}.svg` | Type options-bar alignment glyphs for vertical text: three columns hanging from a top line, centred on a middle line, or standing on a bottom line, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/align-edge-*.svg`, `assets/icons/distribute-*.svg` | Move tool options-bar glyphs: Align (six edges/centres) and Distribute (edges, centres, spacing), a guide line or edge lines with bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/ellipse-dashed.svg` | Elliptical Marquee tool glyph: a dashed horizontal ellipse, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/frame-placeholder.svg` | Frame tool glyph: an empty placeholder frame, crossed corner to corner | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | diff --git a/assets/icons/vertical-align-bottom.svg b/assets/icons/vertical-align-bottom.svg new file mode 100644 index 0000000000..09931ffd77 --- /dev/null +++ b/assets/icons/vertical-align-bottom.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/assets/icons/vertical-align-middle.svg b/assets/icons/vertical-align-middle.svg new file mode 100644 index 0000000000..019f70872d --- /dev/null +++ b/assets/icons/vertical-align-middle.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/assets/icons/vertical-align-top.svg b/assets/icons/vertical-align-top.svg new file mode 100644 index 0000000000..c5269fe99a --- /dev/null +++ b/assets/icons/vertical-align-top.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index 93103b34a3..5b89a68d20 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -182,6 +182,9 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("type-mask-vertical", include_bytes!("../../../assets/icons/type-mask-vertical.svg")), ("undo-2", include_bytes!("../../../assets/icons/undo-2.svg")), ("unlink", include_bytes!("../../../assets/icons/unlink.svg")), + ("vertical-align-bottom", include_bytes!("../../../assets/icons/vertical-align-bottom.svg")), + ("vertical-align-middle", include_bytes!("../../../assets/icons/vertical-align-middle.svg")), + ("vertical-align-top", include_bytes!("../../../assets/icons/vertical-align-top.svg")), ("wand", include_bytes!("../../../assets/icons/wand.svg")), ("wand-sparkles", include_bytes!("../../../assets/icons/wand-sparkles.svg")), ("x", include_bytes!("../../../assets/icons/x.svg")), diff --git a/crates/ui-egui/src/tools/type_tool.rs b/crates/ui-egui/src/tools/type_tool.rs index 42ad346f61..1ebfebf1a8 100644 --- a/crates/ui-egui/src/tools/type_tool.rs +++ b/crates/ui-egui/src/tools/type_tool.rs @@ -130,6 +130,9 @@ fn byte_range(text: &str, a: usize, b: usize) -> (usize, usize) { (at(a.min(b)), at(a.max(b))) } +#[cfg(test)] +mod vertical_align_buttons_tests; + #[cfg(test)] mod canvas_tests; diff --git a/crates/ui-egui/src/tools/type_tool/options_bar.rs b/crates/ui-egui/src/tools/type_tool/options_bar.rs index 33287d1234..d581b79cde 100644 --- a/crates/ui-egui/src/tools/type_tool/options_bar.rs +++ b/crates/ui-egui/src/tools/type_tool/options_bar.rs @@ -95,9 +95,24 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { }, None => o.type_align.as_str(), }; - for (align, icon, tip) in + // Vertical text shows Top / Center / Bottom for the same left / center / right values, after the + // targeted layer's orientation, or with no text targeted the Vertical Type tool's (#12059). The + // Type Mask tools keep the horizontal buttons (not measured). + let vertical = !app.ui.tool.is_type_mask() + && match super::defaults::target_text(app) { + Some(t) => t.orientation == photocraft_doc::text::Orientation::Vertical, + None => app.ui.tool == crate::state::Tool::VerticalType, + }; + let buttons = if vertical { + [ + ("left", "vertical-align-top", tl!("Top align text")), + ("center", "vertical-align-middle", tl!("Center text")), + ("right", "vertical-align-bottom", tl!("Bottom align text")), + ] + } else { [("left", "align-left", tl!("Left align text")), ("center", "align-center", tl!("Center text")), ("right", "align-right", tl!("Right align text"))] - { + }; + for (align, icon, tip) in buttons { if crate::icons::button(ui, icon, 24.0, shown_align == align, tip).clicked() { app.ui.tool_options.type_align = align.into(); apply(app, ui.ctx(), json!({"align": align})); diff --git a/crates/ui-egui/src/tools/type_tool/vertical_align_buttons_tests.rs b/crates/ui-egui/src/tools/type_tool/vertical_align_buttons_tests.rs new file mode 100644 index 0000000000..9d3145e9ef --- /dev/null +++ b/crates/ui-egui/src/tools/type_tool/vertical_align_buttons_tests.rs @@ -0,0 +1,178 @@ +//! #12059: for vertical text the options bar's alignment buttons are Top / Center / Bottom, after +//! the targeted text layer's orientation, or with no text targeted the Vertical Type tool's. They +//! still set left / center / right, and the lit one follows the targeted paragraph. The Type Mask +//! tools keep the horizontal buttons (not measured). + +use egui::accesskit::{Role, Toggled}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use photocraft_doc::{LayerContent, LayerId, text::TextAlign}; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::state::Tool; + +const HORIZONTAL: [&str; 3] = ["Left align text", "Center text", "Right align text"]; +const VERTICAL: [&str; 3] = ["Top align text", "Center text", "Bottom align text"]; + +fn new_app(tool: Tool) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 800, "height": 400})).unwrap(); + app.sync_views(); + app.ui.tool = tool; + app +} + +/// A text layer with `orientation`, made the active layer (selected, not edited). +fn text_layer(app: &mut PhotocraftApp, orientation: &str) -> LayerId { + let made = app.run("type.create", json!({"text": "AB CD", "x": 400, "y": 100, "orientation": orientation})).unwrap(); + LayerId(made["layer"].as_u64().unwrap()) +} + +fn bar(app: PhotocraftApp) -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 60.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + ui.horizontal(|ui| super::options_bar(app, ui)); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(3); + h +} + +/// Which of the two button sets the bar shows; panics on a mix or neither. +fn shown(h: &Harness<'static, PhotocraftApp>) -> [&'static str; 3] { + let has = |label: &str| h.query_by_role_and_label(Role::Button, label).is_some(); + match (has("Top align text") && has("Bottom align text"), has("Left align text") && has("Right align text")) { + (true, false) => VERTICAL, + (false, true) => HORIZONTAL, + (true, true) => panic!("both the vertical and the horizontal buttons"), + (false, false) => panic!("no alignment buttons"), + } +} + +fn lit(h: &Harness<'static, PhotocraftApp>, label: &str) -> bool { + h.get_by_role_and_label(Role::Button, label).accesskit_node().toggled() == Some(Toggled::True) +} + +fn align_of(app: &PhotocraftApp, id: LayerId) -> TextAlign { + match &app.session.active().unwrap().doc.layer(id).unwrap().content { + LayerContent::Text(t) => t.paragraphs[0].style.align, + other => panic!("{}", other.kind_name()), + } +} + +#[test] +fn with_no_text_targeted_the_tool_decides() { + for (tool, buttons) in [(Tool::VerticalType, VERTICAL), (Tool::Type, HORIZONTAL)] { + let h = bar(new_app(tool)); + assert_eq!(shown(&h), buttons, "{tool:?}"); + assert!(lit(&h, buttons[0]), "{tool:?}: the default, left, lights the first button"); + } +} + +#[test] +fn a_selected_text_layer_decides_whatever_the_tool() { + for tool in [Tool::Type, Tool::VerticalType] { + for (orientation, buttons) in [("vertical", VERTICAL), ("horizontal", HORIZONTAL)] { + let mut app = new_app(tool); + text_layer(&mut app, orientation); + assert_eq!(shown(&bar(app)), buttons, "{tool:?}, {orientation} layer selected"); + } + } +} + +#[test] +fn text_being_edited_decides() { + for (orientation, buttons) in [("vertical", VERTICAL), ("horizontal", HORIZONTAL)] { + let mut app = new_app(Tool::Type); + text_layer(&mut app, orientation); + super::edit_active(&mut app).unwrap(); + assert!(app.ui.text_edit.is_some()); + assert_eq!(shown(&bar(app)), buttons, "editing {orientation} text"); + } +} + +#[test] +fn the_vertical_buttons_set_left_center_and_right_and_follow_the_paragraph() { + for (label, align, value) in + [("Bottom align text", TextAlign::Right, "right"), ("Center text", TextAlign::Center, "center"), ("Top align text", TextAlign::Left, "left")] + { + let mut app = new_app(Tool::VerticalType); + let id = text_layer(&mut app, "vertical"); + let mut h = bar(app); + h.get_by_role_and_label(Role::Button, label).click(); + h.run_steps(2); + assert_eq!(align_of(h.state(), id), align, "{label}"); + assert_eq!(h.state().ui.tool_options.type_align, value, "{label}: the tool default too"); + for other in VERTICAL { + assert_eq!(lit(&h, other), other == label, "{label} clicked: {other}"); + } + } +} + +#[test] +fn the_type_mask_tools_keep_the_horizontal_buttons() { + for tool in [Tool::VerticalTypeMask, Tool::HorizontalTypeMask] { + assert_eq!(shown(&bar(new_app(tool))), HORIZONTAL, "{tool:?}, idle"); + let mut app = new_app(tool); + super::pointer_up(&mut app, [400.0, 100.0], [400.0, 100.0]); + assert!(app.ui.text_edit.as_ref().is_some_and(|ed| ed.mask), "{tool:?}: typing a mask"); + assert_eq!(shown(&bar(app)), HORIZONTAL, "{tool:?}, editing"); + } +} + +/// Clicking Toggle text orientation switches the buttons in the same frame (the toggle is drawn +/// before them). +#[test] +fn toggling_the_orientation_switches_the_buttons() { + let mut app = new_app(Tool::Type); + text_layer(&mut app, "horizontal"); + let mut h = bar(app); + assert_eq!(shown(&h), HORIZONTAL); + h.get_by_role_and_label(Role::Button, "Toggle text orientation").click(); + h.run_steps(1); + assert_eq!(shown(&h), VERTICAL, "now vertical"); +} + +/// A text edit left in a background document decides by its own layer, not by a same-id layer of +/// the active document (layer ids repeat across documents; as `styles_at`, #9179). +#[test] +fn a_background_text_edit_decides_by_its_own_document() { + let mut app = new_app(Tool::Type); + let a = app.session.active().unwrap().doc.id.0; + let id = text_layer(&mut app, "vertical"); + app.ui.text_edit = Some(crate::state::TextEdit { + layer: id.0, + document: Some(a), + caret: 0, + anchor: 0, + session: "s".into(), + created: false, + mask: false, + dragging: false, + resize: None, + preedit: None, + }); + // The same file opened twice, its copy of the layer turned horizontal. + let twin = app.session.active().unwrap().doc.as_ref().clone(); + app.session.open_document(twin, None); + app.run("type.orientation.horizontal", json!({"layer": id.0})).unwrap(); + assert_ne!(app.session.active().unwrap().doc.id.0, a, "the other document is active"); + assert_eq!(shown(&bar(app)), VERTICAL, "the edited layer is vertical"); +} + +/// The lit button follows the targeted paragraph, not the tool default. +#[test] +fn the_lit_button_follows_the_paragraph_not_the_tool_default() { + let mut app = new_app(Tool::VerticalType); + app.ui.tool_options.type_align = "center".into(); + let made = app.run("type.create", json!({"text": "AB CD", "x": 400, "y": 100, "orientation": "vertical", "align": "right"})).unwrap(); + let id = LayerId(made["layer"].as_u64().unwrap()); + assert_eq!(align_of(&app, id), TextAlign::Right); + let h = bar(app); + for label in VERTICAL { + assert_eq!(lit(&h, label), label == "Bottom align text", "{label}"); + } +} From a21907fff27a57432605d3b07e80501a51d309e8 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 8 Nov 2026 14:04:53 +0100 Subject: [PATCH 206/219] #9396: carry on the nearest open end across all subpaths With the ends of two open subpaths both within the Pen's reach, the press took the first subpath listed, even when the pointer was nearer the other one's end, so the wrong subpath was carried on. It now compares every open end in reach and takes the nearest. Co-Authored-By: kavishkashehan726-sudo Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/pen_variants_tests.rs | 21 +++++++++++++++++++++ crates/ui-egui/src/tools/vector_ui/pen.rs | 20 ++++++++------------ 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/pen_variants_tests.rs b/crates/ui-egui/src/pen_variants_tests.rs index 71c272f793..2e73604cc9 100644 --- a/crates/ui-egui/src/pen_variants_tests.rs +++ b/crates/ui-egui/src/pen_variants_tests.rs @@ -589,3 +589,24 @@ fn a_press_in_reach_of_both_ends_carries_the_nearer_one_on_and_does_not_close() assert!(!work_path(&app).subpaths[0].closed); assert_eq!(ends(&app), Some([20.0, 20.0]), "from the nearer, first anchor"); } + +/// #9396 review: with the ends of two open subpaths both within reach, the press carries on the +/// subpath whose end is nearest the pointer, not the first one listed. +#[test] +fn a_press_in_reach_of_two_subpaths_ends_carries_the_nearest_on() { + let mut app = app(Tool::Pen); + assert_eq!(app.point_zoom(), 1.0, "a 6 px reach in document pixels"); + app.ui.tool_options.pen_auto_add_delete = false; + click(&mut app, 20.0, 20.0); + click(&mut app, 60.0, 20.0); + crate::vector_ui::pen_commit(&mut app, false); + click(&mut app, 100.0, 100.0); + click(&mut app, 64.0, 22.0); + crate::vector_ui::pen_commit(&mut app, false); + assert_eq!(work_path(&app).subpaths.len(), 2, "two open subpaths"); + // (63, 21): 3.2 px from the first subpath's last anchor, 1.4 px from the second's. + click(&mut app, 63.0, 21.0); + let pen = app.ui.pen.as_ref().expect("a resumed draft"); + assert_eq!(pen.resumes.as_ref().map(|r| r.index), Some(1), "{pen:?}"); + assert_eq!(pen.knots.last().map(|k| k[0]), Some([64.0, 22.0]), "from the second subpath's nearer end"); +} diff --git a/crates/ui-egui/src/tools/vector_ui/pen.rs b/crates/ui-egui/src/tools/vector_ui/pen.rs index 83e99f983f..d31dac59a8 100644 --- a/crates/ui-egui/src/tools/vector_ui/pen.rs +++ b/crates/ui-egui/src/tools/vector_ui/pen.rs @@ -565,18 +565,14 @@ pub(super) fn resume_at(app: &PhotocraftApp, x: f64, y: f64, tol: f64) -> Option let doc = app.session.active()?.doc.id.0; let (name, layer, path) = resume_target(app)?; let dist = |k: &photocraft_doc::Knot| (k.anchor.x - x).hypot(k.anchor.y - y); - path.subpaths.iter().enumerate().filter(|(_, s)| !s.closed).find_map(|(i, s)| { - let last = s.knots.last().map(dist).filter(|d| *d < tol); - let first = s.knots.first().map(dist).filter(|d| *d < tol); - // With both ends in reach (a short path, zoomed out) the nearer one is carried on. - let reversed = match (last, first) { - (Some(l), Some(f)) => f < l, - (Some(_), None) => false, - (None, Some(_)) => true, - (None, None) => return None, - }; - Some(resumed(doc, name.clone(), layer, i, s, reversed)) - }) + // With several ends in reach (a short path, or ends of two subpaths close together, zoomed + // out) the nearest one is carried on; on a tie the last anchor, then the earlier subpath. + let ends = + path.subpaths.iter().enumerate().filter(|(_, s)| !s.closed).flat_map(|(i, s)| { + [(s.knots.last(), false), (s.knots.first(), true)].into_iter().filter_map(move |(k, reversed)| Some((i, s, reversed, dist(k?)))) + }); + let (i, s, reversed, _) = ends.filter(|e| e.3 < tol).reduce(|best, e| if e.3 < best.3 { e } else { best })?; + Some(resumed(doc, name, layer, i, s, reversed)) } /// The endpoint Direct Selection has selected ([`crate::direct_select::selected_open_end`]), as From 51174ad04e5d4ba4875fae9f6cef78bf24fb4037 Mon Sep 17 00:00:00 2001 From: Triwarr <170976175+Triwarr@users.noreply.github.com> Date: Sun, 8 Nov 2026 14:34:23 +0100 Subject: [PATCH 207/219] PDF: review fixes for shape caches and font registration - Render each shape cache clipped to its own bounds instead of the full page clip. - Copy embedded font bytes before locking the shared text engine and take the lock per font, not for the whole registration loop. - Track seen font content keys in a HashSet instead of scanning the font list for every glyph; hash glyph text for fake-bold pairing instead of cloning strings. - Update the module and `pdf-editable` feature docs to describe text and accepted no-op clips. - Poll the cancellation watcher every 20 ms; Device callbacks only cover paints, so a single long path still needs the watcher. --- crates/io/Cargo.toml | 4 +-- crates/io/src/pdf_editable.rs | 53 ++++++++++++++++++++++++----------- 2 files changed, 39 insertions(+), 18 deletions(-) diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index 923d5da891..2090bdb00f 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -16,8 +16,8 @@ corpus = [] heif = ["photocraft-codecs/heif"] # Test support for synthetic memory injection, estimate charges and verification across crates. testgen = [] -# Editable PDF import (experimental): painted paths become shape layers. Text and the other -# unsupported constructs keep the raster page. +# Editable PDF import (experimental): painted paths and embedded-font text become editable +# layers; unsupported constructs keep the raster page. pdf-editable = ["dep:kurbo"] [dependencies] diff --git a/crates/io/src/pdf_editable.rs b/crates/io/src/pdf_editable.rs index e83b6a2581..d5e5d52066 100644 --- a/crates/io/src/pdf_editable.rs +++ b/crates/io/src/pdf_editable.rs @@ -1,16 +1,22 @@ -//! Experimental editable PDF import: a `craft-pdf` device turns painted paths into shape layers. +//! Experimental editable PDF import: a `craft-pdf` device turns painted paths into shape layers +//! and embedded-font text into text layers. //! -//! The first step is deliberately narrow: solid fills and strokes become [`ShapeLayer`]s in -//! document pixels. Text, images, clipping paths, transparency groups, patterns, soft masks and -//! annotations mark the page unsupported, and the caller falls back to the raster importer for -//! that page with a warning. Nothing is ever half imported. +//! Solid fills and strokes become [`ShapeLayer`]s in document pixels; embedded fonts whose glyphs +//! carry Unicode and a PostScript name become [`photocraft_doc::TextLayer`]s, with the font bytes +//! registered in the app's shared text engine. A page-sized axis-aligned clip that contains the +//! artboard is a no-op and is accepted. Images, other clipping paths, transparency groups, +//! patterns, soft masks, annotations and unsupported text mark the page unsupported, and the +//! caller falls back to the raster importer for that page with a warning. Nothing is ever half +//! imported. //! //! This is the app-owned `Device` route: Hayro's interpreter walks the page and reports each //! paint with its real colour and geometry, so shape layers need no page raster underneath. That //! is what makes moving or deleting a layer behave, and what avoids the black-path and //! doubled-text failures of the old PDFium import. -use std::collections::HashMap; +use std::collections::hash_map::DefaultHasher; +use std::collections::{HashMap, HashSet}; +use std::hash::{Hash, Hasher}; use std::panic::AssertUnwindSafe; use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; @@ -39,8 +45,10 @@ const MAX_KNOTS: usize = 1_000_000; const MAX_CACHE_BYTES: u64 = 256 << 20; /// Cap on fill/stroke glyph comparisons so hostile pages cannot make pairing quadratic forever. const MAX_PAIR_CHECKS: usize = 1_000_000; -/// How often the watcher copies the interrupt into craft-pdf's cancellation flag. -const POLL: Duration = Duration::from_millis(5); +/// How often the watcher copies the interrupt into craft-pdf's cancellation flag. The Device's +/// callbacks are only reached per paint, so a page with one long path still needs this; 20 ms +/// keeps wakeups negligible while staying responsive. +const POLL: Duration = Duration::from_millis(20); /// Largest gap (in em) inside one run before a space is inserted. const WORD_GAP: f64 = 0.2; /// Largest baseline offset (in em) for glyphs to share one line. @@ -99,6 +107,7 @@ pub fn page(page: &Page<'_>, rect: Rect, ctl: &Interrupt) -> Result, strokes: Vec::new(), fonts: Vec::new(), font_keys: HashMap::new(), + seen_fonts: HashSet::new(), soft_mask: false, clips: Vec::new(), knots: 0, @@ -157,15 +166,15 @@ pub fn page(page: &Page<'_>, rect: Rect, ctl: &Interrupt) -> Result, fn pair_fake_bold(glyphs: &mut [GlyphRun], strokes: Vec, ctl: &Interrupt) -> Result<(), EditableError> { // Index fills by the exact attributes a stroke must match, so each stroke only compares // against candidates at its own paint order/font/text instead of the whole page. - let mut index: HashMap<(usize, u128, String), Vec> = HashMap::new(); + let mut index: HashMap<(usize, u128, u64), Vec> = HashMap::new(); for (at, glyph) in glyphs.iter().enumerate() { - index.entry((glyph.order, glyph.font.key, glyph.text.clone())).or_default().push(at); + index.entry((glyph.order, glyph.font.key, text_hash(&glyph.text))).or_default().push(at); } let mut paired = vec![false; glyphs.len()]; let mut checks = 0usize; for stroke in strokes { let mut found = None; - if let Some(candidates) = index.get(&(stroke.order, stroke.font.key, stroke.text.clone())) { + if let Some(candidates) = index.get(&(stroke.order, stroke.font.key, text_hash(&stroke.text))) { for &at in candidates { if paired[at] { continue; @@ -179,7 +188,7 @@ fn pair_fake_bold(glyphs: &mut [GlyphRun], strokes: Vec, ctl: &Interru checks += 1; let glyph = &glyphs[at]; let tolerance = 0.01 * glyph.size_px + 0.01; - if (glyph.origin.x - stroke.origin.x).abs() <= tolerance && (glyph.origin.y - stroke.origin.y).abs() <= tolerance { + if glyph.text == stroke.text && (glyph.origin.x - stroke.origin.x).abs() <= tolerance && (glyph.origin.y - stroke.origin.y).abs() <= tolerance { found = Some(at); break; } @@ -194,6 +203,13 @@ fn pair_fake_bold(glyphs: &mut [GlyphRun], strokes: Vec, ctl: &Interru Ok(()) } +/// A hash of a glyph string for the fake-bold index; equality is still checked on the string. +fn text_hash(text: &str) -> u64 { + let mut hasher = DefaultHasher::new(); + text.hash(&mut hasher); + hasher.finish() +} + /// Builds one point-text layer per line and style run, with the layer index it must be inserted /// at to preserve paint order relative to paths. fn text_layers(glyphs: &[GlyphRun], families: &std::collections::HashMap) -> Vec<(usize, Layer)> { @@ -294,9 +310,12 @@ fn text_layer(text: String, run: &GlyphRun, number: usize, family: &str) -> Laye /// the family each font key must use. fn register_fonts(fonts: &[(u128, String, String, FontData)]) -> Result, EditableError> { let shared = photocraft_text::shared(); - let mut engine = shared.lock().unwrap_or_else(|error| error.into_inner()); let mut families = std::collections::HashMap::new(); for (key, postscript, derived, data) in fonts { + // Copy before taking the engine lock; parsing still happens under it, but the UI's + // layout is not blocked while the bytes are cloned. + let bytes = data.as_ref().as_ref().to_vec(); + let mut engine = shared.lock().unwrap_or_else(|error| error.into_inner()); // Prefer an installed full family; a subset only holds the glyphs the page uses. The // Device has the font program's PostScript name, not the PDF `/BaseFont`; a content-keyed // alias keeps two documents' subsets of one name apart for the life of the shared engine, @@ -308,7 +327,7 @@ fn register_fonts(fonts: &[(u128, String, String, FontData)]) -> Result { fonts: Vec<(u128, String, String, FontData)>, /// Hayro's cheap font identity → our content hash, so bytes are hashed once per face. font_keys: HashMap, + /// Content keys of fonts already in [`Self::fonts`], so glyph callbacks do not scan it. + seen_fonts: HashSet, /// Whether a soft mask is active; the first paint under one falls back the page. soft_mask: bool, /// One entry per clip pushed by the interpreter: `true` for an accepted no-op clip. @@ -491,7 +512,7 @@ impl PathDevice<'_> { self.unsupported("shape cache budget"); return; } - shape.cache = Some(photocraft_vector::render_shape(&shape, self.fmt, self.clip)); + shape.cache = Some(photocraft_vector::render_shape(&shape, self.fmt, area)); self.cached = self.cached.saturating_add(bytes); } self.layers.push(Layer::new(format!("Path {}", self.layers.len() + 1), LayerContent::Shape(shape))); @@ -662,7 +683,7 @@ impl<'a> Device<'a> for PathDevice<'_> { let identity = outline.font_cache_key(); let key = *self.font_keys.entry(identity).or_insert_with(|| font_content_key(&data)); let family = family_from_postscript(&postscript); - if !self.fonts.iter().any(|(existing, _, _, _)| *existing == key) { + if self.seen_fonts.insert(key) { self.fonts.push((key, postscript.clone(), family.clone(), data.data.clone())); } let font = FontInfo { key, family, postscript, weight, italic }; From fec4980c7b8e5bc0d027bd6153a10ee502746e02 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sun, 8 Nov 2026 08:44:18 -0500 Subject: [PATCH 208/219] Patch Destination: clone only the selection; guard, error path and redo tests (#12119) The Destination branch of the Patch command no longer clones the whole document: it takes only the selection before the target surface borrows the document, and computes the selection's content bounds once, passing them to `is_hard_rect` and reusing them for the translate. Behaviour is unchanged. Tests are split and extended: Destination with a feathered, ellipse, lasso and holed-rectangle selection leaves the selection in place while the patch still lands; Destination with no selection fails, adds no history entry and a following undo reverts the previous step, not the setup; redo restores both the patched pixels and the moved selection; Source and Content-Aware are separate tests, the latter asserting a pixel change; the main test also asserts source pixels unchanged, the destination carrying the source texture, the moved selection still hard-edged, and the returned damage and offset. A rectangle larger than the patched area and a partly covering selection without a hole cannot be built, because the patched area is the selection's own content bounds; an inverted selection makes the area the whole canvas, which Patch refuses. Verified: `cargo test -p photocraft-engine --lib patch` 18 passed; `--lib retouch` 119 passed; clippy `--all-targets -D warnings` clean; `cargo fmt -p photocraft-engine` clean. Not changed: the coverage pass in `is_hard_rect` (one pass over the patched area per command, the same order of work as the patch itself). Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove and briefed; Sonnet 5.5 wrote at medium --- crates/engine/src/cmd/retouch_cmds/patch.rs | 14 +-- .../patch_destination_selection_tests.rs | 117 +++++++++++++++++- 2 files changed, 119 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/cmd/retouch_cmds/patch.rs b/crates/engine/src/cmd/retouch_cmds/patch.rs index b6bf3b18d6..a132412a4f 100644 --- a/crates/engine/src/cmd/retouch_cmds/patch.rs +++ b/crates/engine/src/cmd/retouch_cmds/patch.rs @@ -213,8 +213,8 @@ fn patch_surface(surf: &mut Surface, sel: &Surface, plan: &Plan, lock: bool, ste /// Whether `sel` is a hard-edged rectangle exactly filling `area` (the only Destination selection /// measured to move with the patch). -fn is_hard_rect(sel: &Surface, area: Rect) -> bool { - sel.default_pixel().first().copied().unwrap_or(0.0) <= 0.0 && sel.content_bounds() == area && coverage(sel, area).iter().all(|c| *c >= 1.0) +fn is_hard_rect(sel: &Surface, bounds: Rect, area: Rect) -> bool { + sel.default_pixel().first().copied().unwrap_or(0.0) <= 0.0 && bounds == area && coverage(sel, area).iter().all(|c| *c >= 1.0) } pub(super) fn patch(s: &mut Session, p: &Value) -> Result { @@ -226,13 +226,13 @@ pub(super) fn patch(s: &mut Session, p: &Value) -> Result { let dmg = if plan.destination { // Destination: a plain rectangular selection moves with the patch, in the same history state. let dmg = s.edit(label, |doc, _| { - let pre = doc.clone(); + let sel = doc.selection.clone().ok_or_else(|| bad(CMD, "select the area to patch first"))?; let (surf, lock) = crate::channel_cmds::target_surface(doc, plan.id, p)?; - let sel = pre.selection.as_ref().ok_or_else(|| bad(CMD, "select the area to patch first"))?; - let dmg = patch_surface(surf, sel, &plan, lock, 1); - if is_hard_rect(sel, plan.area) { + let dmg = patch_surface(surf, &sel, &plan, lock, 1); + let bounds = sel.content_bounds(); + if is_hard_rect(&sel, bounds, plan.area) { let (dx, dy) = plan.offset; - doc.selection = Some(sel.translated(dx, dy, sel.content_bounds())); + doc.selection = Some(sel.translated(dx, dy, bounds)); } Ok(dmg) })?; diff --git a/crates/engine/src/cmd/retouch_cmds/patch_destination_selection_tests.rs b/crates/engine/src/cmd/retouch_cmds/patch_destination_selection_tests.rs index 3aa7879af0..fa42660c54 100644 --- a/crates/engine/src/cmd/retouch_cmds/patch_destination_selection_tests.rs +++ b/crates/engine/src/cmd/retouch_cmds/patch_destination_selection_tests.rs @@ -33,14 +33,41 @@ fn px(s: &Session, x: i32, y: i32) -> [f32; 4] { d.doc.layer(d.active_layer.unwrap()).unwrap().surface().unwrap().rgba(x, y) } +fn history_len(s: &Session) -> usize { + s.active().unwrap().history.past_len() +} + +fn select_rect(s: &mut Session) { + s.execute("select.rect", json!({"x": 120, "y": 120, "width": 40, "height": 40})).unwrap(); +} + +const DESTINATION: fn() -> Value = || json!({"mode": "destination", "offset": [460, 280]}); + #[test] fn destination_moves_the_selection_with_the_patch_and_undo_restores_both() { let mut s = session(); - s.execute("select.rect", json!({"x": 120, "y": 120, "width": 40, "height": 40})).unwrap(); + select_rect(&mut s); let before = px(&s, 600, 420); - s.execute("paint.patch", json!({"mode": "destination", "offset": [460, 280]})).unwrap(); + let source_before: Vec<[f32; 4]> = [(130, 130), (140, 140), (148, 140)].iter().map(|&(x, y)| px(&s, x, y)).collect(); + let r = s.execute("paint.patch", DESTINATION()).unwrap(); + assert_eq!(r["offset"], json!([460, 280])); + // The damage covers the patched area (the 40 px square at the destination, plus the solve's ring). + assert_eq!(r["damage"], json!([578, 398, 44, 44])); assert_eq!(bounds(&mut s), json!([580, 400, 40, 40])); assert_ne!(px(&s, 600, 420), before, "pixels changed too"); + // Source pixels untouched. + let source_after: Vec<[f32; 4]> = [(130, 130), (140, 140), (148, 140)].iter().map(|&(x, y)| px(&s, x, y)).collect(); + assert_eq!(source_after, source_before); + // The destination carries the source texture: the step between two neighbouring checker cells is kept + // in the same direction and at a similar strength (the seamless clone adds a smooth colour correction, and the + // red channel clamps at 0 on the dark green, so the step shrinks). + let step_src = px(&s, 140, 140)[0] - px(&s, 148, 140)[0]; + let step_dst = px(&s, 600, 420)[0] - px(&s, 608, 420)[0]; + assert!(step_src * step_dst > 0.0 && step_dst.abs() > 0.2, "texture kept: source step {step_src}, destination step {step_dst}"); + // The moved selection is still a hard-edged rectangle exactly filling its bounds. + let d = s.active().unwrap(); + let sel = d.doc.selection.as_ref().unwrap(); + assert!(is_hard_rect(sel, sel.content_bounds(), Rect::new(580, 400, 620, 440))); // One history state: a single undo brings back the pixels and the old selection. s.execute("edit.undo", json!({})).unwrap(); assert_eq!(bounds(&mut s), json!([120, 120, 40, 40])); @@ -48,11 +75,91 @@ fn destination_moves_the_selection_with_the_patch_and_undo_restores_both() { } #[test] -fn source_and_content_aware_keep_the_selection() { +fn destination_selection_stays_when_it_is_not_a_plain_rectangle() { + let cases: [(&str, Value, Value); 4] = [ + ("feathered", json!({"x": 120, "y": 120, "width": 40, "height": 40, "feather": 6.0}), json!(null)), + ("ellipse", json!({"x": 120, "y": 120, "width": 40, "height": 40, "ellipse": true}), json!(null)), + ("lasso", json!({"points": [[120, 120], [160, 130], [140, 165]]}), json!("lasso")), + ("rectangle with a hole", json!({"x": 120, "y": 120, "width": 40, "height": 40}), json!("hole")), + ]; + for (name, params, kind) in cases { + let mut s = session(); + let cmd = if kind == json!("lasso") { "select.lasso" } else { "select.rect" }; + s.execute(cmd, params).unwrap(); + if kind == json!("hole") { + // Same bounds as the rectangle, but not every pixel inside is fully selected. + s.execute("select.rect", json!({"x": 135, "y": 135, "width": 10, "height": 10, "mode": "subtract"})).unwrap(); + } + let sel_before = bounds(&mut s); + let area = |s: &Session| -> Vec<[f32; 4]> { (400..440).flat_map(|y| (580..620).map(move |x| (x, y))).map(|(x, y)| px(s, x, y)).collect() }; + let before = area(&s); + s.execute("paint.patch", DESTINATION()).unwrap(); + assert_eq!(bounds(&mut s), sel_before, "{name}: the selection stays in place"); + assert_ne!(area(&s), before, "{name}: the patch still lands"); + } +} + +#[test] +fn partial_coverage_and_oversized_rectangles_are_not_constructible_apart_from_the_hole_case() { + // `plan.area` is the selection's own content bounds, so a rectangle larger than the area cannot + // exist, and the area is never wider than the selection. The only way the bounds equal the area + // yet coverage falls short is a hole (covered above). An inverted selection makes the area the + // whole canvas, which no non-zero drag can keep on the canvas, so Patch refuses it. let mut s = session(); - s.execute("select.rect", json!({"x": 120, "y": 120, "width": 40, "height": 40})).unwrap(); + select_rect(&mut s); + s.execute("select.inverse", json!({})).unwrap(); + assert!(s.execute("paint.patch", DESTINATION()).is_err()); +} + +#[test] +fn destination_without_a_selection_fails_and_leaves_history_alone() { + let mut s = session(); + // A marker step after the setup, so a stray undo would be visible. + s.edit("marker", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(10, 10, 20, 20), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + let h = history_len(&s); + assert!(s.execute("paint.patch", DESTINATION()).is_err()); + assert_eq!(history_len(&s), h, "no history entry"); + // Undo reverts the marker (the last real step), not the setup. + s.execute("edit.undo", json!({})).unwrap(); + assert_ne!(px(&s, 15, 15), [1.0, 0.0, 0.0, 1.0], "the marker step was undone"); + assert!((px(&s, 450, 300)[0] - 0.1).abs() < 0.01 && (px(&s, 450, 300)[1] - 0.6).abs() < 0.01, "the setup step is still there"); +} + +#[test] +fn redo_restores_the_patched_pixels_and_the_moved_selection() { + let mut s = session(); + select_rect(&mut s); + let before = px(&s, 600, 420); + s.execute("paint.patch", DESTINATION()).unwrap(); + let patched = px(&s, 600, 420); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(bounds(&mut s), json!([120, 120, 40, 40])); + assert_eq!(px(&s, 600, 420), before); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(bounds(&mut s), json!([580, 400, 40, 40])); + assert_eq!(px(&s, 600, 420), patched); +} + +#[test] +fn source_mode_keeps_the_selection() { + let mut s = session(); + select_rect(&mut s); s.execute("paint.patch", json!({"offset": [260, 0]})).unwrap(); assert_eq!(bounds(&mut s), json!([120, 120, 40, 40])); - s.execute("paint.patch", json!({"offset": [260, 0], "contentAware": true})).unwrap(); +} + +#[test] +fn content_aware_keeps_the_selection_and_changes_the_pixels() { + let mut s = session(); + select_rect(&mut s); + // Content-Aware ignores the mode and fills the selection itself from the dragged-to place (the + // green half), so the pixels under the selection change. + let before = px(&s, 140, 140); + s.execute("paint.patch", json!({"offset": [400, 0], "contentAware": true})).unwrap(); assert_eq!(bounds(&mut s), json!([120, 120, 40, 40])); + assert_ne!(px(&s, 140, 140), before, "the selected area was filled"); } From 0c6b5645363311830e69ec8324ed7a0737e924ee Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 8 Nov 2026 15:04:43 +0100 Subject: [PATCH 209/219] Magic Wand: skip the selection mask when Contiguous is off; more tests (#11848) PR #11848 built the full-area selection mask for every Subtract and Intersect click, though the global match (Contiguous off) ignores it. Build it only when the fill is contiguous. Tests for the review: Contiguous off keeps the global match, Subtract and Intersect without a selection select nothing, the row check fails on a hole in the selected run, and the fill's half-selected threshold (0.49 vs 0.5) at the seed and at the fill's edge. Co-Authored-By: DuBearte <130190089+DuBearte@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/selection_cmds.rs | 6 ++- .../selection_cmds/wand_seed_outside_tests.rs | 44 ++++++++++++++++++- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/selection_cmds.rs b/crates/engine/src/cmd/selection_cmds.rs index c1c665e111..550dcd6e3f 100644 --- a/crates/engine/src/cmd/selection_cmds.rs +++ b/crates/engine/src/cmd/selection_cmds.rs @@ -143,10 +143,12 @@ fn magic_wand(s: &mut Session, p: &Value) -> Result { }; let m = mode(p); // Subtract and Intersect grow the region from the seed through the selection only (#11779). - let within = matches!(m, SelectionMode::Subtract | SelectionMode::Intersect) + // A global match (Contiguous off) ignores the limit, so it skips the full-area mask. + let contiguous = b(p, "contiguous", true); + let within = (contiguous && matches!(m, SelectionMode::Subtract | SelectionMode::Intersect)) .then(|| s.active().and_then(|st| st.doc.selection.as_ref()).map(|sel| sel::mask_from_surface(Some(sel), area))) .flatten(); - let region = sel::wand_region_within(&img, area, (x, y), f(p, "tolerance", 32.0), b(p, "contiguous", true), b(p, "antiAlias", true), within.as_deref()); + let region = sel::wand_region_within(&img, area, (x, y), f(p, "tolerance", 32.0), contiguous, b(p, "antiAlias", true), within.as_deref()); drop(img); let selected = s.edit("Magic Wand", |doc, _| { doc.selection = sel::combine_region(doc.selection.as_ref(), region.as_ref(), m); diff --git a/crates/engine/src/cmd/selection_cmds/wand_seed_outside_tests.rs b/crates/engine/src/cmd/selection_cmds/wand_seed_outside_tests.rs index 13165d591f..ce289bea91 100644 --- a/crates/engine/src/cmd/selection_cmds/wand_seed_outside_tests.rs +++ b/crates/engine/src/cmd/selection_cmds/wand_seed_outside_tests.rs @@ -23,13 +23,19 @@ fn wand(s: &mut Session, x: i32, tolerance: f64, mode: &str) { s.execute("select.magicWand", json!({"x": x, "y": 400, "tolerance": tolerance, "mode": mode})).unwrap(); } -/// The columns of row 400 that are at least half selected: (first, end), or `None`. +fn wand_global(s: &mut Session, x: i32, tolerance: f64, mode: &str) { + s.execute("select.magicWand", json!({"x": x, "y": 400, "tolerance": tolerance, "mode": mode, "contiguous": false})).unwrap(); +} + +/// The columns of row 400 that are at least half selected: (first, end), or `None`. Every +/// column in between must be selected too, so a hole in the run fails the test. fn selected(s: &Session) -> Option<(i32, i32)> { let doc = &s.active().unwrap().doc; let row = photocraft_geom::Rect::new(0, 400, 800, 401); let m = photocraft_algo::selection::mask_from_surface(doc.selection.as_ref(), row); let first = m.iter().position(|v| *v >= 0.5)?; let last = m.iter().rposition(|v| *v >= 0.5)?; + assert!(m[first..=last].iter().all(|v| *v >= 0.5), "a hole in the selected run {first}..={last}"); Some((first as i32, last as i32 + 1)) } @@ -66,3 +72,39 @@ fn a_seed_inside_the_selection_works_as_before() { wand(&mut s, 320, 10.0, "intersect"); assert_eq!(selected(&s), Some((220, 380))); } + +#[test] +fn contiguous_off_keeps_the_global_match() { + // Without Contiguous the seed's colour matches everywhere, as before: greys 115–145 are + // x 220–500, wherever the seed lies. + let mut s = ramp_selected(); + wand_global(&mut s, 480, 32.0, "subtract"); + assert_eq!(selected(&s), Some((100, 220))); + let mut s = ramp_selected(); + wand_global(&mut s, 480, 32.0, "intersect"); + assert_eq!(selected(&s), Some((220, 380))); +} + +#[test] +fn without_a_selection_subtract_and_intersect_select_nothing() { + for mode in ["subtract", "intersect"] { + let mut s = ramp(); + wand(&mut s, 320, 10.0, mode); + assert_eq!(selected(&s), None, "{mode}"); + } +} + +#[test] +fn the_fill_passes_only_pixels_at_least_half_selected() { + use photocraft_algo::selection::wand_region_within; + use photocraft_geom::Rect; + // One flat grey row of four pixels; only the coverage limit decides the region. + let img = [[128u8, 128, 128, 255]; 4]; + let area = Rect::new(0, 0, 4, 1); + let fill = |within: [f32; 4], seed: i32| wand_region_within(&img, area, (seed, 0), 0.0, true, false, Some(&within)); + assert!(fill([0.49, 1.0, 1.0, 1.0], 0).is_none(), "a seed just under half selected finds nothing"); + let r = fill([0.5, 1.0, 1.0, 1.0], 0).unwrap(); + assert_eq!((0..4).map(|x| r.at(x, 0) >= 0.5).collect::>(), [true; 4], "half selected counts"); + let r = fill([1.0, 1.0, 0.49, 1.0], 0).unwrap(); + assert_eq!((0..4).map(|x| r.at(x, 0) >= 0.5).collect::>(), [true, true, false, false], "the fill stops at 0.49"); +} From cbdce347f07518c9ce2eb16576f55243cffef9fb Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 8 Nov 2026 15:12:49 +0100 Subject: [PATCH 210/219] Background Eraser: Protect Foreground keeps the per-channel distance; tolerance doc Only the sample match uses the measured increase-plus-decrease distance (#11901). Protect Foreground Color keeps today's largest per-channel difference, as #11903 asks for its unmeasured cases. The tolerance doc describes the new distance and the graded falloff. Co-Authored-By: Claude Opus 5.5 --- crates/paint/src/bg_erase.rs | 14 ++++++++++++-- crates/paint/src/bg_erase_graded_alpha_tests.rs | 12 ++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/crates/paint/src/bg_erase.rs b/crates/paint/src/bg_erase.rs index 751d58519b..a47c5acbfb 100644 --- a/crates/paint/src/bg_erase.rs +++ b/crates/paint/src/bg_erase.rs @@ -17,7 +17,11 @@ use crate::retouch::{Footprint, Region, alpha_index, apply_dab_stroke}; pub struct BgEraseSettings { pub sampling: Sampling, pub limits: Limits, - /// 0..1: largest per-channel difference from the sampled colour that is erased. + /// 0..1: how far from the sampled colour a colour is still partly erased. The distance is the + /// largest increase plus the largest decrease over the colour channels (`dist`); an exact + /// match is erased fully, then less the further a colour is, and colours at or beyond the + /// Tolerance are kept (`graded_weight`). Protect Foreground Color still compares the + /// foreground by the largest per-channel difference (`max_channel_dist`). pub tolerance: f32, /// Keep pixels that match the foreground colour. pub protect_foreground: bool, @@ -52,6 +56,12 @@ fn dist(a: &[f32], b: &[f32]) -> f32 { up + down } +/// The largest per-channel difference of `a` from `b`: the distance Protect Foreground Color keeps +/// using (#11901 measured only the sample match; #11903 keeps today's measure for the foreground). +fn max_channel_dist(a: &[f32], b: &[f32]) -> f32 { + a.iter().zip(b).fold(0.0f32, |m, (x, y)| m.max((x - y).abs())) +} + /// The match weight of one dab for a colour `d` away from the sampled colour (#11901): an exact /// match is erased fully, then the weight falls off linearly over the whole Tolerance `tol` after /// a short plateau `d0 = min(tol / 20, 8 levels)`, and colours at or beyond the Tolerance are kept. @@ -151,7 +161,7 @@ pub fn background_eraser_effect(pre: &Surface, bs: &BgEraseSettings) -> Option Date: Sun, 8 Nov 2026 15:13:28 +0100 Subject: [PATCH 211/219] Background Eraser: document that Find Edges grades in this tool Co-Authored-By: Claude Opus 5.5 --- crates/paint/src/bg_erase.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/paint/src/bg_erase.rs b/crates/paint/src/bg_erase.rs index 567d0b2034..9ae05ef384 100644 --- a/crates/paint/src/bg_erase.rs +++ b/crates/paint/src/bg_erase.rs @@ -16,6 +16,9 @@ use crate::retouch::{Footprint, Region, alpha_index, apply_dab_stroke}; #[serde(default, rename_all = "camelCase")] pub struct BgEraseSettings { pub sampling: Sampling, + /// Which matching pixels are erased. [`Limits::FindEdges`] grades the erase over the Tolerance + /// like [`Limits::Contiguous`] in this tool (#11904), unlike Color Replacement, where it turns + /// the soft falloff off. pub limits: Limits, /// 0..1: how far from the sampled colour a colour is still partly erased. The distance is the /// largest increase plus the largest decrease over the colour channels (`dist`); an exact From f3de619549470981c8e8d0e9272bd1cd57fa16cb Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sun, 8 Nov 2026 09:47:11 -0500 Subject: [PATCH 212/219] Patch Destination: a hard rectangle partly off the canvas moves with the patch (#12119) Cause: is_hard_rect required the selection's bounds to equal plan.area, which is clipped to the canvas. A hard rectangle reaching past the canvas (e.g. moved there with select.transformSelection, which keeps the off-canvas pixels) has bounds larger than the area, so the pixels were patched but the selection was left behind. Fix: treat the selection as a hard rectangle when nothing is selected outside its own bounds and every pixel inside those bounds is fully selected, then translate the whole selection by the drag offset. Feathered, non-rectangular and holed selections still stay in place. Verified: new test destination_moves_a_hard_rectangle_that_extends_past_the_canvas failed before the change (selection stayed at -20,100..40,140 while the pixels changed) and passes after, including one-undo restore of pixels and selection. cargo test -p photocraft-engine --lib patch (19 passed) and retouch (120 passed), clippy -D warnings and fmt clean. Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove and briefed; Sonnet 5.5 wrote at medium --- crates/engine/src/cmd/retouch_cmds/patch.rs | 11 +++++----- .../patch_destination_selection_tests.rs | 21 ++++++++++++++++++- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/retouch_cmds/patch.rs b/crates/engine/src/cmd/retouch_cmds/patch.rs index a132412a4f..cb184da85e 100644 --- a/crates/engine/src/cmd/retouch_cmds/patch.rs +++ b/crates/engine/src/cmd/retouch_cmds/patch.rs @@ -211,10 +211,11 @@ fn patch_surface(surf: &mut Surface, sel: &Surface, plan: &Plan, lock: bool, ste apply_coverage(surf, g, &cov, 1.0, None, lock, &healed, BlendMode::Normal) } -/// Whether `sel` is a hard-edged rectangle exactly filling `area` (the only Destination selection -/// measured to move with the patch). -fn is_hard_rect(sel: &Surface, bounds: Rect, area: Rect) -> bool { - sel.default_pixel().first().copied().unwrap_or(0.0) <= 0.0 && bounds == area && coverage(sel, area).iter().all(|c| *c >= 1.0) +/// Whether `sel` is a hard-edged rectangle: nothing selected outside its own `bounds` and every pixel +/// inside fully selected (the only Destination selection measured to move with the patch). The +/// bounds may reach past the canvas; the patched area is then only the part on the canvas. +fn is_hard_rect(sel: &Surface, bounds: Rect) -> bool { + sel.default_pixel().first().copied().unwrap_or(0.0) <= 0.0 && coverage(sel, bounds).iter().all(|c| *c >= 1.0) } pub(super) fn patch(s: &mut Session, p: &Value) -> Result { @@ -230,7 +231,7 @@ pub(super) fn patch(s: &mut Session, p: &Value) -> Result { let (surf, lock) = crate::channel_cmds::target_surface(doc, plan.id, p)?; let dmg = patch_surface(surf, &sel, &plan, lock, 1); let bounds = sel.content_bounds(); - if is_hard_rect(&sel, bounds, plan.area) { + if is_hard_rect(&sel, bounds) { let (dx, dy) = plan.offset; doc.selection = Some(sel.translated(dx, dy, bounds)); } diff --git a/crates/engine/src/cmd/retouch_cmds/patch_destination_selection_tests.rs b/crates/engine/src/cmd/retouch_cmds/patch_destination_selection_tests.rs index fa42660c54..de14bd73c9 100644 --- a/crates/engine/src/cmd/retouch_cmds/patch_destination_selection_tests.rs +++ b/crates/engine/src/cmd/retouch_cmds/patch_destination_selection_tests.rs @@ -67,7 +67,7 @@ fn destination_moves_the_selection_with_the_patch_and_undo_restores_both() { // The moved selection is still a hard-edged rectangle exactly filling its bounds. let d = s.active().unwrap(); let sel = d.doc.selection.as_ref().unwrap(); - assert!(is_hard_rect(sel, sel.content_bounds(), Rect::new(580, 400, 620, 440))); + assert!(is_hard_rect(sel, sel.content_bounds())); // One history state: a single undo brings back the pixels and the old selection. s.execute("edit.undo", json!({})).unwrap(); assert_eq!(bounds(&mut s), json!([120, 120, 40, 40])); @@ -163,3 +163,22 @@ fn content_aware_keeps_the_selection_and_changes_the_pixels() { assert_eq!(bounds(&mut s), json!([120, 120, 40, 40])); assert_ne!(px(&s, 140, 140), before, "the selected area was filled"); } + +#[test] +fn destination_moves_a_hard_rectangle_that_extends_past_the_canvas() { + let mut s = session(); + // 60x40 rectangle with 20 columns off the left edge; the part on the canvas is x 0..40. + s.execute("select.rect", json!({"x": 0, "y": 100, "width": 60, "height": 40})).unwrap(); + s.execute("select.transformSelection", json!({"dx": -20, "dy": 0})).unwrap(); + let sel_before = bounds(&mut s); + let before = px(&s, 480, 400); + s.execute("paint.patch", DESTINATION()).unwrap(); + assert_ne!(px(&s, 480, 400), before, "the patch lands on the canvas part"); + let d = s.active().unwrap(); + let sel = d.doc.selection.as_ref().unwrap(); + assert_eq!(sel.content_bounds(), Rect::new(440, 380, 500, 420), "the whole rectangle moved, off-canvas part included"); + assert!(sel.read_region(Rect::new(440, 380, 500, 420)).chunks_exact(sel.channels().max(1)).all(|p| p[0] >= 1.0)); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(bounds(&mut s), sel_before); + assert_eq!(px(&s, 480, 400), before); +} From 66d5ebed89b3e909b48be5dcadb87a21200ceed5 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sun, 8 Nov 2026 10:14:55 -0500 Subject: [PATCH 213/219] Menus: Layer Via Copy and Layer Via Cut with a capital V, as in the reference (#12357) Cause: the menu bar, the command labels and the selection tools' canvas menu spelled the two commands "Layer via Copy" and "Layer via Cut", while the reference application (and PhotoCraft's own History names and ATN event table) use a capital V. Fix: renamed the labels in crates/ui-egui/src/menu_catalog.rs, the two spec! labels in crates/engine/src/cmd/edit_cmds.rs, and the rows in crates/ui-egui/src/canvas/canvas_tool_menu.rs with its test assertions. In the 33 locale files crates/ui-egui/src/i18n/*.tsv that carry the keys, only the key column of those two rows changed (66 rows), so every translation is kept and no language falls back to English. New tests are in crates/ui-egui/src/layer_via_label_tests.rs, registered with one mod line in lib.rs like the other *_tests.rs files. Verified: the 5 new tests pass; canvas_tool_menu 51 passed; engine edit_cmds 112 passed; i18n 66 passed and 8 live_tests failed with the known local font panic in epaint fonts.rs (font-dependent, green on CI); cargo xtask parity left docs/parity-checklist.md unchanged; clippy -D warnings and fmt are clean. TSV check against HEAD: 34 files, same row count in each, exactly 66 rows differ (2 per file in 33 files, Hindi has none), only the key column of those rows differs, the translation column is byte-identical, no old key remains, and no CR bytes in any touched file. Not changed: the translations' text, the History names and the ATN event table (already the capital V), docs/ui-parity.md and the other parity docs. Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove and briefed; Sonnet 5.5 wrote at medium --- crates/engine/src/cmd/edit_cmds.rs | 4 +- crates/ui-egui/src/canvas/canvas_tool_menu.rs | 4 +- .../src/canvas/canvas_tool_menu/tests.rs | 4 +- crates/ui-egui/src/i18n/ar.tsv | 4 +- crates/ui-egui/src/i18n/az.tsv | 4 +- crates/ui-egui/src/i18n/bs.tsv | 4 +- crates/ui-egui/src/i18n/cs.tsv | 4 +- crates/ui-egui/src/i18n/da.tsv | 4 +- crates/ui-egui/src/i18n/de.tsv | 4 +- crates/ui-egui/src/i18n/el.tsv | 4 +- crates/ui-egui/src/i18n/es.tsv | 4 +- crates/ui-egui/src/i18n/fi.tsv | 4 +- crates/ui-egui/src/i18n/fr.tsv | 4 +- crates/ui-egui/src/i18n/he.tsv | 4 +- crates/ui-egui/src/i18n/hu.tsv | 4 +- crates/ui-egui/src/i18n/id.tsv | 4 +- crates/ui-egui/src/i18n/it.tsv | 4 +- crates/ui-egui/src/i18n/ja.tsv | 4 +- crates/ui-egui/src/i18n/ka.tsv | 4 +- crates/ui-egui/src/i18n/ko.tsv | 4 +- crates/ui-egui/src/i18n/nb.tsv | 4 +- crates/ui-egui/src/i18n/nl.tsv | 4 +- crates/ui-egui/src/i18n/pl.tsv | 4 +- crates/ui-egui/src/i18n/pt-br.tsv | 4 +- crates/ui-egui/src/i18n/ro.tsv | 4 +- crates/ui-egui/src/i18n/ru.tsv | 4 +- crates/ui-egui/src/i18n/sr.tsv | 4 +- crates/ui-egui/src/i18n/sv.tsv | 4 +- crates/ui-egui/src/i18n/ta.tsv | 4 +- crates/ui-egui/src/i18n/th.tsv | 4 +- crates/ui-egui/src/i18n/tr.tsv | 4 +- crates/ui-egui/src/i18n/uk.tsv | 4 +- crates/ui-egui/src/i18n/ur.tsv | 4 +- crates/ui-egui/src/i18n/vi.tsv | 4 +- crates/ui-egui/src/i18n/zh-hans.tsv | 4 +- crates/ui-egui/src/i18n/zh-hant.tsv | 4 +- crates/ui-egui/src/layer_via_label_tests.rs | 63 +++++++++++++++++++ crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/menu_catalog.rs | 4 +- 39 files changed, 140 insertions(+), 74 deletions(-) create mode 100644 crates/ui-egui/src/layer_via_label_tests.rs diff --git a/crates/engine/src/cmd/edit_cmds.rs b/crates/engine/src/cmd/edit_cmds.rs index dbf1a9a6f1..d460524c25 100644 --- a/crates/engine/src/cmd/edit_cmds.rs +++ b/crates/engine/src/cmd/edit_cmds.rs @@ -1267,8 +1267,8 @@ pub fn specs() -> Vec { 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)), + 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)), spec!("layer.mergeVisible", "Merge Visible", &["Layer"], Some("Cmd+Shift+E"), "{}", has_doc, |s, _| merge_visible(s)), spec!("image.autoTone", "Auto Tone", &["Image"], Some("Cmd+Shift+L"), "{}", has_pixels, |s, _| auto_adjust(s, "tone")), spec!("image.autoContrast", "Auto Contrast", &["Image"], Some("Cmd+Alt+Shift+L"), "{}", has_pixels, |s, _| auto_adjust(s, "contrast")), diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu.rs b/crates/ui-egui/src/canvas/canvas_tool_menu.rs index 90d16e45e8..57942de036 100644 --- a/crates/ui-egui/src/canvas/canvas_tool_menu.rs +++ b/crates/ui-egui/src/canvas/canvas_tool_menu.rs @@ -402,8 +402,8 @@ pub const SELECTION_MENU: &[Row] = &[ Some(("Save Selection…", "select.saveSelection")), Some(("Make Work Path…", "select.toWorkPath")), None, - Some(("Layer via Copy", "layer.new.layerViaCopy")), - Some(("Layer via Cut", "layer.new.layerViaCut")), + Some(("Layer Via Copy", "layer.new.layerViaCopy")), + Some(("Layer Via Cut", "layer.new.layerViaCut")), Some(("New Layer…", "layer.new.layer")), None, Some(("Free Transform", "edit.freeTransform")), diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs b/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs index 0ca26a96e6..266e841023 100644 --- a/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs +++ b/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs @@ -399,8 +399,8 @@ fn selection_menus_follow_photoshop_order() { Some("Save Selection…"), Some("Make Work Path…"), None, - Some("Layer via Copy"), - Some("Layer via Cut"), + Some("Layer Via Copy"), + Some("Layer Via Cut"), Some("New Layer…"), None, Some("Free Transform"), diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index c94ef593b5..b6482560e1 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -325,8 +325,8 @@ Artboard… لوح التصميم… Artboard from Group… لوح تصميم من مجموعة… Artboard from Layers… لوح تصميم من طبقات… - Layer via Copy طبقة عبر النسخ - Layer via Cut طبقة عبر القص + Layer Via Copy طبقة عبر النسخ + Layer Via Cut طبقة عبر القص Layer From Background… طبقة من الخلفية… Layer from Background… طبقة من الخلفية… Background from Layer خلفية من طبقة diff --git a/crates/ui-egui/src/i18n/az.tsv b/crates/ui-egui/src/i18n/az.tsv index 5fa57f8379..7b8822c982 100644 --- a/crates/ui-egui/src/i18n/az.tsv +++ b/crates/ui-egui/src/i18n/az.tsv @@ -148,8 +148,8 @@ Duotone… İki ton… Duotone Duoton Trap… Rəng örtüşməsi… - Layer via Copy Kopya vasitəsilə qat - Layer via Cut Kəsərək qat yarat + Layer Via Copy Kopya vasitəsilə qat + Layer Via Cut Kəsərək qat yarat Duplicate Layer… Dublikat qat… Duplicate… Dublikat yarat… Delete Sil diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 9a5926f9a0..27c056d5e3 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -297,8 +297,8 @@ Artboard… Radna ploča… Artboard from Group… Radna ploča iz grupe… Artboard from Layers… Radna ploča iz slojeva… - Layer via Copy Sloj kopiranjem - Layer via Cut Sloj izrezivanjem + Layer Via Copy Sloj kopiranjem + Layer Via Cut Sloj izrezivanjem Layer From Background… Sloj iz pozadine… Layer from Background… Sloj iz pozadine… Background From Layer Pozadina iz sloja diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 7983c59648..66e47bb783 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -448,8 +448,8 @@ Artboard… Kreslicí plocha… Artboard from Group… Kreslicí plocha ze skupiny… Artboard from Layers… Kreslicí plocha z vrstev… - Layer via Copy Vrstva kopírováním - Layer via Cut Vrstva vyjmutím + Layer Via Copy Vrstva kopírováním + Layer Via Cut Vrstva vyjmutím Layer From Background… Vrstva z pozadí… Layer from Background… Vrstva z pozadí… Background from Layer Pozadí z vrstvy diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index bf3859e2fc..16eeb67b88 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -185,8 +185,8 @@ Trap… Trap… # Layer - Layer via Copy Lag via kopiering - Layer via Cut Lag via udklipning + Layer Via Copy Lag via kopiering + Layer Via Cut Lag via udklipning Duplicate Layer… Duplikér lag… Duplicate… Duplikér… Delete Slet diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 6f647bd057..814227eb5a 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -419,8 +419,8 @@ Artboard… Zeichenbereich… Artboard from Group… Zeichenbereich aus Gruppe… Artboard from Layers… Zeichenbereich aus Ebenen… - Layer via Copy Ebene durch Kopieren - Layer via Cut Ebene durch Ausschneiden + Layer Via Copy Ebene durch Kopieren + Layer Via Cut Ebene durch Ausschneiden Layer From Background… Ebene aus Hintergrund… Layer from Background… Ebene aus Hintergrund… Background from Layer Hintergrund aus Ebene diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 4db94496ef..8fd84deab9 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1927,8 +1927,8 @@ Group… Ομάδα… Artboard… Πίνακας σχεδίασης… Artboard from Group… Πίνακας σχεδίασης από ομάδα… - Layer via Copy Επίπεδο μέσω αντιγραφής - Layer via Cut Επίπεδο μέσω αποκοπής + Layer Via Copy Επίπεδο μέσω αντιγραφής + Layer Via Cut Επίπεδο μέσω αποκοπής Layer From Background… Επίπεδο από φόντο… Layer from Background… Επίπεδο από φόντο… Background From Layer Φόντο από επίπεδο diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 5e73e9a447..75b16e771c 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -385,8 +385,8 @@ Artboard… Mesa de trabajo… Artboard from Group… Mesa de trabajo a partir de grupo… Artboard from Layers… Mesa de trabajo a partir de capas… - Layer via Copy Capa vía copiar - Layer via Cut Capa vía cortar + Layer Via Copy Capa vía copiar + Layer Via Cut Capa vía cortar Layer From Background… Capa a partir del fondo… Layer from Background… Capa a partir del fondo… Background from Layer Fondo a partir de capa diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 31bc192002..9d67d3414f 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -186,8 +186,8 @@ Trap… Trappaus… # Layer - Layer via Copy Taso kopioimalla - Layer via Cut Taso leikkaamalla + Layer Via Copy Taso kopioimalla + Layer Via Cut Taso leikkaamalla Duplicate Layer… Monista taso… Duplicate… Monista… Delete Poista diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index d95999ab6c..439781ff10 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -410,8 +410,8 @@ Artboard… Plan de travail… Artboard from Group… Plan de travail d'après un groupe… Artboard from Layers… Plan de travail d'après des calques… - Layer via Copy Calque par copie - Layer via Cut Calque par coupe + Layer Via Copy Calque par copie + Layer Via Cut Calque par coupe Layer From Background… Calque d'après l'arrière-plan… Layer from Background… Calque d'après l'arrière-plan… Background From Layer Arrière-plan d'après le calque diff --git a/crates/ui-egui/src/i18n/he.tsv b/crates/ui-egui/src/i18n/he.tsv index 62b4c04134..c8f682bf39 100644 --- a/crates/ui-egui/src/i18n/he.tsv +++ b/crates/ui-egui/src/i18n/he.tsv @@ -311,8 +311,8 @@ Artboard… לוח ציור… Artboard from Group… לוח ציור מקבוצה… Artboard from Layers… לוח ציור משכבות… - Layer via Copy שכבה דרך העתקה - Layer via Cut שכבה דרך גזירה + Layer Via Copy שכבה דרך העתקה + Layer Via Cut שכבה דרך גזירה Layer From Background… שכבה מרקע… Layer from Background… שכבה מרקע… Background from Layer רקע משכבה diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 3418634dcc..bc4edfa981 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -409,8 +409,8 @@ Layer Content Options… Rétegtartalom beállításai… Layer from Background… Réteg a háttérből… Layer Style Rétegstílus - Layer via Copy Réteg másolással - Layer via Cut Réteg kivágással + Layer Via Copy Réteg másolással + Layer Via Cut Réteg kivágással Layer… Réteg… Left Edges Bal szélek Link Layer Mask Rétegmaszk összekapcsolása diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index c2fe0ba04d..e35857c4bb 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -403,8 +403,8 @@ Artboard… Artboard… Artboard from Group… Artboard dari Grup… Artboard from Layers… Artboard dari Layer… - Layer via Copy Layer via Salin - Layer via Cut Layer via Potong + Layer Via Copy Layer via Salin + Layer Via Cut Layer via Potong Layer From Background… Layer dari Background… Layer from Background… Layer dari Background… Background From Layer Background dari Layer diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 1596245155..0d48264996 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -431,8 +431,8 @@ Artboard… Tavola da disegno… Artboard from Group… Tavola da gruppo… Artboard from Layers… Tavola da livelli… - Layer via Copy Livello tramite copia - Layer via Cut Livello tramite taglia + Layer Via Copy Livello tramite copia + Layer Via Cut Livello tramite taglia Layer From Background… Livello dallo sfondo… Layer from Background… Livello dallo sfondo… Background from Layer Sfondo da livello diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index a65fdad599..9c19da7297 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -441,8 +441,8 @@ Artboard… アートボード… Artboard from Group… グループからアートボード… Artboard from Layers… レイヤーからアートボード… - Layer via Copy コピーしたレイヤー - Layer via Cut 切り取ったレイヤー + Layer Via Copy コピーしたレイヤー + Layer Via Cut 切り取ったレイヤー Layer From Background… 背景からレイヤーへ… Layer from Background… 背景からレイヤーへ… Background From Layer レイヤーから背景 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index bec93b9ce4..c5085bc9f7 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -276,8 +276,8 @@ Artboard… სამუშაო დაფა… Artboard from Group… სამუშაო დაფა ჯგუფიდან… Artboard from Layers… სამუშაო დაფა შრეებიდან… - Layer via Copy შრე კოპირებით - Layer via Cut შრე ამოჭრით + Layer Via Copy შრე კოპირებით + Layer Via Cut შრე ამოჭრით Layer From Background… შრე ფონიდან… Layer from Background… შრე ფონიდან… Background From Layer ფონი შრიდან diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 41556271f1..d5c1a6a3d0 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -407,8 +407,8 @@ Artboard… 아트보드… Artboard from Group… 그룹으로 아트보드 만들기… Artboard from Layers… 레이어로 아트보드 만들기… - Layer via Copy 복사하여 레이어 만들기 - Layer via Cut 잘라내어 레이어 만들기 + Layer Via Copy 복사하여 레이어 만들기 + Layer Via Cut 잘라내어 레이어 만들기 Layer From Background… 배경에서 레이어 만들기… Layer from Background… 배경에서 레이어 만들기… Background from Layer 레이어에서 배경 만들기 diff --git a/crates/ui-egui/src/i18n/nb.tsv b/crates/ui-egui/src/i18n/nb.tsv index 8898daad0e..6124c806fe 100644 --- a/crates/ui-egui/src/i18n/nb.tsv +++ b/crates/ui-egui/src/i18n/nb.tsv @@ -185,8 +185,8 @@ Trap… Overlapping… # Layer - Layer via Copy Lag via kopiering - Layer via Cut Lag via utklipp + Layer Via Copy Lag via kopiering + Layer Via Cut Lag via utklipp Duplicate Layer… Dupliser lag… Duplicate… Dupliser… Delete Slett diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 1ddceec38c..805c7d92d1 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -373,8 +373,8 @@ Artboard… Tekengebied… Artboard from Group… Tekengebied uit groep… Artboard from Layers… Tekengebied uit lagen… - Layer via Copy Laag via kopiëren - Layer via Cut Laag via knippen + Layer Via Copy Laag via kopiëren + Layer Via Cut Laag via knippen Layer From Background… Laag uit achtergrond… Layer from Background… Laag uit achtergrond… Background From Layer Achtergrond uit laag diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index de7d77e8de..4e34bf6a7d 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -416,8 +416,8 @@ Artboard… Obszar roboczy… Artboard from Group… Obszar roboczy z grupy… Artboard from Layers… Obszar roboczy z warstw… - Layer via Copy Warstwa przez kopiowanie - Layer via Cut Warstwa przez wycięcie + Layer Via Copy Warstwa przez kopiowanie + Layer Via Cut Warstwa przez wycięcie Layer From Background… Warstwa z tła… Layer from Background… Warstwa z tła… Background From Layer Tło z warstwy diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index c37ab2c42e..17412673c9 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -406,8 +406,8 @@ Artboard… Prancheta… Artboard from Group… Prancheta a partir de grupo… Artboard from Layers… Prancheta a partir de camadas… - Layer via Copy Camada via cópia - Layer via Cut Camada via recorte + Layer Via Copy Camada via cópia + Layer Via Cut Camada via recorte Layer From Background… Camada a partir do plano de fundo… Layer from Background… Camada a partir do plano de fundo… Background From Layer Plano de fundo a partir da camada diff --git a/crates/ui-egui/src/i18n/ro.tsv b/crates/ui-egui/src/i18n/ro.tsv index 57812b13f3..57539882ab 100644 --- a/crates/ui-egui/src/i18n/ro.tsv +++ b/crates/ui-egui/src/i18n/ro.tsv @@ -353,8 +353,8 @@ Artboard… Planșă de lucru… Artboard from Group… Planșă de lucru din grup… Artboard from Layers… Planșă de lucru din straturi… - Layer via Copy Strat prin copiere - Layer via Cut Strat prin decupare + Layer Via Copy Strat prin copiere + Layer Via Cut Strat prin decupare Layer From Background… Strat din fundal… Layer from Background… Strat din fundal… Background from Layer Fundal din strat diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 55975759f3..10eea079f6 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -365,8 +365,8 @@ Duotone… Дуотон… Duotone Дуотон Trap… Приводка… - Layer via Copy Скопировать на новый слой - Layer via Cut Вырезать на новый слой + Layer Via Copy Скопировать на новый слой + Layer Via Cut Вырезать на новый слой Duplicate Layer… Дублировать слой… Duplicate… Дублировать… Delete Удалить diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index fc4c0aaf2c..af83474e79 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -431,8 +431,8 @@ Artboard… Radna tabla… Artboard from Group… Radna tabla od grupe… Artboard from Layers… Radna tabla od slojeva… - Layer via Copy Sloj kopiranjem - Layer via Cut Sloj isecanjem + Layer Via Copy Sloj kopiranjem + Layer Via Cut Sloj isecanjem Layer From Background… Sloj od pozadine… Layer from Background… Sloj od pozadine… Background From Layer Pozadina od sloja diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 5e301cc811..105b818ae7 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -183,8 +183,8 @@ Trap… Överfyllning… # Layer - Layer via Copy Lager via kopiering - Layer via Cut Lager via urklippning + Layer Via Copy Lager via kopiering + Layer Via Cut Lager via urklippning Duplicate Layer… Duplicera lager… Duplicate… Duplicera… Delete Ta bort diff --git a/crates/ui-egui/src/i18n/ta.tsv b/crates/ui-egui/src/i18n/ta.tsv index c8269c4cbb..8131977605 100644 --- a/crates/ui-egui/src/i18n/ta.tsv +++ b/crates/ui-egui/src/i18n/ta.tsv @@ -305,8 +305,8 @@ Artboard… ஆர்ட்போர்டு… Artboard from Group… குழுவிலிருந்து ஆர்ட்போர்டு… Artboard from Layers… அடுக்குகளிலிருந்து ஆர்ட்போர்டு… - Layer via Copy நகல் வழியாக அடுக்கு - Layer via Cut வெட்டு வழியாக அடுக்கு + Layer Via Copy நகல் வழியாக அடுக்கு + Layer Via Cut வெட்டு வழியாக அடுக்கு Layer From Background… பின்னணியிலிருந்து அடுக்கு… Layer from Background… பின்னணியிலிருந்து அடுக்கு… Background from Layer அடுக்கிலிருந்து பின்னணி diff --git a/crates/ui-egui/src/i18n/th.tsv b/crates/ui-egui/src/i18n/th.tsv index 8340165370..83f23d4fc1 100644 --- a/crates/ui-egui/src/i18n/th.tsv +++ b/crates/ui-egui/src/i18n/th.tsv @@ -176,8 +176,8 @@ Trap… กับดัก… # Layer - Layer via Copy เลเยอร์ผ่านการคัดลอก - Layer via Cut เลเยอร์ผ่านการตัด + Layer Via Copy เลเยอร์ผ่านการคัดลอก + Layer Via Cut เลเยอร์ผ่านการตัด Duplicate Layer… ทำซ้ำเลเยอร์… Duplicate… ทำสำเนา… Delete ลบ diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index e6e68a624e..6bcf9a3e8c 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -339,8 +339,8 @@ Artboard… Çalışma Yüzeyi… Artboard from Group… Gruptan Çalışma Yüzeyi… Artboard from Layers… Katmanlardan Çalışma Yüzeyi… - Layer via Copy Kopyalama Yoluyla Katman - Layer via Cut Kesim Yoluyla Katman + Layer Via Copy Kopyalama Yoluyla Katman + Layer Via Cut Kesim Yoluyla Katman Layer From Background… Arka Plandan Katman… Layer from Background… Arka Plandan Katman… Background From Layer Katmandan Arka Plan diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 49e3cb976f..4b57f9c8f0 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -348,8 +348,8 @@ Duotone… Дуплекс… Duotone Дуплекс Trap… Трепінг… - Layer via Copy Шар через копіювання - Layer via Cut Шар через вирізання + Layer Via Copy Шар через копіювання + Layer Via Cut Шар через вирізання Duplicate Layer… Дублювати шар… Duplicate… Дублювати… Delete Видалити diff --git a/crates/ui-egui/src/i18n/ur.tsv b/crates/ui-egui/src/i18n/ur.tsv index 5812a8053e..a6cadc2114 100644 --- a/crates/ui-egui/src/i18n/ur.tsv +++ b/crates/ui-egui/src/i18n/ur.tsv @@ -311,8 +311,8 @@ Artboard… آرٹ بورڈ… Artboard from Group… گروپ سے آرٹ بورڈ… Artboard from Layers… تہوں سے آرٹ بورڈ… - Layer via Copy کاپی کے ذریعے تہہ - Layer via Cut کٹ کے ذریعے تہہ + Layer Via Copy کاپی کے ذریعے تہہ + Layer Via Cut کٹ کے ذریعے تہہ Layer From Background… پس منظر سے تہہ… Layer from Background… پس منظر سے تہہ… Background from Layer تہہ سے پس منظر diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 94389f5482..0651f9000b 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -458,8 +458,8 @@ Layer from Background… Lớp từ nền… Layer Mask Mặt nạ lớp Layer Style Kiểu lớp - Layer via Copy Lớp bằng sao chép - Layer via Cut Lớp bằng cắt + Layer Via Copy Lớp bằng sao chép + Layer Via Cut Lớp bằng cắt Layer… Lớp… Left Edges Cạnh trái Levels… Mức… diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index afa93fddef..3d8f8659f2 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -423,8 +423,8 @@ Artboard… 画板… Artboard from Group… 从组创建画板… Artboard from Layers… 从图层创建画板… - Layer via Copy 通过复制创建图层 - Layer via Cut 通过剪切创建图层 + Layer Via Copy 通过复制创建图层 + Layer Via Cut 通过剪切创建图层 Layer From Background… 从背景创建图层… Layer from Background… 从背景创建图层… Background From Layer 从图层创建背景 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index af4fd8e9d2..3eb32cc6ef 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -422,8 +422,8 @@ Artboard… 工作區域… Artboard from Group… 從群組建立工作區域… Artboard from Layers… 從圖層建立工作區域… - Layer via Copy 拷貝的圖層 - Layer via Cut 剪下的圖層 + Layer Via Copy 拷貝的圖層 + Layer Via Cut 剪下的圖層 Layer From Background… 從背景建立圖層… Layer from Background… 從背景建立圖層… Background from Layer 從圖層建立背景 diff --git a/crates/ui-egui/src/layer_via_label_tests.rs b/crates/ui-egui/src/layer_via_label_tests.rs new file mode 100644 index 0000000000..b8a20c3b27 --- /dev/null +++ b/crates/ui-egui/src/layer_via_label_tests.rs @@ -0,0 +1,63 @@ +//! "Layer Via Copy" and "Layer Via Cut" carry a capital V in every menu, as in the reference +//! application (#12357), and every translation moved with the key. + +use crate::canvas::canvas_tool_menu::SELECTION_MENU; +use crate::menu_catalog::CATALOG; + +const NEW: [(&str, &str); 2] = [("Layer Via Copy", "layer.new.layerViaCopy"), ("Layer Via Cut", "layer.new.layerViaCut")]; +const OLD: [&str; 2] = ["Layer via Copy", "Layer via Cut"]; + +#[test] +fn menu_bar_rows_use_the_capital_v() { + for (label, id) in NEW { + let row = CATALOG.iter().find(|r| r.3 == id).expect("catalog row"); + assert_eq!(row.1, label); + } + assert!(CATALOG.iter().all(|r| !OLD.contains(&r.1))); +} + +#[test] +fn command_labels_use_the_capital_v() { + let specs = photocraft_engine::cmd::command_specs(); + for (label, id) in NEW { + let spec = specs.iter().find(|s| s.id == id).expect("command spec"); + assert_eq!(spec.label, label); + } +} + +#[test] +fn selection_tools_canvas_menu_uses_the_capital_v() { + for (label, id) in NEW { + assert!(SELECTION_MENU.iter().flatten().any(|r| *r == (label, id)), "{label} row"); + } + assert!(SELECTION_MENU.iter().flatten().all(|r| !OLD.contains(&r.0))); +} + +#[test] +fn every_locale_file_keeps_its_translation_under_the_new_keys() { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/i18n"); + let mut checked = 0; + for entry in std::fs::read_dir(dir).expect("i18n dir") { + let path = entry.expect("entry").path(); + if path.extension().and_then(|e| e.to_str()) != Some("tsv") { + continue; + } + let text = std::fs::read_to_string(&path).expect("tsv"); + let keys: Vec<&str> = text.lines().filter_map(|l| l.split('\t').nth(1)).collect(); + let name = path.display(); + assert!(OLD.iter().all(|o| !keys.contains(o)), "{name} still has an old key"); + // Hindi has no entries for these menus yet; every other file has both. + if keys.contains(&"Layer Via Copy") || keys.contains(&"Layer Via Cut") { + assert!(NEW.iter().all(|(n, _)| keys.contains(n)), "{name} lacks a new key"); + checked += 1; + } + } + assert!(checked >= 30, "only {checked} locale files carry the keys"); +} + +#[test] +fn a_translation_is_found_under_the_new_key() { + let de = crate::i18n::Lang::from_code("de").expect("German"); + assert_eq!(crate::i18n::tr(de, "Layer Via Copy"), "Ebene durch Kopieren"); + assert_eq!(crate::i18n::tr(de, "Layer Via Cut"), "Ebene durch Ausschneiden"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a5e84a6d87..8416644b02 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -907,6 +907,9 @@ mod color_tab_theme_tests; #[cfg(test)] mod new_layer_button_tests; +#[cfg(test)] +mod layer_via_label_tests; + #[cfg(test)] mod smart_filter_rows_tests; diff --git a/crates/ui-egui/src/menu_catalog.rs b/crates/ui-egui/src/menu_catalog.rs index 3838a70bcf..02b3bdc021 100644 --- a/crates/ui-egui/src/menu_catalog.rs +++ b/crates/ui-egui/src/menu_catalog.rs @@ -256,8 +256,8 @@ pub static CATALOG: &[(&[&str], &str, Option<&str>, &str)] = &[ (&["Layer", "New"], "Artboard from Layers…", None, "layer.new.artboardFromLayers"), (&["Layer", "New"], "Frame from Layers", None, "layer.new.frameFromLayers"), (&["Layer", "New"], "---", None, "---"), - (&["Layer", "New"], "Layer via Copy", Some("Cmd+J"), "layer.new.layerViaCopy"), - (&["Layer", "New"], "Layer via Cut", Some("Cmd+Shift+J"), "layer.new.layerViaCut"), + (&["Layer", "New"], "Layer Via Copy", Some("Cmd+J"), "layer.new.layerViaCopy"), + (&["Layer", "New"], "Layer Via Cut", Some("Cmd+Shift+J"), "layer.new.layerViaCut"), (&["Layer"], "Duplicate Layer…", None, "layer.duplicate"), (&["Layer", "Delete"], "Layer", None, "layer.delete"), (&["Layer", "Delete"], "Hidden Layers", None, "layer.delete.hiddenLayers"), From 5ae31d3de2bf945332e5777789cc759eba8fbdf9 Mon Sep 17 00:00:00 2001 From: FredApps Date: Sun, 8 Nov 2026 16:58:06 +0100 Subject: [PATCH 214/219] fix(pdf): preserve editable import fidelity and bound caches --- crates/io/src/pdf_editable.rs | 238 ++++++++++++++++++++++++++++------ 1 file changed, 201 insertions(+), 37 deletions(-) diff --git a/crates/io/src/pdf_editable.rs b/crates/io/src/pdf_editable.rs index d5e5d52066..af56f64e21 100644 --- a/crates/io/src/pdf_editable.rs +++ b/crates/io/src/pdf_editable.rs @@ -34,15 +34,19 @@ use photocraft_color::{Color, ColorMode, PixelFormat, SampleType}; use photocraft_doc::text::{CharStyle, TextRun}; use photocraft_doc::vector::{FillRule, Knot, LineCap, LineJoin, Path, PathOp, ShapeLayer, ShapeStroke, StrokeAlign, Subpath}; use photocraft_doc::{Fill, Layer, LayerContent, TextLayer}; -use photocraft_geom::{Affine, Point, Rect}; +use photocraft_geom::{Affine, Point, Rect, TILE_SIZE}; use photocraft_raster::Interrupt; /// Most layers one editable page may create; past it the page falls back to raster. const MAX_LAYERS: usize = 20_000; /// Most path knots one editable page may hold; past it the page falls back to raster. const MAX_KNOTS: usize = 1_000_000; -/// Pixel bytes of shape caches one editable page may retain before the rest render on demand. +/// Allocated tile bytes of all shape and text caches retained by one editable page. const MAX_CACHE_BYTES: u64 = 256 << 20; +/// Bound text collection before building its layers and caches, including repeated overprinting. +const MAX_GLYPHS: usize = 100_000; +/// The strings in collected glyph records must also fit, not just their record count. +const MAX_GLYPH_BYTES: usize = 16 << 20; /// Cap on fill/stroke glyph comparisons so hostile pages cannot make pairing quadratic forever. const MAX_PAIR_CHECKS: usize = 1_000_000; /// How often the watcher copies the interrupt into craft-pdf's cancellation flag. The Device's @@ -104,6 +108,7 @@ pub fn page(page: &Page<'_>, rect: Rect, ctl: &Interrupt) -> Result, ctl: *ctl, layers: Vec::new(), glyphs: Vec::new(), + glyph_bytes: 0, strokes: Vec::new(), fonts: Vec::new(), font_keys: HashMap::new(), @@ -157,8 +162,12 @@ pub fn page(page: &Page<'_>, rect: Rect, ctl: &Interrupt) -> Result, } pair_fake_bold(&mut device.glyphs, device.strokes, ctl)?; let families = register_fonts(&device.fonts)?; - let mut layers = merge_text_layers(device.layers, text_layers(&device.glyphs, &families)); - render_text_caches(&mut layers, device.dpi, device.fmt, ctl)?; + let texts = text_layers(&device.glyphs, &families, device.dpi, ctl)?; + if device.layers.len().saturating_add(texts.len()) > MAX_LAYERS { + return Err(EditableError::Unsupported("too many objects")); + } + let mut layers = merge_text_layers(device.layers, texts); + render_text_caches(&mut layers, device.dpi, device.fmt, ctl, &mut device.cached)?; Ok(layers) } @@ -168,6 +177,9 @@ fn pair_fake_bold(glyphs: &mut [GlyphRun], strokes: Vec, ctl: &Interru // against candidates at its own paint order/font/text instead of the whole page. let mut index: HashMap<(usize, u128, u64), Vec> = HashMap::new(); for (at, glyph) in glyphs.iter().enumerate() { + if at.is_multiple_of(256) && ctl.check().is_err() { + return Err(EditableError::Cancelled); + } index.entry((glyph.order, glyph.font.key, text_hash(&glyph.text))).or_default().push(at); } let mut paired = vec![false; glyphs.len()]; @@ -188,7 +200,11 @@ fn pair_fake_bold(glyphs: &mut [GlyphRun], strokes: Vec, ctl: &Interru checks += 1; let glyph = &glyphs[at]; let tolerance = 0.01 * glyph.size_px + 0.01; - if glyph.text == stroke.text && (glyph.origin.x - stroke.origin.x).abs() <= tolerance && (glyph.origin.y - stroke.origin.y).abs() <= tolerance { + if glyph.text == stroke.text + && same_style(glyph, &stroke) + && (glyph.origin.x - stroke.origin.x).abs() <= tolerance + && (glyph.origin.y - stroke.origin.y).abs() <= tolerance + { found = Some(at); break; } @@ -212,10 +228,19 @@ fn text_hash(text: &str) -> u64 { /// Builds one point-text layer per line and style run, with the layer index it must be inserted /// at to preserve paint order relative to paths. -fn text_layers(glyphs: &[GlyphRun], families: &std::collections::HashMap) -> Vec<(usize, Layer)> { +fn text_layers( + glyphs: &[GlyphRun], + families: &std::collections::HashMap, + dpi: f64, + ctl: &Interrupt, +) -> Result, EditableError> { let mut out = Vec::new(); let mut i = 0; while i < glyphs.len() { + ctl.check().map_err(|_| EditableError::Cancelled)?; + if out.len() >= MAX_LAYERS { + return Err(EditableError::Unsupported("too many text objects")); + } let first = &glyphs[i]; let family = families.get(&first.font.key).cloned().unwrap_or_else(|| first.font.family.clone()); let (dx, dy) = (first.angle.cos(), first.angle.sin()); @@ -223,6 +248,7 @@ fn text_layers(glyphs: &[GlyphRun], families: &std::collections::HashMap = layout.clusters.iter().map(|c| (c.range.start, c)).collect(); + for (offset, expected) in positions { + let cluster = clusters.get(&offset); + let actual = + cluster.and_then(|c| layout.lines.get(c.line).map(|line| text.transform.apply(Point::new(f64::from(c.x), f64::from(line.baseline))))); + if actual.is_none_or(|at| (at.x - expected.x).abs() > 0.5 || (at.y - expected.y).abs() > 0.5) { + return Err(EditableError::Unsupported("text positioning")); + } + } + } + out.push((first.order, layer)); } if i == start { i += 1; } } - out + Ok(out) } /// Whether `b` belongs to the same line and style run as `a`. @@ -290,6 +334,8 @@ fn text_layer(text: String, run: &GlyphRun, number: usize, family: &str) -> Laye weight: u16::try_from(weight).unwrap_or(400), italic: run.font.italic, faux_bold: run.fake_bold, + // PDF positions already contain kerning; applying font kerning again moves glyphs. + kerning: photocraft_doc::text::Kerning::Off, size_pt: run.size_pt as f32, color: run.color, ..CharStyle::default() @@ -340,7 +386,7 @@ fn register_fonts(fonts: &[(u128, String, String, FontData)]) -> Result Result<(), EditableError> { +fn render_text_caches(layers: &mut [Layer], dpi: f64, fmt: PixelFormat, ctl: &Interrupt, cached: &mut u64) -> Result<(), EditableError> { let shared = photocraft_text::shared(); for layer in layers { if ctl.check().is_err() { @@ -352,7 +398,12 @@ fn render_text_caches(layers: &mut [Layer], dpi: f64, fmt: PixelFormat, ctl: &In // Re-lock per layer so a text-heavy import does not hold the UI's engine for the // whole cache-render loop. let mut engine = shared.lock().unwrap_or_else(|error| error.into_inner()); - let rect = engine.render_layer(text, dpi as f32, fmt); + let layout = engine.layout(text, dpi as f32); + let area = photocraft_text::render::ink_rect(&layout, &text.transform); + admit_cache(cached, area, fmt)?; + let rendered = photocraft_text::render::rasterize(&layout, &text.transform, fmt, text.antialias); + let rect = rendered.rect; + text.cache = Some(rendered.surface); if rect.width() == 0 || rect.height() == 0 { return Err(EditableError::Unsupported("a text cache could not be rendered")); } @@ -361,6 +412,17 @@ fn render_text_caches(layers: &mut [Layer], dpi: f64, fmt: PixelFormat, ctl: &In Ok(()) } +/// Reserves complete tiles before rendering: even one painted pixel allocates a whole tile. +fn admit_cache(cached: &mut u64, area: Rect, fmt: PixelFormat) -> Result<(), EditableError> { + let bytes = area.tile_count().saturating_mul(TILE_SIZE as u64).saturating_mul(TILE_SIZE as u64).saturating_mul(fmt.bytes_per_pixel() as u64); + let total = cached.saturating_add(bytes); + if total > MAX_CACHE_BYTES { + return Err(EditableError::Unsupported("page cache budget")); + } + *cached = total; + Ok(()) +} + /// Puts text layers back at the path index where their glyphs were painted. fn merge_text_layers(layers: Vec, mut texts: Vec<(usize, Layer)>) -> Vec { texts.sort_by_key(|(order, _)| *order); @@ -411,6 +473,7 @@ struct PathDevice<'a> { layers: Vec, /// Filled glyphs in content order. glyphs: Vec, + glyph_bytes: usize, /// Stroked glyphs, paired with a fill at the same place to recognise fake bold. strokes: Vec, /// Embedded fonts met on the page: cache key, PostScript name, derived family and bytes. @@ -424,7 +487,7 @@ struct PathDevice<'a> { /// One entry per clip pushed by the interpreter: `true` for an accepted no-op clip. clips: Vec, knots: usize, - /// Bytes of shape caches retained so far. + /// Allocated tile bytes of caches retained so far. cached: u64, unsupported: Option<&'static str>, cancelled: bool, @@ -501,19 +564,17 @@ impl PathDevice<'_> { self.unsupported("too many objects"); return; } - let reach = stroke.as_ref().map_or(0.0, |stroke| f64::from(stroke.width)); - let b = path_bounds(&path); - let area = Rect::new((b.0 - reach).floor() as i32, (b.1 - reach).floor() as i32, (b.2 + reach).ceil() as i32, (b.3 + reach).ceil() as i32) - .intersect(&self.clip); - let bytes = u64::from(area.width()).saturating_mul(u64::from(area.height())).saturating_mul(self.fmt.bytes_per_pixel() as u64); let mut shape = ShapeLayer { path, fill, stroke, ..ShapeLayer::default() }; + // Use the renderer's actual ink bounds, including miter tips, and reuse the compilation. + // Control-point bounds expanded by one stroke width can clip acute joins. + let compiled = photocraft_vector::CompiledShape::new(&shape, photocraft_vector::DEFAULT_TOLERANCE, self.clip); + let area = compiled.bounds().unwrap_or(Rect::EMPTY).intersect(&self.clip); if area.width() > 0 && area.height() > 0 { - if self.cached.saturating_add(bytes) > MAX_CACHE_BYTES { - self.unsupported("shape cache budget"); + if let Err(EditableError::Unsupported(reason)) = admit_cache(&mut self.cached, area, self.fmt) { + self.unsupported(reason); return; } - shape.cache = Some(photocraft_vector::render_shape(&shape, self.fmt, area)); - self.cached = self.cached.saturating_add(bytes); + shape.cache = Some(compiled.render(self.fmt, area)); } self.layers.push(Layer::new(format!("Path {}", self.layers.len() + 1), LayerContent::Shape(shape))); } @@ -619,6 +680,10 @@ impl<'a> Device<'a> for PathDevice<'_> { self.unsupported("text used as a clipping path"); return; } + if self.glyphs.len().saturating_add(self.strokes.len()) >= MAX_GLYPHS { + self.unsupported("too many glyphs"); + return; + } let text = match glyph.as_unicode() { Some(craft_pdf::interpret::hayro_cmap::BfString::Char(c)) => c.to_string(), Some(craft_pdf::interpret::hayro_cmap::BfString::String(s)) => s, @@ -670,6 +735,18 @@ impl<'a> Device<'a> for PathDevice<'_> { self.unsupported("skewed or mirrored text"); return; } + if let GlyphDrawMode::Stroke(stroke) = draw_mode { + // Only a thin, solid, same-colour outline can be approximated by faux bold. + // Colour equality is checked against the fill in pair_fake_bold. + if !stroke.line_width.is_finite() + || stroke.line_width < 0.0 + || f64::from(stroke.line_width) * transform.determinant().abs().sqrt() > size_pt * 0.05 + || !stroke.dash_array.is_empty() + { + self.unsupported("stroked text geometry"); + return; + } + } let origin = self.doc.apply(Point::new(e, f)); let size_px = size_pt * self.doc.determinant().abs().sqrt(); let angle = b.atan2(a); @@ -686,6 +763,12 @@ impl<'a> Device<'a> for PathDevice<'_> { if self.seen_fonts.insert(key) { self.fonts.push((key, postscript.clone(), family.clone(), data.data.clone())); } + let bytes = std::mem::size_of::().saturating_add(text.len()).saturating_add(family.len()).saturating_add(postscript.len()); + self.glyph_bytes = self.glyph_bytes.saturating_add(bytes); + if self.glyph_bytes > MAX_GLYPH_BYTES { + self.unsupported("glyph memory budget"); + return; + } let font = FontInfo { key, family, postscript, weight, italic }; let glyph = GlyphRun { text, origin, size_pt, size_px, angle, advance, font, color, fake_bold: false, order: self.layers.len() }; if matches!(draw_mode, GlyphDrawMode::Stroke(_)) { @@ -889,20 +972,6 @@ fn knots_affine(transform: &KurboAffine) -> Affine { Affine { m: [a, b, c, d, e, f] } } -/// The anchor bounds of a path, used only to estimate a shape cache's size. -fn path_bounds(path: &Path) -> (f64, f64, f64, f64) { - let mut b = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); - for knot in path.subpaths.iter().flat_map(|subpath| &subpath.knots) { - for point in [knot.anchor, knot.in_ctrl, knot.out_ctrl] { - b.0 = b.0.min(point.x); - b.1 = b.1.min(point.y); - b.2 = b.2.max(point.x); - b.3 = b.3.max(point.y); - } - } - b -} - /// A Hayro stroke as PhotoCraft's stroke: width in pixels, dashes in multiples of the width. fn to_stroke(stroke: &craft_pdf::interpret::StrokeProps, color: Color, scale: f64) -> Option { if !stroke.line_width.is_finite() || stroke.line_width < 0.0 || !scale.is_finite() || scale <= 0.0 { @@ -1126,7 +1195,11 @@ mod tests { "BaseFont" => Object::Name(name.as_bytes().to_vec()), "FirstChar" => 32, "LastChar" => 126, - "Widths" => Object::Array(vec![Object::Integer(600); 95]), + "Widths" => Object::Array((32..=126u8).map(|c| { + let Ok(face) = ttf_parser::Face::parse(font_bytes, 0) else { return Object::Integer(600); }; + let width = face.glyph_index(char::from(c)).and_then(|id| face.glyph_hor_advance(id)).unwrap_or(0); + Object::Real(f32::from(width) * 1000.0 / f32::from(face.units_per_em())) + }).collect()), "FontDescriptor" => descriptor, "Encoding" => "WinAnsiEncoding", }); @@ -1442,7 +1515,7 @@ mod tests { #[test] fn fill_then_stroke_text_is_fake_bold_not_a_second_run() { - let bytes = text_pdf("BT /F1 24 Tf 2 Tr 1 0 0 rg 20 50 Td (Hi) Tj ET"); + let bytes = text_pdf("BT /F1 24 Tf 2 Tr 1 0 0 rg 1 0 0 RG 20 50 Td (Hi) Tj ET"); let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; let imported = import_with_options_and_interrupt("text.pdf", &bytes, &options, &Interrupt::NONE).expect("an editable text import"); let texts: Vec<_> = children(&imported.document) @@ -1640,4 +1713,95 @@ mod tests { let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; assert!(matches!(import_with_options_and_interrupt("paths.pdf", &bytes, &options, &ctl), Err(IoError::Cancelled))); } + #[test] + fn positioned_pdf_text_is_not_silently_reflowed() { + let bytes = text_pdf("BT /F1 24 Tf 20 50 Td [(H) -900 (i)] TJ ET"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("spacing.pdf", &bytes, &options, &Interrupt::NONE).unwrap(); + assert!(children(&imported.document).iter().all(|l| matches!(l.content, LayerContent::Raster(_))), "unrepresentable spacing must rasterise"); + assert!(imported.warnings.iter().any(|w| w.contains("rasterised"))); + } + + #[test] + fn colored_text_outlines_are_not_fake_bold() { + let bytes = text_pdf("BT /F1 24 Tf 1 0 0 rg 0 0 1 RG 1 w 2 Tr 20 50 Td (Hi) Tj ET"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("outline.pdf", &bytes, &options, &Interrupt::NONE).unwrap(); + assert!(children(&imported.document).iter().all(|l| matches!(l.content, LayerContent::Raster(_))), "a blue outline cannot become red bold text"); + } + + #[test] + fn a_shape_cache_keeps_the_whole_miter() { + let bytes = pdf("10 w 10 M 40 20 m 50 70 l 60 20 l S"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("miter.pdf", &bytes, &options, &Interrupt::NONE).unwrap(); + let layer = children(&imported.document).first().unwrap(); + let LayerContent::Shape(shape) = &layer.content else { panic!("expected an editable stroke") }; + let board = Rect::from_xywh(0, 0, 200, 100); + let expected = photocraft_vector::render_shape(shape, imported.document.pixel_format(), board); + assert!(shape.cache.as_ref().unwrap().read_region(board) == expected.read_region(board), "bounds must include the pointed join"); + } + + #[test] + fn cache_budget_charges_full_tiles_and_preserves_the_limit() { + let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::U8, true); + let tile_bytes = TILE_SIZE as u64 * TILE_SIZE as u64 * fmt.bytes_per_pixel() as u64; + let mut used = MAX_CACHE_BYTES - tile_bytes; + admit_cache(&mut used, Rect::from_xywh(0, 0, 1, 1), fmt).unwrap(); + assert_eq!(used, MAX_CACHE_BYTES); + assert_eq!(admit_cache(&mut used, Rect::from_xywh(0, 0, 1, 1), fmt), Err(EditableError::Unsupported("page cache budget"))); + assert_eq!(used, MAX_CACHE_BYTES); + let mut used = 0; + admit_cache(&mut used, Rect::from_xywh(-1, -1, 2, 2), fmt).unwrap(); + assert_eq!(used, 4 * tile_bytes, "a tiny rectangle may straddle four tiles"); + } + + #[test] + fn text_cache_respects_the_budget_already_used_by_shapes() { + let bytes = text_pdf("BT /F1 24 Tf 20 50 Td (Hi) Tj ET"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("text-budget.pdf", &bytes, &options, &Interrupt::NONE).unwrap(); + let mut text = text_layer(&imported.document).clone(); + text.cache = None; + let mut layers = vec![Layer::new("Text", LayerContent::Text(text))]; + let mut cached = MAX_CACHE_BYTES; + assert_eq!( + render_text_caches(&mut layers, 72.0, imported.document.pixel_format(), &Interrupt::NONE, &mut cached), + Err(EditableError::Unsupported("page cache budget")) + ); + let LayerContent::Text(text) = &layers[0].content else { panic!("text layer") }; + assert!(text.cache.is_none(), "reject before allocating the text cache"); + } + + #[test] + fn multiple_text_caches_share_one_allowance() { + let bytes = text_pdf("BT /F1 24 Tf 20 50 Td (Hi) Tj ET"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("text-budget.pdf", &bytes, &options, &Interrupt::NONE).unwrap(); + let mut text = text_layer(&imported.document).clone(); + text.cache = None; + let fmt = imported.document.pixel_format(); + let layout = photocraft_text::shared().lock().unwrap().layout(&text, 72.0); + let area = photocraft_text::render::ink_rect(&layout, &text.transform); + let mut one_cache = 0; + admit_cache(&mut one_cache, area, fmt).unwrap(); + assert!(one_cache > 0); + let mut layers = vec![Layer::new("First", LayerContent::Text(text.clone())), Layer::new("Second", LayerContent::Text(text))]; + let mut cached = MAX_CACHE_BYTES - one_cache; + assert_eq!(render_text_caches(&mut layers, 72.0, fmt, &Interrupt::NONE, &mut cached), Err(EditableError::Unsupported("page cache budget"))); + assert_eq!(cached, MAX_CACHE_BYTES); + assert!(matches!(&layers[0].content, LayerContent::Text(t) if t.cache.is_some())); + assert!(matches!(&layers[1].content, LayerContent::Text(t) if t.cache.is_none())); + let mut cached = 0; + render_text_caches(&mut layers, 72.0, fmt, &Interrupt::NONE, &mut cached).unwrap(); + assert_eq!(cached, one_cache * 2); + assert!(layers.iter().all(|l| matches!(&l.content, LayerContent::Text(t) if t.cache.is_some()))); + } + #[test] + fn thick_text_outlines_are_not_fake_bold() { + let bytes = text_pdf("BT /F1 24 Tf 1 0 0 rg 1 0 0 RG 5 w 2 Tr 20 50 Td (Hi) Tj ET"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("thick-outline.pdf", &bytes, &options, &Interrupt::NONE).unwrap(); + assert!(children(&imported.document).iter().all(|l| matches!(l.content, LayerContent::Raster(_)))); + } } From dd6e77309f6873e916ca78e76858ae90924c956a Mon Sep 17 00:00:00 2001 From: FredApps Date: Sun, 8 Nov 2026 17:28:55 +0100 Subject: [PATCH 215/219] test(ui): align Hand menu anchor checks with fixed zoom rows --- .../canvas/canvas_tool_menu/zoom_menu_anchor_tests.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu/zoom_menu_anchor_tests.rs b/crates/ui-egui/src/canvas/canvas_tool_menu/zoom_menu_anchor_tests.rs index 5cb270385c..d5039a6257 100644 --- a/crates/ui-egui/src/canvas/canvas_tool_menu/zoom_menu_anchor_tests.rs +++ b/crates/ui-egui/src/canvas/canvas_tool_menu/zoom_menu_anchor_tests.rs @@ -35,7 +35,7 @@ fn zoom_menu_steps_keep_the_view_centre_and_history() { } #[test] -fn clicked_point_preference_and_hand_menu_keep_their_existing_anchors() { +fn clicked_point_zoom_keeps_its_anchor_and_hand_menu_rejects_step_commands() { for (tool, center_on_point) in [(Tool::Zoom, true), (Tool::Hand, true), (Tool::Hand, false)] { for (command, direction) in [("view.zoomIn", 1), ("view.zoomOut", -1)] { let mut app = zoom_app(400, 300); @@ -47,6 +47,14 @@ fn clicked_point_preference_and_hand_menu_keep_their_existing_anchors() { right_click(&mut h, p, Modifiers::NONE); let doc = h.state().ui.canvas_tool_menu.as_ref().unwrap().anchor.unwrap().map(|n| n as f32); let ctx = h.ctx.clone(); + if tool == Tool::Hand { + // #12022 gives Hand only fixed zoom rows. An injected step command must not run. + assert!(!available(h.state(), h.state().ui.canvas_tool_menu.as_ref().unwrap(), command)); + choose(h.state_mut(), &ctx, command); + let v = &h.state().ui.views[0]; + assert_eq!((v.zoom, v.center), (before.zoom, before.center), "Hand must reject {command}"); + continue; + } choose(h.state_mut(), &ctx, command); let v = &h.state().ui.views[0]; assert_eq!(v.zoom, crate::zoom_levels::step(before.zoom, direction, [400, 300])); From b5501b9bd1e63204698d69a430990de5d36d1991 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 8 Nov 2026 18:20:55 +0100 Subject: [PATCH 216/219] PR #12049: keep a held ~ erasing on the Background, keep Clear on a locked one The engine turned every "mode": "clear" stroke on the Background into Normal, so a held ~ painted the foreground there instead of erasing to the background colour as on main. The options bar already turns a chosen Clear off at the press, so the engine conversion was not needed: drop it and keep the one check in clear_off_on_background. A Background with locked pixels refuses the stroke, so a chosen Clear now stays chosen there. The "Tool state machine" doc line is back on tool_event. Co-Authored-By: DuBearte Co-Authored-By: Claude Opus 5.5 --- .../src/brush_clear_background_tests.rs | 22 +++++++++--- crates/engine/src/cmd/brush_cmds.rs | 18 ++-------- .../ui-egui/src/canvas/paint_mouse/tests.rs | 35 +++++++++++++++++++ crates/ui-egui/src/canvas/tool_input.rs | 15 ++++++-- 4 files changed, 66 insertions(+), 24 deletions(-) diff --git a/crates/engine/src/brush_clear_background_tests.rs b/crates/engine/src/brush_clear_background_tests.rs index e786af9dd7..e295e68c0b 100644 --- a/crates/engine/src/brush_clear_background_tests.rs +++ b/crates/engine/src/brush_clear_background_tests.rs @@ -1,5 +1,7 @@ -//! #11897: the Brush's Clear mode on the Background paints as Normal with the foreground colour, -//! not the background colour; on a pixel layer Clear still erases, and the Eraser on the +//! #11897: the Brush's chosen Clear on the Background paints as Normal. The options bar turns +//! Clear off at the press there (`clear_off_on_background` in ui-egui), so the engine never sees +//! it; a `"mode": "clear"` stroke that does reach the Background is a held `~`, which keeps +//! erasing to the background colour. On a pixel layer Clear still erases, and the Eraser on the //! Background still paints the background colour. use crate::Session; @@ -27,17 +29,27 @@ fn close(a: [f32; 4], b: [f32; 4]) -> bool { a.iter().zip(&b).all(|(x, y)| (x - y).abs() <= 0.02) } -/// A hard stroke across (10..30, 10), as the canvas sends it with Clear chosen. +/// A hard stroke across (10..30, 10), as the canvas sends it in Clear mode. fn clear_stroke() -> Value { json!({"points": [[10, 10], [30, 10]], "size": 8, "hardness": 1.0, "erase": true, "mode": "clear"}) } +/// A held `~` on the Background erases to the background colour, as before #11897. #[test] -fn clear_on_the_background_paints_the_foreground() { +fn a_clear_stroke_on_the_background_still_paints_the_background_colour() { let mut s = session(); s.execute("paint.stroke", clear_stroke()).unwrap(); let p = pixel(&mut s, 20, 10); - assert!(close(p, BLACK), "painted {p:?}, not the background colour"); + assert!(close(p, GREEN), "painted {p:?}, not the foreground"); + assert_eq!(s.active().unwrap().history.undo_label(), Some("Brush Tool")); +} + +#[test] +fn a_normal_stroke_on_the_background_paints_the_foreground() { + let mut s = session(); + s.execute("paint.stroke", json!({"points": [[10, 10], [30, 10]], "size": 8, "hardness": 1.0})).unwrap(); + let p = pixel(&mut s, 20, 10); + assert!(close(p, BLACK), "{p:?}"); } #[test] diff --git a/crates/engine/src/cmd/brush_cmds.rs b/crates/engine/src/cmd/brush_cmds.rs index 73fded269f..87a4bcdd41 100644 --- a/crates/engine/src/cmd/brush_cmds.rs +++ b/crates/engine/src/cmd/brush_cmds.rs @@ -424,24 +424,10 @@ fn with_blend_mode(mut b: BrushSettings, p: &Value) -> BrushSettings { b } -/// The Brush's Clear on the Background paints as Normal with its own colour, as in Photoshop -/// (#11897): the Background has no transparency to clear. Masks, channels, other layers (even -/// with locked transparency) and the Pencil keep Clear. -fn clear_on_background(s: &Session, p: &Value, mut brush: BrushSettings) -> Result { - if !clear_mode(p) || is_mask_target(p) || crate::channel_cmds::is_channel_target(p) || crate::color_channels::target(s, p)?.is_some() { - return Ok(brush); - } - let id = layer_id(s, p)?; - if s.active().is_some_and(|st| st.doc.is_background(id)) { - brush.erase = false; - } - Ok(brush) -} - /// `paint.stroke`: the Brush (and Eraser) tool. pub fn paint_stroke(s: &mut Session, p: &Value) -> Result { let pts = parse_points(p, "paint.stroke")?; - let brush = clear_on_background(s, p, with_blend_mode(resolve_brush(s, p, "paint.stroke")?, p))?; + let brush = with_blend_mode(resolve_brush(s, p, "paint.stroke")?, p); let label = if brush.erase && !clear_mode(p) { "Eraser" } else { "Brush Tool" }; if flag(p, "fill", false) { return fill_region(s, p, label, brush, pts); @@ -619,7 +605,7 @@ impl LiveStroke { "paint.pencil" => true, other => return Err(bad(other, "live strokes are `paint.stroke` or `paint.pencil`")), }; - let brush = if pencil { pencil_brush(s, p)? } else { clear_on_background(s, p, with_blend_mode(resolve_brush(s, p, cmd)?, p))? }; + let brush = if pencil { pencil_brush(s, p)? } else { with_blend_mode(resolve_brush(s, p, cmd)?, p) }; let (seed, fg) = (brush.seed, brush.color); let (layer, mut brush, zoom) = stroke_target(s, p, brush)?; let st = s.active().ok_or(EngineError::NoDocument)?; diff --git a/crates/ui-egui/src/canvas/paint_mouse/tests.rs b/crates/ui-egui/src/canvas/paint_mouse/tests.rs index cdc95c6158..d1092867fe 100644 --- a/crates/ui-egui/src/canvas/paint_mouse/tests.rs +++ b/crates/ui-egui/src/canvas/paint_mouse/tests.rs @@ -1044,3 +1044,38 @@ fn chosen_clear_paints_normally_on_the_background_and_the_mode_reads_normal() { let px = st.doc.layer(bg).unwrap().surface().unwrap().rgba((d.x + v.center[0]) as i32, (d.y + v.center[1]) as i32); assert!(px[2] > 0.9 && px[0] < 0.1, "the foreground blue, not the background colour: {px:?}"); } + +/// #11897: a Background with Lock All refuses the stroke, so a chosen Clear stays chosen. +#[test] +fn chosen_clear_stays_on_a_locked_background() { + let mut h = harness(None); + h.state_mut().ui.tool_options.brush_clear = true; + let bg = h.state().session.active().unwrap().doc.background().unwrap().id; + h.state_mut().run("layer.select", json!({"layer": bg.0})).unwrap(); + h.state_mut().run("layer.lockLayers", json!({"all": true})).unwrap(); + h.run_steps(2); + drag(&mut h, PointerButton::Primary); + assert!(h.state().ui.tool_options.brush_clear, "the Mode still reads Clear"); +} + +/// #11897: a held `~` on the Background keeps erasing to the background colour. +#[test] +fn held_tilde_on_the_background_paints_the_background_colour() { + let mut h = harness(None); + h.state_mut().run("tools.setColors", json!({"background": "#00ff00"})).unwrap(); + let bg = h.state().session.active().unwrap().doc.background().unwrap().id; + h.state_mut().run("layer.select", json!({"layer": bg.0})).unwrap(); + h.run_steps(2); + h.event(egui::Event::Key { key: egui::Key::Backtick, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(1); + let (a, b) = drag(&mut h, PointerButton::Primary); + h.event(egui::Event::Key { key: egui::Key::Backtick, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(1); + assert_eq!(strokes(&h).last().unwrap()["mode"], json!("clear")); + let app = h.state(); + let v = app.ui.views[0].clone(); + let d = ((a + (b - a) * 0.5) - app.last_canvas_rect.center()) / v.zoom; + let st = app.session.active().unwrap(); + let px = st.doc.layer(bg).unwrap().surface().unwrap().rgba((d.x + v.center[0]) as i32, (d.y + v.center[1]) as i32); + assert!(px[1] > 0.9 && px[0] < 0.1 && px[2] < 0.1, "the background green, not the foreground blue: {px:?}"); +} diff --git a/crates/ui-egui/src/canvas/tool_input.rs b/crates/ui-egui/src/canvas/tool_input.rs index 41e36bcd43..0f8a04f770 100644 --- a/crates/ui-egui/src/canvas/tool_input.rs +++ b/crates/ui-egui/src/canvas/tool_input.rs @@ -377,16 +377,25 @@ fn mixer_press_failed(app: &mut PhotocraftApp, message: String) { } } -/// Tool state machine. Shared by mouse input and automation. /// The Brush's chosen Clear paints as Normal on the Background, and its Mode then reads Normal -/// (#11897). A held `~` keeps today's behaviour. +/// (#11897): at the press of a Brush stroke on the Background's own pixels, Clear (chosen in the +/// options bar, not a held `~`) is turned off, so the stroke is sent as Normal. A Background whose +/// pixels are locked refuses the stroke, so Clear stays chosen there. fn clear_off_on_background(app: &mut PhotocraftApp, tool: Tool) { - let on_background = app.session.active().is_some_and(|st| st.active_layer.is_some_and(|id| st.doc.is_background(id))); + let on_background = app.session.active().is_some_and(|st| { + st.active_layer.is_some_and(|id| { + st.doc.is_background(id) && { + let locks = st.doc.effective_locks(id); + !locks.pixels && !locks.all + } + }) + }); if tool == Tool::Brush && app.ui.tool_options.brush_clear && !app.held_erase && on_background && paint_target(app) == serde_json::json!("pixels") { app.ui.tool_options.brush_clear = false; } } +/// Tool state machine. Shared by mouse input and automation. pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) { if consumes_cancelled_vector_event(app, ev) { return; From 76ad52bf47edbf097e7b33687ef30f27c5bde305 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sun, 8 Nov 2026 12:38:04 -0500 Subject: [PATCH 217/219] Patch Destination: the rectangle check is capped and scans in bands (#12119) Cause: is_hard_rect read selection coverage over the selection's own content bounds in one buffer. After a transform those bounds can reach far past the canvas, while the patch-size guard only measures the on-canvas part, so a huge mostly-off-canvas selection could stall or exhaust memory. Fix: is_hard_rect now calls is_hard_rect_within with MAX_PIXELS (the patch's own cap); bounds over the cap are not a hard rectangle and the selection stays in place. Otherwise coverage is scanned in 64-row bands, stopping at the first pixel below 1.0. Verified: new test covers a limit below and at the pixel count and a hole in the third band; patch (25) and retouch (126) lib tests pass, clippy -D warnings and fmt clean. Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove and briefed; Sonnet 5.5 wrote at medium --- crates/engine/src/cmd/retouch_cmds/patch.rs | 27 +++++++++++++++++-- .../patch_destination_selection_tests.rs | 19 +++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/retouch_cmds/patch.rs b/crates/engine/src/cmd/retouch_cmds/patch.rs index 17066b137e..1f1c6b023e 100644 --- a/crates/engine/src/cmd/retouch_cmds/patch.rs +++ b/crates/engine/src/cmd/retouch_cmds/patch.rs @@ -214,11 +214,34 @@ fn patch_surface(surf: &mut Surface, sel: &Surface, plan: &Plan, lock: bool, ste apply_coverage(surf, g, &cov, 1.0, None, lock, &healed, BlendMode::Normal) } +/// Rows scanned per band by [`is_hard_rect_within`], so the coverage buffer stays small. +const RECT_BAND_ROWS: i32 = 64; + /// Whether `sel` is a hard-edged rectangle: nothing selected outside its own `bounds` and every pixel /// inside fully selected (the only Destination selection measured to move with the patch). The -/// bounds may reach past the canvas; the patched area is then only the part on the canvas. +/// bounds may reach past the canvas; the patched area is then only the part on the canvas. Bounds +/// of more than [`MAX_PIXELS`] pixels (the patch's own cap) are never classified as a hard +/// rectangle, so a huge selection moved mostly off the canvas is not scanned and stays in place. fn is_hard_rect(sel: &Surface, bounds: Rect) -> bool { - sel.default_pixel().first().copied().unwrap_or(0.0) <= 0.0 && coverage(sel, bounds).iter().all(|c| *c >= 1.0) + is_hard_rect_within(sel, bounds, MAX_PIXELS) +} + +/// [`is_hard_rect`] with the pixel `limit` given. Scans `bounds` in bands of [`RECT_BAND_ROWS`] rows +/// and stops at the first pixel that is not fully selected. +fn is_hard_rect_within(sel: &Surface, bounds: Rect, limit: u64) -> bool { + let (w, h) = ((i64::from(bounds.x1) - i64::from(bounds.x0)).max(0) as u64, (i64::from(bounds.y1) - i64::from(bounds.y0)).max(0) as u64); + if w.saturating_mul(h) > limit || sel.default_pixel().first().copied().unwrap_or(0.0) > 0.0 { + return false; + } + let mut y = bounds.y0; + while y < bounds.y1 { + let y1 = y.saturating_add(RECT_BAND_ROWS).min(bounds.y1); + if !coverage(sel, Rect::new(bounds.x0, y, bounds.x1, y1)).iter().all(|c| *c >= 1.0) { + return false; + } + y = y1; + } + true } pub(super) fn patch(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/retouch_cmds/patch_destination_selection_tests.rs b/crates/engine/src/cmd/retouch_cmds/patch_destination_selection_tests.rs index de14bd73c9..7ed9e54efa 100644 --- a/crates/engine/src/cmd/retouch_cmds/patch_destination_selection_tests.rs +++ b/crates/engine/src/cmd/retouch_cmds/patch_destination_selection_tests.rs @@ -182,3 +182,22 @@ fn destination_moves_a_hard_rectangle_that_extends_past_the_canvas() { assert_eq!(bounds(&mut s), sel_before); assert_eq!(px(&s, 480, 400), before); } + +#[test] +fn hard_rect_check_is_capped_and_scans_every_band() { + let mut s = session(); + // 40x150 rectangle: three bands of 64 rows (64 + 64 + 22). + s.execute("select.rect", json!({"x": 100, "y": 100, "width": 40, "height": 150})).unwrap(); + let d = s.active().unwrap(); + let sel = d.doc.selection.as_ref().unwrap(); + let b = sel.content_bounds(); + assert_eq!(b, Rect::new(100, 100, 140, 250)); + assert!(!is_hard_rect_within(sel, b, 5999), "over the limit is never a hard rectangle"); + assert!(is_hard_rect_within(sel, b, 6000), "at the limit it is checked as usual"); + assert!(is_hard_rect_within(sel, b, u64::MAX)); + // A hole in the third band must still be found. + let mut holed = sel.clone(); + let fmt = holed.format(); + holed.write_region(Rect::new(120, 240, 121, 241), &photocraft_raster::from_rgba(&fmt, [0.0, 0.0, 0.0, 0.0])); + assert!(!is_hard_rect_within(&holed, b, u64::MAX), "a hole past the first band fails the check"); +} From 4e43c23e433325f40b72caae0661d2c4f89829cc Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 8 Nov 2026 20:31:42 +0100 Subject: [PATCH 218/219] Magic Wand seed-outside tests: Anti-alias off, so they test the modes with hard edges (#12397) #12146 gives near-matching neighbours a soft fringe when Anti-alias is on. The #11848 tests left Anti-alias at its default and expected exact column bounds, so main's engine tests failed (381 instead of 380). Co-Authored-By: Claude Opus 5.5 --- .../src/cmd/selection_cmds/wand_seed_outside_tests.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/selection_cmds/wand_seed_outside_tests.rs b/crates/engine/src/cmd/selection_cmds/wand_seed_outside_tests.rs index ce289bea91..a0f079a96b 100644 --- a/crates/engine/src/cmd/selection_cmds/wand_seed_outside_tests.rs +++ b/crates/engine/src/cmd/selection_cmds/wand_seed_outside_tests.rs @@ -19,12 +19,14 @@ fn ramp() -> Session { s } +/// Anti-alias off: these tests check Contiguous and the modes with hard edges; the soft fringe +/// near-matching neighbours get is tested in `photocraft_algo`'s wand anti-alias tests. fn wand(s: &mut Session, x: i32, tolerance: f64, mode: &str) { - s.execute("select.magicWand", json!({"x": x, "y": 400, "tolerance": tolerance, "mode": mode})).unwrap(); + s.execute("select.magicWand", json!({"x": x, "y": 400, "tolerance": tolerance, "mode": mode, "antiAlias": false})).unwrap(); } fn wand_global(s: &mut Session, x: i32, tolerance: f64, mode: &str) { - s.execute("select.magicWand", json!({"x": x, "y": 400, "tolerance": tolerance, "mode": mode, "contiguous": false})).unwrap(); + s.execute("select.magicWand", json!({"x": x, "y": 400, "tolerance": tolerance, "mode": mode, "contiguous": false, "antiAlias": false})).unwrap(); } /// The columns of row 400 that are at least half selected: (first, end), or `None`. Every From bfe5d2a6716bac9ec42cc962ce6de74676e03597 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 8 Nov 2026 21:03:37 +0100 Subject: [PATCH 219/219] Magic Wand seed-outside tests: keep Anti-alias on, expect the measured fringe (#12397) Instead of turning Anti-alias off, the tests keep the default setup and expect the reference application's measured results (#11776, #11779): the selection ends at 381, Subtract at 219, Intersect runs 219-381. Suggested by hectorviov on #12399. Co-Authored-By: Claude Opus 5.5 --- .../selection_cmds/wand_seed_outside_tests.rs | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/selection_cmds/wand_seed_outside_tests.rs b/crates/engine/src/cmd/selection_cmds/wand_seed_outside_tests.rs index a0f079a96b..51540e3cfd 100644 --- a/crates/engine/src/cmd/selection_cmds/wand_seed_outside_tests.rs +++ b/crates/engine/src/cmd/selection_cmds/wand_seed_outside_tests.rs @@ -19,14 +19,12 @@ fn ramp() -> Session { s } -/// Anti-alias off: these tests check Contiguous and the modes with hard edges; the soft fringe -/// near-matching neighbours get is tested in `photocraft_algo`'s wand anti-alias tests. fn wand(s: &mut Session, x: i32, tolerance: f64, mode: &str) { - s.execute("select.magicWand", json!({"x": x, "y": 400, "tolerance": tolerance, "mode": mode, "antiAlias": false})).unwrap(); + s.execute("select.magicWand", json!({"x": x, "y": 400, "tolerance": tolerance, "mode": mode})).unwrap(); } fn wand_global(s: &mut Session, x: i32, tolerance: f64, mode: &str) { - s.execute("select.magicWand", json!({"x": x, "y": 400, "tolerance": tolerance, "mode": mode, "contiguous": false, "antiAlias": false})).unwrap(); + s.execute("select.magicWand", json!({"x": x, "y": 400, "tolerance": tolerance, "mode": mode, "contiguous": false})).unwrap(); } /// The columns of row 400 that are at least half selected: (first, end), or `None`. Every @@ -41,11 +39,12 @@ fn selected(s: &Session) -> Option<(i32, i32)> { Some((first as i32, last as i32 + 1)) } -/// Greys 100 to 130 (x 100–380) selected, as tolerance 32 on the first block selects them. +/// Greys 100 to 130 (x 100–380) selected, as tolerance 32 on the first block selects them, plus +/// the Anti-alias fringe: column 380 of grey 135 at about 251/255, as measured in #11776. fn ramp_selected() -> Session { let mut s = ramp(); wand(&mut s, 110, 32.0, "replace"); - assert_eq!(selected(&s), Some((100, 380))); + assert_eq!(selected(&s), Some((100, 381))); s } @@ -54,7 +53,7 @@ fn a_seed_outside_the_selection_subtracts_nothing() { let mut s = ramp_selected(); // Grey 145 (x 460–500) is outside the selection, though its own region reaches into it. wand(&mut s, 480, 32.0, "subtract"); - assert_eq!(selected(&s), Some((100, 380)), "unchanged"); + assert_eq!(selected(&s), Some((100, 381)), "unchanged"); } #[test] @@ -67,12 +66,14 @@ fn a_seed_outside_the_selection_intersects_to_nothing() { #[test] fn a_seed_inside_the_selection_works_as_before() { // Grey 125 with tolerance 10 reaches greys 115–135; inside the selection that is x 220–380. + // With the default Anti-alias fringe, as measured in #11779: Subtract leaves 100–219 (x 219 + // keeps about 6/255, under the 0.5 cut) and Intersect 219–381. let mut s = ramp_selected(); wand(&mut s, 320, 10.0, "subtract"); - assert_eq!(selected(&s), Some((100, 220))); + assert_eq!(selected(&s), Some((100, 219))); let mut s = ramp_selected(); wand(&mut s, 320, 10.0, "intersect"); - assert_eq!(selected(&s), Some((220, 380))); + assert_eq!(selected(&s), Some((219, 381))); } #[test] @@ -81,10 +82,10 @@ fn contiguous_off_keeps_the_global_match() { // x 220–500, wherever the seed lies. let mut s = ramp_selected(); wand_global(&mut s, 480, 32.0, "subtract"); - assert_eq!(selected(&s), Some((100, 220))); + assert_eq!(selected(&s), Some((100, 219))); let mut s = ramp_selected(); wand_global(&mut s, 480, 32.0, "intersect"); - assert_eq!(selected(&s), Some((220, 380))); + assert_eq!(selected(&s), Some((219, 381))); } #[test]