Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified resources/testdata/NotoColorEmoji.ttf
Binary file not shown.
Binary file modified resources/testdata/complex_emoji.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 8 additions & 1 deletion src/error.rs
Original file line number Diff line number Diff line change
@@ -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)]
Expand All @@ -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)]
Expand Down
159 changes: 124 additions & 35 deletions src/icon2svg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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(
Expand All @@ -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<ColorFill>, style: &SvgPathStyle) -> Result<XmlElement, DrawSvgError> {
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<Option<XmlElement>, 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<XmlElement>,
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<DrawItem>, style: &SvgPathStyle) -> Result<XmlElement, DrawSvgError> {
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(
Expand Down Expand Up @@ -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::{
Expand All @@ -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;
Expand Down Expand Up @@ -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
))
);
}
}
4 changes: 2 additions & 2 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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] =
Expand Down
57 changes: 46 additions & 11 deletions src/pens.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<DrawItem>,
pub composite_mode: CompositeMode,
}

#[derive(Debug, Clone)]
pub enum Paint {
Solid(Color),
Expand Down Expand Up @@ -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<BezPath>,
transforms: Vec<Affine>,
/// All the fills that have been finalized.
fills: Vec<ColorFill>,
/// Stack of in-progress item lists. The bottom entry is the root layer.
layer_stack: Vec<Vec<DrawItem>>,
}

/// TODO: Make this into a const once <https://github.com/googlefonts/fontations/pull/1707> has been
Expand Down Expand Up @@ -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<Vec<ColorFill>, GlyphPainterError> {
self.builder.map(|i| i.fills)
/// Returns the completed draw items, or an error if one occurred.
pub fn into_items(self) -> Result<Vec<DrawItem>, GlyphPainterError> {
self.builder.map(|mut b| b.layer_stack.swap_remove(0))
}

fn set_err(&mut self, err: GlyphPainterError) {
Expand Down Expand Up @@ -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,
}));
}
}
}
}
Loading