Skip to content

Commit

Permalink
Merge pull request #3 from MilanLund/feature/format-conversion-media-…
Browse files Browse the repository at this point in the history
…library-support

Add support for Media Library images and format conversion
  • Loading branch information
liamgold authored Aug 3, 2024
2 parents 2a23a16 + c7d5f8f commit a7b97e6
Show file tree
Hide file tree
Showing 3 changed files with 128 additions and 33 deletions.
20 changes: 20 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -396,3 +396,23 @@ FodyWeavers.xsd

# JetBrains Rider
*.sln.iml

# Exclude the entire .idea folder for JetBrains IDEs
.idea/

# Include specific project configuration files
!.idea/misc.xml
!.idea/modules.xml
!.idea/vcs.xml
!.idea/runConfigurations/
!.idea/codeStyles/
!.idea/inspectionProfiles/
!.idea/dictionaries/
!.idea/libraries/

# Exclude user-specific files
.idea/workspace.xml
.idea/tasks.xml
.idea/usage.statistics.xml
.idea/dictionaries/
.idea/shelf/
46 changes: 33 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,7 @@

## Description

Xperience by Kentico comes with image processing abilities for the media library [Kentico.Xperience.ImageProcessing](https://www.nuget.org/packages/Kentico.Xperience.ImageProcessing) but lacks the ability to process images stored as assets in the Content Hub.

Image processing capabilities are on the [roadmap](https://roadmap.kentico.com/c/227-media-asset-transformations) for the Content Hub, but in the meantime, this package provides a way to processing Content Hub assets in the same way as media library images, through the use of SkiaSharp.

NOTE: This package **will** eventually be deprecated once the Content Hub has image processing capabilities.
This package provides a way to resize images and convert them to `webp`, `jpg`, and `png` formats. It supports images from *Media libraries* and Content hub items stored as *Content item assets*.

## Library Version Matrix

Expand All @@ -31,6 +27,21 @@ dotnet add package XperienceCommunity.ImageProcessing

1. Install NuGet package above.

1. Add the following configuration to your `appsettings.json`:

```json
{
"ImageProcessing": {
"ProcessMediaLibrary": true,
"ProcessContentItemAssets": true
}
}
```

- `ProcessMediaLibrary`: Set to `true` to enable image processing for Media library images. Defaults to `true`.
- `ProcessContentItemAssets`: Set to `true` to enable image processing for Content Hub assets. Defaults to `true`.


1. Register the Image Processing middleware using `app.UseXperienceCommunityImageProcessing()`:

```csharp
Expand All @@ -39,25 +50,34 @@ dotnet add package XperienceCommunity.ImageProcessing
app.UseKentico();

// ...
builder.Services.Configure<ImageProcessingOptions>(builder.Configuration.GetSection("ImageProcessing"));

app.UseXperienceCommunityImageProcessing();
```

1. You should be able to use the `width`, `height`, and `maxSideSize` query parameters on your Content Hub asset URLs to resize the image. Examples:
1. You should be able to use the `width`, `height`, and `maxSideSize` query parameters on your image URLs to resize the image. Examples:

1. Resize the image to a width of 100px:
1. Resize the Media library image to a width of 100px:
```
https://yourdomain.com/your-asset-url?width=100
https://yourdomain.com/getmedia/rest-of-your-asset-url?width=100
```
1. Resize the image to a height of 100px:
1. Resize the Content item asset image to a height of 100px:
```
https://yourdomain.com/your-asset-url?height=100
https://yourdomain.com/getContentAsset/rest-of-your-asset-url?height=100
```
1. Resize the image to a maximum side size of 100px:
1. You can also use the `format` query parameter to convert the image to a different format. Allowed values are: `webp`, `jpg` and `png`. Example:
1. Convert the Media library image to `webp`:
```
https://yourdomain.com/your-asset-url?maxSideSize=100
https://yourdomain.com/getmedia/rest-of-your-asset-url?format=webp
```
1. Convert the Content item asset image to `png`:
```
https://yourdomain.com/getContentAsset/rest-of-your-asset-url?format=png
```
## Contributing
Expand Down
95 changes: 75 additions & 20 deletions src/Middleware/ImageProcessingMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,23 @@
using Microsoft.AspNetCore.StaticFiles;
using SkiaSharp;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Options;
using Path = System.IO.Path;

namespace XperienceCommunity.ImageProcessing;

public class ImageProcessingMiddleware(RequestDelegate next, IEventLogService eventLogService)
public class ImageProcessingMiddleware(RequestDelegate next, IEventLogService eventLogService, IOptions<ImageProcessingOptions>? options)
{
private readonly RequestDelegate _next = next ?? throw new ArgumentNullException(nameof(next));
private readonly IEventLogService _eventLogService = eventLogService ?? throw new ArgumentNullException(nameof(eventLogService));
private readonly ImageProcessingOptions _options = options?.Value ?? new ImageProcessingOptions();
private readonly FileExtensionContentTypeProvider _contentTypeProvider = new();

private readonly string[] _supportedContentTypes =
[
"image/jpeg",
"image/png",
"image/gif",
"image/webp"
];

Expand All @@ -38,11 +41,12 @@ public async Task InvokeAsync(HttpContext context)
return;
}

if (context.Request.Path.StartsWithSegments("/getContentAsset"))
if (IsPathToBeProcessed(context.Request.Path, _options))
{
int? width = null;
int? height = null;
int? maxSideSize = null;
var format = contentType;

if (context.Request.Query.ContainsKey("width") && int.TryParse(context.Request.Query["width"], out int parsedWidth))
{
Expand All @@ -59,13 +63,25 @@ public async Task InvokeAsync(HttpContext context)
maxSideSize = parsedMaxSideSize;
}

if (context.Request.Query.ContainsKey("format"))
{
string? formatParsed = context.Request.Query["format"];

if (!string.IsNullOrEmpty(formatParsed))
{
if (!formatParsed.StartsWith("image/")) formatParsed = $"image/{formatParsed}";
if (formatParsed == "image/jpg") formatParsed = "image/jpeg";
if (IsSupportedContentType(formatParsed)) format = formatParsed;
}
}

if (width.HasValue || height.HasValue || maxSideSize.HasValue)
{
responseBodyStream.Seek(0, SeekOrigin.Begin);
var originalImageBytes = responseBodyStream.ToArray();

// Generate ETag
var eTag = GenerateETag(originalImageBytes, width ?? 0, height ?? 0, maxSideSize ?? 0);
var eTag = GenerateETag(originalImageBytes, width ?? 0, height ?? 0, maxSideSize ?? 0, format);

// Check if the ETag matches the client's If-None-Match header
if (context.Request.Headers.IfNoneMatch == eTag)
Expand All @@ -76,17 +92,20 @@ public async Task InvokeAsync(HttpContext context)
return;
}

var resizedImageBytes = await ResizeImageAsync(originalImageBytes, width ?? 0, height ?? 0, maxSideSize ?? 0, contentType);
var processedImageBytes = await ProcessImageAsync(originalImageBytes, width ?? 0, height ?? 0, maxSideSize ?? 0, format, contentType, context.Request.Path);

var filename = $"{Path.GetFileNameWithoutExtension(context.Request.Path)}.{GetFileExtensionByContentType(format)}";

context.Response.Body = originalResponseBodyStream;
context.Response.ContentType = contentType;
context.Response.ContentType = format;
context.Response.Headers.ETag = eTag;
context.Response.Headers.CacheControl = "public, max-age=31536000";
context.Response.Headers.ContentLength = resizedImageBytes.Length;
context.Response.Headers.ContentLength = processedImageBytes.Length;
context.Response.Headers.ContentDisposition = $"inline; filename={filename}";

if (context.Response.StatusCode != StatusCodes.Status304NotModified)
{
await context.Response.Body.WriteAsync(resizedImageBytes);
await context.Response.Body.WriteAsync(processedImageBytes);
}
return;
}
Expand All @@ -95,7 +114,7 @@ public async Task InvokeAsync(HttpContext context)
await CopyStreamAndRestore(responseBodyStream, originalResponseBodyStream, context);
}

private async Task<byte[]> ResizeImageAsync(byte[] imageBytes, int width, int height, int maxSideSize, string contentType)
private async Task<byte[]> ProcessImageAsync(byte[] imageBytes, int width, int height, int maxSideSize, string format, string contentType, string path)
{
if (imageBytes.Length == 0 || !IsSupportedContentType(contentType))
{
Expand All @@ -113,27 +132,32 @@ private async Task<byte[]> ResizeImageAsync(byte[] imageBytes, int width, int he
using var originalBitmap = SKBitmap.Decode(inputStream);
if (originalBitmap == null)
{
_eventLogService.LogWarning(nameof(ImageProcessingMiddleware), nameof(ResizeImageAsync), "Failed to decode image.");
_eventLogService.LogWarning(nameof(ImageProcessingMiddleware), nameof(ProcessImageAsync), "Failed to decode image.");
return imageBytes;
}

var newDims = ImageHelper.EnsureImageDimensions(width, height, maxSideSize, originalBitmap.Width, originalBitmap.Height);
var resizedBitmap = originalBitmap;

using var resizedBitmap = originalBitmap.Resize(new SKImageInfo(newDims[0], newDims[1]), SKFilterQuality.High);
if (resizedBitmap == null)
// Resize the image if it is a Content item asset only as Media library images are already resized by XbyK
if (IsPathContentItemAsset(path))
{
_eventLogService.LogWarning(nameof(ImageProcessingMiddleware), nameof(ResizeImageAsync), "Failed to resize image.");
return imageBytes;
var newDims = ImageHelper.EnsureImageDimensions(width, height, maxSideSize, originalBitmap.Width, originalBitmap.Height);
resizedBitmap = originalBitmap.Resize(new SKImageInfo(newDims[0], newDims[1]), SKFilterQuality.High);
if (resizedBitmap == null)
{
_eventLogService.LogWarning(nameof(ImageProcessingMiddleware), nameof(ProcessImageAsync), "Failed to resize image.");
return imageBytes;
}
}

using var outputStream = new MemoryStream();
var imageFormat = GetImageFormat(contentType);
var imageFormat = GetImageFormat(format);
await Task.Run(() => resizedBitmap.Encode(imageFormat, 80).SaveTo(outputStream));
return outputStream.ToArray();
}
catch (Exception ex)
{
_eventLogService.LogException(nameof(ImageProcessingMiddleware), nameof(ResizeImageAsync), ex);
_eventLogService.LogException(nameof(ImageProcessingMiddleware), nameof(ProcessImageAsync), ex);
return imageBytes;
}
}
Expand All @@ -155,21 +179,46 @@ private bool IsSupportedContentType(string contentType)
return Array.Exists(_supportedContentTypes, ct => ct.Equals(contentType, StringComparison.OrdinalIgnoreCase));
}

private static bool IsPathMediaLibrary(PathString path) => path.StartsWithSegments("/getmedia");
private static bool IsPathContentItemAsset(PathString path) => path.StartsWithSegments("/getContentAsset");

private static bool IsPathToBeProcessed(PathString path, ImageProcessingOptions options)
{
// Set default values
var processMediaLibrary = options.ProcessMediaLibrary ??= true;
var processContentItemAssets = options.ProcessContentItemAssets ??= true;

if (processMediaLibrary && IsPathMediaLibrary(path))
{
return true;
}

return processContentItemAssets && IsPathContentItemAsset(path);
}

private static SKEncodedImageFormat GetImageFormat(string contentType) => contentType switch
{
"image/jpeg" => SKEncodedImageFormat.Jpeg,
"image/png" => SKEncodedImageFormat.Png,
"image/gif" => SKEncodedImageFormat.Gif,
"image/webp" => SKEncodedImageFormat.Webp,
_ => SKEncodedImageFormat.Png,
_ => SKEncodedImageFormat.Webp,
};

private static string GenerateETag(byte[] imageBytes, int width, int height, int maxSideSize)
private static string GetFileExtensionByContentType(string contentType) => contentType switch
{
"image/jpeg" => "jpg",
"image/png" => "png",
"image/webp" => "webp",
_ => "webp",
};

private static string GenerateETag(byte[] imageBytes, int width, int height, int maxSideSize, string format)
{
var inputBytes = imageBytes
.Concat(BitConverter.GetBytes(width))
.Concat(BitConverter.GetBytes(height))
.Concat(BitConverter.GetBytes(maxSideSize))
.Concat(Encoding.UTF8.GetBytes(format))
.ToArray();

var hash = MD5.HashData(inputBytes);
Expand All @@ -184,6 +233,12 @@ private static async Task CopyStreamAndRestore(MemoryStream responseBodyStream,
}
}

public class ImageProcessingOptions
{
public bool? ProcessMediaLibrary { get; set; } = true;
public bool? ProcessContentItemAssets { get; set; } = true;
}

public static class ImageProcessingMiddlewareExtensions
{
/// <summary>
Expand Down

0 comments on commit a7b97e6

Please sign in to comment.