Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 9s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 25s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m40s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m18s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m12s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m13s
probe742/combined-newest SECOND
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
572 lines
21 KiB
C#
572 lines
21 KiB
C#
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<Expression> _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<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));
|
|
}
|
|
|
|
// 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<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;
|
|
}
|
|
|
|
// 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();
|
|
}
|