DEV Community

Matthew-Wise
Matthew-Wise

Posted on

A Practical Guide to SVGs in Umbraco

If you've ever tried to use SVGs properly in Umbraco, you'll know it's not quite plug-and-play. Icons, logos, illustrations - SVGs are everywhere on the modern web.

Out of the box Umbraco will let you upload SVGs. It even has a dedicated Vector Graphics (SVG) media type. But there are two things I wanted to improve.

The Two Problems

Inlining for CSS control.

If you serve an SVG as an <img> tag, it works - but you lose most of what makes SVGs useful. You can't style it with CSS, you can't use currentColor to inherit text colour, you can't target individual paths for hover states or animations. To get that level of control you need the SVG markup inlined directly into your HTML.

But inlining creates its own issues. An <img> tag sandboxes its content - scripts inside the SVG won't execute. Inline that same SVG into the page and any <script> tags or javascript: handlers will run. An editor could unknowingly upload an SVG containing an XSS attack, and you'd be serving it straight into the DOM. SVGs from design tools also come with hard-coded width and height attributes that fight your CSS when inlined - you want a viewBox instead.

Duplicate requests on <img> tags.
Not every SVG needs to be inlined. But when you call GetCropUrl() on a media item, Umbraco adds crop and resize parameters to the URL. So GetCropUrl(200) produces example.svg?width=200, and GetCropUrl(48) produces example.svg?width=48. The browser treats those as different URLs - meaning if you use the same SVG at multiple sizes, it gets downloaded multiple times instead of once. For a vector image that scales to any size, those parameters are pointless and the duplicate requests are pure waste.

Securing SVG Uploads

Since we're inlining SVGs into the page, we need to make sure they're safe before they get anywhere near the DOM.

Umbraco has an IFileStreamSecurityAnalyzer interface that gets called when media is uploaded. Umbraco doesn't ship with an SVG implementation, but the docs include an example. My version makes two changes to that example: it drops the xmlns check (since not all SVGs include it) and adds a check for javascript: protocol handlers:

internal class SvgXssSecurityAnalyzer : IFileStreamSecurityAnalyzer
{
    public bool ShouldHandle(Stream fileStream)
    {
        // reduce memory footprint by partially reading the file
        var startBuffer = new byte[256];
        var endBuffer = new byte[256];
        fileStream.ReadExactly(startBuffer);
        if (endBuffer.Length > fileStream.Length)
            fileStream.Seek(0, SeekOrigin.Begin);
        else
            fileStream.Seek(fileStream.Length - endBuffer.Length, SeekOrigin.Begin);
        fileStream.ReadExactly(endBuffer);
        var startString = Encoding.UTF8.GetString(startBuffer);
        var endString = Encoding.UTF8.GetString(endBuffer);
        return startString.Contains("<svg")
               && endString.Contains("/svg>");
    }

    public bool IsConsideredSafe(Stream fileStream)
    {
        // do not use a using as this will dispose of the underlying stream
        var streamReader = new StreamReader(fileStream);
        var fileContent = streamReader.ReadToEnd();
        return !(fileContent.Contains("<script")
                || fileContent.Contains("/script>")
                || fileContent.Contains("javascript:"));
    }
}
Enter fullscreen mode Exit fullscreen mode

Registration is a single line in DI:

services.AddSingleton<IFileStreamSecurityAnalyzer, SvgXssSecurityAnalyzer>();
Enter fullscreen mode Exit fullscreen mode

Storing SVG Content for Inline Rendering

To inline SVGs in your templates, you need the actual SVG markup available at render time. You could read the file on every request, but that's expensive. Storing the markup as a property on the media item means it gets served from Umbraco's cache instead of hitting IO on every render.

Add a custom property called svgContent to the Vector Graphics (SVG) media type in Umbraco, then create a notification handler that populates it automatically whenever an SVG is saved.

