diff --git a/src/Umbraco.Core/Media/ISvgDimensionExtractor.cs b/src/Umbraco.Core/Media/ISvgDimensionExtractor.cs
new file mode 100644
index 000000000000..31fdcde1641d
--- /dev/null
+++ b/src/Umbraco.Core/Media/ISvgDimensionExtractor.cs
@@ -0,0 +1,18 @@
+using System.Drawing;
+
+namespace Umbraco.Cms.Core.Media;
+
+///
+/// Extracts image dimensions from an SVG stream.
+///
+public interface ISvgDimensionExtractor
+{
+ ///
+ /// Gets the dimensions.
+ ///
+ /// The stream.
+ ///
+ /// The dimensions of the image if the stream was parsable; otherwise, null.
+ ///
+ public Size? GetDimensions(Stream stream);
+}
diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs
index 038060b2acfc..1a82931b5cce 100644
--- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs
+++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs
@@ -1,5 +1,6 @@
using Examine;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core;
@@ -13,7 +14,6 @@
using Umbraco.Cms.Core.DistributedLocking;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Handlers;
-using Umbraco.Cms.Core.HealthChecks.NotificationMethods;
using Umbraco.Cms.Core.HostedServices;
using Umbraco.Cms.Core.Hosting;
using Umbraco.Cms.Core.Install;
@@ -49,6 +49,7 @@
using Umbraco.Cms.Infrastructure.Mail;
using Umbraco.Cms.Infrastructure.Mail.Interfaces;
using Umbraco.Cms.Infrastructure.Manifest;
+using Umbraco.Cms.Infrastructure.Media;
using Umbraco.Cms.Infrastructure.Migrations;
using Umbraco.Cms.Infrastructure.Migrations.Install;
using Umbraco.Cms.Infrastructure.Persistence;
@@ -64,7 +65,6 @@
using Umbraco.Cms.Infrastructure.Serialization;
using Umbraco.Cms.Infrastructure.Services.Implement;
using Umbraco.Extensions;
-using Microsoft.Extensions.DependencyInjection.Extensions;
using IScopeProvider = Umbraco.Cms.Infrastructure.Scoping.IScopeProvider;
namespace Umbraco.Cms.Infrastructure.DependencyInjection;
@@ -228,6 +228,7 @@ public static IUmbracoBuilder AddCoreInitialServices(this IUmbracoBuilder builde
builder.Services.AddSingleton();
builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
@@ -403,6 +404,7 @@ public static IUmbracoBuilder AddCoreNotifications(this IUmbracoBuilder builder)
.AddNotificationHandler()
.AddNotificationHandler()
.AddNotificationHandler()
+ .AddNotificationHandler()
.AddNotificationHandler()
.AddNotificationHandler()
.AddNotificationHandler()
diff --git a/src/Umbraco.Infrastructure/Media/SvgDimensionExtractor.cs b/src/Umbraco.Infrastructure/Media/SvgDimensionExtractor.cs
new file mode 100644
index 000000000000..6d452deb4e5e
--- /dev/null
+++ b/src/Umbraco.Infrastructure/Media/SvgDimensionExtractor.cs
@@ -0,0 +1,164 @@
+using System.Drawing;
+using System.Globalization;
+using System.Xml;
+using System.Xml.Linq;
+using Microsoft.Extensions.Logging;
+using Umbraco.Cms.Core.Media;
+
+namespace Umbraco.Cms.Infrastructure.Media;
+
+///
+public class SvgDimensionExtractor : ISvgDimensionExtractor
+{
+ private readonly ILogger _logger;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The logger.
+ public SvgDimensionExtractor(ILogger logger)
+ => _logger = logger;
+
+ ///
+ public Size? GetDimensions(Stream stream)
+ {
+ if (stream.CanRead is false)
+ {
+ return null;
+ }
+
+ long? originalPosition = null;
+
+ if (stream.CanSeek)
+ {
+ originalPosition = stream.Position;
+ stream.Position = 0;
+ }
+
+ try
+ {
+ return ReadDimensions(stream);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to extract dimensions from SVG stream.");
+ return null;
+ }
+ finally
+ {
+ if (originalPosition.HasValue)
+ {
+ stream.Position = originalPosition.Value;
+ }
+ }
+ }
+
+ private static Size? ReadDimensions(Stream stream)
+ {
+ var settings = new XmlReaderSettings
+ {
+ DtdProcessing = DtdProcessing.Prohibit,
+ XmlResolver = null,
+ };
+ using var reader = XmlReader.Create(stream, settings);
+ var document = XDocument.Load(reader);
+
+ XElement? root = document.Root;
+
+ if (root is null)
+ {
+ return null;
+ }
+
+ var widthAttributeValue = root.Attribute("width")?.Value;
+ var heightAttributeValue = root.Attribute("height")?.Value;
+
+ Size? size = null;
+
+ if (widthAttributeValue is not null && heightAttributeValue is not null)
+ {
+ size = ParseWidthHeightAttributes(widthAttributeValue, heightAttributeValue);
+ }
+
+ // Fall back to viewbox.
+ size ??= ParseViewBox(root);
+
+ return size;
+ }
+
+ private static Size? ParseViewBox(XElement root)
+ {
+ var viewBox = root.Attribute("viewBox")?.Value;
+
+ if (string.IsNullOrWhiteSpace(viewBox))
+ {
+ return null;
+ }
+
+ var parts = viewBox.Split([' ', ',', '\t', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries);
+
+ if (parts.Length != 4)
+ {
+ return null;
+ }
+
+ if (double.TryParse(parts[2], NumberStyles.Float, CultureInfo.InvariantCulture, out var width) is false)
+ {
+ return null;
+ }
+
+ if (double.TryParse(parts[3], NumberStyles.Float, CultureInfo.InvariantCulture, out var height) is false)
+ {
+ return null;
+ }
+
+ if (width < 0 || height < 0)
+ {
+ return null;
+ }
+
+ return new Size(
+ (int)Math.Round(width),
+ (int)Math.Round(height));
+ }
+
+ private static Size? ParseWidthHeightAttributes(string widthAttributeValue, string heightAttributeValue)
+ {
+ if (TryExtractNumericFromValue(widthAttributeValue, out var widthValue)
+ && TryExtractNumericFromValue(heightAttributeValue, out var heightValue))
+ {
+ return new Size(widthValue, heightValue);
+ }
+
+ return null;
+ }
+
+ ///
+ /// Extract a "pixel" value from the width / height attributes.
+ ///
+ private static bool TryExtractNumericFromValue(string attributeValue, out int value)
+ {
+ if (int.TryParse(attributeValue, out int onlyNumbersValue) && onlyNumbersValue > 0)
+ {
+ value = onlyNumbersValue;
+ return true;
+ }
+
+ value = 0;
+
+ var input = attributeValue.Trim();
+
+ if (input.EndsWith("px", StringComparison.OrdinalIgnoreCase))
+ {
+ input = input[..^2].Trim();
+ }
+
+ if (int.TryParse(input, out var numericValue) && numericValue > 0)
+ {
+ value = numericValue;
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/src/Umbraco.Infrastructure/Migrations/Install/DatabaseDataCreator.cs b/src/Umbraco.Infrastructure/Migrations/Install/DatabaseDataCreator.cs
index d440828c8db8..681c05aca78c 100644
--- a/src/Umbraco.Infrastructure/Migrations/Install/DatabaseDataCreator.cs
+++ b/src/Umbraco.Infrastructure/Migrations/Install/DatabaseDataCreator.cs
@@ -1947,6 +1947,44 @@ private void CreatePropertyTypeData()
Description = null,
Variations = (byte)ContentVariation.Nothing,
});
+ _database.Insert(
+ Constants.DatabaseSchema.Tables.PropertyType,
+ "id",
+ false,
+ new PropertyTypeDto
+ {
+ Id = 53,
+ UniqueId = new Guid("5BC7E468-C53E-41A6-A522-2723F3B94514"),
+ DataTypeId = Constants.DataTypes.LabelPixels,
+ ContentTypeId = 1037,
+ PropertyTypeGroupId = 55,
+ Alias = Constants.Conventions.Media.Width,
+ Name = "Width",
+ SortOrder = 0,
+ Mandatory = false,
+ ValidationRegExp = null,
+ Description = null,
+ Variations = (byte)ContentVariation.Nothing,
+ });
+ _database.Insert(
+ Constants.DatabaseSchema.Tables.PropertyType,
+ "id",
+ false,
+ new PropertyTypeDto
+ {
+ Id = 54,
+ UniqueId = new Guid("9E4C2B59-6BC6-4648-BB71-B0F45DDBC274"),
+ DataTypeId = Constants.DataTypes.LabelPixels,
+ ContentTypeId = 1037,
+ PropertyTypeGroupId = 55,
+ Alias = Constants.Conventions.Media.Height,
+ Name = "Height",
+ SortOrder = 0,
+ Mandatory = false,
+ ValidationRegExp = null,
+ Description = null,
+ Variations = (byte)ContentVariation.Nothing,
+ });
}
// Membership property types.
diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs
index fe392ddd1636..6da65bcadf31 100644
--- a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs
+++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs
@@ -169,6 +169,7 @@ protected virtual void DefinePlan()
// To 17.4.0
To("{D4E5F6A7-B8C9-4D0E-A1F2-3B4C5D6E7F80}");
+ To("{72970B86-59D8-403C-B322-FFF43F9DB199}");
// To 18.0.0
// TODO (V18): Enable on 18 branch
diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_17_4_0/AddDimensionsToSvg.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_17_4_0/AddDimensionsToSvg.cs
new file mode 100644
index 000000000000..d5dfcf272ec7
--- /dev/null
+++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_17_4_0/AddDimensionsToSvg.cs
@@ -0,0 +1,101 @@
+using Microsoft.Extensions.Logging;
+using Umbraco.Cms.Core;
+using Umbraco.Cms.Core.Models;
+using Umbraco.Cms.Core.Services;
+using Umbraco.Cms.Core.Services.OperationStatus;
+using Umbraco.Cms.Core.Strings;
+
+namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_17_4_0;
+
+///
+/// Migration that adds umbracoWidth and umbracoHeight to Vector Graphics Media Type.
+///
+public class AddDimensionsToSvg : AsyncMigrationBase
+{
+ private readonly ILogger _logger;
+ private readonly IMediaTypeService _mediaTypeService;
+ private readonly IDataTypeService _dataTypeService;
+ private readonly IShortStringHelper _shortStringHelper;
+
+ private readonly Guid _labelPixelsDataTypeKey = new(Constants.DataTypes.Guids.LabelPixels);
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The for the migration operation.
+ /// The instance for logging.
+ /// The for managing media types.
+ /// The for managing data types.
+ /// The for working with strings.
+ public AddDimensionsToSvg(
+ IMigrationContext context,
+ ILogger logger,
+ IMediaTypeService mediaTypeService,
+ IDataTypeService dataTypeService,
+ IShortStringHelper shortStringHelper)
+ : base(context)
+ {
+ _logger = logger;
+ _mediaTypeService = mediaTypeService;
+ _dataTypeService = dataTypeService;
+ _shortStringHelper = shortStringHelper;
+ }
+
+ ///
+ protected override async Task MigrateAsync()
+ {
+ IMediaType? vectorGraphicsMediaType = _mediaTypeService.Get(Constants.Conventions.MediaTypes.VectorGraphicsAlias);
+
+ if (vectorGraphicsMediaType is null)
+ {
+ _logger.LogInformation("No standard Vector Graphics Media Type configured, ignore adding width/height.");
+ return;
+ }
+
+ IDataType? labelPixelDataType = await _dataTypeService.GetAsync(_labelPixelsDataTypeKey);
+
+ if (labelPixelDataType is null)
+ {
+ _logger.LogInformation("No Label Pixel Data Type configured, ignore adding width/height.");
+ return;
+ }
+
+ PropertyGroup? propertyGroup = vectorGraphicsMediaType.PropertyGroups.FirstOrDefault();
+ if (propertyGroup is null)
+ {
+ _logger.LogWarning("Vector Graphics Media Type has no property groups, skipping adding width/height.");
+ return;
+ }
+
+ int highestSort = vectorGraphicsMediaType.PropertyTypes.Any()
+ ? vectorGraphicsMediaType.PropertyTypes.Max(x => x.SortOrder)
+ : 0;
+
+ // Add new properties (AddPropertyType handles duplicates, so the migration is idempotent).
+ vectorGraphicsMediaType.AddPropertyType(new PropertyType(_shortStringHelper, labelPixelDataType, Constants.Conventions.Media.Width)
+ {
+ Name = "Width",
+ SortOrder = highestSort + 1,
+ PropertyGroupId = new Lazy(()=> propertyGroup.Id),
+ });
+ vectorGraphicsMediaType.AddPropertyType(new PropertyType(_shortStringHelper, labelPixelDataType, Constants.Conventions.Media.Height)
+ {
+ Name = "Height",
+ SortOrder = highestSort + 2,
+ PropertyGroupId = new Lazy(()=> propertyGroup.Id),
+ });
+
+ Attempt attempt = await _mediaTypeService.UpdateAsync(vectorGraphicsMediaType, Constants.Security.SuperUserKey);
+ if (attempt.Success is false)
+ {
+ if (attempt.Exception is not null)
+ {
+ _logger.LogError(attempt.Exception, "Failed to update media type '{Alias}' during migration.", vectorGraphicsMediaType.Alias);
+ }
+ else
+ {
+ _logger.LogWarning("Failed to update media type '{Alias}' during migration. Status: {ResultStatus}", vectorGraphicsMediaType.Alias, attempt.Result);
+ }
+ }
+ }
+}
diff --git a/src/Umbraco.Infrastructure/PropertyEditors/NotificationHandlers/SvgFileUploadMediaSavingNotificationHandler.cs b/src/Umbraco.Infrastructure/PropertyEditors/NotificationHandlers/SvgFileUploadMediaSavingNotificationHandler.cs
new file mode 100644
index 000000000000..60fb905b18b4
--- /dev/null
+++ b/src/Umbraco.Infrastructure/PropertyEditors/NotificationHandlers/SvgFileUploadMediaSavingNotificationHandler.cs
@@ -0,0 +1,111 @@
+using System.Drawing;
+using Microsoft.Extensions.Logging;
+using Umbraco.Cms.Core.Events;
+using Umbraco.Cms.Core.IO;
+using Umbraco.Cms.Core.Media;
+using Umbraco.Cms.Core.Models;
+using Umbraco.Cms.Core.Notifications;
+
+namespace Umbraco.Cms.Infrastructure.PropertyEditors.NotificationHandlers;
+
+///
+/// Notification handler to set width / height of uploaded SVG media.
+///
+internal sealed class SvgFileUploadMediaSavingNotificationHandler : INotificationHandler
+{
+ private readonly ILogger _logger;
+ private readonly ISvgDimensionExtractor _svgDimensionExtractor;
+ private readonly MediaFileManager _mediaFileManager;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public SvgFileUploadMediaSavingNotificationHandler(
+ ILogger logger,
+ ISvgDimensionExtractor svgDimensionExtractor,
+ MediaFileManager mediaFileManager)
+ {
+ _logger = logger;
+ _svgDimensionExtractor = svgDimensionExtractor;
+ _mediaFileManager = mediaFileManager;
+ }
+
+ ///
+ public void Handle(MediaSavingNotification notification)
+ {
+ foreach (IMedia entity in notification.SavedEntities)
+ {
+ if (entity.ContentType.Alias.Equals(Core.Constants.Conventions.MediaTypes.VectorGraphicsAlias) is false)
+ {
+ continue;
+ }
+
+ AutoFillSvgWidthHeight(entity);
+ }
+ }
+
+ private void AutoFillSvgWidthHeight(IContentBase model)
+ {
+ if (model.Properties.TryGetValue(Core.Constants.Conventions.Media.Width, out _) is false
+ || model.Properties.TryGetValue(Core.Constants.Conventions.Media.Height, out _) is false)
+ {
+ _logger.LogDebug("Skipping SVG dimension extraction for media {MediaId}: width/height properties not found on content type.", model.Id);
+ return;
+ }
+
+ IProperty? property = model.Properties
+ .FirstOrDefault(x => x.PropertyType.PropertyEditorAlias == Core.Constants.PropertyEditors.Aliases.UploadField);
+
+ if (property is null)
+ {
+ _logger.LogDebug("Skipping SVG dimension extraction for media {MediaId}: no upload field property found.", model.Id);
+ return;
+ }
+
+ foreach (IPropertyValue pvalue in property.Values)
+ {
+ var svalue = property.GetValue(pvalue.Culture, pvalue.Segment) as string;
+
+ if (string.IsNullOrWhiteSpace(svalue))
+ {
+ continue;
+ }
+
+ string filepath = _mediaFileManager.FileSystem.GetRelativePath(svalue);
+
+ if (_mediaFileManager.FileSystem.FileExists(filepath) is false)
+ {
+ _logger.LogWarning("SVG file not found at path {FilePath} for media {MediaId}.", filepath, model.Id);
+ continue;
+ }
+
+ using Stream filestream = _mediaFileManager.FileSystem.OpenFile(filepath);
+
+ SetWidthAndHeight(model, filestream, pvalue.Culture, pvalue.Segment);
+ }
+ }
+
+ private void SetWidthAndHeight(IContentBase model, Stream filestream, string? culture, string? segment)
+ {
+ Size? size = _svgDimensionExtractor.GetDimensions(filestream);
+ if (size.HasValue)
+ {
+ _logger.LogDebug("Extracted SVG dimensions {Width}x{Height} for media {MediaId}.", size.Value.Width, size.Value.Height, model.Id);
+ SetProperty(model, Core.Constants.Conventions.Media.Width, size.Value.Width, culture, segment);
+ SetProperty(model, Core.Constants.Conventions.Media.Height, size.Value.Height, culture, segment);
+ }
+ else
+ {
+ _logger.LogDebug("Could not extract dimensions from SVG for media {MediaId}.", model.Id);
+ }
+ }
+
+ private static void SetProperty(IContentBase content, string alias, object? value, string? culture, string? segment)
+ {
+ if (string.IsNullOrEmpty(alias) is false &&
+ content.Properties.TryGetValue(alias, out IProperty? property))
+ {
+ property.SetValue(value, culture, segment);
+ }
+ }
+}
diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Media/SvgDimensionExtractorTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Media/SvgDimensionExtractorTests.cs
new file mode 100644
index 000000000000..aa70b3ffbe6f
--- /dev/null
+++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Media/SvgDimensionExtractorTests.cs
@@ -0,0 +1,274 @@
+using System.Drawing;
+using System.Text;
+using Microsoft.Extensions.Logging.Abstractions;
+using NUnit.Framework;
+using Umbraco.Cms.Infrastructure.Media;
+
+namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.Media;
+
+public class SvgDimensionExtractorTests
+{
+ [Test]
+ public void Returns_Null_For_Non_Readable_Stream()
+ {
+ using var stream = new MemoryStream(Encoding.UTF8.GetBytes(""));
+ stream.Close();
+ var sut = CreateSvgDimensionExtractor();
+
+ Size? result = sut.GetDimensions(stream);
+
+ Assert.That(result, Is.Null);
+ }
+
+ [Test]
+ public void Restores_Stream_Position_After_Reading()
+ {
+ var svg = """
+
+ """;
+
+ using var stream = new MemoryStream(Encoding.UTF8.GetBytes(svg));
+ stream.Position = 10;
+ var sut = CreateSvgDimensionExtractor();
+
+ sut.GetDimensions(stream);
+
+ Assert.That(stream.Position, Is.EqualTo(10));
+ }
+
+ [Test]
+ public void Returns_Null_For_Invalid_Xml()
+ {
+ using var stream = new MemoryStream(Encoding.UTF8.GetBytes("this is not xml"));
+ var sut = CreateSvgDimensionExtractor();
+
+ Size? result = sut.GetDimensions(stream);
+
+ Assert.That(result, Is.Null);
+ }
+
+ [Test]
+ public void Returns_Null_For_No_Dimensions_And_No_ViewBox()
+ {
+ var svg = """
+
+ """;
+
+ using var stream = new MemoryStream(Encoding.UTF8.GetBytes(svg));
+ var sut = CreateSvgDimensionExtractor();
+
+ Size? result = sut.GetDimensions(stream);
+
+ Assert.That(result, Is.Null);
+ }
+
+ [Test]
+ public void Falls_Back_To_ViewBox_For_Decimal_Width_Height()
+ {
+ var svg = """
+
+ """;
+
+ using var stream = new MemoryStream(Encoding.UTF8.GetBytes(svg));
+ var sut = CreateSvgDimensionExtractor();
+
+ Size? result = sut.GetDimensions(stream);
+
+ Assert.That(result, Is.EqualTo(new Size(200, 100)));
+ }
+
+ [Test]
+ public void Can_Parse_Attribute_Width_Height_No_Unit()
+ {
+ var svg = """
+
+ """;
+
+ using var stream = new MemoryStream(Encoding.UTF8.GetBytes(svg));
+ var sut = CreateSvgDimensionExtractor();
+
+ Size? result = sut.GetDimensions(stream);
+
+ Assert.That(result, Is.EqualTo(new Size(100, 50)));
+ }
+
+ [Test]
+ public void Can_Parse_Attribute_Width_Height_Pixels()
+ {
+ var svg = """
+
+ """;
+
+ using var stream = new MemoryStream(Encoding.UTF8.GetBytes(svg));
+ var sut = CreateSvgDimensionExtractor();
+
+ Size? result = sut.GetDimensions(stream);
+
+ Assert.That(result, Is.EqualTo(new Size(100, 50)));
+ }
+
+ [Test]
+ public void Returns_Null_For_Zero_Width_Height()
+ {
+ var svg = """
+
+ """;
+
+ using var stream = new MemoryStream(Encoding.UTF8.GetBytes(svg));
+ var sut = CreateSvgDimensionExtractor();
+
+ Size? result = sut.GetDimensions(stream);
+
+ Assert.That(result, Is.Null);
+ }
+
+ [Test]
+ public void Returns_Null_For_Negative_Width_Height()
+ {
+ var svg = """
+
+ """;
+
+ using var stream = new MemoryStream(Encoding.UTF8.GetBytes(svg));
+ var sut = CreateSvgDimensionExtractor();
+
+ Size? result = sut.GetDimensions(stream);
+
+ Assert.That(result, Is.Null);
+ }
+
+ [Test]
+ public void Can_Parse_And_Fallback_Attribute_Width_Height_Percent()
+ {
+ var svg = """
+
+ """;
+
+ using var stream = new MemoryStream(Encoding.UTF8.GetBytes(svg));
+ var sut = CreateSvgDimensionExtractor();
+
+ Size? result = sut.GetDimensions(stream);
+
+ Assert.That(result, Is.EqualTo(new Size(301, 152)));
+ }
+
+ [Test]
+ public void Can_Parse_And_Fallback_Width_Height_Em()
+ {
+ var svg = """
+
+ """;
+
+ using var stream = new MemoryStream(Encoding.UTF8.GetBytes(svg));
+ var sut = CreateSvgDimensionExtractor();
+
+ Size? result = sut.GetDimensions(stream);
+
+ Assert.That(result, Is.EqualTo(new Size(301, 152)));
+ }
+
+ [Test]
+ public void Can_Parse_From_ViewBox()
+ {
+ var svg = """
+
+ """;
+
+ using var stream = new MemoryStream(Encoding.UTF8.GetBytes(svg));
+ var sut = CreateSvgDimensionExtractor();
+
+ Size? result = sut.GetDimensions(stream);
+
+ Assert.That(result, Is.EqualTo(new Size(301, 152)));
+ }
+
+ [Test]
+ public void Can_Parse_From_ViewBox_Single_Digits()
+ {
+ var svg = """
+
+ """;
+
+ using var stream = new MemoryStream(Encoding.UTF8.GetBytes(svg));
+ var sut = CreateSvgDimensionExtractor();
+
+ Size? result = sut.GetDimensions(stream);
+
+ Assert.That(result, Is.EqualTo(new Size(8, 4)));
+ }
+
+ [Test]
+ public void Can_Parse_ViewBox_With_Fractional_Values()
+ {
+ var svg = """
+
+ """;
+
+ using var stream = new MemoryStream(Encoding.UTF8.GetBytes(svg));
+ var sut = CreateSvgDimensionExtractor();
+
+ Size? result = sut.GetDimensions(stream);
+
+ Assert.That(result, Is.EqualTo(new Size(100, 50)));
+ }
+
+ [Test]
+ public void Can_Parse_ViewBox_With_Comma_Separators()
+ {
+ var svg = """
+
+ """;
+
+ using var stream = new MemoryStream(Encoding.UTF8.GetBytes(svg));
+ var sut = CreateSvgDimensionExtractor();
+
+ Size? result = sut.GetDimensions(stream);
+
+ Assert.That(result, Is.EqualTo(new Size(301, 152)));
+ }
+
+ [Test]
+ public void Returns_Null_For_Negative_ViewBox_Dimensions()
+ {
+ var svg = """
+
+ """;
+
+ using var stream = new MemoryStream(Encoding.UTF8.GetBytes(svg));
+ var sut = CreateSvgDimensionExtractor();
+
+ Size? result = sut.GetDimensions(stream);
+
+ Assert.That(result, Is.Null);
+ }
+
+ private SvgDimensionExtractor CreateSvgDimensionExtractor() => new(NullLogger.Instance);
+}
diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PropertyEditors/SvgFileUploadMediaSavingNotificationHandlerTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PropertyEditors/SvgFileUploadMediaSavingNotificationHandlerTests.cs
new file mode 100644
index 000000000000..58663828c12e
--- /dev/null
+++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PropertyEditors/SvgFileUploadMediaSavingNotificationHandlerTests.cs
@@ -0,0 +1,297 @@
+using System.Drawing;
+using System.Text;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using NUnit.Framework;
+using Umbraco.Cms.Core;
+using Umbraco.Cms.Core.Events;
+using Umbraco.Cms.Core.IO;
+using Umbraco.Cms.Core.Media;
+using Umbraco.Cms.Core.Models;
+using Umbraco.Cms.Core.Notifications;
+using Umbraco.Cms.Core.Scoping;
+using Umbraco.Cms.Core.Strings;
+using Umbraco.Cms.Infrastructure.PropertyEditors.NotificationHandlers;
+
+namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.PropertyEditors;
+
+[TestFixture]
+public class SvgFileUploadMediaSavingNotificationHandlerTests
+{
+ private Mock _svgDimensionExtractor = null!;
+ private Mock _fileSystem = null!;
+ private SvgFileUploadMediaSavingNotificationHandler _handler = null!;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _svgDimensionExtractor = new Mock();
+ _fileSystem = new Mock();
+
+ var mediaFileManager = new MediaFileManager(
+ _fileSystem.Object,
+ Mock.Of(),
+ NullLogger.Instance,
+ Mock.Of(),
+ Mock.Of(),
+ new Lazy(() => Mock.Of()));
+
+ _handler = new SvgFileUploadMediaSavingNotificationHandler(
+ NullLogger.Instance,
+ _svgDimensionExtractor.Object,
+ mediaFileManager);
+ }
+
+ [Test]
+ public void Skips_Non_Vector_Graphics_Media_Type()
+ {
+ IMedia media = CreateMedia(Constants.Conventions.MediaTypes.Image);
+
+ _handler.Handle(CreateNotification(media));
+
+ _svgDimensionExtractor.Verify(x => x.GetDimensions(It.IsAny()), Times.Never);
+ }
+
+ [Test]
+ public void Skips_When_Width_Property_Missing()
+ {
+ IMedia media = CreateMedia(
+ Constants.Conventions.MediaTypes.VectorGraphicsAlias,
+ hasWidthProperty: false,
+ hasHeightProperty: true);
+
+ _handler.Handle(CreateNotification(media));
+
+ _svgDimensionExtractor.Verify(x => x.GetDimensions(It.IsAny()), Times.Never);
+ }
+
+ [Test]
+ public void Skips_When_Height_Property_Missing()
+ {
+ IMedia media = CreateMedia(
+ Constants.Conventions.MediaTypes.VectorGraphicsAlias,
+ hasWidthProperty: true,
+ hasHeightProperty: false);
+
+ _handler.Handle(CreateNotification(media));
+
+ _svgDimensionExtractor.Verify(x => x.GetDimensions(It.IsAny()), Times.Never);
+ }
+
+ [Test]
+ public void Skips_When_No_Upload_Field_Property()
+ {
+ IMedia media = CreateMedia(
+ Constants.Conventions.MediaTypes.VectorGraphicsAlias,
+ uploadFieldAlias: null);
+
+ _handler.Handle(CreateNotification(media));
+
+ _svgDimensionExtractor.Verify(x => x.GetDimensions(It.IsAny()), Times.Never);
+ }
+
+ [Test]
+ public void Skips_When_Upload_Value_Is_Empty()
+ {
+ IMedia media = CreateMedia(
+ Constants.Conventions.MediaTypes.VectorGraphicsAlias,
+ uploadValue: string.Empty);
+
+ _handler.Handle(CreateNotification(media));
+
+ _svgDimensionExtractor.Verify(x => x.GetDimensions(It.IsAny()), Times.Never);
+ }
+
+ [Test]
+ public void Skips_When_File_Does_Not_Exist()
+ {
+ IMedia media = CreateMedia(
+ Constants.Conventions.MediaTypes.VectorGraphicsAlias,
+ uploadValue: "/media/test.svg");
+
+ _fileSystem.Setup(x => x.GetRelativePath(It.IsAny())).Returns("media/test.svg");
+ _fileSystem.Setup(x => x.FileExists("media/test.svg")).Returns(false);
+
+ _handler.Handle(CreateNotification(media));
+
+ _svgDimensionExtractor.Verify(x => x.GetDimensions(It.IsAny()), Times.Never);
+ }
+
+ [Test]
+ public void Sets_Width_And_Height_When_Dimensions_Extracted()
+ {
+ IMedia media = CreateMedia(
+ Constants.Conventions.MediaTypes.VectorGraphicsAlias,
+ uploadValue: "/media/test.svg",
+ widthProperty: out var widthProperty,
+ heightProperty: out var heightProperty);
+
+ var svgStream = new MemoryStream(Encoding.UTF8.GetBytes(""));
+
+ _fileSystem.Setup(x => x.GetRelativePath(It.IsAny())).Returns("media/test.svg");
+ _fileSystem.Setup(x => x.FileExists("media/test.svg")).Returns(true);
+ _fileSystem.Setup(x => x.OpenFile("media/test.svg")).Returns(svgStream);
+ _svgDimensionExtractor.Setup(x => x.GetDimensions(svgStream)).Returns(new Size(200, 100));
+
+ _handler.Handle(CreateNotification(media));
+
+ widthProperty.Verify(x => x.SetValue(200, null, null), Times.Once);
+ heightProperty.Verify(x => x.SetValue(100, null, null), Times.Once);
+ }
+
+ [Test]
+ public void Does_Not_Set_Properties_When_Dimensions_Not_Extracted()
+ {
+ IMedia media = CreateMedia(
+ Constants.Conventions.MediaTypes.VectorGraphicsAlias,
+ uploadValue: "/media/test.svg",
+ widthProperty: out var widthProperty,
+ heightProperty: out var heightProperty);
+
+ var svgStream = new MemoryStream(Encoding.UTF8.GetBytes(""));
+
+ _fileSystem.Setup(x => x.GetRelativePath(It.IsAny())).Returns("media/test.svg");
+ _fileSystem.Setup(x => x.FileExists("media/test.svg")).Returns(true);
+ _fileSystem.Setup(x => x.OpenFile("media/test.svg")).Returns(svgStream);
+ _svgDimensionExtractor.Setup(x => x.GetDimensions(svgStream)).Returns((Size?)null);
+
+ _handler.Handle(CreateNotification(media));
+
+ widthProperty.Verify(x => x.SetValue(It.IsAny