diff --git a/Semantics.Color/Color.Conversions.cs b/Semantics.Color/Color.Conversions.cs
index c13a1e6..2bce541 100644
--- a/Semantics.Color/Color.Conversions.cs
+++ b/Semantics.Color/Color.Conversions.cs
@@ -45,7 +45,7 @@ public Vector3 ToSrgbVector3()
/// The hex color string.
/// The linear-RGB color.
/// Thrown when is null.
- /// Thrown when is not a recognised hex length.
+ /// Thrown when is not a recognised hex length, or contains a character that is not a hex digit.
public static Color FromHex(string hex)
{
Ensure.NotNull(hex);
@@ -62,6 +62,16 @@ public static Color FromHex(string hex)
throw new ArgumentException("Hex color must be #RGB, #RRGGBB, or #RRGGBBAA.", nameof(hex));
}
+ // Convert.ToByte(s, 16) accepts a leading '+' and throws FormatException for other bad
+ // characters, so every character is checked here and only hex digits reach it.
+ foreach (char c in h)
+ {
+ if (!IsHexDigit(c))
+ {
+ throw new ArgumentException($"Hex color contains '{c}', which is not a hex digit.", nameof(hex));
+ }
+ }
+
byte r = ParseByte(h, 0);
byte g = ParseByte(h, 2);
byte b = ParseByte(h, 4);
@@ -99,6 +109,9 @@ public static Color FromBytes(byte r, byte g, byte b, byte a = 255) =>
private static byte ParseByte(string hex, int index) =>
Convert.ToByte(hex.Substring(index, 2), 16);
+ private static bool IsHexDigit(char c) =>
+ c is (>= '0' and <= '9') or (>= 'A' and <= 'F') or (>= 'a' and <= 'f');
+
/// Converts this linear color to .
/// The Oklab equivalent.
public Oklab ToOklab() => Oklab.FromColor(this);
diff --git a/Semantics.Test/Colors/HexConversionTests.cs b/Semantics.Test/Colors/HexConversionTests.cs
index e92d49c..e13682e 100644
--- a/Semantics.Test/Colors/HexConversionTests.cs
+++ b/Semantics.Test/Colors/HexConversionTests.cs
@@ -58,4 +58,27 @@ public void ToHex_EmitsAlphaWhenNotOpaque()
[TestMethod]
public void FromHex_InvalidLength_Throws() =>
Assert.ThrowsExactly(() => Color.FromHex("#12345"));
+
+ [TestMethod]
+ [DataRow("#+F+F+F")]
+ [DataRow("+F+F+F")]
+ [DataRow("#00+F00")]
+ [DataRow("#GGGGGG")]
+ [DataRow("#0x0x0x")]
+ [DataRow("#-1-1-1")]
+ [DataRow("#12345G")]
+ [DataRow("#FFFFFF+F")]
+ [DataRow("#F F")]
+ [DataRow("#G00")]
+ public void FromHex_NonHexCharacter_ThrowsArgumentException(string hex) =>
+ Assert.ThrowsExactly(() => Color.FromHex(hex));
+
+ [TestMethod]
+ [DataRow("#abc", "#AABBCC")]
+ [DataRow("#ABC", "#AABBCC")]
+ [DataRow("#3a7bd5", "#3A7BD5")]
+ [DataRow("3A7BD5", "#3A7BD5")]
+ [DataRow("#3a7bd580", "#3A7BD580")]
+ public void FromHex_ValidDigitsInEitherCase_RoundTrip(string hex, string expected) =>
+ Assert.AreEqual(expected, Color.FromHex(hex).ToHex());
}