Skip to content

Commit 8304e64

Browse files
MovGP0Johann DirryKeboo
authored
Porting material-color-utilities to dotnet #2475 (#3920)
* Porting material-color-utilities to dotnet #2475 Backporting to .NET 462 * Implementing unit tests for MaterialColorUtilities * Making enum values PascalCase and using explicit types where not obvious * Modernizes test code syntax Uses collection expressions and generic `Enum.GetValues`, suppresses obsolete member warnings, and removes an unnecessary assertion. --------- Co-authored-by: Johann Dirry <[email protected]> Co-authored-by: Kevin Bost <[email protected]>
1 parent e8b61ec commit 8304e64

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

68 files changed

+9278
-0
lines changed

Directory.packages.props

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,5 +29,8 @@
2929
<PackageVersion Include="TUnit" Version="0.25.21" />
3030
<PackageVersion Include="VirtualizingWrapPanel" Version="1.5.8" />
3131
<PackageVersion Include="XAMLTest" Version="1.3.0-ci616" />
32+
<PackageVersion Include="System.Memory" Version="4.6.3" />
33+
<PackageVersion Include="System.ValueTuple" Version="4.6.1" />
34+
<PackageVersion Include="Polyfill" Version="8.8.1" />
3235
</ItemGroup>
3336
</Project>

