// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
// Reading a colour from CSS.
//
// One recursive-descent parser over the two syntaxes CSS Color 4 defines: the
// legacy comma form, which dates from CSS Color 3 and is a different grammar
// with different rules, and the modern space form. They are not variants of one
// another — `rgb(10%, 50%, 0)` is invalid because a legacy argument list may not
// mix numbers and percentages, while `rgb(10% 50 0)` is valid — so the parser
// decides which one it is reading at the first separator it meets, and applies
// that one's rules to everything after.
//
// What is not here: `calc()`, `var()`, `env()` and the rest of CSS Values.
// Those need a cascade to resolve, and a colour library that guessed at them
// would produce values that look right and are not. A value containing them is
// refused like any other unknown function. Nor are the system colours
// (`Canvas`, `ButtonText`): their values come from the user's settings.
///|
/// Why a string is not a CSS colour.
pub(all) suberror Refused {
/// The text did not continue the way the syntax required, at this offset.
Unexpected(at~ : Int32, got~ : String, want~ : String)
/// A function or keyword that is valid CSS but not a colour this library
/// knows, at this offset.
Unknown(at~ : Int32, got~ : String)
} derive(Eq, Debug)
///|
pub impl Show for Refused with fn output(self, logger) {
match self {
Unexpected(at~, got~, want~) =>
logger.write_string("at \{at}: got \{got}, want \{want}")
Unknown(at~, got~) => logger.write_string("at \{at}: unknown \{got}")
}
}
///|
pub extend Refused with Debug::{to_repr}
///|
pub extend Refused with Show::{to_string, output}
///|
pub extend Refused with Eq::{equal, not_equal}
///|
/// Where the parser has got to. Offsets are code points from the start of the
/// input, so that an error can say where without the caller holding the string.
priv struct Cursor {
text : StringView
length : Int32
mut at : Int32
}
///|
fn Cursor::new(text : StringView) -> Cursor {
{ text, length: text.length(), at: 0, }
}
///|
fn Cursor::done(self : Cursor) -> Bool {
self.at >= self.length
}
///|
fn Cursor::peek(self : Cursor) -> Int32 {
if self.done() {
-1
} else {
self.text.unsafe_get(self.at).to_int()
}
}
///|
fn Cursor::bump(self : Cursor) -> Unit {
if !self.done() {
self.at = self.at + 1
}
}
///|
fn Cursor::eat(self : Cursor, code : Int32) -> Bool {
if self.peek() == code {
self.bump()
true
} else {
false
}
}
///|
/// The offset, as an error wants it.
fn Cursor::offset(self : Cursor) -> Int32 {
self.at
}
///|
/// What was found at the cursor, for an error message: a short run of the input,
/// or the end of it.
fn Cursor::got(self : Cursor) -> String {
if self.done() {
return "end of input"
}
let stop = if self.at + 12 < self.length { self.at + 12 } else { self.length }
self.text.clamped_view(start=self.at, end=stop).to_owned()
}
///|
fn Cursor::unexpected(self : Cursor, want : String) -> Refused {
Unexpected(at=self.offset(), got=self.got(), want~)
}
///|
fn Cursor::unknown(self : Cursor, got : String) -> Refused {
Unknown(at=self.offset(), got~)
}
///|
fn is_space(code : Int32) -> Bool {
code == 0x20 || code == 0x09 || code == 0x0A || code == 0x0D || code == 0x0C
}
///|
fn is_digit(code : Int32) -> Bool {
code >= 0x30 && code <= 0x39
}
///|
fn is_name_start(code : Int32) -> Bool {
(code >= 0x61 && code <= 0x7A) ||
(code >= 0x41 && code <= 0x5A) ||
code == 0x5F
}
///|
fn is_name_char(code : Int32) -> Bool {
is_name_start(code) || is_digit(code) || code == 0x2D
}
///|
fn is_hex_digit(code : Int32) -> Bool {
is_digit(code) ||
(code >= 0x61 && code <= 0x66) ||
(code >= 0x41 && code <= 0x46)
}
///|
fn hex_value(code : Int32) -> Int32 {
if is_digit(code) {
code - 0x30
} else if code >= 0x61 {
code - 0x61 + 10
} else {
code - 0x41 + 10
}
}
///|
/// Skip whitespace and comments. CSS strips comments before parsing a value, so
/// `/**/#fff` and `#/**/fff` are both `#fff`.
fn Cursor::skip_spaces(self : Cursor) -> Unit {
while !self.done() {
if is_space(self.peek()) {
self.bump()
} else if self.peek() == '/' &&
self.at + 1 < self.length &&
self.text.unsafe_get(self.at + 1).to_int() == 0x2A {
self.bump()
self.bump()
while !self.done() {
if self.peek() == 0x2A &&
self.at + 1 < self.length &&
self.text.unsafe_get(self.at + 1).to_int() == 0x2F {
self.bump()
self.bump()
break
}
self.bump()
}
} else {
break
}
}
}
///|
/// One component of a function, read but not yet interpreted. The same `50%`
/// is half a lightness in `oklab()` and 62.5 units of `a` in `lab()`, and only
/// the function knows which, so the term carries what was written and the
/// function decides what it means.
priv enum Term {
/// The `none` keyword: a component the author left out.
Missing
Number(Double)
Percent(Double)
/// An angle, already converted to degrees.
Angle(Double)
}
///|
/// Which component a function is asking for. The wish decides both what is
/// allowed and what the number means, and keeping the two together is what lets
/// a refusal point at the component that was wrong rather than at the bracket
/// that follows it.
priv enum Wanted {
/// A channel of `rgb()`: a number 0–255, or a percentage.
Channel
/// A component of `color()`: a number, or a percentage of one. Nothing is
/// clamped, because a wide-gamut space may hold a colour outside itself.
Fraction
/// Saturation or lightness in `hsl()`, whiteness or blackness in `hwb()`:
/// a percentage whether or not the sign is written.
Percentage
/// `a`, `b` and chroma, where a percentage is a share of this scale.
Scaled(Double)
/// A hue: a number of degrees, or an angle in another unit.
Hue
/// An alpha: a number or percentage of one, clamped.
Alpha
/// A lightness, clamped to its own scale: 100 for the Lab family and 1 for
/// the Oklab family.
Light(Double)
}
///|
/// A number as CSS writes one: an optional sign, digits on at least one side of
/// the point, and an optional exponent.
fn parse_number(cursor : Cursor) -> Double raise Refused {
let start = cursor.at
let mut sign = 1.0
if cursor.eat(0x2D) {
sign = -1.0
} else {
ignore(cursor.eat(0x2B))
}
let mut value = 0.0
let mut digits = 0
while is_digit(cursor.peek()) {
value = value * 10.0 + (cursor.peek() - 0x30).to_double()
digits = digits + 1
cursor.bump()
}
if cursor.peek() == 0x2E {
cursor.bump()
let mut scale = 0.1
while is_digit(cursor.peek()) {
value = value + (cursor.peek() - 0x30).to_double() * scale
scale = scale / 10.0
digits = digits + 1
cursor.bump()
}
}
if digits == 0 {
cursor.at = start
raise cursor.unexpected("a number")
}
if cursor.peek() == 0x65 || cursor.peek() == 0x45 {
let mark = cursor.at
cursor.bump()
let mut exponent_sign = 1
if cursor.eat(0x2D) {
exponent_sign = -1
} else {
ignore(cursor.eat(0x2B))
}
let mut exponent = 0
let mut exponent_digits = 0
while is_digit(cursor.peek()) {
exponent = exponent * 10 + (cursor.peek() - 0x30)
exponent_digits = exponent_digits + 1
cursor.bump()
}
if exponent_digits == 0 {
// `1e` is a number followed by an identifier, not an exponent; leave the
// `e` for whatever wants to read it.
cursor.at = mark
} else {
value = value * @math.pow(10.0, (exponent * exponent_sign).to_double())
}
}
sign * value
}
///|
/// An identifier: the name of a function, a keyword, or a unit.
fn Cursor::ident(self : Cursor) -> StringView {
let start = self.at
while is_name_char(self.peek()) {
self.bump()
}
self.text.clamped_view(start~, end=self.at)
}
///|
/// A component, a number, a percentage, an angle, or `none`.
fn read_term(cursor : Cursor) -> Term raise Refused {
if is_name_start(cursor.peek()) {
let start = cursor.at
let word = cursor.ident()
if word.equal_ignore_ascii_case("none") {
return Missing
}
cursor.at = start
raise cursor.unknown(word.to_owned())
}
let value = parse_number(cursor)
if cursor.eat(0x25) {
return Percent(value)
}
if is_name_start(cursor.peek()) {
let start = cursor.at
let unit = cursor.ident()
let factor = if unit.equal_ignore_ascii_case("deg") {
Some(1.0)
} else if unit.equal_ignore_ascii_case("grad") {
Some(0.9)
} else if unit.equal_ignore_ascii_case("rad") {
Some(180.0 / @math.PI)
} else if unit.equal_ignore_ascii_case("turn") {
Some(360.0)
} else {
None
}
match factor {
Some(factor) => return Angle(value * factor)
None => {
cursor.at = start
raise cursor.unknown(unit.to_owned())
}
}
}
Number(value)
}
///|
/// The hue in 0–360, which is the only range a hue angle is kept in.
fn wrap_hue(degrees : Double) -> Double {
let wrapped = degrees % 360.0
if wrapped < 0.0 {
wrapped + 360.0
} else {
wrapped
}
}
///|
/// One component, read and converted, refusing a kind the function cannot take
/// at the offset where it stands.
fn parse_component(cursor : Cursor, wanted : Wanted) -> Double raise Refused {
convert_term(read_term(cursor), wanted, cursor)
}
///|
/// A term that has been read already, converted — which is what the legacy
/// argument lists need, since they must look at the kind of every component
/// before they know whether the list is legal at all.
fn convert_term(
term : Term,
wanted : Wanted,
cursor : Cursor,
) -> Double raise Refused {
match term {
Missing => @double.not_a_number
Angle(degrees) =>
match wanted {
Hue => wrap_hue(degrees)
_ =>
raise cursor.unexpected(
"a number, because only a hue may be an angle",
)
}
Percent(value) =>
match wanted {
Hue => raise cursor.unexpected("a number or angle, not a percentage")
Channel => clamp01(value / 100.0)
Fraction => value / 100.0
Percentage => clamp01(value / 100.0)
Scaled(scale) => value / 100.0 * scale
Alpha => clamp01(value / 100.0)
Light(scale) => clamp01(value / 100.0) * scale
}
Number(value) =>
match wanted {
Hue => wrap_hue(value)
Channel => clamp01(value / 255.0)
Fraction => value
Percentage => clamp01(value / 100.0)
Scaled(_) => value
Alpha => clamp01(value)
Light(scale) => clamp01(value / scale) * scale
}
}
}
///|
/// A component after the space that separates two of them.
fn parse_component_after_space(
cursor : Cursor,
wanted : Wanted,
) -> Double raise Refused {
cursor.skip_spaces()
parse_component(cursor, wanted)
}
///|
/// The alpha of a legacy argument list, where `none` had not been invented yet.
fn legacy_alpha(cursor : Cursor, term : Term) -> Double raise Refused {
if term is Missing {
raise cursor.unexpected(
"a number for alpha, because `none` is modern syntax",
)
}
convert_term(term, Alpha, cursor)
}
///|
/// A chroma, which cannot be negative: a negative one means the hue turned half
/// way round, and the syntax says so with a hue instead.
fn non_negative(value : Double) -> Double {
if value.is_nan() {
value
} else if value < 0.0 {
0.0
} else {
value
}
}
///|
/// The alpha after a slash, if there is one.
fn parse_alpha(cursor : Cursor) -> Double raise Refused {
cursor.skip_spaces()
if cursor.eat(0x2F) {
cursor.skip_spaces()
let alpha = parse_component(cursor, Alpha)
cursor.skip_spaces()
return alpha
}
1.0
}
///|
/// Whether the argument list uses commas, which is what makes it the legacy
/// syntax even when the function is spelled `rgba()` with a slash in it.
fn Cursor::at_comma(self : Cursor) -> Bool {
self.skip_spaces()
self.peek() == 0x2C
}
///|
/// `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`.
fn parse_hex(cursor : Cursor) -> Color raise Refused {
let start = cursor.at
cursor.bump()
let digits_start = cursor.at
while is_hex_digit(cursor.peek()) {
cursor.bump()
}
let count = cursor.at - digits_start
if count != 3 && count != 4 && count != 6 && count != 8 {
cursor.at = start
raise cursor.unexpected("three, four, six or eight hexadecimal digits")
}
let digit = fn(index : Int32) {
hex_value(cursor.text.unsafe_get(digits_start + index).to_int())
}
let pair = fn(index : Int32) { digit(index) * 16 + digit(index + 1) }
if count == 3 {
Color::rgb(digit(0) * 17, digit(1) * 17, digit(2) * 17)
} else if count == 4 {
Color::rgb(
digit(0) * 17,
digit(1) * 17,
digit(2) * 17,
alpha=digit(3).to_double() / 15.0,
)
} else if count == 6 {
Color::rgb(pair(0), pair(2), pair(4))
} else {
Color::rgb(pair(0), pair(2), pair(4), alpha=pair(6).to_double() / 255.0)
}
}
///|
/// `rgb()` and `rgba()`, in either syntax. The first separator decides which:
/// a comma means the legacy list, whose rules are stricter, and a space the
/// modern one.
fn parse_rgb(cursor : Cursor, legacy_name : Bool) -> Color raise Refused {
let first = read_term(cursor)
if cursor.at_comma() {
if !legacy_name {
raise cursor.unexpected("a space, because rgb() has no comma syntax")
}
return parse_rgb_legacy(cursor, first)
}
let red = convert_term(first, Channel, cursor)
let green = parse_component_after_space(cursor, Channel)
let blue = parse_component_after_space(cursor, Channel)
let alpha = parse_alpha(cursor)
Color::srgb(red, green, blue, alpha~)
}
///|
/// The comma syntax: three channels of the same kind, an optional fourth
/// component for alpha, and no `none` anywhere.
fn parse_rgb_legacy(cursor : Cursor, first : Term) -> Color raise Refused {
ignore(cursor.eat(0x2C))
cursor.skip_spaces()
let second = read_term(cursor)
cursor.skip_spaces()
if !cursor.eat(0x2C) {
raise cursor.unexpected("a comma")
}
cursor.skip_spaces()
let third = read_term(cursor)
let alpha = if cursor.at_comma() {
ignore(cursor.eat(0x2C))
cursor.skip_spaces()
legacy_alpha(cursor, read_term(cursor))
} else {
cursor.skip_spaces()
1.0
}
// One match decides both whether the list is legal and what it means: all
// numbers, or all percentages, and no `none`, which the modern syntax added.
let channels = match (first, second, third) {
(Number(r), Number(g), Number(b)) =>
Some((clamp01(r / 255.0), clamp01(g / 255.0), clamp01(b / 255.0)))
(Percent(r), Percent(g), Percent(b)) =>
Some((clamp01(r / 100.0), clamp01(g / 100.0), clamp01(b / 100.0)))
(Missing, _, _) | (_, Missing, _) | (_, _, Missing) =>
raise cursor.unexpected("numbers, because `none` is modern syntax")
_ =>
raise cursor.unexpected("three numbers or three percentages, not a mix")
}
match channels {
Some((r, g, b)) => Color::srgb(r, g, b, alpha~)
None => raise cursor.unexpected("three numbers or three percentages")
}
}
///|
/// `hsl()` and `hsla()`, in either syntax.
fn parse_hsl(cursor : Cursor, legacy_name : Bool) -> Color raise Refused {
let first = read_term(cursor)
if cursor.at_comma() {
if !legacy_name {
raise cursor.unexpected("a space, because hsl() has no comma syntax")
}
let hue = match first {
Number(value) => wrap_hue(value)
Angle(degrees) => wrap_hue(degrees)
_ => raise cursor.unexpected("a number or angle for the hue")
}
ignore(cursor.eat(0x2C))
cursor.skip_spaces()
let saturation = read_term(cursor)
cursor.skip_spaces()
if !cursor.eat(0x2C) {
raise cursor.unexpected("a comma")
}
cursor.skip_spaces()
let lightness = read_term(cursor)
// A legacy list requires percentages here, which the modern one does not.
let pair = match (saturation, lightness) {
(Percent(s), Percent(l)) => Some((clamp01(s / 100.0), clamp01(l / 100.0)))
(Missing, _) | (_, Missing) =>
raise cursor.unexpected("percentages, because `none` is modern syntax")
_ => raise cursor.unexpected("percentages for saturation and lightness")
}
let alpha = if cursor.at_comma() {
ignore(cursor.eat(0x2C))
cursor.skip_spaces()
legacy_alpha(cursor, read_term(cursor))
} else {
cursor.skip_spaces()
1.0
}
match pair {
Some((saturation, lightness)) =>
Color::hsl(hue, saturation, lightness, alpha~)
None =>
raise cursor.unexpected("percentages for saturation and lightness")
}
} else {
let hue = convert_term(first, Hue, cursor)
let saturation = parse_component_after_space(cursor, Percentage)
let lightness = parse_component_after_space(cursor, Percentage)
let alpha = parse_alpha(cursor)
Color::hsl(hue, saturation, lightness, alpha~)
}
}
///|
/// `hwb()`.
fn parse_hwb(cursor : Cursor) -> Color raise Refused {
let hue = parse_component(cursor, Hue)
let whiteness = parse_component_after_space(cursor, Percentage)
let blackness = parse_component_after_space(cursor, Percentage)
let alpha = parse_alpha(cursor)
Color::hwb(hue, whiteness, blackness, alpha~)
}
///|
/// `lab()`: lightness 0–100, `a` and `b` unbounded, and 100% of `a` is 125.
fn parse_lab(cursor : Cursor) -> Color raise Refused {
let lightness = parse_component(cursor, Light(100.0))
let a = parse_component_after_space(cursor, Scaled(125.0))
let b = parse_component_after_space(cursor, Scaled(125.0))
let alpha = parse_alpha(cursor)
Color::lab(lightness, a, b, alpha~)
}
///|
/// `lch()`: lightness 0–100, chroma from 0, and 100% of chroma is 150.
fn parse_lch(cursor : Cursor) -> Color raise Refused {
let lightness = parse_component(cursor, Light(100.0))
let chroma = parse_component_after_space(cursor, Scaled(150.0))
let hue = parse_component_after_space(cursor, Hue)
let alpha = parse_alpha(cursor)
Color::lch(lightness, non_negative(chroma), hue, alpha~)
}
///|
/// `oklab()`: lightness 0–1, and 100% of `a` and `b` is 0.4.
fn parse_oklab(cursor : Cursor) -> Color raise Refused {
let lightness = parse_component(cursor, Light(1.0))
let a = parse_component_after_space(cursor, Scaled(0.4))
let b = parse_component_after_space(cursor, Scaled(0.4))
let alpha = parse_alpha(cursor)
Color::oklab(lightness, a, b, alpha~)
}
///|
/// `oklch()`: lightness 0–1, chroma from 0, and 100% of chroma is 0.4.
fn parse_oklch(cursor : Cursor) -> Color raise Refused {
let lightness = parse_component(cursor, Light(1.0))
let chroma = parse_component_after_space(cursor, Scaled(0.4))
let hue = parse_component_after_space(cursor, Hue)
let alpha = parse_alpha(cursor)
Color::oklch(lightness, non_negative(chroma), hue, alpha~)
}
///|
/// `color(srgb ...)` and the other spaces it takes. Percentages are fractions of
/// one here, and nothing is clamped: a wide-gamut space is allowed to hold a
/// colour outside itself, and clipping it on the way in would lose the value.
fn parse_color_function(cursor : Cursor) -> Color raise Refused {
cursor.skip_spaces()
if !is_name_start(cursor.peek()) {
raise cursor.unexpected("a colour space")
}
let start = cursor.at
let name = cursor.ident()
let space = match Space::of(name) {
Some(space) =>
if space.is_predefined() {
space
} else {
raise Unknown(at=start, got=name.to_owned())
}
None => raise Unknown(at=start, got=name.to_owned())
}
let first = parse_component_after_space(cursor, Fraction)
let second = parse_component_after_space(cursor, Fraction)
let third = parse_component_after_space(cursor, Fraction)
let alpha = parse_alpha(cursor)
Color::of(space, first, second, third, alpha~)
}
///|
/// A keyword: `transparent`, `currentcolor`, or one of the 148 names.
fn parse_keyword(word : StringView, at : Int32) -> Color raise Refused {
if word.equal_ignore_ascii_case("transparent") {
return Color::transparent()
}
if word.equal_ignore_ascii_case("currentcolor") {
return Color::current()
}
match Color::of_name(word) {
Some(color) => color
None => raise Unknown(at~, got=word.to_owned())
}
}
///|
/// A function call, with the name already read and the opening parenthesis next.
fn parse_function(
cursor : Cursor,
name : StringView,
at : Int32,
) -> Color raise Refused {
// Whitespace and comments may follow the opening parenthesis, so the first
// component is read after them rather than at the bracket.
cursor.skip_spaces()
let key = name.to_lower()
let color = if key.equal("rgb") || key.equal("rgba") {
parse_rgb(cursor, true)
} else if key.equal("hsl") || key.equal("hsla") {
parse_hsl(cursor, true)
} else if key.equal("hwb") {
parse_hwb(cursor)
} else if key.equal("lab") {
parse_lab(cursor)
} else if key.equal("lch") {
parse_lch(cursor)
} else if key.equal("oklab") {
parse_oklab(cursor)
} else if key.equal("oklch") {
parse_oklch(cursor)
} else if key.equal("color") {
parse_color_function(cursor)
} else {
raise Unknown(at~, got=name.to_owned())
}
cursor.skip_spaces()
if !cursor.eat(0x29) {
raise cursor.unexpected("the closing parenthesis")
}
color
}
///|
/// A ``: everything from the first non-blank to the closing bracket.
pub fn Color::decode(text : StringView) -> Color raise Refused {
let cursor = Cursor::new(text)
cursor.skip_spaces()
let color = if cursor.peek() == 0x23 {
parse_hex(cursor)
} else if is_name_start(cursor.peek()) {
// Kept so that a refusal can point at the name rather than at its end.
let start = cursor.at
let word = cursor.ident()
if cursor.peek() == 0x28 {
cursor.bump()
parse_function(cursor, word, start)
} else {
parse_keyword(word, start)
}
} else {
raise cursor.unexpected("a colour")
}
cursor.skip_spaces()
if !cursor.done() {
raise cursor.unexpected("end of input")
}
color
}
///|
/// Read a colour, or `None` where the text is not one.
pub fn Color::decode_opt(text : StringView) -> Color? {
Some(Color::decode(text)) catch {
_ => None
}
}