diff --git a/docs/api.md b/docs/api.md
index 56eec372..5fbdb7c6 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -1547,6 +1547,7 @@ Mode | Color space | Definition object
`hsl` | HSL color space | `modeHsl`
`hsv` | HSV color space | `modeHsv`
`hwb` | HWB color space | `modeHwb`
+`itp` | ICtCp color space | `modeItp`
`jab` | Jzazbz color space | `modeJab`
`jch` | Jzazbz in cylindrical form | `modeJch`
`lab` | CIELAB color space (D50 Illuminant) | `modeLab`
diff --git a/docs/color-spaces.md b/docs/color-spaces.md
index cc53d1e8..d7d37263 100644
--- a/docs/color-spaces.md
+++ b/docs/color-spaces.md
@@ -438,7 +438,21 @@ It has the default _Chroma from Luma_ adjustment applied (effectively Y is subtr
| ------- | ------------- | ----------- |
| `x` | `[-0.0154, 0.0281]`≈ | Cyan-red component |
| `y` | `[0, 0.8453]`≈ | Luma |
-| `b` | `[ -0.2778, 0.3880 ]` ≈ | Blue-yellow component |
+| `b` | `[ -0.2778, 0.3880 ]`≈ | Blue-yellow component |
+
+Does not have gamut limits.
+
+#### `itp`
+
+ICtCp (or ITP) color space, as defined in ITU-R Recommendation BT.2100.
+
+| Channel | Range | Description |
+|---------|-------------------|-----------------------|
+| `i` | `[0, 0.581]`≈ | Intensity |
+| `t` | `[-0.282, 0.278]`≈ | Blue-yellow component |
+| `p` | `[-0.162, 0.279]`≈ | Green–red component |
+
+Serialized as `color(--ictcp i t p)`, with the `none` keyword for any missing color channel. An explicit `alpha < 1` is included as ` / alpha`.
Does not have gamut limits.
diff --git a/docs/guides/tree-shaking.md b/docs/guides/tree-shaking.md
index ec1f750c..e58bc5dc 100644
--- a/docs/guides/tree-shaking.md
+++ b/docs/guides/tree-shaking.md
@@ -125,7 +125,7 @@ interpolate(['red', 'green'], 'lch');
Bootstrap all the color spaces available in Culori.
-It provides the following named exports: `a98`, `cubehelix`, `dlab`, `dlch`, `hsi`, `hsl`, `hsv`, `hwb`, `jab`, `jch`, `lab`, `lab65`, `lch`, `lch65`, `lchuv`, `lrgb`, `luv`, `okhsl`, `okhsv`, `oklab`, `oklch`, `p3`, `prophoto`, `rec2020`, `rgb`, `xyb`, `xyz50`, `xyz65`, and `yiq`.
+It provides the following named exports: `a98`, `cubehelix`, `dlab`, `dlch`, `hsi`, `hsl`, `hsv`, `hwb`, `itp`, `jab`, `jch`, `lab`, `lab65`, `lch`, `lch65`, `lchuv`, `lrgb`, `luv`, `okhsl`, `okhsv`, `oklab`, `oklch`, `p3`, `prophoto`, `rec2020`, `rgb`, `xyb`, `xyz50`, `xyz65`, and `yiq`.
```js
import 'culori/all';
diff --git a/src/bootstrap/all.js b/src/bootstrap/all.js
index acf1fc5a..ec3e5d32 100644
--- a/src/bootstrap/all.js
+++ b/src/bootstrap/all.js
@@ -7,6 +7,7 @@ import modeHsi from '../hsi/definition.js';
import modeHsl from '../hsl/definition.js';
import modeHsv from '../hsv/definition.js';
import modeHwb from '../hwb/definition.js';
+import modeItp from '../itp/definition.js';
import modeJab from '../jab/definition.js';
import modeJch from '../jch/definition.js';
import modeLab from '../lab/definition.js';
@@ -38,6 +39,7 @@ export const hsi = useMode(modeHsi);
export const hsl = useMode(modeHsl);
export const hsv = useMode(modeHsv);
export const hwb = useMode(modeHwb);
+export const itp = useMode(modeItp);
export const jab = useMode(modeJab);
export const jch = useMode(modeJch);
export const lab = useMode(modeLab);
diff --git a/src/index-fn.js b/src/index-fn.js
index c09f8b9b..35f3f41d 100644
--- a/src/index-fn.js
+++ b/src/index-fn.js
@@ -7,6 +7,7 @@ export { default as modeHsi } from './hsi/definition.js';
export { default as modeHsl } from './hsl/definition.js';
export { default as modeHsv } from './hsv/definition.js';
export { default as modeHwb } from './hwb/definition.js';
+export { default as modeItp } from './itp/definition.js';
export { default as modeJab } from './jab/definition.js';
export { default as modeJch } from './jch/definition.js';
export { default as modeLab } from './lab/definition.js';
@@ -171,6 +172,7 @@ export { default as convertHsiToRgb } from './hsi/convertHsiToRgb.js';
export { default as convertHslToRgb } from './hsl/convertHslToRgb.js';
export { default as convertHsvToRgb } from './hsv/convertHsvToRgb.js';
export { default as convertHwbToRgb } from './hwb/convertHwbToRgb.js';
+export { default as convertItpToXyz65 } from './itp/convertItpToXyz65.js';
export { default as convertJabToJch } from './jch/convertJabToJch.js';
export { default as convertJabToRgb } from './jab/convertJabToRgb.js';
export { default as convertJabToXyz65 } from './jab/convertJabToXyz65.js';
@@ -212,6 +214,7 @@ export { default as convertRgbToYiq } from './yiq/convertRgbToYiq.js';
export { default as convertRgbToXyb } from './xyb/convertRgbToXyb.js';
export { default as convertXybToRgb } from './xyb/convertXybToRgb.js';
export { default as convertXyz65ToA98 } from './a98/convertXyz65ToA98.js';
+export { default as convertXyz65ToItp } from './itp/convertXyz65ToItp.js';
export { default as convertXyz65ToJab } from './jab/convertXyz65ToJab.js';
export { default as convertXyz65ToLab65 } from './lab65/convertXyz65ToLab65.js';
export { default as convertXyz65ToP3 } from './p3/convertXyz65ToP3.js';
diff --git a/src/index.js b/src/index.js
index 0de325d7..83ca714b 100644
--- a/src/index.js
+++ b/src/index.js
@@ -7,6 +7,7 @@ import modeHsi from './hsi/definition.js';
import modeHsl from './hsl/definition.js';
import modeHsv from './hsv/definition.js';
import modeHwb from './hwb/definition.js';
+import modeItp from './itp/definition.js';
import modeJab from './jab/definition.js';
import modeJch from './jch/definition.js';
import modeLab from './lab/definition.js';
@@ -172,6 +173,7 @@ export { default as convertHsiToRgb } from './hsi/convertHsiToRgb.js';
export { default as convertHslToRgb } from './hsl/convertHslToRgb.js';
export { default as convertHsvToRgb } from './hsv/convertHsvToRgb.js';
export { default as convertHwbToRgb } from './hwb/convertHwbToRgb.js';
+export { default as convertItpToXyz65 } from './itp/convertItpToXyz65.js';
export { default as convertJabToJch } from './jch/convertJabToJch.js';
export { default as convertJabToRgb } from './jab/convertJabToRgb.js';
export { default as convertJabToXyz65 } from './jab/convertJabToXyz65.js';
@@ -218,6 +220,7 @@ export { default as convertXyz50ToProphoto } from './prophoto/convertXyz50ToProp
export { default as convertXyz50ToRgb } from './xyz50/convertXyz50ToRgb.js';
export { default as convertXyz50ToXyz65 } from './xyz65/convertXyz50ToXyz65.js';
export { default as convertXyz65ToA98 } from './a98/convertXyz65ToA98.js';
+export { default as convertXyz65ToItp } from './itp/convertXyz65ToItp.js';
export { default as convertXyz65ToJab } from './jab/convertXyz65ToJab.js';
export { default as convertXyz65ToLab65 } from './lab65/convertXyz65ToLab65.js';
export { default as convertXyz65ToP3 } from './p3/convertXyz65ToP3.js';
@@ -235,6 +238,7 @@ export {
modeHsl,
modeHsv,
modeHwb,
+ modeItp,
modeJab,
modeJch,
modeLab,
@@ -266,6 +270,7 @@ export const hsi = useMode(modeHsi);
export const hsl = useMode(modeHsl);
export const hsv = useMode(modeHsv);
export const hwb = useMode(modeHwb);
+export const itp = useMode(modeItp);
export const jab = useMode(modeJab);
export const jch = useMode(modeJch);
export const lab = useMode(modeLab);
diff --git a/src/itp/constants.js b/src/itp/constants.js
new file mode 100644
index 00000000..d20d1191
--- /dev/null
+++ b/src/itp/constants.js
@@ -0,0 +1,16 @@
+// PQ Constants
+// https://en.wikipedia.org/wiki/High-dynamic-range_video#Perceptual_quantizer
+export const M1 = 2610 / 16384;
+export const M2 = 2523 / 32;
+export const IM1 = 16384 / 2610;
+export const IM2 = 32 / 2523;
+export const C1 = 3424 / 4096;
+export const C2 = 2413 / 128;
+export const C3 = 2392 / 128;
+
+// Maximum luminance in PQ is 10,000 cd/m^2
+// Relative XYZ has Y=1 for media white
+// BT.2048 says media white Y=203 at PQ 58
+//
+// This is confirmed here: https://www.itu.int/dms_pub/itu-r/opb/rep/R-REP-BT.2408-3-2019-PDF-E.pdf
+export const YW = 203;
diff --git a/src/itp/convertItpToXyz65.js b/src/itp/convertItpToXyz65.js
new file mode 100644
index 00000000..ba3e5ebb
--- /dev/null
+++ b/src/itp/convertItpToXyz65.js
@@ -0,0 +1,27 @@
+import { YW } from './constants.js';
+import { transferPqDecode } from '../hdr/transfer.js';
+
+const convertItpToXyz65 = ({ i, t, p, alpha }) => {
+ const [l, m, s] = [
+ i + 0.008609037037932761 * t + 0.11102962500302593 * p,
+ i - 0.00860903703793275 * t - 0.11102962500302599 * p,
+ i + 0.5600313357106791 * t - 0.32062717498731885 * p
+ ].map(transferPqDecode);
+
+ const [x, y, z] = [
+ 2.0701522183894219 * l -
+ 1.3263473389671556 * m +
+ 0.2066510476294051 * s,
+ 0.3647385209748074 * l + 0.680566024947227 * m - 0.0453045459220346 * s,
+ -0.049747207535812 * l - 0.0492609666966138 * m + 1.1880659249923042 * s
+ ].map(c => Math.max(c / YW, 0));
+
+ const res = { mode: 'xyz65', x, y, z };
+ if (alpha !== undefined) {
+ res.alpha = alpha;
+ }
+
+ return res;
+};
+
+export default convertItpToXyz65;
diff --git a/src/itp/convertXyz65ToItp.js b/src/itp/convertXyz65ToItp.js
new file mode 100644
index 00000000..39eed1ca
--- /dev/null
+++ b/src/itp/convertXyz65ToItp.js
@@ -0,0 +1,30 @@
+import { YW } from './constants.js';
+import { transferPqEncode } from '../hdr/transfer.js';
+
+const convertXyz65ToItp = ({ x, y, z, alpha }) => {
+ const [absX, absY, absZ] = [x, y, z].map(c => Math.max(c * YW, 0));
+ const [l, m, s] = [
+ 0.3592832590121217 * absX +
+ 0.6976051147779502 * absY -
+ 0.0358915932320289 * absZ,
+ -0.1920808463704995 * absX +
+ 1.1004767970374323 * absY +
+ 0.0753748658519118 * absZ,
+ 0.0070797844607477 * absX +
+ 0.0748396662186366 * absY +
+ 0.8433265453898765 * absZ
+ ].map(transferPqEncode);
+
+ const i = 0.5 * l + 0.5 * m;
+ const t = 1.61376953125 * l + -3.323486328125 * m + 1.709716796875 * s;
+ const p = 4.378173828125 * l + -4.24560546875 * m + -0.132568359375 * s;
+
+ const res = { mode: 'itp', i, t, p };
+ if (alpha !== undefined) {
+ res.alpha = alpha;
+ }
+
+ return res;
+};
+
+export default convertXyz65ToItp;
diff --git a/src/itp/definition.js b/src/itp/definition.js
new file mode 100644
index 00000000..9bd7d9e7
--- /dev/null
+++ b/src/itp/definition.js
@@ -0,0 +1,44 @@
+import { interpolatorLinear } from '../interpolate/linear.js';
+import { fixupAlpha } from '../fixup/alpha.js';
+import convertItpToXyz65 from './convertItpToXyz65.js';
+import convertXyz65ToItp from './convertXyz65ToItp.js';
+import { convertRgbToXyz65, convertXyz65ToRgb } from '../index.js';
+
+/*
+ ICtCp (or ITP) color space, as defined in ITU-R Recommendation BT.2100.
+
+ ICtCp is drafted to be supported in CSS within
+ [CSS Color HDR Module Level 1](https://drafts.csswg.org/css-color-hdr/#ICtCp) spec.
+*/
+
+const definition = {
+ mode: 'itp',
+ channels: ['i', 't', 'p', 'alpha'],
+ parse: ['--ictcp'],
+ serialize: '--ictcp',
+
+ toMode: {
+ xyz65: convertItpToXyz65,
+ rgb: color => convertXyz65ToRgb(convertItpToXyz65(color))
+ },
+
+ fromMode: {
+ xyz65: convertXyz65ToItp,
+ rgb: color => convertXyz65ToItp(convertRgbToXyz65(color))
+ },
+
+ ranges: {
+ i: [0, 0.581],
+ t: [-0.369, 0.272],
+ p: [-0.164, 0.331]
+ },
+
+ interpolate: {
+ i: interpolatorLinear,
+ t: interpolatorLinear,
+ p: interpolatorLinear,
+ alpha: { use: interpolatorLinear, fixup: fixupAlpha }
+ }
+};
+
+export default definition;
diff --git a/test/api.test.js b/test/api.test.js
index 0b6f2a4a..b04a0ef8 100644
--- a/test/api.test.js
+++ b/test/api.test.js
@@ -27,6 +27,7 @@ const API_FULL = [
'convertHslToRgb',
'convertHsvToRgb',
'convertHwbToRgb',
+ 'convertItpToXyz65',
'convertJabToJch',
'convertJabToRgb',
'convertJabToXyz65',
@@ -73,6 +74,7 @@ const API_FULL = [
'convertXyz50ToRgb',
'convertXyz50ToXyz65',
'convertXyz65ToA98',
+ 'convertXyz65ToItp',
'convertXyz65ToJab',
'convertXyz65ToLab65',
'convertXyz65ToP3',
@@ -125,6 +127,7 @@ const API_FULL = [
'hsl',
'hsv',
'hwb',
+ 'itp',
'inGamut',
'interpolate',
'interpolateWith',
@@ -161,6 +164,7 @@ const API_FULL = [
'modeHsl',
'modeHsv',
'modeHwb',
+ 'modeItp',
'modeJab',
'modeJch',
'modeLab',
@@ -253,6 +257,7 @@ const API_ALL = [
'hsl',
'hsv',
'hwb',
+ 'itp',
'jab',
'jch',
'lab',
@@ -294,6 +299,7 @@ const API_FN = [
'convertHslToRgb',
'convertHsvToRgb',
'convertHwbToRgb',
+ 'convertItpToXyz65',
'convertJabToJch',
'convertJabToRgb',
'convertJabToXyz65',
@@ -340,6 +346,7 @@ const API_FN = [
'convertXyz50ToRgb',
'convertXyz50ToXyz65',
'convertXyz65ToA98',
+ 'convertXyz65ToItp',
'convertXyz65ToJab',
'convertXyz65ToLab65',
'convertXyz65ToP3',
@@ -412,6 +419,7 @@ const API_FN = [
'modeHsl',
'modeHsv',
'modeHwb',
+ 'modeItp',
'modeJab',
'modeJch',
'modeLab',
diff --git a/test/itp.test.js b/test/itp.test.js
new file mode 100644
index 00000000..7a2958aa
--- /dev/null
+++ b/test/itp.test.js
@@ -0,0 +1,76 @@
+import tape from 'tape';
+import { formatCss, formatHex, itp } from '../src/index.js';
+
+tape('itp', t => {
+ t.deepEqual(
+ itp('white'),
+ {
+ mode: 'itp',
+ i: 0.5806888810416109,
+ t: 1.1102230246251565e-16,
+ p: 2.914335439641036e-16
+ },
+ 'white'
+ );
+ t.deepEqual(
+ itp('black'),
+ {
+ mode: 'itp',
+ i: 7.309559025783966e-7,
+ t: -2.117582368135751e-22,
+ p: 1.3234889800848443e-23
+ },
+ 'black'
+ );
+ t.deepEqual(
+ itp('red'),
+ {
+ mode: 'itp',
+ i: 0.4278802843622844,
+ t: -0.11570435976969046,
+ p: 0.27872894737532694
+ },
+ 'red'
+ );
+ t.end();
+});
+
+tape('color(--ictcp)', t => {
+ t.deepEqual(itp('color(--ictcp 1 0 0 / 0.25)'), {
+ mode: 'itp',
+ i: 1,
+ t: 0,
+ p: 0,
+ alpha: 0.25
+ });
+ t.deepEqual(itp('color(--ictcp 0% 50% 0.5 / 25%)'), {
+ mode: 'itp',
+ i: 0,
+ t: 0.5,
+ p: 0.5,
+ alpha: 0.25
+ });
+ t.end();
+});
+
+tape('formatCss', t => {
+ t.equal(
+ formatCss('color(--ictcp 0% 50% 0.5 / 25%)'),
+ 'color(--ictcp 0 0.5 0.5 / 0.25)'
+ );
+ t.end();
+});
+
+tape('formatCss', t => {
+ t.equal(
+ formatHex({
+ mode: 'itp',
+ i: 0.4278802843622844,
+ t: -0.11570435976969046,
+ p: 0.27872894737532694
+ }),
+ '#ff0000',
+ 'red'
+ );
+ t.end();
+});
diff --git a/tools/ranges.js b/tools/ranges.js
index fc706f34..f420c593 100644
--- a/tools/ranges.js
+++ b/tools/ranges.js
@@ -5,7 +5,7 @@ import { converter, getMode } from '../src/index.js';
for a particular color space, by converting lots of
RGB colors to that space.
*/
-let ranges = (mode, step = 0.01) => {
+let ranges = (mode, step = 1 / 128) => {
let conv = converter(mode);
let chs = getMode(mode).channels;
let res = chs.reduce(
@@ -32,4 +32,4 @@ let ranges = (mode, step = 0.01) => {
return res;
};
-console.log(ranges(process.argv[2], 0.0025));
+console.log(ranges(process.argv[2], 1 / 512));