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