Vector graphics rendering in Unreal Engine: Part 4

Importing SVG files as Unreal assets for on-demand loading and rendering in UMG.

To wrap up this vector graphics rendering series, we’ll expand the capabilities of our SVG rendering solution to cover some critical cases we omitted from the last entry. Then we’ll write a simple parser for the SVG file format so we can import SVG files into the editor and store them on disk as a custom Unreal asset type. Finally, we’ll make a new UMG widget type that can load and render those assets just like the native Image Widget does for raster graphics.

Housekeeping

First, let’s do a little bit of re-organization and cleanup, since the architecture of our SVG rendering solution is in clearer focus now.

  • Rename Public/SVG/Elements.h to Public/SVG/SVGElementProperties.h
  • Create Public/SVG/SVGAttributes.h
    • Move ECanvasElementFillRule, FCanvasElementStyle, and FSVGViewBox into this file
    • Fix any include paths to make sure existing references to the identifiers above are valid and recognized by your IDE
    • Rename FCanvasElementStyle to FSVGStyle
    • Rename ECanvasElementFillRule to ESVGFillRule
  • Rename Public/SVG/ElementUClasses.h to Public/SVG/SVGElements.h
    • Rename USVGElementBase to USVGElement

All the files under Public/SVG should now be prefixed with “SVG”, except for CanvasSVGBuilder.h, which follows our established convention of prefixing our canvas-drawn widget types with “Canvas.”

Before starting the editor again, you’ll also want to add the following core redirects to Config/Default<PluginName>.ini if you’ve saved any widget blueprints that use the Canvas SVG Builder widget (be sure to replace “<PluginName>” with your actual plugin name).

<Plugin>/Config/Default<PluginName>.ini
[CoreRedirects]
+StructRedirects=(OldName="/Script/<PluginName>.CanvasElementStyle",NewName="/Script/<PluginName>.SVGStyle")
+EnumRedirects=(OldName="/Script/<PluginName>.ECanvasElementFillRule",NewName="/Script/<PluginName>.ESVGFillRule")
+ClassRedirects=(OldName="/Script/<PluginName>.SVGElementBase",NewName="/Script/<PluginName>.SVGElement")

Creating a custom asset type

There’s actually nothing particularly special about an Unreal asset type in and of itself — any UObject will do. What makes it usable as an “asset” are several external types, defined in an Editor-only module, that operate on that type. But we do want a class that can represent an SVG in a way that’s fully decoupled from UMG, so let’s go ahead and sketch that out.

Public/SVG/SVGAsset.h
#pragma once

#include "CoreMinimal.h"
#include "SVGAttributes.h"

#include "SVGAsset.generated.h"

class USVGElement;


UCLASS(BlueprintType)
class API USVGAsset : public UObject
{
	GENERATED_BODY()
	
public:
	UPROPERTY(EditAnywhere, Category="Attributes")
	FSVGViewBox ViewBox;
	
	UPROPERTY(EditAnywhere, Instanced, Category="Elements")
	TArray<TObjectPtr<USVGElement>> Children;
};

To make it an asset, we’ll bootstrap a new Type: "Editor" module under our plugin, and add a few interface implementations to that.

<EditorModule>/<PluginName>Editor.Build.cs
using UnrealBuildTool;

public class PluginNameEditor : ModuleRules
{
	public PluginNameEditor(ReadOnlyTargetRules target) : base(target)
	{
		PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
		
		PublicDependencyModuleNames.AddRange(new [] {
			"Core",
		});
		
		PrivateDependencyModuleNames.AddRange(new [] {
			"CoreUObject",
			"<MainModule>",
			"Engine",
			"Slate",
			"SlateCore",
			"UnrealEd",
			"XmlParser",
		});
	}
}

Asset type actions

The first type we need is a class that implements IAssetTypeActions, but we’ll derive from FAssetTypeActions_Base, so we don’t have to implement everything from scratch.

<EditorModule>/Public/SVG/SVGAsset_Editor.h
#pragma once

#include "CoreMinimal.h"
#include "AssetTypeActions_Base.h"


class FSVGAssetTypeActions : public FAssetTypeActions_Base
{
	using Self = FSVGAssetTypeActions;
	using Super = FAssetTypeActions_Base;
	
public:
	UClass* GetSupportedClass() const override;
	FText GetName() const override;
	FColor GetTypeColor() const override;
	uint32 GetCategories() override;
};
<EditorModule>/Private/SVG/SVGAsset_Editor.cpp
#include "SVG/SVGAsset_Editor.h"

#include "SVG/SVGAsset.h"


UClass* FSVGAssetTypeActions::GetSupportedClass() const
{
	return USVGAsset::StaticClass();
}

FText FSVGAssetTypeActions::GetName() const
{
	// TODO: Internationalization
	return FText::FromString("Scalable Vector Graphic");
}

FColor FSVGAssetTypeActions::GetTypeColor() const
{
	// Borrowed from the built-in texture asset type
	return FColor::FromHex("C04040");
}

uint32 FSVGAssetTypeActions::GetCategories()
{
	return EAssetTypeCategories::UI;
}

We’ll have to manually register that type, which we can do in our module startup hook.

<EditorModule>/Public/<PluginName>Editor.h
#pragma once

#include "CoreMinimal.h"
#include "Modules/ModuleManager.h"

class FSVGAssetTypeActions;


class FPluginNameEditorModule : public IModuleInterface
{
public:
	void StartupModule() override;
	void ShutdownModule() override;
	
private:
	TSharedPtr<FSVGAssetTypeActions> m_SVGAssetTypeActions = nullptr;
};
<EditorModule>/Private/<PluginName>Editor.cpp
#include "<PluginName>Editor.h"

#include "SVG/SVGAsset_Editor.h"


void FPluginNameEditorModule::StartupModule()
{
	if (FModuleManager::Get().IsModuleLoaded("AssetTools"))
	{
		m_SVGAssetTypeActions = MakeShared<FSVGAssetTypeActions>();
		
		IAssetTools& assetTools = FAssetToolsModule::GetModule().Get();
		assetTools.RegisterAssetTypeActions(m_SVGAssetTypeActions.ToSharedRef());
	}
}

void FPluginNameEditorModule::ShutdownModule()
{
	if (FModuleManager::Get().IsModuleLoaded("AssetTools")
		&& m_SVGAssetTypeActions.IsValid())
	{
		IAssetTools& assetTools = FAssetToolsModule::GetModule().Get();
		assetTools.UnregisterAssetTypeActions(m_SVGAssetTypeActions.ToSharedRef());
	}
	
	m_SVGAssetTypeActions.Reset();
}


IMPLEMENT_MODULE(FPluginNameEditorModule, <PluginName>Editor)

Asset factories

The main thing that differentiates an “asset type” from any other UObject is being able to save it to (and load it from) disk. To do that, we need a factory.

We’ll start with a “Create New” factory, which will allow us to create a new SVG asset directly from the editor’s Content Browser — this will obviously not be the primary workflow, but it’s by far the easiest to implement, which makes it a good starting point.

<EditorModule>/Public/SVG/SVGAsset_Editor.h
// ...

UCLASS()
class USVGCreateNewFactory : public UFactory
{
	GENERATED_BODY()
	
public:
	USVGCreateNewFactory();
	
	UObject* FactoryCreateNew(
		UClass* uClass,
		UObject* outer,
		FName name,
		EObjectFlags flags,
		UObject* context,
		FFeedbackContext* warn)
		override;
};
<EditorModule>/Private/SVG/SVGAsset_Editor.cpp
// ...

USVGCreateNewFactory::USVGCreateNewFactory()
{
	bCreateNew = true;
	SupportedClass = USVGAsset::StaticClass();
}

UObject* USVGCreateNewFactory::FactoryCreateNew(
	UClass* uClass,
	UObject* parent,
	FName name,
	EObjectFlags flags,
	UObject* cx,
	FFeedbackContext*)
{
	return NewObject<USVGAsset>(parent, uClass, name, flags, cx);
}

Unlike the IAssetTypeActions implementation, there’s no need to manually register our factory — Unreal will automagically find it and make use of it. So with that, we can start up the editor and try creating a new SVG asset. Right-click somewhere in the Content Browser, expand the User Interface submenu, and select Scalable Vector Graphic. Open the newly created asset, and you should see the Details panel populated with the same fields as our Canvas SVG Builder widget from the last entry.

It would be really nice to see the rendered graphic here as well. Achieving that would involve building a custom asset editor with Slate, via a class inheriting from FAssetEditorToolkit. The details of doing that are unfortunately complex enough that I’m going to call it out-of-scope for this post, but if you use your IDE to look up implementations of FAssetEditorToolkit, you’ll find plenty of examples in the engine source code to get a feel for the workflow. We will cover making it trivial to render our USVGAsset via the SCanvasWidget that we created back in Part 1, so if you want to go down that road, it shouldn’t be too hard to get an MVP up and running after we’re done here.

SVG rendering abstraction

In the previous entry, we wrote all of our code for rendering SVGs directly in our UMG widget class. But what if we want to render an SVG asset in some other context? We now have just such a use case (a custom asset editor), and we’ll soon have another (a UMG widget that renders a static SVG asset instead of one created directly in the UMG Designer). Let’s go ahead and refactor that code.

Lifting OnDraw to a separate interface

Currently, OnDraw is a virtual function declared on our UMG Canvas Widget, which is fine if UMG is the only place we’ll be rendering to such a canvas. But our FAssetEditorToolkit doesn’t derive from UObject, let alone UWidget.

To be honest, this isn’t a huge deal. There’s no law that says we must have an interface for this — we could just bind any function with the correct signature to our Slate canvas widget’s OnDraw delegate and call it a day. But an interface would be a good way to clearly signal that a type is canvas-renderable (without an explicit dependency on Slate or UMG), which will make our codebase a little easier to understand and navigate as its complexity scales.

While we’re at it, I’m also going to pull the delegate declaration out of SCanvasWidget.h, because having it co-located with the interface declaration gives us a single source (file) of truth for the callback signature if we ever need to change it. I’m also renaming the delegate from FOnDraw to FOnCanvasDraw to reduce the risk of name collisions.

<MainModule>/Public/ICanvasRenderer.h
#pragma once

#include "CoreMinimal.h"

class SkCanvas;


DECLARE_DELEGATE_ThreeParams(FOnCanvasDraw,
	SkCanvas& canvas,
	FInt32Vector2 size,
	FVector2f scale)

class API ICanvasRenderer
{
public:
	virtual ~ICanvasRenderer() = default;
	
	/**
	 * @param size The surface size measured in hardware device pixels.
	 * @param scale The logical Slate UI scale. Includes the ratio of hardware
	 *    pixels to Slate units, as well as any render transforms applied.
	 */
	virtual void OnDraw(SkCanvas& canvas, FInt32Vector2 size, FVector2f scale) = 0;
};

Then we can update our base UCanvasWidget. This class, being abstract, doesn’t even actually need the interface, since it doesn’t render anything on its own. We could leave it off here and have each of our concrete UMG widget types directly implement ICanvasRenderer instead. Unreal’s UClass infrastructure muddies the waters a bit here: Deriving from ICanvasRenderer would be a much more obvious win if we could declare UCanvasWidget’s override as pure-virtual — thus explicitly passing down the responsibility of implementation to derived classes — but UClasses are not permitted to have pure-virtual functions, so that’s not an option. We’ll leave the no-op override in place for now, but it may be worth revisiting this choice at a later date.

<MainModule>/Public/CanvasWidget.h
#pragma once

