mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-01 13:47:21 -06:00
Images: SVG and EPS export write colorized grayscale and 1-bit images in their fill color
SVG export ignored an image's colorize paint and wrote the image's own black, white and gray pixels. EPS export flattened every colorized image into a process-color raster, so a spot color lost its plate. Both now write the image in the fill color, with the coverage the canvas and PDF export use (darkness times alpha), so white and transparent pixels take no color. - SVG writes a coverage PNG as a mask over a rectangle in the color, shared between repeated images while each keeps its own color. Linked mode writes the coverage PNG as a file, and SVG Tiny 1.2 writes a PNG in the color. A colorized image whose pixels are missing, or whose grayscale pixels can't be decoded, is left out with a warning. - EPS writes fully covered or uncovered pixels as an imagemask in the color's process or Separation ink (Registration on every plate), and a grayscale image as ink samples scaled by coverage. Level 3 masks its uncovered pixels out and Level 2 writes them white; gray midtones are written over white, with the existing warnings. - EPS flattening no longer counts a solid colorize paint on a grayscale or 1-bit image as transparency by itself. Opacity, blending modes, opacity masks, effects and overlapping transparent art still flatten it, and so does a colorize paint the writer can't draw. The svg crate's tests turn on resvg's raster-images feature to render the embedded PNGs. Docs: docs/mcp.md, docs/target-app-parity.md.
This commit is contained in:
@@ -152,6 +152,8 @@ fn parse(p: &Value) -> Result<Settings> {
|
||||
let overprint = choice("overprints", q.overprints.clone(), Overprint::from_id, d.overprint, "preserve or discard")?;
|
||||
let mut flatten = flattener(&q, &[])?;
|
||||
flatten.preserve_overprints &= overprint == Overprint::Preserve;
|
||||
// The writer draws colorized images in their ink.
|
||||
flatten.keeps_colorize = true;
|
||||
let level = q.level.as_ref().map(super::dxf::text);
|
||||
let eps = EpsOptions {
|
||||
level: choice("level", level, Level::from_id, d.level, "2 or 3")?,
|
||||
|
||||
@@ -379,6 +379,38 @@ fn colorized_gray_image_is_omitted_from_legacy_ai_when_requested() {
|
||||
assert!(out["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("are left out")), "{}", out["warnings"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eps_export_writes_a_colorized_image_in_its_spot_ink_without_flattening() {
|
||||
let mut s = session();
|
||||
let id = image(&mut s, "gray", gray(false));
|
||||
s.execute("paint.setFill", &json!({"swatch": "Ink", "ids": [id.0]})).unwrap();
|
||||
let out = s.execute("document.exportEps", &json!({"previewFormat": "none", "useArtboards": false})).unwrap();
|
||||
assert!(out["warnings"].as_array().unwrap().iter().all(|w| !w.as_str().unwrap().contains("transparency is flattened")), "{}", out["warnings"]);
|
||||
let bytes = vectorcraft_format::base64_decode(out["dataBase64"].as_str().unwrap()).unwrap();
|
||||
let ps = String::from_utf8_lossy(&bytes);
|
||||
assert!(ps.contains("imagemask") && ps.contains("/Separation (Ink) cvn"), "the image prints on the Ink plate");
|
||||
assert!(ps.contains("%%DocumentCustomColors: (Ink)"));
|
||||
|
||||
// Opacity still flattens it.
|
||||
s.execute("transparency.set", &json!({"ids": [id.0], "opacity": 50})).unwrap();
|
||||
let out = s.execute("document.exportEps", &json!({"previewFormat": "none", "useArtboards": false})).unwrap();
|
||||
assert!(out["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("transparency is flattened")), "{}", out["warnings"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eps_export_still_flattens_a_colorize_paint_on_a_color_image() {
|
||||
let mut s = session();
|
||||
let rgb = png(image::DynamicImage::ImageRgb8(image::RgbImage::from_raw(2, 1, vec![0, 128, 255, 255, 0, 0]).unwrap()));
|
||||
let id = image(&mut s, "rgb", rgb);
|
||||
s.edit("Colorize", |d, _| {
|
||||
d.node_mut(id).unwrap().extras_mut().colorize = Some(Box::new(Paint::solid(Color::rgb8(255, 0, 0))));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let out = s.execute("document.exportEps", &json!({"previewFormat": "none", "useArtboards": false})).unwrap();
|
||||
assert!(out["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("transparency is flattened")), "{}", out["warnings"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flattened_colorized_image_shows_the_art_under_white_pixels() {
|
||||
let mut s = session();
|
||||
|
||||
+155
-1
@@ -119,6 +119,8 @@ pub(crate) struct Scene<'a> {
|
||||
brushes: Option<Vec<vectorcraft_brush::Brush>>,
|
||||
/// Image keys → what draws them (`None`: left out).
|
||||
images: HashMap<String, Option<Arc<String>>>,
|
||||
/// The source key and solid paint together identify a colorized encoding.
|
||||
colorized_images: HashMap<(String, String), Option<Arc<String>>>,
|
||||
/// Symbols and brush art being written inside one another.
|
||||
nest: u32,
|
||||
}
|
||||
@@ -137,6 +139,7 @@ impl<'a> Scene<'a> {
|
||||
warnings: vec![],
|
||||
brushes: None,
|
||||
images: HashMap::new(),
|
||||
colorized_images: HashMap::new(),
|
||||
nest: 0,
|
||||
}
|
||||
}
|
||||
@@ -297,7 +300,15 @@ impl<'a> Scene<'a> {
|
||||
self.shape(n, &bp, *rule);
|
||||
}
|
||||
NodeKind::Text(t) => self.text(n, t),
|
||||
NodeKind::Image(im) => self.image(&im.key, self.doc.image_blob(im), im.width, im.height, im.xf),
|
||||
NodeKind::Image(im) => {
|
||||
let blob = self.doc.image_blob(im);
|
||||
match n.extras().colorize.as_deref() {
|
||||
Some(p @ Paint::Solid { .. }) if blob.is_none_or(|b| b.gray_source().is_some()) => {
|
||||
self.colorized_image(&im.key, blob, im.width, im.height, im.xf, p);
|
||||
}
|
||||
_ => self.image(&im.key, blob, im.width, im.height, im.xf),
|
||||
}
|
||||
}
|
||||
NodeKind::SymbolInstance { symbol, xf } => {
|
||||
if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol)
|
||||
&& self.nest < MAX_NEST
|
||||
@@ -886,6 +897,149 @@ impl<'a> Scene<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Gray coverage in the selected process or Separation ink. Binary coverage is a stencil;
|
||||
/// continuous coverage leaves zero pixels untouched at Level 3 and writes them as paper at Level 2.
|
||||
fn colorized_image(&mut self, key: &str, blob: Option<&ImageBlob>, width: u32, height: u32, xf: Affine, paint: &Paint) {
|
||||
let place = xf * Affine::scale_non_uniform(f64::from(width.max(1)), f64::from(height.max(1)));
|
||||
if !invertible(place) {
|
||||
return;
|
||||
}
|
||||
let cache = (key.to_string(), format!("{paint:?}"));
|
||||
let encoded = match self.colorized_images.get(&cache) {
|
||||
Some(e) => e.clone(),
|
||||
None => {
|
||||
let e = self.encode_colorized(blob, paint).map(Arc::new);
|
||||
self.colorized_images.insert(cache, e.clone());
|
||||
e
|
||||
}
|
||||
};
|
||||
if let Some(e) = encoded {
|
||||
self.place_image(&e, place);
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_colorized(&mut self, blob: Option<&ImageBlob>, paint: &Paint) -> Option<String> {
|
||||
let Some((w, h, coverage)) = blob.and_then(ImageBlob::coverage) else {
|
||||
self.warn(UNDECODED);
|
||||
return None;
|
||||
};
|
||||
if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_IMAGE_PIXELS {
|
||||
self.warn(LARGE_IMAGE);
|
||||
return None;
|
||||
}
|
||||
let Paint::Solid { color, swatch, tint } = paint else { return None };
|
||||
let ink = self.solid(color, swatch.as_deref(), *tint);
|
||||
let binary = coverage.iter().all(|&v| v == 0 || v == 255);
|
||||
let clear = coverage.contains(&0);
|
||||
if coverage.iter().any(|&v| v > 0 && v < 255) {
|
||||
self.warn(IMAGE_PARTLY);
|
||||
}
|
||||
let keyed = !binary && clear && self.o.level == Level::Three;
|
||||
if !binary && clear && !keyed {
|
||||
self.warn(IMAGE_ALPHA_L2);
|
||||
}
|
||||
// Zero tint is uncommon for covered spot pixels; process uses the same rare key as
|
||||
// ordinary images. Nudge a covered collision so only clear pixels match the mask.
|
||||
let key = match ink {
|
||||
Ink::Spot { .. } => [0, 0, 0, 0],
|
||||
Ink::Process(Space::Gray, ..) => [0, 0, 0, 0],
|
||||
Ink::Process(..) => [255, 0, 255, 0],
|
||||
};
|
||||
let mut data = Vec::new();
|
||||
if binary {
|
||||
let stride = (w as usize).div_ceil(8);
|
||||
data.resize(stride.saturating_mul(h as usize), 0);
|
||||
for (i, &v) in coverage.iter().enumerate() {
|
||||
if v == 255
|
||||
&& let Some(byte) = data.get_mut((i / w as usize) * stride + (i % w as usize) / 8)
|
||||
{
|
||||
*byte |= 0x80 >> (i % w as usize % 8);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for &v in &coverage {
|
||||
if v == 0 && keyed {
|
||||
let n = match ink {
|
||||
Ink::Spot { .. } => 1,
|
||||
Ink::Process(space, ..) => space.n(),
|
||||
};
|
||||
data.extend(key.iter().take(n));
|
||||
continue;
|
||||
}
|
||||
let a = f32::from(v) / 255.0;
|
||||
match ink {
|
||||
Ink::Spot { tint, .. } => {
|
||||
let mut sample = (255.0 * tint * a).round() as u8;
|
||||
if keyed && sample == key[0] {
|
||||
sample = 1;
|
||||
}
|
||||
data.push(sample);
|
||||
}
|
||||
Ink::Process(space, components) => {
|
||||
let paper = if space == Space::Cmyk { 0.0 } else { 1.0 };
|
||||
let n = space.n();
|
||||
let mut samples = [0; 4];
|
||||
for (sample, &component) in samples.iter_mut().zip(components.iter()).take(n) {
|
||||
*sample = (255.0 * (paper + (component - paper) * a)).round() as u8;
|
||||
}
|
||||
if keyed && samples.iter().take(n).eq(key.iter().take(n)) {
|
||||
samples[if n > 1 { 1 } else { 0 }] = 1;
|
||||
}
|
||||
data.extend(samples.iter().take(n));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let (filter, encoded) = match self.o.level {
|
||||
Level::Three => ("/FlateDecode", ps::deflate(&data)),
|
||||
Level::Two => {
|
||||
let mut rl = Vec::new();
|
||||
ps::packbits(&data, &mut rl);
|
||||
rl.push(128);
|
||||
("/RunLengthDecode", rl)
|
||||
}
|
||||
};
|
||||
let mut out = String::new();
|
||||
if binary {
|
||||
// imagemask takes its current ink from the graphics state.
|
||||
out.push_str(&self.ink_code(ink));
|
||||
} else {
|
||||
match ink {
|
||||
Ink::Spot { space, .. } => {
|
||||
let _ = writeln!(out, "VCsp{space} setcolorspace");
|
||||
}
|
||||
Ink::Process(space, ..) => {
|
||||
let _ = writeln!(out, "{} setcolorspace", space.name());
|
||||
}
|
||||
}
|
||||
}
|
||||
let n = match ink {
|
||||
Ink::Spot { .. } => 1,
|
||||
Ink::Process(space, ..) => space.n(),
|
||||
};
|
||||
let decode = if binary { "1 0".to_string() } else { "0 1 ".repeat(n).trim_end().to_string() };
|
||||
let _ = write!(
|
||||
out,
|
||||
"{{ /VCsrc currentfile /ASCII85Decode filter def\n<< /ImageType {} /Width {w} /Height {h} /BitsPerComponent {} /Decode [{decode}] /ImageMatrix [{w} 0 0 {h} 0 0]",
|
||||
if keyed { 4 } else { 1 },
|
||||
if binary { 1 } else { 8 },
|
||||
);
|
||||
if keyed {
|
||||
let mask: Vec<String> = key.iter().take(n).map(|v| format!("{v} {v}")).collect();
|
||||
let _ = write!(out, " /MaskColor [{}]", mask.join(" "));
|
||||
}
|
||||
let _ = writeln!(out, "\n/DataSource VCsrc {filter} filter >> {}\nVCsrc flushfile }} exec", if binary { "imagemask" } else { "image" });
|
||||
out.push_str(&ps::ascii85(&encoded));
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Ink-setting operators in the cached stencil body, built with the same path as solid fills.
|
||||
fn ink_code(&mut self, ink: Ink) -> String {
|
||||
let body = std::mem::take(&mut self.body);
|
||||
self.set_ink(ink);
|
||||
std::mem::replace(&mut self.body, body)
|
||||
}
|
||||
|
||||
/// Image `blob` as RGBA pixels; `None` (with a warning) when it can't be written.
|
||||
fn decode_image(&mut self, blob: Option<&ImageBlob>) -> Option<image::RgbaImage> {
|
||||
let Some(img) = blob.and_then(|blob| image::load_from_memory(&blob.bytes).ok()) else {
|
||||
|
||||
@@ -331,6 +331,187 @@ fn unpack(data: &[u8]) -> Vec<u8> {
|
||||
out
|
||||
}
|
||||
|
||||
fn colorized_doc(gray: Vec<u8>, paint: Option<Paint>) -> Document {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
d.swatches.push(Swatch { name: "Ink".into(), paint: Paint::solid(Color::cmyk(0.0, 1.0, 1.0, 0.0)), global: true, spot: true });
|
||||
let mut png = Vec::new();
|
||||
image::DynamicImage::ImageLuma8(image::GrayImage::from_raw(gray.len() as u32, 1, gray).unwrap())
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
d.images.insert("gray".into(), ImageBlob::png(png));
|
||||
let im = ImageObject {
|
||||
key: "gray".into(),
|
||||
width: 3,
|
||||
height: 1,
|
||||
xf: Affine::translate((10.0, 10.0)) * Affine::scale(10.0),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
};
|
||||
let mut n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)));
|
||||
n.extras_mut().colorize = paint.map(Box::new);
|
||||
d.insert(d.default_layer(), 0, n).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn one_bit_blob() -> ImageBlob {
|
||||
// Uncompressed two-pixel TIFF, BlackIsZero; black then white.
|
||||
let entries: [(u16, u16, u32); 9] =
|
||||
[(256, 3, 2), (257, 3, 1), (258, 3, 1), (259, 3, 1), (262, 3, 1), (273, 4, 122), (277, 3, 1), (278, 3, 1), (279, 4, 1)];
|
||||
let mut bytes = b"II*\0".to_vec();
|
||||
bytes.extend_from_slice(&8u32.to_le_bytes());
|
||||
bytes.extend_from_slice(&9u16.to_le_bytes());
|
||||
for (tag, kind, value) in entries {
|
||||
bytes.extend_from_slice(&tag.to_le_bytes());
|
||||
bytes.extend_from_slice(&kind.to_le_bytes());
|
||||
bytes.extend_from_slice(&1u32.to_le_bytes());
|
||||
bytes.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
bytes.extend_from_slice(&0u32.to_le_bytes());
|
||||
bytes.push(0b0100_0000);
|
||||
ImageBlob::new("image/tiff", bytes)
|
||||
}
|
||||
|
||||
fn colorized_samples(d: &Document, level: Level) -> (String, Vec<u8>, Vec<String>) {
|
||||
let out = export(d, &EpsOptions { level, ..opts(d) }, None, None).unwrap();
|
||||
let ps = text(&out);
|
||||
checked_page(&ps);
|
||||
let encoded = image_data(&ps);
|
||||
let samples = if level == Level::Three { ps::inflate(&encoded, 1 << 20).unwrap() } else { unpack(&encoded) };
|
||||
(ps, samples, out.warnings)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_colorized_images_are_ink_stencils_at_both_levels() {
|
||||
for (paint, setup) in [
|
||||
(Paint::solid(Color::rgb8(255, 0, 0)), "1 0 0 rg"),
|
||||
(Paint::Solid { color: Color::rgb8(255, 0, 0), swatch: Some("Ink".into()), tint: 0.5 }, "VCsp0 setcolorspace 0.5 setcolor"),
|
||||
(Paint::registration(), "VCsp0 setcolorspace 1 setcolor"),
|
||||
] {
|
||||
let d = colorized_doc(vec![0, 255, 0], Some(paint.clone()));
|
||||
for level in [Level::Two, Level::Three] {
|
||||
let (ps, samples, warnings) = colorized_samples(&d, level);
|
||||
assert!(ps.contains("imagemask") && ps.contains("/BitsPerComponent 1 /Decode [1 0]"), "{ps}");
|
||||
assert!(ps.contains(setup), "{ps}");
|
||||
assert_eq!(samples, vec![0b1010_0000]);
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
assert!(ps.contains(if level == Level::Two { "/RunLengthDecode" } else { "/FlateDecode" }));
|
||||
if matches!(paint, Paint::Solid { swatch: Some(_), .. }) {
|
||||
assert!(ps.contains("/Separation"));
|
||||
}
|
||||
if paint.is_registration() {
|
||||
assert!(ps.contains("/All /DeviceCMYK"));
|
||||
assert!(dsc(&ps, "DocumentCustomColors").is_none());
|
||||
} else if matches!(paint, Paint::Solid { swatch: Some(_), .. }) {
|
||||
assert_eq!(dsc(&ps, "DocumentCustomColors").as_deref(), Some("(Ink)"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_bit_source_uses_the_same_stencil_coverage() {
|
||||
let mut d = colorized_doc(vec![0, 255, 255], Some(Paint::solid(Color::rgb8(0, 0, 255))));
|
||||
d.images.insert("gray".into(), one_bit_blob());
|
||||
let id = d.layers[0].children().unwrap()[0].id;
|
||||
if let NodeKind::Image(im) = &mut d.node_mut(id).unwrap().kind {
|
||||
im.width = 2;
|
||||
}
|
||||
assert!(d.images["gray"].gray_source().unwrap().one_bit);
|
||||
let (_, samples, warnings) = colorized_samples(&d, Level::Three);
|
||||
assert_eq!(samples, vec![0b1000_0000]);
|
||||
assert!(warnings.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_source_with_two_spot_tints_writes_two_inks() {
|
||||
let first = Paint::Solid { color: Color::rgb8(255, 0, 0), swatch: Some("Ink".into()), tint: 1.0 };
|
||||
let mut d = colorized_doc(vec![0, 255, 0], Some(first));
|
||||
let mut n = (*d.layers[0].children().unwrap()[0]).clone();
|
||||
n.id = d.alloc_id();
|
||||
n.extras_mut().colorize = Some(Box::new(Paint::Solid { color: Color::rgb8(255, 128, 128), swatch: Some("Ink".into()), tint: 0.5 }));
|
||||
d.insert(d.default_layer(), 1, n).unwrap();
|
||||
let ps = page_of(&d, &opts(&d));
|
||||
assert!(ps.contains("VCsp0 setcolorspace 1 setcolor") && ps.contains("VCsp0 setcolorspace 0.5 setcolor"), "{ps}");
|
||||
assert_eq!(ps.matches("imagemask").count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gray_colorized_ramp_scales_process_and_spot_inks_over_paper() {
|
||||
for level in [Level::Two, Level::Three] {
|
||||
let process = colorized_doc(vec![0, 128, 255], Some(Paint::solid(Color::rgb8(255, 0, 0))));
|
||||
let (ps, pixels, warnings) = colorized_samples(&process, level);
|
||||
assert!(ps.contains("/DeviceRGB setcolorspace") && !ps.contains("imagemask"));
|
||||
if level == Level::Three {
|
||||
assert!(ps.contains("/ImageType 4") && ps.contains("/MaskColor [255 255 0 0 255 255]"));
|
||||
assert_eq!(pixels, vec![255, 0, 0, 255, 128, 128, 255, 0, 255]);
|
||||
assert_eq!(warnings, vec!["partly transparent image pixels are written over white"]);
|
||||
} else {
|
||||
assert!(ps.contains("/ImageType 1") && !ps.contains("/MaskColor"));
|
||||
assert_eq!(pixels, vec![255, 0, 0, 255, 128, 128, 255, 255, 255]);
|
||||
assert_eq!(
|
||||
warnings,
|
||||
vec![
|
||||
"partly transparent image pixels are written over white",
|
||||
"transparent image pixels are written white at PostScript Level 2 (Level 3 masks them out)"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
let gray = colorized_doc(vec![0, 128, 255], Some(Paint::solid(Color::gray(0.5))));
|
||||
let (ps, pixels, warnings) = colorized_samples(&gray, level);
|
||||
assert!(ps.contains("/DeviceGray setcolorspace"));
|
||||
assert_eq!(pixels, if level == Level::Three { vec![128, 192, 0] } else { vec![128, 192, 255] });
|
||||
assert_eq!(ps.contains("/MaskColor [0 0]"), level == Level::Three);
|
||||
assert_eq!(warnings.len(), if level == Level::Three { 1 } else { 2 });
|
||||
|
||||
let spot = colorized_doc(vec![0, 128, 255], Some(Paint::Solid { color: Color::rgb8(255, 128, 128), swatch: Some("Ink".into()), tint: 0.5 }));
|
||||
let (ps, pixels, warnings) = colorized_samples(&spot, level);
|
||||
assert!(ps.contains("/Separation (Ink) cvn") && ps.contains("VCsp0 setcolorspace"));
|
||||
assert_eq!(dsc(&ps, "DocumentCustomColors").as_deref(), Some("(Ink)"));
|
||||
assert_eq!(pixels, vec![128, 64, 0]);
|
||||
assert_eq!(ps.contains("/MaskColor [0 0]"), level == Level::Three);
|
||||
assert_eq!(warnings.len(), if level == Level::Three { 1 } else { 2 });
|
||||
|
||||
let mut cmyk = process.clone();
|
||||
cmyk.color_mode = vectorcraft_doc::ColorMode::Cmyk;
|
||||
let (ps, pixels, _) = colorized_samples(&cmyk, level);
|
||||
assert!(ps.contains("/DeviceCMYK setcolorspace"));
|
||||
assert_eq!(pixels.len(), 12);
|
||||
assert_eq!(&pixels[8..], if level == Level::Three { &[255, 0, 255, 0][..] } else { &[0, 0, 0, 0][..] });
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continuous_colorized_coverage_without_clear_pixels_only_warns_for_midtones() {
|
||||
let d = colorized_doc(vec![0, 128, 254], Some(Paint::solid(Color::rgb8(255, 0, 255))));
|
||||
for level in [Level::Two, Level::Three] {
|
||||
let (ps, pixels, warnings) = colorized_samples(&d, level);
|
||||
assert!(ps.contains("/ImageType 1") && !ps.contains("/MaskColor"));
|
||||
assert_eq!(&pixels[..3], &[255, 0, 255]);
|
||||
assert_eq!(warnings, vec!["partly transparent image pixels are written over white"]);
|
||||
}
|
||||
let d = colorized_doc(vec![0, 128, 255], Some(Paint::solid(Color::rgb8(255, 0, 255))));
|
||||
let (ps, pixels, _) = colorized_samples(&d, Level::Three);
|
||||
assert!(ps.contains("/MaskColor [255 255 0 0 255 255]"));
|
||||
assert_eq!(&pixels[..3], &[255, 1, 255]);
|
||||
assert_eq!(&pixels[6..], &[255, 0, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uncolorized_image_stays_on_original_eps_path() {
|
||||
let d = colorized_doc(vec![0, 128, 255], None);
|
||||
let ps = page_of(&d, &opts(&d));
|
||||
assert!(!ps.contains("imagemask") && ps.contains("/ImageType 1"));
|
||||
let patterned = colorized_doc(vec![0, 128, 255], Some(Paint::Pattern { pattern: "missing".into(), xf: Affine::IDENTITY }));
|
||||
assert_eq!(page_of(&patterned, &opts(&patterned)), ps);
|
||||
let mut color = png_doc();
|
||||
let original = page_of(&color, &opts(&color));
|
||||
let id = color.layers[0].children().unwrap()[0].id;
|
||||
color.node_mut(id).unwrap().extras_mut().colorize = Some(Box::new(Paint::solid(Color::rgb8(255, 0, 0))));
|
||||
assert_eq!(page_of(&color, &opts(&color)), original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_length_and_ascii85_round_trip() {
|
||||
let cycle: Vec<u8> = (0..=255u8).cycle().take(1000).collect();
|
||||
|
||||
@@ -34,7 +34,7 @@ svgtypes = "0.16"
|
||||
skrifa = { workspace = true }
|
||||
# A variable font built in code (no font file is committed).
|
||||
vectorcraft-text = { workspace = true, features = ["test-fonts"] }
|
||||
resvg = { version = "0.45", default-features = false }
|
||||
resvg = { version = "0.45", default-features = false, features = ["raster-images"] }
|
||||
# usvg as a reference viewer that lays text out in a font (text_layout.rs).
|
||||
usvg = { workspace = true, features = ["text"] }
|
||||
vectorcraft-testkit = { workspace = true }
|
||||
|
||||
@@ -107,11 +107,17 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix:
|
||||
symbol_nest: 0,
|
||||
fonts: Vec::new(),
|
||||
shared_images: HashMap::new(),
|
||||
colorized_counts: HashMap::new(),
|
||||
colorized_hrefs: HashMap::new(),
|
||||
shared_colorized: HashMap::new(),
|
||||
layer_nest: 0,
|
||||
non_latin1_names: false,
|
||||
foreign_ns: BTreeMap::new(),
|
||||
};
|
||||
w.assign_name_ids();
|
||||
for layer in &doc.layers {
|
||||
w.count_colorized_images(layer);
|
||||
}
|
||||
// Page Isolated Blending / Page Knockout Group: the page content is one isolated group.
|
||||
let page_group = doc.page_isolate || doc.page_knockout;
|
||||
if page_group {
|
||||
@@ -216,6 +222,8 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix:
|
||||
Output { svg: out, linked: w.linked, warnings: w.warnings, encoding }
|
||||
}
|
||||
|
||||
type ColorizedKey = (String, u32, u32, Option<[u8; 3]>);
|
||||
|
||||
struct Writer<'a> {
|
||||
doc: &'a Document,
|
||||
opts: &'a ExportOptions,
|
||||
@@ -268,6 +276,10 @@ struct Writer<'a> {
|
||||
/// Images written once in the defs and drawn with `<use>`, by key and size: the pieces an
|
||||
/// envelope cuts a distorted image into share its pixels.
|
||||
shared_images: HashMap<(String, u32, u32), String>,
|
||||
/// Counts and generated PNG hrefs for gray images, keyed by source, size and baked RGB.
|
||||
colorized_counts: HashMap<ColorizedKey, usize>,
|
||||
colorized_hrefs: HashMap<ColorizedKey, String>,
|
||||
shared_colorized: HashMap<ColorizedKey, String>,
|
||||
/// Layers being written inside one another (more than one: a sublayer).
|
||||
layer_nest: usize,
|
||||
/// An emitted attribute name requires an encoding wider than Latin-1.
|
||||
|
||||
@@ -117,6 +117,7 @@ fn pin_to_page(n: &mut Node, m: Affine) {
|
||||
|
||||
/// What SVG Tiny 1.2 does without masks.
|
||||
const TINY_MASKS: &str = "SVG Tiny 1.2 has no masks: opacity masks are left out and knockout groups written as plain groups";
|
||||
pub(super) const UNDECODED: &str = "an image couldn't be decoded and is missing from the picture";
|
||||
|
||||
/// A region covering any artwork (mask and filter extents).
|
||||
const BIG: &str = "x=\"-100000\" y=\"-100000\" width=\"200000\" height=\"200000\"";
|
||||
@@ -565,6 +566,18 @@ impl Writer<'_> {
|
||||
}
|
||||
NodeKind::Text(t) => self.text_node(n, t),
|
||||
NodeKind::Image(im) => {
|
||||
if let Some(Paint::Solid { color, .. }) = n.extras().colorize.as_deref()
|
||||
&& let Some(blob) = self.doc.image_blob(im)
|
||||
&& blob.gray_source().is_some()
|
||||
{
|
||||
self.colorized_image(n, im, *color, blob);
|
||||
return;
|
||||
}
|
||||
if matches!(n.extras().colorize.as_deref(), Some(Paint::Solid { .. })) && self.doc.image_blob(im).is_none_or(|b| b.bytes.is_empty()) {
|
||||
// Missing pixels cannot show the original file in place of a colorized image.
|
||||
self.warn(UNDECODED);
|
||||
return;
|
||||
}
|
||||
let id = self.id_attr(n);
|
||||
let m = self.matrix(self.xf * im.xf);
|
||||
let mut props = css::transparency(n);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use super::nodes::{MASK, doc_path};
|
||||
use super::nodes::{MASK, UNDECODED, doc_path};
|
||||
use super::output::sanitize_id;
|
||||
use super::{
|
||||
Affine, BezPath, BlendMode, Color, FillLayer, FillRule, GradientKind, GradientPaint, HashMap, ImageBlob, ImageMode, LineCap, LineJoin,
|
||||
LinkedImage, Node, NodeKind, Paint, PathData, Point, Props, Rect, StrokeAlign, StrokeLayer, Writer, Written, base64_encode, blend_css, fmt_num,
|
||||
stroke, xml_escape,
|
||||
LinkedImage, Node, NodeKind, Paint, PathData, Point, Props, Rect, StrokeAlign, StrokeLayer, Writer, Written, base64_encode, blend_css, css,
|
||||
fmt_num, stroke, xml_escape,
|
||||
};
|
||||
|
||||
/// Image `b` as SVG viewers show it: PNG, JPEG, GIF, WebP and SVG as they are, other formats (a
|
||||
@@ -38,6 +38,125 @@ pub(super) enum StrokePlan {
|
||||
}
|
||||
|
||||
impl Writer<'_> {
|
||||
pub(super) fn count_colorized_images(&mut self, art: &Node) {
|
||||
art.walk(&mut |n| {
|
||||
let NodeKind::Image(im) = &n.kind else { return };
|
||||
let Some(Paint::Solid { color, .. }) = n.extras().colorize.as_deref() else { return };
|
||||
if self.doc.image_blob(im).is_none_or(|blob| blob.gray_source().is_none()) {
|
||||
return;
|
||||
}
|
||||
let key = self.colorized_key(im, *color);
|
||||
*self.colorized_counts.entry(key).or_default() += 1;
|
||||
});
|
||||
}
|
||||
|
||||
fn colorized_key(&self, im: &vectorcraft_doc::ImageObject, color: Color) -> super::ColorizedKey {
|
||||
let rgb = color.to_rgb().map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8);
|
||||
(im.key.clone(), im.width, im.height, self.tiny().then_some(rgb))
|
||||
}
|
||||
|
||||
/// Colorize the decoded gray pixels without changing the source blob. The white pixels of the
|
||||
/// coverage image contribute their alpha to an alpha mask; Tiny uses the same alpha in RGBA.
|
||||
pub(super) fn colorized_image(&mut self, n: &Node, im: &vectorcraft_doc::ImageObject, color: Color, blob: &ImageBlob) {
|
||||
let key = self.colorized_key(im, color);
|
||||
let rgb = color.to_rgb().map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8);
|
||||
let href = if let Some(href) = self.colorized_hrefs.get(&key) {
|
||||
href.clone()
|
||||
} else {
|
||||
let Some((width, height, coverage)) = blob.coverage() else {
|
||||
self.warn(UNDECODED);
|
||||
return;
|
||||
};
|
||||
if u64::from(width)
|
||||
.checked_mul(u64::from(height))
|
||||
.and_then(|pixels| pixels.checked_mul(4))
|
||||
.is_none_or(|bytes| bytes > vectorcraft_doc::MAX_RASTER_ALLOC)
|
||||
{
|
||||
self.warn(UNDECODED);
|
||||
return;
|
||||
}
|
||||
let mut rgba = Vec::with_capacity(coverage.len().saturating_mul(4));
|
||||
for alpha in coverage {
|
||||
rgba.extend_from_slice(&if self.tiny() { [rgb[0], rgb[1], rgb[2], alpha] } else { [255, 255, 255, alpha] });
|
||||
}
|
||||
let Some(pixels) = image::RgbaImage::from_raw(width, height, rgba) else {
|
||||
self.warn(UNDECODED);
|
||||
return;
|
||||
};
|
||||
let mut png = Vec::new();
|
||||
if pixels.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).is_err() {
|
||||
self.warn(UNDECODED);
|
||||
return;
|
||||
}
|
||||
let href = self.blob_href(&ImageBlob::png(png));
|
||||
self.colorized_hrefs.insert(key.clone(), href.clone());
|
||||
href
|
||||
};
|
||||
let shared = if self.opts.images == ImageMode::Embed && self.colorized_counts.get(&key).is_some_and(|count| *count > 1) {
|
||||
if let Some(id) = self.shared_colorized.get(&key) {
|
||||
Some(id.clone())
|
||||
} else {
|
||||
let id = self.fresh_id("colorize-image");
|
||||
self.def(
|
||||
0,
|
||||
&format!(
|
||||
"<image id=\"{id}\" width=\"{}\" height=\"{}\" preserveAspectRatio=\"none\" xlink:href=\"{}\"/>",
|
||||
im.width,
|
||||
im.height,
|
||||
xml_escape(&href)
|
||||
),
|
||||
);
|
||||
self.shared_colorized.insert(key, id.clone());
|
||||
Some(id)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let id = self.id_attr(n);
|
||||
let transform = self.matrix(self.xf * im.xf);
|
||||
let outer = self.attrs(&css::transparency(n));
|
||||
let sampling = if n.extras().pixelated { " image-rendering=\"pixelated\"" } else { "" };
|
||||
if self.tiny() {
|
||||
if let Some(shared) = shared {
|
||||
self.line(&format!("<use{id} transform=\"{transform}\"{outer}{sampling} xlink:href=\"#{shared}\"/>"));
|
||||
} else {
|
||||
self.line(&format!(
|
||||
"<image{id} width=\"{}\" height=\"{}\" transform=\"{transform}\" preserveAspectRatio=\"none\"{outer}{sampling} xlink:href=\"{}\"/>",
|
||||
im.width, im.height, xml_escape(&href)
|
||||
));
|
||||
}
|
||||
return;
|
||||
}
|
||||
let mask = self.fresh_id("colorize-mask");
|
||||
let size = Rect::new(0.0, 0.0, f64::from(im.width), f64::from(im.height));
|
||||
let b = (self.xf * im.xf).transform_rect_bbox(size).inflate(1.0, 1.0);
|
||||
let (x, y, w, h) = (self.num(b.x0), self.num(b.y0), self.num(b.width()), self.num(b.height()));
|
||||
self.def(1, &format!("<mask id=\"{mask}\" {MASK} mask-type=\"alpha\" x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{h}\">"));
|
||||
if let Some(shared) = shared {
|
||||
self.def(2, &format!("<use transform=\"{transform}\"{sampling} xlink:href=\"#{shared}\"/>"));
|
||||
} else {
|
||||
self.def(
|
||||
2,
|
||||
&format!(
|
||||
"<image width=\"{}\" height=\"{}\" transform=\"{transform}\" preserveAspectRatio=\"none\"{sampling} xlink:href=\"{}\"/>",
|
||||
im.width,
|
||||
im.height,
|
||||
xml_escape(&href)
|
||||
),
|
||||
);
|
||||
}
|
||||
self.def(1, "</mask>");
|
||||
self.line(&format!("<g{id} mask=\"url(#{mask})\"{outer}>"));
|
||||
self.depth += 1;
|
||||
self.line(&format!("<g transform=\"{transform}\">"));
|
||||
self.depth += 1;
|
||||
self.line(&format!("<rect width=\"{}\" height=\"{}\" fill=\"{}\"{sampling}/>", im.width, im.height, color.to_hex()));
|
||||
self.depth -= 1;
|
||||
self.line("</g>");
|
||||
self.depth -= 1;
|
||||
self.line("</g>");
|
||||
}
|
||||
|
||||
/// The `href` of image `im`: its file with [`ImageMode::Link`] (or when its pixels are
|
||||
/// missing), else its bytes. `None` when it has neither.
|
||||
pub(super) fn image_href(&mut self, im: &vectorcraft_doc::ImageObject) -> Option<String> {
|
||||
@@ -52,10 +171,16 @@ impl Writer<'_> {
|
||||
/// Images drawn more than once in `art` (the pieces of a distorted image) go into the defs
|
||||
/// once; each piece then draws them with `<use>`.
|
||||
pub(super) fn share_images(&mut self, art: &Node) {
|
||||
self.count_colorized_images(art);
|
||||
// Each image's count and its first piece, which shows what they all show.
|
||||
let mut seen: HashMap<(String, u32, u32), (usize, vectorcraft_doc::ImageObject)> = HashMap::new();
|
||||
art.walk(&mut |c| {
|
||||
if let NodeKind::Image(im) = &c.kind {
|
||||
if matches!(c.extras().colorize.as_deref(), Some(Paint::Solid { .. }))
|
||||
&& self.doc.image_blob(im).is_none_or(|b| b.gray_source().is_some())
|
||||
{
|
||||
return;
|
||||
}
|
||||
seen.entry((im.key.clone(), im.width, im.height)).or_insert_with(|| (0, (**im).clone())).0 += 1;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
//! Colorized gray and bilevel image export, checked as pixels as well as markup.
|
||||
|
||||
use vectorcraft_color::{Color, Paint, Swatch};
|
||||
use vectorcraft_doc::{Document, ImageBlob, ImageObject, LinkInfo, Node, NodeKind};
|
||||
use vectorcraft_geom::Affine;
|
||||
use vectorcraft_svg::{ExportOptions, ImageMode, Profile, base64_encode, export_full};
|
||||
|
||||
fn bilevel() -> Vec<u8> {
|
||||
let entries: [(u16, u16, u32); 9] =
|
||||
[(256, 3, 2), (257, 3, 1), (258, 3, 1), (259, 3, 1), (262, 3, 1), (273, 4, 122), (277, 3, 1), (278, 3, 1), (279, 4, 1)];
|
||||
let mut out = b"II*\0".to_vec();
|
||||
out.extend_from_slice(&8u32.to_le_bytes());
|
||||
out.extend_from_slice(&9u16.to_le_bytes());
|
||||
for (tag, kind, value) in entries {
|
||||
out.extend_from_slice(&tag.to_le_bytes());
|
||||
out.extend_from_slice(&kind.to_le_bytes());
|
||||
out.extend_from_slice(&1u32.to_le_bytes());
|
||||
out.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
out.extend_from_slice(&0u32.to_le_bytes());
|
||||
out.push(0b0100_0000);
|
||||
out
|
||||
}
|
||||
|
||||
fn png(img: image::DynamicImage) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
fn document(bytes: Vec<u8>, paint: Option<Paint>) -> Document {
|
||||
let mut d = Document::new(80.0, 40.0);
|
||||
d.swatches.push(Swatch { name: "Ink".into(), paint: Paint::solid(Color::rgb8(255, 0, 0)), global: true, spot: true });
|
||||
d.images.insert("source".into(), ImageBlob::new("image/tiff", bytes));
|
||||
let im = ImageObject {
|
||||
key: "source".into(),
|
||||
width: 2,
|
||||
height: 1,
|
||||
xf: Affine::translate((10.0, 10.0)) * Affine::scale(20.0),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
};
|
||||
let mut n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)));
|
||||
n.extras_mut().pixelated = true;
|
||||
n.extras_mut().colorize = paint.map(Box::new);
|
||||
d.insert(d.default_layer(), 0, n).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn rendered(svg: &str) -> ([u8; 4], [u8; 4]) {
|
||||
let tree = resvg::usvg::Tree::from_str(svg, &resvg::usvg::Options::default()).unwrap();
|
||||
let mut pixmap = resvg::tiny_skia::Pixmap::new(80, 40).unwrap();
|
||||
pixmap.fill(resvg::tiny_skia::Color::WHITE);
|
||||
resvg::render(&tree, resvg::tiny_skia::Transform::identity(), &mut pixmap.as_mut());
|
||||
let sample = |x| {
|
||||
let p = pixmap.pixel(x, 15).unwrap().demultiply();
|
||||
[p.red(), p.green(), p.blue(), p.alpha()]
|
||||
};
|
||||
(sample(15), sample(45))
|
||||
}
|
||||
|
||||
fn samples(svg: &str, points: &[(u32, u32)]) -> Vec<[u8; 4]> {
|
||||
let tree = resvg::usvg::Tree::from_str(svg, &resvg::usvg::Options::default()).unwrap();
|
||||
let mut pixmap = resvg::tiny_skia::Pixmap::new(80, 40).unwrap();
|
||||
pixmap.fill(resvg::tiny_skia::Color::WHITE);
|
||||
resvg::render(&tree, resvg::tiny_skia::Transform::identity(), &mut pixmap.as_mut());
|
||||
points
|
||||
.iter()
|
||||
.map(|&(x, y)| {
|
||||
let p = pixmap.pixel(x, y).unwrap().demultiply();
|
||||
[p.red(), p.green(), p.blue(), p.alpha()]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_colorized_source_shares_coverage_but_keeps_instance_paint() {
|
||||
let mut d = document(bilevel(), Some(Paint::solid(Color::rgb8(255, 0, 0))));
|
||||
let first_id = d.layers[0].children().unwrap()[0].id;
|
||||
if let NodeKind::Image(im) = &mut d.node_mut(first_id).unwrap().kind {
|
||||
im.xf = Affine::translate((10.0, 10.0)) * Affine::scale(10.0);
|
||||
}
|
||||
for (i, color) in [(1, Color::rgb8(0, 0, 255)), (2, Color::rgb8(255, 0, 0))] {
|
||||
let mut next = (*d.layers[0].children().unwrap()[0]).clone();
|
||||
next.id = d.alloc_id();
|
||||
next.extras_mut().colorize = Some(Box::new(Paint::solid(color)));
|
||||
if let NodeKind::Image(im) = &mut next.kind {
|
||||
im.xf = Affine::translate((10.0 + f64::from(i) * 25.0, 10.0)) * Affine::scale(10.0);
|
||||
}
|
||||
d.insert(d.default_layer(), i as usize, next).unwrap();
|
||||
}
|
||||
for profile in [Profile::Svg11, Profile::Tiny12] {
|
||||
let svg = export_full(&d, &ExportOptions { profile, artboard: Some(0), ..Default::default() }, None).svg;
|
||||
let png_count = svg.matches("data:image/png;base64,").count();
|
||||
assert_eq!(png_count, if profile == Profile::Svg11 { 1 } else { 2 }, "{svg}");
|
||||
assert_eq!(svg.matches("<use").count(), if profile == Profile::Svg11 { 3 } else { 2 }, "{svg}");
|
||||
let actual = samples(&svg, &[(15, 15), (25, 15), (40, 15), (50, 15), (65, 15), (75, 15)]);
|
||||
let expected: [[u8; 4]; 6] = [[255, 0, 0, 255], [255; 4], [0, 0, 255, 255], [255; 4], [255, 0, 0, 255], [255; 4]];
|
||||
for (got, want) in actual.iter().zip(expected) {
|
||||
assert!(got.iter().zip(want).all(|(&a, b)| i16::from(a).abs_diff(i16::from(b)) <= 25), "{actual:?}\n{svg}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colorize_mask_region_follows_an_image_far_from_the_origin() {
|
||||
let mut d = document(bilevel(), Some(Paint::solid(Color::rgb8(255, 0, 0))));
|
||||
let id = d.layers[0].children().unwrap()[0].id;
|
||||
if let NodeKind::Image(im) = &mut d.node_mut(id).unwrap().kind {
|
||||
im.xf = Affine::translate((200_010.0, 10.0)) * Affine::scale(20.0);
|
||||
}
|
||||
let svg = export_full(&d, &ExportOptions::default(), None).svg;
|
||||
let mask = svg.lines().find(|l| l.contains("<mask id=\"colorize-mask")).unwrap();
|
||||
assert!(mask.contains("x=\"200009\" y=\"9\" width=\"42\" height=\"22\""), "{mask}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bilevel_process_and_spot_use_coverage_in_embed_link_and_tiny() {
|
||||
let paints = [Paint::solid(Color::rgb8(255, 0, 0)), Paint::Solid { color: Color::rgb8(255, 0, 0), swatch: Some("Ink".into()), tint: 1.0 }];
|
||||
for paint in paints {
|
||||
let d = document(bilevel(), Some(paint));
|
||||
for profile in [Profile::Svg11, Profile::Tiny12] {
|
||||
let opts = ExportOptions { profile, artboard: Some(0), ..Default::default() };
|
||||
let embedded = export_full(&d, &opts, None);
|
||||
assert_eq!(rendered(&embedded.svg), ([255, 0, 0, 255], [255, 255, 255, 255]), "{}", embedded.svg);
|
||||
assert!(!embedded.svg.contains("colorize-image"), "single images stay inline");
|
||||
assert_eq!(embedded.svg.contains("<mask id=\"colorize-mask"), profile == Profile::Svg11);
|
||||
assert!(embedded.svg.contains("image-rendering=\"pixelated\""));
|
||||
let linked = export_full(&d, &ExportOptions { images: ImageMode::Link, ..opts }, None);
|
||||
assert_eq!(linked.linked.len(), 1);
|
||||
let file = &linked.linked[0];
|
||||
assert!(linked.svg.contains(&file.name));
|
||||
let rgba = image::load_from_memory(&file.bytes).unwrap().to_rgba8();
|
||||
assert_eq!((rgba.get_pixel(0, 0).0[3], rgba.get_pixel(1, 0).0[3]), (255, 0));
|
||||
let inlined = linked.svg.replace(&file.name, &format!("data:image/png;base64,{}", base64_encode(&file.bytes)));
|
||||
assert_eq!(rendered(&inlined), rendered(&embedded.svg));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gray_alpha_and_two_spot_tints_keep_distinct_colors() {
|
||||
let bytes = png(image::DynamicImage::ImageLumaA8(image::GrayAlphaImage::from_raw(2, 1, vec![0, 128, 128, 255]).unwrap()));
|
||||
let mut d = document(bytes, Some(Paint::Solid { color: Color::rgb8(255, 0, 0), swatch: Some("Ink".into()), tint: 1.0 }));
|
||||
let mut second = (*d.layers[0].children().unwrap()[0]).clone();
|
||||
second.id = d.alloc_id();
|
||||
second.extras_mut().colorize = Some(Box::new(Paint::Solid { color: Color::rgb8(255, 128, 128), swatch: Some("Ink".into()), tint: 0.5 }));
|
||||
if let NodeKind::Image(im) = &mut second.kind {
|
||||
im.xf = Affine::translate((10.0, 25.0)) * Affine::scale(10.0);
|
||||
}
|
||||
d.insert(d.default_layer(), 1, second).unwrap();
|
||||
let svg = export_full(&d, &ExportOptions { artboard: Some(0), ..Default::default() }, None).svg;
|
||||
assert_eq!(svg.matches("<mask id=\"colorize-mask").count(), 2);
|
||||
assert!(svg.contains("#ff0000") && svg.contains("#ff8080"), "{svg}");
|
||||
let linked = export_full(&d, &ExportOptions { images: ImageMode::Link, artboard: Some(0), ..Default::default() }, None);
|
||||
assert_eq!(linked.linked.len(), 1, "both tints use one coverage PNG");
|
||||
assert_eq!(linked.svg.matches(&linked.linked[0].name).count(), 2);
|
||||
let (a, b) = rendered(&svg);
|
||||
assert!((a[1] as i16 - 127).abs() <= 2 && (b[1] as i16 - 128).abs() <= 2, "{a:?} {b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gray_midtone_uses_process_cmyk_display_color() {
|
||||
let color = Color::cmyk(0.2, 0.7, 0.1, 0.1);
|
||||
let bytes = png(image::DynamicImage::ImageLuma8(image::GrayImage::from_raw(2, 1, vec![128, 255]).unwrap()));
|
||||
let d = document(bytes, Some(Paint::solid(color)));
|
||||
let out = export_full(&d, &ExportOptions { artboard: Some(0), ..Default::default() }, None);
|
||||
let (actual, white) = rendered(&out.svg);
|
||||
let rgb = color.to_rgb();
|
||||
let expected = rgb.map(|v| (255.0 * (1.0 - 127.0 / 255.0 + v.clamp(0.0, 1.0) * 127.0 / 255.0)).round() as u8);
|
||||
for i in 0..3 {
|
||||
assert!((i16::from(actual[i]) - i16::from(expected[i])).abs() <= 2, "{actual:?}, expected {expected:?}");
|
||||
}
|
||||
assert_eq!(white, [255; 4]);
|
||||
assert!(out.warnings.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uncolorized_and_color_image_controls_keep_original_paths() {
|
||||
let gray = document(bilevel(), None);
|
||||
let svg = export_full(&gray, &ExportOptions { artboard: Some(0), ..Default::default() }, None).svg;
|
||||
assert!(!svg.contains("colorize-mask") && svg.contains("<image"));
|
||||
let patterned = document(bilevel(), Some(Paint::Pattern { pattern: "missing".into(), xf: Affine::IDENTITY }));
|
||||
assert_eq!(export_full(&patterned, &ExportOptions { artboard: Some(0), ..Default::default() }, None).svg, svg);
|
||||
let color = png(image::DynamicImage::ImageRgb8(image::RgbImage::from_raw(2, 1, vec![0, 0, 255, 0, 255, 0]).unwrap()));
|
||||
let mut d = document(color, None);
|
||||
d.images.get_mut("source").unwrap().mime = "image/png".into();
|
||||
let before = export_full(&d, &ExportOptions { artboard: Some(0), ..Default::default() }, None).svg;
|
||||
let id = d.layers[0].children().unwrap()[0].id;
|
||||
d.node_mut(id).unwrap().extras_mut().colorize = Some(Box::new(Paint::solid(Color::rgb8(255, 0, 0))));
|
||||
assert_eq!(export_full(&d, &ExportOptions { artboard: Some(0), ..Default::default() }, None).svg, before);
|
||||
// Pixels that aren't grayscale (an embedded SVG, unknown bytes) keep the uncolorized path.
|
||||
let svg_source = br#"<svg xmlns="http://www.w3.org/2000/svg" width="2" height="1"><rect width="2" height="1" fill="blue"/></svg>"#.to_vec();
|
||||
for (bytes, mime) in [(svg_source, "image/svg+xml"), (vec![1, 2, 3], "image/tiff")] {
|
||||
let mut d = document(bytes, None);
|
||||
d.images.get_mut("source").unwrap().mime = mime.into();
|
||||
let before = export_full(&d, &ExportOptions { artboard: Some(0), ..Default::default() }, None);
|
||||
let id = d.layers[0].children().unwrap()[0].id;
|
||||
d.node_mut(id).unwrap().extras_mut().colorize = Some(Box::new(Paint::solid(Color::rgb8(255, 0, 0))));
|
||||
let after = export_full(&d, &ExportOptions { artboard: Some(0), ..Default::default() }, None);
|
||||
assert!(after.svg.contains("<image"), "{mime}");
|
||||
assert_eq!((after.svg, after.warnings), (before.svg, before.warnings), "{mime}");
|
||||
}
|
||||
|
||||
let mut truncated = bilevel();
|
||||
truncated.pop(); // Keep the TIFF header, but remove its pixel data.
|
||||
let damaged = document(truncated, Some(Paint::solid(Color::rgb8(255, 0, 0))));
|
||||
assert!(damaged.images["source"].gray_source().is_some());
|
||||
let out = export_full(&damaged, &ExportOptions { artboard: Some(0), ..Default::default() }, None);
|
||||
assert!(!out.svg.contains("<image"));
|
||||
assert_eq!(out.warnings, ["an image couldn't be decoded and is missing from the picture"]);
|
||||
|
||||
let mut missing = document(bilevel(), Some(Paint::solid(Color::rgb8(255, 0, 0))));
|
||||
missing.images.remove("source");
|
||||
let id = missing.layers[0].children().unwrap()[0].id;
|
||||
if let NodeKind::Image(im) = &mut missing.node_mut(id).unwrap().kind {
|
||||
im.link = Some(LinkInfo::new("/original/gray.tiff"));
|
||||
}
|
||||
let out = export_full(&missing, &ExportOptions { artboard: Some(0), images: ImageMode::Link, ..Default::default() }, None);
|
||||
assert!(!out.svg.contains("/original/gray.tiff") && !out.svg.contains("<image"));
|
||||
assert!(out.linked.is_empty());
|
||||
assert_eq!(out.warnings, ["an image couldn't be decoded and is missing from the picture"]);
|
||||
|
||||
missing.images.insert("source".into(), ImageBlob::new("image/tiff", vec![]));
|
||||
let out = export_full(&missing, &ExportOptions { artboard: Some(0), ..Default::default() }, None);
|
||||
assert!(!out.svg.contains("/original/gray.tiff") && !out.svg.contains("<image"));
|
||||
assert_eq!(out.warnings, ["an image couldn't be decoded and is missing from the picture"]);
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
mod basic_shapes;
|
||||
mod blend_export;
|
||||
mod colorized_images;
|
||||
mod colour_glyphs;
|
||||
mod container_export;
|
||||
mod css_rules;
|
||||
|
||||
Reference in New Issue
Block a user