Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(color-contrast): support color blend modes hue, saturation, color, luminosity #4365

Merged
merged 4 commits into from
Mar 13, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
48 changes: 48 additions & 0 deletions lib/commons/color/color.js
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,54 @@ export default class Color {

return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}

/**
* Add the value to a color and return a new instance of the Color. The resulting color values are not clamped to valid values of the color space (must be done separately).
* @method add
* @memberof axe.commons.color.Color
* @instance
* @return {Color}
*/
add(value) {
return new Color(
this.red + value,
this.green + value,
this.blue + value,
this.alpha
);
}

/**
* Divide a color by the value and return a new instance of the Color
* @method divide
* @memberof axe.commons.color.Color
* @instance
* @return {Color}
*/
divide(value) {
straker marked this conversation as resolved.
Show resolved Hide resolved
return new Color(
this.red / value,
this.green / value,
this.blue / value,
this.alpha
);
}

/**
* Multiply a color by the value and return a new instance of the Color. The resulting color values are not clamped to valid values of the color space (must be done separately).
* @method multiply
* @memberof axe.commons.color.Color
* @instance
* @return {Color}
*/
multiply(value) {
return new Color(
this.red * value,
this.green * value,
this.blue * value,
this.alpha
);
}
}

// clamp a value between two numbers (inclusive)
Expand Down
176 changes: 144 additions & 32 deletions lib/commons/color/flatten-colors.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import Color from './color';

// clamp a value between two numbers (inclusive)
function clamp(value, min, max) {
return Math.min(Math.max(min, value), max);
}
// @see https://www.w3.org/TR/compositing-1/#blendingnonseparable
const nonSeparableBlendModes = ['hue', 'saturation', 'color', 'luminosity'];

// how to combine background and foreground colors together when using
// the CSS property `mix-blend-mode`. Defaults to `normal`
Expand Down Expand Up @@ -61,28 +59,28 @@ const blendFunctions = {
exclusion(Cb, Cs) {
// @see https://www.w3.org/TR/compositing-1/#blendingexclusion
return Cb + Cs - 2 * Cb * Cs;
},

// non-separate color function take the entire color object
// an not individual color components (red, green, blue)
hue(Cb, Cs) {
// @see https://www.w3.org/TR/compositing-1/#blendinghue
return setLuminosity(setSaturation(Cs, saturation(Cb)), luminosity(Cb));
},
saturation(Cb, Cs) {
// @see https://www.w3.org/TR/compositing-1/#blendingsaturation
return setLuminosity(setSaturation(Cb, saturation(Cs)), luminosity(Cb));
},
color(Cb, Cs) {
// @see https://www.w3.org/TR/compositing-1/#blendingcolor
return setLuminosity(Cs, luminosity(Cb));
},
luminosity(Cb, Cs) {
// @see https://www.w3.org/TR/compositing-1/#blendingluminosity
return setLuminosity(Cb, luminosity(Cs));
}
};

// Simple Alpha Compositing written as non-premultiplied.
// formula: Rrgb × Ra = Srgb × Sa + Drgb × Da × (1 − Sa)
// Cs: the source color
// αs: the source alpha
// Cb: the backdrop color
// αb: the backdrop alpha
// @see https://www.w3.org/TR/compositing-1/#simplealphacompositing
// @see https://www.w3.org/TR/compositing-1/#blending
// @see https://ciechanow.ski/alpha-compositing/
function simpleAlphaCompositing(Cs, αs, Cb, αb, blendMode) {
return (
αs * (1 - αb) * Cs +
// Note: Cs and Cb values need to be between 0 and 1 inclusive for the blend function
// @see https://www.w3.org/TR/compositing-1/#simplealphacompositing
αs * αb * blendFunctions[blendMode](Cb / 255, Cs / 255) * 255 +
(1 - αs) * αb * Cb
);
}