#include "CoreMinimal.h"
#include "ICanvasRenderer.h"
#include "Components/Widget.h"

#include "CanvasWidget.generated.h"

class SCanvasWidget;
class SkCanvas;


UCLASS(Abstract)
class API UCanvasWidget
	: public UWidget
	, public ICanvasRenderer
{
	GENERATED_BODY()
	using Self = UCanvasWidget;
	
public:
	void SynchronizeProperties() override;
	void ReleaseSlateResources(bool) override;
	
protected:
	TSharedRef<SWidget> RebuildWidget() override;
	void OnDraw(SkCanvas& canvas, FInt32Vector2 size, FVector2f scale) override {}
	
	TSharedPtr<SCanvasWidget> SlateWidget;
};

SVG renderer implementation

Now we can pull the rendering code out of SVG/CanvasSVGBuilder.h and into a new SVG/SVGRenderer.h file, where we’ll declare our context-agnostic SVG Renderer class. We’re basically converting the static DrawX methods from the UMG widget into instance methods on a new class, which will also subsume the fields of the old FDrawArgs struct.

Retaining the same ownership semantics of the previous setup with regards to the SkCanvas, the new class will hold a reference to the canvas, precluding the possibility of a default constructor. We’ll also explicitly delete the copy and move constructors to force its lifetime to be scoped to a single function invocation.

<MainModule>/Public/SVG/SVGRenderer.h
#pragma once

#include "CoreMinimal.h"
#include "SVGAttributes.h"
#include "SVGElementProperties.h"
#include "include/core/SkPaint.h"
#include "include/core/SkPath.h"

class USVGElement;
class SkCanvas;


class API FSVGRenderer
{
	using Self = FSVGRenderer;
	
public:
	FSVGRenderer(SkCanvas& canvas, FVector2f scale = FVector2f::UnitVector)
		: m_Canvas(canvas)
		, m_Scale(scale)
	{}
	
	void Draw(const TArray<USVGElement*>& elements);
	
	// No default ctor
	FSVGRenderer() = delete;
	// No copy
	FSVGRenderer(const Self&) = delete;
	Self& operator=(const Self&) = delete;
	// No move
	FSVGRenderer(Self&&) = delete;
	Self& operator=(Self&&) = delete;
	
private:
	void DrawRect(const FSVGRect& props, const FSVGStyle& style);
	void DrawCircle(const FSVGCircle& props, const FSVGStyle& style);
	void DrawEllipse(const FSVGEllipse& props, const FSVGStyle& style);
	void DrawLine(const FSVGLine& props, const FSVGStyle& style);
	void DrawPolyLine(const FSVGPolyLine& props, const FSVGStyle& style);
	void DrawPolygon(const FSVGPolygon& props, const FSVGStyle& style);
	void DrawPath(const FSVGPath& props, const FSVGStyle& style);
	
	/**
	 * @returns true if the paint styles should be applied.
	 */
	bool ConfigureFill(const FSVGStyle& style);
	
	/**
	 * @returns true if the paint styles should be applied.
	 */
	bool ConfigureStroke(const FSVGStyle& style);
	
	SkCanvas& m_Canvas;
	SkPaint m_Paint;
	SkPath m_Path;
	FVector2f m_Scale;
};

Refactoring the DrawX implementations is a bit tedious but not especially complicated, so I’ll just show one example here and let your imagination fill in the rest. But do hold off on implementing the main Draw dispatcher for now — we’ll be reworking that function in the next section.

<MainModule>/Private/SVG/SVGRenderer.cpp
void FSVGRenderer::DrawRect(const FSVGRect& props, const FSVGStyle& style)
{
	auto rect = SkRect::MakeXYWH(
		props.X * m_Scale.X,
		props.Y * m_Scale.Y,
		props.Width * m_Scale.X,
		props.Height * m_Scale.Y);
	
	if (!FMath::IsNearlyZero(props.Radius.X)
		&& !FMath::IsNearlyZero(props.Radius.Y))
	{
		auto rounded = SkRRect::MakeRectXY(
			rect,
			props.Radius.X * m_Scale.X,
			props.Radius.Y * m_Scale.Y);
		
		if (ConfigureFill(style))
			m_Canvas.drawRRect(rounded, m_Paint);
		
		if (ConfigureStroke(style))
			m_Canvas.drawRRect(rounded, m_Paint);
	}
	else
	{
		if (ConfigureFill(style))
			m_Canvas.drawRect(rect, m_Paint);
		
		if (ConfigureStroke(style))
			m_Canvas.drawRect(rect, m_Paint);
	}
}

The SVG Builder widget now becomes downright spartan compared to its previous form.

<MainModule>/Public/SVG/CanvasSVGBuilder.h
#pragma once

#include "CoreMinimal.h"
#include "CanvasWidget.h"
#include "SVGAttributes.h"

#include "CanvasSVGBuilder.generated.h"

class SkCanvas;
class SkPaint;
class USVGElement;


UCLASS()
class API UCanvasSVGBuilder : public UCanvasWidget
{
	GENERATED_BODY()
	
	using Self = UCanvasSVGBuilder;
	
private:
	UPROPERTY(EditAnywhere, Category="Appearance",
		BlueprintGetter="ViewBox",
		BlueprintSetter="SetViewBox")
	FSVGViewBox _ViewBox;
	
public:
	UPROPERTY(EditAnywhere, Instanced, Category="Appearance")
	TArray<USVGElement*> Elements;
	
	UFUNCTION(BlueprintGetter) const FSVGViewBox& ViewBox() const { return _ViewBox; }
	UFUNCTION(BlueprintSetter) void SetViewBox(const FSVGViewBox& in_viewBox);
	
	TOptional<FVector2D> DesiredSize() const;
	
protected:
	TSharedRef<SWidget> RebuildWidget() override;
	void OnDraw(SkCanvas& canvas, FInt32Vector2 size, FVector2f scale) override;
};
<MainModule>/Private/SVG/CanvasSVGBuilder.cpp
#include "SVG/CanvasSVGBuilder.h"

#include "SCanvasWidget.h"
#include "SVG/SVGRenderer.h"

// ...

TSharedRef<SWidget> UCanvasSVGBuilder::RebuildWidget()
{
	SlateWidget = SNew(SCanvasWidget)
		.OnDraw_UObject(this, &Self::OnDraw)
		.DesiredSizeOverride_UObject(this, &Self::DesiredSize);
	
	return SlateWidget.ToSharedRef();
}

TOptional<FVector2D> UCanvasSVGBuilder::DesiredSize() const
{
	return FVector2D { _ViewBox.Width, _ViewBox.Height };
}

void UCanvasSVGBuilder::OnDraw(SkCanvas& canvas, FInt32Vector2 size, FVector2f)
{
	float width = size.X;
	float height = size.Y;
	
	FSVGRenderer renderer (canvas, FVector2f {
		width / _ViewBox.Width,
		height / _ViewBox.Height,
	});
	
	renderer.Draw(Elements);
}

With the rendering logic suitably abstracted, we can now trivially extend rendering functionality to our SVG asset:

<MainModule>/Public/SVG/SVGAsset.h
// ...
#include "ICanvasRenderer.h"
// ...

UCLASS(BlueprintTYpe)
class API USVGAsset
	: public UObject
	, public ICanvasRenderer
{
	GENERATED_BODY()
	
public:
	// ...
	void OnDraw(SkCanvas& canvas, FInt32Vector2 size, FVector2f scale) override;
};
<MainModule>/Private/SVG/SVGAsset.cpp
// ...

void USVGAsset::OnDraw(SkCanvas& canvas, FInt32Vector2 size, FVector2f)
{
	float width = size.X;
	float height = size.Y;
	
	FSVGRenderer renderer (canvas, FVector2f {
		width / ViewBox.Width,
		height / ViewBox.Height,
	});
	
	renderer.Draw(Children);
}

Improving our SVG implementation

If we want to be able to import even relatively simple graphics from sources like Figma or Adobe Illustrator, we’re going to need to make some core changes to our implementation of the SVG specification to cover some cases that we’re not currently handling correctly.

First, our FSVGStyle struct is missing a critical property, and it’s using the wrong defaults for the ones we have implemented. Secondly, we don’t have any representation for the <g> element , nor are we implementing the style-inheritance behavior which is its primary use case.

First, the defaults: Somewhat counter-intuitively, the fill attribute defaults to black, while stroke defaults to none, and stroke-width defaults to 1.

<MainModule>/SVG/Public/SVGAttributes.h
// ...

USTRUCT(BlueprintType)
struct API FSVGStyle
{
	GENERATED_BODY()
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Appearance")
	FColor FillColor = FColor::Black;
	
	// ...
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Appearance")
	FColor StrokeColor = FColor::Transparent;
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Appearance")
	float StrokeWidth = 1;
};

For the Group element, we’ll add a new class deriving from USVGElement. We don’t need a separate struct for its properties, because it doesn’t have any besides the shared ones. Instead, the class will get a Children field just like the SVG asset itself has.

<MainModule>/SVG/Public/SVGElements.h
// ...

UCLASS(DisplayName="Group")
class API USVGGroupElement : public USVGElement
{
	GENERATED_BODY()
	
public:
	USVGGroupElement(const FObjectInitializer& init)
		: Super(init)
	{
		Type = ESVGElement::Group;
	}
	
	UPROPERTY(EditAnywhere, Instanced, Category="Elements")
	TArray<TObjectPtr<USVGElement>> Children;
};

// ...

Wait, what happened to the tagged unions?

This is probably as good a time as any to mention: Remember all the fuss about tagged unions and data locality from the last entry, where I said we would investigate using an array of tagged unions instead of polymorphic class pointers in our custom asset implementation? And how that was basically the whole justification for separating out the “Properties” structs from the “Element” classes?

Yeah, that ain’t gonna happen. The issue is that the same reflection system that’s responsible for Unreal Editor integrations (and which throws up its hands when it encounters a templated type) is also responsible for serialization, which is how we’re able to save and load a USVGAsset to disk at all. Epic have hard-coded the handling of its common containers like TArray and TMap into Unreal Header Tool’s parser, but handling templates as a general case is not a task they’re interested in undertaking.

It’s possible that we could override some of the inherited UObject behavior to manually (de)serialize those fields instead of relying on UPropertys, and maybe even manually handle editor integration with a Details Customization — but honestly? It’s probably not worth the trouble. Yes, reading from the L1 cache is much faster than reading from system memory, but we’re talking about operations that are measured in nanoseconds either way. We would need an extraordinarily complex SVG to register any real difference between the two, and at that point, our use of Skia’s CPU backend is going to be a much more constraining bottleneck.

However, I do still like the separation between our properties structs and element UClasses. The structs are plain-old data that don’t suffer from the banana / gorilla / jungle problem of inheriting from a gargantuan base class, which makes them much more agile to work with. You can’t even write a custom constructor for a UClass, because only some black magic ritual buried deep within The Engine is permitted to new them up. So despite the unforeseen change of plans, we’re going to forge ahead with the existing architecture instead of merging all of our structs’ fields directly into their parent USVGElements.

Modeling overridable inherited styles

To make nested groups of SVG elements actually useful, we need to make it possible for child elements to inherit styling properties from their parents. Take the example from MDN:

<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
	<!-- Using g to inherit presentation attributes -->
	<g fill="white" stroke="green" stroke-width="5">
		<circle cx="40" cy="40" r="25" />
		<circle cx="60" cy="60" r="25" />
	</g>
</svg>

The code above should render both circles with a green 5px stroke and white fill. However, child elements can also override their inherited style properties:

