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::{
38    BackgroundRendering, CT_HeaderFooter, CT_Slide, CT_SlideLayout, CT_SlideMaster,
39};
40
41use crate::ResolveError;
42use crate::style::{referenced_fill, substitute_fill};
43use crate::text::EffectiveListStyle;
44use crate::timeline::{
45    EvaluatedFrameState, EvaluatedShapeState, ResolvedShapeIdentity, ResolvedTimelineSlide,
46    TimelinePosition, evaluate_timeline,
47};
48use crate::{
49    ChartResource, ParagraphAlignment, ResolvedAutofit, ResolvedBackground, ResolvedBullet,
50    ResolvedBulletSize, ResolvedContent, ResolvedGeometry, ResolvedImage, ResolvedImagePlacement,
51    ResolvedLineEnd, ResolvedLineEndKind, ResolvedLineEndSize, ResolvedParagraph,
52    ResolvedRectAlignment, ResolvedRunStyle, ResolvedShape, ResolvedSlide,
53    ResolvedSlideTextDirections, ResolvedTable, ResolvedTableBorder, ResolvedTableCell,
54    ResolvedTableRow, ResolvedTextBody, ResolvedTextRun, ResolvedTextSpacing, ResolvedTileFlip,
55    ResolvedTilePlacement, ScopedChartResources, ScopedHyperlinkTargets, ScopedMediaFailures,
56    ScopedMediaIds, TextAnchor, TextDirection, TextInsets,
57};
58
59/// The producer part that supplied the effective background.
60#[derive(Clone, Copy, Debug, Eq, PartialEq)]
61pub enum BackgroundSource {
62    Slide,
63    Layout,
64    Master,
65}
66
67/// The borrowed background payload selected for one slide.
68#[derive(Clone, Copy, Debug, PartialEq)]
69pub enum BackgroundContent<'a> {
70    Model(&'a BackgroundRendering),
71}
72
73/// The effective background and the per-master colour map used to resolve it.
74#[derive(Clone, Copy, Debug, PartialEq)]
75pub struct EffectiveBackground<'a> {
76    pub source: BackgroundSource,
77    pub content: BackgroundContent<'a>,
78    pub color_map: &'a ColorMap,
79}
80
81/// The four ordered sources in a flattened slide view.
82#[derive(Clone, Copy, Debug, Eq, PartialEq)]
83pub enum FlattenedSource {
84    Background,
85    Master,
86    Layout,
87    Slide,
88}
89
90/// One borrowed entry in final draw order.
91#[derive(Clone, Copy, Debug, PartialEq)]
92pub enum FlattenedItem<'a> {
93    Background(EffectiveBackground<'a>),
94    Shape {
95        source: FlattenedSource,
96        child: &'a ShapeTreeChild,
97        /// Coordinate scale accumulated from every non-shearing parent group.
98        group_scale: (f64, f64),
99        /// Rigid mapping accumulated from every non-shearing parent group.
100        group_transform: Transform,
101        /// Stable diagnostic flags accumulated from parent groups.
102        group_issues: u8,
103    },
104}
105
106const GROUP_ZERO_X: u8 = 1;
107const GROUP_ZERO_Y: u8 = 2;
108const GROUP_SHEAR: u8 = 4;
109
110#[derive(Clone, Copy)]
111struct ShapePlacement {
112    source: FlattenedSource,
113    group_scale: (f64, f64),
114    group_transform: Transform,
115}
116
117impl FlattenedItem<'_> {
118    pub const fn source(&self) -> FlattenedSource {
119        match self {
120            Self::Background(_) => FlattenedSource::Background,
121            Self::Shape { source, .. } => *source,
122        }
123    }
124}
125
126/// The fixed presentation hierarchy and theme inputs for resolving one slide.
127pub struct ResolveCtx<'a> {
128    pub theme: &'a CT_OfficeStyleSheet,
129    pub color_map: ColorMap,
130    pub master: &'a CT_SlideMaster,
131    pub layout: &'a CT_SlideLayout,
132    pub slide: &'a CT_Slide,
133    pub default_text_style: &'a CT_TextListStyle,
134    pub table_styles: Option<&'a CT_TableStyleList>,
135    pub(crate) list_style_cache: RefCell<HashMap<Option<PlaceholderKey>, EffectiveListStyle>>,
136    media_poster_diagnostic_identity: bool,
137    media_failures: Option<&'a ScopedMediaFailures>,
138}
139
140impl<'a> ResolveCtx<'a> {
141    pub fn new(
142        theme: &'a CT_OfficeStyleSheet,
143        color_map: ColorMap,
144        master: &'a CT_SlideMaster,
145        layout: &'a CT_SlideLayout,
146        slide: &'a CT_Slide,
147        default_text_style: &'a CT_TextListStyle,
148    ) -> Self {
149        Self {
150            theme,
151            color_map,
152            master,
153            layout,
154            slide,
155            default_text_style,
156            table_styles: None,
157            list_style_cache: RefCell::new(HashMap::new()),
158            media_poster_diagnostic_identity: false,
159            media_failures: None,
160        }
161    }
162
163    /// Adds the optional `ppt/tableStyles.xml` projection used by table frames.
164    pub fn with_table_styles(mut self, table_styles: &'a CT_TableStyleList) -> Self {
165        self.table_styles = Some(table_styles);
166        self
167    }
168
169    /// Enables private source identity used while applying media fallback policy.
170    #[doc(hidden)]
171    pub fn with_media_poster_diagnostic_identity(mut self) -> Self {
172        self.media_poster_diagnostic_identity = true;
173        self
174    }
175
176    /// Adds renderer-owned failures for media rejected before layout.
177    #[doc(hidden)]
178    pub fn with_media_failures(mut self, failures: &'a ScopedMediaFailures) -> Self {
179        self.media_failures = Some(failures);
180        self
181    }
182
183    pub(crate) fn placeholder_chain<'ctx>(
184        &'ctx self,
185        shape: &CT_Shape,
186    ) -> (Option<&'ctx CT_Shape>, Option<&'ctx CT_Shape>) {
187        self.placeholder_chain_for(shape.placeholder.as_ref())
188    }
189
190    fn placeholder_chain_for<'ctx>(
191        &'ctx self,
192        placeholder: Option<&CT_Placeholder>,
193    ) -> (Option<&'ctx CT_Shape>, Option<&'ctx CT_Shape>) {
194        placeholder.map_or((None, None), |placeholder| {
195            placeholder_chain(placeholder, self.layout, self.master)
196        })
197    }
198
199    fn is_slide_number_placeholder(&self, shape: &CT_Shape) -> bool {
200        if shape
201            .placeholder
202            .as_ref()
203            .is_some_and(|placeholder| placeholder.ph_type == Some(PhType::SlideNumber))
204        {
205            return true;
206        }
207        let (layout, master) = self.placeholder_chain(shape);
208        [layout, master].into_iter().flatten().any(|shape| {
209            shape
210                .placeholder
211                .as_ref()
212                .is_some_and(|placeholder| placeholder.ph_type == Some(PhType::SlideNumber))
213        })
214    }
215
216    /// Resolves an owned transform from the slide, layout, then master shape.
217    pub fn effective_xfrm(&self, shape: &CT_Shape) -> Option<CT_Transform2D> {
218        shape.shape_properties.transform.clone().or_else(|| {
219            inherited_xfrm(shape.placeholder.as_ref()?, self.layout, self.master).cloned()
220        })
221    }
222
223    /// Resolves picture bounds from the slide picture, layout placeholder, then master placeholder.
224    pub fn effective_picture_xfrm(
225        &self,
226        picture: &rpptx_oxml::picture::CT_Picture,
227    ) -> Option<CT_Transform2D> {
228        picture.shape_properties.transform.clone().or_else(|| {
229            inherited_xfrm(picture.placeholder.as_ref()?, self.layout, self.master).cloned()
230        })
231    }
232
233    /// Resolves body properties per field over defaults, master, layout, and slide.
234    pub fn effective_body_pr(&self, shape: &CT_Shape) -> CT_TextBodyProperties {
235        let (layout, master) = self.placeholder_chain(shape);
236        let mut effective = default_body_properties();
237        for source in [master, layout, Some(shape)].into_iter().flatten() {
238            if let Some(text_body) = &source.text_body {
239                merge_body_properties(&mut effective, &text_body.body_properties);
240            }
241        }
242        effective
243    }
244
245    /// Selects the first background in slide, layout, and master order.
246    pub fn effective_background(&self) -> Option<EffectiveBackground<'_>> {
247        for (source, background) in [
248            (
249                BackgroundSource::Slide,
250                self.slide.common_slide_data.background.as_ref(),
251            ),
252            (
253                BackgroundSource::Layout,
254                self.layout.common_slide_data.background.as_ref(),
255            ),
256            (
257                BackgroundSource::Master,
258                self.master.common_slide_data.background.as_ref(),
259            ),
260        ] {
261            if let Some(background) = background {
262                return Some(EffectiveBackground {
263                    source,
264                    content: BackgroundContent::Model(background.rendering()),
265                    color_map: &self.color_map,
266                });
267            }
268        }
269        None
270    }
271
272    /// Returns background and shape-tree leaves in final draw order.
273    pub fn flatten(&self) -> Vec<FlattenedItem<'_>> {
274        let slide_children = &self.slide.common_slide_data.shape_tree.children;
275        let layout_children = &self.layout.common_slide_data.shape_tree.children;
276        let master_children = &self.master.common_slide_data.shape_tree.children;
277        let mut slide_latent = Vec::new();
278        let mut layout_latent = Vec::new();
279        collect_occupied_latent(slide_children, &mut slide_latent);
280        collect_occupied_latent(layout_children, &mut layout_latent);
281
282        let mut flattened = Vec::new();
283        if let Some(background) = self.effective_background() {
284            flattened.push(FlattenedItem::Background(background));
285        }
286        let master_latent_policy = self
287            .master
288            .header_footer
289            .as_ref()
290            .map(LatentPolicy::from_header_footer);
291        let layout_latent_policy = self
292            .layout
293            .header_footer
294            .as_ref()
295            .map(LatentPolicy::from_header_footer);
296        let mut master_deeper = layout_latent.clone();
297        master_deeper.extend(slide_latent.iter().cloned());
298        emit_tree(
299            master_children,
300            PassRules {
301                source: FlattenedSource::Master,
302                emit_non_placeholders: self.layout.show_master_shapes.unwrap_or(true),
303                deeper_latent: &master_deeper,
304                latent_policy: master_latent_policy,
305            },
306            &mut flattened,
307        );
308        emit_tree(
309            layout_children,
310            PassRules {
311                source: FlattenedSource::Layout,
312                emit_non_placeholders: self.slide.show_master_shapes.unwrap_or(true),
313                deeper_latent: &slide_latent,
314                latent_policy: layout_latent_policy,
315            },
316            &mut flattened,
317        );
318        emit_tree(
319            slide_children,
320            PassRules {
321                source: FlattenedSource::Slide,
322                emit_non_placeholders: true,
323                deeper_latent: &[],
324                latent_policy: None,
325            },
326            &mut flattened,
327        );
328        flattened
329    }
330
331    /// Resolves one owned renderer-facing slide at the supplied point size.
332    pub fn resolve_slide(&self, size: (f64, f64)) -> Result<ResolvedSlide, ResolveError> {
333        self.resolve_slide_inner(size, None, None, None, None)
334            .map(|(slide, _)| slide)
335    }
336
337    /// Resolves one slide through the additive slide-local timeline path.
338    pub fn resolve_slide_at(
339        &self,
340        size: (f64, f64),
341        position: TimelinePosition,
342    ) -> Result<ResolvedTimelineSlide, ResolveError> {
343        let (slide, _, identities) =
344            self.resolve_slide_inner_with_identities(size, None, None, None, None)?;
345        let state = evaluate_timeline(
346            self.slide.timing.as_ref(),
347            self.slide.transition.as_ref(),
348            position,
349        )?;
350        Ok(apply_timeline_state(
351            slide,
352            identities,
353            state,
354            timeline_group_geometries(&self.slide.common_slide_data.shape_tree.children),
355        ))
356    }
357
358    /// Resolves one slide using embedded media identifiers scoped to source parts.
359    pub fn resolve_slide_with_media(
360        &self,
361        size: (f64, f64),
362        media: &ScopedMediaIds,
363    ) -> Result<ResolvedSlide, ResolveError> {
364        self.resolve_slide_inner(size, Some(media), None, None, None)
365            .map(|(slide, _)| slide)
366    }
367
368    /// Resolves one slide with source-scoped media and external hyperlink targets.
369    pub fn resolve_slide_with_resources(
370        &self,
371        size: (f64, f64),
372        media: &ScopedMediaIds,
373        hyperlinks: &ScopedHyperlinkTargets,
374    ) -> Result<ResolvedSlide, ResolveError> {
375        self.resolve_slide_inner(size, Some(media), Some(hyperlinks), None, None)
376            .map(|(slide, _)| slide)
377    }
378
379    /// Resolves one slide with source-scoped charts and the caller's font manager.
380    pub fn resolve_slide_with_chart_resources(
381        &self,
382        size: (f64, f64),
383        media: &ScopedMediaIds,
384        hyperlinks: &ScopedHyperlinkTargets,
385        charts: &ScopedChartResources,
386        fonts: &mut FontManager,
387    ) -> Result<ResolvedSlide, ResolveError> {
388        self.resolve_slide_inner(
389            size,
390            Some(media),
391            Some(hyperlinks),
392            Some(charts),
393            Some(fonts),
394        )
395        .map(|(slide, _)| slide)
396    }
397
398    /// Resolves one slide and returns paragraph directions in shape and text-body order.
399    ///
400    /// The outer vector follows `ResolvedSlide::shapes`. Each shape entry contains
401    /// one paragraph-direction vector for a text shape, or one entry per table cell
402    /// in row-major order. Non-text shapes have an empty entry.
403    pub fn resolve_slide_with_chart_resources_and_text_directions(
404        &self,
405        size: (f64, f64),
406        media: &ScopedMediaIds,
407        hyperlinks: &ScopedHyperlinkTargets,
408        charts: &ScopedChartResources,
409        fonts: &mut FontManager,
410    ) -> Result<(ResolvedSlide, ResolvedSlideTextDirections), ResolveError> {
411        self.resolve_slide_inner(
412            size,
413            Some(media),
414            Some(hyperlinks),
415            Some(charts),
416            Some(fonts),
417        )
418    }
419
420    /// Resolves one timestamped slide while retaining text directions and target identity.
421    pub fn resolve_slide_with_chart_resources_and_text_directions_at(
422        &self,
423        size: (f64, f64),
424        media: &ScopedMediaIds,
425        hyperlinks: &ScopedHyperlinkTargets,
426        charts: &ScopedChartResources,
427        fonts: &mut FontManager,
428        position: TimelinePosition,
429    ) -> Result<(ResolvedTimelineSlide, ResolvedSlideTextDirections), ResolveError> {
430        let (slide, directions, identities) = self.resolve_slide_inner_with_identities(
431            size,
432            Some(media),
433            Some(hyperlinks),
434            Some(charts),
435            Some(fonts),
436        )?;
437        let state = evaluate_timeline(
438            self.slide.timing.as_ref(),
439            self.slide.transition.as_ref(),
440            position,
441        )?;
442        Ok((
443            apply_timeline_state(
444                slide,
445                identities,
446                state,
447                timeline_group_geometries(&self.slide.common_slide_data.shape_tree.children),
448            ),
449            directions,
450        ))
451    }
452
453    fn resolve_slide_inner(
454        &self,
455        size: (f64, f64),
456        media: Option<&ScopedMediaIds>,
457        hyperlinks: Option<&ScopedHyperlinkTargets>,
458        charts: Option<&ScopedChartResources>,
459        fonts: Option<&mut FontManager>,
460    ) -> Result<(ResolvedSlide, ResolvedSlideTextDirections), ResolveError> {
461        self.resolve_slide_inner_common(size, media, hyperlinks, charts, fonts, None)
462    }
463
464    fn resolve_slide_inner_with_identities(
465        &self,
466        size: (f64, f64),
467        media: Option<&ScopedMediaIds>,
468        hyperlinks: Option<&ScopedHyperlinkTargets>,
469        charts: Option<&ScopedChartResources>,
470        fonts: Option<&mut FontManager>,
471    ) -> Result<
472        (
473            ResolvedSlide,
474            ResolvedSlideTextDirections,
475            Vec<ResolvedShapeIdentity>,
476        ),
477        ResolveError,
478    > {
479        let mut identities = Vec::new();
480        let (slide, directions) = self.resolve_slide_inner_common(
481            size,
482            media,
483            hyperlinks,
484            charts,
485            fonts,
486            Some(&mut identities),
487        )?;
488        Ok((slide, directions, identities))
489    }
490
491    fn resolve_slide_inner_common(
492        &self,
493        size: (f64, f64),
494        media: Option<&ScopedMediaIds>,
495        hyperlinks: Option<&ScopedHyperlinkTargets>,
496        charts: Option<&ScopedChartResources>,
497        mut fonts: Option<&mut FontManager>,
498        mut identities: Option<&mut Vec<ResolvedShapeIdentity>>,
499    ) -> Result<(ResolvedSlide, ResolvedSlideTextDirections), ResolveError> {
500        let mut slide = ResolvedSlide {
501            size,
502            background: None,
503            shapes: Vec::new(),
504            diagnostics: Vec::new(),
505        };
506        let mut text_directions = Vec::new();
507        for item in self.flatten() {
508            match item {
509                FlattenedItem::Background(background) => {
510                    let source = match background.source {
511                        BackgroundSource::Slide => FlattenedSource::Slide,
512                        BackgroundSource::Layout => FlattenedSource::Layout,
513                        BackgroundSource::Master => FlattenedSource::Master,
514                    };
515                    let (resolved_background, unsupported) = match background.content {
516                        BackgroundContent::Model(BackgroundRendering::Properties(fill)) => {
517                            match fill {
518                                Some(fill) => self.concrete_background_fill(
519                                    fill,
520                                    size,
521                                    source,
522                                    media,
523                                    &mut slide.diagnostics,
524                                )?,
525                                None => (None, None),
526                            }
527                        }
528                        BackgroundContent::Model(BackgroundRendering::Reference {
529                            index,
530                            color,
531                        }) => self.concrete_background_reference(
532                            *index,
533                            color.as_ref(),
534                            size,
535                            &mut slide.diagnostics,
536                        )?,
537                        BackgroundContent::Model(BackgroundRendering::Unsupported(detail)) => {
538                            (None, Some(*detail))
539                        }
540                    };
541                    slide.background = resolved_background;
542                    if let Some(unsupported) = unsupported {
543                        slide.diagnostics.push(Diagnostic {
544                            message: format!("unsupported background {unsupported}"),
545                        });
546                    }
547                }
548                FlattenedItem::Shape {
549                    source,
550                    child,
551                    group_scale,
552                    group_transform,
553                    group_issues,
554                } => {
555                    push_group_diagnostics(group_issues, &mut slide.diagnostics);
556                    let mut shape_text_directions = Vec::new();
557                    if let Some(shape) = self.resolve_flattened_shape(
558                        ShapePlacement {
559                            source,
560                            group_scale,
561                            group_transform,
562                        },
563                        child,
564                        media,
565                        (hyperlinks, charts),
566                        fonts.as_deref_mut(),
567                        (&mut slide.diagnostics, &mut shape_text_directions),
568                    )? {
569                        if let Some(identities) = identities.as_deref_mut() {
570                            identities.push(self.shape_identity(source, child));
571                        }
572                        slide.shapes.push(shape);
573                        text_directions.push(shape_text_directions);
574                    }
575                }
576            }
577        }
578        Ok((slide, text_directions))
579    }
580
581    fn shape_identity(
582        &self,
583        source: FlattenedSource,
584        child: &ShapeTreeChild,
585    ) -> ResolvedShapeIdentity {
586        let children: &[ShapeTreeChild] = match source {
587            FlattenedSource::Slide => &self.slide.common_slide_data.shape_tree.children,
588            FlattenedSource::Layout => &self.layout.common_slide_data.shape_tree.children,
589            FlattenedSource::Master => &self.master.common_slide_data.shape_tree.children,
590            FlattenedSource::Background => &[],
591        };
592        let mut containing_group_ids = Vec::new();
593        find_group_lineage(children, child, &mut Vec::new(), &mut containing_group_ids);
594        ResolvedShapeIdentity {
595            source,
596            shape_id: child.non_visual_id(),
597            containing_group_ids,
598            name: child.non_visual_name(),
599        }
600    }
601
602    fn resolve_flattened_shape(
603        &self,
604        placement: ShapePlacement,
605        child: &ShapeTreeChild,
606        media: Option<&ScopedMediaIds>,
607        scoped_resources: (
608            Option<&ScopedHyperlinkTargets>,
609            Option<&ScopedChartResources>,
610        ),
611        fonts: Option<&mut FontManager>,
612        output: (
613            &mut Vec<Diagnostic>,
614            &mut Vec<Vec<oxml_layout::TextDirection>>,
615        ),
616    ) -> Result<Option<ResolvedShape>, ResolveError> {
617        let (diagnostics, text_directions) = output;
618        let (hyperlinks, charts) = scoped_resources;
619        let ShapePlacement {
620            source,
621            group_scale,
622            group_transform,
623        } = placement;
624        match child {
625            ShapeTreeChild::Shape(shape) => self.resolve_ordinary_shape(
626                shape,
627                placement,
628                media,
629                hyperlinks,
630                diagnostics,
631                text_directions,
632            ),
633            ShapeTreeChild::Picture(picture) => {
634                let Some((bounds, rotation_deg, flip_h, flip_v)) =
635                    transform_values(self.effective_picture_xfrm(picture).as_ref())
636                else {
637                    return Ok(None);
638                };
639                let bounds = scaled_group_bounds(bounds, group_scale);
640                let line_properties = picture.shape_properties.line.as_ref();
641                let line = line_properties
642                    .map(|line| self.concrete_line(line, (bounds.width, bounds.height)))
643                    .transpose()?
644                    .flatten();
645                let (head_end, tail_end) = line_properties
646                    .map(resolved_line_ends)
647                    .unwrap_or((None, None));
648                let shadow = picture
649                    .shape_properties
650                    .effects
651                    .as_ref()
652                    .and_then(|effects| effects.outer_shadow.as_ref())
653                    .map(|shadow| self.concrete_shadow(shadow))
654                    .transpose()?;
655                let (mut geometry, geometry_unsupported) =
656                    self.concrete_picture_geometry(picture, (bounds.width, bounds.height));
657                if let Some(category) = geometry_unsupported {
658                    diagnostics.push(Diagnostic {
659                        message: format!("unsupported {category} retained as picture bounds"),
660                    });
661                }
662                let diagnostic_start = diagnostics.len();
663                let (content, media_unsupported) = resolve_picture_content(
664                    picture,
665                    source,
666                    media,
667                    self.media_failures,
668                    diagnostics,
669                );
670                if media_unsupported.is_some() {
671                    geometry = ResolvedGeometry::BoundsFallback;
672                }
673                if self.media_poster_diagnostic_identity
674                    && media_unsupported.is_some()
675                    && picture.media.is_some()
676                    && let Some(shape_id) = child.non_visual_id()
677                    && let Some(diagnostic) = diagnostics.get_mut(diagnostic_start)
678                {
679                    diagnostic.message = format!(
680                        "unresolved {} media poster for shape {shape_id}: {}",
681                        flattened_source_name(source),
682                        diagnostic.message
683                    );
684                }
685                Ok(Some(ResolvedShape {
686                    group_transform,
687                    bounds,
688                    rotation_deg,
689                    flip_h,
690                    flip_v,
691                    geometry,
692                    fill: None,
693                    image_fill: None,
694                    line,
695                    head_end,
696                    tail_end,
697                    shadow,
698                    content,
699                    unsupported: media_unsupported.or(geometry_unsupported),
700                }))
701            }
702            ShapeTreeChild::GraphicFrame(frame) => {
703                let Some((bounds, rotation_deg, flip_h, flip_v)) =
704                    transform_values(Some(&frame.transform))
705                else {
706                    return Ok(None);
707                };
708                let bounds = scaled_group_bounds(bounds, group_scale);
709                let (content, unsupported, bounds_fallback) = match frame.graphic_data.payload() {
710                    GraphicDataPayload::Table(table) => (
711                        ResolvedContent::Table(self.resolve_table(
712                            table,
713                            source,
714                            hyperlinks,
715                            diagnostics,
716                            text_directions,
717                        )?),
718                        None,
719                        false,
720                    ),
721                    GraphicDataPayload::Chart(_) => self.resolve_chart_content(
722                        frame,
723                        None,
724                        source,
725                        bounds,
726                        media,
727                        charts,
728                        fonts,
729                        diagnostics,
730                    )?,
731                    GraphicDataPayload::SmartArt(_) => {
732                        (ResolvedContent::None, Some("SmartArt"), true)
733                    }
734                    GraphicDataPayload::Ole { preview, .. } => {
735                        if let Some(image) = resolve_ole_preview(
736                            preview.as_deref(),
737                            source,
738                            media,
739                            self.media_failures,
740                        ) {
741                            diagnostics.push(Diagnostic {
742                                message: "OLE object rendered as a static PNG preview. Embedded OLE interactivity is not rendered".to_owned(),
743                            });
744                            (
745                                ResolvedContent::Image(image),
746                                Some("embedded OLE interactivity"),
747                                false,
748                            )
749                        } else {
750                            (ResolvedContent::None, Some("OLE"), true)
751                        }
752                    }
753                    GraphicDataPayload::Other(_) => {
754                        (ResolvedContent::None, Some("unknown graphic frame"), true)
755                    }
756                };
757                if bounds_fallback {
758                    let category = unsupported.expect("bounds fallback has a category");
759                    diagnostics.push(Diagnostic {
760                        message: format!("unsupported {category} content retained as bounds"),
761                    });
762                }
763                Ok(Some(ResolvedShape {
764                    group_transform,
765                    bounds,
766                    rotation_deg,
767                    flip_h,
768                    flip_v,
769                    geometry: if bounds_fallback {
770                        ResolvedGeometry::BoundsFallback
771                    } else {
772                        ResolvedGeometry::Rectangle
773                    },
774                    fill: None,
775                    image_fill: None,
776                    line: None,
777                    head_end: None,
778                    tail_end: None,
779                    shadow: None,
780                    content,
781                    unsupported,
782                }))
783            }
784            ShapeTreeChild::Connector(connector) => {
785                let Some((bounds, rotation_deg, flip_h, flip_v)) =
786                    connector_transform_values(connector.shape_properties.transform.as_ref())
787                else {
788                    return Ok(None);
789                };
790                let bounds = scaled_group_bounds(bounds, group_scale);
791                let (fill, fill_unsupported) = connector
792                    .shape_properties
793                    .fill
794                    .as_ref()
795                    .map(|fill| self.concrete_fill(fill, (bounds.width, bounds.height)))
796                    .transpose()?
797                    .unwrap_or((None, None));
798                let has_line_source =
799                    connector.shape_properties.line.is_some() || connector.style().is_some();
800                let effective_line = self.effective_connector_line(connector)?;
801                let mut line = effective_line
802                    .as_ref()
803                    .map(|line| self.concrete_line(line, (bounds.width, bounds.height)))
804                    .transpose()?
805                    .flatten();
806                let (head_end, tail_end) = effective_line
807                    .as_ref()
808                    .map(resolved_line_ends)
809                    .unwrap_or((None, None));
810                // A connector's effects only add a shadow, so an effect the
811                // theme cannot resolve drops the shadow instead of the slide.
812                let shadow = self
813                    .effective_effects(connector.style(), &connector.shape_properties)
814                    .and_then(|effects| {
815                        effects
816                            .as_ref()
817                            .and_then(|effects| effects.outer_shadow.as_ref())
818                            .map(|shadow| self.concrete_shadow(shadow))
819                            .transpose()
820                    })
821                    .unwrap_or_else(|error| {
822                        diagnostics.push(Diagnostic {
823                            message: format!("connector effect not rendered: {error}"),
824                        });
825                        None
826                    });
827                let (geometry, geometry_unsupported, geometry_diagnostic) = if let Some(custom) =
828                    &connector.shape_properties.custom_geometry
829                {
830                    match self.concrete_custom_geometry(custom, (bounds.width, bounds.height)) {
831                        Ok(geometry) => (geometry, None, None),
832                        Err(error) => (
833                            ResolvedGeometry::BoundsFallback,
834                            Some("connector custom geometry evaluation"),
835                            Some(format!(
836                                "connector custom geometry evaluation failed: {error}; retained as shape bounds"
837                            )),
838                        ),
839                    }
840                } else if let Some(preset) = &connector.shape_properties.preset_geometry {
841                    {
842                        match self.concrete_preset_geometry(preset, (bounds.width, bounds.height)) {
843                            Ok(Some(geometry)) => (geometry, None, None),
844                            Ok(None) => (
845                                ResolvedGeometry::BoundsFallback,
846                                Some("unknown connector preset geometry"),
847                                Some(format!(
848                                    "unknown connector preset geometry `{}` retained as shape bounds",
849                                    preset.preset
850                                )),
851                            ),
852                            Err(error) => (
853                                ResolvedGeometry::BoundsFallback,
854                                Some("connector preset geometry evaluation"),
855                                Some(format!(
856                                    "connector preset geometry `{}` evaluation failed: {error}; retained as shape bounds",
857                                    preset.preset
858                                )),
859                            ),
860                        }
861                    }
862                } else {
863                    (
864                        ResolvedGeometry::BoundsFallback,
865                        Some("connector geometry"),
866                        Some("unsupported connector geometry retained as shape bounds".to_owned()),
867                    )
868                };
869                if let Some(category) = fill_unsupported {
870                    diagnostics.push(Diagnostic {
871                        message: format!(
872                            "unsupported connector {category} retained as shape bounds"
873                        ),
874                    });
875                }
876                if let Some(message) = geometry_diagnostic {
877                    diagnostics.push(Diagnostic { message });
878                }
879                let line_unsupported = (!has_line_source).then_some("connector line style");
880                if line_unsupported.is_some() {
881                    line = Some(Stroke::new(Paint::Solid(Color::BLACK), 1.0));
882                    diagnostics.push(Diagnostic {
883                        message: "unsupported connector line style retained as visible default"
884                            .to_owned(),
885                    });
886                }
887                let unsupported = fill_unsupported
888                    .or(geometry_unsupported)
889                    .or(line_unsupported);
890                Ok(Some(ResolvedShape {
891                    group_transform,
892                    bounds,
893                    rotation_deg,
894                    flip_h,
895                    flip_v,
896                    geometry,
897                    fill,
898                    image_fill: None,
899                    line,
900                    head_end,
901                    tail_end,
902                    shadow,
903                    content: ResolvedContent::None,
904                    unsupported,
905                }))
906            }
907            ShapeTreeChild::AlternateContent(alternate) => {
908                let Some(frame) = alternate.chart_choice() else {
909                    return Ok(None);
910                };
911                let Some((bounds, rotation_deg, flip_h, flip_v)) =
912                    transform_values(Some(&frame.transform))
913                else {
914                    return Ok(None);
915                };
916                let bounds = scaled_group_bounds(bounds, group_scale);
917                let (content, unsupported, bounds_fallback) = if charts.is_some() && fonts.is_some()
918                {
919                    self.resolve_chart_content(
920                        frame,
921                        alternate.picture_fallback(),
922                        source,
923                        bounds,
924                        media,
925                        charts,
926                        fonts,
927                        diagnostics,
928                    )?
929                } else if let Some(picture) = alternate.picture_fallback() {
930                    let (content, _) = resolve_picture_content(
931                        picture,
932                        source,
933                        media,
934                        self.media_failures,
935                        diagnostics,
936                    );
937                    let has_image = matches!(content, ResolvedContent::Image(_));
938                    (content, Some("chart"), !has_image)
939                } else {
940                    (ResolvedContent::None, Some("chart"), true)
941                };
942                if bounds_fallback {
943                    diagnostics.push(Diagnostic {
944                        message: "unsupported chart content retained as bounds".to_owned(),
945                    });
946                }
947                Ok(Some(ResolvedShape {
948                    group_transform,
949                    bounds,
950                    rotation_deg,
951                    flip_h,
952                    flip_v,
953                    geometry: if bounds_fallback {
954                        ResolvedGeometry::BoundsFallback
955                    } else {
956                        ResolvedGeometry::Rectangle
957                    },
958                    fill: None,
959                    image_fill: None,
960                    line: None,
961                    head_end: None,
962                    tail_end: None,
963                    shadow: None,
964                    content,
965                    unsupported,
966                }))
967            }
968            ShapeTreeChild::GroupShape(_) => Ok(None),
969        }
970    }
971
972    #[allow(clippy::too_many_arguments)]
973    fn resolve_chart_content(
974        &self,
975        frame: &rpptx_oxml::graphic_frame::CT_GraphicFrame,
976        fallback: Option<&CT_Picture>,
977        source: FlattenedSource,
978        bounds: Rect,
979        media: Option<&ScopedMediaIds>,
980        charts: Option<&ScopedChartResources>,
981        fonts: Option<&mut FontManager>,
982        diagnostics: &mut Vec<Diagnostic>,
983    ) -> Result<(ResolvedContent, Option<&'static str>, bool), ResolveError> {
984        let Some(charts) = charts else {
985            return Ok((ResolvedContent::None, Some("chart"), true));
986        };
987        let Some(fonts) = fonts else {
988            return Ok((ResolvedContent::None, Some("chart"), true));
989        };
990        let relationship_id = frame.chart_relationship_id();
991        let resource = relationship_id.and_then(|id| charts.get(source, id));
992        let failure = match (relationship_id, resource) {
993            (None, _) => "chart payload has no relationship identifier".to_owned(),
994            (Some(id), None) => format!(
995                "missing {} chart relationship `{id}`",
996                flattened_source_name(source)
997            ),
998            (Some(_), Some(ChartResource::External(target))) => format!(
999                "external {} chart relationship `{}` targets `{target}`",
1000                flattened_source_name(source),
1001                relationship_id.expect("matched a relationship id")
1002            ),
1003            (Some(_), Some(ChartResource::MissingTarget(target))) => format!(
1004                "missing {} chart target `{target}` for relationship `{}`",
1005                flattened_source_name(source),
1006                relationship_id.expect("matched a relationship id")
1007            ),
1008            (Some(_), Some(ChartResource::Invalid(detail))) => format!(
1009                "invalid {} chart relationship `{}`: {detail}",
1010                flattened_source_name(source),
1011                relationship_id.expect("matched a relationship id")
1012            ),
1013            (Some(_), Some(ChartResource::Parsed(chart_space))) => {
1014                let local_bounds = Rect {
1015                    x: 0.0,
1016                    y: 0.0,
1017                    width: bounds.width,
1018                    height: bounds.height,
1019                };
1020                match render_chart(
1021                    &chart_space.chart,
1022                    local_bounds,
1023                    self.theme,
1024                    &self.color_map,
1025                    fonts,
1026                ) {
1027                    Ok(group) => {
1028                        return Ok((ResolvedContent::Group(group), None, false));
1029                    }
1030                    Err(error) => error.to_string(),
1031                }
1032            }
1033        };
1034
1035        if let Some(picture) = fallback {
1036            let (content, _) =
1037                resolve_picture_content(picture, source, media, self.media_failures, diagnostics);
1038            let relationship_id = picture
1039                .blip_fill
1040                .as_ref()
1041                .and_then(|fill| fill.blip.as_ref())
1042                .and_then(|blip| blip.embed.as_deref());
1043            let renderer_compatible = relationship_id
1044                .and_then(|id| media.and_then(|media| media.content_type(source, id)))
1045                .is_some_and(renderer_compatible_image_content_type);
1046            if matches!(content, ResolvedContent::Image(_)) && renderer_compatible {
1047                diagnostics.push(Diagnostic {
1048                    message: format!("unsupported chart rendered as cached image: {failure}"),
1049                });
1050                return Ok((content, Some("chart"), false));
1051            }
1052            if matches!(content, ResolvedContent::Image(_)) {
1053                diagnostics.push(Diagnostic {
1054                    message: format!(
1055                        "unsupported chart cached image is not renderer-compatible: {failure}"
1056                    ),
1057                });
1058            }
1059        }
1060
1061        let placeholder = render_chart_placeholder(
1062            Rect {
1063                x: 0.0,
1064                y: 0.0,
1065                width: bounds.width,
1066                height: bounds.height,
1067            },
1068            fonts,
1069        )
1070        .map_err(|error| ResolveError::ConcreteValue {
1071            kind: "chart placeholder",
1072            detail: error.to_string(),
1073        })?;
1074        diagnostics.push(Diagnostic {
1075            message: format!("unsupported chart rendered as labelled placeholder: {failure}"),
1076        });
1077        Ok((ResolvedContent::Group(placeholder), Some("chart"), true))
1078    }
1079
1080    fn resolve_ordinary_shape(
1081        &self,
1082        shape: &CT_Shape,
1083        placement: ShapePlacement,
1084        media: Option<&ScopedMediaIds>,
1085        hyperlinks: Option<&ScopedHyperlinkTargets>,
1086        diagnostics: &mut Vec<Diagnostic>,
1087        text_directions: &mut Vec<Vec<oxml_layout::TextDirection>>,
1088    ) -> Result<Option<ResolvedShape>, ResolveError> {
1089        let ShapePlacement {
1090            source,
1091            group_scale,
1092            group_transform,
1093        } = placement;
1094        let transform = self.effective_xfrm(shape);
1095        let Some((unscaled_bounds, rotation_deg, flip_h, flip_v)) =
1096            transform_values(transform.as_ref())
1097        else {
1098            return Ok(None);
1099        };
1100        let mut bounds = scaled_group_bounds(unscaled_bounds, group_scale);
1101        let mut group_transform = group_transform;
1102        let effective = self.effective_shape_style(shape)?;
1103        let (fill, image_fill, fill_unsupported, fill_diagnostic) = match effective.fill.as_ref() {
1104            Some(Fill::Blip(fill))
1105                if matches!(shape.shape_properties.fill, Some(Fill::Blip(_))) =>
1106            {
1107                match resolve_image_fill(fill, source, media, self.media_failures, "shape") {
1108                    Ok(image) => (None, Some(image), None, None),
1109                    Err((category, message)) => (None, None, Some(category), Some(message)),
1110                }
1111            }
1112            Some(Fill::Blip(_)) => (
1113                None,
1114                None,
1115                Some("theme-referenced shape picture fill"),
1116                Some(
1117                    "theme-referenced shape picture fill requires theme relationship scope"
1118                        .to_owned(),
1119                ),
1120            ),
1121            Some(fill) => {
1122                let normalized = match fill {
1123                    Fill::Gradient(gradient)
1124                        if gradient.rotate_with_shape == Some(false)
1125                            && rotation_deg.rem_euclid(360.0) == 0.0
1126                            && !flip_h
1127                            && !flip_v
1128                            && group_transform.a > 0.0
1129                            && group_transform.b.abs() < 1.0e-10
1130                            && group_transform.c.abs() < 1.0e-10
1131                            && group_transform.d > 0.0 =>
1132                    {
1133                        let mut normalized = fill.clone();
1134                        let Fill::Gradient(gradient) = &mut normalized else {
1135                            unreachable!("matched a gradient fill")
1136                        };
1137                        gradient.rotate_with_shape = None;
1138                        Some(normalized)
1139                    }
1140                    _ => None,
1141                };
1142                let (fill, unsupported) = self.concrete_fill(
1143                    normalized.as_ref().unwrap_or(fill),
1144                    (bounds.width, bounds.height),
1145                )?;
1146                (fill, None, unsupported, None)
1147            }
1148            None => (None, None, None, None),
1149        };
1150        let line = effective
1151            .line
1152            .as_ref()
1153            .map(|line| self.concrete_line(line, (bounds.width, bounds.height)))
1154            .transpose()?
1155            .flatten();
1156        let (head_end, tail_end) = effective
1157            .line
1158            .as_ref()
1159            .map(resolved_line_ends)
1160            .unwrap_or((None, None));
1161        let shadow = effective
1162            .effects
1163            .as_ref()
1164            .and_then(|effects| effects.outer_shadow.as_ref())
1165            .map(|shadow| self.concrete_shadow(shadow))
1166            .transpose()?;
1167        let transparent_text_only = shape.text_body.is_some()
1168            && fill.is_none()
1169            && image_fill.is_none()
1170            && fill_unsupported.is_none()
1171            && line.is_none()
1172            && shadow.is_none();
1173        if transparent_text_only {
1174            bounds = unscaled_bounds;
1175            group_transform = Transform {
1176                a: group_scale.0,
1177                d: group_scale.1,
1178                ..Transform::IDENTITY
1179            }
1180            .then(group_transform);
1181        }
1182        let (geometry, geometry_unsupported, geometry_diagnostic) = if let Some(custom) =
1183            &shape.shape_properties.custom_geometry
1184        {
1185            match self.concrete_custom_geometry(custom, (bounds.width, bounds.height)) {
1186                Ok(geometry) => (geometry, None, None),
1187                Err(error) => (
1188                    ResolvedGeometry::BoundsFallback,
1189                    Some("custom geometry evaluation"),
1190                    Some(format!(
1191                        "custom geometry evaluation failed: {error}; retained as shape bounds"
1192                    )),
1193                ),
1194            }
1195        } else if let Some(preset) = &shape.shape_properties.preset_geometry {
1196            match self.concrete_preset_geometry(preset, (bounds.width, bounds.height)) {
1197                Ok(Some(geometry)) => (geometry, None, None),
1198                Ok(None) => (
1199                    ResolvedGeometry::BoundsFallback,
1200                    Some("unknown preset geometry"),
1201                    Some(format!(
1202                        "unknown preset geometry `{}` retained as shape bounds",
1203                        preset.preset
1204                    )),
1205                ),
1206                Err(error) => (
1207                    ResolvedGeometry::BoundsFallback,
1208                    Some("preset geometry evaluation"),
1209                    Some(format!(
1210                        "preset geometry `{}` evaluation failed: {error}; retained as shape bounds",
1211                        preset.preset
1212                    )),
1213                ),
1214            }
1215        } else if shape.text_body.is_some() {
1216            (ResolvedGeometry::Rectangle, None, None)
1217        } else {
1218            (
1219                ResolvedGeometry::BoundsFallback,
1220                Some("preset geometry pending evaluation"),
1221                None,
1222            )
1223        };
1224        let content = if let Some(body) = shape.text_body.as_ref() {
1225            let (body, directions) =
1226                self.resolve_text_body(shape, body, source, hyperlinks, diagnostics)?;
1227            text_directions.push(directions);
1228            ResolvedContent::Text(body)
1229        } else {
1230            ResolvedContent::None
1231        };
1232        if let ResolvedContent::Text(text) = &content
1233            && let Some(message) = vertical_text_diagnostic(text.vertical)
1234        {
1235            diagnostics.push(Diagnostic {
1236                message: message.to_owned(),
1237            });
1238        }
1239        if let Some(category) = fill_unsupported {
1240            diagnostics.push(Diagnostic {
1241                message: fill_diagnostic
1242                    .unwrap_or_else(|| format!("unsupported {category} retained as shape bounds")),
1243            });
1244        }
1245        if let Some(category) = geometry_unsupported {
1246            diagnostics.push(Diagnostic {
1247                message: geometry_diagnostic
1248                    .unwrap_or_else(|| format!("unsupported {category} retained as shape bounds")),
1249            });
1250        }
1251        Ok(Some(ResolvedShape {
1252            group_transform,
1253            bounds,
1254            rotation_deg,
1255            flip_h,
1256            flip_v,
1257            geometry,
1258            fill,
1259            image_fill,
1260            line,
1261            head_end,
1262            tail_end,
1263            shadow,
1264            content,
1265            unsupported: fill_unsupported.or(geometry_unsupported),
1266        }))
1267    }
1268
1269    fn concrete_picture_geometry(
1270        &self,
1271        picture: &rpptx_oxml::picture::CT_Picture,
1272        size: (f64, f64),
1273    ) -> (ResolvedGeometry, Option<&'static str>) {
1274        if let Some(custom) = &picture.shape_properties.custom_geometry {
1275            return self
1276                .concrete_custom_geometry(custom, size)
1277                .map(|geometry| (geometry, None))
1278                .unwrap_or((ResolvedGeometry::Rectangle, Some("picture custom geometry")));
1279        }
1280        if let Some(preset) = &picture.shape_properties.preset_geometry {
1281            return match self.concrete_preset_geometry(preset, size) {
1282                Ok(Some(geometry)) => (geometry, None),
1283                Ok(None) => (
1284                    ResolvedGeometry::Rectangle,
1285                    Some("unknown picture preset geometry"),
1286                ),
1287                Err(_) => (
1288                    ResolvedGeometry::Rectangle,
1289                    Some("picture preset geometry evaluation"),
1290                ),
1291            };
1292        }
1293        (ResolvedGeometry::Rectangle, None)
1294    }
1295}
1296
1297fn renderer_compatible_image_content_type(content_type: &str) -> bool {
1298    content_type.eq_ignore_ascii_case("image/png")
1299        || content_type.eq_ignore_ascii_case("image/jpeg")
1300        || content_type.eq_ignore_ascii_case("image/jpg")
1301}
1302
1303fn resolve_picture_content(
1304    picture: &rpptx_oxml::picture::CT_Picture,
1305    source: FlattenedSource,
1306    media: Option<&ScopedMediaIds>,
1307    failures: Option<&ScopedMediaFailures>,
1308    diagnostics: &mut Vec<Diagnostic>,
1309) -> (ResolvedContent, Option<&'static str>) {
1310    let Some(fill) = picture.blip_fill.as_ref() else {
1311        return unsupported_picture(
1312            "alternate picture media",
1313            "alternate picture media is not resolved",
1314            diagnostics,
1315        );
1316    };
1317    match resolve_image_fill(fill, source, media, failures, "picture") {
1318        Ok(image) => (ResolvedContent::Image(image), None),
1319        Err((category, message)) => unsupported_picture(category, &message, diagnostics),
1320    }
1321}
1322
1323fn resolve_ole_preview(
1324    preview: Option<&rpptx_oxml::picture::CT_Picture>,
1325    source: FlattenedSource,
1326    media: Option<&ScopedMediaIds>,
1327    failures: Option<&ScopedMediaFailures>,
1328) -> Option<ResolvedImage> {
1329    let fill = preview?.blip_fill.as_ref()?;
1330    let relationship_id = fill.blip.as_ref()?.embed.as_deref()?;
1331    let media = media?;
1332    if !media
1333        .content_type(source, relationship_id)
1334        .is_some_and(|content_type| content_type.eq_ignore_ascii_case("image/png"))
1335    {
1336        return None;
1337    }
1338    resolve_image_fill(fill, source, Some(media), failures, "picture").ok()
1339}
1340
1341fn resolve_image_fill(
1342    fill: &BlipFill,
1343    source: FlattenedSource,
1344    media: Option<&ScopedMediaIds>,
1345    failures: Option<&ScopedMediaFailures>,
1346    role: &str,
1347) -> Result<ResolvedImage, (&'static str, String)> {
1348    let missing_blip_category = match role {
1349        "picture" => "missing picture blip",
1350        "shape" => "missing shape picture blip",
1351        _ => "missing background blip",
1352    };
1353    let external_media_category = match role {
1354        "picture" => "external picture media",
1355        "shape" => "external shape picture media",
1356        _ => "external background media",
1357    };
1358    let Some(blip) = fill.blip.as_ref() else {
1359        return Err((
1360            missing_blip_category,
1361            format!("{role} fill has no modelled embedded blip"),
1362        ));
1363    };
1364    let Some(relationship_id) = blip.embed.as_deref() else {
1365        let message = if let Some(link) = blip.link.as_deref() {
1366            format!("external {role} relationship `{link}` is unsupported")
1367        } else {
1368            format!("{role} blip has no embedded relationship")
1369        };
1370        return Err((external_media_category, message));
1371    };
1372    let Some(media) = media else {
1373        return Err((
1374            "image media pending relationship resolution",
1375            format!("{role} image media pending relationship resolution"),
1376        ));
1377    };
1378    if let Some(failure) = failures.and_then(|failures| failures.get(source, relationship_id)) {
1379        return Err((
1380            "image render failure",
1381            format!("{role} image relationship `{relationship_id}` {failure}"),
1382        ));
1383    }
1384    let Some(media_id) = media.get(source, relationship_id) else {
1385        return Err((
1386            "missing image relationship",
1387            format!(
1388                "missing {} {role} image relationship `{relationship_id}`",
1389                flattened_source_name(source)
1390            ),
1391        ));
1392    };
1393    Ok(ResolvedImage {
1394        media: media_id,
1395        src_rect: fill.source_rect.as_ref().map(resolved_crop_rect),
1396        placement: match fill.mode.as_ref() {
1397            Some(BlipMode::Tile(tile)) => ResolvedImagePlacement::Tile(ResolvedTilePlacement {
1398                translation: Point {
1399                    x: emu_to_points(tile.translation_x.unwrap_or(0)),
1400                    y: emu_to_points(tile.translation_y.unwrap_or(0)),
1401                },
1402                scale_x: tile
1403                    .scale_x
1404                    .map_or(1.0, |value| f64::from(value.0) / 100_000.0),
1405                scale_y: tile
1406                    .scale_y
1407                    .map_or(1.0, |value| f64::from(value.0) / 100_000.0),
1408                flip: resolved_tile_flip(tile.flip.as_deref()),
1409                alignment: resolved_rect_alignment(tile.alignment.as_deref()),
1410            }),
1411            Some(BlipMode::Stretch { fill_rect, .. }) => ResolvedImagePlacement::Stretch {
1412                fill_rect: fill_rect.as_ref().map(resolved_crop_rect),
1413            },
1414            None => ResolvedImagePlacement::default(),
1415        },
1416        dpi: fill.dpi.filter(|dpi| *dpi > 0).map(f64::from),
1417        rotate_with_shape: fill.rotate_with_shape.unwrap_or(true),
1418        opacity: blip
1419            .alpha_modulation_fix
1420            .map_or(1.0, |amount| f64::from(amount.0) / 100_000.0),
1421    })
1422}
1423
1424fn unsupported_picture(
1425    category: &'static str,
1426    message: &str,
1427    diagnostics: &mut Vec<Diagnostic>,
1428) -> (ResolvedContent, Option<&'static str>) {
1429    diagnostics.push(Diagnostic {
1430        message: message.to_owned(),
1431    });
1432    (ResolvedContent::None, Some(category))
1433}
1434
1435fn flattened_source_name(source: FlattenedSource) -> &'static str {
1436    match source {
1437        FlattenedSource::Background => "background",
1438        FlattenedSource::Master => "master",
1439        FlattenedSource::Layout => "layout",
1440        FlattenedSource::Slide => "slide",
1441    }
1442}
1443
1444fn resolve_direct_hyperlink(
1445    properties: Option<&CT_TextCharacterProperties>,
1446    source: FlattenedSource,
1447    hyperlinks: Option<&ScopedHyperlinkTargets>,
1448    diagnostics: &mut Vec<Diagnostic>,
1449) -> Option<String> {
1450    let hyperlink = properties?.hyperlink_click.as_ref()?;
1451    if let Some(action) = hyperlink.action.as_deref() {
1452        diagnostics.push(Diagnostic {
1453            message: format!(
1454                "unsupported {} hyperlink action `{action}`",
1455                flattened_source_name(source)
1456            ),
1457        });
1458        return None;
1459    }
1460    let Some(relationship_id) = hyperlink.relationship_id.as_deref() else {
1461        diagnostics.push(Diagnostic {
1462            message: format!(
1463                "unsupported {} hyperlink without an external relationship",
1464                flattened_source_name(source)
1465            ),
1466        });
1467        return None;
1468    };
1469    let Some(target) = hyperlinks.and_then(|targets| targets.get(source, relationship_id)) else {
1470        diagnostics.push(Diagnostic {
1471            message: format!(
1472                "missing {} hyperlink relationship `{relationship_id}`",
1473                flattened_source_name(source)
1474            ),
1475        });
1476        return None;
1477    };
1478    Some(target.to_owned())
1479}
1480
1481fn resolved_crop_rect(rect: &RelativeRect) -> crate::CropRect {
1482    crate::CropRect {
1483        left: rect
1484            .left
1485            .map_or(0.0, |value| f64::from(value.0) / 100_000.0),
1486        top: rect.top.map_or(0.0, |value| f64::from(value.0) / 100_000.0),
1487        right: rect
1488            .right
1489            .map_or(0.0, |value| f64::from(value.0) / 100_000.0),
1490        bottom: rect
1491            .bottom
1492            .map_or(0.0, |value| f64::from(value.0) / 100_000.0),
1493    }
1494}
1495
1496fn resolved_tile_flip(value: Option<&str>) -> ResolvedTileFlip {
1497    match value {
1498        Some("x") => ResolvedTileFlip::Horizontal,
1499        Some("y") => ResolvedTileFlip::Vertical,
1500        Some("xy") => ResolvedTileFlip::Both,
1501        Some("none") | None => ResolvedTileFlip::None,
1502        Some(_) => unreachable!("DrawingML tile flip is validated while parsing"),
1503    }
1504}
1505
1506fn resolved_rect_alignment(value: Option<&str>) -> ResolvedRectAlignment {
1507    match value {
1508        Some("t") => ResolvedRectAlignment::Top,
1509        Some("tr") => ResolvedRectAlignment::TopRight,
1510        Some("l") => ResolvedRectAlignment::Left,
1511        Some("ctr") => ResolvedRectAlignment::Center,
1512        Some("r") => ResolvedRectAlignment::Right,
1513        Some("bl") => ResolvedRectAlignment::BottomLeft,
1514        Some("b") => ResolvedRectAlignment::Bottom,
1515        Some("br") => ResolvedRectAlignment::BottomRight,
1516        Some("tl") | None => ResolvedRectAlignment::TopLeft,
1517        Some(_) => unreachable!("DrawingML rectangle alignment is validated while parsing"),
1518    }
1519}
1520
1521fn resolved_line_ends(
1522    line: &CT_LineProperties,
1523) -> (Option<ResolvedLineEnd>, Option<ResolvedLineEnd>) {
1524    (
1525        line.head_end.as_ref().and_then(resolved_line_end),
1526        line.tail_end.as_ref().and_then(resolved_line_end),
1527    )
1528}
1529
1530fn resolved_line_end(end: &LineEnd) -> Option<ResolvedLineEnd> {
1531    let kind = match end.kind? {
1532        LineEndType::None => return None,
1533        LineEndType::Triangle => ResolvedLineEndKind::Triangle,
1534        LineEndType::Stealth => ResolvedLineEndKind::Stealth,
1535        LineEndType::Diamond => ResolvedLineEndKind::Diamond,
1536        LineEndType::Oval => ResolvedLineEndKind::Oval,
1537        LineEndType::Arrow => ResolvedLineEndKind::Arrow,
1538    };
1539    Some(ResolvedLineEnd {
1540        kind,
1541        width: resolved_line_end_size(end.width),
1542        length: resolved_line_end_size(end.length),
1543    })
1544}
1545
1546fn resolved_line_end_size(size: Option<LineEndSize>) -> ResolvedLineEndSize {
1547    match size.unwrap_or(LineEndSize::Medium) {
1548        LineEndSize::Small => ResolvedLineEndSize::Small,
1549        LineEndSize::Medium => ResolvedLineEndSize::Medium,
1550        LineEndSize::Large => ResolvedLineEndSize::Large,
1551    }
1552}
1553
1554impl ResolveCtx<'_> {
1555    fn concrete_background_fill(
1556        &self,
1557        fill: &Fill,
1558        size: (f64, f64),
1559        source: FlattenedSource,
1560        media: Option<&ScopedMediaIds>,
1561        diagnostics: &mut Vec<Diagnostic>,
1562    ) -> Result<(Option<ResolvedBackground>, Option<&'static str>), ResolveError> {
1563        let mut fill = fill.clone();
1564        let slot = self.color_map.theme_slot(ColorMapSlot::Background1);
1565        let reference = self.theme.theme_elements.color_scheme.color(slot);
1566        substitute_fill(&mut fill, Some(reference), "background")?;
1567        self.concrete_background_value(&fill, size, source, media, diagnostics)
1568    }
1569
1570    fn concrete_background_reference(
1571        &self,
1572        index: u32,
1573        color: Option<&ColorChoice>,
1574        size: (f64, f64),
1575        diagnostics: &mut Vec<Diagnostic>,
1576    ) -> Result<(Option<ResolvedBackground>, Option<&'static str>), ResolveError> {
1577        let matrix = &self.theme.theme_elements.format_scheme;
1578        let Some(mut fill) =
1579            referenced_fill(index, &matrix.fill_styles, &matrix.background_fill_styles)?
1580        else {
1581            return Ok((None, None));
1582        };
1583        substitute_fill(&mut fill, color, "background")?;
1584        if matches!(fill, Fill::Blip(_)) {
1585            diagnostics.push(Diagnostic {
1586                message: "theme-referenced background picture requires theme relationship scope"
1587                    .to_owned(),
1588            });
1589            return Ok((None, None));
1590        }
1591        let (paint, unsupported) = self.concrete_background_paint(&fill, size)?;
1592        Ok((paint.map(ResolvedBackground::Paint), unsupported))
1593    }
1594
1595    fn concrete_background_value(
1596        &self,
1597        fill: &Fill,
1598        size: (f64, f64),
1599        source: FlattenedSource,
1600        media: Option<&ScopedMediaIds>,
1601        diagnostics: &mut Vec<Diagnostic>,
1602    ) -> Result<(Option<ResolvedBackground>, Option<&'static str>), ResolveError> {
1603        if let Fill::Blip(fill) = fill {
1604            return Ok(
1605                match resolve_image_fill(fill, source, media, self.media_failures, "background") {
1606                    Ok(image) => (Some(ResolvedBackground::Image(image)), None),
1607                    Err((_category, message)) => {
1608                        diagnostics.push(Diagnostic { message });
1609                        (None, None)
1610                    }
1611                },
1612            );
1613        }
1614        let (paint, unsupported) = self.concrete_background_paint(fill, size)?;
1615        Ok((paint.map(ResolvedBackground::Paint), unsupported))
1616    }
1617
1618    fn concrete_background_paint(
1619        &self,
1620        fill: &Fill,
1621        size: (f64, f64),
1622    ) -> Result<(Option<Paint>, Option<&'static str>), ResolveError> {
1623        let mut fill = fill.clone();
1624        if let Fill::Gradient(gradient) = &mut fill {
1625            gradient.rotate_with_shape = None;
1626        }
1627        self.concrete_fill(&fill, size)
1628    }
1629
1630    fn concrete_fill(
1631        &self,
1632        fill: &Fill,
1633        size: (f64, f64),
1634    ) -> Result<(Option<Paint>, Option<&'static str>), ResolveError> {
1635        match fill {
1636            Fill::NoFill(_) => Ok((None, None)),
1637            Fill::Solid(solid) => {
1638                let Some(choice) = solid.color.as_ref() else {
1639                    return Ok((None, Some("solid fill without colour")));
1640                };
1641                Ok((Some(Paint::Solid(self.concrete_color(choice)?)), None))
1642            }
1643            Fill::Gradient(gradient) => {
1644                if gradient.rotate_with_shape == Some(false) {
1645                    return Ok((None, Some("gradient independent of shape rotation")));
1646                }
1647                match gradient.flip.as_deref() {
1648                    Some("x") => return Ok((None, Some("horizontal gradient flip"))),
1649                    Some("y") => return Ok((None, Some("vertical gradient flip"))),
1650                    Some("xy") => return Ok((None, Some("horizontal and vertical gradient flip"))),
1651                    Some("none") | None => {}
1652                    Some(_) => unreachable!("DrawingML parser validates gradient flip tokens"),
1653                }
1654                if gradient.tile_rect.is_some() {
1655                    return Ok((None, Some("gradient tile rectangle")));
1656                }
1657                if let Some(GradientGeometry::Path(path)) = &gradient.geometry {
1658                    let unsupported = match path.kind {
1659                        PathGradientKind::Circle => "circle path gradient",
1660                        PathGradientKind::Rectangle => "rectangle path gradient",
1661                        PathGradientKind::Shape => "shape path gradient",
1662                    };
1663                    return Ok((None, Some(unsupported)));
1664                }
1665                let mut stops = Vec::with_capacity(gradient.stops.len());
1666                for stop in &gradient.stops {
1667                    let Some(choice) = stop.color.as_ref() else {
1668                        return Ok((None, Some("gradient stop without colour")));
1669                    };
1670                    stops.push(GradientStop {
1671                        offset: f64::from(stop.position.0) / 100_000.0,
1672                        color: self.concrete_color(choice)?,
1673                    });
1674                }
1675                if stops.is_empty() {
1676                    return Ok((None, Some("gradient without concrete stops")));
1677                }
1678                let paint = match &gradient.geometry {
1679                    Some(GradientGeometry::Linear(linear)) => {
1680                        let radians = f64::from(linear.angle.0) / 60_000.0_f64;
1681                        let radians = radians.to_radians();
1682                        let center = Point {
1683                            x: size.0 / 2.0,
1684                            y: size.1 / 2.0,
1685                        };
1686                        let mut direction = Point {
1687                            x: radians.cos(),
1688                            y: radians.sin(),
1689                        };
1690                        if linear.scaled == Some(true) {
1691                            direction.x *= size.0;
1692                            direction.y *= size.1;
1693                        }
1694                        let length = direction.x.hypot(direction.y);
1695                        if length > 0.0 {
1696                            direction.x /= length;
1697                            direction.y /= length;
1698                        }
1699                        let extent =
1700                            (direction.x.abs() * size.0 + direction.y.abs() * size.1) / 2.0;
1701                        let delta = Point {
1702                            x: direction.x * extent,
1703                            y: direction.y * extent,
1704                        };
1705                        Paint::linear(
1706                            Point {
1707                                x: center.x - delta.x,
1708                                y: center.y - delta.y,
1709                            },
1710                            Point {
1711                                x: center.x + delta.x,
1712                                y: center.y + delta.y,
1713                            },
1714                            stops,
1715                            (true, true),
1716                        )
1717                    }
1718                    None => Paint::linear(
1719                        Point { x: 0.0, y: 0.0 },
1720                        Point { x: size.0, y: 0.0 },
1721                        stops,
1722                        (true, true),
1723                    ),
1724                    Some(GradientGeometry::Path(_)) => unreachable!("rejected above"),
1725                };
1726                Ok((Some(paint), None))
1727            }
1728            Fill::Pattern(_) => Ok((None, Some("pattern fill"))),
1729            Fill::Blip(_) => Ok((None, Some("picture fill media"))),
1730        }
1731    }
1732
1733    fn concrete_line(
1734        &self,
1735        line: &CT_LineProperties,
1736        size: (f64, f64),
1737    ) -> Result<Option<Stroke>, ResolveError> {
1738        let Some(fill) = line.fill.as_ref() else {
1739            return Ok(None);
1740        };
1741        let (Some(paint), _) = self.concrete_fill(fill, size)? else {
1742            return Ok(None);
1743        };
1744        let width = emu_to_points(i64::from(line.width.unwrap_or(12_700)));
1745        let cap = match line.cap.unwrap_or(DrawingLineCap::Flat) {
1746            DrawingLineCap::Round => LineCap::Round,
1747            DrawingLineCap::Square => LineCap::Square,
1748            DrawingLineCap::Flat => LineCap::Butt,
1749        };
1750        let join = match line.join.as_ref() {
1751            Some(DrawingLineJoin::Round { .. }) => LineJoin::Round,
1752            Some(DrawingLineJoin::Bevel { .. }) => LineJoin::Bevel,
1753            Some(DrawingLineJoin::Miter { .. }) | None => LineJoin::Miter,
1754        };
1755        let dash = line.dash.as_ref().and_then(|dash| match dash {
1756            LineDash::Preset(preset) => {
1757                let values = preset
1758                    .value
1759                    .dash_array()
1760                    .iter()
1761                    .map(|value| f64::from(*value) * width)
1762                    .collect::<Vec<_>>();
1763                (!values.is_empty()).then_some(values)
1764            }
1765            LineDash::Custom(custom) => {
1766                let values = custom
1767                    .stops
1768                    .iter()
1769                    .flat_map(|stop| [stop.dash, stop.space])
1770                    .map(|value| f64::from(value) / 100_000.0 * width)
1771                    .collect::<Vec<_>>();
1772                (!values.is_empty()).then_some(values)
1773            }
1774        });
1775        Ok(Some(Stroke {
1776            paint,
1777            width,
1778            cap,
1779            join,
1780            dash,
1781        }))
1782    }
1783
1784    fn concrete_shadow(&self, shadow: &CT_OuterShadowEffect) -> Result<Effect, ResolveError> {
1785        let color = shadow
1786            .color
1787            .as_ref()
1788            .map(|choice| self.concrete_color(choice))
1789            .transpose()?
1790            .unwrap_or(Color::BLACK);
1791        let distance = emu_to_points(shadow.distance.unwrap_or(0));
1792        let angle = f64::from(shadow.direction.unwrap_or_default().0) / 60_000.0;
1793        let radians = angle.to_radians();
1794        Ok(Effect::OuterShadow {
1795            dx: radians.cos() * distance,
1796            dy: radians.sin() * distance,
1797            blur: emu_to_points(shadow.blur_radius.unwrap_or(0)),
1798            color,
1799        })
1800    }
1801
1802    pub(crate) fn concrete_color(&self, choice: &ColorChoice) -> Result<Color, ResolveError> {
1803        let owned_lookup = self.theme_lookup();
1804        let lookup = owned_lookup
1805            .iter()
1806            .map(|(name, color)| (name.as_str(), *color))
1807            .collect::<Vec<_>>();
1808        let color = resolve_color(choice, &self.color_map, &lookup).map_err(|error| {
1809            ResolveError::ConcreteValue {
1810                kind: "colour",
1811                detail: error.to_string(),
1812            }
1813        })?;
1814        Ok(Color {
1815            r: f64::from(color.red) / 255.0,
1816            g: f64::from(color.green) / 255.0,
1817            b: f64::from(color.blue) / 255.0,
1818            a: f64::from(color.alpha) / 255.0,
1819        })
1820    }
1821
1822    fn theme_lookup(&self) -> Vec<(String, RgbColor)> {
1823        let mut lookup = self
1824            .theme
1825            .theme_elements
1826            .color_scheme
1827            .iter()
1828            .filter_map(|(slot, choice)| {
1829                base_rgb(choice).map(|color| (slot.as_str().to_owned(), color))
1830            })
1831            .collect::<Vec<_>>();
1832        lookup.push(("black".to_owned(), RgbColor::new(0, 0, 0)));
1833        lookup.push(("white".to_owned(), RgbColor::new(255, 255, 255)));
1834        lookup
1835    }
1836
1837    fn concrete_custom_geometry(
1838        &self,
1839        geometry: &oxml_drawing::geometry::CT_CustomGeometry2D,
1840        size: (f64, f64),
1841    ) -> Result<ResolvedGeometry, ResolveError> {
1842        let evaluated = geometry
1843            .evaluate_with_size(&BTreeMap::new(), size)
1844            .map_err(|error| ResolveError::ConcreteValue {
1845                kind: "custom geometry",
1846                detail: error.to_string(),
1847            })?;
1848        let paths = evaluated
1849            .paths
1850            .into_iter()
1851            .zip(geometry.paths())
1852            .map(|(commands, source)| {
1853                let scale_x = source.width.map_or(1.0, |width| size.0 / width);
1854                let scale_y = source.height.map_or(1.0, |height| size.1 / height);
1855                Path {
1856                    commands: commands
1857                        .into_iter()
1858                        .map(|command| concrete_path_command(command, scale_x, scale_y))
1859                        .collect(),
1860                    fill_rule: FillRule::NonZero,
1861                }
1862            })
1863            .collect();
1864        let first_path = geometry.paths().first();
1865        let text_scale_x = first_path
1866            .and_then(|path| path.width)
1867            .map_or(1.0, |width| size.0 / width);
1868        let text_scale_y = first_path
1869            .and_then(|path| path.height)
1870            .map_or(1.0, |height| size.1 / height);
1871        let text_rect = evaluated.text_rectangle.map(|rectangle| Rect {
1872            x: rectangle.left * text_scale_x,
1873            y: rectangle.top * text_scale_y,
1874            width: (rectangle.right - rectangle.left) * text_scale_x,
1875            height: (rectangle.bottom - rectangle.top) * text_scale_y,
1876        });
1877        Ok(ResolvedGeometry::Custom { paths, text_rect })
1878    }
1879
1880    fn concrete_preset_geometry(
1881        &self,
1882        geometry: &oxml_drawing::geometry::CT_PresetGeometry2D,
1883        size: (f64, f64),
1884    ) -> Result<Option<ResolvedGeometry>, ResolveError> {
1885        let evaluated = geometry
1886            .evaluate(size)
1887            .map_err(|error| ResolveError::ConcreteValue {
1888                kind: "preset geometry",
1889                detail: error.to_string(),
1890            })?;
1891        Ok(evaluated.map(|evaluated| {
1892            let paths = evaluated
1893                .paths
1894                .into_iter()
1895                .map(|commands| Path {
1896                    commands: commands
1897                        .into_iter()
1898                        .map(|command| concrete_path_command(command, 1.0, 1.0))
1899                        .collect(),
1900                    fill_rule: FillRule::NonZero,
1901                })
1902                .collect();
1903            let text_rect = evaluated.text_rectangle.map(|rectangle| Rect {
1904                x: rectangle.left,
1905                y: rectangle.top,
1906                width: rectangle.right - rectangle.left,
1907                height: rectangle.bottom - rectangle.top,
1908            });
1909            ResolvedGeometry::Custom { paths, text_rect }
1910        }))
1911    }
1912
1913    fn resolve_text_body(
1914        &self,
1915        shape: &CT_Shape,
1916        body: &CT_TextBody,
1917        source: FlattenedSource,
1918        hyperlinks: Option<&ScopedHyperlinkTargets>,
1919        diagnostics: &mut Vec<Diagnostic>,
1920    ) -> Result<(ResolvedTextBody, Vec<oxml_layout::TextDirection>), ResolveError> {
1921        let properties = self.effective_body_pr(shape);
1922        let slide_number_placeholder = self.is_slide_number_placeholder(shape);
1923        let resolved = body
1924            .paragraphs()
1925            .iter()
1926            .map(|paragraph| {
1927                self.resolve_paragraph(
1928                    shape,
1929                    paragraph,
1930                    source,
1931                    hyperlinks,
1932                    slide_number_placeholder,
1933                    diagnostics,
1934                )
1935            })
1936            .collect::<Result<Vec<_>, _>>()?;
1937        let (paragraphs, directions) = resolved.into_iter().unzip();
1938        Ok((resolved_text_body(&properties, paragraphs)?, directions))
1939    }
1940
1941    fn resolve_paragraph(
1942        &self,
1943        shape: &CT_Shape,
1944        paragraph: &oxml_drawing::text::CT_TextParagraph,
1945        source: FlattenedSource,
1946        hyperlinks: Option<&ScopedHyperlinkTargets>,
1947        slide_number_placeholder: bool,
1948        diagnostics: &mut Vec<Diagnostic>,
1949    ) -> Result<(ResolvedParagraph, oxml_layout::TextDirection), ResolveError> {
1950        let effective = self.effective_text_properties(shape, paragraph.properties.as_ref(), None);
1951        let paragraph_properties = &effective.paragraph;
1952        let mut runs = Vec::new();
1953        for run in &paragraph.runs {
1954            match run {
1955                TextRun::Run(run) => {
1956                    let mut style = self.resolve_run_style(
1957                        shape,
1958                        paragraph.properties.as_ref(),
1959                        run.properties.as_ref(),
1960                    )?;
1961                    style.hyperlink_url = resolve_direct_hyperlink(
1962                        run.properties.as_ref(),
1963                        source,
1964                        hyperlinks,
1965                        diagnostics,
1966                    );
1967                    runs.push(ResolvedTextRun::Text {
1968                        text: run.text.value.clone(),
1969                        style,
1970                    });
1971                }
1972                TextRun::Break(_) => runs.push(ResolvedTextRun::Break),
1973                TextRun::Field(field) => {
1974                    let mut style = self.resolve_run_style(
1975                        shape,
1976                        paragraph.properties.as_ref(),
1977                        field.run_properties.as_ref(),
1978                    )?;
1979                    style.hyperlink_url = resolve_direct_hyperlink(
1980                        field.run_properties.as_ref(),
1981                        source,
1982                        hyperlinks,
1983                        diagnostics,
1984                    );
1985                    runs.push(ResolvedTextRun::Field {
1986                        text: field
1987                            .text
1988                            .as_ref()
1989                            .map(|text| text.value.clone())
1990                            .unwrap_or_default(),
1991                        field_type: field
1992                            .field_type
1993                            .clone()
1994                            .or_else(|| slide_number_placeholder.then(|| "slidenum".to_owned())),
1995                        style,
1996                    });
1997                }
1998            }
1999        }
2000        let direction = paragraph_base_direction(paragraph_properties.right_to_left);
2001        Ok((
2002            ResolvedParagraph {
2003                level: paragraph_properties.level.unwrap_or(0),
2004                left_margin: emu_to_points(i64::from(
2005                    paragraph_properties.left_margin.unwrap_or(0),
2006                )),
2007                right_margin: emu_to_points(i64::from(
2008                    paragraph_properties.right_margin.unwrap_or(0),
2009                )),
2010                indent: emu_to_points(i64::from(paragraph_properties.indent.unwrap_or(0))),
2011                alignment: paragraph_alignment(paragraph_properties.alignment),
2012                line_spacing: resolved_text_spacing(paragraph_properties.line_spacing.as_ref()),
2013                space_before: resolved_text_spacing(paragraph_properties.space_before.as_ref()),
2014                space_after: resolved_text_spacing(paragraph_properties.space_after.as_ref()),
2015                bullet: paragraph_properties
2016                    .bullet
2017                    .as_ref()
2018                    .and_then(|bullet| self.resolve_bullet(bullet).transpose())
2019                    .transpose()?,
2020                end_style: self.resolve_run_style(
2021                    shape,
2022                    paragraph.properties.as_ref(),
2023                    paragraph.end_properties.as_ref(),
2024                )?,
2025                runs,
2026            },
2027            direction,
2028        ))
2029    }
2030
2031    fn resolve_run_style(
2032        &self,
2033        shape: &CT_Shape,
2034        paragraph: Option<&oxml_drawing::text::CT_TextParagraphProperties>,
2035        run: Option<&CT_TextCharacterProperties>,
2036    ) -> Result<ResolvedRunStyle, ResolveError> {
2037        let effective = self.effective_text_properties(shape, paragraph, run).run;
2038        self.resolved_run_style(effective)
2039    }
2040
2041    fn resolve_table_run_style(
2042        &self,
2043        table_style: &CT_TextCharacterProperties,
2044        paragraph: Option<&oxml_drawing::text::CT_TextParagraphProperties>,
2045        run: Option<&CT_TextCharacterProperties>,
2046    ) -> Result<ResolvedRunStyle, ResolveError> {
2047        let effective = self
2048            .effective_table_text_properties(Some(table_style), paragraph, run)
2049            .run;
2050        self.resolved_run_style(effective)
2051    }
2052
2053    fn resolved_run_style(
2054        &self,
2055        effective: CT_TextCharacterProperties,
2056    ) -> Result<ResolvedRunStyle, ResolveError> {
2057        let fill = effective
2058            .fill
2059            .as_ref()
2060            .map(|fill| self.concrete_fill(fill, (1.0, 1.0)))
2061            .transpose()?
2062            .and_then(|(paint, _)| paint);
2063        Ok(ResolvedRunStyle {
2064            font_size: effective.font_size.map(|size| f64::from(size) / 100.0),
2065            bold: effective.bold.unwrap_or(false),
2066            italic: effective.italic.unwrap_or(false),
2067            all_caps: effective.all_caps.unwrap_or(false),
2068            underline: effective
2069                .underline
2070                .is_some_and(|value| value != oxml_drawing::text::TextUnderline::None),
2071            strike: effective
2072                .strike
2073                .is_some_and(|value| value != oxml_drawing::text::TextStrike::None),
2074            spacing: effective.spacing.as_ref().and_then(text_point_value),
2075            baseline: effective.baseline.as_deref().and_then(parse_percent),
2076            fill,
2077            latin_typeface: effective
2078                .latin
2079                .as_ref()
2080                .map(|font| self.resolve_typeface(&font.typeface, None)),
2081            east_asian_typeface: effective
2082                .east_asian
2083                .as_ref()
2084                .map(|font| self.resolve_typeface(&font.typeface, None)),
2085            complex_script_typeface: effective
2086                .complex_script
2087                .as_ref()
2088                .map(|font| self.resolve_typeface(&font.typeface, None)),
2089            symbol_typeface: effective
2090                .symbol
2091                .as_ref()
2092                .map(|font| self.resolve_typeface(&font.typeface, None)),
2093            hyperlink_url: None,
2094        })
2095    }
2096
2097    fn resolve_bullet(
2098        &self,
2099        bullet: &oxml_drawing::text::TextBullet,
2100    ) -> Result<Option<ResolvedBullet>, ResolveError> {
2101        let color = bullet
2102            .color
2103            .as_ref()
2104            .map(|color| self.concrete_color(&color.color))
2105            .transpose()?;
2106        let font = bullet
2107            .font
2108            .as_ref()
2109            .map(|font| self.resolve_typeface(&font.typeface, None));
2110        let size = bullet.size.as_ref().and_then(|size| match &size.value {
2111            TextBulletSizeValue::Percent(value) => {
2112                parse_percent(value).map(ResolvedBulletSize::Percent)
2113            }
2114            TextBulletSizeValue::Points(value) => {
2115                Some(ResolvedBulletSize::Points(f64::from(*value) / 100.0))
2116            }
2117        });
2118        Ok(match bullet.choice.as_ref() {
2119            Some(TextBulletChoice::Character(character)) => Some(ResolvedBullet::Character {
2120                character: character.character.clone(),
2121                font,
2122                color,
2123                size,
2124            }),
2125            Some(TextBulletChoice::AutoNumber(number)) => Some(ResolvedBullet::AutoNumber {
2126                scheme: number.scheme.as_str().to_owned(),
2127                start_at: u32::from(number.start_at.unwrap_or(1)),
2128                font,
2129                color,
2130                size,
2131            }),
2132            Some(TextBulletChoice::None(_)) | None => None,
2133        })
2134    }
2135
2136    fn resolve_table(
2137        &self,
2138        table: &oxml_drawing::table::CT_Table,
2139        source: FlattenedSource,
2140        hyperlinks: Option<&ScopedHyperlinkTargets>,
2141        diagnostics: &mut Vec<Diagnostic>,
2142        text_directions: &mut Vec<Vec<oxml_layout::TextDirection>>,
2143    ) -> Result<ResolvedTable, ResolveError> {
2144        let column_widths = table
2145            .grid
2146            .columns
2147            .iter()
2148            .map(|width| emu_to_points(width.0))
2149            .collect::<Vec<_>>();
2150        let selected_id = table
2151            .properties
2152            .as_ref()
2153            .and_then(|properties| properties.style_id.as_deref());
2154        let explicit_style = self.table_styles.and_then(|styles| {
2155            selected_id.and_then(|id| styles.styles.iter().find(|style| style.style_id == id))
2156        });
2157        let default_id = self
2158            .table_styles
2159            .and_then(|styles| styles.default_style_id.as_deref());
2160        let default_style = self.table_styles.and_then(|styles| {
2161            default_id.and_then(|id| styles.styles.iter().find(|style| style.style_id == id))
2162        });
2163        let builtin_style = if explicit_style.is_none() {
2164            selected_id.and_then(CT_TableStyle::builtin)
2165        } else {
2166            None
2167        };
2168        let builtin_default = if selected_id.is_none() && default_style.is_none() {
2169            default_id.and_then(CT_TableStyle::builtin)
2170        } else {
2171            None
2172        };
2173        let style = explicit_style
2174            .or(builtin_style.as_ref())
2175            .or(default_style)
2176            .or(builtin_default.as_ref());
2177        let row_count = table.rows.len();
2178        let column_count = table.grid.columns.len();
2179        let table_properties = table.properties.as_ref();
2180        let rows = table
2181            .rows
2182            .iter()
2183            .enumerate()
2184            .map(|(row_index, row)| {
2185                let cells = row
2186                    .cells
2187                    .iter()
2188                    .enumerate()
2189                    .map(|(column_index, cell)| {
2190                        let mut cascade = TableCellCascade::default();
2191                        let position = TableCellPosition {
2192                            row: row_index,
2193                            column: column_index,
2194                            row_count,
2195                            column_count,
2196                        };
2197                        if let Some(style) = style {
2198                            let mut priority = 1u8;
2199                            cascade.apply_region(
2200                                style.whole_table.as_ref(),
2201                                true,
2202                                position,
2203                                priority,
2204                            );
2205                            priority += 1;
2206                            if table_properties.is_some_and(|properties| properties.band_rows) {
2207                                let offset = usize::from(
2208                                    table_properties.is_some_and(|properties| properties.first_row),
2209                                );
2210                                let region = if row_index.saturating_sub(offset) % 2 == 0 {
2211                                    style.band1_horizontal.as_ref()
2212                                } else {
2213                                    style.band2_horizontal.as_ref()
2214                                };
2215                                cascade.apply_region(region, false, position, priority);
2216                                priority += 1;
2217                            }
2218                            if table_properties.is_some_and(|properties| properties.band_columns) {
2219                                let offset = usize::from(
2220                                    table_properties
2221                                        .is_some_and(|properties| properties.first_column),
2222                                );
2223                                let region = if column_index.saturating_sub(offset) % 2 == 0 {
2224                                    style.band1_vertical.as_ref()
2225                                } else {
2226                                    style.band2_vertical.as_ref()
2227                                };
2228                                cascade.apply_region(region, false, position, priority);
2229                                priority += 1;
2230                            }
2231                            if column_index == 0
2232                                && table_properties
2233                                    .is_some_and(|properties| properties.first_column)
2234                            {
2235                                cascade.apply_region(
2236                                    style.first_column.as_ref(),
2237                                    false,
2238                                    position,
2239                                    priority,
2240                                );
2241                                priority += 1;
2242                            }
2243                            if column_index + 1 == column_count
2244                                && table_properties.is_some_and(|properties| properties.last_column)
2245                            {
2246                                cascade.apply_region(
2247                                    style.last_column.as_ref(),
2248                                    false,
2249                                    position,
2250                                    priority,
2251                                );
2252                                priority += 1;
2253                            }
2254                            if row_index == 0
2255                                && table_properties.is_some_and(|properties| properties.first_row)
2256                            {
2257                                cascade.apply_region(
2258                                    style.first_row.as_ref(),
2259                                    false,
2260                                    position,
2261                                    priority,
2262                                );
2263                                priority += 1;
2264                            }
2265                            if row_index + 1 == row_count
2266                                && table_properties.is_some_and(|properties| properties.last_row)
2267                            {
2268                                cascade.apply_region(
2269                                    style.last_row.as_ref(),
2270                                    false,
2271                                    position,
2272                                    priority,
2273                                );
2274                                priority += 1;
2275                            }
2276                            let first_row =
2277                                table_properties.is_some_and(|properties| properties.first_row);
2278                            let last_row =
2279                                table_properties.is_some_and(|properties| properties.last_row);
2280                            let first_column =
2281                                table_properties.is_some_and(|properties| properties.first_column);
2282                            let last_column =
2283                                table_properties.is_some_and(|properties| properties.last_column);
2284                            let corner = match (row_index, column_index) {
2285                                (0, 0) if first_row && first_column => {
2286                                    style.north_west_cell.as_ref()
2287                                }
2288                                (0, column)
2289                                    if column + 1 == column_count && first_row && last_column =>
2290                                {
2291                                    style.north_east_cell.as_ref()
2292                                }
2293                                (row, 0) if row + 1 == row_count && last_row && first_column => {
2294                                    style.south_west_cell.as_ref()
2295                                }
2296                                (row, column)
2297                                    if row + 1 == row_count
2298                                        && column + 1 == column_count
2299                                        && last_row
2300                                        && last_column =>
2301                                {
2302                                    style.south_east_cell.as_ref()
2303                                }
2304                                _ => None,
2305                            };
2306                            cascade.apply_region(corner, false, position, priority);
2307                        }
2308                        if let Some(properties) = &cell.properties {
2309                            cascade.apply_direct(properties);
2310                            for unsupported in &properties.unsupported {
2311                                push_table_diagnostic(diagnostics, unsupported);
2312                            }
2313                        }
2314                        for unsupported in &cascade.unsupported {
2315                            push_table_diagnostic(diagnostics, unsupported);
2316                        }
2317                        let referenced_fill = cascade
2318                            .fill_reference
2319                            .as_ref()
2320                            .map(|reference| self.table_referenced_fill(reference))
2321                            .transpose()?
2322                            .flatten();
2323                        let (fill, fill_unsupported) = referenced_fill
2324                            .as_ref()
2325                            .or(cascade.fill.as_ref())
2326                            .map(|fill| self.concrete_fill(fill, (1.0, 1.0)))
2327                            .transpose()?
2328                            .unwrap_or((None, None));
2329                        if let Some(unsupported) = fill_unsupported {
2330                            push_table_diagnostic(diagnostics, unsupported);
2331                        }
2332                        let table_text_style = table_character_properties(&cascade.text_style)?;
2333                        let mut text = if let Some(body) = cell.text_body.as_ref() {
2334                            let (body, directions) = resolve_standalone_text_body(
2335                                self,
2336                                body,
2337                                &table_text_style,
2338                                source,
2339                                hyperlinks,
2340                                diagnostics,
2341                            )?;
2342                            text_directions.push(directions);
2343                            Some(body)
2344                        } else {
2345                            text_directions.push(Vec::new());
2346                            None
2347                        };
2348                        if let Some(body) = text.as_mut()
2349                            && body.autofit != ResolvedAutofit::None
2350                        {
2351                            body.autofit = ResolvedAutofit::None;
2352                            let message = "table cell autofit is unsupported and was ignored";
2353                            if !diagnostics
2354                                .iter()
2355                                .any(|diagnostic| diagnostic.message == message)
2356                            {
2357                                diagnostics.push(Diagnostic {
2358                                    message: message.to_owned(),
2359                                });
2360                            }
2361                        }
2362                        Ok(ResolvedTableCell {
2363                            text,
2364                            fill,
2365                            margins: TextInsets {
2366                                left: emu_to_points(cascade.margin_left.unwrap_or(91_440)),
2367                                top: emu_to_points(cascade.margin_top.unwrap_or(45_720)),
2368                                right: emu_to_points(cascade.margin_right.unwrap_or(91_440)),
2369                                bottom: emu_to_points(cascade.margin_bottom.unwrap_or(45_720)),
2370                            },
2371                            left: self.resolve_table_border(cascade.left, diagnostics)?,
2372                            right: self.resolve_table_border(cascade.right, diagnostics)?,
2373                            top: self.resolve_table_border(cascade.top, diagnostics)?,
2374                            bottom: self.resolve_table_border(cascade.bottom, diagnostics)?,
2375                            row_span: cell.row_span,
2376                            grid_span: cell.grid_span,
2377                            horizontal_merge: cell.horizontal_merge,
2378                            vertical_merge: cell.vertical_merge,
2379                        })
2380                    })
2381                    .collect::<Result<Vec<_>, ResolveError>>()?;
2382                Ok(ResolvedTableRow {
2383                    height: emu_to_points(row.height.0),
2384                    cells,
2385                })
2386            })
2387            .collect::<Result<Vec<_>, ResolveError>>()?;
2388        let background = match style.and_then(|style| style.background.as_ref()) {
2389            Some(background) => {
2390                let size = (
2391                    column_widths.iter().sum(),
2392                    rows.iter().map(|row| row.height).sum(),
2393                );
2394                self.resolve_table_background(background, size, diagnostics)?
2395            }
2396            None => None,
2397        };
2398        Ok(ResolvedTable {
2399            right_to_left: table_properties.is_some_and(|properties| properties.right_to_left),
2400            column_widths,
2401            rows,
2402            background,
2403        })
2404    }
2405
2406    /// Resolves a table style's `a:tblBg` fill over the whole table box.
2407    fn resolve_table_background(
2408        &self,
2409        background: &CT_TableBackgroundStyle,
2410        size: (f64, f64),
2411        diagnostics: &mut Vec<Diagnostic>,
2412    ) -> Result<Option<Paint>, ResolveError> {
2413        let referenced = background
2414            .fill_reference
2415            .as_ref()
2416            .map(|reference| self.table_referenced_fill(reference))
2417            .transpose()?
2418            .flatten();
2419        let (paint, unsupported) = referenced
2420            .as_ref()
2421            .or(background.fill.as_ref())
2422            .map(|fill| self.concrete_fill(fill, size))
2423            .transpose()?
2424            .unwrap_or((None, None));
2425        for unsupported in background
2426            .unsupported
2427            .iter()
2428            .map(String::as_str)
2429            .chain(unsupported)
2430        {
2431            let message = format!("unsupported table background {unsupported} was ignored");
2432            if !diagnostics
2433                .iter()
2434                .any(|diagnostic| diagnostic.message == message)
2435            {
2436                diagnostics.push(Diagnostic { message });
2437            }
2438        }
2439        Ok(paint)
2440    }
2441
2442    fn resolve_table_border(
2443        &self,
2444        border: Option<(CT_LineProperties, u8)>,
2445        diagnostics: &mut Vec<Diagnostic>,
2446    ) -> Result<Option<ResolvedTableBorder>, ResolveError> {
2447        border
2448            .map(|(line, priority)| {
2449                let stroke = self.concrete_line(&line, (1.0, 1.0))?;
2450                if stroke.is_none()
2451                    && let Some(fill) = &line.fill
2452                {
2453                    let (_, unsupported) = self.concrete_fill(fill, (1.0, 1.0))?;
2454                    if let Some(unsupported) = unsupported {
2455                        push_table_diagnostic(diagnostics, unsupported);
2456                    }
2457                }
2458                Ok(ResolvedTableBorder { stroke, priority })
2459            })
2460            .transpose()
2461    }
2462
2463    fn table_referenced_fill(
2464        &self,
2465        reference: &StyleReference,
2466    ) -> Result<Option<Fill>, ResolveError> {
2467        let StyleReference::Fill(reference) = reference else {
2468            return Ok(None);
2469        };
2470        let matrix = &self.theme.theme_elements.format_scheme;
2471        let Some(mut fill) = referenced_fill(
2472            reference.index,
2473            &matrix.fill_styles,
2474            &matrix.background_fill_styles,
2475        )?
2476        else {
2477            return Ok(None);
2478        };
2479        substitute_fill(&mut fill, reference.color.as_ref(), "table cell")?;
2480        Ok(Some(fill))
2481    }
2482}
2483
2484#[derive(Default)]
2485struct TableCellCascade {
2486    fill: Option<Fill>,
2487    fill_reference: Option<StyleReference>,
2488    left: Option<(CT_LineProperties, u8)>,
2489    right: Option<(CT_LineProperties, u8)>,
2490    top: Option<(CT_LineProperties, u8)>,
2491    bottom: Option<(CT_LineProperties, u8)>,
2492    margin_left: Option<i64>,
2493    margin_right: Option<i64>,
2494    margin_top: Option<i64>,
2495    margin_bottom: Option<i64>,
2496    text_style: TableTextCascade,
2497    unsupported: Vec<String>,
2498}
2499
2500#[derive(Default)]
2501struct TableTextCascade {
2502    bold: Option<bool>,
2503    italic: Option<bool>,
2504    font_collection: Option<FontCollectionIndex>,
2505    color: Option<ColorChoice>,
2506}
2507
2508#[derive(Clone, Copy)]
2509struct TableCellPosition {
2510    row: usize,
2511    column: usize,
2512    row_count: usize,
2513    column_count: usize,
2514}
2515
2516fn table_character_properties(
2517    style: &TableTextCascade,
2518) -> Result<CT_TextCharacterProperties, ResolveError> {
2519    let mut properties = CT_TextCharacterProperties::default();
2520    properties.bold = style.bold;
2521    properties.italic = style.italic;
2522    if let Some(color) = &style.color {
2523        let mut solid = SolidFill::default();
2524        solid.color = Some(color.clone());
2525        properties.fill = Some(Fill::Solid(solid));
2526    }
2527    let font_tokens = match style.font_collection {
2528        Some(FontCollectionIndex::Major) => Some(("+mj-lt", "+mj-ea", "+mj-cs")),
2529        Some(FontCollectionIndex::Minor) => Some(("+mn-lt", "+mn-ea", "+mn-cs")),
2530        Some(FontCollectionIndex::None) | None => None,
2531    };
2532    if let Some((latin, east_asian, complex_script)) = font_tokens {
2533        let font = |typeface| {
2534            TextFont::new(typeface).map_err(|error| ResolveError::ConcreteValue {
2535                kind: "table font",
2536                detail: error.to_string(),
2537            })
2538        };
2539        properties.latin = Some(font(latin)?);
2540        properties.east_asian = Some(font(east_asian)?);
2541        properties.complex_script = Some(font(complex_script)?);
2542    }
2543    Ok(properties)
2544}
2545
2546impl TableCellCascade {
2547    fn apply_region(
2548        &mut self,
2549        region: Option<&CT_TablePartStyle>,
2550        whole_table: bool,
2551        position: TableCellPosition,
2552        priority: u8,
2553    ) {
2554        let Some(region) = region else {
2555            return;
2556        };
2557        if let Some(cell_style) = &region.cell_style {
2558            self.apply_cell_style(cell_style, whole_table, position, priority);
2559        }
2560        if let Some(text_style) = &region.text_style {
2561            self.apply_text_style(text_style);
2562        }
2563    }
2564
2565    fn apply_cell_style(
2566        &mut self,
2567        style: &CT_TableCellStyle,
2568        whole_table: bool,
2569        position: TableCellPosition,
2570        priority: u8,
2571    ) {
2572        if let Some(fill) = &style.fill {
2573            self.fill = Some(fill.clone());
2574            self.fill_reference = None;
2575        }
2576        if let Some(reference) = &style.fill_reference {
2577            self.fill_reference = Some(reference.clone());
2578            self.fill = None;
2579        }
2580        if let Some(borders) = &style.borders {
2581            self.apply_borders(borders, whole_table, position, priority);
2582            self.unsupported.extend(borders.unsupported.iter().cloned());
2583        }
2584        self.unsupported.extend(style.unsupported.iter().cloned());
2585    }
2586
2587    fn apply_borders(
2588        &mut self,
2589        borders: &CT_TableBorders,
2590        whole_table: bool,
2591        position: TableCellPosition,
2592        priority: u8,
2593    ) {
2594        let left = if whole_table {
2595            if position.column > 0 {
2596                borders.inside_vertical.as_ref()
2597            } else {
2598                borders.left.as_ref()
2599            }
2600        } else {
2601            borders.left.as_ref().or(borders.inside_vertical.as_ref())
2602        };
2603        let right = if whole_table {
2604            if position.column + 1 < position.column_count {
2605                borders.inside_vertical.as_ref()
2606            } else {
2607                borders.right.as_ref()
2608            }
2609        } else {
2610            borders.right.as_ref().or(borders.inside_vertical.as_ref())
2611        };
2612        let top = if whole_table {
2613            if position.row > 0 {
2614                borders.inside_horizontal.as_ref()
2615            } else {
2616                borders.top.as_ref()
2617            }
2618        } else {
2619            borders.top.as_ref().or(borders.inside_horizontal.as_ref())
2620        };
2621        let bottom = if whole_table {
2622            if position.row + 1 < position.row_count {
2623                borders.inside_horizontal.as_ref()
2624            } else {
2625                borders.bottom.as_ref()
2626            }
2627        } else {
2628            borders
2629                .bottom
2630                .as_ref()
2631                .or(borders.inside_horizontal.as_ref())
2632        };
2633        apply_table_edge(&mut self.left, left, priority);
2634        apply_table_edge(&mut self.right, right, priority);
2635        apply_table_edge(&mut self.top, top, priority);
2636        apply_table_edge(&mut self.bottom, bottom, priority);
2637    }
2638
2639    fn apply_text_style(&mut self, style: &CT_TableTextStyle) {
2640        if style.bold.is_some() {
2641            self.text_style.bold = style.bold;
2642        }
2643        if style.italic.is_some() {
2644            self.text_style.italic = style.italic;
2645        }
2646        if let Some(StyleReference::Font(reference)) = &style.font_reference {
2647            self.text_style.font_collection = Some(reference.index);
2648            if reference.color.is_some() {
2649                self.text_style.color = reference.color.clone();
2650            }
2651        }
2652        if style.color.is_some() {
2653            self.text_style.color = style.color.clone();
2654        }
2655    }
2656
2657    fn apply_direct(&mut self, properties: &oxml_drawing::table::CT_TableCellProperties) {
2658        if let Some(fill) = &properties.fill {
2659            self.fill = Some(fill.clone());
2660            self.fill_reference = None;
2661        }
2662        if let Some(value) = properties.margin_left {
2663            self.margin_left = Some(value.0);
2664        }
2665        if let Some(value) = properties.margin_right {
2666            self.margin_right = Some(value.0);
2667        }
2668        if let Some(value) = properties.margin_top {
2669            self.margin_top = Some(value.0);
2670        }
2671        if let Some(value) = properties.margin_bottom {
2672            self.margin_bottom = Some(value.0);
2673        }
2674        apply_table_edge(&mut self.left, properties.left.as_ref(), u8::MAX);
2675        apply_table_edge(&mut self.right, properties.right.as_ref(), u8::MAX);
2676        apply_table_edge(&mut self.top, properties.top.as_ref(), u8::MAX);
2677        apply_table_edge(&mut self.bottom, properties.bottom.as_ref(), u8::MAX);
2678    }
2679}
2680
2681fn apply_table_edge(
2682    target: &mut Option<(CT_LineProperties, u8)>,
2683    source: Option<&CT_LineProperties>,
2684    priority: u8,
2685) {
2686    if let Some(source) = source {
2687        *target = Some((source.clone(), priority));
2688    }
2689}
2690
2691fn push_table_diagnostic(diagnostics: &mut Vec<Diagnostic>, unsupported: &str) {
2692    let message = format!("unsupported table cell {unsupported} was ignored");
2693    if !diagnostics
2694        .iter()
2695        .any(|diagnostic| diagnostic.message == message)
2696    {
2697        diagnostics.push(Diagnostic { message });
2698    }
2699}
2700
2701fn push_group_diagnostics(issues: u8, diagnostics: &mut Vec<Diagnostic>) {
2702    let messages = [
2703        (
2704            GROUP_ZERO_X,
2705            "zero horizontal group child extent used finite unit scale",
2706        ),
2707        (
2708            GROUP_ZERO_Y,
2709            "zero vertical group child extent used finite unit scale",
2710        ),
2711        (
2712            GROUP_SHEAR,
2713            "unsupported sheared or singular group transform retained as affine fallback",
2714        ),
2715    ];
2716    for (flag, message) in messages {
2717        if issues & flag != 0
2718            && !diagnostics
2719                .iter()
2720                .any(|diagnostic| diagnostic.message == message)
2721        {
2722            diagnostics.push(Diagnostic {
2723                message: message.to_owned(),
2724            });
2725        }
2726    }
2727}
2728
2729fn scaled_group_bounds(bounds: Rect, scale: (f64, f64)) -> Rect {
2730    Rect {
2731        x: bounds.x * scale.0,
2732        y: bounds.y * scale.1,
2733        width: bounds.width * scale.0,
2734        height: bounds.height * scale.1,
2735    }
2736}
2737
2738fn transform_values(transform: Option<&CT_Transform2D>) -> Option<(Rect, f64, bool, bool)> {
2739    transform_values_with_degenerate_line(transform, false)
2740}
2741
2742fn connector_transform_values(
2743    transform: Option<&CT_Transform2D>,
2744) -> Option<(Rect, f64, bool, bool)> {
2745    transform_values_with_degenerate_line(transform, true)
2746}
2747
2748fn transform_values_with_degenerate_line(
2749    transform: Option<&CT_Transform2D>,
2750    allow_degenerate_line: bool,
2751) -> Option<(Rect, f64, bool, bool)> {
2752    let transform = transform?;
2753    let extent = transform.extent?;
2754    let invalid = if allow_degenerate_line {
2755        extent.cx.0 < 0 || extent.cy.0 < 0 || (extent.cx.0 == 0 && extent.cy.0 == 0)
2756    } else {
2757        extent.cx.0 <= 0 || extent.cy.0 <= 0
2758    };
2759    if invalid {
2760        return None;
2761    }
2762    let offset = transform.offset.unwrap_or_default();
2763    Some((
2764        Rect {
2765            x: emu_to_points(offset.x.0),
2766            y: emu_to_points(offset.y.0),
2767            width: emu_to_points(extent.cx.0),
2768            height: emu_to_points(extent.cy.0),
2769        },
2770        f64::from(transform.rotation.0) / 60_000.0,
2771        transform.flip_horizontal,
2772        transform.flip_vertical,
2773    ))
2774}
2775
2776fn emu_to_points(value: i64) -> f64 {
2777    value as f64 / 12_700.0
2778}
2779
2780fn base_rgb(choice: &ColorChoice) -> Option<RgbColor> {
2781    let resolved = resolve_color(choice, &ColorMap::default(), &[]).ok()?;
2782    Some(RgbColor::new(resolved.red, resolved.green, resolved.blue))
2783}
2784
2785fn concrete_path_command(command: EvaluatedPathCommand, scale_x: f64, scale_y: f64) -> PathCommand {
2786    match command {
2787        EvaluatedPathCommand::MoveTo { x, y } => PathCommand::MoveTo(Point {
2788            x: x * scale_x,
2789            y: y * scale_y,
2790        }),
2791        EvaluatedPathCommand::LineTo { x, y } => PathCommand::LineTo(Point {
2792            x: x * scale_x,
2793            y: y * scale_y,
2794        }),
2795        EvaluatedPathCommand::CubicTo {
2796            x1,
2797            y1,
2798            x2,
2799            y2,
2800            x,
2801            y,
2802        } => PathCommand::CurveTo {
2803            c1: Point {
2804                x: x1 * scale_x,
2805                y: y1 * scale_y,
2806            },
2807            c2: Point {
2808                x: x2 * scale_x,
2809                y: y2 * scale_y,
2810            },
2811            to: Point {
2812                x: x * scale_x,
2813                y: y * scale_y,
2814            },
2815        },
2816        EvaluatedPathCommand::Close => PathCommand::Close,
2817    }
2818}
2819
2820fn coordinate_points(value: Option<&Coordinate32Value>) -> Result<f64, ResolveError> {
2821    match value {
2822        Some(Coordinate32Value::Emu(value)) => Ok(emu_to_points(i64::from(*value))),
2823        Some(Coordinate32Value::UniversalMeasure(value)) => universal_measure_points(value),
2824        None => Ok(0.0),
2825    }
2826}
2827
2828fn universal_measure_points(value: &str) -> Result<f64, ResolveError> {
2829    let split = value
2830        .find(|character: char| character.is_ascii_alphabetic())
2831        .unwrap_or(value.len());
2832    let (number, unit) = value.split_at(split);
2833    let number = number
2834        .parse::<f64>()
2835        .map_err(|error| ResolveError::ConcreteValue {
2836            kind: "universal measure",
2837            detail: error.to_string(),
2838        })?;
2839    match unit {
2840        "pt" => Ok(number),
2841        "in" => Ok(number * 72.0),
2842        "cm" => Ok(number * 72.0 / 2.54),
2843        "mm" => Ok(number * 72.0 / 25.4),
2844        _ => Err(ResolveError::ConcreteValue {
2845            kind: "universal measure",
2846            detail: format!("unsupported unit {unit}"),
2847        }),
2848    }
2849}
2850
2851fn parse_percent(value: &str) -> Option<f64> {
2852    value.parse::<f64>().ok().map(|value| value / 100_000.0)
2853}
2854
2855fn text_point_value(value: &TextPointValue) -> Option<f64> {
2856    match value {
2857        TextPointValue::Centipoints(value) => Some(f64::from(*value) / 100.0),
2858        TextPointValue::UniversalMeasure(value) => universal_measure_points(value).ok(),
2859    }
2860}
2861
2862fn resolved_text_spacing(value: Option<&TextSpacing>) -> Option<ResolvedTextSpacing> {
2863    match value? {
2864        TextSpacing::Percent(value) => parse_percent(value).map(ResolvedTextSpacing::Percent),
2865        TextSpacing::Points(value) => Some(ResolvedTextSpacing::Points(f64::from(*value) / 100.0)),
2866    }
2867}
2868
2869fn paragraph_alignment(alignment: Option<TextAlignment>) -> ParagraphAlignment {
2870    match alignment.unwrap_or(TextAlignment::Left) {
2871        TextAlignment::Left => ParagraphAlignment::Left,
2872        TextAlignment::Center => ParagraphAlignment::Center,
2873        TextAlignment::Right => ParagraphAlignment::Right,
2874        TextAlignment::Justified | TextAlignment::JustifiedLow => ParagraphAlignment::Justified,
2875        TextAlignment::Distributed | TextAlignment::ThaiDistributed => {
2876            ParagraphAlignment::Distributed
2877        }
2878    }
2879}
2880
2881fn paragraph_base_direction(right_to_left: Option<bool>) -> oxml_layout::TextDirection {
2882    match right_to_left {
2883        Some(true) => oxml_layout::TextDirection::RightToLeft,
2884        Some(false) => oxml_layout::TextDirection::LeftToRight,
2885        None => oxml_layout::TextDirection::Auto,
2886    }
2887}
2888
2889fn vertical_text_diagnostic(direction: TextDirection) -> Option<&'static str> {
2890    match direction {
2891        TextDirection::EastAsianVertical => {
2892            Some("east Asian vertical text rendered as rotated vertical text")
2893        }
2894        TextDirection::MongolianVertical => {
2895            Some("Mongolian vertical text rendered as rotated vertical-270 text")
2896        }
2897        TextDirection::WordArtVertical => {
2898            Some("WordArt vertical text rendered as rotated vertical text")
2899        }
2900        TextDirection::WordArtVerticalRtl => {
2901            Some("right-to-left WordArt vertical text rendered as rotated vertical-270 text")
2902        }
2903        TextDirection::Horizontal | TextDirection::Vertical | TextDirection::Vertical270 => None,
2904    }
2905}
2906
2907fn resolve_standalone_text_body(
2908    context: &ResolveCtx<'_>,
2909    body: &CT_TextBody,
2910    table_style: &CT_TextCharacterProperties,
2911    source: FlattenedSource,
2912    hyperlinks: Option<&ScopedHyperlinkTargets>,
2913    diagnostics: &mut Vec<Diagnostic>,
2914) -> Result<(ResolvedTextBody, Vec<oxml_layout::TextDirection>), ResolveError> {
2915    let mut properties = default_body_properties();
2916    merge_body_properties(&mut properties, &body.body_properties);
2917    let resolved = body
2918        .paragraphs()
2919        .iter()
2920        .map(|paragraph| {
2921            let effective = context.effective_table_text_properties(
2922                Some(table_style),
2923                paragraph.properties.as_ref(),
2924                None,
2925            );
2926            let paragraph_properties = &effective.paragraph;
2927            let runs = paragraph
2928                .runs
2929                .iter()
2930                .map(|run| match run {
2931                    TextRun::Run(run) => {
2932                        let mut style = context.resolve_table_run_style(
2933                            table_style,
2934                            paragraph.properties.as_ref(),
2935                            run.properties.as_ref(),
2936                        )?;
2937                        style.hyperlink_url = resolve_direct_hyperlink(
2938                            run.properties.as_ref(),
2939                            source,
2940                            hyperlinks,
2941                            diagnostics,
2942                        );
2943                        Ok(ResolvedTextRun::Text {
2944                            text: run.text.value.clone(),
2945                            style,
2946                        })
2947                    }
2948                    TextRun::Break(_) => Ok(ResolvedTextRun::Break),
2949                    TextRun::Field(field) => {
2950                        let mut style = context.resolve_table_run_style(
2951                            table_style,
2952                            paragraph.properties.as_ref(),
2953                            field.run_properties.as_ref(),
2954                        )?;
2955                        style.hyperlink_url = resolve_direct_hyperlink(
2956                            field.run_properties.as_ref(),
2957                            source,
2958                            hyperlinks,
2959                            diagnostics,
2960                        );
2961                        Ok(ResolvedTextRun::Field {
2962                            text: field
2963                                .text
2964                                .as_ref()
2965                                .map(|text| text.value.clone())
2966                                .unwrap_or_default(),
2967                            field_type: field.field_type.clone(),
2968                            style,
2969                        })
2970                    }
2971                })
2972                .collect::<Result<Vec<_>, ResolveError>>()?;
2973            let direction = paragraph_base_direction(paragraph_properties.right_to_left);
2974            Ok((
2975                ResolvedParagraph {
2976                    level: paragraph_properties.level.unwrap_or(0),
2977                    left_margin: emu_to_points(i64::from(
2978                        paragraph_properties.left_margin.unwrap_or(0),
2979                    )),
2980                    right_margin: emu_to_points(i64::from(
2981                        paragraph_properties.right_margin.unwrap_or(0),
2982                    )),
2983                    indent: emu_to_points(i64::from(paragraph_properties.indent.unwrap_or(0))),
2984                    alignment: paragraph_alignment(paragraph_properties.alignment),
2985                    line_spacing: resolved_text_spacing(paragraph_properties.line_spacing.as_ref()),
2986                    space_before: resolved_text_spacing(paragraph_properties.space_before.as_ref()),
2987                    space_after: resolved_text_spacing(paragraph_properties.space_after.as_ref()),
2988                    bullet: paragraph_properties
2989                        .bullet
2990                        .as_ref()
2991                        .and_then(|bullet| context.resolve_bullet(bullet).transpose())
2992                        .transpose()?,
2993                    end_style: context.resolve_table_run_style(
2994                        table_style,
2995                        paragraph.properties.as_ref(),
2996                        paragraph.end_properties.as_ref(),
2997                    )?,
2998                    runs,
2999                },
3000                direction,
3001            ))
3002        })
3003        .collect::<Result<Vec<_>, ResolveError>>()?;
3004    let (paragraphs, directions) = resolved.into_iter().unzip();
3005    Ok((resolved_text_body(&properties, paragraphs)?, directions))
3006}
3007
3008fn resolved_text_body(
3009    properties: &CT_TextBodyProperties,
3010    paragraphs: Vec<ResolvedParagraph>,
3011) -> Result<ResolvedTextBody, ResolveError> {
3012    let insets = TextInsets {
3013        left: coordinate_points(properties.left_inset.as_ref())?,
3014        top: coordinate_points(properties.top_inset.as_ref())?,
3015        right: coordinate_points(properties.right_inset.as_ref())?,
3016        bottom: coordinate_points(properties.bottom_inset.as_ref())?,
3017    };
3018    let anchor = match properties.anchor.unwrap_or(DrawingTextAnchor::Top) {
3019        DrawingTextAnchor::Top => TextAnchor::Top,
3020        DrawingTextAnchor::Center => TextAnchor::Center,
3021        DrawingTextAnchor::Bottom => TextAnchor::Bottom,
3022        DrawingTextAnchor::Justified => TextAnchor::Justified,
3023        DrawingTextAnchor::Distributed => TextAnchor::Distributed,
3024    };
3025    let vertical = match properties
3026        .vertical
3027        .unwrap_or(DrawingTextVertical::Horizontal)
3028    {
3029        DrawingTextVertical::Horizontal => TextDirection::Horizontal,
3030        DrawingTextVertical::Vertical => TextDirection::Vertical,
3031        DrawingTextVertical::Vertical270 => TextDirection::Vertical270,
3032        DrawingTextVertical::EastAsianVertical => TextDirection::EastAsianVertical,
3033        DrawingTextVertical::MongolianVertical => TextDirection::MongolianVertical,
3034        DrawingTextVertical::WordArtVertical => TextDirection::WordArtVertical,
3035        DrawingTextVertical::WordArtVerticalRtl => TextDirection::WordArtVerticalRtl,
3036    };
3037    let autofit = match properties.autofit.clone().unwrap_or(TextAutofit::NoAutofit) {
3038        TextAutofit::NoAutofit => ResolvedAutofit::None,
3039        TextAutofit::ShapeAutofit => ResolvedAutofit::Shape,
3040        TextAutofit::Normal(normal) => ResolvedAutofit::Normal {
3041            font_scale: normal.font_scale.as_deref().and_then(parse_percent),
3042            line_spacing_reduction: normal
3043                .line_spacing_reduction
3044                .as_deref()
3045                .and_then(parse_percent),
3046        },
3047    };
3048    Ok(ResolvedTextBody {
3049        insets,
3050        anchor,
3051        wrap: properties.wrap.unwrap_or(TextWrap::Square) != TextWrap::None,
3052        vertical,
3053        space_first_last_paragraph: properties.space_first_last_paragraph.unwrap_or(false),
3054        autofit,
3055        paragraphs,
3056    })
3057}
3058
3059#[derive(Clone, Copy)]
3060struct LatentPolicy {
3061    date_time: bool,
3062    footer: bool,
3063    slide_number: bool,
3064}
3065
3066impl LatentPolicy {
3067    fn from_header_footer(header_footer: &CT_HeaderFooter) -> Self {
3068        Self {
3069            date_time: header_footer.date_time_enabled(),
3070            footer: header_footer.footer_enabled(),
3071            slide_number: header_footer.slide_number_enabled(),
3072        }
3073    }
3074
3075    fn permits(self, ph_type: &PhType) -> bool {
3076        match ph_type {
3077            PhType::DateTime => self.date_time,
3078            PhType::Footer => self.footer,
3079            PhType::SlideNumber => self.slide_number,
3080            _ => true,
3081        }
3082    }
3083}
3084
3085#[derive(Clone, Copy)]
3086struct PassRules<'a> {
3087    source: FlattenedSource,
3088    emit_non_placeholders: bool,
3089    deeper_latent: &'a [PlaceholderKey],
3090    latent_policy: Option<LatentPolicy>,
3091}
3092
3093fn collect_occupied_latent(children: &[ShapeTreeChild], keys: &mut Vec<PlaceholderKey>) {
3094    for child in children {
3095        match child {
3096            ShapeTreeChild::GroupShape(group) => collect_occupied_latent(&group.children, keys),
3097            ShapeTreeChild::AlternateContent(alternate) => {
3098                if let Some(fallback) = alternate.selected_fallback() {
3099                    collect_occupied_latent(fallback, keys);
3100                }
3101            }
3102            ShapeTreeChild::Shape(shape) => {
3103                if let Some(placeholder) = shape.placeholder.as_ref()
3104                    && is_latent(&placeholder.effective_type())
3105                    && shape
3106                        .text_body
3107                        .as_ref()
3108                        .is_some_and(|body| !body.plain_text().trim().is_empty())
3109                {
3110                    push_unmatched(keys, placeholder.key());
3111                }
3112            }
3113            _ => {}
3114        }
3115    }
3116}
3117
3118fn push_unmatched(keys: &mut Vec<PlaceholderKey>, key: PlaceholderKey) {
3119    if !keys
3120        .iter()
3121        .any(|existing| placeholder_keys_match(&key, existing))
3122    {
3123        keys.push(key);
3124    }
3125}
3126
3127fn find_group_lineage(
3128    children: &[ShapeTreeChild],
3129    target: &ShapeTreeChild,
3130    lineage: &mut Vec<u32>,
3131    found: &mut Vec<u32>,
3132) -> bool {
3133    for child in children {
3134        if std::ptr::eq(child, target) {
3135            *found = lineage.clone();
3136            return true;
3137        }
3138        match child {
3139            ShapeTreeChild::GroupShape(group) => {
3140                if let Some(id) = child.non_visual_id() {
3141                    lineage.push(id);
3142                }
3143                if find_group_lineage(&group.children, target, lineage, found) {
3144                    return true;
3145                }
3146                if child.non_visual_id().is_some() {
3147                    lineage.pop();
3148                }
3149            }
3150            ShapeTreeChild::AlternateContent(alternate) => {
3151                if let Some(fallback) = alternate.selected_fallback()
3152                    && find_group_lineage(fallback, target, lineage, found)
3153                {
3154                    return true;
3155                }
3156            }
3157            _ => {}
3158        }
3159    }
3160    false
3161}
3162
3163fn apply_timeline_state(
3164    mut slide: ResolvedSlide,
3165    identities: Vec<ResolvedShapeIdentity>,
3166    mut state: EvaluatedFrameState,
3167    mut group_geometries: BTreeMap<u32, TimelineGroupGeometry>,
3168) -> ResolvedTimelineSlide {
3169    let invalid_targets = state
3170        .shapes
3171        .iter()
3172        .filter_map(|(target, evaluated)| (!evaluated.is_finite()).then_some(*target))
3173        .collect::<Vec<_>>();
3174    for target in invalid_targets {
3175        state.shapes.insert(target, EvaluatedShapeState::default());
3176        state.diagnostics.push(Diagnostic {
3177            message: format!("non-finite timeline state ignored for target {target}"),
3178        });
3179    }
3180    let slide_size = slide.size;
3181    for (group_id, bounds) in timeline_group_bounds(&slide, &identities) {
3182        group_geometries
3183            .entry(group_id)
3184            .or_insert_with(|| TimelineGroupGeometry::from_page_bounds(bounds));
3185    }
3186    let shape_states = identities
3187        .iter()
3188        .zip(&mut slide.shapes)
3189        .map(|(identity, shape)| {
3190            if identity.source != FlattenedSource::Slide {
3191                return EvaluatedShapeState::default();
3192            }
3193            let mut evaluated = EvaluatedShapeState::default();
3194            let original_bounds = shape.bounds;
3195            let original_shape = shape.clone();
3196            let mut group_transform = Transform::IDENTITY;
3197            let mut group_page_clips = Vec::new();
3198            for group_id in &identity.containing_group_ids {
3199                let Some(target_state) = state.shapes.get(group_id) else {
3200                    continue;
3201                };
3202                evaluated.visible &= target_state.visible;
3203                evaluated.opacity *= target_state.opacity;
3204                let Some(geometry) = group_geometries.get(group_id).copied() else {
3205                    continue;
3206                };
3207                let target_transform =
3208                    target_state.resolved_page_transform(geometry.center(), slide_size);
3209                group_transform = target_transform.then(group_transform);
3210                if let Some(clip) = target_state.clip {
3211                    group_page_clips.push(group_clip_page_points(clip, geometry, group_transform));
3212                }
3213            }
3214            let direct_state = identity.shape_id.and_then(|id| state.shapes.get(&id));
3215            if let Some(direct_state) = direct_state {
3216                evaluated.visible &= direct_state.visible;
3217                evaluated.opacity *= direct_state.opacity;
3218                evaluated.clip = intersect_optional_rect(evaluated.clip, direct_state.clip);
3219            }
3220            let (scale_x, scale_y, rotation_deg) = direct_state.map_or(
3221                (1.0, 1.0, 0.0),
3222                EvaluatedShapeState::resolved_shape_geometry,
3223            );
3224            let original_page_bounds = resolved_shape_page_bounds(&original_shape);
3225            let original_page_center = (
3226                original_page_bounds.x + original_page_bounds.width / 2.0,
3227                original_page_bounds.y + original_page_bounds.height / 2.0,
3228            );
3229            let page_motion = direct_state.map_or(Transform::IDENTITY, |direct_state| {
3230                direct_state.resolved_motion_translation(original_page_center, slide_size)
3231            });
3232            evaluated.transform = page_motion.then(group_transform);
3233            let animated_bounds = Rect {
3234                x: original_bounds.x + original_bounds.width * (1.0 - scale_x) / 2.0,
3235                y: original_bounds.y + original_bounds.height * (1.0 - scale_y) / 2.0,
3236                width: original_bounds.width * scale_x,
3237                height: original_bounds.height * scale_y,
3238            };
3239            let animated_rotation = shape.rotation_deg + rotation_deg;
3240            let mut animated_shape = shape.clone();
3241            animated_shape.bounds = animated_bounds;
3242            animated_shape.rotation_deg = animated_rotation;
3243            animated_shape.group_transform = animated_shape
3244                .group_transform
3245                .then(page_motion)
3246                .then(group_transform);
3247            for page_clip in group_page_clips {
3248                match page_clip_for_shape(page_clip, &animated_shape) {
3249                    Some(clip) => evaluated.push_oriented_clip(clip),
3250                    None => state.diagnostics.push(Diagnostic {
3251                        message: format!(
3252                            "group timeline clip ignored for shape {}",
3253                            identity.shape_id.unwrap_or(0)
3254                        ),
3255                    }),
3256                }
3257            }
3258            if evaluated.is_finite()
3259                && [
3260                    animated_bounds.x,
3261                    animated_bounds.y,
3262                    animated_bounds.width,
3263                    animated_bounds.height,
3264                    animated_rotation,
3265                ]
3266                .into_iter()
3267                .all(f64::is_finite)
3268            {
3269                *shape = animated_shape;
3270            } else {
3271                state.diagnostics.push(Diagnostic {
3272                    message: format!(
3273                        "non-finite timeline state ignored for shape {}",
3274                        identity.shape_id.unwrap_or(0)
3275                    ),
3276                });
3277                evaluated = EvaluatedShapeState::default();
3278            }
3279            evaluated
3280        })
3281        .collect();
3282    ResolvedTimelineSlide {
3283        slide,
3284        identities,
3285        shape_states,
3286        state,
3287    }
3288}
3289
3290#[derive(Clone, Copy)]
3291struct TimelineGroupGeometry {
3292    normalized_to_page: Transform,
3293}
3294
3295impl TimelineGroupGeometry {
3296    fn from_page_bounds(bounds: Rect) -> Self {
3297        Self {
3298            normalized_to_page: Transform {
3299                a: bounds.width,
3300                d: bounds.height,
3301                e: bounds.x,
3302                f: bounds.y,
3303                ..Transform::IDENTITY
3304            },
3305        }
3306    }
3307
3308    fn center(self) -> (f64, f64) {
3309        let center = self.normalized_to_page.apply(Point { x: 0.5, y: 0.5 });
3310        (center.x, center.y)
3311    }
3312}
3313
3314fn timeline_group_geometries(children: &[ShapeTreeChild]) -> BTreeMap<u32, TimelineGroupGeometry> {
3315    let mut geometries = BTreeMap::new();
3316    collect_timeline_group_geometries(children, Transform::IDENTITY, &mut geometries);
3317    geometries
3318}
3319
3320fn collect_timeline_group_geometries(
3321    children: &[ShapeTreeChild],
3322    parent_to_page: Transform,
3323    geometries: &mut BTreeMap<u32, TimelineGroupGeometry>,
3324) {
3325    for child in children {
3326        match child {
3327            ShapeTreeChild::GroupShape(group) => {
3328                let child_to_parent = group
3329                    .group_transform()
3330                    .and_then(group_affine)
3331                    .map_or(Transform::IDENTITY, |(transform, _)| transform);
3332                if let Some((group_id, geometry)) = child
3333                    .non_visual_id()
3334                    .zip(group.group_transform().and_then(group_extent_geometry))
3335                {
3336                    geometries.insert(
3337                        group_id,
3338                        TimelineGroupGeometry {
3339                            normalized_to_page: geometry.then(parent_to_page),
3340                        },
3341                    );
3342                }
3343                collect_timeline_group_geometries(
3344                    &group.children,
3345                    child_to_parent.then(parent_to_page),
3346                    geometries,
3347                );
3348            }
3349            ShapeTreeChild::AlternateContent(alternate) => {
3350                if let Some(fallback) = alternate.selected_fallback() {
3351                    collect_timeline_group_geometries(fallback, parent_to_page, geometries);
3352                }
3353            }
3354            _ => {}
3355        }
3356    }
3357}
3358
3359fn group_extent_geometry(transform: &CT_Transform2D) -> Option<Transform> {
3360    let offset = transform.offset?;
3361    let extent = transform.extent?;
3362    let x = emu_to_points(offset.x.0);
3363    let y = emu_to_points(offset.y.0);
3364    let width = emu_to_points(extent.cx.0);
3365    let height = emu_to_points(extent.cy.0);
3366    let center_x = x + width / 2.0;
3367    let center_y = y + height / 2.0;
3368    let mut geometry = Transform {
3369        a: width,
3370        d: height,
3371        e: x,
3372        f: y,
3373        ..Transform::IDENTITY
3374    }
3375    .then(Transform::rotate_about(
3376        f64::from(transform.rotation.0) / 60_000.0,
3377        center_x,
3378        center_y,
3379    ));
3380    if transform.flip_horizontal || transform.flip_vertical {
3381        geometry = geometry.then(Transform {
3382            a: if transform.flip_horizontal { -1.0 } else { 1.0 },
3383            d: if transform.flip_vertical { -1.0 } else { 1.0 },
3384            e: if transform.flip_horizontal {
3385                2.0 * center_x
3386            } else {
3387                0.0
3388            },
3389            f: if transform.flip_vertical {
3390                2.0 * center_y
3391            } else {
3392                0.0
3393            },
3394            ..Transform::IDENTITY
3395        });
3396    }
3397    [
3398        geometry.a, geometry.b, geometry.c, geometry.d, geometry.e, geometry.f,
3399    ]
3400    .into_iter()
3401    .all(f64::is_finite)
3402    .then_some(geometry)
3403}
3404
3405fn timeline_group_bounds(
3406    slide: &ResolvedSlide,
3407    identities: &[ResolvedShapeIdentity],
3408) -> BTreeMap<u32, Rect> {
3409    let mut bounds = BTreeMap::new();
3410    for (identity, shape) in identities.iter().zip(&slide.shapes) {
3411        if identity.source != FlattenedSource::Slide {
3412            continue;
3413        }
3414        let shape_bounds = resolved_shape_page_bounds(shape);
3415        for group_id in &identity.containing_group_ids {
3416            bounds
3417                .entry(*group_id)
3418                .and_modify(|group_bounds| *group_bounds = union_rect(*group_bounds, shape_bounds))
3419                .or_insert(shape_bounds);
3420        }
3421    }
3422    bounds
3423}
3424
3425fn resolved_shape_page_bounds(shape: &ResolvedShape) -> Rect {
3426    resolved_shape_page_transform(shape).transform_rect_bbox(Rect {
3427        x: 0.0,
3428        y: 0.0,
3429        width: shape.bounds.width,
3430        height: shape.bounds.height,
3431    })
3432}
3433
3434fn resolved_shape_page_transform(shape: &ResolvedShape) -> Transform {
3435    let center_x = shape.bounds.width / 2.0;
3436    let center_y = shape.bounds.height / 2.0;
3437    let flip = Transform {
3438        a: if shape.flip_h { -1.0 } else { 1.0 },
3439        d: if shape.flip_v { -1.0 } else { 1.0 },
3440        e: if shape.flip_h {
3441            shape.bounds.width
3442        } else {
3443            0.0
3444        },
3445        f: if shape.flip_v {
3446            shape.bounds.height
3447        } else {
3448            0.0
3449        },
3450        ..Transform::IDENTITY
3451    };
3452    Transform::rotate_about(shape.rotation_deg, center_x, center_y)
3453        .then(flip)
3454        .then(timeline_translation(shape.bounds.x, shape.bounds.y))
3455        .then(shape.group_transform)
3456}
3457
3458fn group_clip_page_points(
3459    clip: Rect,
3460    group_geometry: TimelineGroupGeometry,
3461    group_transform: Transform,
3462) -> [Point; 4] {
3463    let normalized_to_page = group_geometry.normalized_to_page.then(group_transform);
3464    [
3465        Point {
3466            x: clip.x,
3467            y: clip.y,
3468        },
3469        Point {
3470            x: clip.x + clip.width,
3471            y: clip.y,
3472        },
3473        Point {
3474            x: clip.x + clip.width,
3475            y: clip.y + clip.height,
3476        },
3477        Point {
3478            x: clip.x,
3479            y: clip.y + clip.height,
3480        },
3481    ]
3482    .map(|point| normalized_to_page.apply(point))
3483}
3484
3485fn page_clip_for_shape(page_clip: [Point; 4], shape: &ResolvedShape) -> Option<[Point; 4]> {
3486    if shape.bounds.width.abs() <= f64::EPSILON || shape.bounds.height.abs() <= f64::EPSILON {
3487        return None;
3488    }
3489    let page_to_local = inverse_transform(resolved_shape_page_transform(shape))?;
3490    Some(page_clip.map(|point| {
3491        let local = page_to_local.apply(point);
3492        Point {
3493            x: local.x / shape.bounds.width,
3494            y: local.y / shape.bounds.height,
3495        }
3496    }))
3497}
3498
3499fn inverse_transform(transform: Transform) -> Option<Transform> {
3500    let determinant = transform.a * transform.d - transform.b * transform.c;
3501    if !determinant.is_finite() || determinant.abs() <= f64::EPSILON {
3502        return None;
3503    }
3504    let inverse = Transform {
3505        a: transform.d / determinant,
3506        b: -transform.b / determinant,
3507        c: -transform.c / determinant,
3508        d: transform.a / determinant,
3509        e: (transform.c * transform.f - transform.d * transform.e) / determinant,
3510        f: (transform.b * transform.e - transform.a * transform.f) / determinant,
3511    };
3512    [
3513        inverse.a, inverse.b, inverse.c, inverse.d, inverse.e, inverse.f,
3514    ]
3515    .into_iter()
3516    .all(f64::is_finite)
3517    .then_some(inverse)
3518}
3519
3520fn union_rect(left: Rect, right: Rect) -> Rect {
3521    let x = left.x.min(right.x);
3522    let y = left.y.min(right.y);
3523    let far_x = (left.x + left.width).max(right.x + right.width);
3524    let far_y = (left.y + left.height).max(right.y + right.height);
3525    Rect {
3526        x,
3527        y,
3528        width: far_x - x,
3529        height: far_y - y,
3530    }
3531}
3532
3533fn intersect_rect(left: Rect, right: Rect) -> Rect {
3534    let x = left.x.max(right.x);
3535    let y = left.y.max(right.y);
3536    let far_x = (left.x + left.width).min(right.x + right.width);
3537    let far_y = (left.y + left.height).min(right.y + right.height);
3538    Rect {
3539        x,
3540        y,
3541        width: (far_x - x).max(0.0),
3542        height: (far_y - y).max(0.0),
3543    }
3544}
3545
3546fn timeline_translation(x: f64, y: f64) -> Transform {
3547    Transform {
3548        e: x,
3549        f: y,
3550        ..Transform::IDENTITY
3551    }
3552}
3553
3554fn intersect_optional_rect(left: Option<Rect>, right: Option<Rect>) -> Option<Rect> {
3555    match (left, right) {
3556        (None, None) => None,
3557        (Some(rect), None) | (None, Some(rect)) => Some(rect),
3558        (Some(left), Some(right)) => Some(intersect_rect(left, right)),
3559    }
3560}
3561
3562fn emit_tree<'a>(
3563    children: &'a [ShapeTreeChild],
3564    rules: PassRules<'_>,
3565    output: &mut Vec<FlattenedItem<'a>>,
3566) {
3567    let mut emitted_latent = Vec::new();
3568    emit_tree_inner(
3569        children,
3570        rules,
3571        Transform::IDENTITY,
3572        0,
3573        &mut emitted_latent,
3574        output,
3575    );
3576}
3577
3578fn emit_tree_inner<'a>(
3579    children: &'a [ShapeTreeChild],
3580    rules: PassRules<'_>,
3581    parent_transform: Transform,
3582    parent_issues: u8,
3583    emitted_latent: &mut Vec<PlaceholderKey>,
3584    output: &mut Vec<FlattenedItem<'a>>,
3585) {
3586    for child in children {
3587        match child {
3588            ShapeTreeChild::GroupShape(group) => {
3589                let (transform, issues) = group
3590                    .group_transform()
3591                    .and_then(group_affine)
3592                    .unwrap_or((Transform::IDENTITY, 0));
3593                emit_tree_inner(
3594                    &group.children,
3595                    rules,
3596                    transform.then(parent_transform),
3597                    parent_issues | issues,
3598                    emitted_latent,
3599                    output,
3600                );
3601            }
3602            ShapeTreeChild::AlternateContent(alternate) => {
3603                if alternate.chart_choice().is_some() {
3604                    emit_leaf(
3605                        child,
3606                        rules,
3607                        parent_transform,
3608                        parent_issues,
3609                        emitted_latent,
3610                        output,
3611                    );
3612                } else if let Some(fallback) = alternate.selected_fallback() {
3613                    emit_tree_inner(
3614                        fallback,
3615                        rules,
3616                        parent_transform,
3617                        parent_issues,
3618                        emitted_latent,
3619                        output,
3620                    );
3621                }
3622            }
3623            _ => emit_leaf(
3624                child,
3625                rules,
3626                parent_transform,
3627                parent_issues,
3628                emitted_latent,
3629                output,
3630            ),
3631        }
3632    }
3633}
3634
3635fn emit_leaf<'a>(
3636    child: &'a ShapeTreeChild,
3637    rules: PassRules<'_>,
3638    group_transform: Transform,
3639    group_issues: u8,
3640    emitted_latent: &mut Vec<PlaceholderKey>,
3641    output: &mut Vec<FlattenedItem<'a>>,
3642) {
3643    let placeholder = child_placeholder(child);
3644    if rules.source == FlattenedSource::Slide {
3645        if let Some(placeholder) = placeholder {
3646            let ph_type = placeholder.effective_type();
3647            if is_latent(&ph_type) {
3648                let occupied = child_is_occupied(child);
3649                let already_emitted = emitted_latent
3650                    .iter()
3651                    .any(|key| placeholder_keys_match(&placeholder.key(), key));
3652                if !occupied || already_emitted {
3653                    return;
3654                }
3655                push_unmatched(emitted_latent, placeholder.key());
3656            }
3657        }
3658        push_flattened_shape(output, rules.source, child, group_transform, group_issues);
3659        return;
3660    }
3661
3662    let Some(placeholder) = placeholder else {
3663        if rules.emit_non_placeholders {
3664            push_flattened_shape(output, rules.source, child, group_transform, group_issues);
3665        }
3666        return;
3667    };
3668    let ph_type = placeholder.effective_type();
3669    let key = placeholder.key();
3670    if !is_latent(&ph_type)
3671        || !rules
3672            .latent_policy
3673            .is_some_and(|policy| policy.permits(&ph_type))
3674        || !child_is_occupied(child)
3675        || rules
3676            .deeper_latent
3677            .iter()
3678            .any(|deeper| placeholder_keys_match(&key, deeper))
3679        || emitted_latent
3680            .iter()
3681            .any(|emitted| placeholder_keys_match(&key, emitted))
3682    {
3683        return;
3684    }
3685    push_unmatched(emitted_latent, key);
3686    push_flattened_shape(output, rules.source, child, group_transform, group_issues);
3687}
3688
3689fn push_flattened_shape<'a>(
3690    output: &mut Vec<FlattenedItem<'a>>,
3691    source: FlattenedSource,
3692    child: &'a ShapeTreeChild,
3693    group_transform: Transform,
3694    group_issues: u8,
3695) {
3696    let (group_scale, group_transform, group_issues) =
3697        split_group_transform(group_transform, group_issues);
3698    output.push(FlattenedItem::Shape {
3699        source,
3700        child,
3701        group_scale,
3702        group_transform,
3703        group_issues,
3704    });
3705}
3706
3707fn split_group_transform(transform: Transform, mut issues: u8) -> ((f64, f64), Transform, u8) {
3708    let scale_x = transform.a.hypot(transform.b);
3709    let scale_y = transform.c.hypot(transform.d);
3710    let dot = transform.a * transform.c + transform.b * transform.d;
3711    let denominator = scale_x * scale_y;
3712    let is_rigid_after_scale = [
3713        transform.a,
3714        transform.b,
3715        transform.c,
3716        transform.d,
3717        transform.e,
3718        transform.f,
3719        scale_x,
3720        scale_y,
3721    ]
3722    .into_iter()
3723    .all(f64::is_finite)
3724        && scale_x > f64::EPSILON
3725        && scale_y > f64::EPSILON
3726        && (dot / denominator).abs() <= 1.0e-9;
3727
3728    if !is_rigid_after_scale {
3729        issues |= GROUP_SHEAR;
3730        return ((1.0, 1.0), transform, issues);
3731    }
3732
3733    (
3734        (scale_x, scale_y),
3735        Transform {
3736            a: transform.a / scale_x,
3737            b: transform.b / scale_x,
3738            c: transform.c / scale_y,
3739            d: transform.d / scale_y,
3740            e: transform.e,
3741            f: transform.f,
3742        },
3743        issues,
3744    )
3745}
3746
3747fn group_affine(transform: &CT_Transform2D) -> Option<(Transform, u8)> {
3748    let offset = transform.offset?;
3749    let extent = transform.extent?;
3750    let child_offset = transform.child_offset?;
3751    let child_extent = transform.child_extent?;
3752    let mut issues = 0;
3753    let x = emu_to_points(offset.x.0);
3754    let y = emu_to_points(offset.y.0);
3755    let width = emu_to_points(extent.cx.0);
3756    let height = emu_to_points(extent.cy.0);
3757    let child_x = emu_to_points(child_offset.x.0);
3758    let child_y = emu_to_points(child_offset.y.0);
3759    let scale_x = if child_extent.cx.0 == 0 {
3760        issues |= GROUP_ZERO_X;
3761        1.0
3762    } else {
3763        extent.cx.0 as f64 / child_extent.cx.0 as f64
3764    };
3765    let scale_y = if child_extent.cy.0 == 0 {
3766        issues |= GROUP_ZERO_Y;
3767        1.0
3768    } else {
3769        extent.cy.0 as f64 / child_extent.cy.0 as f64
3770    };
3771    let mut affine = Transform {
3772        a: scale_x,
3773        b: 0.0,
3774        c: 0.0,
3775        d: scale_y,
3776        e: x - child_x * scale_x,
3777        f: y - child_y * scale_y,
3778    };
3779    let center_x = x + width / 2.0;
3780    let center_y = y + height / 2.0;
3781    affine = affine.then(Transform::rotate_about(
3782        f64::from(transform.rotation.0) / 60_000.0,
3783        center_x,
3784        center_y,
3785    ));
3786    if transform.flip_horizontal || transform.flip_vertical {
3787        affine = affine.then(Transform {
3788            a: if transform.flip_horizontal { -1.0 } else { 1.0 },
3789            b: 0.0,
3790            c: 0.0,
3791            d: if transform.flip_vertical { -1.0 } else { 1.0 },
3792            e: if transform.flip_horizontal {
3793                2.0 * center_x
3794            } else {
3795                0.0
3796            },
3797            f: if transform.flip_vertical {
3798                2.0 * center_y
3799            } else {
3800                0.0
3801            },
3802        });
3803    }
3804    Some((affine, issues))
3805}
3806
3807fn child_placeholder(child: &ShapeTreeChild) -> Option<&CT_Placeholder> {
3808    match child {
3809        ShapeTreeChild::Shape(shape) => shape.placeholder.as_ref(),
3810        ShapeTreeChild::Picture(picture) => picture.placeholder.as_ref(),
3811        _ => None,
3812    }
3813}
3814
3815fn child_is_occupied(child: &ShapeTreeChild) -> bool {
3816    match child {
3817        ShapeTreeChild::Shape(shape) => shape
3818            .text_body
3819            .as_ref()
3820            .is_some_and(|body| !body.plain_text().trim().is_empty()),
3821        ShapeTreeChild::Picture(picture) => picture.blip_fill.is_some(),
3822        _ => false,
3823    }
3824}
3825
3826fn is_latent(ph_type: &PhType) -> bool {
3827    matches!(
3828        ph_type,
3829        PhType::DateTime | PhType::Footer | PhType::SlideNumber
3830    )
3831}
3832
3833fn placeholder_keys_match(left: &PlaceholderKey, right: &PlaceholderKey) -> bool {
3834    if is_latent(&left.ph_type) && is_latent(&right.ph_type) {
3835        left.ph_type == right.ph_type
3836    } else {
3837        left.matches(right)
3838    }
3839}
3840
3841fn default_body_properties() -> CT_TextBodyProperties {
3842    let mut properties = CT_TextBodyProperties::default();
3843    properties.left_inset = Some(Coordinate32Value::Emu(91_440));
3844    properties.top_inset = Some(Coordinate32Value::Emu(45_720));
3845    properties.right_inset = Some(Coordinate32Value::Emu(91_440));
3846    properties.bottom_inset = Some(Coordinate32Value::Emu(45_720));
3847    properties.anchor = Some(DrawingTextAnchor::Top);
3848    properties.wrap = Some(TextWrap::Square);
3849    properties.vertical = Some(DrawingTextVertical::Horizontal);
3850    properties.space_first_last_paragraph = Some(false);
3851    properties.autofit = Some(TextAutofit::NoAutofit);
3852    properties
3853}
3854
3855fn merge_body_properties(target: &mut CT_TextBodyProperties, source: &CT_TextBodyProperties) {
3856    if let Some(value) = &source.left_inset {
3857        target.left_inset = Some(value.clone());
3858    }
3859    if let Some(value) = &source.top_inset {
3860        target.top_inset = Some(value.clone());
3861    }
3862    if let Some(value) = &source.right_inset {
3863        target.right_inset = Some(value.clone());
3864    }
3865    if let Some(value) = &source.bottom_inset {
3866        target.bottom_inset = Some(value.clone());
3867    }
3868    if let Some(value) = source.anchor {
3869        target.anchor = Some(value);
3870    }
3871    if let Some(value) = source.wrap {
3872        target.wrap = Some(value);
3873    }
3874    if let Some(value) = source.vertical {
3875        target.vertical = Some(value);
3876    }
3877    if let Some(value) = source.space_first_last_paragraph {
3878        target.space_first_last_paragraph = Some(value);
3879    }
3880    if let Some(value) = &source.autofit {
3881        target.autofit = Some(value.clone());
3882    }
3883}
3884
3885/// Returns the transform a slide placeholder inherits, ignoring its own.
3886///
3887/// The placeholder matches a layout placeholder, whose own key then matches
3888/// a master placeholder. The first of the two that carries a transform
3889/// supplies it whole, which is the fallback [`ResolveCtx::effective_xfrm`]
3890/// takes when the slide shape has no transform of its own.
3891pub fn inherited_xfrm<'a>(
3892    placeholder: &CT_Placeholder,
3893    layout: &'a CT_SlideLayout,
3894    master: &'a CT_SlideMaster,
3895) -> Option<&'a CT_Transform2D> {
3896    let (layout, master) = placeholder_chain(placeholder, layout, master);
3897    [layout, master]
3898        .into_iter()
3899        .flatten()
3900        .find_map(|shape| shape.shape_properties.transform.as_ref())
3901}
3902
3903fn placeholder_chain<'a>(
3904    placeholder: &CT_Placeholder,
3905    layout: &'a CT_SlideLayout,
3906    master: &'a CT_SlideMaster,
3907) -> (Option<&'a CT_Shape>, Option<&'a CT_Shape>) {
3908    let Some(layout_shape) = find_placeholder(
3909        &layout.common_slide_data.shape_tree.children,
3910        &placeholder.key(),
3911    ) else {
3912        return (None, None);
3913    };
3914    let Some(layout_key) = layout_shape
3915        .placeholder
3916        .as_ref()
3917        .map(|placeholder| placeholder.key())
3918    else {
3919        return (None, None);
3920    };
3921    let master_shape = find_placeholder(&master.common_slide_data.shape_tree.children, &layout_key);
3922    (Some(layout_shape), master_shape)
3923}
3924
3925fn find_placeholder<'a>(
3926    children: &'a [ShapeTreeChild],
3927    key: &PlaceholderKey,
3928) -> Option<&'a CT_Shape> {
3929    for child in children {
3930        let found = match child {
3931            ShapeTreeChild::Shape(shape) => shape
3932                .placeholder
3933                .as_ref()
3934                .is_some_and(|placeholder| placeholder_keys_match(key, &placeholder.key()))
3935                .then_some(shape),
3936            ShapeTreeChild::GroupShape(group) => find_placeholder(&group.children, key),
3937            ShapeTreeChild::AlternateContent(alternate) => alternate
3938                .selected_fallback()
3939                .and_then(|fallback| find_placeholder(fallback, key)),
3940            _ => None,
3941        };
3942        if found.is_some() {
3943            return found;
3944        }
3945    }
3946    None
3947}
3948
3949#[cfg(test)]
3950mod tests {
3951    use std::collections::HashMap;
3952    use std::path::{Path, PathBuf};
3953
3954    use oxml_chart::CT_ChartSpace;
3955    use oxml_drawing::color::ColorMap;
3956    use oxml_drawing::fill::Fill;
3957    use oxml_drawing::text::{
3958        CT_TextListStyle, Coordinate32Value, TextAnchor, TextAutofit, TextVertical, TextWrap,
3959    };
3960    use oxml_drawing::theme::CT_OfficeStyleSheet;
3961    use oxml_opc::OpcPackage;
3962    use oxml_opc::relationship::rel_types;
3963    use rpptx_oxml::placeholder::PhType;
3964    use rpptx_oxml::presentation::CT_Presentation;
3965    use rpptx_oxml::shape_tree::{CT_Shape, ShapeTreeChild};
3966    use rpptx_oxml::slide_parts::{CT_Slide, CT_SlideLayout, CT_SlideMaster, ColorMapOverrideKind};
3967
3968    use super::{
3969        BackgroundSource, FlattenedItem, FlattenedSource, ResolveCtx, inherited_xfrm,
3970        resolved_shape_page_bounds, resolved_shape_page_transform, transform_values,
3971    };
3972    use crate::{
3973        ChartResource, Diagnostic, ParagraphAlignment, ResolvedAutofit, ResolvedBackground,
3974        ResolvedBullet, ResolvedBulletSize, ResolvedContent, ResolvedGeometry,
3975        ResolvedImagePlacement, ResolvedLineEnd, ResolvedLineEndKind, ResolvedLineEndSize,
3976        ResolvedParagraph, ResolvedRectAlignment, ResolvedRunStyle, ResolvedSlide, ResolvedTextRun,
3977        ResolvedTextSpacing, ResolvedTileFlip, ScopedChartResources, ScopedHyperlinkTargets,
3978        ScopedMediaFailures, ScopedMediaIds, TextAnchor as ResolvedTextAnchor, TextDirection,
3979    };
3980    use oxml_layout::{
3981        Color, Effect, FontManager, MediaId, Paint, PathCommand, Point, PositionedElement, Rect,
3982        Transform, walk,
3983    };
3984
3985    const P_NS: &str = "http://schemas.openxmlformats.org/presentationml/2006/main";
3986    const A_NS: &str = "http://schemas.openxmlformats.org/drawingml/2006/main";
3987    const MC_NS: &str = "http://schemas.openxmlformats.org/markup-compatibility/2006";
3988    const EXPECTED_CORPUS_DECKS: usize = 50;
3989
3990    fn assert_close(actual: f64, expected: f64) {
3991        assert!(
3992            (actual - expected).abs() < 1.0e-10,
3993            "expected {expected}, got {actual}"
3994        );
3995    }
3996
3997    struct Fixture {
3998        theme: CT_OfficeStyleSheet,
3999        master: CT_SlideMaster,
4000        layout: CT_SlideLayout,
4001        slide: CT_Slide,
4002        default_text_style: CT_TextListStyle,
4003    }
4004
4005    impl Fixture {
4006        fn new(slide_children: &str, layout_children: &str, master_children: &str) -> Self {
4007            Self::from_xml(
4008                &slide_xml(slide_children),
4009                &layout_xml(layout_children),
4010                &master_xml(master_children),
4011            )
4012        }
4013
4014        fn from_xml(slide_xml: &str, layout_xml: &str, master_xml: &str) -> Self {
4015            Self {
4016                theme: CT_OfficeStyleSheet::office_default(),
4017                master: CT_SlideMaster::from_xml(master_xml.as_bytes()).unwrap(),
4018                layout: CT_SlideLayout::from_xml(layout_xml.as_bytes()).unwrap(),
4019                slide: CT_Slide::from_xml(slide_xml.as_bytes()).unwrap(),
4020                default_text_style: CT_TextListStyle::default(),
4021            }
4022        }
4023
4024        fn context(&self) -> ResolveCtx<'_> {
4025            ResolveCtx::new(
4026                &self.theme,
4027                ColorMap::default(),
4028                &self.master,
4029                &self.layout,
4030                &self.slide,
4031                &self.default_text_style,
4032            )
4033        }
4034
4035        fn slide_shape(&self, index: usize) -> &CT_Shape {
4036            let ShapeTreeChild::Shape(shape) =
4037                &self.slide.common_slide_data.shape_tree.children[index]
4038            else {
4039                panic!("expected an ordinary slide shape");
4040            };
4041            shape
4042        }
4043    }
4044
4045    #[test]
4046    fn slide_placeholder_resolves_to_layout_and_master_counterparts() {
4047        let fixture = Fixture::new(
4048            &shape(Some("title"), Some(7)),
4049            &shape(Some("body"), Some(7)),
4050            &shape(Some("pic"), Some(7)),
4051        );
4052
4053        let context = fixture.context();
4054        let (layout, master) = context.placeholder_chain(fixture.slide_shape(0));
4055
4056        assert_eq!(
4057            layout.unwrap().placeholder.as_ref().unwrap().ph_type,
4058            Some(PhType::Body)
4059        );
4060        assert_eq!(
4061            master.unwrap().placeholder.as_ref().unwrap().ph_type,
4062            Some(PhType::Picture)
4063        );
4064    }
4065
4066    #[test]
4067    fn master_match_uses_the_layout_placeholder_key() {
4068        let fixture = Fixture::new(
4069            &shape(Some("title"), Some(8)),
4070            &shape(Some("ctrTitle"), None),
4071            &shape(Some("title"), Some(5)),
4072        );
4073
4074        let context = fixture.context();
4075        let (layout, master) = context.placeholder_chain(fixture.slide_shape(0));
4076
4077        assert!(layout.is_some());
4078        assert_eq!(master.unwrap().placeholder.as_ref().unwrap().idx, Some(5));
4079    }
4080
4081    #[test]
4082    fn placeholder_lookup_walks_groups_and_selected_fallbacks() {
4083        let nested_layout = format!(
4084            "<p:grpSp><p:nvGrpSpPr/><p:grpSpPr/>{}</p:grpSp>",
4085            shape(Some("body"), Some(11))
4086        );
4087        let fallback_master = format!(
4088            "<mc:AlternateContent><mc:Choice Requires=\"p14\"><p:sp/></mc:Choice><mc:Fallback>{}</mc:Fallback></mc:AlternateContent>",
4089            shape(Some("body"), Some(11))
4090        );
4091        let fixture = Fixture::new(
4092            &shape(Some("body"), Some(11)),
4093            &nested_layout,
4094            &fallback_master,
4095        );
4096
4097        let context = fixture.context();
4098        let (layout, master) = context.placeholder_chain(fixture.slide_shape(0));
4099
4100        assert!(layout.is_some());
4101        assert!(master.is_some());
4102    }
4103
4104    #[test]
4105    fn group_targets_apply_to_every_resolved_descendant() {
4106        let first = shape_with_details(None, None, &transform(0), None).replacen(
4107            "<p:cNvPr/>",
4108            "<p:cNvPr id=\"7\" name=\"First\"/>",
4109            1,
4110        );
4111        let second = shape_with_details(None, None, &transform(127_000), None).replacen(
4112            "<p:cNvPr/>",
4113            "<p:cNvPr id=\"8\" name=\"Second\"/>",
4114            1,
4115        );
4116        let unrelated = shape_with_details(None, None, &transform(254_000), None).replacen(
4117            "<p:cNvPr/>",
4118            "<p:cNvPr id=\"9\" name=\"Other\"/>",
4119            1,
4120        );
4121        let group = format!(
4122            "<p:grpSp><p:nvGrpSpPr><p:cNvPr id=\"10\" name=\"Group\"/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/>{first}{second}</p:grpSp>"
4123        );
4124        let slide = format!(
4125            "<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>",
4126            shape_tree(&format!("{group}{unrelated}"))
4127        );
4128        let fixture = Fixture::from_xml(&slide, &layout_xml(""), &master_xml(""));
4129        let resolved = fixture
4130            .context()
4131            .resolve_slide_at(
4132                (720.0, 540.0),
4133                crate::timeline::TimelinePosition {
4134                    elapsed_ms: 1,
4135                    click_count: 0,
4136                },
4137            )
4138            .unwrap();
4139
4140        assert_eq!(resolved.shape_states.len(), 3);
4141        assert_eq!(resolved.shape_states[0].opacity, 0.5);
4142        assert_eq!(resolved.shape_states[1].opacity, 0.5);
4143        assert_eq!(resolved.shape_states[2].opacity, 1.0);
4144        assert_eq!(resolved.identities[0].containing_group_ids, vec![10]);
4145        assert_eq!(resolved.identities[1].containing_group_ids, vec![10]);
4146        assert!(resolved.identities[2].containing_group_ids.is_empty());
4147    }
4148
4149    #[test]
4150    fn alternate_content_chart_choice_keeps_timeline_identity() {
4151        let alternate = chart_alternate("chart", None).replacen(
4152            "<p:nvGraphicFramePr/>",
4153            "<p:nvGraphicFramePr><p:cNvPr id=\"42\" name=\"!!Chart &amp; Data\"/><p:cNvGraphicFramePr/><p:nvPr/></p:nvGraphicFramePr>",
4154            1,
4155        );
4156        let slide = format!(
4157            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>"#,
4158            shape_tree(&alternate)
4159        );
4160        let fixture = Fixture::from_xml(&slide, &layout_xml(""), &master_xml(""));
4161        let resolved = fixture
4162            .context()
4163            .resolve_slide_at(
4164                (720.0, 540.0),
4165                crate::timeline::TimelinePosition {
4166                    elapsed_ms: 500,
4167                    click_count: 0,
4168                },
4169            )
4170            .unwrap();
4171
4172        assert_eq!(resolved.identities[0].shape_id, Some(42));
4173        assert_eq!(
4174            resolved.identities[0].name.as_deref(),
4175            Some("!!Chart & Data")
4176        );
4177        assert_eq!(resolved.shape_states[0].opacity, 0.75);
4178    }
4179
4180    #[test]
4181    fn timeline_geometry_keeps_shape_centres_and_resolves_motion_origins() {
4182        let first = shape_with_details(None, None, &transform(127_000), None).replacen(
4183            "<p:cNvPr/>",
4184            "<p:cNvPr id=\"7\" name=\"!!Scale &amp; Spin\"/>",
4185            1,
4186        );
4187        let second = shape_with_details(None, None, &transform(254_000), None).replacen(
4188            "<p:cNvPr/>",
4189            "<p:cNvPr id=\"8\" name=\"Target motion\"/>",
4190            1,
4191        );
4192        let slide = format!(
4193            r#"<p:sld xmlns:p="{P_NS}" xmlns:a="{A_NS}"><p:cSld>{}</p:cSld><p:timing><p:tnLst>
4194            <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>
4195            <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>
4196            <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>
4197            <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>
4198            </p:tnLst></p:timing></p:sld>"#,
4199            shape_tree(&format!("{first}{second}"))
4200        );
4201        let fixture = Fixture::from_xml(&slide, &layout_xml(""), &master_xml(""));
4202        let static_slide = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
4203        let resolved = fixture
4204            .context()
4205            .resolve_slide_at(
4206                (720.0, 540.0),
4207                crate::timeline::TimelinePosition {
4208                    elapsed_ms: 1_000,
4209                    click_count: 0,
4210                },
4211            )
4212            .unwrap();
4213
4214        let original = static_slide.shapes[0].bounds;
4215        let animated = resolved.slide.shapes[0].bounds;
4216        assert_close(animated.width, original.width * 2.0);
4217        assert_close(animated.height, original.height * 2.0);
4218        let animated_page = resolved_shape_page_bounds(&resolved.slide.shapes[0]);
4219        assert_close(animated_page.x + animated_page.width / 2.0, 360.0);
4220        assert_close(animated_page.y + animated_page.height / 2.0, 135.0);
4221        assert_close(resolved.slide.shapes[0].rotation_deg, 90.0);
4222        let target_original = resolved_shape_page_bounds(&static_slide.shapes[1]);
4223        let target_animated = resolved_shape_page_bounds(&resolved.slide.shapes[1]);
4224        assert_close(target_animated.x - target_original.x, 360.0);
4225        assert_close(target_animated.y - target_original.y, 135.0);
4226        assert_eq!(
4227            resolved.identities[0].name.as_deref(),
4228            Some("!!Scale & Spin")
4229        );
4230        assert!(resolved.shape_states.iter().all(|state| state.is_finite()));
4231    }
4232
4233    #[test]
4234    fn group_timeline_geometry_uses_one_shared_group_coordinate_space() {
4235        let rotated_transform = transform(50).replacen("<a:xfrm>", "<a:xfrm rot=\"2700000\">", 1);
4236        let first = shape_with_details(None, None, &rotated_transform, None).replacen(
4237            "<p:cNvPr/>",
4238            "<p:cNvPr id=\"7\" name=\"First\"/>",
4239            1,
4240        );
4241        let second = shape_with_details(None, None, &transform(100), None).replacen(
4242            "<p:cNvPr/>",
4243            "<p:cNvPr id=\"8\" name=\"Second\"/>",
4244            1,
4245        );
4246        let group = format!(
4247            "<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>"
4248        );
4249        let slide = format!(
4250            r#"<p:sld xmlns:p="{P_NS}" xmlns:a="{A_NS}"><p:cSld>{}</p:cSld><p:timing><p:tnLst>
4251            <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>
4252            <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>
4253            <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>
4254            </p:tnLst></p:timing></p:sld>"#,
4255            shape_tree(&group)
4256        );
4257        let fixture = Fixture::from_xml(&slide, &layout_xml(""), &master_xml(""));
4258        let static_slide = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
4259        let resolved = fixture
4260            .context()
4261            .resolve_slide_at(
4262                (720.0, 540.0),
4263                crate::timeline::TimelinePosition {
4264                    elapsed_ms: 500,
4265                    click_count: 0,
4266                },
4267            )
4268            .unwrap();
4269        let first_before = resolved_shape_page_bounds(&static_slide.shapes[0]);
4270        let second_before = resolved_shape_page_bounds(&static_slide.shapes[1]);
4271        let group_center_x = 200.0;
4272        let group_center_y = 100.0;
4273
4274        for (before, shape) in [first_before, second_before]
4275            .into_iter()
4276            .zip(&resolved.slide.shapes)
4277        {
4278            let after = resolved_shape_page_bounds(shape);
4279            assert_close(after.width, before.width * 1.5);
4280            assert_close(
4281                after.x + after.width / 2.0,
4282                36.0 + (before.x + before.width / 2.0 - group_center_x) * 1.5,
4283            );
4284            assert_close(
4285                after.y + after.height / 2.0,
4286                27.0 + (before.y + before.height / 2.0 - group_center_y) * 1.5,
4287            );
4288        }
4289        let first_clip = resolved.shape_states[0].local_clip_paths((100.0, 100.0));
4290        let [PathCommand::MoveTo(start), PathCommand::LineTo(next), ..] =
4291            first_clip[0].commands.as_slice()
4292        else {
4293            panic!("group wipe should remain a polygon")
4294        };
4295        assert!(start.x != next.x && start.y != next.y);
4296        assert_eq!(
4297            resolved.shape_states[1]
4298                .local_clip_paths((100.0, 100.0))
4299                .len(),
4300            1
4301        );
4302    }
4303
4304    #[test]
4305    fn parent_group_wipe_does_not_follow_descendant_or_nested_group_animation() {
4306        let child = shape_with_details(None, None, &transform(50), None).replacen(
4307            "<p:cNvPr/>",
4308            "<p:cNvPr id=\"7\" name=\"Animated child\"/>",
4309            1,
4310        );
4311        let nested = format!(
4312            "<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>"
4313        );
4314        let group = format!(
4315            "<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>"
4316        );
4317        let resolve = |descendant_timing: &str| {
4318            let slide = format!(
4319                r#"<p:sld xmlns:p="{P_NS}" xmlns:a="{A_NS}"><p:cSld>{}</p:cSld><p:timing><p:tnLst>
4320                <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>
4321                {descendant_timing}</p:tnLst></p:timing></p:sld>"#,
4322                shape_tree(&group)
4323            );
4324            Fixture::from_xml(&slide, &layout_xml(""), &master_xml(""))
4325                .context()
4326                .resolve_slide_at(
4327                    (720.0, 540.0),
4328                    crate::timeline::TimelinePosition {
4329                        elapsed_ms: 500,
4330                        click_count: 0,
4331                    },
4332                )
4333                .unwrap()
4334        };
4335        let baseline = resolve("");
4336        let animated = resolve(
4337            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>
4338            <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>"#,
4339        );
4340        let page_clip = |resolved: &crate::timeline::ResolvedTimelineSlide| {
4341            let shape = &resolved.slide.shapes[0];
4342            let transform = resolved_shape_page_transform(shape);
4343            resolved.shape_states[0].local_clip_paths((shape.bounds.width, shape.bounds.height))[0]
4344                .commands
4345                .iter()
4346                .filter_map(|command| match command {
4347                    PathCommand::MoveTo(point) | PathCommand::LineTo(point) => {
4348                        Some(transform.apply(*point))
4349                    }
4350                    _ => None,
4351                })
4352                .collect::<Vec<_>>()
4353        };
4354        let baseline_clip = page_clip(&baseline);
4355        let animated_clip = page_clip(&animated);
4356
4357        assert_ne!(
4358            resolved_shape_page_transform(&baseline.slide.shapes[0]),
4359            resolved_shape_page_transform(&animated.slide.shapes[0])
4360        );
4361        assert_eq!(baseline_clip.len(), animated_clip.len());
4362        for (baseline, animated) in baseline_clip.into_iter().zip(animated_clip) {
4363            assert_close(animated.x, baseline.x);
4364            assert_close(animated.y, baseline.y);
4365        }
4366    }
4367
4368    #[test]
4369    fn missing_or_non_placeholder_shape_has_no_chain() {
4370        let slide_children = format!("{}{}", shape(None, None), shape(Some("body"), Some(42)));
4371        let fixture = Fixture::new(&slide_children, "", &shape(Some("body"), Some(42)));
4372        let context = fixture.context();
4373
4374        assert_eq!(
4375            context.placeholder_chain(fixture.slide_shape(0)),
4376            (None, None)
4377        );
4378        assert_eq!(
4379            context.placeholder_chain(fixture.slide_shape(1)),
4380            (None, None)
4381        );
4382    }
4383
4384    #[test]
4385    fn slide_placeholder_without_transform_inherits_layout_position() {
4386        let fixture = Fixture::new(
4387            &shape(Some("body"), Some(1)),
4388            &shape_with_details(Some("body"), Some(1), &transform(10), None),
4389            &shape_with_details(Some("body"), Some(1), &transform(20), None),
4390        );
4391        let transform = fixture
4392            .context()
4393            .effective_xfrm(fixture.slide_shape(0))
4394            .unwrap();
4395
4396        assert_eq!(transform.offset.unwrap().x.0, 10);
4397    }
4398
4399    #[test]
4400    fn effective_transform_uses_slide_layout_master_precedence() {
4401        let slide_children = [
4402            shape_with_details(Some("body"), Some(1), &transform(11), None),
4403            shape(Some("body"), Some(2)),
4404            shape(Some("body"), Some(3)),
4405            shape(Some("body"), Some(4)),
4406        ]
4407        .join("");
4408        let layout_children = [
4409            shape_with_details(Some("body"), Some(1), &transform(21), None),
4410            shape_with_details(Some("body"), Some(2), &transform(22), None),
4411            shape(Some("body"), Some(3)),
4412            shape(Some("body"), Some(4)),
4413        ]
4414        .join("");
4415        let master_children = [
4416            shape_with_details(Some("body"), Some(1), &transform(31), None),
4417            shape_with_details(Some("body"), Some(2), &transform(32), None),
4418            shape_with_details(Some("body"), Some(3), &transform(33), None),
4419            shape(Some("body"), Some(4)),
4420        ]
4421        .join("");
4422        let fixture = Fixture::new(&slide_children, &layout_children, &master_children);
4423        let context = fixture.context();
4424
4425        let offsets: Vec<_> = (0..3)
4426            .map(|index| {
4427                context
4428                    .effective_xfrm(fixture.slide_shape(index))
4429                    .unwrap()
4430                    .offset
4431                    .unwrap()
4432                    .x
4433                    .0
4434            })
4435            .collect();
4436
4437        assert_eq!(offsets, [11, 22, 33]);
4438        assert!(context.effective_xfrm(fixture.slide_shape(3)).is_none());
4439    }
4440
4441    #[test]
4442    fn inherited_transform_skips_the_slide_shape_and_follows_the_layout_key() {
4443        let slide_children = [
4444            shape_with_details(Some("body"), Some(1), &transform(11), None),
4445            shape(Some("body"), Some(3)),
4446            shape(Some("ftr"), Some(9)),
4447            shape(Some("body"), Some(5)),
4448        ]
4449        .join("");
4450        let layout_children = [
4451            shape_with_details(Some("body"), Some(1), &transform(21), None),
4452            shape(Some("body"), Some(3)),
4453            shape(Some("ftr"), Some(10)),
4454        ]
4455        .join("");
4456        let master_children = [
4457            shape_with_details(Some("body"), Some(1), &transform(31), None),
4458            shape_with_details(Some("body"), Some(3), &transform(33), None),
4459            shape_with_details(Some("ftr"), Some(11), &transform(34), None),
4460            shape_with_details(Some("body"), Some(5), &transform(35), None),
4461        ]
4462        .join("");
4463        let fixture = Fixture::new(&slide_children, &layout_children, &master_children);
4464        let context = fixture.context();
4465        let inherited = |index: usize| {
4466            let placeholder = fixture.slide_shape(index).placeholder.as_ref().unwrap();
4467            inherited_xfrm(placeholder, &fixture.layout, &fixture.master)
4468                .map(|transform| transform.offset.unwrap().x.0)
4469        };
4470
4471        assert_eq!(inherited(0), Some(21));
4472        assert_eq!(inherited(1), Some(33));
4473        assert_eq!(inherited(2), Some(34));
4474        assert_eq!(inherited(3), None, "a master match alone is not inherited");
4475        for index in 1..4 {
4476            assert_eq!(
4477                context
4478                    .effective_xfrm(fixture.slide_shape(index))
4479                    .map(|transform| transform.offset.unwrap().x.0),
4480                inherited(index)
4481            );
4482        }
4483    }
4484
4485    #[test]
4486    fn body_properties_merge_per_field_across_the_chain() {
4487        let fixture = Fixture::new(
4488            &shape_with_details(
4489                Some("body"),
4490                Some(4),
4491                "",
4492                Some(r#"<a:bodyPr bIns="50" anchor="ctr"/>"#),
4493            ),
4494            &shape_with_details(
4495                Some("body"),
4496                Some(4),
4497                "",
4498                Some(r#"<a:bodyPr lIns="30" rIns="40" wrap="none"/>"#),
4499            ),
4500            &shape_with_details(
4501                Some("body"),
4502                Some(4),
4503                "",
4504                Some(
4505                    r#"<a:bodyPr lIns="10" tIns="20" anchor="b" vert="vert" spcFirstLastPara="1"><a:spAutoFit/></a:bodyPr>"#,
4506                ),
4507            ),
4508        );
4509        let properties = fixture.context().effective_body_pr(fixture.slide_shape(0));
4510
4511        assert_eq!(properties.left_inset, Some(Coordinate32Value::Emu(30)));
4512        assert_eq!(properties.top_inset, Some(Coordinate32Value::Emu(20)));
4513        assert_eq!(properties.right_inset, Some(Coordinate32Value::Emu(40)));
4514        assert_eq!(properties.bottom_inset, Some(Coordinate32Value::Emu(50)));
4515        assert_eq!(properties.anchor, Some(TextAnchor::Center));
4516        assert_eq!(properties.wrap, Some(TextWrap::None));
4517        assert_eq!(properties.vertical, Some(TextVertical::Vertical));
4518        assert_eq!(properties.space_first_last_paragraph, Some(true));
4519        assert_eq!(properties.autofit, Some(TextAutofit::ShapeAutofit));
4520    }
4521
4522    #[test]
4523    fn body_property_defaults_use_exact_emu_values() {
4524        let fixture = Fixture::new(&shape(None, None), "", "");
4525        let properties = fixture.context().effective_body_pr(fixture.slide_shape(0));
4526
4527        assert_eq!(properties.left_inset, Some(Coordinate32Value::Emu(91_440)));
4528        assert_eq!(properties.right_inset, Some(Coordinate32Value::Emu(91_440)));
4529        assert_eq!(properties.top_inset, Some(Coordinate32Value::Emu(45_720)));
4530        assert_eq!(
4531            properties.bottom_inset,
4532            Some(Coordinate32Value::Emu(45_720))
4533        );
4534        assert_eq!(properties.anchor, Some(TextAnchor::Top));
4535        assert_eq!(properties.wrap, Some(TextWrap::Square));
4536        assert_eq!(properties.vertical, Some(TextVertical::Horizontal));
4537        assert_eq!(properties.space_first_last_paragraph, Some(false));
4538        assert_eq!(properties.autofit, Some(TextAutofit::NoAutofit));
4539    }
4540
4541    #[test]
4542    fn east_asian_vertical_text_degrades_to_rotated_with_a_diagnostic() {
4543        let shape = shape_with_details(
4544            None,
4545            None,
4546            &transform(0),
4547            Some(r#"<a:bodyPr vert="eaVert"/>"#),
4548        )
4549        .replace("<a:p/>", "<a:p><a:r><a:t>visible</a:t></a:r></a:p>");
4550        let resolved = Fixture::new(&shape, "", "")
4551            .context()
4552            .resolve_slide((720.0, 540.0))
4553            .expect("resolve East Asian vertical text");
4554
4555        let ResolvedContent::Text(text) = &resolved.shapes[0].content else {
4556            panic!("fallback text should remain visible");
4557        };
4558        assert_eq!(text.vertical, TextDirection::EastAsianVertical);
4559        assert!(matches!(
4560            &text.paragraphs[0].runs[0],
4561            ResolvedTextRun::Text { text, .. } if text == "visible"
4562        ));
4563        assert!(resolved.diagnostics.iter().any(|diagnostic| {
4564            diagnostic.message == "east Asian vertical text rendered as rotated vertical text"
4565        }));
4566    }
4567
4568    #[test]
4569    fn other_vertical_variants_remain_visible_with_diagnostics() {
4570        let cases = [
4571            (
4572                "mongolianVert",
4573                TextDirection::MongolianVertical,
4574                "Mongolian vertical text rendered as rotated vertical-270 text",
4575            ),
4576            (
4577                "wordArtVert",
4578                TextDirection::WordArtVertical,
4579                "WordArt vertical text rendered as rotated vertical text",
4580            ),
4581            (
4582                "wordArtVertRtl",
4583                TextDirection::WordArtVerticalRtl,
4584                "right-to-left WordArt vertical text rendered as rotated vertical-270 text",
4585            ),
4586        ];
4587
4588        for (value, expected_direction, expected_diagnostic) in cases {
4589            let shape = shape_with_details(
4590                None,
4591                None,
4592                &transform(0),
4593                Some(&format!(r#"<a:bodyPr vert="{value}"/>"#)),
4594            )
4595            .replace("<a:p/>", "<a:p><a:r><a:t>visible</a:t></a:r></a:p>");
4596            let resolved = Fixture::new(&shape, "", "")
4597                .context()
4598                .resolve_slide((720.0, 540.0))
4599                .expect("resolve visible vertical fallback");
4600
4601            let ResolvedContent::Text(text) = &resolved.shapes[0].content else {
4602                panic!("fallback text should remain visible");
4603            };
4604            assert_eq!(text.vertical, expected_direction);
4605            assert!(matches!(
4606                &text.paragraphs[0].runs[0],
4607                ResolvedTextRun::Text { text, .. } if text == "visible"
4608            ));
4609            assert!(
4610                resolved
4611                    .diagnostics
4612                    .iter()
4613                    .any(|diagnostic| { diagnostic.message == expected_diagnostic })
4614            );
4615        }
4616    }
4617
4618    #[test]
4619    fn flattener_omits_template_prompt_and_emits_master_logo_once() {
4620        let fixture = Fixture::new(
4621            &shape_with_text(Some("title"), Some(1), "Slide title"),
4622            "",
4623            &[
4624                shape_with_text(Some("title"), Some(1), "Click to edit Master title style"),
4625                shape_with_text(None, None, "Master logo"),
4626            ]
4627            .join(""),
4628        );
4629
4630        let texts: Vec<_> = fixture
4631            .context()
4632            .flatten()
4633            .iter()
4634            .filter_map(item_text)
4635            .collect();
4636
4637        assert!(!texts.contains(&"Click to edit Master title style".to_owned()));
4638        assert_eq!(
4639            texts.iter().filter(|text| *text == "Master logo").count(),
4640            1
4641        );
4642    }
4643
4644    #[test]
4645    fn flattener_emits_the_four_sources_in_draw_order() {
4646        let master_group = format!(
4647            "<p:grpSp><p:nvGrpSpPr/><p:grpSpPr/>{}</p:grpSp>",
4648            shape_with_text(None, None, "master")
4649        );
4650        let layout_fallback = format!(
4651            "<mc:AlternateContent><mc:Fallback>{}</mc:Fallback></mc:AlternateContent>",
4652            shape_with_text(None, None, "layout")
4653        );
4654        let fixture = Fixture::from_xml(
4655            &slide_xml_with(
4656                "",
4657                "<p:bg><p:bgPr/></p:bg>",
4658                &shape_with_text(None, None, "slide"),
4659            ),
4660            &layout_xml_with("", "", &layout_fallback, ""),
4661            &master_xml_with("", &master_group, ""),
4662        );
4663        let context = fixture.context();
4664        let flattened = context.flatten();
4665
4666        assert_eq!(
4667            flattened
4668                .iter()
4669                .map(FlattenedItem::source)
4670                .collect::<Vec<_>>(),
4671            [
4672                FlattenedSource::Background,
4673                FlattenedSource::Master,
4674                FlattenedSource::Layout,
4675                FlattenedSource::Slide,
4676            ]
4677        );
4678        let FlattenedItem::Background(background) = flattened[0] else {
4679            panic!("expected background first");
4680        };
4681        assert_eq!(background.source, BackgroundSource::Slide);
4682    }
4683
4684    #[test]
4685    fn background_precedence_stops_at_master_when_all_are_absent() {
4686        let slide_first = Fixture::from_xml(
4687            &slide_xml_with("", "<p:bg><p:bgPr/></p:bg>", ""),
4688            &layout_xml_with("", "<p:bg><p:bgPr/></p:bg>", "", ""),
4689            &master_xml_with("<p:bg><p:bgPr/></p:bg>", "", ""),
4690        );
4691        let layout_first = Fixture::from_xml(
4692            &slide_xml_with("", "", ""),
4693            &layout_xml_with("", "<p:bg><p:bgPr/></p:bg>", "", ""),
4694            &master_xml_with("<p:bg><p:bgPr/></p:bg>", "", ""),
4695        );
4696        let master_first = Fixture::from_xml(
4697            &slide_xml_with("", "", ""),
4698            &layout_xml_with("", "", "", ""),
4699            &master_xml_with("<p:bg><p:bgPr/></p:bg>", "", ""),
4700        );
4701        let absent = Fixture::new("", "", "");
4702
4703        assert_eq!(background_source(&slide_first), BackgroundSource::Slide);
4704        assert_eq!(background_source(&layout_first), BackgroundSource::Layout);
4705        assert_eq!(background_source(&master_first), BackgroundSource::Master);
4706        assert!(absent.context().effective_background().is_none());
4707    }
4708
4709    #[test]
4710    fn master_gradient_background_resolves_when_slide_and_layout_omit_one() {
4711        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>"#;
4712        let fixture = Fixture::from_xml(
4713            &slide_xml_with("", "", ""),
4714            &layout_xml_with("", "", "", ""),
4715            &master_xml_with(background, "", ""),
4716        );
4717
4718        let resolved = fixture.context().resolve_slide((40.0, 20.0)).unwrap();
4719        let Some(ResolvedBackground::Paint(Paint::Linear { stops, .. })) = resolved.background
4720        else {
4721            panic!("expected inherited master gradient paint");
4722        };
4723        assert_eq!(stops.len(), 2);
4724        assert_eq!(stops[0].color, Color::from_hex("FF0000"));
4725        assert_eq!(stops[1].color, Color::from_hex("0000FF"));
4726        assert!(resolved.diagnostics.is_empty());
4727    }
4728
4729    #[test]
4730    fn background_gradient_ignores_shape_rotation_policy() {
4731        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>"#;
4732        let fixture = Fixture::from_xml(
4733            &slide_xml_with("", background, ""),
4734            &layout_xml_with("", "", "", ""),
4735            &master_xml_with("", "", ""),
4736        );
4737
4738        let resolved = fixture.context().resolve_slide((40.0, 20.0)).unwrap();
4739        assert!(matches!(
4740            resolved.background,
4741            Some(ResolvedBackground::Paint(Paint::Linear { .. }))
4742        ));
4743        assert!(resolved.diagnostics.is_empty());
4744    }
4745
4746    #[test]
4747    fn background_reference_resolves_phclr_through_the_master_colour_map() {
4748        let master = master_xml_with(
4749            r#"<p:bg><p:bgRef idx="1001"><a:schemeClr val="bg1"/></p:bgRef></p:bg>"#,
4750            "",
4751            "",
4752        )
4753        .replace("bg1=\"lt1\"", "bg1=\"accent6\"");
4754        let fixture = Fixture::from_xml(
4755            &slide_xml_with("", "", ""),
4756            &layout_xml_with("", "", "", ""),
4757            &master,
4758        );
4759        let context = ResolveCtx::new(
4760            &fixture.theme,
4761            fixture.master.color_map.clone(),
4762            &fixture.master,
4763            &fixture.layout,
4764            &fixture.slide,
4765            &fixture.default_text_style,
4766        );
4767
4768        let resolved = context.resolve_slide((40.0, 20.0)).unwrap();
4769        assert_eq!(
4770            resolved.background,
4771            Some(ResolvedBackground::Paint(Paint::Solid(Color::from_hex(
4772                "4EA72E",
4773            ))))
4774        );
4775        assert!(resolved.diagnostics.is_empty());
4776
4777        let mut transformed = Fixture::from_xml(
4778            &slide_xml_with("", "", ""),
4779            &layout_xml_with("", "", "", ""),
4780            &master_xml_with(
4781                r#"<p:bg><p:bgRef idx="1001"><a:srgbClr val="C0504D"><a:tint val="60000"/></a:srgbClr></p:bgRef></p:bg>"#,
4782                "",
4783                "",
4784            ),
4785        );
4786        transformed
4787            .theme
4788            .theme_elements
4789            .format_scheme
4790            .background_fill_styles[0] = oxml_drawing::fill::Fill::from_xml(
4791            br#"<a:solidFill><a:schemeClr val="phClr"><a:shade val="70000"/></a:schemeClr></a:solidFill>"#,
4792        )
4793        .unwrap();
4794
4795        let transformed = transformed.context().resolve_slide((40.0, 20.0)).unwrap();
4796        assert_eq!(
4797            transformed.background,
4798            Some(ResolvedBackground::Paint(Paint::Solid(Color::from_hex(
4799                "BC9897",
4800            ))))
4801        );
4802    }
4803
4804    #[test]
4805    fn background_picture_relationship_uses_its_producer_scope() {
4806        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>"#;
4807        let fixtures = [
4808            Fixture::from_xml(
4809                &slide_xml_with("", background, ""),
4810                &layout_xml_with("", "", "", ""),
4811                &master_xml_with("", "", ""),
4812            ),
4813            Fixture::from_xml(
4814                &slide_xml_with("", "", ""),
4815                &layout_xml_with("", background, "", ""),
4816                &master_xml_with("", "", ""),
4817            ),
4818            Fixture::from_xml(
4819                &slide_xml_with("", "", ""),
4820                &layout_xml_with("", "", "", ""),
4821                &master_xml_with(background, "", ""),
4822            ),
4823        ];
4824        let media = ScopedMediaIds {
4825            slide: HashMap::from([("rId9".to_owned(), MediaId(1))]),
4826            layout: HashMap::from([("rId9".to_owned(), MediaId(2))]),
4827            master: HashMap::from([("rId9".to_owned(), MediaId(3))]),
4828            ..ScopedMediaIds::default()
4829        };
4830
4831        for (fixture, expected) in fixtures.iter().zip([MediaId(1), MediaId(2), MediaId(3)]) {
4832            let resolved = fixture
4833                .context()
4834                .resolve_slide_with_media((40.0, 20.0), &media)
4835                .unwrap();
4836            let Some(ResolvedBackground::Image(image)) = resolved.background else {
4837                panic!("expected source-scoped background image");
4838            };
4839            assert_eq!(image.media, expected);
4840            assert_eq!(image.opacity, 0.3);
4841            assert!(resolved.diagnostics.is_empty());
4842        }
4843    }
4844
4845    #[test]
4846    fn master_relationship_cannot_satisfy_a_theme_background_blip() {
4847        let mut fixture = Fixture::from_xml(
4848            &slide_xml_with("", "", ""),
4849            &layout_xml_with("", "", "", ""),
4850            &master_xml_with(
4851                r#"<p:bg><p:bgRef idx="1001"><a:schemeClr val="bg1"/></p:bgRef></p:bg>"#,
4852                "",
4853                "",
4854            ),
4855        );
4856        fixture
4857            .theme
4858            .theme_elements
4859            .format_scheme
4860            .background_fill_styles[0] = Fill::from_xml(
4861            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>"#,
4862        )
4863        .unwrap();
4864        let media = ScopedMediaIds {
4865            master: HashMap::from([("rId9".to_owned(), MediaId(9))]),
4866            ..ScopedMediaIds::default()
4867        };
4868
4869        let resolved = fixture
4870            .context()
4871            .resolve_slide_with_media((40.0, 20.0), &media)
4872            .unwrap();
4873        assert!(resolved.background.is_none());
4874        assert_eq!(
4875            resolved.diagnostics,
4876            [Diagnostic {
4877                message: "theme-referenced background picture requires theme relationship scope"
4878                    .to_owned(),
4879            }]
4880        );
4881    }
4882
4883    #[test]
4884    fn background_picture_preserves_crop_stretch_and_tile_placement() {
4885        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>"#;
4886        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>"#;
4887        let media = ScopedMediaIds {
4888            slide: HashMap::from([
4889                ("rId1".to_owned(), MediaId(1)),
4890                ("rId2".to_owned(), MediaId(2)),
4891            ]),
4892            ..ScopedMediaIds::default()
4893        };
4894
4895        let tile_fixture = Fixture::from_xml(
4896            &slide_xml_with("", tile, ""),
4897            &layout_xml_with("", "", "", ""),
4898            &master_xml_with("", "", ""),
4899        );
4900        let resolved = tile_fixture
4901            .context()
4902            .resolve_slide_with_media((40.0, 20.0), &media)
4903            .unwrap();
4904        let Some(ResolvedBackground::Image(image)) = resolved.background else {
4905            panic!("expected tiled background image");
4906        };
4907        assert_eq!(image.src_rect.unwrap().left, 0.1);
4908        assert_eq!(image.dpi, Some(144.0));
4909        assert!(!image.rotate_with_shape);
4910        let ResolvedImagePlacement::Tile(tile) = image.placement else {
4911            panic!("expected tile placement");
4912        };
4913        assert_eq!(tile.translation, Point { x: 1.0, y: -2.0 });
4914        assert_eq!((tile.scale_x, tile.scale_y), (0.5, 2.0));
4915        assert_eq!(tile.flip, ResolvedTileFlip::Both);
4916        assert_eq!(tile.alignment, ResolvedRectAlignment::BottomRight);
4917
4918        let stretch_fixture = Fixture::from_xml(
4919            &slide_xml_with("", stretch, ""),
4920            &layout_xml_with("", "", "", ""),
4921            &master_xml_with("", "", ""),
4922        );
4923        let resolved = stretch_fixture
4924            .context()
4925            .resolve_slide_with_media((40.0, 20.0), &media)
4926            .unwrap();
4927        let Some(ResolvedBackground::Image(image)) = resolved.background else {
4928            panic!("expected stretched background image");
4929        };
4930        assert_eq!(
4931            image.placement,
4932            ResolvedImagePlacement::Stretch {
4933                fill_rect: Some(crate::CropRect {
4934                    left: 0.1,
4935                    top: 0.2,
4936                    right: 0.3,
4937                    bottom: 0.4,
4938                }),
4939            }
4940        );
4941    }
4942
4943    #[test]
4944    fn missing_background_picture_relationship_is_diagnosed() {
4945        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>"#;
4946        let fixture = Fixture::from_xml(
4947            &slide_xml_with("", background, ""),
4948            &layout_xml_with("", "", "", ""),
4949            &master_xml_with("", "", ""),
4950        );
4951
4952        let resolved = fixture
4953            .context()
4954            .resolve_slide_with_media((40.0, 20.0), &ScopedMediaIds::default())
4955            .unwrap();
4956        assert!(resolved.background.is_none());
4957        assert_eq!(
4958            resolved.diagnostics,
4959            [Diagnostic {
4960                message: "missing slide background image relationship `rId404`".to_owned(),
4961            }]
4962        );
4963    }
4964
4965    #[test]
4966    fn unsupported_background_fill_records_a_specific_diagnostic() {
4967        let fixture = Fixture::from_xml(
4968            &slide_xml_with(
4969                "",
4970                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>"#,
4971                "",
4972            ),
4973            &layout_xml_with("", "", "", ""),
4974            &master_xml_with("", "", ""),
4975        );
4976
4977        let resolved = fixture.context().resolve_slide((40.0, 20.0)).unwrap();
4978        assert!(resolved.background.is_none());
4979        assert_eq!(
4980            resolved.diagnostics,
4981            vec![oxml_layout::Diagnostic {
4982                message: "unsupported background pattern fill".to_owned(),
4983            }]
4984        );
4985
4986        let group_fill = Fixture::from_xml(
4987            &slide_xml_with("", r#"<p:bg><p:bgPr><a:grpFill/></p:bgPr></p:bg>"#, ""),
4988            &layout_xml_with("", "", "", ""),
4989            &master_xml_with("", "", ""),
4990        )
4991        .context()
4992        .resolve_slide((40.0, 20.0))
4993        .unwrap();
4994        assert!(group_fill.background.is_none());
4995        assert_eq!(
4996            group_fill.diagnostics,
4997            vec![oxml_layout::Diagnostic {
4998                message: "unsupported background group fill".to_owned(),
4999            }]
5000        );
5001    }
5002
5003    #[test]
5004    fn show_master_shapes_suppresses_the_owned_pass() {
5005        let master_suppressed = Fixture::from_xml(
5006            &slide_xml_with("", "", &shape_with_text(None, None, "slide")),
5007            &layout_xml_with(
5008                "showMasterSp=\"0\"",
5009                "",
5010                &shape_with_text(None, None, "layout"),
5011                "",
5012            ),
5013            &master_xml_with("", &shape_with_text(None, None, "master"), ""),
5014        );
5015        let layout_suppressed = Fixture::from_xml(
5016            &slide_xml_with(
5017                "showMasterSp=\"0\"",
5018                "",
5019                &shape_with_text(None, None, "slide"),
5020            ),
5021            &layout_xml_with("", "", &shape_with_text(None, None, "layout"), ""),
5022            &master_xml_with("", &shape_with_text(None, None, "master"), ""),
5023        );
5024
5025        let master_suppressed_texts: Vec<_> = master_suppressed
5026            .context()
5027            .flatten()
5028            .iter()
5029            .filter_map(item_text)
5030            .collect();
5031        let layout_suppressed_texts: Vec<_> = layout_suppressed
5032            .context()
5033            .flatten()
5034            .iter()
5035            .filter_map(item_text)
5036            .collect();
5037
5038        assert_eq!(master_suppressed_texts, ["layout", "slide"]);
5039        assert_eq!(layout_suppressed_texts, ["master", "slide"]);
5040    }
5041
5042    #[test]
5043    fn slide_placeholder_keeps_source_order_and_suppresses_inherited_matches() {
5044        let slide_children = [
5045            shape_with_text(None, None, "before"),
5046            shape_with_text(Some("ftr"), Some(9), "slide footer"),
5047            shape_with_text(None, None, "after"),
5048        ]
5049        .join("");
5050        let fixture = Fixture::from_xml(
5051            &slide_xml_with("", "", &slide_children),
5052            &layout_xml_with(
5053                "",
5054                "",
5055                &shape_with_text(Some("ftr"), Some(9), "layout footer"),
5056                "<p:hf/>",
5057            ),
5058            &master_xml_with(
5059                "",
5060                &shape_with_text(Some("ftr"), Some(9), "master footer"),
5061                "<p:hf/>",
5062            ),
5063        );
5064
5065        let sources_and_text: Vec<_> = fixture
5066            .context()
5067            .flatten()
5068            .iter()
5069            .filter_map(|item| item_text(item).map(|text| (item.source(), text)))
5070            .collect();
5071
5072        assert_eq!(
5073            sources_and_text,
5074            [
5075                (FlattenedSource::Slide, "before".to_owned()),
5076                (FlattenedSource::Slide, "slide footer".to_owned()),
5077                (FlattenedSource::Slide, "after".to_owned()),
5078            ]
5079        );
5080    }
5081
5082    #[test]
5083    fn inherited_latent_placeholders_obey_their_source_header_footer_flags() {
5084        let latent_shapes = [
5085            shape_with_text(Some("dt"), Some(1), "date"),
5086            shape_with_text(Some("ftr"), Some(2), "footer"),
5087            shape_with_text(Some("sldNum"), Some(3), "number"),
5088        ]
5089        .join("");
5090        let layout_fixture = Fixture::from_xml(
5091            &slide_xml_with("", "", ""),
5092            &layout_xml_with("", "", &latent_shapes, "<p:hf dt=\"0\"/>"),
5093            &master_xml_with("", "", "<p:hf sldNum=\"0\"/>"),
5094        );
5095
5096        let layout_sources_and_text = layout_fixture
5097            .context()
5098            .flatten()
5099            .iter()
5100            .filter_map(|item| item_text(item).map(|text| (item.source(), text)))
5101            .collect::<Vec<_>>();
5102        assert_eq!(
5103            layout_sources_and_text,
5104            [
5105                (FlattenedSource::Layout, "footer".to_owned()),
5106                (FlattenedSource::Layout, "number".to_owned()),
5107            ]
5108        );
5109
5110        let master_fixture = Fixture::from_xml(
5111            &slide_xml_with("", "", ""),
5112            &layout_xml_with("", "", "", ""),
5113            &master_xml_with(
5114                "",
5115                &latent_shapes,
5116                "<p:hf dt=\"1\" ftr=\"1\" sldNum=\"0\"/>",
5117            ),
5118        );
5119        let master_sources_and_text = master_fixture
5120            .context()
5121            .flatten()
5122            .iter()
5123            .filter_map(|item| item_text(item).map(|text| (item.source(), text)))
5124            .collect::<Vec<_>>();
5125        assert_eq!(
5126            master_sources_and_text,
5127            [
5128                (FlattenedSource::Master, "date".to_owned()),
5129                (FlattenedSource::Master, "footer".to_owned()),
5130            ]
5131        );
5132    }
5133
5134    #[test]
5135    fn empty_latent_placeholders_fall_back_by_type_and_source() {
5136        fn source_texts(fixture: &Fixture) -> Vec<(FlattenedSource, String)> {
5137            fixture
5138                .context()
5139                .flatten()
5140                .iter()
5141                .filter_map(|item| item_text(item).map(|text| (item.source(), text)))
5142                .collect()
5143        }
5144
5145        for ph_type in ["dt", "ftr", "sldNum"] {
5146            let occupied_slide = Fixture::from_xml(
5147                &slide_xml_with("", "", &shape_with_text(Some(ph_type), Some(1), "slide")),
5148                &layout_xml_with(
5149                    "",
5150                    "",
5151                    &shape_with_text(Some(ph_type), Some(2), "layout"),
5152                    "<p:hf dt=\"0\" ftr=\"0\" sldNum=\"0\"/>",
5153                ),
5154                &master_xml_with(
5155                    "",
5156                    &shape_with_text(Some(ph_type), Some(3), "master"),
5157                    "<p:hf dt=\"0\" ftr=\"0\" sldNum=\"0\"/>",
5158                ),
5159            );
5160            assert_eq!(
5161                source_texts(&occupied_slide),
5162                [(FlattenedSource::Slide, "slide".to_owned())],
5163                "occupied slide {ph_type}"
5164            );
5165
5166            let empty_slide = Fixture::from_xml(
5167                &slide_xml_with("", "", &shape_with_text(Some(ph_type), Some(1), "")),
5168                &layout_xml_with(
5169                    "",
5170                    "",
5171                    &shape_with_text(Some(ph_type), Some(2), "layout"),
5172                    "<p:hf/>",
5173                ),
5174                &master_xml_with(
5175                    "",
5176                    &shape_with_text(Some(ph_type), Some(3), "master"),
5177                    "<p:hf/>",
5178                ),
5179            );
5180            assert_eq!(
5181                source_texts(&empty_slide),
5182                [(FlattenedSource::Layout, "layout".to_owned())],
5183                "empty slide {ph_type}"
5184            );
5185
5186            let empty_layout = Fixture::from_xml(
5187                &slide_xml_with("", "", ""),
5188                &layout_xml_with(
5189                    "",
5190                    "",
5191                    &shape_with_text(Some(ph_type), Some(2), ""),
5192                    "<p:hf/>",
5193                ),
5194                &master_xml_with(
5195                    "",
5196                    &shape_with_text(Some(ph_type), Some(3), "master"),
5197                    "<p:hf/>",
5198                ),
5199            );
5200            assert_eq!(
5201                source_texts(&empty_layout),
5202                [(FlattenedSource::Master, "master".to_owned())],
5203                "empty layout {ph_type}"
5204            );
5205
5206            let empty_master = Fixture::from_xml(
5207                &slide_xml_with("", "", ""),
5208                &layout_xml_with("", "", "", ""),
5209                &master_xml_with("", &shape_with_text(Some(ph_type), Some(3), ""), "<p:hf/>"),
5210            );
5211            assert!(
5212                source_texts(&empty_master).is_empty(),
5213                "empty master {ph_type}"
5214            );
5215        }
5216    }
5217
5218    #[test]
5219    fn slide_owned_latent_placeholder_source_matrix_matches_the_oracle_decision() {
5220        let slide_number = shape_with_text(Some("sldNum"), Some(3), "slide number");
5221        let layout_date = shape_with_text(Some("dt"), Some(1), "layout date");
5222        let cases = [
5223            ("", "", vec!["slide number"]),
5224            (
5225                "",
5226                "<p:hf dt=\"0\" ftr=\"0\" hdr=\"0\" sldNum=\"0\"/>",
5227                vec!["slide number"],
5228            ),
5229            (&layout_date, "<p:hf sldNum=\"1\"/>", vec!["slide number"]),
5230        ];
5231
5232        for (layout_children, master_header_footer, expected) in cases {
5233            let fixture = Fixture::from_xml(
5234                &slide_xml_with("", "", &slide_number),
5235                &layout_xml_with("", "", layout_children, ""),
5236                &master_xml_with("", "", master_header_footer),
5237            );
5238            let texts = fixture
5239                .context()
5240                .flatten()
5241                .iter()
5242                .filter_map(item_text)
5243                .collect::<Vec<_>>();
5244
5245            assert_eq!(
5246                texts, expected,
5247                "master header-footer: {master_header_footer}"
5248            );
5249        }
5250    }
5251
5252    #[test]
5253    fn absent_header_footer_container_hides_inherited_latent_placeholders() {
5254        let slide = shape_with_text(Some("ftr"), Some(11), "slide footer");
5255        let layout = [
5256            shape_with_text(Some("dt"), Some(10), "layout date"),
5257            shape_with_text(Some("sldNum"), Some(12), "layout number"),
5258        ]
5259        .join("");
5260        let fixture = Fixture::from_xml(&slide_xml(&slide), &layout_xml(&layout), &master_xml(""));
5261
5262        let texts = fixture
5263            .context()
5264            .flatten()
5265            .into_iter()
5266            .filter_map(|item| item_text(&item))
5267            .collect::<Vec<_>>();
5268
5269        assert_eq!(texts, ["slide footer"]);
5270    }
5271
5272    #[test]
5273    fn corpus_slide_resolves_without_theme_references() {
5274        let stats = resolve_pinned_corpus();
5275
5276        assert_eq!(stats.decks, EXPECTED_CORPUS_DECKS);
5277        assert!(stats.slides > EXPECTED_CORPUS_DECKS);
5278        assert_eq!(stats.contextual_errors, 0, "{}", stats.errors.join("\n"));
5279        assert_eq!(stats.resolved, stats.slides);
5280        assert_eq!(stats.theme_references, 0);
5281    }
5282
5283    #[test]
5284    fn resolved_contract_contains_no_presentation_or_drawing_types() {
5285        fn assert_owned_and_static(_: ResolvedSlide) {}
5286
5287        let fixture = Fixture::new(&shape_with_details(None, None, &transform(0), None), "", "");
5288        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
5289        assert_owned_and_static(resolved);
5290        for type_name in [
5291            std::any::type_name::<ResolvedSlide>(),
5292            std::any::type_name::<crate::ResolvedShape>(),
5293            std::any::type_name::<crate::ResolvedContent>(),
5294        ] {
5295            assert!(!type_name.contains("rpptx_oxml"));
5296            assert!(!type_name.contains("oxml_drawing"));
5297        }
5298    }
5299
5300    #[test]
5301    fn line_end_resolution_keeps_kind_width_and_length() {
5302        let properties = format!(
5303            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>"#,
5304            transform(0)
5305        );
5306        let fixture = Fixture::new(&shape_with_details(None, None, &properties, None), "", "");
5307
5308        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
5309        let shape = &resolved.shapes[0];
5310        assert_eq!(
5311            shape.head_end,
5312            Some(ResolvedLineEnd {
5313                kind: ResolvedLineEndKind::Diamond,
5314                width: ResolvedLineEndSize::Small,
5315                length: ResolvedLineEndSize::Medium,
5316            })
5317        );
5318        assert_eq!(
5319            shape.tail_end,
5320            Some(ResolvedLineEnd {
5321                kind: ResolvedLineEndKind::Triangle,
5322                width: ResolvedLineEndSize::Medium,
5323                length: ResolvedLineEndSize::Large,
5324            })
5325        );
5326        assert_eq!(shape.line.as_ref().map(|line| line.width), Some(2.0));
5327    }
5328
5329    #[test]
5330    fn same_relationship_id_resolves_to_distinct_media_in_each_source_scope() {
5331        let fixture = Fixture::new(
5332            &picture("rId2", "", "", 25_400),
5333            &picture("rId2", "", "", 12_700),
5334            &picture("rId2", "", "", 0),
5335        );
5336        let media = ScopedMediaIds {
5337            slide: HashMap::from([("rId2".to_owned(), MediaId(1))]),
5338            layout: HashMap::from([("rId2".to_owned(), MediaId(2))]),
5339            master: HashMap::from([("rId2".to_owned(), MediaId(3))]),
5340            ..ScopedMediaIds::default()
5341        };
5342
5343        let resolved = fixture
5344            .context()
5345            .resolve_slide_with_media((720.0, 540.0), &media)
5346            .unwrap();
5347        let ids = resolved
5348            .shapes
5349            .iter()
5350            .map(|shape| match shape.content {
5351                ResolvedContent::Image(ref image) => image.media,
5352                _ => panic!("scoped picture should resolve to image content"),
5353            })
5354            .collect::<Vec<_>>();
5355
5356        assert_eq!(ids, [MediaId(3), MediaId(2), MediaId(1)]);
5357    }
5358
5359    #[test]
5360    fn picture_placeholder_inherits_layout_bounds_and_keeps_slide_media_scope() {
5361        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>"#;
5362        let layout_placeholder = shape_with_details(
5363            Some("pic"),
5364            Some(1),
5365            r#"<a:xfrm><a:off x="12700" y="25400"/><a:ext cx="38100" cy="50800"/></a:xfrm>"#,
5366            None,
5367        );
5368        let master_placeholder = shape_with_details(
5369            Some("pic"),
5370            Some(1),
5371            r#"<a:xfrm><a:off x="127000" y="254000"/><a:ext cx="381000" cy="508000"/></a:xfrm>"#,
5372            None,
5373        );
5374        let fixture = Fixture::new(slide_picture, &layout_placeholder, &master_placeholder);
5375        let context = fixture.context();
5376        let flattened = context.flatten();
5377        let bounded_picture_sources = flattened
5378            .iter()
5379            .filter(|item| {
5380                matches!(
5381                    item,
5382                    FlattenedItem::Shape {
5383                        child: ShapeTreeChild::Picture(picture),
5384                        ..
5385                    } if context.effective_picture_xfrm(picture).is_some()
5386                )
5387            })
5388            .count();
5389        let media = ScopedMediaIds {
5390            slide: HashMap::from([("rId2".to_owned(), MediaId(7))]),
5391            layout: HashMap::from([("rId2".to_owned(), MediaId(8))]),
5392            master: HashMap::from([("rId2".to_owned(), MediaId(9))]),
5393            ..ScopedMediaIds::default()
5394        };
5395
5396        let resolved = context
5397            .resolve_slide_with_media((720.0, 540.0), &media)
5398            .unwrap();
5399        assert_eq!(bounded_picture_sources, 1);
5400        assert_eq!(resolved.shapes.len(), bounded_picture_sources);
5401        let picture = &resolved.shapes[0];
5402        assert_eq!(
5403            picture.bounds,
5404            Rect {
5405                x: 1.0,
5406                y: 2.0,
5407                width: 3.0,
5408                height: 4.0,
5409            }
5410        );
5411        let ResolvedContent::Image(image) = &picture.content else {
5412            panic!("picture placeholder did not retain image content");
5413        };
5414        assert_eq!(image.media, MediaId(7));
5415        assert_eq!(image.src_rect.as_ref().map(|crop| crop.top), Some(0.1));
5416        assert_eq!(image.src_rect.as_ref().map(|crop| crop.bottom), Some(0.2));
5417        assert_eq!(
5418            image.placement,
5419            ResolvedImagePlacement::Stretch {
5420                fill_rect: Some(crate::CropRect::default()),
5421            }
5422        );
5423        assert!(resolved.diagnostics.is_empty());
5424    }
5425
5426    #[test]
5427    fn direct_shape_picture_fill_uses_its_producer_scope() {
5428        let fixture = Fixture::new(
5429            &shape_picture_fill("rId2", 25_400, None),
5430            &shape_picture_fill("rId2", 12_700, None),
5431            &shape_picture_fill("rId2", 0, None),
5432        );
5433        let media = ScopedMediaIds {
5434            slide: HashMap::from([("rId2".to_owned(), MediaId(1))]),
5435            layout: HashMap::from([("rId2".to_owned(), MediaId(2))]),
5436            master: HashMap::from([("rId2".to_owned(), MediaId(3))]),
5437            ..ScopedMediaIds::default()
5438        };
5439
5440        let resolved = fixture
5441            .context()
5442            .resolve_slide_with_media((720.0, 540.0), &media)
5443            .unwrap();
5444        let ids = resolved
5445            .shapes
5446            .iter()
5447            .map(|shape| shape.image_fill.as_ref().unwrap().media)
5448            .collect::<Vec<_>>();
5449
5450        assert_eq!(ids, [MediaId(3), MediaId(2), MediaId(1)]);
5451        assert!(resolved.diagnostics.is_empty());
5452    }
5453
5454    #[test]
5455    fn shape_picture_fill_and_text_are_resolved_independently() {
5456        let shape = shape_picture_fill(
5457            "rId7",
5458            0,
5459            Some(r#"<a:bodyPr/><a:p><a:r><a:t>caption</a:t></a:r></a:p>"#),
5460        );
5461        let fixture = Fixture::new(&shape, "", "");
5462        let media = ScopedMediaIds {
5463            slide: HashMap::from([("rId7".to_owned(), MediaId(7))]),
5464            ..ScopedMediaIds::default()
5465        };
5466
5467        let resolved = fixture
5468            .context()
5469            .resolve_slide_with_media((720.0, 540.0), &media)
5470            .unwrap();
5471        let shape = &resolved.shapes[0];
5472        assert_eq!(shape.image_fill.as_ref().unwrap().media, MediaId(7));
5473        let ResolvedContent::Text(text) = &shape.content else {
5474            panic!("picture-filled shape lost its text");
5475        };
5476        assert!(matches!(
5477            &text.paragraphs[0].runs[0],
5478            ResolvedTextRun::Text { text, .. } if text == "caption"
5479        ));
5480        assert!(resolved.diagnostics.is_empty());
5481    }
5482
5483    #[test]
5484    fn picture_fill_opacity_comes_from_alpha_modulation_fix() {
5485        let opaque = shape_picture_fill("rId7", 0, None);
5486        let faded = opaque.replace(
5487            r#"r:embed="rId7"/>"#,
5488            r#"r:embed="rId7"><a:alphaModFix amt="30000"/></a:blip>"#,
5489        );
5490        assert_ne!(faded, opaque);
5491        let media = ScopedMediaIds {
5492            slide: HashMap::from([("rId7".to_owned(), MediaId(7))]),
5493            ..ScopedMediaIds::default()
5494        };
5495
5496        for (shape, opacity) in [(opaque, 1.0), (faded, 0.3)] {
5497            let fixture = Fixture::new(&shape, "", "");
5498            let resolved = fixture
5499                .context()
5500                .resolve_slide_with_media((720.0, 540.0), &media)
5501                .unwrap();
5502            let image = resolved.shapes[0].image_fill.as_ref().unwrap();
5503            assert_eq!(image.opacity, opacity);
5504            assert!(resolved.diagnostics.is_empty());
5505        }
5506    }
5507
5508    #[test]
5509    fn theme_shape_picture_fill_cannot_use_a_part_relationship() {
5510        let mut fixture = Fixture::new(
5511            &shape_with_details(
5512                None,
5513                None,
5514                &format!(
5515                    r#"{}<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>"#,
5516                    transform(0)
5517                ),
5518                None,
5519            )
5520            .replace(
5521                "</p:sp>",
5522                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>"#,
5523            ),
5524            "",
5525            "",
5526        );
5527        fixture.theme.theme_elements.format_scheme.fill_styles[0] = Fill::from_xml(
5528            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>"#,
5529        )
5530        .unwrap();
5531        let media = ScopedMediaIds {
5532            slide: HashMap::from([("rId9".to_owned(), MediaId(9))]),
5533            master: HashMap::from([("rId9".to_owned(), MediaId(99))]),
5534            ..ScopedMediaIds::default()
5535        };
5536
5537        let resolved = fixture
5538            .context()
5539            .resolve_slide_with_media((720.0, 540.0), &media)
5540            .unwrap();
5541
5542        assert!(resolved.shapes[0].image_fill.is_none());
5543        assert_eq!(
5544            resolved.shapes[0].unsupported,
5545            Some("theme-referenced shape picture fill")
5546        );
5547        assert_eq!(
5548            resolved.diagnostics,
5549            [Diagnostic {
5550                message: "theme-referenced shape picture fill requires theme relationship scope"
5551                    .to_owned(),
5552            }]
5553        );
5554    }
5555
5556    #[test]
5557    fn missing_direct_shape_picture_relationship_keeps_text() {
5558        let shape = shape_picture_fill(
5559            "rId404",
5560            0,
5561            Some(r#"<a:bodyPr/><a:p><a:r><a:t>keep me</a:t></a:r></a:p>"#),
5562        );
5563        let resolved = Fixture::new(&shape, "", "")
5564            .context()
5565            .resolve_slide_with_media((720.0, 540.0), &ScopedMediaIds::default())
5566            .unwrap();
5567
5568        assert!(resolved.shapes[0].image_fill.is_none());
5569        assert!(matches!(
5570            resolved.shapes[0].content,
5571            ResolvedContent::Text(_)
5572        ));
5573        assert_eq!(
5574            resolved.shapes[0].unsupported,
5575            Some("missing image relationship")
5576        );
5577        assert_eq!(
5578            resolved.diagnostics,
5579            [Diagnostic {
5580                message: "missing slide shape image relationship `rId404`".to_owned(),
5581            }]
5582        );
5583    }
5584
5585    #[test]
5586    fn rejected_picture_relationship_reports_and_uses_visible_bounds() {
5587        let fixture = Fixture::new(&picture("rId7", "", "", 0), "", "");
5588        let failures = ScopedMediaFailures {
5589            slide: HashMap::from([(
5590                "rId7".to_owned(),
5591                "exceeds the 64 MiB decoded-image render limit".to_owned(),
5592            )]),
5593            ..ScopedMediaFailures::default()
5594        };
5595
5596        let resolved = fixture
5597            .context()
5598            .with_media_failures(&failures)
5599            .resolve_slide_with_media((720.0, 540.0), &ScopedMediaIds::default())
5600            .unwrap();
5601
5602        assert_eq!(
5603            resolved.shapes[0].geometry,
5604            ResolvedGeometry::BoundsFallback
5605        );
5606        assert_eq!(resolved.shapes[0].unsupported, Some("image render failure"));
5607        assert_eq!(
5608            resolved.diagnostics,
5609            [Diagnostic {
5610                message: "picture image relationship `rId7` exceeds the 64 MiB decoded-image render limit"
5611                    .to_owned(),
5612            }]
5613        );
5614    }
5615
5616    #[test]
5617    fn picture_model_resolves_to_neutral_stretch_and_tile_placement() {
5618        let fixture = Fixture::new(
5619            &format!(
5620                "{}{}{}{}",
5621                picture("rId1", "", "", 0),
5622                picture(
5623                    "rId2",
5624                    "dpi=\"144\" rotWithShape=\"0\"",
5625                    r#"<a:srcRect l="10000"/><a:tile tx="12700" ty="-25400" sx="50000" sy="200000" flip="xy" algn="br"/>"#,
5626                    12_700,
5627                ),
5628                picture(
5629                    "rId4",
5630                    "",
5631                    r#"<a:stretch><a:fillRect l="10000" t="20000" r="30000" b="40000"/></a:stretch>"#,
5632                    25_400,
5633                ),
5634                linked_picture("https://example.invalid/image.png", 38_100),
5635            ),
5636            "",
5637            "",
5638        );
5639        let media = ScopedMediaIds {
5640            slide: HashMap::from([
5641                ("rId1".to_owned(), MediaId(11)),
5642                ("rId2".to_owned(), MediaId(12)),
5643                ("rId4".to_owned(), MediaId(14)),
5644            ]),
5645            ..ScopedMediaIds::default()
5646        };
5647
5648        let resolved = fixture
5649            .context()
5650            .resolve_slide_with_media((720.0, 540.0), &media)
5651            .unwrap();
5652        let ResolvedContent::Image(image) = &resolved.shapes[0].content else {
5653            panic!("default picture should resolve");
5654        };
5655        assert_eq!(image.placement, ResolvedImagePlacement::default());
5656        assert_eq!(image.dpi, None);
5657        assert!(image.rotate_with_shape);
5658
5659        let ResolvedContent::Image(image) = &resolved.shapes[1].content else {
5660            panic!("tile picture should resolve");
5661        };
5662        let ResolvedImagePlacement::Tile(tile) = &image.placement else {
5663            panic!("expected tile placement");
5664        };
5665        assert_eq!(image.src_rect.unwrap().left, 0.1);
5666        assert_eq!(tile.translation, Point { x: 1.0, y: -2.0 });
5667        assert_eq!((tile.scale_x, tile.scale_y), (0.5, 2.0));
5668        assert_eq!(tile.flip, ResolvedTileFlip::Both);
5669        assert_eq!(tile.alignment, ResolvedRectAlignment::BottomRight);
5670        assert_eq!(image.dpi, Some(144.0));
5671        assert!(!image.rotate_with_shape);
5672
5673        let ResolvedContent::Image(image) = &resolved.shapes[2].content else {
5674            panic!("explicit stretch picture should resolve");
5675        };
5676        let ResolvedImagePlacement::Stretch { fill_rect } = &image.placement else {
5677            panic!("expected stretch placement");
5678        };
5679        assert_eq!(
5680            *fill_rect,
5681            Some(crate::CropRect {
5682                left: 0.1,
5683                top: 0.2,
5684                right: 0.3,
5685                bottom: 0.4,
5686            })
5687        );
5688        assert_eq!(resolved.shapes[3].content, ResolvedContent::None);
5689        assert_eq!(
5690            resolved.shapes[3].unsupported,
5691            Some("external picture media")
5692        );
5693        assert!(resolved.diagnostics.iter().any(|diagnostic| {
5694            diagnostic
5695                .message
5696                .contains("external picture relationship `https://example.invalid/image.png`")
5697        }));
5698    }
5699
5700    #[test]
5701    fn transform_resolves_to_points_rotation_and_flips() {
5702        let fixture = Fixture::new(
5703            &shape_with_details(
5704                None,
5705                None,
5706                r#"<a:xfrm rot="5400000" flipH="1" flipV="1"><a:off x="12700" y="25400"/><a:ext cx="38100" cy="50800"/></a:xfrm>"#,
5707                None,
5708            ),
5709            "",
5710            "",
5711        );
5712
5713        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
5714        let shape = &resolved.shapes[0];
5715        assert_eq!(shape.bounds.x, 1.0);
5716        assert_eq!(shape.bounds.y, 2.0);
5717        assert_eq!(shape.bounds.width, 3.0);
5718        assert_eq!(shape.bounds.height, 4.0);
5719        assert_eq!(shape.rotation_deg, 90.0);
5720        assert!(shape.flip_h);
5721        assert!(shape.flip_v);
5722    }
5723
5724    #[test]
5725    fn custom_geometry_scales_each_path_coordinate_space_to_shape_points() {
5726        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>"#;
5727        let shape = shape_with_details(
5728            None,
5729            None,
5730            &format!(
5731                "<a:xfrm><a:off x=\"0\" y=\"0\"/><a:ext cx=\"127000\" cy=\"254000\"/></a:xfrm>{geometry}"
5732            ),
5733            None,
5734        );
5735        let fixture = Fixture::new(&shape, "", "");
5736
5737        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
5738        let ResolvedGeometry::Custom { paths, text_rect } = &resolved.shapes[0].geometry else {
5739            panic!("expected custom geometry");
5740        };
5741        assert_eq!(
5742            paths[0].commands[1],
5743            PathCommand::LineTo(oxml_layout::Point { x: 10.0, y: 20.0 })
5744        );
5745        assert_eq!(
5746            *text_rect,
5747            Some(Rect {
5748                x: 2.5,
5749                y: 5.0,
5750                width: 5.0,
5751                height: 10.0,
5752            })
5753        );
5754    }
5755
5756    #[test]
5757    fn custom_geometry_uses_shape_size_for_omitted_path_dimensions() {
5758        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>"#;
5759        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>"#;
5760        let shapes = [omitted_both, omitted_width]
5761            .map(|geometry| {
5762                shape_with_details(
5763                    None,
5764                    None,
5765                    &format!(
5766                        "<a:xfrm><a:off x=\"0\" y=\"0\"/><a:ext cx=\"127000\" cy=\"254000\"/></a:xfrm>{geometry}"
5767                    ),
5768                    None,
5769                )
5770            })
5771            .join("");
5772        let resolved = Fixture::new(&shapes, "", "")
5773            .context()
5774            .resolve_slide((720.0, 540.0))
5775            .unwrap();
5776
5777        for shape in &resolved.shapes {
5778            let ResolvedGeometry::Custom { paths, .. } = &shape.geometry else {
5779                panic!("expected concrete custom geometry");
5780            };
5781            assert_eq!(
5782                paths[0].commands[1],
5783                PathCommand::LineTo(oxml_layout::Point { x: 10.0, y: 20.0 })
5784            );
5785        }
5786    }
5787
5788    #[test]
5789    fn invalid_custom_geometry_retains_a_diagnosed_bounds_fallback() {
5790        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>"#;
5791        let shape = shape_with_details(None, None, &format!("{}{}", transform(0), geometry), None);
5792        let fixture = Fixture::new(&shape, "", "");
5793
5794        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
5795        assert_eq!(
5796            resolved.shapes[0].geometry,
5797            ResolvedGeometry::BoundsFallback
5798        );
5799        assert_eq!(
5800            resolved.shapes[0].unsupported,
5801            Some("custom geometry evaluation")
5802        );
5803        assert!(
5804            resolved
5805                .diagnostics
5806                .iter()
5807                .any(|diagnostic| { diagnostic.message.contains("unknown guide: missing") })
5808        );
5809    }
5810
5811    #[test]
5812    fn connector_presets_reuse_geometry_and_preserve_line_ends() {
5813        let line = r#"<a:ln w="12700"><a:solidFill><a:srgbClr val="112233"/></a:solidFill><a:headEnd type="triangle"/></a:ln>"#;
5814        let fixture = Fixture::new(
5815            &[
5816                connector("line", 127_000, 254_000, "", line),
5817                connector("straightConnector1", 127_000, 254_000, "", line),
5818                connector(
5819                    "bentConnector3",
5820                    127_000,
5821                    254_000,
5822                    r#"<a:gd name="adj1" fmla="val 25000"/>"#,
5823                    line,
5824                ),
5825                connector(
5826                    "curvedConnector3",
5827                    127_000,
5828                    254_000,
5829                    r#"<a:gd name="adj1" fmla="val 50000"/>"#,
5830                    line,
5831                ),
5832            ]
5833            .join(""),
5834            "",
5835            "",
5836        );
5837
5838        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
5839
5840        assert_eq!(resolved.shapes.len(), 4);
5841        assert!(resolved.diagnostics.is_empty());
5842        for shape in &resolved.shapes {
5843            assert_eq!(shape.unsupported, None);
5844            assert!(shape.line.is_some());
5845            assert_eq!(
5846                shape.head_end.as_ref().map(|end| end.kind),
5847                Some(ResolvedLineEndKind::Triangle)
5848            );
5849        }
5850        for index in [0, 1] {
5851            let ResolvedGeometry::Custom { paths, .. } = &resolved.shapes[index].geometry else {
5852                panic!("expected straight connector geometry");
5853            };
5854            assert_eq!(
5855                paths[0].commands,
5856                [
5857                    PathCommand::MoveTo(Point { x: 0.0, y: 0.0 }),
5858                    PathCommand::LineTo(Point { x: 10.0, y: 20.0 }),
5859                ]
5860            );
5861        }
5862        let ResolvedGeometry::Custom { paths, .. } = &resolved.shapes[2].geometry else {
5863            panic!("expected bent connector geometry");
5864        };
5865        assert_eq!(
5866            paths[0].commands,
5867            [
5868                PathCommand::MoveTo(Point { x: 0.0, y: 0.0 }),
5869                PathCommand::LineTo(Point { x: 2.5, y: 0.0 }),
5870                PathCommand::LineTo(Point { x: 2.5, y: 20.0 }),
5871                PathCommand::LineTo(Point { x: 10.0, y: 20.0 }),
5872            ]
5873        );
5874        let ResolvedGeometry::Custom { paths, .. } = &resolved.shapes[3].geometry else {
5875            panic!("expected curved connector geometry");
5876        };
5877        assert!(matches!(
5878            paths[0].commands.as_slice(),
5879            [
5880                PathCommand::MoveTo(Point { x: 0.0, y: 0.0 }),
5881                PathCommand::CurveTo { to, .. },
5882                PathCommand::CurveTo { .. }
5883            ] if *to == Point { x: 5.0, y: 10.0 }
5884        ));
5885    }
5886
5887    #[test]
5888    fn connector_custom_geometry_reuses_the_shared_path_evaluator() {
5889        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>"#;
5890        let resolved = Fixture::new(connector, "", "")
5891            .context()
5892            .resolve_slide((720.0, 540.0))
5893            .unwrap();
5894
5895        assert!(resolved.diagnostics.is_empty());
5896        assert_eq!(resolved.shapes[0].unsupported, None);
5897        let ResolvedGeometry::Custom { paths, .. } = &resolved.shapes[0].geometry else {
5898            panic!("expected concrete connector custom geometry");
5899        };
5900        assert_eq!(
5901            paths[0].commands,
5902            [
5903                PathCommand::MoveTo(Point { x: 0.0, y: 0.0 }),
5904                PathCommand::LineTo(Point { x: 0.0, y: 10.0 }),
5905                PathCommand::LineTo(Point { x: 10.0, y: 10.0 }),
5906                PathCommand::LineTo(Point { x: 10.0, y: 20.0 }),
5907            ]
5908        );
5909    }
5910
5911    #[test]
5912    fn zero_extent_and_unknown_connector_geometry_keep_finite_visible_results() {
5913        let line = r#"<a:ln><a:solidFill><a:srgbClr val="112233"/></a:solidFill></a:ln>"#;
5914        let fixture = Fixture::new(
5915            &format!(
5916                "{}{}",
5917                connector("straightConnector1", 0, 127_000, "", line),
5918                connector("futureConnector", 127_000, 254_000, "", line),
5919            ),
5920            "",
5921            "",
5922        );
5923
5924        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
5925
5926        let ResolvedGeometry::Custom { paths, .. } = &resolved.shapes[0].geometry else {
5927            panic!("zero-width connector should still evaluate: {:?}", resolved);
5928        };
5929        assert_eq!(
5930            paths[0].commands,
5931            [
5932                PathCommand::MoveTo(Point { x: 0.0, y: 0.0 }),
5933                PathCommand::LineTo(Point { x: 0.0, y: 10.0 }),
5934            ]
5935        );
5936        assert!(paths[0].commands.iter().all(|command| match command {
5937            PathCommand::MoveTo(point) | PathCommand::LineTo(point) =>
5938                point.x.is_finite() && point.y.is_finite(),
5939            _ => true,
5940        }));
5941        assert_eq!(
5942            resolved.shapes[1].geometry,
5943            ResolvedGeometry::BoundsFallback
5944        );
5945        assert_eq!(
5946            resolved.shapes[1].unsupported,
5947            Some("unknown connector preset geometry")
5948        );
5949        assert!(resolved.diagnostics.iter().any(|diagnostic| {
5950            diagnostic.message.contains("futureConnector")
5951                && diagnostic.message.contains("retained as shape bounds")
5952        }));
5953    }
5954
5955    #[test]
5956    fn connector_without_a_direct_line_keeps_a_diagnosed_visible_default() {
5957        let fixture = Fixture::new(&connector("line", 127_000, 254_000, "", ""), "", "");
5958
5959        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
5960        let connector = &resolved.shapes[0];
5961
5962        assert!(matches!(
5963            connector.geometry,
5964            ResolvedGeometry::Custom { .. }
5965        ));
5966        assert_eq!(connector.unsupported, Some("connector line style"));
5967        let line = connector.line.as_ref().expect("visible default line");
5968        assert_eq!(line.paint, Paint::Solid(Color::BLACK));
5969        assert_eq!(line.width, 1.0);
5970        assert_eq!(
5971            resolved.diagnostics,
5972            [Diagnostic {
5973                message: "unsupported connector line style retained as visible default".to_owned(),
5974            }]
5975        );
5976    }
5977
5978    #[test]
5979    fn connector_style_resolves_the_theme_line_under_its_direct_line() {
5980        let styled = |line_index: u32, line: &str| {
5981            connector("line", 127_000, 254_000, "", line).replace(
5982                "</p:cxnSp>",
5983                &format!(
5984                    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>"#
5985                ),
5986            )
5987        };
5988        let fixture = Fixture::new(
5989            &[
5990                styled(2, ""),
5991                styled(2, r#"<a:ln w="38100"/>"#),
5992                styled(0, ""),
5993            ]
5994            .join(""),
5995            "",
5996            "",
5997        );
5998
5999        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
6000
6001        assert!(
6002            resolved.diagnostics.is_empty(),
6003            "{:?}",
6004            resolved.diagnostics
6005        );
6006        let accent1 = Paint::Solid(Color::from_hex("156082"));
6007        let themed = resolved.shapes[0].line.as_ref().expect("theme line");
6008        assert_eq!((&themed.paint, themed.width), (&accent1, 1.5));
6009        let widened = resolved.shapes[1].line.as_ref().expect("overlaid line");
6010        assert_eq!((&widened.paint, widened.width), (&accent1, 3.0));
6011        assert_eq!(resolved.shapes[2].line, None);
6012        assert!(
6013            resolved
6014                .shapes
6015                .iter()
6016                .all(|shape| shape.unsupported.is_none())
6017        );
6018    }
6019
6020    #[test]
6021    fn connector_shadow_resolves_from_its_effect_reference_under_its_direct_effects() {
6022        let styled = |effect_index: u32, direct: &str| {
6023            connector("line", 127_000, 254_000, "", direct).replace(
6024                "</p:cxnSp>",
6025                &format!(
6026                    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>"#
6027                ),
6028            )
6029        };
6030        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>"#;
6031        let fixture = Fixture::new(
6032            &[
6033                styled(3, ""),
6034                styled(0, ""),
6035                styled(3, "<a:effectLst/>"),
6036                connector("line", 127_000, 254_000, "", direct_shadow),
6037            ]
6038            .join(""),
6039            "",
6040            "",
6041        );
6042
6043        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
6044
6045        assert!(
6046            resolved.diagnostics.is_empty(),
6047            "{:?}",
6048            resolved.diagnostics
6049        );
6050        let shadows: Vec<_> = resolved
6051            .shapes
6052            .iter()
6053            .map(|shape| shape.shadow.is_some())
6054            .collect();
6055        assert_eq!(shadows, [true, false, false, true]);
6056        assert!(matches!(
6057            resolved.shapes[3].shadow,
6058            Some(Effect::OuterShadow { color, .. }) if color == Color::from_hex("FF0000")
6059        ));
6060    }
6061
6062    #[test]
6063    fn unresolvable_connector_effect_drops_only_the_shadow_with_a_diagnostic() {
6064        let styled = |effect_reference: &str, direct: &str| {
6065            connector("line", 127_000, 254_000, "", direct).replace(
6066                "</p:cxnSp>",
6067                &format!(
6068                    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>"#
6069                ),
6070            )
6071        };
6072        let placeholder_shadow = r#"<a:effectLst><a:outerShdw blurRad="12700" dist="25400" dir="0"><a:schemeClr val="phClr"/></a:outerShdw></a:effectLst>"#;
6073        let fixture = Fixture::new(
6074            &[
6075                styled(
6076                    r#"<a:effectRef idx="9"><a:schemeClr val="accent1"/></a:effectRef>"#,
6077                    "",
6078                ),
6079                styled(r#"<a:effectRef idx="0"/>"#, placeholder_shadow),
6080            ]
6081            .join(""),
6082            "",
6083            "",
6084        );
6085
6086        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
6087
6088        let messages: Vec<_> = resolved
6089            .diagnostics
6090            .iter()
6091            .map(|diagnostic| diagnostic.message.as_str())
6092            .collect();
6093        assert_eq!(
6094            messages,
6095            [
6096                "connector effect not rendered: effect style index 9 is outside the 3 available entries",
6097                "connector effect not rendered: effect style retains an unresolved phClr placeholder colour",
6098            ]
6099        );
6100        let accent1 = Paint::Solid(Color::from_hex("156082"));
6101        for shape in &resolved.shapes {
6102            assert_eq!(shape.shadow, None);
6103            assert_eq!(shape.line.as_ref().map(|line| &line.paint), Some(&accent1));
6104        }
6105    }
6106
6107    #[test]
6108    fn unknown_preset_keeps_bounds_text_and_diagnostic() {
6109        let shape = format!(
6110            "<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>",
6111            transform(0)
6112        );
6113        let fixture = Fixture::new(&shape, "", "");
6114
6115        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
6116        let shape = &resolved.shapes[0];
6117        assert_eq!(shape.geometry, ResolvedGeometry::BoundsFallback);
6118        assert_eq!(shape.bounds.width, 100.0 / 12_700.0);
6119        assert_eq!(shape.bounds.height, 100.0 / 12_700.0);
6120        assert_eq!(shape.unsupported, Some("unknown preset geometry"));
6121        let ResolvedContent::Text(text) = &shape.content else {
6122            panic!("unknown preset lost its text body");
6123        };
6124        assert!(matches!(
6125            &text.paragraphs[0].runs[0],
6126            ResolvedTextRun::Text { text, .. } if text == "keep this text"
6127        ));
6128        assert!(resolved.diagnostics.iter().any(|diagnostic| {
6129            diagnostic.message.contains("unknown preset geometry")
6130                && diagnostic.message.contains("futureBurst")
6131        }));
6132    }
6133
6134    #[test]
6135    fn text_box_without_geometry_does_not_render_a_fallback_border() {
6136        let shape = shape_with_details(None, None, &transform(0), Some("<a:bodyPr/>"));
6137        let fixture = Fixture::new(&shape, "", "");
6138
6139        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
6140
6141        assert_eq!(resolved.shapes[0].geometry, ResolvedGeometry::Rectangle);
6142        assert_eq!(resolved.shapes[0].unsupported, None);
6143        assert!(resolved.diagnostics.is_empty());
6144    }
6145
6146    #[test]
6147    fn empty_paragraph_end_properties_use_the_normal_character_cascade() {
6148        let shape = shape_with_details(
6149            None,
6150            None,
6151            &format!(
6152                r#"{}<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>"#,
6153                transform(0)
6154            ),
6155            Some(
6156                r#"<a:bodyPr/><a:lstStyle><a:defPPr><a:defRPr sz="1600" cap="all"><a:latin typeface="Carlito"/></a:defRPr></a:defPPr></a:lstStyle>"#,
6157            ),
6158        )
6159        .replace(
6160            "<a:p/>",
6161            r#"<a:p><a:pPr><a:defRPr b="1"/></a:pPr><a:endParaRPr sz="3200" i="1"/></a:p>"#,
6162        );
6163        let fixture = Fixture::new(&shape, "", "");
6164
6165        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
6166        let ResolvedContent::Text(body) = &resolved.shapes[0].content else {
6167            panic!("expected resolved text")
6168        };
6169        let paragraph = &body.paragraphs[0];
6170
6171        assert!(paragraph.runs.is_empty());
6172        assert_eq!(paragraph.end_style.font_size, Some(32.0));
6173        assert!(paragraph.end_style.bold);
6174        assert!(paragraph.end_style.italic);
6175        assert!(paragraph.end_style.all_caps);
6176        assert_eq!(
6177            paragraph.end_style.latin_typeface.as_deref(),
6178            Some("Carlito")
6179        );
6180    }
6181
6182    #[test]
6183    fn preset_black_and_white_resolve_to_concrete_paint() {
6184        let black = format!(
6185            "{}<a:solidFill><a:prstClr val=\"black\"/></a:solidFill>",
6186            transform(0)
6187        );
6188        let white = format!(
6189            "{}<a:solidFill><a:prstClr val=\"white\"/></a:solidFill>",
6190            transform(100)
6191        );
6192        let fixture = Fixture::new(
6193            &format!(
6194                "{}{}",
6195                shape_with_details(None, None, &black, None),
6196                shape_with_details(None, None, &white, None)
6197            ),
6198            "",
6199            "",
6200        );
6201
6202        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
6203        assert_eq!(resolved.shapes[0].fill, Some(Paint::Solid(Color::BLACK)));
6204        assert_eq!(resolved.shapes[1].fill, Some(Paint::Solid(Color::WHITE)));
6205    }
6206
6207    #[test]
6208    fn group_coordinate_scale_changes_bounds_not_stroke_width() {
6209        let leaf = shape_with_details(
6210            None,
6211            None,
6212            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>"#,
6213            None,
6214        );
6215        let group = format!(
6216            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>"#
6217        );
6218        let fixture = Fixture::new(&group, "", "");
6219
6220        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
6221        let shape = &resolved.shapes[0];
6222        assert_eq!(
6223            shape.bounds,
6224            Rect {
6225                x: 2.0,
6226                y: 6.0,
6227                width: 4.0,
6228                height: 9.0,
6229            }
6230        );
6231        assert_eq!(shape.line.as_ref().map(|line| line.width), Some(0.75));
6232        assert_eq!(
6233            shape.group_transform,
6234            Transform {
6235                e: 10.0,
6236                f: 20.0,
6237                ..Transform::IDENTITY
6238            }
6239        );
6240    }
6241
6242    #[test]
6243    fn grouped_text_with_a_visible_stroke_keeps_path_scale_and_stroke_width_invariant() {
6244        let leaf = shape_with_details(
6245            None,
6246            None,
6247            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>"#,
6248            Some(r#"<a:bodyPr/><a:p><a:r><a:t>visible outline</a:t></a:r></a:p>"#),
6249        );
6250        let group = format!(
6251            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>"#
6252        );
6253        let fixture = Fixture::new(&group, "", "");
6254
6255        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
6256        let shape = &resolved.shapes[0];
6257        assert_eq!(
6258            shape.bounds,
6259            Rect {
6260                x: 2.0,
6261                y: 6.0,
6262                width: 4.0,
6263                height: 9.0,
6264            }
6265        );
6266        assert_eq!(shape.line.as_ref().map(|line| line.width), Some(0.75));
6267        assert_eq!(
6268            shape.group_transform,
6269            Transform {
6270                e: 10.0,
6271                f: 20.0,
6272                ..Transform::IDENTITY
6273            }
6274        );
6275        assert!(matches!(shape.content, ResolvedContent::Text(_)));
6276    }
6277
6278    #[test]
6279    fn nested_group_coordinate_mappings_apply_inner_before_outer() {
6280        let leaf = shape_with_details(
6281            None,
6282            None,
6283            r#"<a:xfrm><a:off x="12700" y="25400"/><a:ext cx="38100" cy="50800"/></a:xfrm>"#,
6284            None,
6285        );
6286        let inner = format!(
6287            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>"#
6288        );
6289        let outer = format!(
6290            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>"#
6291        );
6292        let fixture = Fixture::new(&outer, "", "");
6293
6294        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
6295        let shape = &resolved.shapes[0];
6296        assert_eq!(
6297            shape.bounds,
6298            Rect {
6299                x: 8.0,
6300                y: 30.0,
6301                width: 24.0,
6302                height: 60.0,
6303            }
6304        );
6305        assert_eq!(
6306            shape.group_transform,
6307            Transform {
6308                e: 14.0,
6309                f: 30.0,
6310                ..Transform::IDENTITY
6311            }
6312        );
6313    }
6314
6315    #[test]
6316    fn zero_group_child_extent_is_finite_and_diagnosed_once() {
6317        let leaf = shape_with_details(
6318            None,
6319            None,
6320            r#"<a:xfrm><a:off x="76200" y="101600"/><a:ext cx="12700" cy="12700"/></a:xfrm>"#,
6321            None,
6322        );
6323        let group = format!(
6324            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>"#
6325        );
6326        let fixture = Fixture::new(&group, "", "");
6327
6328        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
6329        let shape = &resolved.shapes[0];
6330        assert_eq!(
6331            shape.bounds,
6332            Rect {
6333                x: 6.0,
6334                y: 24.0,
6335                width: 1.0,
6336                height: 3.0,
6337            }
6338        );
6339        assert!(shape.bounds.x.is_finite());
6340        assert!(shape.bounds.y.is_finite());
6341        assert_eq!(
6342            shape.group_transform,
6343            Transform {
6344                e: 5.0,
6345                f: -1.0,
6346                ..Transform::IDENTITY
6347            }
6348        );
6349        assert_eq!(
6350            resolved
6351                .diagnostics
6352                .iter()
6353                .filter(|diagnostic| diagnostic.message.contains("group child extent"))
6354                .count(),
6355            1
6356        );
6357    }
6358
6359    #[test]
6360    fn unsupported_sheared_group_mapping_is_diagnosed_without_non_finite_values() {
6361        let leaf = shape_with_details(None, None, &transform(0), None);
6362        let inner = format!(
6363            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>"#
6364        );
6365        let outer = format!(
6366            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>"#
6367        );
6368        let fixture = Fixture::new(&outer, "", "");
6369
6370        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
6371        let transform = resolved.shapes[0].group_transform;
6372        assert!(
6373            [
6374                transform.a,
6375                transform.b,
6376                transform.c,
6377                transform.d,
6378                transform.e,
6379                transform.f,
6380            ]
6381            .into_iter()
6382            .all(f64::is_finite)
6383        );
6384        assert!(resolved.diagnostics.iter().any(|diagnostic| {
6385            diagnostic.message
6386                == "unsupported sheared or singular group transform retained as affine fallback"
6387        }));
6388    }
6389
6390    #[test]
6391    fn flattened_group_children_keep_sibling_order() {
6392        let group = format!(
6393            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>"#,
6394            shape_with_text(None, None, "inside one"),
6395            shape_with_text(None, None, "inside two")
6396        );
6397        let fixture = Fixture::new(
6398            &format!(
6399                "{}{}{}",
6400                shape_with_text(None, None, "before"),
6401                group,
6402                shape_with_text(None, None, "after")
6403            ),
6404            "",
6405            "",
6406        );
6407
6408        assert_eq!(
6409            fixture
6410                .context()
6411                .flatten()
6412                .iter()
6413                .filter_map(item_text)
6414                .collect::<Vec<_>>(),
6415            ["before", "inside one", "inside two", "after"]
6416        );
6417    }
6418
6419    #[test]
6420    fn paragraph_spacing_and_bullet_size_are_concrete() {
6421        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>"#;
6422        let fixture = Fixture::new(
6423            &shape_with_details(None, None, &transform(0), Some(text)),
6424            "",
6425            "",
6426        );
6427
6428        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
6429        let ResolvedContent::Text(body) = &resolved.shapes[0].content else {
6430            panic!("expected text");
6431        };
6432        let paragraph = &body.paragraphs[0];
6433        assert_eq!(
6434            paragraph.line_spacing,
6435            Some(ResolvedTextSpacing::Percent(1.2))
6436        );
6437        assert_eq!(
6438            paragraph.space_before,
6439            Some(ResolvedTextSpacing::Points(6.0))
6440        );
6441        assert_eq!(
6442            paragraph.space_after,
6443            Some(ResolvedTextSpacing::Points(3.0))
6444        );
6445        assert!(matches!(
6446            paragraph.bullet,
6447            Some(ResolvedBullet::Character {
6448                size: Some(ResolvedBulletSize::Percent(1.25)),
6449                ..
6450            })
6451        ));
6452    }
6453
6454    #[test]
6455    fn explicit_rtl_paragraph_direction_reaches_resolved_layout() {
6456        let text = r#"<a:bodyPr/><a:p><a:pPr rtl="1"/><a:r><a:t>123</a:t></a:r></a:p>"#;
6457        let fixture = Fixture::new(
6458            &shape_with_details(None, None, &transform(0), Some(text)),
6459            "",
6460            "",
6461        );
6462
6463        let (resolved, directions) = fixture
6464            .context()
6465            .resolve_slide_inner((720.0, 540.0), None, None, None, None)
6466            .unwrap();
6467        let ResolvedContent::Text(body) = &resolved.shapes[0].content else {
6468            panic!("expected text");
6469        };
6470        assert_eq!(body.paragraphs[0].runs.len(), 1);
6471        assert_eq!(directions[0][0].len(), body.paragraphs.len());
6472        assert_eq!(directions[0][0][0], oxml_layout::TextDirection::RightToLeft);
6473    }
6474
6475    #[test]
6476    fn legacy_resolved_paragraph_literal_remains_source_compatible() {
6477        let paragraph = ResolvedParagraph {
6478            level: 0,
6479            left_margin: 0.0,
6480            right_margin: 0.0,
6481            indent: 0.0,
6482            alignment: ParagraphAlignment::Left,
6483            line_spacing: None,
6484            space_before: None,
6485            space_after: None,
6486            bullet: None,
6487            end_style: ResolvedRunStyle::default(),
6488            runs: Vec::new(),
6489        };
6490
6491        assert_eq!(paragraph, ResolvedParagraph::default());
6492    }
6493
6494    #[test]
6495    fn auto_number_bullet_keeps_independently_inherited_style() {
6496        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>"#;
6497        let fixture = Fixture::new(
6498            &shape_with_details(None, None, &transform(0), Some(text)),
6499            "",
6500            "",
6501        );
6502
6503        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
6504        let ResolvedContent::Text(body) = &resolved.shapes[0].content else {
6505            panic!("expected text");
6506        };
6507        assert_eq!(
6508            body.paragraphs[0].bullet,
6509            Some(ResolvedBullet::AutoNumber {
6510                scheme: "arabicPeriod".to_owned(),
6511                start_at: 3,
6512                font: Some("Wingdings".to_owned()),
6513                color: Some(Color {
6514                    r: 0x12 as f64 / 255.0,
6515                    g: 0x34 as f64 / 255.0,
6516                    b: 0x56 as f64 / 255.0,
6517                    a: 1.0,
6518                }),
6519                size: Some(ResolvedBulletSize::Percent(1.25)),
6520            })
6521        );
6522    }
6523
6524    #[test]
6525    fn table_cells_resolve_body_and_paragraph_properties() {
6526        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>"#;
6527        let fixture = Fixture::new(frame, "", "");
6528
6529        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
6530        let ResolvedContent::Table(table) = &resolved.shapes[0].content else {
6531            panic!("expected table");
6532        };
6533        let body = table.rows[0].cells[0].text.as_ref().unwrap();
6534        assert_eq!(body.insets.left, 1.0);
6535        assert_eq!(body.anchor, ResolvedTextAnchor::Bottom);
6536        assert_eq!(body.vertical, TextDirection::Vertical);
6537        assert_eq!(body.autofit, ResolvedAutofit::None);
6538        assert_eq!(body.paragraphs[0].left_margin, 2.0);
6539        assert_eq!(
6540            body.paragraphs[0].alignment,
6541            crate::ParagraphAlignment::Center
6542        );
6543        assert!(resolved.diagnostics.iter().any(|diagnostic| {
6544            diagnostic.message == "table cell autofit is unsupported and was ignored"
6545        }));
6546    }
6547
6548    #[test]
6549    fn table_style_regions_resolve_in_documented_precedence() {
6550        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>"#;
6551        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();
6552        let fixture = Fixture::new(frame, "", "");
6553
6554        let resolved = fixture
6555            .context()
6556            .with_table_styles(&styles)
6557            .resolve_slide((20.0, 20.0))
6558            .unwrap();
6559        let ResolvedContent::Table(table) = &resolved.shapes[0].content else {
6560            panic!("expected table");
6561        };
6562
6563        assert_eq!(
6564            table.rows[0].cells[0].fill,
6565            Some(Paint::Solid(Color::from_hex("444444")))
6566        );
6567        assert_eq!(
6568            table.rows[0].cells[1].fill,
6569            Some(Paint::Solid(Color::from_hex("333333")))
6570        );
6571        assert_eq!(
6572            table.rows[1].cells[0].fill,
6573            Some(Paint::Solid(Color::from_hex("222222")))
6574        );
6575        let ResolvedTextRun::Text { style, .. } =
6576            &table.rows[0].cells[0].text.as_ref().unwrap().paragraphs[0].runs[0]
6577        else {
6578            panic!("expected styled text run")
6579        };
6580        assert!(style.bold && style.italic);
6581        assert_eq!(style.fill, Some(Paint::Solid(Color::from_hex("AABBCC"))));
6582        assert_eq!(style.latin_typeface.as_deref(), Some("Aptos Display"));
6583    }
6584
6585    #[test]
6586    fn builtin_table_styles_resolve_without_package_definitions() {
6587        let id = "{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}";
6588        let style = super::CT_TableStyle::builtin(id).expect("known built-in style parses");
6589        assert_eq!(style.style_id, id);
6590        assert!(style.whole_table.is_some());
6591        assert!(super::CT_TableStyle::builtin("{00000000-0000-0000-0000-000000000000}").is_none());
6592
6593        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>"#;
6594        let fixture = Fixture::new(frame, "", "");
6595        let resolved = fixture.context().resolve_slide((20.0, 20.0)).unwrap();
6596        let ResolvedContent::Table(table) = &resolved.shapes[0].content else {
6597            panic!("expected table");
6598        };
6599        assert!(matches!(table.rows[0].cells[0].fill, Some(Paint::Solid(_))));
6600        assert!(matches!(table.rows[1].cells[0].fill, Some(Paint::Solid(_))));
6601        assert_ne!(table.rows[0].cells[0].fill, table.rows[1].cells[0].fill);
6602
6603        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();
6604        let overridden = fixture
6605            .context()
6606            .with_table_styles(&package_style)
6607            .resolve_slide((20.0, 20.0))
6608            .unwrap();
6609        let ResolvedContent::Table(overridden) = &overridden.shapes[0].content else {
6610            panic!("expected table");
6611        };
6612        assert_eq!(
6613            overridden.rows[0].cells[0].fill,
6614            Some(Paint::Solid(Color::from_hex("FF0000")))
6615        );
6616    }
6617
6618    #[test]
6619    fn direct_table_text_and_inside_borders_override_style_defaults() {
6620        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>"#;
6621        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();
6622        let fixture = Fixture::new(frame, "", "");
6623
6624        let resolved = fixture
6625            .context()
6626            .with_table_styles(&styles)
6627            .resolve_slide((20.0, 10.0))
6628            .unwrap();
6629        let ResolvedContent::Table(table) = &resolved.shapes[0].content else {
6630            panic!("expected table");
6631        };
6632        let ResolvedTextRun::Text { style, .. } =
6633            &table.rows[0].cells[0].text.as_ref().unwrap().paragraphs[0].runs[0]
6634        else {
6635            panic!("expected direct run");
6636        };
6637        assert!(!style.bold);
6638        assert_eq!(style.fill, Some(Paint::Solid(Color::from_hex("00FF00"))));
6639        assert_eq!(style.latin_typeface.as_deref(), Some("Courier New"));
6640
6641        let first = &table.rows[0].cells[0];
6642        let second = &table.rows[0].cells[1];
6643        assert_eq!(
6644            first.left.as_ref().unwrap().stroke.as_ref().unwrap().paint,
6645            Paint::Solid(Color::from_hex("FF0000"))
6646        );
6647        assert_eq!(
6648            first.right.as_ref().unwrap().stroke.as_ref().unwrap().paint,
6649            Paint::Solid(Color::from_hex("0000FF"))
6650        );
6651        assert_eq!(
6652            second.left.as_ref().unwrap().stroke.as_ref().unwrap().paint,
6653            Paint::Solid(Color::from_hex("0000FF"))
6654        );
6655        assert_eq!(
6656            second
6657                .right
6658                .as_ref()
6659                .unwrap()
6660                .stroke
6661                .as_ref()
6662                .unwrap()
6663                .paint,
6664            Paint::Solid(Color::from_hex("FF0000"))
6665        );
6666    }
6667
6668    #[test]
6669    fn table_corners_require_both_flags_and_unsupported_fills_are_diagnosed() {
6670        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>"#;
6671        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();
6672        let fixture = Fixture::new(frame, "", "");
6673
6674        let resolved = fixture
6675            .context()
6676            .with_table_styles(&styles)
6677            .resolve_slide((20.0, 10.0))
6678            .unwrap();
6679        let ResolvedContent::Table(table) = &resolved.shapes[0].content else {
6680            panic!("expected table");
6681        };
6682
6683        assert_eq!(
6684            table.rows[0].cells[0].fill,
6685            Some(Paint::Solid(Color::from_hex("333333")))
6686        );
6687        assert_eq!(table.rows[0].cells[1].fill, None);
6688        assert!(
6689            table.rows[0].cells[1]
6690                .left
6691                .as_ref()
6692                .is_some_and(|border| border.stroke.is_none())
6693        );
6694        assert!(resolved.diagnostics.iter().any(|diagnostic| {
6695            diagnostic.message == "unsupported table cell pattern fill was ignored"
6696        }));
6697    }
6698
6699    #[test]
6700    fn builtin_table_style_ids_resolve_unless_the_package_defines_them() {
6701        // python-pptx names Medium Style 2 - Accent 1 without defining it.
6702        let frame = |style_id: &str| {
6703            format!(
6704                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>"#
6705            )
6706        };
6707        let medium_2_accent_1 =
6708            "<a:tableStyleId>{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}</a:tableStyleId>";
6709        let resolve = |frame: &str, styles: Option<&oxml_drawing::table::CT_TableStyleList>| {
6710            let fixture = Fixture::new(frame, "", "");
6711            let mut context = fixture.context();
6712            if let Some(styles) = styles {
6713                context = context.with_table_styles(styles);
6714            }
6715            let resolved = context.resolve_slide((10.0, 30.0)).unwrap();
6716            let ResolvedContent::Table(table) = &resolved.shapes[0].content else {
6717                panic!("expected table");
6718            };
6719            table.clone()
6720        };
6721
6722        let table = resolve(&frame(medium_2_accent_1), None);
6723        let fills = table
6724            .rows
6725            .iter()
6726            .map(|row| row.cells[0].fill.clone())
6727            .collect::<Vec<_>>();
6728        assert_eq!(fills[0], Some(Paint::Solid(Color::from_hex("156082"))));
6729        assert_eq!(fills[1], Some(Paint::Solid(Color::from_hex("CCD2D8"))));
6730        assert_eq!(fills[2], Some(Paint::Solid(Color::from_hex("E7EAED"))));
6731        let heading_rule = table.rows[0].cells[0].bottom.as_ref().unwrap();
6732        let heading_rule = heading_rule.stroke.as_ref().unwrap();
6733        assert_eq!(heading_rule.paint, Paint::Solid(Color::from_hex("FFFFFF")));
6734        assert_eq!(heading_rule.width, 3.0);
6735        let ResolvedTextRun::Text { style, .. } =
6736            &table.rows[0].cells[0].text.as_ref().unwrap().paragraphs[0].runs[0]
6737        else {
6738            panic!("expected heading run");
6739        };
6740        assert!(style.bold);
6741        assert_eq!(style.fill, Some(Paint::Solid(Color::from_hex("FFFFFF"))));
6742
6743        // A definition in the package wins over the built-in one.
6744        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();
6745        let table = resolve(&frame(medium_2_accent_1), Some(&styles));
6746        assert_eq!(
6747            table.rows[0].cells[0].fill,
6748            Some(Paint::Solid(Color::from_hex("FF0000")))
6749        );
6750        assert_eq!(table.rows[1].cells[0].fill, None);
6751    }
6752
6753    #[test]
6754    fn a_builtin_default_style_applies_when_the_package_has_no_definition() {
6755        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>"#;
6756        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();
6757        let fixture = Fixture::new(frame, "", "");
6758        let resolved = fixture
6759            .context()
6760            .with_table_styles(&styles)
6761            .resolve_slide((10.0, 10.0))
6762            .unwrap();
6763        let ResolvedContent::Table(table) = &resolved.shapes[0].content else {
6764            panic!("expected table");
6765        };
6766        assert_eq!(
6767            table.rows[0].cells[0].fill,
6768            Some(Paint::Solid(Color::from_hex("156082")))
6769        );
6770    }
6771
6772    #[test]
6773    fn table_style_background_resolves_over_the_whole_table() {
6774        // Themed Style 1 - Accent 1 fills the table from the theme's second
6775        // fill style (a gradient) in accent 1 and adds a theme effect.
6776        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>"#;
6777        let fixture = Fixture::new(frame, "", "");
6778
6779        let resolved = fixture.context().resolve_slide((20.0, 10.0)).unwrap();
6780        let ResolvedContent::Table(table) = &resolved.shapes[0].content else {
6781            panic!("expected table");
6782        };
6783
6784        let Some(Paint::Linear { start, end, .. }) = &table.background else {
6785            panic!("expected a gradient background, got {:?}", table.background);
6786        };
6787        // The gradient runs down the 20 by 10 point table box, as the theme's
6788        // 90 degree fill style says.
6789        assert!((start.x - 10.0).abs() < 1e-9 && (end.x - 10.0).abs() < 1e-9);
6790        assert!(start.y.abs() < 1e-9 && (end.y - 10.0).abs() < 1e-9);
6791        assert_eq!(
6792            table.rows[0].cells[0].fill,
6793            Some(Paint::Solid(Color::from_hex("156082")))
6794        );
6795        assert!(resolved.diagnostics.iter().any(|diagnostic| {
6796            diagnostic.message == "unsupported table background effect was ignored"
6797        }));
6798    }
6799
6800    #[test]
6801    fn table_cell_autofit_is_ignored_and_records_a_diagnostic() {
6802        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>"#;
6803        let fixture = Fixture::new(frame, "", "");
6804
6805        let resolved = fixture.context().resolve_slide((10.0, 10.0)).unwrap();
6806        let ResolvedContent::Table(table) = &resolved.shapes[0].content else {
6807            panic!("expected table");
6808        };
6809
6810        assert_eq!(
6811            table.rows[0].cells[0].text.as_ref().unwrap().autofit,
6812            ResolvedAutofit::None
6813        );
6814        assert!(
6815            resolved
6816                .diagnostics
6817                .iter()
6818                .any(|diagnostic| diagnostic.message
6819                    == "table cell autofit is unsupported and was ignored")
6820        );
6821    }
6822
6823    #[test]
6824    fn linear_gradient_scaled_modes_cover_non_square_bounds() {
6825        let gradient = |scaled: &str| {
6826            format!(
6827                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>"#
6828            )
6829        };
6830        let slide = [
6831            shape_with_details(None, None, &gradient(""), None),
6832            shape_with_details(None, None, &gradient(r#" scaled="0""#), None),
6833            shape_with_details(None, None, &gradient(r#" scaled="1""#), None),
6834        ]
6835        .join("");
6836        let resolved = Fixture::new(&slide, "", "")
6837            .context()
6838            .resolve_slide((720.0, 540.0))
6839            .unwrap();
6840
6841        for shape in &resolved.shapes[..2] {
6842            let Some(Paint::Linear { start, end, .. }) = shape.fill.as_ref() else {
6843                panic!("expected unscaled linear gradient");
6844            };
6845            assert_close(start.x, 5.0);
6846            assert_close(start.y, -5.0);
6847            assert_close(end.x, 35.0);
6848            assert_close(end.y, 25.0);
6849        }
6850        let Some(Paint::Linear { start, end, .. }) = resolved.shapes[2].fill.as_ref() else {
6851            panic!("expected scaled linear gradient");
6852        };
6853        assert_close(start.x, 0.0);
6854        assert_close(start.y, 0.0);
6855        assert_close(end.x, 40.0);
6856        assert_close(end.y, 20.0);
6857        assert!(resolved.diagnostics.is_empty());
6858    }
6859
6860    #[test]
6861    fn unsupported_gradient_variants_have_distinct_diagnostics() {
6862        let variants = [
6863            (
6864                r#"flip="x""#,
6865                r#"<a:lin ang="0"/>"#,
6866                "horizontal gradient flip",
6867            ),
6868            (
6869                r#"flip="y""#,
6870                r#"<a:lin ang="0"/>"#,
6871                "vertical gradient flip",
6872            ),
6873            (
6874                r#"flip="xy""#,
6875                r#"<a:lin ang="0"/>"#,
6876                "horizontal and vertical gradient flip",
6877            ),
6878            (
6879                "",
6880                r#"<a:lin ang="0"/><a:tileRect l="10000"/>"#,
6881                "gradient tile rectangle",
6882            ),
6883            ("", r#"<a:path path="circle"/>"#, "circle path gradient"),
6884            ("", r#"<a:path path="rect"/>"#, "rectangle path gradient"),
6885            ("", r#"<a:path path="shape"/>"#, "shape path gradient"),
6886        ];
6887
6888        for (attributes, geometry, expected) in variants {
6889            let details = format!(
6890                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>"#
6891            );
6892            let fixture = Fixture::new(&shape_with_details(None, None, &details, None), "", "");
6893            let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
6894
6895            assert!(resolved.shapes[0].fill.is_none(), "{expected}");
6896            assert_eq!(resolved.shapes[0].unsupported, Some(expected));
6897            assert!(resolved.diagnostics[0].message.contains(expected));
6898        }
6899    }
6900
6901    #[test]
6902    fn independent_gradient_is_concrete_only_without_an_effective_shape_rotation() {
6903        let geometry = r#"<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>"#;
6904        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>"#;
6905        let unrotated = shape_with_details(
6906            None,
6907            None,
6908            &format!(
6909                r#"<a:xfrm><a:off x="0" y="0"/><a:ext cx="127000" cy="127000"/></a:xfrm>{geometry}{gradient}"#
6910            ),
6911            None,
6912        );
6913        let rotated = shape_with_details(
6914            None,
6915            None,
6916            &format!(
6917                r#"<a:xfrm rot="5400000"><a:off x="0" y="0"/><a:ext cx="127000" cy="127000"/></a:xfrm>{geometry}{gradient}"#
6918            ),
6919            None,
6920        );
6921        let shapes = format!("{unrotated}{rotated}");
6922        let resolved = Fixture::new(&shapes, "", "")
6923            .context()
6924            .resolve_slide((720.0, 540.0))
6925            .unwrap();
6926
6927        assert!(matches!(
6928            resolved.shapes[0].fill,
6929            Some(Paint::Linear { .. })
6930        ));
6931        assert_eq!(resolved.shapes[0].unsupported, None);
6932        assert_eq!(resolved.shapes[1].fill, None);
6933        assert_eq!(
6934            resolved.shapes[1].unsupported,
6935            Some("gradient independent of shape rotation")
6936        );
6937        assert_eq!(resolved.diagnostics.len(), 1);
6938
6939        let grouped = format!(
6940            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>"#
6941        );
6942        let resolved = Fixture::new(&grouped, "", "")
6943            .context()
6944            .resolve_slide((720.0, 540.0))
6945            .unwrap();
6946        assert_eq!(resolved.shapes[0].fill, None);
6947        assert_eq!(
6948            resolved.shapes[0].unsupported,
6949            Some("gradient independent of shape rotation")
6950        );
6951    }
6952
6953    #[test]
6954    fn theme_style_resolves_to_concrete_paint_line_and_shadow() {
6955        let shape = format!(
6956            "<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>",
6957            transform(0)
6958        );
6959        let fixture = Fixture::new(&shape, "", "");
6960
6961        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
6962        let shape = &resolved.shapes[0];
6963        assert!(matches!(shape.fill, Some(Paint::Solid(_))));
6964        assert!(shape.line.is_some());
6965        assert!(matches!(shape.shadow, Some(Effect::OuterShadow { .. })));
6966    }
6967
6968    #[test]
6969    fn unsupported_content_keeps_bounds_and_diagnostic() {
6970        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>"#;
6971        let fixture = Fixture::new(frame, "", "");
6972
6973        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
6974
6975        assert_eq!(resolved.shapes.len(), 1);
6976        assert_eq!(resolved.shapes[0].bounds.width, 10.0);
6977        assert_eq!(resolved.shapes[0].bounds.height, 20.0);
6978        assert_eq!(
6979            resolved.shapes[0].geometry,
6980            ResolvedGeometry::BoundsFallback
6981        );
6982        assert_eq!(
6983            resolved.shapes[0].unsupported,
6984            Some("unknown graphic frame")
6985        );
6986        assert_eq!(resolved.diagnostics.len(), 1);
6987    }
6988
6989    #[test]
6990    fn ole_png_previews_use_their_producer_scope_and_graphic_frame_transform() {
6991        let fixture = Fixture::new(
6992            &ole_frame("rId7", 25_400, 9_999_999),
6993            &ole_frame("rId7", 12_700, 8_888_888),
6994            &ole_frame("rId7", 0, 7_777_777),
6995        );
6996        let media = ScopedMediaIds {
6997            slide: HashMap::from([("rId7".to_owned(), MediaId(1))]),
6998            layout: HashMap::from([("rId7".to_owned(), MediaId(2))]),
6999            master: HashMap::from([("rId7".to_owned(), MediaId(3))]),
7000            media_content_types: HashMap::from([
7001                (MediaId(1), "image/png".to_owned()),
7002                (MediaId(2), "image/png".to_owned()),
7003                (MediaId(3), "image/png".to_owned()),
7004            ]),
7005        };
7006
7007        let resolved = fixture
7008            .context()
7009            .resolve_slide_with_media((720.0, 540.0), &media)
7010            .unwrap();
7011        let ids = resolved
7012            .shapes
7013            .iter()
7014            .map(|shape| match &shape.content {
7015                ResolvedContent::Image(image) => image.media,
7016                _ => panic!("OLE PNG preview did not resolve as image content"),
7017            })
7018            .collect::<Vec<_>>();
7019        let positions = resolved
7020            .shapes
7021            .iter()
7022            .map(|shape| shape.bounds.x)
7023            .collect::<Vec<_>>();
7024
7025        assert_eq!(ids, [MediaId(3), MediaId(2), MediaId(1)]);
7026        assert_eq!(positions, [0.0, 1.0, 2.0]);
7027        assert!(resolved.shapes.iter().all(|shape| {
7028            shape.geometry == ResolvedGeometry::Rectangle
7029                && shape.unsupported == Some("embedded OLE interactivity")
7030        }));
7031        assert_eq!(
7032            resolved
7033                .diagnostics
7034                .iter()
7035                .map(|diagnostic| diagnostic.message.as_str())
7036                .collect::<Vec<_>>(),
7037            vec![
7038                "OLE object rendered as a static PNG preview. Embedded OLE interactivity is not rendered";
7039                3
7040            ]
7041        );
7042    }
7043
7044    #[test]
7045    fn ole_preview_requires_embedded_resolved_png_media() {
7046        let children = [
7047            ole_frame_without_preview(0),
7048            ole_frame_with_blip(
7049                r#"r:link="https://example.invalid/preview.png""#,
7050                12_700,
7051                12_700,
7052            ),
7053            ole_frame("rIdMissing", 25_400, 25_400),
7054            ole_frame("rIdWmf", 38_100, 38_100),
7055        ]
7056        .join("");
7057        let media = ScopedMediaIds {
7058            slide: HashMap::from([("rIdWmf".to_owned(), MediaId(4))]),
7059            media_content_types: HashMap::from([(MediaId(4), "image/x-wmf".to_owned())]),
7060            ..ScopedMediaIds::default()
7061        };
7062
7063        let resolved = Fixture::new(&children, "", "")
7064            .context()
7065            .resolve_slide_with_media((720.0, 540.0), &media)
7066            .unwrap();
7067
7068        assert_eq!(resolved.shapes.len(), 4);
7069        assert!(resolved.shapes.iter().all(|shape| {
7070            shape.content == ResolvedContent::None
7071                && shape.geometry == ResolvedGeometry::BoundsFallback
7072                && shape.unsupported == Some("OLE")
7073        }));
7074        assert_eq!(
7075            resolved.diagnostics,
7076            vec![
7077                Diagnostic {
7078                    message: "unsupported OLE content retained as bounds".to_owned(),
7079                };
7080                4
7081            ]
7082        );
7083    }
7084
7085    #[test]
7086    fn resolved_shapes_follow_the_flattened_order() {
7087        let master = shape_with_details(None, None, &transform(12_700), None);
7088        let layout = shape_with_details(None, None, &transform(25_400), None);
7089        let slide = shape_with_details(None, None, &transform(38_100), None);
7090        let fixture = Fixture::new(&slide, &layout, &master);
7091
7092        let resolved = fixture.context().resolve_slide((720.0, 540.0)).unwrap();
7093        let x = resolved
7094            .shapes
7095            .iter()
7096            .map(|shape| shape.bounds.x)
7097            .collect::<Vec<_>>();
7098        assert_eq!(x, [1.0, 2.0, 3.0]);
7099    }
7100
7101    #[test]
7102    fn all_corpus_slides_resolve_without_panics() {
7103        let stats = resolve_pinned_corpus();
7104
7105        assert_eq!(stats.decks, EXPECTED_CORPUS_DECKS);
7106        assert_eq!(stats.resolved + stats.contextual_errors, stats.slides);
7107    }
7108
7109    #[test]
7110    fn all_corpus_preset_geometries_evaluate_or_fallback() {
7111        let stats = resolve_pinned_corpus();
7112
7113        assert_eq!(stats.decks, EXPECTED_CORPUS_DECKS);
7114        assert_eq!(stats.contextual_errors, 0, "{}", stats.errors.join("\n"));
7115        assert!(
7116            stats.preset_inputs > 0,
7117            "corpus exercised no preset geometry"
7118        );
7119        assert_eq!(stats.preset_errors, 0, "{}", stats.errors.join("\n"));
7120        assert_eq!(
7121            stats.preset_inputs,
7122            stats.preset_evaluated + stats.preset_unknown,
7123            "not every corpus preset produced geometry or a named unknown fallback"
7124        );
7125        assert_eq!(stats.preset_fallbacks, stats.preset_unknown);
7126    }
7127
7128    #[derive(Default)]
7129    struct CorpusResolveStats {
7130        decks: usize,
7131        slides: usize,
7132        resolved: usize,
7133        contextual_errors: usize,
7134        theme_references: usize,
7135        preset_inputs: usize,
7136        preset_evaluated: usize,
7137        preset_unknown: usize,
7138        preset_errors: usize,
7139        preset_fallbacks: usize,
7140        errors: Vec<String>,
7141    }
7142
7143    fn resolve_pinned_corpus() -> CorpusResolveStats {
7144        let corpus = corpus_dir();
7145        assert!(
7146            corpus.is_dir(),
7147            "the required pinned corpus is missing at {}",
7148            corpus.display()
7149        );
7150        let entries = include_str!("../../../scripts/pptx-corpus-manifest.tsv")
7151            .lines()
7152            .skip(1)
7153            .map(|line| line.split('\t').next().unwrap())
7154            .collect::<Vec<_>>();
7155        assert_eq!(entries.len(), EXPECTED_CORPUS_DECKS);
7156
7157        let mut stats = CorpusResolveStats::default();
7158        for entry in entries {
7159            let path = corpus.join(entry);
7160            assert!(path.is_file(), "missing pinned deck {}", path.display());
7161            let package = OpcPackage::open(&path)
7162                .unwrap_or_else(|error| panic!("{}: {error}", path.display()));
7163            let presentation_part = package
7164                .main_document_part()
7165                .unwrap_or_else(|| panic!("{}: no presentation part", path.display()));
7166            let presentation = CT_Presentation::from_xml(part(&package, &presentation_part))
7167                .unwrap_or_else(|error| panic!("{}: {error}", path.display()));
7168            let default_text_style = presentation.default_text_style.unwrap_or_default();
7169            let size = presentation.slide_size.map_or((720.0, 540.0), |size| {
7170                (
7171                    super::emu_to_points(size.cx.0),
7172                    super::emu_to_points(size.cy.0),
7173                )
7174            });
7175            let presentation_rels = package
7176                .get_part_rels(&presentation_part)
7177                .unwrap_or_else(|| panic!("{}: no presentation relationships", path.display()));
7178
7179            for slide_id in presentation.slide_ids {
7180                stats.slides += 1;
7181                let slide_rel = presentation_rels
7182                    .get_by_id(&slide_id.relationship_id)
7183                    .unwrap_or_else(|| {
7184                        panic!(
7185                            "{}: missing slide relationship {}",
7186                            path.display(),
7187                            slide_id.relationship_id
7188                        )
7189                    });
7190                assert_eq!(slide_rel.rel_type, rel_types::SLIDE, "{}", path.display());
7191                let slide_part =
7192                    OpcPackage::resolve_rel_target(&presentation_part, &slide_rel.target);
7193                let layout_part =
7194                    related_part(&package, &slide_part, rel_types::SLIDE_LAYOUT, &path);
7195                let master_part =
7196                    related_part(&package, &layout_part, rel_types::SLIDE_MASTER, &path);
7197                let theme_part = related_part(&package, &master_part, rel_types::THEME, &path);
7198                let slide = CT_Slide::from_xml(part(&package, &slide_part))
7199                    .unwrap_or_else(|error| panic!("{} {slide_part}: {error}", path.display()));
7200                let layout = CT_SlideLayout::from_xml(part(&package, &layout_part))
7201                    .unwrap_or_else(|error| panic!("{} {layout_part}: {error}", path.display()));
7202                let master = CT_SlideMaster::from_xml(part(&package, &master_part))
7203                    .unwrap_or_else(|error| panic!("{} {master_part}: {error}", path.display()));
7204                let theme = CT_OfficeStyleSheet::from_xml(part(&package, &theme_part))
7205                    .unwrap_or_else(|error| panic!("{} {theme_part}: {error}", path.display()));
7206                let color_map = effective_corpus_color_map(&master, &layout, &slide);
7207                let context = ResolveCtx::new(
7208                    &theme,
7209                    color_map,
7210                    &master,
7211                    &layout,
7212                    &slide,
7213                    &default_text_style,
7214                );
7215                for item in context.flatten() {
7216                    let FlattenedItem::Shape {
7217                        child: ShapeTreeChild::Shape(shape),
7218                        ..
7219                    } = item
7220                    else {
7221                        continue;
7222                    };
7223                    if shape.shape_properties.custom_geometry.is_some() {
7224                        continue;
7225                    }
7226                    let Some(preset) = shape.shape_properties.preset_geometry.as_ref() else {
7227                        continue;
7228                    };
7229                    let Some((bounds, _, _, _)) =
7230                        transform_values(context.effective_xfrm(shape).as_ref())
7231                    else {
7232                        continue;
7233                    };
7234                    stats.preset_inputs += 1;
7235                    match context.concrete_preset_geometry(preset, (bounds.width, bounds.height)) {
7236                        Ok(Some(_)) => stats.preset_evaluated += 1,
7237                        Ok(None) => stats.preset_unknown += 1,
7238                        Err(error) => {
7239                            stats.preset_errors += 1;
7240                            stats.errors.push(format!(
7241                                "{} {slide_part}: preset {}: {error}",
7242                                path.display(),
7243                                preset.preset
7244                            ));
7245                        }
7246                    }
7247                }
7248                let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
7249                    context.resolve_slide(size)
7250                }))
7251                .unwrap_or_else(|_| panic!("{} {slide_part}: resolver panicked", path.display()));
7252                match result {
7253                    Ok(resolved) => {
7254                        let debug = format!("{resolved:?}");
7255                        stats.theme_references += ["schemeClr", "sysClr", "scrgbClr", "prstClr"]
7256                            .into_iter()
7257                            .filter(|marker| debug.contains(marker))
7258                            .count();
7259                        stats.preset_fallbacks += resolved
7260                            .shapes
7261                            .iter()
7262                            .filter(|shape| {
7263                                matches!(
7264                                    shape.unsupported,
7265                                    Some("unknown preset geometry" | "preset geometry evaluation")
7266                                )
7267                            })
7268                            .count();
7269                        stats.resolved += 1;
7270                    }
7271                    Err(error) => {
7272                        stats.contextual_errors += 1;
7273                        stats
7274                            .errors
7275                            .push(format!("{} {slide_part}: {error}", path.display()));
7276                    }
7277                }
7278            }
7279            stats.decks += 1;
7280        }
7281        stats
7282    }
7283
7284    fn corpus_dir() -> PathBuf {
7285        std::env::var_os("RDOCX_PPTX_CORPUS_DIR")
7286            .map(PathBuf::from)
7287            .unwrap_or_else(|| workspace_root().join("corpus/pptx"))
7288    }
7289
7290    fn workspace_root() -> PathBuf {
7291        Path::new(env!("CARGO_MANIFEST_DIR"))
7292            .parent()
7293            .and_then(Path::parent)
7294            .unwrap()
7295            .to_path_buf()
7296    }
7297
7298    fn part<'a>(package: &'a OpcPackage, part_name: &str) -> &'a [u8] {
7299        package
7300            .get_part(part_name)
7301            .unwrap_or_else(|| panic!("missing package part {part_name}"))
7302    }
7303
7304    fn related_part(
7305        package: &OpcPackage,
7306        source_part: &str,
7307        relationship_type: &str,
7308        deck: &Path,
7309    ) -> String {
7310        let relationship = package
7311            .get_part_rels(source_part)
7312            .and_then(|relationships| relationships.get_by_type(relationship_type))
7313            .unwrap_or_else(|| {
7314                panic!(
7315                    "{} {source_part}: missing relationship {relationship_type}",
7316                    deck.display()
7317                )
7318            });
7319        OpcPackage::resolve_rel_target(source_part, &relationship.target)
7320    }
7321
7322    fn effective_corpus_color_map(
7323        master: &CT_SlideMaster,
7324        layout: &CT_SlideLayout,
7325        slide: &CT_Slide,
7326    ) -> ColorMap {
7327        for override_value in [
7328            slide.color_map_override.as_ref(),
7329            layout.color_map_override.as_ref(),
7330        ]
7331        .into_iter()
7332        .flatten()
7333        {
7334            if let ColorMapOverrideKind::Override(map) = &override_value.kind {
7335                return map.clone();
7336            }
7337        }
7338        master.color_map.clone()
7339    }
7340
7341    fn slide_xml(children: &str) -> String {
7342        slide_xml_with("", "", children)
7343    }
7344
7345    fn layout_xml(children: &str) -> String {
7346        layout_xml_with("", "", children, "")
7347    }
7348
7349    fn master_xml(children: &str) -> String {
7350        master_xml_with("", children, "")
7351    }
7352
7353    fn slide_xml_with(attributes: &str, background: &str, children: &str) -> String {
7354        format!(
7355            "<p:sld xmlns:p=\"{P_NS}\" xmlns:a=\"{A_NS}\" xmlns:mc=\"{MC_NS}\" {attributes}><p:cSld>{background}{}</p:cSld></p:sld>",
7356            shape_tree(children)
7357        )
7358    }
7359
7360    fn layout_xml_with(
7361        attributes: &str,
7362        background: &str,
7363        children: &str,
7364        header_footer: &str,
7365    ) -> String {
7366        format!(
7367            "<p:sldLayout xmlns:p=\"{P_NS}\" xmlns:a=\"{A_NS}\" xmlns:mc=\"{MC_NS}\" {attributes}><p:cSld>{background}{}</p:cSld>{header_footer}</p:sldLayout>",
7368            shape_tree(children)
7369        )
7370    }
7371
7372    fn master_xml_with(background: &str, children: &str, header_footer: &str) -> String {
7373        format!(
7374            "<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>",
7375            shape_tree(children)
7376        )
7377    }
7378
7379    fn shape_tree(children: &str) -> String {
7380        format!("<p:spTree><p:nvGrpSpPr/><p:grpSpPr/>{children}</p:spTree>")
7381    }
7382
7383    fn shape(ph_type: Option<&str>, idx: Option<u32>) -> String {
7384        shape_with_details(ph_type, idx, "", None)
7385    }
7386
7387    fn shape_with_text(ph_type: Option<&str>, idx: Option<u32>, text: &str) -> String {
7388        let placeholder = if ph_type.is_none() && idx.is_none() {
7389            String::new()
7390        } else {
7391            let ph_type = ph_type.map_or_else(String::new, |value| format!(" type=\"{value}\""));
7392            let idx = idx.map_or_else(String::new, |value| format!(" idx=\"{value}\""));
7393            format!("<p:ph{ph_type}{idx}/>")
7394        };
7395        format!(
7396            "<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>"
7397        )
7398    }
7399
7400    fn item_text(item: &FlattenedItem<'_>) -> Option<String> {
7401        let FlattenedItem::Shape {
7402            child: ShapeTreeChild::Shape(shape),
7403            ..
7404        } = item
7405        else {
7406            return None;
7407        };
7408        shape.text_body.as_ref().map(|body| body.plain_text())
7409    }
7410
7411    fn background_source(fixture: &Fixture) -> BackgroundSource {
7412        fixture.context().effective_background().unwrap().source
7413    }
7414
7415    #[test]
7416    fn same_relationship_id_resolves_hyperlink_in_its_shape_source_scope() {
7417        let linked_shape = |label: &str, x: i64| {
7418            shape_with_details(None, None, &transform(x), Some("<a:bodyPr/>"))
7419                .replace(
7420                    "<a:p/>",
7421                    &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>"#),
7422                )
7423        };
7424        let fixture = Fixture::new(
7425            &linked_shape("slide", 200),
7426            &linked_shape("layout", 100),
7427            &linked_shape("master", 0),
7428        );
7429        let hyperlinks = ScopedHyperlinkTargets {
7430            slide: HashMap::from([("rId7".to_owned(), "https://slide.example".to_owned())]),
7431            layout: HashMap::from([("rId7".to_owned(), "https://layout.example".to_owned())]),
7432            master: HashMap::from([("rId7".to_owned(), "https://master.example".to_owned())]),
7433        };
7434
7435        let resolved = fixture
7436            .context()
7437            .resolve_slide_with_resources((720.0, 540.0), &ScopedMediaIds::default(), &hyperlinks)
7438            .expect("resolve source-scoped hyperlinks");
7439        let targets = resolved
7440            .shapes
7441            .iter()
7442            .filter_map(|shape| match &shape.content {
7443                ResolvedContent::Text(body) => body.paragraphs[0].runs.first(),
7444                _ => None,
7445            })
7446            .filter_map(|run| match run {
7447                ResolvedTextRun::Text { style, .. } => style.hyperlink_url.as_deref(),
7448                _ => None,
7449            })
7450            .collect::<Vec<_>>();
7451
7452        assert_eq!(
7453            targets,
7454            vec![
7455                "https://master.example",
7456                "https://layout.example",
7457                "https://slide.example"
7458            ]
7459        );
7460    }
7461
7462    #[test]
7463    fn missing_hyperlink_relationship_keeps_text_and_records_diagnostic() {
7464        let shape = shape_with_details(None, None, &transform(0), Some("<a:bodyPr/>"))
7465            .replace(
7466                "<a:p/>",
7467                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>"#,
7468            );
7469        let resolved = Fixture::new(&shape, "", "")
7470            .context()
7471            .resolve_slide_with_resources(
7472                (720.0, 540.0),
7473                &ScopedMediaIds::default(),
7474                &ScopedHyperlinkTargets::default(),
7475            )
7476            .expect("resolve unsupported hyperlinks without dropping text");
7477        let ResolvedContent::Text(body) = &resolved.shapes[0].content else {
7478            panic!("linked text should remain visible")
7479        };
7480
7481        assert_eq!(
7482            body.paragraphs[0]
7483                .runs
7484                .iter()
7485                .filter_map(|run| match run {
7486                    ResolvedTextRun::Text { text, .. } => Some(text.as_str()),
7487                    _ => None,
7488                })
7489                .collect::<String>(),
7490            "missing action internal"
7491        );
7492        let diagnostics = resolved
7493            .diagnostics
7494            .iter()
7495            .map(|diagnostic| diagnostic.message.as_str())
7496            .collect::<Vec<_>>();
7497        for expected in [
7498            "missing slide hyperlink relationship `rId404`",
7499            "unsupported slide hyperlink action `ppaction://macro`",
7500            "unsupported slide hyperlink action `ppaction://hlinksldjump`",
7501        ] {
7502            assert!(diagnostics.contains(&expected), "missing `{expected}`");
7503        }
7504    }
7505
7506    #[test]
7507    fn untyped_slide_number_placeholder_uses_the_current_page_number() {
7508        let slide_shape = shape_with_details(None, Some(4), &transform(0), Some("<a:bodyPr/>"))
7509            .replace(
7510                "<a:p/>",
7511                r#"<a:p><a:fld id="{00112233-4455-6677-8899-AABBCCDDEEFF}"><a:t>stored</a:t></a:fld></a:p>"#,
7512            );
7513        let layout_shape =
7514            shape_with_details(Some("sldNum"), Some(4), &transform(0), Some("<a:bodyPr/>"));
7515        let resolved = Fixture::new(&slide_shape, &layout_shape, "")
7516            .context()
7517            .resolve_slide((720.0, 540.0))
7518            .expect("resolve inherited slide-number placeholder");
7519        let ResolvedContent::Text(body) = &resolved.shapes[0].content else {
7520            panic!("slide-number field should remain visible")
7521        };
7522        let ResolvedTextRun::Field { field_type, .. } = &body.paragraphs[0].runs[0] else {
7523            panic!("expected a field")
7524        };
7525
7526        assert_eq!(field_type.as_deref(), Some("slidenum"));
7527    }
7528
7529    #[test]
7530    fn three_dimensional_chart_uses_cached_image_and_diagnostic() {
7531        let alternate = chart_alternate("rIdChart", Some("rIdPreview")).replace(
7532            r#"r:embed="rIdPreview"/>"#,
7533            r#"r:embed="rIdPreview"><a:alphaModFix amt="30000"/></a:blip>"#,
7534        );
7535        let fixture = Fixture::new(&alternate, "", "");
7536        let preview = MediaId(47);
7537        let media = ScopedMediaIds {
7538            slide: HashMap::from([("rIdPreview".to_owned(), preview)]),
7539            media_content_types: HashMap::from([(preview, "image/png".to_owned())]),
7540            ..ScopedMediaIds::default()
7541        };
7542        let charts = ScopedChartResources {
7543            slide: HashMap::from([(
7544                "rIdChart".to_owned(),
7545                ChartResource::Parsed(Box::new(three_dimensional_chart())),
7546            )]),
7547            ..ScopedChartResources::default()
7548        };
7549        let mut fonts = FontManager::new_deterministic().unwrap();
7550
7551        let resolved = fixture
7552            .context()
7553            .resolve_slide_with_chart_resources(
7554                (720.0, 540.0),
7555                &media,
7556                &ScopedHyperlinkTargets::default(),
7557                &charts,
7558                &mut fonts,
7559            )
7560            .unwrap();
7561
7562        assert!(matches!(
7563            resolved.shapes[0].content,
7564            ResolvedContent::Image(ref image) if image.media == preview && image.opacity == 0.3
7565        ));
7566        assert!(resolved.shapes[0].unsupported.is_some());
7567        assert!(resolved.diagnostics.iter().any(|diagnostic| {
7568            diagnostic.message.contains("unsupported chart")
7569                && diagnostic.message.contains("cached image")
7570        }));
7571
7572        let unsupported_media = ScopedMediaIds {
7573            slide: HashMap::from([("rIdPreview".to_owned(), preview)]),
7574            media_content_types: HashMap::from([(preview, "image/x-wmf".to_owned())]),
7575            ..ScopedMediaIds::default()
7576        };
7577        let mut fonts = FontManager::new_deterministic().unwrap();
7578        let resolved = fixture
7579            .context()
7580            .resolve_slide_with_chart_resources(
7581                (720.0, 540.0),
7582                &unsupported_media,
7583                &ScopedHyperlinkTargets::default(),
7584                &charts,
7585                &mut fonts,
7586            )
7587            .unwrap();
7588        assert!(matches!(
7589            resolved.shapes[0].content,
7590            ResolvedContent::Group(_)
7591        ));
7592        assert!(resolved.diagnostics.iter().any(|diagnostic| {
7593            diagnostic.message.contains("not renderer-compatible")
7594                && diagnostic.message.contains("unsupported chart")
7595        }));
7596    }
7597
7598    #[test]
7599    fn same_chart_relationship_id_is_scoped_to_its_source_part() {
7600        let fixture = Fixture::new(
7601            &chart_frame_at("rId8", 200),
7602            &chart_frame_at("rId8", 100),
7603            &chart_frame("rId8"),
7604        );
7605        let slide = ChartResource::MissingTarget("/ppt/charts/slide.xml".to_owned());
7606        let layout = ChartResource::MissingTarget("/ppt/charts/layout.xml".to_owned());
7607        let master = ChartResource::MissingTarget("/ppt/charts/master.xml".to_owned());
7608        let charts = ScopedChartResources {
7609            slide: HashMap::from([("rId8".to_owned(), slide.clone())]),
7610            layout: HashMap::from([("rId8".to_owned(), layout.clone())]),
7611            master: HashMap::from([("rId8".to_owned(), master.clone())]),
7612        };
7613
7614        let mut fonts = FontManager::new_deterministic().unwrap();
7615        let resolved = fixture
7616            .context()
7617            .resolve_slide_with_chart_resources(
7618                (720.0, 540.0),
7619                &ScopedMediaIds::default(),
7620                &ScopedHyperlinkTargets::default(),
7621                &charts,
7622                &mut fonts,
7623            )
7624            .unwrap();
7625        assert_eq!(resolved.shapes.len(), 3);
7626        let diagnostics = resolved
7627            .diagnostics
7628            .iter()
7629            .map(|diagnostic| diagnostic.message.as_str())
7630            .collect::<Vec<_>>();
7631        for (scope, target) in [
7632            ("master", "/ppt/charts/master.xml"),
7633            ("layout", "/ppt/charts/layout.xml"),
7634            ("slide", "/ppt/charts/slide.xml"),
7635        ] {
7636            assert!(diagnostics.iter().any(|message| {
7637                message.contains(scope) && message.contains("rId8") && message.contains(target)
7638            }));
7639        }
7640    }
7641
7642    #[test]
7643    fn unsupported_chart_without_preview_keeps_labelled_bounds() {
7644        let fixture = Fixture::new(&chart_frame("rIdChart"), "", "");
7645        let charts = ScopedChartResources {
7646            slide: HashMap::from([(
7647                "rIdChart".to_owned(),
7648                ChartResource::Parsed(Box::new(three_dimensional_chart())),
7649            )]),
7650            ..ScopedChartResources::default()
7651        };
7652        let mut fonts = FontManager::new_deterministic().unwrap();
7653        let resolved = fixture
7654            .context()
7655            .resolve_slide_with_chart_resources(
7656                (720.0, 540.0),
7657                &ScopedMediaIds::default(),
7658                &ScopedHyperlinkTargets::default(),
7659                &charts,
7660                &mut fonts,
7661            )
7662            .unwrap();
7663
7664        assert_eq!(
7665            resolved.shapes[0].geometry,
7666            ResolvedGeometry::BoundsFallback
7667        );
7668        let ResolvedContent::Group(group) = &resolved.shapes[0].content else {
7669            panic!("unsupported chart should retain a labelled group");
7670        };
7671        let mut labels = Vec::new();
7672        walk(&group.children, &mut |element, _| {
7673            if let PositionedElement::Text(run) = element {
7674                labels.push(run.text.clone());
7675            }
7676        });
7677        assert_eq!(labels, vec!["Unsupported chart"]);
7678    }
7679
7680    #[test]
7681    fn missing_or_external_chart_relationship_is_contextual() {
7682        let children = format!(
7683            "{}{}{}",
7684            chart_frame("missingChart"),
7685            chart_frame_at("externalChart", 200),
7686            chart_frame_at("missingTarget", 400)
7687        );
7688        let fixture = Fixture::new(&children, "", "");
7689        let charts = ScopedChartResources {
7690            slide: HashMap::from([
7691                (
7692                    "externalChart".to_owned(),
7693                    ChartResource::External("https://example.invalid/chart.xml".to_owned()),
7694                ),
7695                (
7696                    "missingTarget".to_owned(),
7697                    ChartResource::MissingTarget("/ppt/charts/missing.xml".to_owned()),
7698                ),
7699            ]),
7700            ..ScopedChartResources::default()
7701        };
7702        let mut fonts = FontManager::new_deterministic().unwrap();
7703        let resolved = fixture
7704            .context()
7705            .resolve_slide_with_chart_resources(
7706                (720.0, 540.0),
7707                &ScopedMediaIds::default(),
7708                &ScopedHyperlinkTargets::default(),
7709                &charts,
7710                &mut fonts,
7711            )
7712            .unwrap();
7713        let diagnostics = resolved
7714            .diagnostics
7715            .iter()
7716            .map(|diagnostic| diagnostic.message.as_str())
7717            .collect::<Vec<_>>();
7718
7719        assert!(
7720            diagnostics
7721                .iter()
7722                .any(|message| { message.contains("slide") && message.contains("missingChart") })
7723        );
7724        assert!(diagnostics.iter().any(|message| {
7725            message.contains("slide")
7726                && message.contains("externalChart")
7727                && message.contains("https://example.invalid/chart.xml")
7728        }));
7729        assert!(diagnostics.iter().any(|message| {
7730            message.contains("slide")
7731                && message.contains("missingTarget")
7732                && message.contains("/ppt/charts/missing.xml")
7733        }));
7734    }
7735
7736    fn shape_with_details(
7737        ph_type: Option<&str>,
7738        idx: Option<u32>,
7739        shape_properties: &str,
7740        body_properties: Option<&str>,
7741    ) -> String {
7742        let placeholder = if ph_type.is_none() && idx.is_none() {
7743            String::new()
7744        } else {
7745            let ph_type = ph_type.map_or_else(String::new, |value| format!(" type=\"{value}\""));
7746            let idx = idx.map_or_else(String::new, |value| format!(" idx=\"{value}\""));
7747            format!("<p:ph{ph_type}{idx}/>")
7748        };
7749        let text_body = body_properties.map_or_else(String::new, |body_properties| {
7750            format!("<p:txBody>{body_properties}<a:p/></p:txBody>")
7751        });
7752        format!(
7753            "<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>"
7754        )
7755    }
7756
7757    fn chart_frame(relationship_id: &str) -> String {
7758        chart_frame_at(relationship_id, 0)
7759    }
7760
7761    fn chart_frame_at(relationship_id: &str, x: i64) -> String {
7762        format!(
7763            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>"#
7764        )
7765    }
7766
7767    fn chart_alternate(relationship_id: &str, preview_id: Option<&str>) -> String {
7768        let preview = preview_id.map_or_else(String::new, |preview_id| {
7769            picture(preview_id, "", "<a:stretch><a:fillRect/></a:stretch>", 999)
7770        });
7771        format!(
7772            r#"<mc:AlternateContent><mc:Choice Requires="c">{}</mc:Choice><mc:Fallback>{preview}</mc:Fallback></mc:AlternateContent>"#,
7773            chart_frame(relationship_id)
7774        )
7775    }
7776
7777    fn three_dimensional_chart() -> CT_ChartSpace {
7778        CT_ChartSpace::from_xml(
7779            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>"#,
7780        )
7781        .unwrap()
7782    }
7783
7784    fn connector(preset: &str, cx: i64, cy: i64, adjustments: &str, line: &str) -> String {
7785        format!(
7786            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>"#
7787        )
7788    }
7789
7790    fn picture(
7791        relationship_id: &str,
7792        fill_attributes: &str,
7793        fill_children: &str,
7794        x: i64,
7795    ) -> String {
7796        format!(
7797            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>"#,
7798            transform(x)
7799        )
7800    }
7801
7802    fn shape_picture_fill(relationship_id: &str, x: i64, text_body: Option<&str>) -> String {
7803        shape_with_details(
7804            None,
7805            None,
7806            &format!(
7807                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>"#,
7808                transform(x)
7809            ),
7810            text_body,
7811        )
7812    }
7813
7814    fn linked_picture(url: &str, x: i64) -> String {
7815        format!(
7816            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>"#,
7817            transform(x)
7818        )
7819    }
7820
7821    fn ole_frame(relationship_id: &str, frame_x: i64, preview_x: i64) -> String {
7822        ole_frame_with_blip(
7823            &format!(r#"r:embed="{relationship_id}""#),
7824            frame_x,
7825            preview_x,
7826        )
7827    }
7828
7829    fn ole_frame_with_blip(blip_attribute: &str, frame_x: i64, preview_x: i64) -> String {
7830        let preview = format!(
7831            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>"#,
7832            transform(preview_x)
7833        );
7834        ole_frame_payload(frame_x, &preview)
7835    }
7836
7837    fn ole_frame_without_preview(frame_x: i64) -> String {
7838        ole_frame_payload(frame_x, "")
7839    }
7840
7841    fn ole_frame_payload(frame_x: i64, preview: &str) -> String {
7842        format!(
7843            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>"#
7844        )
7845    }
7846
7847    fn transform(x: i64) -> String {
7848        format!("<a:xfrm><a:off x=\"{x}\" y=\"0\"/><a:ext cx=\"100\" cy=\"100\"/></a:xfrm>")
7849    }
7850}