Skip to content

Commit

Permalink
feat: add chroma.contrastAPCA (#353)
Browse files Browse the repository at this point in the history
* feat: add chroma.contrastAPCA

resolves #302

* docs: fix rendering of transparent css colors in docs

* build
  • Loading branch information
gka authored Aug 19, 2024
1 parent 705310d commit fb4bdf2
Show file tree
Hide file tree
Showing 11 changed files with 288 additions and 4 deletions.
66 changes: 66 additions & 0 deletions dist/chroma.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -3687,6 +3687,71 @@
return l1 > l2 ? (l1 + 0.05) / (l2 + 0.05) : (l2 + 0.05) / (l1 + 0.05);
}

/**
* @license
*
* The APCA contrast prediction algorithm is based of the formulas published
* in the APCA-1.0.98G specification by Myndex. The specification is available at:
* https://raw.githubusercontent.com/Myndex/apca-w3/master/images/APCAw3_0.1.17_APCA0.0.98G.svg
*
* Note that the APCA implementation is still beta, so please update to
* future versions of chroma.js when they become available.
*
* You can read more about the APCA Readability Criterion at
* https://readtech.org/ARC/
*/

// constants
var W_offset = 0.027;
var P_in = 0.0005;
var P_out = 0.1;
var R_scale = 1.14;
var B_threshold = 0.022;
var B_exp = 1.414;

function contrastAPCA (text, bg) {
// parse input colors
text = new Color(text);
bg = new Color(bg);
// if text color has alpha, blend against background
if (text.alpha() < 1) {
text = mix(bg, text, text.alpha(), 'rgb');
}
var l_text = lum.apply(void 0, text.rgb());
var l_bg = lum.apply(void 0, bg.rgb());

// soft clamp black levels
var Y_text =
l_text >= B_threshold
? l_text
: l_text + Math.pow(B_threshold - l_text, B_exp);
var Y_bg =
l_bg >= B_threshold ? l_bg : l_bg + Math.pow(B_threshold - l_bg, B_exp);

// normal polarity (dark text on light background)
var S_norm = Math.pow(Y_bg, 0.56) - Math.pow(Y_text, 0.57);
// reverse polarity (light text on dark background)
var S_rev = Math.pow(Y_bg, 0.65) - Math.pow(Y_text, 0.62);
// clamp noise then scale
var C =
Math.abs(Y_bg - Y_text) < P_in
? 0
: Y_text < Y_bg
? S_norm * R_scale
: S_rev * R_scale;
// clamp minimum contrast then offset
var S_apc = Math.abs(C) < P_out ? 0 : C > 0 ? C - W_offset : C + W_offset;
// scale to 100
return S_apc * 100;
}
function lum(r, g, b) {
return (
0.2126729 * Math.pow(r / 255, 2.4) +
0.7151522 * Math.pow(g / 255, 2.4) +
0.072175 * Math.pow(b / 255, 2.4)
);
}

var sqrt = Math.sqrt;
var pow = Math.pow;
var min = Math.min;
Expand Down Expand Up @@ -3904,6 +3969,7 @@
Color: Color,
colors: w3cx11,
contrast: contrast,
contrastAPCA: contrastAPCA,
cubehelix: cubehelix,
deltaE: deltaE,
distance: distance,
Expand Down
16 changes: 15 additions & 1 deletion dist/chroma.min.cjs

Large diffs are not rendered by default.

