diff --git a/resources/testdata/NotoColorEmoji.ttf b/resources/testdata/NotoColorEmoji.ttf index 811859a..e01b614 100644 Binary files a/resources/testdata/NotoColorEmoji.ttf and b/resources/testdata/NotoColorEmoji.ttf differ diff --git a/resources/testdata/complex_emoji.png b/resources/testdata/complex_emoji.png index 7ab3f54..cd56186 100644 Binary files a/resources/testdata/complex_emoji.png and b/resources/testdata/complex_emoji.png differ 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 bb114fd..169a08d 100644 --- a/src/icon2svg.rs +++ b/src/icon2svg.rs @@ -5,11 +5,13 @@ use crate::{ draw_glyph::*, error::DrawSvgError, pathstyle::SvgPathStyle, - pens::{ColorFill, ColorStop, GlyphPainter, Paint}, + pens::{ColorFill, ColorStop, DrawItem, GlyphPainter, Paint}, 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,47 +99,90 @@ 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(paint_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)); - - // Fill - fill_cache.add_fill(&mut path, &fill.paint)?; +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)) +} - // 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); +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) => { + if let Some(path) = fill_to_svg(fill, style, clips_cache, fill_cache)? { + group.push(path); + } } - } - if let Some(id) = clip_parent_id { - path.add_attribute("clip-path", format!("url(#{})", id)); - } + DrawItem::Layer(layer) => { + // Dest means "keep backdrop, discard source", which does nothing. + if layer.composite_mode == CompositeMode::Dest { + continue; + } - // Offset - if fill.offset_x != 0.0 || fill.offset_y != 0.0 { - path.add_attribute( - "transform", - format!("translate({} {})", fill.offset_x, fill.offset_y), - ); + let mut layer_elements = Vec::new(); + add_items( + &layer.items, + style, + &mut layer_elements, + clips_cache, + fill_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) { + g.add_attribute( + "style", + format!("mix-blend-mode: {blend_mode}; isolation: isolate"), + ); + } else if layer.composite_mode != CompositeMode::SrcOver { + return Err(DrawSvgError::CompositeModeNotSupported( + layer.composite_mode, + )); + } + group.push(g); + } } - - 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( @@ -345,6 +391,28 @@ impl std::fmt::Display for ClipId { } } +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, + } +} + #[cfg(test)] mod tests { use crate::{ @@ -356,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; @@ -609,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/lib.rs b/src/lib.rs index 9f75b7e..16b5870 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,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/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..254bc42 100644 --- a/src/text2png.rs +++ b/src/text2png.rs @@ -1,19 +1,20 @@ //! 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::{ - 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. @@ -35,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 @@ -138,7 +141,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 +155,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 +210,81 @@ 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) => { + 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(()) +} + +/// 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. -fn to_pixmap( - fills: &[crate::pens::ColorFill], - background: Color, - height: f64, -) -> Result { - let bounds = compute_bounds(fills); +/// bounding box of the items. +fn to_pixmap(items: &[DrawItem], background: Color, height: f64) -> Result { + let bounds = compute_bounds(items); let width = bounds.width(); let mut pixmap = Pixmap::new(width.ceil() as u32, height.ceil() as u32) @@ -216,31 +293,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) } @@ -292,6 +345,45 @@ impl ToTinySkia for Affine { } } +impl ToTinySkia for CompositeMode { + type T = Option; + + fn to_tinyskia(&self) -> Self::T { + let mode = 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, + _ => return None, // Required as enum is non-exhaustive + }; + Some(mode) + } +} + impl ToTinySkia for Paint { type T = Option>; @@ -452,7 +544,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)