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