diff --git a/resources/testdata/color_icon_composite_screen.svg b/resources/testdata/color_icon_composite_screen.svg new file mode 100644 index 0000000..3b6f51f --- /dev/null +++ b/resources/testdata/color_icon_composite_screen.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/testdata/color_icon_composite_src_in.svg b/resources/testdata/color_icon_composite_src_in.svg new file mode 100644 index 0000000..85064a2 --- /dev/null +++ b/resources/testdata/color_icon_composite_src_in.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/testdata/composite_mode.png b/resources/testdata/composite_mode.png new file mode 100644 index 0000000..21adbff Binary files /dev/null and b/resources/testdata/composite_mode.png differ diff --git a/src/draw_icon/icon2svg.rs b/src/draw_icon/icon2svg.rs index b061495..2c21d33 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,50 +76,27 @@ 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 { - let mut group = Vec::new(); +fn to_svg(draws: Vec, style: &SvgPathStyle) -> Result { 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)?; - - // 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)); - } - - // 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); - } - - if !fill_cache.is_empty() || !clips_cache.is_empty() { + let mut masks_cache = MasksCache::default(); + let mut group = draws_to_svg_elements( + &draws, + style, + &mut clips_cache, + &mut fill_cache, + &mut masks_cache, + )?; + + if !fill_cache.is_empty() || !clips_cache.is_empty() || !masks_cache.is_empty() { group.push( XmlElement::new("defs") .with_children(clips_cache.into_svg()) + .with_children(masks_cache.into_svg()) .with_children(fill_cache.into_svg()), ); } @@ -131,6 +108,158 @@ fn to_svg(fills: Vec, style: &SvgPathStyle) -> Result Result, DrawSvgError> { + let mut elements = Vec::new(); + for draw in draws { + match draw { + ColorDraw::Fill(fill) => { + let [clips @ .., shape] = &fill.clip_paths[..] else { + continue; + }; + 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 clips { + 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), + ); + } + + elements.push(path); + } + ColorDraw::Layer { + mode: skrifa::color::CompositeMode::SrcIn, + draws: source_draws, + } => { + let mask_id = masks_cache.get_id(&elements); + let child_elements = draws_to_svg_elements( + source_draws, + style, + clips_cache, + fill_cache, + masks_cache, + )?; + let mut group = XmlElement::new("g").with_children(child_elements); + group.add_attribute("mask", format!("url(#{mask_id})")); + elements.push(group); + } + ColorDraw::Layer { mode, draws } => { + let child_elements = + draws_to_svg_elements(draws, style, clips_cache, fill_cache, masks_cache)?; + let mut group = XmlElement::new("g").with_children(child_elements); + if *mode != skrifa::color::CompositeMode::SrcOver { + let blend_mode = composite_mode_to_svg_blend_mode(*mode)?; + group.add_attribute( + "style", + format!("mix-blend-mode: {blend_mode}; isolation: isolate"), + ); + } + elements.push(group); + } + } + } + Ok(elements) +} + +fn composite_mode_to_svg_blend_mode( + mode: skrifa::color::CompositeMode, +) -> Result<&'static str, DrawSvgError> { + use skrifa::color::CompositeMode; + match mode { + CompositeMode::SrcOver => Ok("normal"), + CompositeMode::Multiply => Ok("multiply"), + CompositeMode::Screen => Ok("screen"), + CompositeMode::Overlay => Ok("overlay"), + CompositeMode::Darken => Ok("darken"), + CompositeMode::Lighten => Ok("lighten"), + CompositeMode::ColorDodge => Ok("color-dodge"), + CompositeMode::ColorBurn => Ok("color-burn"), + CompositeMode::HardLight => Ok("hard-light"), + CompositeMode::SoftLight => Ok("soft-light"), + CompositeMode::Difference => Ok("difference"), + CompositeMode::Exclusion => Ok("exclusion"), + CompositeMode::HslHue => Ok("hue"), + CompositeMode::HslSaturation => Ok("saturation"), + CompositeMode::HslColor => Ok("color"), + CompositeMode::HslLuminosity => Ok("luminosity"), + unsupported => Err(DrawSvgError::CompositeModeNotSupported(unsupported)), + } +} + +/// Unique identifier for a mask. +#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +struct MaskId(usize); + +impl std::fmt::Display for MaskId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "m{}", self.0) + } +} + +/// Caches and manages SVG masks in ``. +#[derive(Default)] +struct MasksCache { + masks_to_id: HashMap, +} + +impl MasksCache { + fn get_id(&mut self, mask_content: &[XmlElement]) -> MaskId { + fn to_mask_element(el: &XmlElement) -> XmlElement { + let mut mask_el = XmlElement::new(el.tag()); + for (k, v) in el.attributes() { + if k == "fill" { + mask_el.add_attribute("fill", "#ffffff"); + } else { + mask_el.add_attribute(k, v); + } + } + if !el.attributes().iter().any(|(k, _)| k == "fill") { + mask_el.add_attribute("fill", "#ffffff"); + } + for child in el.children() { + mask_el.add_child(to_mask_element(child)); + } + mask_el + } + + let children: Vec = mask_content.iter().map(to_mask_element).collect(); + let mask_el = XmlElement::new("mask").with_children(children); + let next_id = MaskId(self.masks_to_id.len()); + *self.masks_to_id.entry(mask_el).or_insert(next_id) + } + + fn into_svg(self) -> impl Iterator { + let mut masks: Vec<_> = self.masks_to_id.into_iter().collect(); + masks.sort_unstable_by_key(|(_, id)| *id); + masks + .into_iter() + .map(|(mask, id)| mask.with_attribute("id", id)) + } + + fn is_empty(&self) -> bool { + self.masks_to_id.is_empty() + } +} + /// Caches and manages SVG clip paths to avoid duplicates in the `` section. #[derive(Default)] struct ClipsCache { @@ -574,4 +703,39 @@ mod tests { Err(DrawSvgError::SweepGradientNotSupported) ); } + + #[test] + fn color_icon_composite_screen() { + let font = FontRef::new(testdata::COLR_FONT).unwrap(); + let svg = font + .draw_icon(&test_options_bounding_box(IconIdentifier::Codepoint( + 0xf0a0d, + ))) + .unwrap(); + assert_file_eq!(svg, "color_icon_composite_screen.svg"); + } + + #[test] + fn color_icon_composite_src_in() { + let font = FontRef::new(testdata::COLR_FONT).unwrap(); + let svg = font + .draw_icon(&test_options_bounding_box(IconIdentifier::Codepoint( + 0xf0a05, + ))) + .unwrap(); + assert_file_eq!(svg, "color_icon_composite_src_in.svg"); + } + + #[test] + fn icon_with_unsupported_composite_mode_produces_error() { + let font = FontRef::new(testdata::COLR_FONT).unwrap(); + assert_matches!( + font.draw_icon(&test_options_bounding_box(IconIdentifier::Codepoint( + 0xf0a00 + ))), + Err(DrawSvgError::CompositeModeNotSupported( + skrifa::color::CompositeMode::Clear + )) + ); + } } diff --git a/src/error.rs b/src/error.rs index 18a67bf..d3f8053 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("Unsupported SVG feature: composite mode {0:?}")] + CompositeModeNotSupported(skrifa::color::CompositeMode), } #[derive(Debug, Error)] diff --git a/src/pens.rs b/src/pens.rs index 2549a27..67bc356 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,107 @@ 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, _: CompositeMode) { - self.set_err(GlyphPainterError::UnsupportedFontFeature("colr layers")); + 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 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 skrifa::{prelude::LocationRef, raw::FontRef}; + + #[test] + fn painter_composite_glyph() { + // Glyph 0xf0a0d in colr.ttf is composite_SCREEN + let font = FontRef::new(crate::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:?}" + ); + } + + #[test] + fn painter_pop_layer_empty_stack_produces_error() { + let font = FontRef::new(crate::testdata::COLR_FONT).unwrap(); + let mut painter = GlyphPainter::new( + &font, + LocationRef::default(), + Color::BLACK, + Size::unscaled(), + ); + painter.pop_layer_with_mode(CompositeMode::Screen); + assert!(matches!( + painter.into_draws(), + Err(GlyphPainterError::LayerStackUnderflow) + )); + } + + #[test] + fn painter_unclosed_layer_produces_error() { + let font = FontRef::new(crate::testdata::COLR_FONT).unwrap(); + let mut painter = GlyphPainter::new( + &font, + LocationRef::default(), + Color::BLACK, + Size::unscaled(), + ); + painter.push_layer(CompositeMode::Screen); + assert!(matches!( + painter.into_draws(), + Err(GlyphPainterError::LayerStackNotEmpty) + )); } } diff --git a/src/text2png.rs b/src/text2png.rs index e30b718..d6ace2a 100644 --- a/src/text2png.rs +++ b/src/text2png.rs @@ -5,7 +5,7 @@ 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, @@ -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. @@ -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) } @@ -187,17 +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(fills: &[crate::pens::ColorFill]) -> Rect { - fills - .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) - }) - .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: &[crate::pens::ColorDraw]) -> Rect { + fn compute(draws: &[crate::pens::ColorDraw]) -> Option { + 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 { 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 @@ -232,17 +241,68 @@ fn to_mask( } } +/// Recursively renders color draw operations into a Pixmap. +fn render_draws( + draws: &[crate::pens::ColorDraw], + pixmap: &mut Pixmap, + x_offset: f64, + y_offset: f64, +) -> Result<(), TextToPngError> { + for draw in draws { + match draw { + crate::pens::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(), + ); + } + crate::pens::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( - 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,31 +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 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_draws(draws, &mut pixmap, x_offset, y_offset)?; Ok(pixmap) } @@ -411,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; @@ -483,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. diff --git a/src/xml_element.rs b/src/xml_element.rs index 1d633e5..4ecc4e6 100644 --- a/src/xml_element.rs +++ b/src/xml_element.rs @@ -144,6 +144,21 @@ impl XmlElement { self.add_children(children); self } + + /// Returns the tag name of the element. + pub fn tag(&self) -> &str { + &self.tag + } + + /// Returns the attributes of the element. + pub fn attributes(&self) -> &[(String, String)] { + &self.attributes + } + + /// Returns the children of the element. + pub fn children(&self) -> &[XmlElement] { + &self.children + } } /// Formats the `XmlElement` as an XML string.