SVG
Egami.Svg reads SVG 1.1 and SVG 2 files into an editable element tree, saves them back, and draws them through Egami.Drawing's canvas, so the same document renders to a raster, to a PDF page or back out as SVG. It is fully managed and opens nothing outside the file unless you allow it.
Egami.Svg is at 1.0 (1.0.0): reading, saving, styles, drawing, filters, text, color fonts, SVG output and
Raster.Load support are in, and its API is stable: 1.x versions add to it without breaking code that uses it.
Loading
SvgDocument.Load reads an .svg or .svgz file or stream (gzip is detected by its first bytes), and SvgDocument.Parse reads
markup from a string. Loading is lenient, as browsers and resvg read SVG: what cannot be used is skipped, listed in
SvgDocument.Warnings and sent to the context's diagnostics as Svg.* events.
// .svg and .svgz alike; nothing outside the file is opened unless a resolver allows it.
SvgDocument document = SvgDocument.Load(path);
Console.WriteLine($"{document.Title}: {document.Root.Descendants().Count()} elements");
// What a lenient load skipped is in Warnings, and in the context's diagnostics.
foreach (EgamiDiagnostic warning in document.Warnings)
{
Console.WriteLine($"{warning.Id}: {warning.Message}");
}
SvgLoadOptions.Strict throws an SvgException with the line and column instead, for checking files you produce:
// Strict turns everything a lenient load would skip into an exception with the line and column.
try
{
SvgDocument.Parse("<svg>\n <rect width='10' height='10'/>\n</svg>", new SvgLoadOptions { Strict = true });
}
catch (SvgException e)
{
Console.WriteLine($"Line {e.Line}, column {e.Column}: {e.Problem}");
}
The element tree
The document is a tree of SvgElements, like XDocument's: each has a Name and Namespace, an SvgAttributeCollection of
SvgAttributes and an SvgContentList of SvgContent items in file order (child elements, SvgTextContent for text and
CDATA, SvgComment and SvgProcessingInstruction). Elements and attributes Egami does not draw, such as Inkscape's and
Illustrator's, are kept. SvgNamespaces names the SVG, XLink, XML and xmlns namespaces. FindById finds an element by its
id.
SvgDocument document = SvgDocument.Parse(Icon);
// Elements, attributes and text are an editable tree, like XDocument's.
SvgElement body = document.FindById("body")!;
body.Attributes["fill"] = "#c33";
body.Attributes.Set("href", "#other", SvgNamespaces.XLink, "xlink");
SvgElement dot = new("circle");
dot.Attributes["cx"] = "12";
dot.Attributes["cy"] = "12";
dot.Attributes["r"] = "3";
document.Root.Children.Add(dot);
document.Root.Children.Add(new SvgComment(" added in code "));
foreach (SvgElement element in document.Root.Elements())
{
Console.WriteLine($"{element.Name}: {element.Attributes.Count} attributes");
}
Save writes the tree back as SVG, with everything read from the file (unknown elements, foreign namespaces, comments, the
DOCTYPE), so a load and save without edits keeps the document's meaning; entities come out expanded, and attribute order and
whitespace inside tags may change. SvgSaveOptions sets indentation (only where whitespace means nothing, never inside text),
the decimals of numbers and paths set in code, and gzip for .svgz.
// Numbers and paths set in code are written with Precision decimals; what was read keeps its text.
document.FindById("body")!.Attributes.Set("width", 17.666666);
document.Save(path, new SvgSaveOptions { Precision = 2, Gzip = true });
using var stream = new MemoryStream();
document.Save(stream);
Every edit marks the document for re-resolving on its next draw. The tree is not safe for writes from several threads at once, like XDocument.
Drawing
Rasterize draws the document into a new raster at its own size (SvgDocument.Size: the root's width and height, a
percentage or missing side taken of the viewBox, or 100 by 100 when there is none, as resvg sizes documents), scaled, or at a
target width or height; SvgRenderOptions also sets the background and the canvas options.
// At the document's own size, scaled, or at a target width (the height keeps the aspect ratio).
using Raster<Rgba8> icon = document.Rasterize<Rgba8>();
using Raster<Rgba8> large = document.Rasterize<Rgba8>(new SvgRenderOptions { Width = 512 });
using Raster<Rgba8> onWhite = document.Rasterize<Rgba8>(new SvgRenderOptions { Scale = 2, Background = new RgbaF32(1, 1, 1, 1) });
Draw draws on any Egami.Drawing canvas: a raster canvas, a PDF page, a PictureRecorder. It calls only the public canvas
API, so every backend receives the same calls; a box fits the document in by the root's preserveAspectRatio, and an id draws
one element, such as a symbol of a sprite sheet. Drawing never throws on content: what cannot be drawn was reported when
the document was read.
// Fitted into a box by the root's preserveAspectRatio; a PDF page or SvgCanvas receives the same calls.
document.Draw(canvas, new RectF(10, 10, 180, 180));
// One element of a sprite sheet, fitted into its own box.
document.Draw(canvas, "body", new RectF(200, 10, 48, 48));
Rendering follows resvg, the closest static SVG renderer, where SVG leaves a choice open, and is checked against resvg's
test suite. Styles come from Egami.Css: style sheets in style elements (with media queries), the style attribute and
presentation attributes, with selectors up to Selectors level 4.
Paint servers are linear and radial gradients, patterns and the mesh gradients Inkscape writes (meshgradient, SVG 2's
draft): each patch is a Coons patch with a color at each corner, blended bilinearly or, with type="bicubic", smoothly
across patches. Outside its patches a mesh paints nothing. A mesh that names another with href takes its rows and keeps
its own x and y, as Inkscape reads it.
Text
Text is laid out when the document is read: characters are placed by the x, y, dx, dy and rotate lists, shaped in their
fonts (bidi, ligatures, kerning, fallback for characters a font lacks), spaced by letter-spacing, word-spacing and
textLength, anchored, put on a textPath or set vertically, and shifted to their baselines, as resvg lays text out. Each run
of glyphs becomes an SvgTextRun drawn with DrawGlyphRun, so a PDF page or an SVG canvas keeps real text;
SvgRenderOptions.TextAsPaths draws the outlines instead, for a plotter or a cutter.
Wrapped text from SVG 2 is laid out by Egami.Fonts' paragraph layout: inline-size fills lines of that length from the
text's x and y, aligned by text-anchor, and shape-inside flows the text into shapes (less shape-subtract and its
shape-margin, inside shape-padding), filling one shape after another as Inkscape's flowed text does. white-space keeps
spaces and line breaks for pre, pre-wrap and pre-line.
Fonts come from SvgFontOptions: the caller's collection first, then the document's @font-face rules (data URIs, or files
the load options' resolver allows), then the fonts installed on the machine unless
UseSystemFonts is off, then the serif family. A server that renders files it did not write should pass its own fonts and
turn system fonts off, so the output does not depend on the machine.
// A server passes its own fonts and turns the machine's off, so text renders the same everywhere.
Egami.Fonts.FontCollection fonts = new();
fonts.AddFile(System.IO.Path.Combine(folder, "NotoSans-Regular.ttf"));
SvgLoadOptions options = new()
{
Fonts = new SvgFontOptions { Collection = fonts, UseSystemFonts = false, DefaultFamily = "Noto Sans" },
};
SvgDocument label = SvgDocument.Parse(
"<svg xmlns='http://www.w3.org/2000/svg' width='120' height='40'><text x='60' y='26' text-anchor='middle'>Label</text></svg>",
options);
using Raster<Rgba8> raster = label.Rasterize<Rgba8>(new SvgRenderOptions { TextAsPaths = false });
With system fonts off, the label looks the same on every machine.
Color fonts with an OpenType SVG table, such as many emoji fonts, draw in color through SvgGlyphRenderer, an
IColorGlyphRenderer that Egami.Drawing asks when CanvasOptions.ColorGlyphRenderer holds it: each glyph's document is read once
per font. A glyph that also has a COLR version 1 form draws that instead; COLR version 0, sbix and CBDT come after SVG.
Rasterize uses the renderer already; set it yourself on any other raster canvas.
// Emoji and other glyphs from an OpenType SVG table draw in color on a canvas given the renderer.
using (RasterCanvas<Rgba8> canvas = raster.CreateCanvas(new CanvasOptions { ColorGlyphRenderer = SvgGlyphRenderer.Instance }))
{
canvas.DrawText("Hello", new System.Numerics.Vector2(10, 35), font, new SolidPaint(new RgbaF32(0, 0, 0, 1)));
}
Loading SVG as a raster
SvgFormat.Info makes SVG an Egami format: register it with EgamiContext.WithFormat, and Raster.Load reads .svg and
.svgz files drawn at their own size, Raster.Probe gives that size without drawing, and RasterDecoder and the rest of
Egami's loading take them like any image. It is found by content, not by extension: an svg root element after any XML
declaration, comments and DOCTYPE, or gzip data that starts so. SvgDecodeOptions.Scale draws at a multiple of the size,
for icons on high-density screens, and Recovery = Strict throws on what a lenient load skips; a lenient load keeps the
Svg.* warnings in the raster's Metadata.Warnings. The context's safety limits apply, and nothing outside the file is
opened, so images it links are not drawn; text uses the machine's fonts. The format never writes: save SVG with
SvgDocument.Save or SvgCanvas. For a resolver, your own fonts, a target size or a background, load an SvgDocument and
call Rasterize.
// SVG as an Egami format: Raster.Load draws a file at its own size, Raster.Probe reads the size without drawing.
EgamiContext context = EgamiContext.Default.WithFormat(SvgFormat.Info);
RasterInfo info = Raster.Probe(path, context);
using Raster<Rgba8> icon = Raster.Load<Rgba8>(path, context);
// Twice the size, for a high-density screen.
using Raster<Rgba8> sharp = Raster.Load<Rgba8>(path, context, new SvgDecodeOptions { Scale = 2 });
The render tree
Drawing works from the render tree, SvgDocument.RenderTree: the elements with their styles applied, units in pixels, use
copies expanded, paint servers, clip paths, masks and markers linked, and shapes turned into Egami.Drawing paths. It is built
when the document is read and again after an edit, and it is immutable, so a document can be drawn on many threads at once.
Its nodes are SvgNodes: SvgGroup for containers, SvgPath for shapes (fill, stroke and markers, in SvgPaintOrder),
SvgImage for images and SvgTextRun for text, each with its transform, opacity, blend mode, SvgClip, SvgMask and
SvgFilter, and a link back to its SvgElement. A later 1.x release may add node kinds, so a switch over nodes should keep a
default case.
// The resolved tree: groups, paths with their paints and strokes, images; styles applied and units in pixels.
static void Walk(SvgNode node, int depth)
{
string what = node switch
{
SvgPath path => $"path {path.Path.Bounds} fill {path.Fill}",
SvgGroup group => $"group of {group.Children.Count}",
SvgImage image => $"image {image.Destination}",
_ => node.GetType().Name,
};
Console.WriteLine($"{new string(' ', depth * 2)}{node.Id} {what} opacity {node.Opacity}");
if (node is SvgGroup g)
{
foreach (SvgNode child in g.Children)
{
Walk(child, depth + 1);
}
}
}
Walk(document.RenderTree, 0);
Untrusted files
An SVG file is untrusted input. A load honors the context's SafetyLimits: the file, after gzip, is bounded by
MaxTotalBytes (a gzip bomb stops there), entity expansion by MaxMetadataBytes (so a billion-laughs DTD stops), the number
of elements grows with MaxTotalBytes, and elements nest at most 256 deep. Internal DTD entities are expanded, because
Illustrator writes them; external entities and DTDs are never opened. Images, style sheets and fonts the file refers to open
only through SvgLoadOptions.Resolver; SvgResolvers.SameDirectory allows files in one folder and nothing else, and data:
URIs always work. Egami never opens a network connection itself.
// Images, style sheets and fonts the file refers to open only through a resolver; data: URIs always work.
var options = new SvgLoadOptions
{
Resolver = SvgResolvers.SameDirectory(folder),
Dpi = 96,
Context = EgamiContext.Default with { Limits = SafetyLimits.Default with { MaxTotalBytes = 64L << 20 } },
};
SvgDocument document = SvgDocument.Parse(Icon, options);
Writing SVG
SvgCanvas is a Canvas that writes SVG: anything drawn on it, by your code or by a Picture, becomes a vector file that
browsers, Inkscape and Egami.Svg read. Disposing it writes the file to the stream, which stays open. Each call becomes a
path, use, image or text element with the canvas transform; equal gradients, clips, masks and patterns are written
once in defs and shared, an image or a picture drawn many times is stored once, and clips add no nesting (each clip path's
shape is clipped by the one before, and a chain longer than 16 is written as one intersected shape, since readers limit how
deep references nest), so a deep save/restore stack stays flat. Gradients mixed in linear light, or between stops of
different alpha, get extra stops mixed as Egami mixes them, so every viewer draws them alike. Layers become groups with opacity, CSS mix-blend-mode and isolation, and soft masks
become mask elements.
// Anything drawn on a Canvas can be written as SVG: gradients, clips, images and pictures go once into defs.
using (SvgCanvas canvas = new(file, 200, 120, new SvgWriteOptions { Precision = 2, ImageFormat = SvgImageFormat.Png }))
{
canvas.FillRoundRect(new RoundRect(new RectF(10, 10, 180, 100), 12), new LinearGradient(new Vector2(10, 0), new Vector2(190, 0), new RgbaF32(0.1f, 0.4f, 0.9f, 1), new RgbaF32(0.9f, 0.2f, 0.5f, 1)));
canvas.SaveLayer(0.5f);
canvas.StrokePath(Egami.Drawing.Path.Oval(new RectF(30, 30, 140, 60)), new Stroke(6) { Dash = new DashPattern([10, 5]) }, new Rgba8(255, 255, 255, 255));
canvas.Restore();
// A sweep gradient has no SVG form: it is drawn by Egami and embedded as a PNG (Svg.FallbackImage).
canvas.FillCircle(new Vector2(100, 60), 20, new SweepGradient(new Vector2(100, 60), [new GradientStop(0, new RgbaF32(1, 1, 0, 1)), new GradientStop(1, new RgbaF32(0, 1, 1, 1))]));
}
// Disposing writes the file; the stream stays open.
SvgWriteOptions sets the number of decimals (Precision), Compact output, the SvgImageFormat drawn images are embedded
in (PNG, or JPEG for opaque images) and the resolution of fallback images. What SVG cannot express is drawn by Egami into a
PNG over the area it covers, at FallbackScale pixels per unit, and reported as Svg.FallbackImage to the options' context:
sweep gradients, noise, blend and color matrix paints, custom paints, gradients mixed in Oklab or Oklch, clamped images
drawn past their edges, two-point conical gradients whose start circle is not inside the end circle, inner and outer mask
blurs, meshes and patches, and layers with a filter, a backdrop or a mask transfer function. Blend modes CSS lacks (the
Porter-Duff modes other than source-over and plus) are drawn as source-over and reported as Svg.Approximated.
Text is written as glyph outlines by default (SvgTextMode.Outlines): each glyph's outline is in defs once, in font units,
and every use of it scales it, so the file looks the same everywhere. SvgTextMode.Text writes text elements with the font's
family, size, weight and style and each cluster's position, so the text stays searchable and lines up with Egami's layout;
with EmbedSubset each font's glyphs used are embedded as an OpenType subset in an @font-face rule (unless the font's license
forbids it, reported as Svg.FontNotEmbedded). Synthetic bold and italic are drawn as Egami draws them, as a stroke and a slant.
// By default text is glyph outlines, each written once and reused, so the file needs no fonts.
// SvgTextMode.Text writes text elements instead, searchable and selectable; EmbedSubset adds the glyphs used as a font.
SvgWriteOptions options = new() { Text = SvgTextMode.Text, EmbedSubset = true };
using (SvgCanvas canvas = new(file, 300, 60, options))
{
canvas.DrawText("Hello, SVG", new Vector2(10, 40), new Font(face, 28), new Rgba8(20, 20, 20, 255));
}
Recipes
Short answers to common tasks in this area. Each one compiles and runs in Egami's test run.
Render an SVG to PNG
// An SVG drawn 256 pixels wide and saved as PNG; the height keeps the aspect ratio.
SvgDocument document = SvgDocument.Load(input);
using Raster<Rgba8> raster = document.Rasterize<Rgba8>(new SvgRenderOptions { Width = 256 });
raster.Save(output);
Egami.Svg draws as resvg does where SVG leaves a choice.
Recolor an SVG icon
// Every fill and stroke of a one-color icon set to a brand color, then drawn: edits redraw at once.
SvgDocument icon = SvgDocument.Parse(Icon);
foreach (SvgElement element in icon.Root.Descendants())
{
foreach (string paint in (string[])["fill", "stroke"])
{
if (element.Attributes[paint] is string value && value != "none")
{
element.Attributes[paint] = "#7b2ff7";
}
}
}
using Raster<Rgba8> branded = icon.Rasterize<Rgba8>(new SvgRenderOptions { Width = 64 });
Draw icons from an SVG sprite sheet
// Each symbol of a sprite sheet drawn into its own 32-pixel cell of a toolbar strip.
SvgDocument sheet = SvgDocument.Parse(Sheet);
string[] icons = ["plus", "dot", "plus"];
using Raster<Rgba8> strip = Raster.Create<Rgba8>(32 * icons.Length, 32);
using (RasterCanvas<Rgba8> canvas = strip.CreateCanvas())
{
for (int i = 0; i < icons.Length; i++)
{
sheet.Draw(canvas, icons[i], new RectF(32 * i, 0, 32, 32));
}
}
A symbol's viewBox fits it into each cell; the same calls draw on a PDF page.
Flow SVG text into a shape
// SVG 2 text that wraps: inside a circle (as Inkscape writes flowed text), or in lines 160 units long.
SvgDocument card = SvgDocument.Parse("""
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="200" font-family="Noto Sans" font-size="14">
<circle id="area" cx="100" cy="100" r="90" fill="#eef"/>
<text style="shape-inside: url(#area); shape-padding: 6px; text-align: center">
Text flows into the circle line by line, each line as wide as the circle is at that height.
</text>
<text x="220" y="40" style="inline-size: 160px">Wrapped at 160 units, as a paragraph fills a column.</text>
</svg>
""", options);
using Raster<Rgba8> raster = card.Rasterize<Rgba8>();