/**
* Combine the two given color according to alpha blending.
* @method flattenColors
Expand All @@ -92,28 +90,45 @@ function simpleAlphaCompositing(Cs, αs, Cb, αb, blendMode) {
* @param {Color} backdrop Background color
* @return {Color} Blended color
*/
function flattenColors(sourceColor, backdrop, blendMode = 'normal') {
export default function flattenColors(
sourceColor,
backdrop,
blendMode = 'normal'
) {
let blendingResult;
if (nonSeparableBlendModes.includes(blendMode)) {
// Note: Cs and Cb values need to be between 0 and 1 inclusive for the blend function
// @see https://www.w3.org/TR/compositing-1/#simplealphacompositing
straker marked this conversation as resolved.
Show resolved Hide resolved
const Cs = sourceColor.divide(255);
const Cb = backdrop.divide(255);

blendingResult = blendFunctions[blendMode](Cb, Cs).multiply(255);
}

// foreground is the "source" color and background is the "backdrop" color
const r = simpleAlphaCompositing(
sourceColor.red,
sourceColor.alpha,
backdrop.red,
backdrop.alpha,
blendMode
blendMode,
blendingResult?.red
);
const g = simpleAlphaCompositing(
sourceColor.green,
sourceColor.alpha,
backdrop.green,
backdrop.alpha,
blendMode
blendMode,
blendingResult?.green
);
const b = simpleAlphaCompositing(
sourceColor.blue,
sourceColor.alpha,
backdrop.blue,
backdrop.alpha,
blendMode
blendMode,
blendingResult?.blue
);

// formula: αo = αs + αb x (1 - αs)
Expand All @@ -136,11 +151,108 @@ function flattenColors(sourceColor, backdrop, blendMode = 'normal') {
//
// RGB color space doesn't have decimal values so we will follow what browsers do and round
// e.g. rgb(255.2, 127.5, 127.8) === rgb(255, 128, 128)
const Cr = Math.round(r / αo);
const Cg = Math.round(g / αo);
const Cb = Math.round(b / αo);
const Cred = Math.round(r / αo);
const Cgreen = Math.round(g / αo);
const Cblue = Math.round(b / αo);

return new Color(Cred, Cgreen, Cblue, αo);
}

// Simple Alpha Compositing written as non-premultiplied.
// formula: Rrgb × Ra = Srgb × Sa + Drgb × Da × (1 − Sa)
// Cs: the source color
// αs: the source alpha
// Cb: the backdrop color
// αb: the backdrop alpha
// @see https://www.w3.org/TR/compositing-1/#simplealphacompositing
// @see https://www.w3.org/TR/compositing-1/#blending
// @see https://ciechanow.ski/alpha-compositing/
function simpleAlphaCompositing(Cs, αs, Cb, αb, blendMode, blendingResult) {
return (
αs * (1 - αb) * Cs +
αs *
αb *
(blendingResult ??
// Note: Cs and Cb values need to be between 0 and 1 inclusive for the blend function
// @see https://www.w3.org/TR/compositing-1/#simplealphacompositing
blendFunctions[blendMode](Cb / 255, Cs / 255) * 255) +
straker marked this conversation as resolved.
Show resolved Hide resolved
(1 - αs) * αb * Cb
);
}

// clamp a value between two numbers (inclusive)
function clamp(value, min, max) {
return Math.min(Math.max(min, value), max);
}

// following functions taken from the spec
// @see https://www.w3.org/TR/compositing-1/#blendingnonseparable
function luminosity(color) {
return 0.3 * color.red + 0.59 * color.green + 0.11 * color.blue;
}

function clipColor(color) {
const L = luminosity(color);
const n = Math.min(color.red, color.green, color.blue);
const x = Math.max(color.red, color.green, color.blue);

if (n < 0) {
return new Color(
straker marked this conversation as resolved.
Show resolved Hide resolved
L + ((color.red - L) * L) / (L - n),
L + ((color.green - L) * L) / (L - n),
L + ((color.blue - L) * L) / (L - n),
color.alpha
);
}

if (x > 1) {
return new Color(
L + ((color.red - L) * (1 - L)) / (x - L),
L + ((color.green - L) * (1 - L)) / (x - L),
L + ((color.blue - L) * (1 - L)) / (x - L),
color.alpha
);
}

return color;
}

function setLuminosity(color, L) {
const d = L - luminosity(color);
return clipColor(color.add(d));
}

return new Color(Cr, Cg, Cb, αo);
function saturation(color) {
return (
Math.max(color.red, color.green, color.blue) -
Math.min(color.red, color.green, color.blue)
);
}

export default flattenColors;
function setSaturation(color, s) {
const C = new Color(color.red, color.green, color.blue, color.alpha);
const colorEntires = Object.entries(C)
.filter(([prop]) => prop !== 'alpha')
.map(([name, value]) => {
return { name, value };
});
straker marked this conversation as resolved.
Show resolved Hide resolved

// find the min, mid, and max values of the color components
const [Cmin, Cmid, Cmax] = colorEntires.sort((a, b) => {
return a.value - b.value;
});

if (Cmax.value > Cmin.value) {
Cmid.value = ((Cmid.value - Cmin.value) * s) / (Cmax.value - Cmin.value);
Cmax.value = s;
} else {
Cmid.value = Cmax.value = 0;
}

Cmin.value = 0;

C[Cmax.name] = Cmax.value;
C[Cmin.name] = Cmin.value;
C[Cmid.name] = Cmid.value;
return C;
}
78 changes: 78 additions & 0 deletions test/commons/color/color.js
Original file line number Diff line number Diff line change
Expand Up @@ -413,4 +413,82 @@ describe('color.Color', () => {
assert.isTrue(lBlue > lBlack);
});
});

