//! Deterministic, platform-independent SVG rendering. use std::collections::{BTreeMap, BTreeSet}; use std::fmt::Write as _; use serde::Deserialize; use serde_json::Value; use thiserror::Error; use crate::connector::resolve_arrow_geometry_for_shape; #[cfg(test)] use crate::engine::geometry::world_transform; use crate::engine::geometry::{ Affine, bounds_from_points, intersects, path_bounds, stroke_outline as canonical_stroke_outline, union, }; use crate::path_metrics::{DEFAULT_PATH_METRIC_TOLERANCE, path_length, trim_path}; use crate::proto::Bounds; use crate::{ AssetId, AssetSource, BuiltinShapeKind, Document, DocumentSnapshot, FilterEffect, FilterPrimitive, GradientSpread, GradientUnits, LayerId, MaskEffect, MaskMode, PageId, Paint, PaintValue, PathFillRule, PathGeometry, PathSegment, PathSubpath, ShapeId, ShapeParent, ShapeRecord, Vec2, }; const DEFAULT_PADDING: f64 = 20.0; const EMPTY_SIZE: f64 = 100.0; const FALLBACK_FONT: &str = "sans-serif"; /// Filters and resources used for one SVG projection. #[derive(Clone, Debug, Default, PartialEq)] pub struct SvgRenderOptions { /// Page to render. The document's first page is used when omitted. pub page_id: Option, /// Layers to include. An empty set includes every visible layer on the page. pub layer_ids: BTreeSet, /// Shapes to include. Selecting a container includes its descendants. pub selection: BTreeSet, /// Region to include and use as the exact SVG view box. pub region: Option, /// Font families supplied by the caller in addition to SVG generic families. pub available_font_families: BTreeSet, /// External assets that the caller has resolved and made available. pub available_asset_ids: BTreeSet, } /// A deterministic SVG projection and any non-fatal fallback diagnostics. #[derive(Clone, Debug, Eq, PartialEq)] pub struct SvgRenderOutput { /// Complete UTF-8 SVG document. pub svg: String, /// Stable, deduplicated warnings in lexical order. pub warnings: Vec, } /// A deterministic fallback used while rendering a valid snapshot. #[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] pub enum SvgRenderWarning { /// A requested font was replaced with the built-in generic fallback. MissingFont { /// Shape that requested the unavailable family. shape_id: ShapeId, /// Requested CSS font family. requested: String, /// Deterministic family written to the SVG. fallback: String, }, /// A shape referred to an asset that the document or caller could not supply. MissingAsset { /// Shape containing the reference. shape_id: ShapeId, /// Referenced asset ID. asset_id: AssetId, }, /// An external asset exists but was not resolved by the headless caller. UnresolvedExternalAsset { /// Shape containing the reference. shape_id: ShapeId, /// Referenced asset ID. asset_id: AssetId, }, } impl std::fmt::Display for SvgRenderWarning { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::MissingFont { shape_id, requested, fallback } => { write!( formatter, "shape {shape_id} requested unavailable font {requested}; using {fallback}" ) } Self::MissingAsset { shape_id, asset_id } => { write!(formatter, "shape {shape_id} references missing asset {asset_id}") } Self::UnresolvedExternalAsset { shape_id, asset_id } => { write!( formatter, "shape {shape_id} references unresolved external asset {asset_id}" ) } } } } /// Failure to produce an SVG projection from a snapshot. #[derive(Clone, Debug, Error, PartialEq)] pub enum SvgRenderError { /// The requested page is absent from the snapshot. #[error("page {page_id} does not exist")] PageNotFound { page_id: PageId }, /// A render region contains non-finite or negative dimensions. #[error("render region must contain finite coordinates and non-negative dimensions")] InvalidRegion, /// A built-in shape has properties that its renderer cannot decode. #[error("shape {shape_id} ({kind}) has invalid rendering properties: {message}")] InvalidShapeProperties { /// Shape that could not be rendered. shape_id: ShapeId, /// Built-in registry key. kind: String, /// Property decoding error. message: String, }, } struct Renderer<'a> { document: &'a Document, options: &'a SvgRenderOptions, warnings: BTreeSet, font_faces: BTreeMap)>, rendered_bounds: Option, body: String, } impl Renderer<'_> { fn render_shape( &mut self, shape_id: &ShapeId, parent_matrix: Affine, ancestor_selected: bool, output: &mut String, ) -> Result<(), SvgRenderError> { let Some(shape) = self.document.shapes.get(shape_id) else { return Ok(()); }; let selected = ancestor_selected || self.options.selection.contains(shape_id); if !self.options.selection.is_empty() && !selected && !contains_selected_descendant(self.document, shape, &self.options.selection) { return Ok(()); } let matrix = parent_matrix.then(Affine::from_transform(shape.transform)); let local_bounds = shape_local_bounds(self.document, shape)?; let world_bounds = self .attached_text_bounds(shape, matrix) .unwrap_or_else(|| matrix.transform_bounds(local_bounds)); let bound_arrow = shape.kind.as_str() == crate::ARROW_KIND && self .document .bindings .values() .any(|binding| binding.source_shape_id == shape.id); let intersects_region = bound_arrow || self .options .region .is_none_or(|region| intersects(®ion, &world_bounds)); let render_self = (self.options.selection.is_empty() || selected) && intersects_region; let mut inner = String::new(); if render_self { self.inspect_resources(shape); self.rendered_bounds = Some( self.rendered_bounds .map_or(world_bounds, |current| union(current, world_bounds)), ); inner = self.shape_element(shape, matrix)?; } for child_id in &shape.child_ids { self.render_shape(child_id, matrix, selected, &mut inner)?; } if !inner.is_empty() { let mut effect_defs = String::new(); apply_shape_effects(shape, matrix, &mut inner, &mut effect_defs).map_err(|message| { SvgRenderError::InvalidShapeProperties { shape_id: shape.id.clone(), kind: shape.kind.to_string(), message, } })?; if !effect_defs.is_empty() { inner = format!(" {effect_defs}\n{inner}"); } } if inner.is_empty() { return Ok(()); } let metadata_attributes = svg_metadata_attributes(&shape.metadata); writeln!( output, " ", escape_xml(shape.id.as_str()), metadata_attributes, number(f64::from(shape.style.opacity.get())) ) .expect("writing to a String cannot fail"); output.push_str(&inner); output.push_str(" \n"); Ok(()) } fn shape_matrix(&self, shape_id: &ShapeId) -> Option { let shape = self.document.shapes.get(shape_id)?; let local = Affine::from_transform(shape.transform); match &shape.parent { ShapeParent::Layer(_) => Some(local), ShapeParent::Shape(parent_id) => self.shape_matrix(parent_id).map(|parent| parent.then(local)), } } fn attached_text_bounds(&self, shape: &ShapeRecord, _matrix: Affine) -> Option { if shape.kind.as_str() != crate::TEXT_KIND { return None; } let props: TextProps = properties(shape).ok()?; let text_path = props.text_path?; let path = self.document.shapes.get(&text_path.path_id)?; if path.kind.as_str() != crate::PATH_KIND { return None; } let geometry = crate::path_geometry_from_properties(&path.properties).ok()?; let path_matrix = self.shape_matrix(&path.id)?; Some(path_matrix.transform_bounds(path_bounds(&geometry))) } fn shape_element(&mut self, shape: &ShapeRecord, matrix: Affine) -> Result { let transform = affine_svg(matrix); let fill_opacity = number(f64::from( shape.style.fill_opacity.unwrap_or(crate::Opacity::OPAQUE).get(), )); let stroke_opacity = number(f64::from( shape.style.stroke_opacity.unwrap_or(crate::Opacity::OPAQUE).get(), )); let mut output = String::new(); let mut gradient_defs = String::new(); match BuiltinShapeKind::parse(shape.kind.as_str()) { Some(BuiltinShapeKind::Rectangle) => { let props: BoxProps = properties(shape)?; writeln!( output, " ", number(props.width), number(props.height), number(props.radius.min(props.width / 2.0).min(props.height / 2.0).max(0.0)), paint(props.fill.as_ref(), &shape.id, "fill", &mut gradient_defs), paint(props.stroke.as_ref(), &shape.id, "stroke", &mut gradient_defs) ).expect("writing to a String cannot fail"); } Some(BuiltinShapeKind::Container) => { let props: ContainerProps = properties(shape)?; if props.width > 0.0 || props.height > 0.0 || props.fill.is_some() || props.stroke.is_some() || props.title.is_some() { writeln!( output, " ", number(props.width), number(props.height), number(props.radius.min(props.width / 2.0).min(props.height / 2.0).max(0.0)), paint(props.fill.as_ref(), &shape.id, "fill", &mut gradient_defs), paint(props.stroke.as_ref(), &shape.id, "stroke", &mut gradient_defs) ).expect("writing to a String cannot fail"); } if let Some(title) = props.title.filter(|title| !title.is_empty()) { writeln!( output, " {}", escape_xml(&title) ).expect("writing to a String cannot fail"); } } Some(BuiltinShapeKind::Ellipse) => { let props: BoxProps = properties(shape)?; writeln!( output, " ", number(props.width / 2.0), number(props.height / 2.0), number(props.width / 2.0), number(props.height / 2.0), paint(props.fill.as_ref(), &shape.id, "fill", &mut gradient_defs), paint(props.stroke.as_ref(), &shape.id, "stroke", &mut gradient_defs) ).expect("writing to a String cannot fail"); } Some(BuiltinShapeKind::Line) => { let props: LineProps = properties(shape)?; writeln!( output, " ", number(props.a.x), number(props.a.y), number(props.b.x), number(props.b.y), paint(Some(&props.stroke), &shape.id, "stroke", &mut gradient_defs), number(props.width) ).expect("writing to a String cannot fail"); } Some(BuiltinShapeKind::Arrow) => self.render_arrow( shape, &transform, &stroke_opacity, &fill_opacity, &mut output, &mut gradient_defs, )?, Some(BuiltinShapeKind::Text) => { self.render_text(shape, &transform, &fill_opacity, &mut output, &mut gradient_defs)? } Some(BuiltinShapeKind::Markdown) => { self.render_markdown( shape, &transform, &fill_opacity, &stroke_opacity, &mut output, &mut gradient_defs, )?; } Some(BuiltinShapeKind::Image) => { let props: ImageProps = properties(shape)?; if let Some(asset) = self.document.assets.get(&props.asset_id) && let AssetSource::Embedded { bytes } = &asset.source { let mask_id = format!("inkfinite-image-mask-{}", shape.id.as_str().replace(':', "-")); if let Some(mask) = &props.mask { writeln!( output, " {}", image_mask_path(mask, props.width, props.height) ) .expect("writing to a String cannot fail"); } let clip = props .mask .as_ref() .map_or(String::new(), |_| format!(" clip-path=\"url(#{mask_id})\"")); let image = if let Some(crop) = props.crop { format!( "", number(props.width), number(props.height), number(crop.left), number(crop.top), number(1.0 - crop.left - crop.right), number(1.0 - crop.top - crop.bottom), escape_xml(&asset.media_type), base64(bytes) ) } else { format!( "", number(props.width), number(props.height), escape_xml(&asset.media_type), base64(bytes) ) }; writeln!( output, " {image}" ) .expect("writing to a String cannot fail"); if let Some(caption) = props.caption.filter(|caption| !caption.is_empty()) { writeln!( output, " {}", number((props.height - 18.0).max(12.0)), escape_xml(&caption) ).expect("writing to a String cannot fail"); } } } Some(BuiltinShapeKind::Reference) => { let props: ReferenceProps = properties(shape)?; let label = props.label.as_deref().unwrap_or(&props.value); let accent = match props.reference_type.as_str() { "url" => "#2563eb", "file" => "#16a34a", _ => "#7c3aed", }; writeln!( output, " {}{}", number(props.width), number(props.height), escape_xml(label), escape_xml(&props.value) ).expect("writing to a String cannot fail"); } Some(BuiltinShapeKind::Stroke) => { let props: StrokePaintProperties = properties(shape)?; let outline = canonical_stroke_outline(&shape.properties).map_err(|message| { SvgRenderError::InvalidShapeProperties { shape_id: shape.id.clone(), kind: shape.kind.to_string(), message, } })?; if !outline.is_empty() { let path = outline .iter() .enumerate() .map(|(index, point)| { format!( "{} {} {}", if index == 0 { "M" } else { "L" }, number(point.x), number(point.y) ) }) .collect::>() .join(" "); let opacity = shape .style .stroke_opacity .map_or(props.style.opacity, |value| f64::from(value.get())); writeln!( output, " ", paint(Some(&props.style.color), &shape.id, "stroke", &mut gradient_defs), number(opacity) ).expect("writing to a String cannot fail"); } } Some(BuiltinShapeKind::Path) => { let props: PathProps = properties(shape)?; let geometry = PathGeometry { subpaths: props.subpaths, fill_rule: props.fill_rule }; crate::validate_path_geometry(&geometry).map_err(|error| SvgRenderError::InvalidShapeProperties { shape_id: shape.id.clone(), kind: shape.kind.to_string(), message: error.to_string(), })?; writeln!( output, " ", shape.id.as_str().replace(|character: char| !character.is_ascii_alphanumeric(), "-"), path_data(&geometry), paint(props.fill.as_ref(), &shape.id, "fill", &mut gradient_defs), path_fill_rule(props.fill_rule), paint(props.stroke.as_ref(), &shape.id, "stroke", &mut gradient_defs), number(props.stroke_width.unwrap_or(2.0).max(0.0)), ) .expect("writing to a String cannot fail"); } None => {} } if !gradient_defs.is_empty() { output = format!(" {gradient_defs} \n{output}"); } Ok(output) } fn render_arrow( &self, shape: &ShapeRecord, transform: &str, stroke_opacity: &str, fill_opacity: &str, output: &mut String, gradient_defs: &mut String, ) -> Result<(), SvgRenderError> { let props: ArrowProps = properties(shape)?; let geometry = resolve_arrow_geometry_for_shape(self.document, shape).map_err(|error| { SvgRenderError::InvalidShapeProperties { shape_id: shape.id.clone(), kind: shape.kind.to_string(), message: error.to_string(), } })?; let shaft = arrow_shaft_geometry(&geometry.path, &props.style); let dash = props .style .dash .as_ref() .filter(|dash| !dash.is_empty()) .map(|dash| { format!( " stroke-dasharray=\"{}\"", dash.iter().map(|value| number(*value)).collect::>().join(" ") ) }) .unwrap_or_default(); let stroke = paint(Some(&props.style.stroke), &shape.id, "stroke", gradient_defs); writeln!(output, " ", path_data(&shaft), number(props.style.width)).expect("writing to a String cannot fail"); if props.style.head_end.unwrap_or(true) && let Some(head) = arrow_head_geometry(&geometry.path, false) { arrow_head( output, transform, &head, false, &props.style, stroke_opacity, &mut SvgPaintContext { defs: gradient_defs, shape_id: &shape.id }, ); } if props.style.head_start.unwrap_or(false) && let Some(head) = arrow_head_geometry(&geometry.path, true) { arrow_head( output, transform, &head, true, &props.style, stroke_opacity, &mut SvgPaintContext { defs: gradient_defs, shape_id: &shape.id }, ); } if let Some(label) = props.label.filter(|label| !label.text.is_empty()) && let Some((at, _distance)) = arrow_label_position(&geometry.path, &label) { let label_width = deterministic_text_width(&label.text, 14.0) + 8.0; writeln!(output, " {}", number(at.x - label_width / 2.0), number(at.y - 9.0), number(label_width), number(at.x), number(at.y), escape_xml(&label.text)).expect("writing to a String cannot fail"); } Ok(()) } fn render_text( &mut self, shape: &ShapeRecord, transform: &str, fill_opacity: &str, output: &mut String, gradient_defs: &mut String, ) -> Result<(), SvgRenderError> { let props: TextProps = properties(shape)?; let font = self.font(shape, &props.font_family); if let Some(text_path) = &props.text_path && let Some(path) = self.document.shapes.get(&text_path.path_id) && path.kind.as_str() == crate::PATH_KIND { let geometry = crate::path_geometry_from_properties(&path.properties).map_err(|error| { SvgRenderError::InvalidShapeProperties { shape_id: path.id.clone(), kind: path.kind.to_string(), message: error.to_string(), } })?; let geometry = if text_path.direction == "reverse" { reverse_path_geometry(&geometry) } else { geometry }; let path_matrix = self .shape_matrix(&path.id) .ok_or_else(|| SvgRenderError::InvalidShapeProperties { shape_id: shape.id.clone(), kind: shape.kind.to_string(), message: "supporting path has no valid parent transform".into(), })?; let base_path_id = format!( "inkfinite-path-{}", path.id .as_str() .replace(|character: char| !character.is_ascii_alphanumeric(), "-") ); let path_id = if text_path.direction == "reverse" { format!("{base_path_id}-reverse") } else { base_path_id }; if text_path.direction == "reverse" { writeln!( output, " ", path_data(&geometry), affine_svg(path_matrix) ) .expect("writing to a String cannot fail"); } let anchor = match text_path.align.as_str() { "center" => "middle", "end" => "end", _ => "start", }; let color = paint(Some(&props.color), &shape.id, "fill", gradient_defs); writeln!(output, " {}", escape_xml(font), number(props.font_size), number(text_path.offset), text_path.side, if text_path.direction == "reverse" { "rtl" } else { "ltr" }, escape_xml(&props.text)).expect("writing to a String cannot fail"); return Ok(()); } let lines = props.width.map_or_else( || vec![props.text.clone()], |width| wrap_text(&props.text, width, props.font_size), ); let color = paint(Some(&props.color), &shape.id, "fill", gradient_defs); writeln!(output, " ", escape_xml(font), number(props.font_size)).expect("writing to a String cannot fail"); let mut y = 0.0; for line in &lines { writeln!( output, " {}", number(y), escape_xml(line) ) .expect("writing to a String cannot fail"); y += props.font_size * 1.2; } output.push_str(" \n"); Ok(()) } fn render_markdown( &mut self, shape: &ShapeRecord, transform: &str, fill_opacity: &str, stroke_opacity: &str, output: &mut String, gradient_defs: &mut String, ) -> Result<(), SvgRenderError> { let props: MarkdownProps = properties(shape)?; let height = props.height.unwrap_or(props.font_size * 10.0); let font = self.font(shape, &props.font_family).to_owned(); writeln!(output, " ").expect("writing to a String cannot fail"); let background = props .background .clone() .unwrap_or_else(|| PaintValue::Solid("#ffffff".into())); writeln!(output, " ", number(props.width), number(height), paint(Some(&background), &shape.id, "background", gradient_defs), paint(props.border.as_ref(), &shape.id, "border", gradient_defs)).expect("writing to a String cannot fail"); let mut y = 8.0; let line_height = props.font_size * 1.4; for line in markdown_lines(&props.markdown, props.font_size) { let plain_text = strip_markdown(&line.text); let wrapped_lines = wrap_text(&plain_text, props.width - 16.0, line.font_size); let keeps_inline_style = wrapped_lines.len() == 1 && !line.code; for wrapped in wrapped_lines { if y + line_height > height - 8.0 { break; } let content = if keeps_inline_style { markdown_inline_svg(&line.text) } else { escape_xml(&wrapped) }; writeln!(output, " {content}", number(y), escape_xml(if line.code { "monospace" } else { &font }), number(line.font_size), if line.bold { "bold" } else { "normal" }, paint(Some(&props.color), &shape.id, "fill", gradient_defs)).expect("writing to a String cannot fail"); y += line.font_size * 1.4; } } output.push_str(" \n"); Ok(()) } fn font<'a>(&mut self, shape: &ShapeRecord, requested: &'a str) -> &'a str { if is_generic_font(requested) || self.options.available_font_families.contains(requested) { requested } else if let Some(asset) = self.document.assets.values().find(|asset| { asset.media_type.starts_with("font/") && asset.name == requested && matches!(asset.source, AssetSource::Embedded { .. }) }) { let AssetSource::Embedded { bytes } = &asset.source else { unreachable!("matched embedded asset") }; self.font_faces .entry(requested.to_owned()) .or_insert_with(|| (asset.media_type.clone(), bytes.clone())); requested } else { self.warnings.insert(SvgRenderWarning::MissingFont { shape_id: shape.id.clone(), requested: requested.into(), fallback: FALLBACK_FONT.into(), }); FALLBACK_FONT } } fn inspect_resources(&mut self, shape: &ShapeRecord) { for key in ["asset_id", "assetId"] { let Some(asset_id) = shape.properties.get(key).and_then(Value::as_str).map(AssetId::from) else { continue; }; match self.document.assets.get(&asset_id) { None => { self.warnings .insert(SvgRenderWarning::MissingAsset { shape_id: shape.id.clone(), asset_id }); } Some(asset) if matches!(asset.source, AssetSource::External { .. }) && !self.options.available_asset_ids.contains(&asset_id) => { self.warnings .insert(SvgRenderWarning::UnresolvedExternalAsset { shape_id: shape.id.clone(), asset_id }); } Some(_) => {} } } } } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct BoxProps { #[serde(alias = "w")] width: f64, #[serde(alias = "h")] height: f64, #[serde(default)] fill: Option, #[serde(default)] stroke: Option, #[serde(default)] radius: f64, } #[derive(Default, Deserialize)] struct ContainerProps { #[serde(default, alias = "w")] width: f64, #[serde(default, alias = "h")] height: f64, #[serde(default)] title: Option, #[serde(default)] fill: Option, #[serde(default)] stroke: Option, #[serde(default)] radius: f64, } #[derive(Deserialize)] struct LineProps { a: Vec2, b: Vec2, stroke: PaintValue, width: f64, } #[derive(Clone, Deserialize)] #[serde(rename_all = "camelCase")] struct ArrowStyle { stroke: PaintValue, width: f64, head_start: Option, head_end: Option, head_start_style: Option, head_end_style: Option, dash: Option>, } #[derive(Deserialize)] struct ArrowLabel { text: String, align: String, offset: f64, #[serde(default)] distance: Option, } #[derive(Deserialize)] struct ArrowProps { style: ArrowStyle, label: Option, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct TextProps { text: String, #[serde(alias = "font_size")] font_size: f64, #[serde(alias = "font_family")] font_family: String, color: PaintValue, #[serde(alias = "w")] width: Option, #[serde(default, alias = "text_path")] text_path: Option, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct TextPathProps { #[serde(alias = "path_id")] path_id: ShapeId, offset: f64, align: String, side: String, direction: String, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct MarkdownProps { #[serde(alias = "md")] markdown: String, #[serde(alias = "w")] width: f64, #[serde(alias = "h")] height: Option, #[serde(alias = "font_size")] font_size: f64, #[serde(alias = "font_family")] font_family: String, color: PaintValue, #[serde(alias = "bg")] background: Option, border: Option, } #[derive(Clone, Copy, Deserialize)] #[serde(rename_all = "camelCase")] struct ImageCrop { top: f64, right: f64, bottom: f64, left: f64, } #[derive(Clone, Deserialize)] #[serde(rename_all = "camelCase")] struct ImageMask { kind: String, radius: Option, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct ImageProps { #[serde(alias = "w")] width: f64, #[serde(alias = "h")] height: f64, #[serde(alias = "asset_id")] asset_id: AssetId, #[serde(default)] crop: Option, #[serde(default)] caption: Option, #[serde(default)] mask: Option, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct ReferenceProps { #[serde(alias = "w")] width: f64, #[serde(alias = "h")] height: f64, #[serde(alias = "reference_type")] reference_type: String, value: String, label: Option, } #[derive(Deserialize)] struct PathProps { subpaths: Vec, fill_rule: PathFillRule, #[serde(default)] fill: Option, #[serde(default)] stroke: Option, #[serde(default, alias = "strokeWidth")] stroke_width: Option, } #[derive(Deserialize)] struct StrokePaintProperties { style: StrokeStyle, } #[derive(Deserialize)] struct StrokeStyle { color: PaintValue, opacity: f64, } struct MarkdownLine { text: String, font_size: f64, bold: bool, code: bool, } /// Renders a materialized snapshot as deterministic SVG. /// /// Layer and shape order come exclusively from the page, layer, and container /// child lists. Hidden layers are omitted; locked layers remain visible, as in /// the interactive renderer. The function performs no filesystem or font-system /// access, so equal snapshots and options produce byte-for-byte equal output. /// /// # Errors /// /// Returns [`SvgRenderError`] for an unknown page, invalid region, or malformed /// built-in shape properties. pub fn render_svg(snapshot: &DocumentSnapshot, options: &SvgRenderOptions) -> Result { validate_region(options.region)?; let Some(page_id) = options.page_id.as_ref().or_else(|| snapshot.document.page_ids.first()) else { return Ok(empty_svg()); }; let page = snapshot .document .pages .get(page_id) .ok_or_else(|| SvgRenderError::PageNotFound { page_id: page_id.clone() })?; let mut renderer = Renderer { document: &snapshot.document, options, warnings: BTreeSet::new(), font_faces: BTreeMap::new(), rendered_bounds: None, body: String::new(), }; for layer_id in &page.layer_ids { if !options.layer_ids.is_empty() && !options.layer_ids.contains(layer_id) { continue; } let Some(layer) = snapshot.document.layers.get(layer_id) else { continue; }; if !layer.visible { continue; } let mut layer_body = String::new(); for shape_id in &layer.shape_ids { renderer.render_shape(shape_id, Affine::IDENTITY, false, &mut layer_body)?; } if !layer_body.is_empty() { writeln!( renderer.body, " ", escape_xml(layer.id.as_str()), number(f64::from(layer.opacity.get())) ) .expect("writing to a String cannot fail"); renderer.body.push_str(&layer_body); renderer.body.push_str(" \n"); } } let view_box = options.region.or(renderer.rendered_bounds).unwrap_or(Bounds { x: 0.0, y: 0.0, width: EMPTY_SIZE, height: EMPTY_SIZE, }); let view_box = if options.region.is_some() { view_box } else { padded(view_box, DEFAULT_PADDING) }; let width = view_box.width.max(1.0); let height = view_box.height.max(1.0); let clip = options.region.map(|region| { format!( " \n", number(region.x), number(region.y), number(region.width), number(region.height) ) }); let mut svg = format!( "\n", number(view_box.x), number(view_box.y), number(width), number(height), number(width), number(height) ); if !renderer.font_faces.is_empty() { svg.push_str(" \n"); } if let Some(clip) = clip { svg.push_str(&clip); svg.push_str(" \n"); svg.push_str(&indent(&renderer.body, 2)); svg.push_str(" \n"); } else { svg.push_str(&renderer.body); } svg.push_str("\n"); Ok(SvgRenderOutput { svg, warnings: renderer.warnings.into_iter().collect() }) } fn empty_svg() -> SvgRenderOutput { SvgRenderOutput { svg: "\n\n" .into(), warnings: Vec::new(), } } fn properties Deserialize<'de>>(shape: &ShapeRecord) -> Result { serde_json::from_value(Value::Object(shape.properties.clone().into_iter().collect())).map_err(|error| { SvgRenderError::InvalidShapeProperties { shape_id: shape.id.clone(), kind: shape.kind.to_string(), message: error.to_string(), } }) } fn arrow_bounds(document: &Document, shape: &ShapeRecord) -> Result { let geometry = resolve_arrow_geometry_for_shape(document, shape).map_err(|error| SvgRenderError::InvalidShapeProperties { shape_id: shape.id.clone(), kind: shape.kind.to_string(), message: error.to_string(), })?; let props: ArrowProps = properties(shape)?; let mut bounds = path_bounds(&geometry.path); let mut include_head = |at_start: bool| { if let Some(head) = arrow_head_geometry(&geometry.path, at_start) { bounds = union(bounds, bounds_from_points(&[head.tip, head.left, head.right])); } }; if props.style.head_end.unwrap_or(true) { include_head(false); } if props.style.head_start.unwrap_or(false) { include_head(true); } if let Some(label) = props.label.filter(|label| !label.text.is_empty()) && let Some((point, _distance)) = arrow_label_position(&geometry.path, &label) { let half_width = deterministic_text_width(&label.text, 14.0) / 2.0 + 4.0; bounds = union( bounds, bounds_from_points(&[ Vec2 { x: point.x - half_width, y: point.y - 9.0 }, Vec2 { x: point.x + half_width, y: point.y - 9.0 }, Vec2 { x: point.x - half_width, y: point.y + 9.0 }, Vec2 { x: point.x + half_width, y: point.y + 9.0 }, ]), ); } Ok(bounds) } fn shape_local_bounds(document: &Document, shape: &ShapeRecord) -> Result { let bounds = match BuiltinShapeKind::parse(shape.kind.as_str()) { Some(BuiltinShapeKind::Rectangle | BuiltinShapeKind::Ellipse) => { let props: BoxProps = properties(shape)?; Bounds { x: 0.0, y: 0.0, width: props.width, height: props.height } } Some(BuiltinShapeKind::Container) => { let props: ContainerProps = properties(shape)?; Bounds { x: 0.0, y: 0.0, width: props.width, height: props.height } } Some(BuiltinShapeKind::Line) => { let props: LineProps = properties(shape)?; bounds_from_points(&[props.a, props.b]) } Some(BuiltinShapeKind::Arrow) => arrow_bounds(document, shape)?, Some(BuiltinShapeKind::Text) => { let props: TextProps = properties(shape)?; let width = props .width .unwrap_or_else(|| deterministic_text_width(&props.text, props.font_size)); Bounds { x: 0.0, y: 0.0, width, height: props.font_size * 1.2 } } Some(BuiltinShapeKind::Markdown) => { let props: MarkdownProps = properties(shape)?; Bounds { x: 0.0, y: 0.0, width: props.width, height: props.height.unwrap_or(props.font_size * 10.0) } } Some(BuiltinShapeKind::Image) => { let props: ImageProps = properties(shape)?; Bounds { x: 0.0, y: 0.0, width: props.width, height: props.height } } Some(BuiltinShapeKind::Reference) => { let props: ReferenceProps = properties(shape)?; Bounds { x: 0.0, y: 0.0, width: props.width, height: props.height } } Some(BuiltinShapeKind::Stroke) => canonical_stroke_outline(&shape.properties).map_or_else( |message| { Err(SvgRenderError::InvalidShapeProperties { shape_id: shape.id.clone(), kind: shape.kind.to_string(), message, }) }, |outline| Ok(bounds_from_points(&outline)), )?, Some(BuiltinShapeKind::Path) => { let geometry = crate::path_geometry_from_properties(&shape.properties).map_err(|error| { SvgRenderError::InvalidShapeProperties { shape_id: shape.id.clone(), kind: shape.kind.to_string(), message: error.to_string(), } })?; crate::validate_path_geometry(&geometry).map_err(|error| SvgRenderError::InvalidShapeProperties { shape_id: shape.id.clone(), kind: shape.kind.to_string(), message: error.to_string(), })?; crate::engine::geometry::path_bounds(&geometry) } None => Bounds { x: 0.0, y: 0.0, width: 0.0, height: 0.0 }, }; Ok(bounds) } fn apply_shape_effects( shape: &ShapeRecord, matrix: Affine, output: &mut String, defs: &mut String, ) -> Result<(), String> { let clip = shape .properties .get("clip_path") .map(|value| serde_json::from_value::(value.clone())) .transpose() .map_err(|error| format!("clip_path could not be decoded: {error}"))?; let mask = shape .properties .get("mask_effect") .map(|value| serde_json::from_value::(value.clone())) .transpose() .map_err(|error| format!("mask_effect could not be decoded: {error}"))?; let filter = shape .properties .get("filter") .map(|value| serde_json::from_value::(value.clone())) .transpose() .map_err(|error| format!("filter could not be decoded: {error}"))?; let safe_id = shape .id .as_str() .replace(|character: char| !character.is_ascii_alphanumeric(), "-"); let mut attributes = Vec::new(); if let Some(geometry) = clip { let id = format!("inkfinite-clip-{safe_id}"); writeln!(defs, "", path_data(&geometry), affine_svg(matrix), path_fill_rule(geometry.fill_rule)).map_err(|error| error.to_string())?; attributes.push(format!("clip-path=\"url(#{id})\"")); } if let Some(mask) = mask { crate::validate_path_geometry(&mask.geometry).map_err(|error| error.to_string())?; let id = format!("inkfinite-mask-{safe_id}"); let mode = match mask.mode { MaskMode::Alpha => "alpha", MaskMode::Luminance => "luminance", }; writeln!(defs, "", path_data(&mask.geometry), affine_svg(matrix), number(mask.opacity), path_fill_rule(mask.geometry.fill_rule)).map_err(|error| error.to_string())?; attributes.push(format!("mask=\"url(#{id})\"")); } if let Some(filter) = filter { let id = format!("inkfinite-filter-{safe_id}"); write_filter(defs, &id, &filter)?; attributes.push(format!("filter=\"url(#{id})\"")); } if !attributes.is_empty() { *output = format!("{output}", attributes.join(" ")); } Ok(()) } fn write_filter(defs: &mut String, id: &str, filter: &FilterEffect) -> Result<(), String> { write!( defs, "" ) .map_err(|error| error.to_string())?; for (index, primitive) in filter.primitives.iter().enumerate() { let input = if index == 0 { "SourceGraphic".to_owned() } else { format!("inkfinite-filter-{id}-{index}") }; let result = format!("inkfinite-filter-{id}-{}", index + 1); match primitive { FilterPrimitive::Blur { radius } => write!(defs, "", number(*radius)), FilterPrimitive::DropShadow { dx, dy, radius, color, opacity } => write!(defs, "", number(*dx), number(*dy), number(*radius), escape_xml(color), number(*opacity)), FilterPrimitive::Saturate { amount } => write!(defs, "", number(*amount)), FilterPrimitive::HueRotate { degrees } => write!(defs, "", number(*degrees)), FilterPrimitive::Grayscale { amount } => write!(defs, "", number(1.0 - amount)), FilterPrimitive::Invert { amount } => write!(defs, "", number(*amount), number(1.0 - amount), number(*amount), number(1.0 - amount), number(*amount), number(1.0 - amount)), FilterPrimitive::Brightness { amount } => write!(defs, "", number(*amount), number(*amount), number(*amount)), FilterPrimitive::Contrast { amount } => { let intercept = 0.5 - 0.5 * amount; write!(defs, "", number(*amount), number(intercept), number(*amount), number(intercept), number(*amount), number(intercept)) }, FilterPrimitive::Sepia { amount } => write!(defs, "", number(0.393 + 0.607 * (1.0 - amount)), number(0.769 - 0.769 * (1.0 - amount)), number(0.189 - 0.189 * (1.0 - amount)), number(0.349 - 0.349 * (1.0 - amount)), number(0.686 + 0.314 * (1.0 - amount)), number(0.168 - 0.168 * (1.0 - amount)), number(0.272 - 0.272 * (1.0 - amount)), number(0.534 - 0.534 * (1.0 - amount)), number(0.131 + 0.869 * (1.0 - amount))), FilterPrimitive::Opacity { amount } => write!(defs, "", number(*amount)), }.map_err(|error| error.to_string())?; } writeln!(defs, "").map_err(|error| error.to_string()) } fn image_mask_path(mask: &ImageMask, width: f64, height: f64) -> String { match mask.kind.as_str() { "ellipse" => format!( "", number(width / 2.0), number(height / 2.0), number(width / 2.0), number(height / 2.0) ), "rounded" => format!( "", number(width), number(height), number(mask.radius.unwrap_or(16.0).min(width / 2.0).min(height / 2.0).max(0.0)) ), _ => format!("", number(width), number(height)), } } fn reverse_path_geometry(geometry: &PathGeometry) -> PathGeometry { let subpaths = geometry .subpaths .iter() .map(|subpath| { let first = subpath.segments.first(); if !matches!(first, Some(PathSegment::Move { .. })) || subpath.segments.len() < 2 { return subpath.clone(); } let points: Vec = subpath .segments .iter() .map(|segment| match segment { PathSegment::Move { to } | PathSegment::Line { to } | PathSegment::Quadratic { to, .. } | PathSegment::Cubic { to, .. } => *to, }) .collect(); let mut segments = vec![PathSegment::Move { to: *points.last().unwrap_or(&Vec2 { x: 0.0, y: 0.0 }) }]; for index in (1..subpath.segments.len()).rev() { let segment = &subpath.segments[index]; let to = points[index - 1]; match segment { PathSegment::Move { .. } => {} PathSegment::Line { .. } => segments.push(PathSegment::Line { to }), PathSegment::Quadratic { control, .. } => { segments.push(PathSegment::Quadratic { control: *control, to }) } PathSegment::Cubic { control_1, control_2, .. } => { segments.push(PathSegment::Cubic { control_1: *control_2, control_2: *control_1, to }) } } } PathSubpath { segments, closed: subpath.closed, handle_modes: None } }) .collect(); PathGeometry { subpaths, fill_rule: geometry.fill_rule } } fn path_data(geometry: &PathGeometry) -> String { let mut output = String::new(); for subpath in &geometry.subpaths { for segment in &subpath.segments { match segment { PathSegment::Move { to } => write!(output, "M {} {} ", number(to.x), number(to.y)), PathSegment::Line { to } => write!(output, "L {} {} ", number(to.x), number(to.y)), PathSegment::Quadratic { control, to } => write!( output, "Q {} {} {} {} ", number(control.x), number(control.y), number(to.x), number(to.y) ), PathSegment::Cubic { control_1, control_2, to } => write!( output, "C {} {} {} {} {} {} ", number(control_1.x), number(control_1.y), number(control_2.x), number(control_2.y), number(to.x), number(to.y) ), } .expect("writing to a String cannot fail"); } if subpath.closed { output.push_str("Z "); } } output.trim_end().to_owned() } fn path_fill_rule(rule: PathFillRule) -> &'static str { match rule { PathFillRule::NonZero => "nonzero", PathFillRule::EvenOdd => "evenodd", } } fn contains_selected_descendant(document: &Document, shape: &ShapeRecord, selection: &BTreeSet) -> bool { shape.child_ids.iter().any(|id| { selection.contains(id) || document .shapes .get(id) .is_some_and(|child| contains_selected_descendant(document, child, selection)) }) } struct SvgPaintContext<'a> { defs: &'a mut String, shape_id: &'a ShapeId, } #[derive(Clone, Copy)] struct ArrowHeadGeometry { tip: Vec2, left: Vec2, right: Vec2, } fn arrow_head_geometry(geometry: &PathGeometry, at_start: bool) -> Option { let length = path_length(geometry, DEFAULT_PATH_METRIC_TOLERANCE); if length <= f64::EPSILON { return None; } let location = point_at_distance( geometry, if at_start { 0.0 } else { length }, DEFAULT_PATH_METRIC_TOLERANCE, )?; if !nonzero(location.tangent) { return None; } let direction = if at_start { Vec2 { x: -location.tangent.x, y: -location.tangent.y } } else { location.tangent }; let left_direction = rotate(direction, -std::f64::consts::PI / 6.0); let right_direction = rotate(direction, std::f64::consts::PI / 6.0); Some(ArrowHeadGeometry { tip: location.point, left: subtract(location.point, scale(left_direction, 15.0)), right: subtract(location.point, scale(right_direction, 15.0)), }) } fn arrow_shaft_geometry<'a>(geometry: &'a PathGeometry, style: &ArrowStyle) -> std::borrow::Cow<'a, PathGeometry> { let start_trim = if style.head_start.unwrap_or(false) && style.head_start_style.as_deref() == Some("triangle") { 15.0 } else { 0.0 }; let end_trim = if style.head_end.unwrap_or(true) && style.head_end_style.as_deref() == Some("triangle") { 15.0 } else { 0.0 }; let length = path_length(geometry, DEFAULT_PATH_METRIC_TOLERANCE); if start_trim == 0.0 && end_trim == 0.0 || length <= f64::EPSILON { return std::borrow::Cow::Borrowed(geometry); } trim_path( geometry, start_trim, (length - end_trim).max(start_trim), DEFAULT_PATH_METRIC_TOLERANCE, ) .map_or_else(|| std::borrow::Cow::Borrowed(geometry), std::borrow::Cow::Owned) } fn arrow_label_position(geometry: &PathGeometry, label: &ArrowLabel) -> Option<(Vec2, f64)> { let length = path_length(geometry, DEFAULT_PATH_METRIC_TOLERANCE); if length <= f64::EPSILON { return None; } let distance = label.distance.unwrap_or_else(|| match label.align.as_str() { "start" => 0.0, "end" => length, _ => length / 2.0, }); let distance = if distance.is_finite() { distance.clamp(0.0, length) } else if distance.is_sign_negative() { 0.0 } else { length }; let location = point_at_distance(geometry, distance, DEFAULT_PATH_METRIC_TOLERANCE)?; let normal = Vec2 { x: -location.tangent.y, y: location.tangent.x }; Some((add(location.point, scale(normal, label.offset)), location.distance)) } fn point_at_distance( geometry: &PathGeometry, distance: f64, tolerance: f64, ) -> Option { crate::path_metrics::point_at_distance(geometry, distance, tolerance) } fn arrow_head( output: &mut String, transform: &str, head: &ArrowHeadGeometry, at_start: bool, style: &ArrowStyle, opacity: &str, paint_context: &mut SvgPaintContext<'_>, ) { let filled = (at_start && style.head_start_style.as_deref() == Some("triangle")) || (!at_start && style.head_end_style.as_deref() == Some("triangle")); let triangle = if filled { format!( "M {} {} L {} {} L {} {} Z", number(head.tip.x), number(head.tip.y), number(head.left.x), number(head.left.y), number(head.right.x), number(head.right.y) ) } else { format!( "M {} {} L {} {} M {} {} L {} {}", number(head.tip.x), number(head.tip.y), number(head.left.x), number(head.left.y), number(head.tip.x), number(head.tip.y), number(head.right.x), number(head.right.y) ) }; let filled = triangle.ends_with(" Z"); let stroke = paint( Some(&style.stroke), paint_context.shape_id, "stroke", paint_context.defs, ); writeln!( output, " ", if filled { stroke.as_str() } else { "none" }, number(style.width) ) .expect("writing to a String cannot fail"); } fn add(left: Vec2, right: Vec2) -> Vec2 { Vec2 { x: left.x + right.x, y: left.y + right.y } } fn subtract(left: Vec2, right: Vec2) -> Vec2 { Vec2 { x: left.x - right.x, y: left.y - right.y } } fn scale(point: Vec2, factor: f64) -> Vec2 { Vec2 { x: point.x * factor, y: point.y * factor } } fn rotate(point: Vec2, angle: f64) -> Vec2 { let (sine, cosine) = angle.sin_cos(); Vec2 { x: point.x * cosine - point.y * sine, y: point.x * sine + point.y * cosine } } fn nonzero(value: Vec2) -> bool { value.x.abs() > f64::EPSILON || value.y.abs() > f64::EPSILON } fn markdown_lines(source: &str, base_size: f64) -> Vec { let mut result = Vec::new(); let mut code = false; for source_line in source.lines() { if source_line.starts_with("```") { code = !code; continue; } if code { result.push(MarkdownLine { text: source_line.into(), font_size: base_size, bold: false, code: true }); continue; } let hashes = source_line.bytes().take_while(|byte| *byte == b'#').count(); let (text, font_size, bold) = if (1..=6).contains(&hashes) && source_line.as_bytes().get(hashes) == Some(&b' ') { let scale = match hashes { 1 => 1.85, 2 => 1.7, 3 => 1.55, 4 => 1.4, 5 => 1.25, 6 => 1.1, _ => 1.0, }; (source_line[hashes + 1..].to_owned(), base_size * scale, true) } else if let Some(text) = source_line .strip_prefix("- ") .or_else(|| source_line.strip_prefix("* ")) .or_else(|| source_line.strip_prefix("+ ")) { (format!("• {text}"), base_size, false) } else { (source_line.to_owned(), base_size, false) }; result.push(MarkdownLine { text, font_size, bold, code: false }); } result } fn strip_markdown(text: &str) -> String { text.replace("***", "") .replace("___", "") .replace("**", "") .replace("__", "") .replace(['*', '_', '`'], "") } fn markdown_inline_svg(text: &str) -> String { let mut output = String::new(); let mut rest = text; let mut bold = false; let mut italic = false; let mut code = false; while !rest.is_empty() { let marker = ["**", "__", "`", "*", "_"] .into_iter() .filter_map(|candidate| rest.find(candidate).map(|index| (index, candidate))) .min_by_key(|(index, marker)| (*index, std::cmp::Reverse(marker.len()))); let Some((index, marker)) = marker else { push_markdown_span(&mut output, rest, bold, italic, code); break; }; push_markdown_span(&mut output, &rest[..index], bold, italic, code); match marker { "**" | "__" => bold = !bold, "*" | "_" => italic = !italic, "`" => code = !code, _ => {} } rest = &rest[index + marker.len()..]; } output } fn push_markdown_span(output: &mut String, text: &str, bold: bool, italic: bool, code: bool) { if text.is_empty() { return; } let weight = if bold { " font-weight=\"bold\"" } else { "" }; let style = if italic { " font-style=\"italic\"" } else { "" }; let family = if code { " font-family=\"monospace\"" } else { "" }; write!(output, "{}", escape_xml(text)) .expect("writing to a String cannot fail"); } fn wrap_text(text: &str, max_width: f64, font_size: f64) -> Vec { let mut lines = Vec::new(); for source_line in text.split('\n') { let mut current = String::new(); for word in source_line.split(' ') { let candidate = if current.is_empty() { word.into() } else { format!("{current} {word}") }; if deterministic_text_width(&candidate, font_size) > max_width && !current.is_empty() { lines.push(current); current = word.into(); } else { current = candidate; } } lines.push(current); } lines } fn deterministic_text_width(text: &str, font_size: f64) -> f64 { text.chars() .map(|character| { if character.is_ascii_whitespace() { 0.33 } else if "ilI.,'`|!".contains(character) { 0.3 } else if "mwMW@#%&".contains(character) { 0.9 } else { 0.6 } }) .sum::() * font_size } fn is_generic_font(font: &str) -> bool { matches!( font.to_ascii_lowercase().as_str(), "sans-serif" | "serif" | "monospace" | "cursive" | "fantasy" | "system-ui" ) } fn validate_region(region: Option) -> Result<(), SvgRenderError> { if region.is_some_and(|bounds| { !bounds.x.is_finite() || !bounds.y.is_finite() || !bounds.width.is_finite() || !bounds.height.is_finite() || bounds.width < 0.0 || bounds.height < 0.0 }) { Err(SvgRenderError::InvalidRegion) } else { Ok(()) } } fn padded(bounds: Bounds, padding: f64) -> Bounds { Bounds { x: bounds.x - padding, y: bounds.y - padding, width: bounds.width + padding * 2.0, height: bounds.height + padding * 2.0, } } fn affine_svg(matrix: Affine) -> String { format!( "matrix({} {} {} {} {} {})", number(matrix.a), number(matrix.b), number(matrix.c), number(matrix.d), number(matrix.e), number(matrix.f) ) } fn paint(value: Option<&PaintValue>, shape_id: &ShapeId, property: &str, defs: &mut String) -> String { let Some(value) = value else { return "none".into() }; match value { PaintValue::Solid(color) => { if color.is_empty() { "none".into() } else { escape_xml(color) } } PaintValue::Native(Paint::Solid { color }) => { if color.is_empty() { "none".into() } else { escape_xml(color) } } PaintValue::Native(Paint::LinearGradient { x1, y1, x2, y2, units, transform, spread, stops }) => { let id = gradient_id(shape_id, property); write_gradient( defs, &id, &Paint::LinearGradient { x1: *x1, y1: *y1, x2: *x2, y2: *y2, units: *units, transform: *transform, spread: *spread, stops: stops.clone(), }, ); format!("url(#{})", escape_xml(&id)) } PaintValue::Native(Paint::RadialGradient { cx, cy, r, fx, fy, units, transform, spread, stops }) => { let id = gradient_id(shape_id, property); write_gradient( defs, &id, &Paint::RadialGradient { cx: *cx, cy: *cy, r: *r, fx: *fx, fy: *fy, units: *units, transform: *transform, spread: *spread, stops: stops.clone(), }, ); format!("url(#{})", escape_xml(&id)) } } } fn gradient_id(shape_id: &ShapeId, property: &str) -> String { format!( "inkfinite-gradient-{}-{property}", shape_id .as_str() .replace(|character: char| !character.is_ascii_alphanumeric(), "-") ) } fn write_gradient(defs: &mut String, id: &str, paint: &Paint) { let (tag, attributes, stops) = match paint { Paint::LinearGradient { x1, y1, x2, y2, stops, .. } => ( "linearGradient", format!( "x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\"", number(*x1), number(*y1), number(*x2), number(*y2) ), stops, ), Paint::RadialGradient { cx, cy, r, fx, fy, stops, .. } => ( "radialGradient", format!( "cx=\"{}\" cy=\"{}\" r=\"{}\" fx=\"{}\" fy=\"{}\"", number(*cx), number(*cy), number(*r), number(*fx), number(*fy) ), stops, ), Paint::Solid { .. } => return, }; let (units, transform, spread) = match paint { Paint::LinearGradient { units, transform, spread, .. } | Paint::RadialGradient { units, transform, spread, .. } => (*units, *transform, *spread), Paint::Solid { .. } => unreachable!("solid paints do not create gradient definitions"), }; write!( defs, "<{tag} id=\"{}\" {attributes} gradientUnits=\"{}\" gradientTransform=\"matrix({} {} {} {} {} {})\" spreadMethod=\"{}\">", escape_xml(id), gradient_units(units), number(transform.a), number(transform.b), number(transform.c), number(transform.d), number(transform.e), number(transform.f), gradient_spread(spread) ) .expect("writing to a String cannot fail"); write_stops(defs, stops); writeln!(defs, "").expect("writing to a String cannot fail"); } fn write_stops(defs: &mut String, stops: &[crate::GradientStop]) { for stop in stops { write!( defs, "", number(stop.offset), escape_xml(&stop.color), number(stop.opacity) ) .expect("writing to a String cannot fail"); } } fn gradient_units(units: GradientUnits) -> &'static str { match units { GradientUnits::ObjectBoundingBox => "objectBoundingBox", GradientUnits::UserSpaceOnUse => "userSpaceOnUse", } } fn gradient_spread(spread: GradientSpread) -> &'static str { match spread { GradientSpread::Pad => "pad", GradientSpread::Reflect => "reflect", GradientSpread::Repeat => "repeat", } } fn number(value: f64) -> String { if value == 0.0 { return "0".into(); } let formatted = format!("{value:.6}"); formatted.trim_end_matches('0').trim_end_matches('.').to_owned() } fn svg_metadata_attributes(metadata: &crate::SemanticMetadata) -> String { let mut attributes = String::new(); for (name, value) in [ ("data-name", metadata.name.as_deref()), ("data-card-title", metadata.title.as_deref()), ("data-card-body", metadata.body.as_deref()), ("data-role", metadata.role.as_deref()), ("data-description", metadata.description.as_deref()), ("data-source", metadata.source.as_deref()), ("data-link", metadata.link.as_deref()), ] { if let Some(value) = value.filter(|value| !value.is_empty()) { write!(attributes, " {name}=\"{}\"", escape_xml(value)).expect("writing to a String cannot fail"); } } if !metadata.tags.is_empty() { write!(attributes, " data-tags=\"{}\"", escape_xml(&metadata.tags.join(","))) .expect("writing to a String cannot fail"); } if !metadata.custom_metadata.is_empty() && let Ok(value) = serde_json::to_string(&metadata.custom_metadata) { write!(attributes, " data-metadata=\"{}\"", escape_xml(&value)).expect("writing to a String cannot fail"); } attributes } fn escape_xml(value: &str) -> String { value .replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) .replace('\'', "'") } fn escape_css_string(value: &str) -> String { value.replace('\\', "\\\\").replace('\'', "\\'") } fn font_format(media_type: &str) -> &'static str { match media_type { "font/woff" => "woff", "font/ttf" | "application/x-font-ttf" => "truetype", "font/otf" | "application/x-font-opentype" => "opentype", _ => "woff2", } } fn base64(bytes: &[u8]) -> String { const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; let mut encoded = String::with_capacity(bytes.len().div_ceil(3) * 4); for chunk in bytes.chunks(3) { let first = chunk[0]; let second = chunk.get(1).copied().unwrap_or(0); let third = chunk.get(2).copied().unwrap_or(0); encoded.push(char::from(ALPHABET[usize::from(first >> 2)])); encoded.push(char::from(ALPHABET[usize::from(((first & 0x03) << 4) | (second >> 4))])); encoded.push(if chunk.len() > 1 { char::from(ALPHABET[usize::from(((second & 0x0f) << 2) | (third >> 6))]) } else { '=' }); encoded.push(if chunk.len() > 2 { char::from(ALPHABET[usize::from(third & 0x3f)]) } else { '=' }); } encoded } fn indent(value: &str, spaces: usize) -> String { let prefix = " ".repeat(spaces); value.lines().fold(String::new(), |mut output, line| { writeln!(output, "{prefix}{line}").expect("writing to a String cannot fail"); output }) } #[cfg(test)] mod tests;