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 { private static readonly Regex StylePattern = StyleRegex(); private SKBitmap _image; private SKPointI _location; private Option _maybeOpacityExpression; private float _opacity; 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; } ZIndex = textElement.ZIndex ?? 0; 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); } } RichTextKit.TextBlock textBlock = BuildTextBlock(textElement.Text); if (textElement.WidthPercent.HasValue) { var maxWidth = (float)Math.Round(textElement.WidthPercent.Value / 100.0 * context.FrameSize.Width); switch (textElement.Fit) { case TextFit.Wrap: textBlock.MaxWidth = maxWidth; break; case TextFit.Scale: FitTextBlock(textBlock, maxWidth); break; } } _image = new SKBitmap( (int)Math.Ceiling(textBlock.MeasuredWidth), (int)Math.Ceiling(textBlock.MeasuredHeight)); using (var canvas = new SKCanvas(_image)) { canvas.Clear(SKColors.Transparent); textBlock.Paint(canvas, new SKPoint(0, 0)); } 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)); } 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; } 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) { var newStyle = new RichTextKit.Style { FontFamily = style.FontFamily, FontItalic = style.FontItalic, FontSize = style.FontSize, FontWidth = style.FontWidth, FontWeight = style.FontWeight, LetterSpacing = style.LetterSpacing, TextColor = style.TextColor }; 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(); }