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: { //The icon offers the action, not the state: an expanded series shows a minus to //collapse it, and a collapsed series shows a plus to expand it. var series = ParsePath(LiberateIconGeometry.SeriesMinus); if (descriptor.Kind is LiberateIconKind.SeriesCollapsed) series = Union(series, ParsePath(LiberateIconGeometry.SeriesPlusBar)); 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)); //Sitting flush with the top edge keeps the badge out of the stoplight's height, so a Plus //title's stoplight is drawn at exactly the same size as a purchased one's. var badgeRadius = LiberateIconGeometry.PlusBadgeDiameter / 2; var badgeCenterX = stoplightWidth + LiberateIconGeometry.PlusBadgeCornerOffset; var badgeCenterY = badgeRadius; //The badge overlaps whatever it lands on rather than displacing it, so the icon is no wider //than a purchased one once a PDF is present. What keeps that from looking muddy is the rim: //a hole cut through everything underneath, so the row - not the stoplight or the PDF - //shows through the gap around the circle. if (descriptor.IsPlus) { using var rim = BadgeRim(badgeCenterX, badgeCenterY, badgeRadius); body = Difference(body, rim); lamp = Difference(lamp, rim); } layers.Add((lamp, palette.Lamp(descriptor.Lamp))); layers.Add((body, palette.IconFill)); var width = descriptor.IsPlus ? badgeCenterX + badgeRadius : 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 //and in the same place whether or not the download arrow hangs beneath it, and whether //or not the book is a Plus title. 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)); if (descriptor.IsPlus) { using var rim = BadgeRim(badgeCenterX, badgeCenterY, badgeRadius); pdf = Difference(pdf, rim); } layers.Add((pdf, palette.IconFill)); width = MathF.Max(width, left + LiberateIconGeometry.PdfWidth); } //Drawn last so the badge sits on top of everything it overlaps. if (descriptor.IsPlus) { var badge = new SKPath(); badge.AddCircle(badgeCenterX, badgeCenterY, badgeRadius); layers.Add((badge, palette.PlusBadge)); layers.Add((PlusGlyph(badgeCenterX, badgeCenterY), palette.PlusBadgeGlyph)); } return (layers, new SKSize(width, LiberateIconGeometry.StoplightHeight)); } /// The hole cut around the Audible Plus badge, separating it from whatever it overlaps. private static SKPath BadgeRim(float centerX, float centerY, float badgeRadius) { var rim = new SKPath(); rim.AddCircle(centerX, centerY, badgeRadius + LiberateIconGeometry.PlusBadgeRimWidth); return rim; } /// /// The plus drawn on the Audible Plus badge, as two overlapping rounded bars. They are wound the /// same way and filled by the winding rule, so the square where they cross stays filled. /// private static SKPath PlusGlyph(float centerX, float centerY) { var extent = LiberateIconGeometry.PlusBadgeDiameter * LiberateIconGeometry.PlusBadgeGlyphExtent; var thickness = extent * LiberateIconGeometry.PlusBadgeGlyphThickness; var corner = thickness * 0.15f; var plus = new SKPath { FillType = SKPathFillType.Winding }; plus.AddRoundRect(SKRect.Create(centerX - extent / 2, centerY - thickness / 2, extent, thickness), corner, corner); plus.AddRoundRect(SKRect.Create(centerX - thickness / 2, centerY - extent / 2, thickness, extent), corner, corner); return plus; } 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."); } /// Cut out of . The caller keeps the hole. private static SKPath Difference(SKPath path, SKPath hole) { using (path) return path.Op(hole, SKPathOp.Difference) ?? throw new InvalidOperationException("Could not cut an icon path."); } /// 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 }; }