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