diff --git a/Cargo.lock b/Cargo.lock index a1512c6d0..aeec6db30 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1940,6 +1940,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "gif" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" +dependencies = [ + "color_quant", + "weezl 0.1.12", +] + [[package]] name = "gif" version = "0.14.2" @@ -2233,7 +2243,7 @@ dependencies = [ "rustc-hash 2.1.3", "smallvec", "stringprep", - "zune-jpeg", + "zune-jpeg 0.5.15", ] [[package]] @@ -2609,14 +2619,14 @@ dependencies = [ "bytemuck", "byteorder-lite", "color_quant", - "gif", + "gif 0.14.2", "image-webp", "moxcms", "num-traits", "png 0.18.1", "tiff", - "zune-core", - "zune-jpeg", + "zune-core 0.5.3", + "zune-jpeg 0.5.15", ] [[package]] @@ -2837,7 +2847,7 @@ dependencies = [ "bumpalo", "flate2", "float-cmp", - "gif", + "gif 0.14.2", "image-webp", "imagesize 0.14.0", "indexmap", @@ -2852,7 +2862,7 @@ dependencies = [ "tiny-skia-path 0.12.0", "xmp-writer", "yoke", - "zune-jpeg", + "zune-jpeg 0.5.15", ] [[package]] @@ -4380,12 +4390,15 @@ version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43" dependencies = [ + "gif 0.13.3", + "image-webp", "log", "pico-args", "rgb", "svgtypes 0.15.3", "tiny-skia", "usvg 0.45.1", + "zune-jpeg 0.4.21", ] [[package]] @@ -5490,7 +5503,7 @@ dependencies = [ "half", "quick-error 2.0.1", "weezl 0.1.12", - "zune-jpeg", + "zune-jpeg 0.5.15", ] [[package]] @@ -6169,7 +6182,7 @@ dependencies = [ "tiff", "vectorcraft-color", "vectorcraft-geom", - "zune-jpeg", + "zune-jpeg 0.5.15", ] [[package]] @@ -6374,7 +6387,7 @@ name = "vectorcraft-render" version = "0.19.0" dependencies = [ "flate2", - "gif", + "gif 0.14.2", "image", "jpeg-encoder", "log", @@ -7950,19 +7963,34 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + [[package]] name = "zune-core" version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core 0.4.12", +] + [[package]] name = "zune-jpeg" version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ - "zune-core", + "zune-core 0.5.3", ] [[package]] diff --git a/crates/engine/src/cmd/fileio/eps.rs b/crates/engine/src/cmd/fileio/eps.rs index d2424bf0a..49ecfaa34 100644 --- a/crates/engine/src/cmd/fileio/eps.rs +++ b/crates/engine/src/cmd/fileio/eps.rs @@ -152,6 +152,8 @@ fn parse(p: &Value) -> Result { 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")?, diff --git a/crates/engine/src/tests/colorize.rs b/crates/engine/src/tests/colorize.rs index bb9a9ac0f..bcd590c2d 100644 --- a/crates/engine/src/tests/colorize.rs +++ b/crates/engine/src/tests/colorize.rs @@ -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(); diff --git a/crates/eps/src/scene.rs b/crates/eps/src/scene.rs index 83d3e7e01..6e6ba0584 100644 --- a/crates/eps/src/scene.rs +++ b/crates/eps/src/scene.rs @@ -119,6 +119,8 @@ pub(crate) struct Scene<'a> { brushes: Option>, /// Image keys → what draws them (`None`: left out). images: HashMap>>, + /// The source key and solid paint together identify a colorized encoding. + colorized_images: HashMap<(String, String), Option>>, /// 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 { + 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 = 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 { let Some(img) = blob.and_then(|blob| image::load_from_memory(&blob.bytes).ok()) else { diff --git a/crates/eps/src/tests.rs b/crates/eps/src/tests.rs index 40eade3b5..c8271dda8 100644 --- a/crates/eps/src/tests.rs +++ b/crates/eps/src/tests.rs @@ -331,6 +331,187 @@ fn unpack(data: &[u8]) -> Vec { out } +fn colorized_doc(gray: Vec, paint: Option) -> 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, Vec) { + 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 = (0..=255u8).cycle().take(1000).collect(); diff --git a/crates/svg/Cargo.toml b/crates/svg/Cargo.toml index 27be13e20..6ef3b64dd 100644 --- a/crates/svg/Cargo.toml +++ b/crates/svg/Cargo.toml @@ -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 } diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 1da24959c..b9cd372ea 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -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 ``, 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, + colorized_hrefs: HashMap, + shared_colorized: HashMap, /// 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. diff --git a/crates/svg/src/export/nodes.rs b/crates/svg/src/export/nodes.rs index 16722d939..e88ad8509 100644 --- a/crates/svg/src/export/nodes.rs +++ b/crates/svg/src/export/nodes.rs @@ -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); diff --git a/crates/svg/src/export/paint.rs b/crates/svg/src/export/paint.rs index b21d45eb6..462387d70 100644 --- a/crates/svg/src/export/paint.rs +++ b/crates/svg/src/export/paint.rs @@ -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!( + "", + 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!("")); + } else { + self.line(&format!( + "", + 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!("")); + if let Some(shared) = shared { + self.def(2, &format!("")); + } else { + self.def( + 2, + &format!( + "", + im.width, + im.height, + xml_escape(&href) + ), + ); + } + self.def(1, ""); + self.line(&format!("")); + self.depth += 1; + self.line(&format!("")); + self.depth += 1; + self.line(&format!("", im.width, im.height, color.to_hex())); + self.depth -= 1; + self.line(""); + self.depth -= 1; + self.line(""); + } + /// 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 { @@ -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 ``. 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; } }); diff --git a/crates/svg/tests/it/colorized_images.rs b/crates/svg/tests/it/colorized_images.rs new file mode 100644 index 000000000..d3590977f --- /dev/null +++ b/crates/svg/tests/it/colorized_images.rs @@ -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 { + 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 { + let mut out = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out +} + +fn document(bytes: Vec, paint: Option) -> 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(""#.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("` with a `` per instance. An instance the def can't stand for is written as its own art: one stained by its fill; one scaled while the symbol has strokes (their weight doesn't scale), or rotated or @@ -4038,6 +4039,7 @@ is accepted); spot colours are Separation colour spaces, and overprinting fills `overprints: "discard"`. RGB documents are written in CMYK unless `cmykPostScript: false`. When the file would hold an image that can't be decoded, the image is left out, and `warnings` carries "an image couldn't be decoded and is missing from the picture" once for all such images. +Colorized grayscale and 1-bit images use their process or spot ink; the flattener leaves them alone unless they have opacity, a blending mode, an opacity mask or effects, or overlap other transparent art. Binary coverage leaves underlying art untouched. For a grayscale image, Level 3 leaves white (zero-coverage) pixels out, Level 2 writes them white, and gray midtones are written over white at both levels. PostScript has no transparency: it is flattened first with `flattenerPreset` (`high`, `medium` (default), `low` or a saved preset, see `flattener.presets.list`) and `flattener: {…}` options over it (those of diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0699ef5bc..e7e1bbc5f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -927,6 +927,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **CSS swatch libraries:** fractional linear and radial gradient stop positions are preserved, so nearby stops such as 12.5% and 12.890625% remain distinct instead of collapsing to 13%. Reference: [CSS Images 3 color stops](https://www.w3.org/TR/css-images-3/#color-stop-syntax). The original synthetic regression is `palette_io::tests::css_preserves_fractional_gradient_stop_positions` in `crates/color/src/palette_io.rs`. Scores and hours are unchanged. - **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries and five gradient libraries in a library panel, saved as `.vcswatches`, `.gpl`, swatch exchange (`.ase`) or CSS, and loaded from `.vcswatches`, `.gpl` and `.ase` files (`.ase` both ways: RGB, CMYK, Lab and Gray; global, spot and process; groups) and from color books (`.acb`, read only: the RGB, CMYK or Lab colors of a book such as a Pantone book a user owns, named with its prefix and suffix, spot unless it says process, #831); `swatch.library.copyToUser` copies a loaded library into the User Defined folder without replacing a file, and a file with the same extension and bytes as a User Defined library opens as that library. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate. Editing, renaming, deleting or merging a global swatch also changes the colors linked to it in graph designs, the art of brushes and the tile that pattern editing's Cancel puts back, in the same undo step as the rest of the art; Default Swatches with replace unlinks them there too. + - **Colorized images (#1466):** a fill color applied to a selected grayscale or 1-bit image (`paint.setFill`) colorizes it with black, white, a process color or a spot color, and refuses a process color when the image has an alpha channel ([Convert color modes](https://web.archive.org/web/20251220223850/https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/convert-color-modes.html), "Colorize Grayscale or 1-bit images"); black and white are accepted there too. The canvas draws each pixel in the color as far as the pixel is dark, with white pixels transparent; PDF export writes the image in the color (a spot color as its ink, Registration in every ink) with the image as its soft mask; Separations Preview and printed separations show it on its color's plates; native files keep the color. SVG writes the fill color through a coverage mask (or a colorized PNG in Tiny 1.2); EPS writes binary coverage as an image mask in the color's ink, and a grayscale image as ink samples scaled by coverage, with white pixels left out at Level 3 and gray midtones written over white. The prompt for deleting several swatches and groups uses the interface language's plural form for the count. Evidence: `deleting_several_swatches_uses_each_count_form` in `crates/ui-egui/src/panels/swatches/tests.rs`. - **Colorized images (#1466):** a fill color applied to a selected grayscale or 1-bit image (`paint.setFill`) colorizes it with black, white, a process color or a spot color, and refuses a process color when the image has an alpha channel ([Convert color modes](https://web.archive.org/web/20251220223850/https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/convert-color-modes.html), "Colorize Grayscale or 1-bit images"); black and white are accepted there too. The canvas draws each pixel in the color as far as the pixel is dark, with white pixels transparent; PDF export writes the image in the color (a spot color as its ink, Registration in every ink) with the image as its soft mask; Separations Preview and printed separations show it on its color's plates; native files keep the color. EPS and SVG write the image's own pixels. - **Missing library files:** a persistent swatch or graphic style library panel whose file is gone at startup reopens and stays in the preferences, showing the file's path with Locate Library… (`ui.libraryPanel.locate`), which reads the file from where it is now into the same panel.