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.
327 lines
10 KiB
327 lines
10 KiB
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<Expression> _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<Option<PreparedElementImage>> 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<PreparedElementImage>.None) |
|
: new ValueTask<Option<PreparedElementImage>>(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<string, RichTextKit.Style> 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<string, RichTextKit.Style>, RichTextKit.Style) BuildTextStyles() |
|
{ |
|
var styles = new Dictionary<string, RichTextKit.Style>(); |
|
|
|
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(); |
|
}
|
|
|