feat(732): On Now / Next gets a background box, and is on by default (#843)
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
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>
This commit was merged in pull request #843.
This commit is contained in:
@@ -4,12 +4,16 @@ using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Graphics;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Streaming.Graphics;
|
||||
|
||||
public static class GraphicsElementSeeder
|
||||
{
|
||||
private const string OnNowNextYaml =
|
||||
// The pre-#732 default, kept verbatim so an installation still carrying it byte-for-byte can
|
||||
// be recognised as unmodified and upgraded. Never edit an entry here -- it is a fingerprint of
|
||||
// what we shipped, not a template. Add a new entry when the current default changes again.
|
||||
private const string OnNowNextYamlV1 =
|
||||
"""
|
||||
name: On Now / Next
|
||||
epg_entries: 2
|
||||
@@ -51,18 +55,88 @@ public static class GraphicsElementSeeder
|
||||
{{ if (array.size Epg) > 1 }}[next]NEXT {{ Epg[1].Title }}[/next]{{ end }}
|
||||
""";
|
||||
|
||||
public static async Task SeedOnNowNext(TvContext context, IFileSystem fileSystem, CancellationToken cancellationToken)
|
||||
{
|
||||
string seededKey = ConfigElementKey.GraphicsOnNowNextSeeded.Key;
|
||||
bool alreadySeeded = await context.ConfigElements.AnyAsync(c => c.Key == seededKey, cancellationToken);
|
||||
if (alreadySeeded)
|
||||
{
|
||||
return;
|
||||
}
|
||||
private const string OnNowNextYaml =
|
||||
"""
|
||||
name: On Now / Next
|
||||
epg_entries: 2
|
||||
location: BottomLeft
|
||||
horizontal_margin_percent: 4
|
||||
vertical_margin_percent: 8
|
||||
width_percent: 42
|
||||
text_fit: Wrap
|
||||
text_align: Left
|
||||
z_index: 100
|
||||
# transparent until 4s in, fade in 1s, hold 6s, fade out 1s
|
||||
opacity_expression: "LinearFadeDuration(content_seconds, 4, 1, 6)"
|
||||
# #732: a translucent box carries legibility over both bright and dark content. The halo is
|
||||
# cut from 2 to 1 rather than dropped -- the box is translucent, so bright content still
|
||||
# shows through behind the glyphs, but 2px of halo ON TOP of a box over-darkens the text.
|
||||
background_color: "#000000"
|
||||
background_opacity_percent: 65
|
||||
background_padding: 14
|
||||
background_corner_radius: 8
|
||||
# A translucent black box vanishes into dark content, so the box needs an edge of its own.
|
||||
# Low-alpha white reads as a hairline on dark frames without becoming a hard line on bright ones.
|
||||
border_color: "#59FFFFFF"
|
||||
border_width: 1
|
||||
base_style: now
|
||||
styles:
|
||||
- name: now
|
||||
font_family: "Noto Sans"
|
||||
font_size: 30
|
||||
font_weight: 700
|
||||
text_color: "#FFFFFF"
|
||||
halo_color: "#000000"
|
||||
halo_width: 1
|
||||
- name: sub
|
||||
font_family: "Noto Sans"
|
||||
font_size: 22
|
||||
font_weight: 400
|
||||
text_color: "#DDDDDD"
|
||||
halo_color: "#000000"
|
||||
halo_width: 1
|
||||
- name: next
|
||||
font_family: "Noto Sans"
|
||||
font_size: 22
|
||||
font_weight: 400
|
||||
text_color: "#DDDDDD"
|
||||
halo_color: "#000000"
|
||||
halo_width: 1
|
||||
text: |
|
||||
[now]NOW {{ Epg[0].Title }}[/now]
|
||||
{{ if Epg[0].SubTitle }}[sub]{{ Epg[0].SubTitle }}[/sub]{{ end }}
|
||||
{{ if (array.size Epg) > 1 }}[next]NEXT {{ Epg[1].Title }}[/next]{{ end }}
|
||||
""";
|
||||
|
||||
// Every default we have ever shipped, most recent first. A file matching one of these was
|
||||
// written by us and never touched, so replacing it is an upgrade rather than a clobber.
|
||||
private static readonly string[] SupersededDefaults = [OnNowNextYamlV1];
|
||||
|
||||
public static async Task SeedOnNowNext(
|
||||
TvContext context,
|
||||
IFileSystem fileSystem,
|
||||
ILogger logger,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string folder = FileSystemLayout.GraphicsElementsTextTemplatesFolder;
|
||||
string target = fileSystem.Path.Combine(folder, GraphicsElementDefaults.OnNowNextFileName);
|
||||
|
||||
string seededKey = ConfigElementKey.GraphicsOnNowNextSeeded.Key;
|
||||
bool alreadySeeded = await context.ConfigElements.AnyAsync(c => c.Key == seededKey, cancellationToken);
|
||||
|
||||
if (alreadySeeded)
|
||||
{
|
||||
// Already-seeded installations never revisit the file, so a change to the default would
|
||||
// otherwise reach new databases only. Upgrade the ones still carrying an untouched
|
||||
// earlier default; anything an operator edited no longer matches and is left alone.
|
||||
//
|
||||
// Deliberately no CreateDirectory on this branch: before #732 it touched the filesystem
|
||||
// not at all, so an already-seeded install stays bootable on a read-only /config.
|
||||
await UpgradeUnmodifiedTemplate(fileSystem, target, logger, cancellationToken);
|
||||
await EnsureBuiltInElementRow(context, fileSystem, target, cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fileSystem.Directory.Exists(folder))
|
||||
{
|
||||
fileSystem.Directory.CreateDirectory(folder);
|
||||
@@ -78,5 +152,218 @@ public static class GraphicsElementSeeder
|
||||
new ConfigElement { Key = seededKey, Value = "true" },
|
||||
cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await EnsureBuiltInElementRow(context, fileSystem, target, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// `RefreshGraphicsElements` is what normally turns a template file into a `GraphicsElement` row,
|
||||
/// but it runs on the scheduler/stream-start path -- long after startup. Creating the row here
|
||||
/// removes that ordering dependency, so `AttachOnNowNextByDefault` below can never mark itself
|
||||
/// done against an element that simply had not been discovered yet.
|
||||
/// </summary>
|
||||
private static async Task EnsureBuiltInElementRow(
|
||||
TvContext context,
|
||||
IFileSystem fileSystem,
|
||||
string target,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!fileSystem.File.Exists(target))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool exists = await context.GraphicsElements.AnyAsync(e => e.Path == target, cancellationToken);
|
||||
if (exists)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Name is display-only (identity is the filename), but leaving it null sorts the built-in
|
||||
// element into the unnamed bucket at the bottom of the SPA list until the first refresh.
|
||||
await context.GraphicsElements.AddAsync(
|
||||
new Core.Domain.GraphicsElement
|
||||
{
|
||||
Path = target,
|
||||
Kind = GraphicsElementKind.Text,
|
||||
Name = GraphicsElementDefaults.OnNowNextName
|
||||
},
|
||||
cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #732: the On Now / Next overlay is a default, not an opt-in. Existing channels predate that
|
||||
/// decision, so attach the built-in element to them once.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The marker is written only once the built-in element RESOLVES, so an install whose row does
|
||||
/// not exist yet is retried on the next startup rather than stranded permanently. Once written,
|
||||
/// no channel is ever re-attached. While still armed the backfill cannot tell a deliberately
|
||||
/// cleared channel from an untouched one -- a single global flag cannot express both
|
||||
/// properties; see <c>graphics.on-now-next-on-by-default</c> for why that trade is made this
|
||||
/// way. Every channel created after the marker gets the element from
|
||||
/// <c>ChannelGraphicsDefaults.Attach</c> instead, which BOTH create paths call.
|
||||
/// </remarks>
|
||||
public static async Task AttachOnNowNextByDefault(TvContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
string key = ConfigElementKey.GraphicsOnNowNextDefaultAttached.Key;
|
||||
if (await context.ConfigElements.AnyAsync(c => c.Key == key, cancellationToken))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Option<int> maybeElementId = await GetBuiltInElementId(context, cancellationToken);
|
||||
if (maybeElementId.IsNone)
|
||||
{
|
||||
// Nothing to attach TO. Writing the marker here would strand every channel permanently
|
||||
// on the one population this exists for, so stay armed and try again next startup.
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (int elementId in maybeElementId)
|
||||
{
|
||||
// HLS Direct has no frame pipeline to draw into, so an attachment there would be inert
|
||||
// while still reading as "on" in the editor.
|
||||
List<int> channelIds = await context.Channels
|
||||
.Where(c => c.StreamingMode != StreamingMode.HttpLiveStreamingDirect)
|
||||
.Where(c => c.ChannelGraphicsElements.All(cge => cge.GraphicsElementId != elementId))
|
||||
.Select(c => c.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (int channelId in channelIds)
|
||||
{
|
||||
await context.AddAsync(
|
||||
new ChannelGraphicsElement { ChannelId = channelId, GraphicsElementId = elementId },
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
await context.ConfigElements.AddAsync(
|
||||
new ConfigElement { Key = key, Value = "true" },
|
||||
cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Identity is the filename, never the user-editable Name (the #67 lesson carried into #74).
|
||||
/// The Kind is part of it: the five template folders are separate namespaces, so an unrelated
|
||||
/// image/motion/subtitle/script element may legitimately be named `on-now-next.yml` too, and
|
||||
/// filename alone would hand back whichever row the unordered query happened to return first.
|
||||
/// </summary>
|
||||
public static async Task<Option<int>> GetBuiltInElementId(
|
||||
TvContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<(int Id, string Path)> candidates = await context.GraphicsElements
|
||||
.Where(e => e.Kind == GraphicsElementKind.Text)
|
||||
.Select(e => new { e.Id, e.Path })
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(rows => rows.Select(r => (r.Id, r.Path)).ToList());
|
||||
|
||||
var matches = candidates
|
||||
.Where(c => System.IO.Path.GetFileName(c.Path) == GraphicsElementDefaults.OnNowNextFileName)
|
||||
.OrderBy(c => c.Id)
|
||||
.ToList();
|
||||
|
||||
// Lowest id wins if two text templates somehow share the filename, so the choice is stable
|
||||
// across restarts rather than dependent on query order.
|
||||
return matches.Count == 0 ? Option<int>.None : matches[0].Id;
|
||||
}
|
||||
|
||||
private static async Task UpgradeUnmodifiedTemplate(
|
||||
IFileSystem fileSystem,
|
||||
string target,
|
||||
ILogger logger,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// This runs inside the blocking database-startup path, ahead of DatabaseIsReady(). Before
|
||||
// #732 the already-seeded branch never touched the filesystem at all, so an unreadable or
|
||||
// read-only template is a state that used to boot fine -- it must not become a failure to
|
||||
// start. Cosmetic upgrade, best effort.
|
||||
try
|
||||
{
|
||||
if (!fileSystem.File.Exists(target))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string existing = await fileSystem.File.ReadAllTextAsync(target, cancellationToken);
|
||||
if (!SupersededDefaults.Any(d => IsSameTemplate(existing, d)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Write-then-move, never write in place. WriteAllTextAsync truncates first, so an
|
||||
// interrupted write (disk full, IO fault, cancellation) would leave a partial file that
|
||||
// matches no fingerprint and is therefore never repaired on a later boot -- the overlay
|
||||
// would just be gone, permanently, on every channel carrying it.
|
||||
//
|
||||
// The temp name is random per call. A fixed one is shared by two containers on the same
|
||||
// config volume, where one can truncate it while the other is mid-write and then rename
|
||||
// the partial file over the live template. The process id is NOT good enough here: the
|
||||
// image uses an exec-form ENTRYPOINT, so every container's PID namespace makes this
|
||||
// process 1 and every container computes the same name. A random name also means a temp
|
||||
// left by a crashed earlier boot is never reused. Only ever delete the path this call
|
||||
// created.
|
||||
string temp = $"{target}.{fileSystem.Path.GetRandomFileName()}.upgrade.tmp";
|
||||
try
|
||||
{
|
||||
await fileSystem.File.WriteAllTextAsync(temp, OnNowNextYaml, cancellationToken);
|
||||
|
||||
// Deliberately NO in-place fallback when this throws. rename(2) onto a mountpoint
|
||||
// is EBUSY, so a single-file bind mount of this template will not be upgraded --
|
||||
// accepted, because reaching that case needs a pinned file that is ALSO byte-identical
|
||||
// to a shipped default, and the alternative is reintroducing the truncation this
|
||||
// whole dance exists to prevent, on every IO fault rather than just that one.
|
||||
fileSystem.File.Move(temp, target, true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Cleanup must never REPLACE the exception that brought us here. Without this inner
|
||||
// catch, a delete that throws while unwinding a cancellation swaps the
|
||||
// OperationCanceledException for an IOException, which the outer filter then
|
||||
// swallows -- so a real shutdown would be silently downgraded to a warning.
|
||||
try
|
||||
{
|
||||
if (fileSystem.File.Exists(temp))
|
||||
{
|
||||
fileSystem.File.Delete(temp);
|
||||
}
|
||||
}
|
||||
catch (Exception cleanupEx)
|
||||
{
|
||||
logger.LogDebug(cleanupEx, "Could not remove the temporary upgrade file {Path}", temp);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Recoverable filesystem faults only. Catching everything would swallow genuinely fatal
|
||||
// runtime failures (OutOfMemory and friends) and continue booting a compromised process;
|
||||
// letting IO escape would turn a file permission into a restart loop. Cancellation
|
||||
// propagates so shutdown is not swallowed.
|
||||
// OperationCanceledException is deliberately absent from this list so a real shutdown
|
||||
// propagates -- but only a real one: an OCE raised while the token is NOT cancelled is just
|
||||
// another faulty read, and letting it escape is the restart loop this catch exists to stop.
|
||||
catch (Exception ex) when ((ex is IOException
|
||||
or UnauthorizedAccessException
|
||||
or NotSupportedException
|
||||
or System.Security.SecurityException)
|
||||
|| (ex is OperationCanceledException
|
||||
&& !cancellationToken.IsCancellationRequested))
|
||||
{
|
||||
logger.LogWarning(
|
||||
ex,
|
||||
"Could not upgrade the built-in graphics template at {Path}; leaving it as-is",
|
||||
target);
|
||||
}
|
||||
}
|
||||
|
||||
// Compare on content, ignoring the line endings and trailing whitespace an editor or a volume
|
||||
// mount may rewrite. This is a fingerprint check, not a parse: anything that is not one of our
|
||||
// own shipped defaults must fall through untouched.
|
||||
private static bool IsSameTemplate(string left, string right) =>
|
||||
string.Equals(Normalize(left), Normalize(right), StringComparison.Ordinal);
|
||||
|
||||
private static string Normalize(string value) =>
|
||||
value.Replace("\r\n", "\n", StringComparison.Ordinal).TrimEnd();
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@ public partial class 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;
|
||||
@@ -62,30 +66,96 @@ public partial class TextElement(
|
||||
}
|
||||
}
|
||||
|
||||
BackgroundBox box = BuildBackgroundBox();
|
||||
|
||||
|
||||
RichTextKit.TextBlock textBlock = BuildTextBlock(textElement.Text);
|
||||
|
||||
if (textElement.WidthPercent.HasValue)
|
||||
// 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 = maxWidth;
|
||||
textBlock.MaxWidth = textMaxWidth;
|
||||
break;
|
||||
case TextFit.Scale:
|
||||
FitTextBlock(textBlock, maxWidth);
|
||||
FitTextBlock(textBlock, textMaxWidth);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_image = new SKBitmap(
|
||||
(int)Math.Ceiling(textBlock.MeasuredWidth),
|
||||
(int)Math.Ceiling(textBlock.MeasuredHeight));
|
||||
(int)Math.Ceiling(textBlock.MeasuredWidth) + (2 * insetPixels),
|
||||
(int)Math.Ceiling(textBlock.MeasuredHeight) + (2 * insetPixels));
|
||||
using (var canvas = new SKCanvas(_image))
|
||||
{
|
||||
canvas.Clear(SKColors.Transparent);
|
||||
textBlock.Paint(canvas, new SKPoint(0, 0));
|
||||
|
||||
if (box is not null)
|
||||
{
|
||||
DrawBackgroundBox(canvas, box, _image.Width, _image.Height);
|
||||
}
|
||||
|
||||
textBlock.Paint(canvas, new SKPoint(insetPixels, insetPixels));
|
||||
}
|
||||
|
||||
var horizontalMargin =
|
||||
@@ -134,6 +204,158 @@ public partial class TextElement(
|
||||
: 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
|
||||
@@ -211,6 +433,17 @@ public partial class TextElement(
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -286,6 +519,11 @@ public partial class TextElement(
|
||||
|
||||
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,
|
||||
@@ -294,7 +532,11 @@ public partial class TextElement(
|
||||
FontWidth = style.FontWidth,
|
||||
FontWeight = style.FontWeight,
|
||||
LetterSpacing = style.LetterSpacing,
|
||||
TextColor = style.TextColor
|
||||
LineHeight = style.LineHeight,
|
||||
TextColor = style.TextColor,
|
||||
HaloColor = style.HaloColor,
|
||||
HaloWidth = style.HaloWidth,
|
||||
HaloBlur = style.HaloBlur
|
||||
};
|
||||
|
||||
float newSize = newStyle.FontSize * scale;
|
||||
|
||||
Reference in New Issue
Block a user