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