diff --git a/Controllers/LockerImageController.cs b/Controllers/LockerImageController.cs index bae08a8..1a08fe8 100644 --- a/Controllers/LockerImageController.cs +++ b/Controllers/LockerImageController.cs @@ -87,7 +87,6 @@ private async Task GenerateImage(Locker locker) canvas.DrawRect(0, 0, imageInfo.Width, imageInfo.Height, backgroundPaint); - var textBounds = new SKRect(); var segoeFont = await assets.GetFont("Assets/Fonts/Segoe.ttf"); // don't dispose var iconBitmap = await assets.GetBitmap("Assets/Images/Locker/Icon.png"); // don't dispose @@ -113,8 +112,7 @@ private async Task GenerateImage(Locker locker) namePaint.TextSize = nameFontSize; namePaint.FilterQuality = SKFilterQuality.Medium; - namePaint.MeasureText(locker.PlayerName, ref textBounds); - canvas.DrawText(locker.PlayerName, 50 + resize + splitWidth * 3, 58 - textBounds.Top, namePaint); + canvas.DrawAlignedText(locker.PlayerName, new SKPoint(50 + resize + splitWidth * 3, 58), namePaint); using var discordBoxBitmap = await ImageUtils.GenerateDiscordBox(assets, locker.UserName, uiResizingFactor); canvas.DrawBitmap(discordBoxBitmap, imageInfo.Width - 50 - discordBoxBitmap.Width, 39); @@ -145,7 +143,7 @@ private async Task GenerateItemCards(Locker locker, CancellationToken cancellati { var options = new ParallelOptions { - MaxDegreeOfParallelism = Environment.ProcessorCount / 2, + MaxDegreeOfParallelism = Math.Max(1, Environment.ProcessorCount / 2), CancellationToken = cancellationToken }; using var client = clientFactory.CreateClient(); @@ -187,39 +185,60 @@ await Parallel.ForEachAsync(locker.Items, options, async (item, token) => } } - SKBitmap? itemImage = null; - if (itemImageBytes is not null) { var itemImageRaw = SKBitmap.Decode(itemImageBytes); + if (itemImageRaw is null) + { + logger.LogWarning("Image data for {ItemName} ({ItemId}) could not be decoded", item.Name, item.Id); + item.Image = await GenerateItemCard(item, null); + return; + } + + SKBitmap? itemImage; if (itemImageRaw.Width != 256 || itemImageRaw.Height != 256) { fileExists = false; - var resized = itemImageRaw.Resize(new SKImageInfo(256, 256), SKFilterQuality.Medium); - itemImageRaw.Dispose(); - itemImage = resized; + try + { + itemImage = itemImageRaw.Resize(new SKImageInfo(256, 256), SKFilterQuality.Medium); + } + finally + { + itemImageRaw.Dispose(); + } } else { + // Transfer ownership; itemImage is disposed after the card has been generated. itemImage = itemImageRaw; } - if (!fileExists) + if (itemImage is null) { - Directory.CreateDirectory(BASE_ITEM_IMAGE_PATH); - using var data = itemImage.Encode(SKEncodedImageFormat.Png, 100); - var dataBytes = data.AsSpan().ToArray(); - await using var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write, - FileShare.None, 4096, true); - fileStream.SetLength(dataBytes.LongLength); - await fileStream.WriteAsync(dataBytes, token); + logger.LogWarning("Image for {ItemName} ({ItemId}) could not be resized", item.Name, item.Id); + item.Image = await GenerateItemCard(item, null); + return; } - } - using (itemImage) - { - item.Image = await GenerateItemCard(item, itemImage); + using (itemImage) + { + if (!fileExists) + { + Directory.CreateDirectory(BASE_ITEM_IMAGE_PATH); + using var data = itemImage.Encode(SKEncodedImageFormat.Png, 100); + await using var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write, + FileShare.None, 4096, true); + await data.AsStream().CopyToAsync(fileStream, token); + } + + item.Image = await GenerateItemCard(item, itemImage); + } + + return; } + + item.Image = await GenerateItemCard(item, null); }); } @@ -246,11 +265,12 @@ private async Task GenerateItemCard(LockerItem lockerItem, SKBitmap? i questionmarkPaint.TextSize = 256.0f; questionmarkPaint.TextAlign = SKTextAlign.Center; - var questionmarkTextBounds = new SKRect(); - questionmarkPaint.MeasureText("?", ref questionmarkTextBounds); - - canvas.DrawText("?", (float)bitmap.Width / 2, (float)bitmap.Height / 2 + questionmarkTextBounds.Height / 2, - questionmarkPaint); + canvas.DrawAlignedText( + "?", + imageInfo.Rect, + questionmarkPaint, + SKTextAlign.Center, + VerticalTextAlignment.Center); } var typeIcon = lockerItem.SourceType != SourceType.Other @@ -274,10 +294,11 @@ private async Task GenerateItemCard(LockerItem lockerItem, SKBitmap? i namePaint.Typeface = fortniteFont; namePaint.TextAlign = SKTextAlign.Center; - var entryNameTextBounds = new SKRect(); - namePaint.MeasureText(lockerItem.Name, ref entryNameTextBounds); - canvas.DrawText(lockerItem.Name, (float)bitmap.Width / 2, bitmap.Height - 59 + entryNameTextBounds.Height, - namePaint); + canvas.DrawAlignedText( + lockerItem.Name, + new SKPoint(bitmap.Width / 2f, bitmap.Height - 59), + namePaint, + SKTextAlign.Center); using var descriptionPaint = new SKPaint(); descriptionPaint.IsAntialias = true; @@ -286,9 +307,11 @@ private async Task GenerateItemCard(LockerItem lockerItem, SKBitmap? i descriptionPaint.Typeface = fortniteFont; descriptionPaint.TextAlign = SKTextAlign.Center; - descriptionPaint.MeasureText(lockerItem.Description, ref entryNameTextBounds); - canvas.DrawText(lockerItem.Description, (float)bitmap.Width / 2, - bitmap.Height - 42 + entryNameTextBounds.Height, descriptionPaint); + canvas.DrawAlignedText( + lockerItem.Description, + new SKPoint(bitmap.Width / 2f, bitmap.Height - 42), + descriptionPaint, + SKTextAlign.Center); using var sourcePaint = new SKPaint(); sourcePaint.IsAntialias = true; @@ -299,9 +322,12 @@ private async Task GenerateItemCard(LockerItem lockerItem, SKBitmap? i var fontOffset = lockerItem.SourceType == SourceType.Other ? 10 : 42; - sourcePaint.MeasureText(lockerItem.Source, ref entryNameTextBounds); - canvas.DrawText(lockerItem.Source, bitmap.Width - fontOffset, bitmap.Height - entryNameTextBounds.Height + 8, - sourcePaint); + canvas.DrawAlignedText( + lockerItem.Source, + new SKPoint(bitmap.Width - fontOffset, bitmap.Height - 7), + sourcePaint, + SKTextAlign.Right, + VerticalTextAlignment.Bottom); return bitmap; } @@ -342,7 +368,11 @@ private async Task GenerateFooter(float resizeFactor) canvas.DrawRoundRect((50 + 10) * resizeFactor, (imageInfo.Height - 40 * resizeFactor) / 2, 5 * resizeFactor, 40 * resizeFactor, splitR, splitR, splitPaint); - canvas.DrawText(text, (50 + 10 + 5 + 10) * resizeFactor, (imageInfo.Height + textBounds.Height) / 2, textPaint); + canvas.DrawAlignedText( + text, + new SKPoint((50 + 10 + 5 + 10) * resizeFactor, imageInfo.Height / 2f), + textPaint, + verticalAlignment: VerticalTextAlignment.Center); return bitmap; } @@ -358,4 +388,4 @@ private static string changeUrlImageSize(string originalUrl, int size) var modifiedFileName = filePart.Insert(filePart.LastIndexOf('.'), $"_{size}"); return $"{basePart}{modifiedFileName}"; } -} \ No newline at end of file +} diff --git a/Controllers/ShopImageController.cs b/Controllers/ShopImageController.cs index e653ae9..1195ed1 100644 --- a/Controllers/ShopImageController.cs +++ b/Controllers/ShopImageController.cs @@ -59,12 +59,12 @@ public async Task Shop([FromBody] Shop shop, [FromQuery] bool? fo var templateHash = shop.GetTemplateHash(); var localeTemplateHash = shop.GetLocaleTemplateHash(); - SKBitmap? templateBitmap; + SKBitmap templateBitmapCopy; ShopSectionLocationData[]? locationData; using (await namedLock.LockAsync($"shop_template_{templateHash}", cancellationToken).ConfigureAwait(false)) { logger.LogDebug("Acquired shop template lock"); - templateBitmap = cache.Get($"shop_template_bmp_{templateHash}"); + var templateBitmap = cache.Get($"shop_template_bmp_{templateHash}"); locationData = cache.Get($"shop_location_data_{templateHash}"); if (_forceNew || templateBitmap is null) { @@ -76,27 +76,33 @@ public async Task Shop([FromBody] Shop shop, [FromQuery] bool? fo cache.Set($"shop_template_bmp_{templateHash}", templateBitmap, ShopImageCacheOptions); cache.Set($"shop_location_data_{templateHash}", locationData, TimeSpan.FromMinutes(10)); } + templateBitmapCopy = templateBitmap.Copy(); logger.LogDebug("Releasing shop template lock"); } - SKBitmap? localeTemplateBitmap; - using (await namedLock.LockAsync($"shop_template_{localeTemplateHash}", cancellationToken).ConfigureAwait(false)) + using (templateBitmapCopy) { - logger.LogDebug("Acquired locale shop template lock"); - localeTemplateBitmap = cache.Get($"shop_template_{localeTemplateHash}_bmp"); - if (_forceNew || localeTemplateBitmap == null) + SKBitmap localeTemplateBitmapCopy; + using (await namedLock.LockAsync($"shop_template_{localeTemplateHash}", cancellationToken).ConfigureAwait(false)) { - logger.LogDebug("Generating new locale shop template"); - localeTemplateBitmap = await GenerateLocaleTemplate(shop, templateBitmap, locationData!); - cache.Set($"shop_template_{localeTemplateHash}_bmp", localeTemplateBitmap, ShopImageCacheOptions); + logger.LogDebug("Acquired locale shop template lock"); + var localeTemplateBitmap = cache.Get($"shop_template_{localeTemplateHash}_bmp"); + if (_forceNew || localeTemplateBitmap == null) + { + logger.LogDebug("Generating new locale shop template"); + localeTemplateBitmap = await GenerateLocaleTemplate(shop, templateBitmapCopy, locationData!); + cache.Set($"shop_template_{localeTemplateHash}_bmp", localeTemplateBitmap, ShopImageCacheOptions); + } + localeTemplateBitmapCopy = localeTemplateBitmap.Copy(); + logger.LogDebug("Releasing locale shop template lock"); } - logger.LogDebug("Releasing locale shop template lock"); - } - logger.LogDebug("Generating final shop image"); - using var shopImage = await GenerateShopImage(shop, localeTemplateBitmap); - var data = shopImage.Encode(SKEncodedImageFormat.Png, 100); - return File(data.AsStream(true), "image/png"); + logger.LogDebug("Generating final shop image"); + using var localeCopy = localeTemplateBitmapCopy; + using var shopImage = await GenerateShopImage(shop, localeCopy); + var data = shopImage.Encode(SKEncodedImageFormat.Png, 100); + return File(data.AsStream(true), "image/png"); + } } [HttpPost("section")] @@ -108,12 +114,12 @@ public async Task ShopSection( var _isNewShop = isNewShop ?? false; logger.LogInformation("Item Shop section image request received | Locale = {Locale} | New Shop = {SectionId}", locale, section.Id); - SKBitmap? templateBitmap; + SKBitmap templateBitmapCopy; ShopSectionLocationData? shopSectionLocationData; using (await namedLock.LockAsync($"shop_section_template_{section.Id}", cancellationToken).ConfigureAwait(false)) { - templateBitmap = cache.Get($"shop_section_template_bmp_{section.Id}"); + var templateBitmap = cache.Get($"shop_section_template_bmp_{section.Id}"); shopSectionLocationData = cache.Get($"shop_section_location_data_{section.Id}"); if (_isNewShop || templateBitmap is null) { @@ -125,25 +131,28 @@ public async Task ShopSection( cache.Set($"shop_section_location_data_{section.Id}", shopSectionLocationData, TimeSpan.FromMinutes(10)); } + templateBitmapCopy = templateBitmap.Copy(); } - SKBitmap? localeTemplateBitmap; + using var templateCopy = templateBitmapCopy; + SKBitmap localeTemplateBitmapCopy; var lockName = $"shop_section_template_{locale}_{section.Id}"; using (await namedLock.LockAsync(lockName, cancellationToken).ConfigureAwait(false)) { - localeTemplateBitmap = cache.Get($"shop_section_template_{locale}_bmp_{section.Id}"); + var localeTemplateBitmap = cache.Get($"shop_section_template_{locale}_bmp_{section.Id}"); if (_isNewShop || localeTemplateBitmap == null) { localeTemplateBitmap = - await GenerateSectionLocaleTemplate(section, templateBitmap, shopSectionLocationData!); + await GenerateSectionLocaleTemplate(section, templateCopy, shopSectionLocationData!); cache.Set($"shop_section_template_{locale}_bmp_{section.Id}", localeTemplateBitmap, ShopImageCacheOptions); } + localeTemplateBitmapCopy = localeTemplateBitmap.Copy(); } - using var localeTemplateBitmapCopy = localeTemplateBitmap.Copy(); - using var image = await GenerateShopSectionImage(section, localeTemplateBitmapCopy); + using var localeCopy = localeTemplateBitmapCopy; + using var image = await GenerateShopSectionImage(section, localeCopy); var data = image.Encode(SKEncodedImageFormat.Png, 100); return File(data.AsStream(true), "image/png"); } @@ -158,7 +167,7 @@ private async Task PrefetchImages(IReadOnlyList sections, Cancellat var entries = sections.SelectMany(x => x.Entries); var options = new ParallelOptions { - MaxDegreeOfParallelism = Environment.ProcessorCount / 2, + MaxDegreeOfParallelism = Math.Max(1, Environment.ProcessorCount / 2), CancellationToken = cancellationToken }; using var client = clientFactory.CreateClient(); @@ -180,7 +189,8 @@ await Parallel.ForEachAsync(entries, options, async (entry, token) => try { var imageBytes = await client.GetByteArrayAsync(url, token); - bitmap = SKBitmap.Decode(imageBytes); + bitmap = SKBitmap.Decode(imageBytes) + ?? throw new InvalidDataException($"Upstream image for shop entry '{entry.Id}' is invalid."); } catch (Exception) { @@ -217,15 +227,14 @@ private async Task GenerateShopImage(Shop shop, SKBitmap templateBitma } else { - using var backgroundImagePaint = new SKPaintSafe(); + using var backgroundImagePaint = new SKPaint(); backgroundImagePaint.IsAntialias = true; backgroundImagePaint.FilterQuality = SKFilterQuality.Medium; - using var resizedCustomBackgroundBitmap = backgroundBitmap.Resize(imageInfo, SKFilterQuality.Medium); - backgroundImagePaint.Shader = SKShader.CreateBitmap(resizedCustomBackgroundBitmap, SKShaderTileMode.Clamp, - SKShaderTileMode.Repeat); - - canvas.DrawRoundRect(0, 0, imageInfo.Width, imageInfo.Height, 50, 50, backgroundImagePaint); + canvas.Save(); + canvas.ClipRoundRect(new SKRoundRect(imageInfo.Rect, 50), antialias: true); + canvas.DrawBitmap(backgroundBitmap, imageInfo.Rect, backgroundImagePaint); + canvas.Restore(); } canvas.DrawBitmap(templateBitmap, 0, 0); @@ -242,9 +251,12 @@ private async Task GenerateShopImage(Shop shop, SKBitmap templateBitma var maxBoxWidth = imageInfo.Width - 3 * HORIZONTAL_PADDING - shopTitleWidth; - using var creatorCodeBoxBitmap = - await GenerateCreatorCodeBox(shop.CreatorCodeTitle, shop.CreatorCode, maxBoxWidth); - canvas.DrawBitmap(creatorCodeBoxBitmap, imageInfo.Width - 100 - creatorCodeBoxBitmap.Width, 100); + if (maxBoxWidth > 0) + { + using var creatorCodeBoxBitmap = + await GenerateCreatorCodeBox(shop.CreatorCodeTitle, shop.CreatorCode, maxBoxWidth); + canvas.DrawBitmap(creatorCodeBoxBitmap, imageInfo.Width - 100 - creatorCodeBoxBitmap.Width, 100); + } var adBannerBitmap = await assets.GetBitmap("Assets/Images/Shop/ad_banner.png"); // don't dispose canvas.DrawBitmap(adBannerBitmap, imageInfo.Width - 100 - 50 - adBannerBitmap!.Width, @@ -271,7 +283,7 @@ private async Task GenerateLocaleTemplate(Shop shop, SKBitmap template shopTitlePaint.Typeface = await assets.GetFont("Assets/Fonts/Fortnite-86Bold.otf"); var shopTitleWidth = shopTitlePaint.MeasureText(shop.Title); - canvas.DrawText(shop.Title, 100, 50 - shopTitlePaint.FontMetrics.Ascent, shopTitlePaint); + canvas.DrawAlignedText(shop.Title, new SKPoint(100, 50), shopTitlePaint); // Drawing the date using var datePaint = new SKPaint(); @@ -284,8 +296,8 @@ private async Task GenerateLocaleTemplate(Shop shop, SKBitmap template var datePoint = new SKPoint( Math.Max(HORIZONTAL_PADDING + shopTitleWidth / 2f, HORIZONTAL_PADDING + datePaint.MeasureText(shop.Date) / 2), - 313 - datePaint.FontMetrics.Ascent); - canvas.DrawText(shop.Date, datePoint, datePaint); + 313); + canvas.DrawAlignedText(shop.Date, datePoint, datePaint, SKTextAlign.Center); foreach (var sectionLocationData in shopSectionLocationData) { @@ -300,9 +312,8 @@ private async Task GenerateLocaleTemplate(Shop shop, SKBitmap template sectionNamePaint.Color = SKColors.White; sectionNamePaint.Typeface = await assets.GetFont("Assets/Fonts/Fortnite-86BoldItalic.otf"); - var sectionNamePoint = new SKPoint(sectionLocationData.Name.X, - sectionLocationData.Name.Y - sectionNamePaint.FontMetrics.Ascent); - canvas.DrawText(shopSection?.Name, sectionNamePoint, sectionNamePaint); + var sectionNamePoint = new SKPoint(sectionLocationData.Name.X, sectionLocationData.Name.Y); + canvas.DrawAlignedText(shopSection.Name, sectionNamePoint, sectionNamePaint); } foreach (var entryLocationData in sectionLocationData.Entries) @@ -317,18 +328,19 @@ private async Task GenerateLocaleTemplate(Shop shop, SKBitmap template entryNamePaint.Color = SKColors.White; entryNamePaint.Typeface = await assets.GetFont("Assets/Fonts/Fortnite-75Medium.otf"); - var entryNameTextBounds = new SKRect(); var nameLines = SplitNameText(shopEntry.Name, entryLocationData.Name.MaxWidth ?? 0, entryNamePaint); if (nameLines.Length > 1) { - entryNamePaint.MeasureText(nameLines[0], ref entryNameTextBounds); - canvas.DrawText(nameLines[0], entryLocationData.Name.X, - entryLocationData.Name.Y + entryNameTextBounds.Height - 33, entryNamePaint); + canvas.DrawAlignedText( + nameLines[0], + new SKPoint(entryLocationData.Name.X, entryLocationData.Name.Y - 33), + entryNamePaint); } - entryNamePaint.MeasureText(nameLines.Last(), ref entryNameTextBounds); - canvas.DrawText(nameLines.Last(), entryLocationData.Name.X, - entryLocationData.Name.Y + entryNameTextBounds.Height, entryNamePaint); + canvas.DrawAlignedText( + nameLines.Last(), + new SKPoint(entryLocationData.Name.X, entryLocationData.Name.Y), + entryNamePaint); // Draw the shop entry price using var pricePaint = new SKPaint(); @@ -338,9 +350,14 @@ private async Task GenerateLocaleTemplate(Shop shop, SKBitmap template pricePaint.Typeface = await assets.GetFont("Assets/Fonts/Fortnite-75Medium.otf"); var priceTextWidth = pricePaint.MeasureText(shopEntry.FinalPrice); - var pricePoint = new SKPoint(entryLocationData.Price.X, + var pricePoint = new SKPoint( + entryLocationData.Price.X, entryLocationData.Price.Y - pricePaint.FontMetrics.Descent); - canvas.DrawText(shopEntry.FinalPrice, pricePoint, pricePaint); + canvas.DrawAlignedText( + shopEntry.FinalPrice, + pricePoint, + pricePaint, + verticalAlignment: VerticalTextAlignment.Baseline); // Draw strikeout old price if item is discounted if (shopEntry.FinalPrice != shopEntry.RegularPrice) @@ -352,9 +369,14 @@ private async Task GenerateLocaleTemplate(Shop shop, SKBitmap template oldPricePaint.Typeface = await assets.GetFont("Assets/Fonts/Fortnite-75Medium.otf"); var oldPriceTextWidth = oldPricePaint.MeasureText(shopEntry.RegularPrice); - var oldPricePoint = new SKPoint(entryLocationData.Price.X + priceTextWidth + 9, - entryLocationData.Price.Y - pricePaint.FontMetrics.Descent); - canvas.DrawText(shopEntry.RegularPrice, oldPricePoint, oldPricePaint); + var oldPricePoint = new SKPoint( + entryLocationData.Price.X + priceTextWidth + 9, + entryLocationData.Price.Y - oldPricePaint.FontMetrics.Descent); + canvas.DrawAlignedText( + shopEntry.RegularPrice, + oldPricePoint, + oldPricePaint, + verticalAlignment: VerticalTextAlignment.Baseline); // Draw the strikeout line using var strikePaint = new SKPaint(); @@ -551,8 +573,11 @@ private async Task GenerateBanner(string text, IReadOnlyList c } - // 6 + textBounds.Top - canvas.DrawText(text, 13, (float)imageInfo.Height / 2 - textBounds.MidY, bannerPaint); + canvas.DrawAlignedText( + text, + new SKPoint(13, imageInfo.Height / 2f), + bannerPaint, + verticalAlignment: VerticalTextAlignment.Center); return bitmap; } @@ -628,15 +653,15 @@ private async Task GenerateItemCard(ShopEntry shopEntry) // Scale image down to fit the card if (shopEntry is { ImageType: "track", ImageUrl: null }) { - using var coverBitmap = shopEntry.Image.Resize(new SKImageInfo(236, 236), SKFilterQuality.Medium); - - using var roundedCoverBitmap = new SKBitmap(236, 236); - using var roundedCoverCanvas = new SKCanvas(roundedCoverBitmap); - roundedCoverCanvas.ClipRoundRect( - new SKRoundRect(new SKRect(0, 0, coverBitmap.Width, coverBitmap.Height), 10), antialias: true); - roundedCoverCanvas.DrawBitmap(coverBitmap, 0, 0); - - canvas.DrawBitmap(roundedCoverBitmap, 10, 10); + var coverRect = SKRect.Create(10, 10, 236, 236); + + using var coverPaint = new SKPaint(); + coverPaint.IsAntialias = true; + coverPaint.FilterQuality = SKFilterQuality.Medium; + canvas.Save(); + canvas.ClipRoundRect(new SKRoundRect(coverRect, 10), antialias: true); + canvas.DrawBitmap(shopEntry.Image, coverRect, coverPaint); + canvas.Restore(); } else { @@ -654,28 +679,35 @@ private async Task GenerateItemCard(ShopEntry shopEntry) resizeHeight = imageInfo.Height; } - using var resizedImageBitmap = shopEntry.Image.Resize(new SKImageInfo(resizeWidth, resizeHeight), SKFilterQuality.Medium); + using var imagePaint = new SKPaint(); + imagePaint.FilterQuality = SKFilterQuality.Medium; // Car bundles get centered in the middle of the card vertically if (shopEntry.ImageType == "car-bundle") { - var cropY = (resizedImageBitmap.Height - imageInfo.Height) / 2; - var cropRect = new SKRect(0, cropY, resizedImageBitmap.Width, cropY + imageInfo.Height); - canvas.DrawBitmap(resizedImageBitmap, cropRect, - new SKRect(0, 0, resizedImageBitmap.Width, imageInfo.Height)); + var cropY = (resizeHeight - imageInfo.Height) / 2f; + var sourceScaleY = shopEntry.Image.Height / (float)resizeHeight; + var sourceRect = new SKRect(0, cropY * sourceScaleY, shopEntry.Image.Width, + (cropY + imageInfo.Height) * sourceScaleY); + canvas.DrawBitmap(shopEntry.Image, sourceRect, + new SKRect(0, 0, resizeWidth, imageInfo.Height), imagePaint); } // Center image in the middle of the card, if width is bigger than the image - else if (resizedImageBitmap.Width > imageInfo.Width) + else if (resizeWidth > imageInfo.Width) { - var cropX = (resizedImageBitmap.Width - imageInfo.Width) / 2; - var cropRect = new SKRect(cropX, 0, cropX + imageInfo.Width, resizedImageBitmap.Height); - canvas.DrawBitmap(resizedImageBitmap, cropRect, - new SKRect(0, 0, imageInfo.Width, resizedImageBitmap.Height)); + var cropX = (resizeWidth - imageInfo.Width) / 2f; + var sourceScaleX = shopEntry.Image.Width / (float)resizeWidth; + var sourceRect = new SKRect(cropX * sourceScaleX, 0, + (cropX + imageInfo.Width) * sourceScaleX, shopEntry.Image.Height); + canvas.DrawBitmap(shopEntry.Image, sourceRect, + new SKRect(0, 0, imageInfo.Width, resizeHeight), imagePaint); } else { var offsetMulti = shopEntry.Size >= 3f ? 0.08f : 0f; - canvas.DrawBitmap(resizedImageBitmap, new SKPoint(0, resizedImageBitmap.Height * -offsetMulti)); + canvas.DrawBitmap(shopEntry.Image, + new SKRect(0, resizeHeight * -offsetMulti, resizeWidth, + resizeHeight * (1 - offsetMulti)), imagePaint); } } @@ -794,4 +826,4 @@ private async Task GenerateSectionLocaleTemplate(ShopSection section, { return (null, null); } -} \ No newline at end of file +} diff --git a/Controllers/StatsImageController.cs b/Controllers/StatsImageController.cs index 997a4f8..26bc7c0 100644 --- a/Controllers/StatsImageController.cs +++ b/Controllers/StatsImageController.cs @@ -12,6 +12,11 @@ namespace EasyFortniteStats_ImageApi.Controllers; public class StatsImageController(IMemoryCache cache, AsyncKeyedLocker namedLock, SharedAssets assets, ILogger logger) : ControllerBase { + private static readonly MemoryCacheEntryOptions TemplateCacheOptions = new() + { + SlidingExpiration = TimeSpan.FromMinutes(10) + }; + [HttpPost] public async Task Post(Stats stats, StatsType type = StatsType.Normal) { @@ -26,18 +31,23 @@ public async Task Post(Stats stats, StatsType type = StatsType.No : ""; var lockName = $"stats_{type}{backgroundHash}_template_mutex"; - SKBitmap? templateBitmap; + byte[]? templateData; using (await namedLock.LockAsync(lockName).ConfigureAwait(false)) { - cache.TryGetValue($"stats_{type}{backgroundHash}_template_image", out templateBitmap); - if (templateBitmap is null) + var cacheKey = $"stats_{type}{backgroundHash}_template_image"; + cache.TryGetValue(cacheKey, out templateData); + if (templateData is null) { - templateBitmap = await GenerateTemplate(stats, type); - cache.Set($"stats_{type}{backgroundHash}_template_image", templateBitmap); + using var templateBitmap = await GenerateTemplate(stats, type); + using var encodedTemplate = templateBitmap.Encode(SKEncodedImageFormat.Png, 100); + templateData = encodedTemplate.ToArray(); + cache.Set(cacheKey, templateData, TemplateCacheOptions); } } - using var templateCopy = templateBitmap.Copy(); + // The cache owns managed encoded data, while this request exclusively owns the decoded bitmap. + using var templateCopy = SKBitmap.Decode(templateData) + ?? throw new InvalidOperationException("The cached stats template could not be decoded."); using var bitmap = await GenerateImage(stats, type, templateCopy); var data = bitmap.Encode(SKEncodedImageFormat.Png, 100); return File(data.AsStream(true), "image/png"); @@ -69,22 +79,14 @@ await assets.GetBitmap("data/images/{0}", } else { - using var backgroundImagePaint = new SKPaintSafe(); + using var backgroundImagePaint = new SKPaint(); backgroundImagePaint.IsAntialias = true; backgroundImagePaint.FilterQuality = SKFilterQuality.Medium; - if (customBackgroundBitmap.Width != imageInfo.Width || customBackgroundBitmap.Height != imageInfo.Height) - { - using var resizedCustomBackgroundBitmap = - customBackgroundBitmap.Resize(imageInfo, SKFilterQuality.Medium); - backgroundImagePaint.Shader = SKShader.CreateBitmap(resizedCustomBackgroundBitmap, - SKShaderTileMode.Clamp, SKShaderTileMode.Repeat); - } - else - backgroundImagePaint.Shader = SKShader.CreateBitmap(customBackgroundBitmap, SKShaderTileMode.Clamp, - SKShaderTileMode.Repeat); - - canvas.DrawRoundRect(0, 0, imageInfo.Width, imageInfo.Height, 50, 50, backgroundImagePaint); + canvas.Save(); + canvas.ClipRoundRect(new SKRoundRect(imageInfo.Rect, 50), antialias: true); + canvas.DrawBitmap(customBackgroundBitmap, imageInfo.Rect, backgroundImagePaint); + canvas.Restore(); } using var nameSplit = new SKPaint(); @@ -118,8 +120,6 @@ await assets.GetBitmap("data/images/{0}", titlePaint.Typeface = segoeFont; titlePaint.TextSize = 20; - var textBounds = new SKRect(); - if (type == StatsType.Competitive) { var overallBoxRect = new SKRoundRect(SKRect.Create(50, 159, 437, 415), 30); @@ -147,29 +147,21 @@ await assets.GetBitmap("data/images/{0}", var zeroBuildLogo = await assets.GetBitmap("Assets/Images/Stats/ZeroBuildLogo.png"); // don't dispose canvas.DrawBitmap(zeroBuildLogo, new SKPoint(317, 277)); - competitiveBoxTitlePaint.MeasureText("OVERALL", ref textBounds); - canvas.DrawText("OVERALL", 211, 305 - textBounds.Top, competitiveBoxTitlePaint); + canvas.DrawAlignedText("OVERALL", new SKPoint(211, 305), competitiveBoxTitlePaint); - titlePaint.MeasureText("Earnings", ref textBounds); - canvas.DrawText("Earnings", 70, 338 - textBounds.Top, titlePaint); + canvas.DrawAlignedText("Earnings", new SKPoint(70, 338), titlePaint); - titlePaint.MeasureText("Power Ranking", ref textBounds); - canvas.DrawText("Power Ranking", 250, 338 - textBounds.Top, titlePaint); + canvas.DrawAlignedText("Power Ranking", new SKPoint(250, 338), titlePaint); - titlePaint.MeasureText("Games", ref textBounds); - canvas.DrawText("Games", 70, 414 - textBounds.Top, titlePaint); + canvas.DrawAlignedText("Games", new SKPoint(70, 414), titlePaint); - titlePaint.MeasureText("Wins", ref textBounds); - canvas.DrawText("Wins", 231, 414 - textBounds.Top, titlePaint); + canvas.DrawAlignedText("Wins", new SKPoint(231, 414), titlePaint); - titlePaint.MeasureText("Win%", ref textBounds); - canvas.DrawText("Win%", 370, 414 - textBounds.Top, titlePaint); + canvas.DrawAlignedText("Win%", new SKPoint(370, 414), titlePaint); - titlePaint.MeasureText("Kills", ref textBounds); - canvas.DrawText("Kills", 70, 491 - textBounds.Top, titlePaint); + canvas.DrawAlignedText("Kills", new SKPoint(70, 491), titlePaint); - titlePaint.MeasureText("K/D", ref textBounds); - canvas.DrawText("K/D", 231, 491 - textBounds.Top, titlePaint); + canvas.DrawAlignedText("K/D", new SKPoint(231, 491), titlePaint); } else { @@ -177,38 +169,27 @@ await assets.GetBitmap("data/images/{0}", DrawBlurredRoundRect(bitmap, overallBoxRect); canvas.DrawRoundRect(overallBoxRect, boxPaint); - boxTitlePaint.MeasureText("OVERALL", ref textBounds); - canvas.DrawText("OVERALL", 60, 134 - textBounds.Top, boxTitlePaint); + canvas.DrawAlignedText("OVERALL", new SKPoint(60, 134), boxTitlePaint); - titlePaint.MeasureText("Games", ref textBounds); - canvas.DrawText("Games", 70, 184 - textBounds.Top, titlePaint); + canvas.DrawAlignedText("Games", new SKPoint(70, 184), titlePaint); - titlePaint.MeasureText("Wins", ref textBounds); - canvas.DrawText("Wins", 231, 184 - textBounds.Top, titlePaint); + canvas.DrawAlignedText("Wins", new SKPoint(231, 184), titlePaint); - titlePaint.MeasureText("Win%", ref textBounds); - canvas.DrawText("Win%", 370, 184 - textBounds.Top, titlePaint); + canvas.DrawAlignedText("Win%", new SKPoint(370, 184), titlePaint); - titlePaint.MeasureText("Kills", ref textBounds); - canvas.DrawText("Kills", 70, 261 - textBounds.Top, titlePaint); + canvas.DrawAlignedText("Kills", new SKPoint(70, 261), titlePaint); - titlePaint.MeasureText("K/D", ref textBounds); - canvas.DrawText("K/D", 231, 261 - textBounds.Top, titlePaint); + canvas.DrawAlignedText("K/D", new SKPoint(231, 261), titlePaint); - titlePaint.MeasureText("Playtime since Season 7", ref textBounds); - canvas.DrawText("Playtime since Season 7", 70, 338 - textBounds.Top, titlePaint); + canvas.DrawAlignedText("Playtime since Season 7", new SKPoint(70, 338), titlePaint); - titlePaint.MeasureText("days", ref textBounds); - canvas.DrawText("days", 70, 397 - textBounds.Top, titlePaint); + canvas.DrawAlignedText("days", new SKPoint(70, 397), titlePaint); - titlePaint.MeasureText("hours", ref textBounds); - canvas.DrawText("hours", 147, 397 - textBounds.Top, titlePaint); + canvas.DrawAlignedText("hours", new SKPoint(147, 397), titlePaint); - titlePaint.MeasureText("minutes", ref textBounds); - canvas.DrawText("minutes", 231, 397 - textBounds.Top, titlePaint); + canvas.DrawAlignedText("minutes", new SKPoint(231, 397), titlePaint); - titlePaint.MeasureText("BattlePass Level", ref textBounds); - canvas.DrawText("BattlePass Level", 70, 442 - textBounds.Top, titlePaint); + canvas.DrawAlignedText("BattlePass Level", new SKPoint(70, 442), titlePaint); using var battlePassBarBackgroundPaint = new SKPaint(); battlePassBarBackgroundPaint.IsAntialias = true; @@ -221,116 +202,88 @@ await assets.GetBitmap("data/images/{0}", DrawBlurredRoundRect(bitmap, soloBoxRect); canvas.DrawRoundRect(soloBoxRect, boxPaint); - boxTitlePaint.MeasureText("SOLO", ref textBounds); - canvas.DrawText("SOLO", 527, 134 - textBounds.Top, boxTitlePaint); + canvas.DrawAlignedText("SOLO", new SKPoint(527, 134), boxTitlePaint); var soloIcon = await assets.GetBitmap("Assets/Images/Stats/PlaylistIcons/solo.png"); // don't dispose canvas.DrawBitmap(soloIcon, new SKPoint(648, 134)); - titlePaint.MeasureText("Games", ref textBounds); - canvas.DrawText("Games", 537, 184 - textBounds.Top, titlePaint); + canvas.DrawAlignedText("Games", new SKPoint(537, 184), titlePaint); - titlePaint.MeasureText("Wins", ref textBounds); - canvas.DrawText("Wins", 698, 184 - textBounds.Top, titlePaint); + canvas.DrawAlignedText("Wins", new SKPoint(698, 184), titlePaint); - titlePaint.MeasureText("Win%", ref textBounds); - canvas.DrawText("Win%", 837, 184 - textBounds.Top, titlePaint); + canvas.DrawAlignedText("Win%", new SKPoint(837, 184), titlePaint); - titlePaint.MeasureText("Kills", ref textBounds); - canvas.DrawText("Kills", 537, 261 - textBounds.Top, titlePaint); + canvas.DrawAlignedText("Kills", new SKPoint(537, 261), titlePaint); - titlePaint.MeasureText("K/D", ref textBounds); - canvas.DrawText("K/D", 698, 261 - textBounds.Top, titlePaint); + canvas.DrawAlignedText("K/D", new SKPoint(698, 261), titlePaint); - titlePaint.MeasureText("Top 25", ref textBounds); - canvas.DrawText("Top 25", 837, 261 - textBounds.Top, titlePaint); + canvas.DrawAlignedText("Top 25", new SKPoint(837, 261), titlePaint); // Duos var duosBoxRect = new SKRoundRect(SKRect.Create(996, 159, 459, 185), 30); DrawBlurredRoundRect(bitmap, duosBoxRect); canvas.DrawRoundRect(duosBoxRect, boxPaint); - boxTitlePaint.MeasureText("DUOS", ref textBounds); - canvas.DrawText("DUOS", 1006, 134 - textBounds.Top, boxTitlePaint); + canvas.DrawAlignedText("DUOS", new SKPoint(1006, 134), boxTitlePaint); var duosIcon = await assets.GetBitmap("Assets/Images/Stats/PlaylistIcons/duos.png"); // don't dispose canvas.DrawBitmap(duosIcon, new SKPoint(1133, 134)); - titlePaint.MeasureText("Games", ref textBounds); - canvas.DrawText("Games", 1016, 184 - textBounds.Top, titlePaint); + canvas.DrawAlignedText("Games", new SKPoint(1016, 184), titlePaint); - titlePaint.MeasureText("Wins", ref textBounds); - canvas.DrawText("Wins", 1177, 184 - textBounds.Top, titlePaint); + canvas.DrawAlignedText("Wins", new SKPoint(1177, 184), titlePaint); - titlePaint.MeasureText("Win%", ref textBounds); - canvas.DrawText("Win%", 1316, 184 - textBounds.Top, titlePaint); + canvas.DrawAlignedText("Win%", new SKPoint(1316, 184), titlePaint); - titlePaint.MeasureText("Kills", ref textBounds); - canvas.DrawText("Kills", 1016, 261 - textBounds.Top, titlePaint); + canvas.DrawAlignedText("Kills", new SKPoint(1016, 261), titlePaint); - titlePaint.MeasureText("K/D", ref textBounds); - canvas.DrawText("K/D", 1177, 261 - textBounds.Top, titlePaint); + canvas.DrawAlignedText("K/D", new SKPoint(1177, 261), titlePaint); - titlePaint.MeasureText("Top 12", ref textBounds); - canvas.DrawText("Top 12", 1316, 261 - textBounds.Top, titlePaint); + canvas.DrawAlignedText("Top 12", new SKPoint(1316, 261), titlePaint); // Trios var triosBoxRect = new SKRoundRect(SKRect.Create(517, 389, 459, 185), 30); DrawBlurredRoundRect(bitmap, triosBoxRect); canvas.DrawRoundRect(triosBoxRect, boxPaint); - boxTitlePaint.MeasureText("TRIOS", ref textBounds); - canvas.DrawText("TRIOS", 527, 364 - textBounds.Top, boxTitlePaint); + canvas.DrawAlignedText("TRIOS", new SKPoint(527, 364), boxTitlePaint); var triosIcon = await assets.GetBitmap(@"Assets/Images/Stats/PlaylistIcons/trios.png"); // don't dispose canvas.DrawBitmap(triosIcon, new SKPoint(663, 364)); - titlePaint.MeasureText("Games", ref textBounds); - canvas.DrawText("Games", 537, 414 - textBounds.Top, titlePaint); + canvas.DrawAlignedText("Games", new SKPoint(537, 414), titlePaint); - titlePaint.MeasureText("Wins", ref textBounds); - canvas.DrawText("Wins", 698, 414 - textBounds.Top, titlePaint); + canvas.DrawAlignedText("Wins", new SKPoint(698, 414), titlePaint); - titlePaint.MeasureText("Win%", ref textBounds); - canvas.DrawText("Win%", 837, 414 - textBounds.Top, titlePaint); + canvas.DrawAlignedText("Win%", new SKPoint(837, 414), titlePaint); - titlePaint.MeasureText("Kills", ref textBounds); - canvas.DrawText("Kills", 537, 491 - textBounds.Top, titlePaint); + canvas.DrawAlignedText("Kills", new SKPoint(537, 491), titlePaint); - titlePaint.MeasureText("K/D", ref textBounds); - canvas.DrawText("K/D", 698, 491 - textBounds.Top, titlePaint); + canvas.DrawAlignedText("K/D", new SKPoint(698, 491), titlePaint); - titlePaint.MeasureText("Top 6", ref textBounds); - canvas.DrawText("Top 6", 837, 491 - textBounds.Top, titlePaint); + canvas.DrawAlignedText("Top 6", new SKPoint(837, 491), titlePaint); // Squads var squadsBoxRect = new SKRoundRect(SKRect.Create(996, 389, 459, 185), 30); DrawBlurredRoundRect(bitmap, squadsBoxRect); canvas.DrawRoundRect(squadsBoxRect, boxPaint); - boxTitlePaint.MeasureText("SQUADS", ref textBounds); - canvas.DrawText("SQUADS", 1006, 364 - textBounds.Top, boxTitlePaint); + canvas.DrawAlignedText("SQUADS", new SKPoint(1006, 364), boxTitlePaint); var squadsIcon = await assets.GetBitmap(@"Assets/Images/Stats/PlaylistIcons/squads.png"); // don't dispose canvas.DrawBitmap(squadsIcon, new SKPoint(1191, 364)); - titlePaint.MeasureText("Games", ref textBounds); - canvas.DrawText("Games", 1016, 414 - textBounds.Top, titlePaint); + canvas.DrawAlignedText("Games", new SKPoint(1016, 414), titlePaint); - titlePaint.MeasureText("Wins", ref textBounds); - canvas.DrawText("Wins", 1177, 414 - textBounds.Top, titlePaint); + canvas.DrawAlignedText("Wins", new SKPoint(1177, 414), titlePaint); - titlePaint.MeasureText("Win%", ref textBounds); - canvas.DrawText("Win%", 1316, 414 - textBounds.Top, titlePaint); + canvas.DrawAlignedText("Win%", new SKPoint(1316, 414), titlePaint); - titlePaint.MeasureText("Kills", ref textBounds); - canvas.DrawText("Kills", 1016, 491 - textBounds.Top, titlePaint); + canvas.DrawAlignedText("Kills", new SKPoint(1016, 491), titlePaint); - titlePaint.MeasureText("K/D", ref textBounds); - canvas.DrawText("K/D", 1177, 491 - textBounds.Top, titlePaint); + canvas.DrawAlignedText("K/D", new SKPoint(1177, 491), titlePaint); - titlePaint.MeasureText("Top 6", ref textBounds); - canvas.DrawText("Top 6", 1316, 491 - textBounds.Top, titlePaint); + canvas.DrawAlignedText("Top 6", new SKPoint(1316, 491), titlePaint); if (type == StatsType.Normal) { @@ -339,26 +292,20 @@ await assets.GetBitmap("data/images/{0}", DrawBlurredRoundRect(bitmap, teamsBoxRect); canvas.DrawRoundRect(teamsBoxRect, boxPaint); - boxTitlePaint.MeasureText("TEAMS", ref textBounds); - canvas.DrawText("TEAMS", 527, 594 - textBounds.Top, boxTitlePaint); + canvas.DrawAlignedText("TEAMS", new SKPoint(527, 594), boxTitlePaint); var teamsIcon = await assets.GetBitmap("Assets/Images/Stats/PlaylistIcons/teams.png"); // don't dispose canvas.DrawBitmap(teamsIcon, new SKPoint(683, 594)); - titlePaint.MeasureText("Games", ref textBounds); - canvas.DrawText("Games", 537, 644 - textBounds.Top, titlePaint); + canvas.DrawAlignedText("Games", new SKPoint(537, 644), titlePaint); - titlePaint.MeasureText("Wins", ref textBounds); - canvas.DrawText("Wins", 698, 644 - textBounds.Top, titlePaint); + canvas.DrawAlignedText("Wins", new SKPoint(698, 644), titlePaint); - titlePaint.MeasureText("Win%", ref textBounds); - canvas.DrawText("Win%", 837, 644 - textBounds.Top, titlePaint); + canvas.DrawAlignedText("Win%", new SKPoint(837, 644), titlePaint); - titlePaint.MeasureText("Kills", ref textBounds); - canvas.DrawText("Kills", 954, 644 - textBounds.Top, titlePaint); + canvas.DrawAlignedText("Kills", new SKPoint(954, 644), titlePaint); - titlePaint.MeasureText("K/D", ref textBounds); - canvas.DrawText("K/D", 1115, 644 - textBounds.Top, titlePaint); + canvas.DrawAlignedText("K/D", new SKPoint(1115, 644), titlePaint); } return bitmap; @@ -417,19 +364,17 @@ private async Task GenerateImage(Stats stats, StatsType type, SKBitmap rankingPaint.TextSize = 20; rankingPaint.FilterQuality = SKFilterQuality.Medium; - var textBounds = new SKRect(); - var inputIcon = await assets.GetBitmap($"Assets/Images/Stats/InputTypes/{stats.InputType}.png"); // don't dispose canvas.DrawBitmap(inputIcon, 50, 50); - namePaint.MeasureText(stats.PlayerName, ref textBounds); - canvas.DrawText(stats.PlayerName, 159, 58 - textBounds.Top, namePaint); + var playerNameWidth = namePaint.MeasureText(stats.PlayerName); + canvas.DrawAlignedText(stats.PlayerName, new SKPoint(159, 58), namePaint); if (stats.IsVerified) { var verifiedIcon = await assets.GetBitmap("Assets/Images/Stats/Verified.png"); // don't dispose - canvas.DrawBitmap(verifiedIcon, 159 + textBounds.Width + 5, 47); + canvas.DrawBitmap(verifiedIcon, 159 + playerNameWidth + 5, 47); using var discordBoxBitmap = await ImageUtils.GenerateDiscordBox(assets, stats.UserName ?? "???#0000"); canvas.DrawBitmap(discordBoxBitmap, imageInfo.Width - 50 - discordBoxBitmap.Width, 39); @@ -453,16 +398,18 @@ await assets.GetBitmap( $"Assets/Images/Stats/DivisionIcons/{divisionAssetName}.png"); // don't dispose canvas.DrawBitmap(divisionIconBitmap, x - divisionIconBitmap!.Width / 2f, 109); - divisionPaint.MeasureText(rankedStatsEntry.CurrentDivisionName, ref textBounds); - canvas.DrawText(rankedStatsEntry.CurrentDivisionName, x - (int)(textBounds.Width / 2), - 206 - textBounds.Top, divisionPaint); + canvas.DrawAlignedText( + rankedStatsEntry.CurrentDivisionName, + new SKPoint(x, 206), + divisionPaint, + SKTextAlign.Center); if (rankedStatsEntry.Ranking is null) { const int maxBarWidth = 130, barHeight = 6; var progressText = $"{(int)(rankedStatsEntry.Progress * 100)}%"; - rankProgressPaint.MeasureText(progressText, ref textBounds); - var barX = x - textBounds.Width / 2f - maxBarWidth / 2f; + var progressTextWidth = rankProgressPaint.MeasureText(progressText); + var barX = x - progressTextWidth / 2f - maxBarWidth / 2f; using var barBackgroundPaint = new SKPaint(); barBackgroundPaint.IsAntialias = true; @@ -487,66 +434,52 @@ await assets.GetBitmap( canvas.DrawRoundRect(barX, 250, rankProgressBarWidth, barHeight, 10, 10, battlePassBarPaint); } - canvas.DrawText(progressText, barX + maxBarWidth + 7, 247 - textBounds.Top, rankProgressPaint); + canvas.DrawAlignedText(progressText, new SKPoint(barX + maxBarWidth + 7, 247), rankProgressPaint); } else { - rankingPaint.MeasureText(rankedStatsEntry.Ranking, ref textBounds); - canvas.DrawText(rankedStatsEntry.Ranking, x - (int)(textBounds.Width / 2), 245 - textBounds.Top, - rankingPaint); + canvas.DrawAlignedText( + rankedStatsEntry.Ranking, + new SKPoint(x, 245), + rankingPaint, + SKTextAlign.Center); } } - valuePaint.MeasureText(stats.Competitive.Earnings, ref textBounds); - canvas.DrawText(stats.Competitive.Earnings, 70, 365 - textBounds.Top, valuePaint); + canvas.DrawAlignedText(stats.Competitive.Earnings, new SKPoint(70, 365), valuePaint); - valuePaint.MeasureText(stats.Competitive.PowerRanking, ref textBounds); - canvas.DrawText(stats.Competitive.PowerRanking, 250, 365 - textBounds.Top, valuePaint); + canvas.DrawAlignedText(stats.Competitive.PowerRanking, new SKPoint(250, 365), valuePaint); - valuePaint.MeasureText(stats.Overall.MatchesPlayed, ref textBounds); - canvas.DrawText(stats.Overall.MatchesPlayed, 70, 441 - textBounds.Top, valuePaint); + canvas.DrawAlignedText(stats.Overall.MatchesPlayed, new SKPoint(70, 441), valuePaint); - valuePaint.MeasureText(stats.Overall.Wins, ref textBounds); - canvas.DrawText(stats.Overall.Wins, 231, 441 - textBounds.Top, valuePaint); + canvas.DrawAlignedText(stats.Overall.Wins, new SKPoint(231, 441), valuePaint); - valuePaint.MeasureText(stats.Overall.WinRatio, ref textBounds); - canvas.DrawText(stats.Overall.WinRatio, 370, 441 - textBounds.Top, valuePaint); + canvas.DrawAlignedText(stats.Overall.WinRatio, new SKPoint(370, 441), valuePaint); - valuePaint.MeasureText(stats.Overall.Kills, ref textBounds); - canvas.DrawText(stats.Overall.Kills, 70, 518 - textBounds.Top, valuePaint); + canvas.DrawAlignedText(stats.Overall.Kills, new SKPoint(70, 518), valuePaint); - valuePaint.MeasureText(stats.Overall.KD, ref textBounds); - canvas.DrawText(stats.Overall.KD, 231, 518 - textBounds.Top, valuePaint); + canvas.DrawAlignedText(stats.Overall.KD, new SKPoint(231, 518), valuePaint); } else { - valuePaint.MeasureText(stats.Overall.MatchesPlayed, ref textBounds); - canvas.DrawText(stats.Overall.MatchesPlayed, 70, 211 - textBounds.Top, valuePaint); + canvas.DrawAlignedText(stats.Overall.MatchesPlayed, new SKPoint(70, 211), valuePaint); - valuePaint.MeasureText(stats.Overall.Wins, ref textBounds); - canvas.DrawText(stats.Overall.Wins, 231, 211 - textBounds.Top, valuePaint); + canvas.DrawAlignedText(stats.Overall.Wins, new SKPoint(231, 211), valuePaint); - valuePaint.MeasureText(stats.Overall.WinRatio, ref textBounds); - canvas.DrawText(stats.Overall.WinRatio, 370, 211 - textBounds.Top, valuePaint); + canvas.DrawAlignedText(stats.Overall.WinRatio, new SKPoint(370, 211), valuePaint); - valuePaint.MeasureText(stats.Overall.Kills, ref textBounds); - canvas.DrawText(stats.Overall.Kills, 70, 288 - textBounds.Top, valuePaint); + canvas.DrawAlignedText(stats.Overall.Kills, new SKPoint(70, 288), valuePaint); - valuePaint.MeasureText(stats.Overall.KD, ref textBounds); - canvas.DrawText(stats.Overall.KD, 231, 288 - textBounds.Top, valuePaint); + canvas.DrawAlignedText(stats.Overall.KD, new SKPoint(231, 288), valuePaint); - valuePaint.MeasureText(stats.Playtime.Days, ref textBounds); - canvas.DrawText(stats.Playtime.Days, 70, 369 - textBounds.Top, valuePaint); + canvas.DrawAlignedText(stats.Playtime.Days, new SKPoint(70, 369), valuePaint); - valuePaint.MeasureText(stats.Playtime.Hours, ref textBounds); - canvas.DrawText(stats.Playtime.Hours, 147, 369 - textBounds.Top, valuePaint); + canvas.DrawAlignedText(stats.Playtime.Hours, new SKPoint(147, 369), valuePaint); - valuePaint.MeasureText(stats.Playtime.Minutes, ref textBounds); - canvas.DrawText(stats.Playtime.Minutes, 213, 369 - textBounds.Top, valuePaint); + canvas.DrawAlignedText(stats.Playtime.Minutes, new SKPoint(213, 369), valuePaint); var battlePassLevel = ((int)stats.BattlePassLevel).ToString(); - valuePaint.MeasureText(battlePassLevel, ref textBounds); - canvas.DrawText(battlePassLevel, 70, 479 - textBounds.Top, valuePaint); + canvas.DrawAlignedText(battlePassLevel, new SKPoint(70, 479), valuePaint); const int maxBarWidth = 309, barHeight = 20; @@ -570,97 +503,68 @@ await assets.GetBitmap( } } - valuePaint.MeasureText(stats.Solo.MatchesPlayed, ref textBounds); - canvas.DrawText(stats.Solo.MatchesPlayed, 537, 211 - textBounds.Top, valuePaint); + canvas.DrawAlignedText(stats.Solo.MatchesPlayed, new SKPoint(537, 211), valuePaint); - valuePaint.MeasureText(stats.Solo.Wins, ref textBounds); - canvas.DrawText(stats.Solo.Wins, 698, 211 - textBounds.Top, valuePaint); + canvas.DrawAlignedText(stats.Solo.Wins, new SKPoint(698, 211), valuePaint); - valuePaint.MeasureText(stats.Solo.WinRatio, ref textBounds); - canvas.DrawText(stats.Solo.WinRatio, 837, 211 - textBounds.Top, valuePaint); + canvas.DrawAlignedText(stats.Solo.WinRatio, new SKPoint(837, 211), valuePaint); - valuePaint.MeasureText(stats.Solo.Kills, ref textBounds); - canvas.DrawText(stats.Solo.Kills, 537, 288 - textBounds.Top, valuePaint); + canvas.DrawAlignedText(stats.Solo.Kills, new SKPoint(537, 288), valuePaint); - valuePaint.MeasureText(stats.Solo.KD, ref textBounds); - canvas.DrawText(stats.Solo.KD, 698, 288 - textBounds.Top, valuePaint); + canvas.DrawAlignedText(stats.Solo.KD, new SKPoint(698, 288), valuePaint); - valuePaint.MeasureText(stats.Solo.Top25, ref textBounds); - canvas.DrawText(stats.Solo.Top25, 837, 288 - textBounds.Top, valuePaint); + canvas.DrawAlignedText(stats.Solo.Top25!, new SKPoint(837, 288), valuePaint); - valuePaint.MeasureText(stats.Duos.MatchesPlayed, ref textBounds); - canvas.DrawText(stats.Duos.MatchesPlayed, 1016, 211 - textBounds.Top, valuePaint); + canvas.DrawAlignedText(stats.Duos.MatchesPlayed, new SKPoint(1016, 211), valuePaint); - valuePaint.MeasureText(stats.Duos.Wins, ref textBounds); - canvas.DrawText(stats.Duos.Wins, 1177, 211 - textBounds.Top, valuePaint); + canvas.DrawAlignedText(stats.Duos.Wins, new SKPoint(1177, 211), valuePaint); - valuePaint.MeasureText(stats.Duos.WinRatio, ref textBounds); - canvas.DrawText(stats.Duos.WinRatio, 1316, 211 - textBounds.Top, valuePaint); + canvas.DrawAlignedText(stats.Duos.WinRatio, new SKPoint(1316, 211), valuePaint); - valuePaint.MeasureText(stats.Duos.Kills, ref textBounds); - canvas.DrawText(stats.Duos.Kills, 1016, 288 - textBounds.Top, valuePaint); + canvas.DrawAlignedText(stats.Duos.Kills, new SKPoint(1016, 288), valuePaint); - valuePaint.MeasureText(stats.Duos.KD, ref textBounds); - canvas.DrawText(stats.Duos.KD, 1177, 288 - textBounds.Top, valuePaint); + canvas.DrawAlignedText(stats.Duos.KD, new SKPoint(1177, 288), valuePaint); - valuePaint.MeasureText(stats.Duos.Top12, ref textBounds); - canvas.DrawText(stats.Duos.Top12, 1316, 288 - textBounds.Top, valuePaint); + canvas.DrawAlignedText(stats.Duos.Top12!, new SKPoint(1316, 288), valuePaint); - valuePaint.MeasureText(stats.Trios.MatchesPlayed, ref textBounds); - canvas.DrawText(stats.Trios.MatchesPlayed, 537, 441 - textBounds.Top, valuePaint); + canvas.DrawAlignedText(stats.Trios.MatchesPlayed, new SKPoint(537, 441), valuePaint); - valuePaint.MeasureText(stats.Trios.Wins, ref textBounds); - canvas.DrawText(stats.Trios.Wins, 698, 441 - textBounds.Top, valuePaint); + canvas.DrawAlignedText(stats.Trios.Wins, new SKPoint(698, 441), valuePaint); - valuePaint.MeasureText(stats.Trios.WinRatio, ref textBounds); - canvas.DrawText(stats.Trios.WinRatio, 837, 441 - textBounds.Top, valuePaint); + canvas.DrawAlignedText(stats.Trios.WinRatio, new SKPoint(837, 441), valuePaint); - valuePaint.MeasureText(stats.Trios.Kills, ref textBounds); - canvas.DrawText(stats.Trios.Kills, 537, 518 - textBounds.Top, valuePaint); + canvas.DrawAlignedText(stats.Trios.Kills, new SKPoint(537, 518), valuePaint); - valuePaint.MeasureText(stats.Trios.KD, ref textBounds); - canvas.DrawText(stats.Trios.KD, 698, 518 - textBounds.Top, valuePaint); + canvas.DrawAlignedText(stats.Trios.KD, new SKPoint(698, 518), valuePaint); - valuePaint.MeasureText(stats.Trios.Top6, ref textBounds); - canvas.DrawText(stats.Trios.Top6, 837, 518 - textBounds.Top, valuePaint); + canvas.DrawAlignedText(stats.Trios.Top6!, new SKPoint(837, 518), valuePaint); - valuePaint.MeasureText(stats.Squads.MatchesPlayed, ref textBounds); - canvas.DrawText(stats.Squads.MatchesPlayed, 1016, 441 - textBounds.Top, valuePaint); + canvas.DrawAlignedText(stats.Squads.MatchesPlayed, new SKPoint(1016, 441), valuePaint); - valuePaint.MeasureText(stats.Squads.Wins, ref textBounds); - canvas.DrawText(stats.Squads.Wins, 1177, 441 - textBounds.Top, valuePaint); + canvas.DrawAlignedText(stats.Squads.Wins, new SKPoint(1177, 441), valuePaint); - valuePaint.MeasureText(stats.Squads.WinRatio, ref textBounds); - canvas.DrawText(stats.Squads.WinRatio, 1316, 441 - textBounds.Top, valuePaint); + canvas.DrawAlignedText(stats.Squads.WinRatio, new SKPoint(1316, 441), valuePaint); - valuePaint.MeasureText(stats.Squads.Kills, ref textBounds); - canvas.DrawText(stats.Squads.Kills, 1016, 518 - textBounds.Top, valuePaint); + canvas.DrawAlignedText(stats.Squads.Kills, new SKPoint(1016, 518), valuePaint); - valuePaint.MeasureText(stats.Squads.KD, ref textBounds); - canvas.DrawText(stats.Squads.KD, 1177, 518 - textBounds.Top, valuePaint); + canvas.DrawAlignedText(stats.Squads.KD, new SKPoint(1177, 518), valuePaint); - valuePaint.MeasureText(stats.Squads.Top6, ref textBounds); - canvas.DrawText(stats.Squads.Top6, 1316, 518 - textBounds.Top, valuePaint); + canvas.DrawAlignedText(stats.Squads.Top6!, new SKPoint(1316, 518), valuePaint); if (type == StatsType.Normal && stats.Teams is not null) { - valuePaint.MeasureText(stats.Teams.MatchesPlayed, ref textBounds); - canvas.DrawText(stats.Teams.MatchesPlayed, 537, 671 - textBounds.Top, valuePaint); + canvas.DrawAlignedText(stats.Teams.MatchesPlayed, new SKPoint(537, 671), valuePaint); - valuePaint.MeasureText(stats.Teams.Wins, ref textBounds); - canvas.DrawText(stats.Teams.Wins, 698, 671 - textBounds.Top, valuePaint); + canvas.DrawAlignedText(stats.Teams.Wins, new SKPoint(698, 671), valuePaint); - valuePaint.MeasureText(stats.Teams.WinRatio, ref textBounds); - canvas.DrawText(stats.Teams.WinRatio, 837, 671 - textBounds.Top, valuePaint); + canvas.DrawAlignedText(stats.Teams.WinRatio, new SKPoint(837, 671), valuePaint); - valuePaint.MeasureText(stats.Teams.Kills, ref textBounds); - canvas.DrawText(stats.Teams.Kills, 954, 671 - textBounds.Top, valuePaint); + canvas.DrawAlignedText(stats.Teams.Kills, new SKPoint(954, 671), valuePaint); - valuePaint.MeasureText(stats.Teams.KD, ref textBounds); - canvas.DrawText(stats.Teams.KD, 1115, 671 - textBounds.Top, valuePaint); + canvas.DrawAlignedText(stats.Teams.KD, new SKPoint(1115, 671), valuePaint); } return bitmap; @@ -680,4 +584,4 @@ private static void DrawBlurredRoundRect(SKBitmap bitmap, SKRoundRect rect) canvas.DrawBitmap(bitmap, 0, 0, paint); } -} \ No newline at end of file +} diff --git a/Controllers/UtilsImageController.cs b/Controllers/UtilsImageController.cs index 047a906..a25006c 100644 --- a/Controllers/UtilsImageController.cs +++ b/Controllers/UtilsImageController.cs @@ -47,16 +47,17 @@ public async Task GenerateProgressBar(ProgressBar progressBar) } var segoeFont = await assets.GetFont("Assets/Fonts/Segoe.ttf"); - var textBounds = new SKRect(); - using var textPaint = new SKPaint(); textPaint.IsAntialias = true; textPaint.Color = SKColors.White; textPaint.TextSize = 20; textPaint.Typeface = segoeFont; - textPaint.MeasureText(progressBar.Text, ref textBounds); - canvas.DrawText(progressBar.Text, 500 + 5, bitmap.Height / 2f - textBounds.MidY, textPaint); + canvas.DrawAlignedText( + progressBar.Text, + new SKPoint(500 + 5, bitmap.Height / 2f), + textPaint, + verticalAlignment: VerticalTextAlignment.Center); if (progressBar.BarText != null) { @@ -66,9 +67,12 @@ public async Task GenerateProgressBar(ProgressBar progressBar) barTextPaint.TextSize = 15; barTextPaint.Typeface = segoeFont; - barTextPaint.MeasureText(progressBar.BarText, ref textBounds); - canvas.DrawText(progressBar.BarText, (500 - textBounds.Width) / 2f, - bitmap.Height / 2f - textBounds.MidY, barTextPaint); + canvas.DrawAlignedText( + progressBar.BarText, + SKRect.Create(0, 0, 500, bitmap.Height), + barTextPaint, + SKTextAlign.Center, + VerticalTextAlignment.Center); } var data = bitmap.Encode(SKEncodedImageFormat.Png, 100); @@ -84,14 +88,13 @@ public async Task GenerateDropImage(Drop drop) if (!System.IO.File.Exists(filePath)) return BadRequest("Map file doesn't exist."); - var mapBytes = await System.IO.File.ReadAllBytesAsync(filePath); - using var bitmap = SKBitmap.Decode(mapBytes); + using var bitmap = SKBitmap.Decode(filePath); using var canvas = new SKCanvas(bitmap); var markerAmount = Directory.EnumerateFiles("Assets/Images/Map/Markers", "*.png").Count(); var markerBitmap = await assets.GetBitmap( - $"Assets/Images/Map/Markers/{RandomNumberGenerator.GetInt32(markerAmount - 1)}.png"); // don't dispose + $"Assets/Images/Map/Markers/{RandomNumberGenerator.GetInt32(markerAmount)}.png"); // don't dispose const int worldRadius = 80_000; const int xOffset = -12_200; @@ -105,4 +108,4 @@ await assets.GetBitmap( var data = bitmap.Encode(SKEncodedImageFormat.Jpeg, 100); return File(data.AsStream(true), "image/jpeg"); } -} \ No newline at end of file +} diff --git a/EasyFortniteStats-ImageApi.csproj b/EasyFortniteStats-ImageApi.csproj index 26291db..5bca900 100644 --- a/EasyFortniteStats-ImageApi.csproj +++ b/EasyFortniteStats-ImageApi.csproj @@ -10,10 +10,10 @@ - + - + diff --git a/ImageUtils.cs b/ImageUtils.cs index 04a8dff..4861921 100644 --- a/ImageUtils.cs +++ b/ImageUtils.cs @@ -60,8 +60,11 @@ public static async Task GenerateDiscordBox(SharedAssets assets, strin discordTagTextPaint.MeasureText(username, ref discordTagTextBounds); } - canvas.DrawText(username, (10 + 15) * resizeFactor + logoResizeWidth, - (float)imageInfo.Height / 2 - discordTagTextBounds.MidY, discordTagTextPaint); + canvas.DrawAlignedText( + username, + new SKPoint((10 + 15) * resizeFactor + logoResizeWidth, imageInfo.Height / 2f), + discordTagTextPaint, + verticalAlignment: VerticalTextAlignment.Center); return bitmap; } @@ -164,4 +167,4 @@ public static SKColor ParseColor(string hexString) } return SKColor.Parse(hexString); } -} \ No newline at end of file +} diff --git a/Program.cs b/Program.cs index f51846b..fe4ac62 100644 --- a/Program.cs +++ b/Program.cs @@ -9,7 +9,11 @@ builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); builder.Services.AddSingleton(); -builder.Services.AddHttpClient(); +builder.Services.AddHttpClient().ConfigureHttpClientDefaults(http => +{ + // Prevent unexpectedly large upstream images from being buffered in managed memory. + http.ConfigureHttpClient(client => client.MaxResponseContentBufferSize = 16 * 1024 * 1024); +}); builder.Services.AddSingleton(new AsyncKeyedLocker(o => { o.PoolSize = 64; @@ -29,4 +33,4 @@ app.MapControllers(); -app.Run(); \ No newline at end of file +app.Run(); diff --git a/SharedAssets.cs b/SharedAssets.cs index 796ebb1..33bd1a8 100644 --- a/SharedAssets.cs +++ b/SharedAssets.cs @@ -1,108 +1,81 @@ -using System.Runtime.InteropServices; -using Microsoft.Extensions.Caching.Memory; +using System.Collections.Concurrent; using SkiaSharp; namespace EasyFortniteStats_ImageApi; -public class SharedAssets(IMemoryCache memoryCache) +/// +/// Owns native Skia resources that are shared for the lifetime of the application. +/// +public sealed class SharedAssets : IDisposable { - private static readonly MemoryCacheEntryOptions CacheOptions = new() { Priority = CacheItemPriority.NeverRemove }; - private static readonly SemaphoreSlim Semaphore = new(1); + private readonly ConcurrentDictionary> _bitmaps = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary> _fonts = new(StringComparer.Ordinal); + private bool _disposed; - public async ValueTask GetBitmap(string format, string? arg1) + public ValueTask GetBitmap(string format, string? arg1) { - if (arg1 is null) return null; - var path = string.Format(format, arg1); - return await GetBitmap(path); + return arg1 is null ? ValueTask.FromResult(null) : GetBitmap(string.Format(format, arg1)); } - public async ValueTask GetBitmap(string? path) + public ValueTask GetBitmap(string? path) { - if (path is null) return null; + if (path is null) + return ValueTask.FromResult(null); - var key = $"bmp_{path}"; - var cached = memoryCache.Get(key); - if (cached is not null) return cached; - - await Semaphore.WaitAsync(); - - cached = memoryCache.Get(key); - if (cached is not null) + ObjectDisposedException.ThrowIf(_disposed, this); + var lazyBitmap = _bitmaps.GetOrAdd(path, static assetPath => + new Lazy(() => File.Exists(assetPath) ? SKBitmap.Decode(assetPath) : null, + LazyThreadSafetyMode.ExecutionAndPublication)); + try { - Semaphore.Release(); - return cached; + return ValueTask.FromResult(lazyBitmap.Value); } - - if (!File.Exists(path)) + catch { - memoryCache.Set(key, (SKBitmap?)null, CacheOptions); - Semaphore.Release(); - return null; + ((ICollection>>)_bitmaps) + .Remove(new KeyValuePair>(path, lazyBitmap)); + throw; } - - using var data = await ReadToSkData(path); - var bitmap = SKBitmap.Decode(data); - memoryCache.Set(key, bitmap, CacheOptions); - Semaphore.Release(); - return bitmap; } - public async ValueTask GetFont(string path) + public ValueTask GetFont(string path) { - var key = $"font_{path}"; - var cached = memoryCache.Get(key); - if (cached is not null) return cached; - - await Semaphore.WaitAsync(); - - cached = memoryCache.Get(key); - if (cached is not null) + ObjectDisposedException.ThrowIf(_disposed, this); + var lazyTypeface = _fonts.GetOrAdd(path, static assetPath => + new Lazy(() => SKTypeface.FromFile(assetPath) + ?? throw new InvalidOperationException($"Could not load font '{assetPath}'."), + LazyThreadSafetyMode.ExecutionAndPublication)); + try { - Semaphore.Release(); - return cached; + return ValueTask.FromResult(lazyTypeface.Value); + } + catch + { + ((ICollection>>)_fonts) + .Remove(new KeyValuePair>(path, lazyTypeface)); + throw; } - - using var data = await ReadToSkData(path); - var typeface = SKTypeface.FromData(data); - memoryCache.Set(key, typeface, CacheOptions); - Semaphore.Release(); - return typeface; } - private static async Task ReadToSkData(string path) + public void Dispose() { - UnmanagedMemoryStream? fileDataBufferStream = null; - FileStream? fileStream = null; + if (_disposed) + return; - try + _disposed = true; + foreach (var bitmap in _bitmaps.Values) { - fileStream = File.OpenRead(path); - var fileSize = fileStream.Length; - nint fileDataBufferPtr; - - unsafe - { - var fileDataBuffer = NativeMemory.Alloc((nuint)fileSize); - fileDataBufferPtr = (nint)fileDataBuffer; - fileDataBufferStream = - new UnmanagedMemoryStream((byte*)fileDataBuffer, fileSize, fileSize, FileAccess.ReadWrite); - } - - await fileStream.CopyToAsync(fileDataBufferStream); - - unsafe - { - var data = SKData.Create(fileDataBufferPtr, (int)fileSize, - (address, _) => NativeMemory.Free(address.ToPointer())); - return data; - } + if (bitmap.IsValueCreated) + bitmap.Value?.Dispose(); } - finally + + foreach (var font in _fonts.Values) { - if (fileDataBufferStream is not null) - await fileDataBufferStream.DisposeAsync(); - if (fileStream is not null) - await fileStream.DisposeAsync(); + if (font.IsValueCreated) + font.Value.Dispose(); } + + _bitmaps.Clear(); + _fonts.Clear(); } -} \ No newline at end of file +} diff --git a/TextDrawingExtensions.cs b/TextDrawingExtensions.cs new file mode 100644 index 0000000..1c99cb7 --- /dev/null +++ b/TextDrawingExtensions.cs @@ -0,0 +1,72 @@ +using SkiaSharp; + +namespace EasyFortniteStats_ImageApi; + +public enum VerticalTextAlignment +{ + Top, + Center, + Baseline, + Bottom +} + +public static class TextDrawingExtensions +{ + extension(SKCanvas canvas) + { + public void DrawAlignedText(string text, + SKPoint anchor, + SKPaint paint, + SKTextAlign horizontalAlignment = SKTextAlign.Left, + VerticalTextAlignment verticalAlignment = VerticalTextAlignment.Top) + { + var bounds = new SKRect(); + paint.MeasureText(text, ref bounds); + var baseline = verticalAlignment switch + { + VerticalTextAlignment.Top => anchor.Y - bounds.Top, + VerticalTextAlignment.Center => anchor.Y - bounds.MidY, + VerticalTextAlignment.Baseline => anchor.Y, + VerticalTextAlignment.Bottom => anchor.Y - bounds.Bottom, + _ => throw new ArgumentOutOfRangeException(nameof(verticalAlignment), verticalAlignment, null) + }; + + var previousAlignment = paint.TextAlign; + try + { + paint.TextAlign = horizontalAlignment; + canvas.DrawText(text, anchor.X, baseline, paint); + } + finally + { + paint.TextAlign = previousAlignment; + } + } + + public void DrawAlignedText(string text, + SKRect area, + SKPaint paint, + SKTextAlign horizontalAlignment = SKTextAlign.Left, + VerticalTextAlignment verticalAlignment = VerticalTextAlignment.Top) + { + var x = horizontalAlignment switch + { + SKTextAlign.Left => area.Left, + SKTextAlign.Center => area.MidX, + SKTextAlign.Right => area.Right, + _ => throw new ArgumentOutOfRangeException(nameof(horizontalAlignment), horizontalAlignment, null) + }; + + var y = verticalAlignment switch + { + VerticalTextAlignment.Top => area.Top, + VerticalTextAlignment.Center => area.MidY, + VerticalTextAlignment.Baseline => area.Top, + VerticalTextAlignment.Bottom => area.Bottom, + _ => throw new ArgumentOutOfRangeException(nameof(verticalAlignment), verticalAlignment, null) + }; + + canvas.DrawAlignedText(text, new SKPoint(x, y), paint, horizontalAlignment, verticalAlignment); + } + } +}