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