mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 09:39:52 +00:00
Merge pull request #6977 from slafebre/fix/6969-rasterize-gray
Fix #6969: Rasterize to Grayscale or Bitmap makes a grayscale image
This commit is contained in:
@@ -115,7 +115,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Rasterize…",
|
||||
["Object"],
|
||||
None,
|
||||
"{ppi?, background?: transparent|white|black|\"#rrggbb\" (#rrggbbaa and #rgba are refused), antiAlias?: none|art|type (text snapped to pixels)|bool, padding?|addAround?: pt (0–1000), colorModel?: \"rgb\"|\"cmyk\"|\"grayscale\"|\"bitmap\", clippingMask?: bool (a clip group with the art's outline)} replace the selection with an embedded image (PNG; in a CMYK document with its own colour model, a CMYK image of the art's inks, its transparency as its opacity mask); each defaults to document.rasterEffectsSettings; an image larger than 64 Mpx or 32,768 px a side is refused; a frame of threaded text that is rasterized leaves its thread with the text it shows → {id (the image, or its clip group), width, height, warning?: \"an image couldn't be decoded and is missing from the picture\" (as raster exports say)}",
|
||||
"{ppi?, background?: transparent|white|black|\"#rrggbb\" (#rrggbbaa and #rgba are refused), antiAlias?: none|art|type (text snapped to pixels)|bool, padding?|addAround?: pt (0–1000), colorModel?: \"rgb\"|\"cmyk\"|\"grayscale\"|\"bitmap\", clippingMask?: bool (a clip group with the art's outline)} replace the selection with an embedded image (PNG; grayscale makes a gray PNG, and bitmap a 1-bit TIFF or, where it has transparency, a black-and-white gray PNG with alpha; in a CMYK document with its own colour model, a CMYK image of the art's inks, its transparency as its opacity mask); each defaults to document.rasterEffectsSettings; an image larger than 64 Mpx or 32,768 px a side is refused; a frame of threaded text that is rasterized leaves its thread with the text it shows → {id (the image, or its clip group), width, height, warning?: \"an image couldn't be decoded and is missing from the picture\" (as raster exports say)}",
|
||||
has_selection,
|
||||
rasterize
|
||||
),
|
||||
@@ -729,7 +729,9 @@ fn cmyk_raster(
|
||||
/// The image Object › Rasterize makes of some art, before it goes in a document
|
||||
/// ([`rasterize_art`]).
|
||||
pub(crate) struct Rasterized {
|
||||
/// A PNG, or a CMYK image of the art's inks ([`cmyk_raster`]).
|
||||
/// A PNG (greyscale for the Grayscale colour model), a 1-bit TIFF or greyscale PNG for Bitmap
|
||||
/// ([`vectorcraft_render::Rendered::to_bilevel`]), or a CMYK image of the art's inks
|
||||
/// ([`cmyk_raster`]).
|
||||
blob: vectorcraft_doc::ImageBlob,
|
||||
/// A CMYK image's transparency, a greyscale PNG: it becomes the image's opacity mask.
|
||||
alpha: Option<Vec<u8>>,
|
||||
@@ -769,7 +771,8 @@ impl Rasterized {
|
||||
/// Object › Rasterize's image of `nodes` (drawn also where hidden) with the params `p` of command
|
||||
/// `cmd`, each defaulting to `doc`'s raster effects settings: over the bounds of what the art draws
|
||||
/// ([`vectorcraft_render::drawn_bounds`]) and the room around them, in whole pixels. With `inks`,
|
||||
/// a CMYK document's colour model makes a CMYK image ([`cmyk_raster`]); otherwise images are PNGs.
|
||||
/// a CMYK document's colour model makes a CMYK image ([`cmyk_raster`]); otherwise images are PNGs,
|
||||
/// greyscale for the Grayscale and Bitmap colour models (an opaque Bitmap image is a 1-bit TIFF).
|
||||
pub(crate) fn rasterize_art(doc: &vectorcraft_doc::Document, nodes: Vec<Node>, p: &Value, cmd: &str, inks: bool) -> Result<Rasterized> {
|
||||
check_numbers(p, &["ppi"], cmd)?;
|
||||
let ppi = f64_or(p, "ppi", doc.raster_effects_ppi);
|
||||
@@ -836,10 +839,17 @@ pub(crate) fn rasterize_art(doc: &vectorcraft_doc::Document, nodes: Vec<Node>, p
|
||||
let tmp = isolated_doc(doc, nodes);
|
||||
let opts = raster.render_options(RasterFormat::Png);
|
||||
let (img, warning) = render_pixels(&tmp, region, scale, &opts, look.color_model)?;
|
||||
let (blob, alpha) = if inks && doc.color_mode == vectorcraft_doc::ColorMode::Cmyk && look.color_model == RasterColorModel::Document {
|
||||
cmyk_raster(&tmp, region, scale, &opts, &img)?
|
||||
} else {
|
||||
(vectorcraft_doc::ImageBlob::new("image/png", img.to_png().map_err(EngineError::Other)?), None)
|
||||
let png =
|
||||
|bytes: std::result::Result<Vec<u8>, String>| bytes.map(|b| vectorcraft_doc::ImageBlob::new("image/png", b)).map_err(EngineError::Other);
|
||||
let (blob, alpha) = match look.color_model {
|
||||
RasterColorModel::Document if inks && doc.color_mode == vectorcraft_doc::ColorMode::Cmyk => cmyk_raster(&tmp, region, scale, &opts, &img)?,
|
||||
RasterColorModel::Document => (png(img.to_png())?, None),
|
||||
// Grayscale images, as the colour models say (#6969).
|
||||
RasterColorModel::Grayscale => (png(img.to_gray_png())?, None),
|
||||
RasterColorModel::Bitmap => {
|
||||
let (mime, bytes) = img.to_bilevel(ppi).map_err(EngineError::Other)?;
|
||||
(vectorcraft_doc::ImageBlob::new(mime, bytes), None)
|
||||
}
|
||||
};
|
||||
let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale);
|
||||
Ok(Rasterized { blob, alpha, width: img.width, height: img.height, xf, clip, warning })
|
||||
|
||||
@@ -455,3 +455,50 @@ fn rasterize_to_cmyk_makes_a_cmyk_image() {
|
||||
assert_eq!((alpha.get_pixel(1, 1).0, alpha.get_pixel(25, 25).0), ([0], [255]));
|
||||
assert!(mask.clip);
|
||||
}
|
||||
|
||||
/// Rasterize with the Grayscale or Bitmap colour model makes a grayscale image (#6969): the
|
||||
/// image says Grayscale, its file holds one gray channel (one bit a pixel for an opaque Bitmap,
|
||||
/// gray and alpha over a transparent background), in a CMYK document too, and a fill color
|
||||
/// colorizes it.
|
||||
#[test]
|
||||
fn rasterize_to_grayscale_or_bitmap_makes_a_grayscale_image() {
|
||||
for mode in ["rgb", "cmyk"] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100, "colorMode": mode})).unwrap();
|
||||
let r = id(&s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap());
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000", "ids": [r.0]})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"none": true, "ids": [r.0]})).unwrap();
|
||||
let mut rasterize = |model: &str, background: &str| {
|
||||
s.execute("select.set", &json!({"ids": [r.0]})).unwrap();
|
||||
let p = json!({"ppi": 72, "colorModel": model, "background": background, "addAround": 5});
|
||||
let img = id(&s.execute("object.rasterize", &p).unwrap());
|
||||
let info = s.execute("image.info", &json!({"id": img.0})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let n = d.node(img).unwrap();
|
||||
assert!(n.extras().mask.is_none(), "{mode} {model} {background}: no opacity mask");
|
||||
let NodeKind::Image(im) = &n.kind else { panic!("an image") };
|
||||
let blob = d.images[&im.key].clone();
|
||||
let pixels = image::load_from_memory(&blob.bytes).unwrap().to_luma_alpha8();
|
||||
assert_eq!(pixels.dimensions(), (50, 50));
|
||||
let at = |x, y| pixels.get_pixel(x, y).0;
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
(info["colorMode"].clone(), blob.gray_source(), at(25, 25), at(1, 1))
|
||||
};
|
||||
let gray = |one_bit, alpha| Some(vectorcraft_doc::GraySource { one_bit, alpha });
|
||||
let (info, source, [level, alpha], corner) = rasterize("grayscale", "white");
|
||||
assert_eq!((info, source, alpha, corner), (json!("Grayscale"), gray(false, false), 255, [255, 255]), "{mode}");
|
||||
if mode == "rgb" {
|
||||
assert_eq!(level, 54, "red's Rec. 709 luma");
|
||||
} else {
|
||||
assert!((1..=254).contains(&level), "the CMYK red's screen color as a gray: {level}");
|
||||
}
|
||||
assert_eq!(rasterize("grayscale", "transparent"), (json!("Grayscale"), gray(false, true), [level, 255], [255, 0]), "{mode}");
|
||||
assert_eq!(rasterize("bitmap", "white"), (json!("Grayscale"), gray(true, false), [0, 255], [255, 255]), "{mode}");
|
||||
assert_eq!(rasterize("bitmap", "transparent"), (json!("Grayscale"), gray(false, true), [0, 255], [255, 0]), "{mode}");
|
||||
// A fill color colorizes the image, as it does any grayscale image.
|
||||
s.execute("select.set", &json!({"ids": [r.0]})).unwrap();
|
||||
let img = id(&s.execute("object.rasterize", &json!({"ppi": 72, "colorModel": "grayscale"})).unwrap());
|
||||
s.execute("paint.setFill", &json!({"color": "#0000ff", "ids": [img.0]})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.node(img).unwrap().extras().colorize.is_some(), "{mode}: colorized");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,21 @@ pub fn encode(rgba: &[u8], width: u32, height: u32, o: &PngOptions) -> Result<Ve
|
||||
write(&pixels, width, height, Layout { channels, bits: 8, color_type }, &[], o)
|
||||
}
|
||||
|
||||
/// Encode grey levels with alpha (two bytes a pixel, `width`×`height`, row-major) as a greyscale
|
||||
/// PNG: grey and alpha (colour type 4), or grey alone (colour type 0) when every pixel is opaque.
|
||||
pub fn encode_gray(gray_alpha: &[u8], width: u32, height: u32, o: &PngOptions) -> Result<Vec<u8>, String> {
|
||||
check_size(width, height)?;
|
||||
if gray_alpha.len() as u64 != u64::from(width) * u64::from(height) * 2 {
|
||||
return Err("PNG encoding failed: pixel buffer doesn't match the image size".into());
|
||||
}
|
||||
let px = gray_alpha.as_chunks::<2>().0;
|
||||
if px.iter().all(|p| p[1] == 255) {
|
||||
let levels: Vec<u8> = px.iter().map(|p| p[0]).collect();
|
||||
return write(&levels, width, height, Layout { channels: 1, bits: 8, color_type: 0 }, &[], o);
|
||||
}
|
||||
write(gray_alpha, width, height, Layout { channels: 2, bits: 8, color_type: 4 }, &[], o)
|
||||
}
|
||||
|
||||
/// Encode a palette image as an indexed PNG (colour type 3: `PLTE`, and `tRNS` for its
|
||||
/// transparent entry) at 1, 2, 4 or 8 bits per pixel, as few as the palette needs.
|
||||
pub fn encode_indexed(img: &Indexed, o: &PngOptions) -> Result<Vec<u8>, String> {
|
||||
|
||||
@@ -67,6 +67,41 @@ fn png_refuses_a_buffer_of_the_wrong_size() {
|
||||
assert!(encode(&[], 0, 0, &PngOptions::default()).is_err());
|
||||
}
|
||||
|
||||
/// Object › Rasterize's Grayscale and Bitmap images (#6969): a grey PNG of one channel when
|
||||
/// opaque and grey and alpha otherwise; Bitmap's black and white as a 1-bit TIFF when opaque,
|
||||
/// else as a grey PNG with alpha. Odd widths keep each row on a byte.
|
||||
#[test]
|
||||
fn gray_and_bilevel_images_store_gray() {
|
||||
let header = |file: &[u8]| (file[24], file[25]);
|
||||
let gray = |file: &[u8]| image::load_from_memory(file).unwrap().to_luma_alpha8().into_raw();
|
||||
// 3 × 2, premultiplied: black, red, light grey; white, mid grey, almost black.
|
||||
let mut img = Rendered {
|
||||
width: 3,
|
||||
height: 2,
|
||||
pixels: [[0, 0, 0], [255, 0, 0], [200; 3], [255; 3], [128; 3], [127; 3]].iter().flat_map(|&[r, g, b]| [r, g, b, 255]).collect(),
|
||||
};
|
||||
let levels: Vec<u8> = [0u8, 54, 200, 255, 128, 127].iter().flat_map(|&g| [g, 255]).collect();
|
||||
let file = img.to_gray_png().unwrap();
|
||||
assert_eq!(header(&file), (8, 0), "8-bit grey");
|
||||
assert_eq!(gray(&file), levels);
|
||||
let (mime, file) = img.to_bilevel(150.0).unwrap();
|
||||
assert_eq!(mime, "image/tiff");
|
||||
let mut d = ::tiff::decoder::Decoder::new(std::io::Cursor::new(&file)).unwrap();
|
||||
assert_eq!(d.colortype().unwrap(), ::tiff::ColorType::Gray(1));
|
||||
let bw: Vec<u8> = [0u8, 0, 255, 255, 255, 0].iter().flat_map(|&g| [g, 255]).collect();
|
||||
assert_eq!(gray(&file), bw, "from 128 up is white");
|
||||
// Half-covered white over the light grey; a clear pixel is white.
|
||||
img.pixels[8..12].copy_from_slice(&[100, 100, 100, 100]);
|
||||
img.pixels[20..24].copy_from_slice(&[0; 4]);
|
||||
let file = img.to_gray_png().unwrap();
|
||||
assert_eq!(header(&file), (8, 4), "grey and alpha");
|
||||
assert_eq!(gray(&file), [0, 255, 54, 255, 255, 100, 255, 255, 128, 255, 255, 0]);
|
||||
let (mime, file) = img.to_bilevel(150.0).unwrap();
|
||||
assert_eq!((mime, header(&file)), ("image/png", (8, 4)), "no 1-bit grey with alpha");
|
||||
assert_eq!(gray(&file), [0, 255, 0, 255, 255, 100, 255, 255, 255, 255, 255, 0], "still black and white");
|
||||
assert!(super::png::encode_gray(&levels[..10], 3, 2, &PngOptions::default()).is_err());
|
||||
}
|
||||
|
||||
/// A 40×30 pt document with a black circle off the pixel grid.
|
||||
fn circle_doc() -> Document {
|
||||
let mut d = Document::new(40.0, 30.0);
|
||||
|
||||
@@ -356,6 +356,58 @@ impl Rendered {
|
||||
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).map_err(|e| format!("PNG encoding failed: {e}"))?;
|
||||
Ok(buf)
|
||||
}
|
||||
/// Each pixel's grey level and alpha: the Rec. 709 luma of its straight colour, with `bilevel`
|
||||
/// black below 128 and white from there up. A clear pixel is white: no ink where a fill
|
||||
/// colour colorizes the image.
|
||||
fn gray_alpha(&self, bilevel: bool) -> Vec<u8> {
|
||||
let straight = self.to_straight();
|
||||
let px = straight.as_chunks::<4>().0;
|
||||
px.iter()
|
||||
.flat_map(|&[r, g, b, a]| {
|
||||
let y = (0.2126 * f32::from(r) + 0.7152 * f32::from(g) + 0.0722 * f32::from(b)).round().clamp(0.0, 255.0) as u8;
|
||||
let y = if a == 0 || (bilevel && y >= 128) {
|
||||
255
|
||||
} else if bilevel {
|
||||
0
|
||||
} else {
|
||||
y
|
||||
};
|
||||
[y, a]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// Encode as a greyscale PNG without metadata (Object › Rasterize's Grayscale colour model):
|
||||
/// grey levels ([`Self::gray_alpha`]), with alpha only when some pixel isn't opaque.
|
||||
pub fn to_gray_png(&self) -> Result<Vec<u8>, String> {
|
||||
encode::png::encode_gray(&self.gray_alpha(false), self.width, self.height, &encode::png::PngOptions::default())
|
||||
}
|
||||
/// Encode in black and white (Object › Rasterize's Bitmap colour model): a 1-bit TIFF at `ppi`
|
||||
/// when every pixel is opaque, else a grey PNG with alpha of only black and white (one-bit grey
|
||||
/// with alpha is no format readers take). Returns the media type and the file.
|
||||
pub fn to_bilevel(&self, ppi: f64) -> Result<(&'static str, Vec<u8>), String> {
|
||||
let gray_alpha = self.gray_alpha(true);
|
||||
let px = gray_alpha.as_chunks::<2>().0;
|
||||
if !px.iter().all(|p| p[1] == 255) {
|
||||
return Ok(("image/png", encode::png::encode_gray(&gray_alpha, self.width, self.height, &encode::png::PngOptions::default())?));
|
||||
}
|
||||
// WhiteIsZero: a set bit is black. Each row starts on a byte.
|
||||
let mut bits = Vec::with_capacity(px.len() / 8 + self.height as usize);
|
||||
for row in px.chunks(self.width.max(1) as usize) {
|
||||
bits.extend(row.chunks(8).map(|byte| byte.iter().enumerate().fold(0u8, |b, (i, p)| b | u8::from(p[0] == 0) << (7 - i))));
|
||||
}
|
||||
let image = encode::tiff::Image {
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
data: &bits,
|
||||
photometric: encode::tiff::Photometric::WhiteIsZero,
|
||||
samples: 1,
|
||||
bits: 1,
|
||||
alpha: false,
|
||||
};
|
||||
let layout =
|
||||
encode::tiff::Layout { byte_order: encode::tiff::ByteOrder::Little, compression: encode::tiff::Compression::PackBits, ppi, icc: None };
|
||||
Ok(("image/tiff", encode::tiff::write(&image, &layout)?))
|
||||
}
|
||||
/// Encode as JPEG (flattened on white) at `quality` 1..=100.
|
||||
pub fn to_jpeg(&self, quality: u8) -> Result<Vec<u8>, String> {
|
||||
encode::jpeg(self, quality, None)
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user