Skip to main content

latex_rust/
color.rs

1//! Color models for math mode. Channel arithmetic is [`Dim`](crate::Dim) only.
2//!
3//! SVG `fill` / `stroke`, PNG (`tiny-skia`), and egui (`Color32`) all convert
4//! through [`Color::to_rgba8`]. This module resolves a color to 8-bit sRGB so
5//! every backend shares one contract.
6
7use std::collections::BTreeMap;
8use std::fmt;
9use std::sync::OnceLock;
10
11use crate::dim::Dim;
12use crate::error::Error;
13
14const DVIPS: &str = include_str!("../data/dvipsnames.tsv");
15
16/// 8-bit sRGB color. Values are integers; conversion from unit intervals uses `Dim`.
17///
18/// SVG `fill` / `stroke`, PNG (`tiny-skia`), and egui (`Color32`) all convert
19/// through [`Color::to_rgba8`].
20///
21/// # Examples
22///
23/// ```
24/// use latex_rust::Color;
25///
26/// let c = Color::rgb(255, 0, 0);
27/// assert_eq!(c.css_hex(), "#ff0000");
28/// assert_eq!(c.to_rgba8(), [255, 0, 0, 255]);
29/// ```
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31pub struct Color {
32    /// Red 0–255.
33    pub r: u8,
34    /// Green 0–255.
35    pub g: u8,
36    /// Blue 0–255.
37    pub b: u8,
38}
39
40impl Color {
41    /// Opaque sRGB triple.
42    ///
43    /// # Examples
44    ///
45    /// ```
46    /// use latex_rust::Color;
47    /// assert_eq!(Color::rgb(0, 0, 0).css_hex(), "#000000");
48    /// ```
49    #[must_use]
50    pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
51        Self { r, g, b }
52    }
53
54    /// CSS hex `#rrggbb`.
55    #[must_use]
56    pub fn css_hex(self) -> String {
57        format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b)
58    }
59
60    /// Opaque sRGB bytes for PNG (`tiny-skia`) and egui (`Color32`) backends.
61    #[must_use]
62    pub const fn to_rgba8(self) -> [u8; 4] {
63        [self.r, self.g, self.b, 255]
64    }
65}
66
67impl fmt::Display for Color {
68    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69        f.write_str(&self.css_hex())
70    }
71}
72
73const BASE: &[(&str, Color)] = &[
74    ("black", Color::rgb(0, 0, 0)),
75    ("white", Color::rgb(255, 255, 255)),
76    ("red", Color::rgb(255, 0, 0)),
77    ("green", Color::rgb(0, 255, 0)),
78    ("blue", Color::rgb(0, 0, 255)),
79    ("cyan", Color::rgb(0, 255, 255)),
80    ("magenta", Color::rgb(255, 0, 255)),
81    ("yellow", Color::rgb(255, 255, 0)),
82];
83
84/// Named-color registry, including `\definecolor` overrides.
85///
86/// # Examples
87///
88/// ```
89/// use latex_rust::ColorTable;
90///
91/// let mut t = ColorTable::new();
92/// assert!(!t.is_empty());
93/// t.define("ok", "named", "red").unwrap();
94/// assert_eq!(t.get("ok").unwrap().css_hex(), "#ff0000");
95/// ```
96#[derive(Clone, Debug)]
97pub struct ColorTable {
98    named: BTreeMap<String, Color>,
99}
100
101impl Default for ColorTable {
102    fn default() -> Self {
103        Self::new()
104    }
105}
106
107impl ColorTable {
108    /// Standard LaTeX names plus the 68 dvipsnames (CMYK → sRGB via `Dim`).
109    #[must_use]
110    pub fn new() -> Self {
111        builtin().clone()
112    }
113
114    /// Number of registered names.
115    #[must_use]
116    pub fn len(&self) -> usize {
117        self.named.len()
118    }
119
120    /// True when no names are registered.
121    #[must_use]
122    pub fn is_empty(&self) -> bool {
123        self.named.is_empty()
124    }
125
126    /// Look up a named color. Unknown names are [`Error::Unsupported`].
127    pub fn get(&self, name: &str) -> Result<Color, Error> {
128        self.named
129            .get(name)
130            .copied()
131            .ok_or_else(|| Error::Unsupported {
132                what: format!("named color {name}"),
133            })
134    }
135
136    /// `\definecolor{name}{model}{spec}`.
137    pub fn define(&mut self, name: &str, model: &str, spec: &str) -> Result<Color, Error> {
138        let c = parse_color_spec(model, spec, Some(self))?;
139        self.named.insert(name.to_string(), c);
140        Ok(c)
141    }
142}
143
144fn dvipsnames() -> &'static [(String, Color)] {
145    static T: OnceLock<Vec<(String, Color)>> = OnceLock::new();
146    T.get_or_init(load_dvips).as_slice()
147}
148
149fn load_dvips() -> Vec<(String, Color)> {
150    let mut out = Vec::new();
151    let mut lines = DVIPS.lines();
152    let header = lines.next().expect("dvipsnames header");
153    assert_eq!(header, "name\tc\tm\ty\tk", "dvipsnames.tsv schema");
154    for line in lines {
155        if line.is_empty() {
156            continue;
157        }
158        let mut cols = line.split('\t');
159        let name = cols.next().expect("name").to_string();
160        let c = cols.next().expect("c");
161        let m = cols.next().expect("m");
162        let y = cols.next().expect("y");
163        let k = cols.next().expect("k");
164        let color = cmyk_to_rgb(
165            &Dim::parse(c),
166            &Dim::parse(m),
167            &Dim::parse(y),
168            &Dim::parse(k),
169        )
170        .expect("dvipsnames cmyk");
171        out.push((name, color));
172    }
173    assert_eq!(out.len(), 68, "dvipsnames must be the full 68-color set");
174    out
175}
176
177/// Parse `{model}{spec}` as in `\definecolor` / `\color[model]{spec}`.
178///
179/// `named` uses `table` when provided, otherwise a fresh standard table.
180///
181/// # Arguments
182///
183/// * `model` — `named`, `rgb`, `RGB`, `HTML`, `cmyk`, or `gray`.
184/// * `spec` — model-specific components (comma-separated, or a name / hex).
185/// * `table` — optional named-color registry for the `named` model.
186///
187/// # Returns
188///
189/// An opaque sRGB [`Color`].
190///
191/// # Errors
192///
193/// * [`Error::Unsupported`] — unknown model or unknown named color.
194/// * [`Error::Malformed`] — wrong component count or out-of-range value.
195///
196/// # Examples
197///
198/// ```
199/// use latex_rust::parse_color_spec;
200///
201/// let c = parse_color_spec("RGB", "255,0,0", None).unwrap();
202/// assert_eq!(c.css_hex(), "#ff0000");
203/// ```
204pub fn parse_color_spec(
205    model: &str,
206    spec: &str,
207    table: Option<&ColorTable>,
208) -> Result<Color, Error> {
209    let model = model.trim();
210    let spec = spec.trim();
211    match model {
212        "named" => match table {
213            Some(t) => t.get(spec),
214            None => builtin().get(spec),
215        },
216        "rgb" => {
217            let v = unit_components(spec, 3)?;
218            Ok(Color::rgb(
219                unit_to_u8(&v[0])?,
220                unit_to_u8(&v[1])?,
221                unit_to_u8(&v[2])?,
222            ))
223        }
224        "RGB" => {
225            let v = unit_components(spec, 3)?;
226            Ok(Color::rgb(
227                byte_channel(&v[0])?,
228                byte_channel(&v[1])?,
229                byte_channel(&v[2])?,
230            ))
231        }
232        "HTML" => parse_html(spec),
233        "cmyk" => {
234            let v = unit_components(spec, 4)?;
235            cmyk_to_rgb(&v[0], &v[1], &v[2], &v[3])
236        }
237        "gray" => {
238            let v = unit_components(spec, 1)?;
239            let g = unit_to_u8(&v[0])?;
240            Ok(Color::rgb(g, g, g))
241        }
242        other => Err(Error::Unsupported {
243            what: format!("color model {other}"),
244        }),
245    }
246}
247
248fn builtin() -> &'static ColorTable {
249    static T: OnceLock<ColorTable> = OnceLock::new();
250    T.get_or_init(|| {
251        let mut named = BTreeMap::new();
252        for (n, c) in BASE {
253            named.insert((*n).to_string(), *c);
254        }
255        for (n, c) in dvipsnames() {
256            named.insert(n.clone(), *c);
257        }
258        ColorTable { named }
259    })
260}
261
262/// Named color from the standard table (`red`, `RoyalBlue`, …).
263///
264/// # Arguments
265///
266/// * `name` — LaTeX or dvipsnames identifier (case-sensitive as stored).
267///
268/// # Returns
269///
270/// The registered sRGB color.
271///
272/// # Errors
273///
274/// [`Error::Unsupported`] when the name is not in the table.
275///
276/// # Examples
277///
278/// ```
279/// use latex_rust::named_color;
280///
281/// assert_eq!(named_color("red").unwrap().css_hex(), "#ff0000");
282/// assert!(named_color("not-a-color").is_err());
283/// ```
284pub fn named_color(name: &str) -> Result<Color, Error> {
285    builtin().get(name)
286}
287
288fn parse_html(spec: &str) -> Result<Color, Error> {
289    let s = spec.trim().trim_start_matches('#');
290    if s.len() != 6 || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
291        return Err(malformed(format!("HTML color {spec}")));
292    }
293    let n = u32::from_str_radix(s, 16).map_err(|_| malformed(format!("HTML color {spec}")))?;
294    Ok(Color::rgb(
295        ((n >> 16) & 0xff) as u8,
296        ((n >> 8) & 0xff) as u8,
297        (n & 0xff) as u8,
298    ))
299}
300
301fn unit_components(spec: &str, n: usize) -> Result<Vec<Dim>, Error> {
302    let parts: Vec<&str> = spec
303        .split(',')
304        .map(str::trim)
305        .filter(|p| !p.is_empty())
306        .collect();
307    if parts.len() != n {
308        return Err(malformed(format!(
309            "color spec `{spec}` (need {n} components)"
310        )));
311    }
312    Ok(parts.iter().map(|p| Dim::parse(p)).collect())
313}
314
315fn cmyk_to_rgb(c: &Dim, m: &Dim, y: &Dim, k: &Dim) -> Result<Color, Error> {
316    let one = Dim::one();
317    let r = &(&one - c) * &(&one - k);
318    let g = &(&one - m) * &(&one - k);
319    let b = &(&one - y) * &(&one - k);
320    Ok(Color::rgb(
321        unit_to_u8(&r)?,
322        unit_to_u8(&g)?,
323        unit_to_u8(&b)?,
324    ))
325}
326
327fn unit_to_u8(d: &Dim) -> Result<u8, Error> {
328    if d.is_nan() {
329        return Err(malformed("color component NaN"));
330    }
331    let zero = Dim::zero();
332    let one = Dim::one();
333    let clamped = if matches!(d.cmp(&zero), Some(core::cmp::Ordering::Less)) {
334        zero
335    } else if matches!(d.cmp(&one), Some(core::cmp::Ordering::Greater)) {
336        one
337    } else {
338        d.clone()
339    };
340    let scaled = clamped * Dim::from_i64(255);
341    let rounded = &scaled + &Dim::ratio(1, 2);
342    Ok(floor_u8(&rounded))
343}
344
345fn byte_channel(d: &Dim) -> Result<u8, Error> {
346    if d.is_nan() {
347        return Err(malformed("RGB component NaN"));
348    }
349    let zero = Dim::zero();
350    let max = Dim::from_i64(255);
351    if matches!(d.cmp(&zero), Some(core::cmp::Ordering::Less))
352        || matches!(d.cmp(&max), Some(core::cmp::Ordering::Greater))
353    {
354        return Err(malformed("RGB component out of 0..255"));
355    }
356    let rounded = d + &Dim::ratio(1, 2);
357    Ok(floor_u8(&rounded))
358}
359
360fn malformed(what: impl Into<String>) -> Error {
361    Error::Malformed { what: what.into() }
362}
363
364fn floor_u8(d: &Dim) -> u8 {
365    let mut ans = 0u8;
366    let mut bit = 128u8;
367    while bit > 0 {
368        let cand = ans.saturating_add(bit);
369        if Dim::from_i64(i64::from(cand))
370            .cmp(d)
371            .is_some_and(|o| o != core::cmp::Ordering::Greater)
372        {
373            ans = cand;
374        }
375        bit /= 2;
376    }
377    ans
378}