diff --git a/Source/LibationUiBase/BaseUtil.cs b/Source/LibationUiBase/BaseUtil.cs
index 27d50922..fbe87ce6 100644
--- a/Source/LibationUiBase/BaseUtil.cs
+++ b/Source/LibationUiBase/BaseUtil.cs
@@ -8,16 +8,16 @@ public static class BaseUtil
/// A delegate that loads image bytes into the the UI framework's image format.
public static Func LoadImage => s_LoadImage ?? DefaultLoadImageImpl;
- /// A delegate that loads a named resource into the the UI framework's image format.
- public static Func LoadResourceImage => s_LoadResourceImage ?? DefaultLoadResourceImageImpl;
+ /// A delegate that reports whether the UI framework is currently using a dark theme.
+ public static Func IsDarkMode => s_IsDarkMode ?? DefaultIsDarkModeImpl;
public static void SetLoadImageDelegate(Func tryLoadImage)
=> s_LoadImage = tryLoadImage;
- public static void SetLoadResourceImageDelegate(Func tryLoadResourceImage)
- => s_LoadResourceImage = tryLoadResourceImage;
+ public static void SetIsDarkModeDelegate(Func isDarkMode)
+ => s_IsDarkMode = isDarkMode;
private static Func? s_LoadImage;
- private static Func? s_LoadResourceImage;
+ private static Func? s_IsDarkMode;
private static object? DefaultLoadImageImpl(byte[]? imageBytes, PictureSize size)
{
@@ -25,9 +25,6 @@ public static class BaseUtil
return null;
}
- private static object? DefaultLoadResourceImageImpl(string resourceName)
- {
- Serilog.Log.Error("{LoadResourceImage} called without a delegate set. Resource name: {ResourceName}", nameof(LoadResourceImage), resourceName);
- return null;
- }
+ /// Light is the safe assumption for hosts without a theme, like the CLI and tests.
+ private static bool DefaultIsDarkModeImpl() => false;
}
diff --git a/Source/LibationUiBase/GridView/EntryStatus.cs b/Source/LibationUiBase/GridView/EntryStatus.cs
index af0c5321..34c56775 100644
--- a/Source/LibationUiBase/GridView/EntryStatus.cs
+++ b/Source/LibationUiBase/GridView/EntryStatus.cs
@@ -1,6 +1,8 @@
using ApplicationServices;
using DataLayer;
using Dinah.Core;
+using LibationFileManager;
+using LibationUiBase.StatusIcons;
using System;
using System.Collections.Generic;
@@ -53,14 +55,14 @@ public class EntryStatus : ReactiveObject, IComparable
|| PdfStatus is not null and not LiberatedStatus.Liberated
);
public double Opacity => !IsSeries && Book.UserDefinedItem.Tags.ContainsInsensitive("hidden") ? 0.4 : 1;
- public object? ButtonImage => GetLiberateIcon();
+ public object? ButtonImage => GetAndCacheIcon(GetLiberateIconDescriptor());
public string ToolTip => GetTooltip();
private Book Book { get; }
private DateTime lastBookUpdate;
private LiberatedStatus bookStatus;
private readonly bool isAbsent;
- private static readonly Dictionary iconCache = new();
+ private static readonly Dictionary iconCache = [];
internal EntryStatus(LibraryBook libraryBook)
{
@@ -99,32 +101,34 @@ public class EntryStatus : ReactiveObject, IComparable
else return PdfStatus.Value.CompareTo(second.PdfStatus.Value);
}
- private object? GetLiberateIcon()
+ private LiberateIconDescriptor GetLiberateIconDescriptor()
{
+ var isDark = BaseUtil.IsDarkMode();
+
if (IsSeries)
- return Expanded ? GetAndCacheResource("minus") : GetAndCacheResource("plus");
+ return LiberateIconDescriptor.ForSeries(Expanded, isDark);
if (BookStatus == LiberatedStatus.Error)
- return GetAndCacheResource("error");
+ return LiberateIconDescriptor.ForError(isDark);
- string image_lib = BookStatus switch
+ var lamp = BookStatus switch
{
- LiberatedStatus.Liberated => "green",
- LiberatedStatus.PartialDownload => "yellow",
- LiberatedStatus.NotLiberated => "red",
+ LiberatedStatus.Liberated => StoplightLamp.Green,
+ LiberatedStatus.PartialDownload => StoplightLamp.Yellow,
+ LiberatedStatus.NotLiberated => StoplightLamp.Red,
_ => throw new Exception("Unexpected liberation state")
};
- string image_pdf = PdfStatus switch
+ var pdf = PdfStatus switch
{
- LiberatedStatus.Liberated => "_pdf_yes",
- LiberatedStatus.NotLiberated => "_pdf_no",
- LiberatedStatus.Error => "_pdf_no",
- null => "",
+ LiberatedStatus.Liberated => PdfOverlay.Downloaded,
+ LiberatedStatus.NotLiberated => PdfOverlay.NotDownloaded,
+ LiberatedStatus.Error => PdfOverlay.NotDownloaded,
+ null => PdfOverlay.None,
_ => throw new Exception("Unexpected PDF state")
};
- return GetAndCacheResource($"liberate_{image_lib}{image_pdf}");
+ return LiberateIconDescriptor.ForBook(lamp, pdf, isDark);
}
private string GetTooltip()
@@ -165,10 +169,17 @@ public class EntryStatus : ReactiveObject, IComparable
return mouseoverText;
}
- private object? GetAndCacheResource(string rescName)
+ ///
+ /// Load the shared rendering of an icon into this UI framework's image format. There are only a
+ /// couple dozen icons, and grid entries are created on several threads, so cache them all.
+ ///
+ private static object? GetAndCacheIcon(LiberateIconDescriptor descriptor)
{
- if (!iconCache.ContainsKey(rescName))
- iconCache[rescName] = BaseUtil.LoadResourceImage(rescName);
- return iconCache[rescName];
+ lock (iconCache)
+ {
+ if (!iconCache.TryGetValue(descriptor, out var icon))
+ iconCache[descriptor] = icon = BaseUtil.LoadImage(StatusImageGenerator.GetPng(descriptor), PictureSize.Native);
+ return icon;
+ }
}
}
diff --git a/Source/LibationUiBase/LibationUiBase.csproj b/Source/LibationUiBase/LibationUiBase.csproj
index d97f8424..4f9b69ab 100644
--- a/Source/LibationUiBase/LibationUiBase.csproj
+++ b/Source/LibationUiBase/LibationUiBase.csproj
@@ -9,6 +9,12 @@
false
+
+
+
+
+
diff --git a/Source/LibationUiBase/StatusIcons/LiberateIconDescriptor.cs b/Source/LibationUiBase/StatusIcons/LiberateIconDescriptor.cs
new file mode 100644
index 00000000..f43e812d
--- /dev/null
+++ b/Source/LibationUiBase/StatusIcons/LiberateIconDescriptor.cs
@@ -0,0 +1,59 @@
+using System;
+using System.Collections.Generic;
+
+namespace LibationUiBase.StatusIcons;
+
+/// What the Liberate column's icon depicts.
+public enum LiberateIconKind
+{
+ /// A stoplight, optionally with a PDF glyph beside it.
+ Book,
+ /// A series row which can be expanded.
+ SeriesCollapsed,
+ /// A series row which can be collapsed.
+ SeriesExpanded,
+ /// A book whose download errored.
+ Error
+}
+
+/// Which of the stoplight's three lamps is lit.
+public enum StoplightLamp { Red, Yellow, Green }
+
+/// The PDF glyph drawn beside the stoplight, if any.
+public enum PdfOverlay { None, Downloaded, NotDownloaded }
+
+///
+/// Identifies one Liberate column icon. There is a small, finite number of these, so
+/// caches a rendering of each one.
+///
+///
+/// Use the factory methods rather than the constructor: they leave the members which don't apply to
+/// a given at their default, so that (for example) every series row
+/// shares one cache entry instead of one per lamp/PDF combination.
+///
+public readonly record struct LiberateIconDescriptor(LiberateIconKind Kind, StoplightLamp Lamp, PdfOverlay Pdf, bool IsDark)
+{
+ public static LiberateIconDescriptor ForBook(StoplightLamp lamp, PdfOverlay pdf, bool isDark)
+ => new(LiberateIconKind.Book, lamp, pdf, isDark);
+
+ public static LiberateIconDescriptor ForSeries(bool expanded, bool isDark)
+ => new(expanded ? LiberateIconKind.SeriesExpanded : LiberateIconKind.SeriesCollapsed, default, default, isDark);
+
+ public static LiberateIconDescriptor ForError(bool isDark)
+ => new(LiberateIconKind.Error, default, default, isDark);
+
+ /// Every icon the Liberate column can display.
+ public static IEnumerable All()
+ {
+ foreach (var isDark in new[] { false, true })
+ {
+ foreach (var lamp in Enum.GetValues())
+ foreach (var pdf in Enum.GetValues())
+ yield return ForBook(lamp, pdf, isDark);
+
+ yield return ForSeries(expanded: false, isDark);
+ yield return ForSeries(expanded: true, isDark);
+ yield return ForError(isDark);
+ }
+ }
+}
diff --git a/Source/LibationUiBase/StatusIcons/LiberateIconGeometry.cs b/Source/LibationUiBase/StatusIcons/LiberateIconGeometry.cs
new file mode 100644
index 00000000..060b79e5
--- /dev/null
+++ b/Source/LibationUiBase/StatusIcons/LiberateIconGeometry.cs
@@ -0,0 +1,73 @@
+namespace LibationUiBase.StatusIcons;
+
+///
+/// The vector artwork and palette for the Liberate column's icons. These are the single source of
+/// truth for both UIs; rasterizes them.
+///
+///
+/// Path data is SVG path mini-language, and is filled using the even-odd rule so that nested
+/// subpaths (the stoplight's lamp bezels, the error ring, the series square's border) cut holes.
+///
+internal static class LiberateIconGeometry
+{
+ /// Rounded body with three lamp bezels cut out of it. Natural size 46 x 100.
+ public const string StoplightBody = """
+ M0,12 A 12,12 0 0 1 12,0 H34 A 12,12 0 0 1 46,12 V88 A 12,12 0 0 1 34,100 H12 A 12,12 0 0 1 0,88 V12
+ M20,8 H26 A 12,12 0 0 1 26,32 H20 A 12,12 0 0 1 20,8
+ M20,38 H26 A 12,12 0 0 1 26,62 H20 A 12,12 0 0 1 20,38
+ M20,68 H26 A 12,12 0 0 1 26,92 H20 A 12,12 0 0 1 20,68
+ """;
+
+ /// A PDF document glyph. Natural size 45 x 50.5.
+ public const string PdfDocument = """
+ M4,38.5 H3 A 3,3 0 0 1 0,35.5 V21.4 A 3,3 0 0 1 3,18.4 H4 V2 A 2,2 0 0 1 6,0 H30.5 L41,12 V18.4 A 3,3 0 0 1 45,21.4 V35.5 A 3,3 0 0 1 42,38.5 H41 V48.5 A 2,2 0 0 1 39,50.5 H6 A 2,2 0 0 1 4,48.5
+ M6,38.5 H39 V48.5 H6 V38.5
+ M6,18.4 V2 H29 V12 A 1,1 0 0 0 30,13 H39 V18.4
+ M 4.3179,36 c 0,0 0.122,-14.969 0.122,-14.969 1.469,-0.194 2.939,-0.388 4.5,-0.362 1.561,0.026 3.214,0.27 4.357,0.944 1.143,0.674 1.775,1.776 2.015,2.959 0.24,1.184 0.087,2.449 -0.5,3.52 -0.587,1.071 -1.607,1.949 -2.816,2.352 -1.209,0.403 -2.607,0.332 -4.005,0.26 0,0 -0.031,5.265 -0.031,5.265 0,0 -3.673,0.122 -3.673,0.122 0,0 0.031,-0.092 0.031,-0.092
+ m 3.643,-12.428 c 0,0 0.031,4.286 0.031,4.286 0.735,0.051 1.47,0.102 2.107,-0.056 0.638,-0.158 1.178,-0.526 1.459,-1.122 0.281,-0.597 0.301,-1.423 0.01,-2.005 -0.291,-0.582 -0.893,-0.918 -1.546,-1.061 -0.653,-0.143 -1.357,-0.092 -1.709,-0.066 -0.352,0.026 -0.352,0.026 -0.352,0.026
+ m 9.428,12.428 c 2.265,0.245 4.531,0.49 6.674,0.066 2.143,-0.424 4.163,-1.515 5.285,-3.081 1.122,-1.566 1.347,-3.607 1.27,-5.306 -0.076,-1.699 -0.454,-3.056 -1.454,-4.219 -1,-1.163 -2.622,-2.133 -4.704,-2.505 -2.082,-0.373 -4.623,-0.148 -7.164,0.076 0,0 0.092,14.969 0.092,14.969
+ m 3.49,-12.398 c 0,0 0,9.673 0,9.673 0.888,0.02 1.776,0.041 2.653,-0.179 0.877,-0.219 1.745,-0.679 2.367,-1.541 0.622,-0.862 1,-2.127 0.98,-3.403 -0.02,-1.275 -0.439,-2.561 -1.193,-3.337 -0.755,-0.776 -1.847,-1.041 -2.704,-1.158 -0.857,-0.117 -1.48,-0.087 -2.102,-0.056
+ m 11.908,12.245 v-14.785 h8.969 v2.51 h-5.786 v3.612 h5.388 v2.51 h-5.449 v6.092
+ """;
+
+ /// A download arrow, unioned with to mean "PDF not downloaded".
+ public const string PdfDownArrow = """
+ M29,44 V58.7498 H35.0491 A 1.5,1.5 0 0 1 36.1342,61.2861 L23.5607,73.8595 A 1.5,1.5 0 0 1 21.4393,73.8595 L8.8658,61.2861 A 1.5,1.5 0 0 1 9.9509,58.7498 H16 V44 A 1.5,1.5 0 0 1 17.5,42.5 H27.5 A 1.5,1.5 0 0 1 29,44
+ """;
+
+ /// A rounded square containing a minus bar. Natural size 64 x 64.
+ public const string SeriesCollapse = """
+ M0,2 A 2,2 0 0 1 2,0 H62 A2,2 0 0 1 64,2 V62 A 2,2 0 0 1 62,64 H 2 A 2,2 0 0 1 0,62 V2
+ M 2,2 H62 V62 H2 V2
+ M11,28 h42 a 1,1 0 0 1 1,1 v6 a 1,1 0 0 1 -1,1 h-42 a 1,1 0 0 1 -1,-1 v-6 a 1,1 0 0 1 1,-1
+ """;
+
+ /// A vertical bar, unioned with to turn the minus into a plus.
+ public const string SeriesExpandBar
+ = "M28,53 v-42 a 1,1 0 0 1 1,-1 h6 a 1,1 0 0 1 1,1 v42 a 1,1 0 0 1 -1,1 h-6 a 1,1 0 0 1 -1,-1";
+
+ /// A "no entry" sign. Natural size 64 x 64.
+ public const string BookError
+ = "M32,0 a 32,32 0 0 1 0,64 a 32,32 0 0 1 0,-64 m 0,4 a 28,28 0 0 1 0,56 a 28,28 0 0 1 0,-56 m-21,24 h42 a 1,1 0 0 1 1,1 v6 a 1,1 0 0 1 -1,1 h-42 a 1,1 0 0 1 -1,-1 v-6 a 1,1 0 0 1 1,-1";
+
+ #region Layout, in the same units as the artwork above
+
+ /// The height the stoplight body is scaled to. Everything else is sized relative to it.
+ public const float StoplightHeight = 64;
+
+ /// Lamp size, in stoplight-body-scaled units.
+ public const float LampWidth = 20;
+ public const float LampHeight = 18;
+
+ /// Lamp offsets from the top-left of the scaled stoplight body.
+ public const float LampLeft = 5;
+ public const float RedLampTop = 5;
+ public const float YellowLampTop = 23;
+ public const float GreenLampTop = 42;
+
+ /// The width the PDF glyph is scaled to, and the gap between it and the stoplight.
+ public const float PdfWidth = 28.8f;
+ public const float PdfLeftMargin = 4;
+
+ #endregion
+}
diff --git a/Source/LibationUiBase/StatusIcons/LiberateIconPalette.cs b/Source/LibationUiBase/StatusIcons/LiberateIconPalette.cs
new file mode 100644
index 00000000..b89aca87
--- /dev/null
+++ b/Source/LibationUiBase/StatusIcons/LiberateIconPalette.cs
@@ -0,0 +1,45 @@
+using SkiaSharp;
+
+namespace LibationUiBase.StatusIcons;
+
+///
+/// The Liberate icon colors for one theme variant. These mirror the brushes both UIs' themes
+/// declare, so the generated icons match the surrounding chrome.
+///
+internal readonly struct LiberateIconPalette
+{
+ public required SKColor IconFill { get; init; }
+ public required SKColor Red { get; init; }
+ public required SKColor Yellow { get; init; }
+ public required SKColor Green { get; init; }
+ public required SKColor Error { get; init; }
+
+ public SKColor Lamp(StoplightLamp lamp) => lamp switch
+ {
+ StoplightLamp.Red => Red,
+ StoplightLamp.Yellow => Yellow,
+ StoplightLamp.Green => Green,
+ _ => IconFill
+ };
+
+ public static LiberateIconPalette For(bool isDark) => isDark ? Dark : Light;
+
+ private static readonly LiberateIconPalette Light = new()
+ {
+ IconFill = new SKColor(0x23, 0x1F, 0x20),
+ Red = new SKColor(0xF0, 0x60, 0x60),
+ Yellow = new SKColor(0xF0, 0xE1, 0x60),
+ Green = new SKColor(0x70, 0xFA, 0x70),
+ //FireBrick
+ Error = new SKColor(0xB2, 0x22, 0x22)
+ };
+
+ private static readonly LiberateIconPalette Dark = new()
+ {
+ IconFill = new SKColor(0xDC, 0xE0, 0xDF),
+ Red = new SKColor(0x7D, 0x1F, 0x1F),
+ Yellow = new SKColor(0x7D, 0x7D, 0x1F),
+ Green = new SKColor(0x1F, 0x7D, 0x1F),
+ Error = new SKColor(0x80, 0x27, 0x27)
+ };
+}
diff --git a/Source/LibationUiBase/StatusIcons/StatusImageGenerator.cs b/Source/LibationUiBase/StatusIcons/StatusImageGenerator.cs
new file mode 100644
index 00000000..cd532bf3
--- /dev/null
+++ b/Source/LibationUiBase/StatusIcons/StatusImageGenerator.cs
@@ -0,0 +1,167 @@
+using SkiaSharp;
+using System;
+using System.Collections.Generic;
+
+namespace LibationUiBase.StatusIcons;
+
+///
+/// Rasterizes the Liberate column's icons from so that every UI
+/// displays the same image. Renderings are cached; there are only a couple dozen of them.
+///
+public static class StatusImageGenerator
+{
+ ///
+ /// Icons are rendered this many pixels per unit of artwork so they stay sharp on scaled displays.
+ /// Consumers which size an image from its pixel dimensions must divide by this.
+ ///
+ public const int RenderScale = 2;
+
+ /// Render 's icon to a PNG.
+ public static byte[] GetPng(LiberateIconDescriptor descriptor)
+ {
+ lock (cache)
+ {
+ if (!cache.TryGetValue(descriptor, out var png))
+ cache[descriptor] = png = Render(descriptor);
+ return png;
+ }
+ }
+
+ private static readonly Dictionary cache = [];
+
+ private static byte[] Render(LiberateIconDescriptor descriptor)
+ {
+ var (layers, size) = Compose(descriptor, LiberateIconPalette.For(descriptor.IsDark));
+
+ var info = new SKImageInfo(
+ (int)MathF.Ceiling(size.Width * RenderScale),
+ (int)MathF.Ceiling(size.Height * RenderScale),
+ SKColorType.Rgba8888,
+ SKAlphaType.Premul);
+
+ using var surface = SKSurface.Create(info);
+ surface.Canvas.Clear(SKColors.Transparent);
+ surface.Canvas.Scale(RenderScale);
+
+ using var paint = new SKPaint { IsAntialias = true, Style = SKPaintStyle.Fill };
+ foreach (var (path, color) in layers)
+ {
+ paint.Color = color;
+ surface.Canvas.DrawPath(path, paint);
+ path.Dispose();
+ }
+
+ using var image = surface.Snapshot();
+ using var png = image.Encode(SKEncodedImageFormat.Png, 100);
+ return png.ToArray();
+ }
+
+ /// Lay the icon's paths out, bottom layer first, in artwork units.
+ private static (List<(SKPath Path, SKColor Color)> Layers, SKSize Size) Compose(LiberateIconDescriptor descriptor, LiberateIconPalette palette)
+ {
+ var layers = new List<(SKPath, SKColor)>();
+
+ switch (descriptor.Kind)
+ {
+ case LiberateIconKind.Error:
+ {
+ var error = ParsePath(LiberateIconGeometry.BookError);
+ var size = MoveToOrigin(error);
+ layers.Add((error, palette.Error));
+ return (layers, size);
+ }
+
+ case LiberateIconKind.SeriesCollapsed:
+ case LiberateIconKind.SeriesExpanded:
+ {
+ var series = ParsePath(LiberateIconGeometry.SeriesCollapse);
+ if (descriptor.Kind is LiberateIconKind.SeriesExpanded)
+ series = Union(series, ParsePath(LiberateIconGeometry.SeriesExpandBar));
+ var size = MoveToOrigin(series);
+ layers.Add((series, palette.IconFill));
+ return (layers, size);
+ }
+
+ default:
+ return ComposeStoplight(descriptor, palette, layers);
+ }
+ }
+
+ private static (List<(SKPath Path, SKColor Color)>, SKSize) ComposeStoplight(LiberateIconDescriptor descriptor, LiberateIconPalette palette, List<(SKPath, SKColor)> layers)
+ {
+ var body = ParsePath(LiberateIconGeometry.StoplightBody);
+ var bodyBounds = body.TightBounds;
+ var bodyScale = LiberateIconGeometry.StoplightHeight / bodyBounds.Height;
+ var stoplightWidth = bodyBounds.Width * bodyScale;
+ body.Transform(ScaleThenTranslate(bodyScale, -bodyBounds.Left * bodyScale, -bodyBounds.Top * bodyScale));
+
+ var lampTop = descriptor.Lamp switch
+ {
+ StoplightLamp.Red => LiberateIconGeometry.RedLampTop,
+ StoplightLamp.Yellow => LiberateIconGeometry.YellowLampTop,
+ _ => LiberateIconGeometry.GreenLampTop
+ };
+
+ //The lamp goes under the body, so it shows through the bezel cut out of it.
+ var lamp = new SKPath();
+ lamp.AddRect(SKRect.Create(LiberateIconGeometry.LampLeft, lampTop, LiberateIconGeometry.LampWidth, LiberateIconGeometry.LampHeight));
+ layers.Add((lamp, palette.Lamp(descriptor.Lamp)));
+ layers.Add((body, palette.IconFill));
+
+ var width = stoplightWidth;
+
+ if (descriptor.Pdf is not PdfOverlay.None)
+ {
+ var pdf = ParsePath(LiberateIconGeometry.PdfDocument);
+ if (descriptor.Pdf is PdfOverlay.NotDownloaded)
+ pdf = Union(pdf, ParsePath(LiberateIconGeometry.PdfDownArrow));
+
+ //Scale to a fixed width, then center vertically, so the document glyph is the same size
+ //whether or not the download arrow hangs beneath it.
+ var pdfBounds = pdf.TightBounds;
+ var pdfScale = LiberateIconGeometry.PdfWidth / pdfBounds.Width;
+ var left = stoplightWidth + LiberateIconGeometry.PdfLeftMargin;
+ var top = (LiberateIconGeometry.StoplightHeight - pdfBounds.Height * pdfScale) / 2;
+ pdf.Transform(ScaleThenTranslate(pdfScale, left - pdfBounds.Left * pdfScale, top - pdfBounds.Top * pdfScale));
+
+ layers.Add((pdf, palette.IconFill));
+ width = left + LiberateIconGeometry.PdfWidth;
+ }
+
+ return (layers, new SKSize(width, LiberateIconGeometry.StoplightHeight));
+ }
+
+ private static SKPath ParsePath(string svgPathData)
+ {
+ var path = SKPath.ParseSvgPathData(svgPathData)
+ ?? throw new InvalidOperationException($"Unparsable icon path data: {svgPathData}");
+ path.FillType = SKPathFillType.EvenOdd;
+ return path;
+ }
+
+ private static SKPath Union(SKPath first, SKPath second)
+ {
+ using (first)
+ using (second)
+ return first.Op(second, SKPathOp.Union)
+ ?? throw new InvalidOperationException("Could not union icon paths.");
+ }
+
+ /// Shift a path so its bounds start at the origin, and return those bounds' size.
+ private static SKSize MoveToOrigin(SKPath path)
+ {
+ var bounds = path.TightBounds;
+ path.Transform(SKMatrix.CreateTranslation(-bounds.Left, -bounds.Top));
+ return new SKSize(bounds.Width, bounds.Height);
+ }
+
+ private static SKMatrix ScaleThenTranslate(float scale, float translateX, float translateY)
+ => new()
+ {
+ ScaleX = scale,
+ ScaleY = scale,
+ TransX = translateX,
+ TransY = translateY,
+ Persp2 = 1
+ };
+}