Skip to main content

oxml_drawing/
color.rs

1use std::fmt;
2use std::io::Write;
3
4use oxml_core::OxmlError;
5use oxml_core::raw_xml::{capture_element, capture_empty_element};
6use oxml_core::units::{Angle, Percent1000};
7use oxml_core::xml::{get_attr, local_name, matches_local_name};
8use quick_xml::events::{BytesEnd, BytesStart, Event};
9use quick_xml::{Reader, Writer};
10
11use crate::namespace::reject_conflicting_a_prefix;
12use crate::order::OrderedRawChildren;
13
14/// Errors produced while parsing or writing DrawingML colours.
15#[derive(Debug)]
16pub enum ColorError {
17    Xml(OxmlError),
18    InvalidRgb(String),
19    UnresolvedColor(String),
20    MissingAttribute { element: String, attribute: String },
21    UnexpectedElement(String),
22    InvalidTransformValue { element: String, value: String },
23}
24
25impl fmt::Display for ColorError {
26    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
27        match self {
28            Self::Xml(error) => error.fmt(formatter),
29            Self::InvalidRgb(value) => write!(
30                formatter,
31                "DrawingML RGB colour must be exactly six hexadecimal digits: {value}"
32            ),
33            Self::UnresolvedColor(value) => {
34                write!(formatter, "no concrete colour is available for: {value}")
35            }
36            Self::MissingAttribute { element, attribute } => {
37                write!(formatter, "DrawingML {element} requires @{attribute}")
38            }
39            Self::UnexpectedElement(element) => {
40                write!(formatter, "unexpected DrawingML colour element: {element}")
41            }
42            Self::InvalidTransformValue { element, value } => {
43                write!(formatter, "DrawingML {element} has invalid @val: {value}")
44            }
45        }
46    }
47}
48
49impl std::error::Error for ColorError {
50    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
51        match self {
52            Self::Xml(error) => Some(error),
53            _ => None,
54        }
55    }
56}
57
58impl From<OxmlError> for ColorError {
59    fn from(error: OxmlError) -> Self {
60        Self::Xml(error)
61    }
62}
63
64pub type Result<T> = std::result::Result<T, ColorError>;
65
66/// A validated DrawingML sRGB colour.
67#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
68pub struct RgbColor([u8; 3]);
69
70impl RgbColor {
71    /// Creates a colour from its red, green, and blue components.
72    pub const fn new(red: u8, green: u8, blue: u8) -> Self {
73        Self([red, green, blue])
74    }
75
76    /// Parses an `RRGGBB` DrawingML colour value.
77    pub fn parse(value: &str) -> Result<Self> {
78        if value.len() != 6 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
79            return Err(ColorError::InvalidRgb(value.to_owned()));
80        }
81
82        Ok(Self([
83            parse_component(&value[0..2], value)?,
84            parse_component(&value[2..4], value)?,
85            parse_component(&value[4..6], value)?,
86        ]))
87    }
88
89    /// Returns the red, green, and blue components.
90    pub const fn components(self) -> [u8; 3] {
91        self.0
92    }
93}
94
95impl fmt::Display for RgbColor {
96    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
97        write!(
98            formatter,
99            "{:02X}{:02X}{:02X}",
100            self.0[0], self.0[1], self.0[2]
101        )
102    }
103}
104
105/// One DrawingML colour transform.
106#[derive(Clone, Copy, Debug, Eq, PartialEq)]
107pub enum ColorTransform {
108    Tint(Percent1000),
109    Shade(Percent1000),
110    Complement,
111    Inverse,
112    Gray,
113    Alpha(Percent1000),
114    AlphaOffset(Percent1000),
115    AlphaModulation(Percent1000),
116    Hue(Angle),
117    HueOffset(Angle),
118    HueModulation(Percent1000),
119    Saturation(Percent1000),
120    SaturationOffset(Percent1000),
121    SaturationModulation(Percent1000),
122    Luminance(Percent1000),
123    LuminanceOffset(Percent1000),
124    LuminanceModulation(Percent1000),
125    Red(Percent1000),
126    RedOffset(Percent1000),
127    RedModulation(Percent1000),
128    Green(Percent1000),
129    GreenOffset(Percent1000),
130    GreenModulation(Percent1000),
131    Blue(Percent1000),
132    BlueOffset(Percent1000),
133    BlueModulation(Percent1000),
134    Gamma,
135    InverseGamma,
136}
137
138impl ColorTransform {
139    fn from_xml(element: &BytesStart<'_>) -> Result<Option<Self>> {
140        let qualified_name = element.name();
141        let name = local_name(qualified_name.as_ref());
142        let transform = match name {
143            b"tint" => Self::Tint(parse_percent(element)?),
144            b"shade" => Self::Shade(parse_percent(element)?),
145            b"comp" => Self::Complement,
146            b"inv" => Self::Inverse,
147            b"gray" => Self::Gray,
148            b"alpha" => Self::Alpha(parse_percent(element)?),
149            b"alphaOff" => Self::AlphaOffset(parse_percent(element)?),
150            b"alphaMod" => Self::AlphaModulation(parse_percent(element)?),
151            b"hue" => Self::Hue(parse_angle(element)?),
152            b"hueOff" => Self::HueOffset(parse_angle(element)?),
153            b"hueMod" => Self::HueModulation(parse_percent(element)?),
154            b"sat" => Self::Saturation(parse_percent(element)?),
155            b"satOff" => Self::SaturationOffset(parse_percent(element)?),
156            b"satMod" => Self::SaturationModulation(parse_percent(element)?),
157            b"lum" => Self::Luminance(parse_percent(element)?),
158            b"lumOff" => Self::LuminanceOffset(parse_percent(element)?),
159            b"lumMod" => Self::LuminanceModulation(parse_percent(element)?),
160            b"red" => Self::Red(parse_percent(element)?),
161            b"redOff" => Self::RedOffset(parse_percent(element)?),
162            b"redMod" => Self::RedModulation(parse_percent(element)?),
163            b"green" => Self::Green(parse_percent(element)?),
164            b"greenOff" => Self::GreenOffset(parse_percent(element)?),
165            b"greenMod" => Self::GreenModulation(parse_percent(element)?),
166            b"blue" => Self::Blue(parse_percent(element)?),
167            b"blueOff" => Self::BlueOffset(parse_percent(element)?),
168            b"blueMod" => Self::BlueModulation(parse_percent(element)?),
169            b"gamma" => Self::Gamma,
170            b"invGamma" => Self::InverseGamma,
171            _ => return Ok(None),
172        };
173        Ok(Some(transform))
174    }
175
176    fn to_xml<W: Write>(self, writer: &mut Writer<W>) -> Result<()> {
177        let (name, value) = match self {
178            Self::Tint(value) => ("a:tint", Some(value.0)),
179            Self::Shade(value) => ("a:shade", Some(value.0)),
180            Self::Complement => ("a:comp", None),
181            Self::Inverse => ("a:inv", None),
182            Self::Gray => ("a:gray", None),
183            Self::Alpha(value) => ("a:alpha", Some(value.0)),
184            Self::AlphaOffset(value) => ("a:alphaOff", Some(value.0)),
185            Self::AlphaModulation(value) => ("a:alphaMod", Some(value.0)),
186            Self::Hue(value) => ("a:hue", Some(value.0)),
187            Self::HueOffset(value) => ("a:hueOff", Some(value.0)),
188            Self::HueModulation(value) => ("a:hueMod", Some(value.0)),
189            Self::Saturation(value) => ("a:sat", Some(value.0)),
190            Self::SaturationOffset(value) => ("a:satOff", Some(value.0)),
191            Self::SaturationModulation(value) => ("a:satMod", Some(value.0)),
192            Self::Luminance(value) => ("a:lum", Some(value.0)),
193            Self::LuminanceOffset(value) => ("a:lumOff", Some(value.0)),
194            Self::LuminanceModulation(value) => ("a:lumMod", Some(value.0)),
195            Self::Red(value) => ("a:red", Some(value.0)),
196            Self::RedOffset(value) => ("a:redOff", Some(value.0)),
197            Self::RedModulation(value) => ("a:redMod", Some(value.0)),
198            Self::Green(value) => ("a:green", Some(value.0)),
199            Self::GreenOffset(value) => ("a:greenOff", Some(value.0)),
200            Self::GreenModulation(value) => ("a:greenMod", Some(value.0)),
201            Self::Blue(value) => ("a:blue", Some(value.0)),
202            Self::BlueOffset(value) => ("a:blueOff", Some(value.0)),
203            Self::BlueModulation(value) => ("a:blueMod", Some(value.0)),
204            Self::Gamma => ("a:gamma", None),
205            Self::InverseGamma => ("a:invGamma", None),
206        };
207        let mut element = BytesStart::new(name);
208        let value_string = value.map(|raw| raw.to_string());
209        if let Some(value) = value_string.as_deref() {
210            element.push_attribute(("val", value));
211        }
212        writer
213            .write_event(Event::Empty(element))
214            .map_err(OxmlError::from)?;
215        Ok(())
216    }
217}
218
219/// A concrete colour after its DrawingML transform stack is applied.
220#[derive(Clone, Copy, Debug, Eq, PartialEq)]
221pub struct ResolvedColor {
222    pub red: u8,
223    pub green: u8,
224    pub blue: u8,
225    pub alpha: u8,
226}
227
228impl ResolvedColor {
229    pub const fn new(red: u8, green: u8, blue: u8, alpha: u8) -> Self {
230        Self {
231            red,
232            green,
233            blue,
234            alpha,
235        }
236    }
237
238    pub const fn rgba(self) -> [u8; 4] {
239        [self.red, self.green, self.blue, self.alpha]
240    }
241}
242
243/// One of the twelve semantic slots selected by a DrawingML colour map.
244#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
245pub enum ColorMapSlot {
246    Background1,
247    Text1,
248    Background2,
249    Text2,
250    Accent1,
251    Accent2,
252    Accent3,
253    Accent4,
254    Accent5,
255    Accent6,
256    Hyperlink,
257    FollowedHyperlink,
258}
259
260impl ColorMapSlot {
261    fn from_value(value: &str) -> Option<Self> {
262        match value {
263            "bg1" => Some(Self::Background1),
264            "tx1" => Some(Self::Text1),
265            "bg2" => Some(Self::Background2),
266            "tx2" => Some(Self::Text2),
267            "accent1" => Some(Self::Accent1),
268            "accent2" => Some(Self::Accent2),
269            "accent3" => Some(Self::Accent3),
270            "accent4" => Some(Self::Accent4),
271            "accent5" => Some(Self::Accent5),
272            "accent6" => Some(Self::Accent6),
273            "hlink" => Some(Self::Hyperlink),
274            "folHlink" => Some(Self::FollowedHyperlink),
275            _ => None,
276        }
277    }
278
279    const fn index(self) -> usize {
280        match self {
281            Self::Background1 => 0,
282            Self::Text1 => 1,
283            Self::Background2 => 2,
284            Self::Text2 => 3,
285            Self::Accent1 => 4,
286            Self::Accent2 => 5,
287            Self::Accent3 => 6,
288            Self::Accent4 => 7,
289            Self::Accent5 => 8,
290            Self::Accent6 => 9,
291            Self::Hyperlink => 10,
292            Self::FollowedHyperlink => 11,
293        }
294    }
295}
296
297/// One of the twelve concrete slots in a DrawingML theme colour scheme.
298#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
299pub enum ThemeColorSlot {
300    Dark1,
301    Light1,
302    Dark2,
303    Light2,
304    Accent1,
305    Accent2,
306    Accent3,
307    Accent4,
308    Accent5,
309    Accent6,
310    Hyperlink,
311    FollowedHyperlink,
312}
313
314impl ThemeColorSlot {
315    /// Returns the DrawingML theme slot name.
316    pub const fn as_str(self) -> &'static str {
317        match self {
318            Self::Dark1 => "dk1",
319            Self::Light1 => "lt1",
320            Self::Dark2 => "dk2",
321            Self::Light2 => "lt2",
322            Self::Accent1 => "accent1",
323            Self::Accent2 => "accent2",
324            Self::Accent3 => "accent3",
325            Self::Accent4 => "accent4",
326            Self::Accent5 => "accent5",
327            Self::Accent6 => "accent6",
328            Self::Hyperlink => "hlink",
329            Self::FollowedHyperlink => "folHlink",
330        }
331    }
332}
333
334/// The twelve master-controlled mappings applied before theme colour lookup.
335#[derive(Clone, Debug, Eq, PartialEq)]
336pub struct ColorMap {
337    slots: [ThemeColorSlot; 12],
338}
339
340impl ColorMap {
341    /// Creates a colour map from parsed master values in schema attribute order.
342    #[allow(clippy::too_many_arguments)]
343    pub const fn new(
344        background1: ThemeColorSlot,
345        text1: ThemeColorSlot,
346        background2: ThemeColorSlot,
347        text2: ThemeColorSlot,
348        accent1: ThemeColorSlot,
349        accent2: ThemeColorSlot,
350        accent3: ThemeColorSlot,
351        accent4: ThemeColorSlot,
352        accent5: ThemeColorSlot,
353        accent6: ThemeColorSlot,
354        hyperlink: ThemeColorSlot,
355        followed_hyperlink: ThemeColorSlot,
356    ) -> Self {
357        Self {
358            slots: [
359                background1,
360                text1,
361                background2,
362                text2,
363                accent1,
364                accent2,
365                accent3,
366                accent4,
367                accent5,
368                accent6,
369                hyperlink,
370                followed_hyperlink,
371            ],
372        }
373    }
374
375    /// Returns the concrete theme slot selected for one semantic map slot.
376    pub const fn theme_slot(&self, slot: ColorMapSlot) -> ThemeColorSlot {
377        self.slots[slot.index()]
378    }
379
380    /// Returns a copy with only the named layout or slide overrides replaced.
381    pub fn with_overrides(&self, overrides: &[(ColorMapSlot, ThemeColorSlot)]) -> Self {
382        let mut resolved = self.clone();
383        for (source, destination) in overrides {
384            resolved.slots[source.index()] = *destination;
385        }
386        resolved
387    }
388
389    fn mapped_name<'a>(&self, value: &'a str) -> &'a str {
390        ColorMapSlot::from_value(value)
391            .map(|slot| self.theme_slot(slot).as_str())
392            .unwrap_or(value)
393    }
394}
395
396impl Default for ColorMap {
397    fn default() -> Self {
398        Self::new(
399            ThemeColorSlot::Light1,
400            ThemeColorSlot::Dark1,
401            ThemeColorSlot::Light2,
402            ThemeColorSlot::Dark2,
403            ThemeColorSlot::Accent1,
404            ThemeColorSlot::Accent2,
405            ThemeColorSlot::Accent3,
406            ThemeColorSlot::Accent4,
407            ThemeColorSlot::Accent5,
408            ThemeColorSlot::Accent6,
409            ThemeColorSlot::Hyperlink,
410            ThemeColorSlot::FollowedHyperlink,
411        )
412    }
413}
414
415/// One of the four DrawingML colour choice elements.
416#[derive(Clone, Debug, Eq, PartialEq)]
417pub enum ColorChoice {
418    Srgb {
419        value: RgbColor,
420        transforms: Vec<ColorTransform>,
421        raw_children: OrderedRawChildren,
422    },
423    Scheme {
424        value: String,
425        transforms: Vec<ColorTransform>,
426        raw_children: OrderedRawChildren,
427    },
428    System {
429        value: String,
430        last_color: Option<RgbColor>,
431        transforms: Vec<ColorTransform>,
432        raw_children: OrderedRawChildren,
433    },
434    Preset {
435        value: String,
436        transforms: Vec<ColorTransform>,
437        raw_children: OrderedRawChildren,
438    },
439}
440
441impl ColorChoice {
442    /// Creates an sRGB colour without transforms.
443    pub fn srgb(value: RgbColor) -> Self {
444        Self::Srgb {
445            value,
446            transforms: Vec::new(),
447            raw_children: OrderedRawChildren::default(),
448        }
449    }
450
451    /// Parses a colour after the caller has consumed its start event.
452    pub fn from_xml(reader: &mut Reader<&[u8]>, start: &BytesStart<'_>) -> Result<Self> {
453        reject_conflicting_a_prefix(start)?;
454        let qualified_name = start.name();
455        let element_name = local_name(qualified_name.as_ref());
456        let (transforms, raw_children) = capture_children(reader, element_name)?;
457        Self::from_parts(start, transforms, raw_children)
458    }
459
460    /// Parses a colour from a self-closing element.
461    pub fn from_empty_xml(start: &BytesStart<'_>) -> Result<Self> {
462        reject_conflicting_a_prefix(start)?;
463        Self::from_parts(start, Vec::new(), OrderedRawChildren::default())
464    }
465
466    fn from_parts(
467        start: &BytesStart<'_>,
468        transforms: Vec<ColorTransform>,
469        raw_children: OrderedRawChildren,
470    ) -> Result<Self> {
471        let qualified_name = start.name();
472        let element_name = local_name(qualified_name.as_ref());
473        let value = required_attr(start, b"val")?;
474        match element_name {
475            b"srgbClr" => Ok(Self::Srgb {
476                value: RgbColor::parse(&value)?,
477                transforms,
478                raw_children,
479            }),
480            b"schemeClr" => Ok(Self::Scheme {
481                value,
482                transforms,
483                raw_children,
484            }),
485            b"sysClr" => Ok(Self::System {
486                value,
487                last_color: get_attr(start, b"lastClr")
488                    .map(|last| RgbColor::parse(&last))
489                    .transpose()?,
490                transforms,
491                raw_children,
492            }),
493            b"prstClr" => Ok(Self::Preset {
494                value,
495                transforms,
496                raw_children,
497            }),
498            _ => Err(ColorError::UnexpectedElement(
499                String::from_utf8_lossy(start.name().as_ref()).into_owned(),
500            )),
501        }
502    }
503
504    /// Writes this nested colour element with the canonical `a:` prefix.
505    pub fn to_xml<W: Write>(&self, writer: &mut Writer<W>) -> Result<()> {
506        let (tag, value, last_color, transforms, raw_children) = match self {
507            Self::Srgb {
508                value,
509                transforms,
510                raw_children,
511            } => (
512                "a:srgbClr",
513                value.to_string(),
514                None,
515                transforms,
516                raw_children,
517            ),
518            Self::Scheme {
519                value,
520                transforms,
521                raw_children,
522            } => ("a:schemeClr", value.clone(), None, transforms, raw_children),
523            Self::System {
524                value,
525                last_color,
526                transforms,
527                raw_children,
528            } => (
529                "a:sysClr",
530                value.clone(),
531                last_color.map(|colour| colour.to_string()),
532                transforms,
533                raw_children,
534            ),
535            Self::Preset {
536                value,
537                transforms,
538                raw_children,
539            } => ("a:prstClr", value.clone(), None, transforms, raw_children),
540        };
541
542        let mut start = BytesStart::new(tag);
543        start.push_attribute(("val", value.as_str()));
544        if let Some(last_color) = last_color.as_deref() {
545            start.push_attribute(("lastClr", last_color));
546        }
547
548        if raw_children.is_empty() && transforms.is_empty() {
549            writer
550                .write_event(Event::Empty(start))
551                .map_err(OxmlError::from)?;
552            return Ok(());
553        }
554
555        writer
556            .write_event(Event::Start(start))
557            .map_err(OxmlError::from)?;
558        for boundary in 0..=transforms.len() {
559            for raw in raw_children.at(boundary) {
560                writer.get_mut().write_all(raw).map_err(OxmlError::from)?;
561            }
562            if let Some(transform) = transforms.get(boundary) {
563                transform.to_xml(writer)?;
564            }
565        }
566        writer
567            .write_event(Event::End(BytesEnd::new(tag)))
568            .map_err(OxmlError::from)?;
569        Ok(())
570    }
571
572    /// Returns raw, not-yet-modelled children in document order.
573    pub fn raw_children(&self) -> &OrderedRawChildren {
574        match self {
575            Self::Srgb { raw_children, .. }
576            | Self::Scheme { raw_children, .. }
577            | Self::System { raw_children, .. }
578            | Self::Preset { raw_children, .. } => raw_children,
579        }
580    }
581
582    /// Returns modelled transforms in document order.
583    pub fn transforms(&self) -> &[ColorTransform] {
584        match self {
585            Self::Srgb { transforms, .. }
586            | Self::Scheme { transforms, .. }
587            | Self::System { transforms, .. }
588            | Self::Preset { transforms, .. } => transforms,
589        }
590    }
591}
592
593/// Resolves a colour through the master map, lookup table, and transform stack.
594///
595/// Theme slots, system colour names, and preset colour names share the concrete
596/// lookup table. Only scheme colours pass through `color_map`. A system colour
597/// falls back to its `lastClr` value when its name is absent from the lookup.
598pub fn resolve_color(
599    choice: &ColorChoice,
600    color_map: &ColorMap,
601    lookup: &[(&str, RgbColor)],
602) -> Result<ResolvedColor> {
603    let find = |name: &str| {
604        lookup
605            .iter()
606            .find_map(|(candidate, colour)| (*candidate == name).then_some(*colour))
607    };
608    let (base, transforms) = match choice {
609        ColorChoice::Srgb {
610            value, transforms, ..
611        } => (*value, transforms.as_slice()),
612        ColorChoice::Scheme {
613            value, transforms, ..
614        } => {
615            let mapped = color_map.mapped_name(value);
616            (
617                find(mapped).ok_or_else(|| ColorError::UnresolvedColor(mapped.to_owned()))?,
618                transforms.as_slice(),
619            )
620        }
621        ColorChoice::System {
622            value,
623            last_color,
624            transforms,
625            ..
626        } => (
627            find(value)
628                .or(*last_color)
629                .ok_or_else(|| ColorError::UnresolvedColor(value.clone()))?,
630            transforms.as_slice(),
631        ),
632        ColorChoice::Preset {
633            value, transforms, ..
634        } => (
635            find(value).ok_or_else(|| ColorError::UnresolvedColor(value.clone()))?,
636            transforms.as_slice(),
637        ),
638    };
639    Ok(apply_color_transforms(base, transforms))
640}
641
642fn required_attr(element: &BytesStart<'_>, name: &[u8]) -> Result<String> {
643    get_attr(element, name).ok_or_else(|| ColorError::MissingAttribute {
644        element: String::from_utf8_lossy(local_name(element.name().as_ref())).into_owned(),
645        attribute: String::from_utf8_lossy(name).into_owned(),
646    })
647}
648
649fn parse_component(component: &str, full_value: &str) -> Result<u8> {
650    u8::from_str_radix(component, 16).map_err(|_| ColorError::InvalidRgb(full_value.to_owned()))
651}
652
653fn parse_percent(element: &BytesStart<'_>) -> Result<Percent1000> {
654    parse_transform_i32(element).map(Percent1000)
655}
656
657fn parse_angle(element: &BytesStart<'_>) -> Result<Angle> {
658    parse_transform_i32(element).map(Angle)
659}
660
661fn parse_transform_i32(element: &BytesStart<'_>) -> Result<i32> {
662    let value = required_attr(element, b"val")?;
663    value
664        .parse()
665        .map_err(|_| ColorError::InvalidTransformValue {
666            element: String::from_utf8_lossy(local_name(element.name().as_ref())).into_owned(),
667            value,
668        })
669}
670
671fn capture_children(
672    reader: &mut Reader<&[u8]>,
673    end_name: &[u8],
674) -> Result<(Vec<ColorTransform>, OrderedRawChildren)> {
675    let mut transforms = Vec::new();
676    let mut raw_children = OrderedRawChildren::default();
677    let mut buffer = Vec::new();
678
679    loop {
680        match reader
681            .read_event_into(&mut buffer)
682            .map_err(OxmlError::from)?
683        {
684            Event::Start(element) => {
685                let transform = ColorTransform::from_xml(&element)?;
686                let raw = capture_element(reader, &element)?;
687                if let Some(transform) = transform.filter(|_| is_explicit_empty_element(&raw)) {
688                    reject_conflicting_a_prefix(&element)?;
689                    transforms.push(transform);
690                } else {
691                    raw_children.push(transforms.len(), raw);
692                }
693            }
694            Event::Empty(element) => {
695                if let Some(transform) = ColorTransform::from_xml(&element)? {
696                    reject_conflicting_a_prefix(&element)?;
697                    transforms.push(transform);
698                } else {
699                    raw_children.push(transforms.len(), capture_empty_element(&element)?);
700                }
701            }
702            Event::End(element) if matches_local_name(element.name().as_ref(), end_name) => break,
703            Event::Eof => {
704                return Err(ColorError::Xml(OxmlError::MissingElement(format!(
705                    "closing {} colour element",
706                    String::from_utf8_lossy(end_name)
707                ))));
708            }
709            _ => {}
710        }
711        buffer.clear();
712    }
713
714    Ok((transforms, raw_children))
715}
716
717fn is_explicit_empty_element(xml: &[u8]) -> bool {
718    let mut reader = Reader::from_reader(xml);
719    let mut buffer = Vec::new();
720    if !matches!(reader.read_event_into(&mut buffer), Ok(Event::Start(_))) {
721        return false;
722    }
723    loop {
724        buffer.clear();
725        match reader.read_event_into(&mut buffer) {
726            Ok(Event::Text(text)) if is_xml_whitespace(text.as_ref()) => {}
727            Ok(Event::CData(text)) if is_xml_whitespace(text.as_ref()) => {}
728            Ok(Event::Comment(_) | Event::PI(_)) => {}
729            Ok(Event::End(_)) => {
730                buffer.clear();
731                return matches!(reader.read_event_into(&mut buffer), Ok(Event::Eof));
732            }
733            _ => return false,
734        }
735    }
736}
737
738fn is_xml_whitespace(bytes: &[u8]) -> bool {
739    bytes
740        .iter()
741        .all(|byte| matches!(byte, b' ' | b'\t' | b'\n' | b'\r'))
742}
743
744/// Applies a DrawingML transform stack from left to right.
745pub fn apply_color_transforms(colour: RgbColor, transforms: &[ColorTransform]) -> ResolvedColor {
746    let [red, green, blue] = colour.components();
747    let mut current = WorkingColor {
748        red: red as f64 / 255.0,
749        green: green as f64 / 255.0,
750        blue: blue as f64 / 255.0,
751        alpha: 1.0,
752    };
753    for transform in transforms {
754        current.apply(*transform);
755    }
756    current.resolved()
757}
758
759/// Applies spec-correct linear-gamma tint and shade percentages.
760pub fn apply_tint_shade_pct(
761    hex: &str,
762    tint: Option<Percent1000>,
763    shade: Option<Percent1000>,
764) -> String {
765    let Ok(colour) = RgbColor::parse(hex) else {
766        return hex.to_owned();
767    };
768    let mut transforms = Vec::with_capacity(2);
769    if let Some(value) = tint {
770        transforms.push(ColorTransform::Tint(value));
771    }
772    if let Some(value) = shade {
773        transforms.push(ColorTransform::Shade(value));
774    }
775    let resolved = apply_color_transforms(colour, &transforms);
776    RgbColor::new(resolved.red, resolved.green, resolved.blue).to_string()
777}
778
779/// Applies DrawingML HSL luminance modulation followed by offset.
780pub fn apply_lum_mod_off(
781    hex: &str,
782    lum_mod: Option<Percent1000>,
783    lum_off: Option<Percent1000>,
784) -> String {
785    let Ok(colour) = RgbColor::parse(hex) else {
786        return hex.to_owned();
787    };
788    let mut transforms = Vec::with_capacity(2);
789    if let Some(value) = lum_mod {
790        transforms.push(ColorTransform::LuminanceModulation(value));
791    }
792    if let Some(value) = lum_off {
793        transforms.push(ColorTransform::LuminanceOffset(value));
794    }
795    let resolved = apply_color_transforms(colour, &transforms);
796    RgbColor::new(resolved.red, resolved.green, resolved.blue).to_string()
797}
798
799/// Converts one sRGB channel to linear light.
800pub fn srgb_to_linear(channel: f64) -> f64 {
801    if channel <= 0.04045 {
802        channel / 12.92
803    } else {
804        ((channel + 0.055) / 1.055).powf(2.4)
805    }
806}
807
808/// Converts one linear-light channel to sRGB.
809pub fn linear_to_srgb(channel: f64) -> f64 {
810    if channel <= 0.003_130_8 {
811        channel * 12.92
812    } else {
813        1.055 * channel.powf(1.0 / 2.4) - 0.055
814    }
815}
816
817#[derive(Clone, Copy)]
818struct WorkingColor {
819    red: f64,
820    green: f64,
821    blue: f64,
822    alpha: f64,
823}
824
825impl WorkingColor {
826    fn apply(&mut self, transform: ColorTransform) {
827        match transform {
828            ColorTransform::Tint(value) => {
829                let amount = percent(value);
830                self.map_linear(|channel| channel * amount + 1.0 - amount);
831            }
832            ColorTransform::Shade(value) => {
833                let amount = percent(value);
834                self.map_linear(|channel| channel * amount);
835            }
836            ColorTransform::Complement => {
837                self.map_hsl(|hue, sat, lum| ((hue + 0.5).rem_euclid(1.0), sat, lum))
838            }
839            ColorTransform::Inverse => {
840                self.map_linear(|channel| 1.0 - channel);
841            }
842            ColorTransform::Gray => {
843                let luminance = 0.2126 * self.red + 0.7152 * self.green + 0.0722 * self.blue;
844                self.red = luminance;
845                self.green = luminance;
846                self.blue = luminance;
847            }
848            ColorTransform::Alpha(value) => self.alpha = percent(value),
849            ColorTransform::AlphaOffset(value) => self.alpha += percent(value),
850            ColorTransform::AlphaModulation(value) => self.alpha *= percent(value),
851            ColorTransform::Hue(value) => {
852                let hue = angle_turns(value);
853                self.map_hsl(|_, sat, lum| (hue, sat, lum));
854            }
855            ColorTransform::HueOffset(value) => {
856                let offset = angle_turns(value);
857                self.map_hsl(|hue, sat, lum| (hue + offset, sat, lum));
858            }
859            ColorTransform::HueModulation(value) => {
860                let amount = percent(value);
861                self.map_hsl(|hue, sat, lum| (hue * amount, sat, lum));
862            }
863            ColorTransform::Saturation(value) => {
864                let value = percent(value);
865                self.map_hsl(|hue, _, lum| (hue, value, lum));
866            }
867            ColorTransform::SaturationOffset(value) => {
868                let offset = percent(value);
869                self.map_hsl(|hue, sat, lum| (hue, sat + offset, lum));
870            }
871            ColorTransform::SaturationModulation(value) => {
872                let amount = percent(value);
873                self.map_hsl(|hue, sat, lum| (hue, sat * amount, lum));
874            }
875            ColorTransform::Luminance(value) => {
876                let value = percent(value);
877                self.map_hsl(|hue, sat, _| (hue, sat, value));
878            }
879            ColorTransform::LuminanceOffset(value) => {
880                let offset = percent(value);
881                self.map_hsl(|hue, sat, lum| (hue, sat, lum + offset));
882            }
883            ColorTransform::LuminanceModulation(value) => {
884                let amount = percent(value);
885                self.map_hsl(|hue, sat, lum| (hue, sat, lum * amount));
886            }
887            ColorTransform::Red(value) => self.red = linear_to_srgb(percent(value)),
888            ColorTransform::RedOffset(value) => {
889                self.red = linear_to_srgb(srgb_to_linear(self.red) + percent(value));
890            }
891            ColorTransform::RedModulation(value) => {
892                self.red = linear_to_srgb(srgb_to_linear(self.red) * percent(value));
893            }
894            ColorTransform::Green(value) => self.green = linear_to_srgb(percent(value)),
895            ColorTransform::GreenOffset(value) => {
896                self.green = linear_to_srgb(srgb_to_linear(self.green) + percent(value));
897            }
898            ColorTransform::GreenModulation(value) => {
899                self.green = linear_to_srgb(srgb_to_linear(self.green) * percent(value));
900            }
901            ColorTransform::Blue(value) => self.blue = linear_to_srgb(percent(value)),
902            ColorTransform::BlueOffset(value) => {
903                self.blue = linear_to_srgb(srgb_to_linear(self.blue) + percent(value));
904            }
905            ColorTransform::BlueModulation(value) => {
906                self.blue = linear_to_srgb(srgb_to_linear(self.blue) * percent(value));
907            }
908            ColorTransform::Gamma => {
909                self.red = linear_to_srgb(self.red);
910                self.green = linear_to_srgb(self.green);
911                self.blue = linear_to_srgb(self.blue);
912            }
913            ColorTransform::InverseGamma => {
914                self.red = srgb_to_linear(self.red);
915                self.green = srgb_to_linear(self.green);
916                self.blue = srgb_to_linear(self.blue);
917            }
918        }
919        self.clamp();
920    }
921
922    fn map_linear(&mut self, transform: impl Fn(f64) -> f64) {
923        self.red = linear_to_srgb(transform(srgb_to_linear(self.red)));
924        self.green = linear_to_srgb(transform(srgb_to_linear(self.green)));
925        self.blue = linear_to_srgb(transform(srgb_to_linear(self.blue)));
926    }
927
928    fn map_hsl(&mut self, transform: impl Fn(f64, f64, f64) -> (f64, f64, f64)) {
929        let (hue, sat, lum) = rgb_to_hsl(self.red, self.green, self.blue);
930        let (hue, sat, lum) = transform(hue, sat, lum);
931        (self.red, self.green, self.blue) = hsl_to_rgb(
932            hue.rem_euclid(1.0),
933            sat.clamp(0.0, 1.0),
934            lum.clamp(0.0, 1.0),
935        );
936    }
937
938    fn clamp(&mut self) {
939        self.red = self.red.clamp(0.0, 1.0);
940        self.green = self.green.clamp(0.0, 1.0);
941        self.blue = self.blue.clamp(0.0, 1.0);
942        self.alpha = self.alpha.clamp(0.0, 1.0);
943    }
944
945    fn resolved(self) -> ResolvedColor {
946        let alpha = channel_byte(self.alpha);
947        if alpha == 0 {
948            return ResolvedColor::new(0, 0, 0, 0);
949        }
950        let red = alpha_quantized_channel(channel_byte(self.red), alpha);
951        let green = alpha_quantized_channel(channel_byte(self.green), alpha);
952        let blue = alpha_quantized_channel(channel_byte(self.blue), alpha);
953        ResolvedColor::new(red, green, blue, alpha)
954    }
955}
956
957fn percent(value: Percent1000) -> f64 {
958    value.to_fraction()
959}
960
961fn angle_turns(value: Angle) -> f64 {
962    value.to_degrees() / 360.0
963}
964
965fn channel_byte(channel: f64) -> u8 {
966    (channel.clamp(0.0, 1.0) * 255.0 + 1e-9).round() as u8
967}
968
969fn alpha_quantized_channel(channel: u8, alpha: u8) -> u8 {
970    if alpha == u8::MAX {
971        return channel;
972    }
973    let channel = u16::from(channel);
974    let alpha = u16::from(alpha);
975    let premultiplied = (channel * alpha + 127) / 255;
976    ((premultiplied * 255 + alpha / 2) / alpha) as u8
977}
978
979fn rgb_to_hsl(red: f64, green: f64, blue: f64) -> (f64, f64, f64) {
980    let max = red.max(green).max(blue);
981    let min = red.min(green).min(blue);
982    let lum = (max + min) / 2.0;
983    if (max - min).abs() <= f64::EPSILON {
984        return (0.0, 0.0, lum);
985    }
986
987    let delta = max - min;
988    let sat = if lum > 0.5 {
989        delta / (2.0 - max - min)
990    } else {
991        delta / (max + min)
992    };
993    let hue = if (max - red).abs() <= f64::EPSILON {
994        (green - blue) / delta + if green < blue { 6.0 } else { 0.0 }
995    } else if (max - green).abs() <= f64::EPSILON {
996        (blue - red) / delta + 2.0
997    } else {
998        (red - green) / delta + 4.0
999    } / 6.0;
1000    (hue, sat, lum)
1001}
1002
1003fn hsl_to_rgb(hue: f64, sat: f64, lum: f64) -> (f64, f64, f64) {
1004    if sat <= f64::EPSILON {
1005        return (lum, lum, lum);
1006    }
1007    let q = if lum < 0.5 {
1008        lum * (1.0 + sat)
1009    } else {
1010        lum + sat - lum * sat
1011    };
1012    let p = 2.0 * lum - q;
1013    (
1014        hue_to_rgb(p, q, hue + 1.0 / 3.0),
1015        hue_to_rgb(p, q, hue),
1016        hue_to_rgb(p, q, hue - 1.0 / 3.0),
1017    )
1018}
1019
1020fn hue_to_rgb(p: f64, q: f64, hue: f64) -> f64 {
1021    let hue = hue.rem_euclid(1.0);
1022    if hue < 1.0 / 6.0 {
1023        p + (q - p) * 6.0 * hue
1024    } else if hue < 0.5 {
1025        q
1026    } else if hue < 2.0 / 3.0 {
1027        p + (q - p) * (2.0 / 3.0 - hue) * 6.0
1028    } else {
1029        p
1030    }
1031}
1032
1033#[cfg(test)]
1034mod tests {
1035    use std::fs;
1036    use std::path::{Path, PathBuf};
1037    use std::process::Command;
1038
1039    use oxml_core::units::{Angle, Percent1000};
1040    use oxml_opc::OpcPackage;
1041    use oxml_opc::relationship::rel_types;
1042    use quick_xml::events::Event;
1043
1044    use super::{
1045        ColorChoice, ColorError, ColorMap, ColorMapSlot, ColorTransform, ResolvedColor, RgbColor,
1046        ThemeColorSlot, apply_color_transforms, linear_to_srgb, resolve_color, srgb_to_linear,
1047    };
1048    use crate::order::OrderedRawChildren;
1049    use quick_xml::{Reader, Writer};
1050
1051    const POWERPOINT_ORACLE_VERSION: &str = "16.104";
1052    const POWERPOINT_ORACLE_BUILD: &str = "16.104.25121423";
1053    const POWERPOINT_ORACLE_APP_BUILD: &str = "1214";
1054
1055    struct OracleCase {
1056        name: &'static str,
1057        input: RgbColor,
1058        transforms: &'static [ColorTransform],
1059        expected: [u8; 4],
1060    }
1061
1062    const ORACLE_CASES: &[OracleCase] = &[
1063        oracle_case(
1064            "single_tint",
1065            0x1F497D,
1066            &[ColorTransform::Tint(Percent1000(62_000))],
1067            [167, 174, 189, 255],
1068        ),
1069        oracle_case(
1070            "single_shade",
1071            0xEEECE1,
1072            &[ColorTransform::Shade(Percent1000(58_000))],
1073            [187, 185, 176, 255],
1074        ),
1075        oracle_case(
1076            "single_comp",
1077            0x4F81BD,
1078            &[ColorTransform::Complement],
1079            [189, 139, 79, 255],
1080        ),
1081        oracle_case(
1082            "single_inv",
1083            0xC0504D,
1084            &[ColorTransform::Inverse],
1085            [183, 246, 246, 255],
1086        ),
1087        oracle_case(
1088            "single_gray",
1089            0x9BBB59,
1090            &[ColorTransform::Gray],
1091            [173, 173, 173, 255],
1092        ),
1093        oracle_case(
1094            "single_alpha",
1095            0x8064A2,
1096            &[ColorTransform::Alpha(Percent1000(47_000))],
1097            [128, 100, 162, 120],
1098        ),
1099        oracle_case(
1100            "single_alpha_off",
1101            0x4BACC6,
1102            &[ColorTransform::AlphaOffset(Percent1000(-30_000))],
1103            [76, 172, 198, 179],
1104        ),
1105        oracle_case(
1106            "single_alpha_mod",
1107            0xF79646,
1108            &[ColorTransform::AlphaModulation(Percent1000(43_000))],
1109            [248, 151, 70, 110],
1110        ),
1111        oracle_case(
1112            "single_hue",
1113            0x1F497D,
1114            &[ColorTransform::Hue(Angle(9_000_000))],
1115            [31, 125, 78, 255],
1116        ),
1117        oracle_case(
1118            "single_hue_off",
1119            0xEEECE1,
1120            &[ColorTransform::HueOffset(Angle(-3_000_000))],
1121            [238, 225, 225, 255],
1122        ),
1123        oracle_case(
1124            "single_hue_mod",
1125            0x4F81BD,
1126            &[ColorTransform::HueModulation(Percent1000(55_000))],
1127            [85, 189, 79, 255],
1128        ),
1129        oracle_case(
1130            "single_sat",
1131            0xC0504D,
1132            &[ColorTransform::Saturation(Percent1000(72_000))],
1133            [221, 52, 48, 255],
1134        ),
1135        oracle_case(
1136            "single_sat_off",
1137            0x9BBB59,
1138            &[ColorTransform::SaturationOffset(Percent1000(-25_000))],
1139            [145, 158, 118, 255],
1140        ),
1141        oracle_case(
1142            "single_sat_mod",
1143            0x8064A2,
1144            &[ColorTransform::SaturationModulation(Percent1000(45_000))],
1145            [130, 117, 145, 255],
1146        ),
1147        oracle_case(
1148            "single_lum",
1149            0x4BACC6,
1150            &[ColorTransform::Luminance(Percent1000(65_000))],
1151            [119, 192, 212, 255],
1152        ),
1153        oracle_case(
1154            "single_lum_off",
1155            0xF79646,
1156            &[ColorTransform::LuminanceOffset(Percent1000(20_000))],
1157            [251, 205, 168, 255],
1158        ),
1159        oracle_case(
1160            "single_lum_mod",
1161            0x1F497D,
1162            &[ColorTransform::LuminanceModulation(Percent1000(55_000))],
1163            [17, 40, 69, 255],
1164        ),
1165        oracle_case(
1166            "single_red",
1167            0xEEECE1,
1168            &[ColorTransform::Red(Percent1000(20_000))],
1169            [124, 236, 225, 255],
1170        ),
1171        oracle_case(
1172            "single_red_off",
1173            0x4F81BD,
1174            &[ColorTransform::RedOffset(Percent1000(35_000))],
1175            [175, 129, 189, 255],
1176        ),
1177        oracle_case(
1178            "single_red_mod",
1179            0xC0504D,
1180            &[ColorTransform::RedModulation(Percent1000(40_000))],
1181            [127, 80, 77, 255],
1182        ),
1183        oracle_case(
1184            "single_green",
1185            0x9BBB59,
1186            &[ColorTransform::Green(Percent1000(70_000))],
1187            [155, 218, 89, 255],
1188        ),
1189        oracle_case(
1190            "single_green_off",
1191            0x8064A2,
1192            &[ColorTransform::GreenOffset(Percent1000(-20_000))],
1193            [128, 0, 162, 255],
1194        ),
1195        oracle_case(
1196            "single_green_mod",
1197            0x4BACC6,
1198            &[ColorTransform::GreenModulation(Percent1000(140_000))],
1199            [75, 200, 198, 255],
1200        ),
1201        oracle_case(
1202            "single_blue",
1203            0xF79646,
1204            &[ColorTransform::Blue(Percent1000(85_000))],
1205            [247, 150, 237, 255],
1206        ),
1207        oracle_case(
1208            "single_blue_off",
1209            0x1F497D,
1210            &[ColorTransform::BlueOffset(Percent1000(25_000))],
1211            [31, 73, 180, 255],
1212        ),
1213        oracle_case(
1214            "single_blue_mod",
1215            0xEEECE1,
1216            &[ColorTransform::BlueModulation(Percent1000(35_000))],
1217            [238, 236, 140, 255],
1218        ),
1219        oracle_case(
1220            "single_gamma",
1221            0x4F81BD,
1222            &[ColorTransform::Gamma],
1223            [151, 189, 223, 255],
1224        ),
1225        oracle_case(
1226            "single_inv_gamma",
1227            0xC0504D,
1228            &[ColorTransform::InverseGamma],
1229            [134, 20, 19, 255],
1230        ),
1231        oracle_case(
1232            "stack_red_off_then_mod",
1233            0x9BBB59,
1234            &[
1235                ColorTransform::RedOffset(Percent1000(40_000)),
1236                ColorTransform::RedModulation(Percent1000(50_000)),
1237            ],
1238            [163, 187, 89, 255],
1239        ),
1240        oracle_case(
1241            "stack_red_mod_then_off",
1242            0x8064A2,
1243            &[
1244                ColorTransform::RedModulation(Percent1000(50_000)),
1245                ColorTransform::RedOffset(Percent1000(40_000)),
1246            ],
1247            [189, 100, 162, 255],
1248        ),
1249        oracle_case(
1250            "stack_alpha_clamp_high",
1251            0x4BACC6,
1252            &[
1253                ColorTransform::Alpha(Percent1000(75_000)),
1254                ColorTransform::AlphaOffset(Percent1000(80_000)),
1255            ],
1256            [75, 172, 198, 255],
1257        ),
1258        oracle_case(
1259            "stack_alpha_clamp_low",
1260            0xF79646,
1261            &[
1262                ColorTransform::Alpha(Percent1000(25_000)),
1263                ColorTransform::AlphaOffset(Percent1000(-80_000)),
1264            ],
1265            [0, 0, 0, 0],
1266        ),
1267        oracle_case(
1268            "stack_lum_mod_then_off",
1269            0x1F497D,
1270            &[
1271                ColorTransform::LuminanceModulation(Percent1000(60_000)),
1272                ColorTransform::LuminanceOffset(Percent1000(25_000)),
1273            ],
1274            [44, 103, 177, 255],
1275        ),
1276        oracle_case(
1277            "stack_lum_off_then_mod",
1278            0xEEECE1,
1279            &[
1280                ColorTransform::LuminanceOffset(Percent1000(25_000)),
1281                ColorTransform::LuminanceModulation(Percent1000(60_000)),
1282            ],
1283            [153, 153, 153, 255],
1284        ),
1285        oracle_case(
1286            "stack_hsl",
1287            0x4F81BD,
1288            &[
1289                ColorTransform::HueOffset(Angle(4_200_000)),
1290                ColorTransform::SaturationModulation(Percent1000(65_000)),
1291                ColorTransform::LuminanceModulation(Percent1000(80_000)),
1292            ],
1293            [121, 76, 139, 255],
1294        ),
1295        oracle_case(
1296            "stack_tint_then_shade",
1297            0xC0504D,
1298            &[
1299                ColorTransform::Tint(Percent1000(60_000)),
1300                ColorTransform::Shade(Percent1000(70_000)),
1301            ],
1302            [188, 152, 151, 255],
1303        ),
1304        oracle_case(
1305            "stack_shade_then_tint",
1306            0x9BBB59,
1307            &[
1308                ColorTransform::Shade(Percent1000(70_000)),
1309                ColorTransform::Tint(Percent1000(60_000)),
1310            ],
1311            [194, 205, 177, 255],
1312        ),
1313        oracle_case(
1314            "stack_linear_tint",
1315            0x8064A2,
1316            &[
1317                ColorTransform::InverseGamma,
1318                ColorTransform::Tint(Percent1000(55_000)),
1319                ColorTransform::Gamma,
1320            ],
1321            [220, 219, 223, 255],
1322        ),
1323        oracle_case(
1324            "stack_gamma_shade",
1325            0x4BACC6,
1326            &[
1327                ColorTransform::Gamma,
1328                ColorTransform::Shade(Percent1000(55_000)),
1329                ColorTransform::InverseGamma,
1330            ],
1331            [41, 95, 109, 255],
1332        ),
1333        oracle_case(
1334            "stack_comp_hue_gray",
1335            0xF79646,
1336            &[
1337                ColorTransform::Complement,
1338                ColorTransform::HueOffset(Angle(-2_400_000)),
1339                ColorTransform::Gray,
1340            ],
1341            [207, 207, 207, 255],
1342        ),
1343    ];
1344
1345    const fn oracle_case(
1346        name: &'static str,
1347        input: u32,
1348        transforms: &'static [ColorTransform],
1349        expected: [u8; 4],
1350    ) -> OracleCase {
1351        OracleCase {
1352            name,
1353            input: RgbColor::new(
1354                ((input >> 16) & 0xff) as u8,
1355                ((input >> 8) & 0xff) as u8,
1356                (input & 0xff) as u8,
1357            ),
1358            transforms,
1359            expected,
1360        }
1361    }
1362
1363    fn parse(xml: &[u8]) -> ColorChoice {
1364        let mut reader = Reader::from_reader(xml);
1365        let mut buffer = Vec::new();
1366        match reader.read_event_into(&mut buffer).unwrap() {
1367            Event::Start(element) => ColorChoice::from_xml(&mut reader, &element).unwrap(),
1368            Event::Empty(element) => ColorChoice::from_empty_xml(&element).unwrap(),
1369            event => panic!("expected colour element, got {event:?}"),
1370        }
1371    }
1372
1373    fn write(colour: &ColorChoice) -> Vec<u8> {
1374        let mut writer = Writer::new(Vec::new());
1375        colour.to_xml(&mut writer).unwrap();
1376        writer.into_inner()
1377    }
1378
1379    #[test]
1380    fn srgb_colour_parses_and_round_trips() {
1381        let colour = parse(br#"<x:srgbClr val="12ABef"/>"#);
1382        assert_eq!(
1383            colour,
1384            ColorChoice::Srgb {
1385                value: RgbColor::new(0x12, 0xAB, 0xEF),
1386                transforms: Vec::new(),
1387                raw_children: OrderedRawChildren::default(),
1388            }
1389        );
1390        assert_eq!(write(&colour), br#"<a:srgbClr val="12ABEF"/>"#);
1391        assert_eq!(ColorChoice::srgb(RgbColor::new(0x12, 0xAB, 0xEF)), colour);
1392    }
1393
1394    #[test]
1395    fn scheme_colour_parses_and_round_trips() {
1396        let colour = parse(br#"<x:schemeClr val="accent2"/>"#);
1397        assert_eq!(write(&colour), br#"<a:schemeClr val="accent2"/>"#);
1398    }
1399
1400    #[test]
1401    fn system_colour_uses_and_preserves_last_colour() {
1402        let colour = parse(br#"<x:sysClr val="windowText" lastClr="102030"/>"#);
1403        assert_eq!(
1404            write(&colour),
1405            br#"<a:sysClr val="windowText" lastClr="102030"/>"#
1406        );
1407    }
1408
1409    #[test]
1410    fn system_colour_without_last_colour_round_trips() {
1411        let colour = parse(br#"<x:sysClr val="windowText"/>"#);
1412        assert_eq!(write(&colour), br#"<a:sysClr val="windowText"/>"#);
1413    }
1414
1415    #[test]
1416    fn preset_colour_parses_and_round_trips() {
1417        let colour = parse(br#"<x:prstClr val="aliceBlue"/>"#);
1418        assert_eq!(write(&colour), br#"<a:prstClr val="aliceBlue"/>"#);
1419    }
1420
1421    #[test]
1422    fn unknown_colour_children_are_preserved_in_place() {
1423        let input = br#"<x:schemeClr val="accent2"><z:first z:id="1"/><z:second><z:leaf>one &amp; two</z:leaf></z:second></x:schemeClr>"#;
1424        let colour = parse(input);
1425
1426        assert_eq!(
1427            colour.raw_children().at(0).collect::<Vec<_>>(),
1428            vec![
1429                br#"<z:first z:id="1"/>"#.as_slice(),
1430                br#"<z:second><z:leaf>one &amp; two</z:leaf></z:second>"#.as_slice(),
1431            ]
1432        );
1433        assert_eq!(
1434            write(&colour),
1435            br#"<a:schemeClr val="accent2"><z:first z:id="1"/><z:second><z:leaf>one &amp; two</z:leaf></z:second></a:schemeClr>"#
1436        );
1437    }
1438
1439    #[test]
1440    fn malformed_srgb_values_are_rejected() {
1441        assert!(matches!(
1442            RgbColor::parse("12345"),
1443            Err(ColorError::InvalidRgb(value)) if value == "12345"
1444        ));
1445        assert!(matches!(
1446            RgbColor::parse("GG0000"),
1447            Err(ColorError::InvalidRgb(value)) if value == "GG0000"
1448        ));
1449    }
1450
1451    #[test]
1452    fn malformed_system_fallback_is_rejected() {
1453        let xml = br#"<a:sysClr val="window" lastClr="12345"/>"#;
1454        let mut reader = Reader::from_reader(xml.as_slice());
1455        let mut buffer = Vec::new();
1456        let Event::Empty(element) = reader.read_event_into(&mut buffer).unwrap() else {
1457            panic!("expected empty system colour");
1458        };
1459        assert!(ColorChoice::from_empty_xml(&element).is_err());
1460    }
1461
1462    #[test]
1463    fn standard_colour_map_uses_office_theme_slots() {
1464        let map = ColorMap::default();
1465        let expected = [
1466            (ColorMapSlot::Background1, ThemeColorSlot::Light1),
1467            (ColorMapSlot::Text1, ThemeColorSlot::Dark1),
1468            (ColorMapSlot::Background2, ThemeColorSlot::Light2),
1469            (ColorMapSlot::Text2, ThemeColorSlot::Dark2),
1470            (ColorMapSlot::Accent1, ThemeColorSlot::Accent1),
1471            (ColorMapSlot::Accent2, ThemeColorSlot::Accent2),
1472            (ColorMapSlot::Accent3, ThemeColorSlot::Accent3),
1473            (ColorMapSlot::Accent4, ThemeColorSlot::Accent4),
1474            (ColorMapSlot::Accent5, ThemeColorSlot::Accent5),
1475            (ColorMapSlot::Accent6, ThemeColorSlot::Accent6),
1476            (ColorMapSlot::Hyperlink, ThemeColorSlot::Hyperlink),
1477            (
1478                ColorMapSlot::FollowedHyperlink,
1479                ThemeColorSlot::FollowedHyperlink,
1480            ),
1481        ];
1482
1483        for (source, destination) in expected {
1484            assert_eq!(map.theme_slot(source), destination);
1485        }
1486    }
1487
1488    #[test]
1489    fn dark_master_colour_map_inverts_background_and_text() {
1490        let map = ColorMap::default().with_overrides(&[
1491            (ColorMapSlot::Background1, ThemeColorSlot::Dark1),
1492            (ColorMapSlot::Text1, ThemeColorSlot::Light1),
1493        ]);
1494        let theme = [
1495            ("dk1", RgbColor::new(0x1F, 0x49, 0x7D)),
1496            ("lt1", RgbColor::new(0xEE, 0xDD, 0xCC)),
1497        ];
1498
1499        assert_eq!(
1500            resolve_color(
1501                &parse(br#"<a:schemeClr val="bg1"><a:tint val="62000"/></a:schemeClr>"#),
1502                &map,
1503                &theme,
1504            )
1505            .unwrap(),
1506            ResolvedColor::new(167, 174, 189, 255)
1507        );
1508        assert_eq!(
1509            resolve_color(&parse(br#"<a:schemeClr val="tx1"/>"#), &map, &theme).unwrap(),
1510            ResolvedColor::new(0xEE, 0xDD, 0xCC, 255)
1511        );
1512    }
1513
1514    #[test]
1515    fn colour_map_override_wins_before_theme_lookup() {
1516        let master = ColorMap::new(
1517            ThemeColorSlot::Dark2,
1518            ThemeColorSlot::Light2,
1519            ThemeColorSlot::Accent3,
1520            ThemeColorSlot::Accent4,
1521            ThemeColorSlot::Accent5,
1522            ThemeColorSlot::Accent6,
1523            ThemeColorSlot::Accent1,
1524            ThemeColorSlot::Accent2,
1525            ThemeColorSlot::Dark1,
1526            ThemeColorSlot::Light1,
1527            ThemeColorSlot::FollowedHyperlink,
1528            ThemeColorSlot::Hyperlink,
1529        );
1530        let map = master.with_overrides(&[(ColorMapSlot::Background1, ThemeColorSlot::Accent6)]);
1531        let expected = ColorMap::new(
1532            ThemeColorSlot::Accent6,
1533            ThemeColorSlot::Light2,
1534            ThemeColorSlot::Accent3,
1535            ThemeColorSlot::Accent4,
1536            ThemeColorSlot::Accent5,
1537            ThemeColorSlot::Accent6,
1538            ThemeColorSlot::Accent1,
1539            ThemeColorSlot::Accent2,
1540            ThemeColorSlot::Dark1,
1541            ThemeColorSlot::Light1,
1542            ThemeColorSlot::FollowedHyperlink,
1543            ThemeColorSlot::Hyperlink,
1544        );
1545
1546        assert_eq!(map, expected);
1547        assert_eq!(
1548            master.theme_slot(ColorMapSlot::Background1),
1549            ThemeColorSlot::Dark2
1550        );
1551    }
1552
1553    #[test]
1554    fn direct_colours_bypass_the_master_colour_map() {
1555        let standard = ColorMap::default();
1556        let dark = standard.with_overrides(&[
1557            (ColorMapSlot::Background1, ThemeColorSlot::Dark1),
1558            (ColorMapSlot::Text1, ThemeColorSlot::Light1),
1559        ]);
1560        let lookup = [
1561            ("windowText", RgbColor::new(0x10, 0x20, 0x30)),
1562            ("aliceBlue", RgbColor::new(0xF0, 0xF8, 0xFF)),
1563        ];
1564        let direct = [
1565            (
1566                parse(br#"<a:srgbClr val="EEECE1"><a:shade val="58000"/></a:srgbClr>"#),
1567                ResolvedColor::new(187, 185, 176, 255),
1568            ),
1569            (
1570                parse(br#"<a:sysClr val="windowText" lastClr="FFFFFF"/>"#),
1571                ResolvedColor::new(0x10, 0x20, 0x30, 255),
1572            ),
1573            (
1574                parse(br#"<a:sysClr val="missing" lastClr="AABBCC"/>"#),
1575                ResolvedColor::new(0xAA, 0xBB, 0xCC, 255),
1576            ),
1577            (
1578                parse(br#"<a:prstClr val="aliceBlue"/>"#),
1579                ResolvedColor::new(0xF0, 0xF8, 0xFF, 255),
1580            ),
1581        ];
1582
1583        for (colour, expected) in direct {
1584            assert_eq!(
1585                resolve_color(&colour, &standard, &lookup).unwrap(),
1586                expected
1587            );
1588            assert_eq!(resolve_color(&colour, &dark, &lookup).unwrap(), expected);
1589        }
1590    }
1591
1592    #[test]
1593    fn powerpoint_colour_transform_oracle_matches_all_forty_pairs() {
1594        assert_eq!(POWERPOINT_ORACLE_VERSION, "16.104");
1595        assert_eq!(POWERPOINT_ORACLE_BUILD, "16.104.25121423");
1596        assert_eq!(ORACLE_CASES.len(), 40);
1597
1598        for case in ORACLE_CASES {
1599            assert_eq!(
1600                apply_color_transforms(case.input, case.transforms).rgba(),
1601                case.expected,
1602                "PowerPoint colour oracle disagreement for {}",
1603                case.name
1604            );
1605        }
1606    }
1607
1608    #[test]
1609    fn colour_transforms_apply_in_document_order() {
1610        let base = RgbColor::new(0x33, 0x66, 0x99);
1611        let forward = apply_color_transforms(
1612            base,
1613            &[
1614                ColorTransform::RedOffset(Percent1000(40_000)),
1615                ColorTransform::RedModulation(Percent1000(50_000)),
1616            ],
1617        );
1618        let reversed = apply_color_transforms(
1619            base,
1620            &[
1621                ColorTransform::RedModulation(Percent1000(50_000)),
1622                ColorTransform::RedOffset(Percent1000(40_000)),
1623            ],
1624        );
1625
1626        assert_eq!(forward.red, 128);
1627        assert_eq!(reversed.red, 173);
1628        assert_ne!(forward, reversed);
1629    }
1630
1631    #[test]
1632    fn linear_gamma_round_trip_preserves_channel_endpoints() {
1633        assert_eq!(srgb_to_linear(0.0), 0.0);
1634        assert_eq!(srgb_to_linear(1.0), 1.0);
1635        assert_eq!(linear_to_srgb(0.0), 0.0);
1636        assert!((linear_to_srgb(1.0) - 1.0).abs() < f64::EPSILON);
1637        for channel in [0.01, 0.18, 0.5, 0.75] {
1638            assert!((linear_to_srgb(srgb_to_linear(channel)) - channel).abs() < 1e-12);
1639        }
1640    }
1641
1642    #[test]
1643    fn alpha_transforms_clamp_to_the_valid_range() {
1644        let colour = apply_color_transforms(
1645            RgbColor::new(0x12, 0x34, 0x56),
1646            &[
1647                ColorTransform::Alpha(Percent1000(75_000)),
1648                ColorTransform::AlphaModulation(Percent1000(50_000)),
1649                ColorTransform::AlphaOffset(Percent1000(80_000)),
1650            ],
1651        );
1652
1653        assert_eq!(colour, ResolvedColor::new(0x12, 0x34, 0x56, 255));
1654    }
1655
1656    #[test]
1657    fn known_and_unknown_transform_children_keep_document_order() {
1658        let input = br#"<x:srgbClr val="336699"><z:before z:id="1"/><x:tint val="65000"/><z:middle><z:leaf/></z:middle><x:hueOff val="5400000"/><z:after z:id="3"/></x:srgbClr>"#;
1659        let colour = parse(input);
1660
1661        assert_eq!(
1662            colour.transforms(),
1663            &[
1664                ColorTransform::Tint(Percent1000(65_000)),
1665                ColorTransform::HueOffset(Angle(5_400_000)),
1666            ]
1667        );
1668        assert_eq!(
1669            write(&colour),
1670            br#"<a:srgbClr val="336699"><z:before z:id="1"/><a:tint val="65000"/><z:middle><z:leaf/></z:middle><a:hueOff val="5400000"/><z:after z:id="3"/></a:srgbClr>"#
1671        );
1672    }
1673
1674    #[test]
1675    fn nonempty_known_transform_preserves_its_nested_xml_verbatim() {
1676        let input = br#"<x:srgbClr val="336699"><x:tint val="65000"><z:extension z:id="1"/></x:tint></x:srgbClr>"#;
1677        let colour = parse(input);
1678
1679        assert!(colour.transforms().is_empty());
1680        assert_eq!(
1681            write(&colour),
1682            br#"<a:srgbClr val="336699"><x:tint val="65000"><z:extension z:id="1"/></x:tint></a:srgbClr>"#
1683        );
1684    }
1685
1686    #[test]
1687    fn explicit_empty_transform_pair_is_modelled_and_canonicalised() {
1688        let input = br#"<x:srgbClr val="336699"><x:tint val="65000"></x:tint></x:srgbClr>"#;
1689        let colour = parse(input);
1690
1691        assert_eq!(
1692            colour.transforms(),
1693            &[ColorTransform::Tint(Percent1000(65_000))]
1694        );
1695        assert_eq!(
1696            write(&colour),
1697            br#"<a:srgbClr val="336699"><a:tint val="65000"/></a:srgbClr>"#
1698        );
1699    }
1700
1701    #[test]
1702    fn partially_transparent_rgba_matches_powerpoint_png_quantization() {
1703        let offset = apply_color_transforms(
1704            RgbColor::new(0x4B, 0xAC, 0xC6),
1705            &[ColorTransform::AlphaOffset(Percent1000(-30_000))],
1706        );
1707        let modulation = apply_color_transforms(
1708            RgbColor::new(0xF7, 0x96, 0x46),
1709            &[ColorTransform::AlphaModulation(Percent1000(43_000))],
1710        );
1711
1712        assert_eq!(offset.rgba(), [76, 172, 198, 179]);
1713        assert_eq!(modulation.rgba(), [248, 151, 70, 110]);
1714    }
1715
1716    #[test]
1717    #[ignore = "requires RDOCX_POWERPOINT_ORACLE_SHELL, pinned Microsoft PowerPoint, and native shape clipboard PNGs"]
1718    fn generate_powerpoint_colour_transform_oracle() {
1719        assert_powerpoint_build();
1720        let supplied_shell = PathBuf::from(
1721            std::env::var_os("RDOCX_POWERPOINT_ORACLE_SHELL").expect(
1722                "RDOCX_POWERPOINT_ORACLE_SHELL must name a PowerPoint-authored PPTX with one blank slide and one shape named probe_shape",
1723            ),
1724        );
1725        let output_dir = std::env::temp_dir().join(format!(
1726            "rdocx-f055-powerpoint-oracle-{}",
1727            std::process::id()
1728        ));
1729        fs::create_dir_all(&output_dir).unwrap();
1730        let shell_path = output_dir.join("powerpoint-native-shell.pptx");
1731        let deck_path = output_dir.join("colour-transform-oracle.pptx");
1732        fs::write(&shell_path, fs::read(&supplied_shell).unwrap()).unwrap();
1733        validate_powerpoint_shell(&shell_path);
1734        inject_oracle_transforms(&shell_path, &deck_path);
1735        validate_powerpoint_deck(&deck_path);
1736        export_oracle_shapes(&deck_path, &output_dir);
1737
1738        for case in ORACLE_CASES {
1739            let rgba = sample_uniform_centre(&output_dir.join(format!("{}.png", case.name)));
1740            let implementation = apply_color_transforms(case.input, case.transforms).rgba();
1741            println!(
1742                "{}: PowerPoint {rgba:?}, implementation {implementation:?}",
1743                case.name
1744            );
1745        }
1746        println!("oracle artefacts: {}", output_dir.display());
1747    }
1748
1749    fn assert_powerpoint_build() {
1750        let app = "/Applications/Microsoft PowerPoint.app/Contents/Info.plist";
1751        let version = Command::new("/usr/libexec/PlistBuddy")
1752            .args(["-c", "Print :CFBundleShortVersionString", app])
1753            .output()
1754            .unwrap();
1755        let build = Command::new("/usr/libexec/PlistBuddy")
1756            .args(["-c", "Print :CFBundleVersion", app])
1757            .output()
1758            .unwrap();
1759        assert!(version.status.success());
1760        assert!(build.status.success());
1761        assert_eq!(
1762            String::from_utf8(version.stdout).unwrap().trim(),
1763            POWERPOINT_ORACLE_VERSION
1764        );
1765        assert_eq!(
1766            String::from_utf8(build.stdout).unwrap().trim(),
1767            POWERPOINT_ORACLE_BUILD
1768        );
1769    }
1770
1771    fn validate_powerpoint_shell(path: &Path) {
1772        let path = path.to_string_lossy();
1773        let name = Path::new(path.as_ref())
1774            .file_name()
1775            .unwrap()
1776            .to_string_lossy();
1777        let mut script = powerpoint_script_start(120);
1778        script.push_str(&format!(
1779            "set deckPath to \"{path}\"\nopen my POSIX file deckPath\nset shellDeck to presentation \"{name}\"\nif (full name of shellDeck) is not deckPath then error \"oracle shell exact path mismatch\"\nif (count of slides of shellDeck) is not 1 then error \"oracle shell slide count mismatch\"\nif (count of shapes of slide 1 of shellDeck) is not 1 then error \"oracle shell shape count mismatch\"\nif (name of shape 1 of slide 1 of shellDeck) is not \"probe_shape\" then error \"oracle shell lacks probe_shape\"\nclose shellDeck saving no\n"
1780        ));
1781        script.push_str(&powerpoint_script_finish("shellDeck"));
1782        run_powerpoint_script(&script, "PowerPoint shell validation");
1783    }
1784
1785    fn validate_powerpoint_deck(path: &Path) {
1786        let path = path.to_string_lossy();
1787        let name = Path::new(path.as_ref())
1788            .file_name()
1789            .unwrap()
1790            .to_string_lossy();
1791        let mut script = powerpoint_script_start(120);
1792        script.push_str(&format!(
1793            "set deckPath to \"{path}\"\nopen my POSIX file deckPath\nset checkedDeck to presentation \"{name}\"\nif (full name of checkedDeck) is not deckPath then error \"oracle deck exact path mismatch\"\nif (count of slides of checkedDeck) is not 1 then error \"oracle deck slide count mismatch\"\nif (count of shapes of slide 1 of checkedDeck) is not 40 then error \"oracle deck shape count mismatch\"\nset oracleShapeNames to name of every shape of slide 1 of checkedDeck\n"
1794        ));
1795        for case in ORACLE_CASES {
1796            script.push_str(&format!(
1797                "if oracleShapeNames does not contain \"{}\" then error \"missing oracle shape {}\"\n",
1798                case.name, case.name
1799            ));
1800        }
1801        script.push_str("close checkedDeck saving no\n");
1802        script.push_str(&powerpoint_script_finish("checkedDeck"));
1803        run_powerpoint_script(&script, "PowerPoint deck validation");
1804    }
1805
1806    fn inject_oracle_transforms(shell_path: &Path, deck_path: &Path) {
1807        let mut package = OpcPackage::open(shell_path).unwrap();
1808        let presentation_part = package.main_document_part().unwrap();
1809        let slide_target = package
1810            .get_part_rels(&presentation_part)
1811            .unwrap()
1812            .get_by_type(rel_types::SLIDE)
1813            .unwrap()
1814            .target
1815            .clone();
1816        let slide_part = OpcPackage::resolve_rel_target(&presentation_part, &slide_target);
1817        let mut slide_xml =
1818            String::from_utf8(package.get_part(&slide_part).unwrap().to_vec()).unwrap();
1819
1820        let name_marker = "name=\"probe_shape\"";
1821        let name_index = slide_xml.find(name_marker).unwrap();
1822        let shape_start = slide_xml[..name_index].rfind("<p:sp>").unwrap();
1823        let shape_end =
1824            name_index + slide_xml[name_index..].find("</p:sp>").unwrap() + "</p:sp>".len();
1825        let shape_template = &slide_xml[shape_start..shape_end];
1826        let mut generated_shapes = String::new();
1827
1828        for (index, case) in ORACLE_CASES.iter().enumerate() {
1829            let mut shape = shape_template.replacen(
1830                "id=\"2\" name=\"probe_shape\"",
1831                &format!("id=\"{}\" name=\"{}\"", index + 2, case.name),
1832                1,
1833            );
1834            let mut transform_writer = Writer::new(Vec::new());
1835            for transform in case.transforms {
1836                transform.to_xml(&mut transform_writer).unwrap();
1837            }
1838            let transform_xml = String::from_utf8(transform_writer.into_inner()).unwrap();
1839            let replacement = format!(
1840                "<a:srgbClr val=\"{}\">{transform_xml}</a:srgbClr>",
1841                case.input
1842            );
1843            shape = shape.replacen("<a:srgbClr val=\"1F497D\"/>", &replacement, 1);
1844            assert!(shape.contains(&format!("name=\"{}\"", case.name)));
1845            assert!(shape.contains(&replacement));
1846            generated_shapes.push_str(&shape);
1847        }
1848        slide_xml.replace_range(shape_start..shape_end, &generated_shapes);
1849
1850        package.set_part(&slide_part, slide_xml.into_bytes());
1851        package.save(deck_path).unwrap();
1852    }
1853
1854    fn powerpoint_script_start(timeout_seconds: u32) -> String {
1855        format!(
1856            "with timeout of {timeout_seconds} seconds\ntell application \"Microsoft PowerPoint\"\nset previousStartUpDialog to start up dialog\ntry\nif (Version as text) is not \"{POWERPOINT_ORACLE_VERSION}\" then error \"PowerPoint version mismatch: \" & (Version as text)\nif (build as text) is not \"{POWERPOINT_ORACLE_APP_BUILD}\" then error \"PowerPoint application build mismatch: \" & (build as text)\nset start up dialog to false\n"
1857        )
1858    }
1859
1860    fn powerpoint_script_finish(deck_variable: &str) -> String {
1861        format!(
1862            "set start up dialog to previousStartUpDialog\non error errorMessage number errorNumber\ntry\nclose {deck_variable} saving no\nend try\nset start up dialog to previousStartUpDialog\nerror errorMessage number errorNumber\nend try\nend tell\nend timeout\n"
1863        )
1864    }
1865
1866    fn run_powerpoint_script(script: &str, action: &str) {
1867        let result = Command::new("osascript")
1868            .args(["-e", script])
1869            .output()
1870            .unwrap();
1871        assert!(
1872            result.status.success(),
1873            "{action} failed: {}",
1874            String::from_utf8_lossy(&result.stderr)
1875        );
1876    }
1877
1878    fn export_oracle_shapes(deck_path: &Path, output_dir: &Path) {
1879        let deck = deck_path.to_string_lossy();
1880        let name = Path::new(deck.as_ref())
1881            .file_name()
1882            .unwrap()
1883            .to_string_lossy();
1884        let mut script = powerpoint_script_start(600);
1885        script.push_str(&format!(
1886            "set deckPath to \"{deck}\"\nopen my POSIX file deckPath\nset oracleDeck to presentation \"{name}\"\nif (full name of oracleDeck) is not deckPath then error \"oracle export exact path mismatch\"\n"
1887        ));
1888        for case in ORACLE_CASES {
1889            let output = output_dir.join(format!("{}.png", case.name));
1890            script.push_str(&format!(
1891                "set oracleShape to shape \"{}\" of slide 1 of oracleDeck\ncopy shape oracleShape\ndelay 0.5\ntell me\nset pngData to the clipboard as «class PNGf»\nset outputFile to open for access (POSIX file \"{}\") with write permission\nset eof outputFile to 0\nwrite pngData to outputFile\nclose access outputFile\nend tell\n",
1892                case.name,
1893                output.to_string_lossy()
1894            ));
1895        }
1896        script.push_str("close oracleDeck saving no\n");
1897        script.push_str(&powerpoint_script_finish("oracleDeck"));
1898        run_powerpoint_script(&script, "PowerPoint direct shape clipboard render");
1899    }
1900
1901    fn sample_uniform_centre(path: &Path) -> [u8; 4] {
1902        let rgb = run_pngtopnm(path, false);
1903        let alpha = run_pngtopnm(path, true);
1904        assert_eq!((rgb.width, rgb.height), (alpha.width, alpha.height));
1905        let centre_x = rgb.width / 2;
1906        let centre_y = rgb.height / 2;
1907        let mut sample = None;
1908        for y in centre_y - 2..=centre_y + 2 {
1909            for x in centre_x - 2..=centre_x + 2 {
1910                let rgb_offset = (y * rgb.width + x) * 3;
1911                let alpha_offset = y * alpha.width + x;
1912                let pixel = [
1913                    rgb.data[rgb_offset],
1914                    rgb.data[rgb_offset + 1],
1915                    rgb.data[rgb_offset + 2],
1916                    alpha.data[alpha_offset],
1917                ];
1918                assert_eq!(
1919                    *sample.get_or_insert(pixel),
1920                    pixel,
1921                    "non-uniform 5 by 5 centre block in {}",
1922                    path.display()
1923                );
1924            }
1925        }
1926        sample.unwrap()
1927    }
1928
1929    struct NetpbmImage {
1930        width: usize,
1931        height: usize,
1932        data: Vec<u8>,
1933    }
1934
1935    fn run_pngtopnm(path: &Path, alpha: bool) -> NetpbmImage {
1936        let mut command = Command::new("pngtopnm");
1937        if alpha {
1938            command.arg("-alpha");
1939        }
1940        let output = command.arg(path).output().unwrap();
1941        assert!(
1942            output.status.success(),
1943            "pngtopnm failed for {}: {}",
1944            path.display(),
1945            String::from_utf8_lossy(&output.stderr)
1946        );
1947        parse_netpbm(output.stdout, if alpha { b'5' } else { b'6' })
1948    }
1949
1950    fn parse_netpbm(bytes: Vec<u8>, expected_kind: u8) -> NetpbmImage {
1951        let mut cursor = 0;
1952        let magic = netpbm_token(&bytes, &mut cursor);
1953        assert_eq!(magic, [b'P', expected_kind]);
1954        let width = parse_netpbm_usize(netpbm_token(&bytes, &mut cursor));
1955        let height = parse_netpbm_usize(netpbm_token(&bytes, &mut cursor));
1956        assert_eq!(parse_netpbm_usize(netpbm_token(&bytes, &mut cursor)), 255);
1957        assert!(bytes[cursor].is_ascii_whitespace());
1958        cursor += 1;
1959        let channels = if expected_kind == b'6' { 3 } else { 1 };
1960        assert_eq!(bytes.len() - cursor, width * height * channels);
1961        NetpbmImage {
1962            width,
1963            height,
1964            data: bytes[cursor..].to_vec(),
1965        }
1966    }
1967
1968    fn netpbm_token<'a>(bytes: &'a [u8], cursor: &mut usize) -> &'a [u8] {
1969        loop {
1970            while bytes[*cursor].is_ascii_whitespace() {
1971                *cursor += 1;
1972            }
1973            if bytes[*cursor] != b'#' {
1974                break;
1975            }
1976            while bytes[*cursor] != b'\n' {
1977                *cursor += 1;
1978            }
1979        }
1980        let start = *cursor;
1981        while *cursor < bytes.len() && !bytes[*cursor].is_ascii_whitespace() {
1982            *cursor += 1;
1983        }
1984        &bytes[start..*cursor]
1985    }
1986
1987    fn parse_netpbm_usize(token: &[u8]) -> usize {
1988        std::str::from_utf8(token).unwrap().parse().unwrap()
1989    }
1990
1991    #[test]
1992    fn all_twenty_eight_transform_elements_parse_and_write_with_fixed_prefixes() {
1993        let input = br#"<x:srgbClr val="123456"><x:tint val="10000"/><x:shade val="20000"/><x:comp/><x:inv/><x:gray/><x:alpha val="30000"/><x:alphaOff val="-40000"/><x:alphaMod val="50000"/><x:hue val="60000"/><x:hueOff val="-120000"/><x:hueMod val="60000"/><x:sat val="70000"/><x:satOff val="-80000"/><x:satMod val="90000"/><x:lum val="100000"/><x:lumOff val="-10000"/><x:lumMod val="100000"/><x:red val="12000"/><x:redOff val="-13000"/><x:redMod val="40000"/><x:green val="15000"/><x:greenOff val="-16000"/><x:greenMod val="70000"/><x:blue val="18000"/><x:blueOff val="-19000"/><x:blueMod val="100000"/><x:gamma/><x:invGamma/></x:srgbClr>"#;
1994        let colour = parse(input);
1995
1996        assert_eq!(colour.transforms().len(), 28);
1997        let output = String::from_utf8(write(&colour)).unwrap();
1998        assert!(!output.contains("<x:"));
1999        assert!(output.contains("<a:hue val=\"60000\"/>"));
2000        assert!(output.contains("<a:hueOff val=\"-120000\"/>"));
2001        assert!(output.contains("<a:invGamma/>"));
2002    }
2003
2004    #[test]
2005    fn whitespace_and_comments_in_empty_transform_pairs_are_modelled() {
2006        let input = br#"<x:srgbClr val="336699"><x:tint val="65000">
2007            <!-- formatting only -->
2008        </x:tint></x:srgbClr>"#;
2009        let colour = parse(input);
2010
2011        assert_eq!(
2012            colour.transforms(),
2013            &[ColorTransform::Tint(Percent1000(65_000))]
2014        );
2015        assert_eq!(
2016            write(&colour),
2017            br#"<a:srgbClr val="336699"><a:tint val="65000"/></a:srgbClr>"#
2018        );
2019    }
2020}