Something went wrong. Try again.
inkfinite (an infinite canvas/whiteboard) monorepo ink.stormlightlabs.org
svelte rust tauri svg canvas perfect-freehand
Something went wrong. Try again.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808//! 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<PageId>, /// Layers to include. An empty set includes every visible layer on the page. pub layer_ids: BTreeSet<LayerId>, /// Shapes to include. Selecting a container includes its descendants. pub selection: BTreeSet<ShapeId>, /// Region to include and use as the exact SVG view box. pub region: Option<Bounds>, /// Font families supplied by the caller in addition to SVG generic families. pub available_font_families: BTreeSet<String>, /// External assets that the caller has resolved and made available. pub available_asset_ids: BTreeSet<AssetId>,}
/// 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<SvgRenderWarning>,}
/// 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<SvgRenderWarning>, font_faces: BTreeMap<String, (String, Vec<u8>)>, rendered_bounds: Option<Bounds>, 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!(" <defs>{effect_defs}</defs>\n{inner}"); } } if inner.is_empty() { return Ok(()); } let metadata_attributes = svg_metadata_attributes(&shape.metadata); writeln!( output, " <g data-shape-id=\"{}\"{} opacity=\"{}\">", 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(" </g>\n"); Ok(()) }
fn shape_matrix(&self, shape_id: &ShapeId) -> Option<Affine> { 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<Bounds> { 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<String, SvgRenderError> { 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, " <rect transform=\"{transform}\" width=\"{}\" height=\"{}\" rx=\"{}\" fill=\"{}\" fill-opacity=\"{fill_opacity}\" stroke=\"{}\" stroke-opacity=\"{stroke_opacity}\" stroke-width=\"2\"/>", 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, " <rect transform=\"{transform}\" width=\"{}\" height=\"{}\" rx=\"{}\" fill=\"{}\" fill-opacity=\"{fill_opacity}\" stroke=\"{}\" stroke-opacity=\"{stroke_opacity}\" stroke-width=\"2\"/>", 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, " <text transform=\"{transform}\" x=\"8\" y=\"18\" font-family=\"sans-serif\" font-size=\"14\" font-weight=\"600\" fill=\"#1f2937\">{}</text>", escape_xml(&title) ).expect("writing to a String cannot fail"); } } Some(BuiltinShapeKind::Ellipse) => { let props: BoxProps = properties(shape)?; writeln!( output, " <ellipse transform=\"{transform}\" cx=\"{}\" cy=\"{}\" rx=\"{}\" ry=\"{}\" fill=\"{}\" fill-opacity=\"{fill_opacity}\" stroke=\"{}\" stroke-opacity=\"{stroke_opacity}\" stroke-width=\"2\"/>", 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, " <line transform=\"{transform}\" x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" fill=\"none\" stroke=\"{}\" stroke-opacity=\"{stroke_opacity}\" stroke-width=\"{}\"/>", 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, " <defs><clipPath id=\"{mask_id}\">{}</clipPath></defs>", 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!( "<svg x=\"0\" y=\"0\" width=\"{}\" height=\"{}\" viewBox=\"{} {} {} {}\" preserveAspectRatio=\"none\"{clip}><image width=\"1\" height=\"1\" href=\"data:{};base64,{}\"/></svg>", 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!( "<image width=\"{}\" height=\"{}\" href=\"data:{};base64,{}\" preserveAspectRatio=\"none\"{clip}/>", number(props.width), number(props.height), escape_xml(&asset.media_type), base64(bytes) ) }; writeln!( output, " <g transform=\"{transform}\" opacity=\"{fill_opacity}\">{image}</g>" ) .expect("writing to a String cannot fail"); if let Some(caption) = props.caption.filter(|caption| !caption.is_empty()) { writeln!( output, " <text transform=\"{transform}\" x=\"8\" y=\"{}\" font-family=\"sans-serif\" font-size=\"12\" fill=\"#ffffff\" stroke=\"#000000\" stroke-opacity=\"0.35\">{}</text>", 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, " <rect transform=\"{transform}\" width=\"{}\" height=\"{}\" rx=\"8\" fill=\"#f8fafc\" stroke=\"{accent}\" stroke-width=\"2\"/><text transform=\"{transform}\" x=\"12\" y=\"20\" font-family=\"sans-serif\" font-size=\"12\" font-weight=\"600\" fill=\"{accent}\">{}</text><text transform=\"{transform}\" x=\"12\" y=\"40\" font-family=\"sans-serif\" font-size=\"13\" fill=\"#1f2937\">{}</text>", 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::<Vec<_>>() .join(" "); let opacity = shape .style .stroke_opacity .map_or(props.style.opacity, |value| f64::from(value.get())); writeln!( output, " <path transform=\"{transform}\" d=\"{path} Z\" fill=\"{}\" fill-opacity=\"{}\" stroke=\"none\"/>", 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, " <path id=\"inkfinite-path-{}\" transform=\"{transform}\" d=\"{}\" fill=\"{}\" fill-rule=\"{}\" fill-opacity=\"{fill_opacity}\" stroke=\"{}\" stroke-opacity=\"{stroke_opacity}\" stroke-width=\"{}\"/>", 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!(" <defs>{gradient_defs} </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::<Vec<_>>().join(" ") ) }) .unwrap_or_default(); let stroke = paint(Some(&props.style.stroke), &shape.id, "stroke", gradient_defs); writeln!(output, " <path transform=\"{transform}\" d=\"{}\" fill=\"none\" stroke=\"{stroke}\" stroke-opacity=\"{stroke_opacity}\" stroke-width=\"{}\"{dash}/>", 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, " <g transform=\"{transform}\" fill-opacity=\"{fill_opacity}\"><rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"18\" fill=\"#ffffff\" fill-opacity=\"0.9\" stroke=\"#cccccc\"/><text x=\"{}\" y=\"{}\" text-anchor=\"middle\" dominant-baseline=\"middle\" font-family=\"sans-serif\" font-size=\"14\" fill=\"#000000\">{}</text></g>", 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, " <defs><path id=\"{path_id}\" d=\"{}\" transform=\"{}\" fill=\"none\" stroke=\"none\"/></defs>", 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, " <text font-family=\"{}\" font-size=\"{}\" fill=\"{color}\" fill-opacity=\"{fill_opacity}\"><textPath href=\"#{path_id}\" startOffset=\"{}\" text-anchor=\"{anchor}\" side=\"{}\" direction=\"{}\">{}</textPath></text>", 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, " <text transform=\"{transform}\" font-family=\"{}\" font-size=\"{}\" fill=\"{color}\" fill-opacity=\"{fill_opacity}\" dominant-baseline=\"text-before-edge\">", 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, " <tspan x=\"0\" y=\"{}\">{}</tspan>", number(y), escape_xml(line) ) .expect("writing to a String cannot fail"); y += props.font_size * 1.2; } output.push_str(" </text>\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, " <g transform=\"{transform}\">").expect("writing to a String cannot fail"); let background = props .background .clone() .unwrap_or_else(|| PaintValue::Solid("#ffffff".into())); writeln!(output, " <rect width=\"{}\" height=\"{}\" fill=\"{}\" fill-opacity=\"{fill_opacity}\" stroke=\"{}\" stroke-opacity=\"{stroke_opacity}\"/>", 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, " <text x=\"8\" y=\"{}\" font-family=\"{}\" font-size=\"{}\" font-weight=\"{}\" fill=\"{}\" fill-opacity=\"{fill_opacity}\" dominant-baseline=\"text-before-edge\">{content}</text>", 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(" </g>\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<PaintValue>, #[serde(default)] stroke: Option<PaintValue>, #[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<String>, #[serde(default)] fill: Option<PaintValue>, #[serde(default)] stroke: Option<PaintValue>, #[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<bool>, head_end: Option<bool>, head_start_style: Option<String>, head_end_style: Option<String>, dash: Option<Vec<f64>>,}
#[derive(Deserialize)]struct ArrowLabel { text: String, align: String, offset: f64, #[serde(default)] distance: Option<f64>,}
#[derive(Deserialize)]struct ArrowProps { style: ArrowStyle, label: Option<ArrowLabel>,}
#[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<f64>, #[serde(default, alias = "text_path")] text_path: Option<TextPathProps>,}
#[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<f64>, #[serde(alias = "font_size")] font_size: f64, #[serde(alias = "font_family")] font_family: String, color: PaintValue, #[serde(alias = "bg")] background: Option<PaintValue>, border: Option<PaintValue>,}
#[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<f64>,}
#[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<ImageCrop>, #[serde(default)] caption: Option<String>, #[serde(default)] mask: Option<ImageMask>,}
#[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<String>,}
#[derive(Deserialize)]struct PathProps { subpaths: Vec<PathSubpath>, fill_rule: PathFillRule, #[serde(default)] fill: Option<PaintValue>, #[serde(default)] stroke: Option<PaintValue>, #[serde(default, alias = "strokeWidth")] stroke_width: Option<f64>,}
#[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<SvgRenderOutput, SvgRenderError> { 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, " <g data-layer-id=\"{}\" opacity=\"{}\">", 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(" </g>\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!( " <defs><clipPath id=\"inkfinite-region\"><rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\"/></clipPath></defs>\n", number(region.x), number(region.y), number(region.width), number(region.height) ) });
let mut svg = format!( "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"{} {} {} {}\" width=\"{}\" height=\"{}\">\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(" <defs><style>\n"); for (family, (media_type, bytes)) in &renderer.font_faces { writeln!( svg, " @font-face {{ font-family: '{}'; src: url(data:{};base64,{}) format('{}'); }}", escape_css_string(family), escape_xml(media_type), base64(bytes), font_format(media_type) ) .expect("writing to a String cannot fail"); } svg.push_str(" </style></defs>\n"); } if let Some(clip) = clip { svg.push_str(&clip); svg.push_str(" <g clip-path=\"url(#inkfinite-region)\">\n"); svg.push_str(&indent(&renderer.body, 2)); svg.push_str(" </g>\n"); } else { svg.push_str(&renderer.body); } svg.push_str("</svg>\n");
Ok(SvgRenderOutput { svg, warnings: renderer.warnings.into_iter().collect() })}
fn empty_svg() -> SvgRenderOutput { SvgRenderOutput { svg: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 100 100\" width=\"100\" height=\"100\">\n</svg>\n" .into(), warnings: Vec::new(), }}
fn properties<T: for<'de> Deserialize<'de>>(shape: &ShapeRecord) -> Result<T, SvgRenderError> { 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<Bounds, SvgRenderError> { 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<Bounds, SvgRenderError> { 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::<PathGeometry>(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::<MaskEffect>(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::<FilterEffect>(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, "<clipPath id=\"{id}\" clipPathUnits=\"userSpaceOnUse\"><path d=\"{}\" transform=\"{}\" fill-rule=\"{}\"/></clipPath>", 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, "<mask id=\"{id}\" maskUnits=\"userSpaceOnUse\" mask-type=\"{mode}\"><path d=\"{}\" transform=\"{}\" fill=\"white\" fill-opacity=\"{}\" fill-rule=\"{}\"/></mask>", 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!("<g {}>{output}</g>", attributes.join(" ")); } Ok(())}
fn write_filter(defs: &mut String, id: &str, filter: &FilterEffect) -> Result<(), String> { write!( defs, "<filter id=\"{id}\" x=\"-50%\" y=\"-50%\" width=\"200%\" height=\"200%\">" ) .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, "<feGaussianBlur in=\"{input}\" stdDeviation=\"{}\" result=\"{result}\"/>", number(*radius)), FilterPrimitive::DropShadow { dx, dy, radius, color, opacity } => write!(defs, "<feDropShadow in=\"{input}\" dx=\"{}\" dy=\"{}\" stdDeviation=\"{}\" flood-color=\"{}\" flood-opacity=\"{}\" result=\"{result}\"/>", number(*dx), number(*dy), number(*radius), escape_xml(color), number(*opacity)), FilterPrimitive::Saturate { amount } => write!(defs, "<feColorMatrix in=\"{input}\" type=\"saturate\" values=\"{}\" result=\"{result}\"/>", number(*amount)), FilterPrimitive::HueRotate { degrees } => write!(defs, "<feColorMatrix in=\"{input}\" type=\"hueRotate\" values=\"{}\" result=\"{result}\"/>", number(*degrees)), FilterPrimitive::Grayscale { amount } => write!(defs, "<feColorMatrix in=\"{input}\" type=\"saturate\" values=\"{}\" result=\"{result}\"/>", number(1.0 - amount)), FilterPrimitive::Invert { amount } => write!(defs, "<feComponentTransfer in=\"{input}\" result=\"{result}\"><feFuncR type=\"table\" tableValues=\"{} {}\"/><feFuncG type=\"table\" tableValues=\"{} {}\"/><feFuncB type=\"table\" tableValues=\"{} {}\"/></feComponentTransfer>", number(*amount), number(1.0 - amount), number(*amount), number(1.0 - amount), number(*amount), number(1.0 - amount)), FilterPrimitive::Brightness { amount } => write!(defs, "<feComponentTransfer in=\"{input}\" result=\"{result}\"><feFuncR type=\"linear\" slope=\"{}\"/><feFuncG type=\"linear\" slope=\"{}\"/><feFuncB type=\"linear\" slope=\"{}\"/></feComponentTransfer>", number(*amount), number(*amount), number(*amount)), FilterPrimitive::Contrast { amount } => { let intercept = 0.5 - 0.5 * amount; write!(defs, "<feComponentTransfer in=\"{input}\" result=\"{result}\"><feFuncR type=\"linear\" slope=\"{}\" intercept=\"{}\"/><feFuncG type=\"linear\" slope=\"{}\" intercept=\"{}\"/><feFuncB type=\"linear\" slope=\"{}\" intercept=\"{}\"/></feComponentTransfer>", number(*amount), number(intercept), number(*amount), number(intercept), number(*amount), number(intercept)) }, FilterPrimitive::Sepia { amount } => write!(defs, "<feColorMatrix in=\"{input}\" type=\"matrix\" values=\"{} {} {} 0 0 {} {} {} 0 0 {} {} {} 0 0 0 0 0 1 0\" result=\"{result}\"/>", 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, "<feComponentTransfer in=\"{input}\" result=\"{result}\"><feFuncA type=\"linear\" slope=\"{}\"/></feComponentTransfer>", number(*amount)), }.map_err(|error| error.to_string())?; } writeln!(defs, "</filter>").map_err(|error| error.to_string())}
fn image_mask_path(mask: &ImageMask, width: f64, height: f64) -> String { match mask.kind.as_str() { "ellipse" => format!( "<ellipse cx=\"{}\" cy=\"{}\" rx=\"{}\" ry=\"{}\"/>", number(width / 2.0), number(height / 2.0), number(width / 2.0), number(height / 2.0) ), "rounded" => format!( "<rect width=\"{}\" height=\"{}\" rx=\"{}\"/>", number(width), number(height), number(mask.radius.unwrap_or(16.0).min(width / 2.0).min(height / 2.0).max(0.0)) ), _ => format!("<rect width=\"{}\" height=\"{}\"/>", 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<Vec2> = 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<ShapeId>) -> 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<ArrowHeadGeometry> { 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::PathMetricPoint> { 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, " <path transform=\"{transform}\" d=\"{triangle}\" fill=\"{}\" stroke=\"{stroke}\" stroke-opacity=\"{opacity}\" stroke-width=\"{}\"/>", 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<MarkdownLine> { 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, "<tspan{weight}{style}{family}>{}</tspan>", escape_xml(text)) .expect("writing to a String cannot fail");}
fn wrap_text(text: &str, max_width: f64, font_size: f64) -> Vec<String> { 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::<f64>() * 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<Bounds>) -> 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, "</{tag}>").expect("writing to a String cannot fail");}
fn write_stops(defs: &mut String, stops: &[crate::GradientStop]) { for stop in stops { write!( defs, "<stop offset=\"{}\" stop-color=\"{}\" stop-opacity=\"{}\"/>", 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;