<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
	<g fill="white" stroke="green" stroke-width="5">
		<circle cx="40" cy="40" r="25" />
		<circle cx="60" cy="60" r="25" stroke="magenta" />
	</g>
</svg>

In that example, the circle in front should be rendered with a magenta outline instead of green. Your first instinct for handling overrides might be to just check whether the child’s style property is defaulted, but that won’t always work. For example:

<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
	<g fill="white" stroke="green" stroke-width="5">
		<circle cx="40" cy="40" r="25" />
		<circle cx="60" cy="60" r="25" fill="black" />
	</g>
</svg>

Now, the circle in front should be filled black, while the one behind it should be filled white. But black is the default fill color, so if we allow the parent style to take precedence wherever the corresponding child style is equal to its default value, we’ll end up doing the wrong thing.

This type of situation is a common challenge when trying to implement a high-level declarative language like HTML or CSS in a lower-level host language like C++, and there’s not necessarily a single “correct” answer. We’re currently representing a set of style properties as a statically-typed struct appended to each element, but the full CSS spec has roughly a zillion valid properties, which would make that approach unwieldy if we needed to implement all of them. Browsers handle this sort of thing with a full-on virtual machine, interpreting the code on-the-fly as the runtime traverses along its execution path. But keep in mind that our goal here isn’t to exhaustively implement the entire spec — we just want to implement enough of it to provide substantial utility, while keeping our runtime overhead minimal and the implementation itself manageable.

What we’d really like for this particular problem is something like optional values for each of our style properties. That would map much more neatly to SVG’s textual representation, where there either is or isn’t a given attribute appended to a given element tag. But just like with the union situation, we can’t use TOptional for a UProperty because it’s a template. (We also wouldn’t necessarily want to have to consult some separate object every time we need to fall back to the default value for a “null” property.)

What I settled on is a set of flags to indicate the properties that were explicitly set for any given style object. The attributes we need to check for are fill, fill-opacity, fill-rule, stroke, stroke-opacity, stroke-width, and finally opacity (which we’re also adding as a new property). This is a little bit ugly, but if we use bitfields instead of full booleans (and reorder the FillRule property for better alignment), we end up only increasing the size of our struct by 4 bytes (on MSVC). That’s a pretty sweet bargain for 8 additional fields! Here’s the revised layout:

<MainModule>/Public/SVG/SVGAttributes.h
// ...

USTRUCT(BlueprintType)
struct API FSVGStyle
{
	GENERATED_BODY()
	
	FSVGStyle()
		: bExplicitFillColor(false)
		, bExplicitFillOpacity(false)
		, bExplicitFillRule(false)
		, bExplicitStrokeColor(false)
		, bExplicitStrokeOpacity(false)
		, bExplicitStrokeWidth(false)
		, bExplicitOpacity(false)
	{}
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Appearance")
	FColor FillColor = FColor::Black;
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Appearance")
	FColor StrokeColor = FColor::Transparent;
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Appearance")
	float StrokeWidth = 1;
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Appearance")
	float Opacity = 1;
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Appearance")
	ESVGFillRule FillRule = ESVGFillRule::NonZero;
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Appearance")
	uint8 bExplicitFillColor:1;
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Appearance")
	uint8 bExplicitFillOpacity:1;
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Appearance")
	uint8 bExplicitFillRule:1;
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Appearance")
	uint8 bExplicitStrokeColor:1;
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Appearance")
	uint8 bExplicitStrokeOpacity:1;
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Appearance")
	uint8 bExplicitStrokeWidth:1;
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Appearance")
	uint8 bExplicitOpacity:1;
};

// ...

I normally dislike the Unreal convention of prefixing booleans with a b, but in this case, since we can’t type the bitfields as bool (another UHT limitation), it definitely helps to clarify the intent. We can also make some adjustments to the UProperty specifiers to keep our Details panels from devolving into an unreadable mess:

<MainModule>/Public/SVG/SVGAttributes.h
// ...

USTRUCT(BlueprintType)
struct API FSVGStyle
{
	// ...
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Appearance",
		meta=(EditCondition="bExplicitFillColor"))
	FColor FillColor = FColor::Black;
	
	// ...
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, meta=(InlineEditConditionToggle))
	uint8 bExplicitFillColor:1;
	
	// ...
};

// ...

Doing that for each corresponding pair of properties makes the Details panel look like this:

Screenshot of the Unreal Editor showing our SVG Style properties rendered in the Details panel. Each property is shown with a checkbox in front of the property name. The checkbox is checked for any properties which have been set explicitly, while fields that are left at their default values are grayed out, with unchecked boxes.

Implementing style inheritance

We should now add style fields to our SVG asset and SVG Builder widget, since the root <svg> element can have its own style attributes that should be inherited by its children.

<MainModule>/Public/SVG/SVGAsset.h
// ...

UCLASS(BlueprintType)
class API USVGAsset
	: public UObject
	, public ICanvasRenderer
{
	GENERATED_BODY()
	
public:
	UPROPERTY(EditAnywhere, Category="Appearance")
	FSVGViewBox ViewBox;
	
	UPROPERTY(EditAnywhere, Category="Appearance")
	FSVGStyle Style;
	
	UPROPERTY(EditAnywhere, Instanced, Category="Elements")
	TArray<TObjectPtr<USVGElement>> Children;
	
	void OnDraw(SkCanvas& canvas, FInt32Vector2 size, FVector2f scale) override;
};

And we’ll add a m_RootStyle field to our SVG renderer, with a matching constructor parameter.

<MainModule>/Public/SVG/SVGRenderer.h
// ...

class API FSVGRenderer
{
	using Self = FSVGRenderer;
	
public:
	FSVGRenderer(
		SkCanvas& canvas,
		const FSVGStyle& rootStyle = {},
		FVector2f scale = FVector2f::UnitVector
	)
		: m_Canvas(canvas)
		, m_RootStyle(rootStyle)
		, m_Scale(scale)
	{}
	
	// ...
private:
	// ...
	FSVGStyle m_RootStyle;
	// ...
};

Now, when we call one of our renderer’s DrawX(props, style) functions, we’ll pass it the result of merging the element’s style overrides into the root style object. Let’s go ahead and add a utility function to our style struct to compute that merged result.

<MainModule>/Public/SVG/SVGAttributes.h
// ...

USTRUCT(BlueprintType)
struct API FSVGStyle
{
	// ...
	
	FORCEINLINE FSVGStyle WithOverrides(const FSVGStyle& overrides) const
	{
		FSVGStyle result = *this;
		
		if (overrides.bExplicitFillColor)
			result.FillColor = overrides.FillColor;
		else if (overrides.bExplicitFillOpacity)
			result.FillColor.A = overrides.FillColor.A;
		
		if (overrides.bExplicitFillRule)
			result.FillRule = overrides.FillRule;
		
		if (overrides.bExplicitStrokeColor)
			result.StrokeColor = overrides.StrokeColor;
		else if (overrides.bExplicitStrokeOpacity)
			result.StrokeColor.A = overrides.StrokeColor.A;
		
		if (overrides.bExplicitStrokeWidth)
			result.StrokeWidth = overrides.StrokeWidth;
		
		if (overrides.bExplicitOpacity)
			result.Opacity = overrides.Opacity;
		
		return result;
	}
};

// ...

In the renderer, I’m going to split the switch statement responsible for dispatching our individual draw calls into a separate function from the logic that handles iteration, setup and cleanup. This is arguably an excessive amount of indirection, but I really prefer small, narrowly-defined functions for maintainability. As-is, for example, it’s easy to completely miss the single line of cleanup that follows the ~40 lines of switch cases unless you’re really looking for it. Since we’re adding a new case for the Group element and making the style declaration less trivial, this feels like a good opportunity to go ahead and slice things up.

<MainModule>/Public/SVG/SVGRenderer.h
// ...

class API FSVGRenderer
{
	using Self = FSVGRenderer;
	
public:
	// ...
	void Draw(const TArray<USVGElement*>& elements);
	void Draw(const USVGElement* element, const FSVGStyle& style);
	// ...
};
<MainModule>/Private/SVG/SVGRenderer.cpp
// ...

void FSVGRenderer::Draw(const TArray<USVGElement*>& elements)
{
	m_Canvas.clear(SK_ColorTRANSPARENT);
	m_Paint.setAntiAlias(true);
	
	for (USVGElement* element : elements)
	{
		if (!element->IsValidLowLevel())
			continue;
		
		FSVGStyle style = m_RootStyle.WithOverrides(element->Style);
		
		Draw(element, style);
		
		m_Path.rewind();
	}
}

// ...

To add support for our Group element, we’ll just do a little bit of recursion. We’re basically just treating the Group element as if it were its own SVG, but with an inherited viewBox.

<MainModule>/Private/SVG/SVGRenderer.cpp
// ...

void FSVGRenderer::Draw(const USVGElement* element, const FSVGStyle& style)
{
	switch (element->Type)
	{
		case ESVGElement::Group:
		{
			FSVGRenderer recursive (m_Canvas, style, m_Scale);
			recursive.Draw(Cast<USVGGroupElement>(element)->Children);
			break;
		}
		
		default:
		case ESVGElement::None:
			break;
		
		case ESVGElement::Rect:
			DrawRect(Cast<USVGRectElement>(element)->Props, style);
			break;
		
		case ESVGElement::Circle:
			DrawCircle(Cast<USVGCircleElement>(element)->Props, style);
			break;
		
		case ESVGElement::Ellipse:
			DrawEllipse(Cast<USVGEllipseElement>(element)->Props, style);
			break;
		
		case ESVGElement::Line:
			DrawLine(Cast<USVGLineElement>(element)->Props, style);
			break;
		
		case ESVGElement::PolyLine:
			DrawPoly(Cast<USVGPolyLineElement>(element)->Props, style, false);
			break;
		
		case ESVGElement::Polygon:
			DrawPoly(Cast<USVGPolygonElement>(element)->Props, style, true);
			break;
		
		case ESVGElement::Path:
			DrawPath(Cast<USVGPathElement>(element)->Props, style);
			break;
	}
}

// ...

One more thing: you may have noticed that I sneakily replaced the DrawPolyLine and DrawPolygon function calls above with a single DrawPoly call that takes a third argument. If you take a look at the previous DrawPolyLine and DrawPolygon implementations, and note that FSVGPolyLine and FSVGPolygon are literally the same structs with different names, I’m sure you can connect the dots.

Implementing opacity

Lest we forget — we added a new Opacity field to our style object earlier without a lot of fanfare, but we’re not currently doing anything with it. We’ll handle it in our ConfigureFill and ConfigureStroke methods:

<MainModule>/Private/SVG/SVGRenderer.cpp
// ...

bool FSVGRenderer::ConfigureFill(const FSVGStyle& style)
{
	if (!style.FillColor.A
		|| FMath::IsNearlyZero(style.Opacity))
	{
		return false;
	}
	
	m_Paint.setStyle(SkPaint::kFill_Style);
	
	if (style.Opacity < 1.f)
	{
		auto fillColor = FLinearColor::FromSRGBColor(style.FillColor);
		fillColor.A *= style.Opacity;
		
		m_Paint.setColor(fillColor.ToFColorSRGB().ToPackedARGB());
	}
	else
	{
		m_Paint.setColor(style.FillColor.ToPackedARGB());
	}
	
	return true;
}