The property type matters here. A Label (string) won't work - SVG markup easily exceeds 512 characters. If you're on Umbraco 17.3.0+ the Label (long string) type stores as Text in the database, but on earlier 17.x versions there's a known issue where it's capped at 512 characters. If that affects you, you'll need a custom data type to store the content.

//Umbraco extension manifest:
export const manifests: Array<UmbExtensionManifest> = [
    {
        "type": "propertyEditorUi",
        "alias": "Website.PropertyEditorUi.LongLabel",
        "name": "Website Long label",
        "elementName": "umb-property-editor-ui-label",
        "meta": {
            "label": "Long Label",
            "icon": "icon-list",
            "group": "common",
            "propertyEditorSchemaAlias": "Umbraco.Plain.Json" //Means we get IHtmlEncodedString in c#
        }
    },
];
Enter fullscreen mode Exit fullscreen mode

Using a MediaSavingNotification handler to read the file content, clean up the markup, and stores it in svgContent whenever an SVG is saved. For each SVG node it generates a viewBox from existing dimensions if one doesn't exist, strips width and height attributes, and removes the xmlns declaration. The viewBox logic here is based on the approach used in Our.Umbraco.TagHelpers - well worth a look if you haven't already:

HtmlDocument doc = new HtmlDocument();
doc.LoadHtml(fileContents);
var svgs = doc.DocumentNode.SelectNodes("//svg");

foreach (var svgNode in svgs)
{
    if (svgNode.Attributes.Contains("width")
        || svgNode.Attributes.Contains("height"))
    {
        if (!svgNode.Attributes.Contains("viewbox"))
        {
            if (!Decimal.TryParse(
                svgNode.GetAttributeValue("width", "0"),
                out var width)) width = 0;
            if (!Decimal.TryParse(
                svgNode.GetAttributeValue("height", "0"),
                out var height)) height = 0;
            svgNode.SetAttributeValue("viewbox",
                $"0 0 {width} {height}");
        }

        svgNode.Attributes.Remove("width");
        svgNode.Attributes.Remove("height");
    }

    svgNode.Attributes.Remove("xmlns");
}
Enter fullscreen mode Exit fullscreen mode

This all happens once at upload time. Editors don't need to think about it. The full handler with error handling is in the complete example at the end of the post.

The handler is registered on the Umbraco builder:

builder.AddNotificationAsyncHandler<MediaSavingNotification, StoreSvgContentMediaSavingNotificationHandler>();
Enter fullscreen mode Exit fullscreen mode

Cleaning Up the Image Pipeline for SVGs

Remember that issue with adding width and height to SVG output. here's how to fix that

what about something along this line?
The DontCropSvgsImageUrlGenerator wraps the default generator. If the image is an SVG, it creates a new ImageUrlGenerationOptions with only the URL - stripping all crop and resize parameters. Everything else passes through normally:

public string? GetImageUrl(ImageUrlGenerationOptions options)
{
    if (options?.ImageUrl == null)
    {
        return null;
    }

    if (options.ImageUrl.EndsWith(".svg"))
    {
        return _imageGenerator.GetImageUrl(
            new ImageUrlGenerationOptions(options.ImageUrl));
    }

    return _imageGenerator.GetImageUrl(options);
}
Enter fullscreen mode Exit fullscreen mode

The registration replaces Umbraco's default IImageUrlGenerator, wrapping the original ImageSharpImageUrlGenerator inside the decorator:

services.RemoveAll<IImageUrlGenerator>(); //Umbraco only has one by default
services.AddSingleton<IImageUrlGenerator, DontCropSvgsImageUrlGenerator>(
    sp =>
    {
        var generator = new ImageSharpImageUrlGenerator(
            sp.GetRequiredService<SixLabors.ImageSharp.Configuration>(),
            sp.GetService<RequestAuthorizationUtilities>(),
            sp.GetRequiredService<IOptions<ImageSharpMiddlewareOptions>>());
        return new DontCropSvgsImageUrlGenerator(generator);
    });
