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