bool FSVGRenderer::ConfigureStroke(const FSVGStyle& style)
{
	if (!style.StrokeColor.A
		|| FMath::IsNearlyZero(style.StrokeWidth)
		|| FMath::IsNearlyZero(style.Opacity))
	{
		return false;
	}
	
	m_Paint.setStyle(SkPaint::kStroke_Style);
	m_Paint.setStrokeWidth(style.StrokeWidth * m_Scale.X); // TODO
	
	if (style.Opacity < 1.f)
	{
		auto strokeColor = FLinearColor::FromSRGBColor(style.StrokeColor);
		strokeColor.A *= style.Opacity;
		
		m_Paint.setColor(strokeColor.ToFColorSRGB().ToPackedARGB());
	}
	else
	{
		m_Paint.setColor(style.StrokeColor.ToPackedARGB());
	}
	
	return true;
}

To be honest, I’m not 100% sure if what I’m doing there is correct in terms of gamma and color space. The other option would be to do something like this (which would actually be more efficient since we’re not converting to linear space and back):

FColor fillColor = style.FillColor;
float a = fillColor.A / 255.f;
fillColor.A = FMath::RoundToInt((a * style.Opacity) * 255.f);

The result of the solution as-written looks fine to my eyes, but I haven’t done any rigorous testing and I’m not an expert on color-space conversions, so let me know if I got it wrong.

Importing and parsing SVG files

With all of that done, we’re finally ready to circle back to our Editor module and create an import factory for reading SVG files and parsing them to our asset type.

Creating the import factory

While trying to figure out how to do this, I came across several examples in the engine of single UFactory classes that implement both the “create new” and file importing functionality, but I had some trouble getting the engine to invoke the correct function override when I tried that approach, so I’m going to make a separate class for the import factory.

<EditorModule>/Public/SVG/SVGAsset_Editor.h
// ...

UCLASS()
class USVGImportFactory : public UFactory
{
	GENERATED_BODY()
	
public:
	USVGImportFactory();
	
	UObject* FactoryCreateFile(
		UClass* uClass,
		UObject* outer,
		FName name,
		EObjectFlags flags,
		const FString& filename,
		const TCHAR* parms,
		FFeedbackContext* warn,
		bool& out_cancelled)
		override;
};

// ...

We have two override options for importing from a file: FactoryCreateFile, and FactoryCreateText. In theory, the latter is simpler to implement — all we have to do is set the bText flag, and the base factory implementation of FactoryCreateFile will read that file and then pass us the text content for processing. Unfortunately, that’s not going to be ideal for our use case (which I’ll explain shortly), so we’ll implement FactoryCreateFile instead.

Just like the “create new” factory, we’ll configure our instance in the constructor:

<EditorModule>/Private/SVG/SVGAsset_Editor.cpp
// ...

USVGImportFactory::USVGImportFactory()
{
	bCreateNew = false;
	bEditorImport = true;
	bText = true;
	SupportedClass = USVGAsset::StaticClass();
	Formats.Add("svg;Scalable Vector Graphic file");
}

// ...

The Formats field indicates which file formats the factory supports importing. Frankly, I have no idea where, how or why the text after the semicolon is actually used, but all the cool kids (read: all the examples I found in the engine) were doing it, so I filled it in with a reasonable value. The part before the semicolon is the file extension.

Implementing the SVG parser

Before we implement FactoryCreateFile, we need to sketch out the bones of our parser. We’ll make use of a class I found in the API reference, FFastXml, which only has one consumer in the actual engine code, so some caution is probably warranted here. I have no idea how widely used Electra Media Player is (and by extension the XML parser), so it’s possible it hasn’t seen a ton of exhaustive testing. That said, parsing XML isn’t exactly rocket science, and the class is shockingly well-documented (at least by Unreal standards) which inspires confidence.

FFastXml’s entry-point can take either a full path to an XML file, or the full contents of an XML file as a mutable TCHAR* buffer. It’s the mutability requirement that complicates the FactoryCreateText option for our factory, because that method takes the file contents as a const TCHAR*. We could const_cast it and hope for the best, or copy it into a new buffer to pass it safely, but just passing the file path to FFastXml and letting it take over from there is the simpler (and likely faster) option.

Our parser class implements IFastXmlCallback, which is how FFastXml interfaces with our custom parsing behavior, and FGCObject to prevent our temporary USVGAsset instance from being garbage-collected as long as the parser instance is alive. For our own entry-point, we’ll expose a static Parse method, which will instantiate our parser instance and pass it to FFastXml.

<EditorModule>/Private/SVG/SVGParser.h
#pragma once

#include "CoreMinimal.h"
#include "FastXml.h"

class USVGAsset;


class FSVGParser
	: public IFastXmlCallback
	, public FGCObject
{
	using Self = FSVGParser;
	
public:
	static USVGAsset* Parse(
		const FString& filename,
		FFeedbackContext* warn,
		bool& out_cancelled);
	
private:
	FSVGParser(USVGAsset* asset)
		: m_Asset(asset)
	{}
	
public:
	virtual ~FSVGParser() = default;
	
	// FGCObject interface
	void AddReferencedObjects(FReferenceCollector& collector) override;
	FString GetReferencerName() const override { return "FSVGParser"; }
	
protected:
	// IFastXmlCallback interface
	bool ProcessXmlDeclaration(const TCHAR* data, int32 line) override { return true; }
	bool ProcessElement(const TCHAR* name, const TCHAR* data, int32 line) override;
	bool ProcessAttribute(const TCHAR* name, const TCHAR* value) override;
	bool ProcessClose(const TCHAR* element) override;
	bool ProcessComment(const TCHAR* comment) override { return true; }
	
private:
	USVGAsset* m_Asset = nullptr;
};

With that, we have enough information to fill out the FactoryCreateFile implementation.

The USVGAsset object returned by our parser will be created as a temporary object (with no object flags and GetTransientPackage() as its outer), so returning it directly will not result in a new asset file being created. (Don’t ask me how long it took to figure that one out. There may have been some weeping and/or gnashing of teeth involved.) Instead, our import factory will need to call NewObject with the correct outer argument, name, and flags, and pass the parser’s return value as its “template.” This will write the new object to disk and populate it with all of the same UProperty values as the object returned from the parser.

I have no idea what parms is and the API reference is no help. Is that an unusual abbreviation for “parameters”? Is it some really common thing that I’ve never heard of because I’m just a naive web developer operating way out of my depth? Every other FactoryCreateFile implementation I looked at (including the base implementation) calls ParseParms on that argument when the main operation succeeds, so we’ll do it too. (ParseParms, for what it’s worth, is annotated with a doc comment that reads “Import an object from a file,” which raises more questions than it answers. In any event, this implementation works, so we’ll just accept the mystery and move on.)

<EditorModule>/Private/SVG/SVGAsset_Editor.cpp
// ...

UObject* USVGImportFactory::FactoryCreateFile(
	UClass* uClass,
	UObject* outer,
	FName name,
	EObjectFlags flags,
	const FString& filename,
	const TCHAR* parms,
	FFeedbackContext* warn,
	bool& out_cancelled)
{
	AdditionalImportedObjects.Empty();
	
	if (USVGAsset* tmplate = FSVGParser::Parse(filename, warn, out_cancelled))
	{
		ParseParms(parms);
		return NewObject<USVGAsset>(outer, name, flags, tmplate);
	}
	
	UE_LOG(LogTemp, Error, TEXT("Failed to import SVG \"%s\""), *filename);
	return nullptr;
}

// ...

In our parser’s entry-point, we’ll create the new USVGAsset object for the return value. We’ll instantiate the parser instance and pass it to FFastXml’s entry-point. Depending on the result, we’ll either return the new asset, or log an error and return nullptr.

<EditorModule>/Private/SVG/SVGParser.cpp
#include "SVG/SVGParser.h"

#include "SVG/SVGAsset.h"


USVGAsset* FSVGParser::Parse(
	const FString& filename,
	FFeedbackContext* warn,
	bool& out_cancelled)
{
	auto* asset = NewObject<USVGAsset>();
	Self parser (asset);
	
	bool showSlowTaskDialog = true;
	bool showCancelButton = true;
	
	FText err_message;
	int32 err_lineNumber;
	
	if (FFastXml::ParseXmlFile(
		&parser, *filename, nullptr,
		warn, showSlowTaskDialog, showCancelButton,
		err_message, err_lineNumber))
	{
		return asset;
	}
	
	UE_LOG(LogTemp, Error,
		TEXT("SVG parser failed @ line %i: %s"),
		err_lineNumber,
		*err_message.ToString());
	
	return nullptr;
}

void FSVGParser::AddReferencedObjects(FReferenceCollector& collector)
{
	collector.AddReferencedObject(m_Asset);
}

Mapping out the architecture

At this point, let’s take a step back and think about how our parser is going to work. FFastXml will read in the file and invoke our ProcessX implementations as the corresponding symbols are encountered. For example, given this SVG:

<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
	<g fill="white" stroke="green" stroke-width="5">
		<circle cx="40" cy="40" r="25" />
		<circle cx="60" cy="60" r="25" />
	</g>
</svg>

Our parser will receive the following method invocations:

ProcessElement("svg", ...);
	ProcessAttribute("viewBox", "0 0 100 100");
	ProcessAttribute("xmlns", "http://www.w3.org/2000/svg");
	ProcessElement("g", ...);
		ProcessAttribute("fill", "white");
		ProcessAttribute("stroke", "green");
		ProcessAttribute("stroke-width", "5");
		ProcessElement("circle", ...);
			ProcessAttribute("cx", "40");
			ProcessAttribute("cy", "40");
			ProcessAttribute("r", "25");
		ProcessClose("circle");
		ProcessElement("circle", ...);
			ProcessAttribute("cx", "60");
			ProcessAttribute("cy", "60");
			ProcessAttribute("r", "25");
		ProcessClose("circle");
	ProcessClose("g");
ProcessClose("svg");

At each ProcessAttribute event, we’ll need to know which element we’re currently operating on, which we can only do by keeping track of that state in our class. Similarly, we’ll need to know whose Children array to append the new element to when we receive a ProcessElement event.

We can’t just track the current element in a simple USVGElement* field, because then we’d lose track of which element should become active when a child element, like the first circle above, is closed. One totally valid option would be to add a pointer back to the parent to our USVGElement class, like this:

// ...

UCLASS(Abstract, EditInlineNew)
class API USVGElement : public UObject
{
	GENERATED_BODY()

public:
	UPROPERTY()
	ESVGElement Type = ESVGElement::None;
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Appearance")
	FSVGStyle Style;
	
	// NOTE: Just an example! Don't add this!
	UPROPERTY(Transient)
	TWeakObjectPtr<USVGElement> Parent = nullptr;
};

// ...

But I don’t love needing to further bloat our class layout just for the sake of the parser, since we don’t have any other use case for that field currently. It would also always be nullptr after the initial parse, unless we somehow serialized it.

Instead, we’ll use a Stack<USVGElement*> to track our current location in the tree. In the example event sequence above, the indentation level represents the pushes and pops we’ll need, and at any given event, the target for a new element or attribute will be at the top of the stack.

Well… mostly. USVGAsset doesn’t derive from USVGElement, so we can’t put it on the stack. That is, perhaps, an oversight that we could consider correcting. But USVGAsset has some special properties that makes it quite different from the rest of the USVGElement family — most importantly, it can’t be an SVG child element — so I’d rather not do that. Since the root of the tree must be a USVGAsset, and a USVGAsset can only be the root of the tree, we’ll know that we’re operating on the root asset if the stack is empty.

In addition to the stack, it’ll also be handy to have a separate field to track the type of the element we’re currently operating on, so we don’t have to constantly dereference the top of the stack just to access its Type field.

<EditorModule>/Private/SVG/SVGParser.h
// ...

