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.slide.show_master_shapes.unwrap_or(true)
284                    && self.layout.show_master_shapes.unwrap_or(true),
285            },
286            &mut flattened,
287        );
288        emit_tree(
289            layout_children,
290            PassRules {
291                source: FlattenedSource::Layout,
292                emit_non_placeholders: self.slide.show_master_shapes.unwrap_or(true),
293            },
294            &mut flattened,
295        );
296        emit_tree(
297            slide_children,
298            PassRules {
299                source: FlattenedSource::Slide,
300                emit_non_placeholders: true,
301            },
302            &mut flattened,
303        );
304        flattened
305    }
306
307    /// Enumerates physical leaves without visibility or placeholder filtering.
308    /// Child paths retain source ordinals, including nested groups. Opaque
309    /// alternate content stays an explicit leaf rather than a writable fallback.
310    pub fn owned_items(&self, source: FlattenedSource) -> Vec<(Vec<usize>, FlattenedItem<'_>)> {
311        let children = match source {
312            FlattenedSource::Slide => &self.slide.common_slide_data.shape_tree.children,
313            FlattenedSource::Layout => &self.layout.common_slide_data.shape_tree.children,
314            FlattenedSource::Master => &self.master.common_slide_data.shape_tree.children,
315            FlattenedSource::Background => return Vec::new(),
316        };
317        let mut output = Vec::new();
318        collect_owned_items(children, source, Transform::IDENTITY, 0, &[], &mut output);
319        output
320    }
321
322    /// Resolves one owned renderer-facing slide at the supplied point size.
323    pub fn resolve_slide(&self, size: (f64, f64)) -> Result<ResolvedSlide, ResolveError> {
324        self.resolve_slide_inner(size, None, None, None, None)
325            .map(|(slide, _)| slide)
326    }
327
328    /// Resolves one slide through the additive slide-local timeline path.
329    pub fn resolve_slide_at(
330        &self,
331        size: (f64, f64),
332        position: TimelinePosition,
333    ) -> Result<ResolvedTimelineSlide, ResolveError> {
334        let (slide, _, identities) =
335            self.resolve_slide_inner_with_identities(size, None, None, None, None)?;
336        let state = evaluate_timeline(
337            self.slide.timing.as_ref(),
338            self.slide.transition.as_ref(),
339            position,
340        )?;
341        Ok(apply_timeline_state(
342            slide,
343            identities,
344            state,
345            timeline_group_geometries(&self.slide.common_slide_data.shape_tree.children),
346        ))
347    }
348
349    /// Resolves one slide using embedded media identifiers scoped to source parts.
350    pub fn resolve_slide_with_media(
351        &self,
352        size: (f64, f64),
353        media: &ScopedMediaIds,
354    ) -> Result<ResolvedSlide, ResolveError> {
355        self.resolve_slide_inner(size, Some(media), None, None, None)
356            .map(|(slide, _)| slide)
357    }
358
359    /// Resolves one slide with source-scoped media and external hyperlink targets.
360    pub fn resolve_slide_with_resources(
361        &self,
362        size: (f64, f64),
363        media: &ScopedMediaIds,
364        hyperlinks: &ScopedHyperlinkTargets,
365    ) -> Result<ResolvedSlide, ResolveError> {
366        self.resolve_slide_inner(size, Some(media), Some(hyperlinks), None, None)
367            .map(|(slide, _)| slide)
368    }
369
370    /// Resolves one slide with source-scoped charts and the caller's font manager.
371    pub fn resolve_slide_with_chart_resources(
372        &self,
373        size: (f64, f64),
374        media: &ScopedMediaIds,
375        hyperlinks: &ScopedHyperlinkTargets,
376        charts: &ScopedChartResources,
377        fonts: &mut FontManager,
378    ) -> Result<ResolvedSlide, ResolveError> {
379        self.resolve_slide_inner(
380            size,
381            Some(media),
382            Some(hyperlinks),
383            Some(charts),
384            Some(fonts),
385        )
386        .map(|(slide, _)| slide)
387    }
388
389    /// Resolves one slide and returns paragraph directions in shape and text-body order.
390    ///
391    /// The outer vector follows `ResolvedSlide::shapes`. Each shape entry contains
392    /// one paragraph-direction vector for a text shape, or one entry per table cell
393    /// in row-major order. Non-text shapes have an empty entry.
394    pub fn resolve_slide_with_chart_resources_and_text_directions(
395        &self,
396        size: (f64, f64),
397        media: &ScopedMediaIds,
398        hyperlinks: &ScopedHyperlinkTargets,
399        charts: &ScopedChartResources,
400        fonts: &mut FontManager,
401    ) -> Result<(ResolvedSlide, ResolvedSlideTextDirections), ResolveError> {
402        self.resolve_slide_inner(
403            size,
404            Some(media),
405            Some(hyperlinks),
406            Some(charts),
407            Some(fonts),
408        )
409    }
410
411    /// Resolve a physically owned text shape even when draw flattening hides
412    /// its placeholder. Explicit autofit inventory and its read-only report
413    /// need the same producing scope and inheritance as visible rendering.
414    pub fn resolve_owned_text_shape(
415        &self,
416        item: FlattenedItem<'_>,
417        size: (f64, f64),
418        hyperlinks: &ScopedHyperlinkTargets,
419    ) -> Result<(ResolvedSlide, ResolvedSlideTextDirections), ResolveError> {
420        let FlattenedItem::Shape {
421            source,
422            child,
423            group_scale,
424            group_transform,
425            group_issues,
426        } = item
427        else {
428            return Err(ResolveError::ConcreteValue {
429                kind: "autofit owner",
430                detail: "not a physically owned shape".to_owned(),
431            });
432        };
433        let mut resolved = ResolvedSlide {
434            unresolved_member_count: 0,
435            size,
436            background: None,
437            shapes: Vec::new(),
438            diagnostics: Vec::new(),
439        };
440        push_group_diagnostics(group_issues, &mut resolved.diagnostics);
441        let mut directions = Vec::new();
442        let shape = self.resolve_flattened_shape(
443            ShapePlacement {
444                source,
445                group_scale,
446                group_transform,
447            },
448            child,
449            None,
450            (Some(hyperlinks), None),
451            None,
452            (&mut resolved.diagnostics, &mut directions),
453        )?;
454        if let Some(shape) = shape {
455            resolved.shapes.push(shape);
456        }
457        Ok((resolved, vec![directions]))
458    }
459
460    /// Resolves one timestamped slide while retaining text directions and target identity.
461    pub fn resolve_slide_with_chart_resources_and_text_directions_at(
462        &self,
463        size: (f64, f64),
464        media: &ScopedMediaIds,
465        hyperlinks: &ScopedHyperlinkTargets,
466        charts: &ScopedChartResources,
467        fonts: &mut FontManager,
468        position: TimelinePosition,
469    ) -> Result<(ResolvedTimelineSlide, ResolvedSlideTextDirections), ResolveError> {
470        let (slide, directions, identities) = self.resolve_slide_inner_with_identities(
471            size,
472            Some(media),
473            Some(hyperlinks),
474            Some(charts),
475            Some(fonts),
476        )?;
477        let state = evaluate_timeline(
478            self.slide.timing.as_ref(),
479            self.slide.transition.as_ref(),
480            position,
481        )?;
482        Ok((
483            apply_timeline_state(
484                slide,
485                identities,
486                state,
487                timeline_group_geometries(&self.slide.common_slide_data.shape_tree.children),
488            ),
489            directions,
490        ))
491    }
492
493    fn resolve_slide_inner(
494        &self,
495        size: (f64, f64),
496        media: Option<&ScopedMediaIds>,
497        hyperlinks: Option<&ScopedHyperlinkTargets>,
498        charts: Option<&ScopedChartResources>,
499        fonts: Option<&mut FontManager>,
500    ) -> Result<(ResolvedSlide, ResolvedSlideTextDirections), ResolveError> {
501        self.resolve_slide_inner_common(size, media, hyperlinks, charts, fonts, None)
502    }
503
504    fn resolve_slide_inner_with_identities(
505        &self,
506        size: (f64, f64),
507        media: Option<&ScopedMediaIds>,
508        hyperlinks: Option<&ScopedHyperlinkTargets>,
509        charts: Option<&ScopedChartResources>,
510        fonts: Option<&mut FontManager>,
511    ) -> Result<
512        (
513            ResolvedSlide,
514            ResolvedSlideTextDirections,
515            Vec<ResolvedShapeIdentity>,
516        ),
517        ResolveError,
518    > {
519        let mut identities = Vec::new();
520        let (slide, directions) = self.resolve_slide_inner_common(
521            size,
522            media,
523            hyperlinks,
524            charts,
525            fonts,
526            Some(&mut identities),
527        )?;
528        Ok((slide, directions, identities))
529    }
530
531    fn resolve_slide_inner_common(
532        &self,
533        size: (f64, f64),
534        media: Option<&ScopedMediaIds>,
535        hyperlinks: Option<&ScopedHyperlinkTargets>,
536        charts: Option<&ScopedChartResources>,
537        mut fonts: Option<&mut FontManager>,
538        mut identities: Option<&mut Vec<ResolvedShapeIdentity>>,
539    ) -> Result<(ResolvedSlide, ResolvedSlideTextDirections), ResolveError> {
540        for (kind, tree) in [
541            ("slide shape tree", &self.slide.common_slide_data.shape_tree),
542            (
543                "layout shape tree",
544                &self.layout.common_slide_data.shape_tree,
545            ),
546            (
547                "master shape tree",
548                &self.master.common_slide_data.shape_tree,
549            ),
550        ] {
551            rpptx_oxml::shape_tree::validate_shape_nesting(&tree.children).map_err(|error| {
552                ResolveError::ConcreteValue {
553                    kind,
554                    detail: error.to_string(),
555                }
556            })?;
557        }
558        let mut slide = ResolvedSlide {
559            unresolved_member_count: 0,
560            size,
561            background: None,
562            shapes: Vec::new(),
563            diagnostics: Vec::new(),
564        };
565        let mut text_directions = Vec::new();
566        for item in self.flatten() {
567            match item {
568                FlattenedItem::Background(background) => {
569                    let source = match background.source {
570                        BackgroundSource::Slide => FlattenedSource::Slide,
571                        BackgroundSource::Layout => FlattenedSource::Layout,
572                        BackgroundSource::Master => FlattenedSource::Master,
573                    };
574                    let (resolved_background, unsupported) = match background.content {
575                        BackgroundContent::Model(BackgroundRendering::Properties(fill)) => {
576                            match fill {
577                                Some(fill) => self.concrete_background_fill(
578                                    fill,
579                                    size,
580                                    source,
581                                    media,
582                                    &mut slide.diagnostics,
583                                )?,
584                                None => (None, None),
585                            }
586                        }
587                        BackgroundContent::Model(BackgroundRendering::Reference {
588                            index,
589                            color,
590                        }) => self.concrete_background_reference(
591                            *index,
592                            color.as_ref(),
593                            size,
594                            &mut slide.diagnostics,
595                        )?,
596                        BackgroundContent::Model(BackgroundRendering::Unsupported(detail)) => {
597                            (None, Some(*detail))
598                        }
599                    };
600                    slide.background = resolved_background;
601                    if let Some(unsupported) = unsupported {
602                        slide.diagnostics.push(Diagnostic {
603                            message: format!("unsupported background {unsupported}"),
604                        });
605                    }
606                }
607                FlattenedItem::Shape {
608                    source,
609                    child,
610                    group_scale,
611                    group_transform,
612                    group_issues,
613                } => {
614                    push_group_diagnostics(group_issues, &mut slide.diagnostics);
615                    let mut shape_text_directions = Vec::new();
616                    let mut member_diagnostics = Vec::new();
617                    let result = self
618                        .resolve_flattened_shape(
619                            ShapePlacement {
620                                source,
621                                group_scale,
622                                group_transform,
623                            },
624                            child,
625                            media,
626                            (hyperlinks, charts),
627                            fonts.as_deref_mut(),
628                            (&mut member_diagnostics, &mut shape_text_directions),
629                        )
630                        .and_then(|resolved| {
631                            if resolved.is_none() {
632                                self.validate_unplaced_member(child, source, hyperlinks)?;
633                            }
634                            Ok(resolved)
635                        });
636                    let resolved = match result {
637                        Ok(resolved) => {
638                            slide.diagnostics.extend(member_diagnostics);
639                            resolved
640                        }
641                        Err(
642                            error @ (ResolveError::StyleIndexOutOfRange { .. }
643                            | ResolveError::UnresolvedPlaceholderColor { .. }
644                            | ResolveError::ConcreteValue { .. }),
645                        ) => {
646                            // Count the failed owner before deciding whether it can draw.
647                            slide.unresolved_member_count += 1;
648                            shape_text_directions.clear();
649                            self.unresolved_shape_fallback(
650                                ShapePlacement {
651                                    source,
652                                    group_scale,
653                                    group_transform,
654                                },
655                                child,
656                                &error,
657                                &mut slide.diagnostics,
658                            )
659                        }
660                    };
661                    if let Some(shape) = resolved {
662                        if let Some(identities) = identities.as_deref_mut() {
663                            identities.push(self.shape_identity(source, child));
664                        }
665                        slide.shapes.push(shape);
666                        text_directions.push(shape_text_directions);
667                    }
668                }
669            }
670        }
671        Ok((slide, text_directions))
672    }
673
674    // Missing placement must not hide an error in the owned semantic inputs.
675    // Validate only dimension-independent values already consumed by this resolver.
676    // No replacement geometry or guessed dimensions are manufactured.
677    fn validate_unplaced_member(
678        &self,
679        child: &ShapeTreeChild,
680        source: FlattenedSource,
681        hyperlinks: Option<&ScopedHyperlinkTargets>,
682    ) -> Result<(), ResolveError> {
683        let (fill, line, effects) = match child {
684            ShapeTreeChild::Shape(shape) => {
685                let (mut effective, _) = self.shape_style_for_placement(shape, true)?;
686                effective.effects =
687                    self.effective_effects(shape.style(), &shape.shape_properties)?;
688                if let Some(body) = shape.text_body.as_ref() {
689                    self.resolve_text_body(shape, body, source, hyperlinks, &mut Vec::new())?;
690                }
691                (effective.fill, effective.line, effective.effects)
692            }
693            ShapeTreeChild::Picture(picture) => (
694                None,
695                picture.shape_properties.line.clone(),
696                picture.shape_properties.effects.clone(),
697            ),
698            ShapeTreeChild::Connector(connector) => (
699                connector.shape_properties.fill.clone(),
700                self.effective_connector_line(connector, true)?,
701                self.effective_effects(connector.style(), &connector.shape_properties)?,
702            ),
703            ShapeTreeChild::GraphicFrame(frame) => {
704                if let GraphicDataPayload::Table(table) = frame.graphic_data.payload() {
705                    self.resolve_table(
706                        table,
707                        source,
708                        hyperlinks,
709                        &mut Vec::new(),
710                        &mut Vec::new(),
711                    )?;
712                }
713                return Ok(());
714            }
715            ShapeTreeChild::AlternateContent(_) | ShapeTreeChild::GroupShape(_) => return Ok(()),
716        };
717        for fill in [
718            fill.as_ref(),
719            line.as_ref().and_then(|line| line.fill.as_ref()),
720        ]
721        .into_iter()
722        .flatten()
723        {
724            match fill {
725                Fill::Solid(solid) => {
726                    if let Some(color) = solid.color.as_ref() {
727                        self.concrete_color(color)?;
728                    }
729                }
730                Fill::Gradient(gradient)
731                    if gradient.rotate_with_shape != Some(false)
732                        && matches!(gradient.flip.as_deref(), None | Some("none"))
733                        && gradient.tile_rect.is_none()
734                        && !matches!(
735                            gradient.geometry.as_ref(),
736                            Some(GradientGeometry::Path(_))
737                        ) =>
738                {
739                    // These stop colors precede dimension-dependent paint construction.
740                    // Keep concrete_fill's early unsupported boundary for a missing color.
741                    for stop in &gradient.stops {
742                        let Some(color) = stop.color.as_ref() else {
743                            break;
744                        };
745                        self.concrete_color(color)?;
746                    }
747                }
748                _ => {}
749            }
750        }
751        if let Some(shadow) = effects
752            .as_ref()
753            .and_then(|effects| effects.outer_shadow.as_ref())
754        {
755            self.concrete_shadow(shadow)?;
756        }
757        Ok(())
758    }
759
760    fn unresolved_shape_fallback(
761        &self,
762        placement: ShapePlacement,
763        child: &ShapeTreeChild,
764        error: &ResolveError,
765        diagnostics: &mut Vec<Diagnostic>,
766    ) -> Option<ResolvedShape> {
767        let id = child
768            .non_visual_id()
769            .map_or_else(|| "without an id".to_owned(), |id| id.to_string());
770        let name = child.non_visual_name().unwrap_or_default();
771        let source = flattened_source_name(placement.source);
772        let values = match child {
773            ShapeTreeChild::Shape(shape) => transform_values(self.effective_xfrm(shape).as_ref()),
774            ShapeTreeChild::Picture(picture) => {
775                transform_values(self.effective_picture_xfrm(picture).as_ref())
776            }
777            ShapeTreeChild::GraphicFrame(frame) => transform_values(Some(&frame.transform)),
778            ShapeTreeChild::Connector(connector) => {
779                connector_transform_values(connector.shape_properties.transform.as_ref())
780            }
781            ShapeTreeChild::AlternateContent(alternate) => alternate
782                .chart_choice()
783                .and_then(|frame| transform_values(Some(&frame.transform))),
784            ShapeTreeChild::GroupShape(_) => None,
785        };
786        let values = values.filter(|(bounds, rotation, flip_h, flip_v)| {
787            let bounds = scaled_group_bounds(*bounds, placement.group_scale);
788            let matrix = resolved_shape_page_transform(&ResolvedShape {
789                group_transform: placement.group_transform,
790                bounds,
791                rotation_deg: *rotation,
792                flip_h: *flip_h,
793                flip_v: *flip_v,
794                geometry: ResolvedGeometry::BoundsFallback,
795                fill: None,
796                image_fill: None,
797                line: None,
798                head_end: None,
799                tail_end: None,
800                shadow: None,
801                content: ResolvedContent::None,
802                unsupported: Some("unresolved shape"),
803            });
804            [
805                bounds.x,
806                bounds.y,
807                bounds.width,
808                bounds.height,
809                *rotation,
810                matrix.a,
811                matrix.b,
812                matrix.c,
813                matrix.d,
814                matrix.e,
815                matrix.f,
816            ]
817            .into_iter()
818            .all(f64::is_finite)
819                && bounds.width >= 0.0
820                && bounds.height >= 0.0
821                && (bounds.width > 0.0 || bounds.height > 0.0)
822                && [
823                    Point { x: 0.0, y: 0.0 },
824                    Point {
825                        x: bounds.width,
826                        y: 0.0,
827                    },
828                    Point {
829                        x: 0.0,
830                        y: bounds.height,
831                    },
832                    Point {
833                        x: bounds.width,
834                        y: bounds.height,
835                    },
836                ]
837                .into_iter()
838                .all(|point| {
839                    let point = matrix.apply(point);
840                    point.x.is_finite() && point.y.is_finite()
841                })
842        });
843        let retained = if values.is_some() {
844            "retained as bounds"
845        } else {
846            "omitted without trustworthy finite bounds"
847        };
848        diagnostics.push(Diagnostic {
849            message: format!(
850                "unresolved member {source} shape {id} \"{name}\" {retained}: {error}"
851            ),
852        });
853        let (bounds, rotation_deg, flip_h, flip_v) = values?;
854        Some(ResolvedShape {
855            group_transform: placement.group_transform,
856            bounds: scaled_group_bounds(bounds, placement.group_scale),
857            rotation_deg,
858            flip_h,
859            flip_v,
860            geometry: ResolvedGeometry::BoundsFallback,
861            fill: None,
862            image_fill: None,
863            line: None,
864            head_end: None,
865            tail_end: None,
866            shadow: None,
867            content: ResolvedContent::None,
868            unsupported: Some("unresolved shape"),
869        })
870    }
871
872    fn shape_identity(
873        &self,
874        source: FlattenedSource,
875        child: &ShapeTreeChild,
876    ) -> ResolvedShapeIdentity {
877        let children: &[ShapeTreeChild] = match source {
878            FlattenedSource::Slide => &self.slide.common_slide_data.shape_tree.children,
879            FlattenedSource::Layout => &self.layout.common_slide_data.shape_tree.children,
880            FlattenedSource::Master => &self.master.common_slide_data.shape_tree.children,
881            FlattenedSource::Background => &[],
882        };
883        let mut containing_group_ids = Vec::new();
884        find_group_lineage(children, child, &[], &mut containing_group_ids);
885        ResolvedShapeIdentity {
886            source,
887            shape_id: child.non_visual_id(),
888            containing_group_ids,
889            name: child.non_visual_name(),
890        }
891    }
892
893    fn resolve_flattened_shape(
894        &self,
895        placement: ShapePlacement,
896        child: &ShapeTreeChild,
897        media: Option<&ScopedMediaIds>,
898        scoped_resources: (
899            Option<&ScopedHyperlinkTargets>,
900            Option<&ScopedChartResources>,
901        ),
902        fonts: Option<&mut FontManager>,
903        output: (
904            &mut Vec<Diagnostic>,
905            &mut Vec<Vec<oxml_layout::TextDirection>>,
906        ),
907    ) -> Result<Option<ResolvedShape>, ResolveError> {
908        let (diagnostics, text_directions) = output;
909        let (hyperlinks, charts) = scoped_resources;
910        let ShapePlacement {
911            source,
912            group_scale,
913            group_transform,
914        } = placement;
915        match child {
916            ShapeTreeChild::Shape(shape) => self.resolve_ordinary_shape(
917                shape,
918                placement,
919                media,
920                hyperlinks,
921                diagnostics,
922                text_directions,
923            ),
924            ShapeTreeChild::Picture(picture) => {
925                if picture.hidden() {
926                    return Ok(None);
927                }
928                let Some((bounds, rotation_deg, flip_h, flip_v)) =
929                    transform_values(self.effective_picture_xfrm(picture).as_ref())
930                else {
931                    return Ok(None);
932                };
933                let bounds = scaled_group_bounds(bounds, group_scale);
934                let line_properties = picture.shape_properties.line.as_ref();
935                let line = line_properties
936                    .map(|line| self.concrete_line(line, (bounds.width, bounds.height)))
937                    .transpose()?
938                    .flatten();
939                let (head_end, tail_end) = line_properties
940                    .map(resolved_line_ends)
941                    .unwrap_or((None, None));
942                let shadow = picture
943                    .shape_properties
944                    .effects
945                    .as_ref()
946                    .and_then(|effects| effects.outer_shadow.as_ref())
947                    .map(|shadow| self.concrete_shadow(shadow))
948                    .transpose()?;
949                let (mut geometry, geometry_unsupported) =
950                    self.concrete_picture_geometry(picture, (bounds.width, bounds.height));
951                if let Some(category) = geometry_unsupported {
952                    diagnostics.push(Diagnostic {
953                        message: format!("unsupported {category} retained as picture bounds"),
954                    });
955                }
956                let diagnostic_start = diagnostics.len();
957                let (content, media_unsupported) = resolve_picture_content(
958                    picture,
959                    source,
960                    media,
961                    self.media_failures,
962                    diagnostics,
963                );
964                if media_unsupported.is_some() {
965                    geometry = ResolvedGeometry::BoundsFallback;
966                }
967                if self.media_poster_diagnostic_identity
968                    && media_unsupported.is_some()
969                    && picture.media.is_some()
970                    && let Some(shape_id) = child.non_visual_id()
971                    && let Some(diagnostic) = diagnostics.get_mut(diagnostic_start)
972                {
973                    diagnostic.message = format!(
974                        "unresolved {} media poster for shape {shape_id}: {}",
975                        flattened_source_name(source),
976                        diagnostic.message
977                    );
978                }
979                Ok(Some(ResolvedShape {
980                    group_transform,
981                    bounds,
982                    rotation_deg,
983                    flip_h,
984                    flip_v,
985                    geometry,
986                    fill: None,
987                    image_fill: None,
988                    line,
989                    head_end,
990                    tail_end,
991                    shadow,
992                    content,
993                    unsupported: media_unsupported.or(geometry_unsupported),
994                }))
995            }
996            ShapeTreeChild::GraphicFrame(frame) => {
997                let Some((bounds, rotation_deg, flip_h, flip_v)) =
998                    transform_values(Some(&frame.transform))
999                else {
1000                    return Ok(None);
1001                };
1002                let bounds = scaled_group_bounds(bounds, group_scale);
1003                let (content, unsupported, bounds_fallback) = match frame.graphic_data.payload() {
1004                    GraphicDataPayload::Table(table) => (
1005                        ResolvedContent::Table(self.resolve_table(
1006                            table,
1007                            source,
1008                            hyperlinks,
1009                            diagnostics,
1010                            text_directions,
1011                        )?),
1012                        None,
1013                        false,
1014                    ),
1015                    GraphicDataPayload::Chart(_) => self.resolve_chart_content(
1016                        frame,
1017                        None,
1018                        source,
1019                        bounds,
1020                        media,
1021                        charts,
1022                        fonts,
1023                        diagnostics,
1024                    )?,
1025                    GraphicDataPayload::SmartArt(_) => {
1026                        (ResolvedContent::None, Some("SmartArt"), true)
1027                    }
1028                    GraphicDataPayload::Ole { preview, .. } => {
1029                        if let Some(image) = resolve_ole_preview(
1030                            preview.as_deref(),
1031                            source,
1032                            media,
1033                            self.media_failures,
1034                        ) {
1035                            diagnostics.push(Diagnostic {
1036                                message: "OLE object rendered as a static PNG preview. Embedded OLE interactivity is not rendered".to_owned(),
1037                            });
1038                            (
1039                                ResolvedContent::Image(image),
1040                                Some("embedded OLE interactivity"),
1041                                false,
1042                            )
1043                        } else {
1044                            (ResolvedContent::None, Some("OLE"), true)
1045                        }
1046                    }
1047                    GraphicDataPayload::Other(_) => {
1048                        (ResolvedContent::None, Some("unknown graphic frame"), true)
1049                    }
1050                };
1051                if bounds_fallback {
1052                    let category = unsupported.expect("bounds fallback has a category");
1053                    diagnostics.push(Diagnostic {
1054                        message: format!("unsupported {category} content retained as bounds"),
1055                    });
1056                }
1057                Ok(Some(ResolvedShape {
1058                    group_transform,
1059                    bounds,
1060                    rotation_deg,
1061                    flip_h,
1062                    flip_v,
1063                    geometry: if bounds_fallback {
1064                        ResolvedGeometry::BoundsFallback
1065                    } else {
1066                        ResolvedGeometry::Rectangle
1067                    },
1068                    fill: None,
1069                    image_fill: None,
1070                    line: None,
1071                    head_end: None,
1072                    tail_end: None,
1073                    shadow: None,
1074                    content,
1075                    unsupported,
1076                }))
1077            }
1078            ShapeTreeChild::Connector(connector) => {
1079                let Some((bounds, rotation_deg, flip_h, flip_v)) =
1080                    connector_transform_values(connector.shape_properties.transform.as_ref())
1081                else {
1082                    return Ok(None);
1083                };
1084                let bounds = scaled_group_bounds(bounds, group_scale);
1085                let (fill, fill_unsupported) = connector
1086                    .shape_properties
1087                    .fill
1088                    .as_ref()
1089                    .map(|fill| self.concrete_fill(fill, (bounds.width, bounds.height)))
1090                    .transpose()?
1091                    .unwrap_or((None, None));
1092                let has_line_source =
1093                    connector.shape_properties.line.is_some() || connector.style().is_some();
1094                let effective_line = self.effective_connector_line(connector, false)?;
1095                let mut line = effective_line
1096                    .as_ref()
1097                    .map(|line| self.concrete_line(line, (bounds.width, bounds.height)))
1098                    .transpose()?
1099                    .flatten();
1100                let (head_end, tail_end) = effective_line
1101                    .as_ref()
1102                    .map(resolved_line_ends)
1103                    .unwrap_or((None, None));
1104                let shadow = self
1105                    .effective_effects(connector.style(), &connector.shape_properties)
1106                    .and_then(|effects| {
1107                        effects
1108                            .as_ref()
1109                            .and_then(|effects| effects.outer_shadow.as_ref())
1110                            .map(|shadow| self.concrete_shadow(shadow))
1111                            .transpose()
1112                    })?;
1113                let (geometry, geometry_unsupported, geometry_diagnostic) = if let Some(custom) =
1114                    &connector.shape_properties.custom_geometry
1115                {
1116                    match self.concrete_custom_geometry(custom, (bounds.width, bounds.height)) {
1117                        Ok(geometry) => (geometry, None, None),
1118                        Err(error) => (
1119                            ResolvedGeometry::BoundsFallback,
1120                            Some("connector custom geometry evaluation"),
1121                            Some(format!(
1122                                "connector custom geometry evaluation failed: {error}; retained as shape bounds"
1123                            )),
1124                        ),
1125                    }
1126                } else if let Some(preset) = &connector.shape_properties.preset_geometry {
1127                    {
1128                        match self.concrete_preset_geometry(preset, (bounds.width, bounds.height)) {
1129                            Ok(Some(geometry)) => (geometry, None, None),
1130                            Ok(None) => (
1131                                ResolvedGeometry::BoundsFallback,
1132                                Some("unknown connector preset geometry"),
1133                                Some(format!(
1134                                    "unknown connector preset geometry `{}` retained as shape bounds",
1135                                    preset.preset
1136                                )),
1137                            ),
1138                            Err(error) => (
1139                                ResolvedGeometry::BoundsFallback,
1140                                Some("connector preset geometry evaluation"),
1141                                Some(format!(
1142                                    "connector preset geometry `{}` evaluation failed: {error}; retained as shape bounds",
1143                                    preset.preset
1144                                )),
1145                            ),
1146                        }
1147                    }
1148                } else {
1149                    (
1150                        ResolvedGeometry::BoundsFallback,
1151                        Some("connector geometry"),
1152                        Some("unsupported connector geometry retained as shape bounds".to_owned()),
1153                    )
1154                };
1155                if let Some(category) = fill_unsupported {
1156                    diagnostics.push(Diagnostic {
1157                        message: format!(
1158                            "unsupported connector {category} retained as shape bounds"
1159                        ),
1160                    });
1161                }
1162                if let Some(message) = geometry_diagnostic {
1163                    diagnostics.push(Diagnostic { message });
1164                }
1165                let line_unsupported = (!has_line_source).then_some("connector line style");
1166                if line_unsupported.is_some() {
1167                    line = Some(Stroke::new(Paint::Solid(Color::BLACK), 1.0));
1168                    diagnostics.push(Diagnostic {
1169                        message: "unsupported connector line style retained as visible default"
1170                            .to_owned(),
1171                    });
1172                }
1173                let unsupported = fill_unsupported
1174                    .or(geometry_unsupported)
1175                    .or(line_unsupported);
1176                Ok(Some(ResolvedShape {
1177                    group_transform,
1178                    bounds,
1179                    rotation_deg,
1180                    flip_h,
1181                    flip_v,
1182                    geometry,
1183                    fill,
1184                    image_fill: None,
1185                    line,
1186                    head_end,
1187                    tail_end,
1188                    shadow,
1189                    content: ResolvedContent::None,
1190                    unsupported,
1191                }))
1192            }
1193            ShapeTreeChild::AlternateContent(alternate) => {
1194                let Some(frame) = alternate.chart_choice() else {
1195                    return Ok(None);
1196                };
1197                let Some((bounds, rotation_deg, flip_h, flip_v)) =
1198                    transform_values(Some(&frame.transform))
1199                else {
1200                    return Ok(None);
1201                };
1202                let bounds = scaled_group_bounds(bounds, group_scale);
1203                let (content, unsupported, bounds_fallback) = if charts.is_some() && fonts.is_some()
1204                {
1205                    self.resolve_chart_content(
1206                        frame,
1207                        alternate.picture_fallback(),
1208                        source,
1209                        bounds,
1210                        media,
1211                        charts,
1212                        fonts,
1213                        diagnostics,
1214                    )?
1215                } else if let Some(picture) = alternate.picture_fallback() {
1216                    let (content, _) = resolve_picture_content(
1217                        picture,
1218                        source,
1219                        media,
1220                        self.media_failures,
1221                        diagnostics,
1222                    );
1223                    let has_image = matches!(content, ResolvedContent::Image(_));
1224                    (content, Some("chart"), !has_image)
1225                } else {
1226                    (ResolvedContent::None, Some("chart"), true)
1227                };
1228                if bounds_fallback {
1229                    diagnostics.push(Diagnostic {
1230                        message: "unsupported chart content retained as bounds".to_owned(),
1231                    });
1232                }
1233                Ok(Some(ResolvedShape {
1234                    group_transform,
1235                    bounds,
1236                    rotation_deg,
1237                    flip_h,
1238                    flip_v,
1239                    geometry: if bounds_fallback {
1240                        ResolvedGeometry::BoundsFallback
1241                    } else {
1242                        ResolvedGeometry::Rectangle
1243                    },
1244                    fill: None,
1245                    image_fill: None,
1246                    line: None,
1247                    head_end: None,
1248                    tail_end: None,
1249                    shadow: None,
1250                    content,
1251                    unsupported,
1252                }))
1253            }
1254            ShapeTreeChild::GroupShape(_) => Ok(None),
1255        }
1256    }
1257
1258    #[allow(clippy::too_many_arguments)]
1259    fn resolve_chart_content(
1260        &self,
1261        frame: &rpptx_oxml::graphic_frame::CT_GraphicFrame,
1262        fallback: Option<&CT_Picture>,
1263        source: FlattenedSource,
1264        bounds: Rect,
1265        media: Option<&ScopedMediaIds>,
1266        charts: Option<&ScopedChartResources>,
1267        fonts: Option<&mut FontManager>,
1268        diagnostics: &mut Vec<Diagnostic>,
1269    ) -> Result<(ResolvedContent, Option<&'static str>, bool), ResolveError> {
1270        let Some(charts) = charts else {
1271            return Ok((ResolvedContent::None, Some("chart"), true));
1272        };
1273        let Some(fonts) = fonts else {
1274            return Ok((ResolvedContent::None, Some("chart"), true));
1275        };
1276        let relationship_id = frame.chart_relationship_id();
1277        let resource = relationship_id.and_then(|id| charts.get(source, id));
1278        let failure = match (relationship_id, resource) {
1279            (None, _) => "chart payload has no relationship identifier".to_owned(),
1280            (Some(id), None) => format!(
1281                "missing {} chart relationship `{id}`",
1282                flattened_source_name(source)
1283            ),
1284            (Some(_), Some(ChartResource::External(target))) => format!(
1285                "external {} chart relationship `{}` targets `{target}`",
1286                flattened_source_name(source),
1287                relationship_id.expect("matched a relationship id")
1288            ),
1289            (Some(_), Some(ChartResource::MissingTarget(target))) => format!(
1290                "missing {} chart target `{target}` for relationship `{}`",
1291                flattened_source_name(source),
1292                relationship_id.expect("matched a relationship id")
1293            ),
1294            (Some(_), Some(ChartResource::Invalid(detail))) => format!(
1295                "invalid {} chart relationship `{}`: {detail}",
1296                flattened_source_name(source),
1297                relationship_id.expect("matched a relationship id")
1298            ),
1299            (Some(_), Some(ChartResource::Parsed(chart_space))) => {
1300                let local_bounds = Rect {
1301                    x: 0.0,
1302                    y: 0.0,
1303                    width: bounds.width,
1304                    height: bounds.height,
1305                };
1306                match render_chart(
1307                    &chart_space.chart,
1308                    local_bounds,
1309                    self.theme,
1310                    &self.color_map,
1311                    fonts,
1312                ) {
1313                    Ok(group) => {
1314                        return Ok((ResolvedContent::Group(group), None, false));
1315                    }
1316                    Err(error) => error.to_string(),
1317                }
1318            }
1319        };
1320
1321        if let Some(picture) = fallback {
1322            let (content, _) =
1323                resolve_picture_content(picture, source, media, self.media_failures, diagnostics);
1324            let relationship_id = picture
1325                .blip_fill
1326                .as_ref()
1327                .and_then(|fill| fill.blip.as_ref())
1328                .and_then(|blip| blip.embed.as_deref());
1329            let renderer_compatible = relationship_id
1330                .and_then(|id| media.and_then(|media| media.content_type(source, id)))
1331                .is_some_and(renderer_compatible_image_content_type);
1332            if matches!(content, ResolvedContent::Image(_)) && renderer_compatible {
1333                diagnostics.push(Diagnostic {
1334                    message: format!("unsupported chart rendered as cached image: {failure}"),
1335                });
1336                return Ok((content, Some("chart"), false));
1337            }
1338            if matches!(content, ResolvedContent::Image(_)) {
1339                diagnostics.push(Diagnostic {
1340                    message: format!(
1341                        "unsupported chart cached image is not renderer-compatible: {failure}"
1342                    ),
1343                });
1344            }
1345        }
1346
1347        let placeholder = render_chart_placeholder(
1348            Rect {
1349                x: 0.0,
1350                y: 0.0,
1351                width: bounds.width,
1352                height: bounds.height,
1353            },
1354            fonts,
1355        )
1356        .map_err(|error| ResolveError::ConcreteValue {
1357            kind: "chart placeholder",
1358            detail: error.to_string(),
1359        })?;
1360        diagnostics.push(Diagnostic {
1361            message: format!("unsupported chart rendered as labelled placeholder: {failure}"),
1362        });
1363        Ok((ResolvedContent::Group(placeholder), Some("chart"), true))
1364    }
1365
1366    fn resolve_ordinary_shape(
1367        &self,
1368        shape: &CT_Shape,
1369        placement: ShapePlacement,
1370        media: Option<&ScopedMediaIds>,
1371        hyperlinks: Option<&ScopedHyperlinkTargets>,
1372        diagnostics: &mut Vec<Diagnostic>,
1373        text_directions: &mut Vec<Vec<oxml_layout::TextDirection>>,
1374    ) -> Result<Option<ResolvedShape>, ResolveError> {
1375        let ShapePlacement {
1376            source,
1377            group_scale,
1378            group_transform,
1379        } = placement;
1380        let transform = self.effective_xfrm(shape);
1381        let Some((unscaled_bounds, rotation_deg, flip_h, flip_v)) =
1382            transform_values(transform.as_ref())
1383        else {
1384            return Ok(None);
1385        };
1386        let mut bounds = scaled_group_bounds(unscaled_bounds, group_scale);
1387        let mut group_transform = group_transform;
1388        let effective = self.effective_shape_style(shape)?;
1389        let (fill, image_fill, fill_unsupported, fill_diagnostic) = match effective.fill.as_ref() {
1390            Some(Fill::Blip(fill))
1391                if matches!(shape.shape_properties.fill, Some(Fill::Blip(_))) =>
1392            {
1393                match resolve_image_fill(fill, source, media, self.media_failures, "shape") {
1394                    Ok(image) => (None, Some(image), None, None),
1395                    Err((category, message)) => (None, None, Some(category), Some(message)),
1396                }
1397            }
1398            Some(Fill::Blip(_)) => (
1399                None,
1400                None,
1401                Some("theme-referenced shape picture fill"),
1402                Some(
1403                    "theme-referenced shape picture fill requires theme relationship scope"
1404                        .to_owned(),
1405                ),
1406            ),
1407            Some(fill) => {
1408                let normalized = match fill {
1409                    Fill::Gradient(gradient)
1410                        if gradient.rotate_with_shape == Some(false)
1411                            && rotation_deg.rem_euclid(360.0) == 0.0
1412                            && !flip_h
1413                            && !flip_v
1414                            && group_transform.a > 0.0
1415                            && group_transform.b.abs() < 1.0e-10
1416                            && group_transform.c.abs() < 1.0e-10
1417                            && group_transform.d > 0.0 =>
1418                    {
1419                        let mut normalized = fill.clone();
1420                        let Fill::Gradient(gradient) = &mut normalized else {
1421                            unreachable!("matched a gradient fill")
1422                        };
1423                        gradient.rotate_with_shape = None;
1424                        Some(normalized)
1425                    }
1426                    _ => None,
1427                };
1428                let selected = normalized.as_ref().unwrap_or(fill);
1429                let shape_path = matches!(selected, Fill::Gradient(g) if matches!(&g.geometry, Some(GradientGeometry::Path(p)) if p.kind == PathGradientKind::Shape));
1430                let (fill, unsupported) = if shape_path {
1431                    let preset = shape
1432                        .shape_properties
1433                        .preset_geometry
1434                        .as_ref()
1435                        .map(|p| p.preset.as_str())
1436                        .unwrap_or("rect");
1437                    if shape.shape_properties.custom_geometry.is_some()
1438                        || !matches!(preset, "rect" | "ellipse")
1439                    {
1440                        (None, Some("shape path gradient on unsupported contour"))
1441                    } else {
1442                        let mut selected = selected.clone();
1443                        if preset == "ellipse"
1444                            && let Fill::Gradient(g) = &mut selected
1445                            && let Some(GradientGeometry::Path(p)) = &mut g.geometry
1446                        {
1447                            p.kind = PathGradientKind::Circle;
1448                        }
1449                        // A circle path expands to the corner. An ellipse shape stops at its own contour.
1450                        let (mut paint, unsupported) =
1451                            self.concrete_fill(&selected, (bounds.width, bounds.height))?;
1452                        if preset == "ellipse"
1453                            && let Some(Paint::Grid { colors, .. }) = &mut paint
1454                            && let Fill::Gradient(g) = &selected
1455                        {
1456                            let mut stops = g
1457                                .stops
1458                                .iter()
1459                                .filter_map(|stop| {
1460                                    stop.color.as_ref().map(|c| {
1461                                        self.concrete_color(c).map(|color| GradientStop {
1462                                            offset: f64::from(stop.position.0) / 100_000.0,
1463                                            color,
1464                                        })
1465                                    })
1466                                })
1467                                .collect::<Result<Vec<_>, _>>()?;
1468                            stops.sort_by(|a, b| a.offset.total_cmp(&b.offset));
1469                            for (index, color) in colors.iter_mut().enumerate() {
1470                                let x = ((index % 128) as f64 + 0.5) / 64.0 - 1.0;
1471                                let y = ((index / 128) as f64 + 0.5) / 64.0 - 1.0;
1472                                *color = path_gradient_color(&stops, x.hypot(y));
1473                            }
1474                        }
1475                        (paint, unsupported)
1476                    }
1477                } else {
1478                    self.concrete_fill(selected, (bounds.width, bounds.height))?
1479                };
1480                (fill, None, unsupported, None)
1481            }
1482            None => (None, None, None, None),
1483        };
1484        let line = effective
1485            .line
1486            .as_ref()
1487            .map(|line| self.concrete_line(line, (bounds.width, bounds.height)))
1488            .transpose()?
1489            .flatten();
1490        let (head_end, tail_end) = effective
1491            .line
1492            .as_ref()
1493            .map(resolved_line_ends)
1494            .unwrap_or((None, None));
1495        let shadow = effective
1496            .effects
1497            .as_ref()
1498            .and_then(|effects| effects.outer_shadow.as_ref())
1499            .map(|shadow| self.concrete_shadow(shadow))
1500            .transpose()?;
1501        let transparent_text_only = shape.text_body.is_some()
1502            && fill.is_none()
1503            && image_fill.is_none()
1504            && fill_unsupported.is_none()
1505            && line.is_none()
1506            && shadow.is_none();
1507        if transparent_text_only {
1508            bounds = unscaled_bounds;
1509            group_transform = Transform {
1510                a: group_scale.0,
1511                d: group_scale.1,
1512                ..Transform::IDENTITY
1513            }
1514            .then(group_transform);
1515        }
1516        let (geometry, geometry_unsupported, geometry_diagnostic) = if let Some(custom) =
1517            &shape.shape_properties.custom_geometry
1518        {
1519            match self.concrete_custom_geometry(custom, (bounds.width, bounds.height)) {
1520                Ok(geometry) => (geometry, None, None),
1521                Err(error) => (
1522                    ResolvedGeometry::BoundsFallback,
1523                    Some("custom geometry evaluation"),
1524                    Some(format!(
1525                        "custom geometry evaluation failed: {error}; retained as shape bounds"
1526                    )),
1527                ),
1528            }
1529        } else if let Some(preset) = &shape.shape_properties.preset_geometry {
1530            match self.concrete_preset_geometry(preset, (bounds.width, bounds.height)) {
1531                Ok(Some(geometry)) => (geometry, None, None),
1532                Ok(None) => (
1533                    ResolvedGeometry::BoundsFallback,
1534                    Some("unknown preset geometry"),
1535                    Some(format!(
1536                        "unknown preset geometry `{}` retained as shape bounds",
1537                        preset.preset
1538                    )),
1539                ),
1540                Err(error) => (
1541                    ResolvedGeometry::BoundsFallback,
1542                    Some("preset geometry evaluation"),
1543                    Some(format!(
1544                        "preset geometry `{}` evaluation failed: {error}; retained as shape bounds",
1545                        preset.preset
1546                    )),
1547                ),
1548            }
1549        } else if shape.text_body.is_some() {
1550            (ResolvedGeometry::Rectangle, None, None)
1551        } else {
1552            (
1553                ResolvedGeometry::BoundsFallback,
1554                Some("preset geometry pending evaluation"),
1555                None,
1556            )
1557        };
1558        let content = if let Some(body) = shape.text_body.as_ref() {
1559            let (body, directions) =
1560                self.resolve_text_body(shape, body, source, hyperlinks, diagnostics)?;
1561            text_directions.push(directions);
1562            ResolvedContent::Text(body)
1563        } else {
1564            ResolvedContent::None
1565        };
1566        if let ResolvedContent::Text(text) = &content
1567            && let Some(message) = vertical_text_diagnostic(text.vertical)
1568        {
1569            diagnostics.push(Diagnostic {
1570                message: message.to_owned(),
1571            });
1572        }
1573        if let Some(category) = fill_unsupported {
1574            diagnostics.push(Diagnostic {
1575                message: fill_diagnostic
1576                    .unwrap_or_else(|| format!("unsupported {category} retained as shape bounds")),
1577            });
1578        }
1579        if let Some(category) = geometry_unsupported {
1580            diagnostics.push(Diagnostic {
1581                message: geometry_diagnostic
1582                    .unwrap_or_else(|| format!("unsupported {category} retained as shape bounds")),
1583            });
1584        }
1585        Ok(Some(ResolvedShape {
1586            group_transform,
1587            bounds,
1588            rotation_deg,
1589            flip_h,
1590            flip_v,
1591            geometry,
1592            fill,
1593            image_fill,
1594            line,
1595            head_end,
1596            tail_end,
1597            shadow,
1598            content,
1599            unsupported: fill_unsupported.or(geometry_unsupported),
1600        }))
1601    }
1602
1603    fn concrete_picture_geometry(
1604        &self,
1605        picture: &rpptx_oxml::picture::CT_Picture,
1606        size: (f64, f64),
1607    ) -> (ResolvedGeometry, Option<&'static str>) {
1608        if let Some(custom) = &picture.shape_properties.custom_geometry {
1609            return self
1610                .concrete_custom_geometry(custom, size)
1611                .map(|geometry| (geometry, None))
1612                .unwrap_or((ResolvedGeometry::Rectangle, Some("picture custom geometry")));
1613        }
1614        if let Some(preset) = &picture.shape_properties.preset_geometry {
1615            return match self.concrete_preset_geometry(preset, size) {
1616                Ok(Some(geometry)) => (geometry, None),
1617                Ok(None) => (
1618                    ResolvedGeometry::Rectangle,
1619                    Some("unknown picture preset geometry"),
1620                ),
1621                Err(_) => (
1622                    ResolvedGeometry::Rectangle,
1623                    Some("picture preset geometry evaluation"),
1624                ),
1625            };
1626        }
1627        (ResolvedGeometry::Rectangle, None)
1628    }
1629}
1630
1631fn renderer_compatible_image_content_type(content_type: &str) -> bool {
1632    content_type.eq_ignore_ascii_case("image/png")
1633        || content_type.eq_ignore_ascii_case("image/jpeg")
1634        || content_type.eq_ignore_ascii_case("image/jpg")
1635}
1636
1637fn resolve_picture_content(
1638    picture: &rpptx_oxml::picture::CT_Picture,
1639    source: FlattenedSource,
1640    media: Option<&ScopedMediaIds>,
1641    failures: Option<&ScopedMediaFailures>,
1642    diagnostics: &mut Vec<Diagnostic>,
1643) -> (ResolvedContent, Option<&'static str>) {
1644    let Some(fill) = picture.blip_fill.as_ref() else {
1645        return unsupported_picture(
1646            "alternate picture media",
1647            "alternate picture media is not resolved",
1648            diagnostics,
1649        );
1650    };
1651    match resolve_image_fill(fill, source, media, failures, "picture") {
1652        Ok(image) => (ResolvedContent::Image(image), None),
1653        Err((category, message)) => unsupported_picture(category, &message, diagnostics),
1654    }
1655}
1656
1657fn resolve_ole_preview(
1658    preview: Option<&rpptx_oxml::picture::CT_Picture>,
1659    source: FlattenedSource,
1660    media: Option<&ScopedMediaIds>,
1661    failures: Option<&ScopedMediaFailures>,
1662) -> Option<ResolvedImage> {
1663    let fill = preview?.blip_fill.as_ref()?;
1664    let relationship_id = fill.blip.as_ref()?.embed.as_deref()?;
1665    let media = media?;
1666    if !media
1667        .content_type(source, relationship_id)
1668        .is_some_and(|content_type| content_type.eq_ignore_ascii_case("image/png"))
1669    {
1670        return None;
1671    }
1672    resolve_image_fill(fill, source, Some(media), failures, "picture").ok()
1673}
1674
1675fn resolve_image_fill(
1676    fill: &BlipFill,
1677    source: FlattenedSource,
1678    media: Option<&ScopedMediaIds>,
1679    failures: Option<&ScopedMediaFailures>,
1680    role: &str,
1681) -> Result<ResolvedImage, (&'static str, String)> {
1682    let missing_blip_category = match role {
1683        "picture" => "missing picture blip",
1684        "shape" => "missing shape picture blip",
1685        _ => "missing background blip",
1686    };
1687    let external_media_category = match role {
1688        "picture" => "external picture media",
1689        "shape" => "external shape picture media",
1690        _ => "external background media",
1691    };
1692    let Some(blip) = fill.blip.as_ref() else {
1693        return Err((
1694            missing_blip_category,
1695            format!("{role} fill has no modelled embedded blip"),
1696        ));
1697    };
1698    let Some(relationship_id) = blip.embed.as_deref() else {
1699        let message = if let Some(link) = blip.link.as_deref() {
1700            format!("external {role} relationship `{link}` is unsupported")
1701        } else {
1702            format!("{role} blip has no embedded relationship")
1703        };
1704        return Err((external_media_category, message));
1705    };
1706    let Some(media) = media else {
1707        return Err((
1708            "image media pending relationship resolution",
1709            format!("{role} image media pending relationship resolution"),
1710        ));
1711    };
1712    if let Some(failure) = failures.and_then(|failures| failures.get(source, relationship_id)) {
1713        return Err((
1714            "image render failure",
1715            format!("{role} image relationship `{relationship_id}` {failure}"),
1716        ));
1717    }
1718    let Some(media_id) = media.get(source, relationship_id) else {
1719        return Err((
1720            "missing image relationship",
1721            format!(
1722                "missing {} {role} image relationship `{relationship_id}`",
1723                flattened_source_name(source)
1724            ),
1725        ));
1726    };
1727    Ok(ResolvedImage {
1728        media: media_id,
1729        src_rect: fill.source_rect.as_ref().map(resolved_crop_rect),
1730        placement: match fill.mode.as_ref() {
1731            Some(BlipMode::Tile(tile)) => ResolvedImagePlacement::Tile(ResolvedTilePlacement {
1732                translation: Point {
1733                    x: emu_to_points(tile.translation_x.unwrap_or(0)),
1734                    y: emu_to_points(tile.translation_y.unwrap_or(0)),
1735                },
1736                scale_x: tile
1737                    .scale_x
1738                    .map_or(1.0, |value| f64::from(value.0) / 100_000.0),
1739                scale_y: tile
1740                    .scale_y
1741                    .map_or(1.0, |value| f64::from(value.0) / 100_000.0),
1742                flip: resolved_tile_flip(tile.flip.as_deref()),
1743                alignment: resolved_rect_alignment(tile.alignment.as_deref()),
1744            }),
1745            Some(BlipMode::Stretch { fill_rect, .. }) => ResolvedImagePlacement::Stretch {
1746                fill_rect: fill_rect.as_ref().map(resolved_crop_rect),
1747            },
1748            None => ResolvedImagePlacement::default(),
1749        },
1750        dpi: fill.dpi.filter(|dpi| *dpi > 0).map(f64::from),
1751        rotate_with_shape: fill.rotate_with_shape.unwrap_or(true),
1752        opacity: blip
1753            .alpha_modulation_fix
1754            .map_or(1.0, |amount| f64::from(amount.0) / 100_000.0),
1755    })
1756}
1757
1758fn unsupported_picture(
1759    category: &'static str,
1760    message: &str,
1761    diagnostics: &mut Vec<Diagnostic>,
1762) -> (ResolvedContent, Option<&'static str>) {
1763    diagnostics.push(Diagnostic {
1764        message: message.to_owned(),
1765    });
1766    (ResolvedContent::None, Some(category))
1767}
1768
1769fn flattened_source_name(source: FlattenedSource) -> &'static str {
1770    match source {
1771        FlattenedSource::Background => "background",
1772        FlattenedSource::Master => "master",
1773        FlattenedSource::Layout => "layout",
1774        FlattenedSource::Slide => "slide",
1775    }
1776}
1777
1778fn resolve_direct_hyperlink(
1779    properties: Option<&CT_TextCharacterProperties>,
1780    source: FlattenedSource,
1781    hyperlinks: Option<&ScopedHyperlinkTargets>,
1782    diagnostics: &mut Vec<Diagnostic>,
1783) -> Option<String> {
1784    let hyperlink = properties?.hyperlink_click.as_ref()?;
1785    if let Some(action) = hyperlink.action.as_deref() {
1786        diagnostics.push(Diagnostic {
1787            message: format!(
1788                "unsupported {} hyperlink action `{action}`",
1789                flattened_source_name(source)
1790            ),
1791        });
1792        return None;
1793    }
1794    let Some(relationship_id) = hyperlink.relationship_id.as_deref() else {
1795        diagnostics.push(Diagnostic {
1796            message: format!(
1797                "unsupported {} hyperlink without an external relationship",
1798                flattened_source_name(source)
1799            ),
1800        });
1801        return None;
1802    };
1803    let Some(target) = hyperlinks.and_then(|targets| targets.get(source, relationship_id)) else {
1804        diagnostics.push(Diagnostic {
1805            message: format!(
1806                "missing {} hyperlink relationship `{relationship_id}`",
1807                flattened_source_name(source)
1808            ),
1809        });
1810        return None;
1811    };
1812    Some(target.to_owned())
1813}
1814
1815fn resolved_crop_rect(rect: &RelativeRect) -> crate::CropRect {
1816    crate::CropRect {
1817        left: rect
1818            .left
1819            .map_or(0.0, |value| f64::from(value.0) / 100_000.0),
1820        top: rect.top.map_or(0.0, |value| f64::from(value.0) / 100_000.0),
1821        right: rect
1822            .right
1823            .map_or(0.0, |value| f64::from(value.0) / 100_000.0),
1824        bottom: rect
1825            .bottom
1826            .map_or(0.0, |value| f64::from(value.0) / 100_000.0),
1827    }
1828}
1829
1830fn resolved_tile_flip(value: Option<&str>) -> ResolvedTileFlip {
1831    match value {
1832        Some("x") => ResolvedTileFlip::Horizontal,
1833        Some("y") => ResolvedTileFlip::Vertical,
1834        Some("xy") => ResolvedTileFlip::Both,
1835        Some("none") | None => ResolvedTileFlip::None,
1836        Some(_) => unreachable!("DrawingML tile flip is validated while parsing"),
1837    }
1838}
1839
1840fn resolved_rect_alignment(value: Option<&str>) -> ResolvedRectAlignment {
1841    match value {
1842        Some("t") => ResolvedRectAlignment::Top,
1843        Some("tr") => ResolvedRectAlignment::TopRight,
1844        Some("l") => ResolvedRectAlignment::Left,
1845        Some("ctr") => ResolvedRectAlignment::Center,
1846        Some("r") => ResolvedRectAlignment::Right,
1847        Some("bl") => ResolvedRectAlignment::BottomLeft,
1848        Some("b") => ResolvedRectAlignment::Bottom,
1849        Some("br") => ResolvedRectAlignment::BottomRight,
1850        Some("tl") | None => ResolvedRectAlignment::TopLeft,
1851        Some(_) => unreachable!("DrawingML rectangle alignment is validated while parsing"),
1852    }
1853}
1854
1855fn resolved_line_ends(
1856    line: &CT_LineProperties,
1857) -> (Option<ResolvedLineEnd>, Option<ResolvedLineEnd>) {
1858    (
1859        line.head_end.as_ref().and_then(resolved_line_end),
1860        line.tail_end.as_ref().and_then(resolved_line_end),
1861    )
1862}
1863
1864fn resolved_line_end(end: &LineEnd) -> Option<ResolvedLineEnd> {
1865    let kind = match end.kind? {
1866        LineEndType::None => return None,
1867        LineEndType::Triangle => ResolvedLineEndKind::Triangle,
1868        LineEndType::Stealth => ResolvedLineEndKind::Stealth,
1869        LineEndType::Diamond => ResolvedLineEndKind::Diamond,
1870        LineEndType::Oval => ResolvedLineEndKind::Oval,
1871        LineEndType::Arrow => ResolvedLineEndKind::Arrow,
1872    };
1873    Some(ResolvedLineEnd {
1874        kind,
1875        width: resolved_line_end_size(end.width),
1876        length: resolved_line_end_size(end.length),
1877    })
1878}
1879
1880fn resolved_line_end_size(size: Option<LineEndSize>) -> ResolvedLineEndSize {
1881    match size.unwrap_or(LineEndSize::Medium) {
1882        LineEndSize::Small => ResolvedLineEndSize::Small,
1883        LineEndSize::Medium => ResolvedLineEndSize::Medium,
1884        LineEndSize::Large => ResolvedLineEndSize::Large,
1885    }
1886}
1887
1888impl ResolveCtx<'_> {
1889    fn concrete_background_fill(
1890        &self,
1891        fill: &Fill,
1892        size: (f64, f64),
1893        source: FlattenedSource,
1894        media: Option<&ScopedMediaIds>,
1895        diagnostics: &mut Vec<Diagnostic>,
1896    ) -> Result<(Option<ResolvedBackground>, Option<&'static str>), ResolveError> {
1897        let mut fill = fill.clone();
1898        let slot = self.color_map.theme_slot(ColorMapSlot::Background1);
1899        let reference = self.theme.theme_elements.color_scheme.color(slot);
1900        substitute_fill(&mut fill, Some(reference), "background")?;
1901        self.concrete_background_value(&fill, size, source, media, diagnostics)
1902    }
1903
1904    fn concrete_background_reference(
1905        &self,
1906        index: u32,
1907        color: Option<&ColorChoice>,
1908        size: (f64, f64),
1909        diagnostics: &mut Vec<Diagnostic>,
1910    ) -> Result<(Option<ResolvedBackground>, Option<&'static str>), ResolveError> {
1911        let matrix = &self.theme.theme_elements.format_scheme;
1912        let Some(mut fill) =
1913            referenced_fill(index, &matrix.fill_styles, &matrix.background_fill_styles)?
1914        else {
1915            return Ok((None, None));
1916        };
1917        substitute_fill(&mut fill, color, "background")?;
1918        if matches!(fill, Fill::Blip(_)) {
1919            diagnostics.push(Diagnostic {
1920                message: "theme-referenced background picture requires theme relationship scope"
1921                    .to_owned(),
1922            });
1923            return Ok((None, None));
1924        }
1925        let (paint, unsupported) = self.concrete_background_paint(&fill, size)?;
1926        Ok((paint.map(ResolvedBackground::Paint), unsupported))
1927    }
1928
1929    fn concrete_background_value(
1930        &self,
1931        fill: &Fill,
1932        size: (f64, f64),
1933        source: FlattenedSource,
1934        media: Option<&ScopedMediaIds>,
1935        diagnostics: &mut Vec<Diagnostic>,
1936    ) -> Result<(Option<ResolvedBackground>, Option<&'static str>), ResolveError> {
1937        if let Fill::Blip(fill) = fill {
1938            return Ok(
1939                match resolve_image_fill(fill, source, media, self.media_failures, "background") {
1940                    Ok(image) => (Some(ResolvedBackground::Image(image)), None),
1941                    Err((_category, message)) => {
1942                        diagnostics.push(Diagnostic { message });
1943                        (None, None)
1944                    }
1945                },
1946            );
1947        }
1948        let (paint, unsupported) = self.concrete_background_paint(fill, size)?;
1949        Ok((paint.map(ResolvedBackground::Paint), unsupported))
1950    }
1951
1952    fn concrete_background_paint(
1953        &self,
1954        fill: &Fill,
1955        size: (f64, f64),
1956    ) -> Result<(Option<Paint>, Option<&'static str>), ResolveError> {
1957        let mut fill = fill.clone();
1958        if let Fill::Gradient(gradient) = &mut fill {
1959            gradient.rotate_with_shape = None;
1960        }
1961        self.concrete_fill(&fill, size)
1962    }
1963
1964    fn concrete_fill(
1965        &self,
1966        fill: &Fill,
1967        size: (f64, f64),
1968    ) -> Result<(Option<Paint>, Option<&'static str>), ResolveError> {
1969        match fill {
1970            Fill::NoFill(_) => Ok((None, None)),
1971            Fill::Solid(solid) => {
1972                let Some(choice) = solid.color.as_ref() else {
1973                    return Ok((None, Some("solid fill without colour")));
1974                };
1975                Ok((Some(Paint::Solid(self.concrete_color(choice)?)), None))
1976            }
1977            Fill::Gradient(gradient) => {
1978                if gradient.rotate_with_shape == Some(false) {
1979                    return Ok((None, Some("gradient independent of shape rotation")));
1980                }
1981                match gradient.flip.as_deref() {
1982                    Some("x") => return Ok((None, Some("horizontal gradient flip"))),
1983                    Some("y") => return Ok((None, Some("vertical gradient flip"))),
1984                    Some("xy") => return Ok((None, Some("horizontal and vertical gradient flip"))),
1985                    Some("none") | None => {}
1986                    Some(_) => unreachable!("DrawingML parser validates gradient flip tokens"),
1987                }
1988                if gradient.tile_rect.is_some() {
1989                    return Ok((None, Some("gradient tile rectangle")));
1990                }
1991                if let Some(GradientGeometry::Path(path)) = &gradient.geometry
1992                    && !path.fill_to_rect.as_ref().is_some_and(|rect| {
1993                        [rect.left, rect.top, rect.right, rect.bottom]
1994                            .into_iter()
1995                            .all(|value| value.is_some_and(|value| value.0 == 50_000))
1996                    })
1997                {
1998                    return Ok((None, Some("path gradient with non-centred fill rectangle")));
1999                }
2000                let mut stops = Vec::with_capacity(gradient.stops.len());
2001                for stop in &gradient.stops {
2002                    let Some(choice) = stop.color.as_ref() else {
2003                        return Ok((None, Some("gradient stop without colour")));
2004                    };
2005                    stops.push(GradientStop {
2006                        offset: f64::from(stop.position.0) / 100_000.0,
2007                        color: self.concrete_color(choice)?,
2008                    });
2009                }
2010                if stops.is_empty() {
2011                    return Ok((None, Some("gradient without concrete stops")));
2012                }
2013                let paint = match &gradient.geometry {
2014                    Some(GradientGeometry::Linear(linear)) => {
2015                        let radians = f64::from(linear.angle.0) / 60_000.0_f64;
2016                        let radians = radians.to_radians();
2017                        let center = Point {
2018                            x: size.0 / 2.0,
2019                            y: size.1 / 2.0,
2020                        };
2021                        let mut direction = Point {
2022                            x: radians.cos(),
2023                            y: radians.sin(),
2024                        };
2025                        if linear.scaled == Some(true) {
2026                            direction.x *= size.0;
2027                            direction.y *= size.1;
2028                        }
2029                        let length = direction.x.hypot(direction.y);
2030                        if length > 0.0 {
2031                            direction.x /= length;
2032                            direction.y /= length;
2033                        }
2034                        let extent =
2035                            (direction.x.abs() * size.0 + direction.y.abs() * size.1) / 2.0;
2036                        let delta = Point {
2037                            x: direction.x * extent,
2038                            y: direction.y * extent,
2039                        };
2040                        Paint::linear(
2041                            Point {
2042                                x: center.x - delta.x,
2043                                y: center.y - delta.y,
2044                            },
2045                            Point {
2046                                x: center.x + delta.x,
2047                                y: center.y + delta.y,
2048                            },
2049                            powerpoint_gradient_stops(&stops),
2050                            (true, true),
2051                        )
2052                    }
2053                    None => Paint::linear(
2054                        Point { x: 0.0, y: 0.0 },
2055                        Point { x: size.0, y: 0.0 },
2056                        powerpoint_gradient_stops(&stops),
2057                        (true, true),
2058                    ),
2059                    Some(GradientGeometry::Path(path)) => {
2060                        let centered = path.fill_to_rect.as_ref().is_some_and(|r| {
2061                            [r.left, r.top, r.right, r.bottom]
2062                                .into_iter()
2063                                .all(|v| v.is_some_and(|v| v.0 == 50_000))
2064                        });
2065                        if !centered {
2066                            return Ok((
2067                                None,
2068                                Some("path gradient with non-centred fill rectangle"),
2069                            ));
2070                        }
2071                        let mut ordered = stops;
2072                        ordered.sort_by(|a, b| a.offset.total_cmp(&b.offset));
2073                        let mut colors = Vec::with_capacity(128 * 128);
2074                        for y in 0..128 {
2075                            for x in 0..128 {
2076                                let dx = ((f64::from(x) + 0.5) / 128.0 - 0.5).abs() * 2.0;
2077                                let dy = ((f64::from(y) + 0.5) / 128.0 - 0.5).abs() * 2.0;
2078                                let distance = match path.kind {
2079                                    PathGradientKind::Circle => {
2080                                        dx.hypot(dy) / std::f64::consts::SQRT_2
2081                                    }
2082                                    PathGradientKind::Rectangle | PathGradientKind::Shape => {
2083                                        dx.max(dy)
2084                                    }
2085                                };
2086                                colors.push(path_gradient_color(&ordered, distance));
2087                            }
2088                        }
2089                        let Some(paint) = Paint::grid(
2090                            Rect {
2091                                x: 0.0,
2092                                y: 0.0,
2093                                width: size.0,
2094                                height: size.1,
2095                            },
2096                            128,
2097                            128,
2098                            colors,
2099                        ) else {
2100                            return Ok((
2101                                None,
2102                                Some("path gradient without positive finite extent"),
2103                            ));
2104                        };
2105                        paint
2106                    }
2107                };
2108                Ok((Some(paint), None))
2109            }
2110            Fill::Pattern(pattern) => {
2111                let Some(rows) = pattern.preset.as_deref().and_then(native_pattern_rows) else {
2112                    return Ok((None, Some("pattern without supported preset")));
2113                };
2114                let foreground = pattern
2115                    .foreground
2116                    .as_ref()
2117                    .map(|c| self.concrete_color(c))
2118                    .transpose()?
2119                    .unwrap_or(Color::BLACK);
2120                let background = pattern
2121                    .background
2122                    .as_ref()
2123                    .map(|c| self.concrete_color(c))
2124                    .transpose()?
2125                    .unwrap_or(Color::WHITE);
2126                let mut colors = Vec::with_capacity(4096);
2127                for &row in rows {
2128                    let bits = NATIVE_PATTERN_ROW_WEIGHTS[usize::from(row)];
2129                    for x in 0..64 {
2130                        let weight = ((bits >> (126 - x * 2)) & 3) as u8;
2131                        colors.push(blend_fill_colors(
2132                            background,
2133                            foreground,
2134                            f64::from(weight) / 2.0,
2135                        ));
2136                    }
2137                }
2138                Ok((
2139                    Paint::grid(
2140                        Rect {
2141                            x: 0.0,
2142                            y: 0.0,
2143                            width: 8.0,
2144                            height: 8.0,
2145                        },
2146                        64,
2147                        64,
2148                        colors,
2149                    ),
2150                    None,
2151                ))
2152            }
2153            Fill::Blip(_) => Ok((None, Some("picture fill media"))),
2154        }
2155    }
2156
2157    fn concrete_line(
2158        &self,
2159        line: &CT_LineProperties,
2160        size: (f64, f64),
2161    ) -> Result<Option<Stroke>, ResolveError> {
2162        let Some(fill) = line.fill.as_ref() else {
2163            return Ok(None);
2164        };
2165        let (Some(paint), _) = self.concrete_fill(fill, size)? else {
2166            return Ok(None);
2167        };
2168        let width = emu_to_points(i64::from(line.width.unwrap_or(12_700)));
2169        let cap = match line.cap.unwrap_or(DrawingLineCap::Flat) {
2170            DrawingLineCap::Round => LineCap::Round,
2171            DrawingLineCap::Square => LineCap::Square,
2172            DrawingLineCap::Flat => LineCap::Butt,
2173        };
2174        let join = match line.join.as_ref() {
2175            Some(DrawingLineJoin::Round { .. }) => LineJoin::Round,
2176            Some(DrawingLineJoin::Bevel { .. }) => LineJoin::Bevel,
2177            Some(DrawingLineJoin::Miter { .. }) | None => LineJoin::Miter,
2178        };
2179        let dash = line.dash.as_ref().and_then(|dash| match dash {
2180            LineDash::Preset(preset) => {
2181                let values = preset
2182                    .value
2183                    .dash_array()
2184                    .iter()
2185                    .map(|value| f64::from(*value) * width)
2186                    .collect::<Vec<_>>();
2187                (!values.is_empty()).then_some(values)
2188            }
2189            LineDash::Custom(custom) => {
2190                let values = custom
2191                    .stops
2192                    .iter()
2193                    .flat_map(|stop| [stop.dash, stop.space])
2194                    .map(|value| f64::from(value) / 100_000.0 * width)
2195                    .collect::<Vec<_>>();
2196                (!values.is_empty()).then_some(values)
2197            }
2198        });
2199        Ok(Some(Stroke {
2200            paint,
2201            width,
2202            cap,
2203            join,
2204            dash,
2205        }))
2206    }
2207
2208    fn concrete_shadow(&self, shadow: &CT_OuterShadowEffect) -> Result<Effect, ResolveError> {
2209        let color = shadow
2210            .color
2211            .as_ref()
2212            .map(|choice| self.concrete_color(choice))
2213            .transpose()?
2214            .unwrap_or(Color::BLACK);
2215        let distance = emu_to_points(shadow.distance.unwrap_or(0));
2216        let angle = f64::from(shadow.direction.unwrap_or_default().0) / 60_000.0;
2217        let radians = angle.to_radians();
2218        Ok(Effect::OuterShadow {
2219            dx: radians.cos() * distance,
2220            dy: radians.sin() * distance,
2221            blur: emu_to_points(shadow.blur_radius.unwrap_or(0)),
2222            color,
2223        })
2224    }
2225
2226    pub(crate) fn concrete_color(&self, choice: &ColorChoice) -> Result<Color, ResolveError> {
2227        let owned_lookup = self.theme_lookup();
2228        let lookup = owned_lookup
2229            .iter()
2230            .map(|(name, color)| (name.as_str(), *color))
2231            .collect::<Vec<_>>();
2232        let color = resolve_color(choice, &self.color_map, &lookup).map_err(|error| {
2233            ResolveError::ConcreteValue {
2234                kind: "colour",
2235                detail: error.to_string(),
2236            }
2237        })?;
2238        Ok(Color {
2239            r: f64::from(color.red) / 255.0,
2240            g: f64::from(color.green) / 255.0,
2241            b: f64::from(color.blue) / 255.0,
2242            a: f64::from(color.alpha) / 255.0,
2243        })
2244    }
2245
2246    fn theme_lookup(&self) -> Vec<(String, RgbColor)> {
2247        let mut lookup = self
2248            .theme
2249            .theme_elements
2250            .color_scheme
2251            .iter()
2252            .filter_map(|(slot, choice)| {
2253                base_rgb(choice).map(|color| (slot.as_str().to_owned(), color))
2254            })
2255            .collect::<Vec<_>>();
2256        lookup.push(("black".to_owned(), RgbColor::new(0, 0, 0)));
2257        lookup.push(("white".to_owned(), RgbColor::new(255, 255, 255)));
2258        lookup
2259    }
2260
2261    fn concrete_custom_geometry(
2262        &self,
2263        geometry: &oxml_drawing::geometry::CT_CustomGeometry2D,
2264        size: (f64, f64),
2265    ) -> Result<ResolvedGeometry, ResolveError> {
2266        let evaluated = geometry
2267            .evaluate_with_size(&BTreeMap::new(), size)
2268            .map_err(|error| ResolveError::ConcreteValue {
2269                kind: "custom geometry",
2270                detail: error.to_string(),
2271            })?;
2272        let paths = evaluated
2273            .paths
2274            .into_iter()
2275            .zip(geometry.paths())
2276            .map(|(commands, source)| {
2277                let scale_x = source.width.map_or(1.0, |width| size.0 / width);
2278                let scale_y = source.height.map_or(1.0, |height| size.1 / height);
2279                Path {
2280                    commands: commands
2281                        .into_iter()
2282                        .map(|command| concrete_path_command(command, scale_x, scale_y))
2283                        .collect(),
2284                    fill_rule: FillRule::NonZero,
2285                }
2286            })
2287            .collect();
2288        let first_path = geometry.paths().first();
2289        let text_scale_x = first_path
2290            .and_then(|path| path.width)
2291            .map_or(1.0, |width| size.0 / width);
2292        let text_scale_y = first_path
2293            .and_then(|path| path.height)
2294            .map_or(1.0, |height| size.1 / height);
2295        let text_rect = evaluated.text_rectangle.map(|rectangle| Rect {
2296            x: rectangle.left * text_scale_x,
2297            y: rectangle.top * text_scale_y,
2298            width: (rectangle.right - rectangle.left) * text_scale_x,
2299            height: (rectangle.bottom - rectangle.top) * text_scale_y,
2300        });
2301        Ok(ResolvedGeometry::Custom { paths, text_rect })
2302    }
2303
2304    fn concrete_preset_geometry(
2305        &self,
2306        geometry: &oxml_drawing::geometry::CT_PresetGeometry2D,
2307        size: (f64, f64),
2308    ) -> Result<Option<ResolvedGeometry>, ResolveError> {
2309        let evaluated = geometry
2310            .evaluate(size)
2311            .map_err(|error| ResolveError::ConcreteValue {
2312                kind: "preset geometry",
2313                detail: error.to_string(),
2314            })?;
2315        Ok(evaluated.map(|evaluated| {
2316            let paths = evaluated
2317                .paths
2318                .into_iter()
2319                .map(|commands| Path {
2320                    commands: commands
2321                        .into_iter()
2322                        .map(|command| concrete_path_command(command, 1.0, 1.0))
2323                        .collect(),
2324                    fill_rule: FillRule::NonZero,
2325                })
2326                .collect();
2327            let text_rect = evaluated.text_rectangle.map(|rectangle| Rect {
2328                x: rectangle.left,
2329                y: rectangle.top,
2330                width: rectangle.right - rectangle.left,
2331                height: rectangle.bottom - rectangle.top,
2332            });
2333            ResolvedGeometry::Custom { paths, text_rect }
2334        }))
2335    }
2336
2337    fn resolve_text_body(
2338        &self,
2339        shape: &CT_Shape,
2340        body: &CT_TextBody,
2341        source: FlattenedSource,
2342        hyperlinks: Option<&ScopedHyperlinkTargets>,
2343        diagnostics: &mut Vec<Diagnostic>,
2344    ) -> Result<(ResolvedTextBody, Vec<oxml_layout::TextDirection>), ResolveError> {
2345        let properties = self.effective_body_pr(shape);
2346        let slide_number_placeholder = self.is_slide_number_placeholder(shape);
2347        let resolved = body
2348            .paragraphs()
2349            .iter()
2350            .map(|paragraph| {
2351                self.resolve_paragraph(
2352                    shape,
2353                    paragraph,
2354                    source,
2355                    hyperlinks,
2356                    slide_number_placeholder,
2357                    diagnostics,
2358                )
2359            })
2360            .collect::<Result<Vec<_>, _>>()?;
2361        let (paragraphs, directions) = resolved.into_iter().unzip();
2362        Ok((resolved_text_body(&properties, paragraphs)?, directions))
2363    }
2364
2365    fn resolve_paragraph(
2366        &self,
2367        shape: &CT_Shape,
2368        paragraph: &oxml_drawing::text::CT_TextParagraph,
2369        source: FlattenedSource,
2370        hyperlinks: Option<&ScopedHyperlinkTargets>,
2371        slide_number_placeholder: bool,
2372        diagnostics: &mut Vec<Diagnostic>,
2373    ) -> Result<(ResolvedParagraph, oxml_layout::TextDirection), ResolveError> {
2374        let effective = self.effective_text_properties(shape, paragraph.properties.as_ref(), None);
2375        let paragraph_properties = &effective.paragraph;
2376        let mut runs = Vec::new();
2377        for run in &paragraph.runs {
2378            match run {
2379                TextRun::Run(run) => {
2380                    let mut style = self.resolve_run_style(
2381                        shape,
2382                        paragraph.properties.as_ref(),
2383                        run.properties.as_ref(),
2384                    )?;
2385                    style.hyperlink_url = resolve_direct_hyperlink(
2386                        run.properties.as_ref(),
2387                        source,
2388                        hyperlinks,
2389                        diagnostics,
2390                    );
2391                    runs.push(ResolvedTextRun::Text {
2392                        text: run.text.value.clone(),
2393                        style,
2394                    });
2395                }
2396                TextRun::Break(_) => runs.push(ResolvedTextRun::Break),
2397                TextRun::Field(field) => {
2398                    if field.text.is_none()
2399                        && field.field_type.as_deref().is_some_and(|kind| {
2400                            kind == "datetime"
2401                                || (1..=13).any(|index| kind == format!("datetime{index}"))
2402                        })
2403                    {
2404                        diagnostics.push(Diagnostic { message: "uncached automatic date field has no computed date for static rendering".to_owned() });
2405                    }
2406                    let mut style = self.resolve_run_style(
2407                        shape,
2408                        paragraph.properties.as_ref(),
2409                        field.run_properties.as_ref(),
2410                    )?;
2411                    style.hyperlink_url = resolve_direct_hyperlink(
2412                        field.run_properties.as_ref(),
2413                        source,
2414                        hyperlinks,
2415                        diagnostics,
2416                    );
2417                    runs.push(ResolvedTextRun::Field {
2418                        text: field
2419                            .text
2420                            .as_ref()
2421                            .map(|text| text.value.clone())
2422                            .unwrap_or_default(),
2423                        field_type: field
2424                            .field_type
2425                            .clone()
2426                            .or_else(|| slide_number_placeholder.then(|| "slidenum".to_owned())),
2427                        style,
2428                    });
2429                }
2430            }
2431        }
2432        let direction = paragraph_base_direction(paragraph_properties.right_to_left);
2433        Ok((
2434            ResolvedParagraph {
2435                level: paragraph_properties.level.unwrap_or(0),
2436                left_margin: emu_to_points(i64::from(
2437                    paragraph_properties.left_margin.unwrap_or(0),
2438                )),
2439                right_margin: emu_to_points(i64::from(
2440                    paragraph_properties.right_margin.unwrap_or(0),
2441                )),
2442                indent: emu_to_points(i64::from(paragraph_properties.indent.unwrap_or(0))),
2443                alignment: paragraph_alignment(paragraph_properties.alignment),
2444                line_spacing: resolved_text_spacing(paragraph_properties.line_spacing.as_ref()),
2445                space_before: resolved_text_spacing(paragraph_properties.space_before.as_ref()),
2446                space_after: resolved_text_spacing(paragraph_properties.space_after.as_ref()),
2447                bullet: paragraph_properties
2448                    .bullet
2449                    .as_ref()
2450                    .and_then(|bullet| self.resolve_bullet(bullet).transpose())
2451                    .transpose()?,
2452                end_style: self.resolve_run_style(
2453                    shape,
2454                    paragraph.properties.as_ref(),
2455                    paragraph.end_properties.as_ref(),
2456                )?,
2457                runs,
2458            },
2459            direction,
2460        ))
2461    }
2462
2463    fn resolve_run_style(
2464        &self,
2465        shape: &CT_Shape,
2466        paragraph: Option<&oxml_drawing::text::CT_TextParagraphProperties>,
2467        run: Option<&CT_TextCharacterProperties>,
2468    ) -> Result<ResolvedRunStyle, ResolveError> {
2469        let effective = self.effective_text_properties(shape, paragraph, run);
2470        self.resolved_run_style(effective.run, effective.language)
2471    }
2472
2473    fn resolve_table_run_style(
2474        &self,
2475        table_style: &CT_TextCharacterProperties,
2476        paragraph: Option<&oxml_drawing::text::CT_TextParagraphProperties>,
2477        run: Option<&CT_TextCharacterProperties>,
2478    ) -> Result<ResolvedRunStyle, ResolveError> {
2479        let effective = self.effective_table_text_properties(Some(table_style), paragraph, run);
2480        self.resolved_run_style(effective.run, effective.language)
2481    }
2482
2483    fn resolved_run_style(
2484        &self,
2485        effective: CT_TextCharacterProperties,
2486        language: Option<String>,
2487    ) -> Result<ResolvedRunStyle, ResolveError> {
2488        let fill = effective
2489            .fill
2490            .as_ref()
2491            .map(|fill| self.concrete_fill(fill, (1.0, 1.0)))
2492            .transpose()?
2493            .and_then(|(paint, _)| paint);
2494        Ok(ResolvedRunStyle {
2495            font_size: effective.font_size.map(|size| f64::from(size) / 100.0),
2496            bold: effective.bold.unwrap_or(false),
2497            italic: effective.italic.unwrap_or(false),
2498            all_caps: effective.all_caps.unwrap_or(false),
2499            small_caps: effective.small_caps.unwrap_or(false),
2500            language,
2501            highlight: effective
2502                .highlight
2503                .as_ref()
2504                .map(|colour| self.concrete_color(colour))
2505                .transpose()?,
2506            underline: effective
2507                .underline
2508                .is_some_and(|value| value != oxml_drawing::text::TextUnderline::None),
2509            strike: effective
2510                .strike
2511                .is_some_and(|value| value != oxml_drawing::text::TextStrike::None),
2512            spacing: effective.spacing.as_ref().and_then(text_point_value),
2513            baseline: effective.baseline.as_deref().and_then(parse_percent),
2514            fill,
2515            latin_typeface: effective
2516                .latin
2517                .as_ref()
2518                .map(|font| self.resolve_typeface(&font.typeface, None)),
2519            east_asian_typeface: effective
2520                .east_asian
2521                .as_ref()
2522                .map(|font| self.resolve_typeface(&font.typeface, None)),
2523            complex_script_typeface: effective
2524                .complex_script
2525                .as_ref()
2526                .map(|font| self.resolve_typeface(&font.typeface, None)),
2527            symbol_typeface: effective
2528                .symbol
2529                .as_ref()
2530                .map(|font| self.resolve_typeface(&font.typeface, None)),
2531            hyperlink_url: None,
2532        })
2533    }
2534
2535    fn resolve_bullet(
2536        &self,
2537        bullet: &oxml_drawing::text::TextBullet,
2538    ) -> Result<Option<ResolvedBullet>, ResolveError> {
2539        let color = bullet
2540            .color
2541            .as_ref()
2542            .map(|color| self.concrete_color(&color.color))
2543            .transpose()?;
2544        let font = bullet
2545            .font
2546            .as_ref()
2547            .map(|font| self.resolve_typeface(&font.typeface, None));
2548        let size = bullet.size.as_ref().and_then(|size| match &size.value {
2549            TextBulletSizeValue::Percent(value) => {
2550                parse_percent(value).map(ResolvedBulletSize::Percent)
2551            }
2552            TextBulletSizeValue::Points(value) => {
2553                Some(ResolvedBulletSize::Points(f64::from(*value) / 100.0))
2554            }
2555        });
2556        Ok(match bullet.choice.as_ref() {
2557            Some(TextBulletChoice::Character(character)) => Some(ResolvedBullet::Character {
2558                character: character.character.clone(),
2559                font,
2560                color,
2561                size,
2562            }),
2563            Some(TextBulletChoice::AutoNumber(number)) => Some(ResolvedBullet::AutoNumber {
2564                scheme: number.scheme.as_str().to_owned(),
2565                start_at: u32::from(number.start_at.unwrap_or(1)),
2566                font,
2567                color,
2568                size,
2569            }),
2570            Some(TextBulletChoice::None(_)) | None => None,
2571        })
2572    }
2573
2574    fn resolve_table(
2575        &self,
2576        table: &oxml_drawing::table::CT_Table,
2577        source: FlattenedSource,
2578        hyperlinks: Option<&ScopedHyperlinkTargets>,
2579        diagnostics: &mut Vec<Diagnostic>,
2580        text_directions: &mut Vec<Vec<oxml_layout::TextDirection>>,
2581    ) -> Result<ResolvedTable, ResolveError> {
2582        let column_widths = table
2583            .grid
2584            .columns
2585            .iter()
2586            .map(|width| emu_to_points(width.0))
2587            .collect::<Vec<_>>();
2588        let selected_id = table
2589            .properties
2590            .as_ref()
2591            .and_then(|properties| properties.style_id.as_deref());
2592        let explicit_style = self.table_styles.and_then(|styles| {
2593            selected_id.and_then(|id| styles.styles.iter().find(|style| style.style_id == id))
2594        });
2595        let default_id = self
2596            .table_styles
2597            .and_then(|styles| styles.default_style_id.as_deref());
2598        let default_style = self.table_styles.and_then(|styles| {
2599            default_id.and_then(|id| styles.styles.iter().find(|style| style.style_id == id))
2600        });
2601        let builtin_style = if explicit_style.is_none() {
2602            selected_id.and_then(CT_TableStyle::builtin)
2603        } else {
2604            None
2605        };
2606        let builtin_default = if selected_id.is_none() && default_style.is_none() {
2607            default_id.and_then(CT_TableStyle::builtin)
2608        } else {
2609            None
2610        };
2611        let style = explicit_style
2612            .or(builtin_style.as_ref())
2613            .or(default_style)
2614            .or(builtin_default.as_ref());
2615        let row_count = table.rows.len();
2616        let column_count = table.grid.columns.len();
2617        let table_properties = table.properties.as_ref();
2618        let rows = table
2619            .rows
2620            .iter()
2621            .enumerate()
2622            .map(|(row_index, row)| {
2623                let cells = row
2624                    .cells
2625                    .iter()
2626                    .enumerate()
2627                    .map(|(column_index, cell)| {
2628                        let mut cascade = TableCellCascade::default();
2629                        let position = TableCellPosition {
2630                            row: row_index,
2631                            column: column_index,
2632                            row_count,
2633                            column_count,
2634                        };
2635                        if let Some(style) = style {
2636                            let mut priority = 1u8;
2637                            cascade.apply_region(
2638                                style.whole_table.as_ref(),
2639                                true,
2640                                position,
2641                                priority,
2642                            );
2643                            priority += 1;
2644                            if table_properties.is_some_and(|properties| properties.band_rows) {
2645                                let offset = usize::from(
2646                                    table_properties.is_some_and(|properties| properties.first_row),
2647                                );
2648                                let region = if row_index.saturating_sub(offset) % 2 == 0 {
2649                                    style.band1_horizontal.as_ref()
2650                                } else {
2651                                    style.band2_horizontal.as_ref()
2652                                };
2653                                cascade.apply_region(region, false, position, priority);
2654                                priority += 1;
2655                            }
2656                            if table_properties.is_some_and(|properties| properties.band_columns) {
2657                                let offset = usize::from(
2658                                    table_properties
2659                                        .is_some_and(|properties| properties.first_column),
2660                                );
2661                                let region = if column_index.saturating_sub(offset) % 2 == 0 {
2662                                    style.band1_vertical.as_ref()
2663                                } else {
2664                                    style.band2_vertical.as_ref()
2665                                };
2666                                cascade.apply_region(region, false, position, priority);
2667                                priority += 1;
2668                            }
2669                            if column_index == 0
2670                                && table_properties
2671                                    .is_some_and(|properties| properties.first_column)
2672                            {
2673                                cascade.apply_region(
2674                                    style.first_column.as_ref(),
2675                                    false,
2676                                    position,
2677                                    priority,
2678                                );
2679                                priority += 1;
2680                            }
2681                            if column_index + 1 == column_count
2682                                && table_properties.is_some_and(|properties| properties.last_column)
2683                            {
2684                                cascade.apply_region(
2685                                    style.last_column.as_ref(),
2686                                    false,
2687                                    position,
2688                                    priority,
2689                                );
2690                                priority += 1;
2691                            }
2692                            if row_index == 0
2693                                && table_properties.is_some_and(|properties| properties.first_row)
2694                            {
2695                                cascade.apply_region(
2696                                    style.first_row.as_ref(),
2697                                    false,
2698                                    position,
2699                                    priority,
2700                                );
2701                                priority += 1;
2702                            }
2703                            if row_index + 1 == row_count
2704                                && table_properties.is_some_and(|properties| properties.last_row)
2705                            {
2706                                cascade.apply_region(
2707                                    style.last_row.as_ref(),
2708                                    false,
2709                                    position,
2710                                    priority,
2711                                );
2712                                priority += 1;
2713                            }
2714                            let first_row =
2715                                table_properties.is_some_and(|properties| properties.first_row);
2716                            let last_row =
2717                                table_properties.is_some_and(|properties| properties.last_row);
2718                            let first_column =
2719                                table_properties.is_some_and(|properties| properties.first_column);
2720                            let last_column =
2721                                table_properties.is_some_and(|properties| properties.last_column);
2722                            let corner = match (row_index, column_index) {
2723                                (0, 0) if first_row && first_column => {
2724                                    style.north_west_cell.as_ref()
2725                                }
2726                                (0, column)
2727                                    if column + 1 == column_count && first_row && last_column =>
2728                                {
2729                                    style.north_east_cell.as_ref()
2730                                }
2731                                (row, 0) if row + 1 == row_count && last_row && first_column => {
2732                                    style.south_west_cell.as_ref()
2733                                }
2734                                (row, column)
2735                                    if row + 1 == row_count
2736                                        && column + 1 == column_count
2737                                        && last_row
2738                                        && last_column =>
2739                                {
2740                                    style.south_east_cell.as_ref()
2741                                }
2742                                _ => None,
2743                            };
2744                            cascade.apply_region(corner, false, position, priority);
2745                        }
2746                        if let Some(properties) = &cell.properties {
2747                            cascade.apply_direct(properties);
2748                            for unsupported in &properties.unsupported {
2749                                push_table_diagnostic(diagnostics, unsupported);
2750                            }
2751                        }
2752                        for unsupported in &cascade.unsupported {
2753                            push_table_diagnostic(diagnostics, unsupported);
2754                        }
2755                        let referenced_fill = cascade
2756                            .fill_reference
2757                            .as_ref()
2758                            .map(|reference| self.table_referenced_fill(reference))
2759                            .transpose()?
2760                            .flatten();
2761                        let (fill, fill_unsupported) = referenced_fill
2762                            .as_ref()
2763                            .or(cascade.fill.as_ref())
2764                            .map(|fill| self.concrete_fill(fill, (1.0, 1.0)))
2765                            .transpose()?
2766                            .unwrap_or((None, None));
2767                        if let Some(unsupported) = fill_unsupported {
2768                            push_table_diagnostic(diagnostics, unsupported);
2769                        }
2770                        let table_text_style = table_character_properties(&cascade.text_style)?;
2771                        let mut text = if let Some(body) = cell.text_body.as_ref() {
2772                            let (body, directions) = resolve_standalone_text_body(
2773                                self,
2774                                body,
2775                                &table_text_style,
2776                                source,
2777                                hyperlinks,
2778                                diagnostics,
2779                            )?;
2780                            text_directions.push(directions);
2781                            Some(body)
2782                        } else {
2783                            text_directions.push(Vec::new());
2784                            None
2785                        };
2786                        if let Some(body) = text.as_mut()
2787                            && let Some(anchor) = cell
2788                                .properties
2789                                .as_ref()
2790                                .and_then(|properties| properties.anchor)
2791                        {
2792                            body.anchor = resolved_anchor(anchor);
2793                        }
2794                        if let Some(body) = text.as_mut()
2795                            && body.autofit == ResolvedAutofit::Shape
2796                        {
2797                            body.autofit = ResolvedAutofit::None;
2798                            let message = "table cell autofit is unsupported and was ignored";
2799                            if !diagnostics
2800                                .iter()
2801                                .any(|diagnostic| diagnostic.message == message)
2802                            {
2803                                diagnostics.push(Diagnostic {
2804                                    message: message.to_owned(),
2805                                });
2806                            }
2807                        }
2808                        Ok(ResolvedTableCell {
2809                            text,
2810                            fill,
2811                            margins: TextInsets {
2812                                left: emu_to_points(cascade.margin_left.unwrap_or(91_440)),
2813                                top: emu_to_points(cascade.margin_top.unwrap_or(45_720)),
2814                                right: emu_to_points(cascade.margin_right.unwrap_or(91_440)),
2815                                bottom: emu_to_points(cascade.margin_bottom.unwrap_or(45_720)),
2816                            },
2817                            left: self.resolve_table_border(cascade.left, diagnostics)?,
2818                            right: self.resolve_table_border(cascade.right, diagnostics)?,
2819                            top: self.resolve_table_border(cascade.top, diagnostics)?,
2820                            bottom: self.resolve_table_border(cascade.bottom, diagnostics)?,
2821                            row_span: cell.row_span,
2822                            grid_span: cell.grid_span,
2823                            horizontal_merge: cell.horizontal_merge,
2824                            vertical_merge: cell.vertical_merge,
2825                        })
2826                    })
2827                    .collect::<Result<Vec<_>, ResolveError>>()?;
2828                Ok(ResolvedTableRow {
2829                    height: emu_to_points(row.height.0),
2830                    cells,
2831                })
2832            })
2833            .collect::<Result<Vec<_>, ResolveError>>()?;
2834        let background = match style.and_then(|style| style.background.as_ref()) {
2835            Some(background) => {
2836                let size = (
2837                    column_widths.iter().sum(),
2838                    rows.iter().map(|row| row.height).sum(),
2839                );
2840                self.resolve_table_background(background, size, diagnostics)?
2841            }
2842            None => None,
2843        };
2844        Ok(ResolvedTable {
2845            right_to_left: table_properties.is_some_and(|properties| properties.right_to_left),
2846            column_widths,
2847            rows,
2848            background,
2849        })
2850    }
2851
2852    /// Resolves a table style's `a:tblBg` fill over the whole table box.
2853    fn resolve_table_background(
2854        &self,
2855        background: &CT_TableBackgroundStyle,
2856        size: (f64, f64),
2857        diagnostics: &mut Vec<Diagnostic>,
2858    ) -> Result<Option<Paint>, ResolveError> {
2859        let referenced = background
2860            .fill_reference
2861            .as_ref()
2862            .map(|reference| self.table_referenced_fill(reference))
2863            .transpose()?
2864            .flatten();
2865        let (paint, unsupported) = referenced
2866            .as_ref()
2867            .or(background.fill.as_ref())
2868            .map(|fill| self.concrete_fill(fill, size))
2869            .transpose()?
2870            .unwrap_or((None, None));
2871        for unsupported in background
2872            .unsupported
2873            .iter()
2874            .map(String::as_str)
2875            .chain(unsupported)
2876        {
2877            let message = format!("unsupported table background {unsupported} was ignored");
2878            if !diagnostics
2879                .iter()
2880                .any(|diagnostic| diagnostic.message == message)
2881            {
2882                diagnostics.push(Diagnostic { message });
2883            }
2884        }
2885        Ok(paint)
2886    }
2887
2888    fn resolve_table_border(
2889        &self,
2890        border: Option<(CT_LineProperties, u8)>,
2891        diagnostics: &mut Vec<Diagnostic>,
2892    ) -> Result<Option<ResolvedTableBorder>, ResolveError> {
2893        border
2894            .map(|(line, priority)| {
2895                let stroke = self.concrete_line(&line, (1.0, 1.0))?;
2896                if stroke.is_none()
2897                    && let Some(fill) = &line.fill
2898                {
2899                    let (_, unsupported) = self.concrete_fill(fill, (1.0, 1.0))?;
2900                    if let Some(unsupported) = unsupported {
2901                        push_table_diagnostic(diagnostics, unsupported);
2902                    }
2903                }
2904                Ok(ResolvedTableBorder { stroke, priority })
2905            })
2906            .transpose()
2907    }
2908
2909    fn table_referenced_fill(
2910        &self,
2911        reference: &StyleReference,
2912    ) -> Result<Option<Fill>, ResolveError> {
2913        let StyleReference::Fill(reference) = reference else {
2914            return Ok(None);
2915        };
2916        let matrix = &self.theme.theme_elements.format_scheme;
2917        let Some(mut fill) = referenced_fill(
2918            reference.index,
2919            &matrix.fill_styles,
2920            &matrix.background_fill_styles,
2921        )?
2922        else {
2923            return Ok(None);
2924        };
2925        substitute_fill(&mut fill, reference.color.as_ref(), "table cell")?;
2926        Ok(Some(fill))
2927    }
2928}
2929
2930#[derive(Default)]
2931struct TableCellCascade {
2932    fill: Option<Fill>,
2933    fill_reference: Option<StyleReference>,
2934    left: Option<(CT_LineProperties, u8)>,
2935    right: Option<(CT_LineProperties, u8)>,
2936    top: Option<(CT_LineProperties, u8)>,
2937    bottom: Option<(CT_LineProperties, u8)>,
2938    margin_left: Option<i64>,
2939    margin_right: Option<i64>,
2940    margin_top: Option<i64>,
2941    margin_bottom: Option<i64>,
2942    text_style: TableTextCascade,
2943    unsupported: Vec<String>,
2944}
2945
2946#[derive(Default)]
2947struct TableTextCascade {
2948    bold: Option<bool>,
2949    italic: Option<bool>,
2950    font_collection: Option<FontCollectionIndex>,
2951    color: Option<ColorChoice>,
2952}
2953
2954#[derive(Clone, Copy)]
2955struct TableCellPosition {
2956    row: usize,
2957    column: usize,
2958    row_count: usize,
2959    column_count: usize,
2960}
2961
2962fn table_character_properties(
2963    style: &TableTextCascade,
2964) -> Result<CT_TextCharacterProperties, ResolveError> {
2965    let mut properties = CT_TextCharacterProperties::default();
2966    properties.bold = style.bold;
2967    properties.italic = style.italic;
2968    if let Some(color) = &style.color {
2969        let mut solid = SolidFill::default();
2970        solid.color = Some(color.clone());
2971        properties.fill = Some(Fill::Solid(solid));
2972    }
2973    let font_tokens = match style.font_collection {
2974        Some(FontCollectionIndex::Major) => Some(("+mj-lt", "+mj-ea", "+mj-cs")),
2975        Some(FontCollectionIndex::Minor) => Some(("+mn-lt", "+mn-ea", "+mn-cs")),
2976        Some(FontCollectionIndex::None) | None => None,
2977    };
2978    if let Some((latin, east_asian, complex_script)) = font_tokens {
2979        let font = |typeface| {
2980            TextFont::new(typeface).map_err(|error| ResolveError::ConcreteValue {
2981                kind: "table font",
2982                detail: error.to_string(),
2983            })
2984        };
2985        properties.latin = Some(font(latin)?);
2986        properties.east_asian = Some(font(east_asian)?);
2987        properties.complex_script = Some(font(complex_script)?);
2988    }
2989    Ok(properties)
2990}
2991
2992impl TableCellCascade {
2993    fn apply_region(
2994        &mut self,
2995        region: Option<&CT_TablePartStyle>,
2996        whole_table: bool,
2997        position: TableCellPosition,
2998        priority: u8,
2999    ) {
3000        let Some(region) = region else {
3001            return;
3002        };
3003        if let Some(cell_style) = &region.cell_style {
3004            self.apply_cell_style(cell_style, whole_table, position, priority);
3005        }
3006        if let Some(text_style) = &region.text_style {
3007            self.apply_text_style(text_style);
3008        }
3009    }
3010
3011    fn apply_cell_style(
3012        &mut self,
3013        style: &CT_TableCellStyle,
3014        whole_table: bool,
3015        position: TableCellPosition,
3016        priority: u8,
3017    ) {
3018        if let Some(fill) = &style.fill {
3019            self.fill = Some(fill.clone());
3020            self.fill_reference = None;
3021        }
3022        if let Some(reference) = &style.fill_reference {
3023            self.fill_reference = Some(reference.clone());
3024            self.fill = None;
3025        }
3026        if let Some(borders) = &style.borders {
3027            self.apply_borders(borders, whole_table, position, priority);
3028            self.unsupported.extend(borders.unsupported.iter().cloned());
3029        }
3030        self.unsupported.extend(style.unsupported.iter().cloned());
3031    }
3032
3033    fn apply_borders(
3034        &mut self,
3035        borders: &CT_TableBorders,
3036        whole_table: bool,
3037        position: TableCellPosition,
3038        priority: u8,
3039    ) {
3040        let left = if whole_table {
3041            if position.column > 0 {
3042                borders.inside_vertical.as_ref()
3043            } else {
3044                borders.left.as_ref()
3045            }
3046        } else {
3047            borders.left.as_ref().or(borders.inside_vertical.as_ref())
3048        };
3049        let right = if whole_table {
3050            if position.column + 1 < position.column_count {
3051                borders.inside_vertical.as_ref()
3052            } else {
3053                borders.right.as_ref()
3054            }
3055        } else {
3056            borders.right.as_ref().or(borders.inside_vertical.as_ref())
3057        };
3058        let top = if whole_table {
3059            if position.row > 0 {
3060                borders.inside_horizontal.as_ref()
3061            } else {
3062                borders.top.as_ref()
3063            }
3064        } else {
3065            borders.top.as_ref().or(borders.inside_horizontal.as_ref())
3066        };
3067        let bottom = if whole_table {
3068            if position.row + 1 < position.row_count {
3069                borders.inside_horizontal.as_ref()
3070            } else {
3071                borders.bottom.as_ref()
3072            }
3073        } else {
3074            borders
3075                .bottom
3076                .as_ref()
3077                .or(borders.inside_horizontal.as_ref())
3078        };
3079        apply_table_edge(&mut self.left, left, priority);
3080        apply_table_edge(&mut self.right, right, priority);
3081        apply_table_edge(&mut self.top, top, priority);
3082        apply_table_edge(&mut self.bottom, bottom, priority);
3083    }
3084
3085    fn apply_text_style(&mut self, style: &CT_TableTextStyle) {
3086        if style.bold.is_some() {
3087            self.text_style.bold = style.bold;
3088        }
3089        if style.italic.is_some() {
3090            self.text_style.italic = style.italic;
3091        }
3092        if let Some(StyleReference::Font(reference)) = &style.font_reference {
3093            self.text_style.font_collection = Some(reference.index);
3094            if reference.color.is_some() {
3095                self.text_style.color = reference.color.clone();
3096            }
3097        }
3098        if style.color.is_some() {
3099            self.text_style.color = style.color.clone();
3100        }
3101    }
3102
3103    fn apply_direct(&mut self, properties: &oxml_drawing::table::CT_TableCellProperties) {
3104        if let Some(fill) = &properties.fill {
3105            self.fill = Some(fill.clone());
3106            self.fill_reference = None;
3107        }
3108        if let Some(value) = properties.margin_left {
3109            self.margin_left = Some(value.0);
3110        }
3111        if let Some(value) = properties.margin_right {
3112            self.margin_right = Some(value.0);
3113        }
3114        if let Some(value) = properties.margin_top {
3115            self.margin_top = Some(value.0);
3116        }
3117        if let Some(value) = properties.margin_bottom {
3118            self.margin_bottom = Some(value.0);
3119        }
3120        apply_table_edge(&mut self.left, properties.left.as_ref(), u8::MAX);
3121        apply_table_edge(&mut self.right, properties.right.as_ref(), u8::MAX);
3122        apply_table_edge(&mut self.top, properties.top.as_ref(), u8::MAX);
3123        apply_table_edge(&mut self.bottom, properties.bottom.as_ref(), u8::MAX);
3124    }
3125}
3126
3127fn apply_table_edge(
3128    target: &mut Option<(CT_LineProperties, u8)>,
3129    source: Option<&CT_LineProperties>,
3130    priority: u8,
3131) {
3132    if let Some(source) = source {
3133        *target = Some((source.clone(), priority));
3134    }
3135}
3136
3137fn push_table_diagnostic(diagnostics: &mut Vec<Diagnostic>, unsupported: &str) {
3138    let message = format!("unsupported table cell {unsupported} was ignored");
3139    if !diagnostics
3140        .iter()
3141        .any(|diagnostic| diagnostic.message == message)
3142    {
3143        diagnostics.push(Diagnostic { message });
3144    }
3145}
3146
3147fn push_group_diagnostics(issues: u8, diagnostics: &mut Vec<Diagnostic>) {
3148    let messages = [
3149        (
3150            GROUP_ZERO_X,
3151            "zero horizontal group child extent used finite unit scale",
3152        ),
3153        (
3154            GROUP_ZERO_Y,
3155            "zero vertical group child extent used finite unit scale",
3156        ),
3157        (
3158            GROUP_SHEAR,
3159            "unsupported sheared or singular group transform retained as affine fallback",
3160        ),
3161    ];
3162    for (flag, message) in messages {
3163        if issues & flag != 0
3164            && !diagnostics
3165                .iter()
3166                .any(|diagnostic| diagnostic.message == message)
3167        {
3168            diagnostics.push(Diagnostic {
3169                message: message.to_owned(),
3170            });
3171        }
3172    }
3173}
3174
3175fn scaled_group_bounds(bounds: Rect, scale: (f64, f64)) -> Rect {
3176    Rect {
3177        x: bounds.x * scale.0,
3178        y: bounds.y * scale.1,
3179        width: bounds.width * scale.0,
3180        height: bounds.height * scale.1,
3181    }
3182}
3183
3184fn transform_values(transform: Option<&CT_Transform2D>) -> Option<(Rect, f64, bool, bool)> {
3185    transform_values_with_degenerate_line(transform, false)
3186}
3187
3188fn connector_transform_values(
3189    transform: Option<&CT_Transform2D>,
3190) -> Option<(Rect, f64, bool, bool)> {
3191    transform_values_with_degenerate_line(transform, true)
3192}
3193
3194fn transform_values_with_degenerate_line(
3195    transform: Option<&CT_Transform2D>,
3196    allow_degenerate_line: bool,
3197) -> Option<(Rect, f64, bool, bool)> {
3198    let transform = transform?;
3199    let extent = transform.extent?;
3200    let invalid = if allow_degenerate_line {
3201        extent.cx.0 < 0 || extent.cy.0 < 0 || (extent.cx.0 == 0 && extent.cy.0 == 0)
3202    } else {
3203        extent.cx.0 <= 0 || extent.cy.0 <= 0
3204    };
3205    if invalid {
3206        return None;
3207    }
3208    let offset = transform.offset.unwrap_or_default();
3209    Some((
3210        Rect {
3211            x: emu_to_points(offset.x.0),
3212            y: emu_to_points(offset.y.0),
3213            width: emu_to_points(extent.cx.0),
3214            height: emu_to_points(extent.cy.0),
3215        },
3216        f64::from(transform.rotation.0) / 60_000.0,
3217        transform.flip_horizontal,
3218        transform.flip_vertical,
3219    ))
3220}
3221
3222fn emu_to_points(value: i64) -> f64 {
3223    value as f64 / 12_700.0
3224}
3225
3226fn base_rgb(choice: &ColorChoice) -> Option<RgbColor> {
3227    let resolved = resolve_color(choice, &ColorMap::default(), &[]).ok()?;
3228    Some(RgbColor::new(resolved.red, resolved.green, resolved.blue))
3229}
3230
3231fn concrete_path_command(command: EvaluatedPathCommand, scale_x: f64, scale_y: f64) -> PathCommand {
3232    match command {
3233        EvaluatedPathCommand::MoveTo { x, y } => PathCommand::MoveTo(Point {
3234            x: x * scale_x,
3235            y: y * scale_y,
3236        }),
3237        EvaluatedPathCommand::LineTo { x, y } => PathCommand::LineTo(Point {
3238            x: x * scale_x,
3239            y: y * scale_y,
3240        }),
3241        EvaluatedPathCommand::CubicTo {
3242            x1,
3243            y1,
3244            x2,
3245            y2,
3246            x,
3247            y,
3248        } => PathCommand::CurveTo {
3249            c1: Point {
3250                x: x1 * scale_x,
3251                y: y1 * scale_y,
3252            },
3253            c2: Point {
3254                x: x2 * scale_x,
3255                y: y2 * scale_y,
3256            },
3257            to: Point {
3258                x: x * scale_x,
3259                y: y * scale_y,
3260            },
3261        },
3262        EvaluatedPathCommand::Close => PathCommand::Close,
3263    }
3264}
3265
3266fn coordinate_points(value: Option<&Coordinate32Value>) -> Result<f64, ResolveError> {
3267    match value {
3268        Some(Coordinate32Value::Emu(value)) => Ok(emu_to_points(i64::from(*value))),
3269        Some(Coordinate32Value::UniversalMeasure(value)) => universal_measure_points(value),
3270        None => Ok(0.0),
3271    }
3272}
3273
3274fn universal_measure_points(value: &str) -> Result<f64, ResolveError> {
3275    let split = value
3276        .find(|character: char| character.is_ascii_alphabetic())
3277        .unwrap_or(value.len());
3278    let (number, unit) = value.split_at(split);
3279    let number = number
3280        .parse::<f64>()
3281        .map_err(|error| ResolveError::ConcreteValue {
3282            kind: "universal measure",
3283            detail: error.to_string(),
3284        })?;
3285    match unit {
3286        "pt" => Ok(number),
3287        "pc" | "pi" => Ok(number * 12.0),
3288        "in" => Ok(number * 72.0),
3289        "cm" => Ok(number * 72.0 / 2.54),
3290        "mm" => Ok(number * 72.0 / 25.4),
3291        _ => Err(ResolveError::ConcreteValue {
3292            kind: "universal measure",
3293            detail: format!("unsupported unit {unit}"),
3294        }),
3295    }
3296}
3297
3298fn parse_percent(value: &str) -> Option<f64> {
3299    value.parse::<f64>().ok().map(|value| value / 100_000.0)
3300}
3301
3302fn text_point_value(value: &TextPointValue) -> Option<f64> {
3303    match value {
3304        TextPointValue::Centipoints(value) => Some(f64::from(*value) / 100.0),
3305        TextPointValue::UniversalMeasure(value) => universal_measure_points(value).ok(),
3306    }
3307}
3308
3309fn resolved_text_spacing(value: Option<&TextSpacing>) -> Option<ResolvedTextSpacing> {
3310    match value? {
3311        TextSpacing::Percent(value) => parse_percent(value).map(ResolvedTextSpacing::Percent),
3312        TextSpacing::Points(value) => Some(ResolvedTextSpacing::Points(f64::from(*value) / 100.0)),
3313    }
3314}
3315
3316fn paragraph_alignment(alignment: Option<TextAlignment>) -> ParagraphAlignment {
3317    match alignment.unwrap_or(TextAlignment::Left) {
3318        TextAlignment::Left => ParagraphAlignment::Left,
3319        TextAlignment::Center => ParagraphAlignment::Center,
3320        TextAlignment::Right => ParagraphAlignment::Right,
3321        TextAlignment::Justified | TextAlignment::JustifiedLow => ParagraphAlignment::Justified,
3322        TextAlignment::Distributed | TextAlignment::ThaiDistributed => {
3323            ParagraphAlignment::Distributed
3324        }
3325    }
3326}
3327
3328fn paragraph_base_direction(right_to_left: Option<bool>) -> oxml_layout::TextDirection {
3329    match right_to_left {
3330        Some(true) => oxml_layout::TextDirection::RightToLeft,
3331        Some(false) => oxml_layout::TextDirection::LeftToRight,
3332        None => oxml_layout::TextDirection::Auto,
3333    }
3334}
3335
3336fn vertical_text_diagnostic(direction: TextDirection) -> Option<&'static str> {
3337    match direction {
3338        TextDirection::EastAsianVertical => {
3339            Some("east Asian vertical text rendered as rotated vertical text")
3340        }
3341        TextDirection::MongolianVertical => {
3342            Some("Mongolian vertical text rendered as rotated vertical-270 text")
3343        }
3344        TextDirection::WordArtVertical => {
3345            Some("WordArt vertical text rendered as rotated vertical text")
3346        }
3347        TextDirection::WordArtVerticalRtl => {
3348            Some("right-to-left WordArt vertical text rendered as rotated vertical-270 text")
3349        }
3350        TextDirection::Horizontal | TextDirection::Vertical | TextDirection::Vertical270 => None,
3351    }
3352}
3353
3354fn resolve_standalone_text_body(
3355    context: &ResolveCtx<'_>,
3356    body: &CT_TextBody,
3357    table_style: &CT_TextCharacterProperties,
3358    source: FlattenedSource,
3359    hyperlinks: Option<&ScopedHyperlinkTargets>,
3360    diagnostics: &mut Vec<Diagnostic>,
3361) -> Result<(ResolvedTextBody, Vec<oxml_layout::TextDirection>), ResolveError> {
3362    let mut properties = default_body_properties();
3363    merge_body_properties(&mut properties, &body.body_properties);
3364    let resolved = body
3365        .paragraphs()
3366        .iter()
3367        .map(|paragraph| {
3368            let effective = context.effective_table_text_properties(
3369                Some(table_style),
3370                paragraph.properties.as_ref(),
3371                None,
3372            );
3373            let paragraph_properties = &effective.paragraph;
3374            let runs = paragraph
3375                .runs
3376                .iter()
3377                .map(|run| match run {
3378                    TextRun::Run(run) => {
3379                        let mut style = context.resolve_table_run_style(
3380                            table_style,
3381                            paragraph.properties.as_ref(),
3382                            run.properties.as_ref(),
3383                        )?;
3384                        style.hyperlink_url = resolve_direct_hyperlink(
3385                            run.properties.as_ref(),
3386                            source,
3387                            hyperlinks,
3388                            diagnostics,
3389                        );
3390                        Ok(ResolvedTextRun::Text {
3391                            text: run.text.value.clone(),
3392                            style,
3393                        })
3394                    }
3395                    TextRun::Break(_) => Ok(ResolvedTextRun::Break),
3396                    TextRun::Field(field) => {
3397if field.text.is_none()
3398                        && field.field_type.as_deref().is_some_and(|kind| kind == "datetime" || (1..=13).any(|index| kind == format!("datetime{index}")))
3399                    {
3400                        diagnostics.push(Diagnostic { message: "uncached automatic date field has no computed date for static rendering".to_owned() });
3401                    }
3402                        let mut style = context.resolve_table_run_style(
3403                            table_style,
3404                            paragraph.properties.as_ref(),
3405                            field.run_properties.as_ref(),
3406                        )?;
3407                        style.hyperlink_url = resolve_direct_hyperlink(
3408                            field.run_properties.as_ref(),
3409                            source,
3410                            hyperlinks,
3411                            diagnostics,
3412                        );
3413                        Ok(ResolvedTextRun::Field {
3414                            text: field
3415                                .text
3416                                .as_ref()
3417                                .map(|text| text.value.clone())
3418                                .unwrap_or_default(),
3419                            field_type: field.field_type.clone(),
3420                            style,
3421                        })
3422                    }
3423                })
3424                .collect::<Result<Vec<_>, ResolveError>>()?;
3425            let direction = paragraph_base_direction(paragraph_properties.right_to_left);
3426            Ok((
3427                ResolvedParagraph {
3428                    level: paragraph_properties.level.unwrap_or(0),
3429                    left_margin: emu_to_points(i64::from(
3430                        paragraph_properties.left_margin.unwrap_or(0),
3431                    )),
3432                    right_margin: emu_to_points(i64::from(
3433                        paragraph_properties.right_margin.unwrap_or(0),
3434                    )),
3435                    indent: emu_to_points(i64::from(paragraph_properties.indent.unwrap_or(0))),
3436                    alignment: paragraph_alignment(paragraph_properties.alignment),
3437                    line_spacing: resolved_text_spacing(paragraph_properties.line_spacing.as_ref()),
3438                    space_before: resolved_text_spacing(paragraph_properties.space_before.as_ref()),
3439                    space_after: resolved_text_spacing(paragraph_properties.space_after.as_ref()),
3440                    bullet: paragraph_properties
3441                        .bullet
3442                        .as_ref()
3443                        .and_then(|bullet| context.resolve_bullet(bullet).transpose())
3444                        .transpose()?,
3445                    end_style: context.resolve_table_run_style(
3446                        table_style,
3447                        paragraph.properties.as_ref(),
3448                        paragraph.end_properties.as_ref(),
3449                    )?,
3450                    runs,
3451                },
3452                direction,
3453            ))
3454        })
3455        .collect::<Result<Vec<_>, ResolveError>>()?;
3456    let (paragraphs, directions) = resolved.into_iter().unzip();
3457    Ok((resolved_text_body(&properties, paragraphs)?, directions))
3458}
3459
3460fn resolved_anchor(anchor: DrawingTextAnchor) -> TextAnchor {
3461    match anchor {
3462        DrawingTextAnchor::Top => TextAnchor::Top,
3463        DrawingTextAnchor::Center => TextAnchor::Center,
3464        DrawingTextAnchor::Bottom => TextAnchor::Bottom,
3465        DrawingTextAnchor::Justified => TextAnchor::Justified,
3466        DrawingTextAnchor::Distributed => TextAnchor::Distributed,
3467    }
3468}
3469
3470fn resolved_text_body(
3471    properties: &CT_TextBodyProperties,
3472    paragraphs: Vec<ResolvedParagraph>,
3473) -> Result<ResolvedTextBody, ResolveError> {
3474    let insets = TextInsets {
3475        left: coordinate_points(properties.left_inset.as_ref())?,
3476        top: coordinate_points(properties.top_inset.as_ref())?,
3477        right: coordinate_points(properties.right_inset.as_ref())?,
3478        bottom: coordinate_points(properties.bottom_inset.as_ref())?,
3479    };
3480    let anchor = resolved_anchor(properties.anchor.unwrap_or(DrawingTextAnchor::Top));
3481    let vertical = match properties
3482        .vertical
3483        .unwrap_or(DrawingTextVertical::Horizontal)
3484    {
3485        DrawingTextVertical::Horizontal => TextDirection::Horizontal,
3486        DrawingTextVertical::Vertical => TextDirection::Vertical,
3487        DrawingTextVertical::Vertical270 => TextDirection::Vertical270,
3488        DrawingTextVertical::EastAsianVertical => TextDirection::EastAsianVertical,
3489        DrawingTextVertical::MongolianVertical => TextDirection::MongolianVertical,
3490        DrawingTextVertical::WordArtVertical => TextDirection::WordArtVertical,
3491        DrawingTextVertical::WordArtVerticalRtl => TextDirection::WordArtVerticalRtl,
3492    };
3493    let autofit = match properties.autofit.clone().unwrap_or(TextAutofit::NoAutofit) {
3494        TextAutofit::NoAutofit => ResolvedAutofit::None,
3495        TextAutofit::ShapeAutofit => ResolvedAutofit::Shape,
3496        TextAutofit::Normal(normal) => ResolvedAutofit::Normal {
3497            font_scale: normal.font_scale.as_deref().and_then(parse_percent),
3498            line_spacing_reduction: normal
3499                .line_spacing_reduction
3500                .as_deref()
3501                .and_then(parse_percent),
3502        },
3503    };
3504    Ok(ResolvedTextBody {
3505        insets,
3506        anchor,
3507        wrap: properties.wrap.unwrap_or(TextWrap::Square) != TextWrap::None,
3508        vertical,
3509        space_first_last_paragraph: properties.space_first_last_paragraph.unwrap_or(false),
3510        autofit,
3511        paragraphs,
3512    })
3513}
3514
3515#[derive(Clone, Copy)]
3516struct PassRules {
3517    source: FlattenedSource,
3518    emit_non_placeholders: bool,
3519}
3520
3521fn push_unmatched(keys: &mut Vec<PlaceholderKey>, key: PlaceholderKey) {
3522    if !keys
3523        .iter()
3524        .any(|existing| placeholder_keys_match(&key, existing))
3525    {
3526        keys.push(key);
3527    }
3528}
3529
3530fn find_group_lineage(
3531    children: &[ShapeTreeChild],
3532    target: &ShapeTreeChild,
3533    lineage: &[u32],
3534    found: &mut Vec<u32>,
3535) -> bool {
3536    let mut pending = children
3537        .iter()
3538        .rev()
3539        .map(|c| (c, lineage.to_vec()))
3540        .collect::<Vec<_>>();
3541    while let Some((child, mut ancestors)) = pending.pop() {
3542        if std::ptr::eq(child, target) {
3543            *found = ancestors;
3544            return true;
3545        }
3546        let descendants = match child {
3547            ShapeTreeChild::GroupShape(group) => {
3548                if let Some(id) = child.non_visual_id() {
3549                    ancestors.push(id);
3550                }
3551                Some(group.children.as_slice())
3552            }
3553            ShapeTreeChild::AlternateContent(alternate) => alternate.selected_fallback(),
3554            _ => None,
3555        };
3556        if let Some(descendants) = descendants {
3557            pending.extend(descendants.iter().rev().map(|c| (c, ancestors.clone())));
3558        }
3559    }
3560    false
3561}
3562
3563fn apply_timeline_state(
3564    mut slide: ResolvedSlide,
3565    identities: Vec<ResolvedShapeIdentity>,
3566    mut state: EvaluatedFrameState,
3567    mut group_geometries: BTreeMap<u32, TimelineGroupGeometry>,
3568) -> ResolvedTimelineSlide {
3569    let invalid_targets = state
3570        .shapes
3571        .iter()
3572        .filter_map(|(target, evaluated)| (!evaluated.is_finite()).then_some(*target))
3573        .collect::<Vec<_>>();
3574    for target in invalid_targets {
3575        state.shapes.insert(target, EvaluatedShapeState::default());
3576        state.diagnostics.push(Diagnostic {
3577            message: format!("non-finite timeline state ignored for target {target}"),
3578        });
3579    }
3580    let slide_size = slide.size;
3581    for (group_id, bounds) in timeline_group_bounds(&slide, &identities) {
3582        group_geometries
3583            .entry(group_id)
3584            .or_insert_with(|| TimelineGroupGeometry::from_page_bounds(bounds));
3585    }
3586    let shape_states = identities
3587        .iter()
3588        .zip(&mut slide.shapes)
3589        .map(|(identity, shape)| {
3590            if identity.source != FlattenedSource::Slide {
3591                return EvaluatedShapeState::default();
3592            }
3593            let mut evaluated = EvaluatedShapeState::default();
3594            let original_bounds = shape.bounds;
3595            let original_shape = shape.clone();
3596            let mut group_transform = Transform::IDENTITY;
3597            let mut group_page_clips = Vec::new();
3598            for group_id in &identity.containing_group_ids {
3599                let Some(target_state) = state.shapes.get(group_id) else {
3600                    continue;
3601                };
3602                evaluated.visible &= target_state.visible;
3603                evaluated.opacity *= target_state.opacity;
3604                let Some(geometry) = group_geometries.get(group_id).copied() else {
3605                    continue;
3606                };
3607                let target_transform =
3608                    target_state.resolved_page_transform(geometry.center(), slide_size);
3609                group_transform = target_transform.then(group_transform);
3610                if let Some(clip) = target_state.clip {
3611                    group_page_clips.push(group_clip_page_points(clip, geometry, group_transform));
3612                }
3613            }
3614            let direct_state = identity.shape_id.and_then(|id| state.shapes.get(&id));
3615            if let Some(direct_state) = direct_state {
3616                evaluated.visible &= direct_state.visible;
3617                evaluated.opacity *= direct_state.opacity;
3618                evaluated.clip = intersect_optional_rect(evaluated.clip, direct_state.clip);
3619            }
3620            let (scale_x, scale_y, rotation_deg) = direct_state.map_or(
3621                (1.0, 1.0, 0.0),
3622                EvaluatedShapeState::resolved_shape_geometry,
3623            );
3624            let original_page_bounds = resolved_shape_page_bounds(&original_shape);
3625            let original_page_center = (
3626                original_page_bounds.x + original_page_bounds.width / 2.0,
3627                original_page_bounds.y + original_page_bounds.height / 2.0,
3628            );
3629            let page_motion = direct_state.map_or(Transform::IDENTITY, |direct_state| {
3630                direct_state.resolved_motion_translation(original_page_center, slide_size)
3631            });
3632            evaluated.transform = page_motion.then(group_transform);
3633            let animated_bounds = Rect {
3634                x: original_bounds.x + original_bounds.width * (1.0 - scale_x) / 2.0,
3635                y: original_bounds.y + original_bounds.height * (1.0 - scale_y) / 2.0,
3636                width: original_bounds.width * scale_x,
3637                height: original_bounds.height * scale_y,
3638            };
3639            let animated_rotation = shape.rotation_deg + rotation_deg;
3640            let mut animated_shape = shape.clone();
3641            animated_shape.bounds = animated_bounds;
3642            animated_shape.rotation_deg = animated_rotation;
3643            animated_shape.group_transform = animated_shape
3644                .group_transform
3645                .then(page_motion)
3646                .then(group_transform);
3647            for page_clip in group_page_clips {
3648                match page_clip_for_shape(page_clip, &animated_shape) {
3649                    Some(clip) => evaluated.push_oriented_clip(clip),
3650                    None => state.diagnostics.push(Diagnostic {
3651                        message: format!(
3652                            "group timeline clip ignored for shape {}",
3653                            identity.shape_id.unwrap_or(0)
3654                        ),
3655                    }),
3656                }
3657            }
3658            if evaluated.is_finite()
3659                && [
3660                    animated_bounds.x,
3661                    animated_bounds.y,
3662                    animated_bounds.width,
3663                    animated_bounds.height,
3664                    animated_rotation,
3665                ]
3666                .into_iter()
3667                .all(f64::is_finite)
3668            {
3669                *shape = animated_shape;
3670            } else {
3671                state.diagnostics.push(Diagnostic {
3672                    message: format!(
3673                        "non-finite timeline state ignored for shape {}",
3674                        identity.shape_id.unwrap_or(0)
3675                    ),
3676                });
3677                evaluated = EvaluatedShapeState::default();
3678            }
3679            evaluated
3680        })
3681        .collect();
3682    ResolvedTimelineSlide {
3683        slide,
3684        identities,
3685        shape_states,
3686        state,
3687    }
3688}
3689
3690#[derive(Clone, Copy)]
3691struct TimelineGroupGeometry {
3692    normalized_to_page: Transform,
3693}
3694
3695impl TimelineGroupGeometry {
3696    fn from_page_bounds(bounds: Rect) -> Self {
3697        Self {
3698            normalized_to_page: Transform {
3699                a: bounds.width,
3700                d: bounds.height,
3701                e: bounds.x,
3702                f: bounds.y,
3703                ..Transform::IDENTITY
3704            },
3705        }
3706    }
3707
3708    fn center(self) -> (f64, f64) {
3709        let center = self.normalized_to_page.apply(Point { x: 0.5, y: 0.5 });
3710        (center.x, center.y)
3711    }
3712}
3713
3714fn timeline_group_geometries(children: &[ShapeTreeChild]) -> BTreeMap<u32, TimelineGroupGeometry> {
3715    let mut geometries = BTreeMap::new();
3716    collect_timeline_group_geometries(children, Transform::IDENTITY, &mut geometries);
3717    geometries
3718}
3719
3720fn collect_timeline_group_geometries(
3721    children: &[ShapeTreeChild],
3722    parent_to_page: Transform,
3723    geometries: &mut BTreeMap<u32, TimelineGroupGeometry>,
3724) {
3725    let mut pending = children
3726        .iter()
3727        .rev()
3728        .map(|c| (c, parent_to_page))
3729        .collect::<Vec<_>>();
3730    while let Some((child, parent)) = pending.pop() {
3731        match child {
3732            ShapeTreeChild::GroupShape(group) => {
3733                let child_to_parent = group
3734                    .group_transform()
3735                    .and_then(group_affine)
3736                    .map_or(Transform::IDENTITY, |(transform, _)| transform);
3737                if let Some((id, geometry)) = child
3738                    .non_visual_id()
3739                    .zip(group.group_transform().and_then(group_extent_geometry))
3740                {
3741                    geometries.insert(
3742                        id,
3743                        TimelineGroupGeometry {
3744                            normalized_to_page: geometry.then(parent),
3745                        },
3746                    );
3747                }
3748                pending.extend(
3749                    group
3750                        .children
3751                        .iter()
3752                        .rev()
3753                        .map(|c| (c, child_to_parent.then(parent))),
3754                );
3755            }
3756            ShapeTreeChild::AlternateContent(alternate) => {
3757                if let Some(fallback) = alternate.selected_fallback() {
3758                    pending.extend(fallback.iter().rev().map(|c| (c, parent)));
3759                }
3760            }
3761            _ => {}
3762        }
3763    }
3764}
3765
3766fn group_extent_geometry(transform: &CT_Transform2D) -> Option<Transform> {
3767    let offset = transform.offset?;
3768    let extent = transform.extent?;
3769    let x = emu_to_points(offset.x.0);
3770    let y = emu_to_points(offset.y.0);
3771    let width = emu_to_points(extent.cx.0);
3772    let height = emu_to_points(extent.cy.0);
3773    let center_x = x + width / 2.0;
3774    let center_y = y + height / 2.0;
3775    let mut geometry = Transform {
3776        a: width,
3777        d: height,
3778        e: x,
3779        f: y,
3780        ..Transform::IDENTITY
3781    }
3782    .then(Transform::rotate_about(
3783        f64::from(transform.rotation.0) / 60_000.0,
3784        center_x,
3785        center_y,
3786    ));
3787    if transform.flip_horizontal || transform.flip_vertical {
3788        geometry = geometry.then(Transform {
3789            a: if transform.flip_horizontal { -1.0 } else { 1.0 },
3790            d: if transform.flip_vertical { -1.0 } else { 1.0 },
3791            e: if transform.flip_horizontal {
3792                2.0 * center_x
3793            } else {
3794                0.0
3795            },
3796            f: if transform.flip_vertical {
3797                2.0 * center_y
3798            } else {
3799                0.0
3800            },
3801            ..Transform::IDENTITY
3802        });
3803    }
3804    [
3805        geometry.a, geometry.b, geometry.c, geometry.d, geometry.e, geometry.f,
3806    ]
3807    .into_iter()
3808    .all(f64::is_finite)
3809    .then_some(geometry)
3810}
3811
3812fn timeline_group_bounds(
3813    slide: &ResolvedSlide,
3814    identities: &[ResolvedShapeIdentity],
3815) -> BTreeMap<u32, Rect> {
3816    let mut bounds = BTreeMap::new();
3817    for (identity, shape) in identities.iter().zip(&slide.shapes) {
3818        if identity.source != FlattenedSource::Slide {
3819            continue;
3820        }
3821        let shape_bounds = resolved_shape_page_bounds(shape);
3822        for group_id in &identity.containing_group_ids {
3823            bounds
3824                .entry(*group_id)
3825                .and_modify(|group_bounds| *group_bounds = union_rect(*group_bounds, shape_bounds))
3826                .or_insert(shape_bounds);
3827        }
3828    }
3829    bounds
3830}
3831
3832fn resolved_shape_page_bounds(shape: &ResolvedShape) -> Rect {
3833    resolved_shape_page_transform(shape).transform_rect_bbox(Rect {
3834        x: 0.0,
3835        y: 0.0,
3836        width: shape.bounds.width,
3837        height: shape.bounds.height,
3838    })
3839}
3840
3841fn resolved_shape_page_transform(shape: &ResolvedShape) -> Transform {
3842    let center_x = shape.bounds.width / 2.0;
3843    let center_y = shape.bounds.height / 2.0;
3844    let flip = Transform {
3845        a: if shape.flip_h { -1.0 } else { 1.0 },
3846        d: if shape.flip_v { -1.0 } else { 1.0 },
3847        e: if shape.flip_h {
3848            shape.bounds.width
3849        } else {
3850            0.0
3851        },
3852        f: if shape.flip_v {
3853            shape.bounds.height
3854        } else {
3855            0.0
3856        },
3857        ..Transform::IDENTITY
3858    };
3859    Transform::rotate_about(shape.rotation_deg, center_x, center_y)
3860        .then(flip)
3861        .then(timeline_translation(shape.bounds.x, shape.bounds.y))
3862        .then(shape.group_transform)
3863}
3864
3865fn group_clip_page_points(
3866    clip: Rect,
3867    group_geometry: TimelineGroupGeometry,
3868    group_transform: Transform,
3869) -> [Point; 4] {
3870    let normalized_to_page = group_geometry.normalized_to_page.then(group_transform);
3871    [
3872        Point {
3873            x: clip.x,
3874            y: clip.y,
3875        },
3876        Point {
3877            x: clip.x + clip.width,
3878            y: clip.y,
3879        },
3880        Point {
3881            x: clip.x + clip.width,
3882            y: clip.y + clip.height,
3883        },
3884        Point {
3885            x: clip.x,
3886            y: clip.y + clip.height,
3887        },
3888    ]
3889    .map(|point| normalized_to_page.apply(point))
3890}
3891
3892fn page_clip_for_shape(page_clip: [Point; 4], shape: &ResolvedShape) -> Option<[Point; 4]> {
3893    if shape.bounds.width.abs() <= f64::EPSILON || shape.bounds.height.abs() <= f64::EPSILON {
3894        return None;
3895    }
3896    let page_to_local = inverse_transform(resolved_shape_page_transform(shape))?;
3897    Some(page_clip.map(|point| {
3898        let local = page_to_local.apply(point);
3899        Point {
3900            x: local.x / shape.bounds.width,
3901            y: local.y / shape.bounds.height,
3902        }
3903    }))
3904}
3905
3906fn inverse_transform(transform: Transform) -> Option<Transform> {
3907    let determinant = transform.a * transform.d - transform.b * transform.c;
3908    if !determinant.is_finite() || determinant.abs() <= f64::EPSILON {
3909        return None;
3910    }
3911    let inverse = Transform {
3912        a: transform.d / determinant,
3913        b: -transform.b / determinant,
3914        c: -transform.c / determinant,
3915        d: transform.a / determinant,
3916        e: (transform.c * transform.f - transform.d * transform.e) / determinant,
3917        f: (transform.b * transform.e - transform.a * transform.f) / determinant,
3918    };
3919    [
3920        inverse.a, inverse.b, inverse.c, inverse.d, inverse.e, inverse.f,
3921    ]
3922    .into_iter()
3923    .all(f64::is_finite)
3924    .then_some(inverse)
3925}
3926
3927fn union_rect(left: Rect, right: Rect) -> Rect {
3928    let x = left.x.min(right.x);
3929    let y = left.y.min(right.y);
3930    let far_x = (left.x + left.width).max(right.x + right.width);
3931    let far_y = (left.y + left.height).max(right.y + right.height);
3932    Rect {
3933        x,
3934        y,
3935        width: far_x - x,
3936        height: far_y - y,
3937    }
3938}
3939
3940fn intersect_rect(left: Rect, right: Rect) -> Rect {
3941    let x = left.x.max(right.x);
3942    let y = left.y.max(right.y);
3943    let far_x = (left.x + left.width).min(right.x + right.width);
3944    let far_y = (left.y + left.height).min(right.y + right.height);
3945    Rect {
3946        x,
3947        y,
3948        width: (far_x - x).max(0.0),
3949        height: (far_y - y).max(0.0),
3950    }
3951}
3952
3953fn timeline_translation(x: f64, y: f64) -> Transform {
3954    Transform {
3955        e: x,
3956        f: y,
3957        ..Transform::IDENTITY
3958    }
3959}
3960
3961fn intersect_optional_rect(left: Option<Rect>, right: Option<Rect>) -> Option<Rect> {
3962    match (left, right) {
3963        (None, None) => None,
3964        (Some(rect), None) | (None, Some(rect)) => Some(rect),
3965        (Some(left), Some(right)) => Some(intersect_rect(left, right)),
3966    }
3967}
3968
3969fn collect_owned_items<'a>(
3970    children: &'a [ShapeTreeChild],
3971    source: FlattenedSource,
3972    parent: Transform,
3973    issues: u8,
3974    path: &[usize],
3975    output: &mut Vec<(Vec<usize>, FlattenedItem<'a>)>,
3976) {
3977    let mut pending = children
3978        .iter()
3979        .enumerate()
3980        .rev()
3981        .map(|(ordinal, c)| {
3982            let mut own_path = path.to_vec();
3983            own_path.push(ordinal);
3984            (c, parent, issues, own_path)
3985        })
3986        .collect::<Vec<_>>();
3987    while let Some((child, parent, issues, path)) = pending.pop() {
3988        if let ShapeTreeChild::GroupShape(group) = child {
3989            let (transform, own_issues) = group
3990                .group_transform()
3991                .and_then(group_affine)
3992                .unwrap_or((Transform::IDENTITY, GROUP_ZERO_X | GROUP_ZERO_Y));
3993            pending.extend(group.children.iter().enumerate().rev().map(|(ordinal, c)| {
3994                let mut own_path = path.clone();
3995                own_path.push(ordinal);
3996                (c, transform.then(parent), issues | own_issues, own_path)
3997            }));
3998        } else {
3999            let mut leaf = Vec::new();
4000            push_flattened_shape(&mut leaf, source, child, parent, issues);
4001            output.push((path, leaf.remove(0)));
4002        }
4003    }
4004}
4005
4006fn emit_tree<'a>(
4007    children: &'a [ShapeTreeChild],
4008    rules: PassRules,
4009    output: &mut Vec<FlattenedItem<'a>>,
4010) {
4011    let mut emitted_latent = Vec::new();
4012    emit_tree_inner(
4013        children,
4014        rules,
4015        Transform::IDENTITY,
4016        0,
4017        &mut emitted_latent,
4018        output,
4019    );
4020}
4021
4022fn emit_tree_inner<'a>(
4023    children: &'a [ShapeTreeChild],
4024    rules: PassRules,
4025    parent_transform: Transform,
4026    parent_issues: u8,
4027    emitted_latent: &mut Vec<PlaceholderKey>,
4028    output: &mut Vec<FlattenedItem<'a>>,
4029) {
4030    let mut pending = children
4031        .iter()
4032        .rev()
4033        .map(|c| (c, parent_transform, parent_issues))
4034        .collect::<Vec<_>>();
4035    while let Some((child, parent, inherited_issues)) = pending.pop() {
4036        match child {
4037            ShapeTreeChild::GroupShape(group) => {
4038                let (transform, issues) = group
4039                    .group_transform()
4040                    .and_then(group_affine)
4041                    .unwrap_or((Transform::IDENTITY, 0));
4042                pending.extend(
4043                    group
4044                        .children
4045                        .iter()
4046                        .rev()
4047                        .map(|c| (c, transform.then(parent), inherited_issues | issues)),
4048                );
4049            }
4050            ShapeTreeChild::AlternateContent(alternate) if alternate.chart_choice().is_none() => {
4051                if let Some(fallback) = alternate.selected_fallback() {
4052                    pending.extend(fallback.iter().rev().map(|c| (c, parent, inherited_issues)));
4053                }
4054            }
4055            _ => emit_leaf(
4056                child,
4057                rules,
4058                parent,
4059                inherited_issues,
4060                emitted_latent,
4061                output,
4062            ),
4063        }
4064    }
4065}
4066
4067fn emit_leaf<'a>(
4068    child: &'a ShapeTreeChild,
4069    rules: PassRules,
4070    group_transform: Transform,
4071    group_issues: u8,
4072    emitted_latent: &mut Vec<PlaceholderKey>,
4073    output: &mut Vec<FlattenedItem<'a>>,
4074) {
4075    let placeholder = child_placeholder(child);
4076    if rules.source == FlattenedSource::Slide {
4077        if let Some(placeholder) = placeholder {
4078            let ph_type = placeholder.effective_type();
4079            if is_latent(&ph_type) {
4080                let occupied = child_is_occupied(child);
4081                let already_emitted = emitted_latent
4082                    .iter()
4083                    .any(|key| placeholder_keys_match(&placeholder.key(), key));
4084                if !occupied || already_emitted {
4085                    return;
4086                }
4087                push_unmatched(emitted_latent, placeholder.key());
4088            }
4089        }
4090        push_flattened_shape(output, rules.source, child, group_transform, group_issues);
4091        return;
4092    }
4093
4094    let Some(placeholder) = placeholder else {
4095        if rules.emit_non_placeholders {
4096            push_flattened_shape(output, rules.source, child, group_transform, group_issues);
4097        }
4098        return;
4099    };
4100    // Native occupied master/layout footer shapes are authoring templates,
4101    // never inherited visible content. Ordinary master/layout shapes above
4102    // retain their existing showMasterSp policy.
4103    let _ = (placeholder, emitted_latent);
4104}
4105
4106fn push_flattened_shape<'a>(
4107    output: &mut Vec<FlattenedItem<'a>>,
4108    source: FlattenedSource,
4109    child: &'a ShapeTreeChild,
4110    group_transform: Transform,
4111    group_issues: u8,
4112) {
4113    let (group_scale, group_transform, group_issues) =
4114        split_group_transform(group_transform, group_issues);
4115    output.push(FlattenedItem::Shape {
4116        source,
4117        child,
4118        group_scale,
4119        group_transform,
4120        group_issues,
4121    });
4122}
4123
4124fn split_group_transform(transform: Transform, mut issues: u8) -> ((f64, f64), Transform, u8) {
4125    let scale_x = transform.a.hypot(transform.b);
4126    let scale_y = transform.c.hypot(transform.d);
4127    let dot = transform.a * transform.c + transform.b * transform.d;
4128    let denominator = scale_x * scale_y;
4129    let is_rigid_after_scale = [
4130        transform.a,
4131        transform.b,
4132        transform.c,
4133        transform.d,
4134        transform.e,
4135        transform.f,
4136        scale_x,
4137        scale_y,
4138    ]
4139    .into_iter()
4140    .all(f64::is_finite)
4141        && scale_x > f64::EPSILON
4142        && scale_y > f64::EPSILON
4143        && (dot / denominator).abs() <= 1.0e-9;
4144
4145    if !is_rigid_after_scale {
4146        issues |= GROUP_SHEAR;
4147        return ((1.0, 1.0), transform, issues);
4148    }
4149
4150    (
4151        (scale_x, scale_y),
4152        Transform {
4153            a: transform.a / scale_x,
4154            b: transform.b / scale_x,
4155            c: transform.c / scale_y,
4156            d: transform.d / scale_y,
4157            e: transform.e,
4158            f: transform.f,
4159        },
4160        issues,
4161    )
4162}
4163
4164fn group_affine(transform: &CT_Transform2D) -> Option<(Transform, u8)> {
4165    let offset = transform.offset?;
4166    let extent = transform.extent?;
4167    let child_offset = transform.child_offset?;
4168    let child_extent = transform.child_extent?;
4169    let mut issues = 0;
4170    let x = emu_to_points(offset.x.0);
4171    let y = emu_to_points(offset.y.0);
4172    let width = emu_to_points(extent.cx.0);
4173    let height = emu_to_points(extent.cy.0);
4174    let child_x = emu_to_points(child_offset.x.0);
4175    let child_y = emu_to_points(child_offset.y.0);
4176    let scale_x = if child_extent.cx.0 == 0 {
4177        issues |= GROUP_ZERO_X;
4178        1.0
4179    } else {
4180        extent.cx.0 as f64 / child_extent.cx.0 as f64
4181    };
4182    let scale_y = if child_extent.cy.0 == 0 {
4183        issues |= GROUP_ZERO_Y;
4184        1.0
4185    } else {
4186        extent.cy.0 as f64 / child_extent.cy.0 as f64
4187    };
4188    let mut affine = Transform {
4189        a: scale_x,
4190        b: 0.0,
4191        c: 0.0,
4192        d: scale_y,
4193        e: x - child_x * scale_x,
4194        f: y - child_y * scale_y,
4195    };
4196    let center_x = x + width / 2.0;
4197    let center_y = y + height / 2.0;
4198    if transform.flip_horizontal || transform.flip_vertical {
4199        affine = affine.then(Transform {
4200            a: if transform.flip_horizontal { -1.0 } else { 1.0 },
4201            b: 0.0,
4202            c: 0.0,
4203            d: if transform.flip_vertical { -1.0 } else { 1.0 },
4204            e: if transform.flip_horizontal {
4205                2.0 * center_x
4206            } else {
4207                0.0
4208            },
4209            f: if transform.flip_vertical {
4210                2.0 * center_y
4211            } else {
4212                0.0
4213            },
4214        });
4215    }
4216    affine = affine.then(Transform::rotate_about(
4217        f64::from(transform.rotation.0) / 60_000.0,
4218        center_x,
4219        center_y,
4220    ));
4221    Some((affine, issues))
4222}
4223
4224fn child_placeholder(child: &ShapeTreeChild) -> Option<&CT_Placeholder> {
4225    match child {
4226        ShapeTreeChild::Shape(shape) => shape.placeholder.as_ref(),
4227        ShapeTreeChild::Picture(picture) => picture.placeholder.as_ref(),
4228        _ => None,
4229    }
4230}
4231
4232fn child_is_occupied(child: &ShapeTreeChild) -> bool {
4233    match child {
4234        ShapeTreeChild::Shape(shape) => shape.text_body.as_ref().is_some_and(|body| {
4235            !body.plain_text().trim().is_empty()
4236                || body.paragraphs().iter().any(|paragraph| {
4237                    paragraph
4238                        .runs
4239                        .iter()
4240                        .any(|run| matches!(run, TextRun::Field(_)))
4241                })
4242        }),
4243        ShapeTreeChild::Picture(picture) => picture.blip_fill.is_some(),
4244        _ => false,
4245    }
4246}
4247
4248fn is_latent(ph_type: &PhType) -> bool {
4249    matches!(
4250        ph_type,
4251        PhType::DateTime | PhType::Footer | PhType::SlideNumber
4252    )
4253}
4254
4255fn placeholder_keys_match(left: &PlaceholderKey, right: &PlaceholderKey) -> bool {
4256    if is_latent(&left.ph_type) && is_latent(&right.ph_type) {
4257        left.ph_type == right.ph_type
4258    } else {
4259        left.matches(right)
4260    }
4261}
4262
4263fn default_body_properties() -> CT_TextBodyProperties {
4264    let mut properties = CT_TextBodyProperties::default();
4265    properties.left_inset = Some(Coordinate32Value::Emu(91_440));
4266    properties.top_inset = Some(Coordinate32Value::Emu(45_720));
4267    properties.right_inset = Some(Coordinate32Value::Emu(91_440));
4268    properties.bottom_inset = Some(Coordinate32Value::Emu(45_720));
4269    properties.anchor = Some(DrawingTextAnchor::Top);
4270    properties.wrap = Some(TextWrap::Square);
4271    properties.vertical = Some(DrawingTextVertical::Horizontal);
4272    properties.space_first_last_paragraph = Some(false);
4273    properties.autofit = Some(TextAutofit::NoAutofit);
4274    properties
4275}
4276
4277fn merge_body_properties(target: &mut CT_TextBodyProperties, source: &CT_TextBodyProperties) {
4278    if let Some(value) = &source.left_inset {
4279        target.left_inset = Some(value.clone());
4280    }
4281    if let Some(value) = &source.top_inset {
4282        target.top_inset = Some(value.clone());
4283    }
4284    if let Some(value) = &source.right_inset {
4285        target.right_inset = Some(value.clone());
4286    }
4287    if let Some(value) = &source.bottom_inset {
4288        target.bottom_inset = Some(value.clone());
4289    }
4290    if let Some(value) = source.anchor {
4291        target.anchor = Some(value);
4292    }
4293    if let Some(value) = source.wrap {
4294        target.wrap = Some(value);
4295    }
4296    if let Some(value) = source.vertical {
4297        target.vertical = Some(value);
4298    }
4299    if let Some(value) = source.space_first_last_paragraph {
4300        target.space_first_last_paragraph = Some(value);
4301    }
4302    if let Some(value) = &source.autofit {
4303        target.autofit = Some(value.clone());
4304    }
4305}
4306
4307/// Returns the transform a slide placeholder inherits, ignoring its own.
4308///
4309/// The placeholder matches a layout placeholder, whose own key then matches
4310/// a master placeholder. The first of the two that carries a transform
4311/// supplies it whole, which is the fallback [`ResolveCtx::effective_xfrm`]
4312/// takes when the slide shape has no transform of its own.
4313pub fn inherited_xfrm<'a>(
4314    placeholder: &CT_Placeholder,
4315    layout: &'a CT_SlideLayout,
4316    master: &'a CT_SlideMaster,
4317) -> Option<&'a CT_Transform2D> {
4318    let (layout, master) = placeholder_chain(placeholder, layout, master);
4319    [layout, master]
4320        .into_iter()
4321        .flatten()
4322        .find_map(|shape| shape.shape_properties.transform.as_ref())
4323}
4324
4325fn placeholder_chain<'a>(
4326    placeholder: &CT_Placeholder,
4327    layout: &'a CT_SlideLayout,
4328    master: &'a CT_SlideMaster,
4329) -> (Option<&'a CT_Shape>, Option<&'a CT_Shape>) {
4330    let Some(layout_shape) = find_placeholder(
4331        &layout.common_slide_data.shape_tree.children,
4332        &placeholder.key(),
4333    ) else {
4334        return (None, None);
4335    };
4336    let Some(layout_key) = layout_shape
4337        .placeholder
4338        .as_ref()
4339        .map(|placeholder| placeholder.key())
4340    else {
4341        return (None, None);
4342    };
4343    let master_shape = find_placeholder(&master.common_slide_data.shape_tree.children, &layout_key);
4344    (Some(layout_shape), master_shape)
4345}
4346
4347fn find_placeholder<'a>(
4348    children: &'a [ShapeTreeChild],
4349    key: &PlaceholderKey,
4350) -> Option<&'a CT_Shape> {
4351    for child in children {
4352        let found = match child {
4353            ShapeTreeChild::Shape(shape) => shape
4354                .placeholder
4355                .as_ref()
4356                .is_some_and(|placeholder| placeholder_keys_match(key, &placeholder.key()))
4357                .then_some(shape),
4358            ShapeTreeChild::GroupShape(group) => find_placeholder(&group.children, key),
4359            ShapeTreeChild::AlternateContent(alternate) => alternate
4360                .selected_fallback()
4361                .and_then(|fallback| find_placeholder(fallback, key)),
4362            _ => None,
4363        };
4364        if found.is_some() {
4365            return found;
4366        }
4367    }
4368    None
4369}
4370
4371fn blend_fill_colors(a: Color, b: Color, t: f64) -> Color {
4372    let t = t.clamp(0.0, 1.0);
4373    Color {
4374        r: a.r + (b.r - a.r) * t,
4375        g: a.g + (b.g - a.g) * t,
4376        b: a.b + (b.b - a.b) * t,
4377        a: a.a + (b.a - a.a) * t,
4378    }
4379}
4380
4381// PowerPoint's documented two-colour endpoint and mirrored three-colour
4382// gradients use channel-directed gamma 1.875. Other stop lists remain linear.
4383// This PPT resolver samples the curve for existing encoded-RGB Paint consumers.
4384// Word colour/tint policy and source-derived pattern palette weights stay intact.
4385fn powerpoint_fill_colors(a: Color, b: Color, t: f64) -> Color {
4386    fn channel(a: f64, b: f64, t: f64) -> f64 {
4387        let weight = if b >= a {
4388            1.0 - (1.0 - t).powf(1.875)
4389        } else {
4390            t.powf(1.875)
4391        };
4392        a + (b - a) * weight
4393    }
4394    let t = t.clamp(0.0, 1.0);
4395    Color {
4396        r: channel(a.r, b.r, t),
4397        g: channel(a.g, b.g, t),
4398        b: channel(a.b, b.b, t),
4399        a: a.a + (b.a - a.a) * t,
4400    }
4401}
4402
4403fn powerpoint_gradient_stops(stops: &[GradientStop]) -> Vec<GradientStop> {
4404    let mut ordered = stops.to_vec();
4405    ordered.sort_by(|a, b| a.offset.total_cmp(&b.offset));
4406    let gamma = ordered.len() == 2 && ordered[0].offset == 0.0 && ordered[1].offset == 1.0
4407        || ordered.len() == 3
4408            && ordered[0].offset == 0.0
4409            && ordered[2].offset == 1.0
4410            && ordered[0].color == ordered[2].color;
4411    if !gamma {
4412        return ordered;
4413    }
4414    let mut sampled = Vec::with_capacity(ordered.len() + 257);
4415    for i in 0..=256 {
4416        let offset = f64::from(i) / 256.0;
4417        sampled.push(GradientStop {
4418            offset,
4419            color: path_gradient_color(&ordered, offset),
4420        });
4421    }
4422    // Retain exact original stops, including coincident hard colour boundaries.
4423    sampled.extend(ordered);
4424    sampled.sort_by(|a, b| a.offset.total_cmp(&b.offset));
4425    sampled
4426}
4427
4428fn path_gradient_color(stops: &[GradientStop], distance: f64) -> Color {
4429    let Some(first) = stops.first() else {
4430        return Color::BLACK;
4431    };
4432    let gamma = stops.len() == 2 && stops[0].offset == 0.0 && stops[1].offset == 1.0
4433        || stops.len() == 3
4434            && stops[0].offset == 0.0
4435            && stops[2].offset == 1.0
4436            && stops[0].color == stops[2].color;
4437    let distance = distance.clamp(0.0, 1.0);
4438    if distance <= first.offset {
4439        return first.color;
4440    }
4441    for pair in stops.windows(2) {
4442        if distance <= pair[1].offset {
4443            let width = pair[1].offset - pair[0].offset;
4444            let t = if width > 0.0 {
4445                (distance - pair[0].offset) / width
4446            } else {
4447                1.0
4448            };
4449            return if gamma {
4450                powerpoint_fill_colors(pair[0].color, pair[1].color, t)
4451            } else {
4452                blend_fill_colors(pair[0].color, pair[1].color, t)
4453            };
4454        }
4455    }
4456    stops.last().map(|stop| stop.color).unwrap_or(first.color)
4457}
4458
4459// Native PowerPoint 16.113.4 source-owned preset tiles, 8pt with 64 samples per axis.
4460// Original PDF SHA82e0bfb2cd978c59ebac96d72020e2d28fc2a0bead9728ad8ba3e8b1e34674c0.
4461// Each two-bit weight is background, half foreground, or foreground. Equal rows
4462// share only constant data, retaining every sample from the independently captured tiles.
4463const NATIVE_PATTERN_ROW_WEIGHTS: [u128; 77] = [
4464    0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa,
4465    0xaaaa0000000000000000000000000000,
4466    0xaaaaaaaa00000000aaaaaaaa00000000,
4467    0x0000aaaaaaaa00000000aaaaaaaa0000,
4468    0x00000000aaaaaaaa00000000aaaaaaaa,
4469    0xaaaa00000000aaaaaaaa00000000aaaa,
4470    0x00000000000000000000000000000000,
4471    0xaaaa000000000000aaaa000000000000,
4472    0x0000aaaa000000000000aaaa00000000,
4473    0x00000000aaaa000000000000aaaa0000,
4474    0x000000000000aaaa000000000000aaaa,
4475    0xaaaaaaaaaaaaaaaa0000000000000000,
4476    0x0000000000000000aaaaaaaaaaaaaaaa,
4477    0x0000000000000000aaaa000000000000,
4478    0x0000000000000000000000000000aaaa,
4479    0x000000000000000000000000aaaa0000,
4480    0x00000000000000000000aaaa00000000,
4481    0x000000000000aaaaaaaa000000000000,
4482    0x00000000aaaa00000000aaaa00000000,
4483    0x0000aaaa0000000000000000aaaa0000,
4484    0xaaaa000000000000000000000000aaaa,
4485    0xaaaa555500000000000000005555aaaa,
4486    0x5555aaaa5555000000005555aaaa5555,
4487    0x00005555aaaa55555555aaaa55550000,
4488    0x000000005555aaaaaaaa555500000000,
4489    0x000000000000aaaa0000000000000000,
4490    0xaaaa0000aaaa0000aaaa0000aaaa0000,
4491    0xaaaa5555000000000000000000005555,
4492    0x5555aaaa555500000000000000000000,
4493    0x00005555aaaa55550000000000000000,
4494    0x000000005555aaaa5555000000000000,
4495    0x0000000000005555aaaa555500000000,
4496    0x00000000000000005555aaaa55550000,
4497    0x000000000000000000005555aaaa5555,
4498    0x5555000000000000000000005555aaaa,
4499    0xaaaa0000aaaaaaaa000000000000aaaa,
4500    0x00000000aaaaaaaa0000000000000000,
4501    0x000000000000000000000000aaaaaaaa,
4502    0x000000000000aaaaaaaa0000aaaaaaaa,
4503    0xaaaaaaaa0000aaaaaaaa000000000000,
4504    0xaaaaaaaa000000000000000000000000,
4505    0x0000000000000000aaaaaaaa00000000,
4506    0xaaaa000000000000aaaaaaaa0000aaaa,
4507    0x0000aaaa0000aaaa0000aaaa0000aaaa,
4508    0xaaaa00000000000000000000aaaa0000,
4509    0x00000000aaaa0000aaaa000000000000,
4510    0x0000aaaa0000aaaa000000000000aaaa,
4511    0x000000000000aaaa0000aaaa0000aaaa,
4512    0xaaaaaaaaaaaa0000aaaaaaaaaaaa0000,
4513    0xaaaa0000aaaaaaaaaaaa0000aaaaaaaa,
4514    0x0000aaaaaaaaaaaa0000aaaaaaaaaaaa,
4515    0xaaaaaaaa0000aaaaaaaaaaaa0000aaaa,
4516    0xaaaaaaaaaaaa0000aaaaaaaaaaaaaaaa,
4517    0xaaaaaaaaaaaaaaaaaaaaaaaaaaaa0000,
4518    0xaaaaaaaaaaaaaaaa0000aaaaaaaaaaaa,
4519    0x0000aaaaaaaaaaaaaaaaaaaaaaaaaaaa,
4520    0xaaaa0000000000000000aaaa00000000,
4521    0x0000aaaa00000000aaaa000000000000,
4522    0x0000aaaa000000000000000000000000,
4523    0x00000000aaaa00000000000000000000,
4524    0x00000000aaaaaaaaaaaa000000000000,
4525    0x0000aaaaaaaaaaaaaaaaaaaa00000000,
4526    0xaaaa000000000000aaaa00000000aaaa,
4527    0xaaaa000000000000aaaaaaaaaaaaaaaa,
4528    0xaaaa00000000aaaaaaaa000000000000,
4529    0xaaaaaaaaaaaaaaaaaaaa000000000000,
4530    0x00000000aaaa00000000aaaa0000aaaa,
4531    0x0000aaaa0000aaaa0000aaaa00000000,
4532    0x0000aaaa000000000000aaaa0000aaaa,
4533    0x000000000000aaaa0000aaaa00000000,
4534    0xaaaaaaaa00000000000000000000aaaa,
4535    0xaaaaaaaaaaaa00000000000000000000,
4536    0x0000aaaaaaaaaaaa0000000000000000,
4537    0x000000000000aaaaaaaaaaaa00000000,
4538    0x0000000000000000aaaaaaaaaaaa0000,
4539    0x00000000000000000000aaaaaaaaaaaa,
4540    0xaaaa00000000000000000000aaaaaaaa,
4541];
4542
4543fn native_pattern_rows(preset: &str) -> Option<&'static [u8; 64]> {
4544    match preset {
4545        "cross" => Some(&[
4546            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,
4547            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,
4548            1, 1, 1, 1, 1, 1,
4549        ]),
4550        "dkDnDiag" => Some(&[
4551            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,
4552            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,
4553            5, 5, 5, 5, 5, 5,
4554        ]),
4555        "dkHorz" => Some(&[
4556            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,
4557            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,
4558            6, 6, 6, 6, 6, 6,
4559        ]),
4560        "dkUpDiag" => Some(&[
4561            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,
4562            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,
4563            5, 5, 5, 5, 5, 5,
4564        ]),
4565        "dkVert" => Some(&[
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, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2,
4568            2, 2, 2, 2, 2, 2,
4569        ]),
4570        "dashDnDiag" => Some(&[
4571            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,
4572            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,
4573            6, 6, 6, 6, 6, 6, 6, 6, 6,
4574        ]),
4575        "dashHorz" => Some(&[
4576            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,
4577            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,
4578            6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6,
4579        ]),
4580        "dashUpDiag" => Some(&[
4581            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,
4582            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,
4583            6, 6, 6, 6, 6, 6, 6, 6, 6,
4584        ]),
4585        "dashVert" => Some(&[
4586            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,
4587            1, 1, 1, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13,
4588            13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13,
4589        ]),
4590        "diagBrick" => Some(&[
4591            14, 14, 14, 14, 14, 14, 14, 14, 15, 15, 15, 15, 15, 15, 15, 15, 16, 16, 16, 16, 16, 16,
4592            16, 16, 13, 13, 13, 13, 13, 13, 13, 13, 17, 17, 17, 17, 17, 17, 17, 17, 18, 18, 18, 18,
4593            18, 18, 18, 18, 19, 19, 19, 19, 19, 19, 19, 19, 20, 20, 20, 20, 20, 20, 20, 20,
4594        ]),
4595        "diagCross" => Some(&[
4596            21, 21, 21, 21, 21, 21, 21, 21, 22, 22, 22, 22, 22, 22, 22, 22, 23, 23, 23, 23, 23, 23,
4597            23, 23, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 23, 23, 23, 23,
4598            23, 23, 23, 23, 22, 22, 22, 22, 22, 22, 22, 22, 21, 21, 21, 21, 21, 21, 21, 21,
4599        ]),
4600        "divot" => Some(&[
4601            6, 6, 6, 6, 6, 6, 6, 6, 25, 25, 25, 25, 25, 25, 25, 25, 13, 13, 13, 13, 13, 13, 13, 13,
4602            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,
4603            14, 14, 14, 14, 14, 14, 1, 1, 1, 1, 1, 1, 1, 1,
4604        ]),
4605        "dotDmnd" => Some(&[
4606            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,
4607            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,
4608            9, 6, 6, 6, 6, 6, 6, 6, 6,
4609        ]),
4610        "dotGrid" => Some(&[
4611            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,
4612            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,
4613            1, 6, 6, 6, 6, 6, 6, 6, 6,
4614        ]),
4615        "dnDiag" => Some(&[
4616            27, 27, 27, 27, 27, 27, 27, 27, 28, 28, 28, 28, 28, 28, 28, 28, 29, 29, 29, 29, 29, 29,
4617            29, 29, 30, 30, 30, 30, 30, 30, 30, 30, 31, 31, 31, 31, 31, 31, 31, 31, 32, 32, 32, 32,
4618            32, 32, 32, 32, 33, 33, 33, 33, 33, 33, 33, 33, 34, 34, 34, 34, 34, 34, 34, 34,
4619        ]),
4620        "horz" => Some(&[
4621            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,
4622            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,
4623            6, 6, 6, 6, 6, 6,
4624        ]),
4625        "horzBrick" => Some(&[
4626            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,
4627            1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13,
4628            13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13,
4629        ]),
4630        "lgCheck" => Some(&[
4631            11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11,
4632            11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12,
4633            12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12,
4634        ]),
4635        "lgConfetti" => Some(&[
4636            35, 35, 35, 35, 35, 35, 35, 35, 36, 36, 36, 36, 36, 36, 36, 36, 37, 37, 37, 37, 37, 37,
4637            37, 37, 38, 38, 38, 38, 38, 38, 38, 38, 39, 39, 39, 39, 39, 39, 39, 39, 40, 40, 40, 40,
4638            40, 40, 40, 40, 41, 41, 41, 41, 41, 41, 41, 41, 42, 42, 42, 42, 42, 42, 42, 42,
4639        ]),
4640        "lgGrid" => Some(&[
4641            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,
4642            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,
4643            1, 1, 1, 1, 1, 1,
4644        ]),
4645        "ltDnDiag" => Some(&[
4646            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,
4647            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,
4648            10, 10, 10, 10, 10, 10, 10, 10,
4649        ]),
4650        "ltHorz" => Some(&[
4651            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,
4652            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,
4653            6, 6, 6, 6, 6, 6,
4654        ]),
4655        "ltUpDiag" => Some(&[
4656            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,
4657            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,
4658            8, 8, 8, 8, 7, 7, 7, 7, 7, 7, 7, 7,
4659        ]),
4660        "ltVert" => Some(&[
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, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7,
4663            7, 7, 7, 7, 7, 7,
4664        ]),
4665        "narHorz" => Some(&[
4666            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,
4667            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,
4668            6, 6, 6, 6, 6, 6,
4669        ]),
4670        "narVert" => Some(&[
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, 43, 43,
4673            43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43, 43,
4674        ]),
4675        "openDmnd" => Some(&[
4676            44, 44, 44, 44, 44, 44, 44, 44, 8, 8, 8, 8, 8, 8, 8, 8, 45, 45, 45, 45, 45, 45, 45, 45,
4677            25, 25, 25, 25, 25, 25, 25, 25, 45, 45, 45, 45, 45, 45, 45, 45, 8, 8, 8, 8, 8, 8, 8, 8,
4678            44, 44, 44, 44, 44, 44, 44, 44, 14, 14, 14, 14, 14, 14, 14, 14,
4679        ]),
4680        "pct10" => Some(&[
4681            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,
4682            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,
4683            13, 13, 13, 6, 6, 6, 6, 6, 6, 6, 6,
4684        ]),
4685        "pct20" => Some(&[
4686            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,
4687            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,
4688            6, 6, 6, 6, 6, 6,
4689        ]),
4690        "pct25" => Some(&[
4691            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,
4692            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,
4693            9, 9, 9, 9, 9, 9,
4694        ]),
4695        "pct30" => Some(&[
4696            26, 26, 26, 26, 26, 26, 26, 26, 8, 8, 8, 8, 8, 8, 8, 8, 26, 26, 26, 26, 26, 26, 26, 26,
4697            10, 10, 10, 10, 10, 10, 10, 10, 26, 26, 26, 26, 26, 26, 26, 26, 8, 8, 8, 8, 8, 8, 8, 8,
4698            26, 26, 26, 26, 26, 26, 26, 26, 10, 10, 10, 10, 10, 10, 10, 10,
4699        ]),
4700        "pct40" => Some(&[
4701            26, 26, 26, 26, 26, 26, 26, 26, 43, 43, 43, 43, 43, 43, 43, 43, 26, 26, 26, 26, 26, 26,
4702            26, 26, 46, 46, 46, 46, 46, 46, 46, 46, 26, 26, 26, 26, 26, 26, 26, 26, 43, 43, 43, 43,
4703            43, 43, 43, 43, 26, 26, 26, 26, 26, 26, 26, 26, 47, 47, 47, 47, 47, 47, 47, 47,
4704        ]),
4705        "pct5" => Some(&[
4706            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,
4707            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,
4708            6, 6, 6, 6, 6, 6, 6, 6, 6,
4709        ]),
4710        "pct50" => Some(&[
4711            26, 26, 26, 26, 26, 26, 26, 26, 43, 43, 43, 43, 43, 43, 43, 43, 26, 26, 26, 26, 26, 26,
4712            26, 26, 43, 43, 43, 43, 43, 43, 43, 43, 26, 26, 26, 26, 26, 26, 26, 26, 43, 43, 43, 43,
4713            43, 43, 43, 43, 26, 26, 26, 26, 26, 26, 26, 26, 43, 43, 43, 43, 43, 43, 43, 43,
4714        ]),
4715        "pct60" => Some(&[
4716            48, 48, 48, 48, 48, 48, 48, 48, 43, 43, 43, 43, 43, 43, 43, 43, 49, 49, 49, 49, 49, 49,
4717            49, 49, 43, 43, 43, 43, 43, 43, 43, 43, 48, 48, 48, 48, 48, 48, 48, 48, 43, 43, 43, 43,
4718            43, 43, 43, 43, 49, 49, 49, 49, 49, 49, 49, 49, 43, 43, 43, 43, 43, 43, 43, 43,
4719        ]),
4720        "pct70" => Some(&[
4721            50, 50, 50, 50, 50, 50, 50, 50, 51, 51, 51, 51, 51, 51, 51, 51, 50, 50, 50, 50, 50, 50,
4722            50, 50, 51, 51, 51, 51, 51, 51, 51, 51, 50, 50, 50, 50, 50, 50, 50, 50, 51, 51, 51, 51,
4723            51, 51, 51, 51, 50, 50, 50, 50, 50, 50, 50, 50, 51, 51, 51, 51, 51, 51, 51, 51,
4724        ]),
4725        "pct75" => Some(&[
4726            50, 50, 50, 50, 50, 50, 50, 50, 0, 0, 0, 0, 0, 0, 0, 0, 51, 51, 51, 51, 51, 51, 51, 51,
4727            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,
4728            51, 51, 51, 51, 51, 51, 0, 0, 0, 0, 0, 0, 0, 0,
4729        ]),
4730        "pct80" => Some(&[
4731            52, 52, 52, 52, 52, 52, 52, 52, 0, 0, 0, 0, 0, 0, 0, 0, 53, 53, 53, 53, 53, 53, 53, 53,
4732            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,
4733            53, 53, 53, 53, 53, 53, 0, 0, 0, 0, 0, 0, 0, 0,
4734        ]),
4735        "pct90" => Some(&[
4736            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,
4737            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,
4738            55, 55, 55, 55, 55, 55, 55, 55,
4739        ]),
4740        "plaid" => Some(&[
4741            26, 26, 26, 26, 26, 26, 26, 26, 43, 43, 43, 43, 43, 43, 43, 43, 26, 26, 26, 26, 26, 26,
4742            26, 26, 43, 43, 43, 43, 43, 43, 43, 43, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11,
4743            11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11,
4744        ]),
4745        "shingle" => Some(&[
4746            37, 37, 37, 37, 37, 37, 37, 37, 56, 56, 56, 56, 56, 56, 56, 56, 57, 57, 57, 57, 57, 57,
4747            57, 57, 36, 36, 36, 36, 36, 36, 36, 36, 41, 41, 41, 41, 41, 41, 41, 41, 15, 15, 15, 15,
4748            15, 15, 15, 15, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14,
4749        ]),
4750        "smCheck" => Some(&[
4751            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,
4752            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,
4753            5, 5, 5, 5, 5, 5,
4754        ]),
4755        "smConfetti" => Some(&[
4756            1, 1, 1, 1, 1, 1, 1, 1, 13, 13, 13, 13, 13, 13, 13, 13, 58, 58, 58, 58, 58, 58, 58, 58,
4757            15, 15, 15, 15, 15, 15, 15, 15, 25, 25, 25, 25, 25, 25, 25, 25, 14, 14, 14, 14, 14, 14,
4758            14, 14, 59, 59, 59, 59, 59, 59, 59, 59, 16, 16, 16, 16, 16, 16, 16, 16,
4759        ]),
4760        "smGrid" => Some(&[
4761            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,
4762            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,
4763            7, 7, 7, 7, 7, 7,
4764        ]),
4765        "solidDmnd" => Some(&[
4766            25, 25, 25, 25, 25, 25, 25, 25, 60, 60, 60, 60, 60, 60, 60, 60, 61, 61, 61, 61, 61, 61,
4767            61, 61, 53, 53, 53, 53, 53, 53, 53, 53, 61, 61, 61, 61, 61, 61, 61, 61, 60, 60, 60, 60,
4768            60, 60, 60, 60, 25, 25, 25, 25, 25, 25, 25, 25, 6, 6, 6, 6, 6, 6, 6, 6,
4769        ]),
4770        "sphere" => Some(&[
4771            50, 50, 50, 50, 50, 50, 50, 50, 62, 62, 62, 62, 62, 62, 62, 62, 63, 63, 63, 63, 63, 63,
4772            63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 50, 50, 50, 50, 50, 50, 50, 50, 64, 64, 64, 64,
4773            64, 64, 64, 64, 65, 65, 65, 65, 65, 65, 65, 65, 65, 65, 65, 65, 65, 65, 65, 65,
4774        ]),
4775        "trellis" => Some(&[
4776            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,
4777            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,
4778            5, 5, 5, 5, 5, 5,
4779        ]),
4780        "upDiag" => Some(&[
4781            34, 34, 34, 34, 34, 34, 34, 34, 33, 33, 33, 33, 33, 33, 33, 33, 32, 32, 32, 32, 32, 32,
4782            32, 32, 31, 31, 31, 31, 31, 31, 31, 31, 30, 30, 30, 30, 30, 30, 30, 30, 29, 29, 29, 29,
4783            29, 29, 29, 29, 28, 28, 28, 28, 28, 28, 28, 28, 27, 27, 27, 27, 27, 27, 27, 27,
4784        ]),
4785        "vert" => Some(&[
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, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
4788            1, 1, 1, 1, 1, 1,
4789        ]),
4790        "wave" => Some(&[
4791            6, 6, 6, 6, 6, 6, 6, 6, 17, 17, 17, 17, 17, 17, 17, 17, 66, 66, 66, 66, 66, 66, 66, 66,
4792            40, 40, 40, 40, 40, 40, 40, 40, 6, 6, 6, 6, 6, 6, 6, 6, 17, 17, 17, 17, 17, 17, 17, 17,
4793            66, 66, 66, 66, 66, 66, 66, 66, 40, 40, 40, 40, 40, 40, 40, 40,
4794        ]),
4795        "weave" => Some(&[
4796            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,
4797            68, 68, 68, 68, 68, 68, 7, 7, 7, 7, 7, 7, 7, 7, 69, 69, 69, 69, 69, 69, 69, 69, 9, 9,
4798            9, 9, 9, 9, 9, 9, 46, 46, 46, 46, 46, 46, 46, 46,
4799        ]),
4800        "wdDnDiag" => Some(&[
4801            70, 70, 70, 70, 70, 70, 70, 70, 71, 71, 71, 71, 71, 71, 71, 71, 72, 72, 72, 72, 72, 72,
4802            72, 72, 60, 60, 60, 60, 60, 60, 60, 60, 73, 73, 73, 73, 73, 73, 73, 73, 74, 74, 74, 74,
4803            74, 74, 74, 74, 75, 75, 75, 75, 75, 75, 75, 75, 76, 76, 76, 76, 76, 76, 76, 76,
4804        ]),
4805        "wdUpDiag" => Some(&[
4806            76, 76, 76, 76, 76, 76, 76, 76, 75, 75, 75, 75, 75, 75, 75, 75, 74, 74, 74, 74, 74, 74,
4807            74, 74, 73, 73, 73, 73, 73, 73, 73, 73, 60, 60, 60, 60, 60, 60, 60, 60, 72, 72, 72, 72,
4808            72, 72, 72, 72, 71, 71, 71, 71, 71, 71, 71, 71, 70, 70, 70, 70, 70, 70, 70, 70,
4809        ]),
4810        "zigZag" => Some(&[
4811            20, 20, 20, 20, 20, 20, 20, 20, 19, 19, 19, 19, 19, 19, 19, 19, 18, 18, 18, 18, 18, 18,
4812            18, 18, 17, 17, 17, 17, 17, 17, 17, 17, 20, 20, 20, 20, 20, 20, 20, 20, 19, 19, 19, 19,
4813            19, 19, 19, 19, 18, 18, 18, 18, 18, 18, 18, 18, 17, 17, 17, 17, 17, 17, 17, 17,
4814        ]),
4815        _ => None,
4816    }
4817}
4818
4819#[cfg(test)]
4820mod tests {
4821    use std::collections::HashMap;
4822    use std::path::{Path, PathBuf};
4823
4824    use oxml_chart::CT_ChartSpace;
4825    use oxml_drawing::color::ColorMap;
4826    use oxml_drawing::fill::Fill;
4827    use oxml_drawing::text::{
4828        CT_TextListStyle, Coordinate32Value, TextAnchor, TextAutofit, TextVertical, TextWrap,
4829    };
4830    use oxml_drawing::theme::CT_OfficeStyleSheet;
4831    use oxml_opc::OpcPackage;
4832    use oxml_opc::relationship::rel_types;
4833    use rpptx_oxml::placeholder::PhType;
4834    use rpptx_oxml::presentation::CT_Presentation;
4835    use rpptx_oxml::shape_tree::{CT_Shape, ShapeTreeChild};
4836    use rpptx_oxml::slide_parts::{CT_Slide, CT_SlideLayout, CT_SlideMaster, ColorMapOverrideKind};
4837
4838    use super::{
4839        BackgroundSource, FlattenedItem, FlattenedSource, ResolveCtx, inherited_xfrm,
4840        resolved_shape_page_bounds, resolved_shape_page_transform, transform_values,
4841    };
4842    use crate::{
4843        ChartResource, Diagnostic, ParagraphAlignment, ResolvedAutofit, ResolvedBackground,
4844        ResolvedBullet, ResolvedBulletSize, ResolvedContent, ResolvedGeometry,
4845        ResolvedImagePlacement, ResolvedLineEnd, ResolvedLineEndKind, ResolvedLineEndSize,
4846        ResolvedParagraph, ResolvedRectAlignment, ResolvedRunStyle, ResolvedSlide, ResolvedTextRun,
4847        ResolvedTextSpacing, ResolvedTileFlip, ScopedChartResources, ScopedHyperlinkTargets,
4848        ScopedMediaFailures, ScopedMediaIds, TextAnchor as ResolvedTextAnchor, TextDirection,
4849    };
4850    use oxml_layout::{
4851        Color, Effect, FontManager, MediaId, Paint, PathCommand, Point, PositionedElement, Rect,
4852        Transform, walk,
4853    };
4854
4855    const P_NS: &str = "http://schemas.openxmlformats.org/presentationml/2006/main";
4856    const A_NS: &str = "http://schemas.openxmlformats.org/drawingml/2006/main";
4857    const MC_NS: &str = "http://schemas.openxmlformats.org/markup-compatibility/2006";
4858    const EXPECTED_CORPUS_DECKS: usize = 50;
4859
4860    fn assert_close(actual: f64, expected: f64) {
4861        assert!(
4862            (actual - expected).abs() < 1.0e-10,
4863            "expected {expected}, got {actual}"
4864        );
4865    }
4866
4867    #[test]
4868    fn fx214_manual_shape_tree_admission_and_iterative_affine_order() {
4869        use rpptx_oxml::shape_tree::CT_GroupShape;
4870        let leaf = shape_with_details(
4871            None,
4872            None,
4873            "<a:xfrm><a:off x=\"12700\" y=\"25400\"/><a:ext cx=\"38100\" cy=\"50800\"/></a:xfrm>",
4874            None,
4875        );
4876        let mut fixture = Fixture::new(&leaf, "", "");
4877        let leaf = fixture
4878            .slide
4879            .common_slide_data
4880            .shape_tree
4881            .children
4882            .remove(0);
4883        let mut child = leaf;
4884        for id in 0..32 {
4885            let mut group = CT_GroupShape::new_empty(100 + id, "Nested");
4886            group.children.push(child);
4887            child = ShapeTreeChild::GroupShape(Box::new(group));
4888        }
4889        fixture
4890            .slide
4891            .common_slide_data
4892            .shape_tree
4893            .children
4894            .push(child);
4895        let ctx = fixture.context();
4896        let resolved = ctx.resolve_slide((960.0, 540.0)).unwrap();
4897        assert_eq!(resolved.shapes.len(), 1);
4898        assert_eq!(
4899            resolved.shapes[0].bounds,
4900            Rect {
4901                x: 1.0,
4902                y: 2.0,
4903                width: 3.0,
4904                height: 4.0
4905            }
4906        );
4907        assert_eq!(resolved.shapes[0].group_transform, Transform::IDENTITY);
4908        let flat = ctx.flatten();
4909        let FlattenedItem::Shape { child, .. } = flat.last().unwrap() else {
4910            panic!("shape leaf");
4911        };
4912        let lineage = Vec::new();
4913        let mut found = Vec::new();
4914        assert!(super::find_group_lineage(
4915            &fixture.slide.common_slide_data.shape_tree.children,
4916            child,
4917            &lineage,
4918            &mut found
4919        ));
4920        assert_eq!(found, (100..132).rev().collect::<Vec<_>>());
4921        let child = fixture
4922            .slide
4923            .common_slide_data
4924            .shape_tree
4925            .children
4926            .remove(0);
4927        let mut outer = CT_GroupShape::new_empty(132, "Refused");
4928        outer.children.push(child);
4929        fixture
4930            .slide
4931            .common_slide_data
4932            .shape_tree
4933            .children
4934            .push(ShapeTreeChild::GroupShape(Box::new(outer)));
4935        let error = fixture
4936            .context()
4937            .resolve_slide((960.0, 540.0))
4938            .unwrap_err()
4939            .to_string();
4940        assert!(
4941            error.contains("slide shape tree") && error.contains("offending depth 33"),
4942            "{error}"
4943        );
4944        assert!(
4945            fixture
4946                .slide
4947                .to_xml()
4948                .unwrap_err()
4949                .to_string()
4950                .contains("offending depth 33")
4951        );
4952    }
4953
4954    struct Fixture {
4955        theme: CT_OfficeStyleSheet,
4956        master: CT_SlideMaster,
4957        layout: CT_SlideLayout,
4958        slide: CT_Slide,
4959        default_text_style: CT_TextListStyle,
4960    }
4961
4962    impl Fixture {
4963        fn new(slide_children: &str, layout_children: &str, master_children: &str) -> Self {
4964            Self::from_xml(
4965                &slide_xml(slide_children),
4966                &layout_xml(layout_children),
4967                &master_xml(master_children),
4968            )
4969        }
4970
4971        fn from_xml(slide_xml: &str, layout_xml: &str, master_xml: &str) -> Self {
4972            Self {
4973                theme: CT_OfficeStyleSheet::office_default(),
4974                master: CT_SlideMaster::from_xml(master_xml.as_bytes()).unwrap(),
4975                layout: CT_SlideLayout::from_xml(layout_xml.as_bytes()).unwrap(),
4976                slide: CT_Slide::from_xml(slide_xml.as_bytes()).unwrap(),
4977                default_text_style: CT_TextListStyle::default(),
4978            }
4979        }
4980
4981        fn context(&self) -> ResolveCtx<'_> {
4982            ResolveCtx::new(
4983                &self.theme,
4984                ColorMap::default(),
4985                &self.master,
4986                &self.layout,
4987                &self.slide,
4988                &self.default_text_style,
4989            )
4990        }
4991
4992        fn slide_shape(&self, index: usize) -> &CT_Shape {
4993            let ShapeTreeChild::Shape(shape) =
4994                &self.slide.common_slide_data.shape_tree.children[index]
4995            else {
4996                panic!("expected an ordinary slide shape");
4997            };
4998            shape
4999        }
5000    }
5001
5002    #[test]
5003    fn slide_placeholder_resolves_to_layout_and_master_counterparts() {
5004        let fixture = Fixture::new(
5005            &shape(Some("title"), Some(7)),
5006            &shape(Some("body"), Some(7)),
5007            &shape(Some("pic"), Some(7)),
5008        );
5009
5010        let context = fixture.context();
5011        let (layout, master) = context.placeholder_chain(fixture.slide_shape(0));
5012
5013        assert_eq!(
5014            layout.unwrap().placeholder.as_ref().unwrap().ph_type,
5015            Some(PhType::Body)
5016        );
5017        assert_eq!(
5018            master.unwrap().placeholder.as_ref().unwrap().ph_type,
5019            Some(PhType::Picture)
5020        );
5021    }
5022
5023    #[test]
5024    fn master_match_uses_the_layout_placeholder_key() {
5025        let fixture = Fixture::new(
5026            &shape(Some("title"), Some(8)),
5027            &shape(Some("ctrTitle"), None),
5028            &shape(Some("title"), Some(5)),
5029        );
5030
5031        let context = fixture.context();
5032        let (layout, master) = context.placeholder_chain(fixture.slide_shape(0));
5033
5034        assert!(layout.is_some());
5035        assert_eq!(master.unwrap().placeholder.as_ref().unwrap().idx, Some(5));
5036    }
5037
5038    #[test]
5039    fn placeholder_lookup_walks_groups_and_selected_fallbacks() {
5040        let nested_layout = format!(
5041            "<p:grpSp><p:nvGrpSpPr/><p:grpSpPr/>{}</p:grpSp>",
5042            shape(Some("body"), Some(11))
5043        );
5044        let fallback_master = format!(
5045            "<mc:AlternateContent><mc:Choice Requires=\"p14\"><p:sp/></mc:Choice><mc:Fallback>{}</mc:Fallback></mc:AlternateContent>",
5046            shape(Some("body"), Some(11))
5047        );
5048        let fixture = Fixture::new(
5049            &shape(Some("body"), Some(11)),
5050            &nested_layout,
5051            &fallback_master,
5052        );
5053
5054        let context = fixture.context();
5055        let (layout, master) = context.placeholder_chain(fixture.slide_shape(0));
5056
5057        assert!(layout.is_some());
5058        assert!(master.is_some());
5059    }
5060
5061    #[test]
5062    fn group_targets_apply_to_every_resolved_descendant() {
5063        let first = shape_with_details(None, None, &transform(0), None).replacen(
5064            "<p:cNvPr/>",
5065            "<p:cNvPr id=\"7\" name=\"First\"/>",
5066            1,
5067        );
5068        let second = shape_with_details(None, None, &transform(127_000), None).replacen(
5069            "<p:cNvPr/>",
5070            "<p:cNvPr id=\"8\" name=\"Second\"/>",
5071            1,
5072        );
5073        let unrelated = shape_with_details(None, None, &transform(254_000), None).replacen(
5074            "<p:cNvPr/>",
5075            "<p:cNvPr id=\"9\" name=\"Other\"/>",
5076            1,
5077        );
5078        let group = format!(
5079            "<p:grpSp><p:nvGrpSpPr><p:cNvPr id=\"10\" name=\"Group\"/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/>{first}{second}</p:grpSp>"
5080        );
5081        let slide = format!(
5082            "<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>",
5083            shape_tree(&format!("{group}{unrelated}"))
5084        );
5085        let fixture = Fixture::from_xml(&slide, &layout_xml(""), &master_xml(""));
5086        let resolved = fixture
5087            .context()
5088            .resolve_slide_at(
5089                (720.0, 540.0),
5090                crate::timeline::TimelinePosition {
5091                    elapsed_ms: 1,
5092                    click_count: 0,
5093                },
5094            )
5095            .unwrap();
5096
5097        assert_eq!(resolved.shape_states.len(), 3);
5098        assert_eq!(resolved.shape_states[0].opacity, 0.5);
5099        assert_eq!(resolved.shape_states[1].opacity, 0.5);
5100        assert_eq!(resolved.shape_states[2].opacity, 1.0);
5101        assert_eq!(resolved.identities[0].containing_group_ids, vec![10]);
5102        assert_eq!(resolved.identities[1].containing_group_ids, vec![10]);
5103        assert!(resolved.identities[2].containing_group_ids.is_empty());
5104    }
5105
5106    #[test]
5107    fn alternate_content_chart_choice_keeps_timeline_identity() {
5108        let alternate = chart_alternate("chart", None).replacen(
5109            "<p:nvGraphicFramePr/>",
5110            "<p:nvGraphicFramePr><p:cNvPr id=\"42\" name=\"!!Chart &amp; Data\"/><p:cNvGraphicFramePr/><p:nvPr/></p:nvGraphicFramePr>",
5111            1,
5112        );
5113        let slide = format!(
5114            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>"#,
5115            shape_tree(&alternate)
5116        );
5117        let fixture = Fixture::from_xml(&slide, &layout_xml(""), &master_xml(""));
5118        let resolved = fixture
5119            .context()
5120            .resolve_slide_at(
5121                (720.0, 540.0),
5122                crate::timeline::TimelinePosition {
5123                    elapsed_ms: 500,
5124                    click_count: 0,
5125                },
5126            )
5127            .unwrap();
5128
5129        assert_eq!(resolved.identities[0].shape_id, Some(42));
5130        assert_eq!(
5131            resolved.identities[0].name.as_deref(),
5132            Some("!!Chart & Data")
5133        );
5134        assert_eq!(resolved.shape_states[0].opacity, 0.75);
5135    }
5136
5137    #[test]
5138    fn timeline_geometry_keeps_shape_centres_and_resolves_motion_origins() {
5139        let first = shape_with_details(None, None, &transform(127_000), None).replacen(
5140            "<p:cNvPr/>",
5141            "<p:cNvPr id=\"7\" name=\"!!Scale &amp; Spin\"/>",
5142            1,
5143        );
5144        let second = shape_with_details(None, None, &transform(254_000), None).replacen(
5145            "<p:cNvPr/>",
5146            "<p:cNvPr id=\"8\" name=\"Target motion\"/>",
5147            1,
5148        );
5149        let slide = format!(
5150            r#"<p:sld xmlns:p="{P_NS}" xmlns:a="{A_NS}"><p:cSld>{}</p:cSld><p:timing><p:tnLst>
5151            <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>
5152            <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>
5153            <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>
5154            <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>
5155            </p:tnLst></p:timing></p:sld>"#,
5156            shape_tree(&format!("{first}{second}"))
5157        );
5158        let fixture = Fixture::from_xml(&slide, &layout_xml(""), &master_xml(""));
5159        let static_slide = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
5160        let resolved = fixture
5161            .context()
5162            .resolve_slide_at(
5163                (720.0, 540.0),
5164                crate::timeline::TimelinePosition {
5165                    elapsed_ms: 1_000,
5166                    click_count: 0,
5167                },
5168            )
5169            .unwrap();
5170
5171        let original = static_slide.shapes[0].bounds;
5172        let animated = resolved.slide.shapes[0].bounds;
5173        assert_close(animated.width, original.width * 2.0);
5174        assert_close(animated.height, original.height * 2.0);
5175        let animated_page = resolved_shape_page_bounds(&resolved.slide.shapes[0]);
5176        assert_close(animated_page.x + animated_page.width / 2.0, 360.0);
5177        assert_close(animated_page.y + animated_page.height / 2.0, 135.0);
5178        assert_close(resolved.slide.shapes[0].rotation_deg, 90.0);
5179        let target_original = resolved_shape_page_bounds(&static_slide.shapes[1]);
5180        let target_animated = resolved_shape_page_bounds(&resolved.slide.shapes[1]);
5181        assert_close(target_animated.x - target_original.x, 360.0);
5182        assert_close(target_animated.y - target_original.y, 135.0);
5183        assert_eq!(
5184            resolved.identities[0].name.as_deref(),
5185            Some("!!Scale & Spin")
5186        );
5187        assert!(resolved.shape_states.iter().all(|state| state.is_finite()));
5188    }
5189
5190    #[test]
5191    fn group_timeline_geometry_uses_one_shared_group_coordinate_space() {
5192        let rotated_transform = transform(50).replacen("<a:xfrm>", "<a:xfrm rot=\"2700000\">", 1);
5193        let first = shape_with_details(None, None, &rotated_transform, None).replacen(
5194            "<p:cNvPr/>",
5195            "<p:cNvPr id=\"7\" name=\"First\"/>",
5196            1,
5197        );
5198        let second = shape_with_details(None, None, &transform(100), None).replacen(
5199            "<p:cNvPr/>",
5200            "<p:cNvPr id=\"8\" name=\"Second\"/>",
5201            1,
5202        );
5203        let group = format!(
5204            "<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>"
5205        );
5206        let slide = format!(
5207            r#"<p:sld xmlns:p="{P_NS}" xmlns:a="{A_NS}"><p:cSld>{}</p:cSld><p:timing><p:tnLst>
5208            <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>
5209            <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>
5210            <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>
5211            </p:tnLst></p:timing></p:sld>"#,
5212            shape_tree(&group)
5213        );
5214        let fixture = Fixture::from_xml(&slide, &layout_xml(""), &master_xml(""));
5215        let static_slide = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
5216        let resolved = fixture
5217            .context()
5218            .resolve_slide_at(
5219                (720.0, 540.0),
5220                crate::timeline::TimelinePosition {
5221                    elapsed_ms: 500,
5222                    click_count: 0,
5223                },
5224            )
5225            .unwrap();
5226        let first_before = resolved_shape_page_bounds(&static_slide.shapes[0]);
5227        let second_before = resolved_shape_page_bounds(&static_slide.shapes[1]);
5228        let group_center_x = 200.0;
5229        let group_center_y = 100.0;
5230
5231        for (before, shape) in [first_before, second_before]
5232            .into_iter()
5233            .zip(&resolved.slide.shapes)
5234        {
5235            let after = resolved_shape_page_bounds(shape);
5236            assert_close(after.width, before.width * 1.5);
5237            assert_close(
5238                after.x + after.width / 2.0,
5239                36.0 + (before.x + before.width / 2.0 - group_center_x) * 1.5,
5240            );
5241            assert_close(
5242                after.y + after.height / 2.0,
5243                27.0 + (before.y + before.height / 2.0 - group_center_y) * 1.5,
5244            );
5245        }
5246        let first_clip = resolved.shape_states[0].local_clip_paths((100.0, 100.0));
5247        let [PathCommand::MoveTo(start), PathCommand::LineTo(next), ..] =
5248            first_clip[0].commands.as_slice()
5249        else {
5250            panic!("group wipe should remain a polygon")
5251        };
5252        assert!(start.x != next.x && start.y != next.y);
5253        assert_eq!(
5254            resolved.shape_states[1]
5255                .local_clip_paths((100.0, 100.0))
5256                .len(),
5257            1
5258        );
5259    }
5260
5261    #[test]
5262    fn parent_group_wipe_does_not_follow_descendant_or_nested_group_animation() {
5263        let child = shape_with_details(None, None, &transform(50), None).replacen(
5264            "<p:cNvPr/>",
5265            "<p:cNvPr id=\"7\" name=\"Animated child\"/>",
5266            1,
5267        );
5268        let nested = format!(
5269            "<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>"
5270        );
5271        let group = format!(
5272            "<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>"
5273        );
5274        let resolve = |descendant_timing: &str| {
5275            let slide = format!(
5276                r#"<p:sld xmlns:p="{P_NS}" xmlns:a="{A_NS}"><p:cSld>{}</p:cSld><p:timing><p:tnLst>
5277                <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>
5278                {descendant_timing}</p:tnLst></p:timing></p:sld>"#,
5279                shape_tree(&group)
5280            );
5281            Fixture::from_xml(&slide, &layout_xml(""), &master_xml(""))
5282                .context()
5283                .resolve_slide_at(
5284                    (720.0, 540.0),
5285                    crate::timeline::TimelinePosition {
5286                        elapsed_ms: 500,
5287                        click_count: 0,
5288                    },
5289                )
5290                .unwrap()
5291        };
5292        let baseline = resolve("");
5293        let animated = resolve(
5294            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>
5295            <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>"#,
5296        );
5297        let page_clip = |resolved: &crate::timeline::ResolvedTimelineSlide| {
5298            let shape = &resolved.slide.shapes[0];
5299            let transform = resolved_shape_page_transform(shape);
5300            resolved.shape_states[0].local_clip_paths((shape.bounds.width, shape.bounds.height))[0]
5301                .commands
5302                .iter()
5303                .filter_map(|command| match command {
5304                    PathCommand::MoveTo(point) | PathCommand::LineTo(point) => {
5305                        Some(transform.apply(*point))
5306                    }
5307                    _ => None,
5308                })
5309                .collect::<Vec<_>>()
5310        };
5311        let baseline_clip = page_clip(&baseline);
5312        let animated_clip = page_clip(&animated);
5313
5314        assert_ne!(
5315            resolved_shape_page_transform(&baseline.slide.shapes[0]),
5316            resolved_shape_page_transform(&animated.slide.shapes[0])
5317        );
5318        assert_eq!(baseline_clip.len(), animated_clip.len());
5319        for (baseline, animated) in baseline_clip.into_iter().zip(animated_clip) {
5320            assert_close(animated.x, baseline.x);
5321            assert_close(animated.y, baseline.y);
5322        }
5323    }
5324
5325    #[test]
5326    fn missing_or_non_placeholder_shape_has_no_chain() {
5327        let slide_children = format!("{}{}", shape(None, None), shape(Some("body"), Some(42)));
5328        let fixture = Fixture::new(&slide_children, "", &shape(Some("body"), Some(42)));
5329        let context = fixture.context();
5330
5331        assert_eq!(
5332            context.placeholder_chain(fixture.slide_shape(0)),
5333            (None, None)
5334        );
5335        assert_eq!(
5336            context.placeholder_chain(fixture.slide_shape(1)),
5337            (None, None)
5338        );
5339    }
5340
5341    #[test]
5342    fn slide_placeholder_without_transform_inherits_layout_position() {
5343        let fixture = Fixture::new(
5344            &shape(Some("body"), Some(1)),
5345            &shape_with_details(Some("body"), Some(1), &transform(10), None),
5346            &shape_with_details(Some("body"), Some(1), &transform(20), None),
5347        );
5348        let transform = fixture
5349            .context()
5350            .effective_xfrm(fixture.slide_shape(0))
5351            .unwrap();
5352
5353        assert_eq!(transform.offset.unwrap().x.0, 10);
5354    }
5355
5356    #[test]
5357    fn effective_transform_uses_slide_layout_master_precedence() {
5358        let slide_children = [
5359            shape_with_details(Some("body"), Some(1), &transform(11), None),
5360            shape(Some("body"), Some(2)),
5361            shape(Some("body"), Some(3)),
5362            shape(Some("body"), Some(4)),
5363        ]
5364        .join("");
5365        let layout_children = [
5366            shape_with_details(Some("body"), Some(1), &transform(21), None),
5367            shape_with_details(Some("body"), Some(2), &transform(22), None),
5368            shape(Some("body"), Some(3)),
5369            shape(Some("body"), Some(4)),
5370        ]
5371        .join("");
5372        let master_children = [
5373            shape_with_details(Some("body"), Some(1), &transform(31), None),
5374            shape_with_details(Some("body"), Some(2), &transform(32), None),
5375            shape_with_details(Some("body"), Some(3), &transform(33), None),
5376            shape(Some("body"), Some(4)),
5377        ]
5378        .join("");
5379        let fixture = Fixture::new(&slide_children, &layout_children, &master_children);
5380        let context = fixture.context();
5381
5382        let offsets: Vec<_> = (0..3)
5383            .map(|index| {
5384                context
5385                    .effective_xfrm(fixture.slide_shape(index))
5386                    .unwrap()
5387                    .offset
5388                    .unwrap()
5389                    .x
5390                    .0
5391            })
5392            .collect();
5393
5394        assert_eq!(offsets, [11, 22, 33]);
5395        assert!(context.effective_xfrm(fixture.slide_shape(3)).is_none());
5396    }
5397
5398    #[test]
5399    fn inherited_transform_skips_the_slide_shape_and_follows_the_layout_key() {
5400        let slide_children = [
5401            shape_with_details(Some("body"), Some(1), &transform(11), None),
5402            shape(Some("body"), Some(3)),
5403            shape(Some("ftr"), Some(9)),
5404            shape(Some("body"), Some(5)),
5405        ]
5406        .join("");
5407        let layout_children = [
5408            shape_with_details(Some("body"), Some(1), &transform(21), None),
5409            shape(Some("body"), Some(3)),
5410            shape(Some("ftr"), Some(10)),
5411        ]
5412        .join("");
5413        let master_children = [
5414            shape_with_details(Some("body"), Some(1), &transform(31), None),
5415            shape_with_details(Some("body"), Some(3), &transform(33), None),
5416            shape_with_details(Some("ftr"), Some(11), &transform(34), None),
5417            shape_with_details(Some("body"), Some(5), &transform(35), None),
5418        ]
5419        .join("");
5420        let fixture = Fixture::new(&slide_children, &layout_children, &master_children);
5421        let context = fixture.context();
5422        let inherited = |index: usize| {
5423            let placeholder = fixture.slide_shape(index).placeholder.as_ref().unwrap();
5424            inherited_xfrm(placeholder, &fixture.layout, &fixture.master)
5425                .map(|transform| transform.offset.unwrap().x.0)
5426        };
5427
5428        assert_eq!(inherited(0), Some(21));
5429        assert_eq!(inherited(1), Some(33));
5430        assert_eq!(inherited(2), Some(34));
5431        assert_eq!(inherited(3), None, "a master match alone is not inherited");
5432        for index in 1..4 {
5433            assert_eq!(
5434                context
5435                    .effective_xfrm(fixture.slide_shape(index))
5436                    .map(|transform| transform.offset.unwrap().x.0),
5437                inherited(index)
5438            );
5439        }
5440    }
5441
5442    #[test]
5443    fn body_properties_merge_per_field_across_the_chain() {
5444        let fixture = Fixture::new(
5445            &shape_with_details(
5446                Some("body"),
5447                Some(4),
5448                "",
5449                Some(r#"<a:bodyPr bIns="50" anchor="ctr"/>"#),
5450            ),
5451            &shape_with_details(
5452                Some("body"),
5453                Some(4),
5454                "",
5455                Some(r#"<a:bodyPr lIns="30" rIns="40" wrap="none"/>"#),
5456            ),
5457            &shape_with_details(
5458                Some("body"),
5459                Some(4),
5460                "",
5461                Some(
5462                    r#"<a:bodyPr lIns="10" tIns="20" anchor="b" vert="vert" spcFirstLastPara="1"><a:spAutoFit/></a:bodyPr>"#,
5463                ),
5464            ),
5465        );
5466        let properties = fixture.context().effective_body_pr(fixture.slide_shape(0));
5467
5468        assert_eq!(properties.left_inset, Some(Coordinate32Value::Emu(30)));
5469        assert_eq!(properties.top_inset, Some(Coordinate32Value::Emu(20)));
5470        assert_eq!(properties.right_inset, Some(Coordinate32Value::Emu(40)));
5471        assert_eq!(properties.bottom_inset, Some(Coordinate32Value::Emu(50)));
5472        assert_eq!(properties.anchor, Some(TextAnchor::Center));
5473        assert_eq!(properties.wrap, Some(TextWrap::None));
5474        assert_eq!(properties.vertical, Some(TextVertical::Vertical));
5475        assert_eq!(properties.space_first_last_paragraph, Some(true));
5476        assert_eq!(properties.autofit, Some(TextAutofit::ShapeAutofit));
5477    }
5478
5479    #[test]
5480    fn body_property_defaults_use_exact_emu_values() {
5481        let fixture = Fixture::new(&shape(None, None), "", "");
5482        let properties = fixture.context().effective_body_pr(fixture.slide_shape(0));
5483
5484        assert_eq!(properties.left_inset, Some(Coordinate32Value::Emu(91_440)));
5485        assert_eq!(properties.right_inset, Some(Coordinate32Value::Emu(91_440)));
5486        assert_eq!(properties.top_inset, Some(Coordinate32Value::Emu(45_720)));
5487        assert_eq!(
5488            properties.bottom_inset,
5489            Some(Coordinate32Value::Emu(45_720))
5490        );
5491        assert_eq!(properties.anchor, Some(TextAnchor::Top));
5492        assert_eq!(properties.wrap, Some(TextWrap::Square));
5493        assert_eq!(properties.vertical, Some(TextVertical::Horizontal));
5494        assert_eq!(properties.space_first_last_paragraph, Some(false));
5495        assert_eq!(properties.autofit, Some(TextAutofit::NoAutofit));
5496    }
5497
5498    #[test]
5499    fn east_asian_vertical_text_degrades_to_rotated_with_a_diagnostic() {
5500        let shape = shape_with_details(
5501            None,
5502            None,
5503            &transform(0),
5504            Some(r#"<a:bodyPr vert="eaVert"/>"#),
5505        )
5506        .replace("<a:p/>", "<a:p><a:r><a:t>visible</a:t></a:r></a:p>");
5507        let resolved = Fixture::new(&shape, "", "")
5508            .context()
5509            .resolve_slide((720.0, 540.0))
5510            .expect("resolve East Asian vertical text");
5511
5512        let ResolvedContent::Text(text) = &resolved.shapes[0].content else {
5513            panic!("fallback text should remain visible");
5514        };
5515        assert_eq!(text.vertical, TextDirection::EastAsianVertical);
5516        assert!(matches!(
5517            &text.paragraphs[0].runs[0],
5518            ResolvedTextRun::Text { text, .. } if text == "visible"
5519        ));
5520        assert!(resolved.diagnostics.iter().any(|diagnostic| {
5521            diagnostic.message == "east Asian vertical text rendered as rotated vertical text"
5522        }));
5523    }
5524
5525    #[test]
5526    fn other_vertical_variants_remain_visible_with_diagnostics() {
5527        let cases = [
5528            (
5529                "mongolianVert",
5530                TextDirection::MongolianVertical,
5531                "Mongolian vertical text rendered as rotated vertical-270 text",
5532            ),
5533            (
5534                "wordArtVert",
5535                TextDirection::WordArtVertical,
5536                "WordArt vertical text rendered as rotated vertical text",
5537            ),
5538            (
5539                "wordArtVertRtl",
5540                TextDirection::WordArtVerticalRtl,
5541                "right-to-left WordArt vertical text rendered as rotated vertical-270 text",
5542            ),
5543        ];
5544
5545        for (value, expected_direction, expected_diagnostic) in cases {
5546            let shape = shape_with_details(
5547                None,
5548                None,
5549                &transform(0),
5550                Some(&format!(r#"<a:bodyPr vert="{value}"/>"#)),
5551            )
5552            .replace("<a:p/>", "<a:p><a:r><a:t>visible</a:t></a:r></a:p>");
5553            let resolved = Fixture::new(&shape, "", "")
5554                .context()
5555                .resolve_slide((720.0, 540.0))
5556                .expect("resolve visible vertical fallback");
5557
5558            let ResolvedContent::Text(text) = &resolved.shapes[0].content else {
5559                panic!("fallback text should remain visible");
5560            };
5561            assert_eq!(text.vertical, expected_direction);
5562            assert!(matches!(
5563                &text.paragraphs[0].runs[0],
5564                ResolvedTextRun::Text { text, .. } if text == "visible"
5565            ));
5566            assert!(
5567                resolved
5568                    .diagnostics
5569                    .iter()
5570                    .any(|diagnostic| { diagnostic.message == expected_diagnostic })
5571            );
5572        }
5573    }
5574
5575    #[test]
5576    fn flattener_omits_template_prompt_and_emits_master_logo_once() {
5577        let fixture = Fixture::new(
5578            &shape_with_text(Some("title"), Some(1), "Slide title"),
5579            "",
5580            &[
5581                shape_with_text(Some("title"), Some(1), "Click to edit Master title style"),
5582                shape_with_text(None, None, "Master logo"),
5583            ]
5584            .join(""),
5585        );
5586
5587        let texts: Vec<_> = fixture
5588            .context()
5589            .flatten()
5590            .iter()
5591            .filter_map(item_text)
5592            .collect();
5593
5594        assert!(!texts.contains(&"Click to edit Master title style".to_owned()));
5595        assert_eq!(
5596            texts.iter().filter(|text| *text == "Master logo").count(),
5597            1
5598        );
5599    }
5600
5601    #[test]
5602    fn flattener_emits_the_four_sources_in_draw_order() {
5603        let master_group = format!(
5604            "<p:grpSp><p:nvGrpSpPr/><p:grpSpPr/>{}</p:grpSp>",
5605            shape_with_text(None, None, "master")
5606        );
5607        let layout_fallback = format!(
5608            "<mc:AlternateContent><mc:Fallback>{}</mc:Fallback></mc:AlternateContent>",
5609            shape_with_text(None, None, "layout")
5610        );
5611        let fixture = Fixture::from_xml(
5612            &slide_xml_with(
5613                "",
5614                "<p:bg><p:bgPr/></p:bg>",
5615                &shape_with_text(None, None, "slide"),
5616            ),
5617            &layout_xml_with("", "", &layout_fallback, ""),
5618            &master_xml_with("", &master_group, ""),
5619        );
5620        let context = fixture.context();
5621        let flattened = context.flatten();
5622
5623        assert_eq!(
5624            flattened
5625                .iter()
5626                .map(FlattenedItem::source)
5627                .collect::<Vec<_>>(),
5628            [
5629                FlattenedSource::Background,
5630                FlattenedSource::Master,
5631                FlattenedSource::Layout,
5632                FlattenedSource::Slide,
5633            ]
5634        );
5635        let FlattenedItem::Background(background) = flattened[0] else {
5636            panic!("expected background first");
5637        };
5638        assert_eq!(background.source, BackgroundSource::Slide);
5639    }
5640
5641    #[test]
5642    fn background_precedence_stops_at_master_when_all_are_absent() {
5643        let slide_first = Fixture::from_xml(
5644            &slide_xml_with("", "<p:bg><p:bgPr/></p:bg>", ""),
5645            &layout_xml_with("", "<p:bg><p:bgPr/></p:bg>", "", ""),
5646            &master_xml_with("<p:bg><p:bgPr/></p:bg>", "", ""),
5647        );
5648        let layout_first = Fixture::from_xml(
5649            &slide_xml_with("", "", ""),
5650            &layout_xml_with("", "<p:bg><p:bgPr/></p:bg>", "", ""),
5651            &master_xml_with("<p:bg><p:bgPr/></p:bg>", "", ""),
5652        );
5653        let master_first = Fixture::from_xml(
5654            &slide_xml_with("", "", ""),
5655            &layout_xml_with("", "", "", ""),
5656            &master_xml_with("<p:bg><p:bgPr/></p:bg>", "", ""),
5657        );
5658        let absent = Fixture::new("", "", "");
5659
5660        assert_eq!(background_source(&slide_first), BackgroundSource::Slide);
5661        assert_eq!(background_source(&layout_first), BackgroundSource::Layout);
5662        assert_eq!(background_source(&master_first), BackgroundSource::Master);
5663        assert!(absent.context().effective_background().is_none());
5664    }
5665
5666    #[test]
5667    fn master_gradient_background_resolves_when_slide_and_layout_omit_one() {
5668        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>"#;
5669        let fixture = Fixture::from_xml(
5670            &slide_xml_with("", "", ""),
5671            &layout_xml_with("", "", "", ""),
5672            &master_xml_with(background, "", ""),
5673        );
5674
5675        let resolved = fixture.context().resolve_slide((40.0, 20.0)).unwrap();
5676        let Some(ResolvedBackground::Paint(Paint::Linear { stops, .. })) = resolved.background
5677        else {
5678            panic!("expected inherited master gradient paint");
5679        };
5680        assert_eq!(stops.len(), 259);
5681        assert_eq!(stops[0].color, Color::from_hex("FF0000"));
5682        assert_eq!(stops.last().unwrap().color, Color::from_hex("0000FF"));
5683        assert!(resolved.diagnostics.is_empty());
5684    }
5685
5686    #[test]
5687    fn background_gradient_ignores_shape_rotation_policy() {
5688        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>"#;
5689        let fixture = Fixture::from_xml(
5690            &slide_xml_with("", background, ""),
5691            &layout_xml_with("", "", "", ""),
5692            &master_xml_with("", "", ""),
5693        );
5694
5695        let resolved = fixture.context().resolve_slide((40.0, 20.0)).unwrap();
5696        assert!(matches!(
5697            resolved.background,
5698            Some(ResolvedBackground::Paint(Paint::Linear { .. }))
5699        ));
5700        assert!(resolved.diagnostics.is_empty());
5701    }
5702
5703    #[test]
5704    fn background_reference_resolves_phclr_through_the_master_colour_map() {
5705        let master = master_xml_with(
5706            r#"<p:bg><p:bgRef idx="1001"><a:schemeClr val="bg1"/></p:bgRef></p:bg>"#,
5707            "",
5708            "",
5709        )
5710        .replace("bg1=\"lt1\"", "bg1=\"accent6\"");
5711        let fixture = Fixture::from_xml(
5712            &slide_xml_with("", "", ""),
5713            &layout_xml_with("", "", "", ""),
5714            &master,
5715        );
5716        let context = ResolveCtx::new(
5717            &fixture.theme,
5718            fixture.master.color_map.clone(),
5719            &fixture.master,
5720            &fixture.layout,
5721            &fixture.slide,
5722            &fixture.default_text_style,
5723        );
5724
5725        let resolved = context.resolve_slide((40.0, 20.0)).unwrap();
5726        assert_eq!(
5727            resolved.background,
5728            Some(ResolvedBackground::Paint(Paint::Solid(Color::from_hex(
5729                "4EA72E",
5730            ))))
5731        );
5732        assert!(resolved.diagnostics.is_empty());
5733
5734        let mut transformed = Fixture::from_xml(
5735            &slide_xml_with("", "", ""),
5736            &layout_xml_with("", "", "", ""),
5737            &master_xml_with(
5738                r#"<p:bg><p:bgRef idx="1001"><a:srgbClr val="C0504D"><a:tint val="60000"/></a:srgbClr></p:bgRef></p:bg>"#,
5739                "",
5740                "",
5741            ),
5742        );
5743        transformed
5744            .theme
5745            .theme_elements
5746            .format_scheme
5747            .background_fill_styles[0] = oxml_drawing::fill::Fill::from_xml(
5748            br#"<a:solidFill><a:schemeClr val="phClr"><a:shade val="70000"/></a:schemeClr></a:solidFill>"#,
5749        )
5750        .unwrap();
5751
5752        let transformed = transformed.context().resolve_slide((40.0, 20.0)).unwrap();
5753        assert_eq!(
5754            transformed.background,
5755            Some(ResolvedBackground::Paint(Paint::Solid(Color::from_hex(
5756                "BC9897",
5757            ))))
5758        );
5759    }
5760
5761    #[test]
5762    fn background_picture_relationship_uses_its_producer_scope() {
5763        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>"#;
5764        let fixtures = [
5765            Fixture::from_xml(
5766                &slide_xml_with("", background, ""),
5767                &layout_xml_with("", "", "", ""),
5768                &master_xml_with("", "", ""),
5769            ),
5770            Fixture::from_xml(
5771                &slide_xml_with("", "", ""),
5772                &layout_xml_with("", background, "", ""),
5773                &master_xml_with("", "", ""),
5774            ),
5775            Fixture::from_xml(
5776                &slide_xml_with("", "", ""),
5777                &layout_xml_with("", "", "", ""),
5778                &master_xml_with(background, "", ""),
5779            ),
5780        ];
5781        let media = ScopedMediaIds {
5782            slide: HashMap::from([("rId9".to_owned(), MediaId(1))]),
5783            layout: HashMap::from([("rId9".to_owned(), MediaId(2))]),
5784            master: HashMap::from([("rId9".to_owned(), MediaId(3))]),
5785            ..ScopedMediaIds::default()
5786        };
5787
5788        for (fixture, expected) in fixtures.iter().zip([MediaId(1), MediaId(2), MediaId(3)]) {
5789            let resolved = fixture
5790                .context()
5791                .resolve_slide_with_media((40.0, 20.0), &media)
5792                .unwrap();
5793            let Some(ResolvedBackground::Image(image)) = resolved.background else {
5794                panic!("expected source-scoped background image");
5795            };
5796            assert_eq!(image.media, expected);
5797            assert_eq!(image.opacity, 0.3);
5798            assert!(resolved.diagnostics.is_empty());
5799        }
5800    }
5801
5802    #[test]
5803    fn master_relationship_cannot_satisfy_a_theme_background_blip() {
5804        let mut fixture = Fixture::from_xml(
5805            &slide_xml_with("", "", ""),
5806            &layout_xml_with("", "", "", ""),
5807            &master_xml_with(
5808                r#"<p:bg><p:bgRef idx="1001"><a:schemeClr val="bg1"/></p:bgRef></p:bg>"#,
5809                "",
5810                "",
5811            ),
5812        );
5813        fixture
5814            .theme
5815            .theme_elements
5816            .format_scheme
5817            .background_fill_styles[0] = Fill::from_xml(
5818            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>"#,
5819        )
5820        .unwrap();
5821        let media = ScopedMediaIds {
5822            master: HashMap::from([("rId9".to_owned(), MediaId(9))]),
5823            ..ScopedMediaIds::default()
5824        };
5825
5826        let resolved = fixture
5827            .context()
5828            .resolve_slide_with_media((40.0, 20.0), &media)
5829            .unwrap();
5830        assert!(resolved.background.is_none());
5831        assert_eq!(
5832            resolved.diagnostics,
5833            [Diagnostic {
5834                message: "theme-referenced background picture requires theme relationship scope"
5835                    .to_owned(),
5836            }]
5837        );
5838    }
5839
5840    #[test]
5841    fn background_picture_preserves_crop_stretch_and_tile_placement() {
5842        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>"#;
5843        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>"#;
5844        let media = ScopedMediaIds {
5845            slide: HashMap::from([
5846                ("rId1".to_owned(), MediaId(1)),
5847                ("rId2".to_owned(), MediaId(2)),
5848            ]),
5849            ..ScopedMediaIds::default()
5850        };
5851
5852        let tile_fixture = Fixture::from_xml(
5853            &slide_xml_with("", tile, ""),
5854            &layout_xml_with("", "", "", ""),
5855            &master_xml_with("", "", ""),
5856        );
5857        let resolved = tile_fixture
5858            .context()
5859            .resolve_slide_with_media((40.0, 20.0), &media)
5860            .unwrap();
5861        let Some(ResolvedBackground::Image(image)) = resolved.background else {
5862            panic!("expected tiled background image");
5863        };
5864        assert_eq!(image.src_rect.unwrap().left, 0.1);
5865        assert_eq!(image.dpi, Some(144.0));
5866        assert!(!image.rotate_with_shape);
5867        let ResolvedImagePlacement::Tile(tile) = image.placement else {
5868            panic!("expected tile placement");
5869        };
5870        assert_eq!(tile.translation, Point { x: 1.0, y: -2.0 });
5871        assert_eq!((tile.scale_x, tile.scale_y), (0.5, 2.0));
5872        assert_eq!(tile.flip, ResolvedTileFlip::Both);
5873        assert_eq!(tile.alignment, ResolvedRectAlignment::BottomRight);
5874
5875        let stretch_fixture = Fixture::from_xml(
5876            &slide_xml_with("", stretch, ""),
5877            &layout_xml_with("", "", "", ""),
5878            &master_xml_with("", "", ""),
5879        );
5880        let resolved = stretch_fixture
5881            .context()
5882            .resolve_slide_with_media((40.0, 20.0), &media)
5883            .unwrap();
5884        let Some(ResolvedBackground::Image(image)) = resolved.background else {
5885            panic!("expected stretched background image");
5886        };
5887        assert_eq!(
5888            image.placement,
5889            ResolvedImagePlacement::Stretch {
5890                fill_rect: Some(crate::CropRect {
5891                    left: 0.1,
5892                    top: 0.2,
5893                    right: 0.3,
5894                    bottom: 0.4,
5895                }),
5896            }
5897        );
5898    }
5899
5900    #[test]
5901    fn missing_background_picture_relationship_is_diagnosed() {
5902        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>"#;
5903        let fixture = Fixture::from_xml(
5904            &slide_xml_with("", background, ""),
5905            &layout_xml_with("", "", "", ""),
5906            &master_xml_with("", "", ""),
5907        );
5908
5909        let resolved = fixture
5910            .context()
5911            .resolve_slide_with_media((40.0, 20.0), &ScopedMediaIds::default())
5912            .unwrap();
5913        assert!(resolved.background.is_none());
5914        assert_eq!(
5915            resolved.diagnostics,
5916            [Diagnostic {
5917                message: "missing slide background image relationship `rId404`".to_owned(),
5918            }]
5919        );
5920    }
5921
5922    #[test]
5923    fn unsupported_background_fill_records_a_specific_diagnostic() {
5924        let fixture = Fixture::from_xml(
5925            &slide_xml_with(
5926                "",
5927                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>"#,
5928                "",
5929            ),
5930            &layout_xml_with("", "", "", ""),
5931            &master_xml_with("", "", ""),
5932        );
5933
5934        let resolved = fixture.context().resolve_slide((40.0, 20.0)).unwrap();
5935        let Some(ResolvedBackground::Paint(Paint::Grid {
5936            bounds,
5937            columns,
5938            rows,
5939            colors,
5940        })) = &resolved.background
5941        else {
5942            panic!("supported pattern background must paint");
5943        };
5944        assert_eq!((*columns, *rows), (64, 64));
5945        assert_eq!((bounds.width, bounds.height), (8.0, 8.0));
5946        assert!(colors.contains(&Color::from_hex("FF0000")));
5947        assert!(colors.contains(&Color::WHITE));
5948        assert!(resolved.diagnostics.is_empty());
5949
5950        let group_fill = Fixture::from_xml(
5951            &slide_xml_with("", r#"<p:bg><p:bgPr><a:grpFill/></p:bgPr></p:bg>"#, ""),
5952            &layout_xml_with("", "", "", ""),
5953            &master_xml_with("", "", ""),
5954        )
5955        .context()
5956        .resolve_slide((40.0, 20.0))
5957        .unwrap();
5958        assert!(group_fill.background.is_none());
5959        assert_eq!(
5960            group_fill.diagnostics,
5961            vec![oxml_layout::Diagnostic {
5962                message: "unsupported background group fill".to_owned(),
5963            }]
5964        );
5965    }
5966
5967    #[test]
5968    fn fx253_native_visibility_truth_table_preserves_distinct_layers() {
5969        for slide_flag in [None, Some(true), Some(false)] {
5970            for layout_flag in [None, Some(true), Some(false)] {
5971                let flag = |value: Option<bool>| {
5972                    value.map_or(String::new(), |v| {
5973                        format!("showMasterSp=\"{}\"", u8::from(v))
5974                    })
5975                };
5976                let fixture = Fixture::from_xml(
5977                    &slide_xml_with(
5978                        &flag(slide_flag),
5979                        "",
5980                        &[
5981                            shape_with_text(None, None, "slide"),
5982                            shape_with_text(Some("body"), Some(1), "occupied"),
5983                        ]
5984                        .join(""),
5985                    ),
5986                    &layout_xml_with(
5987                        &flag(layout_flag),
5988                        "",
5989                        &[
5990                            shape_with_text(None, None, "layout"),
5991                            shape_with_text(Some("body"), Some(1), "layout template"),
5992                        ]
5993                        .join(""),
5994                        "",
5995                    ),
5996                    &master_xml_with(
5997                        "",
5998                        &[
5999                            shape_with_text(None, None, "master"),
6000                            shape_with_text(Some("title"), Some(2), "master template"),
6001                        ]
6002                        .join(""),
6003                        "",
6004                    ),
6005                );
6006                let texts: Vec<_> = fixture
6007                    .context()
6008                    .flatten()
6009                    .iter()
6010                    .filter_map(item_text)
6011                    .collect();
6012                let mut expected = Vec::new();
6013                if slide_flag.unwrap_or(true) && layout_flag.unwrap_or(true) {
6014                    expected.push("master");
6015                }
6016                if slide_flag.unwrap_or(true) {
6017                    expected.push("layout");
6018                }
6019                expected.extend(["slide", "occupied"]);
6020                assert_eq!(
6021                    texts, expected,
6022                    "slide={slide_flag:?} layout={layout_flag:?}"
6023                );
6024            }
6025        }
6026    }
6027
6028    #[test]
6029    fn show_master_shapes_suppresses_the_owned_pass() {
6030        let master_suppressed = Fixture::from_xml(
6031            &slide_xml_with("", "", &shape_with_text(None, None, "slide")),
6032            &layout_xml_with(
6033                "showMasterSp=\"0\"",
6034                "",
6035                &shape_with_text(None, None, "layout"),
6036                "",
6037            ),
6038            &master_xml_with("", &shape_with_text(None, None, "master"), ""),
6039        );
6040        let layout_suppressed = Fixture::from_xml(
6041            &slide_xml_with(
6042                "showMasterSp=\"0\"",
6043                "",
6044                &shape_with_text(None, None, "slide"),
6045            ),
6046            &layout_xml_with("", "", &shape_with_text(None, None, "layout"), ""),
6047            &master_xml_with("", &shape_with_text(None, None, "master"), ""),
6048        );
6049
6050        let master_suppressed_texts: Vec<_> = master_suppressed
6051            .context()
6052            .flatten()
6053            .iter()
6054            .filter_map(item_text)
6055            .collect();
6056        let layout_suppressed_texts: Vec<_> = layout_suppressed
6057            .context()
6058            .flatten()
6059            .iter()
6060            .filter_map(item_text)
6061            .collect();
6062
6063        assert_eq!(master_suppressed_texts, ["layout", "slide"]);
6064        assert_eq!(layout_suppressed_texts, ["slide"]);
6065    }
6066
6067    #[test]
6068    fn slide_placeholder_keeps_source_order_and_suppresses_inherited_matches() {
6069        let slide_children = [
6070            shape_with_text(None, None, "before"),
6071            shape_with_text(Some("ftr"), Some(9), "slide footer"),
6072            shape_with_text(None, None, "after"),
6073        ]
6074        .join("");
6075        let fixture = Fixture::from_xml(
6076            &slide_xml_with("", "", &slide_children),
6077            &layout_xml_with(
6078                "",
6079                "",
6080                &shape_with_text(Some("ftr"), Some(9), "layout footer"),
6081                "<p:hf/>",
6082            ),
6083            &master_xml_with(
6084                "",
6085                &shape_with_text(Some("ftr"), Some(9), "master footer"),
6086                "<p:hf/>",
6087            ),
6088        );
6089
6090        let sources_and_text: Vec<_> = fixture
6091            .context()
6092            .flatten()
6093            .iter()
6094            .filter_map(|item| item_text(item).map(|text| (item.source(), text)))
6095            .collect();
6096
6097        assert_eq!(
6098            sources_and_text,
6099            [
6100                (FlattenedSource::Slide, "before".to_owned()),
6101                (FlattenedSource::Slide, "slide footer".to_owned()),
6102                (FlattenedSource::Slide, "after".to_owned()),
6103            ]
6104        );
6105    }
6106
6107    #[test]
6108    fn fx208_native_latent_footer_templates_require_slide_owned_fields() {
6109        let inherited = [
6110            shape_with_text(Some("dt"), Some(2), "template date"),
6111            shape_with_text(Some("ftr"), Some(3), "template footer"),
6112            shape_with_text(Some("sldNum"), Some(4), "template number"),
6113        ]
6114        .join("");
6115        let ordinary = shape_with_text(None, None, "ordinary master text");
6116        let owned = shape_with_text(Some("ftr"), Some(3), "owned slide footer");
6117        for flags in [
6118            "",
6119            "<p:hf/>",
6120            "<p:hf dt=\"1\" ftr=\"1\" sldNum=\"1\"/>",
6121            "<p:hf dt=\"0\" ftr=\"0\" sldNum=\"0\"/>",
6122        ] {
6123            let fixture = Fixture::from_xml(
6124                &slide_xml_with("", "", &owned),
6125                &layout_xml_with(
6126                    "type=\"title\" showSpecialPlsOnTitleSld=\"1\"",
6127                    "",
6128                    &inherited,
6129                    flags,
6130                ),
6131                &master_xml_with("", &format!("{inherited}{ordinary}"), flags),
6132            );
6133            let actual = fixture
6134                .context()
6135                .flatten()
6136                .iter()
6137                .filter_map(|item| item_text(item).map(|text| (item.source(), text)))
6138                .collect::<Vec<_>>();
6139            assert!(
6140                actual.contains(&(FlattenedSource::Master, "ordinary master text".into())),
6141                "ordinary master positive setup"
6142            );
6143            assert!(
6144                actual.contains(&(FlattenedSource::Slide, "owned slide footer".into())),
6145                "slide-owned positive setup"
6146            );
6147            assert_eq!(
6148                actual,
6149                [
6150                    (FlattenedSource::Master, "ordinary master text".into()),
6151                    (FlattenedSource::Slide, "owned slide footer".into()),
6152                ],
6153                "F208 native latent-template visibility: {flags}"
6154            );
6155        }
6156    }
6157
6158    #[test]
6159    fn inherited_latent_placeholders_remain_templates_regardless_of_flags() {
6160        let latent_shapes = [
6161            shape_with_text(Some("dt"), Some(1), "date"),
6162            shape_with_text(Some("ftr"), Some(2), "footer"),
6163            shape_with_text(Some("sldNum"), Some(3), "number"),
6164        ]
6165        .join("");
6166        let layout_fixture = Fixture::from_xml(
6167            &slide_xml_with("", "", ""),
6168            &layout_xml_with("", "", &latent_shapes, "<p:hf dt=\"0\"/>"),
6169            &master_xml_with("", "", "<p:hf sldNum=\"0\"/>"),
6170        );
6171
6172        let layout_sources_and_text = layout_fixture
6173            .context()
6174            .flatten()
6175            .iter()
6176            .filter_map(|item| item_text(item).map(|text| (item.source(), text)))
6177            .collect::<Vec<_>>();
6178        assert!(layout_sources_and_text.is_empty());
6179
6180        let master_fixture = Fixture::from_xml(
6181            &slide_xml_with("", "", ""),
6182            &layout_xml_with("", "", "", ""),
6183            &master_xml_with(
6184                "",
6185                &latent_shapes,
6186                "<p:hf dt=\"1\" ftr=\"1\" sldNum=\"0\"/>",
6187            ),
6188        );
6189        let master_sources_and_text = master_fixture
6190            .context()
6191            .flatten()
6192            .iter()
6193            .filter_map(|item| item_text(item).map(|text| (item.source(), text)))
6194            .collect::<Vec<_>>();
6195        assert!(master_sources_and_text.is_empty());
6196    }
6197
6198    #[test]
6199    fn empty_latent_placeholders_do_not_materialize_inherited_templates() {
6200        fn source_texts(fixture: &Fixture) -> Vec<(FlattenedSource, String)> {
6201            fixture
6202                .context()
6203                .flatten()
6204                .iter()
6205                .filter_map(|item| item_text(item).map(|text| (item.source(), text)))
6206                .collect()
6207        }
6208
6209        for ph_type in ["dt", "ftr", "sldNum"] {
6210            let occupied_slide = Fixture::from_xml(
6211                &slide_xml_with("", "", &shape_with_text(Some(ph_type), Some(1), "slide")),
6212                &layout_xml_with(
6213                    "",
6214                    "",
6215                    &shape_with_text(Some(ph_type), Some(2), "layout"),
6216                    "<p:hf dt=\"0\" ftr=\"0\" sldNum=\"0\"/>",
6217                ),
6218                &master_xml_with(
6219                    "",
6220                    &shape_with_text(Some(ph_type), Some(3), "master"),
6221                    "<p:hf dt=\"0\" ftr=\"0\" sldNum=\"0\"/>",
6222                ),
6223            );
6224            assert_eq!(
6225                source_texts(&occupied_slide),
6226                [(FlattenedSource::Slide, "slide".to_owned())],
6227                "occupied slide {ph_type}"
6228            );
6229
6230            let empty_slide = Fixture::from_xml(
6231                &slide_xml_with("", "", &shape_with_text(Some(ph_type), Some(1), "")),
6232                &layout_xml_with(
6233                    "",
6234                    "",
6235                    &shape_with_text(Some(ph_type), Some(2), "layout"),
6236                    "<p:hf/>",
6237                ),
6238                &master_xml_with(
6239                    "",
6240                    &shape_with_text(Some(ph_type), Some(3), "master"),
6241                    "<p:hf/>",
6242                ),
6243            );
6244            assert_eq!(
6245                source_texts(&empty_slide),
6246                Vec::<(FlattenedSource, String)>::new(),
6247                "empty slide {ph_type}"
6248            );
6249
6250            let empty_layout = Fixture::from_xml(
6251                &slide_xml_with("", "", ""),
6252                &layout_xml_with(
6253                    "",
6254                    "",
6255                    &shape_with_text(Some(ph_type), Some(2), ""),
6256                    "<p:hf/>",
6257                ),
6258                &master_xml_with(
6259                    "",
6260                    &shape_with_text(Some(ph_type), Some(3), "master"),
6261                    "<p:hf/>",
6262                ),
6263            );
6264            assert_eq!(
6265                source_texts(&empty_layout),
6266                Vec::<(FlattenedSource, String)>::new(),
6267                "empty layout {ph_type}"
6268            );
6269
6270            let empty_master = Fixture::from_xml(
6271                &slide_xml_with("", "", ""),
6272                &layout_xml_with("", "", "", ""),
6273                &master_xml_with("", &shape_with_text(Some(ph_type), Some(3), ""), "<p:hf/>"),
6274            );
6275            assert!(
6276                source_texts(&empty_master).is_empty(),
6277                "empty master {ph_type}"
6278            );
6279        }
6280    }
6281
6282    #[test]
6283    fn slide_owned_latent_placeholder_source_matrix_matches_the_oracle_decision() {
6284        let slide_number = shape_with_text(Some("sldNum"), Some(3), "slide number");
6285        let layout_date = shape_with_text(Some("dt"), Some(1), "layout date");
6286        let cases = [
6287            ("", "", vec!["slide number"]),
6288            (
6289                "",
6290                "<p:hf dt=\"0\" ftr=\"0\" hdr=\"0\" sldNum=\"0\"/>",
6291                vec!["slide number"],
6292            ),
6293            (&layout_date, "<p:hf sldNum=\"1\"/>", vec!["slide number"]),
6294        ];
6295
6296        for (layout_children, master_header_footer, expected) in cases {
6297            let fixture = Fixture::from_xml(
6298                &slide_xml_with("", "", &slide_number),
6299                &layout_xml_with("", "", layout_children, ""),
6300                &master_xml_with("", "", master_header_footer),
6301            );
6302            let texts = fixture
6303                .context()
6304                .flatten()
6305                .iter()
6306                .filter_map(item_text)
6307                .collect::<Vec<_>>();
6308
6309            assert_eq!(
6310                texts, expected,
6311                "master header-footer: {master_header_footer}"
6312            );
6313        }
6314    }
6315
6316    #[test]
6317    fn absent_header_footer_container_hides_inherited_latent_placeholders() {
6318        let slide = shape_with_text(Some("ftr"), Some(11), "slide footer");
6319        let layout = [
6320            shape_with_text(Some("dt"), Some(10), "layout date"),
6321            shape_with_text(Some("sldNum"), Some(12), "layout number"),
6322        ]
6323        .join("");
6324        let fixture = Fixture::from_xml(&slide_xml(&slide), &layout_xml(&layout), &master_xml(""));
6325
6326        let texts = fixture
6327            .context()
6328            .flatten()
6329            .into_iter()
6330            .filter_map(|item| item_text(&item))
6331            .collect::<Vec<_>>();
6332
6333        assert_eq!(texts, ["slide footer"]);
6334    }
6335
6336    #[test]
6337    fn corpus_slide_resolves_without_theme_references() {
6338        let stats = resolve_pinned_corpus();
6339
6340        assert_eq!(stats.decks, EXPECTED_CORPUS_DECKS);
6341        assert!(stats.slides > EXPECTED_CORPUS_DECKS);
6342        assert_eq!(stats.contextual_errors, 0, "{}", stats.errors.join("\n"));
6343        assert_eq!(stats.unresolved_members, 0, "{}", stats.errors.join("\n"));
6344        assert_eq!(stats.resolved, stats.slides);
6345        assert_eq!(stats.theme_references, 0);
6346    }
6347
6348    #[test]
6349    fn resolved_contract_contains_no_presentation_or_drawing_types() {
6350        fn assert_owned_and_static(_: ResolvedSlide) {}
6351
6352        let fixture = Fixture::new(&shape_with_details(None, None, &transform(0), None), "", "");
6353        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
6354        assert_owned_and_static(resolved);
6355        for type_name in [
6356            std::any::type_name::<ResolvedSlide>(),
6357            std::any::type_name::<crate::ResolvedShape>(),
6358            std::any::type_name::<crate::ResolvedContent>(),
6359        ] {
6360            assert!(!type_name.contains("rpptx_oxml"));
6361            assert!(!type_name.contains("oxml_drawing"));
6362        }
6363    }
6364
6365    #[test]
6366    fn line_end_resolution_keeps_kind_width_and_length() {
6367        let properties = format!(
6368            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>"#,
6369            transform(0)
6370        );
6371        let fixture = Fixture::new(&shape_with_details(None, None, &properties, None), "", "");
6372
6373        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
6374        let shape = &resolved.shapes[0];
6375        assert_eq!(
6376            shape.head_end,
6377            Some(ResolvedLineEnd {
6378                kind: ResolvedLineEndKind::Diamond,
6379                width: ResolvedLineEndSize::Small,
6380                length: ResolvedLineEndSize::Medium,
6381            })
6382        );
6383        assert_eq!(
6384            shape.tail_end,
6385            Some(ResolvedLineEnd {
6386                kind: ResolvedLineEndKind::Triangle,
6387                width: ResolvedLineEndSize::Medium,
6388                length: ResolvedLineEndSize::Large,
6389            })
6390        );
6391        assert_eq!(shape.line.as_ref().map(|line| line.width), Some(2.0));
6392    }
6393
6394    #[test]
6395    fn same_relationship_id_resolves_to_distinct_media_in_each_source_scope() {
6396        let fixture = Fixture::new(
6397            &picture("rId2", "", "", 25_400),
6398            &picture("rId2", "", "", 12_700),
6399            &picture("rId2", "", "", 0),
6400        );
6401        let media = ScopedMediaIds {
6402            slide: HashMap::from([("rId2".to_owned(), MediaId(1))]),
6403            layout: HashMap::from([("rId2".to_owned(), MediaId(2))]),
6404            master: HashMap::from([("rId2".to_owned(), MediaId(3))]),
6405            ..ScopedMediaIds::default()
6406        };
6407
6408        let resolved = fixture
6409            .context()
6410            .resolve_slide_with_media((720.0, 540.0), &media)
6411            .unwrap();
6412        let ids = resolved
6413            .shapes
6414            .iter()
6415            .map(|shape| match shape.content {
6416                ResolvedContent::Image(ref image) => image.media,
6417                _ => panic!("scoped picture should resolve to image content"),
6418            })
6419            .collect::<Vec<_>>();
6420
6421        assert_eq!(ids, [MediaId(3), MediaId(2), MediaId(1)]);
6422    }
6423
6424    #[test]
6425    fn picture_placeholder_inherits_layout_bounds_and_keeps_slide_media_scope() {
6426        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>"#;
6427        let layout_placeholder = shape_with_details(
6428            Some("pic"),
6429            Some(1),
6430            r#"<a:xfrm><a:off x="12700" y="25400"/><a:ext cx="38100" cy="50800"/></a:xfrm>"#,
6431            None,
6432        );
6433        let master_placeholder = shape_with_details(
6434            Some("pic"),
6435            Some(1),
6436            r#"<a:xfrm><a:off x="127000" y="254000"/><a:ext cx="381000" cy="508000"/></a:xfrm>"#,
6437            None,
6438        );
6439        let fixture = Fixture::new(slide_picture, &layout_placeholder, &master_placeholder);
6440        let context = fixture.context();
6441        let flattened = context.flatten();
6442        let bounded_picture_sources = flattened
6443            .iter()
6444            .filter(|item| {
6445                matches!(
6446                    item,
6447                    FlattenedItem::Shape {
6448                        child: ShapeTreeChild::Picture(picture),
6449                        ..
6450                    } if context.effective_picture_xfrm(picture).is_some()
6451                )
6452            })
6453            .count();
6454        let media = ScopedMediaIds {
6455            slide: HashMap::from([("rId2".to_owned(), MediaId(7))]),
6456            layout: HashMap::from([("rId2".to_owned(), MediaId(8))]),
6457            master: HashMap::from([("rId2".to_owned(), MediaId(9))]),
6458            ..ScopedMediaIds::default()
6459        };
6460
6461        let resolved = context
6462            .resolve_slide_with_media((720.0, 540.0), &media)
6463            .unwrap();
6464        assert_eq!(bounded_picture_sources, 1);
6465        assert_eq!(resolved.shapes.len(), bounded_picture_sources);
6466        let picture = &resolved.shapes[0];
6467        assert_eq!(
6468            picture.bounds,
6469            Rect {
6470                x: 1.0,
6471                y: 2.0,
6472                width: 3.0,
6473                height: 4.0,
6474            }
6475        );
6476        let ResolvedContent::Image(image) = &picture.content else {
6477            panic!("picture placeholder did not retain image content");
6478        };
6479        assert_eq!(image.media, MediaId(7));
6480        assert_eq!(image.src_rect.as_ref().map(|crop| crop.top), Some(0.1));
6481        assert_eq!(image.src_rect.as_ref().map(|crop| crop.bottom), Some(0.2));
6482        assert_eq!(
6483            image.placement,
6484            ResolvedImagePlacement::Stretch {
6485                fill_rect: Some(crate::CropRect::default()),
6486            }
6487        );
6488        assert!(resolved.diagnostics.is_empty());
6489    }
6490
6491    #[test]
6492    fn direct_shape_picture_fill_uses_its_producer_scope() {
6493        let fixture = Fixture::new(
6494            &shape_picture_fill("rId2", 25_400, None),
6495            &shape_picture_fill("rId2", 12_700, None),
6496            &shape_picture_fill("rId2", 0, None),
6497        );
6498        let media = ScopedMediaIds {
6499            slide: HashMap::from([("rId2".to_owned(), MediaId(1))]),
6500            layout: HashMap::from([("rId2".to_owned(), MediaId(2))]),
6501            master: HashMap::from([("rId2".to_owned(), MediaId(3))]),
6502            ..ScopedMediaIds::default()
6503        };
6504
6505        let resolved = fixture
6506            .context()
6507            .resolve_slide_with_media((720.0, 540.0), &media)
6508            .unwrap();
6509        let ids = resolved
6510            .shapes
6511            .iter()
6512            .map(|shape| shape.image_fill.as_ref().unwrap().media)
6513            .collect::<Vec<_>>();
6514
6515        assert_eq!(ids, [MediaId(3), MediaId(2), MediaId(1)]);
6516        assert!(resolved.diagnostics.is_empty());
6517    }
6518
6519    #[test]
6520    fn shape_picture_fill_and_text_are_resolved_independently() {
6521        let shape = shape_picture_fill(
6522            "rId7",
6523            0,
6524            Some(r#"<a:bodyPr/><a:p><a:r><a:t>caption</a:t></a:r></a:p>"#),
6525        );
6526        let fixture = Fixture::new(&shape, "", "");
6527        let media = ScopedMediaIds {
6528            slide: HashMap::from([("rId7".to_owned(), MediaId(7))]),
6529            ..ScopedMediaIds::default()
6530        };
6531
6532        let resolved = fixture
6533            .context()
6534            .resolve_slide_with_media((720.0, 540.0), &media)
6535            .unwrap();
6536        let shape = &resolved.shapes[0];
6537        assert_eq!(shape.image_fill.as_ref().unwrap().media, MediaId(7));
6538        let ResolvedContent::Text(text) = &shape.content else {
6539            panic!("picture-filled shape lost its text");
6540        };
6541        assert!(matches!(
6542            &text.paragraphs[0].runs[0],
6543            ResolvedTextRun::Text { text, .. } if text == "caption"
6544        ));
6545        assert!(resolved.diagnostics.is_empty());
6546    }
6547
6548    #[test]
6549    fn picture_fill_opacity_comes_from_alpha_modulation_fix() {
6550        let opaque = shape_picture_fill("rId7", 0, None);
6551        let faded = opaque.replace(
6552            r#"r:embed="rId7"/>"#,
6553            r#"r:embed="rId7"><a:alphaModFix amt="30000"/></a:blip>"#,
6554        );
6555        assert_ne!(faded, opaque);
6556        let media = ScopedMediaIds {
6557            slide: HashMap::from([("rId7".to_owned(), MediaId(7))]),
6558            ..ScopedMediaIds::default()
6559        };
6560
6561        for (shape, opacity) in [(opaque, 1.0), (faded, 0.3)] {
6562            let fixture = Fixture::new(&shape, "", "");
6563            let resolved = fixture
6564                .context()
6565                .resolve_slide_with_media((720.0, 540.0), &media)
6566                .unwrap();
6567            let image = resolved.shapes[0].image_fill.as_ref().unwrap();
6568            assert_eq!(image.opacity, opacity);
6569            assert!(resolved.diagnostics.is_empty());
6570        }
6571    }
6572
6573    #[test]
6574    fn theme_shape_picture_fill_cannot_use_a_part_relationship() {
6575        let mut fixture = Fixture::new(
6576            &shape_with_details(
6577                None,
6578                None,
6579                &format!(
6580                    r#"{}<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>"#,
6581                    transform(0)
6582                ),
6583                None,
6584            )
6585            .replace(
6586                "</p:sp>",
6587                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>"#,
6588            ),
6589            "",
6590            "",
6591        );
6592        fixture.theme.theme_elements.format_scheme.fill_styles[0] = Fill::from_xml(
6593            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>"#,
6594        )
6595        .unwrap();
6596        let media = ScopedMediaIds {
6597            slide: HashMap::from([("rId9".to_owned(), MediaId(9))]),
6598            master: HashMap::from([("rId9".to_owned(), MediaId(99))]),
6599            ..ScopedMediaIds::default()
6600        };
6601
6602        let resolved = fixture
6603            .context()
6604            .resolve_slide_with_media((720.0, 540.0), &media)
6605            .unwrap();
6606
6607        assert!(resolved.shapes[0].image_fill.is_none());
6608        assert_eq!(
6609            resolved.shapes[0].unsupported,
6610            Some("theme-referenced shape picture fill")
6611        );
6612        assert_eq!(
6613            resolved.diagnostics,
6614            [Diagnostic {
6615                message: "theme-referenced shape picture fill requires theme relationship scope"
6616                    .to_owned(),
6617            }]
6618        );
6619    }
6620
6621    #[test]
6622    fn missing_direct_shape_picture_relationship_keeps_text() {
6623        let shape = shape_picture_fill(
6624            "rId404",
6625            0,
6626            Some(r#"<a:bodyPr/><a:p><a:r><a:t>keep me</a:t></a:r></a:p>"#),
6627        );
6628        let resolved = Fixture::new(&shape, "", "")
6629            .context()
6630            .resolve_slide_with_media((720.0, 540.0), &ScopedMediaIds::default())
6631            .unwrap();
6632
6633        assert!(resolved.shapes[0].image_fill.is_none());
6634        assert!(matches!(
6635            resolved.shapes[0].content,
6636            ResolvedContent::Text(_)
6637        ));
6638        assert_eq!(
6639            resolved.shapes[0].unsupported,
6640            Some("missing image relationship")
6641        );
6642        assert_eq!(
6643            resolved.diagnostics,
6644            [Diagnostic {
6645                message: "missing slide shape image relationship `rId404`".to_owned(),
6646            }]
6647        );
6648    }
6649
6650    #[test]
6651    fn rejected_picture_relationship_reports_and_uses_visible_bounds() {
6652        let fixture = Fixture::new(&picture("rId7", "", "", 0), "", "");
6653        let failures = ScopedMediaFailures {
6654            slide: HashMap::from([(
6655                "rId7".to_owned(),
6656                "exceeds the 64 MiB decoded-image render limit".to_owned(),
6657            )]),
6658            ..ScopedMediaFailures::default()
6659        };
6660
6661        let resolved = fixture
6662            .context()
6663            .with_media_failures(&failures)
6664            .resolve_slide_with_media((720.0, 540.0), &ScopedMediaIds::default())
6665            .unwrap();
6666
6667        assert_eq!(
6668            resolved.shapes[0].geometry,
6669            ResolvedGeometry::BoundsFallback
6670        );
6671        assert_eq!(resolved.shapes[0].unsupported, Some("image render failure"));
6672        assert_eq!(
6673            resolved.diagnostics,
6674            [Diagnostic {
6675                message: "picture image relationship `rId7` exceeds the 64 MiB decoded-image render limit"
6676                    .to_owned(),
6677            }]
6678        );
6679    }
6680
6681    #[test]
6682    fn fx209_hidden_picture_is_omitted_without_affecting_visible_owners() {
6683        for (attribute, count) in [
6684            ("", 1),
6685            ("hidden='0'", 1),
6686            ("hidden='false'", 1),
6687            ("hidden='1'", 0),
6688            ("hidden='true'", 0),
6689            ("xmlns:f='urn:foreign' f:hidden='1'", 1),
6690        ] {
6691            let input = picture("rId1", "", "", 0)
6692                .replace("<p:cNvPr/>", &format!("<p:cNvPr {attribute}/>"));
6693            let fixture = Fixture::new(&input, "", "");
6694            let media = ScopedMediaIds {
6695                slide: HashMap::from([("rId1".to_owned(), MediaId(11))]),
6696                ..ScopedMediaIds::default()
6697            };
6698            let resolved = fixture
6699                .context()
6700                .resolve_slide_with_media((40.0, 20.0), &media)
6701                .unwrap();
6702            assert_eq!(resolved.shapes.len(), count, "{attribute}");
6703            assert!(resolved.diagnostics.is_empty());
6704        }
6705    }
6706
6707    #[test]
6708    fn picture_model_resolves_to_neutral_stretch_and_tile_placement() {
6709        let fixture = Fixture::new(
6710            &format!(
6711                "{}{}{}{}",
6712                picture("rId1", "", "", 0),
6713                picture(
6714                    "rId2",
6715                    "dpi=\"144\" rotWithShape=\"0\"",
6716                    r#"<a:srcRect l="10000"/><a:tile tx="12700" ty="-25400" sx="50000" sy="200000" flip="xy" algn="br"/>"#,
6717                    12_700,
6718                ),
6719                picture(
6720                    "rId4",
6721                    "",
6722                    r#"<a:stretch><a:fillRect l="10000" t="20000" r="30000" b="40000"/></a:stretch>"#,
6723                    25_400,
6724                ),
6725                linked_picture("https://example.invalid/image.png", 38_100),
6726            ),
6727            "",
6728            "",
6729        );
6730        let media = ScopedMediaIds {
6731            slide: HashMap::from([
6732                ("rId1".to_owned(), MediaId(11)),
6733                ("rId2".to_owned(), MediaId(12)),
6734                ("rId4".to_owned(), MediaId(14)),
6735            ]),
6736            ..ScopedMediaIds::default()
6737        };
6738
6739        let resolved = fixture
6740            .context()
6741            .resolve_slide_with_media((720.0, 540.0), &media)
6742            .unwrap();
6743        let ResolvedContent::Image(image) = &resolved.shapes[0].content else {
6744            panic!("default picture should resolve");
6745        };
6746        assert_eq!(image.placement, ResolvedImagePlacement::default());
6747        assert_eq!(image.dpi, None);
6748        assert!(image.rotate_with_shape);
6749
6750        let ResolvedContent::Image(image) = &resolved.shapes[1].content else {
6751            panic!("tile picture should resolve");
6752        };
6753        let ResolvedImagePlacement::Tile(tile) = &image.placement else {
6754            panic!("expected tile placement");
6755        };
6756        assert_eq!(image.src_rect.unwrap().left, 0.1);
6757        assert_eq!(tile.translation, Point { x: 1.0, y: -2.0 });
6758        assert_eq!((tile.scale_x, tile.scale_y), (0.5, 2.0));
6759        assert_eq!(tile.flip, ResolvedTileFlip::Both);
6760        assert_eq!(tile.alignment, ResolvedRectAlignment::BottomRight);
6761        assert_eq!(image.dpi, Some(144.0));
6762        assert!(!image.rotate_with_shape);
6763
6764        let ResolvedContent::Image(image) = &resolved.shapes[2].content else {
6765            panic!("explicit stretch picture should resolve");
6766        };
6767        let ResolvedImagePlacement::Stretch { fill_rect } = &image.placement else {
6768            panic!("expected stretch placement");
6769        };
6770        assert_eq!(
6771            *fill_rect,
6772            Some(crate::CropRect {
6773                left: 0.1,
6774                top: 0.2,
6775                right: 0.3,
6776                bottom: 0.4,
6777            })
6778        );
6779        assert_eq!(resolved.shapes[3].content, ResolvedContent::None);
6780        assert_eq!(
6781            resolved.shapes[3].unsupported,
6782            Some("external picture media")
6783        );
6784        assert!(resolved.diagnostics.iter().any(|diagnostic| {
6785            diagnostic
6786                .message
6787                .contains("external picture relationship `https://example.invalid/image.png`")
6788        }));
6789    }
6790
6791    #[test]
6792    fn transform_resolves_to_points_rotation_and_flips() {
6793        let fixture = Fixture::new(
6794            &shape_with_details(
6795                None,
6796                None,
6797                r#"<a:xfrm rot="5400000" flipH="1" flipV="1"><a:off x="12700" y="25400"/><a:ext cx="38100" cy="50800"/></a:xfrm>"#,
6798                None,
6799            ),
6800            "",
6801            "",
6802        );
6803
6804        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
6805        let shape = &resolved.shapes[0];
6806        assert_eq!(shape.bounds.x, 1.0);
6807        assert_eq!(shape.bounds.y, 2.0);
6808        assert_eq!(shape.bounds.width, 3.0);
6809        assert_eq!(shape.bounds.height, 4.0);
6810        assert_eq!(shape.rotation_deg, 90.0);
6811        assert!(shape.flip_h);
6812        assert!(shape.flip_v);
6813    }
6814
6815    #[test]
6816    fn custom_geometry_scales_each_path_coordinate_space_to_shape_points() {
6817        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>"#;
6818        let shape = shape_with_details(
6819            None,
6820            None,
6821            &format!(
6822                "<a:xfrm><a:off x=\"0\" y=\"0\"/><a:ext cx=\"127000\" cy=\"254000\"/></a:xfrm>{geometry}"
6823            ),
6824            None,
6825        );
6826        let fixture = Fixture::new(&shape, "", "");
6827
6828        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
6829        let ResolvedGeometry::Custom { paths, text_rect } = &resolved.shapes[0].geometry else {
6830            panic!("expected custom geometry");
6831        };
6832        assert_eq!(
6833            paths[0].commands[1],
6834            PathCommand::LineTo(oxml_layout::Point { x: 10.0, y: 20.0 })
6835        );
6836        assert_eq!(
6837            *text_rect,
6838            Some(Rect {
6839                x: 2.5,
6840                y: 5.0,
6841                width: 5.0,
6842                height: 10.0,
6843            })
6844        );
6845    }
6846
6847    #[test]
6848    fn custom_geometry_uses_shape_size_for_omitted_path_dimensions() {
6849        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>"#;
6850        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>"#;
6851        let shapes = [omitted_both, omitted_width]
6852            .map(|geometry| {
6853                shape_with_details(
6854                    None,
6855                    None,
6856                    &format!(
6857                        "<a:xfrm><a:off x=\"0\" y=\"0\"/><a:ext cx=\"127000\" cy=\"254000\"/></a:xfrm>{geometry}"
6858                    ),
6859                    None,
6860                )
6861            })
6862            .join("");
6863        let resolved = Fixture::new(&shapes, "", "")
6864            .context()
6865            .resolve_slide((720.0, 540.0))
6866            .unwrap();
6867
6868        for shape in &resolved.shapes {
6869            let ResolvedGeometry::Custom { paths, .. } = &shape.geometry else {
6870                panic!("expected concrete custom geometry");
6871            };
6872            assert_eq!(
6873                paths[0].commands[1],
6874                PathCommand::LineTo(oxml_layout::Point { x: 10.0, y: 20.0 })
6875            );
6876        }
6877    }
6878
6879    #[test]
6880    fn invalid_custom_geometry_retains_a_diagnosed_bounds_fallback() {
6881        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>"#;
6882        let shape = shape_with_details(None, None, &format!("{}{}", transform(0), geometry), None);
6883        let fixture = Fixture::new(&shape, "", "");
6884
6885        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
6886        assert_eq!(
6887            resolved.shapes[0].geometry,
6888            ResolvedGeometry::BoundsFallback
6889        );
6890        assert_eq!(
6891            resolved.shapes[0].unsupported,
6892            Some("custom geometry evaluation")
6893        );
6894        assert!(
6895            resolved
6896                .diagnostics
6897                .iter()
6898                .any(|diagnostic| { diagnostic.message.contains("unknown guide: missing") })
6899        );
6900    }
6901
6902    #[test]
6903    fn connector_presets_reuse_geometry_and_preserve_line_ends() {
6904        let line = r#"<a:ln w="12700"><a:solidFill><a:srgbClr val="112233"/></a:solidFill><a:headEnd type="triangle"/></a:ln>"#;
6905        let fixture = Fixture::new(
6906            &[
6907                connector("line", 127_000, 254_000, "", line),
6908                connector("straightConnector1", 127_000, 254_000, "", line),
6909                connector(
6910                    "bentConnector3",
6911                    127_000,
6912                    254_000,
6913                    r#"<a:gd name="adj1" fmla="val 25000"/>"#,
6914                    line,
6915                ),
6916                connector(
6917                    "curvedConnector3",
6918                    127_000,
6919                    254_000,
6920                    r#"<a:gd name="adj1" fmla="val 50000"/>"#,
6921                    line,
6922                ),
6923            ]
6924            .join(""),
6925            "",
6926            "",
6927        );
6928
6929        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
6930
6931        assert_eq!(resolved.shapes.len(), 4);
6932        assert!(resolved.diagnostics.is_empty());
6933        for shape in &resolved.shapes {
6934            assert_eq!(shape.unsupported, None);
6935            assert!(shape.line.is_some());
6936            assert_eq!(
6937                shape.head_end.as_ref().map(|end| end.kind),
6938                Some(ResolvedLineEndKind::Triangle)
6939            );
6940        }
6941        for index in [0, 1] {
6942            let ResolvedGeometry::Custom { paths, .. } = &resolved.shapes[index].geometry else {
6943                panic!("expected straight connector geometry");
6944            };
6945            assert_eq!(
6946                paths[0].commands,
6947                [
6948                    PathCommand::MoveTo(Point { x: 0.0, y: 0.0 }),
6949                    PathCommand::LineTo(Point { x: 10.0, y: 20.0 }),
6950                ]
6951            );
6952        }
6953        let ResolvedGeometry::Custom { paths, .. } = &resolved.shapes[2].geometry else {
6954            panic!("expected bent connector geometry");
6955        };
6956        assert_eq!(
6957            paths[0].commands,
6958            [
6959                PathCommand::MoveTo(Point { x: 0.0, y: 0.0 }),
6960                PathCommand::LineTo(Point { x: 2.5, y: 0.0 }),
6961                PathCommand::LineTo(Point { x: 2.5, y: 20.0 }),
6962                PathCommand::LineTo(Point { x: 10.0, y: 20.0 }),
6963            ]
6964        );
6965        let ResolvedGeometry::Custom { paths, .. } = &resolved.shapes[3].geometry else {
6966            panic!("expected curved connector geometry");
6967        };
6968        assert!(matches!(
6969            paths[0].commands.as_slice(),
6970            [
6971                PathCommand::MoveTo(Point { x: 0.0, y: 0.0 }),
6972                PathCommand::CurveTo { to, .. },
6973                PathCommand::CurveTo { .. }
6974            ] if *to == Point { x: 5.0, y: 10.0 }
6975        ));
6976    }
6977
6978    #[test]
6979    fn connector_custom_geometry_reuses_the_shared_path_evaluator() {
6980        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>"#;
6981        let resolved = Fixture::new(connector, "", "")
6982            .context()
6983            .resolve_slide((720.0, 540.0))
6984            .unwrap();
6985
6986        assert!(resolved.diagnostics.is_empty());
6987        assert_eq!(resolved.shapes[0].unsupported, None);
6988        let ResolvedGeometry::Custom { paths, .. } = &resolved.shapes[0].geometry else {
6989            panic!("expected concrete connector custom geometry");
6990        };
6991        assert_eq!(
6992            paths[0].commands,
6993            [
6994                PathCommand::MoveTo(Point { x: 0.0, y: 0.0 }),
6995                PathCommand::LineTo(Point { x: 0.0, y: 10.0 }),
6996                PathCommand::LineTo(Point { x: 10.0, y: 10.0 }),
6997                PathCommand::LineTo(Point { x: 10.0, y: 20.0 }),
6998            ]
6999        );
7000    }
7001
7002    #[test]
7003    fn zero_extent_and_unknown_connector_geometry_keep_finite_visible_results() {
7004        let line = r#"<a:ln><a:solidFill><a:srgbClr val="112233"/></a:solidFill></a:ln>"#;
7005        let fixture = Fixture::new(
7006            &format!(
7007                "{}{}",
7008                connector("straightConnector1", 0, 127_000, "", line),
7009                connector("futureConnector", 127_000, 254_000, "", line),
7010            ),
7011            "",
7012            "",
7013        );
7014
7015        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
7016
7017        let ResolvedGeometry::Custom { paths, .. } = &resolved.shapes[0].geometry else {
7018            panic!("zero-width connector should still evaluate: {:?}", resolved);
7019        };
7020        assert_eq!(
7021            paths[0].commands,
7022            [
7023                PathCommand::MoveTo(Point { x: 0.0, y: 0.0 }),
7024                PathCommand::LineTo(Point { x: 0.0, y: 10.0 }),
7025            ]
7026        );
7027        assert!(paths[0].commands.iter().all(|command| match command {
7028            PathCommand::MoveTo(point) | PathCommand::LineTo(point) =>
7029                point.x.is_finite() && point.y.is_finite(),
7030            _ => true,
7031        }));
7032        assert_eq!(
7033            resolved.shapes[1].geometry,
7034            ResolvedGeometry::BoundsFallback
7035        );
7036        assert_eq!(
7037            resolved.shapes[1].unsupported,
7038            Some("unknown connector preset geometry")
7039        );
7040        assert!(resolved.diagnostics.iter().any(|diagnostic| {
7041            diagnostic.message.contains("futureConnector")
7042                && diagnostic.message.contains("retained as shape bounds")
7043        }));
7044    }
7045
7046    #[test]
7047    fn connector_without_a_direct_line_keeps_a_diagnosed_visible_default() {
7048        let fixture = Fixture::new(&connector("line", 127_000, 254_000, "", ""), "", "");
7049
7050        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
7051        let connector = &resolved.shapes[0];
7052
7053        assert!(matches!(
7054            connector.geometry,
7055            ResolvedGeometry::Custom { .. }
7056        ));
7057        assert_eq!(connector.unsupported, Some("connector line style"));
7058        let line = connector.line.as_ref().expect("visible default line");
7059        assert_eq!(line.paint, Paint::Solid(Color::BLACK));
7060        assert_eq!(line.width, 1.0);
7061        assert_eq!(
7062            resolved.diagnostics,
7063            [Diagnostic {
7064                message: "unsupported connector line style retained as visible default".to_owned(),
7065            }]
7066        );
7067    }
7068
7069    #[test]
7070    fn connector_style_resolves_the_theme_line_under_its_direct_line() {
7071        let styled = |line_index: u32, line: &str| {
7072            connector("line", 127_000, 254_000, "", line).replace(
7073                "</p:cxnSp>",
7074                &format!(
7075                    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>"#
7076                ),
7077            )
7078        };
7079        let fixture = Fixture::new(
7080            &[
7081                styled(2, ""),
7082                styled(2, r#"<a:ln w="38100"/>"#),
7083                styled(0, ""),
7084            ]
7085            .join(""),
7086            "",
7087            "",
7088        );
7089
7090        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
7091
7092        assert!(
7093            resolved.diagnostics.is_empty(),
7094            "{:?}",
7095            resolved.diagnostics
7096        );
7097        let accent1 = Paint::Solid(Color::from_hex("156082"));
7098        let themed = resolved.shapes[0].line.as_ref().expect("theme line");
7099        assert_eq!((&themed.paint, themed.width), (&accent1, 1.5));
7100        let widened = resolved.shapes[1].line.as_ref().expect("overlaid line");
7101        assert_eq!((&widened.paint, widened.width), (&accent1, 3.0));
7102        assert_eq!(resolved.shapes[2].line, None);
7103        assert!(
7104            resolved
7105                .shapes
7106                .iter()
7107                .all(|shape| shape.unsupported.is_none())
7108        );
7109    }
7110
7111    #[test]
7112    fn connector_shadow_resolves_from_its_effect_reference_under_its_direct_effects() {
7113        let styled = |effect_index: u32, direct: &str| {
7114            connector("line", 127_000, 254_000, "", direct).replace(
7115                "</p:cxnSp>",
7116                &format!(
7117                    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>"#
7118                ),
7119            )
7120        };
7121        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>"#;
7122        let fixture = Fixture::new(
7123            &[
7124                styled(3, ""),
7125                styled(0, ""),
7126                styled(3, "<a:effectLst/>"),
7127                connector("line", 127_000, 254_000, "", direct_shadow),
7128            ]
7129            .join(""),
7130            "",
7131            "",
7132        );
7133
7134        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
7135
7136        assert!(
7137            resolved.diagnostics.is_empty(),
7138            "{:?}",
7139            resolved.diagnostics
7140        );
7141        let shadows: Vec<_> = resolved
7142            .shapes
7143            .iter()
7144            .map(|shape| shape.shadow.is_some())
7145            .collect();
7146        assert_eq!(shadows, [true, false, false, true]);
7147        assert!(matches!(
7148            resolved.shapes[3].shadow,
7149            Some(Effect::OuterShadow { color, .. }) if color == Color::from_hex("FF0000")
7150        ));
7151    }
7152
7153    #[test]
7154    fn unresolvable_connector_effect_uses_counted_member_bounds_recovery() {
7155        let styled = |effect_reference: &str, direct: &str| {
7156            connector("line", 127_000, 254_000, "", direct).replace(
7157                "</p:cxnSp>",
7158                &format!(
7159                    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>"#
7160                ),
7161            )
7162        };
7163        let placeholder_shadow = r#"<a:effectLst><a:outerShdw blurRad="12700" dist="25400" dir="0"><a:schemeClr val="phClr"/></a:outerShdw></a:effectLst>"#;
7164        let fixture = Fixture::new(
7165            &[
7166                styled(
7167                    r#"<a:effectRef idx="9"><a:schemeClr val="accent1"/></a:effectRef>"#,
7168                    "",
7169                ),
7170                styled(r#"<a:effectRef idx="0"/>"#, placeholder_shadow),
7171            ]
7172            .join(""),
7173            "",
7174            "",
7175        );
7176
7177        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
7178
7179        assert_eq!(resolved.unresolved_member_count, 2);
7180        assert_eq!(resolved.shapes.len(), 2);
7181        assert_eq!(resolved.diagnostics.len(), 2);
7182        assert!(
7183            resolved.diagnostics[0]
7184                .message
7185                .contains("effect style index 9")
7186        );
7187        assert!(
7188            resolved.diagnostics[1]
7189                .message
7190                .contains("effect style retains an unresolved phClr")
7191        );
7192        for shape in &resolved.shapes {
7193            assert_eq!(shape.geometry, ResolvedGeometry::BoundsFallback);
7194            assert_eq!(shape.line, None);
7195            assert_eq!(shape.shadow, None);
7196            assert_eq!(shape.content, ResolvedContent::None);
7197        }
7198    }
7199
7200    #[test]
7201    fn unknown_preset_keeps_bounds_text_and_diagnostic() {
7202        let shape = format!(
7203            "<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>",
7204            transform(0)
7205        );
7206        let fixture = Fixture::new(&shape, "", "");
7207
7208        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
7209        let shape = &resolved.shapes[0];
7210        assert_eq!(shape.geometry, ResolvedGeometry::BoundsFallback);
7211        assert_eq!(shape.bounds.width, 100.0 / 12_700.0);
7212        assert_eq!(shape.bounds.height, 100.0 / 12_700.0);
7213        assert_eq!(shape.unsupported, Some("unknown preset geometry"));
7214        let ResolvedContent::Text(text) = &shape.content else {
7215            panic!("unknown preset lost its text body");
7216        };
7217        assert!(matches!(
7218            &text.paragraphs[0].runs[0],
7219            ResolvedTextRun::Text { text, .. } if text == "keep this text"
7220        ));
7221        assert!(resolved.diagnostics.iter().any(|diagnostic| {
7222            diagnostic.message.contains("unknown preset geometry")
7223                && diagnostic.message.contains("futureBurst")
7224        }));
7225    }
7226
7227    #[test]
7228    fn text_box_without_geometry_does_not_render_a_fallback_border() {
7229        let shape = shape_with_details(None, None, &transform(0), Some("<a:bodyPr/>"));
7230        let fixture = Fixture::new(&shape, "", "");
7231
7232        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
7233
7234        assert_eq!(resolved.shapes[0].geometry, ResolvedGeometry::Rectangle);
7235        assert_eq!(resolved.shapes[0].unsupported, None);
7236        assert!(resolved.diagnostics.is_empty());
7237    }
7238
7239    #[test]
7240    fn empty_paragraph_end_properties_use_the_normal_character_cascade() {
7241        let shape = shape_with_details(
7242            None,
7243            None,
7244            &format!(
7245                r#"{}<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>"#,
7246                transform(0)
7247            ),
7248            Some(
7249                r#"<a:bodyPr/><a:lstStyle><a:defPPr><a:defRPr sz="1600" cap="all"><a:latin typeface="Carlito"/></a:defRPr></a:defPPr></a:lstStyle>"#,
7250            ),
7251        )
7252        .replace(
7253            "<a:p/>",
7254            r#"<a:p><a:pPr><a:defRPr b="1"/></a:pPr><a:endParaRPr sz="3200" i="1"/></a:p>"#,
7255        );
7256        let fixture = Fixture::new(&shape, "", "");
7257
7258        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
7259        let ResolvedContent::Text(body) = &resolved.shapes[0].content else {
7260            panic!("expected resolved text")
7261        };
7262        let paragraph = &body.paragraphs[0];
7263
7264        assert!(paragraph.runs.is_empty());
7265        assert_eq!(paragraph.end_style.font_size, Some(32.0));
7266        assert!(paragraph.end_style.bold);
7267        assert!(paragraph.end_style.italic);
7268        assert!(paragraph.end_style.all_caps);
7269        assert_eq!(
7270            paragraph.end_style.latin_typeface.as_deref(),
7271            Some("Carlito")
7272        );
7273    }
7274
7275    #[test]
7276    fn preset_black_and_white_resolve_to_concrete_paint() {
7277        let black = format!(
7278            "{}<a:solidFill><a:prstClr val=\"black\"/></a:solidFill>",
7279            transform(0)
7280        );
7281        let white = format!(
7282            "{}<a:solidFill><a:prstClr val=\"white\"/></a:solidFill>",
7283            transform(100)
7284        );
7285        let fixture = Fixture::new(
7286            &format!(
7287                "{}{}",
7288                shape_with_details(None, None, &black, None),
7289                shape_with_details(None, None, &white, None)
7290            ),
7291            "",
7292            "",
7293        );
7294
7295        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
7296        assert_eq!(resolved.shapes[0].fill, Some(Paint::Solid(Color::BLACK)));
7297        assert_eq!(resolved.shapes[1].fill, Some(Paint::Solid(Color::WHITE)));
7298    }
7299
7300    #[test]
7301    fn group_coordinate_scale_changes_bounds_not_stroke_width() {
7302        let leaf = shape_with_details(
7303            None,
7304            None,
7305            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>"#,
7306            None,
7307        );
7308        let group = format!(
7309            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>"#
7310        );
7311        let fixture = Fixture::new(&group, "", "");
7312
7313        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
7314        let shape = &resolved.shapes[0];
7315        assert_eq!(
7316            shape.bounds,
7317            Rect {
7318                x: 2.0,
7319                y: 6.0,
7320                width: 4.0,
7321                height: 9.0,
7322            }
7323        );
7324        assert_eq!(shape.line.as_ref().map(|line| line.width), Some(0.75));
7325        assert_eq!(
7326            shape.group_transform,
7327            Transform {
7328                e: 10.0,
7329                f: 20.0,
7330                ..Transform::IDENTITY
7331            }
7332        );
7333    }
7334
7335    #[test]
7336    fn grouped_text_with_a_visible_stroke_keeps_path_scale_and_stroke_width_invariant() {
7337        let leaf = shape_with_details(
7338            None,
7339            None,
7340            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>"#,
7341            Some(r#"<a:bodyPr/><a:p><a:r><a:t>visible outline</a:t></a:r></a:p>"#),
7342        );
7343        let group = format!(
7344            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>"#
7345        );
7346        let fixture = Fixture::new(&group, "", "");
7347
7348        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
7349        let shape = &resolved.shapes[0];
7350        assert_eq!(
7351            shape.bounds,
7352            Rect {
7353                x: 2.0,
7354                y: 6.0,
7355                width: 4.0,
7356                height: 9.0,
7357            }
7358        );
7359        assert_eq!(shape.line.as_ref().map(|line| line.width), Some(0.75));
7360        assert_eq!(
7361            shape.group_transform,
7362            Transform {
7363                e: 10.0,
7364                f: 20.0,
7365                ..Transform::IDENTITY
7366            }
7367        );
7368        assert!(matches!(shape.content, ResolvedContent::Text(_)));
7369    }
7370
7371    #[test]
7372    fn nested_group_coordinate_mappings_apply_inner_before_outer() {
7373        let leaf = shape_with_details(
7374            None,
7375            None,
7376            r#"<a:xfrm><a:off x="12700" y="25400"/><a:ext cx="38100" cy="50800"/></a:xfrm>"#,
7377            None,
7378        );
7379        let inner = format!(
7380            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>"#
7381        );
7382        let outer = format!(
7383            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>"#
7384        );
7385        let fixture = Fixture::new(&outer, "", "");
7386
7387        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
7388        let shape = &resolved.shapes[0];
7389        assert_eq!(
7390            shape.bounds,
7391            Rect {
7392                x: 8.0,
7393                y: 30.0,
7394                width: 24.0,
7395                height: 60.0,
7396            }
7397        );
7398        assert_eq!(
7399            shape.group_transform,
7400            Transform {
7401                e: 14.0,
7402                f: 30.0,
7403                ..Transform::IDENTITY
7404            }
7405        );
7406    }
7407
7408    #[test]
7409    fn zero_group_child_extent_is_finite_and_diagnosed_once() {
7410        let leaf = shape_with_details(
7411            None,
7412            None,
7413            r#"<a:xfrm><a:off x="76200" y="101600"/><a:ext cx="12700" cy="12700"/></a:xfrm>"#,
7414            None,
7415        );
7416        let group = format!(
7417            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>"#
7418        );
7419        let fixture = Fixture::new(&group, "", "");
7420
7421        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
7422        let shape = &resolved.shapes[0];
7423        assert_eq!(
7424            shape.bounds,
7425            Rect {
7426                x: 6.0,
7427                y: 24.0,
7428                width: 1.0,
7429                height: 3.0,
7430            }
7431        );
7432        assert!(shape.bounds.x.is_finite());
7433        assert!(shape.bounds.y.is_finite());
7434        assert_eq!(
7435            shape.group_transform,
7436            Transform {
7437                e: 5.0,
7438                f: -1.0,
7439                ..Transform::IDENTITY
7440            }
7441        );
7442        assert_eq!(
7443            resolved
7444                .diagnostics
7445                .iter()
7446                .filter(|diagnostic| diagnostic.message.contains("group child extent"))
7447                .count(),
7448            1
7449        );
7450    }
7451
7452    #[test]
7453    fn unsupported_sheared_group_mapping_is_diagnosed_without_non_finite_values() {
7454        let leaf = shape_with_details(None, None, &transform(0), None);
7455        let inner = format!(
7456            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>"#
7457        );
7458        let outer = format!(
7459            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>"#
7460        );
7461        let fixture = Fixture::new(&outer, "", "");
7462
7463        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
7464        let transform = resolved.shapes[0].group_transform;
7465        assert!(
7466            [
7467                transform.a,
7468                transform.b,
7469                transform.c,
7470                transform.d,
7471                transform.e,
7472                transform.f,
7473            ]
7474            .into_iter()
7475            .all(f64::is_finite)
7476        );
7477        assert!(resolved.diagnostics.iter().any(|diagnostic| {
7478            diagnostic.message
7479                == "unsupported sheared or singular group transform retained as affine fallback"
7480        }));
7481    }
7482
7483    #[test]
7484    fn flattened_group_children_keep_sibling_order() {
7485        let group = format!(
7486            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>"#,
7487            shape_with_text(None, None, "inside one"),
7488            shape_with_text(None, None, "inside two")
7489        );
7490        let fixture = Fixture::new(
7491            &format!(
7492                "{}{}{}",
7493                shape_with_text(None, None, "before"),
7494                group,
7495                shape_with_text(None, None, "after")
7496            ),
7497            "",
7498            "",
7499        );
7500
7501        assert_eq!(
7502            fixture
7503                .context()
7504                .flatten()
7505                .iter()
7506                .filter_map(item_text)
7507                .collect::<Vec<_>>(),
7508            ["before", "inside one", "inside two", "after"]
7509        );
7510    }
7511
7512    #[test]
7513    fn fx205_pica_spacing_is_resolved_as_source_points() {
7514        for unit in ["pc", "pi"] {
7515            let text = format!(
7516                r#"<a:bodyPr/><a:p><a:r><a:rPr spc="1{unit}"/><a:t>spaced</a:t></a:r></a:p>"#
7517            );
7518            let fixture = Fixture::new(
7519                &shape_with_details(None, None, &transform(0), Some(&text)),
7520                "",
7521                "",
7522            );
7523            let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
7524            let ResolvedContent::Text(body) = &resolved.shapes[0].content else {
7525                panic!("text")
7526            };
7527            let ResolvedTextRun::Text { style, .. } = &body.paragraphs[0].runs[0] else {
7528                panic!("run")
7529            };
7530            assert_eq!(style.spacing, Some(12.0));
7531        }
7532    }
7533
7534    #[test]
7535    fn paragraph_spacing_and_bullet_size_are_concrete() {
7536        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>"#;
7537        let fixture = Fixture::new(
7538            &shape_with_details(None, None, &transform(0), Some(text)),
7539            "",
7540            "",
7541        );
7542
7543        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
7544        let ResolvedContent::Text(body) = &resolved.shapes[0].content else {
7545            panic!("expected text");
7546        };
7547        let paragraph = &body.paragraphs[0];
7548        assert_eq!(
7549            paragraph.line_spacing,
7550            Some(ResolvedTextSpacing::Percent(1.2))
7551        );
7552        assert_eq!(
7553            paragraph.space_before,
7554            Some(ResolvedTextSpacing::Points(6.0))
7555        );
7556        assert_eq!(
7557            paragraph.space_after,
7558            Some(ResolvedTextSpacing::Points(3.0))
7559        );
7560        assert!(matches!(
7561            paragraph.bullet,
7562            Some(ResolvedBullet::Character {
7563                size: Some(ResolvedBulletSize::Percent(1.25)),
7564                ..
7565            })
7566        ));
7567    }
7568
7569    #[test]
7570    fn explicit_rtl_paragraph_direction_reaches_resolved_layout() {
7571        let text = r#"<a:bodyPr/><a:p><a:pPr rtl="1"/><a:r><a:t>123</a:t></a:r></a:p>"#;
7572        let fixture = Fixture::new(
7573            &shape_with_details(None, None, &transform(0), Some(text)),
7574            "",
7575            "",
7576        );
7577
7578        let (resolved, directions) = fixture
7579            .context()
7580            .resolve_slide_inner((720.0, 540.0), None, None, None, None)
7581            .unwrap();
7582        let ResolvedContent::Text(body) = &resolved.shapes[0].content else {
7583            panic!("expected text");
7584        };
7585        assert_eq!(body.paragraphs[0].runs.len(), 1);
7586        assert_eq!(directions[0][0].len(), body.paragraphs.len());
7587        assert_eq!(directions[0][0][0], oxml_layout::TextDirection::RightToLeft);
7588    }
7589
7590    #[test]
7591    fn legacy_resolved_paragraph_literal_remains_source_compatible() {
7592        let paragraph = ResolvedParagraph {
7593            level: 0,
7594            left_margin: 0.0,
7595            right_margin: 0.0,
7596            indent: 0.0,
7597            alignment: ParagraphAlignment::Left,
7598            line_spacing: None,
7599            space_before: None,
7600            space_after: None,
7601            bullet: None,
7602            end_style: ResolvedRunStyle::default(),
7603            runs: Vec::new(),
7604        };
7605
7606        assert_eq!(paragraph, ResolvedParagraph::default());
7607    }
7608
7609    #[test]
7610    fn auto_number_bullet_keeps_independently_inherited_style() {
7611        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>"#;
7612        let fixture = Fixture::new(
7613            &shape_with_details(None, None, &transform(0), Some(text)),
7614            "",
7615            "",
7616        );
7617
7618        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
7619        let ResolvedContent::Text(body) = &resolved.shapes[0].content else {
7620            panic!("expected text");
7621        };
7622        assert_eq!(
7623            body.paragraphs[0].bullet,
7624            Some(ResolvedBullet::AutoNumber {
7625                scheme: "arabicPeriod".to_owned(),
7626                start_at: 3,
7627                font: Some("Wingdings".to_owned()),
7628                color: Some(Color {
7629                    r: 0x12 as f64 / 255.0,
7630                    g: 0x34 as f64 / 255.0,
7631                    b: 0x56 as f64 / 255.0,
7632                    a: 1.0,
7633                }),
7634                size: Some(ResolvedBulletSize::Percent(1.25)),
7635            })
7636        );
7637    }
7638
7639    #[test]
7640    fn fx206_tcpr_anchor_resolution_modes_and_absent_body_fallback() {
7641        for (attribute, expected) in [
7642            ("", ResolvedTextAnchor::Bottom),
7643            (" anchor='t'", ResolvedTextAnchor::Top),
7644            (" anchor='ctr'", ResolvedTextAnchor::Center),
7645            (" anchor='b'", ResolvedTextAnchor::Bottom),
7646        ] {
7647            let frame = format!(
7648                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>"#
7649            );
7650            let fixture = Fixture::new(&frame, "", "");
7651            let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
7652            let ResolvedContent::Table(table) = &resolved.shapes[0].content else {
7653                panic!("table");
7654            };
7655            assert_eq!(
7656                table.rows[0].cells[0].text.as_ref().unwrap().anchor,
7657                expected
7658            );
7659        }
7660    }
7661
7662    #[test]
7663    fn table_cells_resolve_body_and_paragraph_properties() {
7664        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>"#;
7665        let fixture = Fixture::new(frame, "", "");
7666
7667        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
7668        let ResolvedContent::Table(table) = &resolved.shapes[0].content else {
7669            panic!("expected table");
7670        };
7671        let body = table.rows[0].cells[0].text.as_ref().unwrap();
7672        assert_eq!(body.insets.left, 1.0);
7673        assert_eq!(body.anchor, ResolvedTextAnchor::Bottom);
7674        assert_eq!(body.vertical, TextDirection::Vertical);
7675        assert_eq!(body.autofit, ResolvedAutofit::None);
7676        assert_eq!(body.paragraphs[0].left_margin, 2.0);
7677        assert_eq!(
7678            body.paragraphs[0].alignment,
7679            crate::ParagraphAlignment::Center
7680        );
7681        assert!(resolved.diagnostics.iter().any(|diagnostic| {
7682            diagnostic.message == "table cell autofit is unsupported and was ignored"
7683        }));
7684    }
7685
7686    #[test]
7687    fn table_style_regions_resolve_in_documented_precedence() {
7688        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>"#;
7689        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();
7690        let fixture = Fixture::new(frame, "", "");
7691
7692        let resolved = fixture
7693            .context()
7694            .with_table_styles(&styles)
7695            .resolve_slide((20.0, 20.0))
7696            .unwrap();
7697        let ResolvedContent::Table(table) = &resolved.shapes[0].content else {
7698            panic!("expected table");
7699        };
7700
7701        assert_eq!(
7702            table.rows[0].cells[0].fill,
7703            Some(Paint::Solid(Color::from_hex("444444")))
7704        );
7705        assert_eq!(
7706            table.rows[0].cells[1].fill,
7707            Some(Paint::Solid(Color::from_hex("333333")))
7708        );
7709        assert_eq!(
7710            table.rows[1].cells[0].fill,
7711            Some(Paint::Solid(Color::from_hex("222222")))
7712        );
7713        let ResolvedTextRun::Text { style, .. } =
7714            &table.rows[0].cells[0].text.as_ref().unwrap().paragraphs[0].runs[0]
7715        else {
7716            panic!("expected styled text run")
7717        };
7718        assert!(style.bold && style.italic);
7719        assert_eq!(style.fill, Some(Paint::Solid(Color::from_hex("AABBCC"))));
7720        assert_eq!(style.latin_typeface.as_deref(), Some("Aptos Display"));
7721    }
7722
7723    #[test]
7724    fn builtin_table_styles_resolve_without_package_definitions() {
7725        let id = "{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}";
7726        let style = super::CT_TableStyle::builtin(id).expect("known built-in style parses");
7727        assert_eq!(style.style_id, id);
7728        assert!(style.whole_table.is_some());
7729        assert!(super::CT_TableStyle::builtin("{00000000-0000-0000-0000-000000000000}").is_none());
7730
7731        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>"#;
7732        let fixture = Fixture::new(frame, "", "");
7733        let resolved = fixture.context().resolve_slide((20.0, 20.0)).unwrap();
7734        let ResolvedContent::Table(table) = &resolved.shapes[0].content else {
7735            panic!("expected table");
7736        };
7737        assert!(matches!(table.rows[0].cells[0].fill, Some(Paint::Solid(_))));
7738        assert!(matches!(table.rows[1].cells[0].fill, Some(Paint::Solid(_))));
7739        assert_ne!(table.rows[0].cells[0].fill, table.rows[1].cells[0].fill);
7740
7741        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();
7742        let overridden = fixture
7743            .context()
7744            .with_table_styles(&package_style)
7745            .resolve_slide((20.0, 20.0))
7746            .unwrap();
7747        let ResolvedContent::Table(overridden) = &overridden.shapes[0].content else {
7748            panic!("expected table");
7749        };
7750        assert_eq!(
7751            overridden.rows[0].cells[0].fill,
7752            Some(Paint::Solid(Color::from_hex("FF0000")))
7753        );
7754    }
7755
7756    #[test]
7757    fn direct_table_text_and_inside_borders_override_style_defaults() {
7758        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>"#;
7759        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();
7760        let fixture = Fixture::new(frame, "", "");
7761
7762        let resolved = fixture
7763            .context()
7764            .with_table_styles(&styles)
7765            .resolve_slide((20.0, 10.0))
7766            .unwrap();
7767        let ResolvedContent::Table(table) = &resolved.shapes[0].content else {
7768            panic!("expected table");
7769        };
7770        let ResolvedTextRun::Text { style, .. } =
7771            &table.rows[0].cells[0].text.as_ref().unwrap().paragraphs[0].runs[0]
7772        else {
7773            panic!("expected direct run");
7774        };
7775        assert!(!style.bold);
7776        assert_eq!(style.fill, Some(Paint::Solid(Color::from_hex("00FF00"))));
7777        assert_eq!(style.latin_typeface.as_deref(), Some("Courier New"));
7778
7779        let first = &table.rows[0].cells[0];
7780        let second = &table.rows[0].cells[1];
7781        assert_eq!(
7782            first.left.as_ref().unwrap().stroke.as_ref().unwrap().paint,
7783            Paint::Solid(Color::from_hex("FF0000"))
7784        );
7785        assert_eq!(
7786            first.right.as_ref().unwrap().stroke.as_ref().unwrap().paint,
7787            Paint::Solid(Color::from_hex("0000FF"))
7788        );
7789        assert_eq!(
7790            second.left.as_ref().unwrap().stroke.as_ref().unwrap().paint,
7791            Paint::Solid(Color::from_hex("0000FF"))
7792        );
7793        assert_eq!(
7794            second
7795                .right
7796                .as_ref()
7797                .unwrap()
7798                .stroke
7799                .as_ref()
7800                .unwrap()
7801                .paint,
7802            Paint::Solid(Color::from_hex("FF0000"))
7803        );
7804    }
7805
7806    #[test]
7807    fn table_corners_require_both_flags_and_unsupported_fills_are_diagnosed() {
7808        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>"#;
7809        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();
7810        let fixture = Fixture::new(frame, "", "");
7811
7812        let resolved = fixture
7813            .context()
7814            .with_table_styles(&styles)
7815            .resolve_slide((20.0, 10.0))
7816            .unwrap();
7817        let ResolvedContent::Table(table) = &resolved.shapes[0].content else {
7818            panic!("expected table");
7819        };
7820
7821        assert_eq!(
7822            table.rows[0].cells[0].fill,
7823            Some(Paint::Solid(Color::from_hex("333333")))
7824        );
7825        let Some(Paint::Grid {
7826            columns,
7827            rows,
7828            colors,
7829            ..
7830        }) = &table.rows[0].cells[1].fill
7831        else {
7832            panic!("supported direct cell pattern must paint");
7833        };
7834        assert_eq!((*columns, *rows), (64, 64));
7835        assert!(colors.contains(&Color::from_hex("FF0000")));
7836        assert!(colors.contains(&Color::WHITE));
7837        let stroke = table.rows[0].cells[1]
7838            .left
7839            .as_ref()
7840            .and_then(|border| border.stroke.as_ref())
7841            .expect("supported pattern border must remain visible");
7842        let Paint::Grid {
7843            columns,
7844            rows,
7845            colors,
7846            ..
7847        } = &stroke.paint
7848        else {
7849            panic!("expected pattern border grid");
7850        };
7851        assert_eq!((*columns, *rows), (64, 64));
7852        assert!(colors.contains(&Color::from_hex("FF0000")));
7853        assert!(colors.contains(&Color::WHITE));
7854        assert!(!resolved.diagnostics.iter().any(|diagnostic| {
7855            diagnostic.message == "unsupported table cell pattern fill was ignored"
7856        }));
7857    }
7858
7859    #[test]
7860    fn builtin_table_style_ids_resolve_unless_the_package_defines_them() {
7861        // python-pptx names Medium Style 2 - Accent 1 without defining it.
7862        let frame = |style_id: &str| {
7863            format!(
7864                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>"#
7865            )
7866        };
7867        let medium_2_accent_1 =
7868            "<a:tableStyleId>{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}</a:tableStyleId>";
7869        let resolve = |frame: &str, styles: Option<&oxml_drawing::table::CT_TableStyleList>| {
7870            let fixture = Fixture::new(frame, "", "");
7871            let mut context = fixture.context();
7872            if let Some(styles) = styles {
7873                context = context.with_table_styles(styles);
7874            }
7875            let resolved = context.resolve_slide((10.0, 30.0)).unwrap();
7876            let ResolvedContent::Table(table) = &resolved.shapes[0].content else {
7877                panic!("expected table");
7878            };
7879            table.clone()
7880        };
7881
7882        let table = resolve(&frame(medium_2_accent_1), None);
7883        let fills = table
7884            .rows
7885            .iter()
7886            .map(|row| row.cells[0].fill.clone())
7887            .collect::<Vec<_>>();
7888        assert_eq!(fills[0], Some(Paint::Solid(Color::from_hex("156082"))));
7889        assert_eq!(fills[1], Some(Paint::Solid(Color::from_hex("CCD2D8"))));
7890        assert_eq!(fills[2], Some(Paint::Solid(Color::from_hex("E7EAED"))));
7891        let heading_rule = table.rows[0].cells[0].bottom.as_ref().unwrap();
7892        let heading_rule = heading_rule.stroke.as_ref().unwrap();
7893        assert_eq!(heading_rule.paint, Paint::Solid(Color::from_hex("FFFFFF")));
7894        assert_eq!(heading_rule.width, 3.0);
7895        let ResolvedTextRun::Text { style, .. } =
7896            &table.rows[0].cells[0].text.as_ref().unwrap().paragraphs[0].runs[0]
7897        else {
7898            panic!("expected heading run");
7899        };
7900        assert!(style.bold);
7901        assert_eq!(style.fill, Some(Paint::Solid(Color::from_hex("FFFFFF"))));
7902
7903        // A definition in the package wins over the built-in one.
7904        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();
7905        let table = resolve(&frame(medium_2_accent_1), Some(&styles));
7906        assert_eq!(
7907            table.rows[0].cells[0].fill,
7908            Some(Paint::Solid(Color::from_hex("FF0000")))
7909        );
7910        assert_eq!(table.rows[1].cells[0].fill, None);
7911    }
7912
7913    #[test]
7914    fn a_builtin_default_style_applies_when_the_package_has_no_definition() {
7915        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>"#;
7916        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();
7917        let fixture = Fixture::new(frame, "", "");
7918        let resolved = fixture
7919            .context()
7920            .with_table_styles(&styles)
7921            .resolve_slide((10.0, 10.0))
7922            .unwrap();
7923        let ResolvedContent::Table(table) = &resolved.shapes[0].content else {
7924            panic!("expected table");
7925        };
7926        assert_eq!(
7927            table.rows[0].cells[0].fill,
7928            Some(Paint::Solid(Color::from_hex("156082")))
7929        );
7930    }
7931
7932    #[test]
7933    fn table_style_background_resolves_over_the_whole_table() {
7934        // Themed Style 1 - Accent 1 fills the table from the theme's second
7935        // fill style (a gradient) in accent 1 and adds a theme effect.
7936        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>"#;
7937        let fixture = Fixture::new(frame, "", "");
7938
7939        let resolved = fixture.context().resolve_slide((20.0, 10.0)).unwrap();
7940        let ResolvedContent::Table(table) = &resolved.shapes[0].content else {
7941            panic!("expected table");
7942        };
7943
7944        let Some(Paint::Linear { start, end, .. }) = &table.background else {
7945            panic!("expected a gradient background, got {:?}", table.background);
7946        };
7947        // The gradient runs down the 20 by 10 point table box, as the theme's
7948        // 90 degree fill style says.
7949        assert!((start.x - 10.0).abs() < 1e-9 && (end.x - 10.0).abs() < 1e-9);
7950        assert!(start.y.abs() < 1e-9 && (end.y - 10.0).abs() < 1e-9);
7951        assert_eq!(
7952            table.rows[0].cells[0].fill,
7953            Some(Paint::Solid(Color::from_hex("156082")))
7954        );
7955        assert!(resolved.diagnostics.iter().any(|diagnostic| {
7956            diagnostic.message == "unsupported table background effect was ignored"
7957        }));
7958    }
7959
7960    #[test]
7961    fn table_cell_shape_autofit_is_ignored_but_normal_values_are_retained() {
7962        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>"#;
7963        let fixture = Fixture::new(frame, "", "");
7964
7965        let resolved = fixture.context().resolve_slide((10.0, 10.0)).unwrap();
7966        let ResolvedContent::Table(table) = &resolved.shapes[0].content else {
7967            panic!("expected table");
7968        };
7969
7970        assert_eq!(
7971            table.rows[0].cells[0].text.as_ref().unwrap().autofit,
7972            ResolvedAutofit::None
7973        );
7974        assert!(
7975            resolved
7976                .diagnostics
7977                .iter()
7978                .any(|diagnostic| diagnostic.message
7979                    == "table cell autofit is unsupported and was ignored")
7980        );
7981        let normal = frame.replace(
7982            "<a:spAutoFit/>",
7983            "<a:normAutofit fontScale=\"62500\" lnSpcReduction=\"20000\"/>",
7984        );
7985        let fixture = Fixture::new(&normal, "", "");
7986        let resolved = fixture.context().resolve_slide((10.0, 10.0)).unwrap();
7987        let ResolvedContent::Table(table) = &resolved.shapes[0].content else {
7988            panic!("expected table")
7989        };
7990        assert_eq!(
7991            table.rows[0].cells[0].text.as_ref().unwrap().autofit,
7992            ResolvedAutofit::Normal {
7993                font_scale: Some(0.625),
7994                line_spacing_reduction: Some(0.2)
7995            }
7996        );
7997        assert!(
7998            !resolved
7999                .diagnostics
8000                .iter()
8001                .any(|d| d.message.contains("autofit"))
8002        );
8003    }
8004
8005    #[test]
8006    fn linear_gradient_scaled_modes_cover_non_square_bounds() {
8007        let gradient = |scaled: &str| {
8008            format!(
8009                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>"#
8010            )
8011        };
8012        let slide = [
8013            shape_with_details(None, None, &gradient(""), None),
8014            shape_with_details(None, None, &gradient(r#" scaled="0""#), None),
8015            shape_with_details(None, None, &gradient(r#" scaled="1""#), None),
8016        ]
8017        .join("");
8018        let resolved = Fixture::new(&slide, "", "")
8019            .context()
8020            .resolve_slide((720.0, 540.0))
8021            .unwrap();
8022
8023        for shape in &resolved.shapes[..2] {
8024            let Some(Paint::Linear { start, end, .. }) = shape.fill.as_ref() else {
8025                panic!("expected unscaled linear gradient");
8026            };
8027            assert_close(start.x, 5.0);
8028            assert_close(start.y, -5.0);
8029            assert_close(end.x, 35.0);
8030            assert_close(end.y, 25.0);
8031        }
8032        let Some(Paint::Linear { start, end, .. }) = resolved.shapes[2].fill.as_ref() else {
8033            panic!("expected scaled linear gradient");
8034        };
8035        assert_close(start.x, 0.0);
8036        assert_close(start.y, 0.0);
8037        assert_close(end.x, 40.0);
8038        assert_close(end.y, 20.0);
8039        assert!(resolved.diagnostics.is_empty());
8040    }
8041
8042    #[test]
8043    fn unsupported_gradient_variants_have_distinct_diagnostics() {
8044        let variants = [
8045            (
8046                r#"flip="x""#,
8047                r#"<a:lin ang="0"/>"#,
8048                "horizontal gradient flip",
8049            ),
8050            (
8051                r#"flip="y""#,
8052                r#"<a:lin ang="0"/>"#,
8053                "vertical gradient flip",
8054            ),
8055            (
8056                r#"flip="xy""#,
8057                r#"<a:lin ang="0"/>"#,
8058                "horizontal and vertical gradient flip",
8059            ),
8060            (
8061                "",
8062                r#"<a:lin ang="0"/><a:tileRect l="10000"/>"#,
8063                "gradient tile rectangle",
8064            ),
8065            (
8066                "",
8067                r#"<a:path path="circle"/>"#,
8068                "path gradient with non-centred fill rectangle",
8069            ),
8070            (
8071                "",
8072                r#"<a:path path="rect"/>"#,
8073                "path gradient with non-centred fill rectangle",
8074            ),
8075            (
8076                "",
8077                r#"<a:path path="shape"/>"#,
8078                "path gradient with non-centred fill rectangle",
8079            ),
8080        ];
8081
8082        for (attributes, geometry, expected) in variants {
8083            let details = format!(
8084                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>"#
8085            );
8086            let details = if geometry.contains("a:path") {
8087                details.replace(
8088                    r#"<a:srgbClr val="000000"/>"#,
8089                    r#"<a:schemeClr val="phClr"/>"#,
8090                )
8091            } else {
8092                details
8093            };
8094            let fixture = Fixture::new(&shape_with_details(None, None, &details, None), "", "");
8095            let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
8096
8097            assert!(resolved.shapes[0].fill.is_none(), "{expected}");
8098            assert_eq!(resolved.shapes[0].unsupported, Some(expected));
8099            assert!(resolved.diagnostics[0].message.contains(expected));
8100        }
8101    }
8102
8103    #[test]
8104    fn fx205_path_and_pattern_fills_have_concrete_visible_paint() {
8105        // Microsoft Open Specifications documents channel-directed gamma1.875
8106        // only for endpoint pairs and mirrored three-stop lists, not all fills.
8107        let a = Color {
8108            r: 0.0,
8109            g: 1.0,
8110            b: 0.25,
8111            a: 0.2,
8112        };
8113        let b = Color {
8114            r: 1.0,
8115            g: 0.0,
8116            b: 0.25,
8117            a: 0.8,
8118        };
8119        let stops = [
8120            super::GradientStop {
8121                offset: 0.0,
8122                color: a,
8123            },
8124            super::GradientStop {
8125                offset: 1.0,
8126                color: b,
8127            },
8128        ];
8129        let mid = super::path_gradient_color(&stops, 0.5);
8130        let expected = 1.0 - 0.5_f64.powf(1.875);
8131        assert!((mid.r - expected).abs() < 1e-12 && (mid.g - expected).abs() < 1e-12);
8132        assert_eq!(mid.b, 0.25);
8133        assert!((mid.a - 0.5).abs() < 1e-12);
8134        let shifted = [
8135            super::GradientStop {
8136                offset: 0.1,
8137                color: a,
8138            },
8139            super::GradientStop {
8140                offset: 0.9,
8141                color: b,
8142            },
8143        ];
8144        assert!((super::path_gradient_color(&shifted, 0.5).r - 0.5).abs() < 1e-12);
8145        assert_eq!(super::powerpoint_gradient_stops(&shifted).len(), 2);
8146        let mirrored = [
8147            stops[0],
8148            super::GradientStop {
8149                offset: 0.5,
8150                color: b,
8151            },
8152            super::GradientStop {
8153                offset: 1.0,
8154                color: a,
8155            },
8156        ];
8157        assert_eq!(
8158            super::path_gradient_color(&mirrored, 0.25),
8159            super::path_gradient_color(&mirrored, 0.75)
8160        );
8161        let geometry = r#"<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>"#;
8162        for kind in ["circle", "rect", "shape"] {
8163            let fill = format!(
8164                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>"#
8165            );
8166            let shape = shape_with_details(
8167                None,
8168                None,
8169                &format!("{}{}{}", transform(0), geometry, fill),
8170                None,
8171            );
8172            let resolved = Fixture::new(&shape, "", "")
8173                .context()
8174                .resolve_slide((720.0, 540.0))
8175                .unwrap();
8176            assert!(
8177                resolved.shapes[0].fill.is_some(),
8178                "FX205_VISIBLE_PATH: {kind}"
8179            );
8180            assert_eq!(resolved.shapes[0].unsupported, None);
8181            let Some(Paint::Grid {
8182                columns,
8183                rows,
8184                colors,
8185                ..
8186            }) = &resolved.shapes[0].fill
8187            else {
8188                panic!("path did not publish sampled colour paint");
8189            };
8190            assert_eq!((*columns, *rows), (128, 128));
8191            let center = colors[64 * 128 + 64];
8192            assert!(
8193                center.r > 0.98 && center.b < 0.02,
8194                "path centre lost first source stop"
8195            );
8196            assert!(colors[0].b > 0.98, "path boundary lost last source stop");
8197        }
8198        // Supported paths must resolve the producing style reference before paint.
8199        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>"#;
8200        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>"#;
8201        let shape = shape_with_details(None, None, details, None)
8202            .replace("</p:sp>", &format!("{style}</p:sp>"));
8203        let fixture = Fixture::new(&shape, "", "");
8204        let before = fixture.slide.clone();
8205        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
8206        let Some(Paint::Grid { colors, .. }) = &resolved.shapes[0].fill else {
8207            panic!("source-qualified path reference must paint");
8208        };
8209        assert!(colors[64 * 128 + 64].r > 0.98);
8210        assert!(colors[0].b > 0.98);
8211        assert!(resolved.diagnostics.is_empty());
8212        assert_eq!(fixture.slide, before);
8213
8214        for preset in [
8215            "cross",
8216            "dkDnDiag",
8217            "dkHorz",
8218            "dkUpDiag",
8219            "dkVert",
8220            "dashDnDiag",
8221            "dashHorz",
8222            "dashUpDiag",
8223            "dashVert",
8224            "diagBrick",
8225            "diagCross",
8226            "divot",
8227            "dotDmnd",
8228            "dotGrid",
8229            "dnDiag",
8230            "horz",
8231            "horzBrick",
8232            "lgCheck",
8233            "lgConfetti",
8234            "lgGrid",
8235            "ltDnDiag",
8236            "ltHorz",
8237            "ltUpDiag",
8238            "ltVert",
8239            "narHorz",
8240            "narVert",
8241            "openDmnd",
8242            "pct10",
8243            "pct20",
8244            "pct25",
8245            "pct30",
8246            "pct40",
8247            "pct5",
8248            "pct50",
8249            "pct60",
8250            "pct70",
8251            "pct75",
8252            "pct80",
8253            "pct90",
8254            "plaid",
8255            "shingle",
8256            "smCheck",
8257            "smConfetti",
8258            "smGrid",
8259            "solidDmnd",
8260            "sphere",
8261            "trellis",
8262            "upDiag",
8263            "vert",
8264            "wave",
8265            "weave",
8266            "wdDnDiag",
8267            "wdUpDiag",
8268            "zigZag",
8269        ] {
8270            let fill = format!(
8271                r#"<a:pattFill prst="{preset}"><a:fgClr><a:srgbClr val="FF0000"/></a:fgClr><a:bgClr><a:srgbClr val="0000FF"/></a:bgClr></a:pattFill>"#
8272            );
8273            let shape = shape_with_details(
8274                None,
8275                None,
8276                &format!("{}{}{}", transform(0), geometry, fill),
8277                None,
8278            );
8279            let resolved = Fixture::new(&shape, "", "")
8280                .context()
8281                .resolve_slide((720.0, 540.0))
8282                .unwrap();
8283            assert!(
8284                resolved.shapes[0].fill.is_some(),
8285                "FX205_VISIBLE_PATTERN: {preset}"
8286            );
8287            assert_eq!(resolved.shapes[0].unsupported, None);
8288            let Some(Paint::Grid {
8289                bounds,
8290                columns,
8291                rows,
8292                colors,
8293            }) = &resolved.shapes[0].fill
8294            else {
8295                panic!("pattern did not publish sampled colour paint");
8296            };
8297            assert_eq!((*columns, *rows), (64, 64));
8298            assert_eq!((bounds.width, bounds.height), (8.0, 8.0));
8299            assert!(colors.iter().any(|c| c.r == 1.0 && c.b == 0.0));
8300            assert!(colors.iter().any(|c| c.r == 0.0 && c.b == 1.0));
8301        }
8302        assert!(super::native_pattern_rows("foreign-preset").is_none());
8303    }
8304
8305    #[test]
8306    fn independent_gradient_is_concrete_only_without_an_effective_shape_rotation() {
8307        let geometry = r#"<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>"#;
8308        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>"#;
8309        let unrotated = shape_with_details(
8310            None,
8311            None,
8312            &format!(
8313                r#"<a:xfrm><a:off x="0" y="0"/><a:ext cx="127000" cy="127000"/></a:xfrm>{geometry}{gradient}"#
8314            ),
8315            None,
8316        );
8317        let rotated = shape_with_details(
8318            None,
8319            None,
8320            &format!(
8321                r#"<a:xfrm rot="5400000"><a:off x="0" y="0"/><a:ext cx="127000" cy="127000"/></a:xfrm>{geometry}{gradient}"#
8322            ),
8323            None,
8324        );
8325        let shapes = format!("{unrotated}{rotated}");
8326        let resolved = Fixture::new(&shapes, "", "")
8327            .context()
8328            .resolve_slide((720.0, 540.0))
8329            .unwrap();
8330
8331        assert!(matches!(
8332            resolved.shapes[0].fill,
8333            Some(Paint::Linear { .. })
8334        ));
8335        assert_eq!(resolved.shapes[0].unsupported, None);
8336        assert_eq!(resolved.shapes[1].fill, None);
8337        assert_eq!(
8338            resolved.shapes[1].unsupported,
8339            Some("gradient independent of shape rotation")
8340        );
8341        assert_eq!(resolved.diagnostics.len(), 1);
8342
8343        let grouped = format!(
8344            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>"#
8345        );
8346        let resolved = Fixture::new(&grouped, "", "")
8347            .context()
8348            .resolve_slide((720.0, 540.0))
8349            .unwrap();
8350        assert_eq!(resolved.shapes[0].fill, None);
8351        assert_eq!(
8352            resolved.shapes[0].unsupported,
8353            Some("gradient independent of shape rotation")
8354        );
8355    }
8356
8357    #[test]
8358    fn theme_style_resolves_to_concrete_paint_line_and_shadow() {
8359        let shape = format!(
8360            "<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>",
8361            transform(0)
8362        );
8363        let fixture = Fixture::new(&shape, "", "");
8364
8365        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
8366        let shape = &resolved.shapes[0];
8367        assert!(matches!(shape.fill, Some(Paint::Solid(_))));
8368        assert!(shape.line.is_some());
8369        assert!(matches!(shape.shadow, Some(Effect::OuterShadow { .. })));
8370    }
8371
8372    #[test]
8373    fn unsupported_content_keeps_bounds_and_diagnostic() {
8374        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>"#;
8375        let fixture = Fixture::new(frame, "", "");
8376
8377        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
8378
8379        assert_eq!(resolved.shapes.len(), 1);
8380        assert_eq!(resolved.shapes[0].bounds.width, 10.0);
8381        assert_eq!(resolved.shapes[0].bounds.height, 20.0);
8382        assert_eq!(
8383            resolved.shapes[0].geometry,
8384            ResolvedGeometry::BoundsFallback
8385        );
8386        assert_eq!(
8387            resolved.shapes[0].unsupported,
8388            Some("unknown graphic frame")
8389        );
8390        assert_eq!(resolved.diagnostics.len(), 1);
8391    }
8392
8393    #[test]
8394    fn ole_png_previews_use_their_producer_scope_and_graphic_frame_transform() {
8395        let fixture = Fixture::new(
8396            &ole_frame("rId7", 25_400, 9_999_999),
8397            &ole_frame("rId7", 12_700, 8_888_888),
8398            &ole_frame("rId7", 0, 7_777_777),
8399        );
8400        let media = ScopedMediaIds {
8401            slide: HashMap::from([("rId7".to_owned(), MediaId(1))]),
8402            layout: HashMap::from([("rId7".to_owned(), MediaId(2))]),
8403            master: HashMap::from([("rId7".to_owned(), MediaId(3))]),
8404            media_content_types: HashMap::from([
8405                (MediaId(1), "image/png".to_owned()),
8406                (MediaId(2), "image/png".to_owned()),
8407                (MediaId(3), "image/png".to_owned()),
8408            ]),
8409        };
8410
8411        let resolved = fixture
8412            .context()
8413            .resolve_slide_with_media((720.0, 540.0), &media)
8414            .unwrap();
8415        let ids = resolved
8416            .shapes
8417            .iter()
8418            .map(|shape| match &shape.content {
8419                ResolvedContent::Image(image) => image.media,
8420                _ => panic!("OLE PNG preview did not resolve as image content"),
8421            })
8422            .collect::<Vec<_>>();
8423        let positions = resolved
8424            .shapes
8425            .iter()
8426            .map(|shape| shape.bounds.x)
8427            .collect::<Vec<_>>();
8428
8429        assert_eq!(ids, [MediaId(3), MediaId(2), MediaId(1)]);
8430        assert_eq!(positions, [0.0, 1.0, 2.0]);
8431        assert!(resolved.shapes.iter().all(|shape| {
8432            shape.geometry == ResolvedGeometry::Rectangle
8433                && shape.unsupported == Some("embedded OLE interactivity")
8434        }));
8435        assert_eq!(
8436            resolved
8437                .diagnostics
8438                .iter()
8439                .map(|diagnostic| diagnostic.message.as_str())
8440                .collect::<Vec<_>>(),
8441            vec![
8442                "OLE object rendered as a static PNG preview. Embedded OLE interactivity is not rendered";
8443                3
8444            ]
8445        );
8446    }
8447
8448    #[test]
8449    fn ole_preview_requires_embedded_resolved_png_media() {
8450        let children = [
8451            ole_frame_without_preview(0),
8452            ole_frame_with_blip(
8453                r#"r:link="https://example.invalid/preview.png""#,
8454                12_700,
8455                12_700,
8456            ),
8457            ole_frame("rIdMissing", 25_400, 25_400),
8458            ole_frame("rIdWmf", 38_100, 38_100),
8459        ]
8460        .join("");
8461        let media = ScopedMediaIds {
8462            slide: HashMap::from([("rIdWmf".to_owned(), MediaId(4))]),
8463            media_content_types: HashMap::from([(MediaId(4), "image/x-wmf".to_owned())]),
8464            ..ScopedMediaIds::default()
8465        };
8466
8467        let resolved = Fixture::new(&children, "", "")
8468            .context()
8469            .resolve_slide_with_media((720.0, 540.0), &media)
8470            .unwrap();
8471
8472        assert_eq!(resolved.shapes.len(), 4);
8473        assert!(resolved.shapes.iter().all(|shape| {
8474            shape.content == ResolvedContent::None
8475                && shape.geometry == ResolvedGeometry::BoundsFallback
8476                && shape.unsupported == Some("OLE")
8477        }));
8478        assert_eq!(
8479            resolved.diagnostics,
8480            vec![
8481                Diagnostic {
8482                    message: "unsupported OLE content retained as bounds".to_owned(),
8483                };
8484                4
8485            ]
8486        );
8487    }
8488
8489    #[test]
8490    fn resolved_shapes_follow_the_flattened_order() {
8491        let master = shape_with_details(None, None, &transform(12_700), None);
8492        let layout = shape_with_details(None, None, &transform(25_400), None);
8493        let slide = shape_with_details(None, None, &transform(38_100), None);
8494        let fixture = Fixture::new(&slide, &layout, &master);
8495
8496        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
8497        let x = resolved
8498            .shapes
8499            .iter()
8500            .map(|shape| shape.bounds.x)
8501            .collect::<Vec<_>>();
8502        assert_eq!(x, [1.0, 2.0, 3.0]);
8503    }
8504
8505    #[test]
8506    fn all_corpus_slides_resolve_without_panics() {
8507        let stats = resolve_pinned_corpus();
8508
8509        assert_eq!(stats.decks, EXPECTED_CORPUS_DECKS);
8510        assert_eq!(stats.resolved + stats.contextual_errors, stats.slides);
8511    }
8512
8513    #[test]
8514    fn all_corpus_preset_geometries_evaluate_or_fallback() {
8515        let stats = resolve_pinned_corpus();
8516
8517        assert_eq!(stats.decks, EXPECTED_CORPUS_DECKS);
8518        assert_eq!(stats.contextual_errors, 0, "{}", stats.errors.join("\n"));
8519        assert_eq!(stats.unresolved_members, 0, "{}", stats.errors.join("\n"));
8520        assert!(
8521            stats.preset_inputs > 0,
8522            "corpus exercised no preset geometry"
8523        );
8524        assert_eq!(stats.preset_errors, 0, "{}", stats.errors.join("\n"));
8525        assert_eq!(
8526            stats.preset_inputs,
8527            stats.preset_evaluated + stats.preset_unknown,
8528            "not every corpus preset produced geometry or a named unknown fallback"
8529        );
8530        assert_eq!(stats.preset_fallbacks, stats.preset_unknown);
8531    }
8532
8533    #[derive(Default)]
8534    struct CorpusResolveStats {
8535        decks: usize,
8536        slides: usize,
8537        resolved: usize,
8538        contextual_errors: usize,
8539        unresolved_members: usize,
8540        theme_references: usize,
8541        preset_inputs: usize,
8542        preset_evaluated: usize,
8543        preset_unknown: usize,
8544        preset_errors: usize,
8545        preset_fallbacks: usize,
8546        errors: Vec<String>,
8547    }
8548
8549    fn resolve_pinned_corpus() -> CorpusResolveStats {
8550        let corpus = corpus_dir();
8551        assert!(
8552            corpus.is_dir(),
8553            "the required pinned corpus is missing at {}",
8554            corpus.display()
8555        );
8556        let entries = include_str!("../../../scripts/pptx-corpus-manifest.tsv")
8557            .lines()
8558            .skip(1)
8559            .map(|line| line.split('\t').next().unwrap())
8560            .collect::<Vec<_>>();
8561        assert_eq!(entries.len(), EXPECTED_CORPUS_DECKS);
8562
8563        let mut stats = CorpusResolveStats::default();
8564        for entry in entries {
8565            let path = corpus.join(entry);
8566            assert!(path.is_file(), "missing pinned deck {}", path.display());
8567            let package = OpcPackage::open(&path)
8568                .unwrap_or_else(|error| panic!("{}: {error}", path.display()));
8569            let presentation_part = package
8570                .main_document_part()
8571                .unwrap_or_else(|| panic!("{}: no presentation part", path.display()));
8572            let presentation = CT_Presentation::from_xml(part(&package, &presentation_part))
8573                .unwrap_or_else(|error| panic!("{}: {error}", path.display()));
8574            let default_text_style = presentation.default_text_style.unwrap_or_default();
8575            let size = presentation.slide_size.map_or((720.0, 540.0), |size| {
8576                (
8577                    super::emu_to_points(size.cx.0),
8578                    super::emu_to_points(size.cy.0),
8579                )
8580            });
8581            let presentation_rels = package
8582                .get_part_rels(&presentation_part)
8583                .unwrap_or_else(|| panic!("{}: no presentation relationships", path.display()));
8584
8585            for slide_id in presentation.slide_ids {
8586                stats.slides += 1;
8587                let slide_rel = presentation_rels
8588                    .get_by_id(&slide_id.relationship_id)
8589                    .unwrap_or_else(|| {
8590                        panic!(
8591                            "{}: missing slide relationship {}",
8592                            path.display(),
8593                            slide_id.relationship_id
8594                        )
8595                    });
8596                assert_eq!(slide_rel.rel_type, rel_types::SLIDE, "{}", path.display());
8597                let slide_part =
8598                    OpcPackage::resolve_rel_target(&presentation_part, &slide_rel.target);
8599                let layout_part =
8600                    related_part(&package, &slide_part, rel_types::SLIDE_LAYOUT, &path);
8601                let master_part =
8602                    related_part(&package, &layout_part, rel_types::SLIDE_MASTER, &path);
8603                let theme_part = related_part(&package, &master_part, rel_types::THEME, &path);
8604                let slide = CT_Slide::from_xml(part(&package, &slide_part))
8605                    .unwrap_or_else(|error| panic!("{} {slide_part}: {error}", path.display()));
8606                let layout = CT_SlideLayout::from_xml(part(&package, &layout_part))
8607                    .unwrap_or_else(|error| panic!("{} {layout_part}: {error}", path.display()));
8608                let master = CT_SlideMaster::from_xml(part(&package, &master_part))
8609                    .unwrap_or_else(|error| panic!("{} {master_part}: {error}", path.display()));
8610                let theme = CT_OfficeStyleSheet::from_xml(part(&package, &theme_part))
8611                    .unwrap_or_else(|error| panic!("{} {theme_part}: {error}", path.display()));
8612                let color_map = effective_corpus_color_map(&master, &layout, &slide);
8613                let context = ResolveCtx::new(
8614                    &theme,
8615                    color_map,
8616                    &master,
8617                    &layout,
8618                    &slide,
8619                    &default_text_style,
8620                );
8621                for item in context.flatten() {
8622                    let FlattenedItem::Shape {
8623                        child: ShapeTreeChild::Shape(shape),
8624                        ..
8625                    } = item
8626                    else {
8627                        continue;
8628                    };
8629                    if shape.shape_properties.custom_geometry.is_some() {
8630                        continue;
8631                    }
8632                    let Some(preset) = shape.shape_properties.preset_geometry.as_ref() else {
8633                        continue;
8634                    };
8635                    let Some((bounds, _, _, _)) =
8636                        transform_values(context.effective_xfrm(shape).as_ref())
8637                    else {
8638                        continue;
8639                    };
8640                    stats.preset_inputs += 1;
8641                    match context.concrete_preset_geometry(preset, (bounds.width, bounds.height)) {
8642                        Ok(Some(_)) => stats.preset_evaluated += 1,
8643                        Ok(None) => stats.preset_unknown += 1,
8644                        Err(error) => {
8645                            stats.preset_errors += 1;
8646                            stats.errors.push(format!(
8647                                "{} {slide_part}: preset {}: {error}",
8648                                path.display(),
8649                                preset.preset
8650                            ));
8651                        }
8652                    }
8653                }
8654                let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
8655                    context.resolve_slide(size)
8656                }))
8657                .unwrap_or_else(|_| panic!("{} {slide_part}: resolver panicked", path.display()));
8658                match result {
8659                    Ok(resolved) => {
8660                        stats.unresolved_members += resolved.unresolved_member_count;
8661                        if resolved.unresolved_member_count > 0 {
8662                            stats
8663                                .errors
8664                                .extend(resolved.diagnostics.iter().map(|diagnostic| {
8665                                    format!(
8666                                        "{} {slide_part}: {}",
8667                                        path.display(),
8668                                        diagnostic.message
8669                                    )
8670                                }));
8671                        }
8672                        let debug = format!("{resolved:?}");
8673                        stats.theme_references += ["schemeClr", "sysClr", "scrgbClr", "prstClr"]
8674                            .into_iter()
8675                            .filter(|marker| debug.contains(marker))
8676                            .count();
8677                        stats.preset_fallbacks += resolved
8678                            .shapes
8679                            .iter()
8680                            .filter(|shape| {
8681                                matches!(
8682                                    shape.unsupported,
8683                                    Some("unknown preset geometry" | "preset geometry evaluation")
8684                                )
8685                            })
8686                            .count();
8687                        stats.resolved += 1;
8688                    }
8689                    Err(error) => {
8690                        stats.contextual_errors += 1;
8691                        stats
8692                            .errors
8693                            .push(format!("{} {slide_part}: {error}", path.display()));
8694                    }
8695                }
8696            }
8697            stats.decks += 1;
8698        }
8699        stats
8700    }
8701
8702    fn corpus_dir() -> PathBuf {
8703        std::env::var_os("RDOCX_PPTX_CORPUS_DIR")
8704            .map(PathBuf::from)
8705            .unwrap_or_else(|| workspace_root().join("corpus/pptx"))
8706    }
8707
8708    fn workspace_root() -> PathBuf {
8709        Path::new(env!("CARGO_MANIFEST_DIR"))
8710            .parent()
8711            .and_then(Path::parent)
8712            .unwrap()
8713            .to_path_buf()
8714    }
8715
8716    fn part<'a>(package: &'a OpcPackage, part_name: &str) -> &'a [u8] {
8717        package
8718            .get_part(part_name)
8719            .unwrap_or_else(|| panic!("missing package part {part_name}"))
8720    }
8721
8722    fn related_part(
8723        package: &OpcPackage,
8724        source_part: &str,
8725        relationship_type: &str,
8726        deck: &Path,
8727    ) -> String {
8728        let relationship = package
8729            .get_part_rels(source_part)
8730            .and_then(|relationships| relationships.get_by_type(relationship_type))
8731            .unwrap_or_else(|| {
8732                panic!(
8733                    "{} {source_part}: missing relationship {relationship_type}",
8734                    deck.display()
8735                )
8736            });
8737        OpcPackage::resolve_rel_target(source_part, &relationship.target)
8738    }
8739
8740    fn effective_corpus_color_map(
8741        master: &CT_SlideMaster,
8742        layout: &CT_SlideLayout,
8743        slide: &CT_Slide,
8744    ) -> ColorMap {
8745        for override_value in [
8746            slide.color_map_override.as_ref(),
8747            layout.color_map_override.as_ref(),
8748        ]
8749        .into_iter()
8750        .flatten()
8751        {
8752            if let ColorMapOverrideKind::Override(map) = &override_value.kind {
8753                return map.clone();
8754            }
8755        }
8756        master.color_map.clone()
8757    }
8758
8759    fn slide_xml(children: &str) -> String {
8760        slide_xml_with("", "", children)
8761    }
8762
8763    fn layout_xml(children: &str) -> String {
8764        layout_xml_with("", "", children, "")
8765    }
8766
8767    fn master_xml(children: &str) -> String {
8768        master_xml_with("", children, "")
8769    }
8770
8771    fn slide_xml_with(attributes: &str, background: &str, children: &str) -> String {
8772        format!(
8773            "<p:sld xmlns:p=\"{P_NS}\" xmlns:a=\"{A_NS}\" xmlns:mc=\"{MC_NS}\" {attributes}><p:cSld>{background}{}</p:cSld></p:sld>",
8774            shape_tree(children)
8775        )
8776    }
8777
8778    fn layout_xml_with(
8779        attributes: &str,
8780        background: &str,
8781        children: &str,
8782        header_footer: &str,
8783    ) -> String {
8784        format!(
8785            "<p:sldLayout xmlns:p=\"{P_NS}\" xmlns:a=\"{A_NS}\" xmlns:mc=\"{MC_NS}\" {attributes}><p:cSld>{background}{}</p:cSld>{header_footer}</p:sldLayout>",
8786            shape_tree(children)
8787        )
8788    }
8789
8790    fn master_xml_with(background: &str, children: &str, header_footer: &str) -> String {
8791        format!(
8792            "<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>",
8793            shape_tree(children)
8794        )
8795    }
8796
8797    fn shape_tree(children: &str) -> String {
8798        format!("<p:spTree><p:nvGrpSpPr/><p:grpSpPr/>{children}</p:spTree>")
8799    }
8800
8801    fn shape(ph_type: Option<&str>, idx: Option<u32>) -> String {
8802        shape_with_details(ph_type, idx, "", None)
8803    }
8804
8805    fn shape_with_text(ph_type: Option<&str>, idx: Option<u32>, text: &str) -> String {
8806        let placeholder = if ph_type.is_none() && idx.is_none() {
8807            String::new()
8808        } else {
8809            let ph_type = ph_type.map_or_else(String::new, |value| format!(" type=\"{value}\""));
8810            let idx = idx.map_or_else(String::new, |value| format!(" idx=\"{value}\""));
8811            format!("<p:ph{ph_type}{idx}/>")
8812        };
8813        format!(
8814            "<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>"
8815        )
8816    }
8817
8818    fn item_text(item: &FlattenedItem<'_>) -> Option<String> {
8819        let FlattenedItem::Shape {
8820            child: ShapeTreeChild::Shape(shape),
8821            ..
8822        } = item
8823        else {
8824            return None;
8825        };
8826        shape.text_body.as_ref().map(|body| body.plain_text())
8827    }
8828
8829    fn background_source(fixture: &Fixture) -> BackgroundSource {
8830        fixture.context().effective_background().unwrap().source
8831    }
8832
8833    #[test]
8834    fn same_relationship_id_resolves_hyperlink_in_its_shape_source_scope() {
8835        let linked_shape = |label: &str, x: i64| {
8836            shape_with_details(None, None, &transform(x), Some("<a:bodyPr/>"))
8837                .replace(
8838                    "<a:p/>",
8839                    &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>"#),
8840                )
8841        };
8842        let fixture = Fixture::new(
8843            &linked_shape("slide", 200),
8844            &linked_shape("layout", 100),
8845            &linked_shape("master", 0),
8846        );
8847        let hyperlinks = ScopedHyperlinkTargets {
8848            slide: HashMap::from([("rId7".to_owned(), "https://slide.example".to_owned())]),
8849            layout: HashMap::from([("rId7".to_owned(), "https://layout.example".to_owned())]),
8850            master: HashMap::from([("rId7".to_owned(), "https://master.example".to_owned())]),
8851        };
8852
8853        let resolved = fixture
8854            .context()
8855            .resolve_slide_with_resources((720.0, 540.0), &ScopedMediaIds::default(), &hyperlinks)
8856            .expect("resolve source-scoped hyperlinks");
8857        let targets = resolved
8858            .shapes
8859            .iter()
8860            .filter_map(|shape| match &shape.content {
8861                ResolvedContent::Text(body) => body.paragraphs[0].runs.first(),
8862                _ => None,
8863            })
8864            .filter_map(|run| match run {
8865                ResolvedTextRun::Text { style, .. } => style.hyperlink_url.as_deref(),
8866                _ => None,
8867            })
8868            .collect::<Vec<_>>();
8869
8870        assert_eq!(
8871            targets,
8872            vec![
8873                "https://master.example",
8874                "https://layout.example",
8875                "https://slide.example"
8876            ]
8877        );
8878    }
8879
8880    #[test]
8881    fn missing_hyperlink_relationship_keeps_text_and_records_diagnostic() {
8882        let shape = shape_with_details(None, None, &transform(0), Some("<a:bodyPr/>"))
8883            .replace(
8884                "<a:p/>",
8885                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>"#,
8886            );
8887        let resolved = Fixture::new(&shape, "", "")
8888            .context()
8889            .resolve_slide_with_resources(
8890                (720.0, 540.0),
8891                &ScopedMediaIds::default(),
8892                &ScopedHyperlinkTargets::default(),
8893            )
8894            .expect("resolve unsupported hyperlinks without dropping text");
8895        let ResolvedContent::Text(body) = &resolved.shapes[0].content else {
8896            panic!("linked text should remain visible")
8897        };
8898
8899        assert_eq!(
8900            body.paragraphs[0]
8901                .runs
8902                .iter()
8903                .filter_map(|run| match run {
8904                    ResolvedTextRun::Text { text, .. } => Some(text.as_str()),
8905                    _ => None,
8906                })
8907                .collect::<String>(),
8908            "missing action internal"
8909        );
8910        let diagnostics = resolved
8911            .diagnostics
8912            .iter()
8913            .map(|diagnostic| diagnostic.message.as_str())
8914            .collect::<Vec<_>>();
8915        for expected in [
8916            "missing slide hyperlink relationship `rId404`",
8917            "unsupported slide hyperlink action `ppaction://macro`",
8918            "unsupported slide hyperlink action `ppaction://hlinksldjump`",
8919        ] {
8920            assert!(diagnostics.contains(&expected), "missing `{expected}`");
8921        }
8922    }
8923
8924    #[test]
8925    fn untyped_slide_number_placeholder_uses_the_current_page_number() {
8926        let slide_shape = shape_with_details(None, Some(4), &transform(0), Some("<a:bodyPr/>"))
8927            .replace(
8928                "<a:p/>",
8929                r#"<a:p><a:fld id="{00112233-4455-6677-8899-AABBCCDDEEFF}"><a:t>stored</a:t></a:fld></a:p>"#,
8930            );
8931        let layout_shape =
8932            shape_with_details(Some("sldNum"), Some(4), &transform(0), Some("<a:bodyPr/>"));
8933        let resolved = Fixture::new(&slide_shape, &layout_shape, "")
8934            .context()
8935            .resolve_slide((720.0, 540.0))
8936            .expect("resolve inherited slide-number placeholder");
8937        let ResolvedContent::Text(body) = &resolved.shapes[0].content else {
8938            panic!("slide-number field should remain visible")
8939        };
8940        let ResolvedTextRun::Field { field_type, .. } = &body.paragraphs[0].runs[0] else {
8941            panic!("expected a field")
8942        };
8943
8944        assert_eq!(field_type.as_deref(), Some("slidenum"));
8945    }
8946
8947    #[test]
8948    fn three_dimensional_chart_uses_cached_image_and_diagnostic() {
8949        let alternate = chart_alternate("rIdChart", Some("rIdPreview")).replace(
8950            r#"r:embed="rIdPreview"/>"#,
8951            r#"r:embed="rIdPreview"><a:alphaModFix amt="30000"/></a:blip>"#,
8952        );
8953        let fixture = Fixture::new(&alternate, "", "");
8954        let preview = MediaId(47);
8955        let media = ScopedMediaIds {
8956            slide: HashMap::from([("rIdPreview".to_owned(), preview)]),
8957            media_content_types: HashMap::from([(preview, "image/png".to_owned())]),
8958            ..ScopedMediaIds::default()
8959        };
8960        let charts = ScopedChartResources {
8961            slide: HashMap::from([(
8962                "rIdChart".to_owned(),
8963                ChartResource::Parsed(Box::new(three_dimensional_chart())),
8964            )]),
8965            ..ScopedChartResources::default()
8966        };
8967        let mut fonts = FontManager::new_deterministic().unwrap();
8968
8969        let resolved = fixture
8970            .context()
8971            .resolve_slide_with_chart_resources(
8972                (720.0, 540.0),
8973                &media,
8974                &ScopedHyperlinkTargets::default(),
8975                &charts,
8976                &mut fonts,
8977            )
8978            .unwrap();
8979
8980        assert!(matches!(
8981            resolved.shapes[0].content,
8982            ResolvedContent::Image(ref image) if image.media == preview && image.opacity == 0.3
8983        ));
8984        assert!(resolved.shapes[0].unsupported.is_some());
8985        assert!(resolved.diagnostics.iter().any(|diagnostic| {
8986            diagnostic.message.contains("unsupported chart")
8987                && diagnostic.message.contains("cached image")
8988        }));
8989
8990        let unsupported_media = ScopedMediaIds {
8991            slide: HashMap::from([("rIdPreview".to_owned(), preview)]),
8992            media_content_types: HashMap::from([(preview, "image/x-wmf".to_owned())]),
8993            ..ScopedMediaIds::default()
8994        };
8995        let mut fonts = FontManager::new_deterministic().unwrap();
8996        let resolved = fixture
8997            .context()
8998            .resolve_slide_with_chart_resources(
8999                (720.0, 540.0),
9000                &unsupported_media,
9001                &ScopedHyperlinkTargets::default(),
9002                &charts,
9003                &mut fonts,
9004            )
9005            .unwrap();
9006        assert!(matches!(
9007            resolved.shapes[0].content,
9008            ResolvedContent::Group(_)
9009        ));
9010        assert!(resolved.diagnostics.iter().any(|diagnostic| {
9011            diagnostic.message.contains("not renderer-compatible")
9012                && diagnostic.message.contains("unsupported chart")
9013        }));
9014    }
9015
9016    #[test]
9017    fn same_chart_relationship_id_is_scoped_to_its_source_part() {
9018        let fixture = Fixture::new(
9019            &chart_frame_at("rId8", 200),
9020            &chart_frame_at("rId8", 100),
9021            &chart_frame("rId8"),
9022        );
9023        let slide = ChartResource::MissingTarget("/ppt/charts/slide.xml".to_owned());
9024        let layout = ChartResource::MissingTarget("/ppt/charts/layout.xml".to_owned());
9025        let master = ChartResource::MissingTarget("/ppt/charts/master.xml".to_owned());
9026        let charts = ScopedChartResources {
9027            slide: HashMap::from([("rId8".to_owned(), slide.clone())]),
9028            layout: HashMap::from([("rId8".to_owned(), layout.clone())]),
9029            master: HashMap::from([("rId8".to_owned(), master.clone())]),
9030        };
9031
9032        let mut fonts = FontManager::new_deterministic().unwrap();
9033        let resolved = fixture
9034            .context()
9035            .resolve_slide_with_chart_resources(
9036                (720.0, 540.0),
9037                &ScopedMediaIds::default(),
9038                &ScopedHyperlinkTargets::default(),
9039                &charts,
9040                &mut fonts,
9041            )
9042            .unwrap();
9043        assert_eq!(resolved.shapes.len(), 3);
9044        let diagnostics = resolved
9045            .diagnostics
9046            .iter()
9047            .map(|diagnostic| diagnostic.message.as_str())
9048            .collect::<Vec<_>>();
9049        for (scope, target) in [
9050            ("master", "/ppt/charts/master.xml"),
9051            ("layout", "/ppt/charts/layout.xml"),
9052            ("slide", "/ppt/charts/slide.xml"),
9053        ] {
9054            assert!(diagnostics.iter().any(|message| {
9055                message.contains(scope) && message.contains("rId8") && message.contains(target)
9056            }));
9057        }
9058    }
9059
9060    #[test]
9061    fn unsupported_chart_without_preview_keeps_labelled_bounds() {
9062        let fixture = Fixture::new(&chart_frame("rIdChart"), "", "");
9063        let charts = ScopedChartResources {
9064            slide: HashMap::from([(
9065                "rIdChart".to_owned(),
9066                ChartResource::Parsed(Box::new(three_dimensional_chart())),
9067            )]),
9068            ..ScopedChartResources::default()
9069        };
9070        let mut fonts = FontManager::new_deterministic().unwrap();
9071        let resolved = fixture
9072            .context()
9073            .resolve_slide_with_chart_resources(
9074                (720.0, 540.0),
9075                &ScopedMediaIds::default(),
9076                &ScopedHyperlinkTargets::default(),
9077                &charts,
9078                &mut fonts,
9079            )
9080            .unwrap();
9081
9082        assert_eq!(
9083            resolved.shapes[0].geometry,
9084            ResolvedGeometry::BoundsFallback
9085        );
9086        let ResolvedContent::Group(group) = &resolved.shapes[0].content else {
9087            panic!("unsupported chart should retain a labelled group");
9088        };
9089        let mut labels = Vec::new();
9090        walk(&group.children, &mut |element, _| {
9091            if let PositionedElement::Text(run) = element {
9092                labels.push(run.text.clone());
9093            }
9094        });
9095        assert_eq!(labels, vec!["Unsupported chart"]);
9096    }
9097
9098    #[test]
9099    fn missing_or_external_chart_relationship_is_contextual() {
9100        let children = format!(
9101            "{}{}{}",
9102            chart_frame("missingChart"),
9103            chart_frame_at("externalChart", 200),
9104            chart_frame_at("missingTarget", 400)
9105        );
9106        let fixture = Fixture::new(&children, "", "");
9107        let charts = ScopedChartResources {
9108            slide: HashMap::from([
9109                (
9110                    "externalChart".to_owned(),
9111                    ChartResource::External("https://example.invalid/chart.xml".to_owned()),
9112                ),
9113                (
9114                    "missingTarget".to_owned(),
9115                    ChartResource::MissingTarget("/ppt/charts/missing.xml".to_owned()),
9116                ),
9117            ]),
9118            ..ScopedChartResources::default()
9119        };
9120        let mut fonts = FontManager::new_deterministic().unwrap();
9121        let resolved = fixture
9122            .context()
9123            .resolve_slide_with_chart_resources(
9124                (720.0, 540.0),
9125                &ScopedMediaIds::default(),
9126                &ScopedHyperlinkTargets::default(),
9127                &charts,
9128                &mut fonts,
9129            )
9130            .unwrap();
9131        let diagnostics = resolved
9132            .diagnostics
9133            .iter()
9134            .map(|diagnostic| diagnostic.message.as_str())
9135            .collect::<Vec<_>>();
9136
9137        assert!(
9138            diagnostics
9139                .iter()
9140                .any(|message| { message.contains("slide") && message.contains("missingChart") })
9141        );
9142        assert!(diagnostics.iter().any(|message| {
9143            message.contains("slide")
9144                && message.contains("externalChart")
9145                && message.contains("https://example.invalid/chart.xml")
9146        }));
9147        assert!(diagnostics.iter().any(|message| {
9148            message.contains("slide")
9149                && message.contains("missingTarget")
9150                && message.contains("/ppt/charts/missing.xml")
9151        }));
9152    }
9153
9154    fn shape_with_details(
9155        ph_type: Option<&str>,
9156        idx: Option<u32>,
9157        shape_properties: &str,
9158        body_properties: Option<&str>,
9159    ) -> String {
9160        let placeholder = if ph_type.is_none() && idx.is_none() {
9161            String::new()
9162        } else {
9163            let ph_type = ph_type.map_or_else(String::new, |value| format!(" type=\"{value}\""));
9164            let idx = idx.map_or_else(String::new, |value| format!(" idx=\"{value}\""));
9165            format!("<p:ph{ph_type}{idx}/>")
9166        };
9167        let text_body = body_properties.map_or_else(String::new, |body_properties| {
9168            format!("<p:txBody>{body_properties}<a:p/></p:txBody>")
9169        });
9170        format!(
9171            "<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>"
9172        )
9173    }
9174
9175    fn chart_frame(relationship_id: &str) -> String {
9176        chart_frame_at(relationship_id, 0)
9177    }
9178
9179    fn chart_frame_at(relationship_id: &str, x: i64) -> String {
9180        format!(
9181            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>"#
9182        )
9183    }
9184
9185    fn chart_alternate(relationship_id: &str, preview_id: Option<&str>) -> String {
9186        let preview = preview_id.map_or_else(String::new, |preview_id| {
9187            picture(preview_id, "", "<a:stretch><a:fillRect/></a:stretch>", 999)
9188        });
9189        format!(
9190            r#"<mc:AlternateContent><mc:Choice Requires="c">{}</mc:Choice><mc:Fallback>{preview}</mc:Fallback></mc:AlternateContent>"#,
9191            chart_frame(relationship_id)
9192        )
9193    }
9194
9195    fn three_dimensional_chart() -> CT_ChartSpace {
9196        CT_ChartSpace::from_xml(
9197            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>"#,
9198        )
9199        .unwrap()
9200    }
9201
9202    fn connector(preset: &str, cx: i64, cy: i64, adjustments: &str, line: &str) -> String {
9203        format!(
9204            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>"#
9205        )
9206    }
9207
9208    fn picture(
9209        relationship_id: &str,
9210        fill_attributes: &str,
9211        fill_children: &str,
9212        x: i64,
9213    ) -> String {
9214        format!(
9215            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>"#,
9216            transform(x)
9217        )
9218    }
9219
9220    fn shape_picture_fill(relationship_id: &str, x: i64, text_body: Option<&str>) -> String {
9221        shape_with_details(
9222            None,
9223            None,
9224            &format!(
9225                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>"#,
9226                transform(x)
9227            ),
9228            text_body,
9229        )
9230    }
9231
9232    fn linked_picture(url: &str, x: i64) -> String {
9233        format!(
9234            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>"#,
9235            transform(x)
9236        )
9237    }
9238
9239    fn ole_frame(relationship_id: &str, frame_x: i64, preview_x: i64) -> String {
9240        ole_frame_with_blip(
9241            &format!(r#"r:embed="{relationship_id}""#),
9242            frame_x,
9243            preview_x,
9244        )
9245    }
9246
9247    fn ole_frame_with_blip(blip_attribute: &str, frame_x: i64, preview_x: i64) -> String {
9248        let preview = format!(
9249            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>"#,
9250            transform(preview_x)
9251        );
9252        ole_frame_payload(frame_x, &preview)
9253    }
9254
9255    fn ole_frame_without_preview(frame_x: i64) -> String {
9256        ole_frame_payload(frame_x, "")
9257    }
9258
9259    fn ole_frame_payload(frame_x: i64, preview: &str) -> String {
9260        format!(
9261            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>"#
9262        )
9263    }
9264
9265    fn transform(x: i64) -> String {
9266        format!("<a:xfrm><a:off x=\"{x}\" y=\"0\"/><a:ext cx=\"100\" cy=\"100\"/></a:xfrm>")
9267    }
9268    fn fx206_recovery_shape(id: u32, props: &str, style: &str, placed: bool) -> String {
9269        let transform = if placed {
9270            r#"<a:xfrm><a:off x="127000" y="254000"/><a:ext cx="127000" cy="127000"/></a:xfrm>"#
9271        } else {
9272            ""
9273        };
9274        format!(
9275            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>"#
9276        )
9277    }
9278
9279    #[test]
9280    fn fx206_recovery_semantic_errors_keep_siblings_and_count_boundsless_owners() {
9281        let cases = [
9282            (
9283                "",
9284                r#"<p:style><a:lnRef idx="0"/><a:fillRef idx="999"/><a:effectRef idx="0"/><a:fontRef idx="minor"/></p:style>"#,
9285                "style index",
9286            ),
9287            (
9288                r#"<a:solidFill><a:schemeClr val="phClr"/></a:solidFill>"#,
9289                "",
9290                "phClr",
9291            ),
9292            (
9293                r#"<a:solidFill><a:prstClr val="futureColour"/></a:solidFill>"#,
9294                "",
9295                "concrete value",
9296            ),
9297        ];
9298        for (properties, style, reason) in cases {
9299            for placed in [true, false] {
9300                let red = fx206_recovery_shape(
9301                    1,
9302                    r#"<a:solidFill><a:srgbClr val="FF0000"/></a:solidFill>"#,
9303                    "",
9304                    true,
9305                );
9306                let green = fx206_recovery_shape(
9307                    3,
9308                    r#"<a:solidFill><a:srgbClr val="00FF00"/></a:solidFill>"#,
9309                    "",
9310                    true,
9311                );
9312                let failed = fx206_recovery_shape(2, properties, style, placed);
9313                let fixture = Fixture::new(&[red, failed, green].join(""), "", "");
9314                let original = fixture.slide.clone();
9315                let context = fixture.context();
9316                let mut identities = Vec::new();
9317                let (resolved, directions) = context
9318                    .resolve_slide_inner_common(
9319                        (100.0, 100.0),
9320                        None,
9321                        None,
9322                        None,
9323                        None,
9324                        Some(&mut identities),
9325                    )
9326                    .unwrap();
9327                assert_eq!(
9328                    resolved.unresolved_member_count, 1,
9329                    "{reason}, placed={placed}"
9330                );
9331                assert_eq!(
9332                    identities
9333                        .iter()
9334                        .map(|identity| identity.shape_id.unwrap())
9335                        .collect::<Vec<_>>(),
9336                    if placed { vec![1, 2, 3] } else { vec![1, 3] }
9337                );
9338                assert_eq!(directions.len(), resolved.shapes.len());
9339                assert_eq!(
9340                    resolved.shapes.first().unwrap().fill,
9341                    Some(Paint::Solid(Color::from_hex("FF0000")))
9342                );
9343                assert_eq!(
9344                    resolved.shapes.last().unwrap().fill,
9345                    Some(Paint::Solid(Color::from_hex("00FF00")))
9346                );
9347                let messages = resolved
9348                    .diagnostics
9349                    .iter()
9350                    .filter(|diagnostic| diagnostic.message.starts_with("unresolved member "))
9351                    .collect::<Vec<_>>();
9352                assert_eq!(messages.len(), 1);
9353                assert!(messages[0].message.contains("shape 2 \"owner2\""));
9354                assert!(messages[0].message.contains(reason));
9355                assert!(messages[0].message.contains(if placed {
9356                    "retained as bounds"
9357                } else {
9358                    "omitted without trustworthy finite bounds"
9359                }));
9360                if placed {
9361                    assert_eq!(
9362                        resolved.shapes[1].geometry,
9363                        ResolvedGeometry::BoundsFallback
9364                    );
9365                    assert_eq!(resolved.shapes[1].content, ResolvedContent::None);
9366                }
9367                assert_eq!(fixture.slide, original);
9368            }
9369        }
9370    }
9371
9372    #[test]
9373    fn fx206_recovery_valid_zero_and_background_setup_remain_distinct() {
9374        let fixture = Fixture::new(
9375            &fx206_recovery_shape(
9376                1,
9377                r#"<a:solidFill><a:srgbClr val="FF0000"/></a:solidFill>"#,
9378                "",
9379                true,
9380            ),
9381            "",
9382            "",
9383        );
9384        let valid = fixture.context().resolve_slide((100.0, 100.0)).unwrap();
9385        assert_eq!(valid.unresolved_member_count, 0);
9386        assert!(valid.diagnostics.is_empty());
9387        let xml = slide_xml_with(
9388            "",
9389            r#"<p:bg><p:bgRef idx="999"><a:schemeClr val="accent1"/></p:bgRef></p:bg>"#,
9390            "",
9391        );
9392        let fixture = Fixture::from_xml(&xml, &layout_xml(""), &master_xml(""));
9393        assert!(matches!(
9394            fixture.context().resolve_slide((100.0, 100.0)),
9395            Err(crate::ResolveError::StyleIndexOutOfRange { .. })
9396        ));
9397        assert!(CT_Slide::from_xml(b"<p:sld>").is_err());
9398    }
9399
9400    #[test]
9401    fn fx206_recovery_rejects_nonfinite_placement_without_hiding_the_failure() {
9402        let fixture = Fixture::new(&fx206_recovery_shape(2, "", "", true), "", "");
9403        let context = fixture.context();
9404        let mut diagnostics = Vec::new();
9405        let error = crate::ResolveError::ConcreteValue {
9406            kind: "test semantic",
9407            detail: "bad value".to_owned(),
9408        };
9409        let result = context.unresolved_shape_fallback(
9410            super::ShapePlacement {
9411                source: FlattenedSource::Slide,
9412                group_scale: (1.0, 1.0),
9413                group_transform: Transform {
9414                    a: f64::INFINITY,
9415                    ..Transform::IDENTITY
9416                },
9417            },
9418            &fixture.slide.common_slide_data.shape_tree.children[0],
9419            &error,
9420            &mut diagnostics,
9421        );
9422        assert!(result.is_none());
9423        assert_eq!(diagnostics.len(), 1);
9424        assert!(
9425            diagnostics[0]
9426                .message
9427                .contains("omitted without trustworthy finite bounds")
9428        );
9429    }
9430
9431    #[test]
9432    fn fx206_recovery_alias_and_default_namespaces_keep_owned_failure_and_foreign_payload() {
9433        let failed = fx206_recovery_shape(
9434            2,
9435            r#"<a:solidFill><a:schemeClr val="phClr"/></a:solidFill><f:solidFill xmlns:f="urn:foreign"><f:opaque/></f:solidFill>"#,
9436            "",
9437            true,
9438        );
9439        let original = slide_xml(&failed);
9440        let conflicting = original
9441            .replace("xmlns:p=", "xmlns:q=")
9442            .replace("xmlns:a=", "xmlns:r=")
9443            .replace("<p:", "<q:")
9444            .replace("</p:", "</q:")
9445            .replace("<a:", "<r:")
9446            .replace("</a:", "</r:");
9447        assert!(CT_Slide::from_xml(conflicting.as_bytes()).is_err());
9448        let alias = original
9449            .replace("xmlns:p=", "xmlns:q=")
9450            .replace("xmlns:a=", "xmlns:d=")
9451            .replace("<p:", "<q:")
9452            .replace("</p:", "</q:")
9453            .replace("<a:", "<d:")
9454            .replace("</a:", "</d:");
9455        let default = original.replace("<p:", "<").replace("</p:", "</").replacen(
9456            "<sld ",
9457            &format!("<sld xmlns=\"{P_NS}\" "),
9458            1,
9459        );
9460        for xml in [original, alias, default] {
9461            let fixture = Fixture::from_xml(&xml, &layout_xml(""), &master_xml(""));
9462            let snapshot = fixture.slide.clone();
9463            let resolved = fixture.context().resolve_slide((100.0, 100.0)).unwrap();
9464            assert_eq!(resolved.unresolved_member_count, 1);
9465            assert_eq!(resolved.shapes.len(), 1);
9466            assert!(resolved.diagnostics[0].message.contains("phClr"));
9467            assert_eq!(fixture.slide, snapshot);
9468        }
9469    }
9470
9471    #[test]
9472    fn fx206_recovery_gradient_stop_errors_count_with_and_without_placement() {
9473        for line in [false, true] {
9474            for color in [
9475                r#"<a:schemeClr val="phClr"/>"#,
9476                r#"<a:prstClr val="futureColour"/>"#,
9477            ] {
9478                for placed in [false, true] {
9479                    let gradient = format!(
9480                        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>"#
9481                    );
9482                    let properties = if line {
9483                        format!("<a:ln>{gradient}</a:ln>")
9484                    } else {
9485                        gradient
9486                    };
9487                    let children = [
9488                        fx206_recovery_shape(
9489                            1,
9490                            r#"<a:solidFill><a:srgbClr val="FF0000"/></a:solidFill>"#,
9491                            "",
9492                            true,
9493                        ),
9494                        fx206_recovery_shape(2, &properties, "", placed),
9495                        fx206_recovery_shape(
9496                            3,
9497                            r#"<a:solidFill><a:srgbClr val="00FF00"/></a:solidFill>"#,
9498                            "",
9499                            true,
9500                        ),
9501                    ]
9502                    .join("");
9503                    for namespace in 0..3 {
9504                        let xml = match namespace {
9505                            1 => slide_xml(&children)
9506                                .replace("xmlns:a=", "xmlns:d=")
9507                                .replace("<a:", "<d:")
9508                                .replace("</a:", "</d:"),
9509                            2 => slide_xml(&children)
9510                                .replace("xmlns:a=", "xmlns=")
9511                                .replace("<a:", "<")
9512                                .replace("</a:", "</"),
9513                            _ => slide_xml(&children),
9514                        };
9515                        let fixture = Fixture::from_xml(&xml, &layout_xml(""), &master_xml(""));
9516                        let before = fixture.slide.clone();
9517                        let resolved = fixture.context().resolve_slide((100.0, 100.0)).unwrap();
9518                        assert_eq!(
9519                            resolved.unresolved_member_count, 1,
9520                            "line={line} placed={placed} namespace={namespace}"
9521                        );
9522                        assert_eq!(resolved.shapes.len(), if placed { 3 } else { 2 });
9523                        assert_eq!(
9524                            resolved.shapes[0].fill,
9525                            Some(Paint::Solid(Color::from_hex("FF0000")))
9526                        );
9527                        assert_eq!(
9528                            resolved.shapes.last().unwrap().fill,
9529                            Some(Paint::Solid(Color::from_hex("00FF00")))
9530                        );
9531                        assert_eq!(resolved.diagnostics.len(), 1);
9532                        assert!(
9533                            resolved.diagnostics[0]
9534                                .message
9535                                .contains("shape 2 \"owner2\"")
9536                        );
9537                        assert!(resolved.diagnostics[0].message.contains(if placed {
9538                            "retained as bounds"
9539                        } else {
9540                            "omitted without trustworthy finite bounds"
9541                        }));
9542                        assert_eq!(fixture.slide, before);
9543                    }
9544                }
9545            }
9546        }
9547    }
9548
9549    #[test]
9550    fn fx206_recovery_unplaced_gradient_validation_keeps_supported_and_unsupported_boundaries() {
9551        for (attributes, stops, geometry) in [
9552            (
9553                "",
9554                r#"<a:gs pos="0"><a:srgbClr val="112233"/></a:gs>"#,
9555                r#"<a:lin ang="0"/>"#,
9556            ),
9557            (
9558                r#"rotWithShape="0""#,
9559                r#"<a:gs pos="0"><a:schemeClr val="phClr"/></a:gs>"#,
9560                r#"<a:lin ang="0"/>"#,
9561            ),
9562            (
9563                r#"flip="x""#,
9564                r#"<a:gs pos="0"><a:schemeClr val="phClr"/></a:gs>"#,
9565                r#"<a:lin ang="0"/>"#,
9566            ),
9567            (
9568                "",
9569                r#"<a:gs pos="0"><a:schemeClr val="phClr"/></a:gs>"#,
9570                r#"<a:path path="circle"/>"#,
9571            ),
9572            (
9573                "",
9574                r#"<a:gs pos="0"/><a:gs pos="100000"><a:schemeClr val="phClr"/></a:gs>"#,
9575                r#"<a:lin ang="0"/>"#,
9576            ),
9577        ] {
9578            let gradient = format!(
9579                "<a:gradFill {attributes}><a:gsLst>{stops}</a:gsLst>{geometry}</a:gradFill>"
9580            );
9581            let fixture = Fixture::new(&fx206_recovery_shape(2, &gradient, "", false), "", "");
9582            let before = fixture.slide.clone();
9583            let resolved = fixture.context().resolve_slide((100.0, 100.0)).unwrap();
9584            assert_eq!(
9585                resolved.unresolved_member_count, 0,
9586                "attributes={attributes:?}; stops={stops}; geometry={geometry}; diagnostics={:?}",
9587                resolved.diagnostics
9588            );
9589            assert!(resolved.shapes.is_empty());
9590            assert!(resolved.diagnostics.is_empty());
9591            assert_eq!(fixture.slide, before);
9592        }
9593    }
9594
9595    #[test]
9596    fn fx206_recovery_supported_gradient_phclr_before_missing_stop_still_counts() {
9597        for placed in [false, true] {
9598            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>"#;
9599            let fixture = Fixture::new(&fx206_recovery_shape(2, gradient, "", placed), "", "");
9600            let before = fixture.slide.clone();
9601            let resolved = fixture.context().resolve_slide((100.0, 100.0)).unwrap();
9602            assert_eq!(resolved.unresolved_member_count, 1);
9603            assert_eq!(resolved.shapes.len(), usize::from(placed));
9604            assert_eq!(resolved.diagnostics.len(), 1);
9605            assert!(resolved.diagnostics[0].message.contains("phClr"));
9606            assert!(resolved.diagnostics[0].message.contains(if placed {
9607                "retained as bounds"
9608            } else {
9609                "omitted without trustworthy finite bounds"
9610            }));
9611            assert_eq!(fixture.slide, before);
9612        }
9613    }
9614
9615    #[test]
9616    fn fx206_background_gradient_shape_rotation_does_not_hide_phclr_paint() {
9617        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>"#;
9618        let fixture = Fixture::from_xml(
9619            &slide_xml_with("", background, ""),
9620            &layout_xml_with("", "", "", ""),
9621            &master_xml_with("", "", ""),
9622        );
9623        let before = fixture.slide.clone();
9624        let resolved = fixture.context().resolve_slide((40.0, 20.0)).unwrap();
9625        let Some(ResolvedBackground::Paint(Paint::Linear { stops, .. })) = resolved.background
9626        else {
9627            panic!("expected supported background gradient");
9628        };
9629        assert_eq!(stops.len(), 259);
9630        assert_eq!(stops.last().unwrap().color, Color::from_hex("0000FF"));
9631        assert_eq!(resolved.unresolved_member_count, 0);
9632        assert!(resolved.diagnostics.is_empty());
9633        assert_eq!(fixture.slide, before);
9634    }
9635
9636    #[test]
9637    fn fx206_gradient_rotation_policy_keeps_placed_reference_and_rotated_boundary() {
9638        for rotation in [0, 5_400_000] {
9639            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>"#;
9640            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>"#;
9641            let shape = fx206_recovery_shape(2, gradient, style, true)
9642                .replace("<a:xfrm>", &format!("<a:xfrm rot=\"{rotation}\">"));
9643            let fixture = Fixture::new(&shape, "", "");
9644            let before = fixture.slide.clone();
9645            let resolved = fixture.context().resolve_slide((100.0, 100.0)).unwrap();
9646            assert_eq!(resolved.unresolved_member_count, 0);
9647            assert_eq!(resolved.shapes.len(), 1);
9648            if rotation == 0 {
9649                let Some(Paint::Linear { stops, .. }) = &resolved.shapes[0].fill else {
9650                    panic!("expected supported unrotated gradient");
9651                };
9652                assert_eq!(stops.len(), 259);
9653                assert_eq!(stops[0].color, Color::from_hex("FF0000"));
9654                assert_eq!(stops.last().unwrap().color, Color::from_hex("0000FF"));
9655                assert!(resolved.diagnostics.is_empty());
9656            } else {
9657                assert!(resolved.shapes[0].fill.is_none());
9658                assert!(resolved.diagnostics.iter().any(|diagnostic| {
9659                    diagnostic
9660                        .message
9661                        .contains("gradient independent of shape rotation")
9662                }));
9663            }
9664            assert_eq!(fixture.slide, before);
9665        }
9666    }
9667}