describe('add', () => {
it('adds the value to the color', () => {
const color = new Color(0, 0, 0, 1);
const actual = color.add(144);
assert.equal(actual.red, 144);
assert.equal(actual.green, 144);
assert.equal(actual.blue, 144);
assert.equal(actual.alpha, 1);
});

it('does not modify the original color', () => {
const color = new Color(0, 0, 0, 1);
color.add(144);
assert.equal(color.red, 0);
assert.equal(color.green, 0);
assert.equal(color.blue, 0);
assert.equal(color.alpha, 1);
});

it('does not clamp the value', () => {
const color = new Color(0, 0, 0, 1);
const actual = color.add(3000);
assert.equal(actual.red, 3000);
assert.equal(actual.green, 3000);
assert.equal(actual.blue, 3000);
assert.equal(actual.alpha, 1);
});
});

describe('divide', () => {
it('divides the color by the value', () => {
const color = new Color(144, 144, 144, 1);
const actual = color.divide(2);
assert.equal(actual.red, 72);
assert.equal(actual.green, 72);
assert.equal(actual.blue, 72);
assert.equal(actual.alpha, 1);
});

it('does not modify the original color', () => {
const color = new Color(144, 144, 144, 1);
color.divide(2);
assert.equal(color.red, 144);
assert.equal(color.green, 144);
assert.equal(color.blue, 144);
assert.equal(color.alpha, 1);
});
});

describe('multiply', () => {
it('multiplies the color by the value', () => {
const color = new Color(72, 72, 72, 1);
const actual = color.multiply(2);
assert.equal(actual.red, 144);
assert.equal(actual.green, 144);
assert.equal(actual.blue, 144);
assert.equal(actual.alpha, 1);
});

it('does not modify the original color', () => {
const color = new Color(72, 72, 72, 1);
color.multiply(2);
assert.equal(color.red, 72);
assert.equal(color.green, 72);
assert.equal(color.blue, 72);
assert.equal(color.alpha, 1);
});

it('does not clamp the value', () => {
const color = new Color(30, 30, 30, 1);
const actual = color.multiply(100);
assert.equal(actual.red, 3000);
assert.equal(actual.green, 3000);
assert.equal(actual.blue, 3000);
assert.equal(actual.alpha, 1);
});
});
});
Loading
Loading