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