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