Skip to main content

rpptx_layout/
context.rs

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