Merge pull request #6366 from dotCooCoo/fix/issue-5632

Part of #5632: Convert to Grayscale stores an image with one gray channel
This commit is contained in:
BFlat
2026-11-10 07:50:43 +07:00
committed by GitHub
4 changed files with 152 additions and 11 deletions
+32
View File
@@ -60,6 +60,38 @@ impl ImageBlob {
img.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).ok()?;
Some(Self::png(png))
}
/// The image as a grayscale PNG: `f` gives each pixel's gray level from its (straight) RGB, and
/// the PNG has an alpha channel only when some pixel isn't opaque. `f` runs once per distinct
/// color. `None` when the image can't be decoded, or when its file stores grayscale already
/// and no visible pixel changed.
pub fn map_gray(&self, mut f: impl FnMut([u8; 3]) -> u8) -> Option<ImageBlob> {
let img = rgba_within(&self.bytes, MAX_RASTER_ALLOC)?;
let (width, height) = img.dimensions();
let mut memo: HashMap<[u8; 3], u8> = HashMap::new();
let mut changed = self.gray_source().is_none();
let mut opaque = true;
let mut gray_alpha = Vec::with_capacity(img.len() / 2);
for px in img.pixels() {
let rgb = [px.0[0], px.0[1], px.0[2]];
let level = *memo.entry(rgb).or_insert_with(|| f(rgb));
changed |= px.0[3] > 0 && [level; 3] != rgb;
opaque &= px.0[3] == 255;
gray_alpha.extend_from_slice(&[level, px.0[3]]);
}
if !changed {
return None;
}
let mut png = Vec::new();
let mut out = Cursor::new(&mut png);
if opaque {
let levels = gray_alpha.iter().step_by(2).copied().collect();
image::GrayImage::from_raw(width, height, levels)?.write_to(&mut out, image::ImageFormat::Png).ok()?;
} else {
image::GrayAlphaImage::from_raw(width, height, gray_alpha)?.write_to(&mut out, image::ImageFormat::Png).ok()?;
}
Some(Self::png(png))
}
}
/// How a grayscale image's file stores its pixels ([`ImageBlob::gray_source`]).
+33 -10
View File
@@ -48,9 +48,9 @@ pub fn specs() -> Vec<CommandSpec> {
"Convert to Grayscale",
["Edit", "Edit Colors"],
None,
"{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert) → {changed, notChanged, warning?}",
"{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert; an embedded image is stored with one gray channel) → {changed, notChanged, warning?}",
has_selection,
|s, p| recolor(s, p, "edit.colors.toGrayscale", "Convert to Grayscale", &to_gray)
|s, p| recolor_gray(s, p, "edit.colors.toGrayscale", "Convert to Grayscale", &to_gray)
),
cmd!(
"edit.colors.toRGB",
@@ -187,6 +187,8 @@ pub(crate) struct Recolor<'a> {
/// Maps solid colours and gradient stops with their link and tint in place of `f`.
links: Option<&'a LinkMap<'a>>,
scope: Scope,
/// Recolored images are stored with one gray channel ([`ImageBlob::map_gray`]).
gray_images: bool,
/// 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).
@@ -198,7 +200,17 @@ pub(crate) struct Recolor<'a> {
impl<'a> Recolor<'a> {
pub(crate) fn new(scope: Scope, f: &'a dyn Fn(Color) -> Color) -> Self {
Self { f, links: None, scope, images: HashMap::new(), patterns: HashMap::new(), new_images: vec![], new_patterns: vec![], changed: 0 }
Self {
f,
links: None,
scope,
gray_images: false,
images: HashMap::new(),
patterns: HashMap::new(),
new_images: vec![],
new_patterns: vec![],
changed: 0,
}
}
/// A pass that maps only solid colours and gradient stops, with their swatch link and tint
@@ -215,7 +227,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, ..Self::new(self.scope, self.f) }
Self { links: self.links, gray_images: self.gray_images, ..Self::new(self.scope, self.f) }
}
/// The entries of `objects` (ids, each with a flag) whose object this pass would change, other
@@ -313,10 +325,15 @@ impl<'a> Recolor<'a> {
return done.clone();
}
let f = self.f;
let new = d.images.get(key)?.map_rgb(|[r, g, b]| {
let [r, g, b, _] = f(Color::rgb8(r, g, b)).to_rgba8(1.0);
[r, g, b]
});
let blob = d.images.get(key)?;
let new = 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]| {
let [r, g, b, _] = f(Color::rgb8(r, g, b)).to_rgba8(1.0);
[r, g, b]
})
};
let new = new.map(|blob| {
let k = blob.content_key();
self.new_images.push((k.clone(), blob));
@@ -400,6 +417,11 @@ pub(crate) fn recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dy
run_pass(s, p, cmd, label, Recolor::new(Scope::of(p), f))
}
/// [`recolor`] where `f` gives gray colors, so embedded images are stored with one gray channel.
fn recolor_gray(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn(Color) -> Color) -> Result<Value> {
run_pass(s, p, cmd, label, Recolor { gray_images: true, ..Recolor::new(Scope::of(p), f) })
}
/// [`recolor`] with solid colours and gradient stops mapped through `links`, with their swatch
/// link and tint.
pub(crate) fn recolor_links(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn(Color) -> Color, links: &LinkMap) -> Result<Value> {
@@ -575,10 +597,11 @@ fn adjust_balance(s: &mut Session, p: &Value) -> Result<Value> {
other => return Err(bad(C, format!("unknown mode `{other}` (rgb|cmyk|gray|global)"))),
};
let convert = bool_or(p, "convert", false);
recolor(s, p, C, "Adjust Colors", &move |c| {
let f = move |c| {
let out = adjust(c);
if convert { out } else { keep_model(c, out) }
})
};
if convert && mode == "gray" { recolor_gray(s, p, C, "Adjust Colors", &f) } else { recolor(s, p, C, "Adjust Colors", &f) }
}
enum BlendOrder {
+85 -1
View File
@@ -342,7 +342,12 @@ fn pixels(s: &Session, id: NodeId) -> Vec<[u8; 4]> {
/// A selected embedded 2×2 image.
fn image_node(s: &mut Session) -> NodeId {
let bytes = png([[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [255, 255, 255, 128]]);
image_of(s, [[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [255, 255, 255, 128]])
}
/// A selected embedded 2×2 image of the pixels `px`.
fn image_of(s: &mut Session, px: [[u8; 4]; 4]) -> NodeId {
let bytes = png(px);
s.edit("Place", |d, sel| {
d.images.insert("pic".into(), vectorcraft_doc::ImageBlob::png(bytes));
let id = d.alloc_id();
@@ -383,6 +388,85 @@ fn invert_recolours_an_embedded_image() {
assert_eq!(pixels(&s, id)[..2], [[0, 0, 0, 255], [0, 255, 0, 255]]);
}
/// The key of image object `id`'s blob.
fn image_key(s: &Session, id: NodeId) -> String {
let NodeKind::Image(im) = &s.doc().unwrap().doc.node(id).unwrap().kind else { panic!("not an image") };
im.key.clone()
}
/// Convert to Grayscale stores an image with one color channel, alpha kept (#5632).
#[test]
fn convert_to_grayscale_stores_an_image_as_grayscale() {
let mut s = session();
let id = image_node(&mut s);
let r = s.execute("edit.colors.toGrayscale", &json!({})).unwrap();
assert_eq!(r["changed"], 1);
let key = image_key(&s, id);
let source = s.doc().unwrap().doc.images[&key].gray_source();
assert!(source.is_some_and(|g| g.alpha && !g.one_bit), "a grayscale file with alpha: {source:?}");
let gray = |hex: &str| Color::from_hex(hex).unwrap().in_model(vectorcraft_color::cms::Model::Gray).to_rgba8(1.0)[0];
let (r_, g_, b_, w) = (gray("#ff0000"), gray("#00ff00"), gray("#0000ff"), gray("#ffffff"));
assert_eq!(pixels(&s, id), [[r_, r_, r_, 255], [g_, g_, g_, 255], [b_, b_, b_, 255], [w, w, w, 128]]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(pixels(&s, id)[0], [255, 0, 0, 255], "one undo step restores the color image");
s.execute("edit.redo", &json!({})).unwrap();
// An image that is already grayscale has nothing to convert.
let again = s.execute("edit.colors.toGrayscale", &json!({})).unwrap();
assert_eq!(again["changed"], 0);
assert_eq!(image_key(&s, id), key);
}
/// An RGB image whose pixels are all neutral still becomes a one-channel image (#5632).
#[test]
fn convert_to_grayscale_stores_a_neutral_rgb_image_as_grayscale() {
let mut s = session();
let id = image_of(&mut s, [[255, 255, 255, 255], [0, 0, 0, 255], [255, 255, 255, 255], [0, 0, 0, 255]]);
let r = s.execute("edit.colors.toGrayscale", &json!({})).unwrap();
assert_eq!(r["changed"], 1);
let source = s.doc().unwrap().doc.images[&image_key(&s, id)].gray_source();
assert!(source.is_some_and(|g| !g.alpha), "an opaque image has no alpha channel: {source:?}");
}
/// Select All leaves a hidden neutral RGB image out of Convert to Grayscale, which would change it.
#[test]
fn a_hidden_neutral_rgb_image_counts_as_not_converted() {
let mut s = session();
let hidden = image_of(&mut s, [[255, 255, 255, 255], [0, 0, 0, 255], [255, 255, 255, 255], [0, 0, 0, 255]]);
s.execute("layer.setProps", &json!({"ids": [hidden.0], "visible": false})).unwrap();
rect(&mut s, json!("#ff0000"));
s.execute("select.all", &json!({})).unwrap();
let r = s.execute("edit.colors.toGrayscale", &json!({})).unwrap();
assert_eq!(r["notChanged"], json!({"locked": 0, "hidden": 1}), "{r}");
}
/// Adjust Colors converting to gray stores an image with one channel; without Convert it keeps
/// the image's RGB (#5632).
#[test]
fn adjust_colors_converting_to_gray_stores_an_image_as_grayscale() {
let source = |convert: bool| {
let mut s = session();
let id = image_node(&mut s);
s.execute("edit.colors.adjustBalance", &json!({"mode": "gray", "gray": 10, "convert": convert})).unwrap();
s.doc().unwrap().doc.images[&image_key(&s, id)].gray_source()
};
assert!(source(true).is_some(), "converted to gray");
assert!(source(false).is_none(), "the colors keep their model");
}
/// 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() {
let mut s = session();
s.execute("file.documentColorMode", &json!({"mode": "cmyk"})).unwrap();
image_of(&mut s, [[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [10, 200, 30, 255]]);
s.execute("edit.colors.toGrayscale", &json!({})).unwrap();
let opts = vectorcraft_pdf::PdfOptions { created: Some(0), ..vectorcraft_pdf::PdfOptions::uncompressed() };
let pdf = String::from_utf8_lossy(&vectorcraft_pdf::export(&s.doc().unwrap().doc, &opts).unwrap()).into_owned();
let image = pdf.split("/Subtype/Image").nth(1).or_else(|| pdf.split("/Subtype /Image").nth(1)).expect("an image in the PDF");
let image = &image[..image.find("stream").unwrap_or(image.len())];
assert!(image.contains("/DeviceGray") && !image.contains("/DeviceRGB"), "one color channel: {image}");
}
/// "Dots": a red square tile; a selected rectangle filled with it.
fn pattern_fill(s: &mut Session) -> NodeId {
rect(s, json!("#ff0000"));
+2
View File
@@ -2507,6 +2507,8 @@ the image; linked images are left alone) and the tiles of pattern fills and stro
pattern swatch, e.g. "Dots 2"; the original pattern is untouched). Colorized image fills are recolored when fills are in
scope, but their pixels are never recolored. `includeImages: false` leaves other images' pixels unchanged.
For `edit.colors.*`, `fill: false` leaves colorized image fills unchanged. `recolor.apply` always includes fills.
`edit.colors.toGrayscale` and `edit.colors.adjustBalance` with `mode: "gray"` and `convert: true` store an embedded image
with one gray channel, so a PDF writes it in gray.
`includePatterns: false` leaves patterns out. The Blend commands also grade gradient meshes, which keep their shading.
`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 a value that is not a number, it answers `` missing number `intensity` `` and changes nothing.