Enter fullscreen mode Exit fullscreen mode

Rendering the SVG Content

With all of this in place, using SVGs in Razor views is straightforward. An extension method on MediaWithCrops surfaces the stored SVG content (using the C# 14 extension members syntax on .NET 10):

extension(MediaWithCrops? media)
{
    public IHtmlEncodedString? SvgContent =>
        media?.Content is UmbracoMediaVectorGraphics svg
            && !svg.SvgContent.IsNullOrWhiteSpace()
            ? svg.SvgContent
            : null;
}
Enter fullscreen mode Exit fullscreen mode

Then in a component, render the SVG inline if available, falling back to an <img> tag otherwise:

@if (Model.Icon.SvgContent is not null)
{
    @Model.Icon.SvgContent
}
else
{
    <img src="@Model.Icon.GetCropUrl(48, 48)"
         alt="@Model.Icon.GetAltText()" loading="lazy">
}
Enter fullscreen mode Exit fullscreen mode

An alternative here would be to create a custom tag helper in a similar way to Our.Umbraco.TagHelpers.

Wrapping Up

Together it gives you a solid SVG story in Umbraco: editors upload SVGs as normal, the code secures them, fixes the sizing, and serves them from a single URL or inline with full CSS control as the developer and site need.

The Full Handler

Here's the complete StoreSvgContentMediaSavingNotificationHandler ready to copy-paste:

internal class StoreSvgContentMediaSavingNotificationHandler
    : INotificationAsyncHandler<MediaSavingNotification>
{
    private const string SvgContentPropertyAlias = "svgContent";

    private readonly MediaFileManager _mediaFileManager;
    private readonly ILogger<StoreSvgContentMediaSavingNotificationHandler> _logger;

    public StoreSvgContentMediaSavingNotificationHandler(
        MediaFileManager mediaFileManager,
        ILogger<StoreSvgContentMediaSavingNotificationHandler> logger)
    {
        _mediaFileManager = mediaFileManager;
        _logger = logger;
    }

    public async Task HandleAsync(
        MediaSavingNotification notification,
        CancellationToken cancellationToken)
    {
        foreach (var svg in notification.SavedEntities
            .Where(x => x.ContentType.Alias
                .InvariantEquals(UmbConstants.Conventions
                    .MediaTypes.VectorGraphicsAlias)))
        {
            if (svg.HasProperty(SvgContentPropertyAlias) is false) continue;
            try
            {
                using var content = _mediaFileManager.GetFile(svg, out _);
                using var reader = new StreamReader(content);
                var fileContents =
                    await reader.ReadToEndAsync(cancellationToken);

                HtmlDocument doc = new HtmlDocument();
                doc.LoadHtml(fileContents);
                var svgs = doc.DocumentNode.SelectNodes("//svg");

                foreach (var svgNode in svgs)
                {
                    if (svgNode.Attributes.Contains("width")
                        || svgNode.Attributes.Contains("height"))
                    {
                        if (!svgNode.Attributes.Contains("viewbox"))
                        {
                            if (!Decimal.TryParse(
                                svgNode.GetAttributeValue("width", "0"),
                                out var width)) width = 0;
                            if (!Decimal.TryParse(
                                svgNode.GetAttributeValue("height", "0"),
                                out var height)) height = 0;
                            svgNode.SetAttributeValue("viewbox",
                                $"0 0 {width} {height}");
                        }

                        svgNode.Attributes.Remove("width");
                        svgNode.Attributes.Remove("height");
                    }

                    svgNode.Attributes.Remove("xmlns");
                }

                fileContents = doc.DocumentNode.OuterHtml;
                svg.SetValue(SvgContentPropertyAlias, fileContents);
            }
            catch (Exception ex) when (ex is IOException
                || ex is FileNotFoundException)
            {
                _logger.LogError(ex,
                    "Unable to read file to set SVG content "
                    + "for media item with ID {MediaId}.",
                    svg.Id);
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

References

Top comments (0)