1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31pub struct Color {
32 pub r: u8,
34 pub g: u8,
36 pub b: u8,
38}
39
40impl Color {
41 #[must_use]
50 pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
51 Self { r, g, b }
52 }
53
54 #[must_use]
56 pub fn css_hex(self) -> String {
57 format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b)
58 }
59
60 #[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#[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 #[must_use]
110 pub fn new() -> Self {
111 builtin().clone()
112 }
113
114 #[must_use]
116 pub fn len(&self) -> usize {
117 self.named.len()
118 }
119
120 #[must_use]
122 pub fn is_empty(&self) -> bool {
123 self.named.is_empty()
124 }
125
126 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 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
177pub 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
262pub 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}