mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 12:24:19 +00:00
Edit Colors: convert embedded images to indexed color
Edit > Edit Colors > Convert to Indexed Color... (edit.colors.toIndexed) stores the embedded images in the selection as indexed-color PNGs: a palette of at most 256 RGB colors, each pixel the index of one. The dialog previews live and OK is one undo step. - The palette is chosen as for a PNG-8 export (selective, perceptual, adaptive, web, gray, black and white), as evenly spaced levels of red, green and blue (uniform), or from the document's swatches. Dithering is diffusion, pattern, noise or none, with an amount. - Pixels under half opacity become one transparent entry; the others become opaque in their own color. Linked images, colorized images and color glyph images keep their pixels. - 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. - An image of more than 134,217,728 pixels (33,554,432 in the web app) is refused, read from its header before decoding. - With Select All, locked and hidden objects that hold an image that is not an indexed PNG yet are counted in notChanged, without converting them. - The quantizer gains quantize_to, which maps pixels to a given palette. Docs: docs/mcp.md, docs/target-app-parity.md.
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user