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