diff --git a/crates/doc/src/pixels.rs b/crates/doc/src/pixels.rs index 0812095826..1c231f7bbe 100644 --- a/crates/doc/src/pixels.rs +++ b/crates/doc/src/pixels.rs @@ -194,6 +194,27 @@ impl ImageBlob { .collect(); Some((w, h, cover)) } + + /// The pixels as straight RGBA, row by row from the top, with the width and the height; + /// `None` when the image can't be decoded within [`MAX_RASTER_ALLOC`]. + pub fn rgba(&self) -> Option<(u32, u32, Vec)> { + let img = rgba_within(&self.bytes, MAX_RASTER_ALLOC)?; + let (w, h) = img.dimensions(); + Some((w, h, img.into_raw())) + } + + /// The width and height in pixels the image's file says it has; `None` for data that isn't an + /// image the decoder reads. Reads only its header. + pub fn pixel_size(&self) -> Option<(u32, u32)> { + image::ImageReader::new(Cursor::new(self.bytes.as_slice())).with_guessed_format().ok()?.into_dimensions().ok() + } + + /// Whether the image's file is a PNG that stores palette indices (an indexed-color image). + /// Reads only its header. + pub fn indexed_png(&self) -> bool { + // The signature, then the IHDR chunk: length, type, width, height, bit depth, color type. + self.bytes.starts_with(b"\x89PNG\r\n\x1a\n") && self.bytes.get(12..16) == Some(b"IHDR".as_slice()) && self.bytes.get(25) == Some(&3) + } } impl ImageBlob { diff --git a/crates/engine/src/cmd/colorcmds.rs b/crates/engine/src/cmd/colorcmds.rs index bbc56dbc15..b2b44a898d 100644 --- a/crates/engine/src/cmd/colorcmds.rs +++ b/crates/engine/src/cmd/colorcmds.rs @@ -3,7 +3,11 @@ //! shared colour visitor [`Recolor`]: fills and strokes (solid colours, gradient stops and text //! runs), gradient meshes, embedded images and pattern fills. Colours linked to a global swatch are //! unlinked when they change; Adjust Color Balance's Global mode shifts their tints instead. +//! +//! Convert to Indexed Color uses the same visitor for embedded images only, and stores each one as +//! a palette PNG. +use std::cell::Cell; use std::collections::{HashMap, HashSet}; use std::sync::Arc; @@ -15,7 +19,9 @@ use vectorcraft_doc::live::lerp_color; use vectorcraft_doc::pattern::PatternDef; pub(crate) use vectorcraft_doc::recolor::map_paint; use vectorcraft_doc::recolor::{ColorVisitor, Reach, map_links, recolor_node}; -use vectorcraft_doc::{Document, ImageBlob, Node, NodeId, NodeKind}; +use vectorcraft_doc::{Document, ImageBlob, MAX_RASTER_ALLOC, Node, NodeId, NodeKind}; +use vectorcraft_render::encode::png::{PngOptions, encode_indexed}; +use vectorcraft_render::encode::quantize::{Dither, Indexed, PaletteOptions, Reduction, quantize, quantize_to}; use super::edit::selected_roots; use super::{ @@ -66,6 +72,15 @@ pub fn specs() -> Vec { }) } ), + cmd!( + "edit.colors.toIndexed", + "Convert to Indexed Color…", + ["Edit", "Edit Colors"], + None, + "{reduction?: \"selective\" (default)|\"perceptual\"|\"adaptive\"|\"web\"|\"gray\"|\"blackWhite\" (chosen as for a PNG-8 export)|\"uniform\" (evenly spaced levels of red, green and blue: the most levels, 2 to 6, whose cube fits in `colors` with one entry left for transparency)|\"document\" (the colors of the document's swatches, at most 255), colors?: 2..256 (default 256; the most palette entries, the transparent one included; at least 9 for uniform; refused for blackWhite and document), dither?: \"diffusion\" (default)|\"pattern\"|\"noise\"|\"none\", ditherAmount?: 0..100 (default 100), ids?} store each embedded image in the selection (in groups too) as an indexed-color PNG: a palette of at most 256 RGB colors, each pixel the index of one; pixels under half opacity become one transparent entry and the others opaque, keeping their color; linked images, colorized images and the images of color glyphs in outlined type keep their pixels; each image changed becomes a new image, one undo step for all; an image that is an indexed PNG already and would show the same pixels is left as it is, and no undo step is recorded when no image changes; an image of more than 134,217,728 pixels (33,554,432 in the web app) is refused with an error and nothing changes; reduction and dither ids are read as for a PNG-8 export, ignoring case → {changed: images changed, notChanged: the locked and hidden objects Select All left out that hold an image that is not an indexed PNG yet, warning?} (as edit.colors.invert)", + has_selection, + to_indexed + ), cmd!( "edit.colors.saturate", "Saturate…", @@ -149,6 +164,13 @@ fn gray_level(c: Color) -> f32 { /// gradient stop): `(colour, link, tint)`, true when it changed them. pub(crate) type LinkMap<'a> = dyn Fn(&mut Color, &mut Option, &mut f32) -> bool + 'a; +/// What a pass that remakes whole images makes of one: its new copy, `None` when it stays as it is. +type Remake<'a> = dyn Fn(&ImageBlob) -> Option + 'a; + +/// The objects a pass recolors, and the locked and hidden objects the selection left out, each +/// with whether it is hidden ([`pass_targets`]). +type Targets = (Vec, Vec<(NodeId, bool)>); + /// The identity colour map (a pass that only maps linked colours). fn same(c: Color) -> Color { c @@ -191,6 +213,8 @@ pub(crate) struct Recolor<'a> { scope: Scope, /// Recolored images are stored with one gray channel ([`ImageBlob::map_gray`]). gray_images: bool, + /// Makes each embedded image's new copy in place of `f` (`None`: it stays as it is). + remake: Option<&'a Remake<'a>>, /// Image key → the key of its recoloured copy (`None`: unchanged). images: HashMap>, /// Pattern name → the name of its recoloured copy (`None`: unchanged, or being recoloured). @@ -207,6 +231,7 @@ impl<'a> Recolor<'a> { links: None, scope, gray_images: false, + remake: None, images: HashMap::new(), patterns: HashMap::new(), new_images: vec![], @@ -229,7 +254,7 @@ impl<'a> Recolor<'a> { /// A pass that maps colors as this one does, with nothing recolored yet. fn again(&self) -> Self { - Self { links: self.links, gray_images: self.gray_images, ..Self::new(self.scope, self.f) } + Self { links: self.links, gray_images: self.gray_images, remake: self.remake, ..Self::new(self.scope, self.f) } } /// The entries of `objects` (ids, each with a flag) whose object this pass would change, other @@ -328,7 +353,9 @@ impl<'a> Recolor<'a> { } let f = self.f; let blob = d.images.get(key)?; - let new = if self.gray_images { + let new = if let Some(remake) = self.remake { + remake(blob) + } else if self.gray_images { blob.map_gray(|[r, g, b]| f(Color::rgb8(r, g, b)).to_rgba8(1.0)[0]) } else { blob.map_rgb(|[r, g, b]| { @@ -534,6 +561,187 @@ impl NotChanged { } } +/// The objects a pass recolors (the `ids` param's, default: the selection's) and the locked and +/// hidden objects the selection left out ([`left_out`]), each with whether it is hidden. +fn pass_targets(s: &Session, p: &Value, cmd: &str) -> Result { + Ok(match ids_param(p, "ids", cmd)? { + Some(v) => (v, vec![]), + None => { + let roots = selected_roots(s)?; + let st = s.doc()?; + let left = left_out(&st.doc, st.isolation, &roots); + (roots, left) + } + }) +} + +/// Edit › Edit Colors › Convert to Indexed Color: the embedded images of the objects stored as +/// indexed-color PNGs ([`Indexing`]). The pass runs on a copy of the document, so no undo step is +/// recorded when no image changes. +fn to_indexed(s: &mut Session, p: &Value) -> Result { + const C: &str = "edit.colors.toIndexed"; + let indexing = Indexing::of(s, p)?; + // A typing session ends here as `s.edit` would end it, before the copy is taken, so that the + // copy holds what was typed. + if !s.previewing && super::typing_in_progress(s) { + super::settle_interaction(s)?; + } + let remake = |b: &ImageBlob| indexing.convert(b); + let images = Scope { fill: false, stroke: false, images: true, patterns: false }; + let pass = Recolor { remake: Some(&remake), ..Recolor::new(images, &same) }; + // A left-out image counts as one the pass would change when it is an image that is not an + // indexed PNG yet; it is not converted to find out. The empty PNG only gives it a new key. + let would = |b: &ImageBlob| (!b.indexed_png() && b.pixel_size().is_some()).then(|| ImageBlob::png(vec![])); + let (ids, left) = pass_targets(s, p, C)?; + let st = s.doc()?; + let colorized_ok = super::paint::check_colorized(&st.doc).is_ok(); + let not_changed = NotChanged::of( + Recolor { remake: Some(&would), ..Recolor::new(images, &same) }.would_change(&st.doc, &left).into_iter().map(|(_, hidden)| hidden), + ); + let mut doc = (*st.doc).clone(); + let n = pass.run(&mut doc, &ids); + if let Some((w, h)) = indexing.too_large.get() { + return Err(bad(C, format!("an image of {w} × {h} pixels is too large to convert (at most {} pixels)", Indexing::MAX_PIXELS))); + } + if n > 0 { + s.edit("Convert to Indexed Color", |d, _| { + *d = doc; + super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(C, e)) + })?; + } + let mut out = json!({ "changed": n }); + not_changed.report(&mut out); + Ok(out) +} + +/// How Convert to Indexed Color reduces an image's colors: the palette options of a PNG-8 +/// export, with transparency kept and no matte, and the fixed palette of the uniform and document +/// reductions. +struct Indexing { + options: PaletteOptions, + fixed: Option>, + /// The size of an image [`Self::convert`] left as it is because it has more than + /// [`Self::MAX_PIXELS`] pixels. + too_large: Cell>, +} + +impl Indexing { + /// The most pixels an image may have. Converting one holds at most about 8 bytes a pixel at + /// once: decoding (the decoded samples and the RGBA), quantizing (the RGBA, the colors and the + /// indices) and writing the PNG after the RGBA is freed (the indices, the packed and filtered + /// rows and the compressed data). 16 bytes a pixel keeps that within [`MAX_RASTER_ALLOC`] twice + /// over. + const MAX_PIXELS: u64 = MAX_RASTER_ALLOC / 16; + + /// The reduction `p` asks for (see `edit.colors.toIndexed`); a value out of range or of the + /// wrong type is an error. Reduction and dither ids are read as a PNG-8 export reads them. + fn of(s: &Session, p: &Value) -> Result { + const C: &str = "edit.colors.toIndexed"; + let reduction = text_param(p, "reduction", C)?.unwrap_or("selective"); + let colors = match p.get("colors") { + None | Some(Value::Null) => None, + Some(v) => { + Some(v.as_u64().filter(|n| (2..=256).contains(n)).ok_or_else(|| bad(C, "`colors` must be a whole number from 2 to 256"))? as u16) + } + }; + let dither = match text_param(p, "dither", C)? { + None => Dither::Diffusion, + Some(d) => Dither::from_id(d).ok_or_else(|| bad(C, format!("unknown dither `{d}` (diffusion, pattern, noise or none)")))?, + }; + let dither_amount = match p.get("ditherAmount") { + None | Some(Value::Null) => 100, + Some(v) => { + v.as_f64().filter(|x| (0.0..=100.0).contains(x)).ok_or_else(|| bad(C, "`ditherAmount` must be a number from 0 to 100"))?.round() as u8 + } + }; + let options = + |r: Reduction| PaletteOptions { colors: colors.unwrap_or(256), reduction: r, dither, dither_amount, transparency: true, matte: None }; + let refuse_colors = |name: &str| match colors { + Some(_) => Err(bad(C, format!("`colors` does not apply to the {name} reduction"))), + None => Ok(()), + }; + let fixed = |palette: Vec<[u8; 3]>| Self { options: options(Reduction::Adaptive), fixed: Some(palette), too_large: Cell::new(None) }; + Ok(match reduction.to_ascii_lowercase().as_str() { + "uniform" => { + let n = colors.unwrap_or(256); + // One entry is kept for transparent pixels. + let levels = + (2..=6u16).rev().find(|l| l.pow(3) < n).ok_or_else(|| bad(C, "`colors` must be at least 9 for the uniform reduction"))?; + let level = |i: u16| (u32::from(i) * 255 / u32::from(levels - 1)) as u8; + let mut palette = Vec::with_capacity(usize::from(levels.pow(3))); + for r in 0..levels { + for g in 0..levels { + for b in 0..levels { + palette.push([level(r), level(g), level(b)]); + } + } + } + fixed(palette) + } + "document" => { + refuse_colors("document")?; + let mut palette: Vec<[u8; 3]> = vec![]; + for c in s.doc()?.doc.swatches_iter().filter_map(|sw| sw.paint.color()) { + let [r, g, b, _] = c.to_rgba8(1.0); + if !palette.contains(&[r, g, b]) { + palette.push([r, g, b]); + } + } + if palette.is_empty() { + return Err(bad(C, "the document has no color swatches")); + } + if palette.len() > 255 { + return Err(bad(C, format!("the document's swatches hold {} colors; the document reduction takes at most 255", palette.len()))); + } + fixed(palette) + } + id => { + let r = Reduction::from_id(id).ok_or_else(|| { + bad(C, format!("unknown reduction `{reduction}` (selective, perceptual, adaptive, web, gray, blackWhite, uniform or document)")) + })?; + if r == Reduction::BlackWhite { + refuse_colors("blackWhite")?; + } + Self { options: options(r), fixed: None, too_large: Cell::new(None) } + } + }) + } + + /// Image `b` as an indexed-color PNG; `None` when it can't be decoded, when it has more than + /// [`Self::MAX_PIXELS`] pixels (noted in `too_large`), or when it is an indexed PNG already and + /// every pixel would show as it does. + fn convert(&self, b: &ImageBlob) -> Option { + let (w, h) = b.pixel_size()?; + if u64::from(w) * u64::from(h) > Self::MAX_PIXELS { + self.too_large.set(Some((w, h))); + return None; + } + let (w, h, rgba) = b.rgba()?; + let ix = match &self.fixed { + Some(palette) => quantize_to(&rgba, w, h, &self.options, palette), + None => quantize(&rgba, w, h, &self.options), + }; + if b.indexed_png() && shows_same(&ix, &rgba) { + return None; + } + // The PNG writer's buffers take the place of the pixels. + drop(rgba); + Some(ImageBlob::png(encode_indexed(&ix, &PngOptions::default()).ok()?)) + } +} + +/// Whether palette image `ix` shows every pixel of straight RGBA `rgba` as it is: transparent +/// where they are transparent, and the same color where they are opaque. +fn shows_same(ix: &Indexed, rgba: &[u8]) -> bool { + let px = rgba.as_chunks::<4>().0; + px.len() == ix.indices.len() + && px.iter().zip(&ix.indices).all(|(p, &i)| match (p[3], ix.transparent == Some(i)) { + (0, clear) => clear, + (255, false) => ix.palette.get(usize::from(i)).is_some_and(|c| c[..] == p[..3]), + _ => false, + }) +} + fn saturate(s: &mut Session, p: &Value) -> Result { let i = (f64_req(p, "intensity", "edit.colors.saturate")?.clamp(-100.0, 100.0) / 100.0) as f32; recolor(s, p, "edit.colors.saturate", "Saturate", &move |c| { diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs index 8071c1df3d..2f6252d938 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -158,6 +158,7 @@ pub(super) const ALLOWED: &[&str] = &[ "edit.colors.saturate", "edit.colors.toCMYK", "edit.colors.toGrayscale", + "edit.colors.toIndexed", "edit.colors.toRGB", "edit.duplicate", "edit.findNext", diff --git a/crates/engine/src/tests/editcolors.rs b/crates/engine/src/tests/editcolors.rs index a3dcea263a..e3aa59368a 100644 --- a/crates/engine/src/tests/editcolors.rs +++ b/crates/engine/src/tests/editcolors.rs @@ -484,6 +484,250 @@ fn adjust_colors_converting_to_gray_stores_an_image_as_grayscale() { assert!(source(false).is_none(), "the colors keep their model"); } +/// Convert to Indexed Color stores an image as a palette PNG in one undo step; an image with few +/// enough colors keeps them, and converting it again changes nothing and adds no undo step. +#[test] +fn convert_to_indexed_stores_an_image_as_a_palette_png() { + let mut s = session(); + let id = image_node(&mut s); + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute("edit.colors.toIndexed", &json!({})).unwrap(); + assert_eq!(r["changed"], 1, "{r}"); + let key = image_key(&s, id); + assert!(s.doc().unwrap().doc.images[&key].indexed_png(), "an indexed PNG"); + assert_eq!(pixels(&s, id), [[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [255, 255, 255, 255]], "half opaque becomes opaque"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + let again = s.execute("edit.colors.toIndexed", &json!({})).unwrap(); + assert_eq!(again["changed"], 0, "{again}"); + assert_eq!(image_key(&s, id), key); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "no undo step when nothing changes"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(pixels(&s, id)[3], [255, 255, 255, 128], "one undo step restores the image"); +} + +/// Pixels under half opacity become transparent; an indexed image converted to a palette it +/// already shows is left as it is, and one converted to a smaller palette changes. +#[test] +fn convert_to_indexed_keeps_transparency_and_reduces_an_indexed_image() { + let mut s = session(); + let id = image_of(&mut s, [[200, 30, 90, 255], [20, 40, 220, 255], [0, 0, 0, 100], [250, 250, 250, 255]]); + s.execute("edit.colors.toIndexed", &json!({"dither": "none"})).unwrap(); + assert_eq!(pixels(&s, id)[2][3], 0, "under half opacity: transparent"); + let r = s.execute("edit.colors.toIndexed", &json!({"reduction": "blackWhite", "dither": "none"})).unwrap(); + assert_eq!(r["changed"], 1, "{r}"); + let px = pixels(&s, id); + assert_eq!([px[0], px[1], px[3]], [[0, 0, 0, 255], [0, 0, 0, 255], [255, 255, 255, 255]]); + assert_eq!(px[2][3], 0); +} + +/// The uniform reduction takes evenly spaced levels of each channel, and the document reduction +/// the colors of the document's swatches. +#[test] +fn convert_to_indexed_uses_a_uniform_or_the_document_palette() { + let mut s = session(); + let id = image_of(&mut s, [[200, 30, 90, 255], [20, 40, 220, 255], [120, 130, 140, 255], [250, 250, 250, 255]]); + s.execute("edit.colors.toIndexed", &json!({"reduction": "uniform", "colors": 9, "dither": "none"})).unwrap(); + assert_eq!(pixels(&s, id), [[255, 0, 0, 255], [0, 0, 255, 255], [0, 255, 255, 255], [255, 255, 255, 255]]); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.colors.toIndexed", &json!({"reduction": "document"})).unwrap(); + let swatches: Vec<[u8; 3]> = crate::cmd::swatchlib::library_colors(&s, "document") + .unwrap() + .iter() + .map(|c| { + let [r, g, b, _] = c.to_rgba8(1.0); + [r, g, b] + }) + .collect(); + for p in pixels(&s, id) { + assert!(swatches.contains(&[p[0], p[1], p[2]]), "{p:?} is a swatch color"); + } +} + +/// The start of a PNG of `w` × `h` RGBA pixels: its header and a first image data chunk, without +/// the rest of the pixels. +fn png_header(w: u32, h: u32) -> Vec { + let crc = |data: &[u8]| { + let mut c = !0u32; + for &b in data { + c ^= u32::from(b); + for _ in 0..8 { + c = if c & 1 == 1 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 }; + } + } + !c + }; + let chunk = |ty: &[u8; 4], data: &[u8]| { + let body = [ty.as_slice(), data].concat(); + [(data.len() as u32).to_be_bytes().as_slice(), &body, &crc(&body).to_be_bytes()].concat() + }; + let ihdr = [w.to_be_bytes().as_slice(), &h.to_be_bytes(), &[8, 6, 0, 0, 0]].concat(); + [b"\x89PNG\r\n\x1a\n".as_slice(), &chunk(b"IHDR", &ihdr), &chunk(b"IDAT", &[0x78, 0x9c, 0x03, 0x00])].concat() +} + +/// An image larger than Convert to Indexed Color converts is refused by its size, read from the +/// file's header, before any pixel is decoded; nothing changes. +#[test] +fn convert_to_indexed_refuses_an_image_too_large_to_convert() { + let mut s = session(); + let id = image_node(&mut s); + assert_eq!(vectorcraft_doc::ImageBlob::png(png_header(30_000, 30_000)).pixel_size(), Some((30_000, 30_000))); + s.edit("Swap", |d, _| { + d.images.insert("pic".into(), vectorcraft_doc::ImageBlob::png(png_header(30_000, 30_000))); + Ok(()) + }) + .unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let e = s.execute("edit.colors.toIndexed", &json!({})).unwrap_err().to_string(); + assert!(e.contains("an image of 30000 × 30000 pixels is too large to convert"), "{e}"); + assert_eq!(image_key(&s, id), "pic"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); +} + +/// Convert to Indexed Color refuses parameters it can't use, and leaves the image as it is. +#[test] +fn convert_to_indexed_refuses_bad_params() { + let mut s = session(); + let id = image_node(&mut s); + for (p, msg) in [ + (json!({"colors": 1}), "`colors` must be a whole number from 2 to 256"), + (json!({"colors": 257}), "`colors` must be a whole number from 2 to 256"), + (json!({"colors": "8"}), "`colors` must be a whole number from 2 to 256"), + (json!({"colors": 16.5}), "`colors` must be a whole number from 2 to 256"), + (json!({"reduction": "uniform", "colors": 8}), "`colors` must be at least 9 for the uniform reduction"), + (json!({"reduction": "document", "colors": 16}), "`colors` does not apply to the document reduction"), + (json!({"reduction": "blackWhite", "colors": 2}), "`colors` does not apply to the blackWhite reduction"), + (json!({"reduction": "posterize"}), "unknown reduction `posterize`"), + (json!({"dither": "random"}), "unknown dither `random`"), + (json!({"ditherAmount": 101}), "`ditherAmount` must be a number from 0 to 100"), + (json!({"ditherAmount": "50"}), "`ditherAmount` must be a number from 0 to 100"), + ] { + let e = s.execute("edit.colors.toIndexed", &p).unwrap_err().to_string(); + assert!(e.contains(msg), "{p}: {e}"); + } + assert_eq!(image_key(&s, id), "pic"); +} + +/// An embedded `w` × `h` image of the pixels `f(x, y)`, placed and selected. +fn placed(s: &mut Session, w: u32, h: u32, f: impl Fn(u32, u32) -> [u8; 4]) -> NodeId { + let mut png = Vec::new(); + image::RgbaImage::from_fn(w, h, |x, y| image::Rgba(f(x, y))).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let r = s.execute("file.place", &json!({"name": "art.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap(); + NodeId(r["ids"][0].as_u64().unwrap()) +} + +/// `ids` picks the images to convert; a linked image and a colorized image keep their pixels. +#[test] +fn convert_to_indexed_takes_ids_and_leaves_linked_and_colorized_images() { + let mut s = session(); + let a = placed(&mut s, 2, 1, |x, _| [200, 30 + x as u8, 90, 255]); + let b = placed(&mut s, 2, 1, |x, _| [20, 40, 200 + x as u8, 255]); + let (ka, kb) = (image_key(&s, a), image_key(&s, b)); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + let r = s.execute("edit.colors.toIndexed", &json!({"ids": [b.0]})).unwrap(); + assert_eq!(r["changed"], 1, "{r}"); + assert_eq!(image_key(&s, a), ka, "not in `ids`"); + assert_ne!(image_key(&s, b), kb); + s.edit("Link", |d, _| { + let NodeKind::Image(im) = &mut d.node_mut(a).unwrap().kind else { panic!("not an image") }; + im.link = Some(vectorcraft_doc::LinkInfo::new("art.png")); + Ok(()) + }) + .unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute("edit.colors.toIndexed", &json!({"ids": [a.0]})).unwrap(); + assert_eq!(r["changed"], 0, "a linked image: {r}"); + assert_eq!(image_key(&s, a), ka); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step"); + let mut png = Vec::new(); + image::GrayImage::from_pixel(4, 4, image::Luma([90])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let r = s.execute("file.place", &json!({"name": "gray.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap(); + let gray = NodeId(r["ids"][0].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"ids": [gray.0], "color": "#ff0000"})).unwrap(); + assert!(s.doc().unwrap().doc.node(gray).unwrap().extras().colorize.is_some(), "the fill colorizes the image"); + let kg = image_key(&s, gray); + let r = s.execute("edit.colors.toIndexed", &json!({"ids": [gray.0]})).unwrap(); + assert_eq!(r["changed"], 0, "a colorized image: {r}"); + assert_eq!(image_key(&s, gray), kg); +} + +/// With the selection Select All makes, locked and hidden images are left out and counted in +/// `notChanged`; a left-out image too large to convert is counted without an error. +#[test] +fn convert_to_indexed_counts_left_out_images() { + let mut s = session(); + let a = placed(&mut s, 2, 1, |x, _| [200, 30 + x as u8, 90, 255]); + let locked = placed(&mut s, 2, 1, |x, _| [20, 40, 200 + x as u8, 255]); + let hidden = placed(&mut s, 2, 1, |x, _| [120, 140 + x as u8, 20, 255]); + s.edit("Huge", |d, _| { + let NodeKind::Image(im) = &mut d.node_mut(locked).unwrap().kind else { panic!("not an image") }; + im.key = "huge".into(); + d.images.insert("huge".into(), vectorcraft_doc::ImageBlob::png(png_header(30_000, 30_000))); + Ok(()) + }) + .unwrap(); + s.execute("object.lock", &json!({"ids": [locked.0]})).unwrap(); + s.execute("object.hide", &json!({"ids": [hidden.0]})).unwrap(); + s.execute("select.all", &json!({})).unwrap(); + let kh = image_key(&s, hidden); + let r = s.execute("edit.colors.toIndexed", &json!({})).unwrap(); + assert_eq!(r["changed"], 1, "{r}"); + assert_eq!(r["notChanged"], json!({"locked": 1, "hidden": 1}), "{r}"); + assert!(r["warning"].is_string(), "{r}"); + assert!(s.doc().unwrap().doc.images[&image_key(&s, a)].indexed_png()); + assert_eq!((image_key(&s, locked), image_key(&s, hidden)), ("huge".to_string(), kh)); +} + +/// The uniform reduction takes the most levels a channel whose cube leaves one entry for +/// transparency (3 for 64 colors, 6 for 256), and the document reduction takes 1 to 255 swatch +/// colors. +#[test] +fn convert_to_indexed_sizes_the_uniform_and_document_palettes() { + let mut s = session(); + let id = image_of(&mut s, [[200, 30, 90, 255], [20, 40, 220, 255], [120, 130, 140, 255], [250, 250, 250, 255]]); + s.execute("edit.colors.toIndexed", &json!({"reduction": "uniform", "colors": 64, "dither": "none"})).unwrap(); + assert_eq!(pixels(&s, id), [[255, 0, 127, 255], [0, 0, 255, 255], [127, 127, 127, 255], [255, 255, 255, 255]]); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.colors.toIndexed", &json!({"reduction": "uniform", "dither": "none"})).unwrap(); + assert_eq!(pixels(&s, id), [[204, 51, 102, 255], [0, 51, 204, 255], [102, 153, 153, 255], [255, 255, 255, 255]]); + s.execute("edit.undo", &json!({})).unwrap(); + s.edit("No swatches", |d, _| { + d.swatches.clear(); + d.swatch_groups.clear(); + Ok(()) + }) + .unwrap(); + let e = s.execute("edit.colors.toIndexed", &json!({"reduction": "document"})).unwrap_err().to_string(); + assert!(e.contains("the document has no color swatches"), "{e}"); + s.edit("Many swatches", |d, _| { + for i in 0..256u32 { + let paint = Paint::solid(Color::rgb8(i as u8, 7, 9)); + d.swatches.push(vectorcraft_color::swatch::Swatch { name: format!("c{i}"), paint, global: false, spot: false }); + } + Ok(()) + }) + .unwrap(); + let e = s.execute("edit.colors.toIndexed", &json!({"reduction": "document"})).unwrap_err().to_string(); + assert!(e.contains("the document's swatches hold 256 colors; the document reduction takes at most 255"), "{e}"); + assert_eq!(image_key(&s, id), "pic"); +} + +/// `dither` and `ditherAmount` reach the quantizer: a gray ramp reduced to black and white is +/// dithered by each method, and at an amount of 0 it comes out as with no dither. +#[test] +fn convert_to_indexed_dithers_by_the_amount_given() { + let ramp = |p: Value| { + let mut s = session(); + let id = placed(&mut s, 16, 4, |x, _| [(x * 17) as u8, (x * 17) as u8, (x * 17) as u8, 255]); + s.execute("edit.colors.toIndexed", &p).unwrap(); + pixels(&s, id) + }; + let none = ramp(json!({"reduction": "blackWhite", "dither": "none"})); + for d in ["diffusion", "pattern", "noise"] { + assert_eq!(ramp(json!({"reduction": "blackWhite", "dither": d, "ditherAmount": 0})), none, "{d} at 0"); + assert_ne!(ramp(json!({"reduction": "blackWhite", "dither": d})), none, "{d} at 100"); + } +} + /// A PDF of a CMYK document writes an image Convert to Grayscale made with one channel (#5632). #[test] fn a_converted_image_is_written_to_pdf_as_gray_in_a_cmyk_document() { diff --git a/crates/engine/src/tests/ids_without_selection.rs b/crates/engine/src/tests/ids_without_selection.rs index 24a1635c30..b274e7956f 100644 --- a/crates/engine/src/tests/ids_without_selection.rs +++ b/crates/engine/src/tests/ids_without_selection.rs @@ -368,6 +368,7 @@ fn ids_cases(d: u64) -> Vec<(&'static str, &'static str, serde_json::Value, Unse ("edit.colors.invert", "ids", json!({}), Selected), ("edit.colors.toCMYK", "ids", json!({}), Selected), ("edit.colors.toGrayscale", "ids", json!({}), Selected), + ("edit.colors.toIndexed", "ids", json!({}), Selected), ("edit.colors.toRGB", "ids", json!({}), Selected), ("edit.colors.saturate", "ids", json!({"intensity": 50}), Selected), ("edit.colors.adjustBalance", "ids", json!({"r": 20}), Selected), diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index 3e80e0a2dd..a72a695b5d 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -945,6 +945,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{} open Saturate for the selection (dialog `saturate`, fields `intensity` −100..100, `preview`): previews live, OK runs edit.colors.saturate as one undo step", ), + ( + "ui.indexedColorDialog", + "Convert to Indexed Color…", + "", + "{} open Convert to Indexed Color for the selection (dialog `indexedColor`, fields `reduction`, `colors` 2..256, `dither`, `ditherAmount` 0..100, `preview`): previews live, OK runs edit.colors.toIndexed as one undo step", + ), ( "window.swatchLibrary", "Swatch Library", diff --git a/crates/render/src/encode/quantize.rs b/crates/render/src/encode/quantize.rs index ec04c9f466..0c873fd598 100644 --- a/crates/render/src/encode/quantize.rs +++ b/crates/render/src/encode/quantize.rs @@ -154,18 +154,8 @@ pub fn quantize(rgba: &[u8], width: u32, height: u32, o: &PaletteOptions) -> Ind pub fn quantize_locked(rgba: &[u8], width: u32, height: u32, o: &PaletteOptions, locked: &[[u8; 3]]) -> Indexed { let px = rgba.as_chunks::<4>().0; let clear = |p: &[u8; 4]| o.transparency && p[3] < 128; - let gray = matches!(o.reduction, Reduction::Gray | Reduction::BlackWhite); let matte = o.matte.unwrap_or([255; 3]); - let blend = o.matte.is_some() || !o.transparency; - // The opaque colours: blended over the matte, greys for the grey reductions. - let colors: Vec<[u8; 3]> = px - .iter() - .map(|p| { - let a = if blend { p[3] as u32 } else { 255 }; - let c = [0, 1, 2].map(|i| ((p[i] as u32 * a + matte[i] as u32 * (255 - a) + 127) / 255) as u8); - if gray { [luma(c); 3] } else { c } - }) - .collect(); + let colors = opaque_colors(px, o, matches!(o.reduction, Reduction::Gray | Reduction::BlackWhite)); let has_clear = px.iter().any(clear); let k = (o.colors.clamp(2, 256) as usize - usize::from(has_clear)).max(1); let opaque = || colors.iter().zip(px).filter(|(_, p)| !clear(p)).map(|(c, _)| *c); @@ -195,11 +185,46 @@ pub fn quantize_locked(rgba: &[u8], width: u32, height: u32, o: &PaletteOptions, if palette.is_empty() { palette.push(matte); } + finish(px, &colors, width, height, &palette, o, if exact { Dither::None } else { o.dither }) +} + +/// [`quantize`] to the fixed `palette`: each pixel takes its nearest entry, with `o`'s dither, +/// transparency and matte; `o.reduction` sets only the distance weights, and `o.colors` is not +/// used. Only the first 255 colors of `palette` are used, leaving room for the transparent entry. +/// Entries no pixel uses are left out. +pub fn quantize_to(rgba: &[u8], width: u32, height: u32, o: &PaletteOptions, palette: &[[u8; 3]]) -> Indexed { + let px = rgba.as_chunks::<4>().0; + let colors = opaque_colors(px, o, false); + let mut palette: Vec<[u8; 3]> = palette.iter().take(255).copied().collect(); + if palette.is_empty() { + palette.push(o.matte.unwrap_or([255; 3])); + } + finish(px, &colors, width, height, &palette, o, o.dither) +} + +/// The color of each pixel when opaque: blended over the matte, and gray when `gray`. +fn opaque_colors(px: &[[u8; 4]], o: &PaletteOptions, gray: bool) -> Vec<[u8; 3]> { + let matte = o.matte.unwrap_or([255; 3]); + let blend = o.matte.is_some() || !o.transparency; + px.iter() + .map(|p| { + let a = if blend { p[3] as u32 } else { 255 }; + let c = [0, 1, 2].map(|i| ((p[i] as u32 * a + matte[i] as u32 * (255 - a) + 127) / 255) as u8); + if gray { [luma(c); 3] } else { c } + }) + .collect() +} + +/// Map the pixels `px` (their opaque `colors`) to `palette` with `dither`, then drop the entries +/// no pixel uses and put the transparent entry first when a pixel is transparent. +fn finish(px: &[[u8; 4]], colors: &[[u8; 3]], width: u32, height: u32, palette: &[[u8; 3]], o: &PaletteOptions, dither: Dither) -> Indexed { + let clear = |p: &[u8; 4]| o.transparency && p[3] < 128; + let matte = o.matte.unwrap_or([255; 3]); + let has_clear = px.iter().any(clear); let weights = o.reduction.weights(); - let dither = if exact { Dither::None } else { o.dither }; // (A width past the pixels there are would size the error rows from a number nothing checked.) let mut indices = - map(&colors, (width as usize).min(colors.len()), &palette, weights, dither, f32::from(o.dither_amount.min(100)) / 100.0, |i| clear(&px[i])); + map(colors, (width as usize).min(colors.len()), palette, weights, dither, f32::from(o.dither_amount.min(100)) / 100.0, |i| clear(&px[i])); // Drop the entries no pixel uses (fixed palettes), keeping the order. let mut used = [false; 256]; for (i, &ix) in indices.iter().enumerate() { diff --git a/crates/render/src/encode/tests_palette.rs b/crates/render/src/encode/tests_palette.rs index 53a139f3e1..f06217f397 100644 --- a/crates/render/src/encode/tests_palette.rs +++ b/crates/render/src/encode/tests_palette.rs @@ -3,7 +3,7 @@ use vectorcraft_doc::{Appearance, Node}; use vectorcraft_geom::shapes; use super::png::{PngOptions, encode_indexed}; -use super::quantize::{Dither, Indexed, PaletteOptions, Reduction, quantize}; +use super::quantize::{Dither, Indexed, PaletteOptions, Reduction, quantize, quantize_to}; use super::*; /// A `w`×`h` opaque image of `f(x, y)` colours. @@ -93,6 +93,22 @@ fn fixed_palettes() { assert_eq!(bw.palette, [[0, 0, 0], [255, 255, 255]]); } +#[test] +fn a_given_palette_keeps_the_entries_pixels_use() { + let px: Vec = [[250, 10, 10, 255], [10, 10, 240, 255], [0, 0, 0, 40], [240, 20, 30, 255]].concat(); + let given = [[0, 255, 0], [255, 0, 0], [0, 0, 255], [255, 255, 255]]; + let o = PaletteOptions { dither: Dither::None, matte: None, ..Default::default() }; + let ix = quantize_to(&px, 4, 1, &o, &given); + assert_eq!(ix.transparent, Some(0)); + assert_eq!(ix.palette[1..], [[255, 0, 0], [0, 0, 255]], "unused entries left out, the order kept"); + assert_eq!(ix.indices, [1, 2, 0, 1]); + // At most 255 of the given colors are used, leaving room for the transparent entry. + let many: Vec<[u8; 3]> = (0..300u32).map(|i| [(i % 256) as u8, (i / 256) as u8, 0]).collect(); + let all = image(256, 2, |x, y| [x as u8, y as u8, 0]); + let ix = quantize_to(&all, 256, 2, &o, &many); + assert!(ix.palette.len() <= 255 && ix.palette.iter().all(|c| many[..255].contains(c)), "{}", ix.palette.len()); +} + #[test] fn dithering_keeps_the_average_colour() { let px = image(64, 16, |x, _| [(x * 4) as u8; 3]); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 6040ac8fb4..1406d25530 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -60,6 +60,7 @@ pub mod graphic_style_options; pub mod halftone; pub mod hyphenation; pub mod import_pdf; +pub mod indexed_color; pub mod javascript_files; pub mod justification; pub mod layer_options; @@ -347,6 +348,7 @@ registry! { ColorGuideOptions: [color_guide_options::KIND] => color_guide_options::SPEC, ColorBalance: [color_balance::KIND] => color_balance::SPEC, Saturate: [saturate::KIND] => saturate::SPEC, + IndexedColor: [indexed_color::KIND] => indexed_color::SPEC, SaveSwatchLibrary: [save_swatch_library::KIND] => save_swatch_library::SPEC, TileEdgeColor: [tile_edge_color::KIND] => tile_edge_color::SPEC, EyedropperOptions: [eyedropper::KIND] => eyedropper::SPEC, diff --git a/crates/ui-egui/src/dialogs/indexed_color.rs b/crates/ui-egui/src/dialogs/indexed_color.rs new file mode 100644 index 0000000000..6ef266c06c --- /dev/null +++ b/crates/ui-egui/src/dialogs/indexed_color.rs @@ -0,0 +1,135 @@ +//! Edit → Edit Colors → Convert to Indexed Color: the palette (reduction and colors) and the +//! dither of the selection's embedded images, previewed live on the canvas; OK keeps the preview +//! as one undo step (`edit.colors.toIndexed`). +//! +//! Fields: `reduction`, `colors`, `dither`, `ditherAmount` and `preview`. + +use serde_json::{Value, json}; +use vectorcraft_render::encode::quantize::{Dither, Reduction}; + +use super::{DialogSpec, form}; +use crate::state::Dialog; +use crate::{VectorcraftApp, widgets}; + +/// The dialog kind of Convert to Indexed Color. +pub const KIND: &str = "indexedColor"; + +const CMD: &str = "edit.colors.toIndexed"; + +/// The reductions besides those of a PNG-8 export, as (value, label). +const MORE_REDUCTIONS: [(&str, &str); 2] = [("uniform", "Uniform"), ("document", "Document Swatches")]; + +/// Label column and dropdown widths. +const WIDTHS: (f32, f32) = (110.0, 160.0); + +pub(super) const SPEC: DialogSpec = + DialogSpec { heading: |_| tl!("Convert to Indexed Color").into(), body, confirm, preview: true, min_width: 320.0, ..DialogSpec::FORM }; + +/// Open the dialog with the defaults of `edit.colors.toIndexed`. +pub fn open(app: &mut VectorcraftApp) { + let fields = json!({"reduction": "selective", "colors": 256, "dither": "diffusion", "ditherAmount": 100, "preview": true}); + app.ui.dialog = Some(Dialog::new(KIND, fields)); +} + +/// Whether reduction `r` takes a number of colors. +fn takes_colors(r: &str) -> bool { + !matches!(r, "blackWhite" | "document") +} + +/// The fewest colors reduction `r` takes. +fn least_colors(r: &str) -> f64 { + if r == "uniform" { 9.0 } else { 2.0 } +} + +/// `edit.colors.toIndexed` parameters: the reduction, its colors when it takes them, and the dither. +fn params(d: &Dialog) -> Value { + let reduction = d.str("reduction"); + let dither = d.str("dither"); + let mut p = json!({"reduction": reduction, "dither": dither}); + if takes_colors(&reduction) { + p["colors"] = json!(d.f64("colors", 256.0).round().clamp(least_colors(&reduction), 256.0) as u16); + } + if dither != Dither::None.id() { + p["ditherAmount"] = json!(d.f64("ditherAmount", 100.0).round().clamp(0.0, 100.0)); + } + p +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let reductions: Vec<(&str, &str)> = Reduction::ALL.iter().map(|r| (r.id(), r.label())).chain(MORE_REDUCTIONS).collect(); + form::choice(ui, d, "reduction", tl!("Reduction:"), WIDTHS, &reductions); + let reduction = d.str("reduction"); + if takes_colors(&reduction) { + // The field shows the number the command gets: at least the reduction's fewest. + let least = least_colors(&reduction); + let n = d.f64("colors", 256.0).round().clamp(least, 256.0); + d.fields.insert("colors".into(), json!(n as u16)); + widgets::label_row(ui, tl!("Colors:"), WIDTHS.0, |ui| { + if let Some(n) = widgets::range_field(ui, "indexed-colors", n, least..=256.0, "", 0, 60.0) { + d.fields.insert("colors".into(), json!(n.round() as u16)); + } + }); + } + let dithers: Vec<(&str, &str)> = Dither::ALL.iter().map(|x| (x.id(), x.label())).collect(); + form::choice(ui, d, "dither", tl!("Dither:"), WIDTHS, &dithers); + if d.str("dither") != Dither::None.id() { + widgets::label_row(ui, tl!("Amount:"), WIDTHS.0, |ui| { + if let Some(n) = widgets::range_field(ui, "indexed-dither-amount", d.f64("ditherAmount", 100.0), 0.0..=100.0, "%", 0, 60.0) { + d.fields.insert("ditherAmount".into(), json!(n.round())); + } + }); + } + let p = params(d); + form::preview(app, ui, d, "Convert to Indexed Color", CMD, p); + false +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + form::commit_preview(app, CMD, params(d)) +} + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_engine::Session; + + #[test] + fn commits_the_chosen_palette_as_one_step() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + let mut png = Vec::new(); + let img = image::RgbaImage::from_raw(2, 1, vec![200, 30, 90, 255, 20, 40, 220, 255]).unwrap(); + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let id = app + .session + .edit("Place", |d, sel| { + d.images.insert("pic".into(), vectorcraft_doc::ImageBlob::png(png)); + let id = d.alloc_id(); + let im = vectorcraft_doc::ImageObject { + key: "pic".into(), + width: 2, + height: 1, + xf: Default::default(), + link: None, + placement: Default::default(), + pixels: None, + }; + d.insert(d.default_layer(), 0, vectorcraft_doc::Node::new(id, vectorcraft_doc::NodeKind::Image(Box::new(im))))?; + sel.set([id]); + Ok(id) + }) + .unwrap(); + let undo = app.session.doc().unwrap().history.undo.len(); + app.run("ui.indexedColorDialog", json!({})).unwrap(); + let fields = &mut app.ui.dialog.as_mut().unwrap().fields; + fields.insert("reduction".into(), json!("blackWhite")); + fields.insert("dither".into(), json!("none")); + super::super::confirm(&mut app).unwrap(); + let st = app.session.doc().unwrap(); + assert_eq!(st.history.undo.len(), undo + 1); + let vectorcraft_doc::NodeKind::Image(im) = &st.doc.node(id).unwrap().kind else { panic!("not an image") }; + let blob = &st.doc.images[&im.key]; + assert!(blob.indexed_png()); + assert_eq!(blob.rgba().unwrap().2, [0, 0, 0, 255, 0, 0, 0, 255], "black and white only"); + } +} diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index a7d62b6daf..55cf45ce2f 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -428,6 +428,7 @@ fn dialogs_do_not_stretch_to_the_screen() { ("ui.colorGuideOptions", json!({})), ("ui.colorBalanceDialog", json!({})), ("ui.saturateDialog", json!({})), + ("ui.indexedColorDialog", json!({})), ("ui.saveSwatchLibrary", json!({})), ("ui.flattenTransparencyDialog", json!({})), ("ui.expandDialog", json!({})), diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 708ffe9db6..5b53c33df9 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -614,6 +614,8 @@ font-technology Variable Променливи Convert to Artboards Конвертиране в работни области Convert to CMYK Конвертиране в CMYK Convert to Grayscale Конвертиране в ска̀ла на сивото + Convert to Indexed Color Конвертиране в индексирани цветове + Convert to Indexed Color… Конвертиране в индексирани цветове… Convert To Outlines Конвертиране в контури Convert To Point Type Конвертиране в точков текст Convert to RGB Конвертиране в RGB diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 8f5ea73487..af6a4ea010 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -103,6 +103,8 @@ Blend Vertically Stopi okomito Convert to CMYK Pretvori u CMYK Convert to Grayscale Pretvori u nijanse sive + Convert to Indexed Color Pretvori u indeksirane boje + Convert to Indexed Color… Pretvori u indeksirane boje… Convert to RGB Pretvori u RGB Invert Colors Obrni boje Overprint Black… Pretisak crne… diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 5516c9662f..0eb8489e62 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -906,6 +906,8 @@ antiAlias Sharp Nítid Convert to destination (preserve numbers) Converteix al destí (conserva els números) Convert to Global Converteix en global Convert to Grayscale Converteix a escala de grisos + Convert to Indexed Color Converteix a color indexat + Convert to Indexed Color… Converteix a color indexat… Convert To Outlines Converteix en contorns Convert To Point Type Converteix en text de punt Convert to RGB Converteix a RGB diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index d03d192a3a..9a9e40d6e6 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -108,6 +108,8 @@ Blend Vertically Prolnout svisle Convert to CMYK Převést na CMYK Convert to Grayscale Převést na stupně šedi + Convert to Indexed Color Převést na indexované barvy + Convert to Indexed Color… Převést na indexované barvy… Convert to RGB Převést na RGB Invert Colors Invertovat barvy Overprint Black… Přetisknout černou… diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 00c85aaeb6..362df38e9b 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -96,6 +96,8 @@ Blend Vertically Bland lodret Convert to CMYK Konvertér til CMYK Convert to Grayscale Konvertér til gråtoner + Convert to Indexed Color Konvertér til indekserede farver + Convert to Indexed Color… Konvertér til indekserede farver… Convert to RGB Konvertér til RGB Invert Colors Invertér farver Overprint Black… Overprint sort… diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 47337855aa..22913bdc2d 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -607,6 +607,8 @@ font-technology Variable Variabel Convert to Artboards In Zeichenflächen umwandeln Convert to CMYK In CMYK konvertieren Convert to Grayscale In Graustufen konvertieren + Convert to Indexed Color In indizierte Farben konvertieren + Convert to Indexed Color… In indizierte Farben konvertieren… Convert To Outlines In Pfade umwandeln Convert To Point Type In Punkttext umwandeln Convert to RGB In RGB konvertieren diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index c319729a71..9be6950447 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -588,6 +588,8 @@ font-technology Variable Μεταβλητές Convert to Artboards Μετατροπή σε πίνακες σχεδίασης Convert to CMYK Μετατροπή σε CMYK Convert to Grayscale Μετατροπή σε κλίμακα του γκρι + Convert to Indexed Color Μετατροπή σε χρώματα με ευρετήριο + Convert to Indexed Color… Μετατροπή σε χρώματα με ευρετήριο… Convert To Outlines Μετατροπή σε περιγράμματα Convert To Point Type Μετατροπή σε κείμενο σημείου Convert to RGB Μετατροπή σε RGB diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 5b203292a4..a408ab1edf 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -629,6 +629,8 @@ font-technology Variable Variables Convert to Artboards Convertir en mesas de trabajo Convert to CMYK Convertir a CMYK Convert to Grayscale Convertir a escala de grises + Convert to Indexed Color Convertir a color indexado + Convert to Indexed Color… Convertir a color indexado… Convert To Outlines Convertir en contornos Convert To Point Type Convertir en texto de punto Convert to RGB Convertir a RGB diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index b2c6f48d54..8947e5dcc9 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -116,6 +116,8 @@ Blend Vertically Sulauta pystysuunnassa Convert to CMYK Muunna CMYK:ksi Convert to Grayscale Muunna harmaasävyiseksi + Convert to Indexed Color Muunna indeksoiduiksi väreiksi + Convert to Indexed Color… Muunna indeksoiduiksi väreiksi… Convert to RGB Muunna RGB:ksi Invert Colors Käännä värit Overprint Black… Päällepainata musta… diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index e84f344060..156bc45cec 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -630,6 +630,8 @@ font-technology Variable Variables Convert to Artboards Convertir en plans de travail Convert to CMYK Convertir en CMJN Convert to Grayscale Convertir en niveaux de gris + Convert to Indexed Color Convertir en couleurs indexées + Convert to Indexed Color… Convertir en couleurs indexées… Convert To Outlines Vectoriser Convert To Point Type Convertir en texte de point Convert to RGB Convertir en RVB diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 017bc03750..e442f7b320 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -1020,6 +1020,8 @@ antiAlias Sharp शार्प Convert to destination (preserve numbers) गंतव्य में कनवर्ट करें (संख्या संरक्षित करें) Convert to Global ग्लोबल में कनवर्ट करें Convert to Grayscale ग्रेस्केल में बदलें + Convert to Indexed Color अनुक्रमित रंग में बदलें + Convert to Indexed Color… अनुक्रमित रंग में बदलें… Convert To Outlines कन्वर्ट करने के लिए रूपरेखा Convert to Parametric Shape पैरामीट्रिक शेप में कन्वर्ट करें Convert To Point Type पॉइंट टाइप में कनवर्ट करें diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index effb31e5d8..c8d1262fa4 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -820,6 +820,8 @@ font-technology Variable Változó Convert to destination (preserve numbers) Átalakítás a célprofilra (az értékek megőrzésével) Convert to Global Átalakítás globálissá Convert to Grayscale Átalakítás szürkeárnyalatossá + Convert to Indexed Color Átalakítás indexelt színűvé + Convert to Indexed Color… Átalakítás indexelt színűvé… Convert To Outlines Átalakítás útvonallá Convert To Point Type Átalakítás pontszöveggé Convert to RGB Átalakítás RGB-re diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 912c6954eb..764c16f784 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -96,6 +96,8 @@ Blend Vertically Blend vertikal Convert to CMYK Konversi ke CMYK Convert to Grayscale Konversi ke skala kelabu + Convert to Indexed Color Konversi ke warna terindeks + Convert to Indexed Color… Konversi ke warna terindeks… Convert to RGB Konversi ke RGB Invert Colors Balikkan warna Overprint Black… Overprint hitam… diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 2d6c7e2186..d5269965f2 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -629,6 +629,8 @@ font-technology Variable Variabili Convert to Artboards Converti in tavole da disegno Convert to CMYK Converti in CMYK Convert to Grayscale Converti in scala di grigi + Convert to Indexed Color Converti in colore indicizzato + Convert to Indexed Color… Converti in colore indicizzato… Convert To Outlines Converti in contorni Convert To Point Type Converti in testo per punti Convert to RGB Converti in RGB diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 17444d894e..b7ac4f0cfd 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -182,6 +182,8 @@ digits Malayalam マラヤーラム数字 Blend Vertically 上下にブレンド Convert to CMYK CMYK に変換 Convert to Grayscale グレースケールに変換 + Convert to Indexed Color インデックスカラーに変換 + Convert to Indexed Color… インデックスカラーに変換… Convert to RGB RGB に変換 Invert Colors カラー反転 Overprint Black… オーバープリントブラック… diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index be8a847cb2..60cf44518e 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -107,6 +107,8 @@ Blend Vertically ვერტიკალურად შერევა Convert to CMYK CMYK-ად გარდაქმნა Convert to Grayscale ნაცრისფერ ტონებად გარდაქმნა + Convert to Indexed Color ინდექსირებულ ფერებად გარდაქმნა + Convert to Indexed Color… ინდექსირებულ ფერებად გარდაქმნა… Convert to RGB RGB-ად გარდაქმნა Invert Colors ფერების ინვერსია Overprint Black… შავის ზედდაბეჭდვა… diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 991daba6d0..2d91559506 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -175,6 +175,8 @@ digits Malayalam 말라얄람어 Blend Vertically 세로 혼합 Convert to CMYK CMYK로 변환 Convert to Grayscale 회색조로 변환 + Convert to Indexed Color 인덱스 색상으로 변환 + Convert to Indexed Color… 인덱스 색상으로 변환… Convert to RGB RGB로 변환 Invert Colors 색상 반전 Overprint Black… 검정 겹쳐찍기… diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index b8ca33858f..b82f07dfd5 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -597,6 +597,8 @@ Blend Vertically Verticaal mengen Convert to CMYK Converteren naar CMYK Convert to Grayscale Converteren naar grijswaarden + Convert to Indexed Color Converteren naar geïndexeerde kleuren + Convert to Indexed Color… Converteren naar geïndexeerde kleuren… Convert to RGB Converteren naar RGB Invert Colors Kleuren omkeren Overprint Black… Zwart overprinten… diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index eb5afe87cc..65dfc7793b 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -111,6 +111,8 @@ Blend Vertically Przejście w pionie Convert to CMYK Konwertuj na CMYK Convert to Grayscale Konwertuj na skalę szarości + Convert to Indexed Color Konwertuj na kolory indeksowane + Convert to Indexed Color… Konwertuj na kolory indeksowane… Convert to RGB Konwertuj na RGB Invert Colors Odwróć kolory Overprint Black… Nadruk czerni… diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 80615d6370..67f6b9a4ec 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -573,6 +573,8 @@ antiAlias Sharp Nítido Convert to Artboards Converter em Mesas de Trabalho Convert to CMYK Converter para CMYK Convert to Grayscale Converter para Escala de Cinza + Convert to Indexed Color Converter para Cores Indexadas + Convert to Indexed Color… Converter para Cores Indexadas… Convert to RGB Converter para RGB Convert to Shape Converter em Forma Convert to sRGB Converter para sRGB diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index c830e69b37..db6ae094ea 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -627,6 +627,8 @@ font-technology Variable Вариативные Convert to Artboards Преобразовать в монтажные области Convert to CMYK Преобразовать в CMYK Convert to Grayscale Преобразовать в оттенки серого + Convert to Indexed Color Преобразовать в индексированные цвета + Convert to Indexed Color… Преобразовать в индексированные цвета… Convert To Outlines Преобразовать в контуры Convert To Point Type Преобразовать в точечный текст Convert to RGB Преобразовать в RGB diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index d4304b97bd..63d0c87e80 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -616,6 +616,8 @@ font-technology Variable Variabla Convert to Artboards Konvertera till ritytor Convert to CMYK Konvertera till CMYK Convert to Grayscale Konvertera till gråskala + Convert to Indexed Color Konvertera till indexerade färger + Convert to Indexed Color… Konvertera till indexerade färger… Convert To Outlines Konvertera till konturbanor Convert To Point Type Konvertera till punkttext Convert to RGB Konvertera till RGB diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index f5fc694ec2..a61d5e4ff2 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -608,6 +608,8 @@ font-technology Variable Değişken Convert to Artboards Çalışma Yüzeylerine Dönüştür Convert to CMYK CMYK'ye dönüştür Convert to Grayscale Gri Tonlamaya Dönüştür + Convert to Indexed Color Dizinlenmiş Renge Dönüştür + Convert to Indexed Color… Dizinlenmiş Renge Dönüştür… Convert To Outlines Anahatlara Dönüştür Convert To Point Type Nokta Türüne Dönüştür Convert to RGB RGB'ye dönüştür diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 5a36512db0..8ded987bc7 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -623,6 +623,8 @@ font-technology Variable Варіативні Convert to Artboards Перетворити на монтажні області Convert to CMYK Перетворити на CMYK Convert to Grayscale Перетворити на відтінки сірого + Convert to Indexed Color Перетворити на індексовані кольори + Convert to Indexed Color… Перетворити на індексовані кольори… Convert To Outlines Перетворити на контури Convert To Point Type Перетворити на точковий текст Convert to RGB Перетворити на RGB diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 839f5c7405..3e010914da 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -721,6 +721,8 @@ font-technology Variable Biến thiên Convert to Artboards Chuyển thành bảng vẽ Convert to CMYK Chuyển sang CMYK Convert to Grayscale Chuyển sang thang xám + Convert to Indexed Color Chuyển sang màu chỉ mục + Convert to Indexed Color… Chuyển sang màu chỉ mục… Convert To Outlines Chuyển thành đường bao Convert To Point Type Chuyển sang chữ điểm Convert to RGB Chuyển sang RGB diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index b0d6001e32..a98a0fed1a 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -575,6 +575,8 @@ antiAlias Sharp 锐利 Convert to Artboards 转换为画板 Convert to CMYK 转换为 CMYK Convert to Grayscale 转换为灰度 + Convert to Indexed Color 转换为索引颜色 + Convert to Indexed Color… 转换为索引颜色… Convert To Outlines 转换为轮廓 Convert To Point Type 转换为点文字 Convert to RGB 转换为 RGB diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index c24ae37609..5a7cdd9f96 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -591,6 +591,8 @@ font-technology Variable 可變 Convert to Artboards 轉換為工作區域 Convert to CMYK 轉換為 CMYK Convert to Grayscale 轉換為灰階 + Convert to Indexed Color 轉換為索引色彩 + Convert to Indexed Color… 轉換為索引色彩… Convert To Outlines 轉換為外框 Convert To Point Type 轉換為點狀文字 Convert to RGB 轉換為 RGB diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index efa4e95ac4..2e9e6e0890 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -814,6 +814,10 @@ fn run_ui_part8(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option { + crate::dialogs::indexed_color::open(app); + Ok(Value::Null) + } "window.swatchLibrary" => crate::panels::swatches::open_library(app, p), "window.swatchLibrary.other" => crate::panels::swatches::other_library(app, s("path")), "ui.saveSwatchLibrary" => { diff --git a/crates/ui-egui/src/menus/state.rs b/crates/ui-egui/src/menus/state.rs index 4e4da87380..50885f4869 100644 --- a/crates/ui-egui/src/menus/state.rs +++ b/crates/ui-egui/src/menus/state.rs @@ -286,7 +286,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { "ui.newBrush" => app.session.active().is_some(), "ui.brushOptions" => crate::dialogs::brush_options::available(app), "ui.hyphenation" | "ui.justification" | "ui.tateChuYokoSettings" => crate::panels::character::text_style(app).is_some(), - "ui.colorBalanceDialog" | "ui.saturateDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), + "ui.colorBalanceDialog" | "ui.saturateDialog" | "ui.indexedColorDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), "ui.saveSwatchLibrary" => app.session.active().is_some(), id if id.starts_with(crate::panels::swatches::USER_SLOT) => crate::panels::swatches::user_library(app, id).is_some(), "ui.flattenTransparencyDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), diff --git a/crates/ui-egui/src/menus/tree.rs b/crates/ui-egui/src/menus/tree.rs index 5b88346f82..4b86b4152d 100644 --- a/crates/ui-egui/src/menus/tree.rs +++ b/crates/ui-egui/src/menus/tree.rs @@ -121,6 +121,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { c("Blend Vertically", "edit.colors.blendVertically"), c("Convert to CMYK", "edit.colors.toCMYK"), c("Convert to Grayscale", "edit.colors.toGrayscale"), + c("Convert to Indexed Color…", "ui.indexedColorDialog"), c("Convert to RGB", "edit.colors.toRGB"), c("Invert Colors", "edit.colors.invert"), cp( diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 31340a3a48..af6da131e0 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -946,6 +946,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{} open Saturate for the selection (dialog `saturate`, fields `intensity` −100..100, `preview`): previews live, OK runs edit.colors.saturate as one undo step", ), + ( + "ui.indexedColorDialog", + "Convert to Indexed Color…", + "", + "{} open Convert to Indexed Color for the selection (dialog `indexedColor`, fields `reduction`, `colors` 2..256, `dither`, `ditherAmount` 0..100, `preview`): previews live, OK runs edit.colors.toIndexed as one undo step", + ), ( "window.swatchLibrary", "Swatch Library", diff --git a/docs/mcp.md b/docs/mcp.md index 59bb3c78ad..7a21d9d098 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3763,11 +3763,32 @@ with one gray channel, so a PDF writes it in gray. `edit.colors.saturate` needs `intensity`, a number from -100 to 100 (a number outside the range counts as its nearest end). Without one, or with `null`, it answers `` missing number `intensity` ``; with a value that is not a number, such as `"40"`, it answers `` `intensity` must be a number, not "40" ``. Either way it changes nothing. -The `edit.colors.*` commands, `edit.colors.overprintBlack` and `addon.color.filter` answer a `fill`, `stroke`, -`includeImages`, `includePatterns`, `convert`, `remove`, `includeCmyBlacks` or `includeSpotBlacks` that is not true or -false with `` `fill` must be true or false ``, naming the key, a `percentage` that is not a number with -`` `percentage` must be a number ``, and a `mode` that is not a string with `` `mode` must be a string ``. They change -nothing and record no undo step. A null counts as a key left out. +The `edit.colors.*` commands other than `edit.colors.toIndexed`, `edit.colors.overprintBlack` and +`addon.color.filter` answer a `fill`, `stroke`, `includeImages`, `includePatterns`, `convert`, `remove`, +`includeCmyBlacks` or `includeSpotBlacks` that is not true or false with `` `fill` must be true or false ``, naming the +key, a `percentage` that is not a number with `` `percentage` must be a number ``, and a `mode` that is not a string +with `` `mode` must be a string ``. They change nothing and record no undo step. A null counts as a key left out. + +`edit.colors.toIndexed` (Edit › Edit Colors › Convert to Indexed Color…) stores each embedded image in the selection (or +`ids`), in groups too, as an indexed-color PNG: a palette of at most 256 RGB colors, with each pixel the index of one. +`reduction` chooses the palette: `selective` (the default), `perceptual`, `adaptive`, `web`, `gray` and `blackWhite` +choose it as a PNG-8 export does; `uniform` takes evenly spaced levels of red, green and blue (2 to 6 levels, the most +whose cube fits in `colors` with one entry left for transparency); `document` takes the colors of the document's +swatches (at most 255). `colors` (2 to 256, default 256) is the most palette entries, the transparent one included; +`uniform` needs at least 9, and `blackWhite` and `document` refuse it. `dither` is `diffusion` (the default), `pattern`, +`noise` or `none`, and `ditherAmount` is 0 to 100 (default 100). Reduction and dither ids are read as a PNG-8 export +reads them, ignoring case. Pixels under half opacity become one transparent entry and the others become opaque in their +own color. Linked images, colorized image fills and the images of color glyphs in outlined type keep their pixels. Each +image that changes becomes a new image, in one undo step for all of them. An image that is already an indexed PNG and +would show the same pixels is left as it is, and no undo step is recorded when no image changes. `notChanged` counts the +locked and hidden objects Select All left out that hold an image that is not an indexed PNG yet. An image of more than +134,217,728 pixels (33,554,432 in the web app) is refused with an error, read from the file's header before any pixel is +decoded, and nothing changes. A value out of range or of the wrong type, such as `"colors": "8"`, is refused with an +error and changes nothing. + +```json +{"name":"run_command","arguments":{"command":"edit.colors.toIndexed","params":{"reduction":"adaptive","colors":32,"dither":"pattern"}}} +``` The reply of `recolor.apply` and of every command above except the Blend commands has `notChanged: {locked, hidden}`. When the selection is exactly what `select.all` takes (in isolation mode, what it takes inside the isolated group), it diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7044797d5e..21ca62cff0 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1425,6 +1425,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Recolor Artwork layout (#3416):** the Edit chip list uses the modal scroll helper and its actual remaining height; the tab's minimum-height space scrolls with the chips. `tests_recolor` covers 5/20/200/400 colors, window resizing, 100/125/150/200% UI scaling, scrolling to and editing the final chip, Preview, Cancel, Confirm and Undo, alongside the existing #1733 drag regressions. Scores and estimates unchanged. - **Recolor Artwork Edit displays:** buttons beside the Edit tab's wheel show the colors on a smooth color wheel, a segmented color wheel or as color bars ([Edit colors using Recolor Artwork](https://helpx.adobe.com/illustrator/desktop/manage-colors/modify-colors/edit-colors-using-edit-colors-dialog.html)). The segmented wheel draws 24 hues by 5 saturations as solid patches, and its markers move freely as on the smooth wheel. The bars show one solid bar per recolored color in row order: a click selects a bar for the color sliders, and a drag moves its row without changing a color ([Advanced recolor options overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/learn-color-basics/edit-colors-dialog-overview.html)). The dialog opens on the smooth wheel; `display` (`smooth`, `segmented`, `bars`) is the dialog field. `tests_recolor` covers switching, the dialog height, segmented dragging and Brightness, and bar selection, editing, reordering, scrolling, Preview, Cancel and OK. Scores and estimates unchanged. - **Edit Colors parameters:** `edit.colors.saturate` needs a number `intensity`, the value from -100% to 100% that Saturate takes ([Adjusting colors](https://web.archive.org/web/20230128232631/https://helpx.adobe.com/illustrator/using/adjusting-colors.html)), and answers "missing number `intensity`" without one; a number outside the range counts as its nearest end. A value that is not a number, such as `"40"`, is refused with `` `intensity` must be a number, not "40" ``, as in every command that needs a number (`tests::params::a_malformed_required_number_is_not_reported_as_missing`). `edit.colors.adjustBalance` refuses a channel and `color.harmony` refuses `steps` or `amount` that is not a number; both read `null` as a value left out. `tests::editcolors` and `tests::colorguide` cover them. + - **Convert to Indexed Color:** Edit › Edit Colors › Convert to Indexed Color… (`edit.colors.toIndexed`) stores the embedded images in the selection as indexed-color PNGs of at most 256 colors. The palette is chosen as for a PNG-8 export, as evenly spaced levels of red, green and blue, or from the document's swatches, with diffusion, pattern or noise dithering; pixels under half opacity become transparent. The dialog previews live, and OK is one undo step. This is VectorCraft's own command and counts toward no score. `tests::editcolors` and `encode::tests_palette` cover it. Scores and estimates unchanged. - **Working RGB numeric controls:** the Color panel and Color Picker read RGB, HSB, hex and Web Safe RGB values in the active working RGB space. Editing one RGB channel preserves the other native channels, including colors outside display sRGB. Regressions cover sRGB, Display P3, Wide Gamut RGB and ProPhoto RGB, picker initialization, native numeric round trips, and a slider click with one Undo step. Scores and estimates unchanged. - **Pasting into hex color fields:** in the hex fields of the Color Picker, Swatch Options, Recolor Artwork, the Color panel, Document Setup, Duotone, Color Themes and Perspective Grid, a click or keyboard focus selects the digits. A pasted code of six digits, with or without `#`, replaces them wherever the caret is. A pasted code of three digits also replaces them, unless the paste holds only those digits and exactly three digits stay; those digits then edit the text in place. A press on another field commits the text as Tab and Enter do. As VectorCraft's own rule, a paste that is not a color leaves the field as it was. Tests: `a_hex_code_pasted_into_the_hex_field_replaces_its_digits` in `crates/ui-egui/src/dialogs/tests.rs`, `define_grid_takes_a_three_digit_color` in `crates/ui-egui/src/dialogs/tests_perspective.rs` and `a_paste_into_a_hex_field_replaces_or_edits_its_digits` in `crates/ui-egui/src/tests/numfields.rs`. - **Color Picker layout (#2008):** a centered draggable header, equal-width actions above aligned HSB/RGB/Hex and Lab/CMYK fields, compact field and slider, and warnings below the preview. No horizontal scrolling; narrow windows stack the sections with vertical scrolling. Tests in `dialogs/color_picker.rs` cover actual pointer/key input, typed values before OK/Enter, Cancel/Escape rollback, one Undo step, mode-size stability, warnings and layout at two UI scales. The screen sampler sits above the field and the upstream two-grid Tab order is retained. AccessKit regressions check the action names and each channel slider's displayed value, units and range. German and enlarged English UI renders were reviewed locally; no reference images are shipped. This is a layout improvement, with no change to scores or remaining-hour estimates.