1use std::collections::HashMap;
4use std::error::Error;
5use std::fmt;
6use std::sync::Arc;
7
8use oxml_drawing::theme::CT_OfficeStyleSheet;
9use oxml_layout::{
10 Color, Diagnostic, DocumentMetadata, FontFile, FontManager, GroupElement, LayoutResult,
11 MediaId, PageFrame, Paint, Path, PathCommand, PathElement, Point, PositionedElement, Rect,
12 Stroke, Transform,
13};
14use rpptx_layout::{
15 CropRect, ResolvedBackground, ResolvedContent, ResolvedGeometry, ResolvedImage,
16 ResolvedImagePlacement, ResolvedLineEnd, ResolvedLineEndKind, ResolvedLineEndSize,
17 ResolvedRectAlignment, ResolvedShape, ResolvedSlide, ResolvedSlideTextDirections,
18 ResolvedTable, ResolvedTableBorder, ResolvedTextBody, ResolvedTileFlip, ResolvedTilePlacement,
19 ScopedHyperlinkTargets,
20};
21use rpptx_oxml::notes_parts::CT_NotesSlide;
22use rpptx_oxml::slide_parts::{CT_Slide, CT_SlideLayout, CT_SlideMaster};
23
24mod text;
25pub mod timeline;
26
27#[derive(Clone, Copy, Debug, Eq, PartialEq)]
29pub enum RelScope {
30 Slide,
31 Layout,
32 Master,
33}
34
35impl fmt::Display for RelScope {
36 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
37 formatter.write_str(match self {
38 Self::Slide => "slide",
39 Self::Layout => "layout",
40 Self::Master => "master",
41 })
42 }
43}
44
45#[derive(Clone, Debug, Eq, PartialEq)]
47pub struct ResolvedRel {
48 pub target: String,
49 pub relationship_type: String,
50 pub target_mode: Option<String>,
51}
52
53#[derive(Clone, Debug, Default, Eq, PartialEq)]
55pub struct RelScopes {
56 pub slide: HashMap<String, ResolvedRel>,
57 pub layout: HashMap<String, ResolvedRel>,
58 pub master: HashMap<String, ResolvedRel>,
59}
60
61impl RelScopes {
62 pub fn get(
64 &self,
65 scope: RelScope,
66 relationship_id: &str,
67 ) -> Result<&ResolvedRel, RenderInputError> {
68 let relationships = match scope {
69 RelScope::Slide => &self.slide,
70 RelScope::Layout => &self.layout,
71 RelScope::Master => &self.master,
72 };
73 relationships
74 .get(relationship_id)
75 .ok_or_else(|| RenderInputError::MissingRelationship {
76 scope,
77 relationship_id: relationship_id.to_owned(),
78 })
79 }
80
81 pub fn external_hyperlink_targets(&self) -> ScopedHyperlinkTargets {
83 fn external_targets(
84 relationships: &HashMap<String, ResolvedRel>,
85 ) -> HashMap<String, String> {
86 relationships
87 .iter()
88 .filter(|(_, relationship)| {
89 relationship.relationship_type == HYPERLINK_RELATIONSHIP
90 && relationship.target_mode.as_deref() == Some("External")
91 })
92 .map(|(id, relationship)| (id.clone(), relationship.target.clone()))
93 .collect()
94 }
95
96 ScopedHyperlinkTargets {
97 slide: external_targets(&self.slide),
98 layout: external_targets(&self.layout),
99 master: external_targets(&self.master),
100 }
101 }
102}
103
104const HYPERLINK_RELATIONSHIP: &str =
105 "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink";
106
107#[derive(Clone, Debug, Eq, PartialEq)]
109pub struct MediaData {
110 pub bytes: Vec<u8>,
111 pub content_type: String,
112}
113
114#[derive(Clone, Debug, Eq, PartialEq)]
116pub enum RenderInputError {
117 MissingRelationship {
118 scope: RelScope,
119 relationship_id: String,
120 },
121 MissingMediaTarget {
122 scope: RelScope,
123 relationship_id: String,
124 target: String,
125 },
126 SlideIndexOutOfBounds {
127 index: usize,
128 slide_count: usize,
129 },
130 MissingMedia {
131 media: MediaId,
132 },
133 InvalidPicture {
134 media: MediaId,
135 detail: &'static str,
136 },
137 TileLimitExceeded {
138 media: MediaId,
139 requested: usize,
140 limit: usize,
141 },
142 TextLayout {
143 detail: String,
144 },
145}
146
147impl fmt::Display for RenderInputError {
148 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
149 match self {
150 Self::MissingRelationship {
151 scope,
152 relationship_id,
153 } => write!(formatter, "missing {scope} relationship {relationship_id}"),
154 Self::MissingMediaTarget {
155 scope,
156 relationship_id,
157 target,
158 } => write!(
159 formatter,
160 "missing media target {target} for {scope} relationship {relationship_id}"
161 ),
162 Self::SlideIndexOutOfBounds { index, slide_count } => write!(
163 formatter,
164 "slide index {index} is out of bounds for {slide_count} slides"
165 ),
166 Self::MissingMedia { media } => {
167 write!(formatter, "missing picture media {}", media.0)
168 }
169 Self::InvalidPicture { media, detail } => {
170 write!(formatter, "invalid picture media {}: {detail}", media.0)
171 }
172 Self::TileLimitExceeded {
173 media,
174 requested,
175 limit,
176 } => write!(
177 formatter,
178 "picture media {} requests {requested} tiles, above the {limit} tile limit",
179 media.0
180 ),
181 Self::TextLayout { detail } => write!(formatter, "text layout failed: {detail}"),
182 }
183 }
184}
185
186impl Error for RenderInputError {}
187
188#[derive(Clone, Debug)]
190pub struct SlideBundle {
191 pub slide: CT_Slide,
192 pub layout: Arc<CT_SlideLayout>,
193 pub master: Arc<CT_SlideMaster>,
194 pub theme: Arc<CT_OfficeStyleSheet>,
195 pub notes: Option<CT_NotesSlide>,
196 pub hidden: bool,
197 pub relationships: RelScopes,
198}
199
200#[derive(Clone, Debug)]
202pub struct RenderInput {
203 pub slides: Vec<ResolvedSlide>,
204 pub media: HashMap<MediaId, MediaData>,
205 pub fonts: Vec<FontFile>,
206 pub metadata: Option<DocumentMetadata>,
207}
208
209#[derive(Clone, Debug, PartialEq)]
216pub struct ShapeTextLayout {
217 pub frame: Rect,
219 pub usable: Rect,
221 pub font_scale: f64,
223 pub height: f64,
225 pub overflow: bool,
227 pub lines: Vec<TextLineLayout>,
229}
230
231#[derive(Clone, Debug, PartialEq)]
233pub struct TextLineLayout {
234 pub paragraph_index: usize,
236 pub text: String,
238 pub bounds: Rect,
240 pub baseline: f64,
242 pub font_size: f64,
244}
245
246pub fn layout_presentation(input: &RenderInput) -> Result<LayoutResult, RenderInputError> {
248 let mut font_manager = FontManager::new();
249 font_manager.load_additional_fonts(&input.fonts);
250 layout_presentation_with_font_manager(input, font_manager)
251}
252
253pub fn layout_presentation_deterministic(
258 input: &RenderInput,
259) -> Result<LayoutResult, RenderInputError> {
260 let mut font_manager =
261 FontManager::new_deterministic().map_err(|error| RenderInputError::TextLayout {
262 detail: error.to_string(),
263 })?;
264 font_manager.load_additional_fonts(&input.fonts);
265 layout_presentation_with_font_manager(input, font_manager)
266}
267
268pub fn layout_presentation_with_font_manager(
270 input: &RenderInput,
271 mut font_manager: FontManager,
272) -> Result<LayoutResult, RenderInputError> {
273 layout_presentation_with_displayed_numbers(input, &mut font_manager, None, 1)
274}
275
276pub fn layout_presentation_with_font_manager_and_text_directions(
281 input: &RenderInput,
282 mut font_manager: FontManager,
283 text_directions: &[ResolvedSlideTextDirections],
284) -> Result<LayoutResult, RenderInputError> {
285 layout_presentation_with_font_manager_and_text_directions_mut(
286 input,
287 &mut font_manager,
288 text_directions,
289 )
290}
291
292#[doc(hidden)]
299pub fn layout_presentation_with_font_manager_and_text_directions_mut(
300 input: &RenderInput,
301 font_manager: &mut FontManager,
302 text_directions: &[ResolvedSlideTextDirections],
303) -> Result<LayoutResult, RenderInputError> {
304 layout_presentation_with_displayed_numbers(input, font_manager, Some(text_directions), 1)
305}
306
307pub fn layout_presentation_with_displayed_numbers(
310 input: &RenderInput,
311 font_manager: &mut FontManager,
312 text_directions: Option<&[ResolvedSlideTextDirections]>,
313 first_slide_number: usize,
314) -> Result<LayoutResult, RenderInputError> {
315 font_manager.begin_layout();
316 let mut shaping_diagnostics = Vec::new();
317 let pages = (0..input.slides.len())
318 .map(|index| {
319 layout_resolved_slide_with_diagnostics(
320 input,
321 &input.slides[index],
322 first_slide_number.checked_add(index).ok_or_else(|| {
323 RenderInputError::TextLayout {
324 detail: "displayed slide number overflow".into(),
325 }
326 })?,
327 font_manager,
328 text_directions
329 .and_then(|directions| directions.get(index))
330 .map(Vec::as_slice),
331 None,
332 &mut shaping_diagnostics,
333 )
334 .map_err(|error| text_layout_context(error, &format!("slide {}, ", index + 1)))
335 .map(|mut page| {
336 page.page_number = index + 1;
337 Arc::new(page)
338 })
339 })
340 .collect::<Result<Vec<_>, _>>()?;
341 let mut diagnostics: Vec<Diagnostic> = input
342 .slides
343 .iter()
344 .enumerate()
345 .flat_map(|(index, slide)| {
346 let summary = (slide.unresolved_member_count > 0).then(|| Diagnostic {
347 message: format!(
348 "slide {} incomplete: {} unresolved members",
349 index + 1,
350 slide.unresolved_member_count
351 ),
352 });
353 summary.into_iter().chain(slide.diagnostics.iter().cloned())
354 })
355 .collect();
356 diagnostics.extend(
357 shaping_diagnostics
358 .into_iter()
359 .map(|message| Diagnostic { message }),
360 );
361 let mut layout = LayoutResult::new(
362 pages,
363 font_manager.all_font_data(),
364 input.metadata.clone(),
365 Vec::new(),
366 );
367 diagnostics.extend(font_manager.current_font_diagnostics().iter().cloned());
368 layout.diagnostics = diagnostics;
369 Ok(layout)
370}
371
372fn text_layout_context(error: RenderInputError, prefix: &str) -> RenderInputError {
375 match error {
376 RenderInputError::TextLayout { detail } => RenderInputError::TextLayout {
377 detail: format!("{prefix}{detail}"),
378 },
379 error => error,
380 }
381}
382
383pub fn layout_slide(input: &RenderInput, index: usize) -> Result<PageFrame, RenderInputError> {
385 let mut font_manager = FontManager::new();
386 font_manager.load_additional_fonts(&input.fonts);
387 layout_slide_with_fonts(input, index, &mut font_manager)
388}
389
390pub fn layout_shape_text(
398 shape: &ResolvedShape,
399 text: &ResolvedTextBody,
400 font_manager: &mut FontManager,
401 page_number: usize,
402 text_directions: &[Vec<oxml_layout::TextDirection>],
403 width_factor: f64,
404) -> Result<ShapeTextLayout, RenderInputError> {
405 let (usable, _, stacked) = stack_shape_text(
406 shape,
407 text,
408 font_manager,
409 page_number,
410 text_directions,
411 width_factor,
412 )?;
413 let centre = Point {
414 x: shape.bounds.x + shape.bounds.width / 2.0,
415 y: shape.bounds.y + shape.bounds.height / 2.0,
416 };
417 let placed = shape.group_transform.apply(centre);
418 let origin = Point {
419 x: shape.bounds.x + (placed.x - centre.x),
420 y: shape.bounds.y + (placed.y - centre.y),
421 };
422 let on_slide = |rect: Rect| Rect {
423 x: origin.x + rect.x,
424 y: origin.y + rect.y,
425 ..rect
426 };
427 Ok(ShapeTextLayout {
428 frame: Rect {
429 x: origin.x,
430 y: origin.y,
431 width: shape.bounds.width,
432 height: shape.bounds.height,
433 },
434 usable: on_slide(usable),
435 font_scale: stacked.font_scale,
436 height: stacked.height,
437 overflow: !stacked.fits(usable),
438 lines: stacked
439 .lines
440 .into_iter()
441 .map(|line| TextLineLayout {
442 bounds: on_slide(line.bounds),
443 baseline: origin.y + line.baseline,
444 ..line
445 })
446 .collect(),
447 })
448}
449
450pub const AUTOFIT_CANDIDATES: [(u32, u32); 14] = [
453 (100000, 0),
454 (100000, 10000),
455 (92500, 0),
456 (92500, 10000),
457 (92500, 20000),
458 (85000, 20000),
459 (77500, 20000),
460 (70000, 20000),
461 (62500, 20000),
462 (55000, 20000),
463 (47500, 20000),
464 (40000, 20000),
465 (32500, 20000),
466 (25000, 20000),
467];
468
469#[derive(Clone, Debug, PartialEq)]
471pub struct AutofitMeasurement {
472 pub usable: Rect,
473 pub physical_height: f64,
474 pub line_advance_height: f64,
475 pub font_scale: f64,
476 pub width_fits: bool,
478 pub fits: bool,
479}
480
481pub fn measure_authoring_text(
485 shape: &ResolvedShape,
486 text: &ResolvedTextBody,
487 fonts: &mut FontManager,
488 page_number: usize,
489 directions: &[Vec<oxml_layout::TextDirection>],
490) -> Result<AutofitMeasurement, RenderInputError> {
491 let mut unanchored = text.clone();
492 unanchored.anchor = rpptx_layout::TextAnchor::Top;
493 let (usable, _) =
494 text::oriented_content_box(text::content_box(shape, &unanchored), unanchored.vertical);
495 if !usable.x.is_finite()
496 || !usable.y.is_finite()
497 || !usable.width.is_finite()
498 || !usable.height.is_finite()
499 || usable.width <= 0.0
500 || usable.height <= 0.0
501 {
502 return Err(RenderInputError::TextLayout {
503 detail: "autofit usable bounds must be finite and positive".to_owned(),
504 });
505 }
506 let measurement_box = Rect {
510 x: 0.0,
511 y: 0.0,
512 ..usable
513 };
514 let stacked = text::stack_text_for_page_with_directions(
515 fonts,
516 measurement_box,
517 &unanchored,
518 page_number,
519 directions.first().map(Vec::as_slice).unwrap_or(&[]),
520 )
521 .map_err(|error| RenderInputError::TextLayout {
522 detail: error.to_string(),
523 })?;
524 let (physical_height, fits) = stacked
525 .authoring_extent(fonts, &unanchored, measurement_box)
526 .map_err(|error| RenderInputError::TextLayout {
527 detail: error.to_string(),
528 })?;
529 Ok(AutofitMeasurement {
530 usable,
531 physical_height,
532 line_advance_height: stacked.height,
533 font_scale: stacked.font_scale,
534 width_fits: stacked.width_fits,
535 fits,
536 })
537}
538
539pub fn authoring_text_fits_at_anchor(
542 shape: &ResolvedShape,
543 text: &ResolvedTextBody,
544 fonts: &mut FontManager,
545 page_number: usize,
546 directions: &[Vec<oxml_layout::TextDirection>],
547) -> Result<bool, RenderInputError> {
548 let (usable, _, stacked) = stack_shape_text(shape, text, fonts, page_number, directions, 1.0)?;
549 let (_, fits) = stacked
550 .authoring_extent(fonts, text, usable)
551 .map_err(|error| RenderInputError::TextLayout {
552 detail: error.to_string(),
553 })?;
554 Ok(usable.width.is_finite()
555 && usable.height.is_finite()
556 && usable.width > 0.0
557 && usable.height > 0.0
558 && fits)
559}
560
561pub fn authoring_table_cell(
564 shape: &ResolvedShape,
565 table: &ResolvedTable,
566 row_index: usize,
567 column_index: usize,
568) -> Result<(ResolvedShape, ResolvedTextBody), RenderInputError> {
569 let fail = || RenderInputError::TextLayout {
570 detail: "autofit table cell is missing or a merge continuation".to_owned(),
571 };
572 let row = table.rows.get(row_index).ok_or_else(fail)?;
573 let cell = row.cells.get(column_index).ok_or_else(fail)?;
574 if cell.horizontal_merge || cell.vertical_merge || column_index >= table.column_widths.len() {
575 return Err(fail());
576 }
577 let rows = usize::try_from(cell.row_span)
578 .unwrap_or(usize::MAX)
579 .max(1)
580 .min(table.rows.len() - row_index);
581 let columns = usize::try_from(cell.grid_span)
582 .unwrap_or(usize::MAX)
583 .max(1)
584 .min(table.column_widths.len() - column_index);
585 let mut widths = table.column_widths.clone();
586 if table.right_to_left {
587 widths.reverse();
588 }
589 let column_offsets = cumulative_offsets(&widths);
590 let row_offsets =
591 cumulative_offsets(&table.rows.iter().map(|row| row.height).collect::<Vec<_>>());
592 let visual_column = if table.right_to_left {
593 table.column_widths.len() - column_index - columns
594 } else {
595 column_index
596 };
597 let mut text = cell.text.clone().ok_or_else(fail)?;
598 text.insets = cell.margins;
599 let mut cell_shape = shape.clone();
600 cell_shape.bounds = Rect {
601 x: column_offsets[visual_column],
602 y: row_offsets[row_index],
603 width: column_offsets[visual_column + columns] - column_offsets[visual_column],
604 height: row_offsets[row_index + rows] - row_offsets[row_index],
605 };
606 cell_shape.geometry = rpptx_layout::ResolvedGeometry::Rectangle;
607 cell_shape.content = ResolvedContent::Text(text.clone());
608 Ok((cell_shape, text))
609}
610
611pub fn measure_autofit_candidate(
615 shape: &ResolvedShape,
616 text: &ResolvedTextBody,
617 fonts: &mut FontManager,
618 page_number: usize,
619 directions: &[Vec<oxml_layout::TextDirection>],
620 pair: (u32, u32),
621) -> Result<(AutofitMeasurement, AutofitMeasurement), RenderInputError> {
622 if !AUTOFIT_CANDIDATES.contains(&pair) {
623 return Err(RenderInputError::TextLayout {
624 detail: "autofit pair is outside the admitted policy".to_owned(),
625 });
626 }
627 let scale = f64::from(pair.0) / 100000.0;
628 let reduction = f64::from(pair.1) / 100000.0;
629 let round = |size: Option<f64>| -> Result<Option<f64>, RenderInputError> {
630 let nominal = size.unwrap_or(18.0);
631 let rounded = (nominal * scale + 0.5).floor();
632 if !nominal.is_finite() || nominal <= 0.0 || rounded <= 0.0 {
633 return Err(RenderInputError::TextLayout {
634 detail: "autofit quantized point size is not positive and representable".to_owned(),
635 });
636 }
637 Ok(Some(rounded))
638 };
639 let mut quantized = text.clone();
640 quantized.autofit = rpptx_layout::ResolvedAutofit::Normal {
641 font_scale: Some(1.0),
642 line_spacing_reduction: Some(reduction),
643 };
644 for paragraph in &mut quantized.paragraphs {
645 paragraph.end_style.font_size = round(paragraph.end_style.font_size)?;
646 for run in &mut paragraph.runs {
647 if let rpptx_layout::ResolvedTextRun::Text { style, .. }
648 | rpptx_layout::ResolvedTextRun::Field { style, .. } = run
649 {
650 style.font_size = round(style.font_size)?;
651 }
652 }
653 if let Some(
654 rpptx_layout::ResolvedBullet::Character { size, .. }
655 | rpptx_layout::ResolvedBullet::AutoNumber { size, .. },
656 ) = &mut paragraph.bullet
657 && let Some(rpptx_layout::ResolvedBulletSize::Points(points)) = size
658 {
659 *points = round(Some(*points))?.expect("validated positive point size");
660 }
661 }
662 let q = measure_authoring_text(shape, &quantized, fonts, page_number, directions)?;
663 let mut stored = text.clone();
664 stored.autofit = rpptx_layout::ResolvedAutofit::Normal {
665 font_scale: Some(scale),
666 line_spacing_reduction: Some(reduction),
667 };
668 let s = measure_authoring_text(shape, &stored, fonts, page_number, directions)?;
669 Ok((q, s))
670}
671
672fn layout_slide_with_fonts(
673 input: &RenderInput,
674 index: usize,
675 font_manager: &mut FontManager,
676) -> Result<PageFrame, RenderInputError> {
677 layout_slide_with_fonts_and_text_directions(input, index, font_manager, None)
678}
679
680fn layout_slide_with_fonts_and_text_directions(
681 input: &RenderInput,
682 index: usize,
683 font_manager: &mut FontManager,
684 text_directions: Option<&[Vec<Vec<oxml_layout::TextDirection>>]>,
685) -> Result<PageFrame, RenderInputError> {
686 layout_slide_with_fonts_text_directions_and_states(
687 input,
688 index,
689 font_manager,
690 text_directions,
691 None,
692 )
693}
694
695pub(crate) fn layout_slide_with_fonts_text_directions_and_states(
696 input: &RenderInput,
697 index: usize,
698 font_manager: &mut FontManager,
699 text_directions: Option<&[Vec<Vec<oxml_layout::TextDirection>>]>,
700 shape_states: Option<&[rpptx_layout::timeline::EvaluatedShapeState]>,
701) -> Result<PageFrame, RenderInputError> {
702 let slide = input
703 .slides
704 .get(index)
705 .ok_or(RenderInputError::SlideIndexOutOfBounds {
706 index,
707 slide_count: input.slides.len(),
708 })?;
709 layout_resolved_slide_with_fonts_text_directions_and_states(
710 input,
711 slide,
712 index + 1,
713 font_manager,
714 text_directions,
715 shape_states,
716 )
717}
718
719pub(crate) fn layout_resolved_slide_with_fonts_text_directions_and_states(
720 input: &RenderInput,
721 slide: &ResolvedSlide,
722 page_number: usize,
723 font_manager: &mut FontManager,
724 text_directions: Option<&[Vec<Vec<oxml_layout::TextDirection>>]>,
725 shape_states: Option<&[rpptx_layout::timeline::EvaluatedShapeState]>,
726) -> Result<PageFrame, RenderInputError> {
727 layout_resolved_slide_with_diagnostics(
728 input,
729 slide,
730 page_number,
731 font_manager,
732 text_directions,
733 shape_states,
734 &mut Vec::new(),
735 )
736}
737
738pub(crate) fn layout_resolved_slide_with_diagnostics(
739 input: &RenderInput,
740 slide: &ResolvedSlide,
741 page_number: usize,
742 font_manager: &mut FontManager,
743 text_directions: Option<&[Vec<Vec<oxml_layout::TextDirection>>]>,
744 shape_states: Option<&[rpptx_layout::timeline::EvaluatedShapeState]>,
745 diagnostics: &mut Vec<String>,
746) -> Result<PageFrame, RenderInputError> {
747 for shape in &slide.shapes {
750 if let ResolvedContent::Group(root) = &shape.content {
751 let mut pending = vec![(root, 1usize)];
752 while let Some((group, depth)) = pending.pop() {
753 if depth > rpptx_oxml::MAX_PRESENTATION_NESTING {
754 return Err(RenderInputError::TextLayout {
755 detail: format!(
756 "render resolved group nesting exceeds limit {}, offending depth {depth}",
757 rpptx_oxml::MAX_PRESENTATION_NESTING
758 ),
759 });
760 }
761 pending.extend(group.children.iter().filter_map(|child| {
762 if let PositionedElement::Group(group) = child {
763 Some((group, depth + 1))
764 } else {
765 None
766 }
767 }));
768 }
769 }
770 }
771 let mut elements = Vec::new();
772 let mut background_paint = None;
773 match slide.background.as_ref() {
774 Some(ResolvedBackground::Paint(paint)) => background_paint = Some(paint.clone()),
775 Some(ResolvedBackground::Image(image)) => {
776 let shape = ResolvedShape {
777 group_transform: Transform::IDENTITY,
778 bounds: Rect {
779 x: 0.0,
780 y: 0.0,
781 width: slide.size.0,
782 height: slide.size.1,
783 },
784 rotation_deg: 0.0,
785 flip_h: false,
786 flip_v: false,
787 geometry: ResolvedGeometry::Rectangle,
788 fill: None,
789 image_fill: None,
790 line: None,
791 head_end: None,
792 tail_end: None,
793 shadow: None,
794 content: ResolvedContent::None,
795 unsupported: None,
796 };
797 let paths = [Path::rect(Rect {
798 x: 0.0,
799 y: 0.0,
800 width: slide.size.0,
801 height: slide.size.1,
802 })];
803 elements.extend(lower_picture(input, &shape, &paths, image)?);
804 }
805 None => {}
806 }
807 elements.extend(
808 slide
809 .shapes
810 .iter()
811 .enumerate()
812 .map(|(shape_index, shape)| {
813 let state = shape_states.and_then(|states| states.get(shape_index));
814 lower_shape(
815 input,
816 shape,
817 font_manager,
818 page_number,
819 text_directions
820 .and_then(|directions| directions.get(shape_index))
821 .map(Vec::as_slice),
822 diagnostics,
823 )
824 .map_err(|error| {
825 text_layout_context(
826 error,
827 &format!("shape {} in draw order: ", shape_index + 1),
828 )
829 })
830 .map(|mut element| {
831 let Some(state) = state else {
832 return element;
833 };
834 let opacity = if state.visible {
835 f64::from(state.opacity.clamp(0.0, 1.0))
836 } else {
837 0.0
838 };
839 if let PositionedElement::Group(group) = &mut element {
840 group.opacity *= opacity;
841 for clip in
842 state.local_clip_paths((shape.bounds.width, shape.bounds.height))
843 {
844 if group.clip.is_none() {
845 group.clip = Some(clip);
846 } else {
847 group.children = vec![PositionedElement::Group(GroupElement {
848 transform: Transform::IDENTITY,
849 clip: Some(clip),
850 opacity: 1.0,
851 effects: Vec::new(),
852 children: std::mem::take(&mut group.children),
853 })];
854 }
855 }
856 }
857 element
858 })
859 })
860 .collect::<Result<Vec<_>, _>>()?,
861 );
862 let mut page = PageFrame::new(page_number, slide.size.0, slide.size.1, elements);
863 page.background = background_paint;
864 Ok(page)
865}
866
867fn copy_resolved_group(root: &GroupElement) -> GroupElement {
870 let shallow = |group: &GroupElement| GroupElement {
871 transform: group.transform,
872 clip: group.clip.clone(),
873 opacity: group.opacity,
874 effects: group.effects.clone(),
875 children: Vec::with_capacity(group.children.len()),
876 };
877 let mut pending = vec![(root, 0usize, shallow(root))];
878 loop {
879 let (source, next, output) = pending.last_mut().expect("open renderer group");
880 if let Some(child) = source.children.get(*next) {
881 *next += 1;
882 if let PositionedElement::Group(group) = child {
883 pending.push((group, 0, shallow(group)));
884 } else {
885 output.children.push(child.clone());
886 }
887 } else {
888 let (_, _, group) = pending.pop().expect("completed renderer group");
889 if let Some((_, _, output)) = pending.last_mut() {
890 output.children.push(PositionedElement::Group(group));
891 } else {
892 return group;
893 }
894 }
895 }
896}
897
898fn lower_shape(
899 input: &RenderInput,
900 shape: &ResolvedShape,
901 font_manager: &mut FontManager,
902 page_number: usize,
903 text_directions: Option<&[Vec<oxml_layout::TextDirection>]>,
904 diagnostics: &mut Vec<String>,
905) -> Result<PositionedElement, RenderInputError> {
906 let paths = if matches!(shape.content, ResolvedContent::Table(_)) {
907 Vec::new()
908 } else {
909 match &shape.geometry {
910 ResolvedGeometry::Rectangle | ResolvedGeometry::BoundsFallback => {
911 vec![Path::rect(Rect {
912 x: 0.0,
913 y: 0.0,
914 width: shape.bounds.width,
915 height: shape.bounds.height,
916 })]
917 }
918 ResolvedGeometry::Custom { paths, .. } => paths.clone(),
919 }
920 };
921 let stroke = match (&shape.geometry, &shape.fill, &shape.image_fill, &shape.line) {
922 (ResolvedGeometry::BoundsFallback, None, None, None) => {
923 Some(Stroke::new(Paint::Solid(Color::BLACK), 1.0))
924 }
925 _ => shape.line.clone(),
926 };
927 let mut text_children = Vec::new();
928 let mut children = shape
929 .image_fill
930 .as_ref()
931 .map(|image| lower_picture(input, shape, &paths, image))
932 .transpose()?
933 .unwrap_or_default();
934 match &shape.content {
935 ResolvedContent::Image(image) => {
936 children.extend(lower_picture(input, shape, &paths, image)?)
937 }
938 ResolvedContent::Text(text_body) => {
939 let (_, text_transform, stacked) = stack_shape_text(
940 shape,
941 text_body,
942 font_manager,
943 page_number,
944 text_directions.unwrap_or(&[]),
945 1.0,
946 )?;
947 diagnostics.extend(stacked.diagnostics.iter().cloned());
948 debug_assert!(stacked.width.is_finite() && stacked.height.is_finite());
949 text_children = if let Some(transform) = text_transform {
950 vec![PositionedElement::Group(GroupElement {
951 transform,
952 clip: None,
953 opacity: 1.0,
954 effects: Vec::new(),
955 children: stacked.elements,
956 })]
957 } else {
958 stacked.elements
959 };
960 }
961 ResolvedContent::Table(table) => {
962 children.extend(lower_table(
963 table,
964 font_manager,
965 page_number,
966 text_directions.unwrap_or(&[]),
967 diagnostics,
968 )?);
969 }
970 ResolvedContent::Group(group) => {
971 children.push(PositionedElement::Group(copy_resolved_group(group)));
972 }
973 _ => {}
974 }
975 children.extend(
976 paths
977 .iter()
978 .cloned()
979 .map(|path| {
980 PositionedElement::Path(PathElement {
981 path,
982 fill: shape.fill.clone(),
983 stroke: stroke.clone(),
984 })
985 })
986 .collect::<Vec<_>>(),
987 );
988 if let Some(line) = &shape.line {
989 let (head_tangent, tail_tangent) = endpoint_tangents(&paths);
990 if let Some(path) = shape
991 .head_end
992 .as_ref()
993 .zip(head_tangent)
994 .and_then(|(end, tangent)| line_end_path(end, tangent, line.width))
995 {
996 children.push(filled_line_end(path, &line.paint));
997 }
998 if let Some(path) = shape
999 .tail_end
1000 .as_ref()
1001 .zip(tail_tangent)
1002 .and_then(|(end, tangent)| line_end_path(end, tangent, line.width))
1003 {
1004 children.push(filled_line_end(path, &line.paint));
1005 }
1006 }
1007 children.extend(text_children);
1008 Ok(PositionedElement::Group(GroupElement {
1009 transform: shape_transform(shape),
1010 clip: None,
1011 opacity: 1.0,
1012 effects: shape.shadow.iter().cloned().collect(),
1013 children,
1014 }))
1015}
1016
1017fn stack_shape_text(
1020 shape: &ResolvedShape,
1021 text_body: &ResolvedTextBody,
1022 font_manager: &mut FontManager,
1023 page_number: usize,
1024 text_directions: &[Vec<oxml_layout::TextDirection>],
1025 width_factor: f64,
1026) -> Result<(Rect, Option<Transform>, text::StackedText), RenderInputError> {
1027 let (mut content_box, text_transform) =
1028 text::oriented_content_box(text::content_box(shape, text_body), text_body.vertical);
1029 content_box.width *= width_factor;
1030 let paragraph_directions = text_directions.first().map(Vec::as_slice).unwrap_or(&[]);
1031 let stacked = text::stack_text_for_page_with_directions(
1032 font_manager,
1033 content_box,
1034 text_body,
1035 page_number,
1036 paragraph_directions,
1037 )
1038 .map_err(|error| RenderInputError::TextLayout {
1039 detail: error.to_string(),
1040 })?;
1041 Ok((content_box, text_transform, stacked))
1042}
1043
1044fn lower_table(
1045 table: &ResolvedTable,
1046 font_manager: &mut FontManager,
1047 page_number: usize,
1048 text_directions: &[Vec<oxml_layout::TextDirection>],
1049 diagnostics: &mut Vec<String>,
1050) -> Result<Vec<PositionedElement>, RenderInputError> {
1051 let mut physical_column_widths = table.column_widths.clone();
1052 if table.right_to_left {
1053 physical_column_widths.reverse();
1054 }
1055 let column_offsets = cumulative_offsets(&physical_column_widths);
1056 let row_heights = table.rows.iter().map(|row| row.height).collect::<Vec<_>>();
1057 let row_offsets = cumulative_offsets(&row_heights);
1058 let mut fills = Vec::new();
1059 if let Some(background) = &table.background {
1060 fills.push(PositionedElement::Path(PathElement {
1061 path: Path::rect(Rect {
1062 x: 0.0,
1063 y: 0.0,
1064 width: column_offsets.last().copied().unwrap_or(0.0),
1065 height: row_offsets.last().copied().unwrap_or(0.0),
1066 }),
1067 fill: Some(background.clone()),
1068 stroke: None,
1069 }));
1070 }
1071 let mut texts = Vec::new();
1072 let mut borders: HashMap<(bool, usize, usize), TableBorderCandidate> = HashMap::new();
1073 let mut cell_index = 0usize;
1074
1075 for (row_index, row) in table.rows.iter().enumerate() {
1076 for (column_index, cell) in row.cells.iter().enumerate() {
1077 let paragraph_directions = text_directions
1078 .get(cell_index)
1079 .map(Vec::as_slice)
1080 .unwrap_or(&[]);
1081 cell_index += 1;
1082 if cell.horizontal_merge
1083 || cell.vertical_merge
1084 || column_index >= table.column_widths.len()
1085 {
1086 continue;
1087 }
1088 let row_span = usize::try_from(cell.row_span)
1089 .unwrap_or(usize::MAX)
1090 .max(1)
1091 .min(table.rows.len().saturating_sub(row_index));
1092 let column_span = usize::try_from(cell.grid_span)
1093 .unwrap_or(usize::MAX)
1094 .max(1)
1095 .min(table.column_widths.len().saturating_sub(column_index));
1096 let visual_column = if table.right_to_left {
1097 table
1098 .column_widths
1099 .len()
1100 .saturating_sub(column_index + column_span)
1101 } else {
1102 column_index
1103 };
1104 let rectangle = Rect {
1105 x: column_offsets[visual_column],
1106 y: row_offsets[row_index],
1107 width: column_offsets[visual_column + column_span] - column_offsets[visual_column],
1108 height: row_offsets[row_index + row_span] - row_offsets[row_index],
1109 };
1110 if let Some(fill) = &cell.fill {
1111 fills.push(PositionedElement::Path(PathElement {
1112 path: Path::rect(rectangle),
1113 fill: Some(fill.clone()),
1114 stroke: None,
1115 }));
1116 }
1117 if let Some(text_body) = &cell.text {
1118 let mut text_body = text_body.clone();
1119 text_body.insets = cell.margins;
1120 let content = Rect {
1121 x: rectangle.x + text_body.insets.left,
1122 y: rectangle.y + text_body.insets.top,
1123 width: (rectangle.width - text_body.insets.left - text_body.insets.right)
1124 .max(0.0),
1125 height: (rectangle.height - text_body.insets.top - text_body.insets.bottom)
1126 .max(0.0),
1127 };
1128 let (content, transform) = text::oriented_content_box(content, text_body.vertical);
1129 let stacked = text::stack_text_for_page_with_directions(
1130 font_manager,
1131 content,
1132 &text_body,
1133 page_number,
1134 paragraph_directions,
1135 )
1136 .map_err(|error| RenderInputError::TextLayout {
1137 detail: error.to_string(),
1138 })?;
1139 diagnostics.extend(stacked.diagnostics.iter().cloned());
1140 if let Some(transform) = transform {
1141 texts.push(PositionedElement::Group(GroupElement {
1142 transform,
1143 clip: None,
1144 opacity: 1.0,
1145 effects: Vec::new(),
1146 children: stacked.elements,
1147 }));
1148 } else {
1149 texts.extend(stacked.elements);
1150 }
1151 }
1152
1153 let last_row = row_index + row_span - 1;
1154 let last_column = column_index + column_span - 1;
1155 for logical_column in column_index..=last_column {
1156 let physical_column = if table.right_to_left {
1157 table.column_widths.len() - logical_column - 1
1158 } else {
1159 logical_column
1160 };
1161 let top = table.rows[row_index]
1162 .cells
1163 .get(logical_column)
1164 .and_then(|covered| covered.top.as_ref())
1165 .or(cell.top.as_ref());
1166 let bottom = table.rows[last_row]
1167 .cells
1168 .get(logical_column)
1169 .and_then(|covered| covered.bottom.as_ref())
1170 .or(cell.bottom.as_ref());
1171 insert_table_border(&mut borders, (true, row_index, physical_column), top, 4);
1172 insert_table_border(
1173 &mut borders,
1174 (true, row_index + row_span, physical_column),
1175 bottom,
1176 2,
1177 );
1178 }
1179 let left_column = if table.right_to_left {
1180 last_column
1181 } else {
1182 column_index
1183 };
1184 let right_column = if table.right_to_left {
1185 column_index
1186 } else {
1187 last_column
1188 };
1189 for covered_row in row_index..=last_row {
1190 let left = table.rows[covered_row]
1191 .cells
1192 .get(left_column)
1193 .and_then(|covered| covered.left.as_ref())
1194 .or(cell.left.as_ref());
1195 let right = table.rows[covered_row]
1196 .cells
1197 .get(right_column)
1198 .and_then(|covered| covered.right.as_ref())
1199 .or(cell.right.as_ref());
1200 insert_table_border(&mut borders, (false, visual_column, covered_row), left, 3);
1201 insert_table_border(
1202 &mut borders,
1203 (false, visual_column + column_span, covered_row),
1204 right,
1205 1,
1206 );
1207 }
1208 }
1209 }
1210
1211 let mut ordered_borders = borders.into_iter().collect::<Vec<_>>();
1212 ordered_borders.sort_by_key(|(key, _)| *key);
1213 let mut border_elements = Vec::new();
1214 for ((horizontal, boundary, segment), candidate) in ordered_borders {
1215 let Some(stroke) = candidate.border.stroke else {
1216 continue;
1217 };
1218 let path = if horizontal {
1219 open_path(
1220 Point {
1221 x: column_offsets[segment],
1222 y: row_offsets[boundary],
1223 },
1224 Point {
1225 x: column_offsets[segment + 1],
1226 y: row_offsets[boundary],
1227 },
1228 )
1229 } else {
1230 open_path(
1231 Point {
1232 x: column_offsets[boundary],
1233 y: row_offsets[segment],
1234 },
1235 Point {
1236 x: column_offsets[boundary],
1237 y: row_offsets[segment + 1],
1238 },
1239 )
1240 };
1241 border_elements.push(PositionedElement::Path(PathElement {
1242 path,
1243 fill: None,
1244 stroke: Some(stroke),
1245 }));
1246 }
1247 fills.extend(texts);
1248 fills.extend(border_elements);
1249 Ok(fills)
1250}
1251
1252struct TableBorderCandidate {
1253 border: ResolvedTableBorder,
1254 side_rank: u8,
1255}
1256
1257fn insert_table_border(
1258 borders: &mut HashMap<(bool, usize, usize), TableBorderCandidate>,
1259 key: (bool, usize, usize),
1260 border: Option<&ResolvedTableBorder>,
1261 side_rank: u8,
1262) {
1263 let Some(border) = border else {
1264 return;
1265 };
1266 let candidate = TableBorderCandidate {
1267 border: border.clone(),
1268 side_rank,
1269 };
1270 let replace = borders.get(&key).is_none_or(|current| {
1271 let candidate_width = candidate
1272 .border
1273 .stroke
1274 .as_ref()
1275 .map_or(0.0, |stroke| stroke.width);
1276 let current_width = current
1277 .border
1278 .stroke
1279 .as_ref()
1280 .map_or(0.0, |stroke| stroke.width);
1281 (
1282 candidate.border.priority,
1283 ordered_width(candidate_width),
1284 candidate.side_rank,
1285 ) > (
1286 current.border.priority,
1287 ordered_width(current_width),
1288 current.side_rank,
1289 )
1290 });
1291 if replace {
1292 borders.insert(key, candidate);
1293 }
1294}
1295
1296fn ordered_width(width: f64) -> u64 {
1297 if width.is_finite() && width >= 0.0 {
1298 width.to_bits()
1299 } else {
1300 0
1301 }
1302}
1303
1304fn cumulative_offsets(lengths: &[f64]) -> Vec<f64> {
1305 let mut offsets = Vec::with_capacity(lengths.len() + 1);
1306 offsets.push(0.0);
1307 for length in lengths {
1308 offsets.push(offsets.last().copied().unwrap_or(0.0) + length.max(0.0));
1309 }
1310 offsets
1311}
1312
1313fn open_path(start: Point, end: Point) -> Path {
1314 Path {
1315 commands: vec![PathCommand::MoveTo(start), PathCommand::LineTo(end)],
1316 fill_rule: oxml_layout::FillRule::NonZero,
1317 }
1318}
1319
1320const MAX_TILE_ELEMENTS: usize = 4_096;
1321const MAX_TILED_IMAGE_BYTES: usize = 64 * 1024 * 1024;
1322
1323fn lower_picture(
1324 input: &RenderInput,
1325 shape: &ResolvedShape,
1326 paths: &[Path],
1327 resolved_image: &ResolvedImage,
1328) -> Result<Vec<PositionedElement>, RenderInputError> {
1329 let media_id = resolved_image.media;
1330 let media = input
1331 .media
1332 .get(&media_id)
1333 .ok_or(RenderInputError::MissingMedia { media: media_id })?;
1334 let crop = normalized_insets(resolved_image.src_rect, media_id, "source crop")?;
1335 let elements = match &resolved_image.placement {
1336 ResolvedImagePlacement::Stretch { fill_rect } => {
1337 let fill_rect = normalized_insets(*fill_rect, media_id, "stretch fill rectangle")?;
1338 let destination = inset_rect(
1339 picture_coverage_rect(shape, resolved_image.rotate_with_shape),
1340 fill_rect,
1341 );
1342 let image = picture_image(media_id, media, expanded_crop_rect(destination, crop));
1343 let image = counter_rotate_image(image, shape, resolved_image.rotate_with_shape);
1344 let mut image = if crop.is_some() {
1345 clipped_group(Path::rect(destination), vec![image])
1346 } else {
1347 image
1348 };
1349 if !matches!(shape.geometry, ResolvedGeometry::Rectangle)
1350 || (!resolved_image.rotate_with_shape && shape.rotation_deg != 0.0)
1351 {
1352 image = clip_to_picture_shape(shape, paths, vec![image]);
1353 }
1354 vec![image]
1355 }
1356 ResolvedImagePlacement::Tile(tile) => lower_tiled_picture(
1357 shape,
1358 paths,
1359 media_id,
1360 media,
1361 crop,
1362 tile,
1363 resolved_image.dpi,
1364 resolved_image.rotate_with_shape,
1365 )?,
1366 };
1367 if resolved_image.opacity < 1.0 {
1368 return Ok(vec![PositionedElement::Group(GroupElement {
1369 transform: Transform::IDENTITY,
1370 clip: None,
1371 opacity: resolved_image.opacity,
1372 effects: Vec::new(),
1373 children: elements,
1374 })]);
1375 }
1376 Ok(elements)
1377}
1378
1379#[allow(clippy::too_many_arguments)]
1380fn lower_tiled_picture(
1381 shape: &ResolvedShape,
1382 paths: &[Path],
1383 media_id: MediaId,
1384 media: &MediaData,
1385 crop: Option<CropRect>,
1386 tile: &ResolvedTilePlacement,
1387 declared_dpi: Option<f64>,
1388 rotate_with_shape: bool,
1389) -> Result<Vec<PositionedElement>, RenderInputError> {
1390 let info = oxml_media::probe(&media.bytes).ok_or(RenderInputError::InvalidPicture {
1391 media: media_id,
1392 detail: "tile image metadata is unavailable",
1393 })?;
1394 let (native_width, native_height) = tile_native_size_points(info, declared_dpi, media_id)?;
1395 let tile_width = native_width * tile.scale_x;
1396 let tile_height = native_height * tile.scale_y;
1397 if !tile_width.is_finite()
1398 || !tile_height.is_finite()
1399 || tile_width <= 0.0
1400 || tile_height <= 0.0
1401 || !tile.translation.x.is_finite()
1402 || !tile.translation.y.is_finite()
1403 {
1404 return Err(RenderInputError::InvalidPicture {
1405 media: media_id,
1406 detail: "tile size or translation is not finite and positive",
1407 });
1408 }
1409 let shape_rect = local_shape_rect(shape);
1410 let coverage_rect = picture_coverage_rect(shape, rotate_with_shape);
1411 let anchor = tile_alignment_origin(shape_rect, tile_width, tile_height, tile.alignment);
1412 let translated_anchor = Point {
1413 x: anchor.x + tile.translation.x,
1414 y: anchor.y + tile.translation.y,
1415 };
1416 let origin_x = repeated_origin(translated_anchor.x, coverage_rect.x, tile_width);
1417 let origin_y = repeated_origin(translated_anchor.y, coverage_rect.y, tile_height);
1418 if !origin_x.is_finite() || !origin_y.is_finite() {
1419 return Err(RenderInputError::InvalidPicture {
1420 media: media_id,
1421 detail: "tile origin is not finite",
1422 });
1423 }
1424 let first_column = repeated_tile_index(origin_x, translated_anchor.x, tile_width, media_id)?;
1425 let first_row = repeated_tile_index(origin_y, translated_anchor.y, tile_height, media_id)?;
1426 let columns = repeat_count(
1427 origin_x,
1428 coverage_rect.x + coverage_rect.width,
1429 tile_width,
1430 media_id,
1431 )?;
1432 let rows = repeat_count(
1433 origin_y,
1434 coverage_rect.y + coverage_rect.height,
1435 tile_height,
1436 media_id,
1437 )?;
1438 let requested = columns
1439 .checked_mul(rows)
1440 .ok_or(RenderInputError::TileLimitExceeded {
1441 media: media_id,
1442 requested: usize::MAX,
1443 limit: MAX_TILE_ELEMENTS,
1444 })?;
1445 let byte_limit = MAX_TILED_IMAGE_BYTES / media.bytes.len().max(1);
1446 let limit = MAX_TILE_ELEMENTS.min(byte_limit.max(1));
1447 if requested > limit {
1448 return Err(RenderInputError::TileLimitExceeded {
1449 media: media_id,
1450 requested,
1451 limit,
1452 });
1453 }
1454
1455 let mut tiles = Vec::with_capacity(requested);
1456 for row in 0..rows {
1457 for column in 0..columns {
1458 let rect = Rect {
1459 x: origin_x + column as f64 * tile_width,
1460 y: origin_y + row as f64 * tile_height,
1461 width: tile_width,
1462 height: tile_height,
1463 };
1464 let image = picture_image(media_id, media, expanded_crop_rect(rect, crop));
1465 let image = if crop.is_some() {
1466 clipped_group(Path::rect(rect), vec![image])
1467 } else {
1468 image
1469 };
1470 tiles.push(flip_tile(
1471 image,
1472 rect,
1473 tile.flip,
1474 (first_column.rem_euclid(2) == 1) != (column % 2 == 1),
1475 (first_row.rem_euclid(2) == 1) != (row % 2 == 1),
1476 ));
1477 }
1478 }
1479 let tiles = if rotate_with_shape || shape.rotation_deg == 0.0 {
1480 tiles
1481 } else {
1482 vec![PositionedElement::Group(GroupElement {
1483 transform: Transform::rotate_about(
1484 -shape.rotation_deg,
1485 shape.bounds.width / 2.0,
1486 shape.bounds.height / 2.0,
1487 ),
1488 clip: None,
1489 opacity: 1.0,
1490 effects: Vec::new(),
1491 children: tiles,
1492 })]
1493 };
1494 Ok(vec![clip_to_picture_shape(shape, paths, tiles)])
1495}
1496
1497fn normalized_insets(
1498 crop: Option<CropRect>,
1499 media: MediaId,
1500 detail: &'static str,
1501) -> Result<Option<CropRect>, RenderInputError> {
1502 let Some(crop) = crop else {
1503 return Ok(None);
1504 };
1505 let values = [crop.left, crop.top, crop.right, crop.bottom];
1506 if values.iter().any(|value| !value.is_finite()) {
1507 return Err(RenderInputError::InvalidPicture { media, detail });
1508 }
1509 let crop = CropRect {
1510 left: crop.left.clamp(0.0, 1.0),
1511 top: crop.top.clamp(0.0, 1.0),
1512 right: crop.right.clamp(0.0, 1.0),
1513 bottom: crop.bottom.clamp(0.0, 1.0),
1514 };
1515 if crop.left + crop.right >= 1.0 || crop.top + crop.bottom >= 1.0 {
1516 return Err(RenderInputError::InvalidPicture { media, detail });
1517 }
1518 Ok((crop != CropRect::default()).then_some(crop))
1519}
1520
1521fn local_shape_rect(shape: &ResolvedShape) -> Rect {
1522 Rect {
1523 x: 0.0,
1524 y: 0.0,
1525 width: shape.bounds.width,
1526 height: shape.bounds.height,
1527 }
1528}
1529
1530fn picture_coverage_rect(shape: &ResolvedShape, rotate_with_shape: bool) -> Rect {
1531 let rect = local_shape_rect(shape);
1532 if rotate_with_shape || shape.rotation_deg == 0.0 {
1533 return rect;
1534 }
1535 Transform::rotate_about(
1536 shape.rotation_deg,
1537 shape.bounds.width / 2.0,
1538 shape.bounds.height / 2.0,
1539 )
1540 .transform_rect_bbox(rect)
1541}
1542
1543fn inset_rect(rect: Rect, insets: Option<CropRect>) -> Rect {
1544 let Some(insets) = insets else {
1545 return rect;
1546 };
1547 Rect {
1548 x: rect.x + rect.width * insets.left,
1549 y: rect.y + rect.height * insets.top,
1550 width: rect.width * (1.0 - insets.left - insets.right),
1551 height: rect.height * (1.0 - insets.top - insets.bottom),
1552 }
1553}
1554
1555fn expanded_crop_rect(destination: Rect, crop: Option<CropRect>) -> Rect {
1556 let Some(crop) = crop else {
1557 return destination;
1558 };
1559 let retained_width = 1.0 - crop.left - crop.right;
1560 let retained_height = 1.0 - crop.top - crop.bottom;
1561 let width = destination.width / retained_width;
1562 let height = destination.height / retained_height;
1563 Rect {
1564 x: destination.x - width * crop.left,
1565 y: destination.y - height * crop.top,
1566 width,
1567 height,
1568 }
1569}
1570
1571fn picture_image(media_id: MediaId, media: &MediaData, rect: Rect) -> PositionedElement {
1572 PositionedElement::Image {
1573 rect,
1574 data: media.bytes.clone(),
1575 content_type: media.content_type.clone(),
1576 media_id,
1577 }
1578}
1579
1580fn counter_rotate_image(
1581 image: PositionedElement,
1582 shape: &ResolvedShape,
1583 rotate_with_shape: bool,
1584) -> PositionedElement {
1585 if rotate_with_shape || shape.rotation_deg == 0.0 {
1586 return image;
1587 }
1588 PositionedElement::Group(GroupElement {
1589 transform: Transform::rotate_about(
1590 -shape.rotation_deg,
1591 shape.bounds.width / 2.0,
1592 shape.bounds.height / 2.0,
1593 ),
1594 clip: None,
1595 opacity: 1.0,
1596 effects: Vec::new(),
1597 children: vec![image],
1598 })
1599}
1600
1601fn clip_to_picture_shape(
1602 shape: &ResolvedShape,
1603 paths: &[Path],
1604 children: Vec<PositionedElement>,
1605) -> PositionedElement {
1606 if matches!(shape.geometry, ResolvedGeometry::Rectangle) {
1607 return clipped_group(Path::rect(local_shape_rect(shape)), children);
1608 }
1609 clipped_group(combined_clip_path(paths), children)
1610}
1611
1612fn combined_clip_path(paths: &[Path]) -> Path {
1613 Path {
1614 commands: paths
1615 .iter()
1616 .flat_map(|path| path.commands.iter().cloned())
1617 .collect(),
1618 fill_rule: paths
1619 .first()
1620 .map_or(oxml_layout::FillRule::NonZero, |path| path.fill_rule),
1621 }
1622}
1623
1624fn clipped_group(clip: Path, children: Vec<PositionedElement>) -> PositionedElement {
1625 PositionedElement::Group(GroupElement {
1626 transform: Transform::IDENTITY,
1627 clip: Some(clip),
1628 opacity: 1.0,
1629 effects: Vec::new(),
1630 children,
1631 })
1632}
1633
1634fn tile_native_size_points(
1635 mut info: oxml_media::ImageInfo,
1636 declared_dpi: Option<f64>,
1637 media: MediaId,
1638) -> Result<(f64, f64), RenderInputError> {
1639 if let Some(dpi) = declared_dpi {
1640 if !dpi.is_finite() || dpi <= 0.0 {
1641 return Err(RenderInputError::InvalidPicture {
1642 media,
1643 detail: "declared picture DPI is not finite and positive",
1644 });
1645 }
1646 info.dpi_x = Some(dpi);
1647 info.dpi_y = Some(dpi);
1648 }
1649 let size = info
1650 .native_size(96.0)
1651 .ok_or(RenderInputError::InvalidPicture {
1652 media,
1653 detail: "picture DPI cannot produce a native size",
1654 })?;
1655 Ok((
1656 size.width_emu as f64 / 12_700.0,
1657 size.height_emu as f64 / 12_700.0,
1658 ))
1659}
1660
1661fn tile_alignment_origin(
1662 rect: Rect,
1663 tile_width: f64,
1664 tile_height: f64,
1665 alignment: ResolvedRectAlignment,
1666) -> Point {
1667 let center_x = rect.x + (rect.width - tile_width) / 2.0;
1668 let right = rect.x + rect.width - tile_width;
1669 let center_y = rect.y + (rect.height - tile_height) / 2.0;
1670 let bottom = rect.y + rect.height - tile_height;
1671 match alignment {
1672 ResolvedRectAlignment::TopLeft => Point {
1673 x: rect.x,
1674 y: rect.y,
1675 },
1676 ResolvedRectAlignment::Top => Point {
1677 x: center_x,
1678 y: rect.y,
1679 },
1680 ResolvedRectAlignment::TopRight => Point {
1681 x: right,
1682 y: rect.y,
1683 },
1684 ResolvedRectAlignment::Left => Point {
1685 x: rect.x,
1686 y: center_y,
1687 },
1688 ResolvedRectAlignment::Center => Point {
1689 x: center_x,
1690 y: center_y,
1691 },
1692 ResolvedRectAlignment::Right => Point {
1693 x: right,
1694 y: center_y,
1695 },
1696 ResolvedRectAlignment::BottomLeft => Point {
1697 x: rect.x,
1698 y: bottom,
1699 },
1700 ResolvedRectAlignment::Bottom => Point {
1701 x: center_x,
1702 y: bottom,
1703 },
1704 ResolvedRectAlignment::BottomRight => Point {
1705 x: right,
1706 y: bottom,
1707 },
1708 }
1709}
1710
1711fn repeated_origin(anchor: f64, coverage_start: f64, tile_size: f64) -> f64 {
1712 coverage_start - (coverage_start - anchor).rem_euclid(tile_size)
1713}
1714
1715fn repeated_tile_index(
1716 origin: f64,
1717 anchor: f64,
1718 tile_size: f64,
1719 media: MediaId,
1720) -> Result<isize, RenderInputError> {
1721 let index = ((origin - anchor) / tile_size).round();
1722 if !index.is_finite() || index < isize::MIN as f64 || index > isize::MAX as f64 {
1723 return Err(RenderInputError::InvalidPicture {
1724 media,
1725 detail: "tile translation cannot preserve flip phase",
1726 });
1727 }
1728 Ok(index as isize)
1729}
1730
1731fn repeat_count(
1732 origin: f64,
1733 coverage_end: f64,
1734 tile_size: f64,
1735 media: MediaId,
1736) -> Result<usize, RenderInputError> {
1737 let count = ((coverage_end - origin) / tile_size).ceil().max(0.0);
1738 if !count.is_finite() || count > usize::MAX as f64 {
1739 return Err(RenderInputError::TileLimitExceeded {
1740 media,
1741 requested: usize::MAX,
1742 limit: MAX_TILE_ELEMENTS,
1743 });
1744 }
1745 Ok(count as usize)
1746}
1747
1748fn flip_tile(
1749 tile: PositionedElement,
1750 rect: Rect,
1751 flip: ResolvedTileFlip,
1752 odd_column: bool,
1753 odd_row: bool,
1754) -> PositionedElement {
1755 let flip_h =
1756 matches!(flip, ResolvedTileFlip::Horizontal | ResolvedTileFlip::Both) && odd_column;
1757 let flip_v = matches!(flip, ResolvedTileFlip::Vertical | ResolvedTileFlip::Both) && odd_row;
1758 if !flip_h && !flip_v {
1759 return tile;
1760 }
1761 PositionedElement::Group(GroupElement {
1762 transform: Transform {
1763 a: if flip_h { -1.0 } else { 1.0 },
1764 b: 0.0,
1765 c: 0.0,
1766 d: if flip_v { -1.0 } else { 1.0 },
1767 e: if flip_h {
1768 2.0 * rect.x + rect.width
1769 } else {
1770 0.0
1771 },
1772 f: if flip_v {
1773 2.0 * rect.y + rect.height
1774 } else {
1775 0.0
1776 },
1777 },
1778 clip: None,
1779 opacity: 1.0,
1780 effects: Vec::new(),
1781 children: vec![tile],
1782 })
1783}
1784
1785#[derive(Clone, Copy)]
1786struct EndpointTangent {
1787 point: Point,
1788 outward: Point,
1789}
1790
1791fn endpoint_tangents(paths: &[Path]) -> (Option<EndpointTangent>, Option<EndpointTangent>) {
1792 let mut head = None;
1793 let mut tail = None;
1794 for path in paths {
1795 let mut current = None;
1796 let mut subpath_start = None;
1797 for command in &path.commands {
1798 match *command {
1799 PathCommand::MoveTo(point) => {
1800 current = Some(point);
1801 subpath_start = Some(point);
1802 }
1803 PathCommand::LineTo(to) => {
1804 if let Some(from) = current
1805 && let Some(direction) = unit_direction(from, to)
1806 {
1807 head.get_or_insert(EndpointTangent {
1808 point: from,
1809 outward: Point {
1810 x: -direction.x,
1811 y: -direction.y,
1812 },
1813 });
1814 tail = Some(EndpointTangent {
1815 point: to,
1816 outward: direction,
1817 });
1818 }
1819 current = Some(to);
1820 }
1821 PathCommand::CurveTo { c1, c2, to } => {
1822 if let Some(from) = current {
1823 let start_direction = [c1, c2, to]
1824 .into_iter()
1825 .find_map(|candidate| unit_direction(from, candidate));
1826 let end_direction = [c2, c1, from]
1827 .into_iter()
1828 .find_map(|candidate| unit_direction(candidate, to));
1829 if let Some(direction) = start_direction {
1830 head.get_or_insert(EndpointTangent {
1831 point: from,
1832 outward: Point {
1833 x: -direction.x,
1834 y: -direction.y,
1835 },
1836 });
1837 }
1838 if let Some(direction) = end_direction {
1839 tail = Some(EndpointTangent {
1840 point: to,
1841 outward: direction,
1842 });
1843 }
1844 }
1845 current = Some(to);
1846 }
1847 PathCommand::Close => {
1848 if let (Some(from), Some(to)) = (current, subpath_start)
1849 && let Some(direction) = unit_direction(from, to)
1850 {
1851 head.get_or_insert(EndpointTangent {
1852 point: from,
1853 outward: Point {
1854 x: -direction.x,
1855 y: -direction.y,
1856 },
1857 });
1858 tail = Some(EndpointTangent {
1859 point: to,
1860 outward: direction,
1861 });
1862 }
1863 current = subpath_start;
1864 }
1865 }
1866 }
1867 }
1868 (head, tail)
1869}
1870
1871fn unit_direction(from: Point, to: Point) -> Option<Point> {
1872 let dx = to.x - from.x;
1873 let dy = to.y - from.y;
1874 let length = dx.hypot(dy);
1875 (length.is_finite() && length > 1.0e-10).then_some(Point {
1876 x: dx / length,
1877 y: dy / length,
1878 })
1879}
1880
1881fn line_end_path(
1882 end: &ResolvedLineEnd,
1883 tangent: EndpointTangent,
1884 stroke_width: f64,
1885) -> Option<Path> {
1886 if !stroke_width.is_finite() || stroke_width <= 0.0 {
1887 return None;
1888 }
1889 let width = line_end_factor(end.width) * stroke_width;
1890 let length = line_end_factor(end.length) * stroke_width;
1891 let point = |along: f64, across: f64| Point {
1892 x: tangent.point.x + tangent.outward.x * along - tangent.outward.y * across,
1893 y: tangent.point.y + tangent.outward.y * along + tangent.outward.x * across,
1894 };
1895 let path = match end.kind {
1896 ResolvedLineEndKind::Triangle => closed_polygon(vec![
1897 point(0.0, 0.0),
1898 point(-length, width / 2.0),
1899 point(-length, -width / 2.0),
1900 ]),
1901 ResolvedLineEndKind::Stealth => closed_polygon(vec![
1902 point(0.0, 0.0),
1903 point(-length, width / 2.0),
1904 point(-length / 2.0, 0.0),
1905 point(-length, -width / 2.0),
1906 ]),
1907 ResolvedLineEndKind::Diamond => closed_polygon(vec![
1908 point(0.0, 0.0),
1909 point(-length / 2.0, width / 2.0),
1910 point(-length, 0.0),
1911 point(-length / 2.0, -width / 2.0),
1912 ]),
1913 ResolvedLineEndKind::Oval => oval_line_end(&point, width, length),
1914 ResolvedLineEndKind::Arrow => {
1915 let arm = stroke_width.min(width / 2.0);
1916 closed_polygon(vec![
1917 point(0.0, 0.0),
1918 point(-length, width / 2.0),
1919 point(-length, width / 2.0 - arm),
1920 point(-arm, 0.0),
1921 point(-length, -width / 2.0 + arm),
1922 point(-length, -width / 2.0),
1923 ])
1924 }
1925 };
1926 path_is_finite(&path).then_some(path)
1927}
1928
1929fn line_end_factor(size: ResolvedLineEndSize) -> f64 {
1930 match size {
1931 ResolvedLineEndSize::Small => 2.0,
1932 ResolvedLineEndSize::Medium => 3.0,
1933 ResolvedLineEndSize::Large => 5.0,
1934 }
1935}
1936
1937fn closed_polygon(points: Vec<Point>) -> Path {
1938 let mut points = points.into_iter();
1939 let mut commands = points
1940 .next()
1941 .map(PathCommand::MoveTo)
1942 .into_iter()
1943 .collect::<Vec<_>>();
1944 commands.extend(points.map(PathCommand::LineTo));
1945 commands.push(PathCommand::Close);
1946 Path {
1947 commands,
1948 fill_rule: oxml_layout::FillRule::NonZero,
1949 }
1950}
1951
1952fn oval_line_end(point: &impl Fn(f64, f64) -> Point, width: f64, length: f64) -> Path {
1953 const KAPPA: f64 = 0.552_284_749_830_793_6;
1954 let rx = length / 2.0;
1955 let ry = width / 2.0;
1956 let center = -rx;
1957 Path {
1958 commands: vec![
1959 PathCommand::MoveTo(point(0.0, 0.0)),
1960 PathCommand::CurveTo {
1961 c1: point(0.0, KAPPA * ry),
1962 c2: point(center + KAPPA * rx, ry),
1963 to: point(center, ry),
1964 },
1965 PathCommand::CurveTo {
1966 c1: point(center - KAPPA * rx, ry),
1967 c2: point(-length, KAPPA * ry),
1968 to: point(-length, 0.0),
1969 },
1970 PathCommand::CurveTo {
1971 c1: point(-length, -KAPPA * ry),
1972 c2: point(center - KAPPA * rx, -ry),
1973 to: point(center, -ry),
1974 },
1975 PathCommand::CurveTo {
1976 c1: point(center + KAPPA * rx, -ry),
1977 c2: point(0.0, -KAPPA * ry),
1978 to: point(0.0, 0.0),
1979 },
1980 PathCommand::Close,
1981 ],
1982 fill_rule: oxml_layout::FillRule::NonZero,
1983 }
1984}
1985
1986fn path_is_finite(path: &Path) -> bool {
1987 path.commands.iter().all(|command| match command {
1988 PathCommand::MoveTo(point) | PathCommand::LineTo(point) => point_is_finite(*point),
1989 PathCommand::CurveTo { c1, c2, to } => {
1990 point_is_finite(*c1) && point_is_finite(*c2) && point_is_finite(*to)
1991 }
1992 PathCommand::Close => true,
1993 })
1994}
1995
1996fn point_is_finite(point: Point) -> bool {
1997 point.x.is_finite() && point.y.is_finite()
1998}
1999
2000fn filled_line_end(path: Path, paint: &Paint) -> PositionedElement {
2001 PositionedElement::Path(PathElement {
2002 path,
2003 fill: Some(paint.clone()),
2004 stroke: None,
2005 })
2006}
2007
2008pub fn shape_transform(shape: &ResolvedShape) -> Transform {
2011 let center_x = shape.bounds.width / 2.0;
2012 let center_y = shape.bounds.height / 2.0;
2013 let rotation = Transform::rotate_about(shape.rotation_deg, center_x, center_y);
2014 let flip = Transform {
2015 a: if shape.flip_h { -1.0 } else { 1.0 },
2016 b: 0.0,
2017 c: 0.0,
2018 d: if shape.flip_v { -1.0 } else { 1.0 },
2019 e: if shape.flip_h {
2020 shape.bounds.width
2021 } else {
2022 0.0
2023 },
2024 f: if shape.flip_v {
2025 shape.bounds.height
2026 } else {
2027 0.0
2028 },
2029 };
2030 let translation = Transform {
2031 e: shape.bounds.x,
2032 f: shape.bounds.y,
2033 ..Transform::IDENTITY
2034 };
2035 flip.then(rotation)
2036 .then(translation)
2037 .then(shape.group_transform)
2038}
2039
2040pub fn resolve_media_relationship(
2042 relationships: &RelScopes,
2043 scope: RelScope,
2044 relationship_id: &str,
2045 package_media: &HashMap<String, MediaData>,
2046 deck_media: &mut HashMap<MediaId, MediaData>,
2047) -> Result<MediaId, RenderInputError> {
2048 let relationship = relationships.get(scope, relationship_id)?;
2049 let media = package_media.get(&relationship.target).ok_or_else(|| {
2050 RenderInputError::MissingMediaTarget {
2051 scope,
2052 relationship_id: relationship_id.to_owned(),
2053 target: relationship.target.clone(),
2054 }
2055 })?;
2056 let media_id = MediaId::from_bytes(&media.bytes);
2057 deck_media.entry(media_id).or_insert_with(|| media.clone());
2058 Ok(media_id)
2059}
2060
2061#[cfg(test)]
2062mod tests {
2063 use super::*;
2064 use oxml_drawing::color::ColorMap;
2065 use oxml_drawing::text::CT_TextListStyle;
2066 use oxml_layout::{
2067 Color, Diagnostic, Effect, FieldKind, FillRule, GradientStop, GroupElement, Paint, Path,
2068 PathCommand, Point, PositionedElement, Rect, Stroke, Transform, walk,
2069 };
2070 use rpptx_layout::{
2071 ResolveCtx, ResolvedAutofit, ResolvedContent, ResolvedGeometry, ResolvedParagraph,
2072 ResolvedRunStyle, ResolvedShape, ResolvedTable, ResolvedTableBorder, ResolvedTableCell,
2073 ResolvedTableRow, ResolvedTextBody, ResolvedTextRun, TextAnchor, TextDirection, TextInsets,
2074 };
2075
2076 #[test]
2077 fn fx214_manual_renderer_groups_preserve_affine_order_and_refuse_depth33() {
2078 let build = |depth| {
2079 let mut group = GroupElement {
2080 transform: Transform::IDENTITY,
2081 clip: None,
2082 opacity: 0.75,
2083 effects: Vec::new(),
2084 children: Vec::new(),
2085 };
2086 for index in 1..depth {
2087 group = GroupElement {
2088 transform: Transform {
2089 e: index as f64,
2090 f: -(index as f64),
2091 ..Transform::IDENTITY
2092 },
2093 clip: None,
2094 opacity: 0.5,
2095 effects: Vec::new(),
2096 children: vec![PositionedElement::Group(group)],
2097 };
2098 }
2099 group
2100 };
2101 let root = build(32);
2102 assert_eq!(copy_resolved_group(&root), root);
2103 let mut shape = shape(
2104 Rect {
2105 x: 0.0,
2106 y: 0.0,
2107 width: 72.0,
2108 height: 72.0,
2109 },
2110 ResolvedGeometry::Rectangle,
2111 None,
2112 None,
2113 );
2114 shape.content = ResolvedContent::Group(root);
2115 let mut input = RenderInput {
2116 slides: vec![ResolvedSlide {
2117 unresolved_member_count: 0,
2118 size: (960.0, 540.0),
2119 background: None,
2120 shapes: vec![shape],
2121 diagnostics: Vec::new(),
2122 }],
2123 media: HashMap::new(),
2124 fonts: Vec::new(),
2125 metadata: None,
2126 };
2127 assert_eq!(
2128 layout_presentation_deterministic(&input)
2129 .unwrap()
2130 .pages
2131 .len(),
2132 1
2133 );
2134 input.slides[0].shapes[0].content = ResolvedContent::Group(build(33));
2135 let error = layout_presentation_deterministic(&input)
2136 .unwrap_err()
2137 .to_string();
2138 assert!(
2139 error.contains("render resolved group nesting exceeds limit 32, offending depth 33"),
2140 "{error}"
2141 );
2142 }
2143
2144 const IMAGE_RELATIONSHIP: &str =
2145 "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image";
2146
2147 fn media(bytes: &[u8]) -> MediaData {
2148 MediaData {
2149 bytes: bytes.to_vec(),
2150 content_type: "image/png".to_owned(),
2151 }
2152 }
2153
2154 fn relationship(target: &str) -> ResolvedRel {
2155 ResolvedRel {
2156 target: target.to_owned(),
2157 relationship_type: IMAGE_RELATIONSHIP.to_owned(),
2158 target_mode: None,
2159 }
2160 }
2161
2162 fn hyperlink_relationship(target: &str, target_mode: Option<&str>) -> ResolvedRel {
2163 ResolvedRel {
2164 target: target.to_owned(),
2165 relationship_type: HYPERLINK_RELATIONSHIP.to_owned(),
2166 target_mode: target_mode.map(str::to_owned),
2167 }
2168 }
2169
2170 fn color(red: f64, green: f64, blue: f64) -> Color {
2171 Color {
2172 r: red,
2173 g: green,
2174 b: blue,
2175 a: 1.0,
2176 }
2177 }
2178
2179 fn shape(
2180 bounds: Rect,
2181 geometry: ResolvedGeometry,
2182 fill: Option<Paint>,
2183 line: Option<Stroke>,
2184 ) -> ResolvedShape {
2185 ResolvedShape {
2186 group_transform: Transform::IDENTITY,
2187 bounds,
2188 rotation_deg: 0.0,
2189 flip_h: false,
2190 flip_v: false,
2191 geometry,
2192 fill,
2193 image_fill: None,
2194 line,
2195 head_end: None,
2196 tail_end: None,
2197 shadow: None,
2198 content: ResolvedContent::None,
2199 unsupported: None,
2200 }
2201 }
2202
2203 fn table_shape(table: ResolvedTable) -> ResolvedShape {
2204 let mut table_shape = shape(
2205 Rect {
2206 x: 0.0,
2207 y: 0.0,
2208 width: 20.0,
2209 height: 20.0,
2210 },
2211 ResolvedGeometry::Rectangle,
2212 None,
2213 None,
2214 );
2215 table_shape.content = ResolvedContent::Table(table);
2216 table_shape
2217 }
2218
2219 fn table_text(value: &str) -> ResolvedTextBody {
2220 ResolvedTextBody {
2221 insets: TextInsets::default(),
2222 anchor: TextAnchor::Top,
2223 wrap: true,
2224 vertical: TextDirection::Horizontal,
2225 space_first_last_paragraph: false,
2226 autofit: ResolvedAutofit::None,
2227 paragraphs: vec![ResolvedParagraph {
2228 runs: vec![ResolvedTextRun::Text {
2229 text: value.to_owned(),
2230 style: ResolvedRunStyle {
2231 font_size: Some(6.0),
2232 ..ResolvedRunStyle::default()
2233 },
2234 }],
2235 ..ResolvedParagraph::default()
2236 }],
2237 }
2238 }
2239
2240 #[test]
2241 fn fx207_authoring_vertical_extent_is_independent_of_reading_origin() {
2242 let mut fonts = FontManager::new_deterministic().unwrap();
2243 let mut body = table_text("Physical text extent");
2244 body.vertical = TextDirection::Vertical;
2245 body.insets = TextInsets {
2246 left: 3.6,
2247 right: 3.6,
2248 top: 3.6,
2249 bottom: 3.6,
2250 };
2251 let mut physical = None;
2252 for width in [60.0, 1727199.0 / 12700.0, 136.0] {
2253 let owner = shape(
2254 Rect {
2255 x: 72.0,
2256 y: 72.0,
2257 width,
2258 height: 216.0,
2259 },
2260 ResolvedGeometry::Rectangle,
2261 None,
2262 None,
2263 );
2264 let measured = measure_authoring_text(&owner, &body, &mut fonts, 1, &[]).unwrap();
2265 let (expected_usable, _) =
2266 text::oriented_content_box(text::content_box(&owner, &body), body.vertical);
2267 assert_eq!(measured.usable, expected_usable);
2268 if let Some(expected) = physical {
2269 assert_eq!(measured.physical_height, expected);
2270 } else {
2271 physical = Some(measured.physical_height);
2272 }
2273 assert!(measured.width_fits);
2274 }
2275 }
2276
2277 fn table_border() -> ResolvedTableBorder {
2278 ResolvedTableBorder {
2279 stroke: Some(Stroke::new(Paint::Solid(Color::BLACK), 1.0)),
2280 priority: 1,
2281 }
2282 }
2283
2284 fn linked_field_shape(group_transform: Transform) -> ResolvedShape {
2285 let mut linked = shape(
2286 Rect {
2287 x: 10.0,
2288 y: 20.0,
2289 width: 120.0,
2290 height: 40.0,
2291 },
2292 ResolvedGeometry::Rectangle,
2293 None,
2294 None,
2295 );
2296 linked.group_transform = group_transform;
2297 linked.content = ResolvedContent::Text(ResolvedTextBody {
2298 insets: TextInsets::default(),
2299 anchor: TextAnchor::Top,
2300 wrap: true,
2301 vertical: TextDirection::Horizontal,
2302 space_first_last_paragraph: false,
2303 autofit: ResolvedAutofit::None,
2304 paragraphs: vec![ResolvedParagraph {
2305 runs: vec![
2306 ResolvedTextRun::Field {
2307 text: "stored".to_owned(),
2308 field_type: Some("slidenum".to_owned()),
2309 style: ResolvedRunStyle {
2310 font_size: Some(12.0),
2311 ..ResolvedRunStyle::default()
2312 },
2313 },
2314 ResolvedTextRun::Text {
2315 text: " linked".to_owned(),
2316 style: ResolvedRunStyle {
2317 font_size: Some(12.0),
2318 hyperlink_url: Some("https://example.com/deck".to_owned()),
2319 ..ResolvedRunStyle::default()
2320 },
2321 },
2322 ],
2323 ..ResolvedParagraph::default()
2324 }],
2325 });
2326 linked
2327 }
2328
2329 fn rendered_text_and_links(
2330 page: &PageFrame,
2331 ) -> (Vec<(String, Option<FieldKind>)>, Vec<String>) {
2332 let mut text = Vec::new();
2333 let mut links = Vec::new();
2334 walk(&page.elements, &mut |element, _| match element {
2335 PositionedElement::Text(run) => text.push((run.text.clone(), run.field_kind)),
2336 PositionedElement::LinkAnnotation { url, .. } => links.push(url.clone()),
2337 _ => {}
2338 });
2339 (text, links)
2340 }
2341
2342 fn transformed_link_rect(page: &PageFrame) -> Rect {
2343 let mut result = None;
2344 walk(&page.elements, &mut |element, transform| {
2345 if let PositionedElement::LinkAnnotation { rect, .. } = element {
2346 let top_left = transform.apply(Point {
2347 x: rect.x,
2348 y: rect.y,
2349 });
2350 let bottom_right = transform.apply(Point {
2351 x: rect.x + rect.width,
2352 y: rect.y + rect.height,
2353 });
2354 result = Some(Rect {
2355 x: top_left.x,
2356 y: top_left.y,
2357 width: bottom_right.x - top_left.x,
2358 height: bottom_right.y - top_left.y,
2359 });
2360 }
2361 });
2362 result.expect("rendered hyperlink annotation")
2363 }
2364
2365 #[test]
2366 fn slide_number_field_renders_current_page_and_hyperlink_emits_annotation() {
2367 let input = render_input(vec![
2368 slide((200.0, 100.0), Vec::new()),
2369 slide(
2370 (200.0, 100.0),
2371 vec![linked_field_shape(Transform::IDENTITY)],
2372 ),
2373 ]);
2374 let mut fonts = FontManager::new_deterministic().expect("deterministic fonts");
2375
2376 let page = layout_slide_with_fonts(&input, 1, &mut fonts).expect("render slide two");
2377 let (text, links) = rendered_text_and_links(&page);
2378
2379 assert_eq!(text[0], ("2".to_owned(), Some(FieldKind::Page)));
2380 assert!(!links.is_empty());
2381 assert!(links.iter().all(|url| url == "https://example.com/deck"));
2382 }
2383
2384 #[test]
2385 fn grouped_hyperlink_annotation_keeps_transformed_run_bounds() {
2386 let translation = Transform {
2387 e: 30.0,
2388 f: 40.0,
2389 ..Transform::IDENTITY
2390 };
2391 let plain_input = render_input(vec![slide(
2392 (240.0, 180.0),
2393 vec![linked_field_shape(Transform::IDENTITY)],
2394 )]);
2395 let grouped_input = render_input(vec![slide(
2396 (240.0, 180.0),
2397 vec![linked_field_shape(translation)],
2398 )]);
2399 let mut plain_fonts = FontManager::new_deterministic().expect("deterministic fonts");
2400 let mut grouped_fonts = FontManager::new_deterministic().expect("deterministic fonts");
2401 let plain = layout_slide_with_fonts(&plain_input, 0, &mut plain_fonts)
2402 .expect("render ungrouped hyperlink");
2403 let grouped = layout_slide_with_fonts(&grouped_input, 0, &mut grouped_fonts)
2404 .expect("render grouped hyperlink");
2405 let plain_rect = transformed_link_rect(&plain);
2406 let grouped_rect = transformed_link_rect(&grouped);
2407
2408 assert!((grouped_rect.x - plain_rect.x - 30.0).abs() < 1.0e-10);
2409 assert!((grouped_rect.y - plain_rect.y - 40.0).abs() < 1.0e-10);
2410 assert!((grouped_rect.width - plain_rect.width).abs() < 1.0e-10);
2411 assert!((grouped_rect.height - plain_rect.height).abs() < 1.0e-10);
2412 }
2413
2414 #[test]
2415 fn banded_merged_table_renders_correct_fills_without_duplicated_borders() {
2416 let table = ResolvedTable {
2417 right_to_left: false,
2418 background: None,
2419 column_widths: vec![10.0, 10.0],
2420 rows: vec![
2421 ResolvedTableRow {
2422 height: 10.0,
2423 cells: vec![
2424 ResolvedTableCell {
2425 fill: Some(Paint::Solid(Color::from_hex("FF0000"))),
2426 text: Some(table_text("merged")),
2427 left: Some(table_border()),
2428 right: Some(table_border()),
2429 top: Some(table_border()),
2430 bottom: Some(table_border()),
2431 grid_span: 2,
2432 row_span: 1,
2433 ..ResolvedTableCell::default()
2434 },
2435 ResolvedTableCell {
2436 horizontal_merge: true,
2437 ..ResolvedTableCell::default()
2438 },
2439 ],
2440 },
2441 ResolvedTableRow {
2442 height: 10.0,
2443 cells: vec![
2444 ResolvedTableCell {
2445 fill: Some(Paint::Solid(Color::from_hex("00FF00"))),
2446 left: Some(table_border()),
2447 right: Some(table_border()),
2448 top: Some(table_border()),
2449 bottom: Some(table_border()),
2450 ..ResolvedTableCell::default()
2451 },
2452 ResolvedTableCell {
2453 fill: Some(Paint::Solid(Color::from_hex("0000FF"))),
2454 left: Some(table_border()),
2455 right: Some(table_border()),
2456 top: Some(table_border()),
2457 bottom: Some(table_border()),
2458 ..ResolvedTableCell::default()
2459 },
2460 ],
2461 },
2462 ],
2463 };
2464 let layout = layout_presentation(&render_input(vec![slide(
2465 (20.0, 20.0),
2466 vec![table_shape(table)],
2467 )]))
2468 .unwrap();
2469 let png = oxml_pdf::render_page_to_png(&layout, 0, 72.0).unwrap();
2470 let pixmap = tiny_skia::Pixmap::decode_png(&png).unwrap();
2471
2472 let red = rgb_at(&pixmap, 5, 8);
2473 assert!(red.0 > 200 && red.1 < 30 && red.2 < 30, "{red:?}");
2474 assert_eq!(rgb_at(&pixmap, 5, 15), (0, 255, 0));
2475 assert_eq!(rgb_at(&pixmap, 15, 15), (0, 0, 255));
2476 let group = only_group(&layout.pages[0].elements[0]);
2477 assert_eq!(
2478 group
2479 .children
2480 .iter()
2481 .filter(|element| matches!(element, PositionedElement::Path(path) if path.stroke.is_some()))
2482 .count(),
2483 11,
2484 "the merged top row removes its internal vertical segment"
2485 );
2486 assert!(group.children.iter().any(
2487 |element| matches!(element, PositionedElement::Text(run) if run.text == "merged")
2488 ));
2489 }
2490
2491 #[test]
2492 fn merged_continuation_cells_do_not_render_fill_border_or_text_twice() {
2493 let table = ResolvedTable {
2494 right_to_left: false,
2495 background: None,
2496 column_widths: vec![10.0, 10.0],
2497 rows: vec![ResolvedTableRow {
2498 height: 10.0,
2499 cells: vec![
2500 ResolvedTableCell {
2501 fill: Some(Paint::Solid(Color::BLACK)),
2502 grid_span: 2,
2503 ..ResolvedTableCell::default()
2504 },
2505 ResolvedTableCell {
2506 fill: Some(Paint::Solid(Color::WHITE)),
2507 horizontal_merge: true,
2508 ..ResolvedTableCell::default()
2509 },
2510 ],
2511 }],
2512 };
2513 let page = layout_slide(
2514 &render_input(vec![slide((20.0, 10.0), vec![table_shape(table)])]),
2515 0,
2516 )
2517 .unwrap();
2518 let group = only_group(&page.elements[0]);
2519
2520 assert_eq!(group.children.iter().filter(|element| matches!(element, PositionedElement::Path(path) if path.fill.is_some())).count(), 1);
2521 }
2522
2523 #[test]
2524 fn right_to_left_table_keeps_unequal_logical_column_widths() {
2525 let table = ResolvedTable {
2526 right_to_left: true,
2527 background: None,
2528 column_widths: vec![10.0, 20.0],
2529 rows: vec![ResolvedTableRow {
2530 height: 10.0,
2531 cells: vec![
2532 ResolvedTableCell {
2533 fill: Some(Paint::Solid(Color::from_hex("FF0000"))),
2534 ..ResolvedTableCell::default()
2535 },
2536 ResolvedTableCell {
2537 fill: Some(Paint::Solid(Color::from_hex("0000FF"))),
2538 ..ResolvedTableCell::default()
2539 },
2540 ],
2541 }],
2542 };
2543 let layout = layout_presentation(&render_input(vec![slide(
2544 (30.0, 10.0),
2545 vec![table_shape(table)],
2546 )]))
2547 .unwrap();
2548 let png = oxml_pdf::render_page_to_png(&layout, 0, 72.0).unwrap();
2549 let pixmap = tiny_skia::Pixmap::decode_png(&png).unwrap();
2550
2551 assert_eq!(rgb_at(&pixmap, 5, 5), (0, 0, 255));
2552 assert_eq!(rgb_at(&pixmap, 25, 5), (255, 0, 0));
2553 }
2554
2555 #[test]
2556 fn merged_table_uses_far_continuation_border() {
2557 let outer = ResolvedTableBorder {
2558 stroke: Some(Stroke::new(Paint::Solid(Color::from_hex("FF0000")), 3.0)),
2559 priority: 2,
2560 };
2561 let table = ResolvedTable {
2562 right_to_left: false,
2563 background: None,
2564 column_widths: vec![10.0, 10.0],
2565 rows: vec![ResolvedTableRow {
2566 height: 10.0,
2567 cells: vec![
2568 ResolvedTableCell {
2569 grid_span: 2,
2570 right: Some(table_border()),
2571 ..ResolvedTableCell::default()
2572 },
2573 ResolvedTableCell {
2574 horizontal_merge: true,
2575 right: Some(outer),
2576 ..ResolvedTableCell::default()
2577 },
2578 ],
2579 }],
2580 };
2581 let page = layout_slide(
2582 &render_input(vec![slide((20.0, 10.0), vec![table_shape(table)])]),
2583 0,
2584 )
2585 .unwrap();
2586 let group = only_group(&page.elements[0]);
2587 let far_border = group.children.iter().find_map(|element| {
2588 let PositionedElement::Path(path) = element else {
2589 return None;
2590 };
2591 match path.path.commands.as_slice() {
2592 [PathCommand::MoveTo(start), PathCommand::LineTo(end)]
2593 if start.x == 20.0 && end.x == 20.0 =>
2594 {
2595 path.stroke.as_ref()
2596 }
2597 _ => None,
2598 }
2599 });
2600
2601 let far_border = far_border.expect("merged far edge should be emitted");
2602 assert_eq!(far_border.width, 3.0);
2603 assert_eq!(far_border.paint, Paint::Solid(Color::from_hex("FF0000")));
2604 }
2605
2606 #[test]
2607 fn table_background_is_painted_over_the_table_beneath_cell_fills() {
2608 let table = ResolvedTable {
2609 right_to_left: false,
2610 background: Some(Paint::Solid(Color::from_hex("00FF00"))),
2611 column_widths: vec![10.0, 10.0],
2612 rows: vec![ResolvedTableRow {
2613 height: 5.0,
2614 cells: vec![
2615 ResolvedTableCell {
2616 fill: Some(Paint::Solid(Color::from_hex("FF0000"))),
2617 ..ResolvedTableCell::default()
2618 },
2619 ResolvedTableCell::default(),
2620 ],
2621 }],
2622 };
2623 let page = layout_slide(
2624 &render_input(vec![slide((20.0, 5.0), vec![table_shape(table)])]),
2625 0,
2626 )
2627 .unwrap();
2628 let fills = only_group(&page.elements[0])
2629 .children
2630 .iter()
2631 .filter_map(|element| match element {
2632 PositionedElement::Path(path) => Some((path.path.bounds()?, path.fill.clone())),
2633 _ => None,
2634 })
2635 .collect::<Vec<_>>();
2636
2637 let whole = Rect {
2638 x: 0.0,
2639 y: 0.0,
2640 width: 20.0,
2641 height: 5.0,
2642 };
2643 let first_cell = Rect {
2644 width: 10.0,
2645 ..whole
2646 };
2647 assert_eq!(
2648 fills,
2649 vec![
2650 (whole, Some(Paint::Solid(Color::from_hex("00FF00")))),
2651 (first_cell, Some(Paint::Solid(Color::from_hex("FF0000")))),
2652 ]
2653 );
2654 }
2655
2656 #[test]
2657 fn table_cell_margins_place_text_in_the_fixed_content_box() {
2658 let table = ResolvedTable {
2659 right_to_left: false,
2660 background: None,
2661 column_widths: vec![20.0],
2662 rows: vec![ResolvedTableRow {
2663 height: 20.0,
2664 cells: vec![ResolvedTableCell {
2665 text: Some(table_text("cell")),
2666 margins: TextInsets {
2667 left: 2.0,
2668 top: 3.0,
2669 right: 4.0,
2670 bottom: 5.0,
2671 },
2672 ..ResolvedTableCell::default()
2673 }],
2674 }],
2675 };
2676 let page = layout_slide(
2677 &render_input(vec![slide((20.0, 20.0), vec![table_shape(table)])]),
2678 0,
2679 )
2680 .unwrap();
2681 let group = only_group(&page.elements[0]);
2682 let PositionedElement::Text(run) = group
2683 .children
2684 .iter()
2685 .find(|element| matches!(element, PositionedElement::Text(_)))
2686 .expect("cell text should remain visible")
2687 else {
2688 unreachable!()
2689 };
2690
2691 assert!(run.origin.x >= 2.0);
2692 assert!(run.origin.y >= 3.0);
2693 }
2694
2695 fn slide(size: (f64, f64), shapes: Vec<ResolvedShape>) -> ResolvedSlide {
2696 ResolvedSlide {
2697 unresolved_member_count: 0,
2698 size,
2699 background: None,
2700 shapes,
2701 diagnostics: Vec::new(),
2702 }
2703 }
2704
2705 fn render_input(slides: Vec<ResolvedSlide>) -> RenderInput {
2706 RenderInput {
2707 slides,
2708 media: HashMap::new(),
2709 fonts: Vec::new(),
2710 metadata: None,
2711 }
2712 }
2713
2714 fn only_group(element: &PositionedElement) -> &GroupElement {
2715 let PositionedElement::Group(group) = element else {
2716 panic!("shape should lower to one group");
2717 };
2718 group
2719 }
2720
2721 #[test]
2722 fn timeline_lowering_retains_hidden_identity_slots_and_uses_shape_local_clips() {
2723 let first = shape(
2724 Rect {
2725 x: 10.0,
2726 y: 20.0,
2727 width: 40.0,
2728 height: 20.0,
2729 },
2730 ResolvedGeometry::Rectangle,
2731 Some(Paint::Solid(Color::WHITE)),
2732 None,
2733 );
2734 let second = shape(
2735 Rect {
2736 x: 60.0,
2737 y: 20.0,
2738 width: 20.0,
2739 height: 20.0,
2740 },
2741 ResolvedGeometry::Rectangle,
2742 Some(Paint::Solid(Color::BLACK)),
2743 None,
2744 );
2745 let input = render_input(vec![slide((100.0, 60.0), vec![first, second])]);
2746 let mut hidden_state = rpptx_layout::timeline::EvaluatedShapeState::default();
2747 hidden_state.visible = false;
2748 hidden_state.clip = Some(Rect {
2749 x: 0.0,
2750 y: 0.0,
2751 width: 0.25,
2752 height: 1.0,
2753 });
2754 let states = [
2755 hidden_state,
2756 rpptx_layout::timeline::EvaluatedShapeState::default(),
2757 ];
2758 let mut fonts = FontManager::new_deterministic().unwrap();
2759 let page = layout_slide_with_fonts_text_directions_and_states(
2760 &input,
2761 0,
2762 &mut fonts,
2763 None,
2764 Some(&states),
2765 )
2766 .unwrap();
2767
2768 assert_eq!(page.elements.len(), 2);
2769 let hidden = only_group(&page.elements[0]);
2770 assert_eq!(hidden.opacity, 0.0);
2771 assert_eq!(hidden.transform.e, 10.0);
2772 assert_eq!(
2773 hidden.clip,
2774 Some(Path::rect(Rect {
2775 x: 0.0,
2776 y: 0.0,
2777 width: 10.0,
2778 height: 20.0,
2779 }))
2780 );
2781 assert_eq!(only_group(&page.elements[1]).opacity, 1.0);
2782 }
2783
2784 #[test]
2785 fn resolved_outer_shadow_is_lowered_to_the_shape_group() {
2786 let effect = Effect::OuterShadow {
2787 dx: 3.0,
2788 dy: 4.0,
2789 blur: 2.0,
2790 color: Color {
2791 r: 0.5,
2792 g: 0.25,
2793 b: 0.0,
2794 a: 0.75,
2795 },
2796 };
2797 let mut shadowed = shape(
2798 Rect {
2799 x: 2.0,
2800 y: 3.0,
2801 width: 8.0,
2802 height: 6.0,
2803 },
2804 ResolvedGeometry::Rectangle,
2805 Some(Paint::Solid(Color::WHITE)),
2806 None,
2807 );
2808 shadowed.shadow = Some(effect.clone());
2809
2810 let page =
2811 layout_slide(&render_input(vec![slide((20.0, 20.0), vec![shadowed])]), 0).unwrap();
2812
2813 assert_eq!(only_group(&page.elements[0]).effects, vec![effect]);
2814 }
2815
2816 fn assert_point_close(actual: Point, expected: Point) {
2817 const EPSILON: f64 = 1.0e-10;
2818 assert!(
2819 (actual.x - expected.x).abs() < EPSILON && (actual.y - expected.y).abs() < EPSILON,
2820 "expected ({}, {}), got ({}, {})",
2821 expected.x,
2822 expected.y,
2823 actual.x,
2824 actual.y
2825 );
2826 }
2827
2828 #[test]
2829 fn rotated_shape_corners_match_hand_computed_coordinates() {
2830 let mut rotated = shape(
2831 Rect {
2832 x: 10.0,
2833 y: 20.0,
2834 width: 8.0,
2835 height: 4.0,
2836 },
2837 ResolvedGeometry::Rectangle,
2838 Some(Paint::Solid(Color::BLACK)),
2839 None,
2840 );
2841 rotated.rotation_deg = 30.0;
2842 let page = layout_slide(&render_input(vec![slide((40.0, 40.0), vec![rotated])]), 0)
2843 .expect("lower rotated shape");
2844 let transform = only_group(&page.elements[0]).transform;
2845 let radians = 30.0_f64.to_radians();
2846 let (sin, cos) = radians.sin_cos();
2847
2848 for corner in [
2849 Point { x: 0.0, y: 0.0 },
2850 Point { x: 8.0, y: 0.0 },
2851 Point { x: 0.0, y: 4.0 },
2852 Point { x: 8.0, y: 4.0 },
2853 ] {
2854 let dx = corner.x - 4.0;
2855 let dy = corner.y - 2.0;
2856 let expected = Point {
2857 x: 10.0 + 4.0 + cos * dx - sin * dy,
2858 y: 20.0 + 2.0 + sin * dx + cos * dy,
2859 };
2860 assert_point_close(transform.apply(corner), expected);
2861 }
2862 }
2863
2864 #[test]
2865 fn horizontal_and_vertical_flips_are_about_the_shape_centre() {
2866 let bounds = Rect {
2867 x: 10.0,
2868 y: 20.0,
2869 width: 8.0,
2870 height: 4.0,
2871 };
2872 let mut horizontal = shape(
2873 bounds,
2874 ResolvedGeometry::Rectangle,
2875 Some(Paint::Solid(Color::BLACK)),
2876 None,
2877 );
2878 horizontal.flip_h = true;
2879 let mut vertical = horizontal.clone();
2880 vertical.flip_h = false;
2881 vertical.flip_v = true;
2882 let page = layout_slide(
2883 &render_input(vec![slide((40.0, 40.0), vec![horizontal, vertical])]),
2884 0,
2885 )
2886 .expect("lower flipped shapes");
2887 let horizontal = only_group(&page.elements[0]).transform;
2888 let vertical = only_group(&page.elements[1]).transform;
2889
2890 assert_point_close(
2891 horizontal.apply(Point { x: 4.0, y: 2.0 }),
2892 Point { x: 14.0, y: 22.0 },
2893 );
2894 assert_point_close(
2895 horizontal.apply(Point { x: 0.0, y: 0.0 }),
2896 Point { x: 18.0, y: 20.0 },
2897 );
2898 assert_point_close(
2899 horizontal.apply(Point { x: 8.0, y: 4.0 }),
2900 Point { x: 10.0, y: 24.0 },
2901 );
2902 assert_point_close(
2903 vertical.apply(Point { x: 4.0, y: 2.0 }),
2904 Point { x: 14.0, y: 22.0 },
2905 );
2906 assert_point_close(
2907 vertical.apply(Point { x: 0.0, y: 0.0 }),
2908 Point { x: 10.0, y: 24.0 },
2909 );
2910 assert_point_close(
2911 vertical.apply(Point { x: 8.0, y: 4.0 }),
2912 Point { x: 18.0, y: 20.0 },
2913 );
2914 }
2915
2916 #[test]
2917 fn nested_group_transform_applies_child_before_parent() {
2918 let mut nested = shape(
2919 Rect {
2920 x: 10.0,
2921 y: 20.0,
2922 width: 8.0,
2923 height: 4.0,
2924 },
2925 ResolvedGeometry::Rectangle,
2926 Some(Paint::Solid(Color::BLACK)),
2927 None,
2928 );
2929 nested.rotation_deg = 90.0;
2930 nested.flip_h = true;
2931 nested.group_transform = Transform {
2932 a: 2.0,
2933 b: 0.0,
2934 c: 0.0,
2935 d: 3.0,
2936 e: 5.0,
2937 f: 7.0,
2938 };
2939 let page = layout_slide(&render_input(vec![slide((80.0, 100.0), vec![nested])]), 0)
2940 .expect("lower nested shape");
2941 let transform = only_group(&page.elements[0]).transform;
2942
2943 assert_point_close(
2947 transform.apply(Point { x: 0.0, y: 0.0 }),
2948 Point { x: 37.0, y: 85.0 },
2949 );
2950 assert_point_close(
2951 transform.apply(Point { x: 8.0, y: 4.0 }),
2952 Point { x: 29.0, y: 61.0 },
2953 );
2954 assert_point_close(
2955 transform.apply(Point { x: 8.0, y: 0.0 }),
2956 Point { x: 37.0, y: 61.0 },
2957 );
2958 assert_point_close(
2959 transform.apply(Point { x: 0.0, y: 4.0 }),
2960 Point { x: 29.0, y: 85.0 },
2961 );
2962 }
2963
2964 #[test]
2965 fn group_mapping_does_not_clip_a_child_outside_group_bounds() {
2966 let mut outside = shape(
2967 Rect {
2968 x: 40.0,
2969 y: 20.0,
2970 width: 8.0,
2971 height: 4.0,
2972 },
2973 ResolvedGeometry::Rectangle,
2974 Some(Paint::Solid(Color::BLACK)),
2975 None,
2976 );
2977 outside.group_transform = Transform {
2978 e: 30.0,
2979 f: 10.0,
2980 ..Transform::IDENTITY
2981 };
2982 let page = layout_slide(&render_input(vec![slide((60.0, 40.0), vec![outside])]), 0)
2983 .expect("lower grouped child outside nominal group bounds");
2984 let group = only_group(&page.elements[0]);
2985
2986 assert_eq!(group.clip, None);
2987 assert_point_close(
2988 group.transform.apply(Point { x: 0.0, y: 0.0 }),
2989 Point { x: 70.0, y: 30.0 },
2990 );
2991 }
2992
2993 #[test]
2994 fn rotated_gradient_and_outline_share_the_shape_transform() {
2995 let red = color(1.0, 0.0, 0.0);
2996 let blue = color(0.0, 0.0, 1.0);
2997 let mut rotated = shape(
2998 Rect {
2999 x: 8.0,
3000 y: 8.0,
3001 width: 12.0,
3002 height: 6.0,
3003 },
3004 ResolvedGeometry::Rectangle,
3005 Some(Paint::linear(
3006 Point { x: 0.0, y: 0.0 },
3007 Point { x: 12.0, y: 0.0 },
3008 vec![
3009 GradientStop {
3010 offset: 0.0,
3011 color: red,
3012 },
3013 GradientStop {
3014 offset: 0.49,
3015 color: red,
3016 },
3017 GradientStop {
3018 offset: 0.51,
3019 color: blue,
3020 },
3021 GradientStop {
3022 offset: 1.0,
3023 color: blue,
3024 },
3025 ],
3026 (true, true),
3027 )),
3028 Some(Stroke::new(Paint::Solid(Color::BLACK), 2.0)),
3029 );
3030 rotated.rotation_deg = 90.0;
3031 let layout = layout_presentation(&render_input(vec![slide((28.0, 24.0), vec![rotated])]))
3032 .expect("lower rotated gradient");
3033 let png =
3034 oxml_pdf::render_page_to_png(&layout, 0, 72.0).expect("rasterise rotated gradient");
3035 let pixmap = tiny_skia::Pixmap::decode_png(&png).expect("decode rotated gradient");
3036 let rgb = |x, y| {
3037 let pixel = pixmap.pixel(x, y).expect("sample lies inside page");
3038 (pixel.red(), pixel.green(), pixel.blue())
3039 };
3040
3041 assert_eq!(rgb(14, 7), (255, 0, 0));
3042 assert_eq!(rgb(14, 15), (0, 0, 255));
3043 assert_eq!(rgb(11, 11), (0, 0, 0));
3044 assert_eq!(rgb(8, 8), (255, 255, 255));
3045 }
3046
3047 #[test]
3048 fn solid_gradient_and_outlined_shapes_rasterise_at_sampled_pixels() {
3049 let red = color(1.0, 0.0, 0.0);
3050 let blue = color(0.0, 0.0, 1.0);
3051 let green = color(0.0, 1.0, 0.0);
3052 let gradient = Paint::linear(
3053 Point { x: 0.0, y: 0.0 },
3054 Point { x: 8.0, y: 0.0 },
3055 vec![
3056 GradientStop {
3057 offset: 0.0,
3058 color: red,
3059 },
3060 GradientStop {
3061 offset: 0.49,
3062 color: red,
3063 },
3064 GradientStop {
3065 offset: 0.51,
3066 color: blue,
3067 },
3068 GradientStop {
3069 offset: 1.0,
3070 color: blue,
3071 },
3072 ],
3073 (true, true),
3074 );
3075 let input = render_input(vec![slide(
3076 (40.0, 14.0),
3077 vec![
3078 shape(
3079 Rect {
3080 x: 2.0,
3081 y: 2.0,
3082 width: 8.0,
3083 height: 8.0,
3084 },
3085 ResolvedGeometry::Rectangle,
3086 Some(Paint::Solid(red)),
3087 None,
3088 ),
3089 shape(
3090 Rect {
3091 x: 14.0,
3092 y: 2.0,
3093 width: 8.0,
3094 height: 8.0,
3095 },
3096 ResolvedGeometry::Rectangle,
3097 Some(gradient),
3098 None,
3099 ),
3100 shape(
3101 Rect {
3102 x: 26.0,
3103 y: 2.0,
3104 width: 8.0,
3105 height: 8.0,
3106 },
3107 ResolvedGeometry::Rectangle,
3108 None,
3109 Some(Stroke::new(Paint::Solid(green), 2.0)),
3110 ),
3111 ],
3112 )]);
3113
3114 let layout = layout_presentation(&input).expect("lower shape slide");
3115 let png = oxml_pdf::render_page_to_png(&layout, 0, 72.0).expect("rasterise shape slide");
3116 let pixmap = tiny_skia::Pixmap::decode_png(&png).expect("decode shape slide");
3117 let rgb = |x, y| {
3118 let pixel = pixmap.pixel(x, y).expect("sample lies inside page");
3119 (pixel.red(), pixel.green(), pixel.blue())
3120 };
3121
3122 assert_eq!(rgb(5, 5), (255, 0, 0));
3123 assert_eq!(rgb(15, 5), (255, 0, 0));
3124 assert_eq!(rgb(20, 5), (0, 0, 255));
3125 assert_eq!(rgb(26, 5), (0, 255, 0));
3126 assert_eq!(rgb(30, 5), (255, 255, 255));
3127 assert_eq!(rgb(38, 5), (255, 255, 255));
3128 }
3129
3130 #[test]
3131 fn preset_and_custom_geometry_lower_to_ordered_paths() {
3132 let first = Path {
3133 commands: vec![
3134 PathCommand::MoveTo(Point { x: 0.0, y: 0.0 }),
3135 PathCommand::LineTo(Point { x: 4.0, y: 0.0 }),
3136 ],
3137 fill_rule: FillRule::NonZero,
3138 };
3139 let second = Path {
3140 commands: vec![
3141 PathCommand::MoveTo(Point { x: 0.0, y: 1.0 }),
3142 PathCommand::LineTo(Point { x: 4.0, y: 1.0 }),
3143 ],
3144 fill_rule: FillRule::EvenOdd,
3145 };
3146 let fill = Paint::Solid(Color::BLACK);
3147 let line = Stroke::new(Paint::Solid(Color::WHITE), 2.0);
3148 let input = render_input(vec![slide(
3149 (20.0, 20.0),
3150 vec![
3151 shape(
3152 Rect {
3153 x: 2.0,
3154 y: 3.0,
3155 width: 4.0,
3156 height: 5.0,
3157 },
3158 ResolvedGeometry::Rectangle,
3159 Some(fill.clone()),
3160 Some(line.clone()),
3161 ),
3162 shape(
3163 Rect {
3164 x: 8.0,
3165 y: 9.0,
3166 width: 4.0,
3167 height: 5.0,
3168 },
3169 ResolvedGeometry::Custom {
3170 paths: vec![first.clone(), second.clone()],
3171 text_rect: None,
3172 },
3173 Some(fill.clone()),
3174 Some(line.clone()),
3175 ),
3176 ],
3177 )]);
3178
3179 let page = layout_slide(&input, 0).expect("lower first slide");
3180 assert_eq!(page.elements.len(), 2);
3181 let rectangle = only_group(&page.elements[0]);
3182 assert_eq!(
3183 rectangle.transform,
3184 Transform {
3185 e: 2.0,
3186 f: 3.0,
3187 ..Transform::IDENTITY
3188 }
3189 );
3190 let PositionedElement::Path(rectangle) = &rectangle.children[0] else {
3191 panic!("rectangle should lower to a path");
3192 };
3193 assert_eq!(
3194 rectangle.path,
3195 Path::rect(Rect {
3196 x: 0.0,
3197 y: 0.0,
3198 width: 4.0,
3199 height: 5.0
3200 })
3201 );
3202 assert_eq!(rectangle.fill, Some(fill.clone()));
3203 assert_eq!(rectangle.stroke, Some(line.clone()));
3204
3205 let custom = only_group(&page.elements[1]);
3206 assert_eq!(custom.children.len(), 2);
3207 for (element, expected) in custom.children.iter().zip([first, second]) {
3208 let PositionedElement::Path(element) = element else {
3209 panic!("custom geometry should lower to paths");
3210 };
3211 assert_eq!(element.path, expected);
3212 assert_eq!(element.fill, Some(fill.clone()));
3213 assert_eq!(element.stroke, Some(line.clone()));
3214 }
3215 }
3216
3217 #[test]
3218 fn bounds_fallback_emits_a_visible_black_outline() {
3219 let input = render_input(vec![slide(
3220 (20.0, 20.0),
3221 vec![shape(
3222 Rect {
3223 x: 2.0,
3224 y: 3.0,
3225 width: 4.0,
3226 height: 5.0,
3227 },
3228 ResolvedGeometry::BoundsFallback,
3229 None,
3230 None,
3231 )],
3232 )]);
3233
3234 let page = layout_slide(&input, 0).expect("lower fallback slide");
3235 let group = only_group(&page.elements[0]);
3236 let PositionedElement::Path(path) = &group.children[0] else {
3237 panic!("fallback should lower to a path");
3238 };
3239 assert_eq!(path.fill, None);
3240 assert_eq!(
3241 path.stroke,
3242 Some(Stroke::new(Paint::Solid(Color::BLACK), 1.0))
3243 );
3244 }
3245
3246 #[test]
3247 fn triangular_tail_end_emits_an_extra_filled_path() {
3248 let paint = Paint::Solid(color(1.0, 0.0, 0.0));
3249 let mut arrow = shape(
3250 Rect {
3251 x: 2.0,
3252 y: 3.0,
3253 width: 10.0,
3254 height: 10.0,
3255 },
3256 ResolvedGeometry::Custom {
3257 paths: vec![open_line(
3258 Point { x: 0.0, y: 5.0 },
3259 Point { x: 10.0, y: 5.0 },
3260 )],
3261 text_rect: None,
3262 },
3263 None,
3264 Some(Stroke::new(paint.clone(), 2.0)),
3265 );
3266 arrow.tail_end = Some(line_end(ResolvedLineEndKind::Triangle));
3267
3268 let layout = layout_presentation(&render_input(vec![slide((20.0, 20.0), vec![arrow])]))
3269 .expect("lower triangular tail");
3270 let group = only_group(&layout.pages[0].elements[0]);
3271 assert_eq!(group.children.len(), 2);
3272 let PositionedElement::Path(end) = &group.children[1] else {
3273 panic!("tail end should lower to a path");
3274 };
3275 assert_eq!(end.fill, Some(paint));
3276 assert_eq!(end.stroke, None);
3277 assert!(matches!(end.path.commands.last(), Some(PathCommand::Close)));
3278 assert_eq!(
3279 end.path.commands.first(),
3280 Some(&PathCommand::MoveTo(Point { x: 10.0, y: 5.0 }))
3281 );
3282
3283 let png =
3284 oxml_pdf::render_page_to_png(&layout, 0, 72.0).expect("rasterise triangular tail");
3285 let pixmap = tiny_skia::Pixmap::decode_png(&png).expect("decode triangular tail");
3286 let endpoint_pixel = pixmap.pixel(7, 6).expect("sample lies inside page");
3287 assert_eq!(
3288 (
3289 endpoint_pixel.red(),
3290 endpoint_pixel.green(),
3291 endpoint_pixel.blue()
3292 ),
3293 (255, 0, 0)
3294 );
3295 }
3296
3297 #[test]
3298 fn head_end_uses_the_reversed_start_tangent() {
3299 let mut arrow = shape(
3300 Rect {
3301 x: 0.0,
3302 y: 0.0,
3303 width: 20.0,
3304 height: 10.0,
3305 },
3306 ResolvedGeometry::Custom {
3307 paths: vec![open_line(
3308 Point { x: 5.0, y: 5.0 },
3309 Point { x: 15.0, y: 5.0 },
3310 )],
3311 text_rect: None,
3312 },
3313 None,
3314 Some(Stroke::new(Paint::Solid(Color::BLACK), 2.0)),
3315 );
3316 arrow.head_end = Some(line_end(ResolvedLineEndKind::Triangle));
3317 arrow.tail_end = Some(line_end(ResolvedLineEndKind::Triangle));
3318
3319 let page = layout_slide(&render_input(vec![slide((20.0, 10.0), vec![arrow])]), 0)
3320 .expect("lower opposite ends");
3321 let group = only_group(&page.elements[0]);
3322 let PositionedElement::Path(head) = &group.children[1] else {
3323 panic!("head end should be a path");
3324 };
3325 let PositionedElement::Path(tail) = &group.children[2] else {
3326 panic!("tail end should be a path");
3327 };
3328 assert_eq!(
3329 head.path.commands[1],
3330 PathCommand::LineTo(Point { x: 11.0, y: 2.0 })
3331 );
3332 assert_eq!(
3333 tail.path.commands[1],
3334 PathCommand::LineTo(Point { x: 9.0, y: 8.0 })
3335 );
3336 }
3337
3338 #[test]
3339 fn all_supported_line_end_kinds_produce_finite_geometry() {
3340 let tangent = EndpointTangent {
3341 point: Point { x: 10.0, y: 5.0 },
3342 outward: Point { x: 1.0, y: 0.0 },
3343 };
3344 for kind in [
3345 ResolvedLineEndKind::Triangle,
3346 ResolvedLineEndKind::Stealth,
3347 ResolvedLineEndKind::Diamond,
3348 ResolvedLineEndKind::Oval,
3349 ResolvedLineEndKind::Arrow,
3350 ] {
3351 let path = line_end_path(&line_end(kind), tangent, 2.0)
3352 .expect("supported endpoint should produce geometry");
3353 assert!(path_is_finite(&path));
3354 assert!(matches!(path.commands.last(), Some(PathCommand::Close)));
3355 let bounds = path.bounds().expect("endpoint should have bounds");
3356 assert!(bounds.x >= 4.0 && bounds.x + bounds.width <= 10.0);
3357 assert!(bounds.y >= 2.0 && bounds.y + bounds.height <= 8.0);
3358 }
3359 assert_eq!(line_end_factor(ResolvedLineEndSize::Small), 2.0);
3360 assert_eq!(line_end_factor(ResolvedLineEndSize::Medium), 3.0);
3361 assert_eq!(line_end_factor(ResolvedLineEndSize::Large), 5.0);
3362 }
3363
3364 #[test]
3365 fn zero_length_segment_omits_arrowhead_without_panicking() {
3366 let point = Point { x: 5.0, y: 5.0 };
3367 let mut arrow = shape(
3368 Rect {
3369 x: 0.0,
3370 y: 0.0,
3371 width: 10.0,
3372 height: 10.0,
3373 },
3374 ResolvedGeometry::Custom {
3375 paths: vec![open_line(point, point)],
3376 text_rect: None,
3377 },
3378 None,
3379 Some(Stroke::new(Paint::Solid(Color::BLACK), 2.0)),
3380 );
3381 arrow.tail_end = Some(line_end(ResolvedLineEndKind::Triangle));
3382
3383 let page = layout_slide(&render_input(vec![slide((10.0, 10.0), vec![arrow])]), 0)
3384 .expect("lower zero-length line");
3385 assert_eq!(only_group(&page.elements[0]).children.len(), 1);
3386 }
3387
3388 #[test]
3389 fn cropped_picture_renders_only_its_crop_region() {
3390 let png = horizontal_png(&[
3391 [255, 0, 0, 255],
3392 [0, 255, 0, 255],
3393 [0, 255, 0, 255],
3394 [0, 255, 0, 255],
3395 ]);
3396 let media_id = MediaId::from_bytes(&png);
3397 let picture = picture_shape(
3398 Rect {
3399 x: 2.0,
3400 y: 2.0,
3401 width: 8.0,
3402 height: 4.0,
3403 },
3404 media_id,
3405 Some(CropRect {
3406 left: 0.25,
3407 ..CropRect::default()
3408 }),
3409 ResolvedImagePlacement::default(),
3410 None,
3411 );
3412 let input = render_input_with_media(
3413 vec![slide((12.0, 8.0), vec![picture])],
3414 HashMap::from([(media_id, media(&png))]),
3415 );
3416
3417 let layout = layout_presentation(&input).expect("lower cropped picture");
3418 let rendered =
3419 oxml_pdf::render_page_to_png(&layout, 0, 72.0).expect("rasterise cropped picture");
3420 let pixmap = tiny_skia::Pixmap::decode_png(&rendered).expect("decode cropped picture");
3421 let rgb = |x, y| {
3422 let pixel = pixmap.pixel(x, y).expect("sample lies inside page");
3423 (pixel.red(), pixel.green(), pixel.blue())
3424 };
3425
3426 assert_eq!(rgb(3, 4), (0, 255, 0));
3427 assert_eq!(rgb(8, 4), (0, 255, 0));
3428 assert_eq!(rgb(1, 4), (255, 255, 255));
3429 }
3430
3431 #[test]
3432 fn picture_opacity_fades_the_complete_image_layer() {
3433 let png = horizontal_png(&[[255, 0, 0, 255]; 4]);
3434 let media_id = MediaId::from_bytes(&png);
3435 let mut picture = picture_shape(
3436 Rect {
3437 x: 2.0,
3438 y: 2.0,
3439 width: 8.0,
3440 height: 4.0,
3441 },
3442 media_id,
3443 None,
3444 ResolvedImagePlacement::default(),
3445 None,
3446 );
3447 let ResolvedContent::Image(image) = &mut picture.content else {
3448 panic!("picture content");
3449 };
3450 image.opacity = 0.3;
3451 let input = render_input_with_media(
3452 vec![slide((12.0, 8.0), vec![picture])],
3453 HashMap::from([(media_id, media(&png))]),
3454 );
3455
3456 let layout = layout_presentation(&input).expect("lower faded picture");
3457 let rendered =
3458 oxml_pdf::render_page_to_png(&layout, 0, 72.0).expect("rasterise faded picture");
3459 let pixmap = tiny_skia::Pixmap::decode_png(&rendered).expect("decode faded picture");
3460
3461 let (red, green, blue) = rgb_at(&pixmap, 6, 4);
3462 assert_eq!(red, 255);
3463 assert!(
3464 (i32::from(green) - 178).abs() <= 1 && green == blue,
3465 "({red}, {green}, {blue})"
3466 );
3467 assert_eq!(rgb_at(&pixmap, 1, 4), (255, 255, 255));
3468 }
3469
3470 #[test]
3471 fn picture_and_animation_opacity_multiply() {
3472 fn image_opacities(elements: &[PositionedElement], opacity: f64, output: &mut Vec<f64>) {
3473 for element in elements {
3474 match element {
3475 PositionedElement::Group(group) => {
3476 image_opacities(&group.children, opacity * group.opacity, output);
3477 }
3478 PositionedElement::MarkedContent { children, .. } => {
3479 image_opacities(children, opacity, output);
3480 }
3481 PositionedElement::Image { .. } => output.push(opacity),
3482 _ => {}
3483 }
3484 }
3485 }
3486
3487 let png = horizontal_png(&[[255, 0, 0, 255]; 4]);
3488 let media_id = MediaId::from_bytes(&png);
3489 let mut picture = picture_shape(
3490 Rect {
3491 x: 2.0,
3492 y: 2.0,
3493 width: 8.0,
3494 height: 4.0,
3495 },
3496 media_id,
3497 None,
3498 ResolvedImagePlacement::default(),
3499 None,
3500 );
3501 let ResolvedContent::Image(image) = &mut picture.content else {
3502 panic!("picture content");
3503 };
3504 image.opacity = 0.3;
3505 let input = render_input_with_media(
3506 vec![slide((12.0, 8.0), vec![picture])],
3507 HashMap::from([(media_id, media(&png))]),
3508 );
3509 let mut state = rpptx_layout::timeline::EvaluatedShapeState::default();
3510 state.opacity = 0.5;
3511 let mut fonts = FontManager::new_deterministic().unwrap();
3512 let page = layout_slide_with_fonts_text_directions_and_states(
3513 &input,
3514 0,
3515 &mut fonts,
3516 None,
3517 Some(&[state]),
3518 )
3519 .unwrap();
3520
3521 let mut opacities = Vec::new();
3522 image_opacities(&page.elements, 1.0, &mut opacities);
3523 assert_eq!(opacities, vec![0.15]);
3524 let layout = LayoutResult::new(vec![Arc::new(page)], Vec::new(), None, Vec::new());
3525 let rendered = oxml_pdf::render_page_to_png(&layout, 0, 72.0).unwrap();
3526 let pixmap = tiny_skia::Pixmap::decode_png(&rendered).unwrap();
3527 let (red, green, blue) = rgb_at(&pixmap, 6, 4);
3528 assert_eq!(red, 255);
3529 assert!((i32::from(green) - 217).abs() <= 1 && green == blue);
3530 }
3531
3532 #[test]
3533 fn crop_lowers_to_clipped_source_image_geometry() {
3534 let media_id = MediaId(21);
3535 let mut picture = picture_shape(
3536 Rect {
3537 x: 2.0,
3538 y: 3.0,
3539 width: 10.0,
3540 height: 8.0,
3541 },
3542 media_id,
3543 Some(CropRect {
3544 left: 0.25,
3545 right: 0.25,
3546 ..CropRect::default()
3547 }),
3548 ResolvedImagePlacement::default(),
3549 None,
3550 );
3551 picture.line = Some(Stroke::new(Paint::Solid(Color::BLACK), 1.0));
3552 let input = render_input_with_media(
3553 vec![slide((20.0, 20.0), vec![picture])],
3554 HashMap::from([(media_id, media(b"image"))]),
3555 );
3556
3557 let page = layout_slide(&input, 0).expect("lower crop geometry");
3558 let shape_group = only_group(&page.elements[0]);
3559 assert_eq!(shape_group.children.len(), 2, "outline must follow picture");
3560 let crop_clip = only_group(&shape_group.children[0]);
3561 assert_eq!(
3562 crop_clip.clip,
3563 Some(Path::rect(Rect {
3564 x: 0.0,
3565 y: 0.0,
3566 width: 10.0,
3567 height: 8.0,
3568 }))
3569 );
3570 let PositionedElement::Image {
3571 rect, media_id: id, ..
3572 } = &crop_clip.children[0]
3573 else {
3574 panic!("crop group should contain the expanded source image");
3575 };
3576 assert_eq!(*id, media_id);
3577 assert_eq!(
3578 *rect,
3579 Rect {
3580 x: -5.0,
3581 y: 0.0,
3582 width: 20.0,
3583 height: 8.0,
3584 }
3585 );
3586 let PositionedElement::Path(outline) = &shape_group.children[1] else {
3587 panic!("picture outline should remain above image content");
3588 };
3589 assert!(outline.stroke.is_some());
3590 }
3591
3592 #[test]
3593 fn shape_picture_fill_is_clipped_below_stroke_and_text() {
3594 let media_id = MediaId(22);
3595 let clip_path = Path {
3596 commands: vec![
3597 PathCommand::MoveTo(Point { x: 0.0, y: 0.0 }),
3598 PathCommand::LineTo(Point { x: 10.0, y: 0.0 }),
3599 PathCommand::LineTo(Point { x: 5.0, y: 10.0 }),
3600 PathCommand::Close,
3601 ],
3602 fill_rule: FillRule::NonZero,
3603 };
3604 let mut filled = shape(
3605 Rect {
3606 x: 0.0,
3607 y: 0.0,
3608 width: 10.0,
3609 height: 10.0,
3610 },
3611 ResolvedGeometry::Custom {
3612 paths: vec![clip_path.clone()],
3613 text_rect: None,
3614 },
3615 None,
3616 Some(Stroke::new(Paint::Solid(Color::BLACK), 1.0)),
3617 );
3618 filled.image_fill = Some(ResolvedImage {
3619 media: media_id,
3620 src_rect: None,
3621 placement: ResolvedImagePlacement::default(),
3622 dpi: None,
3623 rotate_with_shape: true,
3624 opacity: 1.0,
3625 });
3626 filled.content = ResolvedContent::Text(table_text("caption"));
3627
3628 let mut fallback = shape(
3629 Rect {
3630 x: 10.0,
3631 y: 0.0,
3632 width: 10.0,
3633 height: 10.0,
3634 },
3635 ResolvedGeometry::BoundsFallback,
3636 None,
3637 None,
3638 );
3639 fallback.image_fill = filled.image_fill.clone();
3640 let input = render_input_with_media(
3641 vec![slide((20.0, 10.0), vec![filled, fallback])],
3642 HashMap::from([(media_id, media(b"image"))]),
3643 );
3644
3645 let page = layout_slide(&input, 0).expect("lower shape picture fill");
3646 let filled_group = only_group(&page.elements[0]);
3647 assert!(
3648 filled_group.children.len() > 2,
3649 "text must follow image and stroke"
3650 );
3651 let image_clip = only_group(&filled_group.children[0]);
3652 assert_eq!(image_clip.clip, Some(clip_path));
3653 assert!(matches!(
3654 image_clip.children.as_slice(),
3655 [PositionedElement::Image { media_id: id, .. }] if *id == media_id
3656 ));
3657 let PositionedElement::Path(outline) = &filled_group.children[1] else {
3658 panic!("shape stroke must follow its image fill");
3659 };
3660 assert!(outline.fill.is_none());
3661 assert!(outline.stroke.is_some());
3662
3663 let fallback_group = only_group(&page.elements[1]);
3664 let PositionedElement::Path(bounds) = &fallback_group.children[1] else {
3665 panic!("bounds path must follow its image fill");
3666 };
3667 assert!(
3668 bounds.stroke.is_none(),
3669 "image fill suppresses synthetic border"
3670 );
3671 }
3672
3673 #[test]
3674 fn tile_picture_repeats_media_in_row_major_order_inside_shape_clip() {
3675 let png = horizontal_png(&[[255, 0, 0, 255]]);
3676 let media_id = MediaId::from_bytes(&png);
3677 let picture = picture_shape(
3678 Rect {
3679 x: 2.0,
3680 y: 2.0,
3681 width: 3.0,
3682 height: 2.0,
3683 },
3684 media_id,
3685 None,
3686 ResolvedImagePlacement::Tile(ResolvedTilePlacement {
3687 translation: Point { x: 0.0, y: 0.0 },
3688 scale_x: 1.0,
3689 scale_y: 1.0,
3690 flip: ResolvedTileFlip::None,
3691 alignment: ResolvedRectAlignment::TopLeft,
3692 }),
3693 Some(72.0),
3694 );
3695 let input = render_input_with_media(
3696 vec![slide((8.0, 6.0), vec![picture])],
3697 HashMap::from([(media_id, media(&png))]),
3698 );
3699
3700 let layout = layout_presentation(&input).expect("lower tiled picture");
3701 let shape_group = only_group(&layout.pages[0].elements[0]);
3702 let tiles = only_group(&shape_group.children[0]);
3703 let rects = tiles
3704 .children
3705 .iter()
3706 .map(|tile| match tile {
3707 PositionedElement::Image { rect, .. } => *rect,
3708 _ => panic!("unflipped tile should be one image"),
3709 })
3710 .collect::<Vec<_>>();
3711 assert_eq!(
3712 rects,
3713 [
3714 Rect {
3715 x: 0.0,
3716 y: 0.0,
3717 width: 1.0,
3718 height: 1.0
3719 },
3720 Rect {
3721 x: 1.0,
3722 y: 0.0,
3723 width: 1.0,
3724 height: 1.0
3725 },
3726 Rect {
3727 x: 2.0,
3728 y: 0.0,
3729 width: 1.0,
3730 height: 1.0
3731 },
3732 Rect {
3733 x: 0.0,
3734 y: 1.0,
3735 width: 1.0,
3736 height: 1.0
3737 },
3738 Rect {
3739 x: 1.0,
3740 y: 1.0,
3741 width: 1.0,
3742 height: 1.0
3743 },
3744 Rect {
3745 x: 2.0,
3746 y: 1.0,
3747 width: 1.0,
3748 height: 1.0
3749 },
3750 ]
3751 );
3752 let rendered =
3753 oxml_pdf::render_page_to_png(&layout, 0, 72.0).expect("rasterise tiled picture");
3754 let pixmap = tiny_skia::Pixmap::decode_png(&rendered).expect("decode tiled picture");
3755 assert_eq!(rgb_at(&pixmap, 3, 3), (255, 0, 0));
3756 assert_eq!(rgb_at(&pixmap, 1, 3), (255, 255, 255));
3757 assert_eq!(rgb_at(&pixmap, 5, 3), (255, 255, 255));
3758
3759 assert_eq!(
3760 tile_alignment_origin(
3761 Rect {
3762 x: 0.0,
3763 y: 0.0,
3764 width: 3.0,
3765 height: 2.0,
3766 },
3767 1.0,
3768 1.0,
3769 ResolvedRectAlignment::BottomRight,
3770 ),
3771 Point { x: 2.0, y: 1.0 }
3772 );
3773 assert_eq!(repeated_origin(3.5, 0.0, 1.0), -0.5);
3774 assert_eq!(repeated_origin(0.0, -2.1, 1.0), -3.0);
3775 assert_eq!(repeated_tile_index(-0.5, 2.5, 1.0, media_id).unwrap(), -3);
3776 let flipped = flip_tile(
3777 picture_image(media_id, input.media.get(&media_id).unwrap(), rects[4]),
3778 rects[4],
3779 ResolvedTileFlip::Both,
3780 true,
3781 true,
3782 );
3783 let flipped = only_group(&flipped);
3784 assert_eq!(
3785 flipped.transform,
3786 Transform {
3787 a: -1.0,
3788 b: 0.0,
3789 c: 0.0,
3790 d: -1.0,
3791 e: 3.0,
3792 f: 3.0,
3793 }
3794 );
3795 }
3796
3797 #[test]
3798 fn picture_rotation_policy_counter_rotates_only_image_content() {
3799 let png = horizontal_png(&[[255, 0, 0, 255]]);
3800 let media_id = MediaId::from_bytes(&png);
3801 let mut picture = picture_shape(
3802 Rect {
3803 x: 5.0,
3804 y: 5.0,
3805 width: 10.0,
3806 height: 10.0,
3807 },
3808 media_id,
3809 None,
3810 ResolvedImagePlacement::default(),
3811 None,
3812 );
3813 picture.rotation_deg = 45.0;
3814 let ResolvedContent::Image(image) = &mut picture.content else {
3815 unreachable!();
3816 };
3817 image.rotate_with_shape = false;
3818 let mut tiled_picture = picture_shape(
3819 Rect {
3820 x: 25.0,
3821 y: 5.0,
3822 width: 10.0,
3823 height: 10.0,
3824 },
3825 media_id,
3826 None,
3827 ResolvedImagePlacement::Tile(ResolvedTilePlacement {
3828 translation: Point { x: 0.0, y: 0.0 },
3829 scale_x: 1.0,
3830 scale_y: 1.0,
3831 flip: ResolvedTileFlip::None,
3832 alignment: ResolvedRectAlignment::TopLeft,
3833 }),
3834 Some(72.0),
3835 );
3836 tiled_picture.rotation_deg = 45.0;
3837 let ResolvedContent::Image(image) = &mut tiled_picture.content else {
3838 unreachable!();
3839 };
3840 image.rotate_with_shape = false;
3841 let input = render_input_with_media(
3842 vec![slide((40.0, 20.0), vec![picture, tiled_picture])],
3843 HashMap::from([(media_id, media(&png))]),
3844 );
3845
3846 let layout = layout_presentation(&input).expect("lower picture rotation policy");
3847 let shape = only_group(&layout.pages[0].elements[0]);
3848 let clip = only_group(&shape.children[0]);
3849 assert_eq!(
3850 clip.clip,
3851 Some(Path::rect(local_shape_rect(&input.slides[0].shapes[0])))
3852 );
3853 let image = only_group(&clip.children[0]);
3854 assert_eq!(image.transform, Transform::rotate_about(-45.0, 5.0, 5.0));
3855 assert!(matches!(
3856 image.children.as_slice(),
3857 [PositionedElement::Image { .. }]
3858 ));
3859
3860 let rendered =
3861 oxml_pdf::render_page_to_png(&layout, 0, 72.0).expect("rasterise picture rotation");
3862 let pixmap = tiny_skia::Pixmap::decode_png(&rendered).expect("decode picture rotation");
3863 assert_eq!(rgb_at(&pixmap, 10, 4), (255, 0, 0));
3864 assert_eq!(rgb_at(&pixmap, 4, 4), (255, 255, 255));
3865 assert_eq!(rgb_at(&pixmap, 30, 4), (255, 0, 0));
3866 assert_eq!(rgb_at(&pixmap, 24, 4), (255, 255, 255));
3867 }
3868
3869 #[test]
3870 fn tile_dpi_prefers_declared_then_embedded_then_96() {
3871 let embedded = oxml_media::ImageInfo {
3872 format: oxml_media::ImageFormat::Png,
3873 width_px: 144,
3874 height_px: 72,
3875 dpi_x: Some(72.0),
3876 dpi_y: Some(72.0),
3877 bit_depth: 8,
3878 channels: 4,
3879 has_alpha: true,
3880 };
3881 assert_eq!(
3882 tile_native_size_points(embedded, Some(144.0), MediaId(1)).unwrap(),
3883 (72.0, 36.0)
3884 );
3885 assert_eq!(
3886 tile_native_size_points(embedded, None, MediaId(1)).unwrap(),
3887 (144.0, 72.0)
3888 );
3889 assert_eq!(
3890 tile_native_size_points(
3891 oxml_media::ImageInfo {
3892 dpi_x: None,
3893 dpi_y: None,
3894 ..embedded
3895 },
3896 None,
3897 MediaId(1),
3898 )
3899 .unwrap(),
3900 (108.0, 54.0)
3901 );
3902 }
3903
3904 #[test]
3905 fn equal_picture_bytes_reuse_one_media_id_across_elements() {
3906 let bytes = b"same picture";
3907 let media_id = MediaId::from_bytes(bytes);
3908 let pictures = [0.0, 5.0]
3909 .map(|x| {
3910 picture_shape(
3911 Rect {
3912 x,
3913 y: 0.0,
3914 width: 4.0,
3915 height: 4.0,
3916 },
3917 media_id,
3918 None,
3919 ResolvedImagePlacement::default(),
3920 None,
3921 )
3922 })
3923 .to_vec();
3924 let input = render_input_with_media(
3925 vec![slide((10.0, 5.0), pictures)],
3926 HashMap::from([(media_id, media(bytes))]),
3927 );
3928
3929 let layout = layout_presentation(&input).expect("lower repeated picture media");
3930 let mut ids = Vec::new();
3931 collect_image_ids(&layout.pages[0].elements, &mut ids);
3932 assert_eq!(ids, [media_id, media_id]);
3933 assert_eq!(input.media.len(), 1);
3934 }
3935
3936 #[test]
3937 fn missing_external_media_and_empty_crop_are_contextual() {
3938 let missing_id = MediaId(404);
3939 let missing = picture_shape(
3940 Rect {
3941 x: 0.0,
3942 y: 0.0,
3943 width: 4.0,
3944 height: 4.0,
3945 },
3946 missing_id,
3947 None,
3948 ResolvedImagePlacement::default(),
3949 None,
3950 );
3951 assert_eq!(
3952 layout_slide(&render_input(vec![slide((5.0, 5.0), vec![missing])]), 0).unwrap_err(),
3953 RenderInputError::MissingMedia { media: missing_id }
3954 );
3955
3956 let media_id = MediaId(405);
3957 let empty = picture_shape(
3958 Rect {
3959 x: 0.0,
3960 y: 0.0,
3961 width: 4.0,
3962 height: 4.0,
3963 },
3964 media_id,
3965 Some(CropRect {
3966 left: 0.5,
3967 right: 0.5,
3968 ..CropRect::default()
3969 }),
3970 ResolvedImagePlacement::default(),
3971 None,
3972 );
3973 let input = render_input_with_media(
3974 vec![slide((5.0, 5.0), vec![empty])],
3975 HashMap::from([(media_id, media(b"image"))]),
3976 );
3977 assert_eq!(
3978 layout_slide(&input, 0).unwrap_err(),
3979 RenderInputError::InvalidPicture {
3980 media: media_id,
3981 detail: "source crop",
3982 }
3983 );
3984
3985 let png = horizontal_png(&[[0, 0, 0, 255]]);
3986 let media_id = MediaId::from_bytes(&png);
3987 let excessive = picture_shape(
3988 Rect {
3989 x: 0.0,
3990 y: 0.0,
3991 width: 10.0,
3992 height: 10.0,
3993 },
3994 media_id,
3995 None,
3996 ResolvedImagePlacement::Tile(ResolvedTilePlacement {
3997 translation: Point { x: 0.0, y: 0.0 },
3998 scale_x: 0.000_1,
3999 scale_y: 0.000_1,
4000 flip: ResolvedTileFlip::None,
4001 alignment: ResolvedRectAlignment::TopLeft,
4002 }),
4003 Some(72.0),
4004 );
4005 let input = render_input_with_media(
4006 vec![slide((10.0, 10.0), vec![excessive])],
4007 HashMap::from([(media_id, media(&png))]),
4008 );
4009 assert!(matches!(
4010 layout_slide(&input, 0),
4011 Err(RenderInputError::TileLimitExceeded { media, .. }) if media == media_id
4012 ));
4013 }
4014
4015 fn picture_shape(
4016 bounds: Rect,
4017 media: MediaId,
4018 src_rect: Option<CropRect>,
4019 placement: ResolvedImagePlacement,
4020 dpi: Option<f64>,
4021 ) -> ResolvedShape {
4022 let mut picture = shape(bounds, ResolvedGeometry::Rectangle, None, None);
4023 picture.content = ResolvedContent::Image(ResolvedImage {
4024 media,
4025 src_rect,
4026 placement,
4027 dpi,
4028 rotate_with_shape: true,
4029 opacity: 1.0,
4030 });
4031 picture
4032 }
4033
4034 fn render_input_with_media(
4035 slides: Vec<ResolvedSlide>,
4036 media: HashMap<MediaId, MediaData>,
4037 ) -> RenderInput {
4038 RenderInput {
4039 slides,
4040 media,
4041 fonts: Vec::new(),
4042 metadata: None,
4043 }
4044 }
4045
4046 fn horizontal_png(colors: &[[u8; 4]]) -> Vec<u8> {
4047 let mut pixmap = tiny_skia::Pixmap::new(colors.len() as u32, 1).expect("fixture pixmap");
4048 for (pixel, color) in pixmap.pixels_mut().iter_mut().zip(colors) {
4049 *pixel =
4050 tiny_skia::PremultipliedColorU8::from_rgba(color[0], color[1], color[2], color[3])
4051 .expect("premultiplied fixture colour");
4052 }
4053 pixmap.encode_png().expect("encode fixture PNG")
4054 }
4055
4056 fn rgb_at(pixmap: &tiny_skia::Pixmap, x: u32, y: u32) -> (u8, u8, u8) {
4057 let pixel = pixmap.pixel(x, y).expect("sample lies inside page");
4058 (pixel.red(), pixel.green(), pixel.blue())
4059 }
4060
4061 fn collect_image_ids(elements: &[PositionedElement], ids: &mut Vec<MediaId>) {
4062 for element in elements {
4063 match element {
4064 PositionedElement::Image { media_id, .. } => ids.push(*media_id),
4065 PositionedElement::Group(group) => collect_image_ids(&group.children, ids),
4066 _ => {}
4067 }
4068 }
4069 }
4070
4071 fn open_line(from: Point, to: Point) -> Path {
4072 Path {
4073 commands: vec![PathCommand::MoveTo(from), PathCommand::LineTo(to)],
4074 fill_rule: FillRule::NonZero,
4075 }
4076 }
4077
4078 fn line_end(kind: ResolvedLineEndKind) -> ResolvedLineEnd {
4079 ResolvedLineEnd {
4080 kind,
4081 width: ResolvedLineEndSize::Medium,
4082 length: ResolvedLineEndSize::Medium,
4083 }
4084 }
4085
4086 #[test]
4087 fn layout_slide_rejects_an_out_of_range_index() {
4088 let input = render_input(vec![slide((20.0, 10.0), Vec::new())]);
4089
4090 assert_eq!(
4091 layout_slide(&input, 4).unwrap_err(),
4092 RenderInputError::SlideIndexOutOfBounds {
4093 index: 4,
4094 slide_count: 1,
4095 }
4096 );
4097 }
4098
4099 fn fx206_caps_highlight_fixture(shape: &str) -> LayoutResult {
4100 const P_NS: &str = "http://schemas.openxmlformats.org/presentationml/2006/main";
4101 const A_NS: &str = "http://schemas.openxmlformats.org/drawingml/2006/main";
4102
4103 let slide = CT_Slide::from_xml(
4104 format!(
4105 "<p:sld xmlns:p=\"{P_NS}\" xmlns:a=\"{A_NS}\"><p:cSld><p:spTree><p:nvGrpSpPr/><p:grpSpPr/>{shape}</p:spTree></p:cSld></p:sld>"
4106 )
4107 .as_bytes(),
4108 )
4109 .unwrap();
4110 let empty_tree = "<p:spTree><p:nvGrpSpPr/><p:grpSpPr/></p:spTree>";
4111 let layout = CT_SlideLayout::from_xml(
4112 format!(
4113 "<p:sldLayout xmlns:p=\"{P_NS}\" xmlns:a=\"{A_NS}\"><p:cSld>{empty_tree}</p:cSld></p:sldLayout>"
4114 )
4115 .as_bytes(),
4116 )
4117 .unwrap();
4118 let master = CT_SlideMaster::from_xml(
4119 format!(
4120 "<p:sldMaster xmlns:p=\"{P_NS}\" xmlns:a=\"{A_NS}\"><p:cSld>{empty_tree}</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\"/></p:sldMaster>"
4121 )
4122 .as_bytes(),
4123 )
4124 .unwrap();
4125 let theme = CT_OfficeStyleSheet::office_default();
4126 let default_text_style = CT_TextListStyle::default();
4127 let media = rpptx_layout::ScopedMediaIds::default();
4128 let hyperlinks = rpptx_layout::ScopedHyperlinkTargets::default();
4129 let charts = rpptx_layout::ScopedChartResources::default();
4130 let mut fonts = FontManager::new_deterministic().expect("deterministic fonts");
4131 let (resolved, directions) = ResolveCtx::new(
4132 &theme,
4133 ColorMap::default(),
4134 &master,
4135 &layout,
4136 &slide,
4137 &default_text_style,
4138 )
4139 .resolve_slide_with_chart_resources_and_text_directions(
4140 (720.0, 144.0),
4141 &media,
4142 &hyperlinks,
4143 &charts,
4144 &mut fonts,
4145 )
4146 .unwrap();
4147 layout_presentation_with_font_manager_and_text_directions(
4148 &render_input(vec![resolved]),
4149 fonts,
4150 &[directions],
4151 )
4152 .unwrap()
4153 }
4154
4155 #[test]
4156 fn fx206_highlight_reaches_paint_with_alpha_and_raised_baseline() {
4157 let rendered = fx206_caps_highlight_fixture(
4158 r#"<p:sp><p:nvSpPr><p:cNvPr id="1" name="caps-highlight"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr><p:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="9144000" cy="1828800"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr><p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:r><a:rPr sz="2400" baseline="25000"><a:highlight><a:srgbClr val="FFFF00"><a:alpha val="50000"/></a:srgbClr></a:highlight><a:latin typeface="Carlito"/></a:rPr><a:t>Highlight</a:t></a:r></a:p></p:txBody></p:sp>"#,
4159 );
4160 let mut rectangles = Vec::new();
4161 let mut runs = Vec::new();
4162 walk(&rendered.pages[0].elements, &mut |element, _| {
4163 if let PositionedElement::FilledRect { rect, color } = element {
4164 rectangles.push((*rect, *color));
4165 }
4166 if let PositionedElement::Text(run) = element {
4167 runs.push(run.clone());
4168 }
4169 });
4170 assert_eq!(runs.len(), 1, "ordinary run is a real paint precondition");
4171 assert_eq!(
4172 rectangles.len(),
4173 1,
4174 "qualified highlight must paint exactly once"
4175 );
4176 let rect = &rectangles[0];
4177 assert_eq!(
4178 rect.1,
4179 Color {
4180 r: 1.0,
4181 g: 1.0,
4182 b: 0.0,
4183 a: 128.0 / 255.0
4184 }
4185 );
4186 assert!(rect.0.width > 0.0 && rect.0.height > 0.0);
4187 assert!(rect.0.y < runs[0].origin.y);
4188 let png = oxml_pdf::render_page_to_png(&rendered, 0, 144.0).unwrap();
4189 let pixmap = tiny_skia::Pixmap::decode_png(&png).unwrap();
4190 let yellow = pixmap
4191 .pixels()
4192 .iter()
4193 .filter(|pixel| {
4194 pixel.red() > 240 && pixel.green() > 240 && (110..=145).contains(&pixel.blue())
4195 })
4196 .count();
4197 assert!(
4198 yellow > 100,
4199 "actual alpha-composited highlight pixels behind the glyph run"
4200 );
4201 }
4202
4203 #[test]
4204 fn fx206_smallcaps_fallback_reaches_rich_paint_and_original_logical_clusters() {
4205 let rendered = fx206_caps_highlight_fixture(
4206 r#"<p:sp><p:nvSpPr><p:cNvPr id="1" name="caps-highlight"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr><p:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="9144000" cy="1828800"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr><p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:r><a:rPr sz="2400" cap="small"><a:latin typeface="Carlito"/></a:rPr><a:t>aBß</a:t></a:r></a:p></p:txBody></p:sp>"#,
4207 );
4208 let ordinary = fx206_caps_highlight_fixture(
4209 r#"<p:sp><p:nvSpPr><p:cNvPr id="1" name="caps-highlight"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr><p:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="9144000" cy="1828800"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr><p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:r><a:rPr sz="2400" ><a:latin typeface="Carlito"/></a:rPr><a:t>aBß</a:t></a:r></a:p></p:txBody></p:sp>"#,
4210 );
4211 let mut rich = Vec::new();
4212 walk(&rendered.pages[0].elements, &mut |element, _| {
4213 if let PositionedElement::MultilingualText(run) = element {
4214 rich.push(run.clone());
4215 }
4216 });
4217 assert!(
4218 !rich.is_empty(),
4219 "caps request must survive the rich shaping path"
4220 );
4221 assert_eq!(
4222 rich.iter()
4223 .map(|run| run.logical_text.as_str())
4224 .collect::<String>(),
4225 "aBß"
4226 );
4227 assert!(rich.iter().any(|run| (run.font_size - 19.2).abs() < 1e-9));
4228 assert!(rich.iter().any(|run| (run.font_size - 24.0).abs() < 1e-9));
4229 assert!(rich.iter().any(|run| run.logical_text == "ß"
4230 && run.glyph_ids.len() == 2
4231 && run.clusters.len() == 1
4232 && run.clusters[0].char_start == 0
4233 && run.clusters[0].char_end == 1));
4234 assert!(
4235 rendered
4236 .diagnostics
4237 .iter()
4238 .any(|item| item.message.contains("80 percent uppercase fallback"))
4239 );
4240 let actual = oxml_pdf::render_page_to_png(&rendered, 0, 144.0).unwrap();
4241 let no_caps = oxml_pdf::render_page_to_png(&ordinary, 0, 144.0).unwrap();
4242 assert_ne!(
4243 actual, no_caps,
4244 "actual painted geometry differs from ordinary mixed case"
4245 );
4246 }
4247
4248 #[test]
4249 fn resolved_rtl_numeric_forced_breaks_reach_rich_line_layout() {
4250 const P_NS: &str = "http://schemas.openxmlformats.org/presentationml/2006/main";
4251 const A_NS: &str = "http://schemas.openxmlformats.org/drawingml/2006/main";
4252 let shape = r#"<p:sp><p:nvSpPr><p:cNvPr id="1" name="rtl"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr><p:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="9144000" cy="1828800"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr><p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:pPr rtl="1"/><a:r><a:rPr sz="1800"/><a:t>123</a:t></a:r><a:br/><a:r><a:rPr sz="1800"/><a:t>456</a:t></a:r></a:p></p:txBody></p:sp>"#;
4253 let slide = CT_Slide::from_xml(
4254 format!(
4255 "<p:sld xmlns:p=\"{P_NS}\" xmlns:a=\"{A_NS}\"><p:cSld><p:spTree><p:nvGrpSpPr/><p:grpSpPr/>{shape}</p:spTree></p:cSld></p:sld>"
4256 )
4257 .as_bytes(),
4258 )
4259 .unwrap();
4260 let empty_tree = "<p:spTree><p:nvGrpSpPr/><p:grpSpPr/></p:spTree>";
4261 let layout = CT_SlideLayout::from_xml(
4262 format!(
4263 "<p:sldLayout xmlns:p=\"{P_NS}\" xmlns:a=\"{A_NS}\"><p:cSld>{empty_tree}</p:cSld></p:sldLayout>"
4264 )
4265 .as_bytes(),
4266 )
4267 .unwrap();
4268 let master = CT_SlideMaster::from_xml(
4269 format!(
4270 "<p:sldMaster xmlns:p=\"{P_NS}\" xmlns:a=\"{A_NS}\"><p:cSld>{empty_tree}</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\"/></p:sldMaster>"
4271 )
4272 .as_bytes(),
4273 )
4274 .unwrap();
4275 let theme = CT_OfficeStyleSheet::office_default();
4276 let default_text_style = CT_TextListStyle::default();
4277 let media = rpptx_layout::ScopedMediaIds::default();
4278 let hyperlinks = rpptx_layout::ScopedHyperlinkTargets::default();
4279 let charts = rpptx_layout::ScopedChartResources::default();
4280 let mut fonts = FontManager::new_deterministic().expect("deterministic fonts");
4281 let (resolved, directions) = ResolveCtx::new(
4282 &theme,
4283 ColorMap::default(),
4284 &master,
4285 &layout,
4286 &slide,
4287 &default_text_style,
4288 )
4289 .resolve_slide_with_chart_resources_and_text_directions(
4290 (720.0, 144.0),
4291 &media,
4292 &hyperlinks,
4293 &charts,
4294 &mut fonts,
4295 )
4296 .unwrap();
4297 let rendered = layout_presentation_with_font_manager_and_text_directions(
4298 &render_input(vec![resolved]),
4299 fonts,
4300 &[directions],
4301 )
4302 .unwrap();
4303 let mut runs = Vec::new();
4304 walk(&rendered.pages[0].elements, &mut |element, _| {
4305 if let PositionedElement::MultilingualText(run) = element {
4306 runs.push((run.logical_text.clone(), run.bidi_level, run.origin.y));
4307 }
4308 });
4309
4310 assert_eq!(
4311 runs.iter()
4312 .map(|(text, level, _)| (text.as_str(), *level))
4313 .collect::<Vec<_>>(),
4314 [("123", 2), ("456", 2)]
4315 );
4316 assert!(runs[0].2 < runs[1].2);
4317 }
4318
4319 #[test]
4320 fn master_gradient_background_renders_when_slide_and_layout_omit_one() {
4321 const P_NS: &str = "http://schemas.openxmlformats.org/presentationml/2006/main";
4322 const A_NS: &str = "http://schemas.openxmlformats.org/drawingml/2006/main";
4323 let shape_tree = "<p:spTree><p:nvGrpSpPr/><p:grpSpPr/></p:spTree>";
4324 let slide = CT_Slide::from_xml(
4325 format!(
4326 "<p:sld xmlns:p=\"{P_NS}\" xmlns:a=\"{A_NS}\"><p:cSld>{shape_tree}</p:cSld></p:sld>"
4327 )
4328 .as_bytes(),
4329 )
4330 .unwrap();
4331 let layout = CT_SlideLayout::from_xml(
4332 format!(
4333 "<p:sldLayout xmlns:p=\"{P_NS}\" xmlns:a=\"{A_NS}\"><p:cSld>{shape_tree}</p:cSld></p:sldLayout>"
4334 )
4335 .as_bytes(),
4336 )
4337 .unwrap();
4338 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>"#;
4339 let master = CT_SlideMaster::from_xml(
4340 format!(
4341 "<p:sldMaster xmlns:p=\"{P_NS}\" xmlns:a=\"{A_NS}\"><p:cSld>{background}{shape_tree}</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\"/></p:sldMaster>"
4342 )
4343 .as_bytes(),
4344 )
4345 .unwrap();
4346 let theme = CT_OfficeStyleSheet::office_default();
4347 let default_text_style = CT_TextListStyle::default();
4348 let resolved = ResolveCtx::new(
4349 &theme,
4350 ColorMap::default(),
4351 &master,
4352 &layout,
4353 &slide,
4354 &default_text_style,
4355 )
4356 .resolve_slide((40.0, 20.0))
4357 .unwrap();
4358 let Some(ResolvedBackground::Paint(resolved_background)) = resolved.background.clone()
4359 else {
4360 panic!("expected resolved paint background");
4361 };
4362 let rendered = layout_presentation(&render_input(vec![resolved])).unwrap();
4363
4364 assert_eq!(rendered.pages[0].background, Some(resolved_background));
4365 assert!(rendered.pages[0].elements.is_empty());
4366 let png = oxml_pdf::render_page_to_png(&rendered, 0, 72.0)
4367 .expect("rasterise inherited master gradient");
4368 let pixmap = tiny_skia::Pixmap::decode_png(&png).expect("decode background raster");
4369 let left = rgb_at(&pixmap, 2, 10);
4370 let right = rgb_at(&pixmap, 37, 10);
4371 assert!(
4372 left.0 > left.2,
4373 "left sample should be red-dominant: {left:?}"
4374 );
4375 assert!(
4376 right.2 > right.0,
4377 "right sample should be blue-dominant: {right:?}"
4378 );
4379 }
4380
4381 #[test]
4382 fn absent_background_keeps_the_default_white_raster() {
4383 const P_NS: &str = "http://schemas.openxmlformats.org/presentationml/2006/main";
4384 const A_NS: &str = "http://schemas.openxmlformats.org/drawingml/2006/main";
4385 let shape_tree = "<p:spTree><p:nvGrpSpPr/><p:grpSpPr/></p:spTree>";
4386 let slide = CT_Slide::from_xml(
4387 format!(
4388 "<p:sld xmlns:p=\"{P_NS}\" xmlns:a=\"{A_NS}\"><p:cSld>{shape_tree}</p:cSld></p:sld>"
4389 )
4390 .as_bytes(),
4391 )
4392 .unwrap();
4393 let layout = CT_SlideLayout::from_xml(
4394 format!(
4395 "<p:sldLayout xmlns:p=\"{P_NS}\" xmlns:a=\"{A_NS}\"><p:cSld>{shape_tree}</p:cSld></p:sldLayout>"
4396 )
4397 .as_bytes(),
4398 )
4399 .unwrap();
4400 let master = CT_SlideMaster::from_xml(
4401 format!(
4402 "<p:sldMaster xmlns:p=\"{P_NS}\" xmlns:a=\"{A_NS}\"><p:cSld>{shape_tree}</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\"/></p:sldMaster>"
4403 )
4404 .as_bytes(),
4405 )
4406 .unwrap();
4407 let mut theme = CT_OfficeStyleSheet::office_default();
4408 theme
4409 .theme_elements
4410 .format_scheme
4411 .background_fill_styles[0] = oxml_drawing::fill::Fill::from_xml(
4412 br#"<a:solidFill xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><a:srgbClr val="000000"/></a:solidFill>"#,
4413 )
4414 .unwrap();
4415 let default_text_style = CT_TextListStyle::default();
4416 let resolved = ResolveCtx::new(
4417 &theme,
4418 ColorMap::default(),
4419 &master,
4420 &layout,
4421 &slide,
4422 &default_text_style,
4423 )
4424 .resolve_slide((10.0, 10.0))
4425 .unwrap();
4426
4427 assert!(resolved.background.is_none());
4428 let rendered = layout_presentation(&render_input(vec![resolved])).unwrap();
4429 assert!(rendered.pages[0].background.is_none());
4430 let png = oxml_pdf::render_page_to_png(&rendered, 0, 72.0)
4431 .expect("rasterise absent presentation background");
4432 let pixmap = tiny_skia::Pixmap::decode_png(&png).expect("decode white background raster");
4433 assert_eq!(rgb_at(&pixmap, 5, 5), (255, 255, 255));
4434 }
4435
4436 #[test]
4437 fn background_is_not_duplicated_in_page_elements() {
4438 let mut resolved = slide((20.0, 10.0), Vec::new());
4439 resolved.background = Some(ResolvedBackground::Paint(Paint::Solid(Color::from_hex(
4440 "102030",
4441 ))));
4442
4443 let page = layout_slide(&render_input(vec![resolved]), 0).unwrap();
4444
4445 assert_eq!(
4446 page.background,
4447 Some(Paint::Solid(Color::from_hex("102030")))
4448 );
4449 assert!(page.elements.is_empty());
4450 }
4451
4452 #[test]
4453 fn background_image_is_lowered_before_slide_shapes() {
4454 let png = horizontal_png(&[[0, 0, 255, 255]]);
4455 let media_id = MediaId::from_bytes(&png);
4456 let foreground = shape(
4457 Rect {
4458 x: 2.0,
4459 y: 2.0,
4460 width: 6.0,
4461 height: 6.0,
4462 },
4463 ResolvedGeometry::Rectangle,
4464 Some(Paint::Solid(Color::from_hex("FF0000"))),
4465 None,
4466 );
4467 let mut resolved = slide((10.0, 10.0), vec![foreground]);
4468 resolved.background = Some(ResolvedBackground::Image(ResolvedImage {
4469 media: media_id,
4470 src_rect: None,
4471 placement: ResolvedImagePlacement::default(),
4472 dpi: None,
4473 rotate_with_shape: true,
4474 opacity: 0.3,
4475 }));
4476 let input =
4477 render_input_with_media(vec![resolved], HashMap::from([(media_id, media(&png))]));
4478
4479 let page = layout_slide(&input, 0).unwrap();
4480 let background = only_group(page.elements.first().unwrap());
4481 assert_eq!(background.opacity, 0.3);
4482 assert!(matches!(
4483 background.children.first(),
4484 Some(PositionedElement::Image { media_id: id, .. }) if *id == media_id
4485 ));
4486 assert!(matches!(
4487 page.elements.get(1),
4488 Some(PositionedElement::Group(_))
4489 ));
4490 let rendered = oxml_pdf::render_page_to_png(
4491 &LayoutResult::new(vec![page.into()], Vec::new(), None, Vec::new()),
4492 0,
4493 72.0,
4494 )
4495 .expect("rasterise background image ordering");
4496 let pixmap = tiny_skia::Pixmap::decode_png(&rendered).unwrap();
4497 let (red, green, blue) = rgb_at(&pixmap, 0, 0);
4498 assert!((i32::from(red) - 178).abs() <= 1 && red == green);
4499 assert_eq!(blue, 255);
4500 assert_eq!(rgb_at(&pixmap, 5, 5), (255, 0, 0));
4501 }
4502
4503 #[test]
4504 fn layout_presentation_preserves_page_order_and_diagnostics() {
4505 let mut first = slide((20.0, 10.0), Vec::new());
4506 first.diagnostics.push(Diagnostic {
4507 message: "first diagnostic".to_owned(),
4508 });
4509 let mut second = slide((30.0, 15.0), Vec::new());
4510 second.diagnostics.push(Diagnostic {
4511 message: "second diagnostic".to_owned(),
4512 });
4513 let mut input = render_input(vec![first, second]);
4514 input.metadata = Some(DocumentMetadata {
4515 title: Some("shape deck".to_owned()),
4516 author: Some("rpptx-render".to_owned()),
4517 ..DocumentMetadata::default()
4518 });
4519
4520 let layout = layout_presentation(&input).expect("lower presentation");
4521 assert_eq!(layout.pages.len(), 2);
4522 assert_eq!(
4523 (
4524 layout.pages[0].page_number,
4525 layout.pages[0].width,
4526 layout.pages[0].height
4527 ),
4528 (1, 20.0, 10.0)
4529 );
4530 assert_eq!(
4531 (
4532 layout.pages[1].page_number,
4533 layout.pages[1].width,
4534 layout.pages[1].height
4535 ),
4536 (2, 30.0, 15.0)
4537 );
4538 assert_eq!(
4539 layout
4540 .metadata
4541 .as_ref()
4542 .and_then(|metadata| metadata.title.as_deref()),
4543 Some("shape deck")
4544 );
4545 assert_eq!(
4546 layout
4547 .diagnostics
4548 .iter()
4549 .map(|diagnostic| diagnostic.message.as_str())
4550 .collect::<Vec<_>>(),
4551 vec!["first diagnostic", "second diagnostic"]
4552 );
4553 assert!(layout.fonts.is_empty());
4554 assert!(layout.outlines.is_empty());
4555 assert!(layout.structure.is_none());
4556 }
4557
4558 #[test]
4559 fn same_relationship_id_resolves_independently_in_all_three_scopes() {
4560 let relationships = RelScopes {
4561 slide: HashMap::from([("rId2".to_owned(), relationship("slide.png"))]),
4562 layout: HashMap::from([("rId2".to_owned(), relationship("layout.png"))]),
4563 master: HashMap::from([("rId2".to_owned(), relationship("master.png"))]),
4564 };
4565 let package_media = HashMap::from([
4566 ("slide.png".to_owned(), media(b"slide image")),
4567 ("layout.png".to_owned(), media(b"layout image")),
4568 ("master.png".to_owned(), media(b"master image")),
4569 ]);
4570 let mut deck_media = HashMap::new();
4571
4572 let slide = resolve_media_relationship(
4573 &relationships,
4574 RelScope::Slide,
4575 "rId2",
4576 &package_media,
4577 &mut deck_media,
4578 )
4579 .unwrap();
4580 let layout = resolve_media_relationship(
4581 &relationships,
4582 RelScope::Layout,
4583 "rId2",
4584 &package_media,
4585 &mut deck_media,
4586 )
4587 .unwrap();
4588 let master = resolve_media_relationship(
4589 &relationships,
4590 RelScope::Master,
4591 "rId2",
4592 &package_media,
4593 &mut deck_media,
4594 )
4595 .unwrap();
4596
4597 assert_eq!(slide, MediaId::from_bytes(b"slide image"));
4598 assert_eq!(layout, MediaId::from_bytes(b"layout image"));
4599 assert_eq!(master, MediaId::from_bytes(b"master image"));
4600 assert_eq!(deck_media.len(), 3);
4601 }
4602
4603 #[test]
4604 fn external_hyperlink_projection_keeps_scopes_and_excludes_internal_targets() {
4605 let relationships = RelScopes {
4606 slide: HashMap::from([
4607 (
4608 "rId7".to_owned(),
4609 hyperlink_relationship("https://slide.example", Some("External")),
4610 ),
4611 (
4612 "rId8".to_owned(),
4613 hyperlink_relationship("../slides/slide2.xml", None),
4614 ),
4615 ]),
4616 layout: HashMap::from([(
4617 "rId7".to_owned(),
4618 hyperlink_relationship("https://layout.example", Some("External")),
4619 )]),
4620 master: HashMap::from([(
4621 "rId7".to_owned(),
4622 hyperlink_relationship("https://master.example", Some("External")),
4623 )]),
4624 };
4625
4626 let targets = relationships.external_hyperlink_targets();
4627
4628 assert_eq!(
4629 targets.slide.get("rId7").map(String::as_str),
4630 Some("https://slide.example")
4631 );
4632 assert!(!targets.slide.contains_key("rId8"));
4633 assert_eq!(
4634 targets.layout.get("rId7").map(String::as_str),
4635 Some("https://layout.example")
4636 );
4637 assert_eq!(
4638 targets.master.get("rId7").map(String::as_str),
4639 Some("https://master.example")
4640 );
4641 }
4642
4643 #[test]
4644 fn equal_media_bytes_deduplicate_to_one_media_entry() {
4645 let relationships = RelScopes {
4646 slide: HashMap::from([
4647 ("rId1".to_owned(), relationship("logo-a.png")),
4648 ("rId2".to_owned(), relationship("logo-b.png")),
4649 ]),
4650 ..RelScopes::default()
4651 };
4652 let package_media = HashMap::from([
4653 ("logo-a.png".to_owned(), media(b"shared logo")),
4654 ("logo-b.png".to_owned(), media(b"shared logo")),
4655 ]);
4656 let mut deck_media = HashMap::new();
4657
4658 let first = resolve_media_relationship(
4659 &relationships,
4660 RelScope::Slide,
4661 "rId1",
4662 &package_media,
4663 &mut deck_media,
4664 )
4665 .unwrap();
4666 let second = resolve_media_relationship(
4667 &relationships,
4668 RelScope::Slide,
4669 "rId2",
4670 &package_media,
4671 &mut deck_media,
4672 )
4673 .unwrap();
4674
4675 assert_eq!(first, second);
4676 assert_eq!(deck_media.len(), 1);
4677 }
4678
4679 #[test]
4680 fn missing_relationship_reports_scope_and_id() {
4681 let error = resolve_media_relationship(
4682 &RelScopes::default(),
4683 RelScope::Layout,
4684 "rId9",
4685 &HashMap::new(),
4686 &mut HashMap::new(),
4687 )
4688 .unwrap_err();
4689
4690 assert_eq!(
4691 error,
4692 RenderInputError::MissingRelationship {
4693 scope: RelScope::Layout,
4694 relationship_id: "rId9".to_owned(),
4695 }
4696 );
4697 assert!(error.to_string().contains("layout"));
4698 assert!(error.to_string().contains("rId9"));
4699 }
4700
4701 #[test]
4702 fn render_input_contains_only_resolved_slides() {
4703 let input = RenderInput {
4704 slides: Vec::<ResolvedSlide>::new(),
4705 media: HashMap::new(),
4706 fonts: Vec::new(),
4707 metadata: None,
4708 };
4709
4710 assert!(input.slides.is_empty());
4711 assert_eq!(
4712 std::any::type_name_of_val(&input.slides),
4713 "alloc::vec::Vec<rpptx_layout::ResolvedSlide>"
4714 );
4715 }
4716
4717 #[test]
4718 fn rpptx_render_dependency_direction_is_one_way() {
4719 let manifest = include_str!("../Cargo.toml");
4720 let rpptx_manifest = include_str!("../../rpptx/Cargo.toml");
4721 let binding_manifest = include_str!("../../rpptx-py/Cargo.toml");
4722 assert!(manifest.contains(
4723 "[features]\ndefault = [\"system-fonts\"]\nsystem-fonts = [\"oxml-layout/system-fonts\"]"
4724 ));
4725 assert!(manifest.contains("oxml-layout = { workspace = true, default-features = false }"));
4726 assert!(
4727 rpptx_manifest
4728 .contains("default = [\"default-template\", \"render\", \"system-fonts\"]")
4729 );
4730 assert!(rpptx_manifest.contains(
4731 "system-fonts = [\"oxml-layout/system-fonts\", \"rpptx-render?/system-fonts\"]"
4732 ));
4733 assert!(rpptx_manifest.contains(
4734 "rpptx-render = { workspace = true, default-features = false, optional = true }"
4735 ));
4736 assert!(
4737 binding_manifest.contains(
4738 "rpptx = { workspace = true, features = [\"default-template\", \"render\", \"system-fonts\", \"digital-signatures\"] }"
4739 )
4740 );
4741 for dependency in [
4742 "oxml-drawing.workspace = true",
4743 "oxml-media.workspace = true",
4744 "rpptx-layout.workspace = true",
4745 "rpptx-oxml.workspace = true",
4746 ] {
4747 assert!(manifest.contains(dependency), "missing {dependency}");
4748 }
4749 for oxml_manifest in [
4750 include_str!("../../oxml-core/Cargo.toml"),
4751 include_str!("../../oxml-drawing/Cargo.toml"),
4752 include_str!("../../oxml-layout/Cargo.toml"),
4753 include_str!("../../oxml-media/Cargo.toml"),
4754 include_str!("../../oxml-opc/Cargo.toml"),
4755 include_str!("../../oxml-pdf/Cargo.toml"),
4756 ] {
4757 assert!(!oxml_manifest.contains("rpptx-render"));
4758 }
4759 assert!(manifest.contains("version = \"0.14.2\""));
4760 assert!(manifest.contains("publish = true"));
4761 }
4762 #[test]
4763 fn fx206_recovery_paints_transformed_outline_and_valid_sibling_order() {
4764 let red = shape(
4765 Rect {
4766 x: 30.0,
4767 y: 10.0,
4768 width: 10.0,
4769 height: 10.0,
4770 },
4771 ResolvedGeometry::Rectangle,
4772 Some(Paint::Solid(Color::from_hex("FF0000"))),
4773 None,
4774 );
4775 let mut failed = shape(
4776 Rect {
4777 x: 10.0,
4778 y: 20.0,
4779 width: 10.0,
4780 height: 10.0,
4781 },
4782 ResolvedGeometry::BoundsFallback,
4783 None,
4784 None,
4785 );
4786 failed.group_transform = Transform {
4787 a: 0.0,
4788 b: 1.0,
4789 c: -1.0,
4790 d: 0.0,
4791 e: 50.0,
4792 f: 0.0,
4793 };
4794 failed.unsupported = Some("unresolved shape");
4795 let green = shape(
4796 Rect {
4797 x: 30.0,
4798 y: 10.0,
4799 width: 3.0,
4800 height: 10.0,
4801 },
4802 ResolvedGeometry::Rectangle,
4803 Some(Paint::Solid(Color::from_hex("00FF00"))),
4804 None,
4805 );
4806 let mut resolved = slide((60.0, 40.0), vec![red, failed, green]);
4807 resolved.unresolved_member_count = 1;
4808 resolved.diagnostics.push(Diagnostic {
4809 message:
4810 "unresolved member slide shape 2 \"bad\" retained as bounds: cannot resolve colour"
4811 .to_owned(),
4812 });
4813 let input = render_input(vec![resolved]);
4814 let layout = layout_presentation(&input).unwrap();
4815 assert!(
4816 layout.diagnostics[0]
4817 .message
4818 .contains("slide 1 incomplete: 1 unresolved members")
4819 );
4820 let png = oxml_pdf::render_page_to_png(&layout, 0, 72.0).unwrap();
4821 let pixmap = tiny_skia::Pixmap::decode_png(&png).unwrap();
4822 assert_eq!(rgb_at(&pixmap, 31, 15), (0, 255, 0));
4823 assert_eq!(rgb_at(&pixmap, 35, 15), (255, 0, 0));
4824 let (r, g, b) = rgb_at(&pixmap, 25, 10);
4826 assert!(
4827 r < 160 && g < 160 && b < 160,
4828 "rotated outline not at the analytic edge"
4829 );
4830 assert_eq!(rgb_at(&pixmap, 25, 15), (255, 255, 255));
4831 assert!(
4832 oxml_pdf::render_page_to_png(&layout, 0, 0.0).is_none(),
4833 "backend refusal must remain fatal"
4834 );
4835 }
4836
4837 #[test]
4838 fn fx206_recovery_omission_reports_count_without_fabricating_paint() {
4839 let mut resolved = slide((20.0, 20.0), Vec::new());
4840 resolved.unresolved_member_count = 1;
4841 resolved.diagnostics.push(Diagnostic { message: "unresolved member slide shape 9 \"missing\" omitted without trustworthy finite bounds: bad style".to_owned() });
4842 let layout = layout_presentation(&render_input(vec![resolved])).unwrap();
4843 let png = oxml_pdf::render_page_to_png(&layout, 0, 72.0).unwrap();
4844 let pixmap = tiny_skia::Pixmap::decode_png(&png).unwrap();
4845 assert_eq!(rgb_at(&pixmap, 10, 10), (255, 255, 255));
4846 assert_eq!(layout.diagnostics.len(), 2);
4847 assert!(
4848 layout.diagnostics[0]
4849 .message
4850 .contains("1 unresolved members")
4851 );
4852 }
4853}