diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index df388b81ab..eaf1597423 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -21,7 +21,7 @@ pub fn specs() -> Vec { "Fill", [], None, - "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern) → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}", + "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white; a colorized image whose link is missing takes a solid color as its new colorize color, and refuses a process color while its color is a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern) → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}", has_doc, |s, p| set_paint(s, p, true) ), @@ -690,7 +690,9 @@ fn placed_across(d: &Document, ids: &[NodeId], paint: Paint, p: &Value, fill: bo /// The images among `ids` that a fill of `paint` colorizes: grayscale and 1-bit images, for a solid /// color (or none on a colorized image, which takes the color off). The color may be black, white, /// a process color or a spot color; on an image with an alpha channel, only a spot color, black or -/// white, and other colors are an error. +/// white, and other colors are an error. A colorized image that shows a preview that isn't +/// grayscale, such as a missing link's placeholder, takes a solid color as its new colorize paint; +/// while its paint is one an alpha channel allows, a process color is an error there too. fn colorized_images(d: &Document, ids: &[NodeId], paint: &Paint) -> std::result::Result, String> { let mut out = vec![]; for id in ids { @@ -701,6 +703,19 @@ fn colorized_images(d: &Document, ids: &[NodeId], paint: &Paint) -> std::result: out.push(*id); continue; } + // The Fill proxy shows such an image's colorize paint. Its pixels are not known until the + // file is relinked: a process color replaces only a process color, which the image's + // pixels already accepted, so a relink can't bring back an alpha channel under one. + if matches!(paint, Paint::Solid { .. }) + && let Some(old) = n.extras().colorize.as_deref() + && d.image_blob(im).is_some_and(|b| !b.takes_colorize()) + { + if !colorizes_transparency(d, paint) && colorizes_transparency(d, old) { + return Err(PROCESS_ON_TRANSPARENCY.into()); + } + out.push(*id); + continue; + } // A color-masked image is colorized as it shows: its masked pixels, which have an alpha // channel. let Some(gray) = d.image_blob(&d.shown_image(n, im)).and_then(|b| b.gray_source()) else { continue }; diff --git a/crates/engine/src/tests/colorize.rs b/crates/engine/src/tests/colorize.rs index c71014eb1c..e281b3ee04 100644 --- a/crates/engine/src/tests/colorize.rs +++ b/crates/engine/src/tests/colorize.rs @@ -462,6 +462,49 @@ fn a_colorized_missing_link_placeholder_is_not_flattened() { assert_eq!(colorize(&s, id), Some(Paint::solid(Color::rgb8(255, 0, 0))), "the image keeps its paint"); } +/// A fill color on a colorized image that shows the missing-link placeholder recolors the image, +/// as the Fill proxy shows its colorize paint, and adds no fill item. +#[test] +fn a_fill_color_recolors_a_colorized_missing_link_placeholder() { + let mut s = session(); + let bytes = std::sync::Arc::new(vectorcraft_doc::links::missing_placeholder().unwrap()); + let id = image(&mut s, "pic", ImageBlob { mime: "image/png".into(), bytes: bytes.clone(), proxy: Some(bytes) }); + s.edit("Colorize", |d, _| { + d.node_mut(id).unwrap().extras_mut().colorize = Some(Box::new(Paint::solid(Color::rgb8(255, 0, 0)))); + Ok(()) + }) + .unwrap(); + let appearance = s.doc().unwrap().doc.node(id).unwrap().appearance.clone(); + s.execute("paint.setFill", &json!({"color": "#0000ff", "ids": [id.0]})).unwrap(); + assert_eq!(colorize(&s, id).and_then(|p| p.color()).map(|c| c.to_hex()), Some("#0000ff".into())); + assert_eq!(s.doc().unwrap().doc.node(id).unwrap().appearance, appearance, "no fill item"); + assert_eq!(s.proxy_paints().0.color().map(|c| c.to_hex()), Some("#0000ff".into())); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(colorize(&s, id), Some(Paint::solid(Color::rgb8(255, 0, 0)))); +} + +/// The pixels behind the placeholder may have an alpha channel, so a missing-link image colorized +/// with a spot color takes another spot color, black or white, and refuses a process color. +#[test] +fn a_colorized_missing_link_placeholder_in_a_spot_color_refuses_a_process_color() { + let mut s = session(); + let bytes = std::sync::Arc::new(vectorcraft_doc::links::missing_placeholder().unwrap()); + let id = image(&mut s, "pic", ImageBlob { mime: "image/png".into(), bytes: bytes.clone(), proxy: Some(bytes) }); + s.execute("swatch.new", &json!({"name": "Ink 2", "color": {"c": 100, "m": 0, "y": 0, "k": 0}, "spot": true})).unwrap(); + s.edit("Colorize", |d, _| { + let ink = Paint::Solid { color: Color::rgb8(255, 0, 0), swatch: Some("Ink".into()), tint: 1.0 }; + d.node_mut(id).unwrap().extras_mut().colorize = Some(Box::new(ink)); + Ok(()) + }) + .unwrap(); + let before = colorize(&s, id); + let e = s.execute("paint.setFill", &json!({"color": "#0000ff", "ids": [id.0]})).unwrap_err().to_string(); + assert!(e.contains("a process color can't colorize a grayscale image with transparency"), "{e}"); + assert_eq!(colorize(&s, id), before); + s.execute("paint.setFill", &json!({"swatch": "Ink 2", "ids": [id.0]})).unwrap(); + assert!(matches!(colorize(&s, id), Some(Paint::Solid { swatch: Some(w), .. }) if w == "Ink 2"), "{:?}", colorize(&s, id)); +} + #[test] fn flattened_colorized_image_shows_the_art_under_white_pixels() { let mut s = session(); diff --git a/docs/mcp.md b/docs/mcp.md index 20e37222c6..725f57608e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -578,6 +578,8 @@ ramp runs across them. `paint.setFill` without `item` on a grayscale or 1-bit image sets the color the image is drawn in: each pixel takes the color as far as the pixel is dark, and white pixels are transparent. `none` removes the color. An image with an alpha channel takes only a spot color, black or white; a process color is refused. +A colorized image whose link is missing shows the placeholder, and a color set on it replaces its colorize color. A +process color is refused there when the image is colorized with a spot color, black or white. For `draw_shape` and `draw_path`, omitted paint fields inherit the current [new-art appearance](#new-art). For a filled shape or path without an outline, pass an explicit `fill` and `stroke: "none"`. Lines start with no fill and a visible stroke.