mirror of https://github.com/ErsatzTV/ErsatzTV.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
234 lines
7.8 KiB
234 lines
7.8 KiB
using System.Text.RegularExpressions; |
|
using ErsatzTV.Core.Domain; |
|
using ErsatzTV.Core.Graphics; |
|
using Microsoft.Extensions.Logging; |
|
using NCalc; |
|
using Topten.RichTextKit; |
|
using Scriban; |
|
using Scriban.Runtime; |
|
using SkiaSharp; |
|
using RichTextKit=Topten.RichTextKit; |
|
|
|
namespace ErsatzTV.Infrastructure.Streaming.Graphics; |
|
|
|
public partial class TextElement( |
|
TemplateFunctions templateFunctions, |
|
GraphicsEngineFonts graphicsEngineFonts, |
|
TextGraphicsElement textElement, |
|
Dictionary<string, object> variables, |
|
ILogger logger) |
|
: GraphicsElement, IDisposable |
|
{ |
|
private static readonly Regex StylePattern = StyleRegex(); |
|
|
|
private Option<Expression> _maybeOpacityExpression; |
|
private float _opacity; |
|
private SKBitmap _image; |
|
private SKPointI _location; |
|
|
|
public override async Task InitializeAsync( |
|
Resolution squarePixelFrameSize, |
|
Resolution frameSize, |
|
int frameRate, |
|
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); |
|
} |
|
} |
|
|
|
var scriptObject = new ScriptObject(); |
|
scriptObject.Import(variables, renamer: member => member.Name); |
|
scriptObject.Import("convert_timezone", templateFunctions.ConvertTimeZone); |
|
scriptObject.Import("format_datetime", templateFunctions.FormatDateTime); |
|
|
|
var context = new TemplateContext { MemberRenamer = member => member.Name }; |
|
context.PushGlobal(scriptObject); |
|
string textToRender = await Template.Parse(textElement.Text).RenderAsync(context); |
|
|
|
var textBlock = BuildTextBlock(textToRender); |
|
|
|
_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)); |
|
} |
|
|
|
int horizontalMargin = |
|
(int)Math.Round((textElement.HorizontalMarginPercent ?? 0) / 100.0 * frameSize.Width); |
|
int verticalMargin = (int)Math.Round((textElement.VerticalMarginPercent ?? 0) / 100.0 * frameSize.Height); |
|
|
|
_location = CalculatePosition( |
|
textElement.Location, |
|
frameSize.Width, |
|
frameSize.Height, |
|
_image.Width, |
|
_image.Height, |
|
horizontalMargin, |
|
verticalMargin); |
|
} |
|
catch (Exception ex) |
|
{ |
|
IsFailed = true; |
|
logger.LogWarning(ex, "Failed to initialize text element; will disable for this content"); |
|
} |
|
} |
|
|
|
public override ValueTask<Option<PreparedElementImage>> PrepareImage( |
|
TimeSpan timeOfDay, |
|
TimeSpan contentTime, |
|
TimeSpan contentTotalTime, |
|
TimeSpan channelTime, |
|
CancellationToken cancellationToken) |
|
{ |
|
float opacity = _opacity; |
|
foreach (var expression in _maybeOpacityExpression) |
|
{ |
|
opacity = OpacityExpressionHelper.GetOpacity( |
|
expression, |
|
timeOfDay, |
|
contentTime, |
|
contentTotalTime, |
|
channelTime); |
|
} |
|
|
|
return opacity == 0 |
|
? ValueTask.FromResult(Option<PreparedElementImage>.None) |
|
: new ValueTask<Option<PreparedElementImage>>(new PreparedElementImage(_image, _location, opacity, false)); |
|
} |
|
|
|
public void Dispose() |
|
{ |
|
GC.SuppressFinalize(this); |
|
|
|
_image?.Dispose(); |
|
_image = null; |
|
} |
|
|
|
private TextBlock BuildTextBlock(string textToRender) |
|
{ |
|
var textBlock = new TextBlock { FontMapper = graphicsEngineFonts.Mapper }; |
|
|
|
(Dictionary<string, RichTextKit.Style> styles, RichTextKit.Style baseStyle) = BuildTextStyles(); |
|
|
|
int 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); |
|
} |
|
|
|
var styleName = match.Groups[1].Value; |
|
var innerText = match.Groups[2].Value; |
|
|
|
if (styles.TryGetValue(styleName, out var 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<string, RichTextKit.Style>, RichTextKit.Style) BuildTextStyles() |
|
{ |
|
var styles = new Dictionary<string, RichTextKit.Style>(); |
|
|
|
var 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 (var s in textElement.Styles) |
|
{ |
|
// start with base and merge in additional settings |
|
var 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; |
|
|
|
if (s.TextColor != null && SKColor.TryParse(s.TextColor, out var 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 var color) ? color : SKColors.White |
|
}; |
|
|
|
foreach (var fontSize in Optional(def.FontSize)) |
|
{ |
|
style.FontSize = fontSize; |
|
} |
|
|
|
foreach (var fontWeight in Optional(def.FontWeight)) |
|
{ |
|
style.FontWeight = fontWeight; |
|
} |
|
|
|
foreach (var letterSpacing in Optional(def.LetterSpacing)) |
|
{ |
|
style.LetterSpacing = letterSpacing; |
|
} |
|
|
|
return style; |
|
} |
|
} |
|
|
|
[GeneratedRegex(@"\[(\w+)\](.*?)\[/\1\]")] |
|
private static partial Regex StyleRegex(); |
|
}
|
|
|