Files
ersatztv/ErsatzTV.Infrastructure/Streaming/Graphics/Text/TextElement.cs
T
timothyandtimothy ba6a4b08aa
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
feat(732): On Now / Next gets a background box, and is on by default (#843)
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-26 19:28:25 +00:00

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();
}