From 727fe9d4c9417cdb1e87abe2714ac1ec317e0e3f Mon Sep 17 00:00:00 2001 From: Yonghye Kwon Date: Sun, 30 Aug 2026 04:44:05 +0700 Subject: [PATCH] fix theme tint color matching Signed-off-by: Yonghye Kwon --- src/helper/color.rs | 169 +++++++++++++++++++++++++++++++++++++- tests/integration_test.rs | 22 ++--- 2 files changed, 177 insertions(+), 14 deletions(-) diff --git a/src/helper/color.rs b/src/helper/color.rs index e3ba5dee..96dad623 100644 --- a/src/helper/color.rs +++ b/src/helper/color.rs @@ -16,12 +16,109 @@ pub struct MsHlsColor { const RGBMAX: f64 = 255.0; const HLSMAX: f64 = 255.0; +const EXCEL_RGB_MAX: i32 = 255; +const EXCEL_HLS_MAX: i32 = 240; +const EXCEL_TINT_SHORT_MAX: f64 = 32_767.0; +const PERCENT_SCALE: f64 = 100.0; #[must_use] pub fn calc_tint(rgb: &str, tint: f64) -> String { - let mut ms_hls = convert_rgb_to_ms_hls(rgb); - ms_hls.l = calculate_final_lum_value(tint, f64::from(ms_hls.l)); - convert_ms_hls_to_rgb(&ms_hls) + let tint = normalize_excel_tint(tint); + let (hue, luminance, saturation) = convert_rgb_to_excel_hls(rgb); + let moved = if tint < 0.0 { + f64::from(luminance) * (1.0 + tint) + } else { + f64::from(luminance) * (1.0 - tint) + f64::from(EXCEL_HLS_MAX) * tint + }; + convert_excel_hls_to_rgb( + hue, + num_traits::cast::(moved) + .unwrap() + .clamp(0, EXCEL_HLS_MAX), + saturation, + ) +} + +/// Recover near-whole-percent tints produced by Excel-compatible encoders. +/// +/// Workbook fixtures commonly express 80% as `0.79998168889431442`, matching +/// one step of the legacy signed-short tint range. Treat values within that +/// step as their nominal whole percentage, but retain more distant arbitrary +/// OOXML values such as `0.7999`. +fn normalize_excel_tint(tint: f64) -> f64 { + let whole_percent = (tint * PERCENT_SCALE).round() / PERCENT_SCALE; + if (tint - whole_percent).abs() <= 1.0 / EXCEL_TINT_SHORT_MAX { + whole_percent + } else { + tint + } +} + +/// Convert RGB to the 240-step integer HLS space Excel uses for theme tints. +/// +/// Using the floating-point 255-step conversion below changes native export +/// colours by one level. The RGB/HLS conversions round at their integer +/// stages, while the tint-adjusted luminance is deliberately truncated before +/// the reverse conversion. +fn convert_rgb_to_excel_hls(rgb: &str) -> (i32, i32, i32) { + let (red, green, blue) = split_rgb(rgb); + let brightest = red.max(green).max(blue); + let darkest = red.min(green).min(blue); + let sum = brightest + darkest; + let span = brightest - darkest; + let luminance = (sum * EXCEL_HLS_MAX + EXCEL_RGB_MAX) / (2 * EXCEL_RGB_MAX); + if span == 0 { + return (0, luminance, 0); + } + + let saturation = if luminance <= EXCEL_HLS_MAX / 2 { + (span * EXCEL_HLS_MAX + sum / 2) / sum + } else { + (span * EXCEL_HLS_MAX + (2 * EXCEL_RGB_MAX - sum) / 2) / (2 * EXCEL_RGB_MAX - sum) + }; + let distance = |channel: i32| ((brightest - channel) * (EXCEL_HLS_MAX / 6) + span / 2) / span; + let hue = if red == brightest { + distance(blue) - distance(green) + } else if green == brightest { + EXCEL_HLS_MAX / 3 + distance(red) - distance(blue) + } else { + 2 * EXCEL_HLS_MAX / 3 + distance(green) - distance(red) + }; + (hue.rem_euclid(EXCEL_HLS_MAX), luminance, saturation) +} + +/// Convert Excel's integer HLS representation back to RGB. +fn convert_excel_hls_to_rgb(hue: i32, luminance: i32, saturation: i32) -> String { + let upper = if luminance <= EXCEL_HLS_MAX / 2 { + (luminance * (EXCEL_HLS_MAX + saturation) + EXCEL_HLS_MAX / 2) / EXCEL_HLS_MAX + } else { + luminance + saturation - (luminance * saturation + EXCEL_HLS_MAX / 2) / EXCEL_HLS_MAX + }; + let lower = 2 * luminance - upper; + let channel = |hue_offset: i32| { + let level = excel_hue_level(lower, upper, hue + hue_offset); + ((level * EXCEL_RGB_MAX + EXCEL_HLS_MAX / 2) / EXCEL_HLS_MAX).clamp(0, EXCEL_RGB_MAX) + }; + join_rgb( + channel(EXCEL_HLS_MAX / 3), + channel(0), + channel(-EXCEL_HLS_MAX / 3), + ) +} + +/// Interpolate one RGB channel around Excel's integer hue wheel. +fn excel_hue_level(lower: i32, upper: i32, hue: i32) -> i32 { + let hue = hue.rem_euclid(EXCEL_HLS_MAX); + let sixth = EXCEL_HLS_MAX / 6; + if hue < sixth { + lower + ((upper - lower) * hue + EXCEL_HLS_MAX / 12) / sixth + } else if hue < EXCEL_HLS_MAX / 2 { + upper + } else if hue < 2 * EXCEL_HLS_MAX / 3 { + lower + ((upper - lower) * (2 * EXCEL_HLS_MAX / 3 - hue) + EXCEL_HLS_MAX / 12) / sixth + } else { + lower + } } #[must_use] @@ -187,3 +284,69 @@ fn positive_decimal_part(hue: f64) -> f64 { fn to_i32(num: f64) -> i32 { num_traits::cast(num.round()).unwrap() } + +#[cfg(test)] +mod tests { + use super::{ + EXCEL_TINT_SHORT_MAX, + calc_tint, + normalize_excel_tint, + }; + + #[test] + fn excel_theme_tints_use_the_native_hls_quantization() { + let measured = [ + ("DAB6BA", 0.7999, "F8EFF0"), + ("4F81BD", 0.8, "DCE6F1"), + ("4F81BD", 0.4, "95B3D7"), + ("C0504D", 0.8, "F2DCDB"), + ("C0504D", 0.4, "DA9694"), + ("5B9BD5", 0.8, "DDEBF7"), + ("5B9BD5", 0.4, "9BC2E6"), + ("FFFFFF", -0.15, "D9D9D9"), + ]; + + for (source, tint, expected) in measured { + assert_eq!( + calc_tint(source, tint), + expected, + "{source} tinted by {tint}" + ); + } + } + + #[test] + fn excel_encoded_percent_tints_are_recovered_before_hls_math() { + let measured = [ + ("000000", 0.499_984_740_745_262, "808080"), + ("4EA72E", 0.799_981_688_894_314_4, "DAF2D0"), + ("4EA72E", 0.599_993_896_298_104_8, "B5E6A2"), + ("4EA72E", 0.399_975_585_192_419_2, "8ED973"), + ("4E3B30", 0.399_975_585_192_419_2, "A78570"), + ]; + + for (source, tint, expected) in measured { + assert_eq!( + calc_tint(source, tint), + expected, + "{source} tinted by {tint}" + ); + } + + assert_eq!(normalize_excel_tint(0.799_981_688_894_314_4), 0.8); + assert_eq!(normalize_excel_tint(0.7999), 0.7999); + + let threshold = 1.0 / EXCEL_TINT_SHORT_MAX; + assert_eq!(normalize_excel_tint(0.8 - threshold), 0.8); + assert_eq!( + normalize_excel_tint(0.8 - threshold - f64::EPSILON), + 0.8 - threshold - f64::EPSILON + ); + } + + #[test] + fn tint_adjusted_luminance_is_truncated_before_rgb_conversion() { + // Rounding the 229.5948 luminance would produce F8F1F2 instead. + assert_eq!(calc_tint("DAB6BA", 0.7999), "F8EFF0"); + } +} diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 913973aa..00f67897 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -1786,7 +1786,7 @@ fn issue_184() { .unwrap() .color() .argb_with_theme(theme); - assert_eq!(color, "A88570"); + assert_eq!(color, "A78570"); } #[test] @@ -1862,23 +1862,23 @@ fn issue_184_2() { ("A5", "A6A6A6"), ("A6", "808080"), ("B1", "000000"), - ("B2", "7F7F7F"), + ("B2", "808080"), ("B3", "595959"), ("B4", "404040"), ("B5", "262626"), ("B6", "0D0D0D"), ("C1", "E8E8E8"), - ("C2", "D1D1D1"), - ("C3", "AEAEAE"), + ("C2", "D0D0D0"), + ("C3", "ADADAD"), ("C4", "747474"), - ("C5", "3A3A3A"), - ("C6", "171717"), + ("C5", "393939"), + ("C6", "161616"), ("D1", "0E2841"), - ("D2", "DBEAF7"), - ("D3", "A6CAEC"), - ("D4", "4D95D9"), - ("D5", "21609B"), - ("D6", "163E64"), + ("D2", "DAE9F8"), + ("D3", "A6C9EC"), + ("D4", "4D93D9"), + ("D5", "215C98"), + ("D6", "153D64"), ("A8", "FFC00000"), ("B8", "FFFF0000"), ("C8", "FFFFC000"),