Skip to main content

rpptx_layout/
context.rs

1use std::cell::RefCell;
2use std::collections::BTreeMap;
3use std::collections::HashMap;
4
5use oxml_chart::{render_chart, render_chart_placeholder};
6use oxml_drawing::color::{ColorChoice, ColorMap, ColorMapSlot, RgbColor, resolve_color};
7use oxml_drawing::effect::CT_OuterShadowEffect;
8use oxml_drawing::fill::{
9    BlipFill, BlipMode, Fill, GradientGeometry, PathGradientKind, RelativeRect, SolidFill,
10};
11use oxml_drawing::geometry::EvaluatedPathCommand;
12use oxml_drawing::line::{
13    CT_LineProperties, LineCap as DrawingLineCap, LineDash, LineEnd, LineEndSize, LineEndType,
14    LineJoin as DrawingLineJoin,
15};
16use oxml_drawing::style_ref::{FontCollectionIndex, StyleReference};
17use oxml_drawing::table::{
18    CT_TableBackgroundStyle, CT_TableBorders, CT_TableCellStyle, CT_TablePartStyle, CT_TableStyle,
19    CT_TableStyleList, CT_TableTextStyle,
20};
21use oxml_drawing::text::{
22    CT_TextBody, CT_TextBodyProperties, CT_TextCharacterProperties, CT_TextListStyle,
23    Coordinate32Value, TextAlignment, TextAnchor as DrawingTextAnchor, TextAutofit,
24    TextBulletChoice, TextBulletSizeValue, TextFont, TextPointValue, TextRun, TextSpacing,
25    TextVertical as DrawingTextVertical, TextWrap,
26};
27use oxml_drawing::theme::CT_OfficeStyleSheet;
28use oxml_drawing::xfrm::CT_Transform2D;
29use oxml_layout::{
30    Color, Diagnostic, Effect, FillRule, FontManager, GradientStop, LineCap, LineJoin, Paint, Path,
31    PathCommand, Point, Rect, Stroke, Transform,
32};
33use rpptx_oxml::graphic_frame::GraphicDataPayload;
34use rpptx_oxml::picture::CT_Picture;
35use rpptx_oxml::placeholder::{CT_Placeholder, PhType, PlaceholderKey};
36use rpptx_oxml::shape_tree::{CT_Shape, ShapeTreeChild};
37use rpptx_oxml::slide_parts::{BackgroundRendering, CT_Slide, CT_SlideLayout, CT_SlideMaster};
38
39use crate::ResolveError;
40use crate::style::{referenced_fill, substitute_fill};
41use crate::text::EffectiveListStyle;
42use crate::timeline::{
43    EvaluatedFrameState, EvaluatedShapeState, ResolvedShapeIdentity, ResolvedTimelineSlide,
44    TimelinePosition, evaluate_timeline,
45};
46use crate::{
47    ChartResource, ParagraphAlignment, ResolvedAutofit, ResolvedBackground, ResolvedBullet,
48    ResolvedBulletSize, ResolvedContent, ResolvedGeometry, ResolvedImage, ResolvedImagePlacement,
49    ResolvedLineEnd, ResolvedLineEndKind, ResolvedLineEndSize, ResolvedParagraph,
50    ResolvedRectAlignment, ResolvedRunStyle, ResolvedShape, ResolvedSlide,
51    ResolvedSlideTextDirections, ResolvedTable, ResolvedTableBorder, ResolvedTableCell,
52    ResolvedTableRow, ResolvedTextBody, ResolvedTextRun, ResolvedTextSpacing, ResolvedTileFlip,
53    ResolvedTilePlacement, ScopedChartResources, ScopedHyperlinkTargets, ScopedMediaFailures,
54    ScopedMediaIds, TextAnchor, TextDirection, TextInsets,
55};
56
57/// The producer part that supplied the effective background.
58#[derive(Clone, Copy, Debug, Eq, PartialEq)]
59pub enum BackgroundSource {
60    Slide,
61    Layout,
62    Master,
63}
64
65/// The borrowed background payload selected for one slide.
66#[derive(Clone, Copy, Debug, PartialEq)]
67pub enum BackgroundContent<'a> {
68    Model(&'a BackgroundRendering),
69}
70
71/// The effective background and the per-master colour map used to resolve it.
72#[derive(Clone, Copy, Debug, PartialEq)]
73pub struct EffectiveBackground<'a> {
74    pub source: BackgroundSource,
75    pub content: BackgroundContent<'a>,
76    pub color_map: &'a ColorMap,
77}
78
79/// The four ordered sources in a flattened slide view.
80#[derive(Clone, Copy, Debug, Eq, PartialEq)]
81pub enum FlattenedSource {
82    Background,
83    Master,
84    Layout,
85    Slide,
86}
87
88/// One borrowed entry in final draw order.
89#[derive(Clone, Copy, Debug, PartialEq)]
90pub enum FlattenedItem<'a> {
91    Background(EffectiveBackground<'a>),
92    Shape {
93        source: FlattenedSource,
94        child: &'a ShapeTreeChild,
95        /// Coordinate scale accumulated from every non-shearing parent group.
96        group_scale: (f64, f64),
97        /// Rigid mapping accumulated from every non-shearing parent group.
98        group_transform: Transform,
99        /// Stable diagnostic flags accumulated from parent groups.
100        group_issues: u8,
101    },
102}
103
104const GROUP_ZERO_X: u8 = 1;
105const GROUP_ZERO_Y: u8 = 2;
106const GROUP_SHEAR: u8 = 4;
107
108#[derive(Clone, Copy)]
109struct ShapePlacement {
110    source: FlattenedSource,
111    group_scale: (f64, f64),
112    group_transform: Transform,
113}
114
115impl FlattenedItem<'_> {
116    pub const fn source(&self) -> FlattenedSource {
117        match self {
118            Self::Background(_) => FlattenedSource::Background,
119            Self::Shape { source, .. } => *source,
120        }
121    }
122}
123
124/// The fixed presentation hierarchy and theme inputs for resolving one slide.
125pub struct ResolveCtx<'a> {
126    pub theme: &'a CT_OfficeStyleSheet,
127    pub color_map: ColorMap,
128    pub master: &'a CT_SlideMaster,
129    pub layout: &'a CT_SlideLayout,
130    pub slide: &'a CT_Slide,
131    pub default_text_style: &'a CT_TextListStyle,
132    pub table_styles: Option<&'a CT_TableStyleList>,
133    pub(crate) list_style_cache: RefCell<HashMap<Option<PlaceholderKey>, EffectiveListStyle>>,
134    media_poster_diagnostic_identity: bool,
135    media_failures: Option<&'a ScopedMediaFailures>,
136}
137
138impl<'a> ResolveCtx<'a> {
139    pub fn new(
140        theme: &'a CT_OfficeStyleSheet,
141        color_map: ColorMap,
142        master: &'a CT_SlideMaster,
143        layout: &'a CT_SlideLayout,
144        slide: &'a CT_Slide,
145        default_text_style: &'a CT_TextListStyle,
146    ) -> Self {
147        Self {
148            theme,
149            color_map,
150            master,
151            layout,
152            slide,
153            default_text_style,
154            table_styles: None,
155            list_style_cache: RefCell::new(HashMap::new()),
156            media_poster_diagnostic_identity: false,
157            media_failures: None,
158        }
159    }
160
161    /// Adds the optional `ppt/tableStyles.xml` projection used by table frames.
162    pub fn with_table_styles(mut self, table_styles: &'a CT_TableStyleList) -> Self {
163        self.table_styles = Some(table_styles);
164        self
165    }
166
167    /// Enables private source identity used while applying media fallback policy.
168    #[doc(hidden)]
169    pub fn with_media_poster_diagnostic_identity(mut self) -> Self {
170        self.media_poster_diagnostic_identity = true;
171        self
172    }
173
174    /// Adds renderer-owned failures for media rejected before layout.
175    #[doc(hidden)]
176    pub fn with_media_failures(mut self, failures: &'a ScopedMediaFailures) -> Self {
177        self.media_failures = Some(failures);
178        self
179    }
180
181    pub(crate) fn placeholder_chain<'ctx>(
182        &'ctx self,
183        shape: &CT_Shape,
184    ) -> (Option<&'ctx CT_Shape>, Option<&'ctx CT_Shape>) {
185        self.placeholder_chain_for(shape.placeholder.as_ref())
186    }
187
188    fn placeholder_chain_for<'ctx>(
189        &'ctx self,
190        placeholder: Option<&CT_Placeholder>,
191    ) -> (Option<&'ctx CT_Shape>, Option<&'ctx CT_Shape>) {
192        placeholder.map_or((None, None), |placeholder| {
193            placeholder_chain(placeholder, self.layout, self.master)
194        })
195    }
196
197    fn is_slide_number_placeholder(&self, shape: &CT_Shape) -> bool {
198        if shape
199            .placeholder
200            .as_ref()
201            .is_some_and(|placeholder| placeholder.ph_type == Some(PhType::SlideNumber))
202        {
203            return true;
204        }
205        let (layout, master) = self.placeholder_chain(shape);
206        [layout, master].into_iter().flatten().any(|shape| {
207            shape
208                .placeholder
209                .as_ref()
210                .is_some_and(|placeholder| placeholder.ph_type == Some(PhType::SlideNumber))
211        })
212    }
213
214    /// Resolves an owned transform from the slide, layout, then master shape.
215    pub fn effective_xfrm(&self, shape: &CT_Shape) -> Option<CT_Transform2D> {
216        shape.shape_properties.transform.clone().or_else(|| {
217            inherited_xfrm(shape.placeholder.as_ref()?, self.layout, self.master).cloned()
218        })
219    }
220
221    /// Resolves picture bounds from the slide picture, layout placeholder, then master placeholder.
222    pub fn effective_picture_xfrm(
223        &self,
224        picture: &rpptx_oxml::picture::CT_Picture,
225    ) -> Option<CT_Transform2D> {
226        picture.shape_properties.transform.clone().or_else(|| {
227            inherited_xfrm(picture.placeholder.as_ref()?, self.layout, self.master).cloned()
228        })
229    }
230
231    /// Resolves body properties per field over defaults, master, layout, and slide.
232    pub fn effective_body_pr(&self, shape: &CT_Shape) -> CT_TextBodyProperties {
233        let (layout, master) = self.placeholder_chain(shape);
234        let mut effective = default_body_properties();
235        for source in [master, layout, Some(shape)].into_iter().flatten() {
236            if let Some(text_body) = &source.text_body {
237                merge_body_properties(&mut effective, &text_body.body_properties);
238            }
239        }
240        effective
241    }
242
243    /// Selects the first background in slide, layout, and master order.
244    pub fn effective_background(&self) -> Option<EffectiveBackground<'_>> {
245        for (source, background) in [
246            (
247                BackgroundSource::Slide,
248                self.slide.common_slide_data.background.as_ref(),
249            ),
250            (
251                BackgroundSource::Layout,
252                self.layout.common_slide_data.background.as_ref(),
253            ),
254            (
255                BackgroundSource::Master,
256                self.master.common_slide_data.background.as_ref(),
257            ),
258        ] {
259            if let Some(background) = background {
260                return Some(EffectiveBackground {
261                    source,
262                    content: BackgroundContent::Model(background.rendering()),
263                    color_map: &self.color_map,
264                });
265            }
266        }
267        None
268    }
269
270    /// Returns background and shape-tree leaves in final draw order.
271    pub fn flatten(&self) -> Vec<FlattenedItem<'_>> {
272        let slide_children = &self.slide.common_slide_data.shape_tree.children;
273        let layout_children = &self.layout.common_slide_data.shape_tree.children;
274        let master_children = &self.master.common_slide_data.shape_tree.children;
275        let mut flattened = Vec::new();
276        if let Some(background) = self.effective_background() {
277            flattened.push(FlattenedItem::Background(background));
278        }
279        emit_tree(
280            master_children,
281            PassRules {
282                source: FlattenedSource::Master,
283                emit_non_placeholders: self.layout.show_master_shapes.unwrap_or(true),
284            },
285            &mut flattened,
286        );
287        emit_tree(
288            layout_children,
289            PassRules {
290                source: FlattenedSource::Layout,
291                emit_non_placeholders: self.slide.show_master_shapes.unwrap_or(true),
292            },
293            &mut flattened,
294        );
295        emit_tree(
296            slide_children,
297            PassRules {
298                source: FlattenedSource::Slide,
299                emit_non_placeholders: true,
300            },
301            &mut flattened,
302        );
303        flattened
304    }
305
306    /// Enumerates physical leaves without visibility or placeholder filtering.
307    /// Child paths retain source ordinals, including nested groups. Opaque
308    /// alternate content stays an explicit leaf rather than a writable fallback.
309    pub fn owned_items(&self, source: FlattenedSource) -> Vec<(Vec<usize>, FlattenedItem<'_>)> {
310        let children = match source {
311            FlattenedSource::Slide => &self.slide.common_slide_data.shape_tree.children,
312            FlattenedSource::Layout => &self.layout.common_slide_data.shape_tree.children,
313            FlattenedSource::Master => &self.master.common_slide_data.shape_tree.children,
314            FlattenedSource::Background => return Vec::new(),
315        };
316        let mut output = Vec::new();
317        collect_owned_items(children, source, Transform::IDENTITY, 0, &[], &mut output);
318        output
319    }
320
321    /// Resolves one owned renderer-facing slide at the supplied point size.
322    pub fn resolve_slide(&self, size: (f64, f64)) -> Result<ResolvedSlide, ResolveError> {
323        self.resolve_slide_inner(size, None, None, None, None)
324            .map(|(slide, _)| slide)
325    }
326
327    /// Resolves one slide through the additive slide-local timeline path.
328    pub fn resolve_slide_at(
329        &self,
330        size: (f64, f64),
331        position: TimelinePosition,
332    ) -> Result<ResolvedTimelineSlide, ResolveError> {
333        let (slide, _, identities) =
334            self.resolve_slide_inner_with_identities(size, None, None, None, None)?;
335        let state = evaluate_timeline(
336            self.slide.timing.as_ref(),
337            self.slide.transition.as_ref(),
338            position,
339        )?;
340        Ok(apply_timeline_state(
341            slide,
342            identities,
343            state,
344            timeline_group_geometries(&self.slide.common_slide_data.shape_tree.children),
345        ))
346    }
347
348    /// Resolves one slide using embedded media identifiers scoped to source parts.
349    pub fn resolve_slide_with_media(
350        &self,
351        size: (f64, f64),
352        media: &ScopedMediaIds,
353    ) -> Result<ResolvedSlide, ResolveError> {
354        self.resolve_slide_inner(size, Some(media), None, None, None)
355            .map(|(slide, _)| slide)
356    }
357
358    /// Resolves one slide with source-scoped media and external hyperlink targets.
359    pub fn resolve_slide_with_resources(
360        &self,
361        size: (f64, f64),
362        media: &ScopedMediaIds,
363        hyperlinks: &ScopedHyperlinkTargets,
364    ) -> Result<ResolvedSlide, ResolveError> {
365        self.resolve_slide_inner(size, Some(media), Some(hyperlinks), None, None)
366            .map(|(slide, _)| slide)
367    }
368
369    /// Resolves one slide with source-scoped charts and the caller's font manager.
370    pub fn resolve_slide_with_chart_resources(
371        &self,
372        size: (f64, f64),
373        media: &ScopedMediaIds,
374        hyperlinks: &ScopedHyperlinkTargets,
375        charts: &ScopedChartResources,
376        fonts: &mut FontManager,
377    ) -> Result<ResolvedSlide, ResolveError> {
378        self.resolve_slide_inner(
379            size,
380            Some(media),
381            Some(hyperlinks),
382            Some(charts),
383            Some(fonts),
384        )
385        .map(|(slide, _)| slide)
386    }
387
388    /// Resolves one slide and returns paragraph directions in shape and text-body order.
389    ///
390    /// The outer vector follows `ResolvedSlide::shapes`. Each shape entry contains
391    /// one paragraph-direction vector for a text shape, or one entry per table cell
392    /// in row-major order. Non-text shapes have an empty entry.
393    pub fn resolve_slide_with_chart_resources_and_text_directions(
394        &self,
395        size: (f64, f64),
396        media: &ScopedMediaIds,
397        hyperlinks: &ScopedHyperlinkTargets,
398        charts: &ScopedChartResources,
399        fonts: &mut FontManager,
400    ) -> Result<(ResolvedSlide, ResolvedSlideTextDirections), ResolveError> {
401        self.resolve_slide_inner(
402            size,
403            Some(media),
404            Some(hyperlinks),
405            Some(charts),
406            Some(fonts),
407        )
408    }
409
410    /// Resolve a physically owned text shape even when draw flattening hides
411    /// its placeholder. Explicit autofit inventory and its read-only report
412    /// need the same producing scope and inheritance as visible rendering.
413    pub fn resolve_owned_text_shape(
414        &self,
415        item: FlattenedItem<'_>,
416        size: (f64, f64),
417        hyperlinks: &ScopedHyperlinkTargets,
418    ) -> Result<(ResolvedSlide, ResolvedSlideTextDirections), ResolveError> {
419        let FlattenedItem::Shape {
420            source,
421            child,
422            group_scale,
423            group_transform,
424            group_issues,
425        } = item
426        else {
427            return Err(ResolveError::ConcreteValue {
428                kind: "autofit owner",
429                detail: "not a physically owned shape".to_owned(),
430            });
431        };
432        let mut resolved = ResolvedSlide {
433            unresolved_member_count: 0,
434            size,
435            background: None,
436            shapes: Vec::new(),
437            diagnostics: Vec::new(),
438        };
439        push_group_diagnostics(group_issues, &mut resolved.diagnostics);
440        let mut directions = Vec::new();
441        let shape = self.resolve_flattened_shape(
442            ShapePlacement {
443                source,
444                group_scale,
445                group_transform,
446            },
447            child,
448            None,
449            (Some(hyperlinks), None),
450            None,
451            (&mut resolved.diagnostics, &mut directions),
452        )?;
453        if let Some(shape) = shape {
454            resolved.shapes.push(shape);
455        }
456        Ok((resolved, vec![directions]))
457    }
458
459    /// Resolves one timestamped slide while retaining text directions and target identity.
460    pub fn resolve_slide_with_chart_resources_and_text_directions_at(
461        &self,
462        size: (f64, f64),
463        media: &ScopedMediaIds,
464        hyperlinks: &ScopedHyperlinkTargets,
465        charts: &ScopedChartResources,
466        fonts: &mut FontManager,
467        position: TimelinePosition,
468    ) -> Result<(ResolvedTimelineSlide, ResolvedSlideTextDirections), ResolveError> {
469        let (slide, directions, identities) = self.resolve_slide_inner_with_identities(
470            size,
471            Some(media),
472            Some(hyperlinks),
473            Some(charts),
474            Some(fonts),
475        )?;
476        let state = evaluate_timeline(
477            self.slide.timing.as_ref(),
478            self.slide.transition.as_ref(),
479            position,
480        )?;
481        Ok((
482            apply_timeline_state(
483                slide,
484                identities,
485                state,
486                timeline_group_geometries(&self.slide.common_slide_data.shape_tree.children),
487            ),
488            directions,
489        ))
490    }
491
492    fn resolve_slide_inner(
493        &self,
494        size: (f64, f64),
495        media: Option<&ScopedMediaIds>,
496        hyperlinks: Option<&ScopedHyperlinkTargets>,
497        charts: Option<&ScopedChartResources>,
498        fonts: Option<&mut FontManager>,
499    ) -> Result<(ResolvedSlide, ResolvedSlideTextDirections), ResolveError> {
500        self.resolve_slide_inner_common(size, media, hyperlinks, charts, fonts, None)
501    }
502
503    fn resolve_slide_inner_with_identities(
504        &self,
505        size: (f64, f64),
506        media: Option<&ScopedMediaIds>,
507        hyperlinks: Option<&ScopedHyperlinkTargets>,
508        charts: Option<&ScopedChartResources>,
509        fonts: Option<&mut FontManager>,
510    ) -> Result<
511        (
512            ResolvedSlide,
513            ResolvedSlideTextDirections,
514            Vec<ResolvedShapeIdentity>,
515        ),
516        ResolveError,
517    > {
518        let mut identities = Vec::new();
519        let (slide, directions) = self.resolve_slide_inner_common(
520            size,
521            media,
522            hyperlinks,
523            charts,
524            fonts,
525            Some(&mut identities),
526        )?;
527        Ok((slide, directions, identities))
528    }
529
530    fn resolve_slide_inner_common(
531        &self,
532        size: (f64, f64),
533        media: Option<&ScopedMediaIds>,
534        hyperlinks: Option<&ScopedHyperlinkTargets>,
535        charts: Option<&ScopedChartResources>,
536        mut fonts: Option<&mut FontManager>,
537        mut identities: Option<&mut Vec<ResolvedShapeIdentity>>,
538    ) -> Result<(ResolvedSlide, ResolvedSlideTextDirections), ResolveError> {
539        for (kind, tree) in [
540            ("slide shape tree", &self.slide.common_slide_data.shape_tree),
541            (
542                "layout shape tree",
543                &self.layout.common_slide_data.shape_tree,
544            ),
545            (
546                "master shape tree",
547                &self.master.common_slide_data.shape_tree,
548            ),
549        ] {
550            rpptx_oxml::shape_tree::validate_shape_nesting(&tree.children).map_err(|error| {
551                ResolveError::ConcreteValue {
552                    kind,
553                    detail: error.to_string(),
554                }
555            })?;
556        }
557        let mut slide = ResolvedSlide {
558            unresolved_member_count: 0,
559            size,
560            background: None,
561            shapes: Vec::new(),
562            diagnostics: Vec::new(),
563        };
564        let mut text_directions = Vec::new();
565        for item in self.flatten() {
566            match item {
567                FlattenedItem::Background(background) => {
568                    let source = match background.source {
569                        BackgroundSource::Slide => FlattenedSource::Slide,
570                        BackgroundSource::Layout => FlattenedSource::Layout,
571                        BackgroundSource::Master => FlattenedSource::Master,
572                    };
573                    let (resolved_background, unsupported) = match background.content {
574                        BackgroundContent::Model(BackgroundRendering::Properties(fill)) => {
575                            match fill {
576                                Some(fill) => self.concrete_background_fill(
577                                    fill,
578                                    size,
579                                    source,
580                                    media,
581                                    &mut slide.diagnostics,
582                                )?,
583                                None => (None, None),
584                            }
585                        }
586                        BackgroundContent::Model(BackgroundRendering::Reference {
587                            index,
588                            color,
589                        }) => self.concrete_background_reference(
590                            *index,
591                            color.as_ref(),
592                            size,
593                            &mut slide.diagnostics,
594                        )?,
595                        BackgroundContent::Model(BackgroundRendering::Unsupported(detail)) => {
596                            (None, Some(*detail))
597                        }
598                    };
599                    slide.background = resolved_background;
600                    if let Some(unsupported) = unsupported {
601                        slide.diagnostics.push(Diagnostic {
602                            message: format!("unsupported background {unsupported}"),
603                        });
604                    }
605                }
606                FlattenedItem::Shape {
607                    source,
608                    child,
609                    group_scale,
610                    group_transform,
611                    group_issues,
612                } => {
613                    push_group_diagnostics(group_issues, &mut slide.diagnostics);
614                    let mut shape_text_directions = Vec::new();
615                    let mut member_diagnostics = Vec::new();
616                    let result = self
617                        .resolve_flattened_shape(
618                            ShapePlacement {
619                                source,
620                                group_scale,
621                                group_transform,
622                            },
623                            child,
624                            media,
625                            (hyperlinks, charts),
626                            fonts.as_deref_mut(),
627                            (&mut member_diagnostics, &mut shape_text_directions),
628                        )
629                        .and_then(|resolved| {
630                            if resolved.is_none() {
631                                self.validate_unplaced_member(child, source, hyperlinks)?;
632                            }
633                            Ok(resolved)
634                        });
635                    let resolved = match result {
636                        Ok(resolved) => {
637                            slide.diagnostics.extend(member_diagnostics);
638                            resolved
639                        }
640                        Err(
641                            error @ (ResolveError::StyleIndexOutOfRange { .. }
642                            | ResolveError::UnresolvedPlaceholderColor { .. }
643                            | ResolveError::ConcreteValue { .. }),
644                        ) => {
645                            // Count the failed owner before deciding whether it can draw.
646                            slide.unresolved_member_count += 1;
647                            shape_text_directions.clear();
648                            self.unresolved_shape_fallback(
649                                ShapePlacement {
650                                    source,
651                                    group_scale,
652                                    group_transform,
653                                },
654                                child,
655                                &error,
656                                &mut slide.diagnostics,
657                            )
658                        }
659                    };
660                    if let Some(shape) = resolved {
661                        if let Some(identities) = identities.as_deref_mut() {
662                            identities.push(self.shape_identity(source, child));
663                        }
664                        slide.shapes.push(shape);
665                        text_directions.push(shape_text_directions);
666                    }
667                }
668            }
669        }
670        Ok((slide, text_directions))
671    }
672
673    // Missing placement must not hide an error in the owned semantic inputs.
674    // Validate only dimension-independent values already consumed by this resolver.
675    // No replacement geometry or guessed dimensions are manufactured.
676    fn validate_unplaced_member(
677        &self,
678        child: &ShapeTreeChild,
679        source: FlattenedSource,
680        hyperlinks: Option<&ScopedHyperlinkTargets>,
681    ) -> Result<(), ResolveError> {
682        let (fill, line, effects) = match child {
683            ShapeTreeChild::Shape(shape) => {
684                let (mut effective, _) = self.shape_style_for_placement(shape, true)?;
685                effective.effects =
686                    self.effective_effects(shape.style(), &shape.shape_properties)?;
687                if let Some(body) = shape.text_body.as_ref() {
688                    self.resolve_text_body(shape, body, source, hyperlinks, &mut Vec::new())?;
689                }
690                (effective.fill, effective.line, effective.effects)
691            }
692            ShapeTreeChild::Picture(picture) => (
693                None,
694                picture.shape_properties.line.clone(),
695                picture.shape_properties.effects.clone(),
696            ),
697            ShapeTreeChild::Connector(connector) => (
698                connector.shape_properties.fill.clone(),
699                self.effective_connector_line(connector, true)?,
700                self.effective_effects(connector.style(), &connector.shape_properties)?,
701            ),
702            ShapeTreeChild::GraphicFrame(frame) => {
703                if let GraphicDataPayload::Table(table) = frame.graphic_data.payload() {
704                    self.resolve_table(
705                        table,
706                        source,
707                        hyperlinks,
708                        &mut Vec::new(),
709                        &mut Vec::new(),
710                    )?;
711                }
712                return Ok(());
713            }
714            ShapeTreeChild::AlternateContent(_) | ShapeTreeChild::GroupShape(_) => return Ok(()),
715        };
716        for fill in [
717            fill.as_ref(),
718            line.as_ref().and_then(|line| line.fill.as_ref()),
719        ]
720        .into_iter()
721        .flatten()
722        {
723            match fill {
724                Fill::Solid(solid) => {
725                    if let Some(color) = solid.color.as_ref() {
726                        self.concrete_color(color)?;
727                    }
728                }
729                Fill::Gradient(gradient)
730                    if gradient.rotate_with_shape != Some(false)
731                        && matches!(gradient.flip.as_deref(), None | Some("none"))
732                        && gradient.tile_rect.is_none()
733                        && !matches!(
734                            gradient.geometry.as_ref(),
735                            Some(GradientGeometry::Path(_))
736                        ) =>
737                {
738                    // These stop colors precede dimension-dependent paint construction.
739                    // Keep concrete_fill's early unsupported boundary for a missing color.
740                    for stop in &gradient.stops {
741                        let Some(color) = stop.color.as_ref() else {
742                            break;
743                        };
744                        self.concrete_color(color)?;
745                    }
746                }
747                _ => {}
748            }
749        }
750        if let Some(shadow) = effects
751            .as_ref()
752            .and_then(|effects| effects.outer_shadow.as_ref())
753        {
754            self.concrete_shadow(shadow)?;
755        }
756        Ok(())
757    }
758
759    fn unresolved_shape_fallback(
760        &self,
761        placement: ShapePlacement,
762        child: &ShapeTreeChild,
763        error: &ResolveError,
764        diagnostics: &mut Vec<Diagnostic>,
765    ) -> Option<ResolvedShape> {
766        let id = child
767            .non_visual_id()
768            .map_or_else(|| "without an id".to_owned(), |id| id.to_string());
769        let name = child.non_visual_name().unwrap_or_default();
770        let source = flattened_source_name(placement.source);
771        let values = match child {
772            ShapeTreeChild::Shape(shape) => transform_values(self.effective_xfrm(shape).as_ref()),
773            ShapeTreeChild::Picture(picture) => {
774                transform_values(self.effective_picture_xfrm(picture).as_ref())
775            }
776            ShapeTreeChild::GraphicFrame(frame) => transform_values(Some(&frame.transform)),
777            ShapeTreeChild::Connector(connector) => {
778                connector_transform_values(connector.shape_properties.transform.as_ref())
779            }
780            ShapeTreeChild::AlternateContent(alternate) => alternate
781                .chart_choice()
782                .and_then(|frame| transform_values(Some(&frame.transform))),
783            ShapeTreeChild::GroupShape(_) => None,
784        };
785        let values = values.filter(|(bounds, rotation, flip_h, flip_v)| {
786            let bounds = scaled_group_bounds(*bounds, placement.group_scale);
787            let matrix = resolved_shape_page_transform(&ResolvedShape {
788                group_transform: placement.group_transform,
789                bounds,
790                rotation_deg: *rotation,
791                flip_h: *flip_h,
792                flip_v: *flip_v,
793                geometry: ResolvedGeometry::BoundsFallback,
794                fill: None,
795                image_fill: None,
796                line: None,
797                head_end: None,
798                tail_end: None,
799                shadow: None,
800                content: ResolvedContent::None,
801                unsupported: Some("unresolved shape"),
802            });
803            [
804                bounds.x,
805                bounds.y,
806                bounds.width,
807                bounds.height,
808                *rotation,
809                matrix.a,
810                matrix.b,
811                matrix.c,
812                matrix.d,
813                matrix.e,
814                matrix.f,
815            ]
816            .into_iter()
817            .all(f64::is_finite)
818                && bounds.width >= 0.0
819                && bounds.height >= 0.0
820                && (bounds.width > 0.0 || bounds.height > 0.0)
821                && [
822                    Point { x: 0.0, y: 0.0 },
823                    Point {
824                        x: bounds.width,
825                        y: 0.0,
826                    },
827                    Point {
828                        x: 0.0,
829                        y: bounds.height,
830                    },
831                    Point {
832                        x: bounds.width,
833                        y: bounds.height,
834                    },
835                ]
836                .into_iter()
837                .all(|point| {
838                    let point = matrix.apply(point);
839                    point.x.is_finite() && point.y.is_finite()
840                })
841        });
842        let retained = if values.is_some() {
843            "retained as bounds"
844        } else {
845            "omitted without trustworthy finite bounds"
846        };
847        diagnostics.push(Diagnostic {
848            message: format!(
849                "unresolved member {source} shape {id} \"{name}\" {retained}: {error}"
850            ),
851        });
852        let (bounds, rotation_deg, flip_h, flip_v) = values?;
853        Some(ResolvedShape {
854            group_transform: placement.group_transform,
855            bounds: scaled_group_bounds(bounds, placement.group_scale),
856            rotation_deg,
857            flip_h,
858            flip_v,
859            geometry: ResolvedGeometry::BoundsFallback,
860            fill: None,
861            image_fill: None,
862            line: None,
863            head_end: None,
864            tail_end: None,
865            shadow: None,
866            content: ResolvedContent::None,
867            unsupported: Some("unresolved shape"),
868        })
869    }
870
871    fn shape_identity(
872        &self,
873        source: FlattenedSource,
874        child: &ShapeTreeChild,
875    ) -> ResolvedShapeIdentity {
876        let children: &[ShapeTreeChild] = match source {
877            FlattenedSource::Slide => &self.slide.common_slide_data.shape_tree.children,
878            FlattenedSource::Layout => &self.layout.common_slide_data.shape_tree.children,
879            FlattenedSource::Master => &self.master.common_slide_data.shape_tree.children,
880            FlattenedSource::Background => &[],
881        };
882        let mut containing_group_ids = Vec::new();
883        find_group_lineage(children, child, &[], &mut containing_group_ids);
884        ResolvedShapeIdentity {
885            source,
886            shape_id: child.non_visual_id(),
887            containing_group_ids,
888            name: child.non_visual_name(),
889        }
890    }
891
892    fn resolve_flattened_shape(
893        &self,
894        placement: ShapePlacement,
895        child: &ShapeTreeChild,
896        media: Option<&ScopedMediaIds>,
897        scoped_resources: (
898            Option<&ScopedHyperlinkTargets>,
899            Option<&ScopedChartResources>,
900        ),
901        fonts: Option<&mut FontManager>,
902        output: (
903            &mut Vec<Diagnostic>,
904            &mut Vec<Vec<oxml_layout::TextDirection>>,
905        ),
906    ) -> Result<Option<ResolvedShape>, ResolveError> {
907        let (diagnostics, text_directions) = output;
908        let (hyperlinks, charts) = scoped_resources;
909        let ShapePlacement {
910            source,
911            group_scale,
912            group_transform,
913        } = placement;
914        match child {
915            ShapeTreeChild::Shape(shape) => self.resolve_ordinary_shape(
916                shape,
917                placement,
918                media,
919                hyperlinks,
920                diagnostics,
921                text_directions,
922            ),
923            ShapeTreeChild::Picture(picture) => {
924                if picture.hidden() {
925                    return Ok(None);
926                }
927                let Some((bounds, rotation_deg, flip_h, flip_v)) =
928                    transform_values(self.effective_picture_xfrm(picture).as_ref())
929                else {
930                    return Ok(None);
931                };
932                let bounds = scaled_group_bounds(bounds, group_scale);
933                let line_properties = picture.shape_properties.line.as_ref();
934                let line = line_properties
935                    .map(|line| self.concrete_line(line, (bounds.width, bounds.height)))
936                    .transpose()?
937                    .flatten();
938                let (head_end, tail_end) = line_properties
939                    .map(resolved_line_ends)
940                    .unwrap_or((None, None));
941                let shadow = picture
942                    .shape_properties
943                    .effects
944                    .as_ref()
945                    .and_then(|effects| effects.outer_shadow.as_ref())
946                    .map(|shadow| self.concrete_shadow(shadow))
947                    .transpose()?;
948                let (mut geometry, geometry_unsupported) =
949                    self.concrete_picture_geometry(picture, (bounds.width, bounds.height));
950                if let Some(category) = geometry_unsupported {
951                    diagnostics.push(Diagnostic {
952                        message: format!("unsupported {category} retained as picture bounds"),
953                    });
954                }
955                let diagnostic_start = diagnostics.len();
956                let (content, media_unsupported) = resolve_picture_content(
957                    picture,
958                    source,
959                    media,
960                    self.media_failures,
961                    diagnostics,
962                );
963                if media_unsupported.is_some() {
964                    geometry = ResolvedGeometry::BoundsFallback;
965                }
966                if self.media_poster_diagnostic_identity
967                    && media_unsupported.is_some()
968                    && picture.media.is_some()
969                    && let Some(shape_id) = child.non_visual_id()
970                    && let Some(diagnostic) = diagnostics.get_mut(diagnostic_start)
971                {
972                    diagnostic.message = format!(
973                        "unresolved {} media poster for shape {shape_id}: {}",
974                        flattened_source_name(source),
975                        diagnostic.message
976                    );
977                }
978                Ok(Some(ResolvedShape {
979                    group_transform,
980                    bounds,
981                    rotation_deg,
982                    flip_h,
983                    flip_v,
984                    geometry,
985                    fill: None,
986                    image_fill: None,
987                    line,
988                    head_end,
989                    tail_end,
990                    shadow,
991                    content,
992                    unsupported: media_unsupported.or(geometry_unsupported),
993                }))
994            }
995            ShapeTreeChild::GraphicFrame(frame) => {
996                let Some((bounds, rotation_deg, flip_h, flip_v)) =
997                    transform_values(Some(&frame.transform))
998                else {
999                    return Ok(None);
1000                };
1001                let bounds = scaled_group_bounds(bounds, group_scale);
1002                let (content, unsupported, bounds_fallback) = match frame.graphic_data.payload() {
1003                    GraphicDataPayload::Table(table) => (
1004                        ResolvedContent::Table(self.resolve_table(
1005                            table,
1006                            source,
1007                            hyperlinks,
1008                            diagnostics,
1009                            text_directions,
1010                        )?),
1011                        None,
1012                        false,
1013                    ),
1014                    GraphicDataPayload::Chart(_) => self.resolve_chart_content(
1015                        frame,
1016                        None,
1017                        source,
1018                        bounds,
1019                        media,
1020                        charts,
1021                        fonts,
1022                        diagnostics,
1023                    )?,
1024                    GraphicDataPayload::SmartArt(_) => {
1025                        (ResolvedContent::None, Some("SmartArt"), true)
1026                    }
1027                    GraphicDataPayload::Ole { preview, .. } => {
1028                        if let Some(image) = resolve_ole_preview(
1029                            preview.as_deref(),
1030                            source,
1031                            media,
1032                            self.media_failures,
1033                        ) {
1034                            diagnostics.push(Diagnostic {
1035                                message: "OLE object rendered as a static PNG preview. Embedded OLE interactivity is not rendered".to_owned(),
1036                            });
1037                            (
1038                                ResolvedContent::Image(image),
1039                                Some("embedded OLE interactivity"),
1040                                false,
1041                            )
1042                        } else {
1043                            (ResolvedContent::None, Some("OLE"), true)
1044                        }
1045                    }
1046                    GraphicDataPayload::Other(_) => {
1047                        (ResolvedContent::None, Some("unknown graphic frame"), true)
1048                    }
1049                };
1050                if bounds_fallback {
1051                    let category = unsupported.expect("bounds fallback has a category");
1052                    diagnostics.push(Diagnostic {
1053                        message: format!("unsupported {category} content retained as bounds"),
1054                    });
1055                }
1056                Ok(Some(ResolvedShape {
1057                    group_transform,
1058                    bounds,
1059                    rotation_deg,
1060                    flip_h,
1061                    flip_v,
1062                    geometry: if bounds_fallback {
1063                        ResolvedGeometry::BoundsFallback
1064                    } else {
1065                        ResolvedGeometry::Rectangle
1066                    },
1067                    fill: None,
1068                    image_fill: None,
1069                    line: None,
1070                    head_end: None,
1071                    tail_end: None,
1072                    shadow: None,
1073                    content,
1074                    unsupported,
1075                }))
1076            }
1077            ShapeTreeChild::Connector(connector) => {
1078                let Some((bounds, rotation_deg, flip_h, flip_v)) =
1079                    connector_transform_values(connector.shape_properties.transform.as_ref())
1080                else {
1081                    return Ok(None);
1082                };
1083                let bounds = scaled_group_bounds(bounds, group_scale);
1084                let (fill, fill_unsupported) = connector
1085                    .shape_properties
1086                    .fill
1087                    .as_ref()
1088                    .map(|fill| self.concrete_fill(fill, (bounds.width, bounds.height)))
1089                    .transpose()?
1090                    .unwrap_or((None, None));
1091                let has_line_source =
1092                    connector.shape_properties.line.is_some() || connector.style().is_some();
1093                let effective_line = self.effective_connector_line(connector, false)?;
1094                let mut line = effective_line
1095                    .as_ref()
1096                    .map(|line| self.concrete_line(line, (bounds.width, bounds.height)))
1097                    .transpose()?
1098                    .flatten();
1099                let (head_end, tail_end) = effective_line
1100                    .as_ref()
1101                    .map(resolved_line_ends)
1102                    .unwrap_or((None, None));
1103                let shadow = self
1104                    .effective_effects(connector.style(), &connector.shape_properties)
1105                    .and_then(|effects| {
1106                        effects
1107                            .as_ref()
1108                            .and_then(|effects| effects.outer_shadow.as_ref())
1109                            .map(|shadow| self.concrete_shadow(shadow))
1110                            .transpose()
1111                    })?;
1112                let (geometry, geometry_unsupported, geometry_diagnostic) = if let Some(custom) =
1113                    &connector.shape_properties.custom_geometry
1114                {
1115                    match self.concrete_custom_geometry(custom, (bounds.width, bounds.height)) {
1116                        Ok(geometry) => (geometry, None, None),
1117                        Err(error) => (
1118                            ResolvedGeometry::BoundsFallback,
1119                            Some("connector custom geometry evaluation"),
1120                            Some(format!(
1121                                "connector custom geometry evaluation failed: {error}; retained as shape bounds"
1122                            )),
1123                        ),
1124                    }
1125                } else if let Some(preset) = &connector.shape_properties.preset_geometry {
1126                    {
1127                        match self.concrete_preset_geometry(preset, (bounds.width, bounds.height)) {
1128                            Ok(Some(geometry)) => (geometry, None, None),
1129                            Ok(None) => (
1130                                ResolvedGeometry::BoundsFallback,
1131                                Some("unknown connector preset geometry"),
1132                                Some(format!(
1133                                    "unknown connector preset geometry `{}` retained as shape bounds",
1134                                    preset.preset
1135                                )),
1136                            ),
1137                            Err(error) => (
1138                                ResolvedGeometry::BoundsFallback,
1139                                Some("connector preset geometry evaluation"),
1140                                Some(format!(
1141                                    "connector preset geometry `{}` evaluation failed: {error}; retained as shape bounds",
1142                                    preset.preset
1143                                )),
1144                            ),
1145                        }
1146                    }
1147                } else {
1148                    (
1149                        ResolvedGeometry::BoundsFallback,
1150                        Some("connector geometry"),
1151                        Some("unsupported connector geometry retained as shape bounds".to_owned()),
1152                    )
1153                };
1154                if let Some(category) = fill_unsupported {
1155                    diagnostics.push(Diagnostic {
1156                        message: format!(
1157                            "unsupported connector {category} retained as shape bounds"
1158                        ),
1159                    });
1160                }
1161                if let Some(message) = geometry_diagnostic {
1162                    diagnostics.push(Diagnostic { message });
1163                }
1164                let line_unsupported = (!has_line_source).then_some("connector line style");
1165                if line_unsupported.is_some() {
1166                    line = Some(Stroke::new(Paint::Solid(Color::BLACK), 1.0));
1167                    diagnostics.push(Diagnostic {
1168                        message: "unsupported connector line style retained as visible default"
1169                            .to_owned(),
1170                    });
1171                }
1172                let unsupported = fill_unsupported
1173                    .or(geometry_unsupported)
1174                    .or(line_unsupported);
1175                Ok(Some(ResolvedShape {
1176                    group_transform,
1177                    bounds,
1178                    rotation_deg,
1179                    flip_h,
1180                    flip_v,
1181                    geometry,
1182                    fill,
1183                    image_fill: None,
1184                    line,
1185                    head_end,
1186                    tail_end,
1187                    shadow,
1188                    content: ResolvedContent::None,
1189                    unsupported,
1190                }))
1191            }
1192            ShapeTreeChild::AlternateContent(alternate) => {
1193                let Some(frame) = alternate.chart_choice() else {
1194                    return Ok(None);
1195                };
1196                let Some((bounds, rotation_deg, flip_h, flip_v)) =
1197                    transform_values(Some(&frame.transform))
1198                else {
1199                    return Ok(None);
1200                };
1201                let bounds = scaled_group_bounds(bounds, group_scale);
1202                let (content, unsupported, bounds_fallback) = if charts.is_some() && fonts.is_some()
1203                {
1204                    self.resolve_chart_content(
1205                        frame,
1206                        alternate.picture_fallback(),
1207                        source,
1208                        bounds,
1209                        media,
1210                        charts,
1211                        fonts,
1212                        diagnostics,
1213                    )?
1214                } else if let Some(picture) = alternate.picture_fallback() {
1215                    let (content, _) = resolve_picture_content(
1216                        picture,
1217                        source,
1218                        media,
1219                        self.media_failures,
1220                        diagnostics,
1221                    );
1222                    let has_image = matches!(content, ResolvedContent::Image(_));
1223                    (content, Some("chart"), !has_image)
1224                } else {
1225                    (ResolvedContent::None, Some("chart"), true)
1226                };
1227                if bounds_fallback {
1228                    diagnostics.push(Diagnostic {
1229                        message: "unsupported chart content retained as bounds".to_owned(),
1230                    });
1231                }
1232                Ok(Some(ResolvedShape {
1233                    group_transform,
1234                    bounds,
1235                    rotation_deg,
1236                    flip_h,
1237                    flip_v,
1238                    geometry: if bounds_fallback {
1239                        ResolvedGeometry::BoundsFallback
1240                    } else {
1241                        ResolvedGeometry::Rectangle
1242                    },
1243                    fill: None,
1244                    image_fill: None,
1245                    line: None,
1246                    head_end: None,
1247                    tail_end: None,
1248                    shadow: None,
1249                    content,
1250                    unsupported,
1251                }))
1252            }
1253            ShapeTreeChild::GroupShape(_) => Ok(None),
1254        }
1255    }
1256
1257    #[allow(clippy::too_many_arguments)]
1258    fn resolve_chart_content(
1259        &self,
1260        frame: &rpptx_oxml::graphic_frame::CT_GraphicFrame,
1261        fallback: Option<&CT_Picture>,
1262        source: FlattenedSource,
1263        bounds: Rect,
1264        media: Option<&ScopedMediaIds>,
1265        charts: Option<&ScopedChartResources>,
1266        fonts: Option<&mut FontManager>,
1267        diagnostics: &mut Vec<Diagnostic>,
1268    ) -> Result<(ResolvedContent, Option<&'static str>, bool), ResolveError> {
1269        let Some(charts) = charts else {
1270            return Ok((ResolvedContent::None, Some("chart"), true));
1271        };
1272        let Some(fonts) = fonts else {
1273            return Ok((ResolvedContent::None, Some("chart"), true));
1274        };
1275        let relationship_id = frame.chart_relationship_id();
1276        let resource = relationship_id.and_then(|id| charts.get(source, id));
1277        let failure = match (relationship_id, resource) {
1278            (None, _) => "chart payload has no relationship identifier".to_owned(),
1279            (Some(id), None) => format!(
1280                "missing {} chart relationship `{id}`",
1281                flattened_source_name(source)
1282            ),
1283            (Some(_), Some(ChartResource::External(target))) => format!(
1284                "external {} chart relationship `{}` targets `{target}`",
1285                flattened_source_name(source),
1286                relationship_id.expect("matched a relationship id")
1287            ),
1288            (Some(_), Some(ChartResource::MissingTarget(target))) => format!(
1289                "missing {} chart target `{target}` for relationship `{}`",
1290                flattened_source_name(source),
1291                relationship_id.expect("matched a relationship id")
1292            ),
1293            (Some(_), Some(ChartResource::Invalid(detail))) => format!(
1294                "invalid {} chart relationship `{}`: {detail}",
1295                flattened_source_name(source),
1296                relationship_id.expect("matched a relationship id")
1297            ),
1298            (Some(_), Some(ChartResource::Parsed(chart_space))) => {
1299                let local_bounds = Rect {
1300                    x: 0.0,
1301                    y: 0.0,
1302                    width: bounds.width,
1303                    height: bounds.height,
1304                };
1305                match render_chart(
1306                    &chart_space.chart,
1307                    local_bounds,
1308                    self.theme,
1309                    &self.color_map,
1310                    fonts,
1311                ) {
1312                    Ok(group) => {
1313                        return Ok((ResolvedContent::Group(group), None, false));
1314                    }
1315                    Err(error) => error.to_string(),
1316                }
1317            }
1318        };
1319
1320        if let Some(picture) = fallback {
1321            let (content, _) =
1322                resolve_picture_content(picture, source, media, self.media_failures, diagnostics);
1323            let relationship_id = picture
1324                .blip_fill
1325                .as_ref()
1326                .and_then(|fill| fill.blip.as_ref())
1327                .and_then(|blip| blip.embed.as_deref());
1328            let renderer_compatible = relationship_id
1329                .and_then(|id| media.and_then(|media| media.content_type(source, id)))
1330                .is_some_and(renderer_compatible_image_content_type);
1331            if matches!(content, ResolvedContent::Image(_)) && renderer_compatible {
1332                diagnostics.push(Diagnostic {
1333                    message: format!("unsupported chart rendered as cached image: {failure}"),
1334                });
1335                return Ok((content, Some("chart"), false));
1336            }
1337            if matches!(content, ResolvedContent::Image(_)) {
1338                diagnostics.push(Diagnostic {
1339                    message: format!(
1340                        "unsupported chart cached image is not renderer-compatible: {failure}"
1341                    ),
1342                });
1343            }
1344        }
1345
1346        let placeholder = render_chart_placeholder(
1347            Rect {
1348                x: 0.0,
1349                y: 0.0,
1350                width: bounds.width,
1351                height: bounds.height,
1352            },
1353            fonts,
1354        )
1355        .map_err(|error| ResolveError::ConcreteValue {
1356            kind: "chart placeholder",
1357            detail: error.to_string(),
1358        })?;
1359        diagnostics.push(Diagnostic {
1360            message: format!("unsupported chart rendered as labelled placeholder: {failure}"),
1361        });
1362        Ok((ResolvedContent::Group(placeholder), Some("chart"), true))
1363    }
1364
1365    fn resolve_ordinary_shape(
1366        &self,
1367        shape: &CT_Shape,
1368        placement: ShapePlacement,
1369        media: Option<&ScopedMediaIds>,
1370        hyperlinks: Option<&ScopedHyperlinkTargets>,
1371        diagnostics: &mut Vec<Diagnostic>,
1372        text_directions: &mut Vec<Vec<oxml_layout::TextDirection>>,
1373    ) -> Result<Option<ResolvedShape>, ResolveError> {
1374        let ShapePlacement {
1375            source,
1376            group_scale,
1377            group_transform,
1378        } = placement;
1379        let transform = self.effective_xfrm(shape);
1380        let Some((unscaled_bounds, rotation_deg, flip_h, flip_v)) =
1381            transform_values(transform.as_ref())
1382        else {
1383            return Ok(None);
1384        };
1385        let mut bounds = scaled_group_bounds(unscaled_bounds, group_scale);
1386        let mut group_transform = group_transform;
1387        let effective = self.effective_shape_style(shape)?;
1388        let (fill, image_fill, fill_unsupported, fill_diagnostic) = match effective.fill.as_ref() {
1389            Some(Fill::Blip(fill))
1390                if matches!(shape.shape_properties.fill, Some(Fill::Blip(_))) =>
1391            {
1392                match resolve_image_fill(fill, source, media, self.media_failures, "shape") {
1393                    Ok(image) => (None, Some(image), None, None),
1394                    Err((category, message)) => (None, None, Some(category), Some(message)),
1395                }
1396            }
1397            Some(Fill::Blip(_)) => (
1398                None,
1399                None,
1400                Some("theme-referenced shape picture fill"),
1401                Some(
1402                    "theme-referenced shape picture fill requires theme relationship scope"
1403                        .to_owned(),
1404                ),
1405            ),
1406            Some(fill) => {
1407                let normalized = match fill {
1408                    Fill::Gradient(gradient)
1409                        if gradient.rotate_with_shape == Some(false)
1410                            && rotation_deg.rem_euclid(360.0) == 0.0
1411                            && !flip_h
1412                            && !flip_v
1413                            && group_transform.a > 0.0
1414                            && group_transform.b.abs() < 1.0e-10
1415                            && group_transform.c.abs() < 1.0e-10
1416                            && group_transform.d > 0.0 =>
1417                    {
1418                        let mut normalized = fill.clone();
1419                        let Fill::Gradient(gradient) = &mut normalized else {
1420                            unreachable!("matched a gradient fill")
1421                        };
1422                        gradient.rotate_with_shape = None;
1423                        Some(normalized)
1424                    }
1425                    _ => None,
1426                };
1427                let selected = normalized.as_ref().unwrap_or(fill);
1428                let shape_path = matches!(selected, Fill::Gradient(g) if matches!(&g.geometry, Some(GradientGeometry::Path(p)) if p.kind == PathGradientKind::Shape));
1429                let (fill, unsupported) = if shape_path {
1430                    let preset = shape
1431                        .shape_properties
1432                        .preset_geometry
1433                        .as_ref()
1434                        .map(|p| p.preset.as_str())
1435                        .unwrap_or("rect");
1436                    if shape.shape_properties.custom_geometry.is_some()
1437                        || !matches!(preset, "rect" | "ellipse")
1438                    {
1439                        (None, Some("shape path gradient on unsupported contour"))
1440                    } else {
1441                        let mut selected = selected.clone();
1442                        if preset == "ellipse"
1443                            && let Fill::Gradient(g) = &mut selected
1444                            && let Some(GradientGeometry::Path(p)) = &mut g.geometry
1445                        {
1446                            p.kind = PathGradientKind::Circle;
1447                        }
1448                        // A circle path expands to the corner. An ellipse shape stops at its own contour.
1449                        let (mut paint, unsupported) =
1450                            self.concrete_fill(&selected, (bounds.width, bounds.height))?;
1451                        if preset == "ellipse"
1452                            && let Some(Paint::Grid { colors, .. }) = &mut paint
1453                            && let Fill::Gradient(g) = &selected
1454                        {
1455                            let mut stops = g
1456                                .stops
1457                                .iter()
1458                                .filter_map(|stop| {
1459                                    stop.color.as_ref().map(|c| {
1460                                        self.concrete_color(c).map(|color| GradientStop {
1461                                            offset: f64::from(stop.position.0) / 100_000.0,
1462                                            color,
1463                                        })
1464                                    })
1465                                })
1466                                .collect::<Result<Vec<_>, _>>()?;
1467                            stops.sort_by(|a, b| a.offset.total_cmp(&b.offset));
1468                            for (index, color) in colors.iter_mut().enumerate() {
1469                                let x = ((index % 128) as f64 + 0.5) / 64.0 - 1.0;
1470                                let y = ((index / 128) as f64 + 0.5) / 64.0 - 1.0;
1471                                *color = path_gradient_color(&stops, x.hypot(y));
1472                            }
1473                        }
1474                        (paint, unsupported)
1475                    }
1476                } else {
1477                    self.concrete_fill(selected, (bounds.width, bounds.height))?
1478                };
1479                (fill, None, unsupported, None)
1480            }
1481            None => (None, None, None, None),
1482        };
1483        let line = effective
1484            .line
1485            .as_ref()
1486            .map(|line| self.concrete_line(line, (bounds.width, bounds.height)))
1487            .transpose()?
1488            .flatten();
1489        let (head_end, tail_end) = effective
1490            .line
1491            .as_ref()
1492            .map(resolved_line_ends)
1493            .unwrap_or((None, None));
1494        let shadow = effective
1495            .effects
1496            .as_ref()
1497            .and_then(|effects| effects.outer_shadow.as_ref())
1498            .map(|shadow| self.concrete_shadow(shadow))
1499            .transpose()?;
1500        let transparent_text_only = shape.text_body.is_some()
1501            && fill.is_none()
1502            && image_fill.is_none()
1503            && fill_unsupported.is_none()
1504            && line.is_none()
1505            && shadow.is_none();
1506        if transparent_text_only {
1507            bounds = unscaled_bounds;
1508            group_transform = Transform {
1509                a: group_scale.0,
1510                d: group_scale.1,
1511                ..Transform::IDENTITY
1512            }
1513            .then(group_transform);
1514        }
1515        let (geometry, geometry_unsupported, geometry_diagnostic) = if let Some(custom) =
1516            &shape.shape_properties.custom_geometry
1517        {
1518            match self.concrete_custom_geometry(custom, (bounds.width, bounds.height)) {
1519                Ok(geometry) => (geometry, None, None),
1520                Err(error) => (
1521                    ResolvedGeometry::BoundsFallback,
1522                    Some("custom geometry evaluation"),
1523                    Some(format!(
1524                        "custom geometry evaluation failed: {error}; retained as shape bounds"
1525                    )),
1526                ),
1527            }
1528        } else if let Some(preset) = &shape.shape_properties.preset_geometry {
1529            match self.concrete_preset_geometry(preset, (bounds.width, bounds.height)) {
1530                Ok(Some(geometry)) => (geometry, None, None),
1531                Ok(None) => (
1532                    ResolvedGeometry::BoundsFallback,
1533                    Some("unknown preset geometry"),
1534                    Some(format!(
1535                        "unknown preset geometry `{}` retained as shape bounds",
1536                        preset.preset
1537                    )),
1538                ),
1539                Err(error) => (
1540                    ResolvedGeometry::BoundsFallback,
1541                    Some("preset geometry evaluation"),
1542                    Some(format!(
1543                        "preset geometry `{}` evaluation failed: {error}; retained as shape bounds",
1544                        preset.preset
1545                    )),
1546                ),
1547            }
1548        } else if shape.text_body.is_some() {
1549            (ResolvedGeometry::Rectangle, None, None)
1550        } else {
1551            (
1552                ResolvedGeometry::BoundsFallback,
1553                Some("preset geometry pending evaluation"),
1554                None,
1555            )
1556        };
1557        let content = if let Some(body) = shape.text_body.as_ref() {
1558            let (body, directions) =
1559                self.resolve_text_body(shape, body, source, hyperlinks, diagnostics)?;
1560            text_directions.push(directions);
1561            ResolvedContent::Text(body)
1562        } else {
1563            ResolvedContent::None
1564        };
1565        if let ResolvedContent::Text(text) = &content
1566            && let Some(message) = vertical_text_diagnostic(text.vertical)
1567        {
1568            diagnostics.push(Diagnostic {
1569                message: message.to_owned(),
1570            });
1571        }
1572        if let Some(category) = fill_unsupported {
1573            diagnostics.push(Diagnostic {
1574                message: fill_diagnostic
1575                    .unwrap_or_else(|| format!("unsupported {category} retained as shape bounds")),
1576            });
1577        }
1578        if let Some(category) = geometry_unsupported {
1579            diagnostics.push(Diagnostic {
1580                message: geometry_diagnostic
1581                    .unwrap_or_else(|| format!("unsupported {category} retained as shape bounds")),
1582            });
1583        }
1584        Ok(Some(ResolvedShape {
1585            group_transform,
1586            bounds,
1587            rotation_deg,
1588            flip_h,
1589            flip_v,
1590            geometry,
1591            fill,
1592            image_fill,
1593            line,
1594            head_end,
1595            tail_end,
1596            shadow,
1597            content,
1598            unsupported: fill_unsupported.or(geometry_unsupported),
1599        }))
1600    }
1601
1602    fn concrete_picture_geometry(
1603        &self,
1604        picture: &rpptx_oxml::picture::CT_Picture,
1605        size: (f64, f64),
1606    ) -> (ResolvedGeometry, Option<&'static str>) {
1607        if let Some(custom) = &picture.shape_properties.custom_geometry {
1608            return self
1609                .concrete_custom_geometry(custom, size)
1610                .map(|geometry| (geometry, None))
1611                .unwrap_or((ResolvedGeometry::Rectangle, Some("picture custom geometry")));
1612        }
1613        if let Some(preset) = &picture.shape_properties.preset_geometry {
1614            return match self.concrete_preset_geometry(preset, size) {
1615                Ok(Some(geometry)) => (geometry, None),
1616                Ok(None) => (
1617                    ResolvedGeometry::Rectangle,
1618                    Some("unknown picture preset geometry"),
1619                ),
1620                Err(_) => (
1621                    ResolvedGeometry::Rectangle,
1622                    Some("picture preset geometry evaluation"),
1623                ),
1624            };
1625        }
1626        (ResolvedGeometry::Rectangle, None)
1627    }
1628}
1629
1630fn renderer_compatible_image_content_type(content_type: &str) -> bool {
1631    content_type.eq_ignore_ascii_case("image/png")
1632        || content_type.eq_ignore_ascii_case("image/jpeg")
1633        || content_type.eq_ignore_ascii_case("image/jpg")
1634}
1635
1636fn resolve_picture_content(
1637    picture: &rpptx_oxml::picture::CT_Picture,
1638    source: FlattenedSource,
1639    media: Option<&ScopedMediaIds>,
1640    failures: Option<&ScopedMediaFailures>,
1641    diagnostics: &mut Vec<Diagnostic>,
1642) -> (ResolvedContent, Option<&'static str>) {
1643    let Some(fill) = picture.blip_fill.as_ref() else {
1644        return unsupported_picture(
1645            "alternate picture media",
1646            "alternate picture media is not resolved",
1647            diagnostics,
1648        );
1649    };
1650    match resolve_image_fill(fill, source, media, failures, "picture") {
1651        Ok(image) => (ResolvedContent::Image(image), None),
1652        Err((category, message)) => unsupported_picture(category, &message, diagnostics),
1653    }
1654}
1655
1656fn resolve_ole_preview(
1657    preview: Option<&rpptx_oxml::picture::CT_Picture>,
1658    source: FlattenedSource,
1659    media: Option<&ScopedMediaIds>,
1660    failures: Option<&ScopedMediaFailures>,
1661) -> Option<ResolvedImage> {
1662    let fill = preview?.blip_fill.as_ref()?;
1663    let relationship_id = fill.blip.as_ref()?.embed.as_deref()?;
1664    let media = media?;
1665    if !media
1666        .content_type(source, relationship_id)
1667        .is_some_and(|content_type| content_type.eq_ignore_ascii_case("image/png"))
1668    {
1669        return None;
1670    }
1671    resolve_image_fill(fill, source, Some(media), failures, "picture").ok()
1672}
1673
1674fn resolve_image_fill(
1675    fill: &BlipFill,
1676    source: FlattenedSource,
1677    media: Option<&ScopedMediaIds>,
1678    failures: Option<&ScopedMediaFailures>,
1679    role: &str,
1680) -> Result<ResolvedImage, (&'static str, String)> {
1681    let missing_blip_category = match role {
1682        "picture" => "missing picture blip",
1683        "shape" => "missing shape picture blip",
1684        _ => "missing background blip",
1685    };
1686    let external_media_category = match role {
1687        "picture" => "external picture media",
1688        "shape" => "external shape picture media",
1689        _ => "external background media",
1690    };
1691    let Some(blip) = fill.blip.as_ref() else {
1692        return Err((
1693            missing_blip_category,
1694            format!("{role} fill has no modelled embedded blip"),
1695        ));
1696    };
1697    let Some(relationship_id) = blip.embed.as_deref() else {
1698        let message = if let Some(link) = blip.link.as_deref() {
1699            format!("external {role} relationship `{link}` is unsupported")
1700        } else {
1701            format!("{role} blip has no embedded relationship")
1702        };
1703        return Err((external_media_category, message));
1704    };
1705    let Some(media) = media else {
1706        return Err((
1707            "image media pending relationship resolution",
1708            format!("{role} image media pending relationship resolution"),
1709        ));
1710    };
1711    if let Some(failure) = failures.and_then(|failures| failures.get(source, relationship_id)) {
1712        return Err((
1713            "image render failure",
1714            format!("{role} image relationship `{relationship_id}` {failure}"),
1715        ));
1716    }
1717    let Some(media_id) = media.get(source, relationship_id) else {
1718        return Err((
1719            "missing image relationship",
1720            format!(
1721                "missing {} {role} image relationship `{relationship_id}`",
1722                flattened_source_name(source)
1723            ),
1724        ));
1725    };
1726    Ok(ResolvedImage {
1727        media: media_id,
1728        src_rect: fill.source_rect.as_ref().map(resolved_crop_rect),
1729        placement: match fill.mode.as_ref() {
1730            Some(BlipMode::Tile(tile)) => ResolvedImagePlacement::Tile(ResolvedTilePlacement {
1731                translation: Point {
1732                    x: emu_to_points(tile.translation_x.unwrap_or(0)),
1733                    y: emu_to_points(tile.translation_y.unwrap_or(0)),
1734                },
1735                scale_x: tile
1736                    .scale_x
1737                    .map_or(1.0, |value| f64::from(value.0) / 100_000.0),
1738                scale_y: tile
1739                    .scale_y
1740                    .map_or(1.0, |value| f64::from(value.0) / 100_000.0),
1741                flip: resolved_tile_flip(tile.flip.as_deref()),
1742                alignment: resolved_rect_alignment(tile.alignment.as_deref()),
1743            }),
1744            Some(BlipMode::Stretch { fill_rect, .. }) => ResolvedImagePlacement::Stretch {
1745                fill_rect: fill_rect.as_ref().map(resolved_crop_rect),
1746            },
1747            None => ResolvedImagePlacement::default(),
1748        },
1749        dpi: fill.dpi.filter(|dpi| *dpi > 0).map(f64::from),
1750        rotate_with_shape: fill.rotate_with_shape.unwrap_or(true),
1751        opacity: blip
1752            .alpha_modulation_fix
1753            .map_or(1.0, |amount| f64::from(amount.0) / 100_000.0),
1754    })
1755}
1756
1757fn unsupported_picture(
1758    category: &'static str,
1759    message: &str,
1760    diagnostics: &mut Vec<Diagnostic>,
1761) -> (ResolvedContent, Option<&'static str>) {
1762    diagnostics.push(Diagnostic {
1763        message: message.to_owned(),
1764    });
1765    (ResolvedContent::None, Some(category))
1766}
1767
1768fn flattened_source_name(source: FlattenedSource) -> &'static str {
1769    match source {
1770        FlattenedSource::Background => "background",
1771        FlattenedSource::Master => "master",
1772        FlattenedSource::Layout => "layout",
1773        FlattenedSource::Slide => "slide",
1774    }
1775}
1776
1777fn resolve_direct_hyperlink(
1778    properties: Option<&CT_TextCharacterProperties>,
1779    source: FlattenedSource,
1780    hyperlinks: Option<&ScopedHyperlinkTargets>,
1781    diagnostics: &mut Vec<Diagnostic>,
1782) -> Option<String> {
1783    let hyperlink = properties?.hyperlink_click.as_ref()?;
1784    if let Some(action) = hyperlink.action.as_deref() {
1785        diagnostics.push(Diagnostic {
1786            message: format!(
1787                "unsupported {} hyperlink action `{action}`",
1788                flattened_source_name(source)
1789            ),
1790        });
1791        return None;
1792    }
1793    let Some(relationship_id) = hyperlink.relationship_id.as_deref() else {
1794        diagnostics.push(Diagnostic {
1795            message: format!(
1796                "unsupported {} hyperlink without an external relationship",
1797                flattened_source_name(source)
1798            ),
1799        });
1800        return None;
1801    };
1802    let Some(target) = hyperlinks.and_then(|targets| targets.get(source, relationship_id)) else {
1803        diagnostics.push(Diagnostic {
1804            message: format!(
1805                "missing {} hyperlink relationship `{relationship_id}`",
1806                flattened_source_name(source)
1807            ),
1808        });
1809        return None;
1810    };
1811    Some(target.to_owned())
1812}
1813
1814fn resolved_crop_rect(rect: &RelativeRect) -> crate::CropRect {
1815    crate::CropRect {
1816        left: rect
1817            .left
1818            .map_or(0.0, |value| f64::from(value.0) / 100_000.0),
1819        top: rect.top.map_or(0.0, |value| f64::from(value.0) / 100_000.0),
1820        right: rect
1821            .right
1822            .map_or(0.0, |value| f64::from(value.0) / 100_000.0),
1823        bottom: rect
1824            .bottom
1825            .map_or(0.0, |value| f64::from(value.0) / 100_000.0),
1826    }
1827}
1828
1829fn resolved_tile_flip(value: Option<&str>) -> ResolvedTileFlip {
1830    match value {
1831        Some("x") => ResolvedTileFlip::Horizontal,
1832        Some("y") => ResolvedTileFlip::Vertical,
1833        Some("xy") => ResolvedTileFlip::Both,
1834        Some("none") | None => ResolvedTileFlip::None,
1835        Some(_) => unreachable!("DrawingML tile flip is validated while parsing"),
1836    }
1837}
1838
1839fn resolved_rect_alignment(value: Option<&str>) -> ResolvedRectAlignment {
1840    match value {
1841        Some("t") => ResolvedRectAlignment::Top,
1842        Some("tr") => ResolvedRectAlignment::TopRight,
1843        Some("l") => ResolvedRectAlignment::Left,
1844        Some("ctr") => ResolvedRectAlignment::Center,
1845        Some("r") => ResolvedRectAlignment::Right,
1846        Some("bl") => ResolvedRectAlignment::BottomLeft,
1847        Some("b") => ResolvedRectAlignment::Bottom,
1848        Some("br") => ResolvedRectAlignment::BottomRight,
1849        Some("tl") | None => ResolvedRectAlignment::TopLeft,
1850        Some(_) => unreachable!("DrawingML rectangle alignment is validated while parsing"),
1851    }
1852}
1853
1854fn resolved_line_ends(
1855    line: &CT_LineProperties,
1856) -> (Option<ResolvedLineEnd>, Option<ResolvedLineEnd>) {
1857    (
1858        line.head_end.as_ref().and_then(resolved_line_end),
1859        line.tail_end.as_ref().and_then(resolved_line_end),
1860    )
1861}
1862
1863fn resolved_line_end(end: &LineEnd) -> Option<ResolvedLineEnd> {
1864    let kind = match end.kind? {
1865        LineEndType::None => return None,
1866        LineEndType::Triangle => ResolvedLineEndKind::Triangle,
1867        LineEndType::Stealth => ResolvedLineEndKind::Stealth,
1868        LineEndType::Diamond => ResolvedLineEndKind::Diamond,
1869        LineEndType::Oval => ResolvedLineEndKind::Oval,
1870        LineEndType::Arrow => ResolvedLineEndKind::Arrow,
1871    };
1872    Some(ResolvedLineEnd {
1873        kind,
1874        width: resolved_line_end_size(end.width),
1875        length: resolved_line_end_size(end.length),
1876    })
1877}
1878
1879fn resolved_line_end_size(size: Option<LineEndSize>) -> ResolvedLineEndSize {
1880    match size.unwrap_or(LineEndSize::Medium) {
1881        LineEndSize::Small => ResolvedLineEndSize::Small,
1882        LineEndSize::Medium => ResolvedLineEndSize::Medium,
1883        LineEndSize::Large => ResolvedLineEndSize::Large,
1884    }
1885}
1886
1887impl ResolveCtx<'_> {
1888    fn concrete_background_fill(
1889        &self,
1890        fill: &Fill,
1891        size: (f64, f64),
1892        source: FlattenedSource,
1893        media: Option<&ScopedMediaIds>,
1894        diagnostics: &mut Vec<Diagnostic>,
1895    ) -> Result<(Option<ResolvedBackground>, Option<&'static str>), ResolveError> {
1896        let mut fill = fill.clone();
1897        let slot = self.color_map.theme_slot(ColorMapSlot::Background1);
1898        let reference = self.theme.theme_elements.color_scheme.color(slot);
1899        substitute_fill(&mut fill, Some(reference), "background")?;
1900        self.concrete_background_value(&fill, size, source, media, diagnostics)
1901    }
1902
1903    fn concrete_background_reference(
1904        &self,
1905        index: u32,
1906        color: Option<&ColorChoice>,
1907        size: (f64, f64),
1908        diagnostics: &mut Vec<Diagnostic>,
1909    ) -> Result<(Option<ResolvedBackground>, Option<&'static str>), ResolveError> {
1910        let matrix = &self.theme.theme_elements.format_scheme;
1911        let Some(mut fill) =
1912            referenced_fill(index, &matrix.fill_styles, &matrix.background_fill_styles)?
1913        else {
1914            return Ok((None, None));
1915        };
1916        substitute_fill(&mut fill, color, "background")?;
1917        if matches!(fill, Fill::Blip(_)) {
1918            diagnostics.push(Diagnostic {
1919                message: "theme-referenced background picture requires theme relationship scope"
1920                    .to_owned(),
1921            });
1922            return Ok((None, None));
1923        }
1924        let (paint, unsupported) = self.concrete_background_paint(&fill, size)?;
1925        Ok((paint.map(ResolvedBackground::Paint), unsupported))
1926    }
1927
1928    fn concrete_background_value(
1929        &self,
1930        fill: &Fill,
1931        size: (f64, f64),
1932        source: FlattenedSource,
1933        media: Option<&ScopedMediaIds>,
1934        diagnostics: &mut Vec<Diagnostic>,
1935    ) -> Result<(Option<ResolvedBackground>, Option<&'static str>), ResolveError> {
1936        if let Fill::Blip(fill) = fill {
1937            return Ok(
1938                match resolve_image_fill(fill, source, media, self.media_failures, "background") {
1939                    Ok(image) => (Some(ResolvedBackground::Image(image)), None),
1940                    Err((_category, message)) => {
1941                        diagnostics.push(Diagnostic { message });
1942                        (None, None)
1943                    }
1944                },
1945            );
1946        }
1947        let (paint, unsupported) = self.concrete_background_paint(fill, size)?;
1948        Ok((paint.map(ResolvedBackground::Paint), unsupported))
1949    }
1950
1951    fn concrete_background_paint(
1952        &self,
1953        fill: &Fill,
1954        size: (f64, f64),
1955    ) -> Result<(Option<Paint>, Option<&'static str>), ResolveError> {
1956        let mut fill = fill.clone();
1957        if let Fill::Gradient(gradient) = &mut fill {
1958            gradient.rotate_with_shape = None;
1959        }
1960        self.concrete_fill(&fill, size)
1961    }
1962
1963    fn concrete_fill(
1964        &self,
1965        fill: &Fill,
1966        size: (f64, f64),
1967    ) -> Result<(Option<Paint>, Option<&'static str>), ResolveError> {
1968        match fill {
1969            Fill::NoFill(_) => Ok((None, None)),
1970            Fill::Solid(solid) => {
1971                let Some(choice) = solid.color.as_ref() else {
1972                    return Ok((None, Some("solid fill without colour")));
1973                };
1974                Ok((Some(Paint::Solid(self.concrete_color(choice)?)), None))
1975            }
1976            Fill::Gradient(gradient) => {
1977                if gradient.rotate_with_shape == Some(false) {
1978                    return Ok((None, Some("gradient independent of shape rotation")));
1979                }
1980                match gradient.flip.as_deref() {
1981                    Some("x") => return Ok((None, Some("horizontal gradient flip"))),
1982                    Some("y") => return Ok((None, Some("vertical gradient flip"))),
1983                    Some("xy") => return Ok((None, Some("horizontal and vertical gradient flip"))),
1984                    Some("none") | None => {}
1985                    Some(_) => unreachable!("DrawingML parser validates gradient flip tokens"),
1986                }
1987                if gradient.tile_rect.is_some() {
1988                    return Ok((None, Some("gradient tile rectangle")));
1989                }
1990                if let Some(GradientGeometry::Path(path)) = &gradient.geometry
1991                    && !path.fill_to_rect.as_ref().is_some_and(|rect| {
1992                        [rect.left, rect.top, rect.right, rect.bottom]
1993                            .into_iter()
1994                            .all(|value| value.is_some_and(|value| value.0 == 50_000))
1995                    })
1996                {
1997                    return Ok((None, Some("path gradient with non-centred fill rectangle")));
1998                }
1999                let mut stops = Vec::with_capacity(gradient.stops.len());
2000                for stop in &gradient.stops {
2001                    let Some(choice) = stop.color.as_ref() else {
2002                        return Ok((None, Some("gradient stop without colour")));
2003                    };
2004                    stops.push(GradientStop {
2005                        offset: f64::from(stop.position.0) / 100_000.0,
2006                        color: self.concrete_color(choice)?,
2007                    });
2008                }
2009                if stops.is_empty() {
2010                    return Ok((None, Some("gradient without concrete stops")));
2011                }
2012                let paint = match &gradient.geometry {
2013                    Some(GradientGeometry::Linear(linear)) => {
2014                        let radians = f64::from(linear.angle.0) / 60_000.0_f64;
2015                        let radians = radians.to_radians();
2016                        let center = Point {
2017                            x: size.0 / 2.0,
2018                            y: size.1 / 2.0,
2019                        };
2020                        let mut direction = Point {
2021                            x: radians.cos(),
2022                            y: radians.sin(),
2023                        };
2024                        if linear.scaled == Some(true) {
2025                            direction.x *= size.0;
2026                            direction.y *= size.1;
2027                        }
2028                        let length = direction.x.hypot(direction.y);
2029                        if length > 0.0 {
2030                            direction.x /= length;
2031                            direction.y /= length;
2032                        }
2033                        let extent =
2034                            (direction.x.abs() * size.0 + direction.y.abs() * size.1) / 2.0;
2035                        let delta = Point {
2036                            x: direction.x * extent,
2037                            y: direction.y * extent,
2038                        };
2039                        Paint::linear(
2040                            Point {
2041                                x: center.x - delta.x,
2042                                y: center.y - delta.y,
2043                            },
2044                            Point {
2045                                x: center.x + delta.x,
2046                                y: center.y + delta.y,
2047                            },
2048                            powerpoint_gradient_stops(&stops),
2049                            (true, true),
2050                        )
2051                    }
2052                    None => Paint::linear(
2053                        Point { x: 0.0, y: 0.0 },
2054                        Point { x: size.0, y: 0.0 },
2055                        powerpoint_gradient_stops(&stops),
2056                        (true, true),
2057                    ),
2058                    Some(GradientGeometry::Path(path)) => {
2059                        let centered = path.fill_to_rect.as_ref().is_some_and(|r| {
2060                            [r.left, r.top, r.right, r.bottom]
2061                                .into_iter()
2062                                .all(|v| v.is_some_and(|v| v.0 == 50_000))
2063                        });
2064                        if !centered {
2065                            return Ok((
2066                                None,
2067                                Some("path gradient with non-centred fill rectangle"),
2068                            ));
2069                        }
2070                        let mut ordered = stops;
2071                        ordered.sort_by(|a, b| a.offset.total_cmp(&b.offset));
2072                        let mut colors = Vec::with_capacity(128 * 128);
2073                        for y in 0..128 {
2074                            for x in 0..128 {
2075                                let dx = ((f64::from(x) + 0.5) / 128.0 - 0.5).abs() * 2.0;
2076                                let dy = ((f64::from(y) + 0.5) / 128.0 - 0.5).abs() * 2.0;
2077                                let distance = match path.kind {
2078                                    PathGradientKind::Circle => {
2079                                        dx.hypot(dy) / std::f64::consts::SQRT_2
2080                                    }
2081                                    PathGradientKind::Rectangle | PathGradientKind::Shape => {
2082                                        dx.max(dy)
2083                                    }
2084                                };
2085                                colors.push(path_gradient_color(&ordered, distance));
2086                            }
2087                        }
2088                        let Some(paint) = Paint::grid(
2089                            Rect {
2090                                x: 0.0,
2091                                y: 0.0,
2092                                width: size.0,
2093                                height: size.1,
2094                            },
2095                            128,
2096                            128,
2097                            colors,
2098                        ) else {
2099                            return Ok((
2100                                None,
2101                                Some("path gradient without positive finite extent"),
2102                            ));
2103                        };
2104                        paint
2105                    }
2106                };
2107                Ok((Some(paint), None))
2108            }
2109            Fill::Pattern(pattern) => {
2110                let Some(rows) = pattern.preset.as_deref().and_then(native_pattern_rows) else {
2111                    return Ok((None, Some("pattern without supported preset")));
2112                };
2113                let foreground = pattern
2114                    .foreground
2115                    .as_ref()
2116                    .map(|c| self.concrete_color(c))
2117                    .transpose()?
2118                    .unwrap_or(Color::BLACK);
2119                let background = pattern
2120                    .background
2121                    .as_ref()
2122                    .map(|c| self.concrete_color(c))
2123                    .transpose()?
2124                    .unwrap_or(Color::WHITE);
2125                let mut colors = Vec::with_capacity(4096);
2126                for &row in rows {
2127                    let bits = NATIVE_PATTERN_ROW_WEIGHTS[usize::from(row)];
2128                    for x in 0..64 {
2129                        let weight = ((bits >> (126 - x * 2)) & 3) as u8;
2130                        colors.push(blend_fill_colors(
2131                            background,
2132                            foreground,
2133                            f64::from(weight) / 2.0,
2134                        ));
2135                    }
2136                }
2137                Ok((
2138                    Paint::grid(
2139                        Rect {
2140                            x: 0.0,
2141                            y: 0.0,
2142                            width: 8.0,
2143                            height: 8.0,
2144                        },
2145                        64,
2146                        64,
2147                        colors,
2148                    ),
2149                    None,
2150                ))
2151            }
2152            Fill::Blip(_) => Ok((None, Some("picture fill media"))),
2153        }
2154    }
2155
2156    fn concrete_line(
2157        &self,
2158        line: &CT_LineProperties,
2159        size: (f64, f64),
2160    ) -> Result<Option<Stroke>, ResolveError> {
2161        let Some(fill) = line.fill.as_ref() else {
2162            return Ok(None);
2163        };
2164        let (Some(paint), _) = self.concrete_fill(fill, size)? else {
2165            return Ok(None);
2166        };
2167        let width = emu_to_points(i64::from(line.width.unwrap_or(12_700)));
2168        let cap = match line.cap.unwrap_or(DrawingLineCap::Flat) {
2169            DrawingLineCap::Round => LineCap::Round,
2170            DrawingLineCap::Square => LineCap::Square,
2171            DrawingLineCap::Flat => LineCap::Butt,
2172        };
2173        let join = match line.join.as_ref() {
2174            Some(DrawingLineJoin::Round { .. }) => LineJoin::Round,
2175            Some(DrawingLineJoin::Bevel { .. }) => LineJoin::Bevel,
2176            Some(DrawingLineJoin::Miter { .. }) | None => LineJoin::Miter,
2177        };
2178        let dash = line.dash.as_ref().and_then(|dash| match dash {
2179            LineDash::Preset(preset) => {
2180                let values = preset
2181                    .value
2182                    .dash_array()
2183                    .iter()
2184                    .map(|value| f64::from(*value) * width)
2185                    .collect::<Vec<_>>();
2186                (!values.is_empty()).then_some(values)
2187            }
2188            LineDash::Custom(custom) => {
2189                let values = custom
2190                    .stops
2191                    .iter()
2192                    .flat_map(|stop| [stop.dash, stop.space])
2193                    .map(|value| f64::from(value) / 100_000.0 * width)
2194                    .collect::<Vec<_>>();
2195                (!values.is_empty()).then_some(values)
2196            }
2197        });
2198        Ok(Some(Stroke {
2199            paint,
2200            width,
2201            cap,
2202            join,
2203            dash,
2204        }))
2205    }
2206
2207    fn concrete_shadow(&self, shadow: &CT_OuterShadowEffect) -> Result<Effect, ResolveError> {
2208        let color = shadow
2209            .color
2210            .as_ref()
2211            .map(|choice| self.concrete_color(choice))
2212            .transpose()?
2213            .unwrap_or(Color::BLACK);
2214        let distance = emu_to_points(shadow.distance.unwrap_or(0));
2215        let angle = f64::from(shadow.direction.unwrap_or_default().0) / 60_000.0;
2216        let radians = angle.to_radians();
2217        Ok(Effect::OuterShadow {
2218            dx: radians.cos() * distance,
2219            dy: radians.sin() * distance,
2220            blur: emu_to_points(shadow.blur_radius.unwrap_or(0)),
2221            color,
2222        })
2223    }
2224
2225    pub(crate) fn concrete_color(&self, choice: &ColorChoice) -> Result<Color, ResolveError> {
2226        let owned_lookup = self.theme_lookup();
2227        let lookup = owned_lookup
2228            .iter()
2229            .map(|(name, color)| (name.as_str(), *color))
2230            .collect::<Vec<_>>();
2231        let color = resolve_color(choice, &self.color_map, &lookup).map_err(|error| {
2232            ResolveError::ConcreteValue {
2233                kind: "colour",
2234                detail: error.to_string(),
2235            }
2236        })?;
2237        Ok(Color {
2238            r: f64::from(color.red) / 255.0,
2239            g: f64::from(color.green) / 255.0,
2240            b: f64::from(color.blue) / 255.0,
2241            a: f64::from(color.alpha) / 255.0,
2242        })
2243    }
2244
2245    fn theme_lookup(&self) -> Vec<(String, RgbColor)> {
2246        let mut lookup = self
2247            .theme
2248            .theme_elements
2249            .color_scheme
2250            .iter()
2251            .filter_map(|(slot, choice)| {
2252                base_rgb(choice).map(|color| (slot.as_str().to_owned(), color))
2253            })
2254            .collect::<Vec<_>>();
2255        lookup.push(("black".to_owned(), RgbColor::new(0, 0, 0)));
2256        lookup.push(("white".to_owned(), RgbColor::new(255, 255, 255)));
2257        lookup
2258    }
2259
2260    fn concrete_custom_geometry(
2261        &self,
2262        geometry: &oxml_drawing::geometry::CT_CustomGeometry2D,
2263        size: (f64, f64),
2264    ) -> Result<ResolvedGeometry, ResolveError> {
2265        let evaluated = geometry
2266            .evaluate_with_size(&BTreeMap::new(), size)
2267            .map_err(|error| ResolveError::ConcreteValue {
2268                kind: "custom geometry",
2269                detail: error.to_string(),
2270            })?;
2271        let paths = evaluated
2272            .paths
2273            .into_iter()
2274            .zip(geometry.paths())
2275            .map(|(commands, source)| {
2276                let scale_x = source.width.map_or(1.0, |width| size.0 / width);
2277                let scale_y = source.height.map_or(1.0, |height| size.1 / height);
2278                Path {
2279                    commands: commands
2280                        .into_iter()
2281                        .map(|command| concrete_path_command(command, scale_x, scale_y))
2282                        .collect(),
2283                    fill_rule: FillRule::NonZero,
2284                }
2285            })
2286            .collect();
2287        let first_path = geometry.paths().first();
2288        let text_scale_x = first_path
2289            .and_then(|path| path.width)
2290            .map_or(1.0, |width| size.0 / width);
2291        let text_scale_y = first_path
2292            .and_then(|path| path.height)
2293            .map_or(1.0, |height| size.1 / height);
2294        let text_rect = evaluated.text_rectangle.map(|rectangle| Rect {
2295            x: rectangle.left * text_scale_x,
2296            y: rectangle.top * text_scale_y,
2297            width: (rectangle.right - rectangle.left) * text_scale_x,
2298            height: (rectangle.bottom - rectangle.top) * text_scale_y,
2299        });
2300        Ok(ResolvedGeometry::Custom { paths, text_rect })
2301    }
2302
2303    fn concrete_preset_geometry(
2304        &self,
2305        geometry: &oxml_drawing::geometry::CT_PresetGeometry2D,
2306        size: (f64, f64),
2307    ) -> Result<Option<ResolvedGeometry>, ResolveError> {
2308        let evaluated = geometry
2309            .evaluate(size)
2310            .map_err(|error| ResolveError::ConcreteValue {
2311                kind: "preset geometry",
2312                detail: error.to_string(),
2313            })?;
2314        Ok(evaluated.map(|evaluated| {
2315            let paths = evaluated
2316                .paths
2317                .into_iter()
2318                .map(|commands| Path {
2319                    commands: commands
2320                        .into_iter()
2321                        .map(|command| concrete_path_command(command, 1.0, 1.0))
2322                        .collect(),
2323                    fill_rule: FillRule::NonZero,
2324                })
2325                .collect();
2326            let text_rect = evaluated.text_rectangle.map(|rectangle| Rect {
2327                x: rectangle.left,
2328                y: rectangle.top,
2329                width: rectangle.right - rectangle.left,
2330                height: rectangle.bottom - rectangle.top,
2331            });
2332            ResolvedGeometry::Custom { paths, text_rect }
2333        }))
2334    }
2335
2336    fn resolve_text_body(
2337        &self,
2338        shape: &CT_Shape,
2339        body: &CT_TextBody,
2340        source: FlattenedSource,
2341        hyperlinks: Option<&ScopedHyperlinkTargets>,
2342        diagnostics: &mut Vec<Diagnostic>,
2343    ) -> Result<(ResolvedTextBody, Vec<oxml_layout::TextDirection>), ResolveError> {
2344        let properties = self.effective_body_pr(shape);
2345        let slide_number_placeholder = self.is_slide_number_placeholder(shape);
2346        let resolved = body
2347            .paragraphs()
2348            .iter()
2349            .map(|paragraph| {
2350                self.resolve_paragraph(
2351                    shape,
2352                    paragraph,
2353                    source,
2354                    hyperlinks,
2355                    slide_number_placeholder,
2356                    diagnostics,
2357                )
2358            })
2359            .collect::<Result<Vec<_>, _>>()?;
2360        let (paragraphs, directions) = resolved.into_iter().unzip();
2361        Ok((resolved_text_body(&properties, paragraphs)?, directions))
2362    }
2363
2364    fn resolve_paragraph(
2365        &self,
2366        shape: &CT_Shape,
2367        paragraph: &oxml_drawing::text::CT_TextParagraph,
2368        source: FlattenedSource,
2369        hyperlinks: Option<&ScopedHyperlinkTargets>,
2370        slide_number_placeholder: bool,
2371        diagnostics: &mut Vec<Diagnostic>,
2372    ) -> Result<(ResolvedParagraph, oxml_layout::TextDirection), ResolveError> {
2373        let effective = self.effective_text_properties(shape, paragraph.properties.as_ref(), None);
2374        let paragraph_properties = &effective.paragraph;
2375        let mut runs = Vec::new();
2376        for run in &paragraph.runs {
2377            match run {
2378                TextRun::Run(run) => {
2379                    let mut style = self.resolve_run_style(
2380                        shape,
2381                        paragraph.properties.as_ref(),
2382                        run.properties.as_ref(),
2383                    )?;
2384                    style.hyperlink_url = resolve_direct_hyperlink(
2385                        run.properties.as_ref(),
2386                        source,
2387                        hyperlinks,
2388                        diagnostics,
2389                    );
2390                    runs.push(ResolvedTextRun::Text {
2391                        text: run.text.value.clone(),
2392                        style,
2393                    });
2394                }
2395                TextRun::Break(_) => runs.push(ResolvedTextRun::Break),
2396                TextRun::Field(field) => {
2397                    if field.text.is_none()
2398                        && field.field_type.as_deref().is_some_and(|kind| {
2399                            kind == "datetime"
2400                                || (1..=13).any(|index| kind == format!("datetime{index}"))
2401                        })
2402                    {
2403                        diagnostics.push(Diagnostic { message: "uncached automatic date field has no computed date for static rendering".to_owned() });
2404                    }
2405                    let mut style = self.resolve_run_style(
2406                        shape,
2407                        paragraph.properties.as_ref(),
2408                        field.run_properties.as_ref(),
2409                    )?;
2410                    style.hyperlink_url = resolve_direct_hyperlink(
2411                        field.run_properties.as_ref(),
2412                        source,
2413                        hyperlinks,
2414                        diagnostics,
2415                    );
2416                    runs.push(ResolvedTextRun::Field {
2417                        text: field
2418                            .text
2419                            .as_ref()
2420                            .map(|text| text.value.clone())
2421                            .unwrap_or_default(),
2422                        field_type: field
2423                            .field_type
2424                            .clone()
2425                            .or_else(|| slide_number_placeholder.then(|| "slidenum".to_owned())),
2426                        style,
2427                    });
2428                }
2429            }
2430        }
2431        let direction = paragraph_base_direction(paragraph_properties.right_to_left);
2432        Ok((
2433            ResolvedParagraph {
2434                level: paragraph_properties.level.unwrap_or(0),
2435                left_margin: emu_to_points(i64::from(
2436                    paragraph_properties.left_margin.unwrap_or(0),
2437                )),
2438                right_margin: emu_to_points(i64::from(
2439                    paragraph_properties.right_margin.unwrap_or(0),
2440                )),
2441                indent: emu_to_points(i64::from(paragraph_properties.indent.unwrap_or(0))),
2442                alignment: paragraph_alignment(paragraph_properties.alignment),
2443                line_spacing: resolved_text_spacing(paragraph_properties.line_spacing.as_ref()),
2444                space_before: resolved_text_spacing(paragraph_properties.space_before.as_ref()),
2445                space_after: resolved_text_spacing(paragraph_properties.space_after.as_ref()),
2446                bullet: paragraph_properties
2447                    .bullet
2448                    .as_ref()
2449                    .and_then(|bullet| self.resolve_bullet(bullet).transpose())
2450                    .transpose()?,
2451                end_style: self.resolve_run_style(
2452                    shape,
2453                    paragraph.properties.as_ref(),
2454                    paragraph.end_properties.as_ref(),
2455                )?,
2456                runs,
2457            },
2458            direction,
2459        ))
2460    }
2461
2462    fn resolve_run_style(
2463        &self,
2464        shape: &CT_Shape,
2465        paragraph: Option<&oxml_drawing::text::CT_TextParagraphProperties>,
2466        run: Option<&CT_TextCharacterProperties>,
2467    ) -> Result<ResolvedRunStyle, ResolveError> {
2468        let effective = self.effective_text_properties(shape, paragraph, run);
2469        self.resolved_run_style(effective.run, effective.language)
2470    }
2471
2472    fn resolve_table_run_style(
2473        &self,
2474        table_style: &CT_TextCharacterProperties,
2475        paragraph: Option<&oxml_drawing::text::CT_TextParagraphProperties>,
2476        run: Option<&CT_TextCharacterProperties>,
2477    ) -> Result<ResolvedRunStyle, ResolveError> {
2478        let effective = self.effective_table_text_properties(Some(table_style), paragraph, run);
2479        self.resolved_run_style(effective.run, effective.language)
2480    }
2481
2482    fn resolved_run_style(
2483        &self,
2484        effective: CT_TextCharacterProperties,
2485        language: Option<String>,
2486    ) -> Result<ResolvedRunStyle, ResolveError> {
2487        let fill = effective
2488            .fill
2489            .as_ref()
2490            .map(|fill| self.concrete_fill(fill, (1.0, 1.0)))
2491            .transpose()?
2492            .and_then(|(paint, _)| paint);
2493        Ok(ResolvedRunStyle {
2494            font_size: effective.font_size.map(|size| f64::from(size) / 100.0),
2495            bold: effective.bold.unwrap_or(false),
2496            italic: effective.italic.unwrap_or(false),
2497            all_caps: effective.all_caps.unwrap_or(false),
2498            small_caps: effective.small_caps.unwrap_or(false),
2499            language,
2500            highlight: effective
2501                .highlight
2502                .as_ref()
2503                .map(|colour| self.concrete_color(colour))
2504                .transpose()?,
2505            underline: effective
2506                .underline
2507                .is_some_and(|value| value != oxml_drawing::text::TextUnderline::None),
2508            strike: effective
2509                .strike
2510                .is_some_and(|value| value != oxml_drawing::text::TextStrike::None),
2511            spacing: effective.spacing.as_ref().and_then(text_point_value),
2512            baseline: effective.baseline.as_deref().and_then(parse_percent),
2513            fill,
2514            latin_typeface: effective
2515                .latin
2516                .as_ref()
2517                .map(|font| self.resolve_typeface(&font.typeface, None)),
2518            east_asian_typeface: effective
2519                .east_asian
2520                .as_ref()
2521                .map(|font| self.resolve_typeface(&font.typeface, None)),
2522            complex_script_typeface: effective
2523                .complex_script
2524                .as_ref()
2525                .map(|font| self.resolve_typeface(&font.typeface, None)),
2526            symbol_typeface: effective
2527                .symbol
2528                .as_ref()
2529                .map(|font| self.resolve_typeface(&font.typeface, None)),
2530            hyperlink_url: None,
2531        })
2532    }
2533
2534    fn resolve_bullet(
2535        &self,
2536        bullet: &oxml_drawing::text::TextBullet,
2537    ) -> Result<Option<ResolvedBullet>, ResolveError> {
2538        let color = bullet
2539            .color
2540            .as_ref()
2541            .map(|color| self.concrete_color(&color.color))
2542            .transpose()?;
2543        let font = bullet
2544            .font
2545            .as_ref()
2546            .map(|font| self.resolve_typeface(&font.typeface, None));
2547        let size = bullet.size.as_ref().and_then(|size| match &size.value {
2548            TextBulletSizeValue::Percent(value) => {
2549                parse_percent(value).map(ResolvedBulletSize::Percent)
2550            }
2551            TextBulletSizeValue::Points(value) => {
2552                Some(ResolvedBulletSize::Points(f64::from(*value) / 100.0))
2553            }
2554        });
2555        Ok(match bullet.choice.as_ref() {
2556            Some(TextBulletChoice::Character(character)) => Some(ResolvedBullet::Character {
2557                character: character.character.clone(),
2558                font,
2559                color,
2560                size,
2561            }),
2562            Some(TextBulletChoice::AutoNumber(number)) => Some(ResolvedBullet::AutoNumber {
2563                scheme: number.scheme.as_str().to_owned(),
2564                start_at: u32::from(number.start_at.unwrap_or(1)),
2565                font,
2566                color,
2567                size,
2568            }),
2569            Some(TextBulletChoice::None(_)) | None => None,
2570        })
2571    }
2572
2573    fn resolve_table(
2574        &self,
2575        table: &oxml_drawing::table::CT_Table,
2576        source: FlattenedSource,
2577        hyperlinks: Option<&ScopedHyperlinkTargets>,
2578        diagnostics: &mut Vec<Diagnostic>,
2579        text_directions: &mut Vec<Vec<oxml_layout::TextDirection>>,
2580    ) -> Result<ResolvedTable, ResolveError> {
2581        let column_widths = table
2582            .grid
2583            .columns
2584            .iter()
2585            .map(|width| emu_to_points(width.0))
2586            .collect::<Vec<_>>();
2587        let selected_id = table
2588            .properties
2589            .as_ref()
2590            .and_then(|properties| properties.style_id.as_deref());
2591        let explicit_style = self.table_styles.and_then(|styles| {
2592            selected_id.and_then(|id| styles.styles.iter().find(|style| style.style_id == id))
2593        });
2594        let default_id = self
2595            .table_styles
2596            .and_then(|styles| styles.default_style_id.as_deref());
2597        let default_style = self.table_styles.and_then(|styles| {
2598            default_id.and_then(|id| styles.styles.iter().find(|style| style.style_id == id))
2599        });
2600        let builtin_style = if explicit_style.is_none() {
2601            selected_id.and_then(CT_TableStyle::builtin)
2602        } else {
2603            None
2604        };
2605        let builtin_default = if selected_id.is_none() && default_style.is_none() {
2606            default_id.and_then(CT_TableStyle::builtin)
2607        } else {
2608            None
2609        };
2610        let style = explicit_style
2611            .or(builtin_style.as_ref())
2612            .or(default_style)
2613            .or(builtin_default.as_ref());
2614        let row_count = table.rows.len();
2615        let column_count = table.grid.columns.len();
2616        let table_properties = table.properties.as_ref();
2617        let rows = table
2618            .rows
2619            .iter()
2620            .enumerate()
2621            .map(|(row_index, row)| {
2622                let cells = row
2623                    .cells
2624                    .iter()
2625                    .enumerate()
2626                    .map(|(column_index, cell)| {
2627                        let mut cascade = TableCellCascade::default();
2628                        let position = TableCellPosition {
2629                            row: row_index,
2630                            column: column_index,
2631                            row_count,
2632                            column_count,
2633                        };
2634                        if let Some(style) = style {
2635                            let mut priority = 1u8;
2636                            cascade.apply_region(
2637                                style.whole_table.as_ref(),
2638                                true,
2639                                position,
2640                                priority,
2641                            );
2642                            priority += 1;
2643                            if table_properties.is_some_and(|properties| properties.band_rows) {
2644                                let offset = usize::from(
2645                                    table_properties.is_some_and(|properties| properties.first_row),
2646                                );
2647                                let region = if row_index.saturating_sub(offset) % 2 == 0 {
2648                                    style.band1_horizontal.as_ref()
2649                                } else {
2650                                    style.band2_horizontal.as_ref()
2651                                };
2652                                cascade.apply_region(region, false, position, priority);
2653                                priority += 1;
2654                            }
2655                            if table_properties.is_some_and(|properties| properties.band_columns) {
2656                                let offset = usize::from(
2657                                    table_properties
2658                                        .is_some_and(|properties| properties.first_column),
2659                                );
2660                                let region = if column_index.saturating_sub(offset) % 2 == 0 {
2661                                    style.band1_vertical.as_ref()
2662                                } else {
2663                                    style.band2_vertical.as_ref()
2664                                };
2665                                cascade.apply_region(region, false, position, priority);
2666                                priority += 1;
2667                            }
2668                            if column_index == 0
2669                                && table_properties
2670                                    .is_some_and(|properties| properties.first_column)
2671                            {
2672                                cascade.apply_region(
2673                                    style.first_column.as_ref(),
2674                                    false,
2675                                    position,
2676                                    priority,
2677                                );
2678                                priority += 1;
2679                            }
2680                            if column_index + 1 == column_count
2681                                && table_properties.is_some_and(|properties| properties.last_column)
2682                            {
2683                                cascade.apply_region(
2684                                    style.last_column.as_ref(),
2685                                    false,
2686                                    position,
2687                                    priority,
2688                                );
2689                                priority += 1;
2690                            }
2691                            if row_index == 0
2692                                && table_properties.is_some_and(|properties| properties.first_row)
2693                            {
2694                                cascade.apply_region(
2695                                    style.first_row.as_ref(),
2696                                    false,
2697                                    position,
2698                                    priority,
2699                                );
2700                                priority += 1;
2701                            }
2702                            if row_index + 1 == row_count
2703                                && table_properties.is_some_and(|properties| properties.last_row)
2704                            {
2705                                cascade.apply_region(
2706                                    style.last_row.as_ref(),
2707                                    false,
2708                                    position,
2709                                    priority,
2710                                );
2711                                priority += 1;
2712                            }
2713                            let first_row =
2714                                table_properties.is_some_and(|properties| properties.first_row);
2715                            let last_row =
2716                                table_properties.is_some_and(|properties| properties.last_row);
2717                            let first_column =
2718                                table_properties.is_some_and(|properties| properties.first_column);
2719                            let last_column =
2720                                table_properties.is_some_and(|properties| properties.last_column);
2721                            let corner = match (row_index, column_index) {
2722                                (0, 0) if first_row && first_column => {
2723                                    style.north_west_cell.as_ref()
2724                                }
2725                                (0, column)
2726                                    if column + 1 == column_count && first_row && last_column =>
2727                                {
2728                                    style.north_east_cell.as_ref()
2729                                }
2730                                (row, 0) if row + 1 == row_count && last_row && first_column => {
2731                                    style.south_west_cell.as_ref()
2732                                }
2733                                (row, column)
2734                                    if row + 1 == row_count
2735                                        && column + 1 == column_count
2736                                        && last_row
2737                                        && last_column =>
2738                                {
2739                                    style.south_east_cell.as_ref()
2740                                }
2741                                _ => None,
2742                            };
2743                            cascade.apply_region(corner, false, position, priority);
2744                        }
2745                        if let Some(properties) = &cell.properties {
2746                            cascade.apply_direct(properties);
2747                            for unsupported in &properties.unsupported {
2748                                push_table_diagnostic(diagnostics, unsupported);
2749                            }
2750                        }
2751                        for unsupported in &cascade.unsupported {
2752                            push_table_diagnostic(diagnostics, unsupported);
2753                        }
2754                        let referenced_fill = cascade
2755                            .fill_reference
2756                            .as_ref()
2757                            .map(|reference| self.table_referenced_fill(reference))
2758                            .transpose()?
2759                            .flatten();
2760                        let (fill, fill_unsupported) = referenced_fill
2761                            .as_ref()
2762                            .or(cascade.fill.as_ref())
2763                            .map(|fill| self.concrete_fill(fill, (1.0, 1.0)))
2764                            .transpose()?
2765                            .unwrap_or((None, None));
2766                        if let Some(unsupported) = fill_unsupported {
2767                            push_table_diagnostic(diagnostics, unsupported);
2768                        }
2769                        let table_text_style = table_character_properties(&cascade.text_style)?;
2770                        let mut text = if let Some(body) = cell.text_body.as_ref() {
2771                            let (body, directions) = resolve_standalone_text_body(
2772                                self,
2773                                body,
2774                                &table_text_style,
2775                                source,
2776                                hyperlinks,
2777                                diagnostics,
2778                            )?;
2779                            text_directions.push(directions);
2780                            Some(body)
2781                        } else {
2782                            text_directions.push(Vec::new());
2783                            None
2784                        };
2785                        if let Some(body) = text.as_mut()
2786                            && let Some(anchor) = cell
2787                                .properties
2788                                .as_ref()
2789                                .and_then(|properties| properties.anchor)
2790                        {
2791                            body.anchor = resolved_anchor(anchor);
2792                        }
2793                        if let Some(body) = text.as_mut()
2794                            && body.autofit == ResolvedAutofit::Shape
2795                        {
2796                            body.autofit = ResolvedAutofit::None;
2797                            let message = "table cell autofit is unsupported and was ignored";
2798                            if !diagnostics
2799                                .iter()
2800                                .any(|diagnostic| diagnostic.message == message)
2801                            {
2802                                diagnostics.push(Diagnostic {
2803                                    message: message.to_owned(),
2804                                });
2805                            }
2806                        }
2807                        Ok(ResolvedTableCell {
2808                            text,
2809                            fill,
2810                            margins: TextInsets {
2811                                left: emu_to_points(cascade.margin_left.unwrap_or(91_440)),
2812                                top: emu_to_points(cascade.margin_top.unwrap_or(45_720)),
2813                                right: emu_to_points(cascade.margin_right.unwrap_or(91_440)),
2814                                bottom: emu_to_points(cascade.margin_bottom.unwrap_or(45_720)),
2815                            },
2816                            left: self.resolve_table_border(cascade.left, diagnostics)?,
2817                            right: self.resolve_table_border(cascade.right, diagnostics)?,
2818                            top: self.resolve_table_border(cascade.top, diagnostics)?,
2819                            bottom: self.resolve_table_border(cascade.bottom, diagnostics)?,
2820                            row_span: cell.row_span,
2821                            grid_span: cell.grid_span,
2822                            horizontal_merge: cell.horizontal_merge,
2823                            vertical_merge: cell.vertical_merge,
2824                        })
2825                    })
2826                    .collect::<Result<Vec<_>, ResolveError>>()?;
2827                Ok(ResolvedTableRow {
2828                    height: emu_to_points(row.height.0),
2829                    cells,
2830                })
2831            })
2832            .collect::<Result<Vec<_>, ResolveError>>()?;
2833        let background = match style.and_then(|style| style.background.as_ref()) {
2834            Some(background) => {
2835                let size = (
2836                    column_widths.iter().sum(),
2837                    rows.iter().map(|row| row.height).sum(),
2838                );
2839                self.resolve_table_background(background, size, diagnostics)?
2840            }
2841            None => None,
2842        };
2843        Ok(ResolvedTable {
2844            right_to_left: table_properties.is_some_and(|properties| properties.right_to_left),
2845            column_widths,
2846            rows,
2847            background,
2848        })
2849    }
2850
2851    /// Resolves a table style's `a:tblBg` fill over the whole table box.
2852    fn resolve_table_background(
2853        &self,
2854        background: &CT_TableBackgroundStyle,
2855        size: (f64, f64),
2856        diagnostics: &mut Vec<Diagnostic>,
2857    ) -> Result<Option<Paint>, ResolveError> {
2858        let referenced = background
2859            .fill_reference
2860            .as_ref()
2861            .map(|reference| self.table_referenced_fill(reference))
2862            .transpose()?
2863            .flatten();
2864        let (paint, unsupported) = referenced
2865            .as_ref()
2866            .or(background.fill.as_ref())
2867            .map(|fill| self.concrete_fill(fill, size))
2868            .transpose()?
2869            .unwrap_or((None, None));
2870        for unsupported in background
2871            .unsupported
2872            .iter()
2873            .map(String::as_str)
2874            .chain(unsupported)
2875        {
2876            let message = format!("unsupported table background {unsupported} was ignored");
2877            if !diagnostics
2878                .iter()
2879                .any(|diagnostic| diagnostic.message == message)
2880            {
2881                diagnostics.push(Diagnostic { message });
2882            }
2883        }
2884        Ok(paint)
2885    }
2886
2887    fn resolve_table_border(
2888        &self,
2889        border: Option<(CT_LineProperties, u8)>,
2890        diagnostics: &mut Vec<Diagnostic>,
2891    ) -> Result<Option<ResolvedTableBorder>, ResolveError> {
2892        border
2893            .map(|(line, priority)| {
2894                let stroke = self.concrete_line(&line, (1.0, 1.0))?;
2895                if stroke.is_none()
2896                    && let Some(fill) = &line.fill
2897                {
2898                    let (_, unsupported) = self.concrete_fill(fill, (1.0, 1.0))?;
2899                    if let Some(unsupported) = unsupported {
2900                        push_table_diagnostic(diagnostics, unsupported);
2901                    }
2902                }
2903                Ok(ResolvedTableBorder { stroke, priority })
2904            })
2905            .transpose()
2906    }
2907
2908    fn table_referenced_fill(
2909        &self,
2910        reference: &StyleReference,
2911    ) -> Result<Option<Fill>, ResolveError> {
2912        let StyleReference::Fill(reference) = reference else {
2913            return Ok(None);
2914        };
2915        let matrix = &self.theme.theme_elements.format_scheme;
2916        let Some(mut fill) = referenced_fill(
2917            reference.index,
2918            &matrix.fill_styles,
2919            &matrix.background_fill_styles,
2920        )?
2921        else {
2922            return Ok(None);
2923        };
2924        substitute_fill(&mut fill, reference.color.as_ref(), "table cell")?;
2925        Ok(Some(fill))
2926    }
2927}
2928
2929#[derive(Default)]
2930struct TableCellCascade {
2931    fill: Option<Fill>,
2932    fill_reference: Option<StyleReference>,
2933    left: Option<(CT_LineProperties, u8)>,
2934    right: Option<(CT_LineProperties, u8)>,
2935    top: Option<(CT_LineProperties, u8)>,
2936    bottom: Option<(CT_LineProperties, u8)>,
2937    margin_left: Option<i64>,
2938    margin_right: Option<i64>,
2939    margin_top: Option<i64>,
2940    margin_bottom: Option<i64>,
2941    text_style: TableTextCascade,
2942    unsupported: Vec<String>,
2943}
2944
2945#[derive(Default)]
2946struct TableTextCascade {
2947    bold: Option<bool>,
2948    italic: Option<bool>,
2949    font_collection: Option<FontCollectionIndex>,
2950    color: Option<ColorChoice>,
2951}
2952
2953#[derive(Clone, Copy)]
2954struct TableCellPosition {
2955    row: usize,
2956    column: usize,
2957    row_count: usize,
2958    column_count: usize,
2959}
2960
2961fn table_character_properties(
2962    style: &TableTextCascade,
2963) -> Result<CT_TextCharacterProperties, ResolveError> {
2964    let mut properties = CT_TextCharacterProperties::default();
2965    properties.bold = style.bold;
2966    properties.italic = style.italic;
2967    if let Some(color) = &style.color {
2968        let mut solid = SolidFill::default();
2969        solid.color = Some(color.clone());
2970        properties.fill = Some(Fill::Solid(solid));
2971    }
2972    let font_tokens = match style.font_collection {
2973        Some(FontCollectionIndex::Major) => Some(("+mj-lt", "+mj-ea", "+mj-cs")),
2974        Some(FontCollectionIndex::Minor) => Some(("+mn-lt", "+mn-ea", "+mn-cs")),
2975        Some(FontCollectionIndex::None) | None => None,
2976    };
2977    if let Some((latin, east_asian, complex_script)) = font_tokens {
2978        let font = |typeface| {
2979            TextFont::new(typeface).map_err(|error| ResolveError::ConcreteValue {
2980                kind: "table font",
2981                detail: error.to_string(),
2982            })
2983        };
2984        properties.latin = Some(font(latin)?);
2985        properties.east_asian = Some(font(east_asian)?);
2986        properties.complex_script = Some(font(complex_script)?);
2987    }
2988    Ok(properties)
2989}
2990
2991impl TableCellCascade {
2992    fn apply_region(
2993        &mut self,
2994        region: Option<&CT_TablePartStyle>,
2995        whole_table: bool,
2996        position: TableCellPosition,
2997        priority: u8,
2998    ) {
2999        let Some(region) = region else {
3000            return;
3001        };
3002        if let Some(cell_style) = &region.cell_style {
3003            self.apply_cell_style(cell_style, whole_table, position, priority);
3004        }
3005        if let Some(text_style) = &region.text_style {
3006            self.apply_text_style(text_style);
3007        }
3008    }
3009
3010    fn apply_cell_style(
3011        &mut self,
3012        style: &CT_TableCellStyle,
3013        whole_table: bool,
3014        position: TableCellPosition,
3015        priority: u8,
3016    ) {
3017        if let Some(fill) = &style.fill {
3018            self.fill = Some(fill.clone());
3019            self.fill_reference = None;
3020        }
3021        if let Some(reference) = &style.fill_reference {
3022            self.fill_reference = Some(reference.clone());
3023            self.fill = None;
3024        }
3025        if let Some(borders) = &style.borders {
3026            self.apply_borders(borders, whole_table, position, priority);
3027            self.unsupported.extend(borders.unsupported.iter().cloned());
3028        }
3029        self.unsupported.extend(style.unsupported.iter().cloned());
3030    }
3031
3032    fn apply_borders(
3033        &mut self,
3034        borders: &CT_TableBorders,
3035        whole_table: bool,
3036        position: TableCellPosition,
3037        priority: u8,
3038    ) {
3039        let left = if whole_table {
3040            if position.column > 0 {
3041                borders.inside_vertical.as_ref()
3042            } else {
3043                borders.left.as_ref()
3044            }
3045        } else {
3046            borders.left.as_ref().or(borders.inside_vertical.as_ref())
3047        };
3048        let right = if whole_table {
3049            if position.column + 1 < position.column_count {
3050                borders.inside_vertical.as_ref()
3051            } else {
3052                borders.right.as_ref()
3053            }
3054        } else {
3055            borders.right.as_ref().or(borders.inside_vertical.as_ref())
3056        };
3057        let top = if whole_table {
3058            if position.row > 0 {
3059                borders.inside_horizontal.as_ref()
3060            } else {
3061                borders.top.as_ref()
3062            }
3063        } else {
3064            borders.top.as_ref().or(borders.inside_horizontal.as_ref())
3065        };
3066        let bottom = if whole_table {
3067            if position.row + 1 < position.row_count {
3068                borders.inside_horizontal.as_ref()
3069            } else {
3070                borders.bottom.as_ref()
3071            }
3072        } else {
3073            borders
3074                .bottom
3075                .as_ref()
3076                .or(borders.inside_horizontal.as_ref())
3077        };
3078        apply_table_edge(&mut self.left, left, priority);
3079        apply_table_edge(&mut self.right, right, priority);
3080        apply_table_edge(&mut self.top, top, priority);
3081        apply_table_edge(&mut self.bottom, bottom, priority);
3082    }
3083
3084    fn apply_text_style(&mut self, style: &CT_TableTextStyle) {
3085        if style.bold.is_some() {
3086            self.text_style.bold = style.bold;
3087        }
3088        if style.italic.is_some() {
3089            self.text_style.italic = style.italic;
3090        }
3091        if let Some(StyleReference::Font(reference)) = &style.font_reference {
3092            self.text_style.font_collection = Some(reference.index);
3093            if reference.color.is_some() {
3094                self.text_style.color = reference.color.clone();
3095            }
3096        }
3097        if style.color.is_some() {
3098            self.text_style.color = style.color.clone();
3099        }
3100    }
3101
3102    fn apply_direct(&mut self, properties: &oxml_drawing::table::CT_TableCellProperties) {
3103        if let Some(fill) = &properties.fill {
3104            self.fill = Some(fill.clone());
3105            self.fill_reference = None;
3106        }
3107        if let Some(value) = properties.margin_left {
3108            self.margin_left = Some(value.0);
3109        }
3110        if let Some(value) = properties.margin_right {
3111            self.margin_right = Some(value.0);
3112        }
3113        if let Some(value) = properties.margin_top {
3114            self.margin_top = Some(value.0);
3115        }
3116        if let Some(value) = properties.margin_bottom {
3117            self.margin_bottom = Some(value.0);
3118        }
3119        apply_table_edge(&mut self.left, properties.left.as_ref(), u8::MAX);
3120        apply_table_edge(&mut self.right, properties.right.as_ref(), u8::MAX);
3121        apply_table_edge(&mut self.top, properties.top.as_ref(), u8::MAX);
3122        apply_table_edge(&mut self.bottom, properties.bottom.as_ref(), u8::MAX);
3123    }
3124}
3125
3126fn apply_table_edge(
3127    target: &mut Option<(CT_LineProperties, u8)>,
3128    source: Option<&CT_LineProperties>,
3129    priority: u8,
3130) {
3131    if let Some(source) = source {
3132        *target = Some((source.clone(), priority));
3133    }
3134}
3135
3136fn push_table_diagnostic(diagnostics: &mut Vec<Diagnostic>, unsupported: &str) {
3137    let message = format!("unsupported table cell {unsupported} was ignored");
3138    if !diagnostics
3139        .iter()
3140        .any(|diagnostic| diagnostic.message == message)
3141    {
3142        diagnostics.push(Diagnostic { message });
3143    }
3144}
3145
3146fn push_group_diagnostics(issues: u8, diagnostics: &mut Vec<Diagnostic>) {
3147    let messages = [
3148        (
3149            GROUP_ZERO_X,
3150            "zero horizontal group child extent used finite unit scale",
3151        ),
3152        (
3153            GROUP_ZERO_Y,
3154            "zero vertical group child extent used finite unit scale",
3155        ),
3156        (
3157            GROUP_SHEAR,
3158            "unsupported sheared or singular group transform retained as affine fallback",
3159        ),
3160    ];
3161    for (flag, message) in messages {
3162        if issues & flag != 0
3163            && !diagnostics
3164                .iter()
3165                .any(|diagnostic| diagnostic.message == message)
3166        {
3167            diagnostics.push(Diagnostic {
3168                message: message.to_owned(),
3169            });
3170        }
3171    }
3172}
3173
3174fn scaled_group_bounds(bounds: Rect, scale: (f64, f64)) -> Rect {
3175    Rect {
3176        x: bounds.x * scale.0,
3177        y: bounds.y * scale.1,
3178        width: bounds.width * scale.0,
3179        height: bounds.height * scale.1,
3180    }
3181}
3182
3183fn transform_values(transform: Option<&CT_Transform2D>) -> Option<(Rect, f64, bool, bool)> {
3184    transform_values_with_degenerate_line(transform, false)
3185}
3186
3187fn connector_transform_values(
3188    transform: Option<&CT_Transform2D>,
3189) -> Option<(Rect, f64, bool, bool)> {
3190    transform_values_with_degenerate_line(transform, true)
3191}
3192
3193fn transform_values_with_degenerate_line(
3194    transform: Option<&CT_Transform2D>,
3195    allow_degenerate_line: bool,
3196) -> Option<(Rect, f64, bool, bool)> {
3197    let transform = transform?;
3198    let extent = transform.extent?;
3199    let invalid = if allow_degenerate_line {
3200        extent.cx.0 < 0 || extent.cy.0 < 0 || (extent.cx.0 == 0 && extent.cy.0 == 0)
3201    } else {
3202        extent.cx.0 <= 0 || extent.cy.0 <= 0
3203    };
3204    if invalid {
3205        return None;
3206    }
3207    let offset = transform.offset.unwrap_or_default();
3208    Some((
3209        Rect {
3210            x: emu_to_points(offset.x.0),
3211            y: emu_to_points(offset.y.0),
3212            width: emu_to_points(extent.cx.0),
3213            height: emu_to_points(extent.cy.0),
3214        },
3215        f64::from(transform.rotation.0) / 60_000.0,
3216        transform.flip_horizontal,
3217        transform.flip_vertical,
3218    ))
3219}
3220
3221fn emu_to_points(value: i64) -> f64 {
3222    value as f64 / 12_700.0
3223}
3224
3225fn base_rgb(choice: &ColorChoice) -> Option<RgbColor> {
3226    let resolved = resolve_color(choice, &ColorMap::default(), &[]).ok()?;
3227    Some(RgbColor::new(resolved.red, resolved.green, resolved.blue))
3228}
3229
3230fn concrete_path_command(command: EvaluatedPathCommand, scale_x: f64, scale_y: f64) -> PathCommand {
3231    match command {
3232        EvaluatedPathCommand::MoveTo { x, y } => PathCommand::MoveTo(Point {
3233            x: x * scale_x,
3234            y: y * scale_y,
3235        }),
3236        EvaluatedPathCommand::LineTo { x, y } => PathCommand::LineTo(Point {
3237            x: x * scale_x,
3238            y: y * scale_y,
3239        }),
3240        EvaluatedPathCommand::CubicTo {
3241            x1,
3242            y1,
3243            x2,
3244            y2,
3245            x,
3246            y,
3247        } => PathCommand::CurveTo {
3248            c1: Point {
3249                x: x1 * scale_x,
3250                y: y1 * scale_y,
3251            },
3252            c2: Point {
3253                x: x2 * scale_x,
3254                y: y2 * scale_y,
3255            },
3256            to: Point {
3257                x: x * scale_x,
3258                y: y * scale_y,
3259            },
3260        },
3261        EvaluatedPathCommand::Close => PathCommand::Close,
3262    }
3263}
3264
3265fn coordinate_points(value: Option<&Coordinate32Value>) -> Result<f64, ResolveError> {
3266    match value {
3267        Some(Coordinate32Value::Emu(value)) => Ok(emu_to_points(i64::from(*value))),
3268        Some(Coordinate32Value::UniversalMeasure(value)) => universal_measure_points(value),
3269        None => Ok(0.0),
3270    }
3271}
3272
3273fn universal_measure_points(value: &str) -> Result<f64, ResolveError> {
3274    let split = value
3275        .find(|character: char| character.is_ascii_alphabetic())
3276        .unwrap_or(value.len());
3277    let (number, unit) = value.split_at(split);
3278    let number = number
3279        .parse::<f64>()
3280        .map_err(|error| ResolveError::ConcreteValue {
3281            kind: "universal measure",
3282            detail: error.to_string(),
3283        })?;
3284    match unit {
3285        "pt" => Ok(number),
3286        "pc" | "pi" => Ok(number * 12.0),
3287        "in" => Ok(number * 72.0),
3288        "cm" => Ok(number * 72.0 / 2.54),
3289        "mm" => Ok(number * 72.0 / 25.4),
3290        _ => Err(ResolveError::ConcreteValue {
3291            kind: "universal measure",
3292            detail: format!("unsupported unit {unit}"),
3293        }),
3294    }
3295}
3296
3297fn parse_percent(value: &str) -> Option<f64> {
3298    value.parse::<f64>().ok().map(|value| value / 100_000.0)
3299}
3300
3301fn text_point_value(value: &TextPointValue) -> Option<f64> {
3302    match value {
3303        TextPointValue::Centipoints(value) => Some(f64::from(*value) / 100.0),
3304        TextPointValue::UniversalMeasure(value) => universal_measure_points(value).ok(),
3305    }
3306}
3307
3308fn resolved_text_spacing(value: Option<&TextSpacing>) -> Option<ResolvedTextSpacing> {
3309    match value? {
3310        TextSpacing::Percent(value) => parse_percent(value).map(ResolvedTextSpacing::Percent),
3311        TextSpacing::Points(value) => Some(ResolvedTextSpacing::Points(f64::from(*value) / 100.0)),
3312    }
3313}
3314
3315fn paragraph_alignment(alignment: Option<TextAlignment>) -> ParagraphAlignment {
3316    match alignment.unwrap_or(TextAlignment::Left) {
3317        TextAlignment::Left => ParagraphAlignment::Left,
3318        TextAlignment::Center => ParagraphAlignment::Center,
3319        TextAlignment::Right => ParagraphAlignment::Right,
3320        TextAlignment::Justified | TextAlignment::JustifiedLow => ParagraphAlignment::Justified,
3321        TextAlignment::Distributed | TextAlignment::ThaiDistributed => {
3322            ParagraphAlignment::Distributed
3323        }
3324    }
3325}
3326
3327fn paragraph_base_direction(right_to_left: Option<bool>) -> oxml_layout::TextDirection {
3328    match right_to_left {
3329        Some(true) => oxml_layout::TextDirection::RightToLeft,
3330        Some(false) => oxml_layout::TextDirection::LeftToRight,
3331        None => oxml_layout::TextDirection::Auto,
3332    }
3333}
3334
3335fn vertical_text_diagnostic(direction: TextDirection) -> Option<&'static str> {
3336    match direction {
3337        TextDirection::EastAsianVertical => {
3338            Some("east Asian vertical text rendered as rotated vertical text")
3339        }
3340        TextDirection::MongolianVertical => {
3341            Some("Mongolian vertical text rendered as rotated vertical-270 text")
3342        }
3343        TextDirection::WordArtVertical => {
3344            Some("WordArt vertical text rendered as rotated vertical text")
3345        }
3346        TextDirection::WordArtVerticalRtl => {
3347            Some("right-to-left WordArt vertical text rendered as rotated vertical-270 text")
3348        }
3349        TextDirection::Horizontal | TextDirection::Vertical | TextDirection::Vertical270 => None,
3350    }
3351}
3352
3353fn resolve_standalone_text_body(
3354    context: &ResolveCtx<'_>,
3355    body: &CT_TextBody,
3356    table_style: &CT_TextCharacterProperties,
3357    source: FlattenedSource,
3358    hyperlinks: Option<&ScopedHyperlinkTargets>,
3359    diagnostics: &mut Vec<Diagnostic>,
3360) -> Result<(ResolvedTextBody, Vec<oxml_layout::TextDirection>), ResolveError> {
3361    let mut properties = default_body_properties();
3362    merge_body_properties(&mut properties, &body.body_properties);
3363    let resolved = body
3364        .paragraphs()
3365        .iter()
3366        .map(|paragraph| {
3367            let effective = context.effective_table_text_properties(
3368                Some(table_style),
3369                paragraph.properties.as_ref(),
3370                None,
3371            );
3372            let paragraph_properties = &effective.paragraph;
3373            let runs = paragraph
3374                .runs
3375                .iter()
3376                .map(|run| match run {
3377                    TextRun::Run(run) => {
3378                        let mut style = context.resolve_table_run_style(
3379                            table_style,
3380                            paragraph.properties.as_ref(),
3381                            run.properties.as_ref(),
3382                        )?;
3383                        style.hyperlink_url = resolve_direct_hyperlink(
3384                            run.properties.as_ref(),
3385                            source,
3386                            hyperlinks,
3387                            diagnostics,
3388                        );
3389                        Ok(ResolvedTextRun::Text {
3390                            text: run.text.value.clone(),
3391                            style,
3392                        })
3393                    }
3394                    TextRun::Break(_) => Ok(ResolvedTextRun::Break),
3395                    TextRun::Field(field) => {
3396if field.text.is_none()
3397                        && field.field_type.as_deref().is_some_and(|kind| kind == "datetime" || (1..=13).any(|index| kind == format!("datetime{index}")))
3398                    {
3399                        diagnostics.push(Diagnostic { message: "uncached automatic date field has no computed date for static rendering".to_owned() });
3400                    }
3401                        let mut style = context.resolve_table_run_style(
3402                            table_style,
3403                            paragraph.properties.as_ref(),
3404                            field.run_properties.as_ref(),
3405                        )?;
3406                        style.hyperlink_url = resolve_direct_hyperlink(
3407                            field.run_properties.as_ref(),
3408                            source,
3409                            hyperlinks,
3410                            diagnostics,
3411                        );
3412                        Ok(ResolvedTextRun::Field {
3413                            text: field
3414                                .text
3415                                .as_ref()
3416                                .map(|text| text.value.clone())
3417                                .unwrap_or_default(),
3418                            field_type: field.field_type.clone(),
3419                            style,
3420                        })
3421                    }
3422                })
3423                .collect::<Result<Vec<_>, ResolveError>>()?;
3424            let direction = paragraph_base_direction(paragraph_properties.right_to_left);
3425            Ok((
3426                ResolvedParagraph {
3427                    level: paragraph_properties.level.unwrap_or(0),
3428                    left_margin: emu_to_points(i64::from(
3429                        paragraph_properties.left_margin.unwrap_or(0),
3430                    )),
3431                    right_margin: emu_to_points(i64::from(
3432                        paragraph_properties.right_margin.unwrap_or(0),
3433                    )),
3434                    indent: emu_to_points(i64::from(paragraph_properties.indent.unwrap_or(0))),
3435                    alignment: paragraph_alignment(paragraph_properties.alignment),
3436                    line_spacing: resolved_text_spacing(paragraph_properties.line_spacing.as_ref()),
3437                    space_before: resolved_text_spacing(paragraph_properties.space_before.as_ref()),
3438                    space_after: resolved_text_spacing(paragraph_properties.space_after.as_ref()),
3439                    bullet: paragraph_properties
3440                        .bullet
3441                        .as_ref()
3442                        .and_then(|bullet| context.resolve_bullet(bullet).transpose())
3443                        .transpose()?,
3444                    end_style: context.resolve_table_run_style(
3445                        table_style,
3446                        paragraph.properties.as_ref(),
3447                        paragraph.end_properties.as_ref(),
3448                    )?,
3449                    runs,
3450                },
3451                direction,
3452            ))
3453        })
3454        .collect::<Result<Vec<_>, ResolveError>>()?;
3455    let (paragraphs, directions) = resolved.into_iter().unzip();
3456    Ok((resolved_text_body(&properties, paragraphs)?, directions))
3457}
3458
3459fn resolved_anchor(anchor: DrawingTextAnchor) -> TextAnchor {
3460    match anchor {
3461        DrawingTextAnchor::Top => TextAnchor::Top,
3462        DrawingTextAnchor::Center => TextAnchor::Center,
3463        DrawingTextAnchor::Bottom => TextAnchor::Bottom,
3464        DrawingTextAnchor::Justified => TextAnchor::Justified,
3465        DrawingTextAnchor::Distributed => TextAnchor::Distributed,
3466    }
3467}
3468
3469fn resolved_text_body(
3470    properties: &CT_TextBodyProperties,
3471    paragraphs: Vec<ResolvedParagraph>,
3472) -> Result<ResolvedTextBody, ResolveError> {
3473    let insets = TextInsets {
3474        left: coordinate_points(properties.left_inset.as_ref())?,
3475        top: coordinate_points(properties.top_inset.as_ref())?,
3476        right: coordinate_points(properties.right_inset.as_ref())?,
3477        bottom: coordinate_points(properties.bottom_inset.as_ref())?,
3478    };
3479    let anchor = resolved_anchor(properties.anchor.unwrap_or(DrawingTextAnchor::Top));
3480    let vertical = match properties
3481        .vertical
3482        .unwrap_or(DrawingTextVertical::Horizontal)
3483    {
3484        DrawingTextVertical::Horizontal => TextDirection::Horizontal,
3485        DrawingTextVertical::Vertical => TextDirection::Vertical,
3486        DrawingTextVertical::Vertical270 => TextDirection::Vertical270,
3487        DrawingTextVertical::EastAsianVertical => TextDirection::EastAsianVertical,
3488        DrawingTextVertical::MongolianVertical => TextDirection::MongolianVertical,
3489        DrawingTextVertical::WordArtVertical => TextDirection::WordArtVertical,
3490        DrawingTextVertical::WordArtVerticalRtl => TextDirection::WordArtVerticalRtl,
3491    };
3492    let autofit = match properties.autofit.clone().unwrap_or(TextAutofit::NoAutofit) {
3493        TextAutofit::NoAutofit => ResolvedAutofit::None,
3494        TextAutofit::ShapeAutofit => ResolvedAutofit::Shape,
3495        TextAutofit::Normal(normal) => ResolvedAutofit::Normal {
3496            font_scale: normal.font_scale.as_deref().and_then(parse_percent),
3497            line_spacing_reduction: normal
3498                .line_spacing_reduction
3499                .as_deref()
3500                .and_then(parse_percent),
3501        },
3502    };
3503    Ok(ResolvedTextBody {
3504        insets,
3505        anchor,
3506        wrap: properties.wrap.unwrap_or(TextWrap::Square) != TextWrap::None,
3507        vertical,
3508        space_first_last_paragraph: properties.space_first_last_paragraph.unwrap_or(false),
3509        autofit,
3510        paragraphs,
3511    })
3512}
3513
3514#[derive(Clone, Copy)]
3515struct PassRules {
3516    source: FlattenedSource,
3517    emit_non_placeholders: bool,
3518}
3519
3520fn push_unmatched(keys: &mut Vec<PlaceholderKey>, key: PlaceholderKey) {
3521    if !keys
3522        .iter()
3523        .any(|existing| placeholder_keys_match(&key, existing))
3524    {
3525        keys.push(key);
3526    }
3527}
3528
3529fn find_group_lineage(
3530    children: &[ShapeTreeChild],
3531    target: &ShapeTreeChild,
3532    lineage: &[u32],
3533    found: &mut Vec<u32>,
3534) -> bool {
3535    let mut pending = children
3536        .iter()
3537        .rev()
3538        .map(|c| (c, lineage.to_vec()))
3539        .collect::<Vec<_>>();
3540    while let Some((child, mut ancestors)) = pending.pop() {
3541        if std::ptr::eq(child, target) {
3542            *found = ancestors;
3543            return true;
3544        }
3545        let descendants = match child {
3546            ShapeTreeChild::GroupShape(group) => {
3547                if let Some(id) = child.non_visual_id() {
3548                    ancestors.push(id);
3549                }
3550                Some(group.children.as_slice())
3551            }
3552            ShapeTreeChild::AlternateContent(alternate) => alternate.selected_fallback(),
3553            _ => None,
3554        };
3555        if let Some(descendants) = descendants {
3556            pending.extend(descendants.iter().rev().map(|c| (c, ancestors.clone())));
3557        }
3558    }
3559    false
3560}
3561
3562fn apply_timeline_state(
3563    mut slide: ResolvedSlide,
3564    identities: Vec<ResolvedShapeIdentity>,
3565    mut state: EvaluatedFrameState,
3566    mut group_geometries: BTreeMap<u32, TimelineGroupGeometry>,
3567) -> ResolvedTimelineSlide {
3568    let invalid_targets = state
3569        .shapes
3570        .iter()
3571        .filter_map(|(target, evaluated)| (!evaluated.is_finite()).then_some(*target))
3572        .collect::<Vec<_>>();
3573    for target in invalid_targets {
3574        state.shapes.insert(target, EvaluatedShapeState::default());
3575        state.diagnostics.push(Diagnostic {
3576            message: format!("non-finite timeline state ignored for target {target}"),
3577        });
3578    }
3579    let slide_size = slide.size;
3580    for (group_id, bounds) in timeline_group_bounds(&slide, &identities) {
3581        group_geometries
3582            .entry(group_id)
3583            .or_insert_with(|| TimelineGroupGeometry::from_page_bounds(bounds));
3584    }
3585    let shape_states = identities
3586        .iter()
3587        .zip(&mut slide.shapes)
3588        .map(|(identity, shape)| {
3589            if identity.source != FlattenedSource::Slide {
3590                return EvaluatedShapeState::default();
3591            }
3592            let mut evaluated = EvaluatedShapeState::default();
3593            let original_bounds = shape.bounds;
3594            let original_shape = shape.clone();
3595            let mut group_transform = Transform::IDENTITY;
3596            let mut group_page_clips = Vec::new();
3597            for group_id in &identity.containing_group_ids {
3598                let Some(target_state) = state.shapes.get(group_id) else {
3599                    continue;
3600                };
3601                evaluated.visible &= target_state.visible;
3602                evaluated.opacity *= target_state.opacity;
3603                let Some(geometry) = group_geometries.get(group_id).copied() else {
3604                    continue;
3605                };
3606                let target_transform =
3607                    target_state.resolved_page_transform(geometry.center(), slide_size);
3608                group_transform = target_transform.then(group_transform);
3609                if let Some(clip) = target_state.clip {
3610                    group_page_clips.push(group_clip_page_points(clip, geometry, group_transform));
3611                }
3612            }
3613            let direct_state = identity.shape_id.and_then(|id| state.shapes.get(&id));
3614            if let Some(direct_state) = direct_state {
3615                evaluated.visible &= direct_state.visible;
3616                evaluated.opacity *= direct_state.opacity;
3617                evaluated.clip = intersect_optional_rect(evaluated.clip, direct_state.clip);
3618            }
3619            let (scale_x, scale_y, rotation_deg) = direct_state.map_or(
3620                (1.0, 1.0, 0.0),
3621                EvaluatedShapeState::resolved_shape_geometry,
3622            );
3623            let original_page_bounds = resolved_shape_page_bounds(&original_shape);
3624            let original_page_center = (
3625                original_page_bounds.x + original_page_bounds.width / 2.0,
3626                original_page_bounds.y + original_page_bounds.height / 2.0,
3627            );
3628            let page_motion = direct_state.map_or(Transform::IDENTITY, |direct_state| {
3629                direct_state.resolved_motion_translation(original_page_center, slide_size)
3630            });
3631            evaluated.transform = page_motion.then(group_transform);
3632            let animated_bounds = Rect {
3633                x: original_bounds.x + original_bounds.width * (1.0 - scale_x) / 2.0,
3634                y: original_bounds.y + original_bounds.height * (1.0 - scale_y) / 2.0,
3635                width: original_bounds.width * scale_x,
3636                height: original_bounds.height * scale_y,
3637            };
3638            let animated_rotation = shape.rotation_deg + rotation_deg;
3639            let mut animated_shape = shape.clone();
3640            animated_shape.bounds = animated_bounds;
3641            animated_shape.rotation_deg = animated_rotation;
3642            animated_shape.group_transform = animated_shape
3643                .group_transform
3644                .then(page_motion)
3645                .then(group_transform);
3646            for page_clip in group_page_clips {
3647                match page_clip_for_shape(page_clip, &animated_shape) {
3648                    Some(clip) => evaluated.push_oriented_clip(clip),
3649                    None => state.diagnostics.push(Diagnostic {
3650                        message: format!(
3651                            "group timeline clip ignored for shape {}",
3652                            identity.shape_id.unwrap_or(0)
3653                        ),
3654                    }),
3655                }
3656            }
3657            if evaluated.is_finite()
3658                && [
3659                    animated_bounds.x,
3660                    animated_bounds.y,
3661                    animated_bounds.width,
3662                    animated_bounds.height,
3663                    animated_rotation,
3664                ]
3665                .into_iter()
3666                .all(f64::is_finite)
3667            {
3668                *shape = animated_shape;
3669            } else {
3670                state.diagnostics.push(Diagnostic {
3671                    message: format!(
3672                        "non-finite timeline state ignored for shape {}",
3673                        identity.shape_id.unwrap_or(0)
3674                    ),
3675                });
3676                evaluated = EvaluatedShapeState::default();
3677            }
3678            evaluated
3679        })
3680        .collect();
3681    ResolvedTimelineSlide {
3682        slide,
3683        identities,
3684        shape_states,
3685        state,
3686    }
3687}
3688
3689#[derive(Clone, Copy)]
3690struct TimelineGroupGeometry {
3691    normalized_to_page: Transform,
3692}
3693
3694impl TimelineGroupGeometry {
3695    fn from_page_bounds(bounds: Rect) -> Self {
3696        Self {
3697            normalized_to_page: Transform {
3698                a: bounds.width,
3699                d: bounds.height,
3700                e: bounds.x,
3701                f: bounds.y,
3702                ..Transform::IDENTITY
3703            },
3704        }
3705    }
3706
3707    fn center(self) -> (f64, f64) {
3708        let center = self.normalized_to_page.apply(Point { x: 0.5, y: 0.5 });
3709        (center.x, center.y)
3710    }
3711}
3712
3713fn timeline_group_geometries(children: &[ShapeTreeChild]) -> BTreeMap<u32, TimelineGroupGeometry> {
3714    let mut geometries = BTreeMap::new();
3715    collect_timeline_group_geometries(children, Transform::IDENTITY, &mut geometries);
3716    geometries
3717}
3718
3719fn collect_timeline_group_geometries(
3720    children: &[ShapeTreeChild],
3721    parent_to_page: Transform,
3722    geometries: &mut BTreeMap<u32, TimelineGroupGeometry>,
3723) {
3724    let mut pending = children
3725        .iter()
3726        .rev()
3727        .map(|c| (c, parent_to_page))
3728        .collect::<Vec<_>>();
3729    while let Some((child, parent)) = pending.pop() {
3730        match child {
3731            ShapeTreeChild::GroupShape(group) => {
3732                let child_to_parent = group
3733                    .group_transform()
3734                    .and_then(group_affine)
3735                    .map_or(Transform::IDENTITY, |(transform, _)| transform);
3736                if let Some((id, geometry)) = child
3737                    .non_visual_id()
3738                    .zip(group.group_transform().and_then(group_extent_geometry))
3739                {
3740                    geometries.insert(
3741                        id,
3742                        TimelineGroupGeometry {
3743                            normalized_to_page: geometry.then(parent),
3744                        },
3745                    );
3746                }
3747                pending.extend(
3748                    group
3749                        .children
3750                        .iter()
3751                        .rev()
3752                        .map(|c| (c, child_to_parent.then(parent))),
3753                );
3754            }
3755            ShapeTreeChild::AlternateContent(alternate) => {
3756                if let Some(fallback) = alternate.selected_fallback() {
3757                    pending.extend(fallback.iter().rev().map(|c| (c, parent)));
3758                }
3759            }
3760            _ => {}
3761        }
3762    }
3763}
3764
3765fn group_extent_geometry(transform: &CT_Transform2D) -> Option<Transform> {
3766    let offset = transform.offset?;
3767    let extent = transform.extent?;
3768    let x = emu_to_points(offset.x.0);
3769    let y = emu_to_points(offset.y.0);
3770    let width = emu_to_points(extent.cx.0);
3771    let height = emu_to_points(extent.cy.0);
3772    let center_x = x + width / 2.0;
3773    let center_y = y + height / 2.0;
3774    let mut geometry = Transform {
3775        a: width,
3776        d: height,
3777        e: x,
3778        f: y,
3779        ..Transform::IDENTITY
3780    }
3781    .then(Transform::rotate_about(
3782        f64::from(transform.rotation.0) / 60_000.0,
3783        center_x,
3784        center_y,
3785    ));
3786    if transform.flip_horizontal || transform.flip_vertical {
3787        geometry = geometry.then(Transform {
3788            a: if transform.flip_horizontal { -1.0 } else { 1.0 },
3789            d: if transform.flip_vertical { -1.0 } else { 1.0 },
3790            e: if transform.flip_horizontal {
3791                2.0 * center_x
3792            } else {
3793                0.0
3794            },
3795            f: if transform.flip_vertical {
3796                2.0 * center_y
3797            } else {
3798                0.0
3799            },
3800            ..Transform::IDENTITY
3801        });
3802    }
3803    [
3804        geometry.a, geometry.b, geometry.c, geometry.d, geometry.e, geometry.f,
3805    ]
3806    .into_iter()
3807    .all(f64::is_finite)
3808    .then_some(geometry)
3809}
3810
3811fn timeline_group_bounds(
3812    slide: &ResolvedSlide,
3813    identities: &[ResolvedShapeIdentity],
3814) -> BTreeMap<u32, Rect> {
3815    let mut bounds = BTreeMap::new();
3816    for (identity, shape) in identities.iter().zip(&slide.shapes) {
3817        if identity.source != FlattenedSource::Slide {
3818            continue;
3819        }
3820        let shape_bounds = resolved_shape_page_bounds(shape);
3821        for group_id in &identity.containing_group_ids {
3822            bounds
3823                .entry(*group_id)
3824                .and_modify(|group_bounds| *group_bounds = union_rect(*group_bounds, shape_bounds))
3825                .or_insert(shape_bounds);
3826        }
3827    }
3828    bounds
3829}
3830
3831fn resolved_shape_page_bounds(shape: &ResolvedShape) -> Rect {
3832    resolved_shape_page_transform(shape).transform_rect_bbox(Rect {
3833        x: 0.0,
3834        y: 0.0,
3835        width: shape.bounds.width,
3836        height: shape.bounds.height,
3837    })
3838}
3839
3840fn resolved_shape_page_transform(shape: &ResolvedShape) -> Transform {
3841    let center_x = shape.bounds.width / 2.0;
3842    let center_y = shape.bounds.height / 2.0;
3843    let flip = Transform {
3844        a: if shape.flip_h { -1.0 } else { 1.0 },
3845        d: if shape.flip_v { -1.0 } else { 1.0 },
3846        e: if shape.flip_h {
3847            shape.bounds.width
3848        } else {
3849            0.0
3850        },
3851        f: if shape.flip_v {
3852            shape.bounds.height
3853        } else {
3854            0.0
3855        },
3856        ..Transform::IDENTITY
3857    };
3858    Transform::rotate_about(shape.rotation_deg, center_x, center_y)
3859        .then(flip)
3860        .then(timeline_translation(shape.bounds.x, shape.bounds.y))
3861        .then(shape.group_transform)
3862}
3863
3864fn group_clip_page_points(
3865    clip: Rect,
3866    group_geometry: TimelineGroupGeometry,
3867    group_transform: Transform,
3868) -> [Point; 4] {
3869    let normalized_to_page = group_geometry.normalized_to_page.then(group_transform);
3870    [
3871        Point {
3872            x: clip.x,
3873            y: clip.y,
3874        },
3875        Point {
3876            x: clip.x + clip.width,
3877            y: clip.y,
3878        },
3879        Point {
3880            x: clip.x + clip.width,
3881            y: clip.y + clip.height,
3882        },
3883        Point {
3884            x: clip.x,
3885            y: clip.y + clip.height,
3886        },
3887    ]
3888    .map(|point| normalized_to_page.apply(point))
3889}
3890
3891fn page_clip_for_shape(page_clip: [Point; 4], shape: &ResolvedShape) -> Option<[Point; 4]> {
3892    if shape.bounds.width.abs() <= f64::EPSILON || shape.bounds.height.abs() <= f64::EPSILON {
3893        return None;
3894    }
3895    let page_to_local = inverse_transform(resolved_shape_page_transform(shape))?;
3896    Some(page_clip.map(|point| {
3897        let local = page_to_local.apply(point);
3898        Point {
3899            x: local.x / shape.bounds.width,
3900            y: local.y / shape.bounds.height,
3901        }
3902    }))
3903}
3904
3905fn inverse_transform(transform: Transform) -> Option<Transform> {
3906    let determinant = transform.a * transform.d - transform.b * transform.c;
3907    if !determinant.is_finite() || determinant.abs() <= f64::EPSILON {
3908        return None;
3909    }
3910    let inverse = Transform {
3911        a: transform.d / determinant,
3912        b: -transform.b / determinant,
3913        c: -transform.c / determinant,
3914        d: transform.a / determinant,
3915        e: (transform.c * transform.f - transform.d * transform.e) / determinant,
3916        f: (transform.b * transform.e - transform.a * transform.f) / determinant,
3917    };
3918    [
3919        inverse.a, inverse.b, inverse.c, inverse.d, inverse.e, inverse.f,
3920    ]
3921    .into_iter()
3922    .all(f64::is_finite)
3923    .then_some(inverse)
3924}
3925
3926fn union_rect(left: Rect, right: Rect) -> Rect {
3927    let x = left.x.min(right.x);
3928    let y = left.y.min(right.y);
3929    let far_x = (left.x + left.width).max(right.x + right.width);
3930    let far_y = (left.y + left.height).max(right.y + right.height);
3931    Rect {
3932        x,
3933        y,
3934        width: far_x - x,
3935        height: far_y - y,
3936    }
3937}
3938
3939fn intersect_rect(left: Rect, right: Rect) -> Rect {
3940    let x = left.x.max(right.x);
3941    let y = left.y.max(right.y);
3942    let far_x = (left.x + left.width).min(right.x + right.width);
3943    let far_y = (left.y + left.height).min(right.y + right.height);
3944    Rect {
3945        x,
3946        y,
3947        width: (far_x - x).max(0.0),
3948        height: (far_y - y).max(0.0),
3949    }
3950}
3951
3952fn timeline_translation(x: f64, y: f64) -> Transform {
3953    Transform {
3954        e: x,
3955        f: y,
3956        ..Transform::IDENTITY
3957    }
3958}
3959
3960fn intersect_optional_rect(left: Option<Rect>, right: Option<Rect>) -> Option<Rect> {
3961    match (left, right) {
3962        (None, None) => None,
3963        (Some(rect), None) | (None, Some(rect)) => Some(rect),
3964        (Some(left), Some(right)) => Some(intersect_rect(left, right)),
3965    }
3966}
3967
3968fn collect_owned_items<'a>(
3969    children: &'a [ShapeTreeChild],
3970    source: FlattenedSource,
3971    parent: Transform,
3972    issues: u8,
3973    path: &[usize],
3974    output: &mut Vec<(Vec<usize>, FlattenedItem<'a>)>,
3975) {
3976    let mut pending = children
3977        .iter()
3978        .enumerate()
3979        .rev()
3980        .map(|(ordinal, c)| {
3981            let mut own_path = path.to_vec();
3982            own_path.push(ordinal);
3983            (c, parent, issues, own_path)
3984        })
3985        .collect::<Vec<_>>();
3986    while let Some((child, parent, issues, path)) = pending.pop() {
3987        if let ShapeTreeChild::GroupShape(group) = child {
3988            let (transform, own_issues) = group
3989                .group_transform()
3990                .and_then(group_affine)
3991                .unwrap_or((Transform::IDENTITY, GROUP_ZERO_X | GROUP_ZERO_Y));
3992            pending.extend(group.children.iter().enumerate().rev().map(|(ordinal, c)| {
3993                let mut own_path = path.clone();
3994                own_path.push(ordinal);
3995                (c, transform.then(parent), issues | own_issues, own_path)
3996            }));
3997        } else {
3998            let mut leaf = Vec::new();
3999            push_flattened_shape(&mut leaf, source, child, parent, issues);
4000            output.push((path, leaf.remove(0)));
4001        }
4002    }
4003}
4004
4005fn emit_tree<'a>(
4006    children: &'a [ShapeTreeChild],
4007    rules: PassRules,
4008    output: &mut Vec<FlattenedItem<'a>>,
4009) {
4010    let mut emitted_latent = Vec::new();
4011    emit_tree_inner(
4012        children,
4013        rules,
4014        Transform::IDENTITY,
4015        0,
4016        &mut emitted_latent,
4017        output,
4018    );
4019}
4020
4021fn emit_tree_inner<'a>(
4022    children: &'a [ShapeTreeChild],
4023    rules: PassRules,
4024    parent_transform: Transform,
4025    parent_issues: u8,
4026    emitted_latent: &mut Vec<PlaceholderKey>,
4027    output: &mut Vec<FlattenedItem<'a>>,
4028) {
4029    let mut pending = children
4030        .iter()
4031        .rev()
4032        .map(|c| (c, parent_transform, parent_issues))
4033        .collect::<Vec<_>>();
4034    while let Some((child, parent, inherited_issues)) = pending.pop() {
4035        match child {
4036            ShapeTreeChild::GroupShape(group) => {
4037                let (transform, issues) = group
4038                    .group_transform()
4039                    .and_then(group_affine)
4040                    .unwrap_or((Transform::IDENTITY, 0));
4041                pending.extend(
4042                    group
4043                        .children
4044                        .iter()
4045                        .rev()
4046                        .map(|c| (c, transform.then(parent), inherited_issues | issues)),
4047                );
4048            }
4049            ShapeTreeChild::AlternateContent(alternate) if alternate.chart_choice().is_none() => {
4050                if let Some(fallback) = alternate.selected_fallback() {
4051                    pending.extend(fallback.iter().rev().map(|c| (c, parent, inherited_issues)));
4052                }
4053            }
4054            _ => emit_leaf(
4055                child,
4056                rules,
4057                parent,
4058                inherited_issues,
4059                emitted_latent,
4060                output,
4061            ),
4062        }
4063    }
4064}
4065
4066fn emit_leaf<'a>(
4067    child: &'a ShapeTreeChild,
4068    rules: PassRules,
4069    group_transform: Transform,
4070    group_issues: u8,
4071    emitted_latent: &mut Vec<PlaceholderKey>,
4072    output: &mut Vec<FlattenedItem<'a>>,
4073) {
4074    let placeholder = child_placeholder(child);
4075    if rules.source == FlattenedSource::Slide {
4076        if let Some(placeholder) = placeholder {
4077            let ph_type = placeholder.effective_type();
4078            if is_latent(&ph_type) {
4079                let occupied = child_is_occupied(child);
4080                let already_emitted = emitted_latent
4081                    .iter()
4082                    .any(|key| placeholder_keys_match(&placeholder.key(), key));
4083                if !occupied || already_emitted {
4084                    return;
4085                }
4086                push_unmatched(emitted_latent, placeholder.key());
4087            }
4088        }
4089        push_flattened_shape(output, rules.source, child, group_transform, group_issues);
4090        return;
4091    }
4092
4093    let Some(placeholder) = placeholder else {
4094        if rules.emit_non_placeholders {
4095            push_flattened_shape(output, rules.source, child, group_transform, group_issues);
4096        }
4097        return;
4098    };
4099    // Native occupied master/layout footer shapes are authoring templates,
4100    // never inherited visible content. Ordinary master/layout shapes above
4101    // retain their existing showMasterSp policy.
4102    let _ = (placeholder, emitted_latent);
4103}
4104
4105fn push_flattened_shape<'a>(
4106    output: &mut Vec<FlattenedItem<'a>>,
4107    source: FlattenedSource,
4108    child: &'a ShapeTreeChild,
4109    group_transform: Transform,
4110    group_issues: u8,
4111) {
4112    let (group_scale, group_transform, group_issues) =
4113        split_group_transform(group_transform, group_issues);
4114    output.push(FlattenedItem::Shape {
4115        source,
4116        child,
4117        group_scale,
4118        group_transform,
4119        group_issues,
4120    });
4121}
4122
4123fn split_group_transform(transform: Transform, mut issues: u8) -> ((f64, f64), Transform, u8) {
4124    let scale_x = transform.a.hypot(transform.b);
4125    let scale_y = transform.c.hypot(transform.d);
4126    let dot = transform.a * transform.c + transform.b * transform.d;
4127    let denominator = scale_x * scale_y;
4128    let is_rigid_after_scale = [
4129        transform.a,
4130        transform.b,
4131        transform.c,
4132        transform.d,
4133        transform.e,
4134        transform.f,
4135        scale_x,
4136        scale_y,
4137    ]
4138    .into_iter()
4139    .all(f64::is_finite)
4140        && scale_x > f64::EPSILON
4141        && scale_y > f64::EPSILON
4142        && (dot / denominator).abs() <= 1.0e-9;
4143
4144    if !is_rigid_after_scale {
4145        issues |= GROUP_SHEAR;
4146        return ((1.0, 1.0), transform, issues);
4147    }
4148
4149    (
4150        (scale_x, scale_y),
4151        Transform {
4152            a: transform.a / scale_x,
4153            b: transform.b / scale_x,
4154            c: transform.c / scale_y,
4155            d: transform.d / scale_y,
4156            e: transform.e,
4157            f: transform.f,
4158        },
4159        issues,
4160    )
4161}
4162
4163fn group_affine(transform: &CT_Transform2D) -> Option<(Transform, u8)> {
4164    let offset = transform.offset?;
4165    let extent = transform.extent?;
4166    let child_offset = transform.child_offset?;
4167    let child_extent = transform.child_extent?;
4168    let mut issues = 0;
4169    let x = emu_to_points(offset.x.0);
4170    let y = emu_to_points(offset.y.0);
4171    let width = emu_to_points(extent.cx.0);
4172    let height = emu_to_points(extent.cy.0);
4173    let child_x = emu_to_points(child_offset.x.0);
4174    let child_y = emu_to_points(child_offset.y.0);
4175    let scale_x = if child_extent.cx.0 == 0 {
4176        issues |= GROUP_ZERO_X;
4177        1.0
4178    } else {
4179        extent.cx.0 as f64 / child_extent.cx.0 as f64
4180    };
4181    let scale_y = if child_extent.cy.0 == 0 {
4182        issues |= GROUP_ZERO_Y;
4183        1.0
4184    } else {
4185        extent.cy.0 as f64 / child_extent.cy.0 as f64
4186    };
4187    let mut affine = Transform {
4188        a: scale_x,
4189        b: 0.0,
4190        c: 0.0,
4191        d: scale_y,
4192        e: x - child_x * scale_x,
4193        f: y - child_y * scale_y,
4194    };
4195    let center_x = x + width / 2.0;
4196    let center_y = y + height / 2.0;
4197    if transform.flip_horizontal || transform.flip_vertical {
4198        affine = affine.then(Transform {
4199            a: if transform.flip_horizontal { -1.0 } else { 1.0 },
4200            b: 0.0,
4201            c: 0.0,
4202            d: if transform.flip_vertical { -1.0 } else { 1.0 },
4203            e: if transform.flip_horizontal {
4204                2.0 * center_x
4205            } else {
4206                0.0
4207            },
4208            f: if transform.flip_vertical {
4209                2.0 * center_y
4210            } else {
4211                0.0
4212            },
4213        });
4214    }
4215    affine = affine.then(Transform::rotate_about(
4216        f64::from(transform.rotation.0) / 60_000.0,
4217        center_x,
4218        center_y,
4219    ));
4220    Some((affine, issues))
4221}
4222
4223fn child_placeholder(child: &ShapeTreeChild) -> Option<&CT_Placeholder> {
4224    match child {
4225        ShapeTreeChild::Shape(shape) => shape.placeholder.as_ref(),
4226        ShapeTreeChild::Picture(picture) => picture.placeholder.as_ref(),
4227        _ => None,
4228    }
4229}
4230
4231fn child_is_occupied(child: &ShapeTreeChild) -> bool {
4232    match child {
4233        ShapeTreeChild::Shape(shape) => shape.text_body.as_ref().is_some_and(|body| {
4234            !body.plain_text().trim().is_empty()
4235                || body.paragraphs().iter().any(|paragraph| {
4236                    paragraph
4237                        .runs
4238                        .iter()
4239                        .any(|run| matches!(run, TextRun::Field(_)))
4240                })
4241        }),
4242        ShapeTreeChild::Picture(picture) => picture.blip_fill.is_some(),
4243        _ => false,
4244    }
4245}
4246
4247fn is_latent(ph_type: &PhType) -> bool {
4248    matches!(
4249        ph_type,
4250        PhType::DateTime | PhType::Footer | PhType::SlideNumber
4251    )
4252}
4253
4254fn placeholder_keys_match(left: &PlaceholderKey, right: &PlaceholderKey) -> bool {
4255    if is_latent(&left.ph_type) && is_latent(&right.ph_type) {
4256        left.ph_type == right.ph_type
4257    } else {
4258        left.matches(right)
4259    }
4260}
4261
4262fn default_body_properties() -> CT_TextBodyProperties {
4263    let mut properties = CT_TextBodyProperties::default();
4264    properties.left_inset = Some(Coordinate32Value::Emu(91_440));
4265    properties.top_inset = Some(Coordinate32Value::Emu(45_720));
4266    properties.right_inset = Some(Coordinate32Value::Emu(91_440));
4267    properties.bottom_inset = Some(Coordinate32Value::Emu(45_720));
4268    properties.anchor = Some(DrawingTextAnchor::Top);
4269    properties.wrap = Some(TextWrap::Square);
4270    properties.vertical = Some(DrawingTextVertical::Horizontal);
4271    properties.space_first_last_paragraph = Some(false);
4272    properties.autofit = Some(TextAutofit::NoAutofit);
4273    properties
4274}
4275
4276fn merge_body_properties(target: &mut CT_TextBodyProperties, source: &CT_TextBodyProperties) {
4277    if let Some(value) = &source.left_inset {
4278        target.left_inset = Some(value.clone());
4279    }
4280    if let Some(value) = &source.top_inset {
4281        target.top_inset = Some(value.clone());
4282    }
4283    if let Some(value) = &source.right_inset {
4284        target.right_inset = Some(value.clone());
4285    }
4286    if let Some(value) = &source.bottom_inset {
4287        target.bottom_inset = Some(value.clone());
4288    }
4289    if let Some(value) = source.anchor {
4290        target.anchor = Some(value);
4291    }
4292    if let Some(value) = source.wrap {
4293        target.wrap = Some(value);
4294    }
4295    if let Some(value) = source.vertical {
4296        target.vertical = Some(value);
4297    }
4298    if let Some(value) = source.space_first_last_paragraph {
4299        target.space_first_last_paragraph = Some(value);
4300    }
4301    if let Some(value) = &source.autofit {
4302        target.autofit = Some(value.clone());
4303    }
4304}
4305
4306/// Returns the transform a slide placeholder inherits, ignoring its own.
4307///
4308/// The placeholder matches a layout placeholder, whose own key then matches
4309/// a master placeholder. The first of the two that carries a transform
4310/// supplies it whole, which is the fallback [`ResolveCtx::effective_xfrm`]
4311/// takes when the slide shape has no transform of its own.
4312pub fn inherited_xfrm<'a>(
4313    placeholder: &CT_Placeholder,
4314    layout: &'a CT_SlideLayout,
4315    master: &'a CT_SlideMaster,
4316) -> Option<&'a CT_Transform2D> {
4317    let (layout, master) = placeholder_chain(placeholder, layout, master);
4318    [layout, master]
4319        .into_iter()
4320        .flatten()
4321        .find_map(|shape| shape.shape_properties.transform.as_ref())
4322}
4323
4324fn placeholder_chain<'a>(
4325    placeholder: &CT_Placeholder,
4326    layout: &'a CT_SlideLayout,
4327    master: &'a CT_SlideMaster,
4328) -> (Option<&'a CT_Shape>, Option<&'a CT_Shape>) {
4329    let Some(layout_shape) = find_placeholder(
4330        &layout.common_slide_data.shape_tree.children,
4331        &placeholder.key(),
4332    ) else {
4333        return (None, None);
4334    };
4335    let Some(layout_key) = layout_shape
4336        .placeholder
4337        .as_ref()
4338        .map(|placeholder| placeholder.key())
4339    else {
4340        return (None, None);
4341    };
4342    let master_shape = find_placeholder(&master.common_slide_data.shape_tree.children, &layout_key);
4343    (Some(layout_shape), master_shape)
4344}
4345
4346fn find_placeholder<'a>(
4347    children: &'a [ShapeTreeChild],
4348    key: &PlaceholderKey,
4349) -> Option<&'a CT_Shape> {
4350    for child in children {
4351        let found = match child {
4352            ShapeTreeChild::Shape(shape) => shape
4353                .placeholder
4354                .as_ref()
4355                .is_some_and(|placeholder| placeholder_keys_match(key, &placeholder.key()))
4356                .then_some(shape),
4357            ShapeTreeChild::GroupShape(group) => find_placeholder(&group.children, key),
4358            ShapeTreeChild::AlternateContent(alternate) => alternate
4359                .selected_fallback()
4360                .and_then(|fallback| find_placeholder(fallback, key)),
4361            _ => None,
4362        };
4363        if found.is_some() {
4364            return found;
4365        }
4366    }
4367    None
4368}
4369
4370fn blend_fill_colors(a: Color, b: Color, t: f64) -> Color {
4371    let t = t.clamp(0.0, 1.0);
4372    Color {
4373        r: a.r + (b.r - a.r) * t,
4374        g: a.g + (b.g - a.g) * t,
4375        b: a.b + (b.b - a.b) * t,
4376        a: a.a + (b.a - a.a) * t,
4377    }
4378}
4379
4380// PowerPoint's documented two-colour endpoint and mirrored three-colour
4381// gradients use channel-directed gamma 1.875. Other stop lists remain linear.
4382// This PPT resolver samples the curve for existing encoded-RGB Paint consumers.
4383// Word colour/tint policy and source-derived pattern palette weights stay intact.
4384fn powerpoint_fill_colors(a: Color, b: Color, t: f64) -> Color {
4385    fn channel(a: f64, b: f64, t: f64) -> f64 {
4386        let weight = if b >= a {
4387            1.0 - (1.0 - t).powf(1.875)
4388        } else {
4389            t.powf(1.875)
4390        };
4391        a + (b - a) * weight
4392    }
4393    let t = t.clamp(0.0, 1.0);
4394    Color {
4395        r: channel(a.r, b.r, t),
4396        g: channel(a.g, b.g, t),
4397        b: channel(a.b, b.b, t),
4398        a: a.a + (b.a - a.a) * t,
4399    }
4400}
4401
4402fn powerpoint_gradient_stops(stops: &[GradientStop]) -> Vec<GradientStop> {
4403    let mut ordered = stops.to_vec();
4404    ordered.sort_by(|a, b| a.offset.total_cmp(&b.offset));
4405    let gamma = ordered.len() == 2 && ordered[0].offset == 0.0 && ordered[1].offset == 1.0
4406        || ordered.len() == 3
4407            && ordered[0].offset == 0.0
4408            && ordered[2].offset == 1.0
4409            && ordered[0].color == ordered[2].color;
4410    if !gamma {
4411        return ordered;
4412    }
4413    let mut sampled = Vec::with_capacity(ordered.len() + 257);
4414    for i in 0..=256 {
4415        let offset = f64::from(i) / 256.0;
4416        sampled.push(GradientStop {
4417            offset,
4418            color: path_gradient_color(&ordered, offset),
4419        });
4420    }
4421    // Retain exact original stops, including coincident hard colour boundaries.
4422    sampled.extend(ordered);
4423    sampled.sort_by(|a, b| a.offset.total_cmp(&b.offset));
4424    sampled
4425}
4426
4427fn path_gradient_color(stops: &[GradientStop], distance: f64) -> Color {
4428    let Some(first) = stops.first() else {
4429        return Color::BLACK;
4430    };
4431    let gamma = stops.len() == 2 && stops[0].offset == 0.0 && stops[1].offset == 1.0
4432        || stops.len() == 3
4433            && stops[0].offset == 0.0
4434            && stops[2].offset == 1.0
4435            && stops[0].color == stops[2].color;
4436    let distance = distance.clamp(0.0, 1.0);
4437    if distance <= first.offset {
4438        return first.color;
4439    }
4440    for pair in stops.windows(2) {
4441        if distance <= pair[1].offset {
4442            let width = pair[1].offset - pair[0].offset;
4443            let t = if width > 0.0 {
4444                (distance - pair[0].offset) / width
4445            } else {
4446                1.0
4447            };
4448            return if gamma {
4449                powerpoint_fill_colors(pair[0].color, pair[1].color, t)
4450            } else {
4451                blend_fill_colors(pair[0].color, pair[1].color, t)
4452            };
4453        }
4454    }
4455    stops.last().map(|stop| stop.color).unwrap_or(first.color)
4456}
4457
4458// Native PowerPoint 16.113.4 source-owned preset tiles, 8pt with 64 samples per axis.
4459// Original PDF SHA82e0bfb2cd978c59ebac96d72020e2d28fc2a0bead9728ad8ba3e8b1e34674c0.
4460// Each two-bit weight is background, half foreground, or foreground. Equal rows
4461// share only constant data, retaining every sample from the independently captured tiles.
4462const NATIVE_PATTERN_ROW_WEIGHTS: [u128; 77] = [
4463    0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa,
4464    0xaaaa0000000000000000000000000000,
4465    0xaaaaaaaa00000000aaaaaaaa00000000,
4466    0x0000aaaaaaaa00000000aaaaaaaa0000,
4467    0x00000000aaaaaaaa00000000aaaaaaaa,
4468    0xaaaa00000000aaaaaaaa00000000aaaa,
4469    0x00000000000000000000000000000000,
4470    0xaaaa000000000000aaaa000000000000,
4471    0x0000aaaa000000000000aaaa00000000,
4472    0x00000000aaaa000000000000aaaa0000,
4473    0x000000000000aaaa000000000000aaaa,
4474    0xaaaaaaaaaaaaaaaa0000000000000000,
4475    0x0000000000000000aaaaaaaaaaaaaaaa,
4476    0x0000000000000000aaaa000000000000,
4477    0x0000000000000000000000000000aaaa,
4478    0x000000000000000000000000aaaa0000,
4479    0x00000000000000000000aaaa00000000,
4480    0x000000000000aaaaaaaa000000000000,
4481    0x00000000aaaa00000000aaaa00000000,
4482    0x0000aaaa0000000000000000aaaa0000,
4483    0xaaaa000000000000000000000000aaaa,
4484    0xaaaa555500000000000000005555aaaa,
4485    0x5555aaaa5555000000005555aaaa5555,
4486    0x00005555aaaa55555555aaaa55550000,
4487    0x000000005555aaaaaaaa555500000000,
4488    0x000000000000aaaa0000000000000000,
4489    0xaaaa0000aaaa0000aaaa0000aaaa0000,
4490    0xaaaa5555000000000000000000005555,
4491    0x5555aaaa555500000000000000000000,
4492    0x00005555aaaa55550000000000000000,
4493    0x000000005555aaaa5555000000000000,
4494    0x0000000000005555aaaa555500000000,
4495    0x00000000000000005555aaaa55550000,
4496    0x000000000000000000005555aaaa5555,
4497    0x5555000000000000000000005555aaaa,
4498    0xaaaa0000aaaaaaaa000000000000aaaa,
4499    0x00000000aaaaaaaa0000000000000000,
4500    0x000000000000000000000000aaaaaaaa,
4501    0x000000000000aaaaaaaa0000aaaaaaaa,
4502    0xaaaaaaaa0000aaaaaaaa000000000000,
4503    0xaaaaaaaa000000000000000000000000,
4504    0x0000000000000000aaaaaaaa00000000,
4505    0xaaaa000000000000aaaaaaaa0000aaaa,
4506    0x0000aaaa0000aaaa0000aaaa0000aaaa,
4507    0xaaaa00000000000000000000aaaa0000,
4508    0x00000000aaaa0000aaaa000000000000,
4509    0x0000aaaa0000aaaa000000000000aaaa,
4510    0x000000000000aaaa0000aaaa0000aaaa,
4511    0xaaaaaaaaaaaa0000aaaaaaaaaaaa0000,
4512    0xaaaa0000aaaaaaaaaaaa0000aaaaaaaa,
4513    0x0000aaaaaaaaaaaa0000aaaaaaaaaaaa,
4514    0xaaaaaaaa0000aaaaaaaaaaaa0000aaaa,
4515    0xaaaaaaaaaaaa0000aaaaaaaaaaaaaaaa,
4516    0xaaaaaaaaaaaaaaaaaaaaaaaaaaaa0000,
4517    0xaaaaaaaaaaaaaaaa0000aaaaaaaaaaaa,
4518    0x0000aaaaaaaaaaaaaaaaaaaaaaaaaaaa,
4519    0xaaaa0000000000000000aaaa00000000,
4520    0x0000aaaa00000000aaaa000000000000,
4521    0x0000aaaa000000000000000000000000,
4522    0x00000000aaaa00000000000000000000,
4523    0x00000000aaaaaaaaaaaa000000000000,
4524    0x0000aaaaaaaaaaaaaaaaaaaa00000000,
4525    0xaaaa000000000000aaaa00000000aaaa,
4526    0xaaaa000000000000aaaaaaaaaaaaaaaa,
4527    0xaaaa00000000aaaaaaaa000000000000,
4528    0xaaaaaaaaaaaaaaaaaaaa000000000000,
4529    0x00000000aaaa00000000aaaa0000aaaa,
4530    0x0000aaaa0000aaaa0000aaaa00000000,
4531    0x0000aaaa000000000000aaaa0000aaaa,
4532    0x000000000000aaaa0000aaaa00000000,
4533    0xaaaaaaaa00000000000000000000aaaa,
4534    0xaaaaaaaaaaaa00000000000000000000,
4535    0x0000aaaaaaaaaaaa0000000000000000,
4536    0x000000000000aaaaaaaaaaaa00000000,
4537    0x0000000000000000aaaaaaaaaaaa0000,
4538    0x00000000000000000000aaaaaaaaaaaa,
4539    0xaaaa00000000000000000000aaaaaaaa,
4540];
4541
4542fn native_pattern_rows(preset: &str) -> Option<&'static [u8; 64]> {
4543    match preset {
4544        "cross" => Some(&[
4545            0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
4546            1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
4547            1, 1, 1, 1, 1, 1,
4548        ]),
4549        "dkDnDiag" => Some(&[
4550            2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 5, 5, 5,
4551            5, 5, 5, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5,
4552            5, 5, 5, 5, 5, 5,
4553        ]),
4554        "dkHorz" => Some(&[
4555            0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6,
4556            6, 6, 6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6,
4557            6, 6, 6, 6, 6, 6,
4558        ]),
4559        "dkUpDiag" => Some(&[
4560            4, 4, 4, 4, 4, 4, 4, 4, 3, 3, 3, 3, 3, 3, 3, 3, 2, 2, 2, 2, 2, 2, 2, 2, 5, 5, 5, 5, 5,
4561            5, 5, 5, 4, 4, 4, 4, 4, 4, 4, 4, 3, 3, 3, 3, 3, 3, 3, 3, 2, 2, 2, 2, 2, 2, 2, 2, 5, 5,
4562            5, 5, 5, 5, 5, 5,
4563        ]),
4564        "dkVert" => Some(&[
4565            2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2,
4566            2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2,
4567            2, 2, 2, 2, 2, 2,
4568        ]),
4569        "dashDnDiag" => Some(&[
4570            6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 7, 7, 7, 7, 7, 7, 7, 7, 8, 8, 8, 8, 8,
4571            8, 8, 8, 9, 9, 9, 9, 9, 9, 9, 9, 10, 10, 10, 10, 10, 10, 10, 10, 6, 6, 6, 6, 6, 6, 6,
4572            6, 6, 6, 6, 6, 6, 6, 6, 6,
4573        ]),
4574        "dashHorz" => Some(&[
4575            11, 11, 11, 11, 11, 11, 11, 11, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6,
4576            6, 6, 6, 6, 6, 6, 12, 12, 12, 12, 12, 12, 12, 12, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6,
4577            6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6,
4578        ]),
4579        "dashUpDiag" => Some(&[
4580            6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 10, 10, 10, 10, 10, 10, 10, 10, 9, 9,
4581            9, 9, 9, 9, 9, 9, 8, 8, 8, 8, 8, 8, 8, 8, 7, 7, 7, 7, 7, 7, 7, 7, 6, 6, 6, 6, 6, 6, 6,
4582            6, 6, 6, 6, 6, 6, 6, 6, 6,
4583        ]),
4584        "dashVert" => Some(&[
4585            1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
4586            1, 1, 1, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13,
4587            13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13,
4588        ]),
4589        "diagBrick" => Some(&[
4590            14, 14, 14, 14, 14, 14, 14, 14, 15, 15, 15, 15, 15, 15, 15, 15, 16, 16, 16, 16, 16, 16,
4591            16, 16, 13, 13, 13, 13, 13, 13, 13, 13, 17, 17, 17, 17, 17, 17, 17, 17, 18, 18, 18, 18,
4592            18, 18, 18, 18, 19, 19, 19, 19, 19, 19, 19, 19, 20, 20, 20, 20, 20, 20, 20, 20,
4593        ]),
4594        "diagCross" => Some(&[
4595            21, 21, 21, 21, 21, 21, 21, 21, 22, 22, 22, 22, 22, 22, 22, 22, 23, 23, 23, 23, 23, 23,
4596            23, 23, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 23, 23, 23, 23,
4597            23, 23, 23, 23, 22, 22, 22, 22, 22, 22, 22, 22, 21, 21, 21, 21, 21, 21, 21, 21,
4598        ]),
4599        "divot" => Some(&[
4600            6, 6, 6, 6, 6, 6, 6, 6, 25, 25, 25, 25, 25, 25, 25, 25, 13, 13, 13, 13, 13, 13, 13, 13,
4601            25, 25, 25, 25, 25, 25, 25, 25, 6, 6, 6, 6, 6, 6, 6, 6, 1, 1, 1, 1, 1, 1, 1, 1, 14, 14,
4602            14, 14, 14, 14, 14, 14, 1, 1, 1, 1, 1, 1, 1, 1,
4603        ]),
4604        "dotDmnd" => Some(&[
4605            1, 1, 1, 1, 1, 1, 1, 1, 6, 6, 6, 6, 6, 6, 6, 6, 9, 9, 9, 9, 9, 9, 9, 9, 6, 6, 6, 6, 6,
4606            6, 6, 6, 13, 13, 13, 13, 13, 13, 13, 13, 6, 6, 6, 6, 6, 6, 6, 6, 9, 9, 9, 9, 9, 9, 9,
4607            9, 6, 6, 6, 6, 6, 6, 6, 6,
4608        ]),
4609        "dotGrid" => Some(&[
4610            26, 26, 26, 26, 26, 26, 26, 26, 6, 6, 6, 6, 6, 6, 6, 6, 1, 1, 1, 1, 1, 1, 1, 1, 6, 6,
4611            6, 6, 6, 6, 6, 6, 1, 1, 1, 1, 1, 1, 1, 1, 6, 6, 6, 6, 6, 6, 6, 6, 1, 1, 1, 1, 1, 1, 1,
4612            1, 6, 6, 6, 6, 6, 6, 6, 6,
4613        ]),
4614        "dnDiag" => Some(&[
4615            27, 27, 27, 27, 27, 27, 27, 27, 28, 28, 28, 28, 28, 28, 28, 28, 29, 29, 29, 29, 29, 29,
4616            29, 29, 30, 30, 30, 30, 30, 30, 30, 30, 31, 31, 31, 31, 31, 31, 31, 31, 32, 32, 32, 32,
4617            32, 32, 32, 32, 33, 33, 33, 33, 33, 33, 33, 33, 34, 34, 34, 34, 34, 34, 34, 34,
4618        ]),
4619        "horz" => Some(&[
4620            0, 0, 0, 0, 0, 0, 0, 0, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6,
4621            6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6,
4622            6, 6, 6, 6, 6, 6,
4623        ]),
4624        "horzBrick" => Some(&[
4625            0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
4626            1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13,
4627            13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13,
4628        ]),
4629        "lgCheck" => Some(&[
4630            11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11,
4631            11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12,
4632            12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12,
4633        ]),
4634        "lgConfetti" => Some(&[
4635            35, 35, 35, 35, 35, 35, 35, 35, 36, 36, 36, 36, 36, 36, 36, 36, 37, 37, 37, 37, 37, 37,
4636            37, 37, 38, 38, 38, 38, 38, 38, 38, 38, 39, 39, 39, 39, 39, 39, 39, 39, 40, 40, 40, 40,
4637            40, 40, 40, 40, 41, 41, 41, 41, 41, 41, 41, 41, 42, 42, 42, 42, 42, 42, 42, 42,
4638        ]),
4639        "lgGrid" => Some(&[
4640            0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
4641            1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
4642            1, 1, 1, 1, 1, 1,
4643        ]),
4644        "ltDnDiag" => Some(&[
4645            7, 7, 7, 7, 7, 7, 7, 7, 8, 8, 8, 8, 8, 8, 8, 8, 9, 9, 9, 9, 9, 9, 9, 9, 10, 10, 10, 10,
4646            10, 10, 10, 10, 7, 7, 7, 7, 7, 7, 7, 7, 8, 8, 8, 8, 8, 8, 8, 8, 9, 9, 9, 9, 9, 9, 9, 9,
4647            10, 10, 10, 10, 10, 10, 10, 10,
4648        ]),
4649        "ltHorz" => Some(&[
4650            0, 0, 0, 0, 0, 0, 0, 0, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6,
4651            6, 6, 6, 0, 0, 0, 0, 0, 0, 0, 0, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6,
4652            6, 6, 6, 6, 6, 6,
4653        ]),
4654        "ltUpDiag" => Some(&[
4655            10, 10, 10, 10, 10, 10, 10, 10, 9, 9, 9, 9, 9, 9, 9, 9, 8, 8, 8, 8, 8, 8, 8, 8, 7, 7,
4656            7, 7, 7, 7, 7, 7, 10, 10, 10, 10, 10, 10, 10, 10, 9, 9, 9, 9, 9, 9, 9, 9, 8, 8, 8, 8,
4657            8, 8, 8, 8, 7, 7, 7, 7, 7, 7, 7, 7,
4658        ]),
4659        "ltVert" => Some(&[
4660            7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7,
4661            7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7,
4662            7, 7, 7, 7, 7, 7,
4663        ]),
4664        "narHorz" => Some(&[
4665            0, 0, 0, 0, 0, 0, 0, 0, 6, 6, 6, 6, 6, 6, 6, 6, 0, 0, 0, 0, 0, 0, 0, 0, 6, 6, 6, 6, 6,
4666            6, 6, 6, 0, 0, 0, 0, 0, 0, 0, 0, 6, 6, 6, 6, 6, 6, 6, 6, 0, 0, 0, 0, 0, 0, 0, 0, 6, 6,
4667            6, 6, 6, 6, 6, 6,
4668        ]),
4669        "narVert" => Some(&[
4670            43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43,
4671            43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43,
4672            43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43,
4673        ]),
4674        "openDmnd" => Some(&[
4675            44, 44, 44, 44, 44, 44, 44, 44, 8, 8, 8, 8, 8, 8, 8, 8, 45, 45, 45, 45, 45, 45, 45, 45,
4676            25, 25, 25, 25, 25, 25, 25, 25, 45, 45, 45, 45, 45, 45, 45, 45, 8, 8, 8, 8, 8, 8, 8, 8,
4677            44, 44, 44, 44, 44, 44, 44, 44, 14, 14, 14, 14, 14, 14, 14, 14,
4678        ]),
4679        "pct10" => Some(&[
4680            1, 1, 1, 1, 1, 1, 1, 1, 6, 6, 6, 6, 6, 6, 6, 6, 13, 13, 13, 13, 13, 13, 13, 13, 6, 6,
4681            6, 6, 6, 6, 6, 6, 1, 1, 1, 1, 1, 1, 1, 1, 6, 6, 6, 6, 6, 6, 6, 6, 13, 13, 13, 13, 13,
4682            13, 13, 13, 6, 6, 6, 6, 6, 6, 6, 6,
4683        ]),
4684        "pct20" => Some(&[
4685            7, 7, 7, 7, 7, 7, 7, 7, 6, 6, 6, 6, 6, 6, 6, 6, 9, 9, 9, 9, 9, 9, 9, 9, 6, 6, 6, 6, 6,
4686            6, 6, 6, 7, 7, 7, 7, 7, 7, 7, 7, 6, 6, 6, 6, 6, 6, 6, 6, 9, 9, 9, 9, 9, 9, 9, 9, 6, 6,
4687            6, 6, 6, 6, 6, 6,
4688        ]),
4689        "pct25" => Some(&[
4690            7, 7, 7, 7, 7, 7, 7, 7, 9, 9, 9, 9, 9, 9, 9, 9, 7, 7, 7, 7, 7, 7, 7, 7, 9, 9, 9, 9, 9,
4691            9, 9, 9, 7, 7, 7, 7, 7, 7, 7, 7, 9, 9, 9, 9, 9, 9, 9, 9, 7, 7, 7, 7, 7, 7, 7, 7, 9, 9,
4692            9, 9, 9, 9, 9, 9,
4693        ]),
4694        "pct30" => Some(&[
4695            26, 26, 26, 26, 26, 26, 26, 26, 8, 8, 8, 8, 8, 8, 8, 8, 26, 26, 26, 26, 26, 26, 26, 26,
4696            10, 10, 10, 10, 10, 10, 10, 10, 26, 26, 26, 26, 26, 26, 26, 26, 8, 8, 8, 8, 8, 8, 8, 8,
4697            26, 26, 26, 26, 26, 26, 26, 26, 10, 10, 10, 10, 10, 10, 10, 10,
4698        ]),
4699        "pct40" => Some(&[
4700            26, 26, 26, 26, 26, 26, 26, 26, 43, 43, 43, 43, 43, 43, 43, 43, 26, 26, 26, 26, 26, 26,
4701            26, 26, 46, 46, 46, 46, 46, 46, 46, 46, 26, 26, 26, 26, 26, 26, 26, 26, 43, 43, 43, 43,
4702            43, 43, 43, 43, 26, 26, 26, 26, 26, 26, 26, 26, 47, 47, 47, 47, 47, 47, 47, 47,
4703        ]),
4704        "pct5" => Some(&[
4705            1, 1, 1, 1, 1, 1, 1, 1, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6,
4706            6, 6, 6, 13, 13, 13, 13, 13, 13, 13, 13, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6,
4707            6, 6, 6, 6, 6, 6, 6, 6, 6,
4708        ]),
4709        "pct50" => Some(&[
4710            26, 26, 26, 26, 26, 26, 26, 26, 43, 43, 43, 43, 43, 43, 43, 43, 26, 26, 26, 26, 26, 26,
4711            26, 26, 43, 43, 43, 43, 43, 43, 43, 43, 26, 26, 26, 26, 26, 26, 26, 26, 43, 43, 43, 43,
4712            43, 43, 43, 43, 26, 26, 26, 26, 26, 26, 26, 26, 43, 43, 43, 43, 43, 43, 43, 43,
4713        ]),
4714        "pct60" => Some(&[
4715            48, 48, 48, 48, 48, 48, 48, 48, 43, 43, 43, 43, 43, 43, 43, 43, 49, 49, 49, 49, 49, 49,
4716            49, 49, 43, 43, 43, 43, 43, 43, 43, 43, 48, 48, 48, 48, 48, 48, 48, 48, 43, 43, 43, 43,
4717            43, 43, 43, 43, 49, 49, 49, 49, 49, 49, 49, 49, 43, 43, 43, 43, 43, 43, 43, 43,
4718        ]),
4719        "pct70" => Some(&[
4720            50, 50, 50, 50, 50, 50, 50, 50, 51, 51, 51, 51, 51, 51, 51, 51, 50, 50, 50, 50, 50, 50,
4721            50, 50, 51, 51, 51, 51, 51, 51, 51, 51, 50, 50, 50, 50, 50, 50, 50, 50, 51, 51, 51, 51,
4722            51, 51, 51, 51, 50, 50, 50, 50, 50, 50, 50, 50, 51, 51, 51, 51, 51, 51, 51, 51,
4723        ]),
4724        "pct75" => Some(&[
4725            50, 50, 50, 50, 50, 50, 50, 50, 0, 0, 0, 0, 0, 0, 0, 0, 51, 51, 51, 51, 51, 51, 51, 51,
4726            0, 0, 0, 0, 0, 0, 0, 0, 50, 50, 50, 50, 50, 50, 50, 50, 0, 0, 0, 0, 0, 0, 0, 0, 51, 51,
4727            51, 51, 51, 51, 51, 51, 0, 0, 0, 0, 0, 0, 0, 0,
4728        ]),
4729        "pct80" => Some(&[
4730            52, 52, 52, 52, 52, 52, 52, 52, 0, 0, 0, 0, 0, 0, 0, 0, 53, 53, 53, 53, 53, 53, 53, 53,
4731            0, 0, 0, 0, 0, 0, 0, 0, 52, 52, 52, 52, 52, 52, 52, 52, 0, 0, 0, 0, 0, 0, 0, 0, 53, 53,
4732            53, 53, 53, 53, 53, 53, 0, 0, 0, 0, 0, 0, 0, 0,
4733        ]),
4734        "pct90" => Some(&[
4735            0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 54, 54, 54, 54,
4736            54, 54, 54, 54, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
4737            55, 55, 55, 55, 55, 55, 55, 55,
4738        ]),
4739        "plaid" => Some(&[
4740            26, 26, 26, 26, 26, 26, 26, 26, 43, 43, 43, 43, 43, 43, 43, 43, 26, 26, 26, 26, 26, 26,
4741            26, 26, 43, 43, 43, 43, 43, 43, 43, 43, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11,
4742            11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11,
4743        ]),
4744        "shingle" => Some(&[
4745            37, 37, 37, 37, 37, 37, 37, 37, 56, 56, 56, 56, 56, 56, 56, 56, 57, 57, 57, 57, 57, 57,
4746            57, 57, 36, 36, 36, 36, 36, 36, 36, 36, 41, 41, 41, 41, 41, 41, 41, 41, 15, 15, 15, 15,
4747            15, 15, 15, 15, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14,
4748        ]),
4749        "smCheck" => Some(&[
4750            5, 5, 5, 5, 5, 5, 5, 5, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 5, 5, 5, 5, 5,
4751            5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 5, 5,
4752            5, 5, 5, 5, 5, 5,
4753        ]),
4754        "smConfetti" => Some(&[
4755            1, 1, 1, 1, 1, 1, 1, 1, 13, 13, 13, 13, 13, 13, 13, 13, 58, 58, 58, 58, 58, 58, 58, 58,
4756            15, 15, 15, 15, 15, 15, 15, 15, 25, 25, 25, 25, 25, 25, 25, 25, 14, 14, 14, 14, 14, 14,
4757            14, 14, 59, 59, 59, 59, 59, 59, 59, 59, 16, 16, 16, 16, 16, 16, 16, 16,
4758        ]),
4759        "smGrid" => Some(&[
4760            0, 0, 0, 0, 0, 0, 0, 0, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7,
4761            7, 7, 7, 0, 0, 0, 0, 0, 0, 0, 0, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7,
4762            7, 7, 7, 7, 7, 7,
4763        ]),
4764        "solidDmnd" => Some(&[
4765            25, 25, 25, 25, 25, 25, 25, 25, 60, 60, 60, 60, 60, 60, 60, 60, 61, 61, 61, 61, 61, 61,
4766            61, 61, 53, 53, 53, 53, 53, 53, 53, 53, 61, 61, 61, 61, 61, 61, 61, 61, 60, 60, 60, 60,
4767            60, 60, 60, 60, 25, 25, 25, 25, 25, 25, 25, 25, 6, 6, 6, 6, 6, 6, 6, 6,
4768        ]),
4769        "sphere" => Some(&[
4770            50, 50, 50, 50, 50, 50, 50, 50, 62, 62, 62, 62, 62, 62, 62, 62, 63, 63, 63, 63, 63, 63,
4771            63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 50, 50, 50, 50, 50, 50, 50, 50, 64, 64, 64, 64,
4772            64, 64, 64, 64, 65, 65, 65, 65, 65, 65, 65, 65, 65, 65, 65, 65, 65, 65, 65, 65,
4773        ]),
4774        "trellis" => Some(&[
4775            0, 0, 0, 0, 0, 0, 0, 0, 3, 3, 3, 3, 3, 3, 3, 3, 0, 0, 0, 0, 0, 0, 0, 0, 5, 5, 5, 5, 5,
4776            5, 5, 5, 0, 0, 0, 0, 0, 0, 0, 0, 3, 3, 3, 3, 3, 3, 3, 3, 0, 0, 0, 0, 0, 0, 0, 0, 5, 5,
4777            5, 5, 5, 5, 5, 5,
4778        ]),
4779        "upDiag" => Some(&[
4780            34, 34, 34, 34, 34, 34, 34, 34, 33, 33, 33, 33, 33, 33, 33, 33, 32, 32, 32, 32, 32, 32,
4781            32, 32, 31, 31, 31, 31, 31, 31, 31, 31, 30, 30, 30, 30, 30, 30, 30, 30, 29, 29, 29, 29,
4782            29, 29, 29, 29, 28, 28, 28, 28, 28, 28, 28, 28, 27, 27, 27, 27, 27, 27, 27, 27,
4783        ]),
4784        "vert" => Some(&[
4785            1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
4786            1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
4787            1, 1, 1, 1, 1, 1,
4788        ]),
4789        "wave" => Some(&[
4790            6, 6, 6, 6, 6, 6, 6, 6, 17, 17, 17, 17, 17, 17, 17, 17, 66, 66, 66, 66, 66, 66, 66, 66,
4791            40, 40, 40, 40, 40, 40, 40, 40, 6, 6, 6, 6, 6, 6, 6, 6, 17, 17, 17, 17, 17, 17, 17, 17,
4792            66, 66, 66, 66, 66, 66, 66, 66, 40, 40, 40, 40, 40, 40, 40, 40,
4793        ]),
4794        "weave" => Some(&[
4795            7, 7, 7, 7, 7, 7, 7, 7, 67, 67, 67, 67, 67, 67, 67, 67, 9, 9, 9, 9, 9, 9, 9, 9, 68, 68,
4796            68, 68, 68, 68, 68, 68, 7, 7, 7, 7, 7, 7, 7, 7, 69, 69, 69, 69, 69, 69, 69, 69, 9, 9,
4797            9, 9, 9, 9, 9, 9, 46, 46, 46, 46, 46, 46, 46, 46,
4798        ]),
4799        "wdDnDiag" => Some(&[
4800            70, 70, 70, 70, 70, 70, 70, 70, 71, 71, 71, 71, 71, 71, 71, 71, 72, 72, 72, 72, 72, 72,
4801            72, 72, 60, 60, 60, 60, 60, 60, 60, 60, 73, 73, 73, 73, 73, 73, 73, 73, 74, 74, 74, 74,
4802            74, 74, 74, 74, 75, 75, 75, 75, 75, 75, 75, 75, 76, 76, 76, 76, 76, 76, 76, 76,
4803        ]),
4804        "wdUpDiag" => Some(&[
4805            76, 76, 76, 76, 76, 76, 76, 76, 75, 75, 75, 75, 75, 75, 75, 75, 74, 74, 74, 74, 74, 74,
4806            74, 74, 73, 73, 73, 73, 73, 73, 73, 73, 60, 60, 60, 60, 60, 60, 60, 60, 72, 72, 72, 72,
4807            72, 72, 72, 72, 71, 71, 71, 71, 71, 71, 71, 71, 70, 70, 70, 70, 70, 70, 70, 70,
4808        ]),
4809        "zigZag" => Some(&[
4810            20, 20, 20, 20, 20, 20, 20, 20, 19, 19, 19, 19, 19, 19, 19, 19, 18, 18, 18, 18, 18, 18,
4811            18, 18, 17, 17, 17, 17, 17, 17, 17, 17, 20, 20, 20, 20, 20, 20, 20, 20, 19, 19, 19, 19,
4812            19, 19, 19, 19, 18, 18, 18, 18, 18, 18, 18, 18, 17, 17, 17, 17, 17, 17, 17, 17,
4813        ]),
4814        _ => None,
4815    }
4816}
4817
4818#[cfg(test)]
4819mod tests {
4820    use std::collections::HashMap;
4821    use std::path::{Path, PathBuf};
4822
4823    use oxml_chart::CT_ChartSpace;
4824    use oxml_drawing::color::ColorMap;
4825    use oxml_drawing::fill::Fill;
4826    use oxml_drawing::text::{
4827        CT_TextListStyle, Coordinate32Value, TextAnchor, TextAutofit, TextVertical, TextWrap,
4828    };
4829    use oxml_drawing::theme::CT_OfficeStyleSheet;
4830    use oxml_opc::OpcPackage;
4831    use oxml_opc::relationship::rel_types;
4832    use rpptx_oxml::placeholder::PhType;
4833    use rpptx_oxml::presentation::CT_Presentation;
4834    use rpptx_oxml::shape_tree::{CT_Shape, ShapeTreeChild};
4835    use rpptx_oxml::slide_parts::{CT_Slide, CT_SlideLayout, CT_SlideMaster, ColorMapOverrideKind};
4836
4837    use super::{
4838        BackgroundSource, FlattenedItem, FlattenedSource, ResolveCtx, inherited_xfrm,
4839        resolved_shape_page_bounds, resolved_shape_page_transform, transform_values,
4840    };
4841    use crate::{
4842        ChartResource, Diagnostic, ParagraphAlignment, ResolvedAutofit, ResolvedBackground,
4843        ResolvedBullet, ResolvedBulletSize, ResolvedContent, ResolvedGeometry,
4844        ResolvedImagePlacement, ResolvedLineEnd, ResolvedLineEndKind, ResolvedLineEndSize,
4845        ResolvedParagraph, ResolvedRectAlignment, ResolvedRunStyle, ResolvedSlide, ResolvedTextRun,
4846        ResolvedTextSpacing, ResolvedTileFlip, ScopedChartResources, ScopedHyperlinkTargets,
4847        ScopedMediaFailures, ScopedMediaIds, TextAnchor as ResolvedTextAnchor, TextDirection,
4848    };
4849    use oxml_layout::{
4850        Color, Effect, FontManager, MediaId, Paint, PathCommand, Point, PositionedElement, Rect,
4851        Transform, walk,
4852    };
4853
4854    const P_NS: &str = "http://schemas.openxmlformats.org/presentationml/2006/main";
4855    const A_NS: &str = "http://schemas.openxmlformats.org/drawingml/2006/main";
4856    const MC_NS: &str = "http://schemas.openxmlformats.org/markup-compatibility/2006";
4857    const EXPECTED_CORPUS_DECKS: usize = 50;
4858
4859    fn assert_close(actual: f64, expected: f64) {
4860        assert!(
4861            (actual - expected).abs() < 1.0e-10,
4862            "expected {expected}, got {actual}"
4863        );
4864    }
4865
4866    #[test]
4867    fn fx214_manual_shape_tree_admission_and_iterative_affine_order() {
4868        use rpptx_oxml::shape_tree::CT_GroupShape;
4869        let leaf = shape_with_details(
4870            None,
4871            None,
4872            "<a:xfrm><a:off x=\"12700\" y=\"25400\"/><a:ext cx=\"38100\" cy=\"50800\"/></a:xfrm>",
4873            None,
4874        );
4875        let mut fixture = Fixture::new(&leaf, "", "");
4876        let leaf = fixture
4877            .slide
4878            .common_slide_data
4879            .shape_tree
4880            .children
4881            .remove(0);
4882        let mut child = leaf;
4883        for id in 0..32 {
4884            let mut group = CT_GroupShape::new_empty(100 + id, "Nested");
4885            group.children.push(child);
4886            child = ShapeTreeChild::GroupShape(Box::new(group));
4887        }
4888        fixture
4889            .slide
4890            .common_slide_data
4891            .shape_tree
4892            .children
4893            .push(child);
4894        let ctx = fixture.context();
4895        let resolved = ctx.resolve_slide((960.0, 540.0)).unwrap();
4896        assert_eq!(resolved.shapes.len(), 1);
4897        assert_eq!(
4898            resolved.shapes[0].bounds,
4899            Rect {
4900                x: 1.0,
4901                y: 2.0,
4902                width: 3.0,
4903                height: 4.0
4904            }
4905        );
4906        assert_eq!(resolved.shapes[0].group_transform, Transform::IDENTITY);
4907        let flat = ctx.flatten();
4908        let FlattenedItem::Shape { child, .. } = flat.last().unwrap() else {
4909            panic!("shape leaf");
4910        };
4911        let lineage = Vec::new();
4912        let mut found = Vec::new();
4913        assert!(super::find_group_lineage(
4914            &fixture.slide.common_slide_data.shape_tree.children,
4915            child,
4916            &lineage,
4917            &mut found
4918        ));
4919        assert_eq!(found, (100..132).rev().collect::<Vec<_>>());
4920        let child = fixture
4921            .slide
4922            .common_slide_data
4923            .shape_tree
4924            .children
4925            .remove(0);
4926        let mut outer = CT_GroupShape::new_empty(132, "Refused");
4927        outer.children.push(child);
4928        fixture
4929            .slide
4930            .common_slide_data
4931            .shape_tree
4932            .children
4933            .push(ShapeTreeChild::GroupShape(Box::new(outer)));
4934        let error = fixture
4935            .context()
4936            .resolve_slide((960.0, 540.0))
4937            .unwrap_err()
4938            .to_string();
4939        assert!(
4940            error.contains("slide shape tree") && error.contains("offending depth 33"),
4941            "{error}"
4942        );
4943        assert!(
4944            fixture
4945                .slide
4946                .to_xml()
4947                .unwrap_err()
4948                .to_string()
4949                .contains("offending depth 33")
4950        );
4951    }
4952
4953    struct Fixture {
4954        theme: CT_OfficeStyleSheet,
4955        master: CT_SlideMaster,
4956        layout: CT_SlideLayout,
4957        slide: CT_Slide,
4958        default_text_style: CT_TextListStyle,
4959    }
4960
4961    impl Fixture {
4962        fn new(slide_children: &str, layout_children: &str, master_children: &str) -> Self {
4963            Self::from_xml(
4964                &slide_xml(slide_children),
4965                &layout_xml(layout_children),
4966                &master_xml(master_children),
4967            )
4968        }
4969
4970        fn from_xml(slide_xml: &str, layout_xml: &str, master_xml: &str) -> Self {
4971            Self {
4972                theme: CT_OfficeStyleSheet::office_default(),
4973                master: CT_SlideMaster::from_xml(master_xml.as_bytes()).unwrap(),
4974                layout: CT_SlideLayout::from_xml(layout_xml.as_bytes()).unwrap(),
4975                slide: CT_Slide::from_xml(slide_xml.as_bytes()).unwrap(),
4976                default_text_style: CT_TextListStyle::default(),
4977            }
4978        }
4979
4980        fn context(&self) -> ResolveCtx<'_> {
4981            ResolveCtx::new(
4982                &self.theme,
4983                ColorMap::default(),
4984                &self.master,
4985                &self.layout,
4986                &self.slide,
4987                &self.default_text_style,
4988            )
4989        }
4990
4991        fn slide_shape(&self, index: usize) -> &CT_Shape {
4992            let ShapeTreeChild::Shape(shape) =
4993                &self.slide.common_slide_data.shape_tree.children[index]
4994            else {
4995                panic!("expected an ordinary slide shape");
4996            };
4997            shape
4998        }
4999    }
5000
5001    #[test]
5002    fn slide_placeholder_resolves_to_layout_and_master_counterparts() {
5003        let fixture = Fixture::new(
5004            &shape(Some("title"), Some(7)),
5005            &shape(Some("body"), Some(7)),
5006            &shape(Some("pic"), Some(7)),
5007        );
5008
5009        let context = fixture.context();
5010        let (layout, master) = context.placeholder_chain(fixture.slide_shape(0));
5011
5012        assert_eq!(
5013            layout.unwrap().placeholder.as_ref().unwrap().ph_type,
5014            Some(PhType::Body)
5015        );
5016        assert_eq!(
5017            master.unwrap().placeholder.as_ref().unwrap().ph_type,
5018            Some(PhType::Picture)
5019        );
5020    }
5021
5022    #[test]
5023    fn master_match_uses_the_layout_placeholder_key() {
5024        let fixture = Fixture::new(
5025            &shape(Some("title"), Some(8)),
5026            &shape(Some("ctrTitle"), None),
5027            &shape(Some("title"), Some(5)),
5028        );
5029
5030        let context = fixture.context();
5031        let (layout, master) = context.placeholder_chain(fixture.slide_shape(0));
5032
5033        assert!(layout.is_some());
5034        assert_eq!(master.unwrap().placeholder.as_ref().unwrap().idx, Some(5));
5035    }
5036
5037    #[test]
5038    fn placeholder_lookup_walks_groups_and_selected_fallbacks() {
5039        let nested_layout = format!(
5040            "<p:grpSp><p:nvGrpSpPr/><p:grpSpPr/>{}</p:grpSp>",
5041            shape(Some("body"), Some(11))
5042        );
5043        let fallback_master = format!(
5044            "<mc:AlternateContent><mc:Choice Requires=\"p14\"><p:sp/></mc:Choice><mc:Fallback>{}</mc:Fallback></mc:AlternateContent>",
5045            shape(Some("body"), Some(11))
5046        );
5047        let fixture = Fixture::new(
5048            &shape(Some("body"), Some(11)),
5049            &nested_layout,
5050            &fallback_master,
5051        );
5052
5053        let context = fixture.context();
5054        let (layout, master) = context.placeholder_chain(fixture.slide_shape(0));
5055
5056        assert!(layout.is_some());
5057        assert!(master.is_some());
5058    }
5059
5060    #[test]
5061    fn group_targets_apply_to_every_resolved_descendant() {
5062        let first = shape_with_details(None, None, &transform(0), None).replacen(
5063            "<p:cNvPr/>",
5064            "<p:cNvPr id=\"7\" name=\"First\"/>",
5065            1,
5066        );
5067        let second = shape_with_details(None, None, &transform(127_000), None).replacen(
5068            "<p:cNvPr/>",
5069            "<p:cNvPr id=\"8\" name=\"Second\"/>",
5070            1,
5071        );
5072        let unrelated = shape_with_details(None, None, &transform(254_000), None).replacen(
5073            "<p:cNvPr/>",
5074            "<p:cNvPr id=\"9\" name=\"Other\"/>",
5075            1,
5076        );
5077        let group = format!(
5078            "<p:grpSp><p:nvGrpSpPr><p:cNvPr id=\"10\" name=\"Group\"/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/>{first}{second}</p:grpSp>"
5079        );
5080        let slide = format!(
5081            "<p:sld xmlns:p=\"{P_NS}\" xmlns:a=\"{A_NS}\"><p:cSld>{}</p:cSld><p:timing><p:tnLst><p:set><p:cBhvr><p:cTn id=\"1\" dur=\"1\" fill=\"hold\"/><p:tgtEl><p:spTgt spid=\"10\"/></p:tgtEl><p:attrNameLst><p:attrName>style.opacity</p:attrName></p:attrNameLst></p:cBhvr><p:to><p:strVal val=\"50000\"/></p:to></p:set></p:tnLst></p:timing></p:sld>",
5082            shape_tree(&format!("{group}{unrelated}"))
5083        );
5084        let fixture = Fixture::from_xml(&slide, &layout_xml(""), &master_xml(""));
5085        let resolved = fixture
5086            .context()
5087            .resolve_slide_at(
5088                (720.0, 540.0),
5089                crate::timeline::TimelinePosition {
5090                    elapsed_ms: 1,
5091                    click_count: 0,
5092                },
5093            )
5094            .unwrap();
5095
5096        assert_eq!(resolved.shape_states.len(), 3);
5097        assert_eq!(resolved.shape_states[0].opacity, 0.5);
5098        assert_eq!(resolved.shape_states[1].opacity, 0.5);
5099        assert_eq!(resolved.shape_states[2].opacity, 1.0);
5100        assert_eq!(resolved.identities[0].containing_group_ids, vec![10]);
5101        assert_eq!(resolved.identities[1].containing_group_ids, vec![10]);
5102        assert!(resolved.identities[2].containing_group_ids.is_empty());
5103    }
5104
5105    #[test]
5106    fn alternate_content_chart_choice_keeps_timeline_identity() {
5107        let alternate = chart_alternate("chart", None).replacen(
5108            "<p:nvGraphicFramePr/>",
5109            "<p:nvGraphicFramePr><p:cNvPr id=\"42\" name=\"!!Chart &amp; Data\"/><p:cNvGraphicFramePr/><p:nvPr/></p:nvGraphicFramePr>",
5110            1,
5111        );
5112        let slide = format!(
5113            r#"<p:sld xmlns:p="{P_NS}" xmlns:a="{A_NS}" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart"><p:cSld>{}</p:cSld><p:timing><p:tnLst><p:anim from="100000" to="50000"><p:cBhvr><p:cTn id="1" dur="1000" fill="hold"/><p:tgtEl><p:spTgt spid="42"/></p:tgtEl><p:attrNameLst><p:attrName>style.opacity</p:attrName></p:attrNameLst></p:cBhvr></p:anim></p:tnLst></p:timing></p:sld>"#,
5114            shape_tree(&alternate)
5115        );
5116        let fixture = Fixture::from_xml(&slide, &layout_xml(""), &master_xml(""));
5117        let resolved = fixture
5118            .context()
5119            .resolve_slide_at(
5120                (720.0, 540.0),
5121                crate::timeline::TimelinePosition {
5122                    elapsed_ms: 500,
5123                    click_count: 0,
5124                },
5125            )
5126            .unwrap();
5127
5128        assert_eq!(resolved.identities[0].shape_id, Some(42));
5129        assert_eq!(
5130            resolved.identities[0].name.as_deref(),
5131            Some("!!Chart & Data")
5132        );
5133        assert_eq!(resolved.shape_states[0].opacity, 0.75);
5134    }
5135
5136    #[test]
5137    fn timeline_geometry_keeps_shape_centres_and_resolves_motion_origins() {
5138        let first = shape_with_details(None, None, &transform(127_000), None).replacen(
5139            "<p:cNvPr/>",
5140            "<p:cNvPr id=\"7\" name=\"!!Scale &amp; Spin\"/>",
5141            1,
5142        );
5143        let second = shape_with_details(None, None, &transform(254_000), None).replacen(
5144            "<p:cNvPr/>",
5145            "<p:cNvPr id=\"8\" name=\"Target motion\"/>",
5146            1,
5147        );
5148        let slide = format!(
5149            r#"<p:sld xmlns:p="{P_NS}" xmlns:a="{A_NS}"><p:cSld>{}</p:cSld><p:timing><p:tnLst>
5150            <p:anim from="100000" to="200000"><p:cBhvr><p:cTn id="1" dur="1000" fill="hold"/><p:tgtEl><p:spTgt spid="7"/></p:tgtEl><p:attrNameLst><p:attrName>scale</p:attrName></p:attrNameLst></p:cBhvr></p:anim>
5151            <p:anim from="0" to="5400000"><p:cBhvr><p:cTn id="2" dur="1000" fill="hold"/><p:tgtEl><p:spTgt spid="7"/></p:tgtEl><p:attrNameLst><p:attrName>spin</p:attrName></p:attrNameLst></p:cBhvr></p:anim>
5152            <p:animMotion origin="parent" path="M 0 0 L .5 .25 E"><p:cBhvr><p:cTn id="3" dur="1000" fill="hold"/><p:tgtEl><p:spTgt spid="7"/></p:tgtEl></p:cBhvr></p:animMotion>
5153            <p:animMotion origin="layout" path="M 0 0 L .5 .25 E"><p:cBhvr><p:cTn id="4" dur="1000" fill="hold"/><p:tgtEl><p:spTgt spid="8"/></p:tgtEl></p:cBhvr></p:animMotion>
5154            </p:tnLst></p:timing></p:sld>"#,
5155            shape_tree(&format!("{first}{second}"))
5156        );
5157        let fixture = Fixture::from_xml(&slide, &layout_xml(""), &master_xml(""));
5158        let static_slide = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
5159        let resolved = fixture
5160            .context()
5161            .resolve_slide_at(
5162                (720.0, 540.0),
5163                crate::timeline::TimelinePosition {
5164                    elapsed_ms: 1_000,
5165                    click_count: 0,
5166                },
5167            )
5168            .unwrap();
5169
5170        let original = static_slide.shapes[0].bounds;
5171        let animated = resolved.slide.shapes[0].bounds;
5172        assert_close(animated.width, original.width * 2.0);
5173        assert_close(animated.height, original.height * 2.0);
5174        let animated_page = resolved_shape_page_bounds(&resolved.slide.shapes[0]);
5175        assert_close(animated_page.x + animated_page.width / 2.0, 360.0);
5176        assert_close(animated_page.y + animated_page.height / 2.0, 135.0);
5177        assert_close(resolved.slide.shapes[0].rotation_deg, 90.0);
5178        let target_original = resolved_shape_page_bounds(&static_slide.shapes[1]);
5179        let target_animated = resolved_shape_page_bounds(&resolved.slide.shapes[1]);
5180        assert_close(target_animated.x - target_original.x, 360.0);
5181        assert_close(target_animated.y - target_original.y, 135.0);
5182        assert_eq!(
5183            resolved.identities[0].name.as_deref(),
5184            Some("!!Scale & Spin")
5185        );
5186        assert!(resolved.shape_states.iter().all(|state| state.is_finite()));
5187    }
5188
5189    #[test]
5190    fn group_timeline_geometry_uses_one_shared_group_coordinate_space() {
5191        let rotated_transform = transform(50).replacen("<a:xfrm>", "<a:xfrm rot=\"2700000\">", 1);
5192        let first = shape_with_details(None, None, &rotated_transform, None).replacen(
5193            "<p:cNvPr/>",
5194            "<p:cNvPr id=\"7\" name=\"First\"/>",
5195            1,
5196        );
5197        let second = shape_with_details(None, None, &transform(100), None).replacen(
5198            "<p:cNvPr/>",
5199            "<p:cNvPr id=\"8\" name=\"Second\"/>",
5200            1,
5201        );
5202        let group = format!(
5203            "<p:grpSp><p:nvGrpSpPr><p:cNvPr id=\"10\" name=\"Group\"/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr><a:xfrm rot=\"5400000\"><a:off x=\"1270000\" y=\"635000\"/><a:ext cx=\"2540000\" cy=\"1270000\"/><a:chOff x=\"0\" y=\"0\"/><a:chExt cx=\"200\" cy=\"100\"/></a:xfrm></p:grpSpPr>{first}{second}</p:grpSp>"
5204        );
5205        let slide = format!(
5206            r#"<p:sld xmlns:p="{P_NS}" xmlns:a="{A_NS}"><p:cSld>{}</p:cSld><p:timing><p:tnLst>
5207            <p:anim from="100000" to="200000"><p:cBhvr><p:cTn id="1" dur="1000" fill="hold"/><p:tgtEl><p:spTgt spid="10"/></p:tgtEl><p:attrNameLst><p:attrName>scale</p:attrName></p:attrNameLst></p:cBhvr></p:anim>
5208            <p:animMotion origin="parent" path="M 0 0 L .1 .1 E"><p:cBhvr><p:cTn id="2" dur="1000" fill="hold"/><p:tgtEl><p:spTgt spid="10"/></p:tgtEl></p:cBhvr></p:animMotion>
5209            <p:animEffect transition="in" filter="wipe(right)"><p:cBhvr><p:cTn id="3" dur="1000" fill="hold"/><p:tgtEl><p:spTgt spid="10"/></p:tgtEl></p:cBhvr></p:animEffect>
5210            </p:tnLst></p:timing></p:sld>"#,
5211            shape_tree(&group)
5212        );
5213        let fixture = Fixture::from_xml(&slide, &layout_xml(""), &master_xml(""));
5214        let static_slide = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
5215        let resolved = fixture
5216            .context()
5217            .resolve_slide_at(
5218                (720.0, 540.0),
5219                crate::timeline::TimelinePosition {
5220                    elapsed_ms: 500,
5221                    click_count: 0,
5222                },
5223            )
5224            .unwrap();
5225        let first_before = resolved_shape_page_bounds(&static_slide.shapes[0]);
5226        let second_before = resolved_shape_page_bounds(&static_slide.shapes[1]);
5227        let group_center_x = 200.0;
5228        let group_center_y = 100.0;
5229
5230        for (before, shape) in [first_before, second_before]
5231            .into_iter()
5232            .zip(&resolved.slide.shapes)
5233        {
5234            let after = resolved_shape_page_bounds(shape);
5235            assert_close(after.width, before.width * 1.5);
5236            assert_close(
5237                after.x + after.width / 2.0,
5238                36.0 + (before.x + before.width / 2.0 - group_center_x) * 1.5,
5239            );
5240            assert_close(
5241                after.y + after.height / 2.0,
5242                27.0 + (before.y + before.height / 2.0 - group_center_y) * 1.5,
5243            );
5244        }
5245        let first_clip = resolved.shape_states[0].local_clip_paths((100.0, 100.0));
5246        let [PathCommand::MoveTo(start), PathCommand::LineTo(next), ..] =
5247            first_clip[0].commands.as_slice()
5248        else {
5249            panic!("group wipe should remain a polygon")
5250        };
5251        assert!(start.x != next.x && start.y != next.y);
5252        assert_eq!(
5253            resolved.shape_states[1]
5254                .local_clip_paths((100.0, 100.0))
5255                .len(),
5256            1
5257        );
5258    }
5259
5260    #[test]
5261    fn parent_group_wipe_does_not_follow_descendant_or_nested_group_animation() {
5262        let child = shape_with_details(None, None, &transform(50), None).replacen(
5263            "<p:cNvPr/>",
5264            "<p:cNvPr id=\"7\" name=\"Animated child\"/>",
5265            1,
5266        );
5267        let nested = format!(
5268            "<p:grpSp><p:nvGrpSpPr><p:cNvPr id=\"11\" name=\"Nested\"/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr><a:xfrm><a:off x=\"0\" y=\"0\"/><a:ext cx=\"200\" cy=\"100\"/><a:chOff x=\"0\" y=\"0\"/><a:chExt cx=\"200\" cy=\"100\"/></a:xfrm></p:grpSpPr>{child}</p:grpSp>"
5269        );
5270        let group = format!(
5271            "<p:grpSp><p:nvGrpSpPr><p:cNvPr id=\"10\" name=\"Parent\"/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr><a:xfrm rot=\"2700000\"><a:off x=\"1270000\" y=\"635000\"/><a:ext cx=\"2540000\" cy=\"1270000\"/><a:chOff x=\"0\" y=\"0\"/><a:chExt cx=\"200\" cy=\"100\"/></a:xfrm></p:grpSpPr>{nested}</p:grpSp>"
5272        );
5273        let resolve = |descendant_timing: &str| {
5274            let slide = format!(
5275                r#"<p:sld xmlns:p="{P_NS}" xmlns:a="{A_NS}"><p:cSld>{}</p:cSld><p:timing><p:tnLst>
5276                <p:animEffect transition="in" filter="wipe(right)"><p:cBhvr><p:cTn id="1" dur="1000" fill="hold"/><p:tgtEl><p:spTgt spid="10"/></p:tgtEl></p:cBhvr></p:animEffect>
5277                {descendant_timing}</p:tnLst></p:timing></p:sld>"#,
5278                shape_tree(&group)
5279            );
5280            Fixture::from_xml(&slide, &layout_xml(""), &master_xml(""))
5281                .context()
5282                .resolve_slide_at(
5283                    (720.0, 540.0),
5284                    crate::timeline::TimelinePosition {
5285                        elapsed_ms: 500,
5286                        click_count: 0,
5287                    },
5288                )
5289                .unwrap()
5290        };
5291        let baseline = resolve("");
5292        let animated = resolve(
5293            r#"<p:animMotion origin="layout" path="M 0 0 L .2 .1 E"><p:cBhvr><p:cTn id="2" dur="1000" fill="hold"/><p:tgtEl><p:spTgt spid="11"/></p:tgtEl></p:cBhvr></p:animMotion>
5294            <p:anim from="0" to="5400000"><p:cBhvr><p:cTn id="3" dur="1000" fill="hold"/><p:tgtEl><p:spTgt spid="7"/></p:tgtEl><p:attrNameLst><p:attrName>spin</p:attrName></p:attrNameLst></p:cBhvr></p:anim>"#,
5295        );
5296        let page_clip = |resolved: &crate::timeline::ResolvedTimelineSlide| {
5297            let shape = &resolved.slide.shapes[0];
5298            let transform = resolved_shape_page_transform(shape);
5299            resolved.shape_states[0].local_clip_paths((shape.bounds.width, shape.bounds.height))[0]
5300                .commands
5301                .iter()
5302                .filter_map(|command| match command {
5303                    PathCommand::MoveTo(point) | PathCommand::LineTo(point) => {
5304                        Some(transform.apply(*point))
5305                    }
5306                    _ => None,
5307                })
5308                .collect::<Vec<_>>()
5309        };
5310        let baseline_clip = page_clip(&baseline);
5311        let animated_clip = page_clip(&animated);
5312
5313        assert_ne!(
5314            resolved_shape_page_transform(&baseline.slide.shapes[0]),
5315            resolved_shape_page_transform(&animated.slide.shapes[0])
5316        );
5317        assert_eq!(baseline_clip.len(), animated_clip.len());
5318        for (baseline, animated) in baseline_clip.into_iter().zip(animated_clip) {
5319            assert_close(animated.x, baseline.x);
5320            assert_close(animated.y, baseline.y);
5321        }
5322    }
5323
5324    #[test]
5325    fn missing_or_non_placeholder_shape_has_no_chain() {
5326        let slide_children = format!("{}{}", shape(None, None), shape(Some("body"), Some(42)));
5327        let fixture = Fixture::new(&slide_children, "", &shape(Some("body"), Some(42)));
5328        let context = fixture.context();
5329
5330        assert_eq!(
5331            context.placeholder_chain(fixture.slide_shape(0)),
5332            (None, None)
5333        );
5334        assert_eq!(
5335            context.placeholder_chain(fixture.slide_shape(1)),
5336            (None, None)
5337        );
5338    }
5339
5340    #[test]
5341    fn slide_placeholder_without_transform_inherits_layout_position() {
5342        let fixture = Fixture::new(
5343            &shape(Some("body"), Some(1)),
5344            &shape_with_details(Some("body"), Some(1), &transform(10), None),
5345            &shape_with_details(Some("body"), Some(1), &transform(20), None),
5346        );
5347        let transform = fixture
5348            .context()
5349            .effective_xfrm(fixture.slide_shape(0))
5350            .unwrap();
5351
5352        assert_eq!(transform.offset.unwrap().x.0, 10);
5353    }
5354
5355    #[test]
5356    fn effective_transform_uses_slide_layout_master_precedence() {
5357        let slide_children = [
5358            shape_with_details(Some("body"), Some(1), &transform(11), None),
5359            shape(Some("body"), Some(2)),
5360            shape(Some("body"), Some(3)),
5361            shape(Some("body"), Some(4)),
5362        ]
5363        .join("");
5364        let layout_children = [
5365            shape_with_details(Some("body"), Some(1), &transform(21), None),
5366            shape_with_details(Some("body"), Some(2), &transform(22), None),
5367            shape(Some("body"), Some(3)),
5368            shape(Some("body"), Some(4)),
5369        ]
5370        .join("");
5371        let master_children = [
5372            shape_with_details(Some("body"), Some(1), &transform(31), None),
5373            shape_with_details(Some("body"), Some(2), &transform(32), None),
5374            shape_with_details(Some("body"), Some(3), &transform(33), None),
5375            shape(Some("body"), Some(4)),
5376        ]
5377        .join("");
5378        let fixture = Fixture::new(&slide_children, &layout_children, &master_children);
5379        let context = fixture.context();
5380
5381        let offsets: Vec<_> = (0..3)
5382            .map(|index| {
5383                context
5384                    .effective_xfrm(fixture.slide_shape(index))
5385                    .unwrap()
5386                    .offset
5387                    .unwrap()
5388                    .x
5389                    .0
5390            })
5391            .collect();
5392
5393        assert_eq!(offsets, [11, 22, 33]);
5394        assert!(context.effective_xfrm(fixture.slide_shape(3)).is_none());
5395    }
5396
5397    #[test]
5398    fn inherited_transform_skips_the_slide_shape_and_follows_the_layout_key() {
5399        let slide_children = [
5400            shape_with_details(Some("body"), Some(1), &transform(11), None),
5401            shape(Some("body"), Some(3)),
5402            shape(Some("ftr"), Some(9)),
5403            shape(Some("body"), Some(5)),
5404        ]
5405        .join("");
5406        let layout_children = [
5407            shape_with_details(Some("body"), Some(1), &transform(21), None),
5408            shape(Some("body"), Some(3)),
5409            shape(Some("ftr"), Some(10)),
5410        ]
5411        .join("");
5412        let master_children = [
5413            shape_with_details(Some("body"), Some(1), &transform(31), None),
5414            shape_with_details(Some("body"), Some(3), &transform(33), None),
5415            shape_with_details(Some("ftr"), Some(11), &transform(34), None),
5416            shape_with_details(Some("body"), Some(5), &transform(35), None),
5417        ]
5418        .join("");
5419        let fixture = Fixture::new(&slide_children, &layout_children, &master_children);
5420        let context = fixture.context();
5421        let inherited = |index: usize| {
5422            let placeholder = fixture.slide_shape(index).placeholder.as_ref().unwrap();
5423            inherited_xfrm(placeholder, &fixture.layout, &fixture.master)
5424                .map(|transform| transform.offset.unwrap().x.0)
5425        };
5426
5427        assert_eq!(inherited(0), Some(21));
5428        assert_eq!(inherited(1), Some(33));
5429        assert_eq!(inherited(2), Some(34));
5430        assert_eq!(inherited(3), None, "a master match alone is not inherited");
5431        for index in 1..4 {
5432            assert_eq!(
5433                context
5434                    .effective_xfrm(fixture.slide_shape(index))
5435                    .map(|transform| transform.offset.unwrap().x.0),
5436                inherited(index)
5437            );
5438        }
5439    }
5440
5441    #[test]
5442    fn body_properties_merge_per_field_across_the_chain() {
5443        let fixture = Fixture::new(
5444            &shape_with_details(
5445                Some("body"),
5446                Some(4),
5447                "",
5448                Some(r#"<a:bodyPr bIns="50" anchor="ctr"/>"#),
5449            ),
5450            &shape_with_details(
5451                Some("body"),
5452                Some(4),
5453                "",
5454                Some(r#"<a:bodyPr lIns="30" rIns="40" wrap="none"/>"#),
5455            ),
5456            &shape_with_details(
5457                Some("body"),
5458                Some(4),
5459                "",
5460                Some(
5461                    r#"<a:bodyPr lIns="10" tIns="20" anchor="b" vert="vert" spcFirstLastPara="1"><a:spAutoFit/></a:bodyPr>"#,
5462                ),
5463            ),
5464        );
5465        let properties = fixture.context().effective_body_pr(fixture.slide_shape(0));
5466
5467        assert_eq!(properties.left_inset, Some(Coordinate32Value::Emu(30)));
5468        assert_eq!(properties.top_inset, Some(Coordinate32Value::Emu(20)));
5469        assert_eq!(properties.right_inset, Some(Coordinate32Value::Emu(40)));
5470        assert_eq!(properties.bottom_inset, Some(Coordinate32Value::Emu(50)));
5471        assert_eq!(properties.anchor, Some(TextAnchor::Center));
5472        assert_eq!(properties.wrap, Some(TextWrap::None));
5473        assert_eq!(properties.vertical, Some(TextVertical::Vertical));
5474        assert_eq!(properties.space_first_last_paragraph, Some(true));
5475        assert_eq!(properties.autofit, Some(TextAutofit::ShapeAutofit));
5476    }
5477
5478    #[test]
5479    fn body_property_defaults_use_exact_emu_values() {
5480        let fixture = Fixture::new(&shape(None, None), "", "");
5481        let properties = fixture.context().effective_body_pr(fixture.slide_shape(0));
5482
5483        assert_eq!(properties.left_inset, Some(Coordinate32Value::Emu(91_440)));
5484        assert_eq!(properties.right_inset, Some(Coordinate32Value::Emu(91_440)));
5485        assert_eq!(properties.top_inset, Some(Coordinate32Value::Emu(45_720)));
5486        assert_eq!(
5487            properties.bottom_inset,
5488            Some(Coordinate32Value::Emu(45_720))
5489        );
5490        assert_eq!(properties.anchor, Some(TextAnchor::Top));
5491        assert_eq!(properties.wrap, Some(TextWrap::Square));
5492        assert_eq!(properties.vertical, Some(TextVertical::Horizontal));
5493        assert_eq!(properties.space_first_last_paragraph, Some(false));
5494        assert_eq!(properties.autofit, Some(TextAutofit::NoAutofit));
5495    }
5496
5497    #[test]
5498    fn east_asian_vertical_text_degrades_to_rotated_with_a_diagnostic() {
5499        let shape = shape_with_details(
5500            None,
5501            None,
5502            &transform(0),
5503            Some(r#"<a:bodyPr vert="eaVert"/>"#),
5504        )
5505        .replace("<a:p/>", "<a:p><a:r><a:t>visible</a:t></a:r></a:p>");
5506        let resolved = Fixture::new(&shape, "", "")
5507            .context()
5508            .resolve_slide((720.0, 540.0))
5509            .expect("resolve East Asian vertical text");
5510
5511        let ResolvedContent::Text(text) = &resolved.shapes[0].content else {
5512            panic!("fallback text should remain visible");
5513        };
5514        assert_eq!(text.vertical, TextDirection::EastAsianVertical);
5515        assert!(matches!(
5516            &text.paragraphs[0].runs[0],
5517            ResolvedTextRun::Text { text, .. } if text == "visible"
5518        ));
5519        assert!(resolved.diagnostics.iter().any(|diagnostic| {
5520            diagnostic.message == "east Asian vertical text rendered as rotated vertical text"
5521        }));
5522    }
5523
5524    #[test]
5525    fn other_vertical_variants_remain_visible_with_diagnostics() {
5526        let cases = [
5527            (
5528                "mongolianVert",
5529                TextDirection::MongolianVertical,
5530                "Mongolian vertical text rendered as rotated vertical-270 text",
5531            ),
5532            (
5533                "wordArtVert",
5534                TextDirection::WordArtVertical,
5535                "WordArt vertical text rendered as rotated vertical text",
5536            ),
5537            (
5538                "wordArtVertRtl",
5539                TextDirection::WordArtVerticalRtl,
5540                "right-to-left WordArt vertical text rendered as rotated vertical-270 text",
5541            ),
5542        ];
5543
5544        for (value, expected_direction, expected_diagnostic) in cases {
5545            let shape = shape_with_details(
5546                None,
5547                None,
5548                &transform(0),
5549                Some(&format!(r#"<a:bodyPr vert="{value}"/>"#)),
5550            )
5551            .replace("<a:p/>", "<a:p><a:r><a:t>visible</a:t></a:r></a:p>");
5552            let resolved = Fixture::new(&shape, "", "")
5553                .context()
5554                .resolve_slide((720.0, 540.0))
5555                .expect("resolve visible vertical fallback");
5556
5557            let ResolvedContent::Text(text) = &resolved.shapes[0].content else {
5558                panic!("fallback text should remain visible");
5559            };
5560            assert_eq!(text.vertical, expected_direction);
5561            assert!(matches!(
5562                &text.paragraphs[0].runs[0],
5563                ResolvedTextRun::Text { text, .. } if text == "visible"
5564            ));
5565            assert!(
5566                resolved
5567                    .diagnostics
5568                    .iter()
5569                    .any(|diagnostic| { diagnostic.message == expected_diagnostic })
5570            );
5571        }
5572    }
5573
5574    #[test]
5575    fn flattener_omits_template_prompt_and_emits_master_logo_once() {
5576        let fixture = Fixture::new(
5577            &shape_with_text(Some("title"), Some(1), "Slide title"),
5578            "",
5579            &[
5580                shape_with_text(Some("title"), Some(1), "Click to edit Master title style"),
5581                shape_with_text(None, None, "Master logo"),
5582            ]
5583            .join(""),
5584        );
5585
5586        let texts: Vec<_> = fixture
5587            .context()
5588            .flatten()
5589            .iter()
5590            .filter_map(item_text)
5591            .collect();
5592
5593        assert!(!texts.contains(&"Click to edit Master title style".to_owned()));
5594        assert_eq!(
5595            texts.iter().filter(|text| *text == "Master logo").count(),
5596            1
5597        );
5598    }
5599
5600    #[test]
5601    fn flattener_emits_the_four_sources_in_draw_order() {
5602        let master_group = format!(
5603            "<p:grpSp><p:nvGrpSpPr/><p:grpSpPr/>{}</p:grpSp>",
5604            shape_with_text(None, None, "master")
5605        );
5606        let layout_fallback = format!(
5607            "<mc:AlternateContent><mc:Fallback>{}</mc:Fallback></mc:AlternateContent>",
5608            shape_with_text(None, None, "layout")
5609        );
5610        let fixture = Fixture::from_xml(
5611            &slide_xml_with(
5612                "",
5613                "<p:bg><p:bgPr/></p:bg>",
5614                &shape_with_text(None, None, "slide"),
5615            ),
5616            &layout_xml_with("", "", &layout_fallback, ""),
5617            &master_xml_with("", &master_group, ""),
5618        );
5619        let context = fixture.context();
5620        let flattened = context.flatten();
5621
5622        assert_eq!(
5623            flattened
5624                .iter()
5625                .map(FlattenedItem::source)
5626                .collect::<Vec<_>>(),
5627            [
5628                FlattenedSource::Background,
5629                FlattenedSource::Master,
5630                FlattenedSource::Layout,
5631                FlattenedSource::Slide,
5632            ]
5633        );
5634        let FlattenedItem::Background(background) = flattened[0] else {
5635            panic!("expected background first");
5636        };
5637        assert_eq!(background.source, BackgroundSource::Slide);
5638    }
5639
5640    #[test]
5641    fn background_precedence_stops_at_master_when_all_are_absent() {
5642        let slide_first = Fixture::from_xml(
5643            &slide_xml_with("", "<p:bg><p:bgPr/></p:bg>", ""),
5644            &layout_xml_with("", "<p:bg><p:bgPr/></p:bg>", "", ""),
5645            &master_xml_with("<p:bg><p:bgPr/></p:bg>", "", ""),
5646        );
5647        let layout_first = Fixture::from_xml(
5648            &slide_xml_with("", "", ""),
5649            &layout_xml_with("", "<p:bg><p:bgPr/></p:bg>", "", ""),
5650            &master_xml_with("<p:bg><p:bgPr/></p:bg>", "", ""),
5651        );
5652        let master_first = Fixture::from_xml(
5653            &slide_xml_with("", "", ""),
5654            &layout_xml_with("", "", "", ""),
5655            &master_xml_with("<p:bg><p:bgPr/></p:bg>", "", ""),
5656        );
5657        let absent = Fixture::new("", "", "");
5658
5659        assert_eq!(background_source(&slide_first), BackgroundSource::Slide);
5660        assert_eq!(background_source(&layout_first), BackgroundSource::Layout);
5661        assert_eq!(background_source(&master_first), BackgroundSource::Master);
5662        assert!(absent.context().effective_background().is_none());
5663    }
5664
5665    #[test]
5666    fn master_gradient_background_resolves_when_slide_and_layout_omit_one() {
5667        let background = r#"<p:bg><p:bgPr><a:gradFill rotWithShape="1"><a:gsLst><a:gs pos="0"><a:srgbClr val="FF0000"/></a:gs><a:gs pos="100000"><a:srgbClr val="0000FF"/></a:gs></a:gsLst><a:lin ang="0"/></a:gradFill></p:bgPr></p:bg>"#;
5668        let fixture = Fixture::from_xml(
5669            &slide_xml_with("", "", ""),
5670            &layout_xml_with("", "", "", ""),
5671            &master_xml_with(background, "", ""),
5672        );
5673
5674        let resolved = fixture.context().resolve_slide((40.0, 20.0)).unwrap();
5675        let Some(ResolvedBackground::Paint(Paint::Linear { stops, .. })) = resolved.background
5676        else {
5677            panic!("expected inherited master gradient paint");
5678        };
5679        assert_eq!(stops.len(), 259);
5680        assert_eq!(stops[0].color, Color::from_hex("FF0000"));
5681        assert_eq!(stops.last().unwrap().color, Color::from_hex("0000FF"));
5682        assert!(resolved.diagnostics.is_empty());
5683    }
5684
5685    #[test]
5686    fn background_gradient_ignores_shape_rotation_policy() {
5687        let background = r#"<p:bg><p:bgPr><a:gradFill rotWithShape="0"><a:gsLst><a:gs pos="0"><a:srgbClr val="FF0000"/></a:gs><a:gs pos="100000"><a:srgbClr val="0000FF"/></a:gs></a:gsLst><a:lin ang="0"/></a:gradFill></p:bgPr></p:bg>"#;
5688        let fixture = Fixture::from_xml(
5689            &slide_xml_with("", background, ""),
5690            &layout_xml_with("", "", "", ""),
5691            &master_xml_with("", "", ""),
5692        );
5693
5694        let resolved = fixture.context().resolve_slide((40.0, 20.0)).unwrap();
5695        assert!(matches!(
5696            resolved.background,
5697            Some(ResolvedBackground::Paint(Paint::Linear { .. }))
5698        ));
5699        assert!(resolved.diagnostics.is_empty());
5700    }
5701
5702    #[test]
5703    fn background_reference_resolves_phclr_through_the_master_colour_map() {
5704        let master = master_xml_with(
5705            r#"<p:bg><p:bgRef idx="1001"><a:schemeClr val="bg1"/></p:bgRef></p:bg>"#,
5706            "",
5707            "",
5708        )
5709        .replace("bg1=\"lt1\"", "bg1=\"accent6\"");
5710        let fixture = Fixture::from_xml(
5711            &slide_xml_with("", "", ""),
5712            &layout_xml_with("", "", "", ""),
5713            &master,
5714        );
5715        let context = ResolveCtx::new(
5716            &fixture.theme,
5717            fixture.master.color_map.clone(),
5718            &fixture.master,
5719            &fixture.layout,
5720            &fixture.slide,
5721            &fixture.default_text_style,
5722        );
5723
5724        let resolved = context.resolve_slide((40.0, 20.0)).unwrap();
5725        assert_eq!(
5726            resolved.background,
5727            Some(ResolvedBackground::Paint(Paint::Solid(Color::from_hex(
5728                "4EA72E",
5729            ))))
5730        );
5731        assert!(resolved.diagnostics.is_empty());
5732
5733        let mut transformed = Fixture::from_xml(
5734            &slide_xml_with("", "", ""),
5735            &layout_xml_with("", "", "", ""),
5736            &master_xml_with(
5737                r#"<p:bg><p:bgRef idx="1001"><a:srgbClr val="C0504D"><a:tint val="60000"/></a:srgbClr></p:bgRef></p:bg>"#,
5738                "",
5739                "",
5740            ),
5741        );
5742        transformed
5743            .theme
5744            .theme_elements
5745            .format_scheme
5746            .background_fill_styles[0] = oxml_drawing::fill::Fill::from_xml(
5747            br#"<a:solidFill><a:schemeClr val="phClr"><a:shade val="70000"/></a:schemeClr></a:solidFill>"#,
5748        )
5749        .unwrap();
5750
5751        let transformed = transformed.context().resolve_slide((40.0, 20.0)).unwrap();
5752        assert_eq!(
5753            transformed.background,
5754            Some(ResolvedBackground::Paint(Paint::Solid(Color::from_hex(
5755                "BC9897",
5756            ))))
5757        );
5758    }
5759
5760    #[test]
5761    fn background_picture_relationship_uses_its_producer_scope() {
5762        let background = r#"<p:bg><p:bgPr><a:blipFill><a:blip xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:embed="rId9"><a:alphaModFix amt="30000"/></a:blip><a:stretch><a:fillRect/></a:stretch></a:blipFill></p:bgPr></p:bg>"#;
5763        let fixtures = [
5764            Fixture::from_xml(
5765                &slide_xml_with("", background, ""),
5766                &layout_xml_with("", "", "", ""),
5767                &master_xml_with("", "", ""),
5768            ),
5769            Fixture::from_xml(
5770                &slide_xml_with("", "", ""),
5771                &layout_xml_with("", background, "", ""),
5772                &master_xml_with("", "", ""),
5773            ),
5774            Fixture::from_xml(
5775                &slide_xml_with("", "", ""),
5776                &layout_xml_with("", "", "", ""),
5777                &master_xml_with(background, "", ""),
5778            ),
5779        ];
5780        let media = ScopedMediaIds {
5781            slide: HashMap::from([("rId9".to_owned(), MediaId(1))]),
5782            layout: HashMap::from([("rId9".to_owned(), MediaId(2))]),
5783            master: HashMap::from([("rId9".to_owned(), MediaId(3))]),
5784            ..ScopedMediaIds::default()
5785        };
5786
5787        for (fixture, expected) in fixtures.iter().zip([MediaId(1), MediaId(2), MediaId(3)]) {
5788            let resolved = fixture
5789                .context()
5790                .resolve_slide_with_media((40.0, 20.0), &media)
5791                .unwrap();
5792            let Some(ResolvedBackground::Image(image)) = resolved.background else {
5793                panic!("expected source-scoped background image");
5794            };
5795            assert_eq!(image.media, expected);
5796            assert_eq!(image.opacity, 0.3);
5797            assert!(resolved.diagnostics.is_empty());
5798        }
5799    }
5800
5801    #[test]
5802    fn master_relationship_cannot_satisfy_a_theme_background_blip() {
5803        let mut fixture = Fixture::from_xml(
5804            &slide_xml_with("", "", ""),
5805            &layout_xml_with("", "", "", ""),
5806            &master_xml_with(
5807                r#"<p:bg><p:bgRef idx="1001"><a:schemeClr val="bg1"/></p:bgRef></p:bg>"#,
5808                "",
5809                "",
5810            ),
5811        );
5812        fixture
5813            .theme
5814            .theme_elements
5815            .format_scheme
5816            .background_fill_styles[0] = Fill::from_xml(
5817            br#"<a:blipFill xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><a:blip r:embed="rId9"/><a:stretch><a:fillRect/></a:stretch></a:blipFill>"#,
5818        )
5819        .unwrap();
5820        let media = ScopedMediaIds {
5821            master: HashMap::from([("rId9".to_owned(), MediaId(9))]),
5822            ..ScopedMediaIds::default()
5823        };
5824
5825        let resolved = fixture
5826            .context()
5827            .resolve_slide_with_media((40.0, 20.0), &media)
5828            .unwrap();
5829        assert!(resolved.background.is_none());
5830        assert_eq!(
5831            resolved.diagnostics,
5832            [Diagnostic {
5833                message: "theme-referenced background picture requires theme relationship scope"
5834                    .to_owned(),
5835            }]
5836        );
5837    }
5838
5839    #[test]
5840    fn background_picture_preserves_crop_stretch_and_tile_placement() {
5841        let tile = r#"<p:bg><p:bgPr><a:blipFill dpi="144" rotWithShape="0"><a:blip xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:embed="rId1"/><a:srcRect l="10000"/><a:tile tx="12700" ty="-25400" sx="50000" sy="200000" flip="xy" algn="br"/></a:blipFill></p:bgPr></p:bg>"#;
5842        let stretch = r#"<p:bg><p:bgPr><a:blipFill><a:blip xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:embed="rId2"/><a:stretch><a:fillRect l="10000" t="20000" r="30000" b="40000"/></a:stretch></a:blipFill></p:bgPr></p:bg>"#;
5843        let media = ScopedMediaIds {
5844            slide: HashMap::from([
5845                ("rId1".to_owned(), MediaId(1)),
5846                ("rId2".to_owned(), MediaId(2)),
5847            ]),
5848            ..ScopedMediaIds::default()
5849        };
5850
5851        let tile_fixture = Fixture::from_xml(
5852            &slide_xml_with("", tile, ""),
5853            &layout_xml_with("", "", "", ""),
5854            &master_xml_with("", "", ""),
5855        );
5856        let resolved = tile_fixture
5857            .context()
5858            .resolve_slide_with_media((40.0, 20.0), &media)
5859            .unwrap();
5860        let Some(ResolvedBackground::Image(image)) = resolved.background else {
5861            panic!("expected tiled background image");
5862        };
5863        assert_eq!(image.src_rect.unwrap().left, 0.1);
5864        assert_eq!(image.dpi, Some(144.0));
5865        assert!(!image.rotate_with_shape);
5866        let ResolvedImagePlacement::Tile(tile) = image.placement else {
5867            panic!("expected tile placement");
5868        };
5869        assert_eq!(tile.translation, Point { x: 1.0, y: -2.0 });
5870        assert_eq!((tile.scale_x, tile.scale_y), (0.5, 2.0));
5871        assert_eq!(tile.flip, ResolvedTileFlip::Both);
5872        assert_eq!(tile.alignment, ResolvedRectAlignment::BottomRight);
5873
5874        let stretch_fixture = Fixture::from_xml(
5875            &slide_xml_with("", stretch, ""),
5876            &layout_xml_with("", "", "", ""),
5877            &master_xml_with("", "", ""),
5878        );
5879        let resolved = stretch_fixture
5880            .context()
5881            .resolve_slide_with_media((40.0, 20.0), &media)
5882            .unwrap();
5883        let Some(ResolvedBackground::Image(image)) = resolved.background else {
5884            panic!("expected stretched background image");
5885        };
5886        assert_eq!(
5887            image.placement,
5888            ResolvedImagePlacement::Stretch {
5889                fill_rect: Some(crate::CropRect {
5890                    left: 0.1,
5891                    top: 0.2,
5892                    right: 0.3,
5893                    bottom: 0.4,
5894                }),
5895            }
5896        );
5897    }
5898
5899    #[test]
5900    fn missing_background_picture_relationship_is_diagnosed() {
5901        let background = r#"<p:bg><p:bgPr><a:blipFill><a:blip xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:embed="rId404"/><a:stretch><a:fillRect/></a:stretch></a:blipFill></p:bgPr></p:bg>"#;
5902        let fixture = Fixture::from_xml(
5903            &slide_xml_with("", background, ""),
5904            &layout_xml_with("", "", "", ""),
5905            &master_xml_with("", "", ""),
5906        );
5907
5908        let resolved = fixture
5909            .context()
5910            .resolve_slide_with_media((40.0, 20.0), &ScopedMediaIds::default())
5911            .unwrap();
5912        assert!(resolved.background.is_none());
5913        assert_eq!(
5914            resolved.diagnostics,
5915            [Diagnostic {
5916                message: "missing slide background image relationship `rId404`".to_owned(),
5917            }]
5918        );
5919    }
5920
5921    #[test]
5922    fn unsupported_background_fill_records_a_specific_diagnostic() {
5923        let fixture = Fixture::from_xml(
5924            &slide_xml_with(
5925                "",
5926                r#"<p:bg><p:bgPr><a:pattFill prst="pct5"><a:fgClr><a:srgbClr val="FF0000"/></a:fgClr><a:bgClr><a:srgbClr val="FFFFFF"/></a:bgClr></a:pattFill></p:bgPr></p:bg>"#,
5927                "",
5928            ),
5929            &layout_xml_with("", "", "", ""),
5930            &master_xml_with("", "", ""),
5931        );
5932
5933        let resolved = fixture.context().resolve_slide((40.0, 20.0)).unwrap();
5934        let Some(ResolvedBackground::Paint(Paint::Grid {
5935            bounds,
5936            columns,
5937            rows,
5938            colors,
5939        })) = &resolved.background
5940        else {
5941            panic!("supported pattern background must paint");
5942        };
5943        assert_eq!((*columns, *rows), (64, 64));
5944        assert_eq!((bounds.width, bounds.height), (8.0, 8.0));
5945        assert!(colors.contains(&Color::from_hex("FF0000")));
5946        assert!(colors.contains(&Color::WHITE));
5947        assert!(resolved.diagnostics.is_empty());
5948
5949        let group_fill = Fixture::from_xml(
5950            &slide_xml_with("", r#"<p:bg><p:bgPr><a:grpFill/></p:bgPr></p:bg>"#, ""),
5951            &layout_xml_with("", "", "", ""),
5952            &master_xml_with("", "", ""),
5953        )
5954        .context()
5955        .resolve_slide((40.0, 20.0))
5956        .unwrap();
5957        assert!(group_fill.background.is_none());
5958        assert_eq!(
5959            group_fill.diagnostics,
5960            vec![oxml_layout::Diagnostic {
5961                message: "unsupported background group fill".to_owned(),
5962            }]
5963        );
5964    }
5965
5966    #[test]
5967    fn show_master_shapes_suppresses_the_owned_pass() {
5968        let master_suppressed = Fixture::from_xml(
5969            &slide_xml_with("", "", &shape_with_text(None, None, "slide")),
5970            &layout_xml_with(
5971                "showMasterSp=\"0\"",
5972                "",
5973                &shape_with_text(None, None, "layout"),
5974                "",
5975            ),
5976            &master_xml_with("", &shape_with_text(None, None, "master"), ""),
5977        );
5978        let layout_suppressed = Fixture::from_xml(
5979            &slide_xml_with(
5980                "showMasterSp=\"0\"",
5981                "",
5982                &shape_with_text(None, None, "slide"),
5983            ),
5984            &layout_xml_with("", "", &shape_with_text(None, None, "layout"), ""),
5985            &master_xml_with("", &shape_with_text(None, None, "master"), ""),
5986        );
5987
5988        let master_suppressed_texts: Vec<_> = master_suppressed
5989            .context()
5990            .flatten()
5991            .iter()
5992            .filter_map(item_text)
5993            .collect();
5994        let layout_suppressed_texts: Vec<_> = layout_suppressed
5995            .context()
5996            .flatten()
5997            .iter()
5998            .filter_map(item_text)
5999            .collect();
6000
6001        assert_eq!(master_suppressed_texts, ["layout", "slide"]);
6002        assert_eq!(layout_suppressed_texts, ["master", "slide"]);
6003    }
6004
6005    #[test]
6006    fn slide_placeholder_keeps_source_order_and_suppresses_inherited_matches() {
6007        let slide_children = [
6008            shape_with_text(None, None, "before"),
6009            shape_with_text(Some("ftr"), Some(9), "slide footer"),
6010            shape_with_text(None, None, "after"),
6011        ]
6012        .join("");
6013        let fixture = Fixture::from_xml(
6014            &slide_xml_with("", "", &slide_children),
6015            &layout_xml_with(
6016                "",
6017                "",
6018                &shape_with_text(Some("ftr"), Some(9), "layout footer"),
6019                "<p:hf/>",
6020            ),
6021            &master_xml_with(
6022                "",
6023                &shape_with_text(Some("ftr"), Some(9), "master footer"),
6024                "<p:hf/>",
6025            ),
6026        );
6027
6028        let sources_and_text: Vec<_> = fixture
6029            .context()
6030            .flatten()
6031            .iter()
6032            .filter_map(|item| item_text(item).map(|text| (item.source(), text)))
6033            .collect();
6034
6035        assert_eq!(
6036            sources_and_text,
6037            [
6038                (FlattenedSource::Slide, "before".to_owned()),
6039                (FlattenedSource::Slide, "slide footer".to_owned()),
6040                (FlattenedSource::Slide, "after".to_owned()),
6041            ]
6042        );
6043    }
6044
6045    #[test]
6046    fn fx208_native_latent_footer_templates_require_slide_owned_fields() {
6047        let inherited = [
6048            shape_with_text(Some("dt"), Some(2), "template date"),
6049            shape_with_text(Some("ftr"), Some(3), "template footer"),
6050            shape_with_text(Some("sldNum"), Some(4), "template number"),
6051        ]
6052        .join("");
6053        let ordinary = shape_with_text(None, None, "ordinary master text");
6054        let owned = shape_with_text(Some("ftr"), Some(3), "owned slide footer");
6055        for flags in [
6056            "",
6057            "<p:hf/>",
6058            "<p:hf dt=\"1\" ftr=\"1\" sldNum=\"1\"/>",
6059            "<p:hf dt=\"0\" ftr=\"0\" sldNum=\"0\"/>",
6060        ] {
6061            let fixture = Fixture::from_xml(
6062                &slide_xml_with("", "", &owned),
6063                &layout_xml_with(
6064                    "type=\"title\" showSpecialPlsOnTitleSld=\"1\"",
6065                    "",
6066                    &inherited,
6067                    flags,
6068                ),
6069                &master_xml_with("", &format!("{inherited}{ordinary}"), flags),
6070            );
6071            let actual = fixture
6072                .context()
6073                .flatten()
6074                .iter()
6075                .filter_map(|item| item_text(item).map(|text| (item.source(), text)))
6076                .collect::<Vec<_>>();
6077            assert!(
6078                actual.contains(&(FlattenedSource::Master, "ordinary master text".into())),
6079                "ordinary master positive setup"
6080            );
6081            assert!(
6082                actual.contains(&(FlattenedSource::Slide, "owned slide footer".into())),
6083                "slide-owned positive setup"
6084            );
6085            assert_eq!(
6086                actual,
6087                [
6088                    (FlattenedSource::Master, "ordinary master text".into()),
6089                    (FlattenedSource::Slide, "owned slide footer".into()),
6090                ],
6091                "F208 native latent-template visibility: {flags}"
6092            );
6093        }
6094    }
6095
6096    #[test]
6097    fn inherited_latent_placeholders_remain_templates_regardless_of_flags() {
6098        let latent_shapes = [
6099            shape_with_text(Some("dt"), Some(1), "date"),
6100            shape_with_text(Some("ftr"), Some(2), "footer"),
6101            shape_with_text(Some("sldNum"), Some(3), "number"),
6102        ]
6103        .join("");
6104        let layout_fixture = Fixture::from_xml(
6105            &slide_xml_with("", "", ""),
6106            &layout_xml_with("", "", &latent_shapes, "<p:hf dt=\"0\"/>"),
6107            &master_xml_with("", "", "<p:hf sldNum=\"0\"/>"),
6108        );
6109
6110        let layout_sources_and_text = layout_fixture
6111            .context()
6112            .flatten()
6113            .iter()
6114            .filter_map(|item| item_text(item).map(|text| (item.source(), text)))
6115            .collect::<Vec<_>>();
6116        assert!(layout_sources_and_text.is_empty());
6117
6118        let master_fixture = Fixture::from_xml(
6119            &slide_xml_with("", "", ""),
6120            &layout_xml_with("", "", "", ""),
6121            &master_xml_with(
6122                "",
6123                &latent_shapes,
6124                "<p:hf dt=\"1\" ftr=\"1\" sldNum=\"0\"/>",
6125            ),
6126        );
6127        let master_sources_and_text = master_fixture
6128            .context()
6129            .flatten()
6130            .iter()
6131            .filter_map(|item| item_text(item).map(|text| (item.source(), text)))
6132            .collect::<Vec<_>>();
6133        assert!(master_sources_and_text.is_empty());
6134    }
6135
6136    #[test]
6137    fn empty_latent_placeholders_do_not_materialize_inherited_templates() {
6138        fn source_texts(fixture: &Fixture) -> Vec<(FlattenedSource, String)> {
6139            fixture
6140                .context()
6141                .flatten()
6142                .iter()
6143                .filter_map(|item| item_text(item).map(|text| (item.source(), text)))
6144                .collect()
6145        }
6146
6147        for ph_type in ["dt", "ftr", "sldNum"] {
6148            let occupied_slide = Fixture::from_xml(
6149                &slide_xml_with("", "", &shape_with_text(Some(ph_type), Some(1), "slide")),
6150                &layout_xml_with(
6151                    "",
6152                    "",
6153                    &shape_with_text(Some(ph_type), Some(2), "layout"),
6154                    "<p:hf dt=\"0\" ftr=\"0\" sldNum=\"0\"/>",
6155                ),
6156                &master_xml_with(
6157                    "",
6158                    &shape_with_text(Some(ph_type), Some(3), "master"),
6159                    "<p:hf dt=\"0\" ftr=\"0\" sldNum=\"0\"/>",
6160                ),
6161            );
6162            assert_eq!(
6163                source_texts(&occupied_slide),
6164                [(FlattenedSource::Slide, "slide".to_owned())],
6165                "occupied slide {ph_type}"
6166            );
6167
6168            let empty_slide = Fixture::from_xml(
6169                &slide_xml_with("", "", &shape_with_text(Some(ph_type), Some(1), "")),
6170                &layout_xml_with(
6171                    "",
6172                    "",
6173                    &shape_with_text(Some(ph_type), Some(2), "layout"),
6174                    "<p:hf/>",
6175                ),
6176                &master_xml_with(
6177                    "",
6178                    &shape_with_text(Some(ph_type), Some(3), "master"),
6179                    "<p:hf/>",
6180                ),
6181            );
6182            assert_eq!(
6183                source_texts(&empty_slide),
6184                Vec::<(FlattenedSource, String)>::new(),
6185                "empty slide {ph_type}"
6186            );
6187
6188            let empty_layout = Fixture::from_xml(
6189                &slide_xml_with("", "", ""),
6190                &layout_xml_with(
6191                    "",
6192                    "",
6193                    &shape_with_text(Some(ph_type), Some(2), ""),
6194                    "<p:hf/>",
6195                ),
6196                &master_xml_with(
6197                    "",
6198                    &shape_with_text(Some(ph_type), Some(3), "master"),
6199                    "<p:hf/>",
6200                ),
6201            );
6202            assert_eq!(
6203                source_texts(&empty_layout),
6204                Vec::<(FlattenedSource, String)>::new(),
6205                "empty layout {ph_type}"
6206            );
6207
6208            let empty_master = Fixture::from_xml(
6209                &slide_xml_with("", "", ""),
6210                &layout_xml_with("", "", "", ""),
6211                &master_xml_with("", &shape_with_text(Some(ph_type), Some(3), ""), "<p:hf/>"),
6212            );
6213            assert!(
6214                source_texts(&empty_master).is_empty(),
6215                "empty master {ph_type}"
6216            );
6217        }
6218    }
6219
6220    #[test]
6221    fn slide_owned_latent_placeholder_source_matrix_matches_the_oracle_decision() {
6222        let slide_number = shape_with_text(Some("sldNum"), Some(3), "slide number");
6223        let layout_date = shape_with_text(Some("dt"), Some(1), "layout date");
6224        let cases = [
6225            ("", "", vec!["slide number"]),
6226            (
6227                "",
6228                "<p:hf dt=\"0\" ftr=\"0\" hdr=\"0\" sldNum=\"0\"/>",
6229                vec!["slide number"],
6230            ),
6231            (&layout_date, "<p:hf sldNum=\"1\"/>", vec!["slide number"]),
6232        ];
6233
6234        for (layout_children, master_header_footer, expected) in cases {
6235            let fixture = Fixture::from_xml(
6236                &slide_xml_with("", "", &slide_number),
6237                &layout_xml_with("", "", layout_children, ""),
6238                &master_xml_with("", "", master_header_footer),
6239            );
6240            let texts = fixture
6241                .context()
6242                .flatten()
6243                .iter()
6244                .filter_map(item_text)
6245                .collect::<Vec<_>>();
6246
6247            assert_eq!(
6248                texts, expected,
6249                "master header-footer: {master_header_footer}"
6250            );
6251        }
6252    }
6253
6254    #[test]
6255    fn absent_header_footer_container_hides_inherited_latent_placeholders() {
6256        let slide = shape_with_text(Some("ftr"), Some(11), "slide footer");
6257        let layout = [
6258            shape_with_text(Some("dt"), Some(10), "layout date"),
6259            shape_with_text(Some("sldNum"), Some(12), "layout number"),
6260        ]
6261        .join("");
6262        let fixture = Fixture::from_xml(&slide_xml(&slide), &layout_xml(&layout), &master_xml(""));
6263
6264        let texts = fixture
6265            .context()
6266            .flatten()
6267            .into_iter()
6268            .filter_map(|item| item_text(&item))
6269            .collect::<Vec<_>>();
6270
6271        assert_eq!(texts, ["slide footer"]);
6272    }
6273
6274    #[test]
6275    fn corpus_slide_resolves_without_theme_references() {
6276        let stats = resolve_pinned_corpus();
6277
6278        assert_eq!(stats.decks, EXPECTED_CORPUS_DECKS);
6279        assert!(stats.slides > EXPECTED_CORPUS_DECKS);
6280        assert_eq!(stats.contextual_errors, 0, "{}", stats.errors.join("\n"));
6281        assert_eq!(stats.unresolved_members, 0, "{}", stats.errors.join("\n"));
6282        assert_eq!(stats.resolved, stats.slides);
6283        assert_eq!(stats.theme_references, 0);
6284    }
6285
6286    #[test]
6287    fn resolved_contract_contains_no_presentation_or_drawing_types() {
6288        fn assert_owned_and_static(_: ResolvedSlide) {}
6289
6290        let fixture = Fixture::new(&shape_with_details(None, None, &transform(0), None), "", "");
6291        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
6292        assert_owned_and_static(resolved);
6293        for type_name in [
6294            std::any::type_name::<ResolvedSlide>(),
6295            std::any::type_name::<crate::ResolvedShape>(),
6296            std::any::type_name::<crate::ResolvedContent>(),
6297        ] {
6298            assert!(!type_name.contains("rpptx_oxml"));
6299            assert!(!type_name.contains("oxml_drawing"));
6300        }
6301    }
6302
6303    #[test]
6304    fn line_end_resolution_keeps_kind_width_and_length() {
6305        let properties = format!(
6306            r#"{}<a:prstGeom prst="line"><a:avLst/></a:prstGeom><a:ln w="25400"><a:solidFill><a:prstClr val="black"/></a:solidFill><a:headEnd type="diamond" w="sm"/><a:tailEnd type="triangle" len="lg"/></a:ln>"#,
6307            transform(0)
6308        );
6309        let fixture = Fixture::new(&shape_with_details(None, None, &properties, None), "", "");
6310
6311        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
6312        let shape = &resolved.shapes[0];
6313        assert_eq!(
6314            shape.head_end,
6315            Some(ResolvedLineEnd {
6316                kind: ResolvedLineEndKind::Diamond,
6317                width: ResolvedLineEndSize::Small,
6318                length: ResolvedLineEndSize::Medium,
6319            })
6320        );
6321        assert_eq!(
6322            shape.tail_end,
6323            Some(ResolvedLineEnd {
6324                kind: ResolvedLineEndKind::Triangle,
6325                width: ResolvedLineEndSize::Medium,
6326                length: ResolvedLineEndSize::Large,
6327            })
6328        );
6329        assert_eq!(shape.line.as_ref().map(|line| line.width), Some(2.0));
6330    }
6331
6332    #[test]
6333    fn same_relationship_id_resolves_to_distinct_media_in_each_source_scope() {
6334        let fixture = Fixture::new(
6335            &picture("rId2", "", "", 25_400),
6336            &picture("rId2", "", "", 12_700),
6337            &picture("rId2", "", "", 0),
6338        );
6339        let media = ScopedMediaIds {
6340            slide: HashMap::from([("rId2".to_owned(), MediaId(1))]),
6341            layout: HashMap::from([("rId2".to_owned(), MediaId(2))]),
6342            master: HashMap::from([("rId2".to_owned(), MediaId(3))]),
6343            ..ScopedMediaIds::default()
6344        };
6345
6346        let resolved = fixture
6347            .context()
6348            .resolve_slide_with_media((720.0, 540.0), &media)
6349            .unwrap();
6350        let ids = resolved
6351            .shapes
6352            .iter()
6353            .map(|shape| match shape.content {
6354                ResolvedContent::Image(ref image) => image.media,
6355                _ => panic!("scoped picture should resolve to image content"),
6356            })
6357            .collect::<Vec<_>>();
6358
6359        assert_eq!(ids, [MediaId(3), MediaId(2), MediaId(1)]);
6360    }
6361
6362    #[test]
6363    fn picture_placeholder_inherits_layout_bounds_and_keeps_slide_media_scope() {
6364        let slide_picture = r#"<p:pic><p:nvPicPr><p:cNvPr/><p:cNvPicPr/><p:nvPr><p:ph type="pic" idx="1"/></p:nvPr></p:nvPicPr><p:blipFill><a:blip xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:embed="rId2"/><a:srcRect t="10000" b="20000"/><a:stretch><a:fillRect/></a:stretch></p:blipFill><p:spPr/></p:pic>"#;
6365        let layout_placeholder = shape_with_details(
6366            Some("pic"),
6367            Some(1),
6368            r#"<a:xfrm><a:off x="12700" y="25400"/><a:ext cx="38100" cy="50800"/></a:xfrm>"#,
6369            None,
6370        );
6371        let master_placeholder = shape_with_details(
6372            Some("pic"),
6373            Some(1),
6374            r#"<a:xfrm><a:off x="127000" y="254000"/><a:ext cx="381000" cy="508000"/></a:xfrm>"#,
6375            None,
6376        );
6377        let fixture = Fixture::new(slide_picture, &layout_placeholder, &master_placeholder);
6378        let context = fixture.context();
6379        let flattened = context.flatten();
6380        let bounded_picture_sources = flattened
6381            .iter()
6382            .filter(|item| {
6383                matches!(
6384                    item,
6385                    FlattenedItem::Shape {
6386                        child: ShapeTreeChild::Picture(picture),
6387                        ..
6388                    } if context.effective_picture_xfrm(picture).is_some()
6389                )
6390            })
6391            .count();
6392        let media = ScopedMediaIds {
6393            slide: HashMap::from([("rId2".to_owned(), MediaId(7))]),
6394            layout: HashMap::from([("rId2".to_owned(), MediaId(8))]),
6395            master: HashMap::from([("rId2".to_owned(), MediaId(9))]),
6396            ..ScopedMediaIds::default()
6397        };
6398
6399        let resolved = context
6400            .resolve_slide_with_media((720.0, 540.0), &media)
6401            .unwrap();
6402        assert_eq!(bounded_picture_sources, 1);
6403        assert_eq!(resolved.shapes.len(), bounded_picture_sources);
6404        let picture = &resolved.shapes[0];
6405        assert_eq!(
6406            picture.bounds,
6407            Rect {
6408                x: 1.0,
6409                y: 2.0,
6410                width: 3.0,
6411                height: 4.0,
6412            }
6413        );
6414        let ResolvedContent::Image(image) = &picture.content else {
6415            panic!("picture placeholder did not retain image content");
6416        };
6417        assert_eq!(image.media, MediaId(7));
6418        assert_eq!(image.src_rect.as_ref().map(|crop| crop.top), Some(0.1));
6419        assert_eq!(image.src_rect.as_ref().map(|crop| crop.bottom), Some(0.2));
6420        assert_eq!(
6421            image.placement,
6422            ResolvedImagePlacement::Stretch {
6423                fill_rect: Some(crate::CropRect::default()),
6424            }
6425        );
6426        assert!(resolved.diagnostics.is_empty());
6427    }
6428
6429    #[test]
6430    fn direct_shape_picture_fill_uses_its_producer_scope() {
6431        let fixture = Fixture::new(
6432            &shape_picture_fill("rId2", 25_400, None),
6433            &shape_picture_fill("rId2", 12_700, None),
6434            &shape_picture_fill("rId2", 0, None),
6435        );
6436        let media = ScopedMediaIds {
6437            slide: HashMap::from([("rId2".to_owned(), MediaId(1))]),
6438            layout: HashMap::from([("rId2".to_owned(), MediaId(2))]),
6439            master: HashMap::from([("rId2".to_owned(), MediaId(3))]),
6440            ..ScopedMediaIds::default()
6441        };
6442
6443        let resolved = fixture
6444            .context()
6445            .resolve_slide_with_media((720.0, 540.0), &media)
6446            .unwrap();
6447        let ids = resolved
6448            .shapes
6449            .iter()
6450            .map(|shape| shape.image_fill.as_ref().unwrap().media)
6451            .collect::<Vec<_>>();
6452
6453        assert_eq!(ids, [MediaId(3), MediaId(2), MediaId(1)]);
6454        assert!(resolved.diagnostics.is_empty());
6455    }
6456
6457    #[test]
6458    fn shape_picture_fill_and_text_are_resolved_independently() {
6459        let shape = shape_picture_fill(
6460            "rId7",
6461            0,
6462            Some(r#"<a:bodyPr/><a:p><a:r><a:t>caption</a:t></a:r></a:p>"#),
6463        );
6464        let fixture = Fixture::new(&shape, "", "");
6465        let media = ScopedMediaIds {
6466            slide: HashMap::from([("rId7".to_owned(), MediaId(7))]),
6467            ..ScopedMediaIds::default()
6468        };
6469
6470        let resolved = fixture
6471            .context()
6472            .resolve_slide_with_media((720.0, 540.0), &media)
6473            .unwrap();
6474        let shape = &resolved.shapes[0];
6475        assert_eq!(shape.image_fill.as_ref().unwrap().media, MediaId(7));
6476        let ResolvedContent::Text(text) = &shape.content else {
6477            panic!("picture-filled shape lost its text");
6478        };
6479        assert!(matches!(
6480            &text.paragraphs[0].runs[0],
6481            ResolvedTextRun::Text { text, .. } if text == "caption"
6482        ));
6483        assert!(resolved.diagnostics.is_empty());
6484    }
6485
6486    #[test]
6487    fn picture_fill_opacity_comes_from_alpha_modulation_fix() {
6488        let opaque = shape_picture_fill("rId7", 0, None);
6489        let faded = opaque.replace(
6490            r#"r:embed="rId7"/>"#,
6491            r#"r:embed="rId7"><a:alphaModFix amt="30000"/></a:blip>"#,
6492        );
6493        assert_ne!(faded, opaque);
6494        let media = ScopedMediaIds {
6495            slide: HashMap::from([("rId7".to_owned(), MediaId(7))]),
6496            ..ScopedMediaIds::default()
6497        };
6498
6499        for (shape, opacity) in [(opaque, 1.0), (faded, 0.3)] {
6500            let fixture = Fixture::new(&shape, "", "");
6501            let resolved = fixture
6502                .context()
6503                .resolve_slide_with_media((720.0, 540.0), &media)
6504                .unwrap();
6505            let image = resolved.shapes[0].image_fill.as_ref().unwrap();
6506            assert_eq!(image.opacity, opacity);
6507            assert!(resolved.diagnostics.is_empty());
6508        }
6509    }
6510
6511    #[test]
6512    fn theme_shape_picture_fill_cannot_use_a_part_relationship() {
6513        let mut fixture = Fixture::new(
6514            &shape_with_details(
6515                None,
6516                None,
6517                &format!(
6518                    r#"{}<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>"#,
6519                    transform(0)
6520                ),
6521                None,
6522            )
6523            .replace(
6524                "</p:sp>",
6525                r#"<p:style><a:lnRef idx="0"><a:schemeClr val="accent1"/></a:lnRef><a:fillRef idx="1"><a:schemeClr val="accent1"/></a:fillRef><a:effectRef idx="0"><a:schemeClr val="accent1"/></a:effectRef><a:fontRef idx="minor"><a:schemeClr val="tx1"/></a:fontRef></p:style></p:sp>"#,
6526            ),
6527            "",
6528            "",
6529        );
6530        fixture.theme.theme_elements.format_scheme.fill_styles[0] = Fill::from_xml(
6531            br#"<a:blipFill xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><a:blip xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:embed="rId9"/><a:stretch><a:fillRect/></a:stretch></a:blipFill>"#,
6532        )
6533        .unwrap();
6534        let media = ScopedMediaIds {
6535            slide: HashMap::from([("rId9".to_owned(), MediaId(9))]),
6536            master: HashMap::from([("rId9".to_owned(), MediaId(99))]),
6537            ..ScopedMediaIds::default()
6538        };
6539
6540        let resolved = fixture
6541            .context()
6542            .resolve_slide_with_media((720.0, 540.0), &media)
6543            .unwrap();
6544
6545        assert!(resolved.shapes[0].image_fill.is_none());
6546        assert_eq!(
6547            resolved.shapes[0].unsupported,
6548            Some("theme-referenced shape picture fill")
6549        );
6550        assert_eq!(
6551            resolved.diagnostics,
6552            [Diagnostic {
6553                message: "theme-referenced shape picture fill requires theme relationship scope"
6554                    .to_owned(),
6555            }]
6556        );
6557    }
6558
6559    #[test]
6560    fn missing_direct_shape_picture_relationship_keeps_text() {
6561        let shape = shape_picture_fill(
6562            "rId404",
6563            0,
6564            Some(r#"<a:bodyPr/><a:p><a:r><a:t>keep me</a:t></a:r></a:p>"#),
6565        );
6566        let resolved = Fixture::new(&shape, "", "")
6567            .context()
6568            .resolve_slide_with_media((720.0, 540.0), &ScopedMediaIds::default())
6569            .unwrap();
6570
6571        assert!(resolved.shapes[0].image_fill.is_none());
6572        assert!(matches!(
6573            resolved.shapes[0].content,
6574            ResolvedContent::Text(_)
6575        ));
6576        assert_eq!(
6577            resolved.shapes[0].unsupported,
6578            Some("missing image relationship")
6579        );
6580        assert_eq!(
6581            resolved.diagnostics,
6582            [Diagnostic {
6583                message: "missing slide shape image relationship `rId404`".to_owned(),
6584            }]
6585        );
6586    }
6587
6588    #[test]
6589    fn rejected_picture_relationship_reports_and_uses_visible_bounds() {
6590        let fixture = Fixture::new(&picture("rId7", "", "", 0), "", "");
6591        let failures = ScopedMediaFailures {
6592            slide: HashMap::from([(
6593                "rId7".to_owned(),
6594                "exceeds the 64 MiB decoded-image render limit".to_owned(),
6595            )]),
6596            ..ScopedMediaFailures::default()
6597        };
6598
6599        let resolved = fixture
6600            .context()
6601            .with_media_failures(&failures)
6602            .resolve_slide_with_media((720.0, 540.0), &ScopedMediaIds::default())
6603            .unwrap();
6604
6605        assert_eq!(
6606            resolved.shapes[0].geometry,
6607            ResolvedGeometry::BoundsFallback
6608        );
6609        assert_eq!(resolved.shapes[0].unsupported, Some("image render failure"));
6610        assert_eq!(
6611            resolved.diagnostics,
6612            [Diagnostic {
6613                message: "picture image relationship `rId7` exceeds the 64 MiB decoded-image render limit"
6614                    .to_owned(),
6615            }]
6616        );
6617    }
6618
6619    #[test]
6620    fn fx209_hidden_picture_is_omitted_without_affecting_visible_owners() {
6621        for (attribute, count) in [
6622            ("", 1),
6623            ("hidden='0'", 1),
6624            ("hidden='false'", 1),
6625            ("hidden='1'", 0),
6626            ("hidden='true'", 0),
6627            ("xmlns:f='urn:foreign' f:hidden='1'", 1),
6628        ] {
6629            let input = picture("rId1", "", "", 0)
6630                .replace("<p:cNvPr/>", &format!("<p:cNvPr {attribute}/>"));
6631            let fixture = Fixture::new(&input, "", "");
6632            let media = ScopedMediaIds {
6633                slide: HashMap::from([("rId1".to_owned(), MediaId(11))]),
6634                ..ScopedMediaIds::default()
6635            };
6636            let resolved = fixture
6637                .context()
6638                .resolve_slide_with_media((40.0, 20.0), &media)
6639                .unwrap();
6640            assert_eq!(resolved.shapes.len(), count, "{attribute}");
6641            assert!(resolved.diagnostics.is_empty());
6642        }
6643    }
6644
6645    #[test]
6646    fn picture_model_resolves_to_neutral_stretch_and_tile_placement() {
6647        let fixture = Fixture::new(
6648            &format!(
6649                "{}{}{}{}",
6650                picture("rId1", "", "", 0),
6651                picture(
6652                    "rId2",
6653                    "dpi=\"144\" rotWithShape=\"0\"",
6654                    r#"<a:srcRect l="10000"/><a:tile tx="12700" ty="-25400" sx="50000" sy="200000" flip="xy" algn="br"/>"#,
6655                    12_700,
6656                ),
6657                picture(
6658                    "rId4",
6659                    "",
6660                    r#"<a:stretch><a:fillRect l="10000" t="20000" r="30000" b="40000"/></a:stretch>"#,
6661                    25_400,
6662                ),
6663                linked_picture("https://example.invalid/image.png", 38_100),
6664            ),
6665            "",
6666            "",
6667        );
6668        let media = ScopedMediaIds {
6669            slide: HashMap::from([
6670                ("rId1".to_owned(), MediaId(11)),
6671                ("rId2".to_owned(), MediaId(12)),
6672                ("rId4".to_owned(), MediaId(14)),
6673            ]),
6674            ..ScopedMediaIds::default()
6675        };
6676
6677        let resolved = fixture
6678            .context()
6679            .resolve_slide_with_media((720.0, 540.0), &media)
6680            .unwrap();
6681        let ResolvedContent::Image(image) = &resolved.shapes[0].content else {
6682            panic!("default picture should resolve");
6683        };
6684        assert_eq!(image.placement, ResolvedImagePlacement::default());
6685        assert_eq!(image.dpi, None);
6686        assert!(image.rotate_with_shape);
6687
6688        let ResolvedContent::Image(image) = &resolved.shapes[1].content else {
6689            panic!("tile picture should resolve");
6690        };
6691        let ResolvedImagePlacement::Tile(tile) = &image.placement else {
6692            panic!("expected tile placement");
6693        };
6694        assert_eq!(image.src_rect.unwrap().left, 0.1);
6695        assert_eq!(tile.translation, Point { x: 1.0, y: -2.0 });
6696        assert_eq!((tile.scale_x, tile.scale_y), (0.5, 2.0));
6697        assert_eq!(tile.flip, ResolvedTileFlip::Both);
6698        assert_eq!(tile.alignment, ResolvedRectAlignment::BottomRight);
6699        assert_eq!(image.dpi, Some(144.0));
6700        assert!(!image.rotate_with_shape);
6701
6702        let ResolvedContent::Image(image) = &resolved.shapes[2].content else {
6703            panic!("explicit stretch picture should resolve");
6704        };
6705        let ResolvedImagePlacement::Stretch { fill_rect } = &image.placement else {
6706            panic!("expected stretch placement");
6707        };
6708        assert_eq!(
6709            *fill_rect,
6710            Some(crate::CropRect {
6711                left: 0.1,
6712                top: 0.2,
6713                right: 0.3,
6714                bottom: 0.4,
6715            })
6716        );
6717        assert_eq!(resolved.shapes[3].content, ResolvedContent::None);
6718        assert_eq!(
6719            resolved.shapes[3].unsupported,
6720            Some("external picture media")
6721        );
6722        assert!(resolved.diagnostics.iter().any(|diagnostic| {
6723            diagnostic
6724                .message
6725                .contains("external picture relationship `https://example.invalid/image.png`")
6726        }));
6727    }
6728
6729    #[test]
6730    fn transform_resolves_to_points_rotation_and_flips() {
6731        let fixture = Fixture::new(
6732            &shape_with_details(
6733                None,
6734                None,
6735                r#"<a:xfrm rot="5400000" flipH="1" flipV="1"><a:off x="12700" y="25400"/><a:ext cx="38100" cy="50800"/></a:xfrm>"#,
6736                None,
6737            ),
6738            "",
6739            "",
6740        );
6741
6742        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
6743        let shape = &resolved.shapes[0];
6744        assert_eq!(shape.bounds.x, 1.0);
6745        assert_eq!(shape.bounds.y, 2.0);
6746        assert_eq!(shape.bounds.width, 3.0);
6747        assert_eq!(shape.bounds.height, 4.0);
6748        assert_eq!(shape.rotation_deg, 90.0);
6749        assert!(shape.flip_h);
6750        assert!(shape.flip_v);
6751    }
6752
6753    #[test]
6754    fn custom_geometry_scales_each_path_coordinate_space_to_shape_points() {
6755        let geometry = r#"<a:custGeom><a:avLst/><a:rect l="5400" t="2700" r="16200" b="8100"/><a:pathLst><a:path w="21600" h="10800"><a:moveTo><a:pt x="0" y="0"/></a:moveTo><a:lnTo><a:pt x="21600" y="10800"/></a:lnTo></a:path></a:pathLst></a:custGeom>"#;
6756        let shape = shape_with_details(
6757            None,
6758            None,
6759            &format!(
6760                "<a:xfrm><a:off x=\"0\" y=\"0\"/><a:ext cx=\"127000\" cy=\"254000\"/></a:xfrm>{geometry}"
6761            ),
6762            None,
6763        );
6764        let fixture = Fixture::new(&shape, "", "");
6765
6766        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
6767        let ResolvedGeometry::Custom { paths, text_rect } = &resolved.shapes[0].geometry else {
6768            panic!("expected custom geometry");
6769        };
6770        assert_eq!(
6771            paths[0].commands[1],
6772            PathCommand::LineTo(oxml_layout::Point { x: 10.0, y: 20.0 })
6773        );
6774        assert_eq!(
6775            *text_rect,
6776            Some(Rect {
6777                x: 2.5,
6778                y: 5.0,
6779                width: 5.0,
6780                height: 10.0,
6781            })
6782        );
6783    }
6784
6785    #[test]
6786    fn custom_geometry_uses_shape_size_for_omitted_path_dimensions() {
6787        let omitted_both = r#"<a:custGeom><a:avLst/><a:pathLst><a:path><a:moveTo><a:pt x="0" y="0"/></a:moveTo><a:lnTo><a:pt x="r" y="b"/></a:lnTo></a:path></a:pathLst></a:custGeom>"#;
6788        let omitted_width = r#"<a:custGeom><a:avLst/><a:pathLst><a:path h="100"><a:moveTo><a:pt x="0" y="0"/></a:moveTo><a:lnTo><a:pt x="r" y="100"/></a:lnTo></a:path></a:pathLst></a:custGeom>"#;
6789        let shapes = [omitted_both, omitted_width]
6790            .map(|geometry| {
6791                shape_with_details(
6792                    None,
6793                    None,
6794                    &format!(
6795                        "<a:xfrm><a:off x=\"0\" y=\"0\"/><a:ext cx=\"127000\" cy=\"254000\"/></a:xfrm>{geometry}"
6796                    ),
6797                    None,
6798                )
6799            })
6800            .join("");
6801        let resolved = Fixture::new(&shapes, "", "")
6802            .context()
6803            .resolve_slide((720.0, 540.0))
6804            .unwrap();
6805
6806        for shape in &resolved.shapes {
6807            let ResolvedGeometry::Custom { paths, .. } = &shape.geometry else {
6808                panic!("expected concrete custom geometry");
6809            };
6810            assert_eq!(
6811                paths[0].commands[1],
6812                PathCommand::LineTo(oxml_layout::Point { x: 10.0, y: 20.0 })
6813            );
6814        }
6815    }
6816
6817    #[test]
6818    fn invalid_custom_geometry_retains_a_diagnosed_bounds_fallback() {
6819        let geometry = r#"<a:custGeom><a:avLst/><a:pathLst><a:path w="1" h="1"><a:moveTo><a:pt x="missing" y="0"/></a:moveTo></a:path></a:pathLst></a:custGeom>"#;
6820        let shape = shape_with_details(None, None, &format!("{}{}", transform(0), geometry), None);
6821        let fixture = Fixture::new(&shape, "", "");
6822
6823        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
6824        assert_eq!(
6825            resolved.shapes[0].geometry,
6826            ResolvedGeometry::BoundsFallback
6827        );
6828        assert_eq!(
6829            resolved.shapes[0].unsupported,
6830            Some("custom geometry evaluation")
6831        );
6832        assert!(
6833            resolved
6834                .diagnostics
6835                .iter()
6836                .any(|diagnostic| { diagnostic.message.contains("unknown guide: missing") })
6837        );
6838    }
6839
6840    #[test]
6841    fn connector_presets_reuse_geometry_and_preserve_line_ends() {
6842        let line = r#"<a:ln w="12700"><a:solidFill><a:srgbClr val="112233"/></a:solidFill><a:headEnd type="triangle"/></a:ln>"#;
6843        let fixture = Fixture::new(
6844            &[
6845                connector("line", 127_000, 254_000, "", line),
6846                connector("straightConnector1", 127_000, 254_000, "", line),
6847                connector(
6848                    "bentConnector3",
6849                    127_000,
6850                    254_000,
6851                    r#"<a:gd name="adj1" fmla="val 25000"/>"#,
6852                    line,
6853                ),
6854                connector(
6855                    "curvedConnector3",
6856                    127_000,
6857                    254_000,
6858                    r#"<a:gd name="adj1" fmla="val 50000"/>"#,
6859                    line,
6860                ),
6861            ]
6862            .join(""),
6863            "",
6864            "",
6865        );
6866
6867        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
6868
6869        assert_eq!(resolved.shapes.len(), 4);
6870        assert!(resolved.diagnostics.is_empty());
6871        for shape in &resolved.shapes {
6872            assert_eq!(shape.unsupported, None);
6873            assert!(shape.line.is_some());
6874            assert_eq!(
6875                shape.head_end.as_ref().map(|end| end.kind),
6876                Some(ResolvedLineEndKind::Triangle)
6877            );
6878        }
6879        for index in [0, 1] {
6880            let ResolvedGeometry::Custom { paths, .. } = &resolved.shapes[index].geometry else {
6881                panic!("expected straight connector geometry");
6882            };
6883            assert_eq!(
6884                paths[0].commands,
6885                [
6886                    PathCommand::MoveTo(Point { x: 0.0, y: 0.0 }),
6887                    PathCommand::LineTo(Point { x: 10.0, y: 20.0 }),
6888                ]
6889            );
6890        }
6891        let ResolvedGeometry::Custom { paths, .. } = &resolved.shapes[2].geometry else {
6892            panic!("expected bent connector geometry");
6893        };
6894        assert_eq!(
6895            paths[0].commands,
6896            [
6897                PathCommand::MoveTo(Point { x: 0.0, y: 0.0 }),
6898                PathCommand::LineTo(Point { x: 2.5, y: 0.0 }),
6899                PathCommand::LineTo(Point { x: 2.5, y: 20.0 }),
6900                PathCommand::LineTo(Point { x: 10.0, y: 20.0 }),
6901            ]
6902        );
6903        let ResolvedGeometry::Custom { paths, .. } = &resolved.shapes[3].geometry else {
6904            panic!("expected curved connector geometry");
6905        };
6906        assert!(matches!(
6907            paths[0].commands.as_slice(),
6908            [
6909                PathCommand::MoveTo(Point { x: 0.0, y: 0.0 }),
6910                PathCommand::CurveTo { to, .. },
6911                PathCommand::CurveTo { .. }
6912            ] if *to == Point { x: 5.0, y: 10.0 }
6913        ));
6914    }
6915
6916    #[test]
6917    fn connector_custom_geometry_reuses_the_shared_path_evaluator() {
6918        let connector = r#"<p:cxnSp><p:nvCxnSpPr><p:cNvPr/><p:cNvCxnSpPr/><p:nvPr/></p:nvCxnSpPr><p:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="127000" cy="254000"/></a:xfrm><a:custGeom><a:avLst/><a:gdLst/><a:ahLst/><a:cxnLst/><a:rect l="l" t="t" r="r" b="b"/><a:pathLst><a:path w="100" h="100"><a:moveTo><a:pt x="0" y="0"/></a:moveTo><a:lnTo><a:pt x="0" y="50"/></a:lnTo><a:lnTo><a:pt x="100" y="50"/></a:lnTo><a:lnTo><a:pt x="100" y="100"/></a:lnTo></a:path></a:pathLst></a:custGeom><a:ln w="12700"><a:solidFill><a:srgbClr val="112233"/></a:solidFill></a:ln></p:spPr></p:cxnSp>"#;
6919        let resolved = Fixture::new(connector, "", "")
6920            .context()
6921            .resolve_slide((720.0, 540.0))
6922            .unwrap();
6923
6924        assert!(resolved.diagnostics.is_empty());
6925        assert_eq!(resolved.shapes[0].unsupported, None);
6926        let ResolvedGeometry::Custom { paths, .. } = &resolved.shapes[0].geometry else {
6927            panic!("expected concrete connector custom geometry");
6928        };
6929        assert_eq!(
6930            paths[0].commands,
6931            [
6932                PathCommand::MoveTo(Point { x: 0.0, y: 0.0 }),
6933                PathCommand::LineTo(Point { x: 0.0, y: 10.0 }),
6934                PathCommand::LineTo(Point { x: 10.0, y: 10.0 }),
6935                PathCommand::LineTo(Point { x: 10.0, y: 20.0 }),
6936            ]
6937        );
6938    }
6939
6940    #[test]
6941    fn zero_extent_and_unknown_connector_geometry_keep_finite_visible_results() {
6942        let line = r#"<a:ln><a:solidFill><a:srgbClr val="112233"/></a:solidFill></a:ln>"#;
6943        let fixture = Fixture::new(
6944            &format!(
6945                "{}{}",
6946                connector("straightConnector1", 0, 127_000, "", line),
6947                connector("futureConnector", 127_000, 254_000, "", line),
6948            ),
6949            "",
6950            "",
6951        );
6952
6953        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
6954
6955        let ResolvedGeometry::Custom { paths, .. } = &resolved.shapes[0].geometry else {
6956            panic!("zero-width connector should still evaluate: {:?}", resolved);
6957        };
6958        assert_eq!(
6959            paths[0].commands,
6960            [
6961                PathCommand::MoveTo(Point { x: 0.0, y: 0.0 }),
6962                PathCommand::LineTo(Point { x: 0.0, y: 10.0 }),
6963            ]
6964        );
6965        assert!(paths[0].commands.iter().all(|command| match command {
6966            PathCommand::MoveTo(point) | PathCommand::LineTo(point) =>
6967                point.x.is_finite() && point.y.is_finite(),
6968            _ => true,
6969        }));
6970        assert_eq!(
6971            resolved.shapes[1].geometry,
6972            ResolvedGeometry::BoundsFallback
6973        );
6974        assert_eq!(
6975            resolved.shapes[1].unsupported,
6976            Some("unknown connector preset geometry")
6977        );
6978        assert!(resolved.diagnostics.iter().any(|diagnostic| {
6979            diagnostic.message.contains("futureConnector")
6980                && diagnostic.message.contains("retained as shape bounds")
6981        }));
6982    }
6983
6984    #[test]
6985    fn connector_without_a_direct_line_keeps_a_diagnosed_visible_default() {
6986        let fixture = Fixture::new(&connector("line", 127_000, 254_000, "", ""), "", "");
6987
6988        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
6989        let connector = &resolved.shapes[0];
6990
6991        assert!(matches!(
6992            connector.geometry,
6993            ResolvedGeometry::Custom { .. }
6994        ));
6995        assert_eq!(connector.unsupported, Some("connector line style"));
6996        let line = connector.line.as_ref().expect("visible default line");
6997        assert_eq!(line.paint, Paint::Solid(Color::BLACK));
6998        assert_eq!(line.width, 1.0);
6999        assert_eq!(
7000            resolved.diagnostics,
7001            [Diagnostic {
7002                message: "unsupported connector line style retained as visible default".to_owned(),
7003            }]
7004        );
7005    }
7006
7007    #[test]
7008    fn connector_style_resolves_the_theme_line_under_its_direct_line() {
7009        let styled = |line_index: u32, line: &str| {
7010            connector("line", 127_000, 254_000, "", line).replace(
7011                "</p:cxnSp>",
7012                &format!(
7013                    r#"<p:style><a:lnRef idx="{line_index}"><a:schemeClr val="accent1"/></a:lnRef><a:fillRef idx="0"><a:schemeClr val="accent1"/></a:fillRef><a:effectRef idx="1"><a:schemeClr val="accent1"/></a:effectRef><a:fontRef idx="minor"><a:schemeClr val="tx1"/></a:fontRef></p:style></p:cxnSp>"#
7014                ),
7015            )
7016        };
7017        let fixture = Fixture::new(
7018            &[
7019                styled(2, ""),
7020                styled(2, r#"<a:ln w="38100"/>"#),
7021                styled(0, ""),
7022            ]
7023            .join(""),
7024            "",
7025            "",
7026        );
7027
7028        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
7029
7030        assert!(
7031            resolved.diagnostics.is_empty(),
7032            "{:?}",
7033            resolved.diagnostics
7034        );
7035        let accent1 = Paint::Solid(Color::from_hex("156082"));
7036        let themed = resolved.shapes[0].line.as_ref().expect("theme line");
7037        assert_eq!((&themed.paint, themed.width), (&accent1, 1.5));
7038        let widened = resolved.shapes[1].line.as_ref().expect("overlaid line");
7039        assert_eq!((&widened.paint, widened.width), (&accent1, 3.0));
7040        assert_eq!(resolved.shapes[2].line, None);
7041        assert!(
7042            resolved
7043                .shapes
7044                .iter()
7045                .all(|shape| shape.unsupported.is_none())
7046        );
7047    }
7048
7049    #[test]
7050    fn connector_shadow_resolves_from_its_effect_reference_under_its_direct_effects() {
7051        let styled = |effect_index: u32, direct: &str| {
7052            connector("line", 127_000, 254_000, "", direct).replace(
7053                "</p:cxnSp>",
7054                &format!(
7055                    r#"<p:style><a:lnRef idx="2"><a:schemeClr val="accent1"/></a:lnRef><a:fillRef idx="0"><a:schemeClr val="accent1"/></a:fillRef><a:effectRef idx="{effect_index}"><a:schemeClr val="accent1"/></a:effectRef><a:fontRef idx="minor"><a:schemeClr val="tx1"/></a:fontRef></p:style></p:cxnSp>"#
7056                ),
7057            )
7058        };
7059        let direct_shadow = r#"<a:ln w="12700"/><a:effectLst><a:outerShdw blurRad="12700" dist="25400" dir="0"><a:srgbClr val="FF0000"/></a:outerShdw></a:effectLst>"#;
7060        let fixture = Fixture::new(
7061            &[
7062                styled(3, ""),
7063                styled(0, ""),
7064                styled(3, "<a:effectLst/>"),
7065                connector("line", 127_000, 254_000, "", direct_shadow),
7066            ]
7067            .join(""),
7068            "",
7069            "",
7070        );
7071
7072        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
7073
7074        assert!(
7075            resolved.diagnostics.is_empty(),
7076            "{:?}",
7077            resolved.diagnostics
7078        );
7079        let shadows: Vec<_> = resolved
7080            .shapes
7081            .iter()
7082            .map(|shape| shape.shadow.is_some())
7083            .collect();
7084        assert_eq!(shadows, [true, false, false, true]);
7085        assert!(matches!(
7086            resolved.shapes[3].shadow,
7087            Some(Effect::OuterShadow { color, .. }) if color == Color::from_hex("FF0000")
7088        ));
7089    }
7090
7091    #[test]
7092    fn unresolvable_connector_effect_uses_counted_member_bounds_recovery() {
7093        let styled = |effect_reference: &str, direct: &str| {
7094            connector("line", 127_000, 254_000, "", direct).replace(
7095                "</p:cxnSp>",
7096                &format!(
7097                    r#"<p:style><a:lnRef idx="2"><a:schemeClr val="accent1"/></a:lnRef><a:fillRef idx="0"><a:schemeClr val="accent1"/></a:fillRef>{effect_reference}<a:fontRef idx="minor"><a:schemeClr val="tx1"/></a:fontRef></p:style></p:cxnSp>"#
7098                ),
7099            )
7100        };
7101        let placeholder_shadow = r#"<a:effectLst><a:outerShdw blurRad="12700" dist="25400" dir="0"><a:schemeClr val="phClr"/></a:outerShdw></a:effectLst>"#;
7102        let fixture = Fixture::new(
7103            &[
7104                styled(
7105                    r#"<a:effectRef idx="9"><a:schemeClr val="accent1"/></a:effectRef>"#,
7106                    "",
7107                ),
7108                styled(r#"<a:effectRef idx="0"/>"#, placeholder_shadow),
7109            ]
7110            .join(""),
7111            "",
7112            "",
7113        );
7114
7115        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
7116
7117        assert_eq!(resolved.unresolved_member_count, 2);
7118        assert_eq!(resolved.shapes.len(), 2);
7119        assert_eq!(resolved.diagnostics.len(), 2);
7120        assert!(
7121            resolved.diagnostics[0]
7122                .message
7123                .contains("effect style index 9")
7124        );
7125        assert!(
7126            resolved.diagnostics[1]
7127                .message
7128                .contains("effect style retains an unresolved phClr")
7129        );
7130        for shape in &resolved.shapes {
7131            assert_eq!(shape.geometry, ResolvedGeometry::BoundsFallback);
7132            assert_eq!(shape.line, None);
7133            assert_eq!(shape.shadow, None);
7134            assert_eq!(shape.content, ResolvedContent::None);
7135        }
7136    }
7137
7138    #[test]
7139    fn unknown_preset_keeps_bounds_text_and_diagnostic() {
7140        let shape = format!(
7141            "<p:sp><p:nvSpPr><p:cNvPr/><p:cNvSpPr/><p:nvPr/></p:nvSpPr><p:spPr>{}<a:prstGeom prst=\"futureBurst\"/></p:spPr><p:txBody><a:bodyPr/><a:p><a:r><a:t>keep this text</a:t></a:r></a:p></p:txBody></p:sp>",
7142            transform(0)
7143        );
7144        let fixture = Fixture::new(&shape, "", "");
7145
7146        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
7147        let shape = &resolved.shapes[0];
7148        assert_eq!(shape.geometry, ResolvedGeometry::BoundsFallback);
7149        assert_eq!(shape.bounds.width, 100.0 / 12_700.0);
7150        assert_eq!(shape.bounds.height, 100.0 / 12_700.0);
7151        assert_eq!(shape.unsupported, Some("unknown preset geometry"));
7152        let ResolvedContent::Text(text) = &shape.content else {
7153            panic!("unknown preset lost its text body");
7154        };
7155        assert!(matches!(
7156            &text.paragraphs[0].runs[0],
7157            ResolvedTextRun::Text { text, .. } if text == "keep this text"
7158        ));
7159        assert!(resolved.diagnostics.iter().any(|diagnostic| {
7160            diagnostic.message.contains("unknown preset geometry")
7161                && diagnostic.message.contains("futureBurst")
7162        }));
7163    }
7164
7165    #[test]
7166    fn text_box_without_geometry_does_not_render_a_fallback_border() {
7167        let shape = shape_with_details(None, None, &transform(0), Some("<a:bodyPr/>"));
7168        let fixture = Fixture::new(&shape, "", "");
7169
7170        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
7171
7172        assert_eq!(resolved.shapes[0].geometry, ResolvedGeometry::Rectangle);
7173        assert_eq!(resolved.shapes[0].unsupported, None);
7174        assert!(resolved.diagnostics.is_empty());
7175    }
7176
7177    #[test]
7178    fn empty_paragraph_end_properties_use_the_normal_character_cascade() {
7179        let shape = shape_with_details(
7180            None,
7181            None,
7182            &format!(
7183                r#"{}<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>"#,
7184                transform(0)
7185            ),
7186            Some(
7187                r#"<a:bodyPr/><a:lstStyle><a:defPPr><a:defRPr sz="1600" cap="all"><a:latin typeface="Carlito"/></a:defRPr></a:defPPr></a:lstStyle>"#,
7188            ),
7189        )
7190        .replace(
7191            "<a:p/>",
7192            r#"<a:p><a:pPr><a:defRPr b="1"/></a:pPr><a:endParaRPr sz="3200" i="1"/></a:p>"#,
7193        );
7194        let fixture = Fixture::new(&shape, "", "");
7195
7196        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
7197        let ResolvedContent::Text(body) = &resolved.shapes[0].content else {
7198            panic!("expected resolved text")
7199        };
7200        let paragraph = &body.paragraphs[0];
7201
7202        assert!(paragraph.runs.is_empty());
7203        assert_eq!(paragraph.end_style.font_size, Some(32.0));
7204        assert!(paragraph.end_style.bold);
7205        assert!(paragraph.end_style.italic);
7206        assert!(paragraph.end_style.all_caps);
7207        assert_eq!(
7208            paragraph.end_style.latin_typeface.as_deref(),
7209            Some("Carlito")
7210        );
7211    }
7212
7213    #[test]
7214    fn preset_black_and_white_resolve_to_concrete_paint() {
7215        let black = format!(
7216            "{}<a:solidFill><a:prstClr val=\"black\"/></a:solidFill>",
7217            transform(0)
7218        );
7219        let white = format!(
7220            "{}<a:solidFill><a:prstClr val=\"white\"/></a:solidFill>",
7221            transform(100)
7222        );
7223        let fixture = Fixture::new(
7224            &format!(
7225                "{}{}",
7226                shape_with_details(None, None, &black, None),
7227                shape_with_details(None, None, &white, None)
7228            ),
7229            "",
7230            "",
7231        );
7232
7233        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
7234        assert_eq!(resolved.shapes[0].fill, Some(Paint::Solid(Color::BLACK)));
7235        assert_eq!(resolved.shapes[1].fill, Some(Paint::Solid(Color::WHITE)));
7236    }
7237
7238    #[test]
7239    fn group_coordinate_scale_changes_bounds_not_stroke_width() {
7240        let leaf = shape_with_details(
7241            None,
7242            None,
7243            r#"<a:xfrm><a:off x="12700" y="25400"/><a:ext cx="25400" cy="38100"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:ln w="9525"><a:solidFill><a:srgbClr val="FF0000"/></a:solidFill></a:ln>"#,
7244            None,
7245        );
7246        let group = format!(
7247            r#"<p:grpSp><p:nvGrpSpPr/><p:grpSpPr><a:xfrm><a:off x="127000" y="254000"/><a:ext cx="254000" cy="762000"/><a:chOff x="0" y="0"/><a:chExt cx="127000" cy="254000"/></a:xfrm></p:grpSpPr>{leaf}</p:grpSp>"#
7248        );
7249        let fixture = Fixture::new(&group, "", "");
7250
7251        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
7252        let shape = &resolved.shapes[0];
7253        assert_eq!(
7254            shape.bounds,
7255            Rect {
7256                x: 2.0,
7257                y: 6.0,
7258                width: 4.0,
7259                height: 9.0,
7260            }
7261        );
7262        assert_eq!(shape.line.as_ref().map(|line| line.width), Some(0.75));
7263        assert_eq!(
7264            shape.group_transform,
7265            Transform {
7266                e: 10.0,
7267                f: 20.0,
7268                ..Transform::IDENTITY
7269            }
7270        );
7271    }
7272
7273    #[test]
7274    fn grouped_text_with_a_visible_stroke_keeps_path_scale_and_stroke_width_invariant() {
7275        let leaf = shape_with_details(
7276            None,
7277            None,
7278            r#"<a:xfrm><a:off x="12700" y="25400"/><a:ext cx="25400" cy="38100"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:ln w="9525"><a:solidFill><a:srgbClr val="FF0000"/></a:solidFill></a:ln>"#,
7279            Some(r#"<a:bodyPr/><a:p><a:r><a:t>visible outline</a:t></a:r></a:p>"#),
7280        );
7281        let group = format!(
7282            r#"<p:grpSp><p:nvGrpSpPr/><p:grpSpPr><a:xfrm><a:off x="127000" y="254000"/><a:ext cx="254000" cy="762000"/><a:chOff x="0" y="0"/><a:chExt cx="127000" cy="254000"/></a:xfrm></p:grpSpPr>{leaf}</p:grpSp>"#
7283        );
7284        let fixture = Fixture::new(&group, "", "");
7285
7286        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
7287        let shape = &resolved.shapes[0];
7288        assert_eq!(
7289            shape.bounds,
7290            Rect {
7291                x: 2.0,
7292                y: 6.0,
7293                width: 4.0,
7294                height: 9.0,
7295            }
7296        );
7297        assert_eq!(shape.line.as_ref().map(|line| line.width), Some(0.75));
7298        assert_eq!(
7299            shape.group_transform,
7300            Transform {
7301                e: 10.0,
7302                f: 20.0,
7303                ..Transform::IDENTITY
7304            }
7305        );
7306        assert!(matches!(shape.content, ResolvedContent::Text(_)));
7307    }
7308
7309    #[test]
7310    fn nested_group_coordinate_mappings_apply_inner_before_outer() {
7311        let leaf = shape_with_details(
7312            None,
7313            None,
7314            r#"<a:xfrm><a:off x="12700" y="25400"/><a:ext cx="38100" cy="50800"/></a:xfrm>"#,
7315            None,
7316        );
7317        let inner = format!(
7318            r#"<p:grpSp><p:nvGrpSpPr/><p:grpSpPr><a:xfrm><a:off x="12700" y="25400"/><a:ext cx="254000" cy="381000"/><a:chOff x="0" y="0"/><a:chExt cx="127000" cy="127000"/></a:xfrm></p:grpSpPr>{leaf}</p:grpSp>"#
7319        );
7320        let outer = format!(
7321            r#"<p:grpSp><p:nvGrpSpPr/><p:grpSpPr><a:xfrm><a:off x="127000" y="254000"/><a:ext cx="508000" cy="635000"/><a:chOff x="0" y="0"/><a:chExt cx="127000" cy="127000"/></a:xfrm></p:grpSpPr>{inner}</p:grpSp>"#
7322        );
7323        let fixture = Fixture::new(&outer, "", "");
7324
7325        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
7326        let shape = &resolved.shapes[0];
7327        assert_eq!(
7328            shape.bounds,
7329            Rect {
7330                x: 8.0,
7331                y: 30.0,
7332                width: 24.0,
7333                height: 60.0,
7334            }
7335        );
7336        assert_eq!(
7337            shape.group_transform,
7338            Transform {
7339                e: 14.0,
7340                f: 30.0,
7341                ..Transform::IDENTITY
7342            }
7343        );
7344    }
7345
7346    #[test]
7347    fn zero_group_child_extent_is_finite_and_diagnosed_once() {
7348        let leaf = shape_with_details(
7349            None,
7350            None,
7351            r#"<a:xfrm><a:off x="76200" y="101600"/><a:ext cx="12700" cy="12700"/></a:xfrm>"#,
7352            None,
7353        );
7354        let group = format!(
7355            r#"<p:grpSp><p:nvGrpSpPr/><p:grpSpPr><a:xfrm><a:off x="127000" y="254000"/><a:ext cx="254000" cy="381000"/><a:chOff x="63500" y="88900"/><a:chExt cx="0" cy="127000"/></a:xfrm></p:grpSpPr>{leaf}</p:grpSp>"#
7356        );
7357        let fixture = Fixture::new(&group, "", "");
7358
7359        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
7360        let shape = &resolved.shapes[0];
7361        assert_eq!(
7362            shape.bounds,
7363            Rect {
7364                x: 6.0,
7365                y: 24.0,
7366                width: 1.0,
7367                height: 3.0,
7368            }
7369        );
7370        assert!(shape.bounds.x.is_finite());
7371        assert!(shape.bounds.y.is_finite());
7372        assert_eq!(
7373            shape.group_transform,
7374            Transform {
7375                e: 5.0,
7376                f: -1.0,
7377                ..Transform::IDENTITY
7378            }
7379        );
7380        assert_eq!(
7381            resolved
7382                .diagnostics
7383                .iter()
7384                .filter(|diagnostic| diagnostic.message.contains("group child extent"))
7385                .count(),
7386            1
7387        );
7388    }
7389
7390    #[test]
7391    fn unsupported_sheared_group_mapping_is_diagnosed_without_non_finite_values() {
7392        let leaf = shape_with_details(None, None, &transform(0), None);
7393        let inner = format!(
7394            r#"<p:grpSp><p:nvGrpSpPr/><p:grpSpPr><a:xfrm rot="2700000"><a:off x="0" y="0"/><a:ext cx="127000" cy="127000"/><a:chOff x="0" y="0"/><a:chExt cx="127000" cy="127000"/></a:xfrm></p:grpSpPr>{leaf}</p:grpSp>"#
7395        );
7396        let outer = format!(
7397            r#"<p:grpSp><p:nvGrpSpPr/><p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="254000" cy="127000"/><a:chOff x="0" y="0"/><a:chExt cx="127000" cy="127000"/></a:xfrm></p:grpSpPr>{inner}</p:grpSp>"#
7398        );
7399        let fixture = Fixture::new(&outer, "", "");
7400
7401        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
7402        let transform = resolved.shapes[0].group_transform;
7403        assert!(
7404            [
7405                transform.a,
7406                transform.b,
7407                transform.c,
7408                transform.d,
7409                transform.e,
7410                transform.f,
7411            ]
7412            .into_iter()
7413            .all(f64::is_finite)
7414        );
7415        assert!(resolved.diagnostics.iter().any(|diagnostic| {
7416            diagnostic.message
7417                == "unsupported sheared or singular group transform retained as affine fallback"
7418        }));
7419    }
7420
7421    #[test]
7422    fn flattened_group_children_keep_sibling_order() {
7423        let group = format!(
7424            r#"<p:grpSp><p:nvGrpSpPr/><p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="127000" cy="127000"/><a:chOff x="0" y="0"/><a:chExt cx="127000" cy="127000"/></a:xfrm></p:grpSpPr>{}{}</p:grpSp>"#,
7425            shape_with_text(None, None, "inside one"),
7426            shape_with_text(None, None, "inside two")
7427        );
7428        let fixture = Fixture::new(
7429            &format!(
7430                "{}{}{}",
7431                shape_with_text(None, None, "before"),
7432                group,
7433                shape_with_text(None, None, "after")
7434            ),
7435            "",
7436            "",
7437        );
7438
7439        assert_eq!(
7440            fixture
7441                .context()
7442                .flatten()
7443                .iter()
7444                .filter_map(item_text)
7445                .collect::<Vec<_>>(),
7446            ["before", "inside one", "inside two", "after"]
7447        );
7448    }
7449
7450    #[test]
7451    fn fx205_pica_spacing_is_resolved_as_source_points() {
7452        for unit in ["pc", "pi"] {
7453            let text = format!(
7454                r#"<a:bodyPr/><a:p><a:r><a:rPr spc="1{unit}"/><a:t>spaced</a:t></a:r></a:p>"#
7455            );
7456            let fixture = Fixture::new(
7457                &shape_with_details(None, None, &transform(0), Some(&text)),
7458                "",
7459                "",
7460            );
7461            let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
7462            let ResolvedContent::Text(body) = &resolved.shapes[0].content else {
7463                panic!("text")
7464            };
7465            let ResolvedTextRun::Text { style, .. } = &body.paragraphs[0].runs[0] else {
7466                panic!("run")
7467            };
7468            assert_eq!(style.spacing, Some(12.0));
7469        }
7470    }
7471
7472    #[test]
7473    fn paragraph_spacing_and_bullet_size_are_concrete() {
7474        let text = r#"<a:bodyPr/><a:p><a:pPr><a:lnSpc><a:spcPct val="120000"/></a:lnSpc><a:spcBef><a:spcPts val="600"/></a:spcBef><a:spcAft><a:spcPts val="300"/></a:spcAft><a:buSzPct val="125000"/><a:buChar char="*"/></a:pPr><a:r><a:t>spaced</a:t></a:r></a:p>"#;
7475        let fixture = Fixture::new(
7476            &shape_with_details(None, None, &transform(0), Some(text)),
7477            "",
7478            "",
7479        );
7480
7481        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
7482        let ResolvedContent::Text(body) = &resolved.shapes[0].content else {
7483            panic!("expected text");
7484        };
7485        let paragraph = &body.paragraphs[0];
7486        assert_eq!(
7487            paragraph.line_spacing,
7488            Some(ResolvedTextSpacing::Percent(1.2))
7489        );
7490        assert_eq!(
7491            paragraph.space_before,
7492            Some(ResolvedTextSpacing::Points(6.0))
7493        );
7494        assert_eq!(
7495            paragraph.space_after,
7496            Some(ResolvedTextSpacing::Points(3.0))
7497        );
7498        assert!(matches!(
7499            paragraph.bullet,
7500            Some(ResolvedBullet::Character {
7501                size: Some(ResolvedBulletSize::Percent(1.25)),
7502                ..
7503            })
7504        ));
7505    }
7506
7507    #[test]
7508    fn explicit_rtl_paragraph_direction_reaches_resolved_layout() {
7509        let text = r#"<a:bodyPr/><a:p><a:pPr rtl="1"/><a:r><a:t>123</a:t></a:r></a:p>"#;
7510        let fixture = Fixture::new(
7511            &shape_with_details(None, None, &transform(0), Some(text)),
7512            "",
7513            "",
7514        );
7515
7516        let (resolved, directions) = fixture
7517            .context()
7518            .resolve_slide_inner((720.0, 540.0), None, None, None, None)
7519            .unwrap();
7520        let ResolvedContent::Text(body) = &resolved.shapes[0].content else {
7521            panic!("expected text");
7522        };
7523        assert_eq!(body.paragraphs[0].runs.len(), 1);
7524        assert_eq!(directions[0][0].len(), body.paragraphs.len());
7525        assert_eq!(directions[0][0][0], oxml_layout::TextDirection::RightToLeft);
7526    }
7527
7528    #[test]
7529    fn legacy_resolved_paragraph_literal_remains_source_compatible() {
7530        let paragraph = ResolvedParagraph {
7531            level: 0,
7532            left_margin: 0.0,
7533            right_margin: 0.0,
7534            indent: 0.0,
7535            alignment: ParagraphAlignment::Left,
7536            line_spacing: None,
7537            space_before: None,
7538            space_after: None,
7539            bullet: None,
7540            end_style: ResolvedRunStyle::default(),
7541            runs: Vec::new(),
7542        };
7543
7544        assert_eq!(paragraph, ResolvedParagraph::default());
7545    }
7546
7547    #[test]
7548    fn auto_number_bullet_keeps_independently_inherited_style() {
7549        let text = r#"<a:bodyPr/><a:lstStyle><a:lvl1pPr><a:buClr><a:srgbClr val="123456"/></a:buClr><a:buSzPct val="125000"/><a:buFont typeface="Wingdings"/><a:buChar char="*"/></a:lvl1pPr></a:lstStyle><a:p><a:pPr><a:buAutoNum type="arabicPeriod" startAt="3"/></a:pPr><a:r><a:t>numbered</a:t></a:r></a:p>"#;
7550        let fixture = Fixture::new(
7551            &shape_with_details(None, None, &transform(0), Some(text)),
7552            "",
7553            "",
7554        );
7555
7556        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
7557        let ResolvedContent::Text(body) = &resolved.shapes[0].content else {
7558            panic!("expected text");
7559        };
7560        assert_eq!(
7561            body.paragraphs[0].bullet,
7562            Some(ResolvedBullet::AutoNumber {
7563                scheme: "arabicPeriod".to_owned(),
7564                start_at: 3,
7565                font: Some("Wingdings".to_owned()),
7566                color: Some(Color {
7567                    r: 0x12 as f64 / 255.0,
7568                    g: 0x34 as f64 / 255.0,
7569                    b: 0x56 as f64 / 255.0,
7570                    a: 1.0,
7571                }),
7572                size: Some(ResolvedBulletSize::Percent(1.25)),
7573            })
7574        );
7575    }
7576
7577    #[test]
7578    fn fx206_tcpr_anchor_resolution_modes_and_absent_body_fallback() {
7579        for (attribute, expected) in [
7580            ("", ResolvedTextAnchor::Bottom),
7581            (" anchor='t'", ResolvedTextAnchor::Top),
7582            (" anchor='ctr'", ResolvedTextAnchor::Center),
7583            (" anchor='b'", ResolvedTextAnchor::Bottom),
7584        ] {
7585            let frame = format!(
7586                r#"<p:graphicFrame><p:nvGraphicFramePr/><p:xfrm><a:off x="0" y="0"/><a:ext cx="127000" cy="127000"/></p:xfrm><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/table"><a:tbl><a:tblGrid><a:gridCol w="127000"/></a:tblGrid><a:tr h="127000"><a:tc><a:txBody><a:bodyPr anchor="b"/><a:lstStyle/><a:p><a:r><a:t>cell</a:t></a:r></a:p></a:txBody><a:tcPr{attribute}/></a:tc></a:tr></a:tbl></a:graphicData></a:graphic></p:graphicFrame>"#
7587            );
7588            let fixture = Fixture::new(&frame, "", "");
7589            let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
7590            let ResolvedContent::Table(table) = &resolved.shapes[0].content else {
7591                panic!("table");
7592            };
7593            assert_eq!(
7594                table.rows[0].cells[0].text.as_ref().unwrap().anchor,
7595                expected
7596            );
7597        }
7598    }
7599
7600    #[test]
7601    fn table_cells_resolve_body_and_paragraph_properties() {
7602        let frame = r#"<p:graphicFrame><p:nvGraphicFramePr/><p:xfrm><a:off x="0" y="0"/><a:ext cx="127000" cy="127000"/></p:xfrm><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/table"><a:tbl><a:tblGrid><a:gridCol w="127000"/></a:tblGrid><a:tr h="127000"><a:tc><a:txBody><a:bodyPr lIns="12700" anchor="b" vert="vert"><a:spAutoFit/></a:bodyPr><a:lstStyle/><a:p><a:pPr marL="25400" algn="ctr"/><a:r><a:t>cell</a:t></a:r></a:p></a:txBody><a:tcPr/></a:tc></a:tr></a:tbl></a:graphicData></a:graphic></p:graphicFrame>"#;
7603        let fixture = Fixture::new(frame, "", "");
7604
7605        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
7606        let ResolvedContent::Table(table) = &resolved.shapes[0].content else {
7607            panic!("expected table");
7608        };
7609        let body = table.rows[0].cells[0].text.as_ref().unwrap();
7610        assert_eq!(body.insets.left, 1.0);
7611        assert_eq!(body.anchor, ResolvedTextAnchor::Bottom);
7612        assert_eq!(body.vertical, TextDirection::Vertical);
7613        assert_eq!(body.autofit, ResolvedAutofit::None);
7614        assert_eq!(body.paragraphs[0].left_margin, 2.0);
7615        assert_eq!(
7616            body.paragraphs[0].alignment,
7617            crate::ParagraphAlignment::Center
7618        );
7619        assert!(resolved.diagnostics.iter().any(|diagnostic| {
7620            diagnostic.message == "table cell autofit is unsupported and was ignored"
7621        }));
7622    }
7623
7624    #[test]
7625    fn table_style_regions_resolve_in_documented_precedence() {
7626        let frame = r#"<p:graphicFrame><p:nvGraphicFramePr/><p:xfrm><a:off x="0" y="0"/><a:ext cx="254000" cy="254000"/></p:xfrm><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/table"><a:tbl><a:tblPr firstRow="1" firstCol="1" bandRow="1"><a:tableStyleId>style</a:tableStyleId></a:tblPr><a:tblGrid><a:gridCol w="127000"/><a:gridCol w="127000"/></a:tblGrid><a:tr h="127000"><a:tc><a:txBody><a:bodyPr/><a:p><a:r><a:t>styled</a:t></a:r></a:p></a:txBody></a:tc><a:tc/></a:tr><a:tr h="127000"><a:tc/><a:tc/></a:tr></a:tbl></a:graphicData></a:graphic></p:graphicFrame>"#;
7627        let styles = oxml_drawing::table::CT_TableStyleList::from_xml(br#"<a:tblStyleLst xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" def="style"><a:tblStyle styleId="style" styleName="Style"><a:wholeTbl><a:tcStyle><a:solidFill><a:srgbClr val="111111"/></a:solidFill></a:tcStyle></a:wholeTbl><a:band1H><a:tcStyle><a:solidFill><a:srgbClr val="222222"/></a:solidFill></a:tcStyle></a:band1H><a:firstRow><a:tcTxStyle b="on" i="1"><a:fontRef idx="major"/><a:srgbClr val="AABBCC"/></a:tcTxStyle><a:tcStyle><a:solidFill><a:srgbClr val="333333"/></a:solidFill></a:tcStyle></a:firstRow><a:nwCell><a:tcStyle><a:solidFill><a:srgbClr val="444444"/></a:solidFill></a:tcStyle></a:nwCell></a:tblStyle></a:tblStyleLst>"#).unwrap();
7628        let fixture = Fixture::new(frame, "", "");
7629
7630        let resolved = fixture
7631            .context()
7632            .with_table_styles(&styles)
7633            .resolve_slide((20.0, 20.0))
7634            .unwrap();
7635        let ResolvedContent::Table(table) = &resolved.shapes[0].content else {
7636            panic!("expected table");
7637        };
7638
7639        assert_eq!(
7640            table.rows[0].cells[0].fill,
7641            Some(Paint::Solid(Color::from_hex("444444")))
7642        );
7643        assert_eq!(
7644            table.rows[0].cells[1].fill,
7645            Some(Paint::Solid(Color::from_hex("333333")))
7646        );
7647        assert_eq!(
7648            table.rows[1].cells[0].fill,
7649            Some(Paint::Solid(Color::from_hex("222222")))
7650        );
7651        let ResolvedTextRun::Text { style, .. } =
7652            &table.rows[0].cells[0].text.as_ref().unwrap().paragraphs[0].runs[0]
7653        else {
7654            panic!("expected styled text run")
7655        };
7656        assert!(style.bold && style.italic);
7657        assert_eq!(style.fill, Some(Paint::Solid(Color::from_hex("AABBCC"))));
7658        assert_eq!(style.latin_typeface.as_deref(), Some("Aptos Display"));
7659    }
7660
7661    #[test]
7662    fn builtin_table_styles_resolve_without_package_definitions() {
7663        let id = "{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}";
7664        let style = super::CT_TableStyle::builtin(id).expect("known built-in style parses");
7665        assert_eq!(style.style_id, id);
7666        assert!(style.whole_table.is_some());
7667        assert!(super::CT_TableStyle::builtin("{00000000-0000-0000-0000-000000000000}").is_none());
7668
7669        let frame = r#"<p:graphicFrame><p:nvGraphicFramePr/><p:xfrm><a:off x="0" y="0"/><a:ext cx="254000" cy="254000"/></p:xfrm><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/table"><a:tbl><a:tblPr firstRow="1" bandRow="1"><a:tableStyleId>{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}</a:tableStyleId></a:tblPr><a:tblGrid><a:gridCol w="127000"/></a:tblGrid><a:tr h="127000"><a:tc/></a:tr><a:tr h="127000"><a:tc/></a:tr></a:tbl></a:graphicData></a:graphic></p:graphicFrame>"#;
7670        let fixture = Fixture::new(frame, "", "");
7671        let resolved = fixture.context().resolve_slide((20.0, 20.0)).unwrap();
7672        let ResolvedContent::Table(table) = &resolved.shapes[0].content else {
7673            panic!("expected table");
7674        };
7675        assert!(matches!(table.rows[0].cells[0].fill, Some(Paint::Solid(_))));
7676        assert!(matches!(table.rows[1].cells[0].fill, Some(Paint::Solid(_))));
7677        assert_ne!(table.rows[0].cells[0].fill, table.rows[1].cells[0].fill);
7678
7679        let package_style = oxml_drawing::table::CT_TableStyleList::from_xml(br#"<a:tblStyleLst xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><a:tblStyle styleId="{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}" styleName="Package override"><a:wholeTbl><a:tcStyle><a:solidFill><a:srgbClr val="FF0000"/></a:solidFill></a:tcStyle></a:wholeTbl></a:tblStyle></a:tblStyleLst>"#).unwrap();
7680        let overridden = fixture
7681            .context()
7682            .with_table_styles(&package_style)
7683            .resolve_slide((20.0, 20.0))
7684            .unwrap();
7685        let ResolvedContent::Table(overridden) = &overridden.shapes[0].content else {
7686            panic!("expected table");
7687        };
7688        assert_eq!(
7689            overridden.rows[0].cells[0].fill,
7690            Some(Paint::Solid(Color::from_hex("FF0000")))
7691        );
7692    }
7693
7694    #[test]
7695    fn direct_table_text_and_inside_borders_override_style_defaults() {
7696        let frame = r#"<p:graphicFrame><p:nvGraphicFramePr/><p:xfrm><a:off x="0" y="0"/><a:ext cx="254000" cy="127000"/></p:xfrm><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/table"><a:tbl><a:tblPr><a:tableStyleId>style</a:tableStyleId></a:tblPr><a:tblGrid><a:gridCol w="127000"/><a:gridCol w="127000"/></a:tblGrid><a:tr h="127000"><a:tc><a:txBody><a:bodyPr/><a:p><a:r><a:rPr b="0"><a:solidFill><a:srgbClr val="00FF00"/></a:solidFill><a:latin typeface="Courier New"/></a:rPr><a:t>direct</a:t></a:r></a:p></a:txBody></a:tc><a:tc/></a:tr></a:tbl></a:graphicData></a:graphic></p:graphicFrame>"#;
7697        let styles = oxml_drawing::table::CT_TableStyleList::from_xml(br#"<a:tblStyleLst xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" def="style"><a:tblStyle styleId="style" styleName="Style"><a:wholeTbl><a:tcTxStyle b="on"><a:fontRef idx="major"/><a:srgbClr val="FF0000"/></a:tcTxStyle><a:tcStyle><a:tcBdr><a:left><a:ln w="12700"><a:solidFill><a:srgbClr val="FF0000"/></a:solidFill></a:ln></a:left><a:right><a:ln w="12700"><a:solidFill><a:srgbClr val="FF0000"/></a:solidFill></a:ln></a:right><a:insideV><a:ln w="25400"><a:solidFill><a:srgbClr val="0000FF"/></a:solidFill></a:ln></a:insideV></a:tcBdr></a:tcStyle></a:wholeTbl></a:tblStyle></a:tblStyleLst>"#).unwrap();
7698        let fixture = Fixture::new(frame, "", "");
7699
7700        let resolved = fixture
7701            .context()
7702            .with_table_styles(&styles)
7703            .resolve_slide((20.0, 10.0))
7704            .unwrap();
7705        let ResolvedContent::Table(table) = &resolved.shapes[0].content else {
7706            panic!("expected table");
7707        };
7708        let ResolvedTextRun::Text { style, .. } =
7709            &table.rows[0].cells[0].text.as_ref().unwrap().paragraphs[0].runs[0]
7710        else {
7711            panic!("expected direct run");
7712        };
7713        assert!(!style.bold);
7714        assert_eq!(style.fill, Some(Paint::Solid(Color::from_hex("00FF00"))));
7715        assert_eq!(style.latin_typeface.as_deref(), Some("Courier New"));
7716
7717        let first = &table.rows[0].cells[0];
7718        let second = &table.rows[0].cells[1];
7719        assert_eq!(
7720            first.left.as_ref().unwrap().stroke.as_ref().unwrap().paint,
7721            Paint::Solid(Color::from_hex("FF0000"))
7722        );
7723        assert_eq!(
7724            first.right.as_ref().unwrap().stroke.as_ref().unwrap().paint,
7725            Paint::Solid(Color::from_hex("0000FF"))
7726        );
7727        assert_eq!(
7728            second.left.as_ref().unwrap().stroke.as_ref().unwrap().paint,
7729            Paint::Solid(Color::from_hex("0000FF"))
7730        );
7731        assert_eq!(
7732            second
7733                .right
7734                .as_ref()
7735                .unwrap()
7736                .stroke
7737                .as_ref()
7738                .unwrap()
7739                .paint,
7740            Paint::Solid(Color::from_hex("FF0000"))
7741        );
7742    }
7743
7744    #[test]
7745    fn table_corners_require_both_flags_and_unsupported_fills_are_diagnosed() {
7746        let frame = r#"<p:graphicFrame><p:nvGraphicFramePr/><p:xfrm><a:off x="0" y="0"/><a:ext cx="254000" cy="127000"/></p:xfrm><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/table"><a:tbl><a:tblPr firstRow="1"><a:tableStyleId>style</a:tableStyleId></a:tblPr><a:tblGrid><a:gridCol w="127000"/><a:gridCol w="127000"/></a:tblGrid><a:tr h="127000"><a:tc/><a:tc><a:tcPr><a:lnL><a:pattFill prst="pct5"><a:fgClr><a:srgbClr val="FF0000"/></a:fgClr><a:bgClr><a:srgbClr val="FFFFFF"/></a:bgClr></a:pattFill></a:lnL><a:pattFill prst="pct5"><a:fgClr><a:srgbClr val="FF0000"/></a:fgClr><a:bgClr><a:srgbClr val="FFFFFF"/></a:bgClr></a:pattFill></a:tcPr></a:tc></a:tr></a:tbl></a:graphicData></a:graphic></p:graphicFrame>"#;
7747        let styles = oxml_drawing::table::CT_TableStyleList::from_xml(br#"<a:tblStyleLst xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" def="style"><a:tblStyle styleId="style" styleName="Style"><a:wholeTbl><a:tcStyle><a:solidFill><a:srgbClr val="111111"/></a:solidFill></a:tcStyle></a:wholeTbl><a:firstRow><a:tcStyle><a:solidFill><a:srgbClr val="333333"/></a:solidFill></a:tcStyle></a:firstRow><a:nwCell><a:tcStyle><a:solidFill><a:srgbClr val="444444"/></a:solidFill></a:tcStyle></a:nwCell></a:tblStyle></a:tblStyleLst>"#).unwrap();
7748        let fixture = Fixture::new(frame, "", "");
7749
7750        let resolved = fixture
7751            .context()
7752            .with_table_styles(&styles)
7753            .resolve_slide((20.0, 10.0))
7754            .unwrap();
7755        let ResolvedContent::Table(table) = &resolved.shapes[0].content else {
7756            panic!("expected table");
7757        };
7758
7759        assert_eq!(
7760            table.rows[0].cells[0].fill,
7761            Some(Paint::Solid(Color::from_hex("333333")))
7762        );
7763        let Some(Paint::Grid {
7764            columns,
7765            rows,
7766            colors,
7767            ..
7768        }) = &table.rows[0].cells[1].fill
7769        else {
7770            panic!("supported direct cell pattern must paint");
7771        };
7772        assert_eq!((*columns, *rows), (64, 64));
7773        assert!(colors.contains(&Color::from_hex("FF0000")));
7774        assert!(colors.contains(&Color::WHITE));
7775        let stroke = table.rows[0].cells[1]
7776            .left
7777            .as_ref()
7778            .and_then(|border| border.stroke.as_ref())
7779            .expect("supported pattern border must remain visible");
7780        let Paint::Grid {
7781            columns,
7782            rows,
7783            colors,
7784            ..
7785        } = &stroke.paint
7786        else {
7787            panic!("expected pattern border grid");
7788        };
7789        assert_eq!((*columns, *rows), (64, 64));
7790        assert!(colors.contains(&Color::from_hex("FF0000")));
7791        assert!(colors.contains(&Color::WHITE));
7792        assert!(!resolved.diagnostics.iter().any(|diagnostic| {
7793            diagnostic.message == "unsupported table cell pattern fill was ignored"
7794        }));
7795    }
7796
7797    #[test]
7798    fn builtin_table_style_ids_resolve_unless_the_package_defines_them() {
7799        // python-pptx names Medium Style 2 - Accent 1 without defining it.
7800        let frame = |style_id: &str| {
7801            format!(
7802                r#"<p:graphicFrame><p:nvGraphicFramePr/><p:xfrm><a:off x="0" y="0"/><a:ext cx="127000" cy="381000"/></p:xfrm><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/table"><a:tbl><a:tblPr firstRow="1" bandRow="1">{style_id}</a:tblPr><a:tblGrid><a:gridCol w="127000"/></a:tblGrid><a:tr h="127000"><a:tc><a:txBody><a:bodyPr/><a:p><a:r><a:t>head</a:t></a:r></a:p></a:txBody></a:tc></a:tr><a:tr h="127000"><a:tc/></a:tr><a:tr h="127000"><a:tc/></a:tr></a:tbl></a:graphicData></a:graphic></p:graphicFrame>"#
7803            )
7804        };
7805        let medium_2_accent_1 =
7806            "<a:tableStyleId>{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}</a:tableStyleId>";
7807        let resolve = |frame: &str, styles: Option<&oxml_drawing::table::CT_TableStyleList>| {
7808            let fixture = Fixture::new(frame, "", "");
7809            let mut context = fixture.context();
7810            if let Some(styles) = styles {
7811                context = context.with_table_styles(styles);
7812            }
7813            let resolved = context.resolve_slide((10.0, 30.0)).unwrap();
7814            let ResolvedContent::Table(table) = &resolved.shapes[0].content else {
7815                panic!("expected table");
7816            };
7817            table.clone()
7818        };
7819
7820        let table = resolve(&frame(medium_2_accent_1), None);
7821        let fills = table
7822            .rows
7823            .iter()
7824            .map(|row| row.cells[0].fill.clone())
7825            .collect::<Vec<_>>();
7826        assert_eq!(fills[0], Some(Paint::Solid(Color::from_hex("156082"))));
7827        assert_eq!(fills[1], Some(Paint::Solid(Color::from_hex("CCD2D8"))));
7828        assert_eq!(fills[2], Some(Paint::Solid(Color::from_hex("E7EAED"))));
7829        let heading_rule = table.rows[0].cells[0].bottom.as_ref().unwrap();
7830        let heading_rule = heading_rule.stroke.as_ref().unwrap();
7831        assert_eq!(heading_rule.paint, Paint::Solid(Color::from_hex("FFFFFF")));
7832        assert_eq!(heading_rule.width, 3.0);
7833        let ResolvedTextRun::Text { style, .. } =
7834            &table.rows[0].cells[0].text.as_ref().unwrap().paragraphs[0].runs[0]
7835        else {
7836            panic!("expected heading run");
7837        };
7838        assert!(style.bold);
7839        assert_eq!(style.fill, Some(Paint::Solid(Color::from_hex("FFFFFF"))));
7840
7841        // A definition in the package wins over the built-in one.
7842        let styles = oxml_drawing::table::CT_TableStyleList::from_xml(br#"<a:tblStyleLst xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" def="{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}"><a:tblStyle styleId="{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}" styleName="Own"><a:firstRow><a:tcStyle><a:solidFill><a:srgbClr val="FF0000"/></a:solidFill></a:tcStyle></a:firstRow></a:tblStyle></a:tblStyleLst>"#).unwrap();
7843        let table = resolve(&frame(medium_2_accent_1), Some(&styles));
7844        assert_eq!(
7845            table.rows[0].cells[0].fill,
7846            Some(Paint::Solid(Color::from_hex("FF0000")))
7847        );
7848        assert_eq!(table.rows[1].cells[0].fill, None);
7849    }
7850
7851    #[test]
7852    fn a_builtin_default_style_applies_when_the_package_has_no_definition() {
7853        let frame = r#"<p:graphicFrame><p:nvGraphicFramePr/><p:xfrm><a:off x="0" y="0"/><a:ext cx="127000" cy="127000"/></p:xfrm><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/table"><a:tbl><a:tblPr firstRow="1"/><a:tblGrid><a:gridCol w="127000"/></a:tblGrid><a:tr h="127000"><a:tc/></a:tr></a:tbl></a:graphicData></a:graphic></p:graphicFrame>"#;
7854        let styles = oxml_drawing::table::CT_TableStyleList::from_xml(br#"<a:tblStyleLst xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" def="{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}"/>"#).unwrap();
7855        let fixture = Fixture::new(frame, "", "");
7856        let resolved = fixture
7857            .context()
7858            .with_table_styles(&styles)
7859            .resolve_slide((10.0, 10.0))
7860            .unwrap();
7861        let ResolvedContent::Table(table) = &resolved.shapes[0].content else {
7862            panic!("expected table");
7863        };
7864        assert_eq!(
7865            table.rows[0].cells[0].fill,
7866            Some(Paint::Solid(Color::from_hex("156082")))
7867        );
7868    }
7869
7870    #[test]
7871    fn table_style_background_resolves_over_the_whole_table() {
7872        // Themed Style 1 - Accent 1 fills the table from the theme's second
7873        // fill style (a gradient) in accent 1 and adds a theme effect.
7874        let frame = r#"<p:graphicFrame><p:nvGraphicFramePr/><p:xfrm><a:off x="0" y="0"/><a:ext cx="254000" cy="127000"/></p:xfrm><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/table"><a:tbl><a:tblPr firstRow="1"><a:tableStyleId>{3C2FFA5D-87B4-456A-9821-1D502468CF0F}</a:tableStyleId></a:tblPr><a:tblGrid><a:gridCol w="127000"/><a:gridCol w="127000"/></a:tblGrid><a:tr h="63500"><a:tc/><a:tc/></a:tr><a:tr h="63500"><a:tc/><a:tc/></a:tr></a:tbl></a:graphicData></a:graphic></p:graphicFrame>"#;
7875        let fixture = Fixture::new(frame, "", "");
7876
7877        let resolved = fixture.context().resolve_slide((20.0, 10.0)).unwrap();
7878        let ResolvedContent::Table(table) = &resolved.shapes[0].content else {
7879            panic!("expected table");
7880        };
7881
7882        let Some(Paint::Linear { start, end, .. }) = &table.background else {
7883            panic!("expected a gradient background, got {:?}", table.background);
7884        };
7885        // The gradient runs down the 20 by 10 point table box, as the theme's
7886        // 90 degree fill style says.
7887        assert!((start.x - 10.0).abs() < 1e-9 && (end.x - 10.0).abs() < 1e-9);
7888        assert!(start.y.abs() < 1e-9 && (end.y - 10.0).abs() < 1e-9);
7889        assert_eq!(
7890            table.rows[0].cells[0].fill,
7891            Some(Paint::Solid(Color::from_hex("156082")))
7892        );
7893        assert!(resolved.diagnostics.iter().any(|diagnostic| {
7894            diagnostic.message == "unsupported table background effect was ignored"
7895        }));
7896    }
7897
7898    #[test]
7899    fn table_cell_shape_autofit_is_ignored_but_normal_values_are_retained() {
7900        let frame = r#"<p:graphicFrame><p:nvGraphicFramePr/><p:xfrm><a:off x="0" y="0"/><a:ext cx="127000" cy="127000"/></p:xfrm><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/table"><a:tbl><a:tblGrid><a:gridCol w="127000"/></a:tblGrid><a:tr h="127000"><a:tc><a:txBody><a:bodyPr><a:spAutoFit/></a:bodyPr><a:p><a:r><a:t>cell</a:t></a:r></a:p></a:txBody><a:tcPr/></a:tc></a:tr></a:tbl></a:graphicData></a:graphic></p:graphicFrame>"#;
7901        let fixture = Fixture::new(frame, "", "");
7902
7903        let resolved = fixture.context().resolve_slide((10.0, 10.0)).unwrap();
7904        let ResolvedContent::Table(table) = &resolved.shapes[0].content else {
7905            panic!("expected table");
7906        };
7907
7908        assert_eq!(
7909            table.rows[0].cells[0].text.as_ref().unwrap().autofit,
7910            ResolvedAutofit::None
7911        );
7912        assert!(
7913            resolved
7914                .diagnostics
7915                .iter()
7916                .any(|diagnostic| diagnostic.message
7917                    == "table cell autofit is unsupported and was ignored")
7918        );
7919        let normal = frame.replace(
7920            "<a:spAutoFit/>",
7921            "<a:normAutofit fontScale=\"62500\" lnSpcReduction=\"20000\"/>",
7922        );
7923        let fixture = Fixture::new(&normal, "", "");
7924        let resolved = fixture.context().resolve_slide((10.0, 10.0)).unwrap();
7925        let ResolvedContent::Table(table) = &resolved.shapes[0].content else {
7926            panic!("expected table")
7927        };
7928        assert_eq!(
7929            table.rows[0].cells[0].text.as_ref().unwrap().autofit,
7930            ResolvedAutofit::Normal {
7931                font_scale: Some(0.625),
7932                line_spacing_reduction: Some(0.2)
7933            }
7934        );
7935        assert!(
7936            !resolved
7937                .diagnostics
7938                .iter()
7939                .any(|d| d.message.contains("autofit"))
7940        );
7941    }
7942
7943    #[test]
7944    fn linear_gradient_scaled_modes_cover_non_square_bounds() {
7945        let gradient = |scaled: &str| {
7946            format!(
7947                r#"<a:xfrm><a:off x="0" y="0"/><a:ext cx="508000" cy="254000"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:gradFill><a:gsLst><a:gs pos="0"><a:srgbClr val="000000"/></a:gs><a:gs pos="100000"><a:srgbClr val="FFFFFF"/></a:gs></a:gsLst><a:lin ang="2700000"{scaled}/></a:gradFill>"#
7948            )
7949        };
7950        let slide = [
7951            shape_with_details(None, None, &gradient(""), None),
7952            shape_with_details(None, None, &gradient(r#" scaled="0""#), None),
7953            shape_with_details(None, None, &gradient(r#" scaled="1""#), None),
7954        ]
7955        .join("");
7956        let resolved = Fixture::new(&slide, "", "")
7957            .context()
7958            .resolve_slide((720.0, 540.0))
7959            .unwrap();
7960
7961        for shape in &resolved.shapes[..2] {
7962            let Some(Paint::Linear { start, end, .. }) = shape.fill.as_ref() else {
7963                panic!("expected unscaled linear gradient");
7964            };
7965            assert_close(start.x, 5.0);
7966            assert_close(start.y, -5.0);
7967            assert_close(end.x, 35.0);
7968            assert_close(end.y, 25.0);
7969        }
7970        let Some(Paint::Linear { start, end, .. }) = resolved.shapes[2].fill.as_ref() else {
7971            panic!("expected scaled linear gradient");
7972        };
7973        assert_close(start.x, 0.0);
7974        assert_close(start.y, 0.0);
7975        assert_close(end.x, 40.0);
7976        assert_close(end.y, 20.0);
7977        assert!(resolved.diagnostics.is_empty());
7978    }
7979
7980    #[test]
7981    fn unsupported_gradient_variants_have_distinct_diagnostics() {
7982        let variants = [
7983            (
7984                r#"flip="x""#,
7985                r#"<a:lin ang="0"/>"#,
7986                "horizontal gradient flip",
7987            ),
7988            (
7989                r#"flip="y""#,
7990                r#"<a:lin ang="0"/>"#,
7991                "vertical gradient flip",
7992            ),
7993            (
7994                r#"flip="xy""#,
7995                r#"<a:lin ang="0"/>"#,
7996                "horizontal and vertical gradient flip",
7997            ),
7998            (
7999                "",
8000                r#"<a:lin ang="0"/><a:tileRect l="10000"/>"#,
8001                "gradient tile rectangle",
8002            ),
8003            (
8004                "",
8005                r#"<a:path path="circle"/>"#,
8006                "path gradient with non-centred fill rectangle",
8007            ),
8008            (
8009                "",
8010                r#"<a:path path="rect"/>"#,
8011                "path gradient with non-centred fill rectangle",
8012            ),
8013            (
8014                "",
8015                r#"<a:path path="shape"/>"#,
8016                "path gradient with non-centred fill rectangle",
8017            ),
8018        ];
8019
8020        for (attributes, geometry, expected) in variants {
8021            let details = format!(
8022                r#"<a:xfrm><a:off x="0" y="0"/><a:ext cx="127000" cy="127000"/></a:xfrm><a:gradFill {attributes}><a:gsLst><a:gs pos="0"><a:srgbClr val="000000"/></a:gs><a:gs pos="100000"><a:srgbClr val="FFFFFF"/></a:gs></a:gsLst>{geometry}</a:gradFill>"#
8023            );
8024            let details = if geometry.contains("a:path") {
8025                details.replace(
8026                    r#"<a:srgbClr val="000000"/>"#,
8027                    r#"<a:schemeClr val="phClr"/>"#,
8028                )
8029            } else {
8030                details
8031            };
8032            let fixture = Fixture::new(&shape_with_details(None, None, &details, None), "", "");
8033            let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
8034
8035            assert!(resolved.shapes[0].fill.is_none(), "{expected}");
8036            assert_eq!(resolved.shapes[0].unsupported, Some(expected));
8037            assert!(resolved.diagnostics[0].message.contains(expected));
8038        }
8039    }
8040
8041    #[test]
8042    fn fx205_path_and_pattern_fills_have_concrete_visible_paint() {
8043        // Microsoft Open Specifications documents channel-directed gamma1.875
8044        // only for endpoint pairs and mirrored three-stop lists, not all fills.
8045        let a = Color {
8046            r: 0.0,
8047            g: 1.0,
8048            b: 0.25,
8049            a: 0.2,
8050        };
8051        let b = Color {
8052            r: 1.0,
8053            g: 0.0,
8054            b: 0.25,
8055            a: 0.8,
8056        };
8057        let stops = [
8058            super::GradientStop {
8059                offset: 0.0,
8060                color: a,
8061            },
8062            super::GradientStop {
8063                offset: 1.0,
8064                color: b,
8065            },
8066        ];
8067        let mid = super::path_gradient_color(&stops, 0.5);
8068        let expected = 1.0 - 0.5_f64.powf(1.875);
8069        assert!((mid.r - expected).abs() < 1e-12 && (mid.g - expected).abs() < 1e-12);
8070        assert_eq!(mid.b, 0.25);
8071        assert!((mid.a - 0.5).abs() < 1e-12);
8072        let shifted = [
8073            super::GradientStop {
8074                offset: 0.1,
8075                color: a,
8076            },
8077            super::GradientStop {
8078                offset: 0.9,
8079                color: b,
8080            },
8081        ];
8082        assert!((super::path_gradient_color(&shifted, 0.5).r - 0.5).abs() < 1e-12);
8083        assert_eq!(super::powerpoint_gradient_stops(&shifted).len(), 2);
8084        let mirrored = [
8085            stops[0],
8086            super::GradientStop {
8087                offset: 0.5,
8088                color: b,
8089            },
8090            super::GradientStop {
8091                offset: 1.0,
8092                color: a,
8093            },
8094        ];
8095        assert_eq!(
8096            super::path_gradient_color(&mirrored, 0.25),
8097            super::path_gradient_color(&mirrored, 0.75)
8098        );
8099        let geometry = r#"<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>"#;
8100        for kind in ["circle", "rect", "shape"] {
8101            let fill = format!(
8102                r#"<a:gradFill rotWithShape="1"><a:gsLst><a:gs pos="0"><a:srgbClr val="FF0000"/></a:gs><a:gs pos="100000"><a:srgbClr val="0000FF"/></a:gs></a:gsLst><a:path path="{kind}"><a:fillToRect l="50000" t="50000" r="50000" b="50000"/></a:path></a:gradFill>"#
8103            );
8104            let shape = shape_with_details(
8105                None,
8106                None,
8107                &format!("{}{}{}", transform(0), geometry, fill),
8108                None,
8109            );
8110            let resolved = Fixture::new(&shape, "", "")
8111                .context()
8112                .resolve_slide((720.0, 540.0))
8113                .unwrap();
8114            assert!(
8115                resolved.shapes[0].fill.is_some(),
8116                "FX205_VISIBLE_PATH: {kind}"
8117            );
8118            assert_eq!(resolved.shapes[0].unsupported, None);
8119            let Some(Paint::Grid {
8120                columns,
8121                rows,
8122                colors,
8123                ..
8124            }) = &resolved.shapes[0].fill
8125            else {
8126                panic!("path did not publish sampled colour paint");
8127            };
8128            assert_eq!((*columns, *rows), (128, 128));
8129            let center = colors[64 * 128 + 64];
8130            assert!(
8131                center.r > 0.98 && center.b < 0.02,
8132                "path centre lost first source stop"
8133            );
8134            assert!(colors[0].b > 0.98, "path boundary lost last source stop");
8135        }
8136        // Supported paths must resolve the producing style reference before paint.
8137        let details = r#"<a:xfrm><a:off x="0" y="0"/><a:ext cx="1270000" cy="1270000"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:gradFill><a:gsLst><a:gs pos="0"><a:schemeClr val="phClr"/></a:gs><a:gs pos="100000"><a:srgbClr val="0000FF"/></a:gs></a:gsLst><a:path path="rect"><a:fillToRect l="50000" t="50000" r="50000" b="50000"/></a:path></a:gradFill>"#;
8138        let style = r#"<p:style><a:lnRef idx="0"/><a:fillRef idx="0"><a:srgbClr val="FF0000"/></a:fillRef><a:effectRef idx="0"/><a:fontRef idx="minor"/></p:style>"#;
8139        let shape = shape_with_details(None, None, details, None)
8140            .replace("</p:sp>", &format!("{style}</p:sp>"));
8141        let fixture = Fixture::new(&shape, "", "");
8142        let before = fixture.slide.clone();
8143        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
8144        let Some(Paint::Grid { colors, .. }) = &resolved.shapes[0].fill else {
8145            panic!("source-qualified path reference must paint");
8146        };
8147        assert!(colors[64 * 128 + 64].r > 0.98);
8148        assert!(colors[0].b > 0.98);
8149        assert!(resolved.diagnostics.is_empty());
8150        assert_eq!(fixture.slide, before);
8151
8152        for preset in [
8153            "cross",
8154            "dkDnDiag",
8155            "dkHorz",
8156            "dkUpDiag",
8157            "dkVert",
8158            "dashDnDiag",
8159            "dashHorz",
8160            "dashUpDiag",
8161            "dashVert",
8162            "diagBrick",
8163            "diagCross",
8164            "divot",
8165            "dotDmnd",
8166            "dotGrid",
8167            "dnDiag",
8168            "horz",
8169            "horzBrick",
8170            "lgCheck",
8171            "lgConfetti",
8172            "lgGrid",
8173            "ltDnDiag",
8174            "ltHorz",
8175            "ltUpDiag",
8176            "ltVert",
8177            "narHorz",
8178            "narVert",
8179            "openDmnd",
8180            "pct10",
8181            "pct20",
8182            "pct25",
8183            "pct30",
8184            "pct40",
8185            "pct5",
8186            "pct50",
8187            "pct60",
8188            "pct70",
8189            "pct75",
8190            "pct80",
8191            "pct90",
8192            "plaid",
8193            "shingle",
8194            "smCheck",
8195            "smConfetti",
8196            "smGrid",
8197            "solidDmnd",
8198            "sphere",
8199            "trellis",
8200            "upDiag",
8201            "vert",
8202            "wave",
8203            "weave",
8204            "wdDnDiag",
8205            "wdUpDiag",
8206            "zigZag",
8207        ] {
8208            let fill = format!(
8209                r#"<a:pattFill prst="{preset}"><a:fgClr><a:srgbClr val="FF0000"/></a:fgClr><a:bgClr><a:srgbClr val="0000FF"/></a:bgClr></a:pattFill>"#
8210            );
8211            let shape = shape_with_details(
8212                None,
8213                None,
8214                &format!("{}{}{}", transform(0), geometry, fill),
8215                None,
8216            );
8217            let resolved = Fixture::new(&shape, "", "")
8218                .context()
8219                .resolve_slide((720.0, 540.0))
8220                .unwrap();
8221            assert!(
8222                resolved.shapes[0].fill.is_some(),
8223                "FX205_VISIBLE_PATTERN: {preset}"
8224            );
8225            assert_eq!(resolved.shapes[0].unsupported, None);
8226            let Some(Paint::Grid {
8227                bounds,
8228                columns,
8229                rows,
8230                colors,
8231            }) = &resolved.shapes[0].fill
8232            else {
8233                panic!("pattern did not publish sampled colour paint");
8234            };
8235            assert_eq!((*columns, *rows), (64, 64));
8236            assert_eq!((bounds.width, bounds.height), (8.0, 8.0));
8237            assert!(colors.iter().any(|c| c.r == 1.0 && c.b == 0.0));
8238            assert!(colors.iter().any(|c| c.r == 0.0 && c.b == 1.0));
8239        }
8240        assert!(super::native_pattern_rows("foreign-preset").is_none());
8241    }
8242
8243    #[test]
8244    fn independent_gradient_is_concrete_only_without_an_effective_shape_rotation() {
8245        let geometry = r#"<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>"#;
8246        let gradient = r#"<a:gradFill rotWithShape="0"><a:gsLst><a:gs pos="0"><a:srgbClr val="000000"/></a:gs><a:gs pos="100000"><a:srgbClr val="FFFFFF"/></a:gs></a:gsLst><a:lin ang="0"/></a:gradFill>"#;
8247        let unrotated = shape_with_details(
8248            None,
8249            None,
8250            &format!(
8251                r#"<a:xfrm><a:off x="0" y="0"/><a:ext cx="127000" cy="127000"/></a:xfrm>{geometry}{gradient}"#
8252            ),
8253            None,
8254        );
8255        let rotated = shape_with_details(
8256            None,
8257            None,
8258            &format!(
8259                r#"<a:xfrm rot="5400000"><a:off x="0" y="0"/><a:ext cx="127000" cy="127000"/></a:xfrm>{geometry}{gradient}"#
8260            ),
8261            None,
8262        );
8263        let shapes = format!("{unrotated}{rotated}");
8264        let resolved = Fixture::new(&shapes, "", "")
8265            .context()
8266            .resolve_slide((720.0, 540.0))
8267            .unwrap();
8268
8269        assert!(matches!(
8270            resolved.shapes[0].fill,
8271            Some(Paint::Linear { .. })
8272        ));
8273        assert_eq!(resolved.shapes[0].unsupported, None);
8274        assert_eq!(resolved.shapes[1].fill, None);
8275        assert_eq!(
8276            resolved.shapes[1].unsupported,
8277            Some("gradient independent of shape rotation")
8278        );
8279        assert_eq!(resolved.diagnostics.len(), 1);
8280
8281        let grouped = format!(
8282            r#"<p:grpSp><p:nvGrpSpPr/><p:grpSpPr><a:xfrm rot="5400000"><a:off x="0" y="0"/><a:ext cx="127000" cy="127000"/><a:chOff x="0" y="0"/><a:chExt cx="127000" cy="127000"/></a:xfrm></p:grpSpPr>{unrotated}</p:grpSp>"#
8283        );
8284        let resolved = Fixture::new(&grouped, "", "")
8285            .context()
8286            .resolve_slide((720.0, 540.0))
8287            .unwrap();
8288        assert_eq!(resolved.shapes[0].fill, None);
8289        assert_eq!(
8290            resolved.shapes[0].unsupported,
8291            Some("gradient independent of shape rotation")
8292        );
8293    }
8294
8295    #[test]
8296    fn theme_style_resolves_to_concrete_paint_line_and_shadow() {
8297        let shape = format!(
8298            "<p:sp><p:nvSpPr><p:cNvPr/><p:cNvSpPr/><p:nvPr/></p:nvSpPr><p:spPr>{}<a:effectLst><a:outerShdw blurRad=\"12700\" dist=\"25400\" dir=\"0\"><a:schemeClr val=\"accent2\"/></a:outerShdw></a:effectLst></p:spPr><p:style><a:lnRef idx=\"1\"><a:srgbClr val=\"112233\"/></a:lnRef><a:fillRef idx=\"1\"><a:srgbClr val=\"445566\"/></a:fillRef><a:effectRef idx=\"1\"><a:srgbClr val=\"778899\"/></a:effectRef><a:fontRef idx=\"minor\"><a:srgbClr val=\"000000\"/></a:fontRef></p:style></p:sp>",
8299            transform(0)
8300        );
8301        let fixture = Fixture::new(&shape, "", "");
8302
8303        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
8304        let shape = &resolved.shapes[0];
8305        assert!(matches!(shape.fill, Some(Paint::Solid(_))));
8306        assert!(shape.line.is_some());
8307        assert!(matches!(shape.shadow, Some(Effect::OuterShadow { .. })));
8308    }
8309
8310    #[test]
8311    fn unsupported_content_keeps_bounds_and_diagnostic() {
8312        let frame = r#"<p:graphicFrame><p:nvGraphicFramePr/><p:xfrm><a:off x="0" y="0"/><a:ext cx="127000" cy="254000"/></p:xfrm><a:graphic><a:graphicData uri="urn:unsupported"><a:unknown/></a:graphicData></a:graphic></p:graphicFrame>"#;
8313        let fixture = Fixture::new(frame, "", "");
8314
8315        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
8316
8317        assert_eq!(resolved.shapes.len(), 1);
8318        assert_eq!(resolved.shapes[0].bounds.width, 10.0);
8319        assert_eq!(resolved.shapes[0].bounds.height, 20.0);
8320        assert_eq!(
8321            resolved.shapes[0].geometry,
8322            ResolvedGeometry::BoundsFallback
8323        );
8324        assert_eq!(
8325            resolved.shapes[0].unsupported,
8326            Some("unknown graphic frame")
8327        );
8328        assert_eq!(resolved.diagnostics.len(), 1);
8329    }
8330
8331    #[test]
8332    fn ole_png_previews_use_their_producer_scope_and_graphic_frame_transform() {
8333        let fixture = Fixture::new(
8334            &ole_frame("rId7", 25_400, 9_999_999),
8335            &ole_frame("rId7", 12_700, 8_888_888),
8336            &ole_frame("rId7", 0, 7_777_777),
8337        );
8338        let media = ScopedMediaIds {
8339            slide: HashMap::from([("rId7".to_owned(), MediaId(1))]),
8340            layout: HashMap::from([("rId7".to_owned(), MediaId(2))]),
8341            master: HashMap::from([("rId7".to_owned(), MediaId(3))]),
8342            media_content_types: HashMap::from([
8343                (MediaId(1), "image/png".to_owned()),
8344                (MediaId(2), "image/png".to_owned()),
8345                (MediaId(3), "image/png".to_owned()),
8346            ]),
8347        };
8348
8349        let resolved = fixture
8350            .context()
8351            .resolve_slide_with_media((720.0, 540.0), &media)
8352            .unwrap();
8353        let ids = resolved
8354            .shapes
8355            .iter()
8356            .map(|shape| match &shape.content {
8357                ResolvedContent::Image(image) => image.media,
8358                _ => panic!("OLE PNG preview did not resolve as image content"),
8359            })
8360            .collect::<Vec<_>>();
8361        let positions = resolved
8362            .shapes
8363            .iter()
8364            .map(|shape| shape.bounds.x)
8365            .collect::<Vec<_>>();
8366
8367        assert_eq!(ids, [MediaId(3), MediaId(2), MediaId(1)]);
8368        assert_eq!(positions, [0.0, 1.0, 2.0]);
8369        assert!(resolved.shapes.iter().all(|shape| {
8370            shape.geometry == ResolvedGeometry::Rectangle
8371                && shape.unsupported == Some("embedded OLE interactivity")
8372        }));
8373        assert_eq!(
8374            resolved
8375                .diagnostics
8376                .iter()
8377                .map(|diagnostic| diagnostic.message.as_str())
8378                .collect::<Vec<_>>(),
8379            vec![
8380                "OLE object rendered as a static PNG preview. Embedded OLE interactivity is not rendered";
8381                3
8382            ]
8383        );
8384    }
8385
8386    #[test]
8387    fn ole_preview_requires_embedded_resolved_png_media() {
8388        let children = [
8389            ole_frame_without_preview(0),
8390            ole_frame_with_blip(
8391                r#"r:link="https://example.invalid/preview.png""#,
8392                12_700,
8393                12_700,
8394            ),
8395            ole_frame("rIdMissing", 25_400, 25_400),
8396            ole_frame("rIdWmf", 38_100, 38_100),
8397        ]
8398        .join("");
8399        let media = ScopedMediaIds {
8400            slide: HashMap::from([("rIdWmf".to_owned(), MediaId(4))]),
8401            media_content_types: HashMap::from([(MediaId(4), "image/x-wmf".to_owned())]),
8402            ..ScopedMediaIds::default()
8403        };
8404
8405        let resolved = Fixture::new(&children, "", "")
8406            .context()
8407            .resolve_slide_with_media((720.0, 540.0), &media)
8408            .unwrap();
8409
8410        assert_eq!(resolved.shapes.len(), 4);
8411        assert!(resolved.shapes.iter().all(|shape| {
8412            shape.content == ResolvedContent::None
8413                && shape.geometry == ResolvedGeometry::BoundsFallback
8414                && shape.unsupported == Some("OLE")
8415        }));
8416        assert_eq!(
8417            resolved.diagnostics,
8418            vec![
8419                Diagnostic {
8420                    message: "unsupported OLE content retained as bounds".to_owned(),
8421                };
8422                4
8423            ]
8424        );
8425    }
8426
8427    #[test]
8428    fn resolved_shapes_follow_the_flattened_order() {
8429        let master = shape_with_details(None, None, &transform(12_700), None);
8430        let layout = shape_with_details(None, None, &transform(25_400), None);
8431        let slide = shape_with_details(None, None, &transform(38_100), None);
8432        let fixture = Fixture::new(&slide, &layout, &master);
8433
8434        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
8435        let x = resolved
8436            .shapes
8437            .iter()
8438            .map(|shape| shape.bounds.x)
8439            .collect::<Vec<_>>();
8440        assert_eq!(x, [1.0, 2.0, 3.0]);
8441    }
8442
8443    #[test]
8444    fn all_corpus_slides_resolve_without_panics() {
8445        let stats = resolve_pinned_corpus();
8446
8447        assert_eq!(stats.decks, EXPECTED_CORPUS_DECKS);
8448        assert_eq!(stats.resolved + stats.contextual_errors, stats.slides);
8449    }
8450
8451    #[test]
8452    fn all_corpus_preset_geometries_evaluate_or_fallback() {
8453        let stats = resolve_pinned_corpus();
8454
8455        assert_eq!(stats.decks, EXPECTED_CORPUS_DECKS);
8456        assert_eq!(stats.contextual_errors, 0, "{}", stats.errors.join("\n"));
8457        assert_eq!(stats.unresolved_members, 0, "{}", stats.errors.join("\n"));
8458        assert!(
8459            stats.preset_inputs > 0,
8460            "corpus exercised no preset geometry"
8461        );
8462        assert_eq!(stats.preset_errors, 0, "{}", stats.errors.join("\n"));
8463        assert_eq!(
8464            stats.preset_inputs,
8465            stats.preset_evaluated + stats.preset_unknown,
8466            "not every corpus preset produced geometry or a named unknown fallback"
8467        );
8468        assert_eq!(stats.preset_fallbacks, stats.preset_unknown);
8469    }
8470
8471    #[derive(Default)]
8472    struct CorpusResolveStats {
8473        decks: usize,
8474        slides: usize,
8475        resolved: usize,
8476        contextual_errors: usize,
8477        unresolved_members: usize,
8478        theme_references: usize,
8479        preset_inputs: usize,
8480        preset_evaluated: usize,
8481        preset_unknown: usize,
8482        preset_errors: usize,
8483        preset_fallbacks: usize,
8484        errors: Vec<String>,
8485    }
8486
8487    fn resolve_pinned_corpus() -> CorpusResolveStats {
8488        let corpus = corpus_dir();
8489        assert!(
8490            corpus.is_dir(),
8491            "the required pinned corpus is missing at {}",
8492            corpus.display()
8493        );
8494        let entries = include_str!("../../../scripts/pptx-corpus-manifest.tsv")
8495            .lines()
8496            .skip(1)
8497            .map(|line| line.split('\t').next().unwrap())
8498            .collect::<Vec<_>>();
8499        assert_eq!(entries.len(), EXPECTED_CORPUS_DECKS);
8500
8501        let mut stats = CorpusResolveStats::default();
8502        for entry in entries {
8503            let path = corpus.join(entry);
8504            assert!(path.is_file(), "missing pinned deck {}", path.display());
8505            let package = OpcPackage::open(&path)
8506                .unwrap_or_else(|error| panic!("{}: {error}", path.display()));
8507            let presentation_part = package
8508                .main_document_part()
8509                .unwrap_or_else(|| panic!("{}: no presentation part", path.display()));
8510            let presentation = CT_Presentation::from_xml(part(&package, &presentation_part))
8511                .unwrap_or_else(|error| panic!("{}: {error}", path.display()));
8512            let default_text_style = presentation.default_text_style.unwrap_or_default();
8513            let size = presentation.slide_size.map_or((720.0, 540.0), |size| {
8514                (
8515                    super::emu_to_points(size.cx.0),
8516                    super::emu_to_points(size.cy.0),
8517                )
8518            });
8519            let presentation_rels = package
8520                .get_part_rels(&presentation_part)
8521                .unwrap_or_else(|| panic!("{}: no presentation relationships", path.display()));
8522
8523            for slide_id in presentation.slide_ids {
8524                stats.slides += 1;
8525                let slide_rel = presentation_rels
8526                    .get_by_id(&slide_id.relationship_id)
8527                    .unwrap_or_else(|| {
8528                        panic!(
8529                            "{}: missing slide relationship {}",
8530                            path.display(),
8531                            slide_id.relationship_id
8532                        )
8533                    });
8534                assert_eq!(slide_rel.rel_type, rel_types::SLIDE, "{}", path.display());
8535                let slide_part =
8536                    OpcPackage::resolve_rel_target(&presentation_part, &slide_rel.target);
8537                let layout_part =
8538                    related_part(&package, &slide_part, rel_types::SLIDE_LAYOUT, &path);
8539                let master_part =
8540                    related_part(&package, &layout_part, rel_types::SLIDE_MASTER, &path);
8541                let theme_part = related_part(&package, &master_part, rel_types::THEME, &path);
8542                let slide = CT_Slide::from_xml(part(&package, &slide_part))
8543                    .unwrap_or_else(|error| panic!("{} {slide_part}: {error}", path.display()));
8544                let layout = CT_SlideLayout::from_xml(part(&package, &layout_part))
8545                    .unwrap_or_else(|error| panic!("{} {layout_part}: {error}", path.display()));
8546                let master = CT_SlideMaster::from_xml(part(&package, &master_part))
8547                    .unwrap_or_else(|error| panic!("{} {master_part}: {error}", path.display()));
8548                let theme = CT_OfficeStyleSheet::from_xml(part(&package, &theme_part))
8549                    .unwrap_or_else(|error| panic!("{} {theme_part}: {error}", path.display()));
8550                let color_map = effective_corpus_color_map(&master, &layout, &slide);
8551                let context = ResolveCtx::new(
8552                    &theme,
8553                    color_map,
8554                    &master,
8555                    &layout,
8556                    &slide,
8557                    &default_text_style,
8558                );
8559                for item in context.flatten() {
8560                    let FlattenedItem::Shape {
8561                        child: ShapeTreeChild::Shape(shape),
8562                        ..
8563                    } = item
8564                    else {
8565                        continue;
8566                    };
8567                    if shape.shape_properties.custom_geometry.is_some() {
8568                        continue;
8569                    }
8570                    let Some(preset) = shape.shape_properties.preset_geometry.as_ref() else {
8571                        continue;
8572                    };
8573                    let Some((bounds, _, _, _)) =
8574                        transform_values(context.effective_xfrm(shape).as_ref())
8575                    else {
8576                        continue;
8577                    };
8578                    stats.preset_inputs += 1;
8579                    match context.concrete_preset_geometry(preset, (bounds.width, bounds.height)) {
8580                        Ok(Some(_)) => stats.preset_evaluated += 1,
8581                        Ok(None) => stats.preset_unknown += 1,
8582                        Err(error) => {
8583                            stats.preset_errors += 1;
8584                            stats.errors.push(format!(
8585                                "{} {slide_part}: preset {}: {error}",
8586                                path.display(),
8587                                preset.preset
8588                            ));
8589                        }
8590                    }
8591                }
8592                let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
8593                    context.resolve_slide(size)
8594                }))
8595                .unwrap_or_else(|_| panic!("{} {slide_part}: resolver panicked", path.display()));
8596                match result {
8597                    Ok(resolved) => {
8598                        stats.unresolved_members += resolved.unresolved_member_count;
8599                        if resolved.unresolved_member_count > 0 {
8600                            stats
8601                                .errors
8602                                .extend(resolved.diagnostics.iter().map(|diagnostic| {
8603                                    format!(
8604                                        "{} {slide_part}: {}",
8605                                        path.display(),
8606                                        diagnostic.message
8607                                    )
8608                                }));
8609                        }
8610                        let debug = format!("{resolved:?}");
8611                        stats.theme_references += ["schemeClr", "sysClr", "scrgbClr", "prstClr"]
8612                            .into_iter()
8613                            .filter(|marker| debug.contains(marker))
8614                            .count();
8615                        stats.preset_fallbacks += resolved
8616                            .shapes
8617                            .iter()
8618                            .filter(|shape| {
8619                                matches!(
8620                                    shape.unsupported,
8621                                    Some("unknown preset geometry" | "preset geometry evaluation")
8622                                )
8623                            })
8624                            .count();
8625                        stats.resolved += 1;
8626                    }
8627                    Err(error) => {
8628                        stats.contextual_errors += 1;
8629                        stats
8630                            .errors
8631                            .push(format!("{} {slide_part}: {error}", path.display()));
8632                    }
8633                }
8634            }
8635            stats.decks += 1;
8636        }
8637        stats
8638    }
8639
8640    fn corpus_dir() -> PathBuf {
8641        std::env::var_os("RDOCX_PPTX_CORPUS_DIR")
8642            .map(PathBuf::from)
8643            .unwrap_or_else(|| workspace_root().join("corpus/pptx"))
8644    }
8645
8646    fn workspace_root() -> PathBuf {
8647        Path::new(env!("CARGO_MANIFEST_DIR"))
8648            .parent()
8649            .and_then(Path::parent)
8650            .unwrap()
8651            .to_path_buf()
8652    }
8653
8654    fn part<'a>(package: &'a OpcPackage, part_name: &str) -> &'a [u8] {
8655        package
8656            .get_part(part_name)
8657            .unwrap_or_else(|| panic!("missing package part {part_name}"))
8658    }
8659
8660    fn related_part(
8661        package: &OpcPackage,
8662        source_part: &str,
8663        relationship_type: &str,
8664        deck: &Path,
8665    ) -> String {
8666        let relationship = package
8667            .get_part_rels(source_part)
8668            .and_then(|relationships| relationships.get_by_type(relationship_type))
8669            .unwrap_or_else(|| {
8670                panic!(
8671                    "{} {source_part}: missing relationship {relationship_type}",
8672                    deck.display()
8673                )
8674            });
8675        OpcPackage::resolve_rel_target(source_part, &relationship.target)
8676    }
8677
8678    fn effective_corpus_color_map(
8679        master: &CT_SlideMaster,
8680        layout: &CT_SlideLayout,
8681        slide: &CT_Slide,
8682    ) -> ColorMap {
8683        for override_value in [
8684            slide.color_map_override.as_ref(),
8685            layout.color_map_override.as_ref(),
8686        ]
8687        .into_iter()
8688        .flatten()
8689        {
8690            if let ColorMapOverrideKind::Override(map) = &override_value.kind {
8691                return map.clone();
8692            }
8693        }
8694        master.color_map.clone()
8695    }
8696
8697    fn slide_xml(children: &str) -> String {
8698        slide_xml_with("", "", children)
8699    }
8700
8701    fn layout_xml(children: &str) -> String {
8702        layout_xml_with("", "", children, "")
8703    }
8704
8705    fn master_xml(children: &str) -> String {
8706        master_xml_with("", children, "")
8707    }
8708
8709    fn slide_xml_with(attributes: &str, background: &str, children: &str) -> String {
8710        format!(
8711            "<p:sld xmlns:p=\"{P_NS}\" xmlns:a=\"{A_NS}\" xmlns:mc=\"{MC_NS}\" {attributes}><p:cSld>{background}{}</p:cSld></p:sld>",
8712            shape_tree(children)
8713        )
8714    }
8715
8716    fn layout_xml_with(
8717        attributes: &str,
8718        background: &str,
8719        children: &str,
8720        header_footer: &str,
8721    ) -> String {
8722        format!(
8723            "<p:sldLayout xmlns:p=\"{P_NS}\" xmlns:a=\"{A_NS}\" xmlns:mc=\"{MC_NS}\" {attributes}><p:cSld>{background}{}</p:cSld>{header_footer}</p:sldLayout>",
8724            shape_tree(children)
8725        )
8726    }
8727
8728    fn master_xml_with(background: &str, children: &str, header_footer: &str) -> String {
8729        format!(
8730            "<p:sldMaster xmlns:p=\"{P_NS}\" xmlns:a=\"{A_NS}\" xmlns:mc=\"{MC_NS}\"><p:cSld>{background}{}</p:cSld><p:clrMap bg1=\"lt1\" tx1=\"dk1\" bg2=\"lt2\" tx2=\"dk2\" accent1=\"accent1\" accent2=\"accent2\" accent3=\"accent3\" accent4=\"accent4\" accent5=\"accent5\" accent6=\"accent6\" hlink=\"hlink\" folHlink=\"folHlink\"/>{header_footer}</p:sldMaster>",
8731            shape_tree(children)
8732        )
8733    }
8734
8735    fn shape_tree(children: &str) -> String {
8736        format!("<p:spTree><p:nvGrpSpPr/><p:grpSpPr/>{children}</p:spTree>")
8737    }
8738
8739    fn shape(ph_type: Option<&str>, idx: Option<u32>) -> String {
8740        shape_with_details(ph_type, idx, "", None)
8741    }
8742
8743    fn shape_with_text(ph_type: Option<&str>, idx: Option<u32>, text: &str) -> String {
8744        let placeholder = if ph_type.is_none() && idx.is_none() {
8745            String::new()
8746        } else {
8747            let ph_type = ph_type.map_or_else(String::new, |value| format!(" type=\"{value}\""));
8748            let idx = idx.map_or_else(String::new, |value| format!(" idx=\"{value}\""));
8749            format!("<p:ph{ph_type}{idx}/>")
8750        };
8751        format!(
8752            "<p:sp><p:nvSpPr><p:cNvPr/><p:cNvSpPr/><p:nvPr>{placeholder}</p:nvPr></p:nvSpPr><p:spPr/><p:txBody><a:bodyPr/><a:p><a:r><a:t>{text}</a:t></a:r></a:p></p:txBody></p:sp>"
8753        )
8754    }
8755
8756    fn item_text(item: &FlattenedItem<'_>) -> Option<String> {
8757        let FlattenedItem::Shape {
8758            child: ShapeTreeChild::Shape(shape),
8759            ..
8760        } = item
8761        else {
8762            return None;
8763        };
8764        shape.text_body.as_ref().map(|body| body.plain_text())
8765    }
8766
8767    fn background_source(fixture: &Fixture) -> BackgroundSource {
8768        fixture.context().effective_background().unwrap().source
8769    }
8770
8771    #[test]
8772    fn same_relationship_id_resolves_hyperlink_in_its_shape_source_scope() {
8773        let linked_shape = |label: &str, x: i64| {
8774            shape_with_details(None, None, &transform(x), Some("<a:bodyPr/>"))
8775                .replace(
8776                    "<a:p/>",
8777                    &format!(r#"<a:p><a:r><a:rPr><a:hlinkClick xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId7"/></a:rPr><a:t>{label}</a:t></a:r></a:p>"#),
8778                )
8779        };
8780        let fixture = Fixture::new(
8781            &linked_shape("slide", 200),
8782            &linked_shape("layout", 100),
8783            &linked_shape("master", 0),
8784        );
8785        let hyperlinks = ScopedHyperlinkTargets {
8786            slide: HashMap::from([("rId7".to_owned(), "https://slide.example".to_owned())]),
8787            layout: HashMap::from([("rId7".to_owned(), "https://layout.example".to_owned())]),
8788            master: HashMap::from([("rId7".to_owned(), "https://master.example".to_owned())]),
8789        };
8790
8791        let resolved = fixture
8792            .context()
8793            .resolve_slide_with_resources((720.0, 540.0), &ScopedMediaIds::default(), &hyperlinks)
8794            .expect("resolve source-scoped hyperlinks");
8795        let targets = resolved
8796            .shapes
8797            .iter()
8798            .filter_map(|shape| match &shape.content {
8799                ResolvedContent::Text(body) => body.paragraphs[0].runs.first(),
8800                _ => None,
8801            })
8802            .filter_map(|run| match run {
8803                ResolvedTextRun::Text { style, .. } => style.hyperlink_url.as_deref(),
8804                _ => None,
8805            })
8806            .collect::<Vec<_>>();
8807
8808        assert_eq!(
8809            targets,
8810            vec![
8811                "https://master.example",
8812                "https://layout.example",
8813                "https://slide.example"
8814            ]
8815        );
8816    }
8817
8818    #[test]
8819    fn missing_hyperlink_relationship_keeps_text_and_records_diagnostic() {
8820        let shape = shape_with_details(None, None, &transform(0), Some("<a:bodyPr/>"))
8821            .replace(
8822                "<a:p/>",
8823                r#"<a:p><a:r><a:rPr><a:hlinkClick xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId404"/></a:rPr><a:t>missing</a:t></a:r><a:r><a:rPr><a:hlinkClick action="ppaction://macro"/></a:rPr><a:t> action</a:t></a:r><a:r><a:rPr><a:hlinkClick xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId8" action="ppaction://hlinksldjump"/></a:rPr><a:t> internal</a:t></a:r></a:p>"#,
8824            );
8825        let resolved = Fixture::new(&shape, "", "")
8826            .context()
8827            .resolve_slide_with_resources(
8828                (720.0, 540.0),
8829                &ScopedMediaIds::default(),
8830                &ScopedHyperlinkTargets::default(),
8831            )
8832            .expect("resolve unsupported hyperlinks without dropping text");
8833        let ResolvedContent::Text(body) = &resolved.shapes[0].content else {
8834            panic!("linked text should remain visible")
8835        };
8836
8837        assert_eq!(
8838            body.paragraphs[0]
8839                .runs
8840                .iter()
8841                .filter_map(|run| match run {
8842                    ResolvedTextRun::Text { text, .. } => Some(text.as_str()),
8843                    _ => None,
8844                })
8845                .collect::<String>(),
8846            "missing action internal"
8847        );
8848        let diagnostics = resolved
8849            .diagnostics
8850            .iter()
8851            .map(|diagnostic| diagnostic.message.as_str())
8852            .collect::<Vec<_>>();
8853        for expected in [
8854            "missing slide hyperlink relationship `rId404`",
8855            "unsupported slide hyperlink action `ppaction://macro`",
8856            "unsupported slide hyperlink action `ppaction://hlinksldjump`",
8857        ] {
8858            assert!(diagnostics.contains(&expected), "missing `{expected}`");
8859        }
8860    }
8861
8862    #[test]
8863    fn untyped_slide_number_placeholder_uses_the_current_page_number() {
8864        let slide_shape = shape_with_details(None, Some(4), &transform(0), Some("<a:bodyPr/>"))
8865            .replace(
8866                "<a:p/>",
8867                r#"<a:p><a:fld id="{00112233-4455-6677-8899-AABBCCDDEEFF}"><a:t>stored</a:t></a:fld></a:p>"#,
8868            );
8869        let layout_shape =
8870            shape_with_details(Some("sldNum"), Some(4), &transform(0), Some("<a:bodyPr/>"));
8871        let resolved = Fixture::new(&slide_shape, &layout_shape, "")
8872            .context()
8873            .resolve_slide((720.0, 540.0))
8874            .expect("resolve inherited slide-number placeholder");
8875        let ResolvedContent::Text(body) = &resolved.shapes[0].content else {
8876            panic!("slide-number field should remain visible")
8877        };
8878        let ResolvedTextRun::Field { field_type, .. } = &body.paragraphs[0].runs[0] else {
8879            panic!("expected a field")
8880        };
8881
8882        assert_eq!(field_type.as_deref(), Some("slidenum"));
8883    }
8884
8885    #[test]
8886    fn three_dimensional_chart_uses_cached_image_and_diagnostic() {
8887        let alternate = chart_alternate("rIdChart", Some("rIdPreview")).replace(
8888            r#"r:embed="rIdPreview"/>"#,
8889            r#"r:embed="rIdPreview"><a:alphaModFix amt="30000"/></a:blip>"#,
8890        );
8891        let fixture = Fixture::new(&alternate, "", "");
8892        let preview = MediaId(47);
8893        let media = ScopedMediaIds {
8894            slide: HashMap::from([("rIdPreview".to_owned(), preview)]),
8895            media_content_types: HashMap::from([(preview, "image/png".to_owned())]),
8896            ..ScopedMediaIds::default()
8897        };
8898        let charts = ScopedChartResources {
8899            slide: HashMap::from([(
8900                "rIdChart".to_owned(),
8901                ChartResource::Parsed(Box::new(three_dimensional_chart())),
8902            )]),
8903            ..ScopedChartResources::default()
8904        };
8905        let mut fonts = FontManager::new_deterministic().unwrap();
8906
8907        let resolved = fixture
8908            .context()
8909            .resolve_slide_with_chart_resources(
8910                (720.0, 540.0),
8911                &media,
8912                &ScopedHyperlinkTargets::default(),
8913                &charts,
8914                &mut fonts,
8915            )
8916            .unwrap();
8917
8918        assert!(matches!(
8919            resolved.shapes[0].content,
8920            ResolvedContent::Image(ref image) if image.media == preview && image.opacity == 0.3
8921        ));
8922        assert!(resolved.shapes[0].unsupported.is_some());
8923        assert!(resolved.diagnostics.iter().any(|diagnostic| {
8924            diagnostic.message.contains("unsupported chart")
8925                && diagnostic.message.contains("cached image")
8926        }));
8927
8928        let unsupported_media = ScopedMediaIds {
8929            slide: HashMap::from([("rIdPreview".to_owned(), preview)]),
8930            media_content_types: HashMap::from([(preview, "image/x-wmf".to_owned())]),
8931            ..ScopedMediaIds::default()
8932        };
8933        let mut fonts = FontManager::new_deterministic().unwrap();
8934        let resolved = fixture
8935            .context()
8936            .resolve_slide_with_chart_resources(
8937                (720.0, 540.0),
8938                &unsupported_media,
8939                &ScopedHyperlinkTargets::default(),
8940                &charts,
8941                &mut fonts,
8942            )
8943            .unwrap();
8944        assert!(matches!(
8945            resolved.shapes[0].content,
8946            ResolvedContent::Group(_)
8947        ));
8948        assert!(resolved.diagnostics.iter().any(|diagnostic| {
8949            diagnostic.message.contains("not renderer-compatible")
8950                && diagnostic.message.contains("unsupported chart")
8951        }));
8952    }
8953
8954    #[test]
8955    fn same_chart_relationship_id_is_scoped_to_its_source_part() {
8956        let fixture = Fixture::new(
8957            &chart_frame_at("rId8", 200),
8958            &chart_frame_at("rId8", 100),
8959            &chart_frame("rId8"),
8960        );
8961        let slide = ChartResource::MissingTarget("/ppt/charts/slide.xml".to_owned());
8962        let layout = ChartResource::MissingTarget("/ppt/charts/layout.xml".to_owned());
8963        let master = ChartResource::MissingTarget("/ppt/charts/master.xml".to_owned());
8964        let charts = ScopedChartResources {
8965            slide: HashMap::from([("rId8".to_owned(), slide.clone())]),
8966            layout: HashMap::from([("rId8".to_owned(), layout.clone())]),
8967            master: HashMap::from([("rId8".to_owned(), master.clone())]),
8968        };
8969
8970        let mut fonts = FontManager::new_deterministic().unwrap();
8971        let resolved = fixture
8972            .context()
8973            .resolve_slide_with_chart_resources(
8974                (720.0, 540.0),
8975                &ScopedMediaIds::default(),
8976                &ScopedHyperlinkTargets::default(),
8977                &charts,
8978                &mut fonts,
8979            )
8980            .unwrap();
8981        assert_eq!(resolved.shapes.len(), 3);
8982        let diagnostics = resolved
8983            .diagnostics
8984            .iter()
8985            .map(|diagnostic| diagnostic.message.as_str())
8986            .collect::<Vec<_>>();
8987        for (scope, target) in [
8988            ("master", "/ppt/charts/master.xml"),
8989            ("layout", "/ppt/charts/layout.xml"),
8990            ("slide", "/ppt/charts/slide.xml"),
8991        ] {
8992            assert!(diagnostics.iter().any(|message| {
8993                message.contains(scope) && message.contains("rId8") && message.contains(target)
8994            }));
8995        }
8996    }
8997
8998    #[test]
8999    fn unsupported_chart_without_preview_keeps_labelled_bounds() {
9000        let fixture = Fixture::new(&chart_frame("rIdChart"), "", "");
9001        let charts = ScopedChartResources {
9002            slide: HashMap::from([(
9003                "rIdChart".to_owned(),
9004                ChartResource::Parsed(Box::new(three_dimensional_chart())),
9005            )]),
9006            ..ScopedChartResources::default()
9007        };
9008        let mut fonts = FontManager::new_deterministic().unwrap();
9009        let resolved = fixture
9010            .context()
9011            .resolve_slide_with_chart_resources(
9012                (720.0, 540.0),
9013                &ScopedMediaIds::default(),
9014                &ScopedHyperlinkTargets::default(),
9015                &charts,
9016                &mut fonts,
9017            )
9018            .unwrap();
9019
9020        assert_eq!(
9021            resolved.shapes[0].geometry,
9022            ResolvedGeometry::BoundsFallback
9023        );
9024        let ResolvedContent::Group(group) = &resolved.shapes[0].content else {
9025            panic!("unsupported chart should retain a labelled group");
9026        };
9027        let mut labels = Vec::new();
9028        walk(&group.children, &mut |element, _| {
9029            if let PositionedElement::Text(run) = element {
9030                labels.push(run.text.clone());
9031            }
9032        });
9033        assert_eq!(labels, vec!["Unsupported chart"]);
9034    }
9035
9036    #[test]
9037    fn missing_or_external_chart_relationship_is_contextual() {
9038        let children = format!(
9039            "{}{}{}",
9040            chart_frame("missingChart"),
9041            chart_frame_at("externalChart", 200),
9042            chart_frame_at("missingTarget", 400)
9043        );
9044        let fixture = Fixture::new(&children, "", "");
9045        let charts = ScopedChartResources {
9046            slide: HashMap::from([
9047                (
9048                    "externalChart".to_owned(),
9049                    ChartResource::External("https://example.invalid/chart.xml".to_owned()),
9050                ),
9051                (
9052                    "missingTarget".to_owned(),
9053                    ChartResource::MissingTarget("/ppt/charts/missing.xml".to_owned()),
9054                ),
9055            ]),
9056            ..ScopedChartResources::default()
9057        };
9058        let mut fonts = FontManager::new_deterministic().unwrap();
9059        let resolved = fixture
9060            .context()
9061            .resolve_slide_with_chart_resources(
9062                (720.0, 540.0),
9063                &ScopedMediaIds::default(),
9064                &ScopedHyperlinkTargets::default(),
9065                &charts,
9066                &mut fonts,
9067            )
9068            .unwrap();
9069        let diagnostics = resolved
9070            .diagnostics
9071            .iter()
9072            .map(|diagnostic| diagnostic.message.as_str())
9073            .collect::<Vec<_>>();
9074
9075        assert!(
9076            diagnostics
9077                .iter()
9078                .any(|message| { message.contains("slide") && message.contains("missingChart") })
9079        );
9080        assert!(diagnostics.iter().any(|message| {
9081            message.contains("slide")
9082                && message.contains("externalChart")
9083                && message.contains("https://example.invalid/chart.xml")
9084        }));
9085        assert!(diagnostics.iter().any(|message| {
9086            message.contains("slide")
9087                && message.contains("missingTarget")
9088                && message.contains("/ppt/charts/missing.xml")
9089        }));
9090    }
9091
9092    fn shape_with_details(
9093        ph_type: Option<&str>,
9094        idx: Option<u32>,
9095        shape_properties: &str,
9096        body_properties: Option<&str>,
9097    ) -> String {
9098        let placeholder = if ph_type.is_none() && idx.is_none() {
9099            String::new()
9100        } else {
9101            let ph_type = ph_type.map_or_else(String::new, |value| format!(" type=\"{value}\""));
9102            let idx = idx.map_or_else(String::new, |value| format!(" idx=\"{value}\""));
9103            format!("<p:ph{ph_type}{idx}/>")
9104        };
9105        let text_body = body_properties.map_or_else(String::new, |body_properties| {
9106            format!("<p:txBody>{body_properties}<a:p/></p:txBody>")
9107        });
9108        format!(
9109            "<p:sp><p:nvSpPr><p:cNvPr/><p:cNvSpPr/><p:nvPr>{placeholder}</p:nvPr></p:nvSpPr><p:spPr>{shape_properties}</p:spPr>{text_body}</p:sp>"
9110        )
9111    }
9112
9113    fn chart_frame(relationship_id: &str) -> String {
9114        chart_frame_at(relationship_id, 0)
9115    }
9116
9117    fn chart_frame_at(relationship_id: &str, x: i64) -> String {
9118        format!(
9119            r#"<p:graphicFrame><p:nvGraphicFramePr/><p:xfrm><a:off x="{x}" y="0"/><a:ext cx="1270000" cy="762000"/></p:xfrm><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="{relationship_id}"/></a:graphicData></a:graphic></p:graphicFrame>"#
9120        )
9121    }
9122
9123    fn chart_alternate(relationship_id: &str, preview_id: Option<&str>) -> String {
9124        let preview = preview_id.map_or_else(String::new, |preview_id| {
9125            picture(preview_id, "", "<a:stretch><a:fillRect/></a:stretch>", 999)
9126        });
9127        format!(
9128            r#"<mc:AlternateContent><mc:Choice Requires="c">{}</mc:Choice><mc:Fallback>{preview}</mc:Fallback></mc:AlternateContent>"#,
9129            chart_frame(relationship_id)
9130        )
9131    }
9132
9133    fn three_dimensional_chart() -> CT_ChartSpace {
9134        CT_ChartSpace::from_xml(
9135            br#"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart><c:plotArea><c:bar3DChart><c:barDir val="col"/></c:bar3DChart></c:plotArea></c:chart></c:chartSpace>"#,
9136        )
9137        .unwrap()
9138    }
9139
9140    fn connector(preset: &str, cx: i64, cy: i64, adjustments: &str, line: &str) -> String {
9141        format!(
9142            r#"<p:cxnSp><p:nvCxnSpPr><p:cNvPr/><p:cNvCxnSpPr/><p:nvPr/></p:nvCxnSpPr><p:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="{cx}" cy="{cy}"/></a:xfrm><a:prstGeom prst="{preset}"><a:avLst>{adjustments}</a:avLst></a:prstGeom>{line}</p:spPr></p:cxnSp>"#
9143        )
9144    }
9145
9146    fn picture(
9147        relationship_id: &str,
9148        fill_attributes: &str,
9149        fill_children: &str,
9150        x: i64,
9151    ) -> String {
9152        format!(
9153            r#"<p:pic><p:nvPicPr><p:cNvPr/><p:cNvPicPr/><p:nvPr/></p:nvPicPr><p:blipFill {fill_attributes}><a:blip xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:embed="{relationship_id}"/>{fill_children}</p:blipFill><p:spPr>{}<a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr></p:pic>"#,
9154            transform(x)
9155        )
9156    }
9157
9158    fn shape_picture_fill(relationship_id: &str, x: i64, text_body: Option<&str>) -> String {
9159        shape_with_details(
9160            None,
9161            None,
9162            &format!(
9163                r#"{}<a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:blipFill><a:blip xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:embed="{relationship_id}"/><a:stretch><a:fillRect/></a:stretch></a:blipFill>"#,
9164                transform(x)
9165            ),
9166            text_body,
9167        )
9168    }
9169
9170    fn linked_picture(url: &str, x: i64) -> String {
9171        format!(
9172            r#"<p:pic><p:nvPicPr><p:cNvPr/><p:cNvPicPr/><p:nvPr/></p:nvPicPr><p:blipFill><a:blip xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:link="{url}"/></p:blipFill><p:spPr>{}<a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr></p:pic>"#,
9173            transform(x)
9174        )
9175    }
9176
9177    fn ole_frame(relationship_id: &str, frame_x: i64, preview_x: i64) -> String {
9178        ole_frame_with_blip(
9179            &format!(r#"r:embed="{relationship_id}""#),
9180            frame_x,
9181            preview_x,
9182        )
9183    }
9184
9185    fn ole_frame_with_blip(blip_attribute: &str, frame_x: i64, preview_x: i64) -> String {
9186        let preview = format!(
9187            r#"<p:pic><p:nvPicPr><p:cNvPr/><p:cNvPicPr/><p:nvPr/></p:nvPicPr><p:blipFill><a:blip xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" {blip_attribute}/><a:stretch><a:fillRect/></a:stretch></p:blipFill><p:spPr>{}<a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr></p:pic>"#,
9188            transform(preview_x)
9189        );
9190        ole_frame_payload(frame_x, &preview)
9191    }
9192
9193    fn ole_frame_without_preview(frame_x: i64) -> String {
9194        ole_frame_payload(frame_x, "")
9195    }
9196
9197    fn ole_frame_payload(frame_x: i64, preview: &str) -> String {
9198        format!(
9199            r#"<p:graphicFrame><p:nvGraphicFramePr/><p:xfrm><a:off x="{frame_x}" y="0"/><a:ext cx="100" cy="100"/></p:xfrm><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/presentationml/2006/ole"><p:oleObj><p:embed/>{preview}</p:oleObj></a:graphicData></a:graphic></p:graphicFrame>"#
9200        )
9201    }
9202
9203    fn transform(x: i64) -> String {
9204        format!("<a:xfrm><a:off x=\"{x}\" y=\"0\"/><a:ext cx=\"100\" cy=\"100\"/></a:xfrm>")
9205    }
9206    fn fx206_recovery_shape(id: u32, props: &str, style: &str, placed: bool) -> String {
9207        let transform = if placed {
9208            r#"<a:xfrm><a:off x="127000" y="254000"/><a:ext cx="127000" cy="127000"/></a:xfrm>"#
9209        } else {
9210            ""
9211        };
9212        format!(
9213            r#"<p:sp><p:nvSpPr><p:cNvPr id="{id}" name="owner{id}"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr><p:spPr>{transform}<a:prstGeom prst="rect"/>{props}</p:spPr>{style}</p:sp>"#
9214        )
9215    }
9216
9217    #[test]
9218    fn fx206_recovery_semantic_errors_keep_siblings_and_count_boundsless_owners() {
9219        let cases = [
9220            (
9221                "",
9222                r#"<p:style><a:lnRef idx="0"/><a:fillRef idx="999"/><a:effectRef idx="0"/><a:fontRef idx="minor"/></p:style>"#,
9223                "style index",
9224            ),
9225            (
9226                r#"<a:solidFill><a:schemeClr val="phClr"/></a:solidFill>"#,
9227                "",
9228                "phClr",
9229            ),
9230            (
9231                r#"<a:solidFill><a:prstClr val="futureColour"/></a:solidFill>"#,
9232                "",
9233                "concrete value",
9234            ),
9235        ];
9236        for (properties, style, reason) in cases {
9237            for placed in [true, false] {
9238                let red = fx206_recovery_shape(
9239                    1,
9240                    r#"<a:solidFill><a:srgbClr val="FF0000"/></a:solidFill>"#,
9241                    "",
9242                    true,
9243                );
9244                let green = fx206_recovery_shape(
9245                    3,
9246                    r#"<a:solidFill><a:srgbClr val="00FF00"/></a:solidFill>"#,
9247                    "",
9248                    true,
9249                );
9250                let failed = fx206_recovery_shape(2, properties, style, placed);
9251                let fixture = Fixture::new(&[red, failed, green].join(""), "", "");
9252                let original = fixture.slide.clone();
9253                let context = fixture.context();
9254                let mut identities = Vec::new();
9255                let (resolved, directions) = context
9256                    .resolve_slide_inner_common(
9257                        (100.0, 100.0),
9258                        None,
9259                        None,
9260                        None,
9261                        None,
9262                        Some(&mut identities),
9263                    )
9264                    .unwrap();
9265                assert_eq!(
9266                    resolved.unresolved_member_count, 1,
9267                    "{reason}, placed={placed}"
9268                );
9269                assert_eq!(
9270                    identities
9271                        .iter()
9272                        .map(|identity| identity.shape_id.unwrap())
9273                        .collect::<Vec<_>>(),
9274                    if placed { vec![1, 2, 3] } else { vec![1, 3] }
9275                );
9276                assert_eq!(directions.len(), resolved.shapes.len());
9277                assert_eq!(
9278                    resolved.shapes.first().unwrap().fill,
9279                    Some(Paint::Solid(Color::from_hex("FF0000")))
9280                );
9281                assert_eq!(
9282                    resolved.shapes.last().unwrap().fill,
9283                    Some(Paint::Solid(Color::from_hex("00FF00")))
9284                );
9285                let messages = resolved
9286                    .diagnostics
9287                    .iter()
9288                    .filter(|diagnostic| diagnostic.message.starts_with("unresolved member "))
9289                    .collect::<Vec<_>>();
9290                assert_eq!(messages.len(), 1);
9291                assert!(messages[0].message.contains("shape 2 \"owner2\""));
9292                assert!(messages[0].message.contains(reason));
9293                assert!(messages[0].message.contains(if placed {
9294                    "retained as bounds"
9295                } else {
9296                    "omitted without trustworthy finite bounds"
9297                }));
9298                if placed {
9299                    assert_eq!(
9300                        resolved.shapes[1].geometry,
9301                        ResolvedGeometry::BoundsFallback
9302                    );
9303                    assert_eq!(resolved.shapes[1].content, ResolvedContent::None);
9304                }
9305                assert_eq!(fixture.slide, original);
9306            }
9307        }
9308    }
9309
9310    #[test]
9311    fn fx206_recovery_valid_zero_and_background_setup_remain_distinct() {
9312        let fixture = Fixture::new(
9313            &fx206_recovery_shape(
9314                1,
9315                r#"<a:solidFill><a:srgbClr val="FF0000"/></a:solidFill>"#,
9316                "",
9317                true,
9318            ),
9319            "",
9320            "",
9321        );
9322        let valid = fixture.context().resolve_slide((100.0, 100.0)).unwrap();
9323        assert_eq!(valid.unresolved_member_count, 0);
9324        assert!(valid.diagnostics.is_empty());
9325        let xml = slide_xml_with(
9326            "",
9327            r#"<p:bg><p:bgRef idx="999"><a:schemeClr val="accent1"/></p:bgRef></p:bg>"#,
9328            "",
9329        );
9330        let fixture = Fixture::from_xml(&xml, &layout_xml(""), &master_xml(""));
9331        assert!(matches!(
9332            fixture.context().resolve_slide((100.0, 100.0)),
9333            Err(crate::ResolveError::StyleIndexOutOfRange { .. })
9334        ));
9335        assert!(CT_Slide::from_xml(b"<p:sld>").is_err());
9336    }
9337
9338    #[test]
9339    fn fx206_recovery_rejects_nonfinite_placement_without_hiding_the_failure() {
9340        let fixture = Fixture::new(&fx206_recovery_shape(2, "", "", true), "", "");
9341        let context = fixture.context();
9342        let mut diagnostics = Vec::new();
9343        let error = crate::ResolveError::ConcreteValue {
9344            kind: "test semantic",
9345            detail: "bad value".to_owned(),
9346        };
9347        let result = context.unresolved_shape_fallback(
9348            super::ShapePlacement {
9349                source: FlattenedSource::Slide,
9350                group_scale: (1.0, 1.0),
9351                group_transform: Transform {
9352                    a: f64::INFINITY,
9353                    ..Transform::IDENTITY
9354                },
9355            },
9356            &fixture.slide.common_slide_data.shape_tree.children[0],
9357            &error,
9358            &mut diagnostics,
9359        );
9360        assert!(result.is_none());
9361        assert_eq!(diagnostics.len(), 1);
9362        assert!(
9363            diagnostics[0]
9364                .message
9365                .contains("omitted without trustworthy finite bounds")
9366        );
9367    }
9368
9369    #[test]
9370    fn fx206_recovery_alias_and_default_namespaces_keep_owned_failure_and_foreign_payload() {
9371        let failed = fx206_recovery_shape(
9372            2,
9373            r#"<a:solidFill><a:schemeClr val="phClr"/></a:solidFill><f:solidFill xmlns:f="urn:foreign"><f:opaque/></f:solidFill>"#,
9374            "",
9375            true,
9376        );
9377        let original = slide_xml(&failed);
9378        let conflicting = original
9379            .replace("xmlns:p=", "xmlns:q=")
9380            .replace("xmlns:a=", "xmlns:r=")
9381            .replace("<p:", "<q:")
9382            .replace("</p:", "</q:")
9383            .replace("<a:", "<r:")
9384            .replace("</a:", "</r:");
9385        assert!(CT_Slide::from_xml(conflicting.as_bytes()).is_err());
9386        let alias = original
9387            .replace("xmlns:p=", "xmlns:q=")
9388            .replace("xmlns:a=", "xmlns:d=")
9389            .replace("<p:", "<q:")
9390            .replace("</p:", "</q:")
9391            .replace("<a:", "<d:")
9392            .replace("</a:", "</d:");
9393        let default = original.replace("<p:", "<").replace("</p:", "</").replacen(
9394            "<sld ",
9395            &format!("<sld xmlns=\"{P_NS}\" "),
9396            1,
9397        );
9398        for xml in [original, alias, default] {
9399            let fixture = Fixture::from_xml(&xml, &layout_xml(""), &master_xml(""));
9400            let snapshot = fixture.slide.clone();
9401            let resolved = fixture.context().resolve_slide((100.0, 100.0)).unwrap();
9402            assert_eq!(resolved.unresolved_member_count, 1);
9403            assert_eq!(resolved.shapes.len(), 1);
9404            assert!(resolved.diagnostics[0].message.contains("phClr"));
9405            assert_eq!(fixture.slide, snapshot);
9406        }
9407    }
9408
9409    #[test]
9410    fn fx206_recovery_gradient_stop_errors_count_with_and_without_placement() {
9411        for line in [false, true] {
9412            for color in [
9413                r#"<a:schemeClr val="phClr"/>"#,
9414                r#"<a:prstClr val="futureColour"/>"#,
9415            ] {
9416                for placed in [false, true] {
9417                    let gradient = format!(
9418                        r#"<a:gradFill><a:gsLst><a:gs pos="0">{color}</a:gs><a:gs pos="100000"><a:srgbClr val="00FF00"/></a:gs></a:gsLst><a:lin ang="0" scaled="1"/></a:gradFill>"#
9419                    );
9420                    let properties = if line {
9421                        format!("<a:ln>{gradient}</a:ln>")
9422                    } else {
9423                        gradient
9424                    };
9425                    let children = [
9426                        fx206_recovery_shape(
9427                            1,
9428                            r#"<a:solidFill><a:srgbClr val="FF0000"/></a:solidFill>"#,
9429                            "",
9430                            true,
9431                        ),
9432                        fx206_recovery_shape(2, &properties, "", placed),
9433                        fx206_recovery_shape(
9434                            3,
9435                            r#"<a:solidFill><a:srgbClr val="00FF00"/></a:solidFill>"#,
9436                            "",
9437                            true,
9438                        ),
9439                    ]
9440                    .join("");
9441                    for namespace in 0..3 {
9442                        let xml = match namespace {
9443                            1 => slide_xml(&children)
9444                                .replace("xmlns:a=", "xmlns:d=")
9445                                .replace("<a:", "<d:")
9446                                .replace("</a:", "</d:"),
9447                            2 => slide_xml(&children)
9448                                .replace("xmlns:a=", "xmlns=")
9449                                .replace("<a:", "<")
9450                                .replace("</a:", "</"),
9451                            _ => slide_xml(&children),
9452                        };
9453                        let fixture = Fixture::from_xml(&xml, &layout_xml(""), &master_xml(""));
9454                        let before = fixture.slide.clone();
9455                        let resolved = fixture.context().resolve_slide((100.0, 100.0)).unwrap();
9456                        assert_eq!(
9457                            resolved.unresolved_member_count, 1,
9458                            "line={line} placed={placed} namespace={namespace}"
9459                        );
9460                        assert_eq!(resolved.shapes.len(), if placed { 3 } else { 2 });
9461                        assert_eq!(
9462                            resolved.shapes[0].fill,
9463                            Some(Paint::Solid(Color::from_hex("FF0000")))
9464                        );
9465                        assert_eq!(
9466                            resolved.shapes.last().unwrap().fill,
9467                            Some(Paint::Solid(Color::from_hex("00FF00")))
9468                        );
9469                        assert_eq!(resolved.diagnostics.len(), 1);
9470                        assert!(
9471                            resolved.diagnostics[0]
9472                                .message
9473                                .contains("shape 2 \"owner2\"")
9474                        );
9475                        assert!(resolved.diagnostics[0].message.contains(if placed {
9476                            "retained as bounds"
9477                        } else {
9478                            "omitted without trustworthy finite bounds"
9479                        }));
9480                        assert_eq!(fixture.slide, before);
9481                    }
9482                }
9483            }
9484        }
9485    }
9486
9487    #[test]
9488    fn fx206_recovery_unplaced_gradient_validation_keeps_supported_and_unsupported_boundaries() {
9489        for (attributes, stops, geometry) in [
9490            (
9491                "",
9492                r#"<a:gs pos="0"><a:srgbClr val="112233"/></a:gs>"#,
9493                r#"<a:lin ang="0"/>"#,
9494            ),
9495            (
9496                r#"rotWithShape="0""#,
9497                r#"<a:gs pos="0"><a:schemeClr val="phClr"/></a:gs>"#,
9498                r#"<a:lin ang="0"/>"#,
9499            ),
9500            (
9501                r#"flip="x""#,
9502                r#"<a:gs pos="0"><a:schemeClr val="phClr"/></a:gs>"#,
9503                r#"<a:lin ang="0"/>"#,
9504            ),
9505            (
9506                "",
9507                r#"<a:gs pos="0"><a:schemeClr val="phClr"/></a:gs>"#,
9508                r#"<a:path path="circle"/>"#,
9509            ),
9510            (
9511                "",
9512                r#"<a:gs pos="0"/><a:gs pos="100000"><a:schemeClr val="phClr"/></a:gs>"#,
9513                r#"<a:lin ang="0"/>"#,
9514            ),
9515        ] {
9516            let gradient = format!(
9517                "<a:gradFill {attributes}><a:gsLst>{stops}</a:gsLst>{geometry}</a:gradFill>"
9518            );
9519            let fixture = Fixture::new(&fx206_recovery_shape(2, &gradient, "", false), "", "");
9520            let before = fixture.slide.clone();
9521            let resolved = fixture.context().resolve_slide((100.0, 100.0)).unwrap();
9522            assert_eq!(
9523                resolved.unresolved_member_count, 0,
9524                "attributes={attributes:?}; stops={stops}; geometry={geometry}; diagnostics={:?}",
9525                resolved.diagnostics
9526            );
9527            assert!(resolved.shapes.is_empty());
9528            assert!(resolved.diagnostics.is_empty());
9529            assert_eq!(fixture.slide, before);
9530        }
9531    }
9532
9533    #[test]
9534    fn fx206_recovery_supported_gradient_phclr_before_missing_stop_still_counts() {
9535        for placed in [false, true] {
9536            let gradient = r#"<a:gradFill><a:gsLst><a:gs pos="0"><a:schemeClr val="phClr"/></a:gs><a:gs pos="100000"/></a:gsLst><a:lin ang="0"/></a:gradFill>"#;
9537            let fixture = Fixture::new(&fx206_recovery_shape(2, gradient, "", placed), "", "");
9538            let before = fixture.slide.clone();
9539            let resolved = fixture.context().resolve_slide((100.0, 100.0)).unwrap();
9540            assert_eq!(resolved.unresolved_member_count, 1);
9541            assert_eq!(resolved.shapes.len(), usize::from(placed));
9542            assert_eq!(resolved.diagnostics.len(), 1);
9543            assert!(resolved.diagnostics[0].message.contains("phClr"));
9544            assert!(resolved.diagnostics[0].message.contains(if placed {
9545                "retained as bounds"
9546            } else {
9547                "omitted without trustworthy finite bounds"
9548            }));
9549            assert_eq!(fixture.slide, before);
9550        }
9551    }
9552
9553    #[test]
9554    fn fx206_background_gradient_shape_rotation_does_not_hide_phclr_paint() {
9555        let background = r#"<p:bg><p:bgPr><a:gradFill rotWithShape="0"><a:gsLst><a:gs pos="0"><a:schemeClr val="phClr"/></a:gs><a:gs pos="100000"><a:srgbClr val="0000FF"/></a:gs></a:gsLst><a:lin ang="0"/></a:gradFill></p:bgPr></p:bg>"#;
9556        let fixture = Fixture::from_xml(
9557            &slide_xml_with("", background, ""),
9558            &layout_xml_with("", "", "", ""),
9559            &master_xml_with("", "", ""),
9560        );
9561        let before = fixture.slide.clone();
9562        let resolved = fixture.context().resolve_slide((40.0, 20.0)).unwrap();
9563        let Some(ResolvedBackground::Paint(Paint::Linear { stops, .. })) = resolved.background
9564        else {
9565            panic!("expected supported background gradient");
9566        };
9567        assert_eq!(stops.len(), 259);
9568        assert_eq!(stops.last().unwrap().color, Color::from_hex("0000FF"));
9569        assert_eq!(resolved.unresolved_member_count, 0);
9570        assert!(resolved.diagnostics.is_empty());
9571        assert_eq!(fixture.slide, before);
9572    }
9573
9574    #[test]
9575    fn fx206_gradient_rotation_policy_keeps_placed_reference_and_rotated_boundary() {
9576        for rotation in [0, 5_400_000] {
9577            let gradient = r#"<a:gradFill rotWithShape="0"><a:gsLst><a:gs pos="0"><a:schemeClr val="phClr"/></a:gs><a:gs pos="100000"><a:srgbClr val="0000FF"/></a:gs></a:gsLst><a:lin ang="0"/></a:gradFill>"#;
9578            let style = r#"<p:style><a:lnRef idx="0"/><a:fillRef idx="0"><a:srgbClr val="FF0000"/></a:fillRef><a:effectRef idx="0"/><a:fontRef idx="minor"/></p:style>"#;
9579            let shape = fx206_recovery_shape(2, gradient, style, true)
9580                .replace("<a:xfrm>", &format!("<a:xfrm rot=\"{rotation}\">"));
9581            let fixture = Fixture::new(&shape, "", "");
9582            let before = fixture.slide.clone();
9583            let resolved = fixture.context().resolve_slide((100.0, 100.0)).unwrap();
9584            assert_eq!(resolved.unresolved_member_count, 0);
9585            assert_eq!(resolved.shapes.len(), 1);
9586            if rotation == 0 {
9587                let Some(Paint::Linear { stops, .. }) = &resolved.shapes[0].fill else {
9588                    panic!("expected supported unrotated gradient");
9589                };
9590                assert_eq!(stops.len(), 259);
9591                assert_eq!(stops[0].color, Color::from_hex("FF0000"));
9592                assert_eq!(stops.last().unwrap().color, Color::from_hex("0000FF"));
9593                assert!(resolved.diagnostics.is_empty());
9594            } else {
9595                assert!(resolved.shapes[0].fill.is_none());
9596                assert!(resolved.diagnostics.iter().any(|diagnostic| {
9597                    diagnostic
9598                        .message
9599                        .contains("gradient independent of shape rotation")
9600                }));
9601            }
9602            assert_eq!(fixture.slide, before);
9603        }
9604    }
9605}