pixl.ink
v1.0.1
Published
A battery-included color space / color model library for JavaScript.
Readme
Color Encyclopedia
A battery-included color space / color model library for JavaScript.
- Dozens of RGB spaces (sRGB, Rec.709/2020, Display-P3, Adobe RGB, ACES, camera gamuts, ...)
- Perceptual spaces (CIELAB/LCh, CIELUV, DIN99/99o, OKLab/OKLCh, ProLab, SRLab2, ...)
- Appearance models (CAM02, CAM16, Hellwig 2022, ZCAM)
- HDR encodings (ICtCp, ICaCb, JzAzBz/JzCzHz, Rec.2100 PQ/HLG, scRGB, XYB, ...)
- UI-oriented models (HSL/HSV/HWB/HSI, OKHSL/OKHSV, HPLuv/HSLuv, RYB, Prismatic, NCS, ...)
- Notation systems and chromaticity spaces (Munsell, NCS, xyY, CIE 1960 UCS, UVW, ...)
- Color-vision simulation, perceptibility checks, whitepoint utilities
You can see the library in action in the color picker/encyclopedia at: https://pixl.ink/
Installation
Available on npm: https://www.npmjs.com/package/pixl.ink
npm install pixl.inkThis package has a single runtime dependency:
Quick start
import { spaces } from "pixl.ink";
// sRGB (gamma-encoded) -> XYZ
const redXyz = spaces.srgb.from({ r: 1, g: 0, b: 0 });
// XYZ -> OKLCh (perceptual polar space)
const oklch = spaces.oklch.to(redXyz);
// { l: ~0.628, c: ~0.644, h: ~0.081 } (all normalized, see below)
// Modify chroma & convert back to sRGB
const moreChroma = { ...oklch, c: Math.min(oklch.c * 1.2, 1) };
const xyz2 = spaces.oklch.from(moreChroma);
const srgb2 = spaces.srgb.to(xyz2);
// srgb2 = { r: 1, g: ~0.1, b: ~0.0 }
// Format as CSS color()
const css = spaces.displayp3.format(
spaces.displayp3.to(xyz2)
);
// "color(display-p3 0.942 0.184 0.108)"Key idea: every conversion goes through CIE 1931 XYZ (D65 / 2°). You never call XYZ yourself unless you want to.
Core exports
From the package root (index.js):
import {
spaces,
cvd,
whites,
isColorPerceivable,
getForegroundColor,
SPECTRAL_LOCUS,
symbols,
tags,
} from "pixl.ink";spaces
spaces is a map of all implemented spaces:
Object.keys(spaces);
// ["xyz", "srgb", "rec709", "rec2020", "oklab", "oklch", "cam16", "jzazbz", ...]Each entry is a space object of the form:
type Space = {
name: string; // short human-readable name
long: string; // long description
css: string; // CSS identifier where applicable
tags: string[]; // categories, e.g. ["device_rgb", "wide_gamut"]
base?: string; // lineage, e.g. "CIE 1931 XYZ"
ui: Record<string, {
from: number;
to: number;
step: number;
round: number;
name: string;
primary?: boolean;
}>;
// optional
options?: Record<string, OptionSpec>;
bake?: (provided?: Partial<Options>) => any;
format?: (native: any) => string;
expected?: Record<string, any>;
lossy?: boolean;
unbounded?: boolean;
// conversions (see next section)
from(native: any, out?: XYZ, params?: any): XYZ;
to(xyz: XYZ, out?: any, unclamped?: boolean, params?: any): any;
};Space API: from / to
Every space provides the same basic API:
space.from(native, out?) -> xyzspace.to(xyz, out?, unclamped?, params?) -> native
Where:
native= the space's own coordinate object:- sRGB:
{ r, g, b } - CIELAB:
{ l, a, b }(normalized) - OKLCh:
{ l, c, h }(normalized) - etc.
- sRGB:
xyz={ x, y, z }in the fixed D65 / 2° intermediate.outis optional and lets you reuse objects / arrays.unclamped(bool) controls whetherto()clamps channels to[0,1].paramsis an optional baked parameter object for configurable spaces (see below).
Example: XYZ -> CAM16 JMh with custom viewing conditions:
const cam16 = spaces.cam16;
// 1) Bake viewing conditions
const params = cam16.bake({
whitepoint: "D65",
observer: "2",
adaptingLuminance: 64 / Math.PI * 0.2,
backgroundLuminance: 20,
surround: "average",
discounting: false,
});
// 2) Convert XYZ -> CAM16
const jmh = cam16.to(xyz, {}, true, params);
// jmh = { j, m, h } in [0,1] transport units
// 3) Back to XYZ
const xyzBack = cam16.from(jmh, {}, params);Transport normalization & transfer encoding
1. Transport normalization (0-1 channels)
All to() and from() methods work on normalized channels in [0,1], regardless of the physical units in the spec.
Examples:
CIELAB
- Spec units:
L* ∈ [0,100],a*,b* ~ [-130,130]. - Library units:
lab.lisL*/100lab.ais(a* / 260) + 0.5lab.bis(b* / 260) + 0.5
// Neutral gray L* = 50, a* = 0, b* = 0 const lab = spaces.cielab.to(xyz); // lab ~ { l: 0.5, a: 0.5, b: 0.5 } const realL = lab.l * 100; const reala = (lab.a - 0.5) * 260; const realb = (lab.b - 0.5) * 260;- Spec units:
OKLCh
- Spec:
L ∈ [0,1],Croughly[0,0.4],hin degrees. - Library:
lis unchangedcisC / 0.4hishDeg / 360
- Spec:
sRGB
- Spec: R'G'B' in
[0,1]gamma-encoded. - Library: inputs/outputs are
[0,1]as well.
- Spec: R'G'B' in
The ui ranges are metadata for tools built on top (like the demo at https://pixl.ink/) and do not change the math. They describe how to present each channel (ranges, steps, display precision), not what from/to accept.
2. Transfer functions (TRCs, log curves, HDR encodings)
Spaces that are not linear (sRGB, Rec.709, AdobeRGB, ACEScc, log encodings, PQ/HLG, etc.) bake the transfer function into from/to:
Device RGB with gamma/segment TRCs
// Rec.709 encoded RGB -> XYZ const xyz709 = spaces.rec709.from({ r: 0.5, g: 0.5, b: 0.5 }); // internally: // - rec709ToLinear() per channel // - multiply by Rec.709 primaries -> XYZ // XYZ -> encoded Rec.709 const rgb709 = spaces.rec709.to(xyz709); // internally: // - XYZ -> linear RGB // - linearToRec709() per channelACEScc / ACEScct
These are log encodings of ACES AP1 linear light:
// ACEScc normalized channels in [0,1] const xyzFromAcescc = spaces.acescc.from({ r: 0.5, g: 0.5, b: 0.5 }); // internally: // - map [0,1] -> code value range [CC_MIN, CC_MAX] // - acesccToLinear() // - AP1 matrix -> XYZ const acescc = spaces.acescc.to(xyzFromAcescc); // - XYZ -> AP1 linear // - linearToAcescc() // - map code range back to [0,1]HDR encodings
rec2100pquses ST.2084 PQ with BT.2020 primaries.rec2100hlguses HLG transfer with BT.2020 primaries.ictcp,icacb,jzazbz,zcamcombine PQ/HLG, cone/LMS transforms, and opponent axes.
You never have to manually gamma-decode or apply PQ/HLG: pass the encoded signal to from, get encoded back from to.
Clamping, unbounded & lossy spaces
Many to() implementations are of the form:
space.to = (xyz, out = {}, unclamped = false, params = defaults) => {
...
out.r = clamp(rawR, 0, 1, unclamped);
...
};unclamped = false(default): values are clamped to[0,1].unclamped = true: no clamping; useful when you want:- encoded values outside nominal range (e.g., oversaturated wide-gamut).
- to inspect how far a color is out of gamut.
Some spaces also have flags:
space.unbounded = trueSpace is conceptually unbounded (e.g. CIE RGB, LMS cones, Kubelka-Munk K/S). Expect values outside[0,1]for real data.space.lossy = trueRound-trip XYZ -> space -> XYZ isn't exact by design (RYB approximation, NCS, some appearance models).
Configurable spaces: options & bake
Spaces like CIELAB, CIELUV, CAM02, CAM16, ZCAM, RLAB, HunterLab, etc. support user-selectable whitepoints, observers and viewing conditions.
Pattern:
const lab = spaces.cielab;
// Inspect available options
console.log(lab.options);
// { whitepoint: { type: "enum", ... }, observer: { type: "enum", ... } }
// Bake once and reuse
const params = lab.bake({ whitepoint: "D50", observer: "2" });
// Use params for conversions
const labColor = lab.to(xyz, {}, true, params);
const xyzBack = lab.from(labColor, {}, params);Details:
optionsis a schema:type: "number" | "boolean" | "enum"- with
min/maxorallowed, anddefault.
bake(providedOptions):- merges user options with defaults via
resolveOptions. - precomputes matrices and constants (whitepoint XYZ, CAT02/CAT16 adaptation, etc.).
- returns an opaque
paramsobject to pass intofrom/to.
- merges user options with defaults via
This keeps the heavy math out of the hot path; you bake once per configuration.
Other helpers
Color-vision deficiency simulation: cvd
import { cvd } from "pixl.ink";
const original = { r: 1, g: 0.5, b: 0 }; // sRGB in [0,1]
// Simulate protanopia
const protan = {};
cvd.simulate(protan, original, "protanopia");
console.log(protan); // adjusted sRGB triple
// Available modes & descriptions
console.log(cvd.modes);
/*
{
none: { name, description },
protanopia: { ... },
deuteranopia:{ ... },
tritanopia: { ... },
protanomaly, deuteranomaly, tritanomaly,
s_cone_monochromacy, l_cone_monochromacy, m_cone_monochromacy,
achromatopsia, achromatomaly
}
*/- Input and output are gamma-encoded sRGB in
[0,1]. - Internally, simulation operates in linear RGB / LMS as appropriate.
Perception & spectral locus
import { isColorPerceivable, SPECTRAL_LOCUS } from "pixl.ink";
const result = isColorPerceivable({ x: 0.3, y: 0.3, z: 0.3 });
/*
{
isVisible: true | false,
reason: "Within human perception" | "Outside human visual gamut" | ...
}
*/
// SPECTRAL_LOCUS is an array of [x, y] xy-chromaticity points around the spectral locus.Foreground text color
import { getForegroundColor } from "pixl.ink";
const bg = { r: 0.2, g: 0.1, b: 0.8 }; // sRGB in [0,1]
const fgName = getForegroundColor(bg); // "white" or "black"This uses WCAG-style relative luminance and contrast ratio to pick a high-contrast foreground.
Whitepoints
import { whites } from "pixl.ink";
console.log(whites.descriptions.D65);The whitepoint system uses programmatically computed chromaticities rather than hardcoded 2° values:
- D-series and Planckian sources are calculated from temperature using standard approximations (see
points.js), improving accuracy. - Additional whitepoints including more LEDs and indoor daylight variants.
- Spaces that depend on LMS HPE cone fundamentals use the Hunt-Pointer-Estevez version rather than the Stockman & Sharpe set. This matches common practice in many color appearance models, but may not be entirely biologically accurate.
Underlying utilities (getWhitepointXYZ, etc.) live in ./whites/points.js.
Examples
sRGB -> Display-P3 -> OKLCh
const { srgb, displayp3, oklch } = spaces;
// Hex to XYZ via sRGB
function hexToXyz(hex) {
const n = hex.replace("#", "");
const r = parseInt(n.slice(0, 2), 16) / 255;
const g = parseInt(n.slice(2, 4), 16) / 255;
const b = parseInt(n.slice(4, 6), 16) / 255;
return srgb.from({ r, g, b });
}
const xyz = hexToXyz("#ff00ff");
// XYZ -> Display P3 (encoded)
const p3 = displayp3.to(xyz);
// { r,g,b ∈ [0,1] with sRGB TRC over P3 primaries }
// XYZ -> OKLCh
const lcH = oklch.to(xyz);
// Adjust hue, clamp, etc.Using unclamped outputs for gamut inspection
// XYZ well outside sRGB
const crazyXyz = { x: 0.7, y: 0.7, z: 0.1 };
const s1 = spaces.srgb.to(crazyXyz); // clamped by default
const s2 = spaces.srgb.to(crazyXyz, {}, true); // unclamped, may have <0 or >1
console.log(s1, s2);ACEScg linear pipeline
const { acescg, aces2065, srgb, linearrgb } = spaces;
// sRGB (encoded) -> XYZ -> ACEScg (AP1 linear)
const xyz = srgb.from({ r: 0.8, g: 0.6, b: 0.1 });
const ap1 = acescg.to(xyz); // linear AP1 RGB
// ACEScg -> ACES 2065-1 (AP0, adapted D60->D65)
const xyzFromAp1 = acescg.from(ap1);
const ap0 = aces2065.to(xyzFromAp1);
// back to linear sRGB
const linearSrgb = linearrgb.to(xyzFromAp1);Tags, symbols & metadata
For tooling or documentation, the library exposes some classification helpers:
import { tags, symbols } from "pixl.ink";
console.log(tags.perceptual_uniform);
// { label: "Perceptual", description: "Distances approximately match perceived color differences ..." }
console.log(symbols.l, symbols.h, symbols.Cz);
// "𝑙", "ℎ", "𝐶𝑧" - useful for axis labels, legends, etc.Each space's tags and base fields are purely descriptive; they don't change behavior but are handy for building filtered lists or grouped documentation.
Testing & reference data
The repository includes a comprehensive test suite (index.test.js) that verifies:
- Every space exports the required fields and functions.
- For spaces with
options,bake()works and returns a parameter object. - For non-lossy spaces, XYZ -> space -> XYZ round-trips within tolerance.
expectedblocks are checked against independent reference values for a set of canonical sRGB hex colors.
This is why you see large expected: { "#FFFFFF": { ... } } tables in each space file: those are normalized transport values used for regression tests, not hand-wavy examples.
Notes & design choices
- XYZ anchor: Everything goes through D65/2° CIE XYZ. Spaces with other whites (Rec.601, NTSC, ProPhoto, DCI-P3, etc.) use Bradford or CAT02/CAT16-style adaptation internally.
- Whitepoints: Computed dynamically (D-series daylight, Planckian locus, etc.) with more illuminants included.
- Manual memory management: Hot-path math uses small object/array pools (
alloc3/free3, etc.) instead of allocating new arrays every time. This gives predictable performance and avoids GC spikes when doing a lot of conversions. - No global state: Viewing conditions and whitepoints are always passed explicitly via
bake()output. There is no global "set whitepoint" knob that silently changes other spaces.
License & contributions
This library is licensed under GPL v3.0. See LICENSE for full terms.
If you add a new space:
- Follow the same
export default { ... }contract. - Implement
fromandtoagainst XYZ (D65/2°). - Add
uimetadata andtags/base. - Provide an
expectedtable from an independent reference. - Run tests.
PRs are welcome.
