charts.color
Hex colours to RGB and back, and sRGB channels to linear light and back, identically in every language.
1.0.1 · published 2026-10-03 by charlie · Anterra
Pinned by 39 tests, run in TypeScript, Python and Rust.parseHex 9 · toHex 9 · srgbToLinear 8 · linearToSrgb 13
What it does
The small kit every colour calculation in `charts.*` starts from: hex text to three 8-bit channels and back (`parseHex`, `toHex`), and each channel to linear light and back (`srgbToLinear`, `linearToSrgb`). A group, because the four only make sense together and `charts.interpolate-color` and `charts.contrast` use them all.
**Hex.** `parseHex` accepts `#rrggbb` and the CSS short form `#rgb` (each digit doubled, so `#f80` is `#ff8800`), in either case. A missing `#`, an alpha channel, `rgb()` syntax or a colour name is an error, not a guess. `toHex` always writes lowercase `#rrggbb`.
The functions
A group: 4 functions that work together, each in its own file, each pinned by its own tests in TypeScript, Python and Rust. A project can install only the ones it calls.
- parseHex (hex: string) -> Rgb
- toHex (rgb: Rgb) -> string
- srgbToLinear (channel: int) -> float
- linearToSrgb (value: float) -> int
The type it declares, generated into your project
/** An sRGB colour as three 8-bit channels. */
export interface Rgb {
/** 0 to 255 */
readonly r: number;
/** 0 to 255 */
readonly g: number;
/** 0 to 255 */
readonly b: number;
}
Once installed, your code imports each one from the group's module.
parseHex throws on bad input 9 tests
export function parseHex(hex: string): Rgb
| hex | string | "#rrggbb" or "#rgb", either case |
| returns | Rgb |
For example
parseHex(#e69f00)→ r 230, g 159, b 0 six digitsparseHex(#56B4E9)→ r 86, g 180, b 233 upper caseparseHex(#f80)→ r 255, g 136, b 0 three digits double each one, as CSS does
import { parseHex } from "#fune/charts.color@^1";
import { type Rgb } from "./charts_color_types.ts";
const HEX = "0123456789abcdef";
function nibble(ch: string, hex: string): number {
const n = HEX.indexOf(ch.toLowerCase());
if (n < 0) throw new RangeError(`"${hex}" is not a hex colour (#rgb or #rrggbb)`);
return n;
}
/**
* "#rrggbb" or the short "#rgb" (each digit doubled, as CSS does), in either
* case. Anything else, including a missing "#", names or rgb() syntax, is an
* error rather than a guess.
*/
export function parseHex(hex: string): Rgb {
if (typeof hex !== "string" || hex[0] !== "#" || (hex.length !== 4 && hex.length !== 7)) {
throw new RangeError(`"${hex}" is not a hex colour (#rgb or #rrggbb)`);
}
if (hex.length === 4) {
const r = nibble(hex[1], hex);
const g = nibble(hex[2], hex);
const b = nibble(hex[3], hex);
return { r: r * 17, g: g * 17, b: b * 17 };
}
return {
r: nibble(hex[1], hex) * 16 + nibble(hex[2], hex),
g: nibble(hex[3], hex) * 16 + nibble(hex[4], hex),
b: nibble(hex[5], hex) * 16 + nibble(hex[6], hex),
};
}toHex throws on bad input 9 tests
export function toHex(rgb: Rgb): string
| rgb | Rgb | |
| returns | string | lowercase "#rrggbb" |
For example
toHex(r 230, g 159, b 0)→ #e69f00 lower case, two digits per channeltoHex(r 1, g 10, b 15)→ #010a0f single-digit channels are zero-paddedtoHex(r 255, g 255, b 255)→ #ffffff white
import { toHex } from "#fune/charts.color@^1";
import { type Rgb } from "./charts_color_types.ts";
const HEX = "0123456789abcdef";
function pair(value: number): string {
if (!Number.isInteger(value) || value < 0 || value > 255) {
throw new RangeError(`rgb channels must be whole numbers from 0 to 255, received ${value}`);
}
return HEX[Math.floor(value / 16)] + HEX[value % 16];
}
/** Lowercase "#rrggbb", the form every renderer accepts. */
export function toHex(rgb: Rgb): string {
return "#" + pair(rgb.r) + pair(rgb.g) + pair(rgb.b);
}srgbToLinear throws on bad input 8 tests
export function srgbToLinear(channel: number): number
| channel | int | 0 to 255, as stored in a hex colour |
| returns | float | linear light 0 to 1, rounded to 12 decimal places |
For example
srgbToLinear(0)→ 0 black is 0srgbToLinear(255)→ 1 white is 1srgbToLinear(1)→ 0 the darkest step is on the straight segment: 1/255/12.92
import { srgbToLinear } from "#fune/charts.color@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { pow } from "./math_pow.ts"; ← from math.pow ^1.0.0 · built alongside by fune
import { roundFloat } from "./math_round_float.ts"; ← from math.round-float ^1.0.0 · built alongside by fune
/**
* The sRGB transfer function (IEC 61966-2-1) undone: an 8-bit channel as
* linear light from 0 to 1. Colour arithmetic (mixing, luminance) is only
* meaningful on these values, not on the stored channel numbers.
*/
export function srgbToLinear(channel: number): number {
if (!Number.isInteger(channel) || channel < 0 || channel > 255) {
throw new RangeError(`channel must be a whole number from 0 to 255, received ${channel}`);
}
const c = channel / 255;
const linear = c <= 0.04045 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4);
return roundFloat(linear, 12);
}linearToSrgb throws on bad input 13 tests
export function linearToSrgb(value: number): number
| value | float | linear light; below 0 or above 1 is clipped |
| returns | int | 0 to 255, rounded half away from zero |
For example
linearToSrgb(0.5)→ 188 half the light is 188, not 128linearToSrgb(0.216)→ 128 back from 128's linear valuelinearToSrgb(0)→ 1 back from 1's linear value
import { linearToSrgb } from "#fune/charts.color@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { pow } from "./math_pow.ts"; ← from math.pow ^1.0.0 · built alongside by fune
import { roundFloat } from "./math_round_float.ts"; ← from math.round-float ^1.0.0 · built alongside by fune
/**
* Linear light back to an 8-bit sRGB channel. Values outside 0..1 (a mix in
* another colour space can land slightly out of gamut) are clipped first.
*/
export function linearToSrgb(value: number): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new RangeError(`value must be a finite number, received ${value}`);
}
const v = value < 0 ? 0 : value > 1 ? 1 : value;
const encoded = v <= 0.0031308 ? 12.92 * v : 1.055 * pow(v, 1 / 2.4) - 0.055;
return roundFloat(encoded * 255, 0);
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 2 dependencies, pins them in fune.lock, downloads only the TypeScript package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:
fune add charts.color
That builds the whole group. To build only what you call, and whatever it uses inside the group:
fune add charts.color --only parseHex
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./charts.color-1.0.1-typescript.fune, or fetch it from a terminal with fune pull charts.color@1.0.1:typescript.
The whole function, every language, is one file too: charts.color-1.0.1.fune, 21,407 bytes, sha256 0057820f92301d76cf2398e9b40b4a0afd23d46a3dfb780173bc780bdaa04082. It installs into a project of any language.
Customise it in your app
The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.
before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.
// fune: before charts.color.parseHex
// fune: before charts.color.toHex
// fune: before charts.color.srgbToLinear
// fune: before charts.color.linearToSrgb
after — your function gets the result and the arguments, and returns the final result.
// fune: after charts.color.parseHex
// fune: after charts.color.toHex
// fune: after charts.color.srgbToLinear
// fune: after charts.color.linearToSrgb
replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.
// fune: replace math.pow in charts.color
// fune: replace math.round-float in charts.color
step — your function runs at a numbered point inside a function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show charts.color --steps.
// fune: step charts.color.<fn> after <n|label>
Tests
A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.
parseHex 9 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| six digits | #e69f00 | → | r 230, g 159, b 0 |
| upper case | #56B4E9 | → | r 86, g 180, b 233 |
| three digits double each one, as CSS does | #f80 | → | r 255, g 136, b 0 |
| black | #000000 | → | r 0, g 0, b 0 |
| white | #FFF | → | r 255, g 255, b 255 |
| a missing # is an error | e69f00 | → | error: is not a hex colour (#rgb or #rrggbb) |
| a non-hex digit is an error | #e69g00 | → | error: is not a hex colour (#rgb or #rrggbb) |
| an alpha channel is not accepted | #e69f00ff | → | error: is not a hex colour (#rgb or #rrggbb) |
| a colour name is an error | red | → | error: is not a hex colour (#rgb or #rrggbb) |
toHex 9 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| lower case, two digits per channel | r 230, g 159, b 0 | → | #e69f00 |
| single-digit channels are zero-padded | r 1, g 10, b 15 | → | #010a0f |
| white | r 255, g 255, b 255 | → | #ffffff |
| a channel above 255 is an error | r 256, g 0, b 0 | → | error: rgb channels must be whole numbers from 0 to 255 |
| a negative channel is an error | r 0, g -1, b 0 | → | error: rgb channels must be whole numbers from 0 to 255 |
| black | r 0, g 0, b 0 | → | #000000 |
| 15 and 16 straddle the digit boundary, so both are padded to two digits | r 15, g 16, b 255 | → | #0f10ff |
| mid grey | r 128, g 128, b 128 | → | #808080 |
| a blue channel above 255 is an error | r 0, g 0, b 300 | → | error: rgb channels must be whole numbers from 0 to 255 |
srgbToLinear 8 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| black is 0 | 0 | → | 0 |
| white is 1 | 255 | → | 1 |
| the darkest step is on the straight segment: 1/255/12.92 | 1 | → | 0 |
| 10 is the last value on the straight segment | 10 | → | 0.003 |
| 11 is the first on the power curve | 11 | → | 0.003 |
| mid grey 128 is only 21.6% of the light, not 50% | 128 | → | 0.216 |
| 188 is about half the light | 188 | → | 0.503 |
| above 255 is an error | 256 | → | error: channel must be a whole number from 0 to 255 |
linearToSrgb 13 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| half the light is 188, not 128 | 0.5 | → | 188 |
| back from 128's linear value | 0.216 | → | 128 |
| back from 1's linear value | 0 | → | 1 |
| the straight segment's end: 12.92 x 0.0031308 x 255 = 10.31 | 0.003 | → | 10 |
| on the straight segment 6.59 rounds to 7 | 0.002 | → | 7 |
| white | 1 | → | 255 |
| below 0 clips to black | -0.1 | → | 0 |
| above 1 clips to white | 1.2 | → | 255 |
| 18% grey | 0.18 | → | 118 |
| black stays black | 0 | → | 0 |
Show the other 3 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| on the straight segment 3.29 rounds down to 3 | 0.001 | → | 3 |
| a value that is not a number is an error, not black | 0.5 | → | error: value must be a finite number |
| null is an error, not black | — | → | error: value must be a finite number |
More from the author
**Linear light.** A stored channel is gamma-encoded: 128 is only 21.6% of the light of 255, and averaging stored values gives muddy, too-dark mixes. `srgbToLinear` applies the sRGB transfer function of IEC 61966-2-1: c / 12.92 when c = channel / 255 is at most 0.04045, otherwise ((c + 0.055) / 1.055)^2.4. It returns linear light from 0 to 1, rounded to 12 decimal places. `linearToSrgb` inverts it (12.92 v up to 0.0031308, otherwise 1.055 v^(1/2.4) - 0.055), clipping to 0..1 first because a mix computed in another colour space can land a hair out of gamut, and rounds the channel half away from zero. Every 8-bit value survives the round trip.
The powers come from `math.pow`, not `Math.pow` or `**`, so all three languages return the same bits, and the rounding from `math.round-float`.
Sources: IEC 61966-2-1:1999, "Default RGB colour space - sRGB"; W3C, CSS Color Module Level 4, section 10.2 "Predefined sRGB" (the same transfer function) and section 5.2 "The RGB hexadecimal notations".
1.0.1 adds tests; behaviour unchanged. The Rust vector adapter now refuses an argument that is not a number with the same message as TypeScript and Python, so the new error tests mean the same in all three.