class FSVGParser
	: public IFastXmlCallback
	, public FGCObject
{
	using Self = FSVGParser;
	// ...
private:
	std::stack<USVGElement*> m_Stack;
	ESVGElement m_CurType = ESVGElement::None;
	USVGAsset* m_Asset = nullptr;
};

(Small side note: yes, that is a C++ standard library container, not an Unreal container. Unreal’s Containers module doesn’t have a purpose-built stack type, but after writing this implementation, I later discovered that the common recommendation is to just use a TArray since it has Push, Pop, and Last methods with the expected performance characteristics. However, since I’ve already written the implementation with std::stack and this is an editor-only module anyway, I’m going to leave it as-written.)

For ProcessAttribute, we’ll want some sort of dynamic dispatch behavior, similar to the DrawX methods in our renderer, so we don’t need to clutter a single method body with a massive inline switch statement.

Once we’ve dispatched to the correct attribute handler, we need a way to identify the specific struct field to write to (and how to interpret its value string), but comparing strings is slow (and inconvenient, since we’re dealing with raw C strings). Unreal’s FName class is intended as a solution to that problem. I won’t get into the weeds of how it’s implemented (mainly because I don’t actually know all the details), but suffice it to say that both the storage and comparison of FNames is highly optimized compared to other string representations. For that reason, we’ll operate on FNames instead of const TCHAR*s whenever it makes sense to do so.

<EditorModule>/Private/SVG/SVGParser.h
// ...

class FSVGParser
	: public IFastXmlCallback
	, public FGCObject
{
	using Self = FSVGParser;
	// ...
private:
	bool ProcessRootAttr(const FName& name, const TCHAR* value);
	bool ProcessRectAttr(const FName& name, const TCHAR* value);
	bool ProcessCircleAttr(const FName& name, const TCHAR* value);
	bool ProcessEllipseAttr(const FName& name, const TCHAR* value);
	bool ProcessLineAttr(const FName& name, const TCHAR* value);
	bool ProcessPolyLineAttr(const FName& name, const TCHAR* value);
	bool ProcessPolygonAttr(const FName& name, const TCHAR* value);
	bool ProcessPathAttr(const FName& name, const TCHAR* value);
	// ...
};

In addition to attributes for specific elements, there are also style attributes that can exist on any element. We’ll add a method for handling them, and a helper function for identifying them.

<EditorModule>/Private/SVG/SVGParser.h
// ...

class FSVGParser
	: public IFastXmlCallback
	, public FGCObject
{
	using Self = FSVGParser;
	// ...
private:
	bool ProcessStyleAttr(const FName& name, const TCHAR* value);
	// ...
	static bool IsStyleAttr(const FName& name);
};

When we encounter an attribute we don’t recognize, it’ll be a good idea to make some noise about it in the Output Log instead of just quietly ignoring it, so we’ll add a method to handle that too.

<EditorModule>/Private/SVG/SVGParser.h
// ...

class FSVGParser
	: public IFastXmlCallback
	, public FGCObject
{
	using Self = FSVGParser;
	// ...
private:
	// ...
	bool LogUnknownAttr(const FName& name, const TCHAR* value) const;
};

Finally, it’ll be convenient to have a lookup table of element names to their corresponding enum types for the ProcessElement method.

<EditorModule>/Private/SVG/SVGParser.h
// ...

class FSVGParser
	: public IFastXmlCallback
	, public FGCObject
{
	using Self = FSVGParser;
	// ...
private:
	// ...
	inline static const TMap<FName, ESVGElement> k_TypeLookup {
		{ "svg", ESVGElement::Root },
		{ "g", ESVGElement::Group },
		{ "rect", ESVGElement::Rect },
		{ "circle", ESVGElement::Circle },
		{ "ellipse", ESVGElement::Ellipse },
		{ "line", ESVGElement::Line },
		{ "polyline", ESVGElement::PolyLine },
		{ "polygon", ESVGElement::Polygon },
		{ "path", ESVGElement::Path },
	};
};

Processing elements

Now we can start implementing these methods, beginning with ProcessElement. We’ll start by creating an FName from the input string, and bailing early with a warning log if it’s unrecognized.

<EditorModule>/Private/SVG/SVGParser.cpp
// ...

bool FSVGParser::ProcessElement(const TCHAR* nameStr, const TCHAR* data, int32 line)
{
	FName name (nameStr);
	
	if (!k_TypeLookup.Contains(name))
	{
		UE_LOG(LogTemp, Warning,
			TEXT("Unrecognized element <%s/> -- ignoring"),
			nameStr);
		
		return true;
	}
	
	// TODO
}

If it’s the root <svg/>, we can just set our type state and bail, since we already have our root asset object and we can’t add it to the stack.

<EditorModule>/Private/SVG/SVGParser.cpp
// ...

bool FSVGParser::ProcessElement(const TCHAR* nameStr, const TCHAR* data, int32 line)
{
	// ...
	
	ESVGElement type = k_TypeLookup[name];
	
	if (type == ESVGElement::Root)
	{
		m_CurType = type;
		return true;
	}
	
	// TODO
}

Otherwise, we’ll prepare to append the new element to the tree by identifying its parent.

<EditorModule>/Private/SVG/SVGParser.cpp
// ...

bool FSVGParser::ProcessElement(const TCHAR* nameStr, const TCHAR* data, int32 line)
{
	// ...
	
	USVGElement* node = nullptr;
	UObject* outer = GetTransientPackage();
	
	if (!m_Stack.empty())
	{
		// Closing an element pops it from the stack, so this would mean we've
		// tried to add a child to an element that shouldn't have children
		if (m_CurType != ESVGElement::Group)
		{
			UE_LOG(LogTemp, Error,
				TEXT("Unexpected child <%s/> found under <%s/>, which cannot have children!"),
				*ToString(type),
				*ToString(m_CurType));
			
			return false;
		}
		
		outer = m_Stack.top();
	}
	else
	{
		outer = m_Asset;
	}
	
	// TODO
}

Here’s that ToString helper function for the error log:

<MainModule>/Public/SVG/SVGElementProperties.h
FORCEINLINE FString ToString(ESVGElement e)
{
	switch (e)
	{
		case ESVGElement::None: return "NONE";
		case ESVGElement::Root: return "svg";
		case ESVGElement::Group: return "g";
		case ESVGElement::Rect: return "rect";
		case ESVGElement::Circle: return "circle";
		case ESVGElement::Ellipse: return "ellipse";
		case ESVGElement::Line: return "line";
		case ESVGElement::PolyLine: return "line";
		case ESVGElement::Polygon: return "polygon";
		case ESVGElement::Path: return "path";
		default: return "UNKNOWN";
	}
}

Finally we can instantiate the appropriate element type, append it to its parent, and push it onto the stack.

<EditorModule>/Private/SVG/SVGParser.cpp
// ...

bool FSVGParser::ProcessElement(const TCHAR* nameStr, const TCHAR* data, int32 line)
{
	// ...
	
	switch (type)
	{
		case ESVGElement::Group:
			node = NewObject<USVGGroupElement>(outer);
			break;
		
		case ESVGElement::Rect:
			node = NewObject<USVGRectElement>(outer);
			break;
		
		case ESVGElement::Circle:
			node = NewObject<USVGCircleElement>(outer);
			break;
		
		case ESVGElement::Ellipse:
			node = NewObject<USVGEllipseElement>(outer);
			break;
		
		case ESVGElement::Line:
			node = NewObject<USVGEllipseElement>(outer);
			break;
		
		case ESVGElement::PolyLine:
			node = NewObject<USVGPolyLineElement>(outer);
			break;
		
		case ESVGElement::Polygon:
			node = NewObject<USVGPolygonElement>(outer);
			break;
		
		case ESVGElement::Path:
			node = NewObject<USVGPathElement>(outer);
			break;
		
		default:
			check(false);
	}
	
	m_CurType = type;
	
	if (!m_Stack.empty())
	{
		// TODO: Eventually there will be other valid parent types
		auto* parent = CastChecked<USVGGroupElement>(outer);
		parent->Children.Add(node);
	}
	else
	{
		m_Asset->Children.Add(node);
	}
	
	m_Stack.push(node);
	
	return true;
}

ProcessAttribute will be a very straightforward dispatcher.

<EditorModule>/Private/SVG/SVGParser.cpp
// ...

bool FSVGParser::ProcessAttribute(const TCHAR* nameStr, const TCHAR* value)
{
	FName name (nameStr);
	
	if (m_CurType == ESVGElement::Group || IsStyleAttr(name))
		return ProcessStyleAttr(name, value);
	
	switch (m_CurType)
	{
		default:
		case ESVGElement::None:
			return true;
		
		case ESVGElement::Root:
			return ProcessRootAttr(name, value);
		
		case ESVGElement::Rect:
			return ProcessRectAttr(name, value);
		
		case ESVGElement::Circle:
			return ProcessCircleAttr(name, value);
		
		case ESVGElement::Ellipse:
			return ProcessEllipseAttr(name, value);
		
		case ESVGElement::Line:
			return ProcessLineAttr(name, value);
		
		case ESVGElement::PolyLine:
			return ProcessPolyLineAttr(name, value);
		
		case ESVGElement::Polygon:
			return ProcessPolygonAttr(name, value);
		
		case ESVGElement::Path:
			return ProcessPathAttr(name, value);
	}
}

In ProcessClose, we just need to pop the current element from the top of the stack and update our current-type state accordingly.

<EditorModule>/Private/SVG/SVGParser.cpp
// ...

bool FSVGParser::ProcessClose(const TCHAR* element)
{
	if (!m_Stack.empty())
	{
		m_Stack.pop();
		
		if (!m_Stack.empty())
			m_CurType = m_Stack.top()->Type;
		else
			m_CurType = ESVGElement::Root;
	}
	else
	{
		m_CurType = ESVGElement::None;
	}
	
	return true;
}

Now we can start processing attributes. First though, I’m going to define all the attribute names we currently support, both to make the FName comparisons a bit faster and to reduce the likelihood of headaches from unnoticed typos.

<EditorModule>/Private/SVG/SVGParser.cpp
// ...

namespace SVGNames {

const FName k_Fill = "fill";
const FName k_FillOpacity = "fill-opacity";
const FName k_Stroke = "stroke";
const FName k_StrokeOpacity = "stroke-opacity";
const FName k_StrokeWidth = "stroke-width";
const FName k_Opacity = "opacity";
const FName k_FillRule = "fill-rule";
const FName k_NonZero = "nonzero";
const FName k_EvenOdd = "evenodd";
const FName k_ViewBox = "viewBox";
const FName k_X = "x";
const FName k_Y = "y";
const FName k_Width = "width";
const FName k_Height = "height";
const FName k_RX = "rx";
const FName k_RY = "ry";
const FName k_CX = "cx";
const FName k_CY = "cy";
const FName k_R = "r";
const FName k_X1 = "x1";
const FName k_Y1 = "y1";
const FName k_X2 = "x2";
const FName k_Y2 = "y2";
const FName k_Points = "points";
const FName k_D = "d";

} // namespace SVGNames

Style attributes are the most interesting case, so we’ll look at that first:

<EditorModule>/Private/SVG/SVGParser.cpp
// ...

bool FSVGParser::IsStyleAttr(const FName& name)
{
	using namespace SVGNames;
	
	return (name == k_Fill
		|| name == k_FillOpacity
		|| name == k_Stroke
		|| name == k_StrokeWidth
		|| name == k_StrokeOpacity
		|| name == k_Opacity
		|| name == k_FillRule);
}

