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.0 (not the latest) · published 2026-10-03 by charlie · Anterra

Pinned by 31 tests, run in TypeScript, Python and Rust · fewer than the registry now requires. 1.0.1 adds them.parseHex 9 · toHex 5 · srgbToLinear 8 · linearToSrgb 9

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 5 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 9 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 · 20 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 {
    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.0-rust.fune · 13,808 bytes sha256 da761fad2348b48e1f542fe859197e8be015e415c533f403b2f26eaaccc897d0

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

The whole function, every language, is one file too: charts.color-1.0.0.fune, 19,719 bytes, sha256 828b8ee0f7f2de31e8dcab050778b11b0d6c822f5a3360d54d9f6c63110b136e. 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.

This version has fewer tests than the registry now requires. It was published before every function had to have 8. 1.0.1 meets it, and a project on ^1.0.0 installs that or newer.

  • toHex has 5 tests; every function needs at least 8. Add 3 more to vectors.json ("fn": "toHex"): the ordinary case, the boundaries (zero, negative, the largest values), the rounding edge and every error it documents
  • linearToSrgb throws (throws yes) but none of its 9 tests expects an error; add an "expectError" vector ("fn": "linearToSrgb") for each error it documents, or declare `throws no` if it cannot throw

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 5 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

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 9 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

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

Files

PathBytes
README.md1,606
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.rs685
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.json3,618