| | 1 | | using Allyaria.Theming.Contracts; |
| | 2 | | using Allyaria.Theming.Helpers; |
| | 3 | | using System.Diagnostics.CodeAnalysis; |
| | 4 | | using System.Text.RegularExpressions; |
| | 5 | |
|
| | 6 | | namespace Allyaria.Theming.Values; |
| | 7 | |
|
| | 8 | | /// <summary> |
| | 9 | | /// Represents a strongly-typed CSS image value used by Allyaria theming. The underlying string is normalized to a |
| | 10 | | /// canonical CSS <c>url("…")</c> token. If the input contains a pre-formed <c>url(...)</c> (even within a longer |
| | 11 | | /// declaration such as <c>linear-gradient(...), url(foo) no-repeat</c>), only the first <c>url(...)</c> is extracted an |
| | 12 | | /// used; all other content is discarded. Otherwise, the raw input is validated, unwrapped (if quoted), escaped, and |
| | 13 | | /// wrapped as <c>url("…")</c>. |
| | 14 | | /// </summary> |
| | 15 | | public sealed class AllyariaImageValue : ValueBase |
| | 16 | | { |
| | 17 | | /// <summary>Initializes a new instance of the <see cref="AllyariaImageValue" /> class.</summary> |
| | 18 | | /// <param name="value"> |
| | 19 | | /// The raw image reference to normalize (absolute/relative URL, data/blob URI, or any string containing a <c>url(.. |
| | 20 | | /// ). |
| | 21 | | /// </param> |
| | 22 | | /// <exception cref="ArgumentException"> |
| | 23 | | /// Thrown when <paramref name="value" /> is <c>null</c>, empty, whitespace, contains disallowed control characters, |
| | 24 | | /// resolves to an unsupported URI scheme (e.g., <c>javascript:</c>, <c>vbscript:</c>). |
| | 25 | | /// </exception> |
| | 26 | | public AllyariaImageValue(string value) |
| 110 | 27 | | : base(Normalize(value)) { } |
| | 28 | |
|
| | 29 | | /// <summary> |
| | 30 | | /// If <paramref name="value" /> parses as an absolute <see cref="Uri" />, ensures its scheme is allowed. Allowed sc |
| | 31 | | /// are <c>http</c>, <c>https</c>, <c>data</c>, and <c>blob</c>. Relative values are permitted. |
| | 32 | | /// </summary> |
| | 33 | | /// <param name="value">The value to check.</param> |
| | 34 | | private static void EnsureAllowedAbsoluteSchemeIfPresent(string value) |
| | 35 | | { |
| 52 | 36 | | if (Uri.TryCreate(value, UriKind.Absolute, out var uri)) |
| | 37 | | { |
| 12 | 38 | | var allowed = uri.Scheme.Equals("http", StringComparison.OrdinalIgnoreCase) || |
| 12 | 39 | | uri.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase) || |
| 12 | 40 | | uri.Scheme.Equals("data", StringComparison.OrdinalIgnoreCase) || |
| 12 | 41 | | uri.Scheme.Equals("blob", StringComparison.OrdinalIgnoreCase); |
| | 42 | |
|
| 12 | 43 | | if (!allowed) |
| | 44 | | { |
| 4 | 45 | | throw new ArgumentException($"Unsupported URI scheme '{uri.Scheme}'.", nameof(value)); |
| | 46 | | } |
| | 47 | | } |
| 48 | 48 | | } |
| | 49 | |
|
| | 50 | | /// <summary> |
| | 51 | | /// Throws <see cref="ArgumentException" /> when <paramref name="value" /> starts with a dangerous scheme such as |
| | 52 | | /// <c>javascript:</c> or <c>vbscript:</c> (case-insensitive). |
| | 53 | | /// </summary> |
| | 54 | | /// <param name="value">The value to inspect.</param> |
| | 55 | | private static void EnsureNoDangerousSchemes(string value) |
| | 56 | | { |
| 58 | 57 | | if (value.StartsWith("javascript:", StringComparison.OrdinalIgnoreCase) || |
| 58 | 58 | | value.StartsWith("vbscript:", StringComparison.OrdinalIgnoreCase)) |
| | 59 | | { |
| 6 | 60 | | throw new ArgumentException("Unsupported URI scheme for CSS image value.", nameof(value)); |
| | 61 | | } |
| 52 | 62 | | } |
| | 63 | |
|
| | 64 | | /// <summary> |
| | 65 | | /// Escapes a string for safe inclusion inside a CSS <c>url("…")</c> token. Currently escapes backslashes and double |
| | 66 | | /// quotes. Parentheses and spaces are safe inside quotes and preserved as-is. |
| | 67 | | /// </summary> |
| | 68 | | /// <param name="value">The unquoted, validated value to escape.</param> |
| | 69 | | /// <returns>The escaped string.</returns> |
| 48 | 70 | | private static string EscapeForCssUrl(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); |
| | 71 | |
|
| | 72 | | /// <summary> |
| | 73 | | /// Attempts to extract the inner content of the first CSS <c>url(...)</c> token found in <paramref name="input" />. |
| | 74 | | /// returned string excludes the surrounding <c>url(</c> and <c>)</c> and preserves any quotes inside the token. |
| | 75 | | /// </summary> |
| | 76 | | /// <param name="input">A CSS string that may contain one or more <c>url(...)</c> tokens.</param> |
| | 77 | | /// <returns>The inner value of the first <c>url(...)</c> if found; otherwise <c>null</c>.</returns> |
| | 78 | | private static string? ExtractFirstUrlInnerValueOrDefault(string input) |
| | 79 | | { |
| 58 | 80 | | var match = Regex.Match( |
| 58 | 81 | | input, |
| 58 | 82 | | @"url\(\s*(?<inner>.*?)\s*\)", |
| 58 | 83 | | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Singleline |
| 58 | 84 | | ); |
| | 85 | |
|
| 58 | 86 | | return match.Success |
| 58 | 87 | | ? match.Groups["inner"].Value |
| 58 | 88 | | : null; |
| | 89 | | } |
| | 90 | |
|
| | 91 | | /// <summary>Normalizes an input string for use as a canonical CSS image <c>url("…")</c> token.</summary> |
| | 92 | | /// <param name="value"> |
| | 93 | | /// Raw image reference. May be a bare path/URI or a larger CSS value containing one or more <c>url(...)</c> tokens. |
| | 94 | | /// </param> |
| | 95 | | /// <returns>A trimmed, validated, and escaped string in the form <c>url("…")</c>.</returns> |
| | 96 | | /// <exception cref="ArgumentException"> |
| | 97 | | /// Thrown when <paramref name="value" /> is <c>null</c>, empty, whitespace, contains disallowed control characters, |
| | 98 | | /// resolves to an unsupported URI scheme (e.g., <c>javascript:</c>, <c>vbscript:</c>). |
| | 99 | | /// </exception> |
| | 100 | | private static string Normalize(string value) |
| | 101 | | { |
| 62 | 102 | | var trimmed = ValidateInput(value); |
| 58 | 103 | | var extractedOrOriginal = ExtractFirstUrlInnerValueOrDefault(trimmed) ?? trimmed; |
| 58 | 104 | | var unquoted = UnwrapOptionalQuotes(extractedOrOriginal); |
| | 105 | |
|
| 58 | 106 | | EnsureNoDangerousSchemes(unquoted); |
| 52 | 107 | | EnsureAllowedAbsoluteSchemeIfPresent(unquoted); |
| | 108 | |
|
| 48 | 109 | | var escaped = EscapeForCssUrl(unquoted); |
| | 110 | |
|
| 48 | 111 | | return $"url(\"{escaped}\")"; |
| | 112 | | } |
| | 113 | |
|
| | 114 | | /// <summary>Parses the specified string into an <see cref="AllyariaImageValue" />.</summary> |
| | 115 | | /// <param name="value">The input string to parse.</param> |
| | 116 | | /// <returns>A new <see cref="AllyariaImageValue" /> containing the normalized <paramref name="value" />.</returns> |
| | 117 | | /// <exception cref="ArgumentException"> |
| | 118 | | /// Thrown when <paramref name="value" /> is invalid; see |
| | 119 | | /// <see cref="Normalize(string)" />. |
| | 120 | | /// </exception> |
| 2 | 121 | | public static AllyariaImageValue Parse(string value) => new(value); |
| | 122 | |
|
| | 123 | | /// <summary>Builds CSS declarations for a background image with a contrast-enhancing overlay.</summary> |
| | 124 | | /// <param name="backgroundColor"> |
| | 125 | | /// The known page or container background color beneath the image. Used to determine whether the overlay should be |
| | 126 | | /// light for better contrast. |
| | 127 | | /// </param> |
| | 128 | | /// <param name="stretch"> |
| | 129 | | /// If <c>true</c>, returns a set of individual CSS declarations—<c>background-image</c>, <c>background-position</c> |
| | 130 | | /// <c>background-repeat</c>, and <c>background-size</c>—so that the image covers the area and is centered with no r |
| | 131 | | /// If <c>false</c>, returns only a single <c>background-image</c> declaration (no extra sizing or positioning). |
| | 132 | | /// </param> |
| | 133 | | /// <returns> |
| | 134 | | /// A CSS string: either just a <c>background-image</c> property (when <paramref name="stretch" /> is <c>false</c>), |
| | 135 | | /// multiple declarations joined together to achieve a centered, cover-filling background (when <paramref name="stre |
| | 136 | | /// is <c>true</c>). |
| | 137 | | /// </returns> |
| | 138 | | /// <remarks> |
| | 139 | | /// The overlay increases contrast: – For light backgrounds (relative luminance ≥ 0.5), it uses <c>rgba(0,0,0,0.5)</ |
| | 140 | | /// For dark backgrounds, it uses <c>rgba(255,255,255,0.5)</c>. This helps keep the background image legible regardl |
| | 141 | | /// the page’s base color. |
| | 142 | | /// </remarks> |
| | 143 | | public string ToCssBackground(AllyariaColorValue backgroundColor, bool stretch = true) |
| | 144 | | { |
| 12 | 145 | | var lum = ColorHelper.RelativeLuminance(backgroundColor); |
| | 146 | |
|
| 12 | 147 | | var overlay = lum >= 0.5 |
| 12 | 148 | | ? "rgba(0, 0, 0, 0.5)" |
| 12 | 149 | | : "rgba(255, 255, 255, 0.5)"; |
| | 150 | |
|
| 12 | 151 | | var image = $"background-image:linear-gradient({overlay},{overlay}),{Value};"; |
| 12 | 152 | | var position = "background-position:center;"; |
| 12 | 153 | | var repeat = "background-repeat:no-repeat;"; |
| 12 | 154 | | var size = "background-size:cover"; |
| | 155 | |
|
| 12 | 156 | | return stretch |
| 12 | 157 | | ? $"{image}{position}{repeat}{size}" |
| 12 | 158 | | : image; |
| | 159 | | } |
| | 160 | |
|
| | 161 | | /// <summary>Builds CSS variables for a background image with a contrast-enhancing overlay.</summary> |
| | 162 | | /// <param name="prefix">An string used to namespace the CSS variables.</param> |
| | 163 | | /// <param name="backgroundColor"> |
| | 164 | | /// The known page or container background color beneath the image. Used to determine whether the overlay should be |
| | 165 | | /// light for better contrast. |
| | 166 | | /// </param> |
| | 167 | | /// <param name="stretch"> |
| | 168 | | /// If <c>true</c>, returns a set of individual CSS variables—<c>background-image</c>, <c>background-position</c>, |
| | 169 | | /// <c>background-repeat</c>, and <c>background-size</c>—so that the image covers the area and is centered with no r |
| | 170 | | /// If <c>false</c>, returns only a single <c>background-image</c> declaration (no extra sizing or positioning). |
| | 171 | | /// </param> |
| | 172 | | /// <returns> |
| | 173 | | /// A CSS string: either just a <c>background-image</c> variable (when <paramref name="stretch" /> is <c>false</c>), |
| | 174 | | /// multiple variables joined together to achieve a centered, cover-filling background (when <paramref name="stretch |
| | 175 | | /// <c>true</c>). |
| | 176 | | /// </returns> |
| | 177 | | /// <remarks> |
| | 178 | | /// The overlay increases contrast: – For light backgrounds (relative luminance ≥ 0.5), it uses <c>rgba(0,0,0,0.5)</ |
| | 179 | | /// For dark backgrounds, it uses <c>rgba(255,255,255,0.5)</c>. This helps keep the background image legible regardl |
| | 180 | | /// the page’s base color. |
| | 181 | | /// </remarks> |
| | 182 | | public string ToCssVarsBackground(string prefix, AllyariaColorValue backgroundColor, bool stretch = true) |
| | 183 | | { |
| 8 | 184 | | var lum = ColorHelper.RelativeLuminance(backgroundColor); |
| | 185 | |
|
| 8 | 186 | | var overlay = lum >= 0.5 |
| 8 | 187 | | ? "rgba(0, 0, 0, 0.5)" |
| 8 | 188 | | : "rgba(255, 255, 255, 0.5)"; |
| | 189 | |
|
| 8 | 190 | | var image = $"{prefix}background-image:linear-gradient({overlay},{overlay}),{Value};"; |
| 8 | 191 | | var position = $"{prefix}background-position:center;"; |
| 8 | 192 | | var repeat = $"{prefix}background-repeat:no-repeat;"; |
| 8 | 193 | | var size = $"{prefix}background-size:cover"; |
| | 194 | |
|
| 8 | 195 | | return stretch |
| 8 | 196 | | ? $"{image}{position}{repeat}{size}" |
| 8 | 197 | | : image; |
| | 198 | | } |
| | 199 | |
|
| | 200 | | /// <summary>Attempts to parse the specified string into an <see cref="AllyariaImageValue" />.</summary> |
| | 201 | | /// <param name="value">The input string to parse.</param> |
| | 202 | | /// <param name="result"> |
| | 203 | | /// When this method returns, contains the parsed <see cref="AllyariaImageValue" /> if parsing succeeded; otherwise |
| | 204 | | /// <c>null</c>. |
| | 205 | | /// </param> |
| | 206 | | /// <returns><c>true</c> if parsing succeeded; otherwise, <c>false</c>.</returns> |
| | 207 | | public static bool TryParse(string value, [NotNullWhen(true)] out AllyariaImageValue? result) |
| | 208 | | { |
| | 209 | | try |
| | 210 | | { |
| 8 | 211 | | result = new AllyariaImageValue(value); |
| | 212 | |
|
| 2 | 213 | | return true; |
| | 214 | | } |
| 6 | 215 | | catch |
| | 216 | | { |
| 6 | 217 | | result = null; |
| | 218 | |
|
| 6 | 219 | | return false; |
| | 220 | | } |
| 8 | 221 | | } |
| | 222 | |
|
| | 223 | | /// <summary> |
| | 224 | | /// Removes a single pair of matching surrounding quotes (single or double) from <paramref name="s" />, if present. |
| | 225 | | /// the original string if not quoted or if quotes do not match. |
| | 226 | | /// </summary> |
| | 227 | | /// <param name="s">The input string to unwrap.</param> |
| | 228 | | /// <returns>The unwrapped string, or <paramref name="s" /> if no wrapping quotes are present.</returns> |
| | 229 | | private static string UnwrapOptionalQuotes(string s) |
| | 230 | | { |
| 58 | 231 | | if (s.Length >= 2) |
| | 232 | | { |
| 58 | 233 | | var first = s[0]; |
| 58 | 234 | | var last = s[^1]; |
| | 235 | |
|
| 58 | 236 | | if ((first is '"' && last is '"') || (first is '\'' && last is '\'')) |
| | 237 | | { |
| 4 | 238 | | return s.Substring(1, s.Length - 2); |
| | 239 | | } |
| | 240 | | } |
| | 241 | |
|
| 54 | 242 | | return s; |
| | 243 | | } |
| | 244 | |
|
| | 245 | | /// <summary>Defines an implicit conversion from <see cref="string" /> to <see cref="AllyariaImageValue" />.</summar |
| | 246 | | /// <param name="value">The string value to convert.</param> |
| | 247 | | /// <returns>A new <see cref="AllyariaImageValue" /> containing the normalized <paramref name="value" />.</returns> |
| | 248 | | /// <exception cref="ArgumentException"> |
| | 249 | | /// Thrown when <paramref name="value" /> is invalid; see |
| | 250 | | /// <see cref="Normalize(string)" />. |
| | 251 | | /// </exception> |
| 14 | 252 | | public static implicit operator AllyariaImageValue(string value) => new(value); |
| | 253 | |
|
| | 254 | | /// <summary>Defines an implicit conversion from <see cref="AllyariaImageValue" /> to <see cref="string" />.</summar |
| | 255 | | /// <param name="value">The <see cref="AllyariaImageValue" /> instance.</param> |
| | 256 | | /// <returns>The underlying normalized string value (a canonical CSS <c>url("…")</c> token).</returns> |
| | 257 | | /// <exception cref="ArgumentNullException">Thrown when <paramref name="value" /> is <c>null</c>.</exception> |
| 6 | 258 | | public static implicit operator string(AllyariaImageValue value) => value.Value; |
| | 259 | | } |