Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Directory.packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,8 @@
<PackageVersion Include="TUnit" Version="0.25.21" />
<PackageVersion Include="VirtualizingWrapPanel" Version="1.5.8" />
<PackageVersion Include="XAMLTest" Version="1.3.0-ci616" />
<PackageVersion Include="System.Memory" Version="4.6.3" />
<PackageVersion Include="System.ValueTuple" Version="4.6.1" />
<PackageVersion Include="Polyfill" Version="8.8.1" />
</ItemGroup>
</Project>
38 changes: 38 additions & 0 deletions MaterialDesignToolkit.Full.sln
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MaterialDesign3Demo", "src\
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MaterialDesignDemo.Shared", "src\MaterialDesignDemo.Shared\MaterialDesignDemo.Shared.csproj", "{B39795A7-D66A-4F2F-9F41-050838D14048}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MaterialColorUtilities", "src\MaterialDesign3.MaterialColorUtilities\MaterialColorUtilities.csproj", "{2C29B80E-1689-43CE-85AC-71799666B4AC}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MaterialColorUtilities.Tests", "tests\MaterialColorUtilities.Tests\MaterialColorUtilities.Tests.csproj", "{91485BEA-759F-406E-87B7-68D94CF66DE4}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -251,6 +255,38 @@ Global
{B39795A7-D66A-4F2F-9F41-050838D14048}.Release|x64.Build.0 = Release|Any CPU
{B39795A7-D66A-4F2F-9F41-050838D14048}.Release|x86.ActiveCfg = Release|Any CPU
{B39795A7-D66A-4F2F-9F41-050838D14048}.Release|x86.Build.0 = Release|Any CPU
{2C29B80E-1689-43CE-85AC-71799666B4AC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2C29B80E-1689-43CE-85AC-71799666B4AC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2C29B80E-1689-43CE-85AC-71799666B4AC}.Debug|ARM.ActiveCfg = Debug|Any CPU
{2C29B80E-1689-43CE-85AC-71799666B4AC}.Debug|ARM.Build.0 = Debug|Any CPU
{2C29B80E-1689-43CE-85AC-71799666B4AC}.Debug|x64.ActiveCfg = Debug|Any CPU
{2C29B80E-1689-43CE-85AC-71799666B4AC}.Debug|x64.Build.0 = Debug|Any CPU
{2C29B80E-1689-43CE-85AC-71799666B4AC}.Debug|x86.ActiveCfg = Debug|Any CPU
{2C29B80E-1689-43CE-85AC-71799666B4AC}.Debug|x86.Build.0 = Debug|Any CPU
{2C29B80E-1689-43CE-85AC-71799666B4AC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2C29B80E-1689-43CE-85AC-71799666B4AC}.Release|Any CPU.Build.0 = Release|Any CPU
{2C29B80E-1689-43CE-85AC-71799666B4AC}.Release|ARM.ActiveCfg = Release|Any CPU
{2C29B80E-1689-43CE-85AC-71799666B4AC}.Release|ARM.Build.0 = Release|Any CPU
{2C29B80E-1689-43CE-85AC-71799666B4AC}.Release|x64.ActiveCfg = Release|Any CPU
{2C29B80E-1689-43CE-85AC-71799666B4AC}.Release|x64.Build.0 = Release|Any CPU
{2C29B80E-1689-43CE-85AC-71799666B4AC}.Release|x86.ActiveCfg = Release|Any CPU
{2C29B80E-1689-43CE-85AC-71799666B4AC}.Release|x86.Build.0 = Release|Any CPU
{91485BEA-759F-406E-87B7-68D94CF66DE4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{91485BEA-759F-406E-87B7-68D94CF66DE4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{91485BEA-759F-406E-87B7-68D94CF66DE4}.Debug|ARM.ActiveCfg = Debug|Any CPU
{91485BEA-759F-406E-87B7-68D94CF66DE4}.Debug|ARM.Build.0 = Debug|Any CPU
{91485BEA-759F-406E-87B7-68D94CF66DE4}.Debug|x64.ActiveCfg = Debug|Any CPU
{91485BEA-759F-406E-87B7-68D94CF66DE4}.Debug|x64.Build.0 = Debug|Any CPU
{91485BEA-759F-406E-87B7-68D94CF66DE4}.Debug|x86.ActiveCfg = Debug|Any CPU
{91485BEA-759F-406E-87B7-68D94CF66DE4}.Debug|x86.Build.0 = Debug|Any CPU
{91485BEA-759F-406E-87B7-68D94CF66DE4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{91485BEA-759F-406E-87B7-68D94CF66DE4}.Release|Any CPU.Build.0 = Release|Any CPU
{91485BEA-759F-406E-87B7-68D94CF66DE4}.Release|ARM.ActiveCfg = Release|Any CPU
{91485BEA-759F-406E-87B7-68D94CF66DE4}.Release|ARM.Build.0 = Release|Any CPU
{91485BEA-759F-406E-87B7-68D94CF66DE4}.Release|x64.ActiveCfg = Release|Any CPU
{91485BEA-759F-406E-87B7-68D94CF66DE4}.Release|x64.Build.0 = Release|Any CPU
{91485BEA-759F-406E-87B7-68D94CF66DE4}.Release|x86.ActiveCfg = Release|Any CPU
{91485BEA-759F-406E-87B7-68D94CF66DE4}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand All @@ -262,6 +298,8 @@ Global
{18401177-A30E-4330-8F6B-101DF876CE99} = {55087897-5F09-45CA-8E12-12B36B45F262}
{98627CBE-F009-482E-97E9-C69C7135E91F} = {D34BE232-DE51-43C1-ABDC-B69003BB50FF}
{B39795A7-D66A-4F2F-9F41-050838D14048} = {D34BE232-DE51-43C1-ABDC-B69003BB50FF}
{2C29B80E-1689-43CE-85AC-71799666B4AC} = {9E303A4A-3712-44B9-91EE-830FDC087795}
{91485BEA-759F-406E-87B7-68D94CF66DE4} = {9E303A4A-3712-44B9-91EE-830FDC087795}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {730B2F9E-74AE-46CE-9E61-89AA5C6D5DD3}
Expand Down
75 changes: 75 additions & 0 deletions src/MaterialDesign3.MaterialColorUtilities/Blend/Blend.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
using static System.Math;
using static MaterialColorUtilities.MathUtils;

namespace MaterialColorUtilities;

/// <summary>
/// Functions for blending in HCT and CAM16.
/// </summary>
public static class Blend
{
/// <summary>
/// Blend the design color's HCT hue towards the key color's HCT hue, in a way that
/// leaves the original color recognizable and recognizably shifted towards the key color.
/// </summary>
/// <param name="designColor">ARGB representation of an arbitrary color.</param>
/// <param name="sourceColor">ARGB representation of the main theme color.</param>
/// <returns>
/// The design color with a hue shifted towards the system's color, a slightly
/// warmer/cooler variant of the design color's hue.
/// </returns>
public static int Harmonize(int designColor, int sourceColor)
{
var fromHct = Hct.FromInt(designColor);
var toHct = Hct.FromInt(sourceColor);
var differenceDegrees = DifferenceDegrees(fromHct.Hue, toHct.Hue);
var rotationDegrees = Min(differenceDegrees * 0.5, 15.0);
var outputHue = SanitizeDegreesDouble(
fromHct.Hue + rotationDegrees * RotationDirection(fromHct.Hue, toHct.Hue));
return Hct.From(outputHue, fromHct.Chroma, fromHct.Tone).Argb;
}

/// <summary>
/// Blends hue from one color into another. The chroma and tone of the original color are maintained.
/// </summary>
/// <param name="from">ARGB representation of color.</param>
/// <param name="to">ARGB representation of color.</param>
/// <param name="amount">How much blending to perform; 0.0 &gt;= and &lt;= 1.0.</param>
/// <returns>
/// <paramref name="from"/> with a hue blended towards <paramref name="to"/>.
/// Chroma and tone are constant.
/// </returns>
public static int HctHue(int from, int to, double amount)
{
var ucs = Cam16Ucs(from, to, amount);
var ucsCam = Cam16.FromInt(ucs);
var fromCam = Cam16.FromInt(from);
var blended = Hct.From(ucsCam.GetHue(), fromCam.GetChroma(), ColorUtils.LstarFromArgb(from));
return blended.Argb;
}

/// <summary>
/// Blend in CAM16-UCS space.
/// </summary>
/// <param name="from">ARGB representation of color.</param>
/// <param name="to">ARGB representation of color.</param>
/// <param name="amount">How much blending to perform; 0.0 &gt;= and &lt;= 1.0.</param>
/// <returns>
/// <paramref name="from"/>, blended towards <paramref name="to"/>. Hue, chroma, and tone will change.
/// </returns>
public static int Cam16Ucs(int from, int to, double amount)
{
var fromCam = Cam16.FromInt(from);
var toCam = Cam16.FromInt(to);
var fromJ = fromCam.GetJstar();
var fromA = fromCam.GetAstar();
var fromB = fromCam.GetBstar();
var toJ = toCam.GetJstar();
var toA = toCam.GetAstar();
var toB = toCam.GetBstar();
var jstar = fromJ + (toJ - fromJ) * amount;
var astar = fromA + (toA - fromA) * amount;
var bstar = fromB + (toB - fromB) * amount;
return Cam16.FromUcs(jstar, astar, bstar).ToInt();
}
}
196 changes: 196 additions & 0 deletions src/MaterialDesign3.MaterialColorUtilities/Contrast/Contrast.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
using static System.Math;

namespace MaterialColorUtilities;

/// <summary>
/// Color science for contrast utilities.
///
/// Utility methods for calculating contrast given two colors, or calculating a color given one
/// color and a contrast ratio.
///
/// Contrast ratio is calculated using XYZ's Y. When linearized to match human perception, Y
/// becomes HCT's tone and L*a*b*'s' L*.
/// </summary>
public static class Contrast
{
// The minimum contrast ratio of two colors.
// Contrast ratio equation = lighter + 5 / darker + 5, if lighter == darker, ratio == 1.
public const double RatioMin = 1.0;

// The maximum contrast ratio of two colors.
// Contrast ratio equation = lighter + 5 / darker + 5. Lighter and darker scale from 0 to 100.
// If lighter == 100, darker = 0, ratio == 21.
public const double RatioMax = 21.0;

public const double Ratio30 = 3.0;
public const double Ratio45 = 4.5;
public const double Ratio70 = 7.0;

// Given a color and a contrast ratio to reach, the luminance of a color that reaches that ratio
// with the color can be calculated. However, that luminance may not contrast as desired, i.e. the
// contrast ratio of the input color and the returned luminance may not reach the contrast ratio
// asked for.
//
// When the desired contrast ratio and the result contrast ratio differ by more than this amount,
// an error value should be returned, or the method should be documented as 'unsafe', meaning,
// it will return a valid luminance but that luminance may not meet the requested contrast ratio.
//
// 0.04 selected because it ensures the resulting ratio rounds to the same tenth.
private const double ContrastRatioEpsilon = 0.04;

// Color spaces that measure luminance, such as Y in XYZ, L* in L*a*b*, or T in HCT, are known as
// perceptually accurate color spaces.
//
// To be displayed, they must gamut map to a "display space", one that has a defined limit on the
// number of colors. Display spaces include sRGB, more commonly understood as RGB/HSL/HSV/HSB.
// Gamut mapping is undefined and not defined by the color space. Any gamut mapping algorithm must
// choose how to sacrifice accuracy in hue, saturation, and/or lightness.
//
// A principled solution is to maintain lightness, thus maintaining contrast/a11y, maintain hue,
// thus maintaining aesthetic intent, and reduce chroma until the color is in gamut.
//
// HCT chooses this solution, but, that doesn't mean it will _exactly_ matched desired lightness,
// if only because RGB is quantized: RGB is expressed as a set of integers: there may be an RGB
// color with, for example, 47.892 lightness, but not 47.891.
//
// To allow for this inherent incompatibility between perceptually accurate color spaces and
// display color spaces, methods that take a contrast ratio and luminance, and return a luminance
// that reaches that contrast ratio for the input luminance, purposefully darken/lighten their
// result such that the desired contrast ratio will be reached even if inaccuracy is introduced.
//
// 0.4 is generous, ex. HCT requires much less delta. It was chosen because it provides a rough
// guarantee that as long as a perceptual color space gamut maps lightness such that the resulting
// lightness rounds to the same as the requested, the desired contrast ratio will be reached.
private const double LuminanceGamutMapTolerance = 0.4;

/// <summary>
/// Contrast ratio is a measure of legibility, its used to compare the lightness of two colors.
/// This method is used commonly in industry due to its use by WCAG.
///
/// To compare lightness, the colors are expressed in the XYZ color space, where Y is lightness,
/// also known as relative luminance.
///
/// The equation is ratio = lighter Y + 5 / darker Y + 5.
/// </summary>
public static double RatioOfYs(double y1, double y2)
{
var lighter = y1 > y2 ? y1 : y2;
var darker = (lighter == y2) ? y1 : y2;
return (lighter + 5.0) / (darker + 5.0);
}

/// <summary>
/// Contrast ratio of two tones. T in HCT, L* in L*a*b*. Also known as luminance or perceptual
/// luminance.
///
/// Contrast ratio is defined using Y in XYZ, relative luminance. However, relative luminance is
/// linear to number of photons, not to perception of lightness. Perceptual luminance, L* in
/// L*a*b*, T in HCT, is. Designers prefer color spaces with perceptual luminance since they're
/// accurate to the eye.
///
/// Y and L* are pure functions of each other, so it possible to use perceptually accurate color
/// spaces, and measure contrast, and measure contrast in a much more understandable way: instead
/// of a ratio, a linear difference. This allows a designer to determine what they need to adjust a
/// color's lightness to in order to reach their desired contrast, instead of guessing &amp; checking
/// with hex codes.
/// </summary>
public static double RatioOfTones(double toneA, double toneB)
{
return RatioOfYs(
ColorUtils.YFromLstar(MathUtils.Clamp(0.0, 100.0, toneA)),
ColorUtils.YFromLstar(MathUtils.Clamp(0.0, 100.0, toneB)));
}

/// <summary>
/// Returns T in HCT, L* in L*a*b* &gt;= tone parameter that ensures ratio with input T/L*.
/// Returns -1 if ratio cannot be achieved.
/// </summary>
/// <param name="tone">Tone return value must contrast with.</param>
/// <param name="ratio">Desired contrast ratio of return value and tone parameter.</param>
public static double Lighter(double tone, double ratio)
{
if (tone is < 0.0 or > 100.0)
{
return -1.0;
}
var darkY = ColorUtils.YFromLstar(tone);
var lightY = ratio * (darkY + 5.0) - 5.0;
if (lightY is < 0.0 or > 100.0)
{
return -1.0;
}
var realContrast = RatioOfYs(lightY, darkY);
var delta = Abs(realContrast - ratio);
if (realContrast < ratio && delta > ContrastRatioEpsilon)
{
return -1.0;
}

var returnValue = ColorUtils.LstarFromY(lightY) + LuminanceGamutMapTolerance;
if (returnValue is < 0.0 or > 100.0)
{
return -1.0;
}
return returnValue;
}

/// <summary>
/// Tone &gt;= tone parameter that ensures ratio. 100 if ratio cannot be achieved.
///
/// This method is unsafe because the returned value is guaranteed to be in bounds, but, the in
/// bounds return value may not reach the desired ratio.
/// </summary>
/// <param name="tone">Tone return value must contrast with.</param>
/// <param name="ratio">Desired contrast ratio of return value and tone parameter.</param>
public static double LighterUnsafe(double tone, double ratio)
{
var lighterSafe = Lighter(tone, ratio);
return lighterSafe < 0.0 ? 100.0 : lighterSafe;
}

/// <summary>
/// Returns T in HCT, L* in L*a*b* &lt;= tone parameter that ensures ratio with input T/L*.
/// Returns -1 if ratio cannot be achieved.
/// </summary>
/// <param name="tone">Tone return value must contrast with.</param>
/// <param name="ratio">Desired contrast ratio of return value and tone parameter.</param>
public static double Darker(double tone, double ratio)
{
if (tone is < 0.0 or > 100.0)
{
return -1.0;
}
var lightY = ColorUtils.YFromLstar(tone);
var darkY = ((lightY + 5.0) / ratio) - 5.0;
if (darkY is < 0.0 or > 100.0)
{
return -1.0;
}
var realContrast = RatioOfYs(lightY, darkY);
var delta = Abs(realContrast - ratio);
if (realContrast < ratio && delta > ContrastRatioEpsilon)
{
return -1.0;
}
var returnValue = ColorUtils.LstarFromY(darkY) - LuminanceGamutMapTolerance;
if (returnValue is < 0.0 or > 100.0)
{
return -1.0;
}
return returnValue;
}

/// <summary>
/// Tone &lt;= tone parameter that ensures ratio. 0 if ratio cannot be achieved.
///
/// This method is unsafe because the returned value is guaranteed to be in bounds, but, the in
/// bounds return value may not reach the desired ratio.
/// </summary>
/// <param name="tone">Tone return value must contrast with.</param>
/// <param name="ratio">Desired contrast ratio of return value and tone parameter.</param>
public static double DarkerUnsafe(double tone, double ratio)
{
var darkerSafe = Darker(tone, ratio);
return Max(0.0, darkerSafe);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using static System.Math;

namespace MaterialColorUtilities;

/// <summary>
/// Check and/or fix universally disliked colors.
///
/// Color science studies of color preference indicate universal distaste for dark yellow-greens,
/// and also show this is correlated to distaste for biological waste and rotting food.
///
/// See Palmer and Schloss, 2010 or Schloss and Palmer's Chapter 21 in Handbook of Color
/// Psychology (2015).
/// </summary>
public static class DislikeAnalyzer
{
/// <summary>
/// Returns true if color is disliked.
///
/// Disliked is defined as a dark yellow-green that is not neutral.
/// </summary>
public static bool IsDisliked(Hct hct)
{
var roundedHue = Round(hct.Hue);
var roundedChroma = Round(hct.Chroma);
var roundedTone = Round(hct.Tone);

var huePasses = roundedHue is >= 90.0 and <= 111.0;
var chromaPasses = roundedChroma > 16.0;
var tonePasses = roundedTone < 65.0;

return huePasses && chromaPasses && tonePasses;
}

/// <summary>
/// If color is disliked, lighten it to make it likable.
/// </summary>
public static Hct FixIfDisliked(Hct hct)
{
return IsDisliked(hct)
? Hct.From(hct.Hue, hct.Chroma, 70.0)
: hct;
}
}
Loading
Loading