Skip to main content

graphviz_pure_core/
color.rs

1//! Color translation tables, hex/HSV parsing, and SVG color formatting ported from
2//! Graphviz 16.1.0 (`lib/common/colxlate.c`, `lib/common/colortbl.h`, `lib/common/color.h`).
3
4use crate::color_table::COLOR_LIB;
5use serde::{Deserialize, Serialize};
6use std::fmt;
7
8/// Resolved 8-bit RGBA color (`r`, `g`, `b`, `a`).
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
10pub struct RgbaColor {
11    pub r: u8,
12    pub g: u8,
13    pub b: u8,
14    pub a: u8,
15}
16
17impl Default for RgbaColor {
18    #[inline]
19    fn default() -> Self {
20        Self::BLACK
21    }
22}
23
24impl RgbaColor {
25    /// Opaque black (`#000000`).
26    pub const BLACK: Self = Self::rgb(0, 0, 0);
27    /// Opaque white (`#ffffff`).
28    pub const WHITE: Self = Self::rgb(255, 255, 255);
29    /// Transparent color (`invis` / `none` / `transparent` in Graphviz `colortbl.h`).
30    pub const TRANSPARENT: Self = Self::rgba(255, 255, 254, 0);
31
32    /// Creates a new `RgbaColor` from 8-bit RGBA components.
33    #[inline]
34    pub const fn new(r: u8, g: u8, b: u8, a: u8) -> Self {
35        Self { r, g, b, a }
36    }
37
38    /// Creates an opaque `RgbaColor` (`a = 255`) from 8-bit RGB components.
39    #[inline]
40    pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
41        Self { r, g, b, a: 255 }
42    }
43
44    /// Creates an `RgbaColor` from 8-bit RGBA components.
45    #[inline]
46    pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> Self {
47        Self { r, g, b, a }
48    }
49
50    /// Returns `true` if the color is completely transparent (`a == 0`).
51    #[inline]
52    pub const fn is_transparent(&self) -> bool {
53        self.a == 0
54    }
55
56    /// Returns `true` if the color is completely opaque (`a == 255`).
57    #[inline]
58    pub const fn is_opaque(&self) -> bool {
59        self.a == 255
60    }
61
62    /// Returns normalized alpha opacity in `[0.0, 1.0]`.
63    #[inline]
64    pub fn opacity(&self) -> f64 {
65        f64::from(self.a) / 255.0
66    }
67
68    /// Formats the color as a 6-digit lowercase hex string (`#rrggbb`).
69    pub fn to_hex_rgb(&self) -> String {
70        format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b)
71    }
72
73    /// Formats the color as an 8-digit lowercase hex string (`#rrggbbaa`).
74    pub fn to_hex_rgba(&self) -> String {
75        format!("#{:02x}{:02x}{:02x}{:02x}", self.r, self.g, self.b, self.a)
76    }
77
78    /// Formats the color for SVG attributes:
79    /// - `"none"` when `a == 0` (`transparent` / `invis` / `none`)
80    /// - `"#rrggbb"` when `a == 255`
81    /// - `"#rrggbbaa"` when `0 < a < 255`
82    pub fn to_svg_hex(&self) -> String {
83        if self.a == 0 {
84            "none".to_string()
85        } else if self.a == 255 {
86            self.to_hex_rgb()
87        } else {
88            self.to_hex_rgba()
89        }
90    }
91
92    /// Formats the color as a CSS/SVG `rgb(...)` or `rgba(...)` string.
93    pub fn to_css_rgba(&self) -> String {
94        if self.a == 0 {
95            "rgba(0,0,0,0)".to_string()
96        } else if self.a == 255 {
97            format!("rgb({},{},{})", self.r, self.g, self.b)
98        } else {
99            let alpha = (f64::from(self.a) / 255.0 * 1000.0).round() / 1000.0;
100            format!("rgba({},{},{},{})", self.r, self.g, self.b, alpha)
101        }
102    }
103
104    /// Alias for [`Self::to_svg_hex`].
105    #[inline]
106    pub fn to_svg_string(&self) -> String {
107        self.to_svg_hex()
108    }
109
110    /// Converts normalized HSV (`h, s, v` in `[0.0, 1.0]`) to an opaque `RgbaColor`,
111    /// matching `hsv2rgb` in `lib/common/colxlate.c`.
112    pub fn from_hsv(h: f64, s: f64, v: f64) -> Self {
113        Self::from_hsva(h, s, v, 1.0)
114    }
115
116    /// Converts normalized HSVA (`h, s, v, a` in `[0.0, 1.0]`) to an `RgbaColor`,
117    /// matching `hsv2rgb` and `RGBA_BYTE` conversion in `lib/common/colxlate.c`.
118    pub fn from_hsva(h: f64, s: f64, v: f64, a: f64) -> Self {
119        // fmax(fmin(x, 1.0), 0.0): a NaN becomes 1.
120        let unit = |x: f64| if x.is_nan() { 1.0 } else { x.clamp(0.0, 1.0) };
121        let (h, s, v, a) = (unit(h), unit(s), unit(v), unit(a));
122        let (r, g, b) = hsv2rgb(h, s, v);
123        Self {
124            r: (r * 255.0) as u8,
125            g: (g * 255.0) as u8,
126            b: (b * 255.0) as u8,
127            a: (a * 255.0) as u8,
128        }
129    }
130
131    /// Converts this `RgbaColor` into normalized `(h, s, v, a)` in `[0.0, 1.0]`,
132    /// matching `rgb2hsv` in `lib/common/colxlate.c`.
133    pub fn to_hsva(&self) -> (f64, f64, f64, f64) {
134        let r = f64::from(self.r) / 255.0;
135        let g = f64::from(self.g) / 255.0;
136        let b = f64::from(self.b) / 255.0;
137        let a = f64::from(self.a) / 255.0;
138        let (h, s, v) = rgb2hsv(r, g, b);
139        (h, s, v, a)
140    }
141}
142
143impl fmt::Display for RgbaColor {
144    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
145        f.write_str(&self.to_svg_hex())
146    }
147}
148
149/// Port of `hsv2rgb` from `lib/common/colxlate.c`.
150pub fn hsv2rgb(mut h: f64, s: f64, v: f64) -> (f64, f64, f64) {
151    if s <= 0.0 {
152        (v, v, v)
153    } else {
154        if h >= 1.0 {
155            h = 0.0;
156        }
157        h *= 6.0;
158        let i = h as i32;
159        let f = h - f64::from(i);
160        let p = v * (1.0 - s);
161        let q = v * (1.0 - s * f);
162        let t = v * (1.0 - s * (1.0 - f));
163        match i {
164            0 => (v, t, p),
165            1 => (q, v, p),
166            2 => (p, v, t),
167            3 => (p, q, v),
168            4 => (t, p, v),
169            _ => (v, p, q),
170        }
171    }
172}
173
174/// Port of `rgb2hsv` from `lib/common/colxlate.c`.
175pub fn rgb2hsv(r: f64, g: f64, b: f64) -> (f64, f64, f64) {
176    let rgbmin = r.min(g.min(b));
177    let rgbmax = r.max(g.max(b));
178    let st = if rgbmax > 0.0 {
179        (rgbmax - rgbmin) / rgbmax
180    } else {
181        0.0
182    };
183    let mut ht = 0.0;
184    if st > 0.0 {
185        let denom = rgbmax - rgbmin;
186        let rc = (rgbmax - r) / denom;
187        let gc = (rgbmax - g) / denom;
188        let bc = (rgbmax - b) / denom;
189        if (r - rgbmax).abs() == 0.0 {
190            ht = bc - gc;
191        } else if (g - rgbmax).abs() == 0.0 {
192            ht = 2.0 + rc - bc;
193        } else if (b - rgbmax).abs() == 0.0 {
194            ht = 4.0 + gc - rc;
195        }
196        ht *= 60.0;
197        if ht < 0.0 {
198            ht += 360.0;
199        }
200    }
201    (ht / 360.0, st, rgbmax)
202}
203
204/// Resolves a Graphviz color specification (`#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`,
205/// `"h,s,v"`, `"h s v"`, or X11/SVG/Brewer named color) to an [`RgbaColor`].
206pub fn resolve_color(spec: &str) -> Option<RgbaColor> {
207    resolve_color_with_scheme(spec, None)
208}
209
210/// Resolves a Graphviz color specification with an optional active `colorscheme`, following
211/// `colorxlate` in `lib/common/colxlate.c`. Returns `None` for an unknown color (C yields opaque
212/// black and warns; see [`warn_unknown_color`]).
213pub fn resolve_color_with_scheme(spec: &str, colorscheme: Option<&str>) -> Option<RgbaColor> {
214    let p = spec.trim_start_matches(' ');
215    if p.is_empty() {
216        return None;
217    }
218
219    // 1. Hex color: `#rrggbb[aa]`, or the 3 digit form `#rgb` (C `sscanf("#%2x%2x%2x%2x")`).
220    if let Some(color) = parse_hex_color(p) {
221        return Some(color);
222    }
223
224    // 2. HSV / HSVA numeric specification: starts with '.' or ASCII digit
225    let first = p.as_bytes()[0];
226    if (first == b'.' || first.is_ascii_digit())
227        && let Some(hsv_color) = parse_hsv_color(p)
228    {
229        return Some(hsv_color);
230    }
231
232    // 3. Named color lookup (X11 / SVG / Brewer color schemes)
233    let resolved_name = normalize_color_scheme_name(p, colorscheme);
234    lookup_named_color(&resolved_name)
235}
236
237/// Prints `Warning: <name> is not a known color.` to stderr, once per distinct name, like
238/// `gvrender_resolve_color`.
239pub fn warn_unknown_color(name: &str) {
240    use std::collections::HashSet;
241    use std::sync::{Mutex, OnceLock};
242    static SEEN: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
243    let seen = SEEN.get_or_init(|| Mutex::new(HashSet::new()));
244    if let Ok(mut seen) = seen.lock()
245        && seen.insert(name.to_string())
246    {
247        eprintln!("Warning: {name} is not a known color.");
248    }
249}
250
251/// Formats a Graphviz color spec into an SVG color string (`#rrggbb`, `#rrggbbaa`, or `"none"`),
252/// falling back to `"black"` (`#000000`) if unknown.
253pub fn format_svg_color(spec: &str) -> String {
254    resolve_color(spec).unwrap_or(RgbaColor::BLACK).to_svg_hex()
255}
256
257/// Reads up to `max` hex digits like one `%{max}x` conversion, skipping leading whitespace.
258fn scan_hex(s: &str, max: usize) -> Option<(u32, &str)> {
259    let s = s.trim_start();
260    let n = s
261        .bytes()
262        .take(max)
263        .take_while(u8::is_ascii_hexdigit)
264        .count();
265    if n == 0 {
266        return None;
267    }
268    Some((u32::from_str_radix(&s[..n], 16).ok()?, &s[n..]))
269}
270
271fn parse_hex_color(p: &str) -> Option<RgbaColor> {
272    let rest = p.strip_prefix('#')?;
273    let mut vals = [0u32, 0, 0, 255];
274    let mut cur = rest;
275    let mut count = 0;
276    for slot in vals.iter_mut() {
277        let Some((v, next)) = scan_hex(cur, 2) else {
278            break;
279        };
280        *slot = v;
281        cur = next;
282        count += 1;
283    }
284    if count >= 3 {
285        return Some(RgbaColor::rgba(
286            vals[0] as u8,
287            vals[1] as u8,
288            vals[2] as u8,
289            vals[3] as u8,
290        ));
291    }
292    if p.len() == 4 {
293        let (mut r, rest) = scan_hex(rest, 1)?;
294        let (mut g, rest) = scan_hex(rest, 1)?;
295        let (mut b, _) = scan_hex(rest, 1)?;
296        r |= r << 4;
297        g |= g << 4;
298        b |= b << 4;
299        return Some(RgbaColor::rgb(r as u8, g as u8, b as u8));
300    }
301    None
302}
303
304fn parse_hsv_color(p: &str) -> Option<RgbaColor> {
305    // colorxlate: commas become blanks, then `sscanf("%lf%lf%lf%lf") >= 3`, so text after
306    // the numbers (`0.1,0.9,0.7;0.3` of a weighted list) is ignored.
307    let canon: String = p.chars().map(|c| if c == ',' { ' ' } else { c }).collect();
308    let mut nums = [0.0_f64, 0.0, 0.0, 1.0];
309    let mut rest = canon.as_str();
310    let mut count = 0;
311    while count < 4 {
312        let Some((v, r)) = scan_lf(rest) else { break };
313        nums[count] = v;
314        rest = r;
315        count += 1;
316    }
317    if count >= 3 {
318        Some(RgbaColor::from_hsva(nums[0], nums[1], nums[2], nums[3]))
319    } else {
320        None
321    }
322}
323
324/// One `%lf` conversion: skips white space, then the longest `strtod` prefix.
325fn scan_lf(s: &str) -> Option<(f64, &str)> {
326    let t = s.trim_start_matches([' ', '\t', '\n', '\r', '\x0b', '\x0c']);
327    let b = t.as_bytes();
328    let mut i = usize::from(matches!(b.first(), Some(b'+' | b'-')));
329    let lower = t[i..]
330        .get(..8.min(t.len() - i))
331        .unwrap_or("")
332        .to_ascii_lowercase();
333    let neg = b.first() == Some(&b'-');
334    for (word, v) in [
335        ("infinity", f64::INFINITY),
336        ("inf", f64::INFINITY),
337        ("nan", f64::NAN),
338    ] {
339        if lower.starts_with(word) {
340            let v = if neg { -v } else { v };
341            return Some((v, &t[i + word.len()..]));
342        }
343    }
344    let start = i;
345    while i < b.len() && b[i].is_ascii_digit() {
346        i += 1;
347    }
348    let mut digits = i - start;
349    if i < b.len() && b[i] == b'.' {
350        let f = i + 1;
351        let mut j = f;
352        while j < b.len() && b[j].is_ascii_digit() {
353            j += 1;
354        }
355        digits += j - f;
356        if digits > 0 {
357            i = j;
358        }
359    }
360    if digits == 0 {
361        return None;
362    }
363    if i < b.len() && (b[i] == b'e' || b[i] == b'E') {
364        let mut j = i + 1;
365        if j < b.len() && (b[j] == b'+' || b[j] == b'-') {
366            j += 1;
367        }
368        let e = j;
369        while j < b.len() && b[j].is_ascii_digit() {
370            j += 1;
371        }
372        if j > e {
373            i = j;
374        }
375    }
376    let text = t[..i].strip_suffix('.').unwrap_or(&t[..i]);
377    Some((text.parse().unwrap_or(0.0), &t[i..]))
378}
379
380/// Port of `resolveColor` from `lib/common/colxlate.c`.
381fn normalize_color_scheme_name(str_in: &str, colorscheme: Option<&str>) -> String {
382    let lower = str_in.to_ascii_lowercase();
383    if lower == "black" || lower == "white" || lower == "lightgrey" {
384        return lower;
385    }
386
387    let is_non_dflt = |scheme: Option<&str>| -> Option<String> {
388        let s = scheme?;
389        if s.is_empty() || s.get(..3).is_some_and(|p| p.eq_ignore_ascii_case("x11")) {
390            None
391        } else {
392            Some(s.to_ascii_lowercase())
393        }
394    };
395
396    if let Some(c2) = lower.strip_prefix('/') {
397        if let Some(slash_idx) = c2.find('/') {
398            if let Some(after_double) = c2.strip_prefix('/') {
399                if let Some(scheme) = is_non_dflt(colorscheme) {
400                    format!("/{scheme}/{after_double}")
401                } else {
402                    after_double.to_string()
403                }
404            } else if c2.starts_with("x11/") {
405                c2[slash_idx + 1..].to_string()
406            } else {
407                lower
408            }
409        } else {
410            c2.to_string()
411        }
412    } else if let Some(scheme) = is_non_dflt(colorscheme) {
413        format!("/{scheme}/{lower}")
414    } else {
415        lower
416    }
417}
418
419fn lookup_named_color(name: &str) -> Option<RgbaColor> {
420    let key = name.to_ascii_lowercase();
421    let idx = COLOR_LIB
422        .binary_search_by(|(n, _)| (*n).cmp(key.as_str()))
423        .ok()?;
424    let [r, g, b, a] = COLOR_LIB[idx].1;
425    Some(RgbaColor::rgba(r, g, b, a))
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431
432    #[test]
433    fn test_named_colors_table_is_sorted() {
434        for window in COLOR_LIB.windows(2) {
435            assert!(
436                window[0].0 < window[1].0,
437                "{:?} >= {:?}",
438                window[0].0,
439                window[1].0
440            );
441        }
442    }
443
444    #[test]
445    fn test_x11_svg_named_colors() {
446        assert_eq!(
447            resolve_color("lightblue"),
448            Some(RgbaColor::rgb(173, 216, 230))
449        );
450        assert_eq!(
451            resolve_color("lightgrey"),
452            Some(RgbaColor::rgb(211, 211, 211))
453        );
454        assert_eq!(
455            resolve_color("LightGray"),
456            Some(RgbaColor::rgb(211, 211, 211))
457        );
458        assert_eq!(resolve_color("red"), Some(RgbaColor::rgb(255, 0, 0)));
459        assert_eq!(resolve_color("gold"), Some(RgbaColor::rgb(255, 215, 0)));
460        assert_eq!(
461            resolve_color("darkslategray"),
462            Some(RgbaColor::rgb(47, 79, 79))
463        );
464        assert_eq!(
465            resolve_color("darkslategrey"),
466            Some(RgbaColor::rgb(47, 79, 79))
467        );
468        assert_eq!(
469            resolve_color("transparent"),
470            Some(RgbaColor::rgba(255, 255, 254, 0))
471        );
472        assert_eq!(resolve_color("none").unwrap().to_svg_hex(), "none");
473        assert!(resolve_color("invis").unwrap().is_transparent());
474    }
475
476    #[test]
477    fn test_hex_colors() {
478        assert_eq!(resolve_color("#ff0000"), Some(RgbaColor::rgb(255, 0, 0)));
479        assert_eq!(
480            resolve_color("#ff000080"),
481            Some(RgbaColor::rgba(255, 0, 0, 128))
482        );
483        assert_eq!(
484            resolve_color("#f0a"),
485            Some(RgbaColor::rgb(0xff, 0x00, 0xaa))
486        );
487        // C `sscanf` semantics: `#1234` is neither `#rrggbb` nor the 3 digit form.
488        assert_eq!(resolve_color("#1234"), None);
489        assert_eq!(resolve_color("#add8e6").unwrap().to_svg_hex(), "#add8e6");
490        assert_eq!(
491            resolve_color("#ff000080").unwrap().to_svg_hex(),
492            "#ff000080"
493        );
494        assert_eq!(
495            resolve_color("#12345"),
496            Some(RgbaColor::rgba(0x12, 0x34, 0x5, 255))
497        );
498    }
499
500    #[test]
501    fn test_hsv_colors() {
502        // Pure red: h=0, s=1, v=1
503        assert_eq!(
504            resolve_color("0.0,1.0,1.0"),
505            Some(RgbaColor::rgb(255, 0, 0))
506        );
507        // Pure green: h=1/3, s=1, v=1
508        let green = resolve_color("0.333333, 1.0, 1.0").unwrap();
509        assert_eq!(green.g, 255);
510        assert!(green.r <= 1 && green.b == 0);
511        // Space-separated HSVA
512        let semi = resolve_color("0.0 0.0 0.5 0.5").unwrap();
513        assert_eq!(semi, RgbaColor::rgba(127, 127, 127, 127));
514    }
515
516    #[test]
517    fn test_full_tables_match_c() {
518        // Expected values taken from `dot -Tsvg` 16.1.0.
519        let hex = |spec: &str, scheme: Option<&str>| {
520            resolve_color_with_scheme(spec, scheme)
521                .unwrap_or_else(|| panic!("{spec} unresolved"))
522                .to_hex_rgb()
523        };
524        assert_eq!(hex("/blues9/5", None), "#6baed6");
525        assert_eq!(hex("/set312/7", None), "#b3de69");
526        assert_eq!(hex("/rdylgn11/3", None), "#f46d43");
527        assert_eq!(hex("/svg/aliceblue", None), "#f0f8ff");
528        assert_eq!(hex("/svg/yellowgreen", None), "#9acd32");
529        assert_eq!(hex("/x11/navy", None), "#000080");
530        assert_eq!(hex("5", Some("blues9")), "#6baed6");
531        assert_eq!(hex("//3", Some("blues9")), "#c6dbef");
532        assert_eq!(hex("navy", Some("X11")), "#000080");
533    }
534
535    #[test]
536    fn test_unknown_colors_are_not_resolved() {
537        assert_eq!(resolve_color("nosuchcolor"), None);
538        assert_eq!(resolve_color("/blues9/10"), None);
539        assert_eq!(resolve_color("9"), None);
540        assert_eq!(resolve_color_with_scheme("9", Some("blues3")), None);
541        assert_eq!(resolve_color_with_scheme("red", Some("blues9")), None);
542        assert_eq!(resolve_color("gray101"), None);
543        assert_eq!(resolve_color("#ffff"), None);
544    }
545}