9 changes: 8 additions & 1 deletion docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,13 @@ <h4 id="-color1-color2-">(color1, color2)</h4>
// contrast greater than 4.5 = high enough
chroma.contrast(&#39;pink&#39;, &#39;purple&#39;);
</code></pre>
<h3 id="chroma-contrastapca">chroma.contrastAPCA</h3>
<h4 id="-text-background-">(text, background)</h4>
<p><strong>New (3.1):</strong> Computes the <a href="https://www.myndex.com/APCA/">APCA contrast</a> ratio of a text color against its background color. The basic idea is that you check the contrast between the text and background color and then use <a href="https://raw.githubusercontent.com/Myndex/apca-w3/master/images/APCAlookupByContrast.jpeg">this lookup table</a> to find the minimum font size you&#39;re allowed to use (given the font weight and purpose of the text). </p>
<pre><code class="lang-js">chroma.contrastAPCA(&#39;hotpink&#39;, &#39;pink&#39;);
chroma.contrastAPCA(&#39;purple&#39;, &#39;pink&#39;);
</code></pre>
<p>Read more about how to interpret and use this metric at <a href="https://readtech.org/ARC">APCA Readability Criterion</a>. Please note that the APCA algorithm is still in beta and may change be subject to changes in the future.</p>
<h3 id="chroma-distance">chroma.distance</h3>
<h4 id="-color1-color2-mode-lab-">(color1, color2, mode=&#39;lab&#39;)</h4>
<p>Computes the <a href="https://en.wikipedia.org/wiki/Euclidean_distance#Three_dimensions">Euclidean distance</a> between two colors in a given color space (default is <code>Lab</code>).</p>
Expand Down Expand Up @@ -1007,7 +1014,7 @@ <h3 id="1-0-0">1.0.0</h3>
[
'background-color:' + (isCSS ? val : col.hex()),
'color:' + (l < 0.7 ? 'white' : 'black'),
'opacity:' + col.alpha()
...(isCSS ? [] : ['opacity:' + col.alpha()])
].join(';')
);

Expand Down
66 changes: 66 additions & 0 deletions docs/libs/chroma.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -3687,6 +3687,71 @@
return l1 > l2 ? (l1 + 0.05) / (l2 + 0.05) : (l2 + 0.05) / (l1 + 0.05);
}

/**
* @license
*
* The APCA contrast prediction algorithm is based of the formulas published
* in the APCA-1.0.98G specification by Myndex. The specification is available at:
* https://raw.githubusercontent.com/Myndex/apca-w3/master/images/APCAw3_0.1.17_APCA0.0.98G.svg
*
* Note that the APCA implementation is still beta, so please update to
* future versions of chroma.js when they become available.
*
* You can read more about the APCA Readability Criterion at
* https://readtech.org/ARC/
*/

// constants
var W_offset = 0.027;
var P_in = 0.0005;
var P_out = 0.1;
var R_scale = 1.14;
var B_threshold = 0.022;
var B_exp = 1.414;

function contrastAPCA (text, bg) {
// parse input colors
text = new Color(text);
bg = new Color(bg);
// if text color has alpha, blend against background
if (text.alpha() < 1) {
text = mix(bg, text, text.alpha(), 'rgb');
}
var l_text = lum.apply(void 0, text.rgb());
var l_bg = lum.apply(void 0, bg.rgb());

// soft clamp black levels
var Y_text =
l_text >= B_threshold
? l_text
: l_text + Math.pow(B_threshold - l_text, B_exp);
var Y_bg =
l_bg >= B_threshold ? l_bg : l_bg + Math.pow(B_threshold - l_bg, B_exp);

// normal polarity (dark text on light background)
var S_norm = Math.pow(Y_bg, 0.56) - Math.pow(Y_text, 0.57);
// reverse polarity (light text on dark background)
var S_rev = Math.pow(Y_bg, 0.65) - Math.pow(Y_text, 0.62);
// clamp noise then scale
var C =
Math.abs(Y_bg - Y_text) < P_in
? 0
: Y_text < Y_bg
? S_norm * R_scale
: S_rev * R_scale;
// clamp minimum contrast then offset
var S_apc = Math.abs(C) < P_out ? 0 : C > 0 ? C - W_offset : C + W_offset;
// scale to 100
return S_apc * 100;
}
function lum(r, g, b) {
return (
0.2126729 * Math.pow(r / 255, 2.4) +
0.7151522 * Math.pow(g / 255, 2.4) +
0.072175 * Math.pow(b / 255, 2.4)
);
}

var sqrt = Math.sqrt;
var pow = Math.pow;
var min = Math.min;
Expand Down Expand Up @@ -3904,6 +3969,7 @@
Color: Color,
colors: w3cx11,
contrast: contrast,
contrastAPCA: contrastAPCA,
cubehelix: cubehelix,
deltaE: deltaE,
distance: distance,
Expand Down
16 changes: 15 additions & 1 deletion docs/libs/chroma.min.cjs

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/src/footer.inc.html
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@
[
'background-color:' + (isCSS ? val : col.hex()),
'color:' + (l < 0.7 ? 'white' : 'black'),
'opacity:' + col.alpha()
...(isCSS ? [] : ['opacity:' + col.alpha()])
].join(';')
);

Expand Down
13 changes: 13 additions & 0 deletions docs/src/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,19 @@ chroma.contrast('pink', 'hotpink');
chroma.contrast('pink', 'purple');
```

### chroma.contrastAPCA
#### (text, background)

**New (3.1):** Computes the [APCA contrast](https://www.myndex.com/APCA/) ratio of a text color against its background color. The basic idea is that you check the contrast between the text and background color and then use [this lookup table](https://raw.githubusercontent.com/Myndex/apca-w3/master/images/APCAlookupByContrast.jpeg) to find the minimum font size you're allowed to use (given the font weight and purpose of the text).

```js
chroma.contrastAPCA('hotpink', 'pink');
chroma.contrastAPCA('purple', 'pink');
```

Read more about how to interpret and use this metric at [APCA Readability Criterion](https://readtech.org/ARC). Please note that the APCA algorithm is still in beta and may change be subject to changes in the future.


### chroma.distance
#### (color1, color2, mode='lab')

Expand Down
3 changes: 3 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import scale from './src/generator/scale.js';
// other utility methods
import { analyze } from './src/utils/analyze.js';
import contrast from './src/utils/contrast.js';
import contrastAPCA from './src/utils/contrastAPCA.js';
import deltaE from './src/utils/delta-e.js';
import distance from './src/utils/distance.js';
import { limits } from './src/utils/analyze.js';
Expand All @@ -65,6 +66,7 @@ Object.assign(chroma, {
Color,
colors,
contrast,
contrastAPCA,
cubehelix,
deltaE,
distance,
Expand All @@ -89,6 +91,7 @@ export {
Color,
colors,
contrast,
contrastAPCA,
cubehelix,
deltaE,
distance,
Expand Down
2 changes: 2 additions & 0 deletions index.umd.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import scale from './src/generator/scale.js';
// other utility methods
import { analyze } from './src/utils/analyze.js';
import contrast from './src/utils/contrast.js';
import contrastAPCA from './src/utils/contrastAPCA.js';
import deltaE from './src/utils/delta-e.js';
import distance from './src/utils/distance.js';
import { limits } from './src/utils/analyze.js';
Expand All @@ -81,6 +82,7 @@ Object.assign(chroma, {
Color,
colors,
contrast,
contrastAPCA,
cubehelix,
deltaE,
distance,
Expand Down
68 changes: 68 additions & 0 deletions src/utils/contrastAPCA.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import Color from '../Color.js';
import mix from '../generator/mix.js';

/**
* @license
*
* The APCA contrast prediction algorithm is based of the formulas published
* in the APCA-1.0.98G specification by Myndex. The specification is available at:
* https://raw.githubusercontent.com/Myndex/apca-w3/master/images/APCAw3_0.1.17_APCA0.0.98G.svg
*
* Note that the APCA implementation is still beta, so please update to
* future versions of chroma.js when they become available.
*
* You can read more about the APCA Readability Criterion at
* https://readtech.org/ARC/
*/

// constants
const W_offset = 0.027;
const P_in = 0.0005;
const P_out = 0.1;
const R_scale = 1.14;
const B_threshold = 0.022;
const B_exp = 1.414;

export default (text, bg) => {
// parse input colors
text = new Color(text);
bg = new Color(bg);
// if text color has alpha, blend against background
if (text.alpha() < 1) {
text = mix(bg, text, text.alpha(), 'rgb');
}
const l_text = lum(...text.rgb());
const l_bg = lum(...bg.rgb());

// soft clamp black levels
const Y_text =
l_text >= B_threshold
? l_text
: l_text + Math.pow(B_threshold - l_text, B_exp);
const Y_bg =
l_bg >= B_threshold ? l_bg : l_bg + Math.pow(B_threshold - l_bg, B_exp);

// normal polarity (dark text on light background)
const S_norm = Math.pow(Y_bg, 0.56) - Math.pow(Y_text, 0.57);
// reverse polarity (light text on dark background)
const S_rev = Math.pow(Y_bg, 0.65) - Math.pow(Y_text, 0.62);
// clamp noise then scale
const C =
Math.abs(Y_bg - Y_text) < P_in
? 0
: Y_text < Y_bg
? S_norm * R_scale
: S_rev * R_scale;
// clamp minimum contrast then offset
const S_apc = Math.abs(C) < P_out ? 0 : C > 0 ? C - W_offset : C + W_offset;
// scale to 100
return S_apc * 100;
};

function lum(r, g, b) {
return (
0.2126729 * Math.pow(r / 255, 2.4) +
0.7151522 * Math.pow(g / 255, 2.4) +
0.072175 * Math.pow(b / 255, 2.4)
);
}
31 changes: 31 additions & 0 deletions test/contrast.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, it, expect } from 'vitest';
import contrastAPCA from '../src/utils/contrastAPCA.js';
import chroma from 'chroma-js';

const contrast = chroma.contrast;
Expand All @@ -24,3 +25,33 @@ describe('Testing contrast ratio', () => {
expect(contrast('black', '#222').toFixed(2)).toBe('1.32');
});
});

describe('Testing contrast ratio with APCA', () => {
it('maximum contrast', () => {
expect(+contrastAPCA('black', 'white').toFixed(1)).toBe(106);
expect(+contrastAPCA('white', 'black').toFixed(1)).toBe(-107.9);
});

it('minimum contrast', () => {
expect(+contrastAPCA('gray', 'gray').toFixed(1)).toBe(0);
});

it('contrast without alpha', () => {
expect(+contrastAPCA('#594d45', '#ffd4d4').toFixed(1)).toBe(69.0);
expect(+contrastAPCA('#b04646', '#d6d6d6').toFixed(1)).toBe(52.9);
expect(+contrastAPCA('#c2afaf', '#d6d6d6').toFixed(1)).toBe(17.0);
});

it('contrast with alpha', () => {
// todo: there's a slight difference to the values shown in the APCA demo
// when computing contrast between colors with alpha
expect(+contrastAPCA('#00000044', 'white').toFixed(1)).toBe(37.3); // 36.7
expect(+contrastAPCA('#ffffffc0', 'black').toFixed(1)).toBe(-68); // -68.6
});

it('inverse contrast', () => {
expect(+contrastAPCA('#f5f5b3', '#614f63').toFixed(1)).toBe(-81.7);
expect(+contrastAPCA('#67a7d6', '#4f6357').toFixed(1)).toBe(-30.5);
expect(+contrastAPCA('#d667cb', '#b04646').toFixed(1)).toBe(-17.9);
});
});

0 comments on commit fb4bdf2

Please sign in to comment.