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};
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    fn parse(value: &str) -> Option<Self> {
96        match value {
97            "tl" => Some(Self::TopLeft),
98            "t" => Some(Self::Top),
99            "tr" => Some(Self::TopRight),
100            "l" => Some(Self::Left),
101            "ctr" => Some(Self::Center),
102            "r" => Some(Self::Right),
103            "bl" => Some(Self::BottomLeft),
104            "b" => Some(Self::Bottom),
105            "br" => Some(Self::BottomRight),
106            _ => None,
107        }
108    }
109
110    const fn as_str(self) -> &'static str {
111        match self {
112            Self::TopLeft => "tl",
113            Self::Top => "t",
114            Self::TopRight => "tr",
115            Self::Left => "l",
116            Self::Center => "ctr",
117            Self::Right => "r",
118            Self::BottomLeft => "bl",
119            Self::Bottom => "b",
120            Self::BottomRight => "br",
121        }
122    }
123}
124
125/// Modelled properties of one DrawingML `a:outerShdw` effect.
126#[allow(non_camel_case_types)]
127#[derive(Clone, Debug, Default, Eq, PartialEq)]
128pub struct CT_OuterShadowEffect {
129    pub blur_radius: Option<i64>,
130    pub distance: Option<i64>,
131    pub direction: Option<Angle>,
132    pub scale_x: Option<Percent1000>,
133    pub scale_y: Option<Percent1000>,
134    pub skew_x: Option<Angle>,
135    pub skew_y: Option<Angle>,
136    pub alignment: Option<RectAlignment>,
137    pub rotate_with_shape: Option<bool>,
138    pub color: Option<ColorChoice>,
139    raw_children: OrderedRawChildren,
140}
141
142impl CT_OuterShadowEffect {
143    fn from_start(start: &BytesStart<'_>) -> Result<Self> {
144        let shadow = Self {
145            blur_radius: optional_parse(start, b"blurRad")?,
146            distance: optional_parse(start, b"dist")?,
147            direction: optional_parse::<i32>(start, b"dir")?.map(Angle),
148            scale_x: optional_parse::<i32>(start, b"sx")?.map(Percent1000),
149            scale_y: optional_parse::<i32>(start, b"sy")?.map(Percent1000),
150            skew_x: optional_parse::<i32>(start, b"kx")?.map(Angle),
151            skew_y: optional_parse::<i32>(start, b"ky")?.map(Angle),
152            alignment: optional_enum(start, b"algn", RectAlignment::parse)?,
153            rotate_with_shape: optional_bool(start, b"rotWithShape")?,
154            color: None,
155            raw_children: OrderedRawChildren::default(),
156        };
157        shadow.validate_attributes()?;
158        Ok(shadow)
159    }
160
161    fn from_element(reader: &mut Reader<&[u8]>, start: &BytesStart<'_>) -> Result<Self> {
162        let mut shadow = Self::from_start(start)?;
163        let mut boundary = 0;
164        let mut buffer = Vec::new();
165        loop {
166            match reader
167                .read_event_into(&mut buffer)
168                .map_err(OxmlError::from)?
169            {
170                Event::Start(element)
171                    if is_color(element.name().as_ref()) && shadow.color.is_none() =>
172                {
173                    shadow.color = Some(ColorChoice::from_xml(reader, &element)?);
174                    boundary = 1;
175                }
176                Event::Empty(element)
177                    if is_color(element.name().as_ref()) && shadow.color.is_none() =>
178                {
179                    shadow.color = Some(ColorChoice::from_empty_xml(&element)?);
180                    boundary = 1;
181                }
182                Event::Start(element) => shadow
183                    .raw_children
184                    .push(boundary, capture_element(reader, &element)?),
185                Event::Empty(element) => shadow
186                    .raw_children
187                    .push(boundary, capture_empty_element(&element)?),
188                Event::End(element)
189                    if matches_local_name(element.name().as_ref(), b"outerShdw") =>
190                {
191                    break;
192                }
193                Event::Eof => return Err(missing_end("outerShdw")),
194                _ => {}
195            }
196            buffer.clear();
197        }
198        if shadow.color.is_none() && shadow.raw_children.is_empty() {
199            return Err(EffectError::MissingColor);
200        }
201        Ok(shadow)
202    }
203
204    fn validate_attributes(&self) -> Result<()> {
205        if let Some(value) = self.blur_radius {
206            validate_coordinate("blurRad", value)?;
207        }
208        if let Some(value) = self.distance {
209            validate_coordinate("dist", value)?;
210        }
211        if let Some(value) = self.direction {
212            validate_direction(value.0)?;
213        }
214        if let Some(value) = self.skew_x {
215            validate_skew("kx", value.0)?;
216        }
217        if let Some(value) = self.skew_y {
218            validate_skew("ky", value.0)?;
219        }
220        Ok(())
221    }
222
223    fn write_xml<W: Write>(&self, writer: &mut Writer<W>) -> Result<()> {
224        self.validate_attributes()?;
225        if self.color.is_none() && self.raw_children.is_empty() {
226            return Err(EffectError::MissingColor);
227        }
228        let mut start = BytesStart::new("a:outerShdw");
229        let blur_radius = self.blur_radius.map(|value| value.to_string());
230        let distance = self.distance.map(|value| value.to_string());
231        let direction = self.direction.map(|value| value.0.to_string());
232        let scale_x = self.scale_x.map(|value| value.0.to_string());
233        let scale_y = self.scale_y.map(|value| value.0.to_string());
234        let skew_x = self.skew_x.map(|value| value.0.to_string());
235        let skew_y = self.skew_y.map(|value| value.0.to_string());
236        for (name, value) in [
237            ("blurRad", blur_radius.as_deref()),
238            ("dist", distance.as_deref()),
239            ("dir", direction.as_deref()),
240            ("sx", scale_x.as_deref()),
241            ("sy", scale_y.as_deref()),
242            ("kx", skew_x.as_deref()),
243            ("ky", skew_y.as_deref()),
244        ] {
245            if let Some(value) = value {
246                start.push_attribute((name, value));
247            }
248        }
249        if let Some(alignment) = self.alignment {
250            start.push_attribute(("algn", alignment.as_str()));
251        }
252        if let Some(rotate) = self.rotate_with_shape.map(bool_text) {
253            start.push_attribute(("rotWithShape", rotate));
254        }
255
256        write_start(writer, start)?;
257        emit_raw(writer, self.raw_children.at(0))?;
258        if let Some(color) = &self.color {
259            color.to_xml(writer)?;
260        }
261        emit_raw(writer, self.raw_children.at(1))?;
262        write_end(writer, "a:outerShdw")
263    }
264
265    pub fn raw_children(&self) -> &OrderedRawChildren {
266        &self.raw_children
267    }
268}
269
270/// DrawingML `a:effectLst` with an outer shadow and preserved other effects.
271#[allow(non_camel_case_types)]
272#[derive(Clone, Debug, Default, Eq, PartialEq)]
273pub struct CT_EffectList {
274    pub outer_shadow: Option<CT_OuterShadowEffect>,
275    raw_children: OrderedRawChildren,
276}
277
278impl CT_EffectList {
279    /// Parses one complete `a:effectLst` with any namespace prefix.
280    pub fn from_xml(xml: &[u8]) -> Result<Self> {
281        let mut reader = Reader::from_reader(xml);
282        let mut buffer = Vec::new();
283        loop {
284            match reader
285                .read_event_into(&mut buffer)
286                .map_err(OxmlError::from)?
287            {
288                Event::Start(element)
289                    if matches_local_name(element.name().as_ref(), b"effectLst") =>
290                {
291                    return Self::from_element(&mut reader);
292                }
293                Event::Empty(element)
294                    if matches_local_name(element.name().as_ref(), b"effectLst") =>
295                {
296                    return Ok(Self::default());
297                }
298                Event::Start(element) | Event::Empty(element) => {
299                    return Err(unexpected(&element));
300                }
301                Event::Eof => {
302                    return Err(EffectError::Xml(OxmlError::MissingElement(
303                        "DrawingML effect list".to_owned(),
304                    )));
305                }
306                _ => {}
307            }
308            buffer.clear();
309        }
310    }
311
312    fn from_element(reader: &mut Reader<&[u8]>) -> Result<Self> {
313        let mut effects = Self::default();
314        let mut boundary = 0;
315        let mut buffer = Vec::new();
316        loop {
317            match reader
318                .read_event_into(&mut buffer)
319                .map_err(OxmlError::from)?
320            {
321                Event::Start(element)
322                    if matches_local_name(element.name().as_ref(), b"outerShdw")
323                        && effects.outer_shadow.is_none() =>
324                {
325                    effects.outer_shadow =
326                        Some(CT_OuterShadowEffect::from_element(reader, &element)?);
327                    boundary = 1;
328                }
329                Event::Empty(element)
330                    if matches_local_name(element.name().as_ref(), b"outerShdw")
331                        && effects.outer_shadow.is_none() =>
332                {
333                    return Err(EffectError::MissingColor);
334                }
335                Event::Start(element) => effects
336                    .raw_children
337                    .push(boundary, capture_element(reader, &element)?),
338                Event::Empty(element) => effects
339                    .raw_children
340                    .push(boundary, capture_empty_element(&element)?),
341                Event::End(element)
342                    if matches_local_name(element.name().as_ref(), b"effectLst") =>
343                {
344                    break;
345                }
346                Event::Eof => return Err(missing_end("effectLst")),
347                _ => {}
348            }
349            buffer.clear();
350        }
351        Ok(effects)
352    }
353
354    /// Writes this list with a fixed root prefix and the shadow in schema order.
355    pub fn to_xml(&self) -> Result<Vec<u8>> {
356        let mut writer = Writer::new(Vec::new());
357        self.write_xml(&mut writer)?;
358        Ok(writer.into_inner())
359    }
360
361    /// Writes this effect list into an existing XML writer.
362    pub fn write_xml<W: Write>(&self, writer: &mut Writer<W>) -> Result<()> {
363        if self.outer_shadow.is_none() && self.raw_children.is_empty() {
364            return write_empty(writer, BytesStart::new("a:effectLst"));
365        }
366        write_start(writer, BytesStart::new("a:effectLst"))?;
367        emit_raw(writer, self.raw_children.at(0))?;
368        if let Some(shadow) = &self.outer_shadow {
369            shadow.write_xml(writer)?;
370        }
371        emit_raw(writer, self.raw_children.at(1))?;
372        write_end(writer, "a:effectLst")
373    }
374
375    pub fn raw_children(&self) -> &OrderedRawChildren {
376        &self.raw_children
377    }
378
379    /// Reports an unresolved placeholder colour inside an opaque effect child.
380    pub fn has_unmodelled_placeholder_color(&self) -> bool {
381        self.raw_children
382            .at(0)
383            .chain(self.raw_children.at(1))
384            .any(raw_contains_placeholder_color)
385            || self.outer_shadow.as_ref().is_some_and(|shadow| {
386                shadow
387                    .raw_children()
388                    .at(0)
389                    .chain(shadow.raw_children().at(1))
390                    .any(raw_contains_placeholder_color)
391            })
392    }
393}
394
395pub(crate) fn raw_contains_placeholder_color(xml: &[u8]) -> bool {
396    let mut reader = NsReader::from_reader(xml);
397    let mut buffer = Vec::new();
398    loop {
399        match reader.read_resolved_event_into(&mut buffer) {
400            Ok((namespace, Event::Start(element) | Event::Empty(element)))
401                if is_drawingml_namespace(&namespace)
402                    && matches_local_name(element.name().as_ref(), b"schemeClr")
403                    && get_attr(&element, b"val").as_deref() == Some("phClr") =>
404            {
405                return true;
406            }
407            Ok((_, Event::Eof)) | Err(_) => return false,
408            _ => {}
409        }
410        buffer.clear();
411    }
412}
413
414pub(crate) fn raw_is_effect_dag(xml: &[u8]) -> bool {
415    let mut reader = NsReader::from_reader(xml);
416    let mut buffer = Vec::new();
417    loop {
418        match reader.read_resolved_event_into(&mut buffer) {
419            Ok((namespace, Event::Start(element) | Event::Empty(element))) => {
420                return is_drawingml_namespace(&namespace)
421                    && matches_local_name(element.name().as_ref(), b"effectDag");
422            }
423            Ok((_, Event::Eof)) | Err(_) => return false,
424            _ => {}
425        }
426        buffer.clear();
427    }
428}
429
430fn is_drawingml_namespace(namespace: &ResolveResult<'_>) -> bool {
431    match namespace {
432        ResolveResult::Bound(Namespace(uri)) => *uri == A_NS.as_bytes(),
433        ResolveResult::Unknown(prefix) => prefix == b"a",
434        ResolveResult::Unbound => false,
435    }
436}
437
438fn is_color(name: &[u8]) -> bool {
439    matches!(
440        local_name(name),
441        b"srgbClr" | b"schemeClr" | b"sysClr" | b"prstClr"
442    )
443}
444
445fn validate_coordinate(attribute: &str, value: i64) -> Result<()> {
446    if (0..=MAX_POSITIVE_COORDINATE).contains(&value) {
447        Ok(())
448    } else {
449        Err(invalid_value(attribute, value))
450    }
451}
452
453fn validate_direction(value: i32) -> Result<()> {
454    if (0..FULL_CIRCLE_ANGLE).contains(&value) {
455        Ok(())
456    } else {
457        Err(invalid_value("dir", value))
458    }
459}
460
461fn validate_skew(attribute: &str, value: i32) -> Result<()> {
462    if (-QUARTER_CIRCLE_ANGLE..QUARTER_CIRCLE_ANGLE).contains(&value) {
463        Ok(())
464    } else {
465        Err(invalid_value(attribute, value))
466    }
467}
468
469fn invalid_value(attribute: &str, value: impl ToString) -> EffectError {
470    EffectError::InvalidAttribute {
471        element: "outerShdw".to_owned(),
472        attribute: attribute.to_owned(),
473        value: value.to_string(),
474    }
475}
476
477fn optional_parse<T: std::str::FromStr>(start: &BytesStart<'_>, name: &[u8]) -> Result<Option<T>> {
478    get_attr(start, name)
479        .map(|value| {
480            value
481                .parse()
482                .map_err(|_| invalid(start, name, value.to_owned()))
483        })
484        .transpose()
485}
486
487fn optional_bool(start: &BytesStart<'_>, name: &[u8]) -> Result<Option<bool>> {
488    get_attr(start, name)
489        .map(|value| match value.as_str() {
490            "1" | "true" => Ok(true),
491            "0" | "false" => Ok(false),
492            _ => Err(invalid(start, name, value)),
493        })
494        .transpose()
495}
496
497fn optional_enum<T>(
498    start: &BytesStart<'_>,
499    name: &[u8],
500    parse: impl FnOnce(&str) -> Option<T>,
501) -> Result<Option<T>> {
502    get_attr(start, name)
503        .map(|value| parse(&value).ok_or_else(|| invalid(start, name, value)))
504        .transpose()
505}
506
507fn invalid(start: &BytesStart<'_>, attribute: &[u8], value: String) -> EffectError {
508    EffectError::InvalidAttribute {
509        element: String::from_utf8_lossy(local_name(start.name().as_ref())).into_owned(),
510        attribute: String::from_utf8_lossy(attribute).into_owned(),
511        value,
512    }
513}
514
515fn unexpected(start: &BytesStart<'_>) -> EffectError {
516    EffectError::UnexpectedElement(String::from_utf8_lossy(start.name().as_ref()).into_owned())
517}
518
519fn missing_end(name: &str) -> EffectError {
520    EffectError::Xml(OxmlError::MissingElement(format!("closing a:{name}")))
521}
522
523const fn bool_text(value: bool) -> &'static str {
524    if value { "1" } else { "0" }
525}
526
527fn emit_raw<'a, W: Write>(
528    writer: &mut Writer<W>,
529    children: impl Iterator<Item = &'a [u8]>,
530) -> Result<()> {
531    for child in children {
532        writer.get_mut().write_all(child).map_err(OxmlError::from)?;
533    }
534    Ok(())
535}
536
537fn write_start<W: Write>(writer: &mut Writer<W>, start: BytesStart<'_>) -> Result<()> {
538    writer
539        .write_event(Event::Start(start))
540        .map_err(OxmlError::from)?;
541    Ok(())
542}
543
544fn write_empty<W: Write>(writer: &mut Writer<W>, start: BytesStart<'_>) -> Result<()> {
545    writer
546        .write_event(Event::Empty(start))
547        .map_err(OxmlError::from)?;
548    Ok(())
549}
550
551fn write_end<W: Write>(writer: &mut Writer<W>, name: &str) -> Result<()> {
552    writer
553        .write_event(Event::End(BytesEnd::new(name)))
554        .map_err(OxmlError::from)?;
555    Ok(())
556}
557
558#[cfg(test)]
559mod tests {
560    use oxml_core::units::{Angle, Percent1000};
561
562    use super::{CT_EffectList, EffectError, RectAlignment};
563    use crate::color::ColorChoice;
564
565    #[test]
566    fn a_shape_with_glow_round_trips_with_glow_intact_as_raw_xml() {
567        let xml = br#"<z:effectLst><a:glow rad="63500"><a:srgbClr val="FF0000"><a:alpha val="50000"/></a:srgbClr><!--kept--></a:glow></z:effectLst>"#;
568
569        let written = CT_EffectList::from_xml(xml).unwrap().to_xml().unwrap();
570
571        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>"#);
572    }
573
574    #[test]
575    fn outer_shadow_properties_and_colour_round_trip_structurally() {
576        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>"#;
577        let parsed = CT_EffectList::from_xml(xml).unwrap();
578        let shadow = parsed.outer_shadow.as_ref().unwrap();
579
580        assert_eq!(shadow.blur_radius, Some(50_800));
581        assert_eq!(shadow.distance, Some(38_100));
582        assert_eq!(shadow.direction, Some(Angle(2_700_000)));
583        assert_eq!(shadow.scale_x, Some(Percent1000(120_000)));
584        assert_eq!(shadow.scale_y, Some(Percent1000(80_000)));
585        assert_eq!(shadow.skew_x, Some(Angle(1_200_000)));
586        assert_eq!(shadow.skew_y, Some(Angle(-600_000)));
587        assert_eq!(shadow.alignment, Some(RectAlignment::BottomRight));
588        assert_eq!(shadow.rotate_with_shape, Some(true));
589        assert!(matches!(shadow.color, Some(ColorChoice::Scheme { .. })));
590
591        let written = parsed.to_xml().unwrap();
592        assert_eq!(CT_EffectList::from_xml(&written).unwrap(), parsed);
593    }
594
595    #[test]
596    fn effect_list_writes_schema_order_and_keeps_raw_effect_positions() {
597        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>"#;
598
599        let written = CT_EffectList::from_xml(xml).unwrap().to_xml().unwrap();
600
601        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>"#);
602    }
603
604    #[test]
605    fn malformed_outer_shadow_values_return_errors_without_panicking() {
606        let cases: &[&[u8]] = &[
607            br#"<a:effectLst><a:outerShdw blurRad="-1"><a:srgbClr val="000000"/></a:outerShdw></a:effectLst>"#,
608            br#"<a:effectLst><a:outerShdw dist="27273042316901"><a:srgbClr val="000000"/></a:outerShdw></a:effectLst>"#,
609            br#"<a:effectLst><a:outerShdw dir="21600000"><a:srgbClr val="000000"/></a:outerShdw></a:effectLst>"#,
610            br#"<a:effectLst><a:outerShdw kx="5400000"><a:srgbClr val="000000"/></a:outerShdw></a:effectLst>"#,
611            br#"<a:effectLst><a:outerShdw sx="wide"><a:srgbClr val="000000"/></a:outerShdw></a:effectLst>"#,
612            br#"<a:effectLst><a:outerShdw algn="middle"><a:srgbClr val="000000"/></a:outerShdw></a:effectLst>"#,
613            br#"<a:effectLst><a:outerShdw rotWithShape="maybe"><a:srgbClr val="000000"/></a:outerShdw></a:effectLst>"#,
614            br#"<a:effectLst><a:outerShdw><a:srgbClr val="XYZXYZ"/></a:outerShdw></a:effectLst>"#,
615            br#"<a:effectLst><a:outerShdw/></a:effectLst>"#,
616        ];
617        for xml in cases {
618            let result = std::panic::catch_unwind(|| CT_EffectList::from_xml(xml));
619            assert!(
620                result.is_ok(),
621                "effect parser panicked for {}",
622                String::from_utf8_lossy(xml)
623            );
624            assert!(
625                result.unwrap().is_err(),
626                "malformed outer shadow parsed successfully"
627            );
628        }
629        assert!(matches!(
630            CT_EffectList::from_xml(cases[8]),
631            Err(EffectError::MissingColor)
632        ));
633    }
634}