Skip to main content

rust_docx/parse/
colors.rs

1use super::context::ThemeColorMap;
2
3/// Mirrors TypeScript `DEFAULT_DRAWING_SCHEME_COLORS`.
4pub fn default_drawing_scheme_colors() -> ThemeColorMap {
5    ThemeColorMap::from([
6        ("bg1".to_string(), "#ffffff".to_string()),
7        ("bg2".to_string(), "#f3f4f6".to_string()),
8        ("tx1".to_string(), "#000000".to_string()),
9        ("tx2".to_string(), "#1f2937".to_string()),
10        ("dk1".to_string(), "#000000".to_string()),
11        ("dk2".to_string(), "#1f2937".to_string()),
12        ("lt1".to_string(), "#ffffff".to_string()),
13        ("lt2".to_string(), "#f3f4f6".to_string()),
14        ("accent1".to_string(), "#4472c4".to_string()),
15        ("accent2".to_string(), "#ed7d31".to_string()),
16        ("accent3".to_string(), "#70ad47".to_string()),
17        ("accent4".to_string(), "#5b9bd5".to_string()),
18        ("accent5".to_string(), "#7030a0".to_string()),
19        ("accent6".to_string(), "#ffc000".to_string()),
20        ("hlink".to_string(), "#0563c1".to_string()),
21        ("folhlink".to_string(), "#954f72".to_string()),
22        ("followedhyperlink".to_string(), "#954f72".to_string()),
23    ])
24}
25
26/// Mirrors TypeScript `normalizeHexColor`.
27pub fn normalize_hex_color(value: Option<&str>) -> Option<String> {
28    let normalized = value?.trim();
29    if normalized.is_empty() || normalized.eq_ignore_ascii_case("auto") {
30        return None;
31    }
32
33    if normalized.len() == 6 && normalized.chars().all(|ch| ch.is_ascii_hexdigit()) {
34        return Some(format!("#{normalized}"));
35    }
36
37    if normalized.len() == 3 && normalized.chars().all(|ch| ch.is_ascii_hexdigit()) {
38        let expanded: String = normalized.chars().flat_map(|ch| [ch, ch]).collect();
39        return Some(format!("#{expanded}"));
40    }
41
42    if normalized.starts_with('#')
43        && normalized.len() == 7
44        && normalized[1..].chars().all(|ch| ch.is_ascii_hexdigit())
45    {
46        return Some(normalized.to_string());
47    }
48
49    None
50}
51
52/// Mirrors TypeScript `resolveDrawingColorFromXml`.
53#[derive(Clone, Debug, PartialEq)]
54pub struct ResolvedDrawingColor {
55    pub color: String,
56    pub opacity: Option<f64>,
57}
58
59pub fn resolve_drawing_color_from_xml(
60    color_xml: Option<&str>,
61    theme_colors: &ThemeColorMap,
62) -> Option<ResolvedDrawingColor> {
63    let color_xml = color_xml?;
64    let defaults = default_drawing_scheme_colors();
65
66    let srgb = find_drawing_attribute_value(color_xml, "a:srgbClr", "val")
67        .as_deref()
68        .and_then(|value| normalize_hex_color(Some(value)));
69    let linear_rgb = ["r", "g", "b"]
70        .into_iter()
71        .map(|channel| {
72            let raw = find_drawing_attribute_value(color_xml, "a:scrgbClr", channel)?;
73            let value = raw.parse::<f64>().ok().filter(|value| value.is_finite())?;
74            Some((self::srgb((value / 100_000.0).clamp(0.0, 1.0)) * 255.0).round() as u8)
75        })
76        .collect::<Option<Vec<_>>>()
77        .map(|rgb| format!("#{:02X}{:02X}{:02X}", rgb[0], rgb[1], rgb[2]));
78    let sys = find_drawing_attribute_value(color_xml, "a:sysClr", "lastClr")
79        .as_deref()
80        .and_then(|value| normalize_hex_color(Some(value)));
81    let scheme_token = find_drawing_attribute_value(color_xml, "a:schemeClr", "val")
82        .map(|token| token.trim().to_ascii_lowercase());
83    let scheme = scheme_token.as_ref().and_then(|token| {
84        theme_colors
85            .get(token)
86            .cloned()
87            .or_else(|| defaults.get(token).cloned())
88    });
89    let preset = find_drawing_attribute_value(color_xml, "a:prstClr", "val").and_then(|value| {
90        match value.as_str() {
91            "black" => Some("#000000".to_string()),
92            "white" => Some("#ffffff".to_string()),
93            _ => None,
94        }
95    });
96    let color = apply_drawing_color_transforms(&srgb.or(linear_rgb).or(sys).or(scheme).or(preset)?, color_xml)?;
97
98    let alpha_raw = find_drawing_attribute_value(color_xml, "a:alpha", "val");
99    let alpha = alpha_raw.and_then(|raw| raw.parse::<f64>().ok());
100    let opacity = alpha
101        .filter(|value| value.is_finite() && *value >= 0.0)
102        .map(|value| (value / 100_000.0).clamp(0.0, 1.0));
103
104    Some(ResolvedDrawingColor { color, opacity })
105}
106
107fn apply_drawing_color_transforms(color: &str, xml: &str) -> Option<String> {
108    let tags = [
109        "a:tint", "a:shade", "a:sat", "a:satMod", "a:satOff", "a:lum", "a:lumMod", "a:lumOff",
110    ];
111    let transforms = crate::xml::extract_balanced_tag_blocks_in_order(xml, &tags);
112    if transforms.is_empty() {
113        return Some(color.to_string());
114    }
115    let values = transforms.into_iter().filter_map(|tag| {
116        let value = crate::xml::get_attribute(&xml[tag.start..tag.end], "val")?
117            .parse::<f64>()
118            .ok()?;
119        if !value.is_finite() {
120            return None;
121        }
122        Some((tag.tag_name, value / 100_000.0))
123    });
124    transform_rgb(color, values, true)
125}
126
127fn linear(c: f64) -> f64 {
128    if c <= 0.04045 {
129        c / 12.92
130    } else {
131        ((c + 0.055) / 1.055).powf(2.4)
132    }
133}
134
135pub(super) fn srgb(c: f64) -> f64 {
136    if c <= 0.0031308 {
137        c * 12.92
138    } else {
139        1.055 * c.powf(1. / 2.4) - 0.055
140    }
141}
142
143pub(super) fn hsl_to_rgb(hue: f64, saturation: f64, lightness: f64) -> [f64; 3] {
144    let chroma = (1.0 - (2.0 * lightness - 1.0).abs()) * saturation;
145    let x = chroma * (1.0 - (hue % 2.0 - 1.0).abs());
146    let offset = lightness - chroma / 2.0;
147    match hue as u32 {
148        0 => [chroma, x, 0.0],
149        1 => [x, chroma, 0.0],
150        2 => [0.0, chroma, x],
151        3 => [0.0, x, chroma],
152        4 => [x, 0.0, chroma],
153        _ => [chroma, 0.0, x],
154    }
155    .map(|channel| channel + offset)
156}
157
158pub(super) fn transform_rgb(
159    color: &str,
160    transforms: impl IntoIterator<Item = (impl AsRef<str>, f64)>,
161    linear_tints: bool,
162) -> Option<String> {
163    let mut rgb = [0.0; 3];
164    for (index, channel) in rgb.iter_mut().enumerate() {
165        *channel =
166            u8::from_str_radix(color.get(1 + index * 2..3 + index * 2)?, 16).ok()? as f64 / 255.0;
167    }
168    for (name, amount) in transforms {
169        let name = name.as_ref().trim_start_matches("a:");
170        match name {
171            "tint" | "shade" if linear_tints => {
172                rgb = rgb.map(|c| {
173                    srgb(linear(c) * amount + if name == "tint" { 1. - amount } else { 0. })
174                });
175            }
176            "gamma" => rgb = rgb.map(srgb),
177            "invGamma" => rgb = rgb.map(linear),
178            "inv" => rgb = rgb.map(|c| srgb(1. - linear(c))),
179            "gray" => rgb = [0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]; 3],
180            "red" | "green" | "blue" | "redMod" | "greenMod" | "blueMod" | "redOff"
181            | "greenOff" | "blueOff" => {
182                let i = if name.starts_with("red") {
183                    0
184                } else if name.starts_with("green") {
185                    1
186                } else {
187                    2
188                };
189                rgb[i] = srgb(if name.ends_with("Mod") {
190                    linear(rgb[i]) * amount
191                } else if name.ends_with("Off") {
192                    linear(rgb[i]) + amount
193                } else {
194                    amount
195                });
196            }
197            "tint" => rgb = rgb.map(|channel| channel * amount + 1.0 - amount),
198            "shade" => rgb = rgb.map(|channel| channel * amount),
199            "comp" | "hue" | "hueMod" | "hueOff" | "sat" | "satMod" | "satOff" | "lum"
200            | "lumMod" | "lumOff" => {
201                let max = rgb.into_iter().fold(0.0_f64, f64::max);
202                let min = rgb.into_iter().fold(1.0_f64, f64::min);
203                let delta = max - min;
204                let mut lightness = (max + min) / 2.0;
205                let mut saturation = if delta == 0.0 {
206                    0.0
207                } else {
208                    delta / (1.0 - (2.0 * lightness - 1.0).abs())
209                };
210                let hue = if delta == 0.0 {
211                    0.0
212                } else if max == rgb[0] {
213                    ((rgb[1] - rgb[2]) / delta).rem_euclid(6.0)
214                } else if max == rgb[1] {
215                    (rgb[2] - rgb[0]) / delta + 2.0
216                } else {
217                    (rgb[0] - rgb[1]) / delta + 4.0
218                };
219                let hue = if name == "comp" || name.starts_with("hue") {
220                    match name {
221                        "comp" => hue + 3.,
222                        "hue" => amount * 6.,
223                        "hueMod" => hue * amount,
224                        _ => hue + amount * 6.,
225                    }
226                    .rem_euclid(6.)
227                } else {
228                    let channel = if name.starts_with("sat") {
229                        &mut saturation
230                    } else {
231                        &mut lightness
232                    };
233                    *channel = if name.ends_with("Mod") {
234                        *channel * amount
235                    } else if name.ends_with("Off") {
236                        *channel + amount
237                    } else {
238                        amount
239                    }
240                    .clamp(0., 1.);
241                    hue
242                };
243                rgb = hsl_to_rgb(hue, saturation, lightness);
244            }
245            _ => continue,
246        }
247        rgb = rgb.map(|channel| channel.clamp(0.0, 1.0));
248    }
249    Some(format!(
250        "#{:02x}{:02x}{:02x}",
251        (rgb[0] * 255.0).round() as u8,
252        (rgb[1] * 255.0).round() as u8,
253        (rgb[2] * 255.0).round() as u8
254    ))
255}
256
257fn find_drawing_attribute_value(xml: &str, tag_name: &str, attribute: &str) -> Option<String> {
258    let tag_token = super::scan::find_tag_token(xml, tag_name)?;
259    crate::xml::get_attribute(&tag_token, attribute)
260}