From 7a5912318d36849ba8360b23ed899a49274fbdce Mon Sep 17 00:00:00 2001 From: wmedrano Date: Tue, 4 Aug 2026 10:08:15 -0700 Subject: [PATCH 1/2] Add layer support to pen --- src/draw_icon/icon2svg.rs | 13 +++-- src/error.rs | 2 + src/pens.rs | 106 ++++++++++++++++++++++++++++++++++---- src/text2png.rs | 30 +++++++---- 4 files changed, 127 insertions(+), 24 deletions(-) diff --git a/src/draw_icon/icon2svg.rs b/src/draw_icon/icon2svg.rs index b061495..3006908 100644 --- a/src/draw_icon/icon2svg.rs +++ b/src/draw_icon/icon2svg.rs @@ -5,7 +5,7 @@ use super::{draw_glyph, get_pen, DrawOptions, DrawingInstructions, GlyphType}; use crate::{ error::DrawSvgError, pathstyle::SvgPathStyle, - pens::{ColorFill, ColorStop, GlyphPainter, Paint}, + pens::{ColorDraw, ColorStop, GlyphPainter, Paint}, xml_element::{HexColor, TruncatedFloat, XmlElement}, }; use kurbo::Affine; @@ -76,14 +76,19 @@ fn draw_color_glyph( )); } - to_svg(painter.into_fills()?, &options.style) + let draws = painter.into_draws()?; + to_svg(draws, &options.style) } -fn to_svg(fills: Vec, style: &SvgPathStyle) -> Result { +fn to_svg(draws: 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() { + for draw in draws.iter() { + let fill = match draw { + ColorDraw::Fill(color_fill) => color_fill, + ColorDraw::Layer { .. } => return Err(DrawSvgError::LayersNotSupported), + }; // Path let Some(shape) = fill.clip_paths.last() else { continue; diff --git a/src/error.rs b/src/error.rs index 18a67bf..2e50f3c 100644 --- a/src/error.rs +++ b/src/error.rs @@ -26,6 +26,8 @@ pub enum DrawSvgError { ColorGlyphNotSupported(GlyphId), #[error("Unexpected draw type: {0}")] UnExpectedDrawType(String), + #[error("Layers not yet supported")] + LayersNotSupported, } #[derive(Debug, Error)] diff --git a/src/pens.rs b/src/pens.rs index 2549a27..98a1db2 100644 --- a/src/pens.rs +++ b/src/pens.rs @@ -124,6 +124,18 @@ pub enum Paint { }, } +/// A drawing operation produced by exercising a color glyph. +#[derive(Debug, Clone)] +pub enum ColorDraw { + /// A single fill operation. + Fill(ColorFill), + /// A layer with a composite mode and child drawing operations. + Layer { + mode: CompositeMode, + draws: Vec, + }, +} + /// Error that occurs when trying to use a color painter. #[derive(Error, Debug)] pub enum GlyphPainterError { @@ -133,9 +145,13 @@ pub enum GlyphPainterError { UnsupportedFontFeature(&'static str), #[error("{0}")] DrawError(#[from] DrawError), + #[error("Layer stack underflow")] + LayerStackUnderflow, + #[error("Layer stack not empty")] + LayerStackNotEmpty, } -/// A [ColorPainter] that generates a series of [ColorFill]s. +/// A [ColorPainter] that generates a series of [ColorDraw]s. pub struct GlyphPainter<'a> { /// The x-offset for the next fill operation. pub x: f64, @@ -155,8 +171,10 @@ struct ColorFillsBuilder { /// The path for the next fill. paths: Vec, transforms: Vec, - /// All the fills that have been finalized. - fills: Vec, + /// The current layer that is being drawn. + current_layer: Vec, + /// Stack of open drawing layers. + layer_stack: Vec>, } /// TODO: Make this into a const once has been @@ -200,14 +218,19 @@ impl<'a> GlyphPainter<'a> { builder: Ok(ColorFillsBuilder { paths: Vec::new(), transforms: Vec::new(), - fills: Vec::new(), + layer_stack: vec![], + current_layer: 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 color drawing operations, or an error if one occurred. + pub fn into_draws(self) -> Result, GlyphPainterError> { + let builder = self.builder?; + if !builder.layer_stack.is_empty() { + return Err(GlyphPainterError::LayerStackNotEmpty); + } + Ok(builder.current_layer) } fn set_err(&mut self, err: GlyphPainterError) { @@ -401,15 +424,76 @@ impl<'a> ColorPainter for GlyphPainter<'a> { transform, }, }; - builder.fills.push(ColorFill { + builder.current_layer.push(ColorDraw::Fill(ColorFill { paint, clip_paths: builder.paths.clone(), offset_x: self.x, offset_y: self.y, - }); + })); + } + + fn push_layer(&mut self, _mode: CompositeMode) { + let Ok(builder) = self.builder.as_mut() else { + return; + }; + let parent = std::mem::take(&mut builder.current_layer); + builder.layer_stack.push(parent); } - fn push_layer(&mut self, _: CompositeMode) { - self.set_err(GlyphPainterError::UnsupportedFontFeature("colr layers")); + fn pop_layer_with_mode(&mut self, mode: CompositeMode) { + let Ok(builder) = self.builder.as_mut() else { + return; + }; + let Some(parent) = builder.layer_stack.pop() else { + self.set_err(GlyphPainterError::LayerStackUnderflow); + return; + }; + let child = std::mem::replace(&mut builder.current_layer, parent); + builder + .current_layer + .push(ColorDraw::Layer { mode, draws: child }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testdata; + use skrifa::{prelude::LocationRef, raw::FontRef}; + + #[test] + fn painter_composite_glyph() { + // Glyph 0xf0a0d in colr.ttf is composite_SCREEN + let font = FontRef::new(testdata::COLR_FONT).unwrap(); + let cmap = font.cmap().unwrap(); + let glyph_id = cmap.map_codepoint(0xf0a0d_u32).unwrap(); + let color_glyph = font.color_glyphs().get(glyph_id).unwrap(); + + let mut painter = GlyphPainter::new( + &font, + LocationRef::default(), + Color::BLACK, + Size::unscaled(), + ); + color_glyph + .paint(LocationRef::default(), &mut painter) + .unwrap(); + let draws = painter.into_draws().unwrap(); + + assert!(!draws.is_empty()); + fn has_layer_mode(draws: &[ColorDraw], expected: CompositeMode) -> bool { + for draw in draws { + if let ColorDraw::Layer { mode, draws } = draw { + if *mode == expected || has_layer_mode(draws, expected) { + return true; + } + } + } + false + } + assert!( + has_layer_mode(&draws, CompositeMode::Screen), + "Expected at least one Layer with Screen mode in draws: {draws:?}" + ); } } diff --git a/src/text2png.rs b/src/text2png.rs index e30b718..aff15e6 100644 --- a/src/text2png.rs +++ b/src/text2png.rs @@ -35,6 +35,8 @@ pub enum TextToPngError { GlyphPainterError(#[from] GlyphPainterError), #[error("Malformed gradient")] MalformedGradient, + #[error("{0} not supported")] + NotSupported(&'static str), } // TODO: From can be autoderived with `#[from]` once @@ -138,7 +140,8 @@ 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 draws = painter.into_draws()?; + let pixmap = to_pixmap(&draws, options.background, expected_height)?; let bytes = encode_png(pixmap)?; Ok(bytes) } @@ -189,12 +192,15 @@ fn clip_bounds(paths: &[BezPath]) -> Option { /// Computes the union of bounding boxes for all provided color fills, /// considering their respective offsets and clip paths. -fn compute_bounds(fills: &[crate::pens::ColorFill]) -> Rect { - fills +fn compute_bounds(draws: &[crate::pens::ColorDraw]) -> Rect { + draws .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(|draw| match draw { + crate::pens::ColorDraw::Fill(fill) => { + let add_offset = |b| b + Vec2::new(fill.offset_x, fill.offset_y); + clip_bounds(&fill.clip_paths).map(add_offset) + } + crate::pens::ColorDraw::Layer { .. } => todo!(), }) .reduce(|a, b| a.union(b)) .unwrap_or_default() @@ -238,11 +244,11 @@ fn to_mask( /// The Pixmap's width is determined automatically based on the /// bounding box of the fills. fn to_pixmap( - fills: &[crate::pens::ColorFill], + draws: &[crate::pens::ColorDraw], background: Color, height: f64, ) -> Result { - let bounds = compute_bounds(fills); + let bounds = compute_bounds(draws); let width = bounds.width(); let mut pixmap = Pixmap::new(width.ceil() as u32, height.ceil() as u32) @@ -251,7 +257,13 @@ 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 { + for draw in draws { + let fill = match draw { + crate::pens::ColorDraw::Fill(fill) => fill, + crate::pens::ColorDraw::Layer { .. } => { + return Err(TextToPngError::NotSupported("layers not yet supported")) + } + }; let transform = Transform::from_translate( (fill.offset_x + x_offset) as f32, (fill.offset_y + y_offset) as f32, From 43bc5e6e826a062dd1a593856500e65fd66c2614 Mon Sep 17 00:00:00 2001 From: wmedrano Date: Tue, 18 Aug 2026 16:01:41 -0700 Subject: [PATCH 2/2] Support layers for png --- resources/testdata/composite_mode.png | Bin 0 -> 25109 bytes src/text2png.rs | 171 ++++++++++++++++++-------- 2 files changed, 122 insertions(+), 49 deletions(-) create mode 100644 resources/testdata/composite_mode.png diff --git a/resources/testdata/composite_mode.png b/resources/testdata/composite_mode.png new file mode 100644 index 0000000000000000000000000000000000000000..21adbffaf4adf8f6f9ade93e939ac58ff4e602cc GIT binary patch literal 25109 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 CC{9NJ literal 0 HcmV?d00001 diff --git a/src/text2png.rs b/src/text2png.rs index aff15e6..beecb85 100644 --- a/src/text2png.rs +++ b/src/text2png.rs @@ -1,11 +1,11 @@ //! renders text into png, forked from use crate::{ measure::shape, - pens::{foreground_paint, GlyphPainter, GlyphPainterError, Paint}, + pens::{foreground_paint, ColorDraw, 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, @@ -13,7 +13,7 @@ use skrifa::{ use thiserror::Error; use tiny_skia::{ Color, FillRule, GradientStop, LinearGradient, Mask, Paint as SkiaPaint, PathBuilder, Pixmap, - Point as SkiaPoint, RadialGradient, Shader, SpreadMode, SweepGradient, Transform, + PixmapPaint, Point as SkiaPoint, RadialGradient, Shader, SpreadMode, SweepGradient, Transform, }; /// Errors encountered during the text-to-PNG rendering process. @@ -190,20 +190,23 @@ fn clip_bounds(paths: &[BezPath]) -> Option { .reduce(|a, b| a.intersect(b)) } -/// Computes the union of bounding boxes for all provided color fills, -/// considering their respective offsets and clip paths. -fn compute_bounds(draws: &[crate::pens::ColorDraw]) -> Rect { - draws - .iter() - .filter_map(|draw| match draw { - crate::pens::ColorDraw::Fill(fill) => { - let add_offset = |b| b + Vec2::new(fill.offset_x, fill.offset_y); - clip_bounds(&fill.clip_paths).map(add_offset) - } - crate::pens::ColorDraw::Layer { .. } => todo!(), - }) - .reduce(|a, b| a.union(b)) - .unwrap_or_default() +/// Computes the union of bounding boxes for all provided color fills, considering their respective +/// offsets and clip paths. +fn compute_bounds(draws: &[ColorDraw]) -> Rect { + fn compute(draws: &[ColorDraw]) -> Option { + draws + .iter() + .filter_map(|draw| match draw { + ColorDraw::Fill(fill) => { + let add_offset = |b| b + Vec2::new(fill.offset_x, fill.offset_y); + clip_bounds(&fill.clip_paths).map(add_offset) + } + ColorDraw::Layer { draws, .. } => compute(draws), + }) + .reduce(|a, b| a.union(b)) + } + + compute(draws).unwrap_or_default() } /// Create a mask from the intersection of all `paths`. If there are @@ -238,13 +241,64 @@ fn to_mask( } } +/// Recursively renders color draw operations into a Pixmap. +fn render_draws( + draws: &[ColorDraw], + pixmap: &mut Pixmap, + x_offset: f64, + y_offset: f64, +) -> Result<(), TextToPngError> { + for draw in draws { + match draw { + ColorDraw::Fill(fill) => { + let transform = Transform::from_translate( + (fill.offset_x + x_offset) as f32, + (fill.offset_y + y_offset) as f32, + ); + let [clips @ .., path] = fill.clip_paths.as_slice() else { + continue; + }; + let mask = to_mask(clips, (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(), + ); + } + ColorDraw::Layer { mode, draws } => { + let mut layer_pixmap = Pixmap::new(pixmap.width(), pixmap.height()) + .ok_or(TextToPngError::TextTooSmall)?; + render_draws(draws, &mut layer_pixmap, x_offset, y_offset)?; + let paint = PixmapPaint { + blend_mode: mode.to_tinyskia(), + ..PixmapPaint::default() + }; + pixmap.draw_pixmap( + 0, + 0, + layer_pixmap.as_ref(), + &paint, + Transform::identity(), + None, + ); + } + } + } + Ok(()) +} + /// Creates a Pixmap from a collection of color fills, 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( - draws: &[crate::pens::ColorDraw], + draws: &[ColorDraw], background: Color, height: f64, ) -> Result { @@ -257,37 +311,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 draw in draws { - let fill = match draw { - crate::pens::ColorDraw::Fill(fill) => fill, - crate::pens::ColorDraw::Layer { .. } => { - return Err(TextToPngError::NotSupported("layers not yet supported")) - } - }; - 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_draws(draws, &mut pixmap, x_offset, y_offset)?; Ok(pixmap) } @@ -423,6 +447,44 @@ impl ToTinySkia for Vec { } } +impl ToTinySkia for CompositeMode { + type T = tiny_skia::BlendMode; + + fn to_tinyskia(&self) -> tiny_skia::BlendMode { + match self { + CompositeMode::Clear => tiny_skia::BlendMode::Clear, + CompositeMode::Src => tiny_skia::BlendMode::Source, + CompositeMode::Dest => tiny_skia::BlendMode::Destination, + CompositeMode::SrcOver => tiny_skia::BlendMode::SourceOver, + CompositeMode::DestOver => tiny_skia::BlendMode::DestinationOver, + CompositeMode::SrcIn => tiny_skia::BlendMode::SourceIn, + CompositeMode::DestIn => tiny_skia::BlendMode::DestinationIn, + CompositeMode::SrcOut => tiny_skia::BlendMode::SourceOut, + CompositeMode::DestOut => tiny_skia::BlendMode::DestinationOut, + CompositeMode::SrcAtop => tiny_skia::BlendMode::SourceAtop, + CompositeMode::DestAtop => tiny_skia::BlendMode::DestinationAtop, + CompositeMode::Xor => tiny_skia::BlendMode::Xor, + CompositeMode::Plus => tiny_skia::BlendMode::Plus, + CompositeMode::Screen => tiny_skia::BlendMode::Screen, + CompositeMode::Overlay => tiny_skia::BlendMode::Overlay, + CompositeMode::Darken => tiny_skia::BlendMode::Darken, + CompositeMode::Lighten => tiny_skia::BlendMode::Lighten, + CompositeMode::ColorDodge => tiny_skia::BlendMode::ColorDodge, + CompositeMode::ColorBurn => tiny_skia::BlendMode::ColorBurn, + CompositeMode::HardLight => tiny_skia::BlendMode::HardLight, + CompositeMode::SoftLight => tiny_skia::BlendMode::SoftLight, + CompositeMode::Difference => tiny_skia::BlendMode::Difference, + CompositeMode::Exclusion => tiny_skia::BlendMode::Exclusion, + CompositeMode::Multiply => tiny_skia::BlendMode::Multiply, + CompositeMode::HslHue => tiny_skia::BlendMode::Hue, + CompositeMode::HslSaturation => tiny_skia::BlendMode::Saturation, + CompositeMode::HslColor => tiny_skia::BlendMode::Color, + CompositeMode::HslLuminosity => tiny_skia::BlendMode::Luminosity, + _ => tiny_skia::BlendMode::SourceOver, + } + } +} + impl ToTinySkia for kurbo::Point { type T = SkiaPoint; @@ -495,6 +557,17 @@ mod tests { assert_file_eq!(png_bytes, "sweep_gradient.png"); } + #[test] + fn composite_mode() { + let composite_mode_text = "\u{f0a00}\u{f0a01}\u{f0a02}\u{f0a03}\u{f0a04}\u{f0a05}\u{f0a06}\u{f0a07}\u{f0a08}\u{f0a09}\u{f0a0a}\u{f0a0b}\u{f0a0c}\u{f0a0d}\u{f0a0e}\u{f0a0f}\n\u{f0a10}\u{f0a11}\u{f0a12}\u{f0a13}\u{f0a14}\u{f0a15}\u{f0a16}\u{f0a17}\u{f0a18}\u{f0a19}\u{f0a1a}\u{f0a1b}"; + let png_bytes = text2png( + composite_mode_text, + &Text2PngOptions::new(testdata::COLR_FONT, 64.0), + ) + .unwrap(); + assert_file_eq!(png_bytes, "composite_mode.png"); + } + #[test] fn complex_emoji() { // TODO: Improve the centering algorithm.