feat(10-01): create BrandingService with magic byte validation and auto-compression
- Add IBrandingService interface with ImportLogoAsync, Save/Clear/GetMspLogoAsync - Add BrandingService: PNG/JPEG magic byte detection, rejects unsupported formats with descriptive error, auto-compresses files over 512 KB using WPF PresentationCore imaging - Add BrandingServiceTests: 9 tests covering validation, rejection, compression, CRUD - Deviation: used WPF BitmapEncoder/TransformedBitmap instead of System.Drawing.Bitmap (System.Drawing.Common not available without new NuGet package; WPF PresentationCore is in the existing stack per architectural decisions)
This commit is contained in:
152
SharepointToolbox/Services/BrandingService.cs
Normal file
152
SharepointToolbox/Services/BrandingService.cs
Normal file
@@ -0,0 +1,152 @@
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using SharepointToolbox.Core.Models;
|
||||
using SharepointToolbox.Infrastructure.Persistence;
|
||||
|
||||
namespace SharepointToolbox.Services;
|
||||
|
||||
public class BrandingService : IBrandingService
|
||||
{
|
||||
private const int MaxSizeBytes = 512 * 1024; // 512 KB
|
||||
|
||||
// PNG signature: first 4 bytes
|
||||
private static readonly byte[] PngMagic = { 0x89, 0x50, 0x4E, 0x47 };
|
||||
// JPEG signature: first 3 bytes
|
||||
private static readonly byte[] JpegMagic = { 0xFF, 0xD8, 0xFF };
|
||||
|
||||
private readonly BrandingRepository _repository;
|
||||
|
||||
public BrandingService(BrandingRepository repository)
|
||||
{
|
||||
_repository = repository;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a file, validates that it is PNG or JPEG via magic bytes, auto-compresses if over 512 KB,
|
||||
/// and returns a LogoData record. Does NOT persist anything — the caller decides where to store it.
|
||||
/// </summary>
|
||||
public async Task<LogoData> ImportLogoAsync(string filePath)
|
||||
{
|
||||
var bytes = await File.ReadAllBytesAsync(filePath);
|
||||
|
||||
var mimeType = DetectMimeType(bytes);
|
||||
|
||||
if (bytes.Length > MaxSizeBytes)
|
||||
{
|
||||
bytes = CompressToLimit(bytes, mimeType, MaxSizeBytes);
|
||||
}
|
||||
|
||||
return new LogoData
|
||||
{
|
||||
Base64 = Convert.ToBase64String(bytes),
|
||||
MimeType = mimeType
|
||||
};
|
||||
}
|
||||
|
||||
public async Task SaveMspLogoAsync(LogoData logo)
|
||||
{
|
||||
var settings = await _repository.LoadAsync();
|
||||
settings.MspLogo = logo;
|
||||
await _repository.SaveAsync(settings);
|
||||
}
|
||||
|
||||
public async Task ClearMspLogoAsync()
|
||||
{
|
||||
var settings = await _repository.LoadAsync();
|
||||
settings.MspLogo = null;
|
||||
await _repository.SaveAsync(settings);
|
||||
}
|
||||
|
||||
public async Task<LogoData?> GetMspLogoAsync()
|
||||
{
|
||||
var settings = await _repository.LoadAsync();
|
||||
return settings.MspLogo;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Private helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static string DetectMimeType(byte[] bytes)
|
||||
{
|
||||
if (bytes.Length == 0)
|
||||
throw new InvalidDataException("File is empty. Only PNG and JPG files are accepted.");
|
||||
|
||||
if (bytes.Length >= 4 && bytes[0] == PngMagic[0] && bytes[1] == PngMagic[1]
|
||||
&& bytes[2] == PngMagic[2] && bytes[3] == PngMagic[3])
|
||||
return "image/png";
|
||||
|
||||
if (bytes.Length >= 3 && bytes[0] == JpegMagic[0] && bytes[1] == JpegMagic[1]
|
||||
&& bytes[2] == JpegMagic[2])
|
||||
return "image/jpeg";
|
||||
|
||||
throw new InvalidDataException(
|
||||
"File format is not PNG or JPG. Only PNG and JPG are accepted.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compresses image bytes using WPF imaging (PresentationCore) to fit within <paramref name="maxBytes"/>.
|
||||
/// Resizes proportionally to max 300x300 at quality 75 first pass; if still too large, 200x200 at quality 50.
|
||||
/// </summary>
|
||||
private static byte[] CompressToLimit(byte[] bytes, string mimeType, int maxBytes)
|
||||
{
|
||||
// First pass: resize to 300x300 max, quality 75
|
||||
var compressed = ResizeAndEncode(bytes, mimeType, 300, 75);
|
||||
if (compressed.Length <= maxBytes)
|
||||
return compressed;
|
||||
|
||||
// Second pass: resize to 200x200 max, quality 50
|
||||
compressed = ResizeAndEncode(bytes, mimeType, 200, 50);
|
||||
return compressed;
|
||||
}
|
||||
|
||||
private static byte[] ResizeAndEncode(byte[] originalBytes, string mimeType, int maxDimension, int quality)
|
||||
{
|
||||
// Decode source image using WPF BitmapDecoder
|
||||
using var inputStream = new MemoryStream(originalBytes);
|
||||
var decoder = BitmapDecoder.Create(
|
||||
inputStream,
|
||||
BitmapCreateOptions.PreservePixelFormat,
|
||||
BitmapCacheOption.OnLoad);
|
||||
|
||||
var frame = decoder.Frames[0];
|
||||
|
||||
// Calculate target dimensions (proportional scaling)
|
||||
double srcWidth = frame.PixelWidth;
|
||||
double srcHeight = frame.PixelHeight;
|
||||
double scale = Math.Min((double)maxDimension / srcWidth, (double)maxDimension / srcHeight);
|
||||
|
||||
// Only scale down, never up
|
||||
if (scale >= 1.0)
|
||||
scale = 1.0;
|
||||
|
||||
int targetWidth = Math.Max(1, (int)(srcWidth * scale));
|
||||
int targetHeight = Math.Max(1, (int)(srcHeight * scale));
|
||||
|
||||
// Scale the bitmap using TransformedBitmap
|
||||
var scaledBitmap = new TransformedBitmap(
|
||||
frame,
|
||||
new ScaleTransform(scale, scale));
|
||||
|
||||
// Encode to target format
|
||||
using var outputStream = new MemoryStream();
|
||||
BitmapEncoder encoder = mimeType == "image/png"
|
||||
? new PngBitmapEncoder()
|
||||
: CreateJpegEncoder(quality);
|
||||
|
||||
encoder.Frames.Add(BitmapFrame.Create(scaledBitmap));
|
||||
encoder.Save(outputStream);
|
||||
|
||||
return outputStream.ToArray();
|
||||
}
|
||||
|
||||
private static BitmapEncoder CreateJpegEncoder(int quality)
|
||||
{
|
||||
return new JpegBitmapEncoder
|
||||
{
|
||||
QualityLevel = quality
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user