bool FSVGParser::ProcessStyleAttr(const FName& name, const TCHAR* value)
{
	using namespace SVGNames;
	
	FSVGStyle& style = m_Stack.empty()
		? m_Asset->Style
		: m_Stack.top()->Style;
	
	if (name == k_Fill || name == k_Stroke)
	{
		// TODO: Parse color
	}
	
	// TODO
}

Parsing CSS colors

The supported syntax for CSS color values (and by extension, SVG color values) is pretty expansive . Realistically, we’re expecting our imported assets to be created in some software tool like Figma or Illustrator, so we don’t really need to support the whole range. I would wager that virtually all of these tools stick to two or three representations — likely hex, keywords, and maybe RGB(A) with integral 0-255 values — so those are the three representations that we’ll aim for.

We’ll create another static helper method for the task, and then flesh it out piece by piece.

<EditorModule>/Private/SVG/SVGParser.h
// ...

class FSVGParser
	: public IFastXmlCallback
	, public FGCObject
{
	using Self = FSVGParser;
	// ...
private:
	// ...
	static bool ParseColor(const TCHAR* value, FColor& out_result);
}
<EditorModule>/Private/SVG/SVGParser.cpp
// ...
#include "Internationalization/Regex.h"
// ...

bool FSVGParser::ParseColor(const TCHAR* value, FColor& out_result)
{
	// TODO
}

RGB(A) syntax

Starting with the most interesting case, we’ll use Unreal’s Regex module to parse RGB(A) values. Unfortunately, regular expressions have accumulated a bit of a bad rap over the years, perhaps most famously immortalized by this (admittedly funny) quote from Jamie Zawinski:

Some people, when confronted with a problem, think “I know, I’ll use regular expressions.” Now they have two problems.

So I think this warrants a bit of justification, though I won’t spill too much ink over it because it’s a big topic that would be better covered elsewhere.

By my estimation, the most common misgivings about regex stem from one or more of:

  • Concerns about their performance (when misused)
  • Concerns about their readability (in general)
  • Headaches encountered by less experienced developers when trying to shoehorn them into problems for which they’re not a good fit

(And to qualify that third point, by “less experienced” I’m referring pretty specifically to experience with parsers and the formal language theory behind them, not general experience with software engineering more broadly.)

The first and third points above objectively do not apply here. In fact, a well-designed (and well-implemented ) regular expression would represent not just an adequate solution, but an optimal solution to this particular problem. That’s because the formal grammar that specifies the RGB(A) syntax is a regular grammar, meaning that, taken in isolation, it describes a regular language, which is exactly what regular expressions were designed for. A regular expression is just a concise syntax for constructing a deterministic finite-state machine designed specifically to match that particular pattern for any given string in O(n)O(n) time and O(1)O(1) space.

The second point, about readability, is a fair one, but there are some small steps we can take to ease the burden of writing (and reading) the pattern we’ll need here.

Let’s start by inlining the formal grammar from MDN as a comment in our function body, with some modifications to cover the specific forms we intend to support.

<EditorModule>/Private/SVG/SVGParser.cpp
// ...

bool FSVGParser::ParseColor(const TCHAR* value, FColor& out_result)
{
	/*
	RGB(A) values
	-----------------------------------------------------------------------------
	<rgb(a)>
		= `rgb` [`a`]? `(` <byte> [ [ \s ]+ <byte> ]{2} [ `/` <alpha> ]? `)`
		| `rgb` [`a`]? `(` <byte> [ <comma> <byte> ]{2} [ <comma> <alpha> ]? `)`
		
	<byte>
		= [`0`...`9`]+
		
	<comma>
		= `,` [ \s ]*
		
	<alpha>
		= [`0`...`9`]* `.` [`0`...`9`]+
		| [`0`...`9`]+ [ `.` [`0`...`9`]* ]?
	*/
	
	// TODO
}

Formal grammar specifications typically ignore whitespace, but our regex pattern will need to account for them, so I explicitly included them in our grammar definition above. For the meta-syntax, I’ve used backticks to delimit literal characters, angle braces for the named rules, square brackets for grouping, the standard regex quantifiers, and ellipses to denote a range.

Examples of the variations that grammar describes are:

rgb(0 128 255)
rgb(0 128 255 / 0.5)

rgba(0 128 255)
rgba(0 128 255 / 0.5)

rgb(0, 128, 255)
rgba(0, 128, 255, 0.5)

The first four examples are specified by the (quite new) CSS Color Module level 4, which we include mainly for future proofing, while the last two have been in the spec since level 2 (ca. 2003) and are much more likely to actually be encountered. (All versions of the spec since level 2 also support percentages for the R, G and B values, e.g. rgb(0%, 50%, 100%), but I have personally never witnessed that form in actual use, so I very much doubt we need to worry about it.)

Unreal’s FRegexPattern constructor takes a const FString& as its first argument, so we can actually build the string from named variables instead of needing to supply one long (and confounding) string-literal, which should help with readability.

We can also simplify some of our rules quite a bit if we assume that we’ll only be receiving well-formed inputs. For example, we can use [.0-9]+ to match the the optional alpha value, because we can be reasonably confident that a venerable tool like Figma or Illustrator is not going to try to hand us a number with multiple decimal points or no digits.

Another reason regex is a great fit for this use case is that we can specify capture groups, which are parenthesis-delimited sub-expressions in our expression that can be separately extracted from the match result. In most implementations, captured sub-matches are accessed by an index starting with 1 (because the index 0 is reserved for the full match). We’ll use those to grab the R, G, B and A values out of the match.

Putting it all together (and pretending we can use a whitespace-insignificant syntax), our final expression will look like this:

rgba?\(
	( [0-9]+ ) [,\s] \s*
	( [0-9]+ ) [,\s] \s*
	( [0-9]+ )
	(?:
		\s* [/,] \s*
		( [.0-9]+ )
	)?
\)

To briefly recap the syntax there, in case you’re not familiar:

  • [] delimits a set of characters, with - as the range operator, so [0-9] is equivalent to the interval [0,9][0,9].
  • () delimits a capture group, as explained just above.
  • (?: and ) delimit a non-capturing group — a way to group a sub-expression without creating an indexed capture for the match. (We’re using it here so we can mark the entire sub-expression containing the alpha value as optional.)
  • ?, * and + are “quantifiers” that modify the preceding sub-expression.
    • ? means 0 or 1 (i.e., optional).
    • * means 0 or more (optional, potentially repeating).
    • + means 1 or more (non-optional, potentially repeating).
  • \s is a “character class” (basically a pre-defined keyword) which is shorthand for the set of all whitespace characters (equivalent to [ \t\r\n]).
  • \( and \) are simply escapes, so we can match the literal ’(’ and ’)’ characters instead of creating a match group.

Let’s go ahead and translate that to C++:

<EditorModule>/Private/SVG/SVGParser.cpp
// ...

bool FSVGParser::ParseColor(const TCHAR* value, FColor& out_result)
{
	// ...
	static const TCHAR* byte = TEXT("([0-9]+)");
	static const TCHAR* sep = TEXT("[,\\s]\\s*");
	static const TCHAR* alpha = TEXT("([.0-9]+)");
	static const auto alphaSubExpr = FString::Printf(
		TEXT("(?:\\s*[/,]\\s*%s)?"),
		alpha);
	
	static const FRegexPattern rgbPattern {
		FString::Printf(
			TEXT("rgba?\\(")
				TEXT("%s%s") // R
				TEXT("%s%s") // G
				TEXT("%s")   // B
				TEXT("%s")   // A?
			TEXT("\\)"),
			byte, sep,
			byte, sep,
			byte,
			*alphaSubExpr)
	};
	
	// TODO
}

Hmm. So admittedly, that’s still kind of a mess. The combined form (as a raw string literal) would be this, if you find it more palatable:

<EditorModule>/Private/SVG/SVGParser.cpp
bool FSVGParser::ParseColor(const TCHAR* value, FColor& out_result)
{
	// ...
	static const FRegexPattern rgbPattern {
		TEXT(R"#(rgba?\(([0-9]+)[,\s]\s*([0-9]+)[,\s]\s*([0-9]+)(?:\s*[/,]\s*([.0-9]+))?\))#")
	};
	
	// TODO
}

One advantage of the combined form is that we can easily paste it into a tool like Regexr to validate and analyze it in detail. Here’s a permalink to the pattern above, validated against our examples . I’m going to stick with the latter form, and annotate it with a comment linking to the Regexr definition.

We can match it against the value we’ve received, and extract the capture groups, using an FRegexMatcher:

<EditorModule>/Private/SVG/SVGParser.cpp
bool FSVGParser::ParseColor(const TCHAR* value, FColor& out_result)
{
	// ...
	
	FRegexMatcher rgbMatcher (rgbPattern, value);
	if (rgbMatcher.FindNext())
	{
		out_result.R = FCString::Atoi(*rgbMatcher.GetCaptureGroup(1));
		out_result.G = FCString::Atoi(*rgbMatcher.GetCaptureGroup(2));
		out_result.B = FCString::Atoi(*rgbMatcher.GetCaptureGroup(3));
		
		FString aMatch = rgbMatcher.GetCaptureGroup(4);
		float aFrac = !aMatch.IsEmpty()
			? FCString::Atof(*aMatch)
			: 1.f;
		
		out_result.A = FMath::RoundToInt(aFrac * 255);
		
		return true;
	}
	
	return false;
}

Hex syntax

Hex colors are much easier to match, since FColor has built-in support for them. We’ll put this code above the RGB(A) logic in our function, to avoid initializing the regex objects if we don’t need them:

<EditorModule>/Private/SVG/SVGParser.cpp
bool FSVGParser::ParseColor(const TCHAR* value, FColor& out_result)
{
	if (value[0] == TEXT('#'))
	{
		out_result = FColor::FromHex(value);
		return true;
	}
	
	// ...
}

Color keywords

Finally, there are the pre-defined CSS color keywords . This will also be pretty trivial, since we can just look up the value in a hashmap. But hoo boy, that’s a long list of keywords.

My About page states that everything you read here is written by me personally, without AI assistance — except where otherwise noted. Well, it is time to take note, because the one area where I’ve consistently found tools like ChatGPT really, really useful is for writing staggering quantities of boilerplate code.

Don’t get me wrong, I’m totally fine with mindlessly thoking away on my keyboard for long stretches of time when it’s called for. Put on some good music and it can even be pretty therapeutic. But sometimes, the task before me is so tedious that it begs for a different approach. We could sit down and write a script with some fancy regex manipulation to ease the burden — and I’ve definitely gone down that road before — but today, it is the Year of Our Lord two-thousand and twenty-three, and we have robots that can do it for us.

Here is the challenge I posed to our synthetic friend:

Hi! Could you help me write some C++ boilerplate?

I’m trying to initialize a TMap of const TCHAR* keys to FColor values, where the keys are all of the named color keywords supported by the current CSS specification.

FColor’s main constructor has this signature: FColor(uint8_t r, uint8_t g, uint8_t b), and TMap has a constructor accepting nested braced initializer lists.

Here are a few color definitions as an example:

Can you help me fill out the rest of the entries, so that it includes all of the CSS named color keywords with the correct values?

At first, of course, it tried to take the easy way out, spitting out a similarly incomplete list, with a comment just before the closing brace: // Add more color entries here...

Don’t try to teach me how to fish, robot. I know how to fish. I just want you to feed me so that I don’t have to.

Fortunately, it didn’t take much additional prodding:

Thanks, that’s very helpful! But can you fill out the entire list for me, instead of truncating it?

The response:

Certainly! I apologize for the truncation. Here’s an expanded version of the code with the complete list of CSS named color keywords:

