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(), It.IsAny(), It.IsAny()), Times.Never); + heightProperty.Verify(x => x.SetValue(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public void Passes_Culture_And_Segment_To_SetValue() + { + IMedia media = CreateMedia( + Constants.Conventions.MediaTypes.VectorGraphicsAlias, + uploadValue: "/media/test.svg", + culture: "en-US", + segment: "seg1", + 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, "en-US", "seg1"), Times.Once); + heightProperty.Verify(x => x.SetValue(100, "en-US", "seg1"), Times.Once); + } + + private static MediaSavingNotification CreateNotification(IMedia media) + => new(media, new EventMessages()); + + private static IMedia CreateMedia( + string contentTypeAlias, + bool hasWidthProperty = true, + bool hasHeightProperty = true, + string? uploadFieldAlias = Constants.PropertyEditors.Aliases.UploadField, + string? uploadValue = null, + string? culture = null, + string? segment = null) => + CreateMedia( + contentTypeAlias, + hasWidthProperty, + hasHeightProperty, + uploadFieldAlias, + uploadValue, + culture, + segment, + out _, + out _); + + private static IMedia CreateMedia( + string contentTypeAlias, + string? uploadValue, + out Mock widthProperty, + out Mock heightProperty, + string? culture = null, + string? segment = null) => + CreateMedia( + contentTypeAlias, + hasWidthProperty: true, + hasHeightProperty: true, + uploadFieldAlias: Constants.PropertyEditors.Aliases.UploadField, + uploadValue: uploadValue, + culture: culture, + segment: segment, + widthProperty: out widthProperty, + heightProperty: out heightProperty); + + private static IMedia CreateMedia( + string contentTypeAlias, + bool hasWidthProperty, + bool hasHeightProperty, + string? uploadFieldAlias, + string? uploadValue, + string? culture, + string? segment, + out Mock widthProperty, + out Mock heightProperty) + { + var contentType = new Mock(); + contentType.Setup(x => x.Alias).Returns(contentTypeAlias); + + var properties = new Mock(); + + // Width property. + widthProperty = new Mock(); + IProperty? widthOut = widthProperty.Object; + properties + .Setup(x => x.TryGetValue(Constants.Conventions.Media.Width, out widthOut)) + .Returns(hasWidthProperty); + + // Height property. + heightProperty = new Mock(); + IProperty? heightOut = heightProperty.Object; + properties + .Setup(x => x.TryGetValue(Constants.Conventions.Media.Height, out heightOut)) + .Returns(hasHeightProperty); + + // Upload field property. + if (uploadFieldAlias is not null) + { + var uploadPropertyType = new Mock(); + uploadPropertyType.Setup(x => x.PropertyEditorAlias).Returns(uploadFieldAlias); + + var uploadProperty = new Mock(); + uploadProperty.Setup(x => x.PropertyType).Returns(uploadPropertyType.Object); + uploadProperty.Setup(x => x.GetValue(culture, segment)).Returns(uploadValue); + uploadProperty.Setup(x => x.Values).Returns(new List + { + CreatePropertyValue(culture, segment), + }); + + properties + .Setup(x => x.GetEnumerator()) + .Returns(() => new List { uploadProperty.Object }.GetEnumerator()); + } + else + { + properties + .Setup(x => x.GetEnumerator()) + .Returns(() => new List().GetEnumerator()); + } + + var media = new Mock(); + media.Setup(x => x.ContentType).Returns(contentType.Object); + media.Setup(x => x.Properties).Returns(properties.Object); + + return media.Object; + } + + private static IPropertyValue CreatePropertyValue(string? culture, string? segment) + { + var propertyValue = new Mock(); + propertyValue.Setup(x => x.Culture).Returns(culture); + propertyValue.Setup(x => x.Segment).Returns(segment); + return propertyValue.Object; + } +}