Skip to main content

latexml_core/common/
color.rs

1use std::{
2  fmt,
3  hash::{Hash, Hasher},
4};
5
6/// Color in a specific color model, matching Perl's LaTeXML::Common::Color hierarchy.
7///
8/// Core models: rgb, cmy, cmyk, hsb, gray.
9/// PartialEq compares by model + components, matching Perl's Object::ne
10/// behavior via toString (e.g., cmyk(0,0,0,1) ≠ rgb(0,0,0)).
11#[derive(Clone, Copy, Debug, PartialEq)]
12pub enum Color {
13  // Eq is manually implemented below since f64 doesn't derive Eq,
14  // but our floats are always valid (no NaN).
15  Rgb(f64, f64, f64),
16  Cmy(f64, f64, f64),
17  Cmyk(f64, f64, f64, f64),
18  Hsb(f64, f64, f64),
19  Gray(f64),
20}
21
22/// Perl: use constant Black => bless ['rgb', 0, 0, 0], '...::rgb';
23pub const BLACK: Color = Color::Rgb(0.0, 0.0, 0.0);
24/// Perl: use constant White => bless ['rgb', 1, 1, 1], '...::rgb';
25pub const WHITE: Color = Color::Rgb(1.0, 1.0, 1.0);
26
27// f64 doesn't implement Eq, but our color components are always valid floats (no NaN).
28impl Eq for Color {}
29
30impl Hash for Color {
31  fn hash<H: Hasher>(&self, hasher: &mut H) {
32    std::mem::discriminant(self).hash(hasher);
33    for c in self.components() {
34      ((c * 100000.0) as i64).hash(hasher);
35    }
36  }
37}
38
39/// Perl: toString → "model(c1,c2,...)"
40impl fmt::Display for Color {
41  fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
42    let model = self.model();
43    let comps: Vec<String> = self
44      .components()
45      .iter()
46      .map(|c| format_component(*c))
47      .collect();
48    write!(f, "{model}({})", comps.join(","))
49  }
50}
51
52impl Color {
53  /// Return the color model name. Perl: $self->model
54  pub fn model(&self) -> &'static str {
55    match self {
56      Color::Rgb(..) => "rgb",
57      Color::Cmy(..) => "cmy",
58      Color::Cmyk(..) => "cmyk",
59      Color::Hsb(..) => "hsb",
60      Color::Gray(..) => "gray",
61    }
62  }
63
64  /// Return the component values. Perl: $self->components
65  pub fn components(&self) -> Vec<f64> {
66    match self {
67      Color::Rgb(r, g, b) => vec![*r, *g, *b],
68      Color::Cmy(c, m, y) => vec![*c, *m, *y],
69      Color::Cmyk(c, m, y, k) => vec![*c, *m, *y, *k],
70      Color::Hsb(h, s, b) => vec![*h, *s, *b],
71      Color::Gray(g) => vec![*g],
72    }
73  }
74
75  /// Convert to RGB model. Perl: $self->rgb
76  pub fn to_rgb(&self) -> Color {
77    match self {
78      Color::Rgb(..) => *self,
79      Color::Cmy(c, m, y) => Color::Rgb(1.0 - c, 1.0 - m, 1.0 - y),
80      Color::Cmyk(..) => self.to_cmy().to_rgb(),
81      Color::Hsb(h, s, b) => {
82        let i = (6.0 * h) as i32;
83        let f = 6.0 * h - i as f64;
84        let u = b * (1.0 - s * (1.0 - f));
85        let v = b * (1.0 - s * f);
86        let w = b * (1.0 - s);
87        match i {
88          0 => Color::Rgb(*b, u, w),
89          1 => Color::Rgb(v, *b, w),
90          2 => Color::Rgb(w, *b, u),
91          3 => Color::Rgb(w, v, *b),
92          4 => Color::Rgb(u, w, *b),
93          5 => Color::Rgb(*b, w, v),
94          6 => Color::Rgb(*b, w, w),
95          _ => Color::Rgb(*b, w, w), // fallback
96        }
97      },
98      Color::Gray(g) => Color::Rgb(*g, *g, *g),
99    }
100  }
101
102  /// Convert to CMY model. Perl: $self->cmy
103  pub fn to_cmy(&self) -> Color {
104    match self {
105      Color::Cmy(..) => *self,
106      Color::Rgb(r, g, b) => Color::Cmy(1.0 - r, 1.0 - g, 1.0 - b),
107      Color::Cmyk(c, m, y, k) => Color::Cmy((c + k).min(1.0), (m + k).min(1.0), (y + k).min(1.0)),
108      Color::Hsb(..) => self.to_rgb().to_cmy(),
109      Color::Gray(g) => Color::Cmy(1.0 - g, 1.0 - g, 1.0 - g),
110    }
111  }
112
113  /// Convert to CMYK model. Perl: $self->cmyk
114  pub fn to_cmyk(&self) -> Color {
115    match self {
116      Color::Cmyk(..) => *self,
117      Color::Cmy(c, m, y) => {
118        // Perl: undercolor-removal with beta parameters all = 1
119        let k = c.min(*m).min(*y);
120        Color::Cmyk(
121          (c - k).clamp(0.0, 1.0),
122          (m - k).clamp(0.0, 1.0),
123          (y - k).clamp(0.0, 1.0),
124          k,
125        )
126      },
127      Color::Rgb(..) => self.to_cmy().to_cmyk(),
128      Color::Hsb(..) => self.to_rgb().to_cmyk(),
129      Color::Gray(g) => Color::Cmyk(0.0, 0.0, 0.0, 1.0 - g),
130    }
131  }
132
133  /// Convert to HSB model. Perl: $self->hsb
134  pub fn to_hsb(&self) -> Color {
135    match self {
136      Color::Hsb(..) => *self,
137      Color::Rgb(r, g, b) => {
138        // Perl: rgb.pm Phi function + hsb dispatch
139        let i = 4 * (if *r >= *g { 1 } else { 0 })
140          + 2 * (if *g >= *b { 1 } else { 0 })
141          + (if *b >= *r { 1 } else { 0 });
142        match i {
143          1 => phi(*b, *g, *r, 3.0, 1.0),
144          2 => phi(*g, *r, *b, 1.0, 1.0),
145          3 => phi(*g, *b, *r, 3.0, -1.0),
146          4 => phi(*r, *b, *g, 5.0, 1.0),
147          5 => phi(*b, *r, *g, 5.0, -1.0),
148          6 => phi(*r, *g, *b, 1.0, -1.0),
149          7 => Color::Hsb(0.0, 0.0, *b),
150          _ => Color::Hsb(0.0, 0.0, 0.0),
151        }
152      },
153      Color::Cmy(..) => self.to_rgb().to_hsb(),
154      Color::Cmyk(..) => self.to_cmy().to_hsb(),
155      Color::Gray(g) => Color::Hsb(0.0, 0.0, *g),
156    }
157  }
158
159  /// Convert to gray model. Perl: $self->gray
160  pub fn to_gray(&self) -> Color {
161    match self {
162      Color::Gray(..) => *self,
163      Color::Rgb(r, g, b) => Color::Gray(0.3 * r + 0.59 * g + 0.11 * b),
164      Color::Cmy(c, m, y) => Color::Gray(1.0 - (0.3 * c + 0.59 * m + 0.11 * y)),
165      Color::Cmyk(c, m, y, k) => Color::Gray(1.0 - (0.3 * c + 0.59 * m + 0.11 * y + k).min(1.0)),
166      Color::Hsb(..) => self.to_rgb().to_gray(),
167    }
168  }
169
170  /// Convert to another model by name. Perl: $self->convert($tomodel)
171  /// Handles both core models (rgb, cmy, cmyk, hsb, gray) and
172  /// extended models (HTML, RGB, Hsb, HSB, Gray, tHsb, wave).
173  pub fn convert(&self, to_model: &str) -> Color {
174    match to_model {
175      "rgb" => self.to_rgb(),
176      "cmy" => self.to_cmy(),
177      "cmyk" => self.to_cmyk(),
178      "hsb" => self.to_hsb(),
179      "gray" => self.to_gray(),
180      // Extended models map to their core equivalent
181      "HTML" | "RGB" => self.to_rgb(),
182      "Hsb" | "HSB" | "tHsb" => self.to_hsb(),
183      "Gray" => self.to_gray(),
184      _ => *self,
185    }
186  }
187
188  /// Return components scaled to the target model's native range.
189  /// Core models use 0-1 range. Extended models use their native ranges:
190  /// HTML/RGB: 0-255, Hsb: h=0-360/s=0-1/b=0-1, HSB: 0-240, Gray: 0-15.
191  /// Perl: Color objects in extended models store components in native range.
192  pub fn components_for_model(&self, model: &str) -> Vec<f64> {
193    let core = self.convert(model);
194    let comps = core.components();
195    match model {
196      "HTML" | "RGB" => comps.iter().map(|c| (c * 255.0).round()).collect(),
197      "Hsb" => vec![(comps[0] * 360.0).round(), comps[1], comps[2]],
198      "HSB" => comps.iter().map(|c| (c * 240.0).round()).collect(),
199      "Gray" => vec![(comps[0] * 15.0).round()],
200      _ => comps,
201    }
202  }
203
204  /// Convert to hex attribute string. Perl: $self->toAttribute() = $self->rgb->toHex()
205  pub fn to_attribute(&self) -> String {
206    let rgb = self.to_rgb();
207    if let Color::Rgb(r, g, b) = rgb {
208      format!(
209        "#{:02X}{:02X}{:02X}",
210        component_to_u8(r),
211        component_to_u8(g),
212        component_to_u8(b)
213      )
214    } else {
215      unreachable!()
216    }
217  }
218
219  /// Complement. Perl: $self->complement
220  pub fn complement(&self) -> Color {
221    match self {
222      Color::Rgb(r, g, b) => Color::Rgb(1.0 - r, 1.0 - g, 1.0 - b),
223      Color::Cmy(c, m, y) => Color::Cmy(1.0 - c, 1.0 - m, 1.0 - y),
224      Color::Cmyk(..) => self.to_cmy().complement().to_cmyk(),
225      Color::Hsb(h, s, b) => {
226        let hp = if *h < 0.5 { h + 0.5 } else { h - 0.5 };
227        let bp = 1.0 - b * (1.0 - s);
228        let sp = if bp == 0.0 { 0.0 } else { b * s / bp };
229        Color::Hsb(hp, sp, bp)
230      },
231      Color::Gray(g) => Color::Gray(1.0 - g),
232    }
233  }
234
235  /// Mix self*fraction + other*(1-fraction). Perl: $self->mix($other, $fraction)
236  pub fn mix(&self, other: &Color, fraction: f64) -> Color {
237    let (base, other) = self.align_models(other);
238    // Hsb: mix in rgb space then convert back
239    if matches!(&base, Color::Hsb(..)) {
240      return base.to_rgb().mix(&other, fraction).to_hsb();
241    }
242    let a = base.components();
243    let b = other.components();
244    // Allow extrapolation (fraction outside [0,1]) so callers can express
245    // xcolor's `c!p` for p>100 ("darker than base"); clamp the resulting
246    // components back into the model's valid [0,1] range.
247    let mixed: Vec<f64> = a
248      .iter()
249      .zip(b.iter())
250      .map(|(ai, bi)| (fraction * ai + (1.0 - fraction) * bi).clamp(0.0, 1.0))
251      .collect();
252    from_model_components(base.model(), &mixed)
253  }
254
255  /// Add component-wise. Perl: $self->add($other)
256  pub fn add(&self, other: &Color) -> Color {
257    let (base, other) = self.align_models(other);
258    let a = base.components();
259    let b = other.components();
260    let added: Vec<f64> = a.iter().zip(b.iter()).map(|(ai, bi)| ai + bi).collect();
261    from_model_components(base.model(), &added)
262  }
263
264  /// Scale all components. Perl: $self->scale($m)
265  pub fn scale(&self, m: f64) -> Color {
266    let scaled: Vec<f64> = self.components().iter().map(|c| m * c).collect();
267    from_model_components(self.model(), &scaled)
268  }
269
270  /// Multiply by component vector. Perl: $self->multiply(@m)
271  pub fn multiply(&self, factors: &[f64]) -> Color {
272    let comps = self.components();
273    let result: Vec<f64> = comps
274      .iter()
275      .zip(factors.iter())
276      .map(|(c, f)| c * f)
277      .collect();
278    from_model_components(self.model(), &result)
279  }
280
281  /// Align two colors to the same model for operations.
282  /// Perl: if base is gray, convert to other's model; else convert other to base's model.
283  fn align_models(&self, other: &Color) -> (Color, Color) {
284    if self.model() == other.model() {
285      return (*self, *other);
286    }
287    if self.model() == "gray" {
288      (self.convert(other.model()), *other)
289    } else {
290      (*self, other.convert(self.model()))
291    }
292  }
293
294  /// Format the RGB components as a comma-separated string for reversion.
295  /// Perl: join(',', $color->rgb->components)
296  pub fn rgb_components_string(&self) -> String {
297    let rgb = self.to_rgb();
298    let comps = rgb.components();
299    comps
300      .iter()
301      .map(|c| format_component(*c))
302      .collect::<Vec<_>>()
303      .join(",")
304  }
305
306  /// Encode for state storage: "model c1 c2 ..."
307  pub fn to_stored(&self) -> String {
308    let model = self.model();
309    let comps: Vec<String> = self
310      .components()
311      .iter()
312      .map(|c| format_component(*c))
313      .collect();
314    format!("{model} {}", comps.join(" "))
315  }
316
317  /// Decode from state storage format "model c1 c2 ..."
318  pub fn from_stored(s: &str) -> Option<Color> {
319    let parts: Vec<&str> = s.split_whitespace().collect();
320    if parts.is_empty() {
321      return None;
322    }
323    let model = parts[0];
324    let comps: Vec<f64> = parts[1..]
325      .iter()
326      .filter_map(|p| p.parse::<f64>().ok())
327      .collect();
328    Some(from_model_components(model, &comps))
329  }
330}
331
332/// Convert color component float (0.0-1.0) to u8 (0-255).
333/// Matches Perl's `roundto($n * 255, 0)` which adds a small epsilon factor.
334fn component_to_u8(v: f64) -> u8 {
335  let scaled = v.clamp(0.0, 1.0) * 255.0 * (1.0 + 100.0 * f64::EPSILON);
336  scaled.round() as u8
337}
338
339/// Format a float component like Perl: integers without decimal point.
340pub fn format_component(v: f64) -> String {
341  if (v - v.round()).abs() < 1e-10 {
342    format!("{}", v.round() as i64)
343  } else {
344    format!("{v}")
345  }
346}
347
348/// Perl rgb.pm: Phi function for RGB→HSB conversion
349fn phi(x: f64, y: f64, z: f64, u: f64, v: f64) -> Color {
350  Color::Hsb(
351    (u * (x - z) + v * (x - y)) / (6.0 * (x - z)),
352    (x - z) / x,
353    x,
354  )
355}
356
357/// Parse color components from a spec string (comma or space separated)
358fn parse_components(spec: &str) -> Vec<f64> {
359  // Perl commit a8b75dbb (#2551): support mixed-delimiter input. When the spec
360  // contains a comma, split on comma first, then allow whitespace splits inside
361  // each component so e.g. `153 153, 192` for {RGB}{153 153, 192} yields 3 values.
362  if spec.contains(',') {
363    spec
364      .split(',')
365      .flat_map(|s| s.split_whitespace())
366      .filter_map(|s| s.trim().parse::<f64>().ok())
367      .collect()
368  } else {
369    spec
370      .split_whitespace()
371      .filter_map(|s| s.parse::<f64>().ok())
372      .collect()
373  }
374}
375
376/// Create a Color from model name and component values
377pub fn from_model_components(model: &str, comps: &[f64]) -> Color {
378  match model {
379    "rgb" if comps.len() >= 3 => Color::Rgb(comps[0], comps[1], comps[2]),
380    "cmy" if comps.len() >= 3 => Color::Cmy(comps[0], comps[1], comps[2]),
381    "cmyk" if comps.len() >= 4 => Color::Cmyk(comps[0], comps[1], comps[2], comps[3]),
382    "hsb" if comps.len() >= 3 => Color::Hsb(comps[0], comps[1], comps[2]),
383    "gray" if !comps.is_empty() => Color::Gray(comps[0]),
384    _ => BLACK,
385  }
386}
387
388/// Parse a color from model name + spec string.
389/// Perl: Color($model, components)->toCore
390pub fn color_from_model_spec(model: &str, spec: &str) -> Color {
391  let spec = spec.trim().trim_matches(|c| c == '{' || c == '}').trim();
392  let c = parse_components(spec);
393  from_model_components(model, &c)
394}
395
396#[cfg(test)]
397mod tests {
398  use super::*;
399
400  fn eq_close(a: f64, b: f64) -> bool { (a - b).abs() < 1e-6 }
401
402  #[test]
403  fn model_names() {
404    assert_eq!(Color::Rgb(0.0, 0.0, 0.0).model(), "rgb");
405    assert_eq!(Color::Cmy(0.0, 0.0, 0.0).model(), "cmy");
406    assert_eq!(Color::Cmyk(0.0, 0.0, 0.0, 0.0).model(), "cmyk");
407    assert_eq!(Color::Hsb(0.0, 0.0, 0.0).model(), "hsb");
408    assert_eq!(Color::Gray(0.0).model(), "gray");
409  }
410
411  #[test]
412  fn components_match_variant() {
413    assert_eq!(Color::Rgb(0.1, 0.2, 0.3).components(), vec![0.1, 0.2, 0.3]);
414    assert_eq!(Color::Cmyk(0.1, 0.2, 0.3, 0.4).components(), vec![
415      0.1, 0.2, 0.3, 0.4
416    ]);
417    assert_eq!(Color::Gray(0.5).components(), vec![0.5]);
418  }
419
420  #[test]
421  fn black_and_white_constants() {
422    assert_eq!(BLACK, Color::Rgb(0.0, 0.0, 0.0));
423    assert_eq!(WHITE, Color::Rgb(1.0, 1.0, 1.0));
424  }
425
426  #[test]
427  fn to_rgb_idempotent() {
428    let c = Color::Rgb(0.25, 0.5, 0.75);
429    assert_eq!(c.to_rgb(), c);
430  }
431
432  #[test]
433  fn rgb_cmy_invert_components() {
434    let cmy = Color::Cmy(0.25, 0.5, 0.75);
435    let rgb = cmy.to_rgb();
436    if let Color::Rgb(r, g, b) = rgb {
437      assert!(
438        eq_close(r, 0.75) && eq_close(g, 0.5) && eq_close(b, 0.25),
439        "got {rgb:?}"
440      );
441    } else {
442      panic!("expected Rgb after cmy.to_rgb(), got {rgb:?}");
443    }
444  }
445
446  #[test]
447  fn complement_flips_rgb() {
448    let c = Color::Rgb(0.1, 0.2, 0.3);
449    let comp = c.complement();
450    if let Color::Rgb(r, g, b) = comp {
451      assert!(
452        eq_close(r, 0.9) && eq_close(g, 0.8) && eq_close(b, 0.7),
453        "got {comp:?}"
454      );
455    } else {
456      panic!("Rgb complement should return Rgb");
457    }
458  }
459
460  #[test]
461  fn display_format_parenthesized() {
462    let c = Color::Rgb(0.5, 0.5, 0.5);
463    let s = format!("{c}");
464    assert!(s.starts_with("rgb(") && s.ends_with(')'), "got {s:?}");
465  }
466
467  #[test]
468  fn from_model_components_rgb() {
469    let c = from_model_components("rgb", &[0.1, 0.2, 0.3]);
470    assert_eq!(c, Color::Rgb(0.1, 0.2, 0.3));
471  }
472
473  #[test]
474  fn from_model_components_gray_single() {
475    let c = from_model_components("gray", &[0.5]);
476    assert_eq!(c, Color::Gray(0.5));
477  }
478
479  #[test]
480  fn from_model_components_unknown_is_black() {
481    // Fallback: unknown model returns BLACK.
482    let c = from_model_components("nonesuch", &[1.0, 1.0, 1.0]);
483    assert_eq!(c, BLACK);
484  }
485
486  #[test]
487  fn from_model_components_insufficient_comps_is_black() {
488    // rgb needs 3 comps; giving 1 falls through to BLACK.
489    let c = from_model_components("rgb", &[0.5]);
490    assert_eq!(c, BLACK);
491  }
492
493  #[test]
494  fn color_from_model_spec_parses_braced() {
495    // Braces are stripped before parsing.
496    let c = color_from_model_spec("rgb", "{0.1, 0.2, 0.3}");
497    assert_eq!(c, Color::Rgb(0.1, 0.2, 0.3));
498  }
499
500  #[test]
501  fn color_from_model_spec_parses_spaces() {
502    // Spaces work as separators too.
503    let c = color_from_model_spec("rgb", "0.1 0.2 0.3");
504    assert_eq!(c, Color::Rgb(0.1, 0.2, 0.3));
505  }
506
507  #[test]
508  fn color_inequality_across_models() {
509    // rgb(0,0,0) ≠ cmyk(0,0,0,1) — different models never compare equal.
510    assert_ne!(BLACK, Color::Cmyk(0.0, 0.0, 0.0, 1.0));
511    assert_ne!(WHITE, Color::Gray(1.0));
512  }
513}