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