Merge pull request #7220 from dotCooCoo/feat/image-indexed-color

Edit Colors: convert embedded images to indexed color
This commit is contained in:
BFlat
2026-11-14 02:40:36 +07:00
committed by GitHub
44 changed files with 770 additions and 23 deletions
+21
View File
@@ -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<u8>)> {
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 {
+211 -3
View File
@@ -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<CommandSpec> {
})
}
),
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<String>, &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<ImageBlob> + '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<NodeId>, 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<String, Option<String>>,
/// 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<Targets> {
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<Value> {
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<Vec<[u8; 3]>>,
/// The size of an image [`Self::convert`] left as it is because it has more than
/// [`Self::MAX_PIXELS`] pixels.
too_large: Cell<Option<(u32, u32)>>,
}
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<Self> {
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<ImageBlob> {
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<Value> {
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| {
@@ -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",
+244
View File
@@ -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<u8> {
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() {
@@ -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),
+6
View File
@@ -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",
+38 -13
View File
@@ -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() {
+17 -1
View File
@@ -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<u8> = [[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]);
+2
View File
@@ -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,
+135
View File
@@ -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<Value, String> {
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");
}
}
+1
View File
@@ -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!({})),
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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
Can't render this file because it is too large.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -1036,6 +1036,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 पॉइंट टाइप में कनवर्ट करें
Can't render this file because it is too large.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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…
1 # Indonesian (id) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation
96
97
98
99
100
101
102
103
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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… オーバープリントブラック…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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… შავის ზედდაბეჭდვა…
Can't render this file because it is too large.
+2
View File
@@ -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… 검정 겹쳐찍기…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -598,6 +598,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…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -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
Can't render this file because it is too large.
+2
View File
@@ -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
Can't render this file because it is too large.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 5 and column 25.
+2
View File
@@ -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
Can't render this file because it is too large.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 204 and column 5.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -814,6 +814,10 @@ fn run_ui_part8(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<
crate::dialogs::saturate::open(app);
Ok(Value::Null)
}
"ui.indexedColorDialog" => {
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" => {
+1 -1
View File
@@ -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()),
+1
View File
@@ -121,6 +121,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec<Item>)> {
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(
+6
View File
@@ -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",
+26 -5
View File
@@ -3864,11 +3864,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
+1
View File
@@ -1446,6 +1446,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.