From 13adeed569f43f5b7360f95b79714ea95c6deb76 Mon Sep 17 00:00:00 2001 From: wmedrano Date: Tue, 17 Feb 2026 16:01:40 -0800 Subject: [PATCH 1/7] Create DrawItem datastructure --- src/icon2svg.rs | 88 ++++++++++++++++++++++++++----------------- src/pens.rs | 57 ++++++++++++++++++++++------ src/text2png.rs | 99 +++++++++++++++++++++++++++++++------------------ 3 files changed, 163 insertions(+), 81 deletions(-) diff --git a/src/icon2svg.rs b/src/icon2svg.rs index bb114fd..4f42c04 100644 --- a/src/icon2svg.rs +++ b/src/icon2svg.rs @@ -5,7 +5,7 @@ use crate::{ draw_glyph::*, error::DrawSvgError, pathstyle::SvgPathStyle, - pens::{ColorFill, ColorStop, GlyphPainter, Paint}, + pens::{ColorStop, DrawItem, GlyphPainter, Paint}, xml_element::{HexColor, TruncatedFloat, XmlElement}, }; use kurbo::Affine; @@ -96,47 +96,69 @@ fn draw_color_glyph( ) .with_attribute("height", options.width_height) .with_attribute("width", options.width_height) - .with_child(to_svg(painter.into_fills()?, &options.style)?); + .with_child(to_svg(painter.into_items()?, &options.style)?); Ok(svg.to_string()) } -fn to_svg(fills: Vec, style: &SvgPathStyle) -> Result { - let mut group = Vec::new(); - let mut clips_cache = ClipsCache::default(); - let mut fill_cache = PaintCache::default(); - for fill in fills.iter() { - // Path - let Some(shape) = fill.clip_paths.last() else { - continue; - }; - let mut path = XmlElement::new("path").with_attribute("d", style.write_svg_path(shape)); +fn add_items( + items: &[DrawItem], + style: &SvgPathStyle, + group: &mut Vec, + clips_cache: &mut ClipsCache, + fill_cache: &mut PaintCache, +) -> Result<(), DrawSvgError> { + for item in items { + match item { + DrawItem::Fill(fill) => { + // Path + let Some(shape) = fill.clip_paths.last() else { + continue; + }; + let mut path = + XmlElement::new("path").with_attribute("d", style.write_svg_path(shape)); + + // Fill + fill_cache.add_fill(&mut path, &fill.paint)?; + + // Clip + let mut clip_parent_id = None; + if fill.clip_paths.len() > 1 { + for clip in &fill.clip_paths[0..fill.clip_paths.len() - 1] { + let id = clips_cache + .get_id(clip_parent_id, style.write_svg_path(clip).to_string()); + clip_parent_id = Some(id); + } + } + if let Some(id) = clip_parent_id { + path.add_attribute("clip-path", format!("url(#{})", id)); + } - // Fill - fill_cache.add_fill(&mut path, &fill.paint)?; + // Offset + if fill.offset_x != 0.0 || fill.offset_y != 0.0 { + path.add_attribute( + "transform", + format!("translate({} {})", fill.offset_x, fill.offset_y), + ); + } - // Clip - let mut clip_parent_id = None; - if fill.clip_paths.len() > 1 { - for clip in &fill.clip_paths[0..fill.clip_paths.len() - 1] { - let id = clips_cache.get_id(clip_parent_id, style.write_svg_path(clip).to_string()); - clip_parent_id = Some(id); + group.push(path); + } + DrawItem::Layer(layer) => { + let mut layer_group = Vec::new(); + add_items(&layer.items, style, &mut layer_group, clips_cache, fill_cache)?; + group.push(XmlElement::new("g").with_children(layer_group)); } } - if let Some(id) = clip_parent_id { - path.add_attribute("clip-path", format!("url(#{})", id)); - } - - // Offset - if fill.offset_x != 0.0 || fill.offset_y != 0.0 { - path.add_attribute( - "transform", - format!("translate({} {})", fill.offset_x, fill.offset_y), - ); - } - - group.push(path); } + Ok(()) +} + +fn to_svg(items: Vec, style: &SvgPathStyle) -> Result { + let mut group = Vec::new(); + let mut clips_cache = ClipsCache::default(); + let mut fill_cache = PaintCache::default(); + add_items(&items, style, &mut group, &mut clips_cache, &mut fill_cache)?; if !fill_cache.is_empty() || !clips_cache.is_empty() { group.push( diff --git a/src/pens.rs b/src/pens.rs index fd2b040..d15457e 100644 --- a/src/pens.rs +++ b/src/pens.rs @@ -89,6 +89,21 @@ pub struct ColorFill { pub offset_y: f64, } +/// A single draw operation: either a flat fill or a composited sub-layer. +#[derive(Debug, Clone)] +pub enum DrawItem { + Fill(ColorFill), + Layer(LayerGroup), +} + +/// A group of draw items rendered to an offscreen surface and composited back +/// with the given composite mode. +#[derive(Debug, Clone)] +pub struct LayerGroup { + pub items: Vec, + pub composite_mode: CompositeMode, +} + #[derive(Debug, Clone)] pub enum Paint { Solid(Color), @@ -146,11 +161,11 @@ pub struct GlyphPainter<'a> { } struct ColorFillsBuilder { - /// The path for the next fill. + /// The clip path stack for the next fill. paths: Vec, transforms: Vec, - /// All the fills that have been finalized. - fills: Vec, + /// Stack of in-progress item lists. The bottom entry is the root layer. + layer_stack: Vec>, } /// TODO: Make this into a const once has been @@ -194,14 +209,14 @@ impl<'a> GlyphPainter<'a> { builder: Ok(ColorFillsBuilder { paths: Vec::new(), transforms: Vec::new(), - fills: Vec::new(), + layer_stack: vec![Vec::new()], }), } } - /// Returns the completed color fills, or an error if one occurred. - pub fn into_fills(self) -> Result, GlyphPainterError> { - self.builder.map(|i| i.fills) + /// Returns the completed draw items, or an error if one occurred. + pub fn into_items(self) -> Result, GlyphPainterError> { + self.builder.map(|mut b| b.layer_stack.swap_remove(0)) } fn set_err(&mut self, err: GlyphPainterError) { @@ -395,15 +410,35 @@ impl<'a> ColorPainter for GlyphPainter<'a> { transform, }, }; - builder.fills.push(ColorFill { + let fill = ColorFill { paint, clip_paths: builder.paths.clone(), offset_x: self.x, offset_y: self.y, - }); + }; + if let Some(items) = builder.layer_stack.last_mut() { + items.push(DrawItem::Fill(fill)); + } + } + + fn push_layer(&mut self, _composite_mode: CompositeMode) { + let Ok(builder) = self.builder.as_mut() else { + return; + }; + builder.layer_stack.push(Vec::new()); } - fn push_layer(&mut self, _: CompositeMode) { - self.set_err(GlyphPainterError::UnsupportedFontFeature("colr layers")); + fn pop_layer_with_mode(&mut self, composite_mode: CompositeMode) { + let Ok(builder) = self.builder.as_mut() else { + return; + }; + if let Some(items) = builder.layer_stack.pop() { + if let Some(parent) = builder.layer_stack.last_mut() { + parent.push(DrawItem::Layer(LayerGroup { + items, + composite_mode, + })); + } + } } } diff --git a/src/text2png.rs b/src/text2png.rs index f99c34e..a193100 100644 --- a/src/text2png.rs +++ b/src/text2png.rs @@ -1,7 +1,7 @@ //! renders text into png, forked from use crate::{ measure::shape, - pens::{foreground_paint, GlyphPainter, GlyphPainterError, Paint}, + pens::{foreground_paint, DrawItem, GlyphPainter, GlyphPainterError, Paint}, }; use kurbo::{Affine, BezPath, PathEl, Rect, Shape, Vec2}; use skrifa::{ @@ -138,7 +138,7 @@ pub fn text2png(text: &str, options: &Text2PngOptions) -> Result, TextTo } let expected_height = (options.line_spacing * options.font_size * text.lines().count() as f32) as f64; - let pixmap = to_pixmap(&painter.into_fills()?, options.background, expected_height)?; + let pixmap = to_pixmap(&painter.into_items()?, options.background, expected_height)?; let bytes = pixmap.encode_png()?; Ok(bytes) } @@ -152,14 +152,24 @@ fn clip_bounds(paths: &[BezPath]) -> Option { .reduce(|a, b| a.intersect(b)) } -/// Computes the union of bounding boxes for all provided color fills, +/// Computes the union of bounding boxes for all provided draw items, /// considering their respective offsets and clip paths. -fn compute_bounds(fills: &[crate::pens::ColorFill]) -> Rect { - fills +fn compute_bounds(items: &[DrawItem]) -> Rect { + items .iter() - .filter_map(|fill| { - let add_offset = |b| b + Vec2::new(fill.offset_x, fill.offset_y); - clip_bounds(&fill.clip_paths).map(add_offset) + .filter_map(|item| match item { + DrawItem::Fill(fill) => { + let add_offset = |b| b + Vec2::new(fill.offset_x, fill.offset_y); + clip_bounds(&fill.clip_paths).map(add_offset) + } + DrawItem::Layer(layer) => { + let b = compute_bounds(&layer.items); + if b == Rect::default() { + None + } else { + Some(b) + } + } }) .reduce(|a, b| a.union(b)) .unwrap_or_default() @@ -197,17 +207,56 @@ fn to_mask( } } -/// Creates a Pixmap from a collection of color fills, centering them +fn render_items( + items: &[DrawItem], + pixmap: &mut Pixmap, + x_offset: f64, + y_offset: f64, +) -> Result<(), TextToPngError> { + for item in items { + match item { + DrawItem::Fill(fill) => { + let transform = Transform::from_translate( + (fill.offset_x + x_offset) as f32, + (fill.offset_y + y_offset) as f32, + ); + let Some(path) = fill.clip_paths.last() else { + continue; + }; + let mask = to_mask( + // OK: Guaranteed to be at least length 1 in above statement. + &fill.clip_paths[0..fill.clip_paths.len() - 1], + (pixmap.width(), pixmap.height()), + transform, + )?; + pixmap.fill_path( + &path.to_tinyskia().ok_or(TextToPngError::PathBuildError)?, + &fill + .paint + .to_tinyskia() + .ok_or(TextToPngError::MalformedGradient)?, + FILL_RULE, + transform, + mask.as_ref(), + ); + } + DrawItem::Layer(layer) => render_items(&layer.items, pixmap, x_offset, y_offset)?, + } + } + Ok(()) +} + +/// Creates a Pixmap from a collection of draw items, centering them /// vertically within the given height. /// /// The Pixmap's width is determined automatically based on the -/// bounding box of the fills. +/// bounding box of the items. fn to_pixmap( - fills: &[crate::pens::ColorFill], + items: &[DrawItem], background: Color, height: f64, ) -> Result { - let bounds = compute_bounds(fills); + let bounds = compute_bounds(items); let width = bounds.width(); let mut pixmap = Pixmap::new(width.ceil() as u32, height.ceil() as u32) @@ -216,31 +265,7 @@ fn to_pixmap( let x_offset = -bounds.min_x(); let y_offset_for_centering = (height - bounds.height()) / 2.0; let y_offset = y_offset_for_centering - bounds.min_y(); - for fill in fills { - let transform = Transform::from_translate( - (fill.offset_x + x_offset) as f32, - (fill.offset_y + y_offset) as f32, - ); - let Some(path) = fill.clip_paths.last() else { - continue; - }; - let mask = to_mask( - // OK: Guaranteed to be at least length 1 in above statement. - &fill.clip_paths[0..fill.clip_paths.len() - 1], - (pixmap.width(), pixmap.height()), - transform, - )?; - pixmap.fill_path( - &path.to_tinyskia().ok_or(TextToPngError::PathBuildError)?, - &fill - .paint - .to_tinyskia() - .ok_or(TextToPngError::MalformedGradient)?, - FILL_RULE, - transform, - mask.as_ref(), - ); - } + render_items(items, &mut pixmap, x_offset, y_offset)?; Ok(pixmap) } From 857b0de37dc446ee207074bc1d9fbbb170de9b3b Mon Sep 17 00:00:00 2001 From: wmedrano Date: Tue, 17 Feb 2026 16:54:52 -0800 Subject: [PATCH 2/7] Support layers for text2png --- resources/testdata/NotoColorEmoji.ttf | Bin 91796 -> 8212 bytes src/icon2svg.rs | 8 ++- src/lib.rs | 4 +- src/text2png.rs | 76 +++++++++++++++++++++++--- 4 files changed, 76 insertions(+), 12 deletions(-) diff --git a/resources/testdata/NotoColorEmoji.ttf b/resources/testdata/NotoColorEmoji.ttf index 811859a9827ea4056714bcf6477a0f0228442d6a..ff5df3b0e0b78a86ebf1ceda16c883641473269b 100644 GIT binary patch delta 411 zcmW;IKPZH89LMp`_j#U^8?afE#Y*undh3srkj>w?X*9)hf(To O?_@2`d?+mSmF*vB4L-X7 delta 3294 zcmeIw&nv@m9LMq3=kwi4YPPYqWRVM}9sDYktSE(Axwtwx$U!VQ(Byy%rS0Tm7fM#j zwym>ec7-=~YM<9)mF%Nnvfu)6!UGdZu~1 zHhB=MhRsBzeq=*Uspv|N-(YSNxeY0}j4mngjSsxx8TYuxIZly778#6V2z^MQ8=Z(F zf<^=+?-fsYz#Xn|ff7!noL6KxL;?HQ#TM4FidhVz7cG+g!52PJK^ZUL!uAa0S@JA- zmOQ%#9%vUak2xd|l}t?oGVct$*j$1DbBrSpU=}ciG=|Y{tfY~ArLAm|{!OxE{NL89 zNwR6{e;a3&BJ~}a5c8b-;DNb-;DNb-;DNb-;Dt-yKN*0i+r| Ax&QzG diff --git a/src/icon2svg.rs b/src/icon2svg.rs index 4f42c04..5b23bab 100644 --- a/src/icon2svg.rs +++ b/src/icon2svg.rs @@ -146,7 +146,13 @@ fn add_items( } DrawItem::Layer(layer) => { let mut layer_group = Vec::new(); - add_items(&layer.items, style, &mut layer_group, clips_cache, fill_cache)?; + add_items( + &layer.items, + style, + &mut layer_group, + clips_cache, + fill_cache, + )?; group.push(XmlElement::new("g").with_children(layer_group)); } } diff --git a/src/lib.rs b/src/lib.rs index 9f75b7e..ef2fc74 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -50,8 +50,8 @@ mod testdata { // Taken from https://github.com/googlefonts/color-fonts/blob/main/fonts/test_glyphs-glyf_colr_1.ttf pub static COLR_FONT: &[u8] = include_bytes!("../resources/testdata/colr.ttf"); // Generated with: - // klippa --path NotoColorEmoji-Regular.ttf --output-file resources/testdata/NotoColorEmoji.ttf \ - // --unicodes U+1F973 --gids 1760 + // skera --path NotoColorEmoji-Regular.ttf --output-file resources/testdata/NotoColorEmoji.ttf \ + // --unicodes U+1F973 --gids 1760,1959 pub static NOTO_EMOJI_FONT: &[u8] = include_bytes!("../resources/testdata/NotoColorEmoji.ttf"); pub static CAVEAT_FONT: &[u8] = include_bytes!("../resources/testdata/caveat.ttf"); pub static NOTO_KUFI_ARABIC_FONT: &[u8] = diff --git a/src/text2png.rs b/src/text2png.rs index a193100..336943e 100644 --- a/src/text2png.rs +++ b/src/text2png.rs @@ -5,15 +5,16 @@ use crate::{ }; use kurbo::{Affine, BezPath, PathEl, Rect, Shape, Vec2}; use skrifa::{ - color::{ColorPainter, Extend, PaintError}, + color::{ColorPainter, CompositeMode, Extend, PaintError}, prelude::{LocationRef, Size}, raw::{FontRef, ReadError}, MetadataProvider, }; use thiserror::Error; use tiny_skia::{ - Color, FillRule, GradientStop, LinearGradient, Mask, Paint as SkiaPaint, PathBuilder, Pixmap, - Point as SkiaPoint, RadialGradient, Shader, SpreadMode, SweepGradient, Transform, + BlendMode, Color, FillRule, GradientStop, LinearGradient, Mask, Paint as SkiaPaint, + PathBuilder, Pixmap, PixmapPaint, Point as SkiaPoint, RadialGradient, Shader, SpreadMode, + SweepGradient, Transform, }; /// Errors encountered during the text-to-PNG rendering process. @@ -240,7 +241,30 @@ fn render_items( mask.as_ref(), ); } - DrawItem::Layer(layer) => render_items(&layer.items, pixmap, x_offset, y_offset)?, + DrawItem::Layer(layer) => match layer.composite_mode.to_tinyskia() { + BlendMode::SourceOver => { + render_items(&layer.items, pixmap, x_offset, y_offset)?; + } + blend_mode => { + let Some(mut layer_pixmap) = Pixmap::new(pixmap.width(), pixmap.height()) + else { + // Unreachable unless pixmap has 0 width or height. + continue; + }; + render_items(&layer.items, &mut layer_pixmap, x_offset, y_offset)?; + pixmap.draw_pixmap( + 0, + 0, + layer_pixmap.as_ref(), + &PixmapPaint { + blend_mode, + ..PixmapPaint::default() + }, + Transform::identity(), + None, + ); + } + }, } } Ok(()) @@ -251,11 +275,7 @@ fn render_items( /// /// The Pixmap's width is determined automatically based on the /// bounding box of the items. -fn to_pixmap( - items: &[DrawItem], - background: Color, - height: f64, -) -> Result { +fn to_pixmap(items: &[DrawItem], background: Color, height: f64) -> Result { let bounds = compute_bounds(items); let width = bounds.width(); @@ -317,6 +337,44 @@ impl ToTinySkia for Affine { } } +impl ToTinySkia for CompositeMode { + type T = BlendMode; + + fn to_tinyskia(&self) -> Self::T { + match self { + CompositeMode::Clear => BlendMode::Clear, + CompositeMode::Src => BlendMode::Source, + CompositeMode::Dest => BlendMode::Destination, + CompositeMode::SrcOver => BlendMode::SourceOver, + CompositeMode::DestOver => BlendMode::DestinationOver, + CompositeMode::SrcIn => BlendMode::SourceIn, + CompositeMode::DestIn => BlendMode::DestinationIn, + CompositeMode::SrcOut => BlendMode::SourceOut, + CompositeMode::DestOut => BlendMode::DestinationOut, + CompositeMode::SrcAtop => BlendMode::SourceAtop, + CompositeMode::DestAtop => BlendMode::DestinationAtop, + CompositeMode::Xor => BlendMode::Xor, + CompositeMode::Plus => BlendMode::Plus, + CompositeMode::Screen => BlendMode::Screen, + CompositeMode::Overlay => BlendMode::Overlay, + CompositeMode::Darken => BlendMode::Darken, + CompositeMode::Lighten => BlendMode::Lighten, + CompositeMode::ColorDodge => BlendMode::ColorDodge, + CompositeMode::ColorBurn => BlendMode::ColorBurn, + CompositeMode::HardLight => BlendMode::HardLight, + CompositeMode::SoftLight => BlendMode::SoftLight, + CompositeMode::Difference => BlendMode::Difference, + CompositeMode::Exclusion => BlendMode::Exclusion, + CompositeMode::Multiply => BlendMode::Multiply, + CompositeMode::HslHue => BlendMode::Hue, + CompositeMode::HslSaturation => BlendMode::Saturation, + CompositeMode::HslColor => BlendMode::Color, + CompositeMode::HslLuminosity => BlendMode::Luminosity, + _ => BlendMode::SourceOver, + } + } +} + impl ToTinySkia for Paint { type T = Option>; From 21b349a7cd464213f9a5afa3f3ed25895b80b8b4 Mon Sep 17 00:00:00 2001 From: wmedrano Date: Wed, 18 Feb 2026 15:17:40 -0800 Subject: [PATCH 3/7] Support layers for svg --- src/icon2svg.rs | 172 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 159 insertions(+), 13 deletions(-) diff --git a/src/icon2svg.rs b/src/icon2svg.rs index 5b23bab..863e67a 100644 --- a/src/icon2svg.rs +++ b/src/icon2svg.rs @@ -9,7 +9,9 @@ use crate::{ xml_element::{HexColor, TruncatedFloat, XmlElement}, }; use kurbo::Affine; -use skrifa::{prelude::Size, raw::TableProvider, FontRef, GlyphId, MetadataProvider}; +use skrifa::{ + color::CompositeMode, prelude::Size, raw::TableProvider, FontRef, GlyphId, MetadataProvider, +}; use tiny_skia::Color; /// Draws an icon from a font. @@ -85,6 +87,7 @@ fn draw_color_glyph( )); } + let paint_items = painter.into_items()?; let svg = XmlElement::new("svg") .with_attribute("xmlns", "http://www.w3.org/2000/svg") .with_attribute( @@ -96,7 +99,7 @@ fn draw_color_glyph( ) .with_attribute("height", options.width_height) .with_attribute("width", options.width_height) - .with_child(to_svg(painter.into_items()?, &options.style)?); + .with_child(to_svg(paint_items, &options.style)?); Ok(svg.to_string()) } @@ -107,16 +110,16 @@ fn add_items( group: &mut Vec, clips_cache: &mut ClipsCache, fill_cache: &mut PaintCache, + filter_cache: &mut FilterCache, ) -> Result<(), DrawSvgError> { for item in items { match item { DrawItem::Fill(fill) => { // Path - let Some(shape) = fill.clip_paths.last() else { - continue; + let mut path = match fill.clip_paths.last() { + Some(p) => XmlElement::new("path").with_attribute("d", style.write_svg_path(p)), + None => continue, }; - let mut path = - XmlElement::new("path").with_attribute("d", style.write_svg_path(shape)); // Fill fill_cache.add_fill(&mut path, &fill.paint)?; @@ -145,15 +148,31 @@ fn add_items( group.push(path); } DrawItem::Layer(layer) => { - let mut layer_group = Vec::new(); + // Dest means "keep backdrop, discard source", which does nothing. + if layer.composite_mode == CompositeMode::Dest { + continue; + } + + let mut layer_elements = Vec::new(); add_items( &layer.items, style, - &mut layer_group, + &mut layer_elements, clips_cache, fill_cache, + filter_cache, )?; - group.push(XmlElement::new("g").with_children(layer_group)); + let mut g = XmlElement::new("g").with_children(layer_elements); + if let Some(blend_mode) = composite_mode_to_mix_blend_mode(&layer.composite_mode) { + g.add_attribute( + "style", + format!("mix-blend-mode: {blend_mode}; isolation: isolate"), + ); + } else if let Some(def) = composite_mode_to_filter_operator(&layer.composite_mode) { + let id = filter_cache.get_id(def); + g.add_attribute("filter", format!("url(#{id})")); + } + group.push(g); } } } @@ -164,13 +183,22 @@ fn to_svg(items: Vec, style: &SvgPathStyle) -> Result) -> std::fmt::Result { + write!(f, "fm{}", self.0) + } +} + +/// Represents an feComposite filter definition. +#[derive(Copy, Clone, PartialEq, Eq, Hash)] +struct FilterDef { + operator: &'static str, + /// If true, swap SourceGraphic and BackgroundImage (for Dest* variants). + swap: bool, +} + +/// Caches and manages SVG filter elements to avoid duplicates in the `` section. +#[derive(Default)] +struct FilterCache { + def_to_id: HashMap, +} + +impl FilterCache { + /// Get the id for a filter with the given definition. + fn get_id(&mut self, def: FilterDef) -> FilterId { + let next_id = FilterId(self.def_to_id.len()); + *self.def_to_id.entry(def).or_insert(next_id) + } + + /// Returns an iterator over the filter elements, suitable for inclusion in ``. + fn into_svg(self) -> impl Iterator { + let mut filters: Vec<_> = self.def_to_id.into_iter().collect(); + filters.sort_unstable_by_key(|(_, id)| *id); + filters.into_iter().map(|(def, id)| { + let (src, dst) = if def.swap { + ("BackgroundImage", "SourceGraphic") + } else { + ("SourceGraphic", "BackgroundImage") + }; + let fe = if def.operator == "arithmetic" { + XmlElement::new("feComposite") + .with_attribute("in", src) + .with_attribute("in2", dst) + .with_attribute("operator", "arithmetic") + .with_attribute("k1", "0") + .with_attribute("k2", "1") + .with_attribute("k3", "1") + .with_attribute("k4", "0") + } else if def.operator == "clear" { + XmlElement::new("feFlood") + .with_attribute("flood-color", "black") + .with_attribute("flood-opacity", "0") + } else { + XmlElement::new("feComposite") + .with_attribute("in", src) + .with_attribute("in2", dst) + .with_attribute("operator", def.operator) + }; + XmlElement::new("filter") + .with_attribute("id", id) + .with_attribute("x", "0%") + .with_attribute("y", "0%") + .with_attribute("width", "100%") + .with_attribute("height", "100%") + .with_child(fe) + }) + } + + /// Returns true if there are no filters. + fn is_empty(&self) -> bool { + self.def_to_id.is_empty() + } +} + +fn composite_mode_to_mix_blend_mode(mode: &CompositeMode) -> Option<&'static str> { + match mode { + CompositeMode::SrcOver => None, // The default + CompositeMode::Screen => Some("screen"), + CompositeMode::Overlay => Some("overlay"), + CompositeMode::Darken => Some("darken"), + CompositeMode::Lighten => Some("lighten"), + CompositeMode::ColorDodge => Some("color-dodge"), + CompositeMode::ColorBurn => Some("color-burn"), + CompositeMode::HardLight => Some("hard-light"), + CompositeMode::SoftLight => Some("soft-light"), + CompositeMode::Difference => Some("difference"), + CompositeMode::Exclusion => Some("exclusion"), + CompositeMode::Multiply => Some("multiply"), + CompositeMode::HslHue => Some("hue"), + CompositeMode::HslSaturation => Some("saturation"), + CompositeMode::HslColor => Some("color"), + CompositeMode::HslLuminosity => Some("luminosity"), + _ => None, + } +} + +/// Returns the `FilterDef` for modes not expressible as mix-blend-mode. +/// +/// Returns `None` for modes handled elsewhere (SrcOver, blend modes, Src, Dest). +fn composite_mode_to_filter_operator(mode: &CompositeMode) -> Option { + let (operator, swap) = match mode { + CompositeMode::Clear => ("clear", false), + CompositeMode::DestOver => ("over", true), + CompositeMode::SrcIn => ("in", false), + CompositeMode::DestIn => ("in", true), + CompositeMode::SrcOut => ("out", false), + CompositeMode::DestOut => ("out", true), + CompositeMode::SrcAtop => ("atop", false), + CompositeMode::DestAtop => ("atop", true), + CompositeMode::Xor => ("xor", false), + CompositeMode::Plus => ("arithmetic", false), + _ => return None, + }; + Some(FilterDef { operator, swap }) +} + #[cfg(test)] mod tests { use crate::{ From 5c5006ae2da2c181b6bcb9523d1adefecd0b8c82 Mon Sep 17 00:00:00 2001 From: wmedrano Date: Thu, 26 Feb 2026 15:00:09 -0800 Subject: [PATCH 4/7] Add unit test --- resources/testdata/NotoColorEmoji.ttf | Bin 8212 -> 10736 bytes resources/testdata/color_icon_src_in.svg | 1 + resources/testdata/complex_emoji.png | Bin 5137 -> 7467 bytes src/icon2svg.rs | 17 +++++++++++++++++ src/lib.rs | 2 +- src/text2png.rs | 2 +- 6 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 resources/testdata/color_icon_src_in.svg diff --git a/resources/testdata/NotoColorEmoji.ttf b/resources/testdata/NotoColorEmoji.ttf index ff5df3b0e0b78a86ebf1ceda16c883641473269b..e01b6149e635d19c9d94e60ed4fd0b25ea5e3d7e 100644 GIT binary patch delta 4321 zcma)93vg7`8UD|?ckgTO?q-vHWtVI=*aGa_&_?vN3FGvgM(wyY6okb;s~9#j#@{nXe+ijSha&=t8n|ByGs-*eC2k^g9^MU04iG({3k4vdd2`Pp5bC{ZFpq;4KqG=J>w_dj@-DE%Wy z9T{GFZh!jintO?wu0;Ex@ulsV3tCq75;4(Uy>?>FBt?JKPQhWpxto!#AR2? z2mtK`AdfiVa*y}=6T_Vs`1+2MBo}?`9JZ{uifuE6oVeI93l;Bk2YZygz~17s`C`77Z|2wXJNQHV3I03&2LDK!CEX`i$eZQ+<=0%BTt(NPT*nkc zX;YRcFDb`VU2Rb3sjJni)VtNE)mPPzHB)QT3fjfmHQHU;W7-SaQ9YxN=@;qS^j-Qp z`sYT_=r9WI3HJ-;?dE5mxW`)M5ev!Kuw9UBDUGP9F%t>~14^>4KGp5d=DNDj_De@~ z)6`FVSI|Sc={NNsYaWj#?G+tg*u+`T9+BfxBgH5Og_$@H2E06(^g~l}T2rQr&E?(M zR4QGcOm-xdRY@i24}?Qqq0X)hKQy#;Wj4EV>(FzK5mWDDoUx_#v+L@5>Q(sxG`PS6 zvecM8f6L(DmKE8PFAEQ(y_)A_3s3bmHuR+=NS*vlVvOx=lAf)y7>(LD%6p^^dV{E{ zGneaeejqu-77l&mI}_^4h*@9WNK~M+jA<53>F%uIilSNoeu`SJAQ4H#{ta&kV~UvV zC{Vk*v)R(!DcH_ae2GQhs3&Sxha(X)WSU{a9WvZO!yT~PM!+xvx}a5H4E?K201|E^ z=r%%bcNn*6Mw~n9fim!CXbTDjUr~0tRt!PY>B>S@G!o?aqcSJ zlB7snkt7!w4#w$cTxJYQ30X+0lB_E(ua?btl5Os~7B~E^Z8G95%`oSN@5zqNZq4IT zbXk)zQ|QNZiObxQNNJT(aiY{oS1&z1F%t$d z*jozr4XC7EnunJ^n?AK3<%MyU%2`}z$w~_svZm`nFsQjWN6q!*+oW zvgTrlLgiRQ$oF5{zu=|?b#*M+QRS`R3O2KXZCx)vE_Kro?ZSHrpWt8{7ZrJ$U!|HA zwT<^7&dVj^tusjmK4Ajc@$^-3tplcP-Q^G)a*a)`;ZjT=Qzl`k~R$9m6BV(ZcpTI!*5L z)ixqKquWP@cZ?Qp=TCWzYP~5OYShA(=9N^Glw!Edu-P=;H23>+&)U+bnsQWc4u>0E zQBe_2rK!wY<%VeMye`p8j4X9@}&*8VYnLul>sKHKFu95f?iKeATqZv+}LKQ zJXT`_+>)l^<#4NJSW_e=9`3C1hJ#gYH$>99!u%D@USAcfMhw($|5QFGg~d0d%Ad=L zGRpP%6-yBXr>N~0Jp0Vbh4s~PRKNzP0AVPh%Gth7MR0~Q0L zz#xD9-s*P5I_{JYk=>V=*QutI85^A0O;qx0CodAfk|K;k;U_1x`7s;0T6Ft1gHd50L#EW2aW?T0sDcc zfhT~U0~Y~smMy}eP@zSfq`WvEDm2c?G!HrKIasy|TF&2<@3Y@LvE(G2g=&J?1;))( zBQ;mgqJVl^Y7Kea*L6Njn)gWWc2fL1;2c$TIXV_{N;{e>sR11(C7teiPVS-^O<8VE zac4Y2br`9ngddsS;iNHl_O=@$3o5jgj z{5Upf>74Yk>`YD-4m(1v5L!qrj%YKdY+0g7WR#!BZ{g>`08w*&GRzm!PBSSC-d zOQ|(ao(gI}JjZE9o@#$Xkvt_-3g@ZouM~B%RqGfXqA{q;(!6+p9ewqsxf1_tV0ND@y70>_6y^&@gBv4S||8=m;+06f6fYP_EtCUopl;AO zWkDk(-l0m$fSS|^s!#`~P#$RkE7pFB#PSY`eHMo}I7@Q~dlR)`9QET&)SQVbC&NO* zh0Hjc0os*7hVgZ)z|)IK8WUPNM8woCg`}+~ANl-w8=Hsd?oJTIsuIX7fny##k^rt8 zJjvndbz1haJlXaL+0R@`HXQ{2A=$dF+tFw=YuDG;+xdLnet&=7_O$lf{r&y6{otTo zC=~4ZV-xnsxm)aGhql`HkY!IzP1)PFZ7V((uWLPU;DFuJ^P+7(^pbt}ir4JEz?=3z zZhgz9JKwX9Y(HYt?H|~aR~)lH`Q(!#tL?UITyRIE=m&Ha3QVRYaIyiMNDo74Nk|<> zpKzU{NBl}7oMxa$e6>V!g)GB7>@N5*JY7z5MIuXMqF2ZWc`?dp78C2UcJ0KZQ^P_H g%q_y~j`2=T?k;82j{`rWfO`F%Z)@0|0UM|*qL z8&(Adt~FSk{4iXYjuYsIR?se>~QHE@cz-FjzgLzgzbW(oTrXFa+y)wy)=x$2LB z&@XIT*R^W(8s{jg8F{|jzm98|N%F+5z3C*Xqw0rcr4uGkT1DmeFfm z_bl}Ec=mdZdCq(OHgnD4<}~wZbE~=E>@_c$|9K0&Ro+JLT09gD z>HENU(s${|4E^)`BR%2wMcTN2zjqg+Acx@ZurD`0nO(#iGK_X-zuO@wya+Pow z@=3zUC3hy>*HufZNR$lXo!%zb2p13xC_W?y$UefCDE5$7NDq0QtR)O7EaLtE#_**C z5vKYvgz7r5x)=Ld{SqVcI*&n@92p^~_Xp39l-)qJpMJP_C>o ziOP(lY)?gKYTQCeVW!EiCiNgHQIzpSRG=5M9Sr2^7w)08qfkZ3VU$uv>EFgsJ5>=% zS6DEfMc6_h7sVC|`w+Ac=|#4MsEL4u>Z{0AqYKm$Eofmb3(eFzOs0-OlS4KKQ?8q+=dfS# zF!c)Rs24Gw`aQ-`zr|Soc}=u8#|!Kk@hVYkZ;CgiYuM8W_sQ0ynp%s|)Fd9F4nvY! zj#1PwMpBD0f*QndsvlKU4~9wjd3kOeK@IVnNrn3tnG^wPj3B{I{_`UnXt&@vt#EJ# zPQ2|@r0u^dOCw{)o_3sr-#C}o|KR+#{gQ(> zuQ}IuUw5$c52tJW4SPbO#+}+--OmeSD#*z#ZS4rINUd4vF3As%99-894?X%n>p&*; diff --git a/resources/testdata/color_icon_src_in.svg b/resources/testdata/color_icon_src_in.svg new file mode 100644 index 0000000..fb08031 --- /dev/null +++ b/resources/testdata/color_icon_src_in.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/testdata/complex_emoji.png b/resources/testdata/complex_emoji.png index 7ab3f544b9e89e8926df8fa826f160f1ba81fd59..cd561861ed1b61fde3134fe4f4436b20cfcd436a 100644 GIT binary patch literal 7467 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 literal 5137 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 diff --git a/src/icon2svg.rs b/src/icon2svg.rs index 863e67a..0ef27c0 100644 --- a/src/icon2svg.rs +++ b/src/icon2svg.rs @@ -766,6 +766,23 @@ mod tests { assert_eq!(svg.matches("url(#p0)").count(), 2); } + #[test] + fn color_icon_with_src_in_blending() { + let font = FontRef::new(testdata::NOTO_EMOJI_FONT).unwrap(); + let svg = draw_icon( + &font, + &DrawOptions::new( + // gid 1959 in the original NotoColorEmoji font, uses SrcIn blending. + IconIdentifier::GlyphId(GlyphId::new(2)), + 128.0, + LocationRef::default(), + SvgPathStyle::Unchanged(2), + ), + ) + .unwrap(); + assert_file_eq!(svg, "color_icon_src_in.svg"); + } + // Sweep gradients are not supported in SVG. #[test] fn icon_with_sweep_gradient_produces_error() { diff --git a/src/lib.rs b/src/lib.rs index ef2fc74..16b5870 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -51,7 +51,7 @@ mod testdata { pub static COLR_FONT: &[u8] = include_bytes!("../resources/testdata/colr.ttf"); // Generated with: // skera --path NotoColorEmoji-Regular.ttf --output-file resources/testdata/NotoColorEmoji.ttf \ - // --unicodes U+1F973 --gids 1760,1959 + // --unicodes U+1F973,U+1F1F5,U+1F1F1 --gids 1760,1959 pub static NOTO_EMOJI_FONT: &[u8] = include_bytes!("../resources/testdata/NotoColorEmoji.ttf"); pub static CAVEAT_FONT: &[u8] = include_bytes!("../resources/testdata/caveat.ttf"); pub static NOTO_KUFI_ARABIC_FONT: &[u8] = diff --git a/src/text2png.rs b/src/text2png.rs index 336943e..89d3f52 100644 --- a/src/text2png.rs +++ b/src/text2png.rs @@ -535,7 +535,7 @@ mod tests { fn complex_emoji() { // TODO: Improve the centering algorithm. let png_bytes = text2png( - "🥳", + "🥳🇵🇱", &Text2PngOptions { background: Color::WHITE, ..Text2PngOptions::new(testdata::NOTO_EMOJI_FONT, 64.0) From bb0ff0214b364ebd0f86970913ce66d6ffd51a9a Mon Sep 17 00:00:00 2001 From: wmedrano Date: Thu, 26 Feb 2026 16:20:05 -0800 Subject: [PATCH 5/7] cleanup --- src/icon2svg.rs | 141 +++++++++++++++++++++++++++++------------------- 1 file changed, 85 insertions(+), 56 deletions(-) diff --git a/src/icon2svg.rs b/src/icon2svg.rs index 0ef27c0..803abf4 100644 --- a/src/icon2svg.rs +++ b/src/icon2svg.rs @@ -5,7 +5,7 @@ use crate::{ draw_glyph::*, error::DrawSvgError, pathstyle::SvgPathStyle, - pens::{ColorStop, DrawItem, GlyphPainter, Paint}, + pens::{ColorFill, ColorStop, DrawItem, GlyphPainter, Paint}, xml_element::{HexColor, TruncatedFloat, XmlElement}, }; use kurbo::Affine; @@ -104,6 +104,34 @@ fn draw_color_glyph( Ok(svg.to_string()) } +fn fill_to_svg( + fill: &ColorFill, + style: &SvgPathStyle, + clips_cache: &mut ClipsCache, + fill_cache: &mut PaintCache, +) -> Result, DrawSvgError> { + let Some(shape) = fill.clip_paths.last() else { + return Ok(None); + }; + let mut path = XmlElement::new("path").with_attribute("d", style.write_svg_path(shape)); + fill_cache.add_fill(&mut path, &fill.paint)?; + let mut clip_parent_id = None; + for clip in &fill.clip_paths[0..fill.clip_paths.len() - 1] { + let id = clips_cache.get_id(clip_parent_id, style.write_svg_path(clip).to_string()); + clip_parent_id = Some(id); + } + if let Some(id) = clip_parent_id { + path.add_attribute("clip-path", format!("url(#{})", id)); + } + if fill.offset_x != 0.0 || fill.offset_y != 0.0 { + path.add_attribute( + "transform", + format!("translate({} {})", fill.offset_x, fill.offset_y), + ); + } + Ok(Some(path)) +} + fn add_items( items: &[DrawItem], style: &SvgPathStyle, @@ -115,37 +143,9 @@ fn add_items( for item in items { match item { DrawItem::Fill(fill) => { - // Path - let mut path = match fill.clip_paths.last() { - Some(p) => XmlElement::new("path").with_attribute("d", style.write_svg_path(p)), - None => continue, - }; - - // Fill - fill_cache.add_fill(&mut path, &fill.paint)?; - - // Clip - let mut clip_parent_id = None; - if fill.clip_paths.len() > 1 { - for clip in &fill.clip_paths[0..fill.clip_paths.len() - 1] { - let id = clips_cache - .get_id(clip_parent_id, style.write_svg_path(clip).to_string()); - clip_parent_id = Some(id); - } - } - if let Some(id) = clip_parent_id { - path.add_attribute("clip-path", format!("url(#{})", id)); + if let Some(path) = fill_to_svg(fill, style, clips_cache, fill_cache)? { + group.push(path); } - - // Offset - if fill.offset_x != 0.0 || fill.offset_y != 0.0 { - path.add_attribute( - "transform", - format!("translate({} {})", fill.offset_x, fill.offset_y), - ); - } - - group.push(path); } DrawItem::Layer(layer) => { // Dest means "keep backdrop, discard source", which does nothing. @@ -411,12 +411,40 @@ impl std::fmt::Display for FilterId { } } -/// Represents an feComposite filter definition. +/// The SVG filter primitive to use for a Porter-Duff composite mode. +#[derive(Copy, Clone, PartialEq, Eq, Hash)] +enum FilterOperator { + In, + Out, + Atop, + Over, + Xor, + /// feComposite arithmetic with k2=1, k3=1: result = src + dst + Plus, + /// feFlood with flood-opacity=0: result = transparent + Clear, +} + +impl FilterOperator { + fn as_str(self) -> &'static str { + match self { + FilterOperator::In => "in", + FilterOperator::Out => "out", + FilterOperator::Atop => "atop", + FilterOperator::Over => "over", + FilterOperator::Xor => "xor", + FilterOperator::Plus => "arithmetic", + FilterOperator::Clear => "clear", + } + } +} + +/// Represents an SVG filter definition for a Porter-Duff composite mode. #[derive(Copy, Clone, PartialEq, Eq, Hash)] struct FilterDef { - operator: &'static str, + operator: FilterOperator, /// If true, swap SourceGraphic and BackgroundImage (for Dest* variants). - swap: bool, + swap_src_dst: bool, } /// Caches and manages SVG filter elements to avoid duplicates in the `` section. @@ -437,29 +465,27 @@ impl FilterCache { let mut filters: Vec<_> = self.def_to_id.into_iter().collect(); filters.sort_unstable_by_key(|(_, id)| *id); filters.into_iter().map(|(def, id)| { - let (src, dst) = if def.swap { + let (src, dst) = if def.swap_src_dst { ("BackgroundImage", "SourceGraphic") } else { ("SourceGraphic", "BackgroundImage") }; - let fe = if def.operator == "arithmetic" { - XmlElement::new("feComposite") + let fe = match def.operator { + FilterOperator::Plus => XmlElement::new("feComposite") .with_attribute("in", src) .with_attribute("in2", dst) .with_attribute("operator", "arithmetic") .with_attribute("k1", "0") .with_attribute("k2", "1") .with_attribute("k3", "1") - .with_attribute("k4", "0") - } else if def.operator == "clear" { - XmlElement::new("feFlood") + .with_attribute("k4", "0"), + FilterOperator::Clear => XmlElement::new("feFlood") .with_attribute("flood-color", "black") - .with_attribute("flood-opacity", "0") - } else { - XmlElement::new("feComposite") + .with_attribute("flood-opacity", "0"), + op => XmlElement::new("feComposite") .with_attribute("in", src) .with_attribute("in2", dst) - .with_attribute("operator", def.operator) + .with_attribute("operator", op.as_str()), }; XmlElement::new("filter") .with_attribute("id", id) @@ -503,20 +529,23 @@ fn composite_mode_to_mix_blend_mode(mode: &CompositeMode) -> Option<&'static str /// /// Returns `None` for modes handled elsewhere (SrcOver, blend modes, Src, Dest). fn composite_mode_to_filter_operator(mode: &CompositeMode) -> Option { - let (operator, swap) = match mode { - CompositeMode::Clear => ("clear", false), - CompositeMode::DestOver => ("over", true), - CompositeMode::SrcIn => ("in", false), - CompositeMode::DestIn => ("in", true), - CompositeMode::SrcOut => ("out", false), - CompositeMode::DestOut => ("out", true), - CompositeMode::SrcAtop => ("atop", false), - CompositeMode::DestAtop => ("atop", true), - CompositeMode::Xor => ("xor", false), - CompositeMode::Plus => ("arithmetic", false), + let (operator, swap_src_dst) = match mode { + CompositeMode::Clear => (FilterOperator::Clear, false), + CompositeMode::DestOver => (FilterOperator::Over, true), + CompositeMode::SrcIn => (FilterOperator::In, false), + CompositeMode::DestIn => (FilterOperator::In, true), + CompositeMode::SrcOut => (FilterOperator::Out, false), + CompositeMode::DestOut => (FilterOperator::Out, true), + CompositeMode::SrcAtop => (FilterOperator::Atop, false), + CompositeMode::DestAtop => (FilterOperator::Atop, true), + CompositeMode::Xor => (FilterOperator::Xor, false), + CompositeMode::Plus => (FilterOperator::Plus, false), _ => return None, }; - Some(FilterDef { operator, swap }) + Some(FilterDef { + operator, + swap_src_dst, + }) } #[cfg(test)] From f64baf1346d33be4214a606cecd4a859e49aa347 Mon Sep 17 00:00:00 2001 From: wmedrano Date: Fri, 27 Feb 2026 09:53:25 -0800 Subject: [PATCH 6/7] Remove failing composite modes --- resources/testdata/color_icon_src_in.svg | 1 - src/error.rs | 9 +- src/icon2svg.rs | 191 ++++------------------- src/text2png.rs | 2 +- 4 files changed, 39 insertions(+), 164 deletions(-) delete mode 100644 resources/testdata/color_icon_src_in.svg diff --git a/resources/testdata/color_icon_src_in.svg b/resources/testdata/color_icon_src_in.svg deleted file mode 100644 index fb08031..0000000 --- a/resources/testdata/color_icon_src_in.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/error.rs b/src/error.rs index eed4bd5..4a0d8eb 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,5 +1,10 @@ use crate::{iconid::IconIdentifier, pens::GlyphPainterError}; -use skrifa::{color::PaintError, outline::DrawError, raw::ReadError, GlyphId}; +use skrifa::{ + color::{CompositeMode, PaintError}, + outline::DrawError, + raw::ReadError, + GlyphId, +}; use thiserror::Error; #[derive(Error, Debug)] @@ -22,6 +27,8 @@ pub enum DrawSvgError { ReadError(&'static str, skrifa::raw::ReadError), #[error("Unsupported SVG feature: sweep gradient")] SweepGradientNotSupported, + #[error("Unsupported SVG feature: composite mode {0:?}")] + CompositeModeNotSupported(CompositeMode), } #[derive(Debug, Error)] diff --git a/src/icon2svg.rs b/src/icon2svg.rs index 803abf4..169a08d 100644 --- a/src/icon2svg.rs +++ b/src/icon2svg.rs @@ -138,7 +138,6 @@ fn add_items( group: &mut Vec, clips_cache: &mut ClipsCache, fill_cache: &mut PaintCache, - filter_cache: &mut FilterCache, ) -> Result<(), DrawSvgError> { for item in items { match item { @@ -160,7 +159,6 @@ fn add_items( &mut layer_elements, clips_cache, fill_cache, - filter_cache, )?; let mut g = XmlElement::new("g").with_children(layer_elements); if let Some(blend_mode) = composite_mode_to_mix_blend_mode(&layer.composite_mode) { @@ -168,9 +166,10 @@ fn add_items( "style", format!("mix-blend-mode: {blend_mode}; isolation: isolate"), ); - } else if let Some(def) = composite_mode_to_filter_operator(&layer.composite_mode) { - let id = filter_cache.get_id(def); - g.add_attribute("filter", format!("url(#{id})")); + } else if layer.composite_mode != CompositeMode::SrcOver { + return Err(DrawSvgError::CompositeModeNotSupported( + layer.composite_mode, + )); } group.push(g); } @@ -183,22 +182,13 @@ fn to_svg(items: Vec, style: &SvgPathStyle) -> Result) -> std::fmt::Result { - write!(f, "fm{}", self.0) - } -} - -/// The SVG filter primitive to use for a Porter-Duff composite mode. -#[derive(Copy, Clone, PartialEq, Eq, Hash)] -enum FilterOperator { - In, - Out, - Atop, - Over, - Xor, - /// feComposite arithmetic with k2=1, k3=1: result = src + dst - Plus, - /// feFlood with flood-opacity=0: result = transparent - Clear, -} - -impl FilterOperator { - fn as_str(self) -> &'static str { - match self { - FilterOperator::In => "in", - FilterOperator::Out => "out", - FilterOperator::Atop => "atop", - FilterOperator::Over => "over", - FilterOperator::Xor => "xor", - FilterOperator::Plus => "arithmetic", - FilterOperator::Clear => "clear", - } - } -} - -/// Represents an SVG filter definition for a Porter-Duff composite mode. -#[derive(Copy, Clone, PartialEq, Eq, Hash)] -struct FilterDef { - operator: FilterOperator, - /// If true, swap SourceGraphic and BackgroundImage (for Dest* variants). - swap_src_dst: bool, -} - -/// Caches and manages SVG filter elements to avoid duplicates in the `` section. -#[derive(Default)] -struct FilterCache { - def_to_id: HashMap, -} - -impl FilterCache { - /// Get the id for a filter with the given definition. - fn get_id(&mut self, def: FilterDef) -> FilterId { - let next_id = FilterId(self.def_to_id.len()); - *self.def_to_id.entry(def).or_insert(next_id) - } - - /// Returns an iterator over the filter elements, suitable for inclusion in ``. - fn into_svg(self) -> impl Iterator { - let mut filters: Vec<_> = self.def_to_id.into_iter().collect(); - filters.sort_unstable_by_key(|(_, id)| *id); - filters.into_iter().map(|(def, id)| { - let (src, dst) = if def.swap_src_dst { - ("BackgroundImage", "SourceGraphic") - } else { - ("SourceGraphic", "BackgroundImage") - }; - let fe = match def.operator { - FilterOperator::Plus => XmlElement::new("feComposite") - .with_attribute("in", src) - .with_attribute("in2", dst) - .with_attribute("operator", "arithmetic") - .with_attribute("k1", "0") - .with_attribute("k2", "1") - .with_attribute("k3", "1") - .with_attribute("k4", "0"), - FilterOperator::Clear => XmlElement::new("feFlood") - .with_attribute("flood-color", "black") - .with_attribute("flood-opacity", "0"), - op => XmlElement::new("feComposite") - .with_attribute("in", src) - .with_attribute("in2", dst) - .with_attribute("operator", op.as_str()), - }; - XmlElement::new("filter") - .with_attribute("id", id) - .with_attribute("x", "0%") - .with_attribute("y", "0%") - .with_attribute("width", "100%") - .with_attribute("height", "100%") - .with_child(fe) - }) - } - - /// Returns true if there are no filters. - fn is_empty(&self) -> bool { - self.def_to_id.is_empty() - } -} - fn composite_mode_to_mix_blend_mode(mode: &CompositeMode) -> Option<&'static str> { match mode { CompositeMode::SrcOver => None, // The default @@ -525,29 +413,6 @@ fn composite_mode_to_mix_blend_mode(mode: &CompositeMode) -> Option<&'static str } } -/// Returns the `FilterDef` for modes not expressible as mix-blend-mode. -/// -/// Returns `None` for modes handled elsewhere (SrcOver, blend modes, Src, Dest). -fn composite_mode_to_filter_operator(mode: &CompositeMode) -> Option { - let (operator, swap_src_dst) = match mode { - CompositeMode::Clear => (FilterOperator::Clear, false), - CompositeMode::DestOver => (FilterOperator::Over, true), - CompositeMode::SrcIn => (FilterOperator::In, false), - CompositeMode::DestIn => (FilterOperator::In, true), - CompositeMode::SrcOut => (FilterOperator::Out, false), - CompositeMode::DestOut => (FilterOperator::Out, true), - CompositeMode::SrcAtop => (FilterOperator::Atop, false), - CompositeMode::DestAtop => (FilterOperator::Atop, true), - CompositeMode::Xor => (FilterOperator::Xor, false), - CompositeMode::Plus => (FilterOperator::Plus, false), - _ => return None, - }; - Some(FilterDef { - operator, - swap_src_dst, - }) -} - #[cfg(test)] mod tests { use crate::{ @@ -559,7 +424,7 @@ mod tests { testdata, }; use regex::Regex; - use skrifa::{prelude::LocationRef, FontRef, GlyphId, MetadataProvider}; + use skrifa::{color::CompositeMode, prelude::LocationRef, FontRef, GlyphId, MetadataProvider}; use tiny_skia::Color; use super::DrawOptions; @@ -795,23 +660,6 @@ mod tests { assert_eq!(svg.matches("url(#p0)").count(), 2); } - #[test] - fn color_icon_with_src_in_blending() { - let font = FontRef::new(testdata::NOTO_EMOJI_FONT).unwrap(); - let svg = draw_icon( - &font, - &DrawOptions::new( - // gid 1959 in the original NotoColorEmoji font, uses SrcIn blending. - IconIdentifier::GlyphId(GlyphId::new(2)), - 128.0, - LocationRef::default(), - SvgPathStyle::Unchanged(2), - ), - ) - .unwrap(); - assert_file_eq!(svg, "color_icon_src_in.svg"); - } - // Sweep gradients are not supported in SVG. #[test] fn icon_with_sweep_gradient_produces_error() { @@ -829,4 +677,25 @@ mod tests { Err(DrawSvgError::SweepGradientNotSupported) ); } + + #[test] + fn color_icon_with_src_in_blending_produces_not_supported_error() { + let font = FontRef::new(testdata::NOTO_EMOJI_FONT).unwrap(); + let result = draw_icon( + &font, + &DrawOptions::new( + // gid 1959 in the original NotoColorEmoji font, uses SrcIn blending. + IconIdentifier::GlyphId(GlyphId::new(2)), + 128.0, + LocationRef::default(), + SvgPathStyle::Unchanged(2), + ), + ); + assert_matches!( + result, + Err(DrawSvgError::CompositeModeNotSupported( + CompositeMode::SrcIn + )) + ); + } } diff --git a/src/text2png.rs b/src/text2png.rs index 89d3f52..2308b31 100644 --- a/src/text2png.rs +++ b/src/text2png.rs @@ -370,7 +370,7 @@ impl ToTinySkia for CompositeMode { CompositeMode::HslSaturation => BlendMode::Saturation, CompositeMode::HslColor => BlendMode::Color, CompositeMode::HslLuminosity => BlendMode::Luminosity, - _ => BlendMode::SourceOver, + _ => BlendMode::SourceOver, // Required as enum is non-exhaustive } } } From e2d1126ce939f667ef7f0c06101d1bfb8e799250 Mon Sep 17 00:00:00 2001 From: wmedrano Date: Fri, 27 Feb 2026 11:28:20 -0800 Subject: [PATCH 7/7] cleanup --- src/text2png.rs | 63 ++++++++++++++++++++++++++++--------------------- 1 file changed, 36 insertions(+), 27 deletions(-) diff --git a/src/text2png.rs b/src/text2png.rs index 2308b31..254bc42 100644 --- a/src/text2png.rs +++ b/src/text2png.rs @@ -36,6 +36,8 @@ pub enum TextToPngError { GlyphPainterError(#[from] GlyphPainterError), #[error("Malformed gradient")] MalformedGradient, + #[error("Unsupported composite mode")] + UnsupportedCompositeMode, } // TODO: From can be autoderived with `#[from]` once @@ -241,30 +243,36 @@ fn render_items( mask.as_ref(), ); } - DrawItem::Layer(layer) => match layer.composite_mode.to_tinyskia() { - BlendMode::SourceOver => { - render_items(&layer.items, pixmap, x_offset, y_offset)?; - } - blend_mode => { - let Some(mut layer_pixmap) = Pixmap::new(pixmap.width(), pixmap.height()) - else { - // Unreachable unless pixmap has 0 width or height. - continue; - }; - render_items(&layer.items, &mut layer_pixmap, x_offset, y_offset)?; - pixmap.draw_pixmap( - 0, - 0, - layer_pixmap.as_ref(), - &PixmapPaint { - blend_mode, - ..PixmapPaint::default() - }, - Transform::identity(), - None, - ); + DrawItem::Layer(layer) => { + match layer + .composite_mode + .to_tinyskia() + .ok_or(TextToPngError::UnsupportedCompositeMode)? + { + BlendMode::SourceOver => { + render_items(&layer.items, pixmap, x_offset, y_offset)?; + } + blend_mode => { + let Some(mut layer_pixmap) = Pixmap::new(pixmap.width(), pixmap.height()) + else { + // Unreachable unless pixmap has 0 width or height. + continue; + }; + render_items(&layer.items, &mut layer_pixmap, x_offset, y_offset)?; + pixmap.draw_pixmap( + 0, + 0, + layer_pixmap.as_ref(), + &PixmapPaint { + blend_mode, + ..PixmapPaint::default() + }, + Transform::identity(), + None, + ); + } } - }, + } } } Ok(()) @@ -338,10 +346,10 @@ impl ToTinySkia for Affine { } impl ToTinySkia for CompositeMode { - type T = BlendMode; + type T = Option; fn to_tinyskia(&self) -> Self::T { - match self { + let mode = match self { CompositeMode::Clear => BlendMode::Clear, CompositeMode::Src => BlendMode::Source, CompositeMode::Dest => BlendMode::Destination, @@ -370,8 +378,9 @@ impl ToTinySkia for CompositeMode { CompositeMode::HslSaturation => BlendMode::Saturation, CompositeMode::HslColor => BlendMode::Color, CompositeMode::HslLuminosity => BlendMode::Luminosity, - _ => BlendMode::SourceOver, // Required as enum is non-exhaustive - } + _ => return None, // Required as enum is non-exhaustive + }; + Some(mode) } }