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