using System.Text.RegularExpressions; using ErsatzTV.Core.Graphics; using ErsatzTV.Core.Interfaces.Streaming; using Microsoft.Extensions.Logging; using NCalc; using SkiaSharp; using RichTextKit = Topten.RichTextKit; namespace ErsatzTV.Infrastructure.Streaming.Graphics; public partial class TextElement( GraphicsEngineFonts graphicsEngineFonts, TextGraphicsElement textElement, ILogger logger) : GraphicsElement, IDisposable { // Far larger than any sane overlay on an 8K frame, and small enough that every downstream // int cast stays well inside range. private const float MaxBoxDimension = 10_000f; private static readonly Regex StylePattern = StyleRegex(); private SKBitmap _image; private SKPointI _location; private Option _maybeOpacityExpression; private float _opacity; public override int ZIndex { get; } = textElement.ZIndex ?? 0; public override string DebugKey { get; } = $"Text {textElement.DebugName()}"; public void Dispose() { GC.SuppressFinalize(this); _image?.Dispose(); _image = null; } public override Task InitializeAsync(GraphicsEngineContext context, CancellationToken cancellationToken) { try { if (!string.IsNullOrWhiteSpace(textElement.OpacityExpression)) { var expression = new Expression(textElement.OpacityExpression); expression.EvaluateFunction += OpacityExpressionHelper.EvaluateFunction; _maybeOpacityExpression = expression; } else { _opacity = (textElement.OpacityPercent ?? 100) / 100.0f; } if (!string.IsNullOrWhiteSpace(textElement.IncludeFontsFrom)) { if (Directory.Exists(textElement.IncludeFontsFrom)) { graphicsEngineFonts.LoadFonts(textElement.IncludeFontsFrom); } else { logger.LogWarning( "include_fonts_from path {Directory} does not exist", textElement.IncludeFontsFrom); } } BackgroundBox box = BuildBackgroundBox(); RichTextKit.TextBlock textBlock = BuildTextBlock(textElement.Text); // Padding and border sit OUTSIDE the laid-out text on every side, so they shrink the // space the text may occupy and grow the bitmap that holds it. Zero when there is no // box, which reproduces the pre-#732 geometry exactly. // // Round ONCE, here, and use the same integer on both sides: the bitmap grows by // 2 * insetPixels, so subtracting the unrounded inset from the wrap budget would let a // fractional padding push the finished box a pixel past width_percent. var insetPixels = (int)Math.Ceiling(box?.Inset ?? 0f); // Bound the inset against the FRAME even when there is no width_percent. Sanitize caps // each field individually, but padding and border add up, and without a budget nothing // else clamps them -- a two-field fat-finger would otherwise allocate a bitmap far // larger than the frame it is drawn onto. Pre-#732 no config value could inflate the // bitmap independently of the measured text. int frameInsetCap = Math.Max(0, Math.Min(context.FrameSize.Width, context.FrameSize.Height) / 2); if (insetPixels > frameInsetCap) { logger.LogWarning( "Background padding/border of {Inset}px exceeds the frame; clamping to {Clamped}px", insetPixels, frameInsetCap); insetPixels = frameInsetCap; box = box?.ClampedTo(frameInsetCap); } // A width_percent of 1e300 makes maxWidth Infinity, and every int cast below it is then // unspecified. Treat a non-finite budget as "no budget", which is what an absent // width_percent already means. if (textElement.WidthPercent.HasValue && float.IsFinite((float)(textElement.WidthPercent.Value / 100.0 * context.FrameSize.Width))) { var maxWidth = (float)Math.Round(textElement.WidthPercent.Value / 100.0 * context.FrameSize.Width); // A padding wider than the budget itself cannot be honoured AND stay inside it. // Clamp the inset rather than squeezing the text to 1px: an unclamped floor turns a // fat-fingered background_padding into a box several times the requested width. int maxInset = Math.Max(0, (int)Math.Floor((maxWidth - 1) / 2)); if (insetPixels > maxInset) { logger.LogWarning( "Background padding/border of {Inset}px does not fit within width_percent " + "({MaxWidth}px); clamping to {Clamped}px", insetPixels, maxWidth, maxInset); // Clamp the BOX, not just the bitmap's inset. Shrinking insetPixels alone leaves // DrawBackgroundBox stroking at the original border width, which is centred on a // rect that no longer has room for it -- the stroke then floods the element. insetPixels = maxInset; box = box?.ClampedTo(maxInset); } // width_percent bounds the ELEMENT, so the text gets what is left after the insets. // With no box the budget is passed through untouched -- not through Math.Max -- so a // width_percent that rounds to 0 keeps its exact pre-#732 behavior. float textMaxWidth = insetPixels == 0 ? maxWidth : Math.Max(1f, maxWidth - (2 * insetPixels)); switch (textElement.Fit) { case TextFit.Wrap: textBlock.MaxWidth = textMaxWidth; break; case TextFit.Scale: FitTextBlock(textBlock, textMaxWidth); break; } } _image = new SKBitmap( (int)Math.Ceiling(textBlock.MeasuredWidth) + (2 * insetPixels), (int)Math.Ceiling(textBlock.MeasuredHeight) + (2 * insetPixels)); using (var canvas = new SKCanvas(_image)) { canvas.Clear(SKColors.Transparent); if (box is not null) { DrawBackgroundBox(canvas, box, _image.Width, _image.Height); } textBlock.Paint(canvas, new SKPoint(insetPixels, insetPixels)); } var horizontalMargin = (int)Math.Round((textElement.HorizontalMarginPercent ?? 0) / 100.0 * context.FrameSize.Width); var verticalMargin = (int)Math.Round((textElement.VerticalMarginPercent ?? 0) / 100.0 * context.FrameSize.Height); _location = CalculatePosition( textElement.Location, context.FrameSize.Width, context.FrameSize.Height, _image.Width, _image.Height, horizontalMargin, verticalMargin); } catch (Exception ex) { IsFinished = true; logger.LogWarning(ex, "Failed to initialize text element; will disable for this content"); } return Task.CompletedTask; } public override ValueTask> PrepareImage( TimeSpan timeOfDay, TimeSpan contentTime, TimeSpan contentTotalTime, TimeSpan channelTime, CancellationToken cancellationToken) { float opacity = _opacity; foreach (Expression expression in _maybeOpacityExpression) { opacity = OpacityExpressionHelper.GetOpacity( expression, timeOfDay, contentTime, contentTotalTime, channelTime); } return opacity == 0 ? ValueTask.FromResult(Option.None) : new ValueTask>(new PreparedElementImage(_image, _location, opacity, ZIndex, false)); } // A background box is drawn only when a colour actually parses. An unparseable colour is // warned about and skipped rather than substituted, so a typo never silently changes the // look into something that appears deliberate. private BackgroundBox BuildBackgroundBox() { SKColor? fill = ParseOptionalColor(textElement.BackgroundColor, "background_color"); if (fill.HasValue) { fill = ApplyOpacityPercent(fill.Value, textElement.BackgroundOpacityPercent); } SKColor? border = ParseOptionalColor(textElement.BorderColor, "border_color"); // A border colour with no explicit width means a hairline border, not an invisible one: // "border_color set, nothing drawn" is the more confusing of the two readings. float borderWidth = Sanitize(textElement.BorderWidth ?? 1, "border_width"); if (!border.HasValue) { borderWidth = 0; } if (!fill.HasValue && borderWidth <= 0) { return null; } return new BackgroundBox( fill, border, borderWidth, Sanitize(textElement.BackgroundCornerRadius, "background_corner_radius"), Sanitize(textElement.BackgroundPadding, "background_padding")); } // YAML happily yields 1e100 or NaN. Cast to float those become Infinity/NaN, and // (int)Math.Ceiling(Infinity) is an unspecified value -- in practice int.MinValue, which sails // straight past every `> maxInset` clamp and can wrap 2 * inset back to zero. Sanitize at the // boundary so no downstream arithmetic ever sees a non-finite value. private float Sanitize(double? value, string fieldName) { if (value is not { } raw) { return 0f; } if (double.IsNaN(raw) || raw < 0) { logger.LogWarning("Ignoring out-of-range {Field} value {Value}", fieldName, raw); return 0f; } if (raw > MaxBoxDimension) { logger.LogWarning( "Clamping {Field} value {Value} to {Max}", fieldName, raw, MaxBoxDimension); return MaxBoxDimension; } return (float)raw; } private SKColor? ParseOptionalColor(string value, string fieldName) { if (string.IsNullOrWhiteSpace(value)) { return null; } if (SKColor.TryParse(value, out SKColor parsed)) { return parsed; } logger.LogWarning( "Unable to parse {Field} value {Value}; that part of the background box will not be drawn", fieldName, value); return null; } private static SKColor ApplyOpacityPercent(SKColor color, int? opacityPercent) { if (opacityPercent is not { } percent) { return color; } int clamped = Math.Clamp(percent, 0, 100); return color.WithAlpha((byte)Math.Round(color.Alpha * clamped / 100.0)); } private static void DrawBackgroundBox(SKCanvas canvas, BackgroundBox box, int width, int height) { // Skia strokes centred on the path, so half the border would fall outside the bitmap and // be clipped. Inset the rect by half the width to keep the whole border visible. float half = box.BorderWidth / 2f; var rect = new SKRect(half, half, width - half, height - half); // A radius larger than half the shorter side is not expressible as a rounded rect. float radius = Math.Min(box.CornerRadius, Math.Min(rect.Width, rect.Height) / 2f); radius = Math.Max(0, radius); if (box.Fill is { } fill) { using var fillPaint = new SKPaint { Color = fill, Style = SKPaintStyle.Fill, IsAntialias = true }; canvas.DrawRoundRect(rect, radius, radius, fillPaint); } if (box.Border is { } border && box.BorderWidth > 0) { using var borderPaint = new SKPaint { Color = border, Style = SKPaintStyle.Stroke, StrokeWidth = box.BorderWidth, IsAntialias = true }; canvas.DrawRoundRect(rect, radius, radius, borderPaint); } } private sealed record BackgroundBox( SKColor? Fill, SKColor? Border, float BorderWidth, float CornerRadius, float Padding) { public float Inset => Padding + BorderWidth; // Border first, then whatever is left goes to padding: a border that cannot be drawn inside // the bitmap is worse than a thin one, and padding degrades gracefully to zero. public BackgroundBox ClampedTo(float maxInset) { float borderWidth = Math.Min(BorderWidth, maxInset); float padding = Math.Max(0, maxInset - borderWidth); return this with { BorderWidth = borderWidth, Padding = padding }; } } private RichTextKit.TextBlock BuildTextBlock(string textToRender) { var textBlock = new RichTextKit.TextBlock { FontMapper = graphicsEngineFonts.Mapper, Alignment = textElement.Align switch { TextAlignment.Center => RichTextKit.TextAlignment.Center, TextAlignment.Right => RichTextKit.TextAlignment.Right, TextAlignment.Left => RichTextKit.TextAlignment.Left, _ => RichTextKit.TextAlignment.Auto } }; (Dictionary styles, RichTextKit.Style baseStyle) = BuildTextStyles(); var lastIndex = 0; foreach (Match match in StylePattern.Matches(textToRender)) { // unstyled text before match if (match.Index > lastIndex) { textBlock.AddText(textToRender.AsSpan(lastIndex, match.Index - lastIndex), baseStyle); } string styleName = match.Groups[1].Value; string innerText = match.Groups[2].Value; if (styles.TryGetValue(styleName, out RichTextKit.Style style)) { textBlock.AddText(innerText, style); } else { textBlock.AddText(match.Value, baseStyle); } lastIndex = match.Index + match.Length; } // unstyled text after match if (lastIndex < textToRender.Length) { textBlock.AddText(textToRender.AsSpan(lastIndex), baseStyle); } return textBlock; } private (Dictionary, RichTextKit.Style) BuildTextStyles() { var styles = new Dictionary(); StyleDefinition baseStyleDef = textElement.Styles.Find(s => s.Name == textElement.BaseStyle); if (baseStyleDef == null) { throw new InvalidOperationException( $"The specified base_style '{textElement.BaseStyle}' was not found in the styles list."); } foreach (StyleDefinition s in textElement.Styles) { // start with base and merge in additional settings RichTextKit.Style finalStyle = RichTextStyleFromDef(baseStyleDef); finalStyle.FontFamily = s.FontFamily ?? finalStyle.FontFamily; finalStyle.FontItalic = s.FontItalic ?? finalStyle.FontItalic; finalStyle.FontSize = s.FontSize ?? finalStyle.FontSize; finalStyle.FontWeight = s.FontWeight ?? finalStyle.FontWeight; finalStyle.LetterSpacing = s.LetterSpacing ?? finalStyle.LetterSpacing; finalStyle.LineHeight = s.LineHeight ?? finalStyle.LineHeight; if (s.TextColor != null && SKColor.TryParse(s.TextColor, out SKColor parsedColor)) { finalStyle.TextColor = parsedColor; } // Halo is per-style in the schema and was being dropped here, so a non-base style's // halo_* silently inherited the base style's. The seeded template only looked correct // because all three of its styles declare the same halo. finalStyle.HaloWidth = s.HaloWidth ?? finalStyle.HaloWidth; finalStyle.HaloBlur = s.HaloBlur ?? finalStyle.HaloBlur; if (s.HaloColor != null && SKColor.TryParse(s.HaloColor, out SKColor parsedHalo)) { finalStyle.HaloColor = parsedHalo; } styles[s.Name] = finalStyle; } return (styles, RichTextStyleFromDef(baseStyleDef)); RichTextKit.Style RichTextStyleFromDef(StyleDefinition def) { var style = new RichTextKit.Style { FontFamily = def.FontFamily, FontItalic = def.FontItalic ?? false, TextColor = SKColor.TryParse(def.TextColor, out SKColor color) ? color : SKColors.White }; if (SKColor.TryParse(def.HaloColor, out SKColor parsedHaloColor)) { style.HaloColor = parsedHaloColor; } foreach (float haloWidth in Optional(def.HaloWidth)) { style.HaloWidth = haloWidth; } foreach (float haloBlur in Optional(def.HaloBlur)) { style.HaloBlur = haloBlur; } foreach (float fontSize in Optional(def.FontSize)) { style.FontSize = fontSize; } foreach (int fontWeight in Optional(def.FontWeight)) { style.FontWeight = fontWeight; } foreach (float letterSpacing in Optional(def.LetterSpacing)) { style.LetterSpacing = letterSpacing; } foreach (float lineHeight in Optional(def.LineHeight)) { style.LineHeight = lineHeight; } return style; } } private static void FitTextBlock(RichTextKit.TextBlock block, float maxWidth) { if (block.MeasuredWidth <= maxWidth) { return; } var originalContent = block.StyleRuns .Select(run => (run.ToString(), run.Style)) .ToList(); float scale = maxWidth / block.MeasuredWidth; const float MIN_FONT_SIZE = 5.0f; while (true) { block.Clear(); var isAtMinSize = false; foreach ((string text, RichTextKit.IStyle style) in originalContent) { // Carry across every property the YAML schema can set, not just the ones the scale // needs (the rest are RichTextKit defaults we never touch). Halo and // line height were being dropped here, which only mattered once #732 gave the box // insets that can push a previously-fitting element into the Scale path: adding a // background would then silently remove the halo it sits behind. var newStyle = new RichTextKit.Style { FontFamily = style.FontFamily, FontItalic = style.FontItalic, FontSize = style.FontSize, FontWidth = style.FontWidth, FontWeight = style.FontWeight, LetterSpacing = style.LetterSpacing, LineHeight = style.LineHeight, TextColor = style.TextColor, HaloColor = style.HaloColor, HaloWidth = style.HaloWidth, HaloBlur = style.HaloBlur }; float newSize = newStyle.FontSize * scale; if (newSize < MIN_FONT_SIZE) { newSize = MIN_FONT_SIZE; isAtMinSize = true; } newStyle.FontSize = newSize; block.AddText(text, newStyle); } if (block.MeasuredWidth <= maxWidth) { break; } if (isAtMinSize) { break; } scale -= 0.01f; } } [GeneratedRegex(@"\[(\w+)\](.*?)\[/\1\]")] private static partial Regex StyleRegex(); }