And here, for your copy-and-pasting pleasure, is the glorious result that eventually trickled out (modified, of course, to use FName for the key type instead of const TCHAR* — whoops):

<EditorModule>/Private/SVG/SVGParser.h
// ...

class FSVGParser
	: public IFastXmlCallback
	, public FGCObject
{
	using Self = FSVGParser;
	// ...
private:
	// ...
	inline static const TMap<FName, FColor> k_ColorNameLookup {
		{ TEXT("aliceblue"), FColor(240, 248, 255) },
		{ TEXT("antiquewhite"), FColor(250, 235, 215) },
		{ TEXT("aqua"), FColor(0, 255, 255) },
		{ TEXT("aquamarine"), FColor(127, 255, 212) },
		{ TEXT("azure"), FColor(240, 255, 255) },
		{ TEXT("beige"), FColor(245, 245, 220) },
		{ TEXT("bisque"), FColor(255, 228, 196) },
		{ TEXT("black"), FColor(0, 0, 0) },
		{ TEXT("blanchedalmond"), FColor(255, 235, 205) },
		{ TEXT("blue"), FColor(0, 0, 255) },
		{ TEXT("blueviolet"), FColor(138, 43, 226) },
		{ TEXT("brown"), FColor(165, 42, 42) },
		{ TEXT("burlywood"), FColor(222, 184, 135) },
		{ TEXT("cadetblue"), FColor(95, 158, 160) },
		{ TEXT("chartreuse"), FColor(127, 255, 0) },
		{ TEXT("chocolate"), FColor(210, 105, 30) },
		{ TEXT("coral"), FColor(255, 127, 80) },
		{ TEXT("cornflowerblue"), FColor(100, 149, 237) },
		{ TEXT("cornsilk"), FColor(255, 248, 220) },
		{ TEXT("crimson"), FColor(220, 20, 60) },
		{ TEXT("cyan"), FColor(0, 255, 255) },
		{ TEXT("darkblue"), FColor(0, 0, 139) },
		{ TEXT("darkcyan"), FColor(0, 139, 139) },
		{ TEXT("darkgoldenrod"), FColor(184, 134, 11) },
		{ TEXT("darkgray"), FColor(169, 169, 169) },
		{ TEXT("darkgreen"), FColor(0, 100, 0) },
		{ TEXT("darkgrey"), FColor(169, 169, 169) },
		{ TEXT("darkkhaki"), FColor(189, 183, 107) },
		{ TEXT("darkmagenta"), FColor(139, 0, 139) },
		{ TEXT("darkolivegreen"), FColor(85, 107, 47) },
		{ TEXT("darkorange"), FColor(255, 140, 0) },
		{ TEXT("darkorchid"), FColor(153, 50, 204) },
		{ TEXT("darkred"), FColor(139, 0, 0) },
		{ TEXT("darksalmon"), FColor(233, 150, 122) },
		{ TEXT("darkseagreen"), FColor(143, 188, 143) },
		{ TEXT("darkslateblue"), FColor(72, 61, 139) },
		{ TEXT("darkslategray"), FColor(47, 79, 79) },
		{ TEXT("darkslategrey"), FColor(47, 79, 79) },
		{ TEXT("darkturquoise"), FColor(0, 206, 209) },
		{ TEXT("darkviolet"), FColor(148, 0, 211) },
		{ TEXT("deeppink"), FColor(255, 20, 147) },
		{ TEXT("deepskyblue"), FColor(0, 191, 255) },
		{ TEXT("dimgray"), FColor(105, 105, 105) },
		{ TEXT("dimgrey"), FColor(105, 105, 105) },
		{ TEXT("dodgerblue"), FColor(30, 144, 255) },
		{ TEXT("firebrick"), FColor(178, 34, 34) },
		{ TEXT("floralwhite"), FColor(255, 250, 240) },
		{ TEXT("forestgreen"), FColor(34, 139, 34) },
		{ TEXT("fuchsia"), FColor(255, 0, 255) },
		{ TEXT("gainsboro"), FColor(220, 220, 220) },
		{ TEXT("ghostwhite"), FColor(248, 248, 255) },
		{ TEXT("gold"), FColor(255, 215, 0) },
		{ TEXT("goldenrod"), FColor(218, 165, 32) },
		{ TEXT("gray"), FColor(128, 128, 128) },
		{ TEXT("green"), FColor(0, 128, 0) },
		{ TEXT("greenyellow"), FColor(173, 255, 47) },
		{ TEXT("grey"), FColor(128, 128, 128) },
		{ TEXT("honeydew"), FColor(240, 255, 240) },
		{ TEXT("hotpink"), FColor(255, 105, 180) },
		{ TEXT("indianred"), FColor(205, 92, 92) },
		{ TEXT("indigo"), FColor(75, 0, 130) },
		{ TEXT("ivory"), FColor(255, 255, 240) },
		{ TEXT("khaki"), FColor(240, 230, 140) },
		{ TEXT("lavender"), FColor(230, 230, 250) },
		{ TEXT("lavenderblush"), FColor(255, 240, 245) },
		{ TEXT("lawngreen"), FColor(124, 252, 0) },
		{ TEXT("lemonchiffon"), FColor(255, 250, 205) },
		{ TEXT("lightblue"), FColor(173, 216, 230) },
		{ TEXT("lightcoral"), FColor(240, 128, 128) },
		{ TEXT("lightcyan"), FColor(224, 255, 255) },
		{ TEXT("lightgoldenrodyellow"), FColor(250, 250, 210) },
		{ TEXT("lightgray"), FColor(211, 211, 211) },
		{ TEXT("lightgreen"), FColor(144, 238, 144) },
		{ TEXT("lightgrey"), FColor(211, 211, 211) },
		{ TEXT("lightpink"), FColor(255, 182, 193) },
		{ TEXT("lightsalmon"), FColor(255, 160, 122) },
		{ TEXT("lightseagreen"), FColor(32, 178, 170) },
		{ TEXT("lightskyblue"), FColor(135, 206, 250) },
		{ TEXT("lightslategray"), FColor(119, 136, 153) },
		{ TEXT("lightslategrey"), FColor(119, 136, 153) },
		{ TEXT("lightsteelblue"), FColor(176, 196, 222) },
		{ TEXT("lightyellow"), FColor(255, 255, 224) },
		{ TEXT("lime"), FColor(0, 255, 0) },
		{ TEXT("limegreen"), FColor(50, 205, 50) },
		{ TEXT("linen"), FColor(250, 240, 230) },
		{ TEXT("magenta"), FColor(255, 0, 255) },
		{ TEXT("maroon"), FColor(128, 0, 0) },
		{ TEXT("mediumaquamarine"), FColor(102, 205, 170) },
		{ TEXT("mediumblue"), FColor(0, 0, 205) },
		{ TEXT("mediumorchid"), FColor(186, 85, 211) },
		{ TEXT("mediumpurple"), FColor(147, 112, 219) },
		{ TEXT("mediumseagreen"), FColor(60, 179, 113) },
		{ TEXT("mediumslateblue"), FColor(123, 104, 238) },
		{ TEXT("mediumspringgreen"), FColor(0, 250, 154) },
		{ TEXT("mediumturquoise"), FColor(72, 209, 204) },
		{ TEXT("mediumvioletred"), FColor(199, 21, 133) },
		{ TEXT("midnightblue"), FColor(25, 25, 112) },
		{ TEXT("mintcream"), FColor(245, 255, 250) },
		{ TEXT("mistyrose"), FColor(255, 228, 225) },
		{ TEXT("moccasin"), FColor(255, 228, 181) },
		{ TEXT("navajowhite"), FColor(255, 222, 173) },
		{ TEXT("navy"), FColor(0, 0, 128) },
		{ TEXT("oldlace"), FColor(253, 245, 230) },
		{ TEXT("olive"), FColor(128, 128, 0) },
		{ TEXT("olivedrab"), FColor(107, 142, 35) },
		{ TEXT("orange"), FColor(255, 165, 0) },
		{ TEXT("orangered"), FColor(255, 69, 0) },
		{ TEXT("orchid"), FColor(218, 112, 214) },
		{ TEXT("palegoldenrod"), FColor(238, 232, 170) },
		{ TEXT("palegreen"), FColor(152, 251, 152) },
		{ TEXT("paleturquoise"), FColor(175, 238, 238) },
		{ TEXT("palevioletred"), FColor(219, 112, 147) },
		{ TEXT("papayawhip"), FColor(255, 239, 213) },
		{ TEXT("peachpuff"), FColor(255, 218, 185) },
		{ TEXT("peru"), FColor(205, 133, 63) },
		{ TEXT("pink"), FColor(255, 192, 203) },
		{ TEXT("plum"), FColor(221, 160, 221) },
		{ TEXT("powderblue"), FColor(176, 224, 230) },
		{ TEXT("purple"), FColor(128, 0, 128) },
		{ TEXT("rebeccapurple"), FColor(102, 51, 153) },
		{ TEXT("red"), FColor(255, 0, 0) },
		{ TEXT("rosybrown"), FColor(188, 143, 143) },
		{ TEXT("royalblue"), FColor(65, 105, 225) },
		{ TEXT("saddlebrown"), FColor(139, 69, 19) },
		{ TEXT("salmon"), FColor(250, 128, 114) },
		{ TEXT("sandybrown"), FColor(244, 164, 96) },
		{ TEXT("seagreen"), FColor(46, 139, 87) },
		{ TEXT("seashell"), FColor(255, 245, 238) },
		{ TEXT("sienna"), FColor(160, 82, 45) },
		{ TEXT("silver"), FColor(192, 192, 192) },
		{ TEXT("skyblue"), FColor(135, 206, 235) },
		{ TEXT("slateblue"), FColor(106, 90, 205) },
		{ TEXT("slategray"), FColor(112, 128, 144) },
		{ TEXT("slategrey"), FColor(112, 128, 144) },
		{ TEXT("snow"), FColor(255, 250, 250) },
		{ TEXT("springgreen"), FColor(0, 255, 127) },
		{ TEXT("steelblue"), FColor(70, 130, 180) },
		{ TEXT("tan"), FColor(210, 180, 140) },
		{ TEXT("teal"), FColor(0, 128, 128) },
		{ TEXT("thistle"), FColor(216, 191, 216) },
		{ TEXT("tomato"), FColor(255, 99, 71) },
		{ TEXT("turquoise"), FColor(64, 224, 208) },
		{ TEXT("violet"), FColor(238, 130, 238) },
		{ TEXT("wheat"), FColor(245, 222, 179) },
		{ TEXT("white"), FColor(255, 255, 255) },
		{ TEXT("whitesmoke"), FColor(245, 245, 245) },
		{ TEXT("yellow"), FColor(255, 255, 0) },
		{ TEXT("yellowgreen"), FColor(154, 205, 50) },
	};
};

What a time to be alive!

Honestly, I don’t know why this sort of thing tickles me so much. At a time when there’s a lot of breathless sensationalism about how smart these language models are, I suppose it’s the irony of asking them to squander considerable compute resources on the sort of simple drudgery that could have been accomplished by a 35-year-old regular expression engine (or literally anyone with access to a web browser, some sort of typing instrument, and plenty of time on their hands) that brings me so much joy. I should think about doing a more thorough writeup on these types of AI tools eventually, because I have some thoughts.

But for now — on with our regularly scheduled programming.

Add the keyword lookup to the top of our function and a warning log at the end in case of failure, and ParseColor is a wrap! All together, here’s what we end up with:

<EditorModule>/Private/SVG/SVGParser.cpp
// ...

