Functional Weave
Code in Rust

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.

  1. parse_hex (hex: string) -> Rgb
  2. to_hex (rgb: Rgb) -> string
  3. srgb_to_linear (channel: int) -> float
  4. linear_to_srgb (value: float) -> int

The type it declares, generated into your project

/// An sRGB colour as three 8-bit channels.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Rgb {
    /// 0 to 255
    pub r: i64,
    /// 0 to 255
    pub g: i64,
    /// 0 to 255
    pub b: i64,
}

Once installed, your code imports each one from the group's module.

parse_hex throws on bad input 9 tests

pub fn parse_hex(hex: &str) -> Rgb
hexstring"#rrggbb" or "#rgb", either case
returnsRgb

For example

  • parse_hex(#e69f00) → r 230, g 159, b 0 six digits
  • parse_hex(#56B4E9) → r 86, g 180, b 233 upper case
  • parse_hex(#f80) → r 255, g 136, b 0 three digits double each one, as CSS does
fune!(charts.color@^1);  // then call parse_hex(…)
impl/rust/parse_hex.rs · 41 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one

fn nibble(ch: u8, hex: &str) -> i64 {
    match ch {
        b'0'..=b'9' => i64::from(ch - b'0'),
        b'a'..=b'f' => i64::from(ch - b'a') + 10,
        b'A'..=b'F' => i64::from(ch - b'A') + 10,
        _ => panic!("\"{}\" is not a hex colour (#rgb or #rrggbb)", hex),
    }
}

/// "#rrggbb" or the short "#rgb" (digits doubled, as CSS does), either case.
///
/// # Panics
/// Panics on anything else.
pub fn parse_hex(hex: &str) -> Rgb {
    let bytes = hex.as_bytes();
    if bytes.first() != Some(&b'#') || (bytes.len() != 4 && bytes.len() != 7) {
        panic!("\"{}\" is not a hex colour (#rgb or #rrggbb)", hex);
    }
    if bytes.len() == 4 {
        return Rgb {
            r: nibble(bytes[1], hex) * 17,
            g: nibble(bytes[2], hex) * 17,
            b: nibble(bytes[3], hex) * 17,
        };
    }
    Rgb {
        r: nibble(bytes[1], hex) * 16 + nibble(bytes[2], hex),
        g: nibble(bytes[3], hex) * 16 + nibble(bytes[4], hex),
        b: nibble(bytes[5], hex) * 16 + nibble(bytes[6], hex),
    }
}

pub fn rgb_to_value(rgb: &Rgb) -> Value {
    Value::obj(vec![("r", Value::Int(rgb.r)), ("g", Value::Int(rgb.g)), ("b", Value::Int(rgb.b))])
}

pub fn fune_vector(args: &[Value]) -> Value {
    rgb_to_value(&parse_hex(args[0].as_str()))
}

to_hex throws on bad input 9 tests

pub fn to_hex(rgb: &Rgb) -> String
rgbRgb
returnsstringlowercase "#rrggbb"

For example

  • to_hex(r 230, g 159, b 0) → #e69f00 lower case, two digits per channel
  • to_hex(r 1, g 10, b 15) → #010a0f single-digit channels are zero-padded
  • to_hex(r 255, g 255, b 255) → #ffffff white
fune!(charts.color@^1);  // then call to_hex(…)
impl/rust/to_hex.rs · 29 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one

const HEX: &[u8; 16] = b"0123456789abcdef";

fn pair(value: i64) -> String {
    if !(0..=255).contains(&value) {
        panic!("rgb channels must be whole numbers from 0 to 255, received {}", value);
    }
    let mut s = String::new();
    s.push(HEX[(value / 16) as usize] as char);
    s.push(HEX[(value % 16) as usize] as char);
    s
}

/// Lowercase "#rrggbb", the form every renderer accepts.
///
/// # Panics
/// Panics if a channel is outside 0..=255.
pub fn to_hex(rgb: &Rgb) -> String {
    format!("#{}{}{}", pair(rgb.r), pair(rgb.g), pair(rgb.b))
}

pub fn rgb_from_value(v: &Value) -> Rgb {
    Rgb { r: v.get("r").as_i64(), g: v.get("g").as_i64(), b: v.get("b").as_i64() }
}

pub fn fune_vector(args: &[Value]) -> Value {
    Value::str(&to_hex(&rgb_from_value(&args[0])))
}

srgb_to_linear throws on bad input 8 tests

pub fn srgb_to_linear(channel: i64) -> f64
channelint0 to 255, as stored in a hex colour
returnsfloatlinear light 0 to 1, rounded to 12 decimal places

For example

  • srgb_to_linear(0) → 0 black is 0
  • srgb_to_linear(255) → 1 white is 1
  • srgb_to_linear(1) → 0 the darkest step is on the straight segment: 1/255/12.92
fune!(charts.color@^1);  // then call srgb_to_linear(…)
impl/rust/srgb_to_linear.rs · 21 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::math_pow::pow;  ← from math.pow ^1.0.0 · built alongside by fune
use super::math_round_float::round_float;  ← 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, rounded to 12 places.
///
/// # Panics
/// Panics if the channel is outside 0..=255.
pub fn srgb_to_linear(channel: i64) -> f64 {
    if !(0..=255).contains(&channel) {
        panic!("channel must be a whole number from 0 to 255, received {}", channel);
    }
    let c = channel as f64 / 255.0;
    let linear = if c <= 0.04045 { c / 12.92 } else { pow((c + 0.055) / 1.055, 2.4) };
    round_float(linear, 12)
}

pub fn fune_vector(args: &[Value]) -> Value {
    Value::Float(srgb_to_linear(args[0].as_i64()))
}

linear_to_srgb throws on bad input 13 tests

pub fn linear_to_srgb(value: f64) -> i64
valuefloatlinear light; below 0 or above 1 is clipped
returnsint0 to 255, rounded half away from zero

For example

  • linear_to_srgb(0.5) → 188 half the light is 188, not 128
  • linear_to_srgb(0.216) → 128 back from 128's linear value
  • linear_to_srgb(0) → 1 back from 1's linear value
fune!(charts.color@^1);  // then call linear_to_srgb(…)
impl/rust/linear_to_srgb.rs · 25 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::math_pow::pow;  ← from math.pow ^1.0.0 · built alongside by fune
use super::math_round_float::round_float;  ← from math.round-float ^1.0.0 · built alongside by fune

/// Linear light back to an 8-bit sRGB channel, clipping to 0..1 first.
///
/// # Panics
/// Panics if `value` is not finite.
pub fn linear_to_srgb(value: f64) -> i64 {
    if !value.is_finite() {
        panic!("value must be a finite number, received {}", value);
    }
    let v = if value < 0.0 { 0.0 } else if value > 1.0 { 1.0 } else { value };
    let encoded = if v <= 0.0031308 { 12.92 * v } else { 1.055 * pow(v, 1.0 / 2.4) - 0.055 };
    round_float(encoded * 255.0, 0) as i64
}

pub fn fune_vector(args: &[Value]) -> Value {
    // Refuse what the typed signature cannot hold (text, null) with the wording
    // TypeScript and Python use, rather than read it as 0.
    if !matches!(args[0], Value::Int(_) | Value::Float(_)) {
        panic!("value must be a finite number, received a value that is not a number");
    }
    Value::Int(linear_to_srgb(args[0].as_f64()))
}

Install

fune build

With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 2 dependencies, pins them in fune.lock, downloads only the Rust 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. A crate’s build.rs runs it before every compile. 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
Download for Rust charts.color-1.0.1-rust.fune · 15,496 bytes sha256 1129e0c54275b0a93936656e97e121a117a826530f730f1511e19bcee45ec7b5

The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./charts.color-1.0.1-rust.fune, or fetch it from a terminal with fune pull charts.color@1.0.1:rust.

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

CaseArgumentsExpected
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

CaseArgumentsExpected
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

CaseArgumentsExpected
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

CaseArgumentsExpected
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
CaseArgumentsExpected
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.

Files

PathBytes
README.md1,814
impl/python/linear_to_srgb.py573
impl/python/parse_hex.py897
impl/python/srgb_to_linear.py574
impl/python/to_hex.py489
impl/rust/linear_to_srgb.rs981
impl/rust/parse_hex.rs1,298
impl/rust/srgb_to_linear.rs716
impl/rust/to_hex.rs821
impl/typescript/linear_to_srgb.ts620
impl/typescript/parse_hex.ts1,058
impl/typescript/srgb_to_linear.ts673
impl/typescript/to_hex.ts508
vectors.json4,649