From c49c0955be0d377944fb4840f9bd52d1cb741e33 Mon Sep 17 00:00:00 2001 From: Tulio Martins <113527485+tulio240@users.noreply.github.com> Date: Tue, 7 May 2024 06:55:59 -0300 Subject: [PATCH] Refactor typst-svg (#4074) --- crates/typst-svg/src/image.rs | 40 ++ crates/typst-svg/src/lib.rs | 1100 +-------------------------------- crates/typst-svg/src/paint.rs | 583 +++++++++++++++++ crates/typst-svg/src/shape.rs | 195 ++++++ crates/typst-svg/src/text.rs | 308 +++++++++ 5 files changed, 1140 insertions(+), 1086 deletions(-) create mode 100644 crates/typst-svg/src/image.rs create mode 100644 crates/typst-svg/src/paint.rs create mode 100644 crates/typst-svg/src/shape.rs create mode 100644 crates/typst-svg/src/text.rs diff --git a/crates/typst-svg/src/image.rs b/crates/typst-svg/src/image.rs new file mode 100644 index 000000000..87bfbd273 --- /dev/null +++ b/crates/typst-svg/src/image.rs @@ -0,0 +1,40 @@ +use base64::Engine; +use ecow::{eco_format, EcoString}; +use typst::layout::{Abs, Axes}; +use typst::visualize::{Image, ImageFormat, RasterFormat, VectorFormat}; + +use crate::SVGRenderer; + +impl SVGRenderer { + /// Render an image element. + pub(super) fn render_image(&mut self, image: &Image, size: &Axes) { + let url = convert_image_to_base64_url(image); + self.xml.start_element("image"); + self.xml.write_attribute("xlink:href", &url); + self.xml.write_attribute("width", &size.x.to_pt()); + self.xml.write_attribute("height", &size.y.to_pt()); + self.xml.write_attribute("preserveAspectRatio", "none"); + self.xml.end_element(); + } +} + +/// Encode an image into a data URL. The format of the URL is +/// `data:image/{format};base64,`. +#[comemo::memoize] +pub fn convert_image_to_base64_url(image: &Image) -> EcoString { + let format = match image.format() { + ImageFormat::Raster(f) => match f { + RasterFormat::Png => "png", + RasterFormat::Jpg => "jpeg", + RasterFormat::Gif => "gif", + }, + ImageFormat::Vector(f) => match f { + VectorFormat::Svg => "svg+xml", + }, + }; + + let mut url = eco_format!("data:image/{format};base64,"); + let data = base64::engine::general_purpose::STANDARD.encode(image.data()); + url.push_str(&data); + url +} diff --git a/crates/typst-svg/src/lib.rs b/crates/typst-svg/src/lib.rs index 677f2ba72..3b9c4401f 100644 --- a/crates/typst-svg/src/lib.rs +++ b/crates/typst-svg/src/lib.rs @@ -1,31 +1,25 @@ //! Rendering of Typst documents into SVG images. -use std::collections::HashMap; -use std::f32::consts::TAU; -use std::fmt::{self, Display, Formatter, Write}; -use std::io::Read; +mod image; +mod paint; +mod shape; +mod text; -use base64::Engine; -use ecow::{eco_format, EcoString}; -use ttf_parser::{GlyphId, OutlineBuilder}; -use typst::foundations::Repr; +use std::collections::HashMap; +use std::fmt::{self, Display, Formatter, Write}; + +use ecow::EcoString; +use ttf_parser::OutlineBuilder; use typst::layout::{ - Abs, Angle, Axes, Frame, FrameItem, FrameKind, GroupItem, Point, Quadrant, Ratio, - Size, Transform, + Abs, Frame, FrameItem, FrameKind, GroupItem, Point, Ratio, Size, Transform, }; use typst::model::Document; -use typst::text::{Font, TextItem}; use typst::util::hash128; -use typst::visualize::{ - Color, FixedStroke, Geometry, Gradient, Image, ImageFormat, LineCap, LineJoin, Paint, - Path, PathItem, Pattern, RasterFormat, RatioOrAngle, RelativeTo, Shape, VectorFormat, -}; +use typst::visualize::{Gradient, Pattern}; use xmlwriter::XmlWriter; -/// The number of segments in a conic gradient. -/// This is a heuristic value that seems to work well. -/// Smaller values could be interesting for optimization. -const CONIC_SEGMENT: usize = 360; +use crate::paint::{GradientRef, PatternRef, SVGSubGradient}; +use crate::text::RenderedGlyph; /// Export a frame into a SVG file. #[typst_macros::time(name = "svg")] @@ -148,83 +142,6 @@ impl State { } } -/// A reference to a deduplicated gradient, with a transform matrix. -/// -/// Allows gradients to be reused across multiple invocations, -/// simply by changing the transform matrix. -#[derive(Hash)] -struct GradientRef { - /// The ID of the deduplicated gradient - id: Id, - /// The gradient kind (used to determine the SVG element to use) - /// but without needing to clone the entire gradient. - kind: GradientKind, - /// The transform matrix to apply to the gradient. - transform: Transform, -} - -/// A reference to a deduplicated pattern, with a transform matrix. -/// -/// Allows patterns to be reused across multiple invocations, -/// simply by changing the transform matrix. -#[derive(Hash)] -struct PatternRef { - /// The ID of the deduplicated gradient - id: Id, - /// The transform matrix to apply to the pattern. - transform: Transform, - /// The ratio of the size of the cell to the size of the filled area. - ratio: Axes, -} - -/// A subgradient for conic gradients. -#[derive(Hash)] -struct SVGSubGradient { - /// The center point of the gradient. - center: Axes, - /// The start point of the subgradient. - t0: Angle, - /// The end point of the subgradient. - t1: Angle, - /// The color at the start point of the subgradient. - c0: Color, - /// The color at the end point of the subgradient. - c1: Color, -} - -/// The kind of linear gradient. -#[derive(Hash, Clone, Copy, PartialEq, Eq)] -enum GradientKind { - /// A linear gradient. - Linear, - /// A radial gradient. - Radial, - /// A conic gradient. - Conic, -} - -impl From<&Gradient> for GradientKind { - fn from(value: &Gradient) -> Self { - match value { - Gradient::Linear { .. } => GradientKind::Linear, - Gradient::Radial { .. } => GradientKind::Radial, - Gradient::Conic { .. } => GradientKind::Conic, - } - } -} - -/// Represents a glyph to be rendered. -enum RenderedGlyph { - /// A path is a sequence of drawing commands. - /// - /// It is in the format of `M x y L x y C x1 y1 x2 y2 x y Z`. - Path(EcoString), - /// An image is a URL to an image file, plus the size and transform. - /// - /// The url is in the format of `data:image/{format};base64,`. - Image { url: EcoString, width: f64, height: f64, ts: Transform }, -} - impl SVGRenderer { /// Create a new SVG renderer with empty glyph and clip path. fn new() -> Self { @@ -259,20 +176,6 @@ impl SVGRenderer { self.xml.write_attribute("xmlns:h5", "http://www.w3.org/1999/xhtml"); } - /// Render a frame to a string. - fn render_pattern_frame( - &mut self, - state: State, - ts: Transform, - frame: &Frame, - ) -> String { - let mut xml = XmlWriter::new(xmlwriter::Options::default()); - std::mem::swap(&mut self.xml, &mut xml); - self.render_frame(state, ts, frame); - std::mem::swap(&mut self.xml, &mut xml); - xml.end_document() - } - /// Render a frame with the given transform. fn render_frame(&mut self, state: State, ts: Transform, frame: &Frame) { self.xml.start_element("g"); @@ -327,7 +230,7 @@ impl SVGRenderer { if let Some(clip_path) = &group.clip_path { let hash = hash128(&group); - let id = self.clip_paths.insert_with(hash, || convert_path(clip_path)); + let id = self.clip_paths.insert_with(hash, || shape::convert_path(clip_path)); self.xml.write_attribute_fmt("clip-path", format_args!("url(#{id})")); } @@ -335,382 +238,6 @@ impl SVGRenderer { self.xml.end_element(); } - /// Render a text item. The text is rendered as a group of glyphs. We will - /// try to render the text as SVG first, then bitmap, then outline. If none - /// of them works, we will skip the text. - fn render_text(&mut self, state: State, text: &TextItem) { - let scale: f64 = text.size.to_pt() / text.font.units_per_em(); - - self.xml.start_element("g"); - self.xml.write_attribute("class", "typst-text"); - self.xml.write_attribute("transform", "scale(1, -1)"); - - let mut x: f64 = 0.0; - for glyph in &text.glyphs { - let id = GlyphId(glyph.id); - let offset = x + glyph.x_offset.at(text.size).to_pt(); - - self.render_svg_glyph(text, id, offset, scale) - .or_else(|| self.render_bitmap_glyph(text, id, offset)) - .or_else(|| { - self.render_outline_glyph( - state - .pre_concat(Transform::scale(Ratio::one(), -Ratio::one())) - .pre_translate(Point::new(Abs::pt(offset), Abs::zero())), - text, - id, - offset, - scale, - ) - }); - - x += glyph.x_advance.at(text.size).to_pt(); - } - - self.xml.end_element(); - } - - /// Render a glyph defined by an SVG. - fn render_svg_glyph( - &mut self, - text: &TextItem, - id: GlyphId, - x_offset: f64, - scale: f64, - ) -> Option<()> { - let data_url = convert_svg_glyph_to_base64_url(&text.font, id)?; - let upem = Abs::raw(text.font.units_per_em()); - let origin_ascender = text.font.metrics().ascender.at(upem).to_pt(); - - let glyph_hash = hash128(&(&text.font, id)); - let id = self.glyphs.insert_with(glyph_hash, || RenderedGlyph::Image { - url: data_url, - width: upem.to_pt(), - height: upem.to_pt(), - ts: Transform::translate(Abs::zero(), Abs::pt(-origin_ascender)) - .post_concat(Transform::scale(Ratio::new(scale), Ratio::new(-scale))), - }); - - self.xml.start_element("use"); - self.xml.write_attribute_fmt("xlink:href", format_args!("#{id}")); - self.xml.write_attribute("x", &x_offset); - self.xml.end_element(); - - Some(()) - } - - /// Render a glyph defined by a bitmap. - fn render_bitmap_glyph( - &mut self, - text: &TextItem, - id: GlyphId, - x_offset: f64, - ) -> Option<()> { - let (image, bitmap_x_offset, bitmap_y_offset) = - convert_bitmap_glyph_to_image(&text.font, id)?; - - let glyph_hash = hash128(&(&text.font, id)); - let id = self.glyphs.insert_with(glyph_hash, || { - let width = image.width(); - let height = image.height(); - let url = convert_image_to_base64_url(&image); - let ts = Transform::translate( - Abs::pt(bitmap_x_offset), - Abs::pt(-height - bitmap_y_offset), - ); - RenderedGlyph::Image { url, width, height, ts } - }); - - let target_height = text.size.to_pt(); - self.xml.start_element("use"); - self.xml.write_attribute_fmt("xlink:href", format_args!("#{id}")); - - // The image is stored with the height of `image.height()`, but we want - // to render it with a height of `target_height`. So we need to scale - // it. - let scale_factor = target_height / image.height(); - self.xml.write_attribute("x", &(x_offset / scale_factor)); - self.xml.write_attribute_fmt( - "transform", - format_args!("scale({scale_factor} -{scale_factor})",), - ); - self.xml.end_element(); - - Some(()) - } - - /// Render a glyph defined by an outline. - fn render_outline_glyph( - &mut self, - state: State, - text: &TextItem, - glyph_id: GlyphId, - x_offset: f64, - scale: f64, - ) -> Option<()> { - let scale = Ratio::new(scale); - let path = convert_outline_glyph_to_path(&text.font, glyph_id, scale)?; - let hash = hash128(&(&text.font, glyph_id, scale)); - let id = self.glyphs.insert_with(hash, || RenderedGlyph::Path(path)); - - let glyph_size = text.font.ttf().glyph_bounding_box(glyph_id)?; - let width = glyph_size.width() as f64 * scale.get(); - let height = glyph_size.height() as f64 * scale.get(); - - self.xml.start_element("use"); - self.xml.write_attribute_fmt("xlink:href", format_args!("#{id}")); - self.xml.write_attribute_fmt("x", format_args!("{x_offset}")); - self.write_fill( - &text.fill, - Size::new(Abs::pt(width), Abs::pt(height)), - self.text_paint_transform(state, &text.fill), - ); - if let Some(stroke) = &text.stroke { - self.write_stroke( - stroke, - Size::new(Abs::pt(width), Abs::pt(height)), - self.text_paint_transform(state, &stroke.paint), - ); - } - self.xml.end_element(); - - Some(()) - } - - fn text_paint_transform(&self, state: State, paint: &Paint) -> Transform { - match paint { - Paint::Solid(_) => Transform::identity(), - Paint::Gradient(gradient) => match gradient.unwrap_relative(true) { - RelativeTo::Self_ => Transform::identity(), - RelativeTo::Parent => Transform::scale( - Ratio::new(state.size.x.to_pt()), - Ratio::new(state.size.y.to_pt()), - ) - .post_concat(state.transform.invert().unwrap()), - }, - Paint::Pattern(pattern) => match pattern.unwrap_relative(true) { - RelativeTo::Self_ => Transform::identity(), - RelativeTo::Parent => state.transform.invert().unwrap(), - }, - } - } - - /// Render a shape element. - fn render_shape(&mut self, state: State, shape: &Shape) { - self.xml.start_element("path"); - self.xml.write_attribute("class", "typst-shape"); - - if let Some(paint) = &shape.fill { - self.write_fill( - paint, - self.shape_fill_size(state, paint, shape), - self.shape_paint_transform(state, paint, shape), - ); - } else { - self.xml.write_attribute("fill", "none"); - } - - if let Some(stroke) = &shape.stroke { - self.write_stroke( - stroke, - self.shape_fill_size(state, &stroke.paint, shape), - self.shape_paint_transform(state, &stroke.paint, shape), - ); - } - - let path = convert_geometry_to_path(&shape.geometry); - self.xml.write_attribute("d", &path); - self.xml.end_element(); - } - - /// Calculate the transform of the shape's fill or stroke. - fn shape_paint_transform( - &self, - state: State, - paint: &Paint, - shape: &Shape, - ) -> Transform { - let mut shape_size = shape.geometry.bbox_size(); - // Edge cases for strokes. - if shape_size.x.to_pt() == 0.0 { - shape_size.x = Abs::pt(1.0); - } - - if shape_size.y.to_pt() == 0.0 { - shape_size.y = Abs::pt(1.0); - } - - if let Paint::Gradient(gradient) = paint { - match gradient.unwrap_relative(false) { - RelativeTo::Self_ => Transform::scale( - Ratio::new(shape_size.x.to_pt()), - Ratio::new(shape_size.y.to_pt()), - ), - RelativeTo::Parent => Transform::scale( - Ratio::new(state.size.x.to_pt()), - Ratio::new(state.size.y.to_pt()), - ) - .post_concat(state.transform.invert().unwrap()), - } - } else if let Paint::Pattern(pattern) = paint { - match pattern.unwrap_relative(false) { - RelativeTo::Self_ => Transform::identity(), - RelativeTo::Parent => state.transform.invert().unwrap(), - } - } else { - Transform::identity() - } - } - - /// Calculate the size of the shape's fill. - fn shape_fill_size(&self, state: State, paint: &Paint, shape: &Shape) -> Size { - let mut shape_size = shape.geometry.bbox_size(); - // Edge cases for strokes. - if shape_size.x.to_pt() == 0.0 { - shape_size.x = Abs::pt(1.0); - } - - if shape_size.y.to_pt() == 0.0 { - shape_size.y = Abs::pt(1.0); - } - - if let Paint::Gradient(gradient) = paint { - match gradient.unwrap_relative(false) { - RelativeTo::Self_ => shape_size, - RelativeTo::Parent => state.size, - } - } else { - shape_size - } - } - - /// Write a fill attribute. - fn write_fill(&mut self, fill: &Paint, size: Size, ts: Transform) { - match fill { - Paint::Solid(color) => self.xml.write_attribute("fill", &color.encode()), - Paint::Gradient(gradient) => { - let id = self.push_gradient(gradient, size, ts); - self.xml.write_attribute_fmt("fill", format_args!("url(#{id})")); - } - Paint::Pattern(pattern) => { - let id = self.push_pattern(pattern, size, ts); - self.xml.write_attribute_fmt("fill", format_args!("url(#{id})")); - } - } - } - - /// Pushes a gradient to the list of gradients to write SVG file. - /// - /// If the gradient is already present, returns the id of the existing - /// gradient. Otherwise, inserts the gradient and returns the id of the - /// inserted gradient. If the transform of the gradient is the identify - /// matrix, the returned ID will be the ID of the "source" gradient, - /// this is a file size optimization. - fn push_gradient(&mut self, gradient: &Gradient, size: Size, ts: Transform) -> Id { - let gradient_id = self - .gradients - .insert_with(hash128(&(gradient, size.aspect_ratio())), || { - (gradient.clone(), size.aspect_ratio()) - }); - - if ts.is_identity() { - return gradient_id; - } - - self.gradient_refs - .insert_with(hash128(&(gradient_id, ts)), || GradientRef { - id: gradient_id, - kind: gradient.into(), - transform: ts, - }) - } - - fn push_pattern(&mut self, pattern: &Pattern, size: Size, ts: Transform) -> Id { - let pattern_size = pattern.size() + pattern.spacing(); - // Unfortunately due to a limitation of `xmlwriter`, we need to - // render the frame twice: once to allocate all of the resources - // that it needs and once to actually render it. - self.render_pattern_frame( - State::new(pattern_size, Transform::identity()), - Transform::identity(), - pattern.frame(), - ); - - let pattern_id = self.patterns.insert_with(hash128(pattern), || pattern.clone()); - self.pattern_refs - .insert_with(hash128(&(pattern_id, ts)), || PatternRef { - id: pattern_id, - transform: ts, - ratio: Axes::new( - Ratio::new(pattern_size.x.to_pt() / size.x.to_pt()), - Ratio::new(pattern_size.y.to_pt() / size.y.to_pt()), - ), - }) - } - - /// Write a stroke attribute. - fn write_stroke( - &mut self, - stroke: &FixedStroke, - size: Size, - fill_transform: Transform, - ) { - match &stroke.paint { - Paint::Solid(color) => self.xml.write_attribute("stroke", &color.encode()), - Paint::Gradient(gradient) => { - let id = self.push_gradient(gradient, size, fill_transform); - self.xml.write_attribute_fmt("stroke", format_args!("url(#{id})")); - } - Paint::Pattern(pattern) => { - let id = self.push_pattern(pattern, size, fill_transform); - self.xml.write_attribute_fmt("stroke", format_args!("url(#{id})")); - } - } - - self.xml.write_attribute("stroke-width", &stroke.thickness.to_pt()); - self.xml.write_attribute( - "stroke-linecap", - match stroke.cap { - LineCap::Butt => "butt", - LineCap::Round => "round", - LineCap::Square => "square", - }, - ); - self.xml.write_attribute( - "stroke-linejoin", - match stroke.join { - LineJoin::Miter => "miter", - LineJoin::Round => "round", - LineJoin::Bevel => "bevel", - }, - ); - self.xml - .write_attribute("stroke-miterlimit", &stroke.miter_limit.get()); - if let Some(pattern) = &stroke.dash { - self.xml.write_attribute("stroke-dashoffset", &pattern.phase.to_pt()); - self.xml.write_attribute( - "stroke-dasharray", - &pattern - .array - .iter() - .map(|dash| dash.to_pt().to_string()) - .collect::>() - .join(" "), - ); - } - } - - /// Render an image element. - fn render_image(&mut self, image: &Image, size: &Axes) { - let url = convert_image_to_base64_url(image); - self.xml.start_element("image"); - self.xml.write_attribute("xlink:href", &url); - self.xml.write_attribute("width", &size.x.to_pt()); - self.xml.write_attribute("height", &size.y.to_pt()); - self.xml.write_attribute("preserveAspectRatio", "none"); - self.xml.end_element(); - } - /// Finalize the SVG file. This must be called after all rendering is done. fn finalize(mut self) -> String { self.write_glyph_defs(); @@ -723,45 +250,6 @@ impl SVGRenderer { self.xml.end_document() } - /// Build the glyph definitions. - fn write_glyph_defs(&mut self) { - if self.glyphs.is_empty() { - return; - } - - self.xml.start_element("defs"); - self.xml.write_attribute("id", "glyph"); - - for (id, glyph) in self.glyphs.iter() { - self.xml.start_element("symbol"); - self.xml.write_attribute("id", &id); - self.xml.write_attribute("overflow", "visible"); - - match glyph { - RenderedGlyph::Path(path) => { - self.xml.start_element("path"); - self.xml.write_attribute("d", &path); - self.xml.end_element(); - } - RenderedGlyph::Image { url, width, height, ts } => { - self.xml.start_element("image"); - self.xml.write_attribute("xlink:href", &url); - self.xml.write_attribute("width", &width); - self.xml.write_attribute("height", &height); - if !ts.is_identity() { - self.xml.write_attribute("transform", &SvgMatrix(*ts)); - } - self.xml.write_attribute("preserveAspectRatio", "none"); - self.xml.end_element(); - } - } - - self.xml.end_element(); - } - - self.xml.end_element(); - } - /// Build the clip path definitions. fn write_clip_path_defs(&mut self) { if self.clip_paths.is_empty() { @@ -782,472 +270,6 @@ impl SVGRenderer { self.xml.end_element(); } - - /// Write the raw gradients (without transform) to the SVG file. - fn write_gradients(&mut self) { - if self.gradients.is_empty() { - return; - } - - self.xml.start_element("defs"); - self.xml.write_attribute("id", "gradients"); - - for (id, (gradient, ratio)) in self.gradients.iter() { - match &gradient { - Gradient::Linear(linear) => { - self.xml.start_element("linearGradient"); - self.xml.write_attribute("id", &id); - self.xml.write_attribute("spreadMethod", "pad"); - self.xml.write_attribute("gradientUnits", "userSpaceOnUse"); - - let angle = Gradient::correct_aspect_ratio(linear.angle, *ratio); - let (sin, cos) = (angle.sin(), angle.cos()); - let length = sin.abs() + cos.abs(); - let (x1, y1, x2, y2) = match angle.quadrant() { - Quadrant::First => (0.0, 0.0, cos * length, sin * length), - Quadrant::Second => (1.0, 0.0, cos * length + 1.0, sin * length), - Quadrant::Third => { - (1.0, 1.0, cos * length + 1.0, sin * length + 1.0) - } - Quadrant::Fourth => (0.0, 1.0, cos * length, sin * length + 1.0), - }; - - self.xml.write_attribute("x1", &x1); - self.xml.write_attribute("y1", &y1); - self.xml.write_attribute("x2", &x2); - self.xml.write_attribute("y2", &y2); - } - Gradient::Radial(radial) => { - self.xml.start_element("radialGradient"); - self.xml.write_attribute("id", &id); - self.xml.write_attribute("spreadMethod", "pad"); - self.xml.write_attribute("gradientUnits", "userSpaceOnUse"); - self.xml.write_attribute("cx", &radial.center.x.get()); - self.xml.write_attribute("cy", &radial.center.y.get()); - self.xml.write_attribute("r", &radial.radius.get()); - self.xml.write_attribute("fx", &radial.focal_center.x.get()); - self.xml.write_attribute("fy", &radial.focal_center.y.get()); - self.xml.write_attribute("fr", &radial.focal_radius.get()); - } - Gradient::Conic(conic) => { - self.xml.start_element("pattern"); - self.xml.write_attribute("id", &id); - self.xml.write_attribute("viewBox", "0 0 1 1"); - self.xml.write_attribute("preserveAspectRatio", "none"); - self.xml.write_attribute("patternUnits", "userSpaceOnUse"); - self.xml.write_attribute("width", "2"); - self.xml.write_attribute("height", "2"); - self.xml.write_attribute("x", "-0.5"); - self.xml.write_attribute("y", "-0.5"); - - // The rotation angle, negated to match rotation in PNG. - let angle: f32 = - -(Gradient::correct_aspect_ratio(conic.angle, *ratio).to_rad() - as f32) - .rem_euclid(TAU); - let center: (f32, f32) = - (conic.center.x.get() as f32, conic.center.y.get() as f32); - - // We build an arg segment for each segment of a circle. - let dtheta = TAU / CONIC_SEGMENT as f32; - for i in 0..CONIC_SEGMENT { - let theta1 = dtheta * i as f32; - let theta2 = dtheta * (i + 1) as f32; - - // Create the path for the segment. - let mut builder = SvgPathBuilder::default(); - builder.move_to( - correct_pattern_pos(center.0), - correct_pattern_pos(center.1), - ); - builder.line_to( - correct_pattern_pos(-2.0 * (theta1 + angle).cos() + center.0), - correct_pattern_pos(2.0 * (theta1 + angle).sin() + center.1), - ); - builder.arc( - (2.0, 2.0), - 0.0, - 0, - 1, - ( - correct_pattern_pos( - -2.0 * (theta2 + angle).cos() + center.0, - ), - correct_pattern_pos( - 2.0 * (theta2 + angle).sin() + center.1, - ), - ), - ); - builder.close(); - - let t1 = (i as f32) / CONIC_SEGMENT as f32; - let t2 = (i + 1) as f32 / CONIC_SEGMENT as f32; - let subgradient = SVGSubGradient { - center: conic.center, - t0: Angle::rad((theta1 + angle) as f64), - t1: Angle::rad((theta2 + angle) as f64), - c0: gradient - .sample(RatioOrAngle::Ratio(Ratio::new(t1 as f64))), - c1: gradient - .sample(RatioOrAngle::Ratio(Ratio::new(t2 as f64))), - }; - let id = self - .conic_subgradients - .insert_with(hash128(&subgradient), || subgradient); - - // Add the path to the pattern. - self.xml.start_element("path"); - self.xml.write_attribute("d", &builder.0); - self.xml.write_attribute_fmt("fill", format_args!("url(#{id})")); - self.xml - .write_attribute_fmt("stroke", format_args!("url(#{id})")); - self.xml.write_attribute("stroke-width", "0"); - self.xml.write_attribute("shape-rendering", "optimizeSpeed"); - self.xml.end_element(); - } - - // We skip the default stop generation code. - self.xml.end_element(); - continue; - } - } - - for window in gradient.stops_ref().windows(2) { - let (start_c, start_t) = window[0]; - let (end_c, end_t) = window[1]; - - self.xml.start_element("stop"); - self.xml.write_attribute("offset", &start_t.repr()); - self.xml.write_attribute("stop-color", &start_c.to_hex()); - self.xml.end_element(); - - // Generate (256 / len) stops between the two stops. - // This is a workaround for a bug in many readers: - // They tend to just ignore the color space of the gradient. - // The goal is to have smooth gradients but not to balloon the file size - // too much if there are already a lot of stops as in most presets. - let len = if gradient.anti_alias() { - (256 / gradient.stops_ref().len() as u32).max(2) - } else { - 2 - }; - - for i in 1..(len - 1) { - let t0 = i as f64 / (len - 1) as f64; - let t = start_t + (end_t - start_t) * t0; - let c = gradient.sample(RatioOrAngle::Ratio(t)); - - self.xml.start_element("stop"); - self.xml.write_attribute("offset", &t.repr()); - self.xml.write_attribute("stop-color", &c.to_hex()); - self.xml.end_element(); - } - - self.xml.start_element("stop"); - self.xml.write_attribute("offset", &end_t.repr()); - self.xml.write_attribute("stop-color", &end_c.to_hex()); - self.xml.end_element() - } - - self.xml.end_element(); - } - - self.xml.end_element() - } - - /// Write the sub-gradients that are used for conic gradients. - fn write_subgradients(&mut self) { - if self.conic_subgradients.is_empty() { - return; - } - - self.xml.start_element("defs"); - self.xml.write_attribute("id", "subgradients"); - for (id, gradient) in self.conic_subgradients.iter() { - let x1 = 2.0 - gradient.t0.cos() as f32 + gradient.center.x.get() as f32; - let y1 = gradient.t0.sin() as f32 + gradient.center.y.get() as f32; - let x2 = 2.0 - gradient.t1.cos() as f32 + gradient.center.x.get() as f32; - let y2 = gradient.t1.sin() as f32 + gradient.center.y.get() as f32; - - self.xml.start_element("linearGradient"); - self.xml.write_attribute("id", &id); - self.xml.write_attribute("gradientUnits", "objectBoundingBox"); - self.xml.write_attribute("x1", &x1); - self.xml.write_attribute("y1", &y1); - self.xml.write_attribute("x2", &x2); - self.xml.write_attribute("y2", &y2); - - self.xml.start_element("stop"); - self.xml.write_attribute("offset", "0%"); - self.xml.write_attribute("stop-color", &gradient.c0.to_hex()); - self.xml.end_element(); - - self.xml.start_element("stop"); - self.xml.write_attribute("offset", "100%"); - self.xml.write_attribute("stop-color", &gradient.c1.to_hex()); - self.xml.end_element(); - - self.xml.end_element(); - } - self.xml.end_element(); - } - - fn write_gradient_refs(&mut self) { - if self.gradient_refs.is_empty() { - return; - } - - self.xml.start_element("defs"); - self.xml.write_attribute("id", "gradient-refs"); - for (id, gradient_ref) in self.gradient_refs.iter() { - match gradient_ref.kind { - GradientKind::Linear => { - self.xml.start_element("linearGradient"); - self.xml.write_attribute( - "gradientTransform", - &SvgMatrix(gradient_ref.transform), - ); - } - GradientKind::Radial => { - self.xml.start_element("radialGradient"); - self.xml.write_attribute( - "gradientTransform", - &SvgMatrix(gradient_ref.transform), - ); - } - GradientKind::Conic => { - self.xml.start_element("pattern"); - self.xml.write_attribute( - "patternTransform", - &SvgMatrix(gradient_ref.transform), - ); - } - } - - self.xml.write_attribute("id", &id); - - // Writing the href attribute to the "reference" gradient. - self.xml - .write_attribute_fmt("href", format_args!("#{}", gradient_ref.id)); - - // Also writing the xlink:href attribute for compatibility. - self.xml - .write_attribute_fmt("xlink:href", format_args!("#{}", gradient_ref.id)); - self.xml.end_element(); - } - - self.xml.end_element(); - } - - /// Write the raw gradients (without transform) to the SVG file. - fn write_patterns(&mut self) { - if self.patterns.is_empty() { - return; - } - - self.xml.start_element("defs"); - self.xml.write_attribute("id", "patterns"); - - for (id, pattern) in - self.patterns.iter().map(|(i, p)| (i, p.clone())).collect::>() - { - let size = pattern.size() + pattern.spacing(); - self.xml.start_element("pattern"); - self.xml.write_attribute("id", &id); - self.xml.write_attribute("width", &size.x.to_pt()); - self.xml.write_attribute("height", &size.y.to_pt()); - self.xml.write_attribute("patternUnits", "userSpaceOnUse"); - self.xml.write_attribute_fmt( - "viewBox", - format_args!("0 0 {:.3} {:.3}", size.x.to_pt(), size.y.to_pt()), - ); - - // Render the frame. - let state = State::new(size, Transform::identity()); - let ts = Transform::identity(); - self.render_frame(state, ts, pattern.frame()); - - self.xml.end_element(); - } - - self.xml.end_element() - } - - /// Writes the references to the deduplicated patterns for each usage site. - fn write_pattern_refs(&mut self) { - if self.pattern_refs.is_empty() { - return; - } - - self.xml.start_element("defs"); - self.xml.write_attribute("id", "pattern-refs"); - for (id, pattern_ref) in self.pattern_refs.iter() { - self.xml.start_element("pattern"); - self.xml - .write_attribute("patternTransform", &SvgMatrix(pattern_ref.transform)); - - self.xml.write_attribute("id", &id); - - // Writing the href attribute to the "reference" pattern. - self.xml - .write_attribute_fmt("href", format_args!("#{}", pattern_ref.id)); - - // Also writing the xlink:href attribute for compatibility. - self.xml - .write_attribute_fmt("xlink:href", format_args!("#{}", pattern_ref.id)); - self.xml.end_element(); - } - - self.xml.end_element(); - } -} - -/// Convert an outline glyph to an SVG path. -#[comemo::memoize] -fn convert_outline_glyph_to_path( - font: &Font, - id: GlyphId, - scale: Ratio, -) -> Option { - let mut builder = SvgPathBuilder::with_scale(scale); - font.ttf().outline_glyph(id, &mut builder)?; - Some(builder.0) -} - -/// Convert a bitmap glyph to an encoded image URL. -#[comemo::memoize] -fn convert_bitmap_glyph_to_image(font: &Font, id: GlyphId) -> Option<(Image, f64, f64)> { - let raster = font.ttf().glyph_raster_image(id, std::u16::MAX)?; - if raster.format != ttf_parser::RasterImageFormat::PNG { - return None; - } - let image = Image::new(raster.data.into(), RasterFormat::Png.into(), None).ok()?; - Some((image, raster.x as f64, raster.y as f64)) -} - -/// Convert an SVG glyph to an encoded image URL. -#[comemo::memoize] -fn convert_svg_glyph_to_base64_url(font: &Font, id: GlyphId) -> Option { - let mut data = font.ttf().glyph_svg_image(id)?.data; - - // Decompress SVGZ. - let mut decoded = vec![]; - if data.starts_with(&[0x1f, 0x8b]) { - let mut decoder = flate2::read::GzDecoder::new(data); - decoder.read_to_end(&mut decoded).ok()?; - data = &decoded; - } - - let upem = Abs::raw(font.units_per_em()); - let (width, height) = (upem.to_pt(), upem.to_pt()); - let origin_ascender = font.metrics().ascender.at(upem).to_pt(); - - // Parse XML. - let mut svg_str = std::str::from_utf8(data).ok()?.to_owned(); - let mut start_span = None; - let mut last_viewbox = None; - - // Parse xml and find the viewBox of the svg element. - // ... - // ~~~~~^~~~~~~ - for n in xmlparser::Tokenizer::from(svg_str.as_str()) { - let tok = n.unwrap(); - match tok { - xmlparser::Token::ElementStart { span, local, .. } => { - if local.as_str() == "svg" { - start_span = Some(span); - break; - } - } - xmlparser::Token::Attribute { span, local, value, .. } => { - if local.as_str() == "viewBox" { - last_viewbox = Some((span, value)); - } - } - xmlparser::Token::ElementEnd { .. } => break, - _ => {} - } - } - - if last_viewbox.is_none() { - // Correct the viewbox if it is not present. `-origin_ascender` is to - // make sure the glyph is rendered at the correct position - svg_str.insert_str( - start_span.unwrap().range().end, - format!(r#" viewBox="0 {} {width} {height}""#, -origin_ascender).as_str(), - ); - } - - let mut url: EcoString = "data:image/svg+xml;base64,".into(); - let b64_encoded = - base64::engine::general_purpose::STANDARD.encode(svg_str.as_bytes()); - url.push_str(&b64_encoded); - - Some(url) -} - -/// Convert a geometry to an SVG path. -#[comemo::memoize] -fn convert_geometry_to_path(geometry: &Geometry) -> EcoString { - let mut builder = SvgPathBuilder::default(); - match geometry { - Geometry::Line(t) => { - builder.move_to(0.0, 0.0); - builder.line_to(t.x.to_pt() as f32, t.y.to_pt() as f32); - } - Geometry::Rect(rect) => { - let x = rect.x.to_pt() as f32; - let y = rect.y.to_pt() as f32; - builder.rect(x, y); - } - Geometry::Path(p) => return convert_path(p), - }; - builder.0 -} - -fn convert_path(path: &Path) -> EcoString { - let mut builder = SvgPathBuilder::default(); - for item in &path.0 { - match item { - PathItem::MoveTo(m) => { - builder.move_to(m.x.to_pt() as f32, m.y.to_pt() as f32) - } - PathItem::LineTo(l) => { - builder.line_to(l.x.to_pt() as f32, l.y.to_pt() as f32) - } - PathItem::CubicTo(c1, c2, t) => builder.curve_to( - c1.x.to_pt() as f32, - c1.y.to_pt() as f32, - c2.x.to_pt() as f32, - c2.y.to_pt() as f32, - t.x.to_pt() as f32, - t.y.to_pt() as f32, - ), - PathItem::ClosePath => builder.close(), - } - } - builder.0 -} - -/// Encode an image into a data URL. The format of the URL is -/// `data:image/{format};base64,`. -#[comemo::memoize] -fn convert_image_to_base64_url(image: &Image) -> EcoString { - let format = match image.format() { - ImageFormat::Raster(f) => match f { - RasterFormat::Png => "png", - RasterFormat::Jpg => "jpeg", - RasterFormat::Gif => "gif", - }, - ImageFormat::Vector(f) => match f { - VectorFormat::Svg => "svg+xml", - }, - }; - - let mut url = eco_format!("data:image/{format};base64,"); - let data = base64::engine::general_purpose::STANDARD.encode(image.data()); - url.push_str(&data); - url } /// Deduplicates its elements. It is used to deduplicate glyphs and clip paths. @@ -1419,97 +441,3 @@ impl ttf_parser::OutlineBuilder for SvgPathBuilder { write!(&mut self.0, "Z ").unwrap(); } } - -/// Encode the color as an SVG color. -trait ColorEncode { - /// Encode the color. - fn encode(&self) -> EcoString; -} - -impl ColorEncode for Color { - fn encode(&self) -> EcoString { - match *self { - c @ Color::Rgb(_) - | c @ Color::Luma(_) - | c @ Color::Cmyk(_) - | c @ Color::Hsv(_) => c.to_hex(), - Color::LinearRgb(rgb) => { - if rgb.alpha != 1.0 { - eco_format!( - "color(srgb-linear {:.5} {:.5} {:.5} / {:.5})", - rgb.red, - rgb.green, - rgb.blue, - rgb.alpha - ) - } else { - eco_format!( - "color(srgb-linear {:.5} {:.5} {:.5})", - rgb.red, - rgb.green, - rgb.blue, - ) - } - } - Color::Oklab(oklab) => { - if oklab.alpha != 1.0 { - eco_format!( - "oklab({:.3}% {:.5} {:.5} / {:.5})", - oklab.l * 100.0, - oklab.a, - oklab.b, - oklab.alpha - ) - } else { - eco_format!( - "oklab({:.3}% {:.5} {:.5})", - oklab.l * 100.0, - oklab.a, - oklab.b, - ) - } - } - Color::Oklch(oklch) => { - if oklch.alpha != 1.0 { - eco_format!( - "oklch({:.3}% {:.5} {:.3}deg / {:.3})", - oklch.l * 100.0, - oklch.chroma, - oklch.hue.into_degrees(), - oklch.alpha - ) - } else { - eco_format!( - "oklch({:.3}% {:.5} {:.3}deg)", - oklch.l * 100.0, - oklch.chroma, - oklch.hue.into_degrees(), - ) - } - } - Color::Hsl(hsl) => { - if hsl.alpha != 1.0 { - eco_format!( - "hsla({:.3}deg {:.3}% {:.3}% / {:.5})", - hsl.hue.into_degrees(), - hsl.saturation * 100.0, - hsl.lightness * 100.0, - hsl.alpha, - ) - } else { - eco_format!( - "hsl({:.3}deg {:.3}% {:.3}%)", - hsl.hue.into_degrees(), - hsl.saturation * 100.0, - hsl.lightness * 100.0, - ) - } - } - } - } -} - -/// Maps a coordinate in a unit size square to a coordinate in the pattern. -fn correct_pattern_pos(x: f32) -> f32 { - (x + 0.5) / 2.0 -} diff --git a/crates/typst-svg/src/paint.rs b/crates/typst-svg/src/paint.rs new file mode 100644 index 000000000..fa3812bc7 --- /dev/null +++ b/crates/typst-svg/src/paint.rs @@ -0,0 +1,583 @@ +use std::f32::consts::TAU; + +use ecow::{eco_format, EcoString}; +use ttf_parser::OutlineBuilder; +use typst::foundations::Repr; +use typst::layout::{Angle, Axes, Frame, Quadrant, Ratio, Size, Transform}; +use typst::util::hash128; +use typst::visualize::{Color, Gradient, Paint, Pattern, RatioOrAngle}; +use xmlwriter::XmlWriter; + +use crate::{Id, SVGRenderer, State, SvgMatrix, SvgPathBuilder}; + +/// The number of segments in a conic gradient. +/// This is a heuristic value that seems to work well. +/// Smaller values could be interesting for optimization. +const CONIC_SEGMENT: usize = 360; + +impl SVGRenderer { + /// Render a frame to a string. + pub(super) fn render_pattern_frame( + &mut self, + state: State, + ts: Transform, + frame: &Frame, + ) -> String { + let mut xml = XmlWriter::new(xmlwriter::Options::default()); + std::mem::swap(&mut self.xml, &mut xml); + self.render_frame(state, ts, frame); + std::mem::swap(&mut self.xml, &mut xml); + xml.end_document() + } + + /// Write a fill attribute. + pub(super) fn write_fill(&mut self, fill: &Paint, size: Size, ts: Transform) { + match fill { + Paint::Solid(color) => self.xml.write_attribute("fill", &color.encode()), + Paint::Gradient(gradient) => { + let id = self.push_gradient(gradient, size, ts); + self.xml.write_attribute_fmt("fill", format_args!("url(#{id})")); + } + Paint::Pattern(pattern) => { + let id = self.push_pattern(pattern, size, ts); + self.xml.write_attribute_fmt("fill", format_args!("url(#{id})")); + } + } + } + + /// Pushes a gradient to the list of gradients to write SVG file. + /// + /// If the gradient is already present, returns the id of the existing + /// gradient. Otherwise, inserts the gradient and returns the id of the + /// inserted gradient. If the transform of the gradient is the identify + /// matrix, the returned ID will be the ID of the "source" gradient, + /// this is a file size optimization. + pub(super) fn push_gradient( + &mut self, + gradient: &Gradient, + size: Size, + ts: Transform, + ) -> Id { + let gradient_id = self + .gradients + .insert_with(hash128(&(gradient, size.aspect_ratio())), || { + (gradient.clone(), size.aspect_ratio()) + }); + + if ts.is_identity() { + return gradient_id; + } + + self.gradient_refs + .insert_with(hash128(&(gradient_id, ts)), || GradientRef { + id: gradient_id, + kind: gradient.into(), + transform: ts, + }) + } + + pub(super) fn push_pattern( + &mut self, + pattern: &Pattern, + size: Size, + ts: Transform, + ) -> Id { + let pattern_size = pattern.size() + pattern.spacing(); + // Unfortunately due to a limitation of `xmlwriter`, we need to + // render the frame twice: once to allocate all of the resources + // that it needs and once to actually render it. + self.render_pattern_frame( + State::new(pattern_size, Transform::identity()), + Transform::identity(), + pattern.frame(), + ); + + let pattern_id = self.patterns.insert_with(hash128(pattern), || pattern.clone()); + self.pattern_refs + .insert_with(hash128(&(pattern_id, ts)), || PatternRef { + id: pattern_id, + transform: ts, + ratio: Axes::new( + Ratio::new(pattern_size.x.to_pt() / size.x.to_pt()), + Ratio::new(pattern_size.y.to_pt() / size.y.to_pt()), + ), + }) + } + + /// Write the raw gradients (without transform) to the SVG file. + pub(super) fn write_gradients(&mut self) { + if self.gradients.is_empty() { + return; + } + + self.xml.start_element("defs"); + self.xml.write_attribute("id", "gradients"); + + for (id, (gradient, ratio)) in self.gradients.iter() { + match &gradient { + Gradient::Linear(linear) => { + self.xml.start_element("linearGradient"); + self.xml.write_attribute("id", &id); + self.xml.write_attribute("spreadMethod", "pad"); + self.xml.write_attribute("gradientUnits", "userSpaceOnUse"); + + let angle = Gradient::correct_aspect_ratio(linear.angle, *ratio); + let (sin, cos) = (angle.sin(), angle.cos()); + let length = sin.abs() + cos.abs(); + let (x1, y1, x2, y2) = match angle.quadrant() { + Quadrant::First => (0.0, 0.0, cos * length, sin * length), + Quadrant::Second => (1.0, 0.0, cos * length + 1.0, sin * length), + Quadrant::Third => { + (1.0, 1.0, cos * length + 1.0, sin * length + 1.0) + } + Quadrant::Fourth => (0.0, 1.0, cos * length, sin * length + 1.0), + }; + + self.xml.write_attribute("x1", &x1); + self.xml.write_attribute("y1", &y1); + self.xml.write_attribute("x2", &x2); + self.xml.write_attribute("y2", &y2); + } + Gradient::Radial(radial) => { + self.xml.start_element("radialGradient"); + self.xml.write_attribute("id", &id); + self.xml.write_attribute("spreadMethod", "pad"); + self.xml.write_attribute("gradientUnits", "userSpaceOnUse"); + self.xml.write_attribute("cx", &radial.center.x.get()); + self.xml.write_attribute("cy", &radial.center.y.get()); + self.xml.write_attribute("r", &radial.radius.get()); + self.xml.write_attribute("fx", &radial.focal_center.x.get()); + self.xml.write_attribute("fy", &radial.focal_center.y.get()); + self.xml.write_attribute("fr", &radial.focal_radius.get()); + } + Gradient::Conic(conic) => { + self.xml.start_element("pattern"); + self.xml.write_attribute("id", &id); + self.xml.write_attribute("viewBox", "0 0 1 1"); + self.xml.write_attribute("preserveAspectRatio", "none"); + self.xml.write_attribute("patternUnits", "userSpaceOnUse"); + self.xml.write_attribute("width", "2"); + self.xml.write_attribute("height", "2"); + self.xml.write_attribute("x", "-0.5"); + self.xml.write_attribute("y", "-0.5"); + + // The rotation angle, negated to match rotation in PNG. + let angle: f32 = + -(Gradient::correct_aspect_ratio(conic.angle, *ratio).to_rad() + as f32) + .rem_euclid(TAU); + let center: (f32, f32) = + (conic.center.x.get() as f32, conic.center.y.get() as f32); + + // We build an arg segment for each segment of a circle. + let dtheta = TAU / CONIC_SEGMENT as f32; + for i in 0..CONIC_SEGMENT { + let theta1 = dtheta * i as f32; + let theta2 = dtheta * (i + 1) as f32; + + // Create the path for the segment. + let mut builder = SvgPathBuilder::default(); + builder.move_to( + correct_pattern_pos(center.0), + correct_pattern_pos(center.1), + ); + builder.line_to( + correct_pattern_pos(-2.0 * (theta1 + angle).cos() + center.0), + correct_pattern_pos(2.0 * (theta1 + angle).sin() + center.1), + ); + builder.arc( + (2.0, 2.0), + 0.0, + 0, + 1, + ( + correct_pattern_pos( + -2.0 * (theta2 + angle).cos() + center.0, + ), + correct_pattern_pos( + 2.0 * (theta2 + angle).sin() + center.1, + ), + ), + ); + builder.close(); + + let t1 = (i as f32) / CONIC_SEGMENT as f32; + let t2 = (i + 1) as f32 / CONIC_SEGMENT as f32; + let subgradient = SVGSubGradient { + center: conic.center, + t0: Angle::rad((theta1 + angle) as f64), + t1: Angle::rad((theta2 + angle) as f64), + c0: gradient + .sample(RatioOrAngle::Ratio(Ratio::new(t1 as f64))), + c1: gradient + .sample(RatioOrAngle::Ratio(Ratio::new(t2 as f64))), + }; + let id = self + .conic_subgradients + .insert_with(hash128(&subgradient), || subgradient); + + // Add the path to the pattern. + self.xml.start_element("path"); + self.xml.write_attribute("d", &builder.0); + self.xml.write_attribute_fmt("fill", format_args!("url(#{id})")); + self.xml + .write_attribute_fmt("stroke", format_args!("url(#{id})")); + self.xml.write_attribute("stroke-width", "0"); + self.xml.write_attribute("shape-rendering", "optimizeSpeed"); + self.xml.end_element(); + } + + // We skip the default stop generation code. + self.xml.end_element(); + continue; + } + } + + for window in gradient.stops_ref().windows(2) { + let (start_c, start_t) = window[0]; + let (end_c, end_t) = window[1]; + + self.xml.start_element("stop"); + self.xml.write_attribute("offset", &start_t.repr()); + self.xml.write_attribute("stop-color", &start_c.to_hex()); + self.xml.end_element(); + + // Generate (256 / len) stops between the two stops. + // This is a workaround for a bug in many readers: + // They tend to just ignore the color space of the gradient. + // The goal is to have smooth gradients but not to balloon the file size + // too much if there are already a lot of stops as in most presets. + let len = if gradient.anti_alias() { + (256 / gradient.stops_ref().len() as u32).max(2) + } else { + 2 + }; + + for i in 1..(len - 1) { + let t0 = i as f64 / (len - 1) as f64; + let t = start_t + (end_t - start_t) * t0; + let c = gradient.sample(RatioOrAngle::Ratio(t)); + + self.xml.start_element("stop"); + self.xml.write_attribute("offset", &t.repr()); + self.xml.write_attribute("stop-color", &c.to_hex()); + self.xml.end_element(); + } + + self.xml.start_element("stop"); + self.xml.write_attribute("offset", &end_t.repr()); + self.xml.write_attribute("stop-color", &end_c.to_hex()); + self.xml.end_element() + } + + self.xml.end_element(); + } + + self.xml.end_element() + } + + /// Write the sub-gradients that are used for conic gradients. + pub(super) fn write_subgradients(&mut self) { + if self.conic_subgradients.is_empty() { + return; + } + + self.xml.start_element("defs"); + self.xml.write_attribute("id", "subgradients"); + for (id, gradient) in self.conic_subgradients.iter() { + let x1 = 2.0 - gradient.t0.cos() as f32 + gradient.center.x.get() as f32; + let y1 = gradient.t0.sin() as f32 + gradient.center.y.get() as f32; + let x2 = 2.0 - gradient.t1.cos() as f32 + gradient.center.x.get() as f32; + let y2 = gradient.t1.sin() as f32 + gradient.center.y.get() as f32; + + self.xml.start_element("linearGradient"); + self.xml.write_attribute("id", &id); + self.xml.write_attribute("gradientUnits", "objectBoundingBox"); + self.xml.write_attribute("x1", &x1); + self.xml.write_attribute("y1", &y1); + self.xml.write_attribute("x2", &x2); + self.xml.write_attribute("y2", &y2); + + self.xml.start_element("stop"); + self.xml.write_attribute("offset", "0%"); + self.xml.write_attribute("stop-color", &gradient.c0.to_hex()); + self.xml.end_element(); + + self.xml.start_element("stop"); + self.xml.write_attribute("offset", "100%"); + self.xml.write_attribute("stop-color", &gradient.c1.to_hex()); + self.xml.end_element(); + + self.xml.end_element(); + } + self.xml.end_element(); + } + + pub(super) fn write_gradient_refs(&mut self) { + if self.gradient_refs.is_empty() { + return; + } + + self.xml.start_element("defs"); + self.xml.write_attribute("id", "gradient-refs"); + for (id, gradient_ref) in self.gradient_refs.iter() { + match gradient_ref.kind { + GradientKind::Linear => { + self.xml.start_element("linearGradient"); + self.xml.write_attribute( + "gradientTransform", + &SvgMatrix(gradient_ref.transform), + ); + } + GradientKind::Radial => { + self.xml.start_element("radialGradient"); + self.xml.write_attribute( + "gradientTransform", + &SvgMatrix(gradient_ref.transform), + ); + } + GradientKind::Conic => { + self.xml.start_element("pattern"); + self.xml.write_attribute( + "patternTransform", + &SvgMatrix(gradient_ref.transform), + ); + } + } + + self.xml.write_attribute("id", &id); + + // Writing the href attribute to the "reference" gradient. + self.xml + .write_attribute_fmt("href", format_args!("#{}", gradient_ref.id)); + + // Also writing the xlink:href attribute for compatibility. + self.xml + .write_attribute_fmt("xlink:href", format_args!("#{}", gradient_ref.id)); + self.xml.end_element(); + } + + self.xml.end_element(); + } + + /// Write the raw gradients (without transform) to the SVG file. + pub(super) fn write_patterns(&mut self) { + if self.patterns.is_empty() { + return; + } + + self.xml.start_element("defs"); + self.xml.write_attribute("id", "patterns"); + + for (id, pattern) in + self.patterns.iter().map(|(i, p)| (i, p.clone())).collect::>() + { + let size = pattern.size() + pattern.spacing(); + self.xml.start_element("pattern"); + self.xml.write_attribute("id", &id); + self.xml.write_attribute("width", &size.x.to_pt()); + self.xml.write_attribute("height", &size.y.to_pt()); + self.xml.write_attribute("patternUnits", "userSpaceOnUse"); + self.xml.write_attribute_fmt( + "viewBox", + format_args!("0 0 {:.3} {:.3}", size.x.to_pt(), size.y.to_pt()), + ); + + // Render the frame. + let state = State::new(size, Transform::identity()); + let ts = Transform::identity(); + self.render_frame(state, ts, pattern.frame()); + + self.xml.end_element(); + } + + self.xml.end_element() + } + + /// Writes the references to the deduplicated patterns for each usage site. + pub(super) fn write_pattern_refs(&mut self) { + if self.pattern_refs.is_empty() { + return; + } + + self.xml.start_element("defs"); + self.xml.write_attribute("id", "pattern-refs"); + for (id, pattern_ref) in self.pattern_refs.iter() { + self.xml.start_element("pattern"); + self.xml + .write_attribute("patternTransform", &SvgMatrix(pattern_ref.transform)); + + self.xml.write_attribute("id", &id); + + // Writing the href attribute to the "reference" pattern. + self.xml + .write_attribute_fmt("href", format_args!("#{}", pattern_ref.id)); + + // Also writing the xlink:href attribute for compatibility. + self.xml + .write_attribute_fmt("xlink:href", format_args!("#{}", pattern_ref.id)); + self.xml.end_element(); + } + + self.xml.end_element(); + } +} + +/// A reference to a deduplicated pattern, with a transform matrix. +/// +/// Allows patterns to be reused across multiple invocations, +/// simply by changing the transform matrix. +#[derive(Hash)] +pub struct PatternRef { + /// The ID of the deduplicated gradient + id: Id, + /// The transform matrix to apply to the pattern. + transform: Transform, + /// The ratio of the size of the cell to the size of the filled area. + ratio: Axes, +} + +/// A reference to a deduplicated gradient, with a transform matrix. +/// +/// Allows gradients to be reused across multiple invocations, +/// simply by changing the transform matrix. +#[derive(Hash)] +pub struct GradientRef { + /// The ID of the deduplicated gradient + id: Id, + /// The gradient kind (used to determine the SVG element to use) + /// but without needing to clone the entire gradient. + kind: GradientKind, + /// The transform matrix to apply to the gradient. + transform: Transform, +} + +/// A subgradient for conic gradients. +#[derive(Hash)] +pub struct SVGSubGradient { + /// The center point of the gradient. + center: Axes, + /// The start point of the subgradient. + t0: Angle, + /// The end point of the subgradient. + t1: Angle, + /// The color at the start point of the subgradient. + c0: Color, + /// The color at the end point of the subgradient. + c1: Color, +} + +/// The kind of linear gradient. +#[derive(Hash, Clone, Copy, PartialEq, Eq)] +enum GradientKind { + /// A linear gradient. + Linear, + /// A radial gradient. + Radial, + /// A conic gradient. + Conic, +} + +impl From<&Gradient> for GradientKind { + fn from(value: &Gradient) -> Self { + match value { + Gradient::Linear { .. } => GradientKind::Linear, + Gradient::Radial { .. } => GradientKind::Radial, + Gradient::Conic { .. } => GradientKind::Conic, + } + } +} + +/// Encode the color as an SVG color. +pub trait ColorEncode { + /// Encode the color. + fn encode(&self) -> EcoString; +} + +impl ColorEncode for Color { + fn encode(&self) -> EcoString { + match *self { + c @ Color::Rgb(_) + | c @ Color::Luma(_) + | c @ Color::Cmyk(_) + | c @ Color::Hsv(_) => c.to_hex(), + Color::LinearRgb(rgb) => { + if rgb.alpha != 1.0 { + eco_format!( + "color(srgb-linear {:.5} {:.5} {:.5} / {:.5})", + rgb.red, + rgb.green, + rgb.blue, + rgb.alpha + ) + } else { + eco_format!( + "color(srgb-linear {:.5} {:.5} {:.5})", + rgb.red, + rgb.green, + rgb.blue, + ) + } + } + Color::Oklab(oklab) => { + if oklab.alpha != 1.0 { + eco_format!( + "oklab({:.3}% {:.5} {:.5} / {:.5})", + oklab.l * 100.0, + oklab.a, + oklab.b, + oklab.alpha + ) + } else { + eco_format!( + "oklab({:.3}% {:.5} {:.5})", + oklab.l * 100.0, + oklab.a, + oklab.b, + ) + } + } + Color::Oklch(oklch) => { + if oklch.alpha != 1.0 { + eco_format!( + "oklch({:.3}% {:.5} {:.3}deg / {:.3})", + oklch.l * 100.0, + oklch.chroma, + oklch.hue.into_degrees(), + oklch.alpha + ) + } else { + eco_format!( + "oklch({:.3}% {:.5} {:.3}deg)", + oklch.l * 100.0, + oklch.chroma, + oklch.hue.into_degrees(), + ) + } + } + Color::Hsl(hsl) => { + if hsl.alpha != 1.0 { + eco_format!( + "hsla({:.3}deg {:.3}% {:.3}% / {:.5})", + hsl.hue.into_degrees(), + hsl.saturation * 100.0, + hsl.lightness * 100.0, + hsl.alpha, + ) + } else { + eco_format!( + "hsl({:.3}deg {:.3}% {:.3}%)", + hsl.hue.into_degrees(), + hsl.saturation * 100.0, + hsl.lightness * 100.0, + ) + } + } + } + } +} + +/// Maps a coordinate in a unit size square to a coordinate in the pattern. +pub fn correct_pattern_pos(x: f32) -> f32 { + (x + 0.5) / 2.0 +} diff --git a/crates/typst-svg/src/shape.rs b/crates/typst-svg/src/shape.rs new file mode 100644 index 000000000..4caae2fdc --- /dev/null +++ b/crates/typst-svg/src/shape.rs @@ -0,0 +1,195 @@ +use ecow::EcoString; +use ttf_parser::OutlineBuilder; +use typst::layout::{Abs, Ratio, Size, Transform}; +use typst::visualize::{ + FixedStroke, Geometry, LineCap, LineJoin, Paint, Path, PathItem, RelativeTo, Shape, +}; + +use crate::paint::ColorEncode; +use crate::{SVGRenderer, State, SvgPathBuilder}; + +impl SVGRenderer { + /// Render a shape element. + pub(super) fn render_shape(&mut self, state: State, shape: &Shape) { + self.xml.start_element("path"); + self.xml.write_attribute("class", "typst-shape"); + + if let Some(paint) = &shape.fill { + self.write_fill( + paint, + self.shape_fill_size(state, paint, shape), + self.shape_paint_transform(state, paint, shape), + ); + } else { + self.xml.write_attribute("fill", "none"); + } + + if let Some(stroke) = &shape.stroke { + self.write_stroke( + stroke, + self.shape_fill_size(state, &stroke.paint, shape), + self.shape_paint_transform(state, &stroke.paint, shape), + ); + } + + let path = convert_geometry_to_path(&shape.geometry); + self.xml.write_attribute("d", &path); + self.xml.end_element(); + } + + /// Calculate the transform of the shape's fill or stroke. + fn shape_paint_transform( + &self, + state: State, + paint: &Paint, + shape: &Shape, + ) -> Transform { + let mut shape_size = shape.geometry.bbox_size(); + // Edge cases for strokes. + if shape_size.x.to_pt() == 0.0 { + shape_size.x = Abs::pt(1.0); + } + + if shape_size.y.to_pt() == 0.0 { + shape_size.y = Abs::pt(1.0); + } + + if let Paint::Gradient(gradient) = paint { + match gradient.unwrap_relative(false) { + RelativeTo::Self_ => Transform::scale( + Ratio::new(shape_size.x.to_pt()), + Ratio::new(shape_size.y.to_pt()), + ), + RelativeTo::Parent => Transform::scale( + Ratio::new(state.size.x.to_pt()), + Ratio::new(state.size.y.to_pt()), + ) + .post_concat(state.transform.invert().unwrap()), + } + } else if let Paint::Pattern(pattern) = paint { + match pattern.unwrap_relative(false) { + RelativeTo::Self_ => Transform::identity(), + RelativeTo::Parent => state.transform.invert().unwrap(), + } + } else { + Transform::identity() + } + } + + /// Calculate the size of the shape's fill. + fn shape_fill_size(&self, state: State, paint: &Paint, shape: &Shape) -> Size { + let mut shape_size = shape.geometry.bbox_size(); + // Edge cases for strokes. + if shape_size.x.to_pt() == 0.0 { + shape_size.x = Abs::pt(1.0); + } + + if shape_size.y.to_pt() == 0.0 { + shape_size.y = Abs::pt(1.0); + } + + if let Paint::Gradient(gradient) = paint { + match gradient.unwrap_relative(false) { + RelativeTo::Self_ => shape_size, + RelativeTo::Parent => state.size, + } + } else { + shape_size + } + } + + /// Write a stroke attribute. + pub(super) fn write_stroke( + &mut self, + stroke: &FixedStroke, + size: Size, + fill_transform: Transform, + ) { + match &stroke.paint { + Paint::Solid(color) => self.xml.write_attribute("stroke", &color.encode()), + Paint::Gradient(gradient) => { + let id = self.push_gradient(gradient, size, fill_transform); + self.xml.write_attribute_fmt("stroke", format_args!("url(#{id})")); + } + Paint::Pattern(pattern) => { + let id = self.push_pattern(pattern, size, fill_transform); + self.xml.write_attribute_fmt("stroke", format_args!("url(#{id})")); + } + } + + self.xml.write_attribute("stroke-width", &stroke.thickness.to_pt()); + self.xml.write_attribute( + "stroke-linecap", + match stroke.cap { + LineCap::Butt => "butt", + LineCap::Round => "round", + LineCap::Square => "square", + }, + ); + self.xml.write_attribute( + "stroke-linejoin", + match stroke.join { + LineJoin::Miter => "miter", + LineJoin::Round => "round", + LineJoin::Bevel => "bevel", + }, + ); + self.xml + .write_attribute("stroke-miterlimit", &stroke.miter_limit.get()); + if let Some(pattern) = &stroke.dash { + self.xml.write_attribute("stroke-dashoffset", &pattern.phase.to_pt()); + self.xml.write_attribute( + "stroke-dasharray", + &pattern + .array + .iter() + .map(|dash| dash.to_pt().to_string()) + .collect::>() + .join(" "), + ); + } + } +} + +/// Convert a geometry to an SVG path. +#[comemo::memoize] +fn convert_geometry_to_path(geometry: &Geometry) -> EcoString { + let mut builder = SvgPathBuilder::default(); + match geometry { + Geometry::Line(t) => { + builder.move_to(0.0, 0.0); + builder.line_to(t.x.to_pt() as f32, t.y.to_pt() as f32); + } + Geometry::Rect(rect) => { + let x = rect.x.to_pt() as f32; + let y = rect.y.to_pt() as f32; + builder.rect(x, y); + } + Geometry::Path(p) => return convert_path(p), + }; + builder.0 +} + +pub fn convert_path(path: &Path) -> EcoString { + let mut builder = SvgPathBuilder::default(); + for item in &path.0 { + match item { + PathItem::MoveTo(m) => { + builder.move_to(m.x.to_pt() as f32, m.y.to_pt() as f32) + } + PathItem::LineTo(l) => { + builder.line_to(l.x.to_pt() as f32, l.y.to_pt() as f32) + } + PathItem::CubicTo(c1, c2, t) => builder.curve_to( + c1.x.to_pt() as f32, + c1.y.to_pt() as f32, + c2.x.to_pt() as f32, + c2.y.to_pt() as f32, + t.x.to_pt() as f32, + t.y.to_pt() as f32, + ), + PathItem::ClosePath => builder.close(), + } + } + builder.0 +} diff --git a/crates/typst-svg/src/text.rs b/crates/typst-svg/src/text.rs new file mode 100644 index 000000000..c29c7a68d --- /dev/null +++ b/crates/typst-svg/src/text.rs @@ -0,0 +1,308 @@ +use std::io::Read; + +use base64::Engine; +use ecow::EcoString; +use ttf_parser::GlyphId; +use typst::layout::{Abs, Point, Ratio, Size, Transform}; +use typst::text::{Font, TextItem}; +use typst::util::hash128; +use typst::visualize::{Image, Paint, RasterFormat, RelativeTo}; + +use crate::{SVGRenderer, State, SvgMatrix, SvgPathBuilder}; + +impl SVGRenderer { + /// Render a text item. The text is rendered as a group of glyphs. We will + /// try to render the text as SVG first, then bitmap, then outline. If none + /// of them works, we will skip the text. + pub(super) fn render_text(&mut self, state: State, text: &TextItem) { + let scale: f64 = text.size.to_pt() / text.font.units_per_em(); + + self.xml.start_element("g"); + self.xml.write_attribute("class", "typst-text"); + self.xml.write_attribute("transform", "scale(1, -1)"); + + let mut x: f64 = 0.0; + for glyph in &text.glyphs { + let id = GlyphId(glyph.id); + let offset = x + glyph.x_offset.at(text.size).to_pt(); + + self.render_svg_glyph(text, id, offset, scale) + .or_else(|| self.render_bitmap_glyph(text, id, offset)) + .or_else(|| { + self.render_outline_glyph( + state + .pre_concat(Transform::scale(Ratio::one(), -Ratio::one())) + .pre_translate(Point::new(Abs::pt(offset), Abs::zero())), + text, + id, + offset, + scale, + ) + }); + + x += glyph.x_advance.at(text.size).to_pt(); + } + + self.xml.end_element(); + } + + /// Render a glyph defined by an SVG. + fn render_svg_glyph( + &mut self, + text: &TextItem, + id: GlyphId, + x_offset: f64, + scale: f64, + ) -> Option<()> { + let data_url = convert_svg_glyph_to_base64_url(&text.font, id)?; + let upem = Abs::raw(text.font.units_per_em()); + let origin_ascender = text.font.metrics().ascender.at(upem).to_pt(); + + let glyph_hash = hash128(&(&text.font, id)); + let id = self.glyphs.insert_with(glyph_hash, || RenderedGlyph::Image { + url: data_url, + width: upem.to_pt(), + height: upem.to_pt(), + ts: Transform::translate(Abs::zero(), Abs::pt(-origin_ascender)) + .post_concat(Transform::scale(Ratio::new(scale), Ratio::new(-scale))), + }); + + self.xml.start_element("use"); + self.xml.write_attribute_fmt("xlink:href", format_args!("#{id}")); + self.xml.write_attribute("x", &x_offset); + self.xml.end_element(); + + Some(()) + } + + /// Render a glyph defined by a bitmap. + fn render_bitmap_glyph( + &mut self, + text: &TextItem, + id: GlyphId, + x_offset: f64, + ) -> Option<()> { + let (image, bitmap_x_offset, bitmap_y_offset) = + convert_bitmap_glyph_to_image(&text.font, id)?; + + let glyph_hash = hash128(&(&text.font, id)); + let id = self.glyphs.insert_with(glyph_hash, || { + let width = image.width(); + let height = image.height(); + let url = crate::image::convert_image_to_base64_url(&image); + let ts = Transform::translate( + Abs::pt(bitmap_x_offset), + Abs::pt(-height - bitmap_y_offset), + ); + RenderedGlyph::Image { url, width, height, ts } + }); + + let target_height = text.size.to_pt(); + self.xml.start_element("use"); + self.xml.write_attribute_fmt("xlink:href", format_args!("#{id}")); + + // The image is stored with the height of `image.height()`, but we want + // to render it with a height of `target_height`. So we need to scale + // it. + let scale_factor = target_height / image.height(); + self.xml.write_attribute("x", &(x_offset / scale_factor)); + self.xml.write_attribute_fmt( + "transform", + format_args!("scale({scale_factor} -{scale_factor})",), + ); + self.xml.end_element(); + + Some(()) + } + + /// Render a glyph defined by an outline. + fn render_outline_glyph( + &mut self, + state: State, + text: &TextItem, + glyph_id: GlyphId, + x_offset: f64, + scale: f64, + ) -> Option<()> { + let scale = Ratio::new(scale); + let path = convert_outline_glyph_to_path(&text.font, glyph_id, scale)?; + let hash = hash128(&(&text.font, glyph_id, scale)); + let id = self.glyphs.insert_with(hash, || RenderedGlyph::Path(path)); + + let glyph_size = text.font.ttf().glyph_bounding_box(glyph_id)?; + let width = glyph_size.width() as f64 * scale.get(); + let height = glyph_size.height() as f64 * scale.get(); + + self.xml.start_element("use"); + self.xml.write_attribute_fmt("xlink:href", format_args!("#{id}")); + self.xml.write_attribute_fmt("x", format_args!("{x_offset}")); + self.write_fill( + &text.fill, + Size::new(Abs::pt(width), Abs::pt(height)), + self.text_paint_transform(state, &text.fill), + ); + if let Some(stroke) = &text.stroke { + self.write_stroke( + stroke, + Size::new(Abs::pt(width), Abs::pt(height)), + self.text_paint_transform(state, &stroke.paint), + ); + } + self.xml.end_element(); + + Some(()) + } + + fn text_paint_transform(&self, state: State, paint: &Paint) -> Transform { + match paint { + Paint::Solid(_) => Transform::identity(), + Paint::Gradient(gradient) => match gradient.unwrap_relative(true) { + RelativeTo::Self_ => Transform::identity(), + RelativeTo::Parent => Transform::scale( + Ratio::new(state.size.x.to_pt()), + Ratio::new(state.size.y.to_pt()), + ) + .post_concat(state.transform.invert().unwrap()), + }, + Paint::Pattern(pattern) => match pattern.unwrap_relative(true) { + RelativeTo::Self_ => Transform::identity(), + RelativeTo::Parent => state.transform.invert().unwrap(), + }, + } + } + + /// Build the glyph definitions. + pub(super) fn write_glyph_defs(&mut self) { + if self.glyphs.is_empty() { + return; + } + + self.xml.start_element("defs"); + self.xml.write_attribute("id", "glyph"); + + for (id, glyph) in self.glyphs.iter() { + self.xml.start_element("symbol"); + self.xml.write_attribute("id", &id); + self.xml.write_attribute("overflow", "visible"); + + match glyph { + RenderedGlyph::Path(path) => { + self.xml.start_element("path"); + self.xml.write_attribute("d", &path); + self.xml.end_element(); + } + RenderedGlyph::Image { url, width, height, ts } => { + self.xml.start_element("image"); + self.xml.write_attribute("xlink:href", &url); + self.xml.write_attribute("width", &width); + self.xml.write_attribute("height", &height); + if !ts.is_identity() { + self.xml.write_attribute("transform", &SvgMatrix(*ts)); + } + self.xml.write_attribute("preserveAspectRatio", "none"); + self.xml.end_element(); + } + } + + self.xml.end_element(); + } + + self.xml.end_element(); + } +} + +/// Represents a glyph to be rendered. +pub enum RenderedGlyph { + /// A path is a sequence of drawing commands. + /// + /// It is in the format of `M x y L x y C x1 y1 x2 y2 x y Z`. + Path(EcoString), + /// An image is a URL to an image file, plus the size and transform. + /// + /// The url is in the format of `data:image/{format};base64,`. + Image { url: EcoString, width: f64, height: f64, ts: Transform }, +} + +/// Convert an outline glyph to an SVG path. +#[comemo::memoize] +fn convert_outline_glyph_to_path( + font: &Font, + id: GlyphId, + scale: Ratio, +) -> Option { + let mut builder = SvgPathBuilder::with_scale(scale); + font.ttf().outline_glyph(id, &mut builder)?; + Some(builder.0) +} + +/// Convert a bitmap glyph to an encoded image URL. +#[comemo::memoize] +fn convert_bitmap_glyph_to_image(font: &Font, id: GlyphId) -> Option<(Image, f64, f64)> { + let raster = font.ttf().glyph_raster_image(id, std::u16::MAX)?; + if raster.format != ttf_parser::RasterImageFormat::PNG { + return None; + } + let image = Image::new(raster.data.into(), RasterFormat::Png.into(), None).ok()?; + Some((image, raster.x as f64, raster.y as f64)) +} + +/// Convert an SVG glyph to an encoded image URL. +#[comemo::memoize] +fn convert_svg_glyph_to_base64_url(font: &Font, id: GlyphId) -> Option { + let mut data = font.ttf().glyph_svg_image(id)?.data; + + // Decompress SVGZ. + let mut decoded = vec![]; + if data.starts_with(&[0x1f, 0x8b]) { + let mut decoder = flate2::read::GzDecoder::new(data); + decoder.read_to_end(&mut decoded).ok()?; + data = &decoded; + } + + let upem = Abs::raw(font.units_per_em()); + let (width, height) = (upem.to_pt(), upem.to_pt()); + let origin_ascender = font.metrics().ascender.at(upem).to_pt(); + + // Parse XML. + let mut svg_str = std::str::from_utf8(data).ok()?.to_owned(); + let mut start_span = None; + let mut last_viewbox = None; + + // Parse xml and find the viewBox of the svg element. + // ... + // ~~~~~^~~~~~~ + for n in xmlparser::Tokenizer::from(svg_str.as_str()) { + let tok = n.unwrap(); + match tok { + xmlparser::Token::ElementStart { span, local, .. } => { + if local.as_str() == "svg" { + start_span = Some(span); + break; + } + } + xmlparser::Token::Attribute { span, local, value, .. } => { + if local.as_str() == "viewBox" { + last_viewbox = Some((span, value)); + } + } + xmlparser::Token::ElementEnd { .. } => break, + _ => {} + } + } + + if last_viewbox.is_none() { + // Correct the viewbox if it is not present. `-origin_ascender` is to + // make sure the glyph is rendered at the correct position + svg_str.insert_str( + start_span.unwrap().range().end, + format!(r#" viewBox="0 {} {width} {height}""#, -origin_ascender).as_str(), + ); + } + + let mut url: EcoString = "data:image/svg+xml;base64,".into(); + let b64_encoded = + base64::engine::general_purpose::STANDARD.encode(svg_str.as_bytes()); + url.push_str(&b64_encoded); + + Some(url) +}