bool FSVGParser::ParseColor(const TCHAR* value, FColor& out_result)
{
	if (k_ColorNameLookup.Contains(value))
	{
		out_result = k_ColorNameLookup[value];
		return true;
	}
	
	if (value[0] == TEXT('#'))
	{
		out_result = FColor::FromHex(value);
		return true;
	}
	
	/*
	RGB(A) values
	-----------------------------------------------------------------------------
	<rgb(a)>
		= `rgb` [`a`]? `(` <byte> [ [ \s ]+ <byte> ]{2} [ `/` <alpha> ]? `)`
		| `rgb` [`a`]? `(` <byte> [ <comma> <byte> ]{2} [ <comma> <alpha> ]? `)`
		
	<byte>
		= [`0`...`9`]+
		
	<comma>
		= `,` [ \s ]*
		
	<alpha>
		= [`0`...`9`]* `.` [`0`...`9`]+
		| [`0`...`9`]+ [ `.` [`0`...`9`]* ]?
	*/
	
	// https://regexr.com/7h58d
	static const FRegexPattern rgbPattern {
		TEXT(R"#(rgba?\(([0-9]+)[,\s]\s*([0-9]+)[,\s]\s*([0-9]+)(?:\s*[/,]\s*([.0-9]+))?\))#")
	};
	
	FRegexMatcher rgbMatcher (rgbPattern, value);
	if (rgbMatcher.FindNext())
	{
		out_result.R = FCString::Atoi(*rgbMatcher.GetCaptureGroup(1));
		out_result.G = FCString::Atoi(*rgbMatcher.GetCaptureGroup(2));
		out_result.B = FCString::Atoi(*rgbMatcher.GetCaptureGroup(3));
		
		FString aMatch = rgbMatcher.GetCaptureGroup(4);
		float aFrac = !aMatch.IsEmpty()
			? FCString::Atof(*aMatch)
			: 1.f;
		
		out_result.A = FMath::RoundToInt(aFrac * 255);
		
		return true;
	}
	
	UE_LOG(LogTemp, Warning,
		TEXT("Failed to parse color value `%s` -- ignoring"),
		value);
	
	return false;
}

Processing attributes

We can finally get back to our ProcessStyleAttr implementation, and it’s pretty straightforward from here. We just need to remember to set the bExplicitX flag for each attribute we process.

<EditorModule>/Private/SVG/SVGParser.cpp
// ...

bool FSVGParser::ProcessStyleAttr(const FName& name, const TCHAR* value)
{
	using namespace SVGNames;
	
	FSVGStyle& style = m_Stack.empty()
		? m_Asset->Style
		: m_Stack.top()->Style;
	
	if (name == k_Fill || name == k_Stroke)
	{
		FColor color;
		if (!ParseColor(value, color))
			return true;
		
		if (name == k_Fill)
		{
			style.FillColor = color;
			style.bExplicitFillColor = true;
		}
		else
		{
			style.StrokeColor = color;
			style.bExplicitStrokeColor = true;
		}
		
		return true;
	}
	
	if (name == k_FillOpacity)
	{
		style.FillColor.A = FMath::RoundToInt(FCString::Atof(value) * 255);
		style.bExplicitFillOpacity = true;
		return true;
	}
	
	if (name == k_StrokeOpacity)
	{
		style.StrokeColor.A = FMath::RoundToInt(FCString::Atof(value) * 255);
		style.bExplicitStrokeOpacity = true;
		return true;
	}
	
	if (name == k_StrokeWidth)
	{
		style.StrokeWidth = FCString::Atof(value);
		style.bExplicitStrokeWidth = true;
		return true;
	}
	
	if (name == k_Opacity)
	{
		style.Opacity = FCString::Atof(value);
		style.bExplicitOpacity = true;
		return true;
	}
	
	if (name == k_FillRule)
	{
		FName valueName (value);
		
		if (valueName == k_NonZero)
		{
			style.FillRule = ESVGFillRule::NonZero;
			style.bExplicitFillRule = true;
		}
		else if (valueName == k_EvenOdd)
		{
			style.FillRule = ESVGFillRule::EvenOdd;
			style.bExplicitFillRule = true;
		}
		else
		{
			UE_LOG(LogTemp, Warning,
				TEXT("Unrecognized value for fill-rule attribute: \"%s\" -- ignoring"),
				value);
		}
		
		return true;
	}
}

// ...

For ProcessRootAttr, we’ll do something very similar for the viewBox attribute to what we did for CSS colors, so we can extract the individual values from the string. This time though, the grammar is dirt simple: four space-separated numbers.

<EditorModule>/Private/SVG/SVGParser.cpp
// ...

bool FSVGParser::ProcessRootAttr(const FName& name, const TCHAR* value)
{
	if (name == SVGNames::k_ViewBox)
	{
		static const FRegexPattern p (R"#(([-.0-9]+)\s+([-.0-9]+)\s+([.0-9]+)\s+([.0-9]+))#");
		FRegexMatcher m (p, value);
		
		if (m.FindNext())
		{
			m_Asset->ViewBox.X = FCString::Atof(*m.GetCaptureGroup(1));
			m_Asset->ViewBox.Y = FCString::Atof(*m.GetCaptureGroup(2));
			m_Asset->ViewBox.Width = FCString::Atof(*m.GetCaptureGroup(3));
			m_Asset->ViewBox.Height = FCString::Atof(*m.GetCaptureGroup(4));
		}
		
		return true;
	}
	
	UE_LOG(LogTemp, Warning,
		TEXT("<svg/> attribute \"%s\" is not currently supported -- ignoring"),
		*name.ToString());
	
	return true;
}

// ...

For PolyLine and Polygon, we’ll forward to a shared ProcessPolyAttr method. The points attribute for those elements takes the form of a sequence of space-separated coordinates, where the x and y values can be either comma-separated or space-separated. We’ll create capture groups to match a single x,y pair, and iterate over each match in the string by calling FRegexMatcher::FindNext until it returns false.

<EditorModule>/Private/SVG/SVGParser.h
// ...

class FSVGParser
	: public IFastXmlCallback
	, public FGCObject
{
	using Self = FSVGParser;
	// ...
private:
	// ...
	bool ProcessPolyAttr(FSVGPoly& props, const FName& name, const TCHAR* value);
	// ...
};
<EditorModule>/Private/SVG/SVGParser.cpp
// ...

bool FSVGParser::ProcessPolyLineAttr(const FName& name, const TCHAR* value)
{
	auto* polyLine = CastChecked<USVGPolyLineElement>(m_Stack.top());
	return ProcessPolyAttr(polyLine->Props, name, value);
}

bool FSVGParser::ProcessPolygonAttr(const FName& name, const TCHAR* value)
{
	auto* polygon = CastChecked<USVGPolygonElement>(m_Stack.top());
	return ProcessPolyAttr(polygon->Props, name, value);
}

bool FSVGParser::ProcessPolyAttr(FSVGPoly& props, const FName& name, const TCHAR* value)
{
	if (name == SVGNames::k_Points)
	{
		static const FRegexPattern pattern (R"#((-?[.0-9]+)[,\s]+(-?[.0-9]+))#");
		FRegexMatcher m (pattern, value);
		
		while (m.FindNext())
		{
			float x = FCString::Atof(*m.GetCaptureGroup(1));
			float y = FCString::Atof(*m.GetCaptureGroup(2));
			props.Points.Emplace(x, y);
		}
		
		return true;
	}
	
	return LogUnknownAttr(name, value);
}

// ...

The remaining attributes are incredibly mundane and predictable, so I’ll refer you to one final example and leave you to fill in the rest:

<EditorModule>/Private/SVG/SVGParser.cpp
// ...

bool FSVGParser::ProcessCircleAttr(const FName& name, const TCHAR* value)
{
	using namespace SVGNames;
	auto* circle = CastChecked<USVGCircleElement>(m_Stack.top());
	
	if (name == k_CX)
		circle->Props.Center.X = FCString::Atof(value);
	
	else if (name == k_CY)
		circle->Props.Center.Y = FCString::Atof(value);
	
	else if (name == k_R)
		circle->Props.Radius = FCString::Atof(value);
	
	else
		return LogUnknownAttr(name, value);
	
	return true;
}

// ...

Checking our work

This has been a long one, but we’re finally at the end of the road. All that’s left to do is create a new UMG widget to render an SVG asset, drag an SVG file into the Content Browser, and give it a test drive.

<MainModule>/Public/SVG/CanvasSVG.h
#pragma once

#include "CoreMinimal.h"
#include "CanvasWidget.h"
#include "SVGAttributes.h"

#include "CanvasSVG.generated.h"

class SkCanvas;
class USVGAsset;


UCLASS()
class API UCanvasSVG : public UCanvasWidget
{
	GENERATED_BODY()
	
	using Self = UCanvasSVG;
	
private:
	UPROPERTY(EditAnywhere, Category="Appearance", DisplayName="SVG",
		BlueprintGetter="SVG",
		BlueprintSetter="SetSVG")
	TObjectPtr<USVGAsset> _SVGAsset = nullptr;
	
public:
	UFUNCTION(BlueprintGetter) USVGAsset* SVG() const { return _SVGAsset; }
	UFUNCTION(BlueprintSetter) void SetSVG(USVGAsset* in_svg);
	
	TOptional<FVector2D> DesiredSize() const;
	
protected:
	TSharedRef<SWidget> RebuildWidget() override;
	void OnDraw(SkCanvas& canvas, FInt32Vector2 size, FVector2f scale) override;
};
<MainModule>/Private/SVG/CanvasSVG.cpp
#include "SVG/CanvasSVG.h"

#include "SCanvasWidget.h"
#include "include/core/SkCanvas.h"
#include "SVG/SVGAsset.h"
#include "SVG/SVGRenderer.h"


void UCanvasSVG::SetSVG(USVGAsset* in_svg)
{
	if (_SVGAsset == in_svg)
		return;
	
	_SVGAsset = in_svg;
	
	if (SlateWidget.IsValid())
		SlateWidget->RequestRedraw();
}

TOptional<FVector2D> UCanvasSVG::DesiredSize() const
{
	if (!_SVGAsset || !_SVGAsset->IsValidLowLevel())
		return {};
	
	return FVector2D {
		_SVGAsset->ViewBox.Width,
		_SVGAsset->ViewBox.Height,
	};
}

TSharedRef<SWidget> UCanvasSVG::RebuildWidget()
{
	SlateWidget = SNew(SCanvasWidget)
		.OnDraw_UObject(this, &Self::OnDraw)
		.DesiredSizeOverride_UObject(this, &Self::DesiredSize);
	
	return SlateWidget.ToSharedRef();
}

void UCanvasSVG::OnDraw(SkCanvas& canvas, FInt32Vector2 size, FVector2f scale)
{
	if (!_SVGAsset || !_SVGAsset->IsValidLowLevel())
		return canvas.clear(SK_ColorTRANSPARENT);
	
	_SVGAsset->OnDraw(canvas, size, scale);
}

And here’s an imported SVG file rendering via the Canvas SVG widget in a new widget blueprint:

Screenshot of an imported SVG asset rendering in Unreal Engine's UMG designer

What’s next?

There’s plenty of room for improvement on this barebones implementation: rendering with Unreal’s RHI for big performance gains, huge portions of the SVG spec left untouched, a proper asset editor with custom thumbnails, etc. I don’t have any concrete plans at this point, and it might be a good time to pivot to one of my other goals for overhauling the UI workflow in Unreal. Time will tell. But until then, I hope you enjoyed coming along on this journey with me, and thanks for reading!