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