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>
675 lines
25 KiB
C#
675 lines
25 KiB
C#
using ErsatzTV.Core.Domain;
|
|
using ErsatzTV.Core.Graphics;
|
|
using ErsatzTV.Core.Interfaces.Streaming;
|
|
using ErsatzTV.FFmpeg;
|
|
using ErsatzTV.FFmpeg.State;
|
|
using ErsatzTV.Infrastructure.Streaming.Graphics;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using NUnit.Framework;
|
|
using Shouldly;
|
|
using LanguageExt;
|
|
using SkiaSharp;
|
|
|
|
namespace ErsatzTV.Tests.Infrastructure.Graphics;
|
|
|
|
/// <summary>
|
|
/// Pixel-level cover for the #732 background box. These assert the RENDERED BITMAP rather than the
|
|
/// parsed model, because every failure mode this feature has is a silent no-op: the YAML parses, the
|
|
/// element initializes, and nothing is drawn. Geometry assertions are all RELATIVE to a no-box
|
|
/// baseline so they do not depend on which typeface the host resolves.
|
|
/// </summary>
|
|
[TestFixture]
|
|
public class TextElementBackgroundBoxTests
|
|
{
|
|
private const int FrameWidth = 1920;
|
|
|
|
// Derived from the documented rule, not a hardcoded 1080p result: hardcoding it makes correct
|
|
// production code fail the moment the test frame size changes.
|
|
private static int FrameInsetCap => Math.Min(FrameWidth, FrameHeight) / 2;
|
|
private const int FrameHeight = 1080;
|
|
|
|
private static TextGraphicsElement BaseElement() =>
|
|
new()
|
|
{
|
|
Name = "test",
|
|
Location = WatermarkLocation.BottomLeft,
|
|
BaseStyle = "body",
|
|
Styles =
|
|
[
|
|
new StyleDefinition
|
|
{
|
|
Name = "body",
|
|
// Required: a null font_family makes CustomFontMapper throw on a null dictionary
|
|
// key, which TextElement swallows into "disable for this content" (the #570 trap).
|
|
// The family need not resolve -- an unknown one falls back to Skia's default.
|
|
FontFamily = "Roboto",
|
|
FontSize = 40,
|
|
TextColor = "#FFFFFF"
|
|
}
|
|
],
|
|
Text = "Hello"
|
|
};
|
|
|
|
// TextElement swallows every initialization failure into a logged warning, so a broken render
|
|
// would otherwise surface as a null bitmap with no explanation. Capture the warning and rethrow.
|
|
private sealed class ThrowingLogger : ILogger
|
|
{
|
|
public IDisposable BeginScope<TState>(TState state) where TState : notnull => null!;
|
|
|
|
public bool IsEnabled(LogLevel logLevel) => true;
|
|
|
|
public void Log<TState>(
|
|
LogLevel logLevel,
|
|
EventId eventId,
|
|
TState state,
|
|
Exception exception,
|
|
Func<TState, Exception, string> formatter)
|
|
{
|
|
if (logLevel >= LogLevel.Warning && exception is not null)
|
|
{
|
|
throw new InvalidOperationException(formatter(state, exception), exception);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static SKBitmap Render(TextGraphicsElement element)
|
|
{
|
|
var fonts = new GraphicsEngineFonts(new CustomFontMapper(NullLogger<CustomFontMapper>.Instance));
|
|
var textElement = new TextElement(fonts, element, new ThrowingLogger());
|
|
|
|
var context = new GraphicsEngineContext(
|
|
"1",
|
|
null,
|
|
[],
|
|
new Dictionary<string, object>(),
|
|
new Resolution { Width = FrameWidth, Height = FrameHeight },
|
|
new Resolution { Width = FrameWidth, Height = FrameHeight },
|
|
new FrameRate("30"),
|
|
DateTimeOffset.UnixEpoch,
|
|
DateTimeOffset.UnixEpoch,
|
|
TimeSpan.Zero,
|
|
TimeSpan.FromMinutes(1),
|
|
TimeSpan.FromMinutes(1));
|
|
|
|
textElement.InitializeAsync(context, CancellationToken.None).GetAwaiter().GetResult();
|
|
|
|
Option<PreparedElementImage> maybeImage = textElement
|
|
.PrepareImage(TimeSpan.Zero, TimeSpan.Zero, TimeSpan.FromMinutes(1), TimeSpan.Zero, CancellationToken.None)
|
|
.AsTask().GetAwaiter().GetResult();
|
|
|
|
PreparedElementImage prepared = maybeImage.IfNone(() => throw new InvalidOperationException(
|
|
"the element produced no image; initialization failed"));
|
|
|
|
return prepared.Image;
|
|
}
|
|
|
|
// The whole suite is meaningless if the host cannot lay out any text at all, so prove the
|
|
// baseline is non-degenerate rather than letting a 0x0 bitmap pass every relative assertion.
|
|
[Test]
|
|
public void Baseline_Renders_A_Non_Empty_Bitmap()
|
|
{
|
|
SKBitmap baseline = Render(BaseElement());
|
|
|
|
baseline.Width.ShouldBeGreaterThan(0);
|
|
baseline.Height.ShouldBeGreaterThan(0);
|
|
}
|
|
|
|
[Test]
|
|
public void No_Background_Fields_Leaves_Corner_Transparent()
|
|
{
|
|
SKBitmap baseline = Render(BaseElement());
|
|
|
|
baseline.GetPixel(0, 0).Alpha.ShouldBe((byte)0);
|
|
baseline.GetPixel(baseline.Width - 1, baseline.Height - 1).Alpha.ShouldBe((byte)0);
|
|
}
|
|
|
|
[Test]
|
|
public void Background_Color_Fills_The_Box()
|
|
{
|
|
TextGraphicsElement element = BaseElement();
|
|
element.BackgroundColor = "#FF0000";
|
|
|
|
SKBitmap rendered = Render(element);
|
|
|
|
SKColor corner = rendered.GetPixel(0, 0);
|
|
corner.Alpha.ShouldBe((byte)255);
|
|
corner.Red.ShouldBe((byte)255);
|
|
corner.Green.ShouldBe((byte)0);
|
|
corner.Blue.ShouldBe((byte)0);
|
|
}
|
|
|
|
[Test]
|
|
public void Background_Padding_Grows_The_Bitmap_On_Every_Side()
|
|
{
|
|
const int Padding = 12;
|
|
|
|
SKBitmap baseline = Render(BaseElement());
|
|
|
|
TextGraphicsElement element = BaseElement();
|
|
element.BackgroundColor = "#000000";
|
|
element.BackgroundPadding = Padding;
|
|
|
|
SKBitmap padded = Render(element);
|
|
|
|
padded.Width.ShouldBe(baseline.Width + (2 * Padding));
|
|
padded.Height.ShouldBe(baseline.Height + (2 * Padding));
|
|
}
|
|
|
|
// Growing the bitmap is not the same as moving the text into it. If the text were still painted
|
|
// at (0,0) the geometry assertions above would all still hold while the glyphs sat on the box
|
|
// edge, so pin the padding band itself as pure background.
|
|
[Test]
|
|
public void Background_Padding_Actually_Insets_The_Text()
|
|
{
|
|
const int Padding = 16;
|
|
|
|
TextGraphicsElement element = BaseElement();
|
|
element.BackgroundColor = "#FF0000";
|
|
element.BackgroundPadding = Padding;
|
|
|
|
SKBitmap rendered = Render(element);
|
|
|
|
// A full-width band inside the top padding must contain nothing but the fill colour.
|
|
for (var x = 0; x < rendered.Width; x++)
|
|
{
|
|
SKColor pixel = rendered.GetPixel(x, Padding / 2);
|
|
pixel.Red.ShouldBe((byte)255, $"pixel at x={x} in the padding band is not the fill colour");
|
|
pixel.Green.ShouldBe((byte)0, $"pixel at x={x} in the padding band is not the fill colour");
|
|
pixel.Blue.ShouldBe((byte)0, $"pixel at x={x} in the padding band is not the fill colour");
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void Background_Opacity_Percent_Scales_The_Alpha()
|
|
{
|
|
TextGraphicsElement element = BaseElement();
|
|
element.BackgroundColor = "#FF0000";
|
|
element.BackgroundOpacityPercent = 50;
|
|
|
|
SKBitmap rendered = Render(element);
|
|
|
|
// 255 * 0.5, rounded. Skia stores premultiplied alpha, so assert the alpha channel only.
|
|
rendered.GetPixel(0, 0).Alpha.ShouldBe((byte)128);
|
|
}
|
|
|
|
[Test]
|
|
public void Corner_Radius_Rounds_The_Corner_Away()
|
|
{
|
|
TextGraphicsElement element = BaseElement();
|
|
element.BackgroundColor = "#FF0000";
|
|
element.BackgroundPadding = 20;
|
|
element.BackgroundCornerRadius = 20;
|
|
|
|
SKBitmap rendered = Render(element);
|
|
|
|
// The extreme corner falls outside a 20px radius, the middle of the left edge does not.
|
|
rendered.GetPixel(0, 0).Alpha.ShouldBe((byte)0);
|
|
rendered.GetPixel(0, rendered.Height / 2).Alpha.ShouldBe((byte)255);
|
|
}
|
|
|
|
[Test]
|
|
public void Border_Color_Draws_A_Border_Distinct_From_The_Fill()
|
|
{
|
|
TextGraphicsElement element = BaseElement();
|
|
element.BackgroundColor = "#FF0000";
|
|
element.BackgroundPadding = 20;
|
|
element.BorderColor = "#00FF00";
|
|
element.BorderWidth = 4;
|
|
|
|
SKBitmap rendered = Render(element);
|
|
|
|
// On the border ring...
|
|
SKColor edge = rendered.GetPixel(1, rendered.Height / 2);
|
|
edge.Green.ShouldBeGreaterThan((byte)200);
|
|
edge.Red.ShouldBeLessThan((byte)100);
|
|
|
|
// ...and inside it, still the fill.
|
|
SKColor inside = rendered.GetPixel(10, rendered.Height / 2);
|
|
inside.Red.ShouldBe((byte)255);
|
|
inside.Green.ShouldBe((byte)0);
|
|
}
|
|
|
|
[Test]
|
|
public void Border_Width_Is_Included_In_The_Bitmap_So_The_Border_Is_Not_Clipped()
|
|
{
|
|
const int BorderWidth = 6;
|
|
|
|
SKBitmap baseline = Render(BaseElement());
|
|
|
|
TextGraphicsElement element = BaseElement();
|
|
element.BorderColor = "#00FF00";
|
|
element.BorderWidth = BorderWidth;
|
|
|
|
SKBitmap bordered = Render(element);
|
|
|
|
bordered.Width.ShouldBe(baseline.Width + (2 * BorderWidth));
|
|
bordered.Height.ShouldBe(baseline.Height + (2 * BorderWidth));
|
|
}
|
|
|
|
[Test]
|
|
public void Unparseable_Background_Color_Draws_No_Box_Rather_Than_Substituting_One()
|
|
{
|
|
SKBitmap baseline = Render(BaseElement());
|
|
|
|
TextGraphicsElement element = BaseElement();
|
|
element.BackgroundColor = "not-a-color";
|
|
element.BackgroundPadding = 25;
|
|
|
|
SKBitmap rendered = Render(element);
|
|
|
|
// No box means no padding either: the geometry is the untouched baseline.
|
|
rendered.Width.ShouldBe(baseline.Width);
|
|
rendered.Height.ShouldBe(baseline.Height);
|
|
rendered.GetPixel(0, 0).Alpha.ShouldBe((byte)0);
|
|
}
|
|
|
|
[Test]
|
|
public void Border_Color_Without_An_Explicit_Width_Draws_A_Hairline()
|
|
{
|
|
SKBitmap baseline = Render(BaseElement());
|
|
|
|
TextGraphicsElement element = BaseElement();
|
|
element.BorderColor = "#00FF00";
|
|
|
|
SKBitmap rendered = Render(element);
|
|
|
|
rendered.Width.ShouldBe(baseline.Width + 2);
|
|
rendered.GetPixel(0, rendered.Height / 2).Green.ShouldBeGreaterThan((byte)200);
|
|
}
|
|
|
|
// A box with no padding or border must not move anything: this is the seam where "adding a
|
|
// background" could silently change an existing overlay's geometry.
|
|
[Test]
|
|
public void A_Fill_With_No_Padding_Or_Border_Leaves_The_Geometry_Untouched()
|
|
{
|
|
SKBitmap baseline = Render(BaseElement());
|
|
|
|
TextGraphicsElement element = BaseElement();
|
|
element.BackgroundColor = "#FF0000";
|
|
|
|
SKBitmap filled = Render(element);
|
|
|
|
filled.Width.ShouldBe(baseline.Width);
|
|
filled.Height.ShouldBe(baseline.Height);
|
|
}
|
|
|
|
// The inset is rounded up to a whole pixel and the SAME integer is used on both sides. Subtracting
|
|
// the unrounded value from the wrap budget while adding its ceiling to the bitmap overflows
|
|
// width_percent by a rounding remainder, which an integer padding cannot expose.
|
|
[Test]
|
|
public void Fractional_Padding_Still_Respects_Width_Percent()
|
|
{
|
|
const double WidthPercent = 20;
|
|
|
|
foreach (double padding in new[] { 12.3, 14.0, 29.7, 0.5 })
|
|
{
|
|
TextGraphicsElement element = BaseElement();
|
|
element.Text = "The quick brown fox jumps over the lazy dog and keeps running past the edge";
|
|
element.Fit = TextFit.Wrap;
|
|
element.WidthPercent = WidthPercent;
|
|
element.BackgroundColor = "#000000";
|
|
element.BackgroundPadding = padding;
|
|
|
|
SKBitmap rendered = Render(element);
|
|
|
|
var budget = (int)Math.Round(WidthPercent / 100.0 * FrameWidth);
|
|
rendered.Width.ShouldBeLessThanOrEqualTo(budget, $"padding {padding} overflowed the budget");
|
|
}
|
|
}
|
|
|
|
// An inset wider than the budget cannot be honoured AND stay inside it, so the INSET is clamped
|
|
// rather than the text being squeezed to nothing. Asserting a width bound here would be wrong:
|
|
// wrapping cannot break below one glyph, so a narrow width_percent overflows with or without a
|
|
// box (pre-existing). What the clamp guarantees is that the box's own contribution stops growing
|
|
// at the budget -- so a runaway padding renders identically to the largest one that fits.
|
|
[Test]
|
|
public void An_Oversized_Padding_Is_Clamped_To_The_Largest_That_Fits()
|
|
{
|
|
const double WidthPercent = 20;
|
|
var budget = (int)Math.Round(WidthPercent / 100.0 * FrameWidth);
|
|
int maxInset = (budget - 1) / 2;
|
|
|
|
static TextGraphicsElement WithPadding(double padding)
|
|
{
|
|
TextGraphicsElement element = BaseElement();
|
|
element.Text = "The quick brown fox jumps over the lazy dog";
|
|
element.Fit = TextFit.Wrap;
|
|
element.WidthPercent = WidthPercent;
|
|
element.BackgroundColor = "#000000";
|
|
element.BackgroundPadding = padding;
|
|
return element;
|
|
}
|
|
|
|
SKBitmap runaway = Render(WithPadding(400));
|
|
SKBitmap clamped = Render(WithPadding(maxInset));
|
|
|
|
runaway.Width.ShouldBe(clamped.Width);
|
|
runaway.Height.ShouldBe(clamped.Height);
|
|
|
|
// and the clamp actually bit -- an unclamped 400px padding would add 800px of box
|
|
runaway.Width.ShouldBeLessThan(budget + (2 * maxInset));
|
|
}
|
|
|
|
// FitTextBlock rebuilds every style from scratch. It used to drop the halo, which only became
|
|
// reachable once a box's insets could push a previously-fitting element into the Scale path --
|
|
// adding a background would then silently remove the halo behind the text.
|
|
[Test]
|
|
public void The_Scale_Path_Preserves_The_Halo()
|
|
{
|
|
static TextGraphicsElement Scaled(bool withHalo)
|
|
{
|
|
TextGraphicsElement element = BaseElement();
|
|
element.Text = "The quick brown fox jumps over the lazy dog";
|
|
element.Fit = TextFit.Scale;
|
|
element.WidthPercent = 15;
|
|
element.BackgroundColor = "#000000";
|
|
element.BackgroundPadding = 12;
|
|
element.Styles[0].HaloColor = withHalo ? "#00FF00" : null;
|
|
element.Styles[0].HaloWidth = withHalo ? 3 : null;
|
|
return element;
|
|
}
|
|
|
|
SKBitmap withHalo = Render(Scaled(true));
|
|
SKBitmap withoutHalo = Render(Scaled(false));
|
|
|
|
withHalo.Width.ShouldBe(withoutHalo.Width);
|
|
withHalo.Height.ShouldBe(withoutHalo.Height);
|
|
|
|
var differing = 0;
|
|
for (var x = 0; x < withHalo.Width; x++)
|
|
{
|
|
for (var y = 0; y < withHalo.Height; y++)
|
|
{
|
|
if (withHalo.GetPixel(x, y) != withoutHalo.GetPixel(x, y))
|
|
{
|
|
differing++;
|
|
}
|
|
}
|
|
}
|
|
|
|
// If the Scale path dropped the halo, the two renders would be pixel-identical.
|
|
differing.ShouldBeGreaterThan(0, "the halo made no difference through the Scale path");
|
|
}
|
|
|
|
// width_percent bounds the ELEMENT. If the insets were not subtracted from the wrap width the
|
|
// box would overflow the budget by 2*inset, which is exactly the bug this pins.
|
|
[Test]
|
|
public void Width_Percent_Bounds_The_Whole_Box_Including_Padding()
|
|
{
|
|
const double WidthPercent = 20;
|
|
const int Padding = 30;
|
|
|
|
TextGraphicsElement element = BaseElement();
|
|
element.Text = "The quick brown fox jumps over the lazy dog and keeps on running well past the edge";
|
|
element.Fit = TextFit.Wrap;
|
|
element.WidthPercent = WidthPercent;
|
|
element.BackgroundColor = "#000000";
|
|
element.BackgroundPadding = Padding;
|
|
|
|
SKBitmap rendered = Render(element);
|
|
|
|
var budget = (int)Math.Round(WidthPercent / 100.0 * FrameWidth);
|
|
rendered.Width.ShouldBeLessThanOrEqualTo(budget);
|
|
}
|
|
|
|
// YAML yields doubles, so 1e100 and NaN are reachable from a config file. Cast to float they
|
|
// become Infinity/NaN, and (int)Math.Ceiling of those is an unspecified value that sails past
|
|
// every clamp and can wrap the doubled inset back to zero.
|
|
[Test]
|
|
public void Non_Finite_And_Absurd_Box_Values_Do_Not_Corrupt_The_Geometry()
|
|
{
|
|
SKBitmap baseline = Render(BaseElement());
|
|
|
|
foreach (double bad in new[] { 1e100, double.NaN, double.PositiveInfinity, -5.0 })
|
|
{
|
|
TextGraphicsElement element = BaseElement();
|
|
element.BackgroundColor = "#FF0000";
|
|
element.BackgroundPadding = bad;
|
|
|
|
SKBitmap rendered = Render(element);
|
|
|
|
rendered.Width.ShouldBeGreaterThan(0, $"padding {bad} produced a degenerate width");
|
|
rendered.Height.ShouldBeGreaterThan(0, $"padding {bad} produced a degenerate height");
|
|
rendered.Width.ShouldBeGreaterThanOrEqualTo(baseline.Width, $"padding {bad} shrank the element");
|
|
rendered.Width.ShouldBeLessThanOrEqualTo(baseline.Width + (2 * FrameInsetCap), $"padding {bad} was not clamped");
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void A_Non_Finite_Width_Percent_Is_Treated_As_No_Budget()
|
|
{
|
|
SKBitmap baseline = Render(BaseElement());
|
|
|
|
// This pins the OUTCOME -- a non-finite budget behaves like no budget -- not the
|
|
// float.IsFinite guard, which no mutation can distinguish because .NET saturates float-to-int
|
|
// conversion. See the "Not covered by any mutation" note in docs/graphics-elements.md.
|
|
const int Padding = 10;
|
|
|
|
TextGraphicsElement element = BaseElement();
|
|
element.Fit = TextFit.Wrap;
|
|
element.WidthPercent = 1e300;
|
|
element.BackgroundColor = "#FF0000";
|
|
element.BackgroundPadding = Padding;
|
|
|
|
SKBitmap rendered = Render(element);
|
|
|
|
rendered.Width.ShouldBe(baseline.Width + (2 * Padding));
|
|
rendered.Height.ShouldBe(baseline.Height + (2 * Padding));
|
|
}
|
|
|
|
// Clamping insetPixels alone leaves DrawBackgroundBox stroking at the ORIGINAL border width,
|
|
// centred on a rect that no longer has room for it -- the stroke then floods the whole element
|
|
// and paints over the interior. The box's own fields must be clamped too.
|
|
[Test]
|
|
public void An_Oversized_Border_Is_Clamped_And_Does_Not_Flood_The_Element()
|
|
{
|
|
TextGraphicsElement element = BaseElement();
|
|
element.Text = "The quick brown fox jumps over the lazy dog";
|
|
element.Fit = TextFit.Wrap;
|
|
element.WidthPercent = 20;
|
|
element.BackgroundColor = "#0000FF";
|
|
element.BorderColor = "#FF0000";
|
|
element.BorderWidth = 400;
|
|
|
|
SKBitmap rendered = Render(element);
|
|
|
|
// Somewhere inside the element there must still be fill, not solid border.
|
|
var fillPixels = 0;
|
|
for (var x = 0; x < rendered.Width; x++)
|
|
{
|
|
for (var y = 0; y < rendered.Height; y++)
|
|
{
|
|
SKColor px = rendered.GetPixel(x, y);
|
|
if (px.Blue > 200 && px.Red < 100)
|
|
{
|
|
fillPixels++;
|
|
}
|
|
}
|
|
}
|
|
|
|
fillPixels.ShouldBeGreaterThan(0, "the border flooded the element; no fill survived");
|
|
}
|
|
|
|
// Sanitize bounds each field on its own, but padding and border ADD UP, and with no
|
|
// width_percent nothing else bounded them -- two fields could allocate a bitmap far larger than
|
|
// the frame it is drawn onto. This pins the frame cap itself; the non-finite test above cannot,
|
|
// because its bound is looser than the growth this clause prevents.
|
|
[Test]
|
|
public void The_Inset_Is_Capped_Against_The_Frame_Even_With_No_Width_Percent()
|
|
{
|
|
SKBitmap baseline = Render(BaseElement());
|
|
|
|
TextGraphicsElement element = BaseElement();
|
|
element.BackgroundColor = "#FF0000";
|
|
element.BackgroundPadding = 4000;
|
|
element.BorderColor = "#00FF00";
|
|
element.BorderWidth = 4000;
|
|
|
|
SKBitmap rendered = Render(element);
|
|
|
|
// 8000px of requested inset collapses to the frame cap, not to 16000px of bitmap growth.
|
|
rendered.Width.ShouldBe(baseline.Width + (2 * FrameInsetCap));
|
|
rendered.Height.ShouldBe(baseline.Height + (2 * FrameInsetCap));
|
|
}
|
|
|
|
// halo_* is documented as a per-style field. The merge loop used to rebuild each style from the
|
|
// base and then override only font/colour, so a non-base style's halo silently inherited the
|
|
// base one -- invisible in the seeded template, whose three styles declare identical halos.
|
|
[Test]
|
|
public void A_Non_Base_Style_Uses_Its_Own_Halo_Not_The_Base_Styles()
|
|
{
|
|
static TextGraphicsElement WithSubHalo(float haloWidth)
|
|
{
|
|
TextGraphicsElement element = BaseElement();
|
|
element.Styles.Add(new StyleDefinition
|
|
{
|
|
Name = "sub",
|
|
FontFamily = "Roboto",
|
|
FontSize = 40,
|
|
TextColor = "#FFFFFF",
|
|
HaloColor = "#00FF00",
|
|
HaloWidth = haloWidth
|
|
});
|
|
element.Text = "[sub]Hello[/sub]";
|
|
return element;
|
|
}
|
|
|
|
SKBitmap thin = Render(WithSubHalo(1));
|
|
SKBitmap thick = Render(WithSubHalo(6));
|
|
|
|
static int HaloPixels(SKBitmap b)
|
|
{
|
|
var n = 0;
|
|
for (var x = 0; x < b.Width; x++)
|
|
{
|
|
for (var y = 0; y < b.Height; y++)
|
|
{
|
|
SKColor px = b.GetPixel(x, y);
|
|
if (px.Green > 150 && px.Red < 120)
|
|
{
|
|
n++;
|
|
}
|
|
}
|
|
}
|
|
|
|
return n;
|
|
}
|
|
|
|
// If the style's own halo were ignored, both renders would use the base style's (none) and
|
|
// neither would contain halo pixels.
|
|
HaloPixels(thin).ShouldBeGreaterThan(0, "the style's own halo was not applied");
|
|
HaloPixels(thick).ShouldBeGreaterThan(HaloPixels(thin), "halo_width had no effect per style");
|
|
}
|
|
|
|
private static int PixelsMatching(SKBitmap b, Func<SKColor, bool> predicate)
|
|
{
|
|
var n = 0;
|
|
for (var x = 0; x < b.Width; x++)
|
|
{
|
|
for (var y = 0; y < b.Height; y++)
|
|
{
|
|
if (predicate(b.GetPixel(x, y)))
|
|
{
|
|
n++;
|
|
}
|
|
}
|
|
}
|
|
|
|
return n;
|
|
}
|
|
|
|
// halo_blur is a per-style field too, and the merge loop drops it just as silently as halo_color
|
|
// did. Blur spreads the halo over more pixels at lower alpha, so a blurred halo covers more area.
|
|
[Test]
|
|
public void A_Non_Base_Style_Uses_Its_Own_Halo_Blur()
|
|
{
|
|
static TextGraphicsElement WithSubBlur(float blur)
|
|
{
|
|
TextGraphicsElement element = BaseElement();
|
|
element.Styles.Add(new StyleDefinition
|
|
{
|
|
Name = "sub",
|
|
FontFamily = "Roboto",
|
|
FontSize = 40,
|
|
TextColor = "#FFFFFF",
|
|
HaloColor = "#00FF00",
|
|
HaloWidth = 3,
|
|
HaloBlur = blur
|
|
});
|
|
element.Text = "[sub]Hello[/sub]";
|
|
return element;
|
|
}
|
|
|
|
SKBitmap sharp = Render(WithSubBlur(0));
|
|
SKBitmap blurred = Render(WithSubBlur(5));
|
|
|
|
static bool Greenish(SKColor px) => px.Green > 60 && px.Red < 140 && px.Alpha > 0;
|
|
|
|
PixelsMatching(blurred, Greenish)
|
|
.ShouldNotBe(PixelsMatching(sharp, Greenish), "halo_blur had no effect per style");
|
|
}
|
|
|
|
// FitTextBlock rebuilds every style from scratch on the Scale path. line_height and halo_blur are
|
|
// carried across there; without them a scaled element silently loses line spacing and halo blur.
|
|
[Test]
|
|
public void The_Scale_Path_Preserves_Line_Height()
|
|
{
|
|
static TextGraphicsElement Scaled(float lineHeight)
|
|
{
|
|
TextGraphicsElement element = BaseElement();
|
|
element.Text = "The quick brown fox jumps over the lazy dog";
|
|
element.Fit = TextFit.Scale;
|
|
element.WidthPercent = 15;
|
|
element.Styles[0].LineHeight = lineHeight;
|
|
return element;
|
|
}
|
|
|
|
SKBitmap tight = Render(Scaled(1.0f));
|
|
SKBitmap loose = Render(Scaled(2.5f));
|
|
|
|
loose.Height.ShouldBeGreaterThan(tight.Height, "line_height was dropped by the Scale path");
|
|
}
|
|
|
|
[Test]
|
|
public void The_Scale_Path_Preserves_Halo_Blur()
|
|
{
|
|
static TextGraphicsElement Scaled(float blur)
|
|
{
|
|
TextGraphicsElement element = BaseElement();
|
|
element.Text = "The quick brown fox jumps over the lazy dog";
|
|
element.Fit = TextFit.Scale;
|
|
element.WidthPercent = 15;
|
|
element.Styles[0].HaloColor = "#00FF00";
|
|
element.Styles[0].HaloWidth = 3;
|
|
element.Styles[0].HaloBlur = blur;
|
|
return element;
|
|
}
|
|
|
|
SKBitmap sharp = Render(Scaled(0));
|
|
SKBitmap blurred = Render(Scaled(5));
|
|
|
|
static bool Greenish(SKColor px) => px.Green > 60 && px.Red < 140 && px.Alpha > 0;
|
|
|
|
PixelsMatching(blurred, Greenish)
|
|
.ShouldNotBe(PixelsMatching(sharp, Greenish), "halo_blur was dropped by the Scale path");
|
|
}
|
|
|
|
// The documented range is 0-100. The sole opacity test used 50, so Math.Clamp could be removed
|
|
// with everything green while an out-of-range value wrapped to an unrelated alpha.
|
|
[Test]
|
|
public void Background_Opacity_Percent_Is_Clamped_To_Its_Documented_Range()
|
|
{
|
|
static SKColor CornerAt(int percent)
|
|
{
|
|
TextGraphicsElement element = BaseElement();
|
|
element.BackgroundColor = "#FF0000";
|
|
element.BackgroundOpacityPercent = percent;
|
|
return Render(element).GetPixel(0, 0);
|
|
}
|
|
|
|
CornerAt(-50).Alpha.ShouldBe((byte)0, "a negative opacity did not clamp to 0");
|
|
CornerAt(400).Alpha.ShouldBe((byte)255, "an opacity over 100 did not clamp to 100");
|
|
}
|
|
}
|