Skip to main content

oxml_drawing/
effect.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::name::{Namespace, ResolveResult};
10use quick_xml::reader::NsReader;
11use quick_xml::{Reader, Writer, XmlVersion};
12
13use crate::color::{ColorChoice, ColorError};
14use crate::namespace::A_NS;
15use crate::order::OrderedRawChildren;
16
17const MAX_POSITIVE_COORDINATE: i64 = 27_273_042_316_900;
18const FULL_CIRCLE_ANGLE: i32 = 21_600_000;
19const QUARTER_CIRCLE_ANGLE: i32 = 5_400_000;
20
21/// Errors produced while parsing or writing DrawingML effects.
22#[derive(Debug)]
23pub enum EffectError {
24    Xml(OxmlError),
25    Color(ColorError),
26    UnexpectedElement(String),
27    MissingColor,
28    InvalidAttribute {
29        element: String,
30        attribute: String,
31        value: String,
32    },
33}
34
35impl fmt::Display for EffectError {
36    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
37        match self {
38            Self::Xml(error) => error.fmt(formatter),
39            Self::Color(error) => error.fmt(formatter),
40            Self::UnexpectedElement(element) => {
41                write!(formatter, "unexpected DrawingML effect element: {element}")
42            }
43            Self::MissingColor => write!(formatter, "DrawingML outerShdw requires a colour child"),
44            Self::InvalidAttribute {
45                element,
46                attribute,
47                value,
48            } => write!(
49                formatter,
50                "DrawingML {element} has invalid @{attribute}: {value}"
51            ),
52        }
53    }
54}
55
56impl std::error::Error for EffectError {
57    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
58        match self {
59            Self::Xml(error) => Some(error),
60            Self::Color(error) => Some(error),
61            _ => None,
62        }
63    }
64}
65
66impl From<OxmlError> for EffectError {
67    fn from(error: OxmlError) -> Self {
68        Self::Xml(error)
69    }
70}
71
72impl From<ColorError> for EffectError {
73    fn from(error: ColorError) -> Self {
74        Self::Color(error)
75    }
76}
77
78pub type Result<T> = std::result::Result<T, EffectError>;
79
80/// DrawingML rectangle alignment used as the origin of a shadow transform.
81#[derive(Clone, Copy, Debug, Eq, PartialEq)]
82pub enum RectAlignment {
83    TopLeft,
84    Top,
85    TopRight,
86    Left,
87    Center,
88    Right,
89    BottomLeft,
90    Bottom,
91    BottomRight,
92}
93
94impl RectAlignment {
95    /// Parses an `ST_RectAlignment` token such as `tl` or `ctr`.
96    pub fn parse(value: &str) -> Option<Self> {
97        match value {
98            "tl" => Some(Self::TopLeft),
99            "t" => Some(Self::Top),
100            "tr" => Some(Self::TopRight),
101            "l" => Some(Self::Left),
102            "ctr" => Some(Self::Center),
103            "r" => Some(Self::Right),
104            "bl" => Some(Self::BottomLeft),
105            "b" => Some(Self::Bottom),
106            "br" => Some(Self::BottomRight),
107            _ => None,
108        }
109    }
110
111    /// Returns the `ST_RectAlignment` token written for this alignment.
112    pub const fn as_str(self) -> &'static str {
113        match self {
114            Self::TopLeft => "tl",
115            Self::Top => "t",
116            Self::TopRight => "tr",
117            Self::Left => "l",
118            Self::Center => "ctr",
119            Self::Right => "r",
120            Self::BottomLeft => "bl",
121            Self::Bottom => "b",
122            Self::BottomRight => "br",
123        }
124    }
125}
126
127/// Modelled properties of one DrawingML `a:outerShdw` effect.
128#[allow(non_camel_case_types)]
129#[derive(Clone, Debug, Default, Eq, PartialEq)]
130pub struct CT_OuterShadowEffect {
131    pub blur_radius: Option<i64>,
132    pub distance: Option<i64>,
133    pub direction: Option<Angle>,
134    pub scale_x: Option<Percent1000>,
135    pub scale_y: Option<Percent1000>,
136    pub skew_x: Option<Angle>,
137    pub skew_y: Option<Angle>,
138    pub alignment: Option<RectAlignment>,
139    pub rotate_with_shape: Option<bool>,
140    pub color: Option<ColorChoice>,
141    raw_children: OrderedRawChildren,
142}
143
144impl CT_OuterShadowEffect {
145    fn from_start(start: &BytesStart<'_>) -> Result<Self> {
146        let shadow = Self {
147            blur_radius: optional_parse(start, b"blurRad")?,
148            distance: optional_parse(start, b"dist")?,
149            direction: optional_parse::<i32>(start, b"dir")?.map(Angle),
150            scale_x: optional_parse::<i32>(start, b"sx")?.map(Percent1000),
151            scale_y: optional_parse::<i32>(start, b"sy")?.map(Percent1000),
152            skew_x: optional_parse::<i32>(start, b"kx")?.map(Angle),
153            skew_y: optional_parse::<i32>(start, b"ky")?.map(Angle),
154            alignment: optional_enum(start, b"algn", RectAlignment::parse)?,
155            rotate_with_shape: optional_bool(start, b"rotWithShape")?,
156            color: None,
157            raw_children: OrderedRawChildren::default(),
158        };
159        shadow.validate_attributes()?;
160        Ok(shadow)
161    }
162
163    fn from_element(reader: &mut Reader<&[u8]>, start: &BytesStart<'_>) -> Result<Self> {
164        let mut shadow = Self::from_start(start)?;
165        let mut boundary = 0;
166        let mut buffer = Vec::new();
167        loop {
168            match reader
169                .read_event_into(&mut buffer)
170                .map_err(OxmlError::from)?
171            {
172                Event::Start(element)
173                    if is_color(element.name().as_ref()) && shadow.color.is_none() =>
174                {
175                    shadow.color = Some(ColorChoice::from_xml(reader, &element)?);
176                    boundary = 1;
177                }
178                Event::Empty(element)
179                    if is_color(element.name().as_ref()) && shadow.color.is_none() =>
180                {
181                    shadow.color = Some(ColorChoice::from_empty_xml(&element)?);
182                    boundary = 1;
183                }
184                Event::Start(element) => shadow
185                    .raw_children
186                    .push(boundary, capture_element(reader, &element)?),
187                Event::Empty(element) => shadow
188                    .raw_children
189                    .push(boundary, capture_empty_element(&element)?),
190                Event::End(element)
191                    if matches_local_name(element.name().as_ref(), b"outerShdw") =>
192                {
193                    break;
194                }
195                Event::Eof => return Err(missing_end("outerShdw")),
196                _ => {}
197            }
198            buffer.clear();
199        }
200        if shadow.color.is_none() && shadow.raw_children.is_empty() {
201            return Err(EffectError::MissingColor);
202        }
203        Ok(shadow)
204    }
205
206    fn validate_attributes(&self) -> Result<()> {
207        if let Some(value) = self.blur_radius {
208            validate_coordinate("blurRad", value)?;
209        }
210        if let Some(value) = self.distance {
211            validate_coordinate("dist", value)?;
212        }
213        if let Some(value) = self.direction {
214            validate_direction(value.0)?;
215        }
216        if let Some(value) = self.skew_x {
217            validate_skew("kx", value.0)?;
218        }
219        if let Some(value) = self.skew_y {
220            validate_skew("ky", value.0)?;
221        }
222        Ok(())
223    }
224
225    fn write_xml<W: Write>(&self, writer: &mut Writer<W>) -> Result<()> {
226        self.validate_attributes()?;
227        if self.color.is_none() && self.raw_children.is_empty() {
228            return Err(EffectError::MissingColor);
229        }
230        let mut start = BytesStart::new("a:outerShdw");
231        let blur_radius = self.blur_radius.map(|value| value.to_string());
232        let distance = self.distance.map(|value| value.to_string());
233        let direction = self.direction.map(|value| value.0.to_string());
234        let scale_x = self.scale_x.map(|value| value.0.to_string());
235        let scale_y = self.scale_y.map(|value| value.0.to_string());
236        let skew_x = self.skew_x.map(|value| value.0.to_string());
237        let skew_y = self.skew_y.map(|value| value.0.to_string());
238        for (name, value) in [
239            ("blurRad", blur_radius.as_deref()),
240            ("dist", distance.as_deref()),
241            ("dir", direction.as_deref()),
242            ("sx", scale_x.as_deref()),
243            ("sy", scale_y.as_deref()),
244            ("kx", skew_x.as_deref()),
245            ("ky", skew_y.as_deref()),
246        ] {
247            if let Some(value) = value {
248                start.push_attribute((name, value));
249            }
250        }
251        if let Some(alignment) = self.alignment {
252            start.push_attribute(("algn", alignment.as_str()));
253        }
254        if let Some(rotate) = self.rotate_with_shape.map(bool_text) {
255            start.push_attribute(("rotWithShape", rotate));
256        }
257
258        write_start(writer, start)?;
259        emit_raw(writer, self.raw_children.at(0))?;
260        if let Some(color) = &self.color {
261            color.to_xml(writer)?;
262        }
263        emit_raw(writer, self.raw_children.at(1))?;
264        write_end(writer, "a:outerShdw")
265    }
266
267    pub fn raw_children(&self) -> &OrderedRawChildren {
268        &self.raw_children
269    }
270
271    /// Reports a colour choice that is kept as raw XML, such as an
272    /// `a:scrgbClr` or `a:hslClr`, which `color` does not model.
273    pub fn has_unmodelled_color(&self) -> bool {
274        (0..=1).any(|boundary| self.raw_children.at(boundary).any(raw_is_color))
275    }
276
277    /// Replaces the shadow colour, dropping an unmodelled colour choice so
278    /// that the element keeps the single colour its schema allows. The new
279    /// colour takes the dropped one's place among the unmodelled siblings.
280    pub fn replace_color(&mut self, color: ColorChoice) {
281        let mut raw_children = OrderedRawChildren::default();
282        let mut boundary = 0;
283        for child in self.raw_children.at(0) {
284            if raw_is_color(child) {
285                boundary = 1;
286            } else {
287                raw_children.push(boundary, child.to_vec());
288            }
289        }
290        for child in self.raw_children.at(1).filter(|child| !raw_is_color(child)) {
291            raw_children.push(1, child.to_vec());
292        }
293        self.raw_children = raw_children;
294        self.color = Some(color);
295    }
296}
297
298/// DrawingML `a:effectLst` with an outer shadow and preserved other effects.
299#[allow(non_camel_case_types)]
300#[derive(Clone, Debug, Default, Eq, PartialEq)]
301pub struct CT_EffectList {
302    pub outer_shadow: Option<CT_OuterShadowEffect>,
303    raw_attributes: Vec<(String, String)>,
304    raw_children: OrderedRawChildren,
305}
306
307impl CT_EffectList {
308    /// Parses one complete `a:effectLst` with any namespace prefix.
309    pub fn from_xml(xml: &[u8]) -> Result<Self> {
310        let mut reader = Reader::from_reader(xml);
311        let mut buffer = Vec::new();
312        loop {
313            match reader
314                .read_event_into(&mut buffer)
315                .map_err(OxmlError::from)?
316            {
317                Event::Start(element)
318                    if matches_local_name(element.name().as_ref(), b"effectLst") =>
319                {
320                    let mut effects = Self::from_element(&mut reader)?;
321                    effects.raw_attributes = capture_raw_attributes(&element)?;
322                    return Ok(effects);
323                }
324                Event::Empty(element)
325                    if matches_local_name(element.name().as_ref(), b"effectLst") =>
326                {
327                    return Ok(Self {
328                        raw_attributes: capture_raw_attributes(&element)?,
329                        ..Self::default()
330                    });
331                }
332                Event::Start(element) | Event::Empty(element) => {
333                    return Err(unexpected(&element));
334                }
335                Event::Eof => {
336                    return Err(EffectError::Xml(OxmlError::MissingElement(
337                        "DrawingML effect list".to_owned(),
338                    )));
339                }
340                _ => {}
341            }
342            buffer.clear();
343        }
344    }
345
346    fn from_element(reader: &mut Reader<&[u8]>) -> Result<Self> {
347        let mut effects = Self::default();
348        let mut boundary = 0;
349        let mut buffer = Vec::new();
350        loop {
351            match reader
352                .read_event_into(&mut buffer)
353                .map_err(OxmlError::from)?
354            {
355                Event::Start(element)
356                    if matches_local_name(element.name().as_ref(), b"outerShdw")
357                        && effects.outer_shadow.is_none() =>
358                {
359                    effects.outer_shadow =
360                        Some(CT_OuterShadowEffect::from_element(reader, &element)?);
361                    boundary = 1;
362                }
363                Event::Empty(element)
364                    if matches_local_name(element.name().as_ref(), b"outerShdw")
365                        && effects.outer_shadow.is_none() =>
366                {
367                    return Err(EffectError::MissingColor);
368                }
369                Event::Start(element) => {
370                    boundary = boundary.max(raw_effect_boundary(element.name().as_ref()));
371                    effects
372                        .raw_children
373                        .push(boundary, capture_element(reader, &element)?);
374                }
375                Event::Empty(element) => {
376                    boundary = boundary.max(raw_effect_boundary(element.name().as_ref()));
377                    effects
378                        .raw_children
379                        .push(boundary, capture_empty_element(&element)?);
380                }
381                Event::End(element)
382                    if matches_local_name(element.name().as_ref(), b"effectLst") =>
383                {
384                    break;
385                }
386                Event::Eof => return Err(missing_end("effectLst")),
387                _ => {}
388            }
389            buffer.clear();
390        }
391        Ok(effects)
392    }
393
394    /// Writes this list with a fixed root prefix and the shadow in schema order.
395    pub fn to_xml(&self) -> Result<Vec<u8>> {
396        let mut writer = Writer::new(Vec::new());
397        self.write_xml(&mut writer)?;
398        Ok(writer.into_inner())
399    }
400
401    /// Writes this effect list into an existing XML writer.
402    pub fn write_xml<W: Write>(&self, writer: &mut Writer<W>) -> Result<()> {
403        let mut start = BytesStart::new("a:effectLst");
404        for (name, value) in &self.raw_attributes {
405            start.push_attribute((name.as_str(), value.as_str()));
406        }
407        if self.outer_shadow.is_none() && self.raw_children.is_empty() {
408            return write_empty(writer, start);
409        }
410        write_start(writer, start)?;
411        emit_raw(writer, self.raw_children.at(0))?;
412        if let Some(shadow) = &self.outer_shadow {
413            shadow.write_xml(writer)?;
414        }
415        emit_raw(writer, self.raw_children.at(1))?;
416        write_end(writer, "a:effectLst")
417    }
418
419    pub fn raw_children(&self) -> &OrderedRawChildren {
420        &self.raw_children
421    }
422
423    /// Reports an unresolved placeholder colour inside an opaque effect child.
424    pub fn has_unmodelled_placeholder_color(&self) -> bool {
425        self.raw_children
426            .at(0)
427            .chain(self.raw_children.at(1))
428            .any(raw_contains_placeholder_color)
429            || self.outer_shadow.as_ref().is_some_and(|shadow| {
430                shadow
431                    .raw_children()
432                    .at(0)
433                    .chain(shadow.raw_children().at(1))
434                    .any(raw_contains_placeholder_color)
435            })
436    }
437}
438
439/// Places an effect the schema orders after `a:outerShdw` behind the outer
440/// shadow slot, so a shadow added later is still written before it.
441fn raw_effect_boundary(name: &[u8]) -> usize {
442    match local_name(name) {
443        b"prstShdw" | b"reflection" | b"softEdge" => 1,
444        _ => 0,
445    }
446}
447
448pub(crate) fn raw_contains_placeholder_color(xml: &[u8]) -> bool {
449    let mut reader = NsReader::from_reader(xml);
450    let mut buffer = Vec::new();
451    loop {
452        match reader.read_resolved_event_into(&mut buffer) {
453            Ok((namespace, Event::Start(element) | Event::Empty(element)))
454                if is_drawingml_namespace(&namespace)
455                    && matches_local_name(element.name().as_ref(), b"schemeClr")
456                    && get_attr(&element, b"val").as_deref() == Some("phClr") =>
457            {
458                return true;
459            }
460            Ok((_, Event::Eof)) | Err(_) => return false,
461            _ => {}
462        }
463        buffer.clear();
464    }
465}
466
467/// Reports whether one captured element is a DrawingML `a:effectDag`.
468pub fn raw_is_effect_dag(xml: &[u8]) -> bool {
469    let mut reader = NsReader::from_reader(xml);
470    let mut buffer = Vec::new();
471    loop {
472        match reader.read_resolved_event_into(&mut buffer) {
473            Ok((namespace, Event::Start(element) | Event::Empty(element))) => {
474                return is_drawingml_namespace(&namespace)
475                    && matches_local_name(element.name().as_ref(), b"effectDag");
476            }
477            Ok((_, Event::Eof)) | Err(_) => return false,
478            _ => {}
479        }
480        buffer.clear();
481    }
482}
483
484fn is_drawingml_namespace(namespace: &ResolveResult<'_>) -> bool {
485    match namespace {
486        ResolveResult::Bound(Namespace(uri)) => *uri == A_NS.as_bytes(),
487        ResolveResult::Unknown(prefix) => prefix == b"a",
488        ResolveResult::Unbound => false,
489    }
490}
491
492fn raw_is_color(xml: &[u8]) -> bool {
493    let name_end = xml
494        .iter()
495        .position(|byte| byte.is_ascii_whitespace() || matches!(byte, b'/' | b'>'))
496        .unwrap_or(xml.len());
497    matches!(
498        local_name(xml.get(1..name_end).unwrap_or_default()),
499        b"scrgbClr" | b"srgbClr" | b"hslClr" | b"sysClr" | b"schemeClr" | b"prstClr"
500    )
501}
502
503fn capture_raw_attributes(start: &BytesStart<'_>) -> Result<Vec<(String, String)>> {
504    let mut raw = Vec::new();
505    for attribute in start.attributes() {
506        let attribute = attribute.map_err(OxmlError::from)?;
507        let name = std::str::from_utf8(attribute.key.as_ref())
508            .map_err(OxmlError::from)?
509            .to_owned();
510        let value = attribute
511            .decoded_and_normalized_value(XmlVersion::Implicit1_0, start.decoder())
512            .map_err(OxmlError::from)?
513            .into_owned();
514        raw.push((name, value));
515    }
516    Ok(raw)
517}
518
519fn is_color(name: &[u8]) -> bool {
520    matches!(
521        local_name(name),
522        b"srgbClr" | b"schemeClr" | b"sysClr" | b"prstClr"
523    )
524}
525
526fn validate_coordinate(attribute: &str, value: i64) -> Result<()> {
527    if (0..=MAX_POSITIVE_COORDINATE).contains(&value) {
528        Ok(())
529    } else {
530        Err(invalid_value(attribute, value))
531    }
532}
533
534fn validate_direction(value: i32) -> Result<()> {
535    if (0..FULL_CIRCLE_ANGLE).contains(&value) {
536        Ok(())
537    } else {
538        Err(invalid_value("dir", value))
539    }
540}
541
542fn validate_skew(attribute: &str, value: i32) -> Result<()> {
543    if (-QUARTER_CIRCLE_ANGLE..QUARTER_CIRCLE_ANGLE).contains(&value) {
544        Ok(())
545    } else {
546        Err(invalid_value(attribute, value))
547    }
548}
549
550fn invalid_value(attribute: &str, value: impl ToString) -> EffectError {
551    EffectError::InvalidAttribute {
552        element: "outerShdw".to_owned(),
553        attribute: attribute.to_owned(),
554        value: value.to_string(),
555    }
556}
557
558fn optional_parse<T: std::str::FromStr>(start: &BytesStart<'_>, name: &[u8]) -> Result<Option<T>> {
559    get_attr(start, name)
560        .map(|value| {
561            value
562                .parse()
563                .map_err(|_| invalid(start, name, value.to_owned()))
564        })
565        .transpose()
566}
567
568fn optional_bool(start: &BytesStart<'_>, name: &[u8]) -> Result<Option<bool>> {
569    get_attr(start, name)
570        .map(|value| match value.as_str() {
571            "1" | "true" => Ok(true),
572            "0" | "false" => Ok(false),
573            _ => Err(invalid(start, name, value)),
574        })
575        .transpose()
576}
577
578fn optional_enum<T>(
579    start: &BytesStart<'_>,
580    name: &[u8],
581    parse: impl FnOnce(&str) -> Option<T>,
582) -> Result<Option<T>> {
583    get_attr(start, name)
584        .map(|value| parse(&value).ok_or_else(|| invalid(start, name, value)))
585        .transpose()
586}
587
588fn invalid(start: &BytesStart<'_>, attribute: &[u8], value: String) -> EffectError {
589    EffectError::InvalidAttribute {
590        element: String::from_utf8_lossy(local_name(start.name().as_ref())).into_owned(),
591        attribute: String::from_utf8_lossy(attribute).into_owned(),
592        value,
593    }
594}
595
596fn unexpected(start: &BytesStart<'_>) -> EffectError {
597    EffectError::UnexpectedElement(String::from_utf8_lossy(start.name().as_ref()).into_owned())
598}
599
600fn missing_end(name: &str) -> EffectError {
601    EffectError::Xml(OxmlError::MissingElement(format!("closing a:{name}")))
602}
603
604const fn bool_text(value: bool) -> &'static str {
605    if value { "1" } else { "0" }
606}
607
608fn emit_raw<'a, W: Write>(
609    writer: &mut Writer<W>,
610    children: impl Iterator<Item = &'a [u8]>,
611) -> Result<()> {
612    for child in children {
613        writer.get_mut().write_all(child).map_err(OxmlError::from)?;
614    }
615    Ok(())
616}
617
618fn write_start<W: Write>(writer: &mut Writer<W>, start: BytesStart<'_>) -> Result<()> {
619    writer
620        .write_event(Event::Start(start))
621        .map_err(OxmlError::from)?;
622    Ok(())
623}
624
625fn write_empty<W: Write>(writer: &mut Writer<W>, start: BytesStart<'_>) -> Result<()> {
626    writer
627        .write_event(Event::Empty(start))
628        .map_err(OxmlError::from)?;
629    Ok(())
630}
631
632fn write_end<W: Write>(writer: &mut Writer<W>, name: &str) -> Result<()> {
633    writer
634        .write_event(Event::End(BytesEnd::new(name)))
635        .map_err(OxmlError::from)?;
636    Ok(())
637}
638
639#[cfg(test)]
640mod tests {
641    use oxml_core::units::{Angle, Percent1000};
642
643    use super::{CT_EffectList, EffectError, RectAlignment};
644    use crate::color::ColorChoice;
645
646    #[test]
647    fn a_shape_with_glow_round_trips_with_glow_intact_as_raw_xml() {
648        let xml = br#"<z:effectLst><a:glow rad="63500"><a:srgbClr val="FF0000"><a:alpha val="50000"/></a:srgbClr><!--kept--></a:glow></z:effectLst>"#;
649
650        let written = CT_EffectList::from_xml(xml).unwrap().to_xml().unwrap();
651
652        assert_eq!(written, br#"<a:effectLst><a:glow rad="63500"><a:srgbClr val="FF0000"><a:alpha val="50000"/></a:srgbClr><!--kept--></a:glow></a:effectLst>"#);
653    }
654
655    #[test]
656    fn outer_shadow_properties_and_colour_round_trip_structurally() {
657        let xml = br#"<z:effectLst><z:outerShdw blurRad="50800" dist="38100" dir="2700000" sx="120000" sy="80000" kx="1200000" ky="-600000" algn="br" rotWithShape="1"><z:schemeClr val="accent2"><z:alpha val="60000"/></z:schemeClr></z:outerShdw></z:effectLst>"#;
658        let parsed = CT_EffectList::from_xml(xml).unwrap();
659        let shadow = parsed.outer_shadow.as_ref().unwrap();
660
661        assert_eq!(shadow.blur_radius, Some(50_800));
662        assert_eq!(shadow.distance, Some(38_100));
663        assert_eq!(shadow.direction, Some(Angle(2_700_000)));
664        assert_eq!(shadow.scale_x, Some(Percent1000(120_000)));
665        assert_eq!(shadow.scale_y, Some(Percent1000(80_000)));
666        assert_eq!(shadow.skew_x, Some(Angle(1_200_000)));
667        assert_eq!(shadow.skew_y, Some(Angle(-600_000)));
668        assert_eq!(shadow.alignment, Some(RectAlignment::BottomRight));
669        assert_eq!(shadow.rotate_with_shape, Some(true));
670        assert!(matches!(shadow.color, Some(ColorChoice::Scheme { .. })));
671
672        let written = parsed.to_xml().unwrap();
673        assert_eq!(CT_EffectList::from_xml(&written).unwrap(), parsed);
674    }
675
676    #[test]
677    fn effect_list_writes_schema_order_and_keeps_raw_effect_positions() {
678        let xml = br#"<z:effectLst><x:before/><z:blur rad="10"/><z:glow rad="20"><x:item>one &amp; two</x:item></z:glow><z:innerShdw><z:srgbClr val="010203"/></z:innerShdw><z:outerShdw dist="30"><x:shadowBefore/><z:srgbClr val="AABBCC"/><x:shadowAfter/></z:outerShdw><z:prstShdw prst="shdw1"><z:srgbClr val="040506"/></z:prstShdw><z:reflection blurRad="40"/><x:after/></z:effectLst>"#;
679
680        let written = CT_EffectList::from_xml(xml).unwrap().to_xml().unwrap();
681
682        assert_eq!(written, br#"<a:effectLst><x:before/><z:blur rad="10"/><z:glow rad="20"><x:item>one &amp; two</x:item></z:glow><z:innerShdw><z:srgbClr val="010203"/></z:innerShdw><a:outerShdw dist="30"><x:shadowBefore/><a:srgbClr val="AABBCC"/><x:shadowAfter/></a:outerShdw><z:prstShdw prst="shdw1"><z:srgbClr val="040506"/></z:prstShdw><z:reflection blurRad="40"/><x:after/></a:effectLst>"#);
683    }
684
685    #[test]
686    fn an_outer_shadow_added_later_precedes_the_effects_the_schema_puts_after_it() {
687        let xml = br#"<a:effectLst><a:glow rad="1"><a:srgbClr val="FF0000"/></a:glow><x:middle/><a:reflection/><x:after/><a:softEdge rad="2"/></a:effectLst>"#;
688        let mut effects = CT_EffectList::from_xml(xml).unwrap();
689        assert_eq!(effects.to_xml().unwrap(), xml);
690
691        effects.outer_shadow = Some(super::CT_OuterShadowEffect {
692            color: Some(ColorChoice::srgb(crate::color::RgbColor::new(0, 0, 0))),
693            ..Default::default()
694        });
695        assert_eq!(
696            effects.to_xml().unwrap(),
697            br#"<a:effectLst><a:glow rad="1"><a:srgbClr val="FF0000"/></a:glow><x:middle/><a:outerShdw><a:srgbClr val="000000"/></a:outerShdw><a:reflection/><x:after/><a:softEdge rad="2"/></a:effectLst>"#
698        );
699    }
700
701    #[test]
702    fn root_attributes_and_an_unmodelled_shadow_colour_are_kept_until_replaced() {
703        let xml = br#"<z:effectLst xmlns:x="urn:x" x:keep="1"><z:outerShdw dist="1"><z:scrgbClr r="0" g="0" b="0"><z:alpha val="50000"/></z:scrgbClr><x:after/></z:outerShdw></z:effectLst>"#;
704        let mut effects = CT_EffectList::from_xml(xml).unwrap();
705        assert_eq!(
706            effects.to_xml().unwrap(),
707            br#"<a:effectLst xmlns:x="urn:x" x:keep="1"><a:outerShdw dist="1"><z:scrgbClr r="0" g="0" b="0"><z:alpha val="50000"/></z:scrgbClr><x:after/></a:outerShdw></a:effectLst>"#
708        );
709        let shadow = effects.outer_shadow.as_mut().unwrap();
710        assert!(shadow.color.is_none());
711        assert!(shadow.has_unmodelled_color());
712
713        shadow.replace_color(ColorChoice::srgb(crate::color::RgbColor::new(1, 2, 3)));
714        assert!(!shadow.has_unmodelled_color());
715        assert_eq!(
716            effects.to_xml().unwrap(),
717            br#"<a:effectLst xmlns:x="urn:x" x:keep="1"><a:outerShdw dist="1"><a:srgbClr val="010203"/><x:after/></a:outerShdw></a:effectLst>"#
718        );
719    }
720
721    #[test]
722    fn malformed_outer_shadow_values_return_errors_without_panicking() {
723        let cases: &[&[u8]] = &[
724            br#"<a:effectLst><a:outerShdw blurRad="-1"><a:srgbClr val="000000"/></a:outerShdw></a:effectLst>"#,
725            br#"<a:effectLst><a:outerShdw dist="27273042316901"><a:srgbClr val="000000"/></a:outerShdw></a:effectLst>"#,
726            br#"<a:effectLst><a:outerShdw dir="21600000"><a:srgbClr val="000000"/></a:outerShdw></a:effectLst>"#,
727            br#"<a:effectLst><a:outerShdw kx="5400000"><a:srgbClr val="000000"/></a:outerShdw></a:effectLst>"#,
728            br#"<a:effectLst><a:outerShdw sx="wide"><a:srgbClr val="000000"/></a:outerShdw></a:effectLst>"#,
729            br#"<a:effectLst><a:outerShdw algn="middle"><a:srgbClr val="000000"/></a:outerShdw></a:effectLst>"#,
730            br#"<a:effectLst><a:outerShdw rotWithShape="maybe"><a:srgbClr val="000000"/></a:outerShdw></a:effectLst>"#,
731            br#"<a:effectLst><a:outerShdw><a:srgbClr val="XYZXYZ"/></a:outerShdw></a:effectLst>"#,
732            br#"<a:effectLst><a:outerShdw/></a:effectLst>"#,
733        ];
734        for xml in cases {
735            let result = std::panic::catch_unwind(|| CT_EffectList::from_xml(xml));
736            assert!(
737                result.is_ok(),
738                "effect parser panicked for {}",
739                String::from_utf8_lossy(xml)
740            );
741            assert!(
742                result.unwrap().is_err(),
743                "malformed outer shadow parsed successfully"
744            );
745        }
746        assert!(matches!(
747            CT_EffectList::from_xml(cases[8]),
748            Err(EffectError::MissingColor)
749        ));
750    }
751}