MaterialDesignToolkit.Full.sln

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MaterialDesign3Demo", "src\
6363
EndProject
6464
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MaterialDesignDemo.Shared", "src\MaterialDesignDemo.Shared\MaterialDesignDemo.Shared.csproj", "{B39795A7-D66A-4F2F-9F41-050838D14048}"
6565
EndProject
66+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MaterialColorUtilities", "src\MaterialDesign3.MaterialColorUtilities\MaterialColorUtilities.csproj", "{2C29B80E-1689-43CE-85AC-71799666B4AC}"
67+
EndProject
68+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MaterialColorUtilities.Tests", "tests\MaterialColorUtilities.Tests\MaterialColorUtilities.Tests.csproj", "{91485BEA-759F-406E-87B7-68D94CF66DE4}"
69+
EndProject
6670
Global
6771
GlobalSection(SolutionConfigurationPlatforms) = preSolution
6872
Debug|Any CPU = Debug|Any CPU
@@ -251,6 +255,38 @@ Global
251255
{B39795A7-D66A-4F2F-9F41-050838D14048}.Release|x64.Build.0 = Release|Any CPU
252256
{B39795A7-D66A-4F2F-9F41-050838D14048}.Release|x86.ActiveCfg = Release|Any CPU
253257
{B39795A7-D66A-4F2F-9F41-050838D14048}.Release|x86.Build.0 = Release|Any CPU
258+
{2C29B80E-1689-43CE-85AC-71799666B4AC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
259+
{2C29B80E-1689-43CE-85AC-71799666B4AC}.Debug|Any CPU.Build.0 = Debug|Any CPU
260+
{2C29B80E-1689-43CE-85AC-71799666B4AC}.Debug|ARM.ActiveCfg = Debug|Any CPU
261+
{2C29B80E-1689-43CE-85AC-71799666B4AC}.Debug|ARM.Build.0 = Debug|Any CPU
262+
{2C29B80E-1689-43CE-85AC-71799666B4AC}.Debug|x64.ActiveCfg = Debug|Any CPU
263+
{2C29B80E-1689-43CE-85AC-71799666B4AC}.Debug|x64.Build.0 = Debug|Any CPU
264+
{2C29B80E-1689-43CE-85AC-71799666B4AC}.Debug|x86.ActiveCfg = Debug|Any CPU
265+
{2C29B80E-1689-43CE-85AC-71799666B4AC}.Debug|x86.Build.0 = Debug|Any CPU
266+
{2C29B80E-1689-43CE-85AC-71799666B4AC}.Release|Any CPU.ActiveCfg = Release|Any CPU
267+
{2C29B80E-1689-43CE-85AC-71799666B4AC}.Release|Any CPU.Build.0 = Release|Any CPU
268+
{2C29B80E-1689-43CE-85AC-71799666B4AC}.Release|ARM.ActiveCfg = Release|Any CPU
269+
{2C29B80E-1689-43CE-85AC-71799666B4AC}.Release|ARM.Build.0 = Release|Any CPU
270+
{2C29B80E-1689-43CE-85AC-71799666B4AC}.Release|x64.ActiveCfg = Release|Any CPU
271+
{2C29B80E-1689-43CE-85AC-71799666B4AC}.Release|x64.Build.0 = Release|Any CPU
272+
{2C29B80E-1689-43CE-85AC-71799666B4AC}.Release|x86.ActiveCfg = Release|Any CPU
273+
{2C29B80E-1689-43CE-85AC-71799666B4AC}.Release|x86.Build.0 = Release|Any CPU
274+
{91485BEA-759F-406E-87B7-68D94CF66DE4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
275+
{91485BEA-759F-406E-87B7-68D94CF66DE4}.Debug|Any CPU.Build.0 = Debug|Any CPU
276+
{91485BEA-759F-406E-87B7-68D94CF66DE4}.Debug|ARM.ActiveCfg = Debug|Any CPU
277+
{91485BEA-759F-406E-87B7-68D94CF66DE4}.Debug|ARM.Build.0 = Debug|Any CPU
278+
{91485BEA-759F-406E-87B7-68D94CF66DE4}.Debug|x64.ActiveCfg = Debug|Any CPU
279+
{91485BEA-759F-406E-87B7-68D94CF66DE4}.Debug|x64.Build.0 = Debug|Any CPU
280+
{91485BEA-759F-406E-87B7-68D94CF66DE4}.Debug|x86.ActiveCfg = Debug|Any CPU
281+
{91485BEA-759F-406E-87B7-68D94CF66DE4}.Debug|x86.Build.0 = Debug|Any CPU
282+
{91485BEA-759F-406E-87B7-68D94CF66DE4}.Release|Any CPU.ActiveCfg = Release|Any CPU
283+
{91485BEA-759F-406E-87B7-68D94CF66DE4}.Release|Any CPU.Build.0 = Release|Any CPU
284+
{91485BEA-759F-406E-87B7-68D94CF66DE4}.Release|ARM.ActiveCfg = Release|Any CPU
285+
{91485BEA-759F-406E-87B7-68D94CF66DE4}.Release|ARM.Build.0 = Release|Any CPU
286+
{91485BEA-759F-406E-87B7-68D94CF66DE4}.Release|x64.ActiveCfg = Release|Any CPU
287+
{91485BEA-759F-406E-87B7-68D94CF66DE4}.Release|x64.Build.0 = Release|Any CPU
288+
{91485BEA-759F-406E-87B7-68D94CF66DE4}.Release|x86.ActiveCfg = Release|Any CPU
289+
{91485BEA-759F-406E-87B7-68D94CF66DE4}.Release|x86.Build.0 = Release|Any CPU
254290
EndGlobalSection
255291
GlobalSection(SolutionProperties) = preSolution
256292
HideSolutionNode = FALSE
@@ -262,6 +298,8 @@ Global
262298
{18401177-A30E-4330-8F6B-101DF876CE99} = {55087897-5F09-45CA-8E12-12B36B45F262}
263299
{98627CBE-F009-482E-97E9-C69C7135E91F} = {D34BE232-DE51-43C1-ABDC-B69003BB50FF}
264300
{B39795A7-D66A-4F2F-9F41-050838D14048} = {D34BE232-DE51-43C1-ABDC-B69003BB50FF}
301+
{2C29B80E-1689-43CE-85AC-71799666B4AC} = {9E303A4A-3712-44B9-91EE-830FDC087795}
302+
{91485BEA-759F-406E-87B7-68D94CF66DE4} = {9E303A4A-3712-44B9-91EE-830FDC087795}
265303
EndGlobalSection
266304
GlobalSection(ExtensibilityGlobals) = postSolution
267305
SolutionGuid = {730B2F9E-74AE-46CE-9E61-89AA5C6D5DD3}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
using static System.Math;
2+
using static MaterialColorUtilities.MathUtils;
3+
4+
namespace MaterialColorUtilities;
5+
6+
/// <summary>
7+
/// Functions for blending in HCT and CAM16.
8+
/// </summary>
9+
public static class Blend
10+
{
11+
/// <summary>
12+
/// Blend the design color's HCT hue towards the key color's HCT hue, in a way that
13+
/// leaves the original color recognizable and recognizably shifted towards the key color.
14+
/// </summary>
15+
/// <param name="designColor">ARGB representation of an arbitrary color.</param>
16+
/// <param name="sourceColor">ARGB representation of the main theme color.</param>
17+
/// <returns>
18+
/// The design color with a hue shifted towards the system's color, a slightly
19+
/// warmer/cooler variant of the design color's hue.
20+
/// </returns>
21+
public static int Harmonize(int designColor, int sourceColor)
22+
{
23+
var fromHct = Hct.FromInt(designColor);
24+
var toHct = Hct.FromInt(sourceColor);
25+
double differenceDegrees = DifferenceDegrees(fromHct.Hue, toHct.Hue);
26+
double rotationDegrees = Min(differenceDegrees * 0.5, 15.0);
27+
double outputHue = SanitizeDegreesDouble(
28+
fromHct.Hue + rotationDegrees * RotationDirection(fromHct.Hue, toHct.Hue));
29+
return Hct.From(outputHue, fromHct.Chroma, fromHct.Tone).Argb;
30+
}
31+
32+
/// <summary>
33+
/// Blends hue from one color into another. The chroma and tone of the original color are maintained.
34+
/// </summary>
35+
/// <param name="from">ARGB representation of color.</param>
36+
/// <param name="to">ARGB representation of color.</param>
37+
/// <param name="amount">How much blending to perform; 0.0 &gt;= and &lt;= 1.0.</param>
38+
/// <returns>
39+
/// <paramref name="from"/> with a hue blended towards <paramref name="to"/>.
40+
/// Chroma and tone are constant.
41+
/// </returns>
42+
public static int HctHue(int from, int to, double amount)
43+
{
44+
int ucs = Cam16Ucs(from, to, amount);
45+
var ucsCam = Cam16.FromInt(ucs);
46+
var fromCam = Cam16.FromInt(from);
47+
var blended = Hct.From(ucsCam.GetHue(), fromCam.GetChroma(), ColorUtils.LstarFromArgb(from));
48+
return blended.Argb;
49+
}
50+
51+
/// <summary>
52+
/// Blend in CAM16-UCS space.
53+
/// </summary>
54+
/// <param name="from">ARGB representation of color.</param>
55+
/// <param name="to">ARGB representation of color.</param>
56+
/// <param name="amount">How much blending to perform; 0.0 &gt;= and &lt;= 1.0.</param>
57+
/// <returns>
58+
/// <paramref name="from"/>, blended towards <paramref name="to"/>. Hue, chroma, and tone will change.
59+
/// </returns>
60+
public static int Cam16Ucs(int from, int to, double amount)
61+
{
62+
var fromCam = Cam16.FromInt(from);
63+
var toCam = Cam16.FromInt(to);
64+
double fromJ = fromCam.GetJstar();
65+
double fromA = fromCam.GetAstar();
66+
double fromB = fromCam.GetBstar();
67+
double toJ = toCam.GetJstar();
68+
double toA = toCam.GetAstar();
69+
double toB = toCam.GetBstar();
70+
double jstar = fromJ + (toJ - fromJ) * amount;
71+
double astar = fromA + (toA - fromA) * amount;
72+
double bstar = fromB + (toB - fromB) * amount;
73+
return Cam16.FromUcs(jstar, astar, bstar).ToInt();
74+
}
75+
}
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
using static System.Math;
2+
3+
namespace MaterialColorUtilities;
4+
5+
/// <summary>
6+
/// Color science for contrast utilities.
7+
///
8+
/// Utility methods for calculating contrast given two colors, or calculating a color given one
9+
/// color and a contrast ratio.
10+
///
11+
/// Contrast ratio is calculated using XYZ's Y. When linearized to match human perception, Y
12+
/// becomes HCT's tone and L*a*b*'s' L*.
13+
/// </summary>
14+
public static class Contrast
15+
{
16+
// The minimum contrast ratio of two colors.
17+
// Contrast ratio equation = lighter + 5 / darker + 5, if lighter == darker, ratio == 1.
18+
public const double RatioMin = 1.0;
19+
20+
// The maximum contrast ratio of two colors.
21+
// Contrast ratio equation = lighter + 5 / darker + 5. Lighter and darker scale from 0 to 100.
22+
// If lighter == 100, darker = 0, ratio == 21.
23+
public const double RatioMax = 21.0;
24+
25+
public const double Ratio30 = 3.0;
26+
public const double Ratio45 = 4.5;
27+
public const double Ratio70 = 7.0;
28+
29+
// Given a color and a contrast ratio to reach, the luminance of a color that reaches that ratio
30+
// with the color can be calculated. However, that luminance may not contrast as desired, i.e. the
31+
// contrast ratio of the input color and the returned luminance may not reach the contrast ratio
32+
// asked for.
33+
//
34+
// When the desired contrast ratio and the result contrast ratio differ by more than this amount,
35+
// an error value should be returned, or the method should be documented as 'unsafe', meaning,
36+
// it will return a valid luminance but that luminance may not meet the requested contrast ratio.
37+
//
38+
// 0.04 selected because it ensures the resulting ratio rounds to the same tenth.
39+
private const double ContrastRatioEpsilon = 0.04;
40+
41+
// Color spaces that measure luminance, such as Y in XYZ, L* in L*a*b*, or T in HCT, are known as
42+
// perceptually accurate color spaces.
43+
//
44+
// To be displayed, they must gamut map to a "display space", one that has a defined limit on the
45+
// number of colors. Display spaces include sRGB, more commonly understood as RGB/HSL/HSV/HSB.
46+
// Gamut mapping is undefined and not defined by the color space. Any gamut mapping algorithm must
47+
// choose how to sacrifice accuracy in hue, saturation, and/or lightness.
48+
//
49+
// A principled solution is to maintain lightness, thus maintaining contrast/a11y, maintain hue,
50+
// thus maintaining aesthetic intent, and reduce chroma until the color is in gamut.
51+
//
52+
// HCT chooses this solution, but, that doesn't mean it will _exactly_ matched desired lightness,
53+
// if only because RGB is quantized: RGB is expressed as a set of integers: there may be an RGB
54+
// color with, for example, 47.892 lightness, but not 47.891.
55+
//
56+
// To allow for this inherent incompatibility between perceptually accurate color spaces and
57+
// display color spaces, methods that take a contrast ratio and luminance, and return a luminance
58+
// that reaches that contrast ratio for the input luminance, purposefully darken/lighten their
59+
// result such that the desired contrast ratio will be reached even if inaccuracy is introduced.
60+
//
61+
// 0.4 is generous, ex. HCT requires much less delta. It was chosen because it provides a rough
62+
// guarantee that as long as a perceptual color space gamut maps lightness such that the resulting
63+
// lightness rounds to the same as the requested, the desired contrast ratio will be reached.
64+
private const double LuminanceGamutMapTolerance = 0.4;
65+
66+
/// <summary>
67+
/// Contrast ratio is a measure of legibility, its used to compare the lightness of two colors.
68+
/// This method is used commonly in industry due to its use by WCAG.
69+
///
70+
/// To compare lightness, the colors are expressed in the XYZ color space, where Y is lightness,
71+
/// also known as relative luminance.
72+
///
73+
/// The equation is ratio = lighter Y + 5 / darker Y + 5.
74+
/// </summary>
75+
public static double RatioOfYs(double y1, double y2)
76+
{
77+
double lighter = y1 > y2 ? y1 : y2;
78+
double darker = (lighter == y2) ? y1 : y2;
79+
return (lighter + 5.0) / (darker + 5.0);
80+
}
81+
82+
/// <summary>
83+
/// Contrast ratio of two tones. T in HCT, L* in L*a*b*. Also known as luminance or perceptual
84+
/// luminance.
85+
///
86+
/// Contrast ratio is defined using Y in XYZ, relative luminance. However, relative luminance is
87+
/// linear to number of photons, not to perception of lightness. Perceptual luminance, L* in
88+
/// L*a*b*, T in HCT, is. Designers prefer color spaces with perceptual luminance since they're
89+
/// accurate to the eye.
90+
///
91+
/// Y and L* are pure functions of each other, so it possible to use perceptually accurate color
92+
/// spaces, and measure contrast, and measure contrast in a much more understandable way: instead
93+
/// of a ratio, a linear difference. This allows a designer to determine what they need to adjust a
94+
/// color's lightness to in order to reach their desired contrast, instead of guessing &amp; checking
95+
/// with hex codes.
96+
/// </summary>
97+
public static double RatioOfTones(double toneA, double toneB)
98+
{
99+
return RatioOfYs(
100+
ColorUtils.YFromLstar(MathUtils.Clamp(0.0, 100.0, toneA)),
101+
ColorUtils.YFromLstar(MathUtils.Clamp(0.0, 100.0, toneB)));
102+
}
103+
104+
/// <summary>
105+
/// Returns T in HCT, L* in L*a*b* &gt;= tone parameter that ensures ratio with input T/L*.
106+
/// Returns -1 if ratio cannot be achieved.
107+
/// </summary>
108+
/// <param name="tone">Tone return value must contrast with.</param>
109+
/// <param name="ratio">Desired contrast ratio of return value and tone parameter.</param>
110+
public static double Lighter(double tone, double ratio)
111+
{
112+
if (tone is < 0.0 or > 100.0)
113+
{
114+
return -1.0;
115+
}
116+
double darkY = ColorUtils.YFromLstar(tone);
117+
double lightY = ratio * (darkY + 5.0) - 5.0;
118+
if (lightY is < 0.0 or > 100.0)
119+
{
120+
return -1.0;
121+
}
122+
double realContrast = RatioOfYs(lightY, darkY);
123+
double delta = Abs(realContrast - ratio);
124+
if (realContrast < ratio && delta > ContrastRatioEpsilon)
125+
{
126+
return -1.0;
127+
}
128+
129+
double returnValue = ColorUtils.LstarFromY(lightY) + LuminanceGamutMapTolerance;
130+
if (returnValue is < 0.0 or > 100.0)
131+
{
132+
return -1.0;
133+
}
134+
return returnValue;
135+
}
136+
137+
/// <summary>
138+
/// Tone &gt;= tone parameter that ensures ratio. 100 if ratio cannot be achieved.
139+
///
140+
/// This method is unsafe because the returned value is guaranteed to be in bounds, but, the in
141+
/// bounds return value may not reach the desired ratio.
142+
/// </summary>
143+
/// <param name="tone">Tone return value must contrast with.</param>
144+
/// <param name="ratio">Desired contrast ratio of return value and tone parameter.</param>
145+
public static double LighterUnsafe(double tone, double ratio)
146+
{
147+
double lighterSafe = Lighter(tone, ratio);
148+
return lighterSafe < 0.0 ? 100.0 : lighterSafe;
149+
}
150+
151+
/// <summary>
152+
/// Returns T in HCT, L* in L*a*b* &lt;= tone parameter that ensures ratio with input T/L*.
153+
/// Returns -1 if ratio cannot be achieved.
154+
/// </summary>
155+
/// <param name="tone">Tone return value must contrast with.</param>
156+
/// <param name="ratio">Desired contrast ratio of return value and tone parameter.</param>
157+
public static double Darker(double tone, double ratio)
158+
{
159+
if (tone is < 0.0 or > 100.0)
160+
{
161+
return -1.0;
162+
}
163+
double lightY = ColorUtils.YFromLstar(tone);
164+
double darkY = ((lightY + 5.0) / ratio) - 5.0;
165+
if (darkY is < 0.0 or > 100.0)
166+
{
167+
return -1.0;
168+
}
169+
double realContrast = RatioOfYs(lightY, darkY);
170+
double delta = Abs(realContrast - ratio);
171+
if (realContrast < ratio && delta > ContrastRatioEpsilon)
172+
{
173+
return -1.0;
174+
}
175+
double returnValue = ColorUtils.LstarFromY(darkY) - LuminanceGamutMapTolerance;
176+
if (returnValue is < 0.0 or > 100.0)
177+
{
178+
return -1.0;
179+
}
180+
return returnValue;
181+
}
182+
183+
/// <summary>
184+
/// Tone &lt;= tone parameter that ensures ratio. 0 if ratio cannot be achieved.
185+
///
186+
/// This method is unsafe because the returned value is guaranteed to be in bounds, but, the in
187+
/// bounds return value may not reach the desired ratio.
188+
/// </summary>
189+
/// <param name="tone">Tone return value must contrast with.</param>
190+
/// <param name="ratio">Desired contrast ratio of return value and tone parameter.</param>
191+
public static double DarkerUnsafe(double tone, double ratio)
192+
{
193+
double darkerSafe = Darker(tone, ratio);
194+
return Max(0.0, darkerSafe);
195+
}
196+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
using static System.Math;
2+
3+
namespace MaterialColorUtilities;
4+
5+
/// <summary>
6+
/// Check and/or fix universally disliked colors.
7+
///
8+
/// Color science studies of color preference indicate universal distaste for dark yellow-greens,
9+
/// and also show this is correlated to distaste for biological waste and rotting food.
10+
///
11+
/// See Palmer and Schloss, 2010 or Schloss and Palmer's Chapter 21 in Handbook of Color
12+
/// Psychology (2015).
13+
/// </summary>
14+
public static class DislikeAnalyzer
15+
{
16+
/// <summary>
17+
/// Returns true if color is disliked.
18+
///
19+
/// Disliked is defined as a dark yellow-green that is not neutral.
20+
/// </summary>
21+
public static bool IsDisliked(Hct hct)
22+
{
23+
double roundedHue = Round(hct.Hue);
24+
double roundedChroma = Round(hct.Chroma);
25+
double roundedTone = Round(hct.Tone);
26+
27+
bool huePasses = roundedHue is >= 90.0 and <= 111.0;
28+
bool chromaPasses = roundedChroma > 16.0;
29+
bool tonePasses = roundedTone < 65.0;
30+
31+
return huePasses && chromaPasses && tonePasses;
32+
}
33+
34+
/// <summary>
35+
/// If color is disliked, lighten it to make it likable.
36+
/// </summary>
37+
public static Hct FixIfDisliked(Hct hct)
38+
{
39+
return IsDisliked(hct)
40+
? Hct.From(hct.Hue, hct.Chroma, 70.0)
41+
: hct;
42+
}
43+
}

0 commit comments

Comments
 (0)