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:
dotCooCoo
2026-11-01 00:35:27 +07:00
committed by bflatastic
parent 93e27cafd3
commit bb6cbfb2b4
13 changed files with 795 additions and 15 deletions
+13
View File
@@ -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);
+128 -3
View File
@@ -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;
}
});