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 Ok(elements)
888}
889
890#[allow(clippy::too_many_arguments)]
891fn lower_tiled_picture(
892 shape: &ResolvedShape,
893 paths: &[Path],
894 media_id: MediaId,
895 media: &MediaData,
896 crop: Option<CropRect>,
897 tile: &ResolvedTilePlacement,
898 declared_dpi: Option<f64>,
899 rotate_with_shape: bool,
900) -> Result<Vec<PositionedElement>, RenderInputError> {
901 let info = oxml_media::probe(&media.bytes).ok_or(RenderInputError::InvalidPicture {
902 media: media_id,
903 detail: "tile image metadata is unavailable",
904 })?;
905 let (native_width, native_height) = tile_native_size_points(info, declared_dpi, media_id)?;
906 let tile_width = native_width * tile.scale_x;
907 let tile_height = native_height * tile.scale_y;
908 if !tile_width.is_finite()
909 || !tile_height.is_finite()
910 || tile_width <= 0.0
911 || tile_height <= 0.0
912 || !tile.translation.x.is_finite()
913 || !tile.translation.y.is_finite()
914 {
915 return Err(RenderInputError::InvalidPicture {
916 media: media_id,
917 detail: "tile size or translation is not finite and positive",
918 });
919 }
920 let shape_rect = local_shape_rect(shape);
921 let coverage_rect = picture_coverage_rect(shape, rotate_with_shape);
922 let anchor = tile_alignment_origin(shape_rect, tile_width, tile_height, tile.alignment);
923 let translated_anchor = Point {
924 x: anchor.x + tile.translation.x,
925 y: anchor.y + tile.translation.y,
926 };
927 let origin_x = repeated_origin(translated_anchor.x, coverage_rect.x, tile_width);
928 let origin_y = repeated_origin(translated_anchor.y, coverage_rect.y, tile_height);
929 if !origin_x.is_finite() || !origin_y.is_finite() {
930 return Err(RenderInputError::InvalidPicture {
931 media: media_id,
932 detail: "tile origin is not finite",
933 });
934 }
935 let first_column = repeated_tile_index(origin_x, translated_anchor.x, tile_width, media_id)?;
936 let first_row = repeated_tile_index(origin_y, translated_anchor.y, tile_height, media_id)?;
937 let columns = repeat_count(
938 origin_x,
939 coverage_rect.x + coverage_rect.width,
940 tile_width,
941 media_id,
942 )?;
943 let rows = repeat_count(
944 origin_y,
945 coverage_rect.y + coverage_rect.height,
946 tile_height,
947 media_id,
948 )?;
949 let requested = columns
950 .checked_mul(rows)
951 .ok_or(RenderInputError::TileLimitExceeded {
952 media: media_id,
953 requested: usize::MAX,
954 limit: MAX_TILE_ELEMENTS,
955 })?;
956 let byte_limit = MAX_TILED_IMAGE_BYTES / media.bytes.len().max(1);
957 let limit = MAX_TILE_ELEMENTS.min(byte_limit.max(1));
958 if requested > limit {
959 return Err(RenderInputError::TileLimitExceeded {
960 media: media_id,
961 requested,
962 limit,
963 });
964 }
965
966 let mut tiles = Vec::with_capacity(requested);
967 for row in 0..rows {
968 for column in 0..columns {
969 let rect = Rect {
970 x: origin_x + column as f64 * tile_width,
971 y: origin_y + row as f64 * tile_height,
972 width: tile_width,
973 height: tile_height,
974 };
975 let image = picture_image(media_id, media, expanded_crop_rect(rect, crop));
976 let image = if crop.is_some() {
977 clipped_group(Path::rect(rect), vec![image])
978 } else {
979 image
980 };
981 tiles.push(flip_tile(
982 image,
983 rect,
984 tile.flip,
985 (first_column.rem_euclid(2) == 1) != (column % 2 == 1),
986 (first_row.rem_euclid(2) == 1) != (row % 2 == 1),
987 ));
988 }
989 }
990 let tiles = if rotate_with_shape || shape.rotation_deg == 0.0 {
991 tiles
992 } else {
993 vec![PositionedElement::Group(GroupElement {
994 transform: Transform::rotate_about(
995 -shape.rotation_deg,
996 shape.bounds.width / 2.0,
997 shape.bounds.height / 2.0,
998 ),
999 clip: None,
1000 opacity: 1.0,
1001 effects: Vec::new(),
1002 children: tiles,
1003 })]
1004 };
1005 Ok(vec![clip_to_picture_shape(shape, paths, tiles)])
1006}
1007
1008fn normalized_insets(
1009 crop: Option<CropRect>,
1010 media: MediaId,
1011 detail: &'static str,
1012) -> Result<Option<CropRect>, RenderInputError> {
1013 let Some(crop) = crop else {
1014 return Ok(None);
1015 };
1016 let values = [crop.left, crop.top, crop.right, crop.bottom];
1017 if values.iter().any(|value| !value.is_finite()) {
1018 return Err(RenderInputError::InvalidPicture { media, detail });
1019 }
1020 let crop = CropRect {
1021 left: crop.left.clamp(0.0, 1.0),
1022 top: crop.top.clamp(0.0, 1.0),
1023 right: crop.right.clamp(0.0, 1.0),
1024 bottom: crop.bottom.clamp(0.0, 1.0),
1025 };
1026 if crop.left + crop.right >= 1.0 || crop.top + crop.bottom >= 1.0 {
1027 return Err(RenderInputError::InvalidPicture { media, detail });
1028 }
1029 Ok((crop != CropRect::default()).then_some(crop))
1030}
1031
1032fn local_shape_rect(shape: &ResolvedShape) -> Rect {
1033 Rect {
1034 x: 0.0,
1035 y: 0.0,
1036 width: shape.bounds.width,
1037 height: shape.bounds.height,
1038 }
1039}
1040
1041fn picture_coverage_rect(shape: &ResolvedShape, rotate_with_shape: bool) -> Rect {
1042 let rect = local_shape_rect(shape);
1043 if rotate_with_shape || shape.rotation_deg == 0.0 {
1044 return rect;
1045 }
1046 Transform::rotate_about(
1047 shape.rotation_deg,
1048 shape.bounds.width / 2.0,
1049 shape.bounds.height / 2.0,
1050 )
1051 .transform_rect_bbox(rect)
1052}
1053
1054fn inset_rect(rect: Rect, insets: Option<CropRect>) -> Rect {
1055 let Some(insets) = insets else {
1056 return rect;
1057 };
1058 Rect {
1059 x: rect.x + rect.width * insets.left,
1060 y: rect.y + rect.height * insets.top,
1061 width: rect.width * (1.0 - insets.left - insets.right),
1062 height: rect.height * (1.0 - insets.top - insets.bottom),
1063 }
1064}
1065
1066fn expanded_crop_rect(destination: Rect, crop: Option<CropRect>) -> Rect {
1067 let Some(crop) = crop else {
1068 return destination;
1069 };
1070 let retained_width = 1.0 - crop.left - crop.right;
1071 let retained_height = 1.0 - crop.top - crop.bottom;
1072 let width = destination.width / retained_width;
1073 let height = destination.height / retained_height;
1074 Rect {
1075 x: destination.x - width * crop.left,
1076 y: destination.y - height * crop.top,
1077 width,
1078 height,
1079 }
1080}
1081
1082fn picture_image(media_id: MediaId, media: &MediaData, rect: Rect) -> PositionedElement {
1083 PositionedElement::Image {
1084 rect,
1085 data: media.bytes.clone(),
1086 content_type: media.content_type.clone(),
1087 media_id,
1088 }
1089}
1090
1091fn counter_rotate_image(
1092 image: PositionedElement,
1093 shape: &ResolvedShape,
1094 rotate_with_shape: bool,
1095) -> PositionedElement {
1096 if rotate_with_shape || shape.rotation_deg == 0.0 {
1097 return image;
1098 }
1099 PositionedElement::Group(GroupElement {
1100 transform: Transform::rotate_about(
1101 -shape.rotation_deg,
1102 shape.bounds.width / 2.0,
1103 shape.bounds.height / 2.0,
1104 ),
1105 clip: None,
1106 opacity: 1.0,
1107 effects: Vec::new(),
1108 children: vec![image],
1109 })
1110}
1111
1112fn clip_to_picture_shape(
1113 shape: &ResolvedShape,
1114 paths: &[Path],
1115 children: Vec<PositionedElement>,
1116) -> PositionedElement {
1117 if matches!(shape.geometry, ResolvedGeometry::Rectangle) {
1118 return clipped_group(Path::rect(local_shape_rect(shape)), children);
1119 }
1120 clipped_group(combined_clip_path(paths), children)
1121}
1122
1123fn combined_clip_path(paths: &[Path]) -> Path {
1124 Path {
1125 commands: paths
1126 .iter()
1127 .flat_map(|path| path.commands.iter().cloned())
1128 .collect(),
1129 fill_rule: paths
1130 .first()
1131 .map_or(oxml_layout::FillRule::NonZero, |path| path.fill_rule),
1132 }
1133}
1134
1135fn clipped_group(clip: Path, children: Vec<PositionedElement>) -> PositionedElement {
1136 PositionedElement::Group(GroupElement {
1137 transform: Transform::IDENTITY,
1138 clip: Some(clip),
1139 opacity: 1.0,
1140 effects: Vec::new(),
1141 children,
1142 })
1143}
1144
1145fn tile_native_size_points(
1146 mut info: oxml_media::ImageInfo,
1147 declared_dpi: Option<f64>,
1148 media: MediaId,
1149) -> Result<(f64, f64), RenderInputError> {
1150 if let Some(dpi) = declared_dpi {
1151 if !dpi.is_finite() || dpi <= 0.0 {
1152 return Err(RenderInputError::InvalidPicture {
1153 media,
1154 detail: "declared picture DPI is not finite and positive",
1155 });
1156 }
1157 info.dpi_x = Some(dpi);
1158 info.dpi_y = Some(dpi);
1159 }
1160 let size = info
1161 .native_size(96.0)
1162 .ok_or(RenderInputError::InvalidPicture {
1163 media,
1164 detail: "picture DPI cannot produce a native size",
1165 })?;
1166 Ok((
1167 size.width_emu as f64 / 12_700.0,
1168 size.height_emu as f64 / 12_700.0,
1169 ))
1170}
1171
1172fn tile_alignment_origin(
1173 rect: Rect,
1174 tile_width: f64,
1175 tile_height: f64,
1176 alignment: ResolvedRectAlignment,
1177) -> Point {
1178 let center_x = rect.x + (rect.width - tile_width) / 2.0;
1179 let right = rect.x + rect.width - tile_width;
1180 let center_y = rect.y + (rect.height - tile_height) / 2.0;
1181 let bottom = rect.y + rect.height - tile_height;
1182 match alignment {
1183 ResolvedRectAlignment::TopLeft => Point {
1184 x: rect.x,
1185 y: rect.y,
1186 },
1187 ResolvedRectAlignment::Top => Point {
1188 x: center_x,
1189 y: rect.y,
1190 },
1191 ResolvedRectAlignment::TopRight => Point {
1192 x: right,
1193 y: rect.y,
1194 },
1195 ResolvedRectAlignment::Left => Point {
1196 x: rect.x,
1197 y: center_y,
1198 },
1199 ResolvedRectAlignment::Center => Point {
1200 x: center_x,
1201 y: center_y,
1202 },
1203 ResolvedRectAlignment::Right => Point {
1204 x: right,
1205 y: center_y,
1206 },
1207 ResolvedRectAlignment::BottomLeft => Point {
1208 x: rect.x,
1209 y: bottom,
1210 },
1211 ResolvedRectAlignment::Bottom => Point {
1212 x: center_x,
1213 y: bottom,
1214 },
1215 ResolvedRectAlignment::BottomRight => Point {
1216 x: right,
1217 y: bottom,
1218 },
1219 }
1220}
1221
1222fn repeated_origin(anchor: f64, coverage_start: f64, tile_size: f64) -> f64 {
1223 coverage_start - (coverage_start - anchor).rem_euclid(tile_size)
1224}
1225
1226fn repeated_tile_index(
1227 origin: f64,
1228 anchor: f64,
1229 tile_size: f64,
1230 media: MediaId,
1231) -> Result<isize, RenderInputError> {
1232 let index = ((origin - anchor) / tile_size).round();
1233 if !index.is_finite() || index < isize::MIN as f64 || index > isize::MAX as f64 {
1234 return Err(RenderInputError::InvalidPicture {
1235 media,
1236 detail: "tile translation cannot preserve flip phase",
1237 });
1238 }
1239 Ok(index as isize)
1240}
1241
1242fn repeat_count(
1243 origin: f64,
1244 coverage_end: f64,
1245 tile_size: f64,
1246 media: MediaId,
1247) -> Result<usize, RenderInputError> {
1248 let count = ((coverage_end - origin) / tile_size).ceil().max(0.0);
1249 if !count.is_finite() || count > usize::MAX as f64 {
1250 return Err(RenderInputError::TileLimitExceeded {
1251 media,
1252 requested: usize::MAX,
1253 limit: MAX_TILE_ELEMENTS,
1254 });
1255 }
1256 Ok(count as usize)
1257}
1258
1259fn flip_tile(
1260 tile: PositionedElement,
1261 rect: Rect,
1262 flip: ResolvedTileFlip,
1263 odd_column: bool,
1264 odd_row: bool,
1265) -> PositionedElement {
1266 let flip_h =
1267 matches!(flip, ResolvedTileFlip::Horizontal | ResolvedTileFlip::Both) && odd_column;
1268 let flip_v = matches!(flip, ResolvedTileFlip::Vertical | ResolvedTileFlip::Both) && odd_row;
1269 if !flip_h && !flip_v {
1270 return tile;
1271 }
1272 PositionedElement::Group(GroupElement {
1273 transform: Transform {
1274 a: if flip_h { -1.0 } else { 1.0 },
1275 b: 0.0,
1276 c: 0.0,
1277 d: if flip_v { -1.0 } else { 1.0 },
1278 e: if flip_h {
1279 2.0 * rect.x + rect.width
1280 } else {
1281 0.0
1282 },
1283 f: if flip_v {
1284 2.0 * rect.y + rect.height
1285 } else {
1286 0.0
1287 },
1288 },
1289 clip: None,
1290 opacity: 1.0,
1291 effects: Vec::new(),
1292 children: vec![tile],
1293 })
1294}
1295
1296#[derive(Clone, Copy)]
1297struct EndpointTangent {
1298 point: Point,
1299 outward: Point,
1300}
1301
1302fn endpoint_tangents(paths: &[Path]) -> (Option<EndpointTangent>, Option<EndpointTangent>) {
1303 let mut head = None;
1304 let mut tail = None;
1305 for path in paths {
1306 let mut current = None;
1307 let mut subpath_start = None;
1308 for command in &path.commands {
1309 match *command {
1310 PathCommand::MoveTo(point) => {
1311 current = Some(point);
1312 subpath_start = Some(point);
1313 }
1314 PathCommand::LineTo(to) => {
1315 if let Some(from) = current
1316 && let Some(direction) = unit_direction(from, to)
1317 {
1318 head.get_or_insert(EndpointTangent {
1319 point: from,
1320 outward: Point {
1321 x: -direction.x,
1322 y: -direction.y,
1323 },
1324 });
1325 tail = Some(EndpointTangent {
1326 point: to,
1327 outward: direction,
1328 });
1329 }
1330 current = Some(to);
1331 }
1332 PathCommand::CurveTo { c1, c2, to } => {
1333 if let Some(from) = current {
1334 let start_direction = [c1, c2, to]
1335 .into_iter()
1336 .find_map(|candidate| unit_direction(from, candidate));
1337 let end_direction = [c2, c1, from]
1338 .into_iter()
1339 .find_map(|candidate| unit_direction(candidate, to));
1340 if let Some(direction) = start_direction {
1341 head.get_or_insert(EndpointTangent {
1342 point: from,
1343 outward: Point {
1344 x: -direction.x,
1345 y: -direction.y,
1346 },
1347 });
1348 }
1349 if let Some(direction) = end_direction {
1350 tail = Some(EndpointTangent {
1351 point: to,
1352 outward: direction,
1353 });
1354 }
1355 }
1356 current = Some(to);
1357 }
1358 PathCommand::Close => {
1359 if let (Some(from), Some(to)) = (current, subpath_start)
1360 && let Some(direction) = unit_direction(from, to)
1361 {
1362 head.get_or_insert(EndpointTangent {
1363 point: from,
1364 outward: Point {
1365 x: -direction.x,
1366 y: -direction.y,
1367 },
1368 });
1369 tail = Some(EndpointTangent {
1370 point: to,
1371 outward: direction,
1372 });
1373 }
1374 current = subpath_start;
1375 }
1376 }
1377 }
1378 }
1379 (head, tail)
1380}
1381
1382fn unit_direction(from: Point, to: Point) -> Option<Point> {
1383 let dx = to.x - from.x;
1384 let dy = to.y - from.y;
1385 let length = dx.hypot(dy);
1386 (length.is_finite() && length > 1.0e-10).then_some(Point {
1387 x: dx / length,
1388 y: dy / length,
1389 })
1390}
1391
1392fn line_end_path(
1393 end: &ResolvedLineEnd,
1394 tangent: EndpointTangent,
1395 stroke_width: f64,
1396) -> Option<Path> {
1397 if !stroke_width.is_finite() || stroke_width <= 0.0 {
1398 return None;
1399 }
1400 let width = line_end_factor(end.width) * stroke_width;
1401 let length = line_end_factor(end.length) * stroke_width;
1402 let point = |along: f64, across: f64| Point {
1403 x: tangent.point.x + tangent.outward.x * along - tangent.outward.y * across,
1404 y: tangent.point.y + tangent.outward.y * along + tangent.outward.x * across,
1405 };
1406 let path = match end.kind {
1407 ResolvedLineEndKind::Triangle => closed_polygon(vec![
1408 point(0.0, 0.0),
1409 point(-length, width / 2.0),
1410 point(-length, -width / 2.0),
1411 ]),
1412 ResolvedLineEndKind::Stealth => closed_polygon(vec![
1413 point(0.0, 0.0),
1414 point(-length, width / 2.0),
1415 point(-length / 2.0, 0.0),
1416 point(-length, -width / 2.0),
1417 ]),
1418 ResolvedLineEndKind::Diamond => closed_polygon(vec![
1419 point(0.0, 0.0),
1420 point(-length / 2.0, width / 2.0),
1421 point(-length, 0.0),
1422 point(-length / 2.0, -width / 2.0),
1423 ]),
1424 ResolvedLineEndKind::Oval => oval_line_end(&point, width, length),
1425 ResolvedLineEndKind::Arrow => {
1426 let arm = stroke_width.min(width / 2.0);
1427 closed_polygon(vec![
1428 point(0.0, 0.0),
1429 point(-length, width / 2.0),
1430 point(-length, width / 2.0 - arm),
1431 point(-arm, 0.0),
1432 point(-length, -width / 2.0 + arm),
1433 point(-length, -width / 2.0),
1434 ])
1435 }
1436 };
1437 path_is_finite(&path).then_some(path)
1438}
1439
1440fn line_end_factor(size: ResolvedLineEndSize) -> f64 {
1441 match size {
1442 ResolvedLineEndSize::Small => 2.0,
1443 ResolvedLineEndSize::Medium => 3.0,
1444 ResolvedLineEndSize::Large => 5.0,
1445 }
1446}
1447
1448fn closed_polygon(points: Vec<Point>) -> Path {
1449 let mut points = points.into_iter();
1450 let mut commands = points
1451 .next()
1452 .map(PathCommand::MoveTo)
1453 .into_iter()
1454 .collect::<Vec<_>>();
1455 commands.extend(points.map(PathCommand::LineTo));
1456 commands.push(PathCommand::Close);
1457 Path {
1458 commands,
1459 fill_rule: oxml_layout::FillRule::NonZero,
1460 }
1461}
1462
1463fn oval_line_end(point: &impl Fn(f64, f64) -> Point, width: f64, length: f64) -> Path {
1464 const KAPPA: f64 = 0.552_284_749_830_793_6;
1465 let rx = length / 2.0;
1466 let ry = width / 2.0;
1467 let center = -rx;
1468 Path {
1469 commands: vec![
1470 PathCommand::MoveTo(point(0.0, 0.0)),
1471 PathCommand::CurveTo {
1472 c1: point(0.0, KAPPA * ry),
1473 c2: point(center + KAPPA * rx, ry),
1474 to: point(center, ry),
1475 },
1476 PathCommand::CurveTo {
1477 c1: point(center - KAPPA * rx, ry),
1478 c2: point(-length, KAPPA * ry),
1479 to: point(-length, 0.0),
1480 },
1481 PathCommand::CurveTo {
1482 c1: point(-length, -KAPPA * ry),
1483 c2: point(center - KAPPA * rx, -ry),
1484 to: point(center, -ry),
1485 },
1486 PathCommand::CurveTo {
1487 c1: point(center + KAPPA * rx, -ry),
1488 c2: point(0.0, -KAPPA * ry),
1489 to: point(0.0, 0.0),
1490 },
1491 PathCommand::Close,
1492 ],
1493 fill_rule: oxml_layout::FillRule::NonZero,
1494 }
1495}
1496
1497fn path_is_finite(path: &Path) -> bool {
1498 path.commands.iter().all(|command| match command {
1499 PathCommand::MoveTo(point) | PathCommand::LineTo(point) => point_is_finite(*point),
1500 PathCommand::CurveTo { c1, c2, to } => {
1501 point_is_finite(*c1) && point_is_finite(*c2) && point_is_finite(*to)
1502 }
1503 PathCommand::Close => true,
1504 })
1505}
1506
1507fn point_is_finite(point: Point) -> bool {
1508 point.x.is_finite() && point.y.is_finite()
1509}
1510
1511fn filled_line_end(path: Path, paint: &Paint) -> PositionedElement {
1512 PositionedElement::Path(PathElement {
1513 path,
1514 fill: Some(paint.clone()),
1515 stroke: None,
1516 })
1517}
1518
1519fn shape_transform(shape: &ResolvedShape) -> Transform {
1520 let center_x = shape.bounds.width / 2.0;
1521 let center_y = shape.bounds.height / 2.0;
1522 let rotation = Transform::rotate_about(shape.rotation_deg, center_x, center_y);
1523 let flip = Transform {
1524 a: if shape.flip_h { -1.0 } else { 1.0 },
1525 b: 0.0,
1526 c: 0.0,
1527 d: if shape.flip_v { -1.0 } else { 1.0 },
1528 e: if shape.flip_h {
1529 shape.bounds.width
1530 } else {
1531 0.0
1532 },
1533 f: if shape.flip_v {
1534 shape.bounds.height
1535 } else {
1536 0.0
1537 },
1538 };
1539 let translation = Transform {
1540 e: shape.bounds.x,
1541 f: shape.bounds.y,
1542 ..Transform::IDENTITY
1543 };
1544 rotation
1545 .then(flip)
1546 .then(translation)
1547 .then(shape.group_transform)
1548}
1549
1550pub fn resolve_media_relationship(
1552 relationships: &RelScopes,
1553 scope: RelScope,
1554 relationship_id: &str,
1555 package_media: &HashMap<String, MediaData>,
1556 deck_media: &mut HashMap<MediaId, MediaData>,
1557) -> Result<MediaId, RenderInputError> {
1558 let relationship = relationships.get(scope, relationship_id)?;
1559 let media = package_media.get(&relationship.target).ok_or_else(|| {
1560 RenderInputError::MissingMediaTarget {
1561 scope,
1562 relationship_id: relationship_id.to_owned(),
1563 target: relationship.target.clone(),
1564 }
1565 })?;
1566 let media_id = MediaId::from_bytes(&media.bytes);
1567 deck_media.entry(media_id).or_insert_with(|| media.clone());
1568 Ok(media_id)
1569}
1570
1571#[cfg(test)]
1572mod tests {
1573 use super::*;
1574 use oxml_drawing::color::ColorMap;
1575 use oxml_drawing::text::CT_TextListStyle;
1576 use oxml_layout::{
1577 Color, Diagnostic, Effect, FieldKind, FillRule, GradientStop, GroupElement, Paint, Path,
1578 PathCommand, Point, PositionedElement, Rect, Stroke, Transform, walk,
1579 };
1580 use rpptx_layout::{
1581 ResolveCtx, ResolvedAutofit, ResolvedContent, ResolvedGeometry, ResolvedParagraph,
1582 ResolvedRunStyle, ResolvedShape, ResolvedTable, ResolvedTableBorder, ResolvedTableCell,
1583 ResolvedTableRow, ResolvedTextBody, ResolvedTextRun, TextAnchor, TextDirection, TextInsets,
1584 };
1585
1586 const IMAGE_RELATIONSHIP: &str =
1587 "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image";
1588
1589 fn media(bytes: &[u8]) -> MediaData {
1590 MediaData {
1591 bytes: bytes.to_vec(),
1592 content_type: "image/png".to_owned(),
1593 }
1594 }
1595
1596 fn relationship(target: &str) -> ResolvedRel {
1597 ResolvedRel {
1598 target: target.to_owned(),
1599 relationship_type: IMAGE_RELATIONSHIP.to_owned(),
1600 target_mode: None,
1601 }
1602 }
1603
1604 fn hyperlink_relationship(target: &str, target_mode: Option<&str>) -> ResolvedRel {
1605 ResolvedRel {
1606 target: target.to_owned(),
1607 relationship_type: HYPERLINK_RELATIONSHIP.to_owned(),
1608 target_mode: target_mode.map(str::to_owned),
1609 }
1610 }
1611
1612 fn color(red: f64, green: f64, blue: f64) -> Color {
1613 Color {
1614 r: red,
1615 g: green,
1616 b: blue,
1617 a: 1.0,
1618 }
1619 }
1620
1621 fn shape(
1622 bounds: Rect,
1623 geometry: ResolvedGeometry,
1624 fill: Option<Paint>,
1625 line: Option<Stroke>,
1626 ) -> ResolvedShape {
1627 ResolvedShape {
1628 group_transform: Transform::IDENTITY,
1629 bounds,
1630 rotation_deg: 0.0,
1631 flip_h: false,
1632 flip_v: false,
1633 geometry,
1634 fill,
1635 image_fill: None,
1636 line,
1637 head_end: None,
1638 tail_end: None,
1639 shadow: None,
1640 content: ResolvedContent::None,
1641 unsupported: None,
1642 }
1643 }
1644
1645 fn table_shape(table: ResolvedTable) -> ResolvedShape {
1646 let mut table_shape = shape(
1647 Rect {
1648 x: 0.0,
1649 y: 0.0,
1650 width: 20.0,
1651 height: 20.0,
1652 },
1653 ResolvedGeometry::Rectangle,
1654 None,
1655 None,
1656 );
1657 table_shape.content = ResolvedContent::Table(table);
1658 table_shape
1659 }
1660
1661 fn table_text(value: &str) -> ResolvedTextBody {
1662 ResolvedTextBody {
1663 insets: TextInsets::default(),
1664 anchor: TextAnchor::Top,
1665 wrap: true,
1666 vertical: TextDirection::Horizontal,
1667 space_first_last_paragraph: false,
1668 autofit: ResolvedAutofit::None,
1669 paragraphs: vec![ResolvedParagraph {
1670 runs: vec![ResolvedTextRun::Text {
1671 text: value.to_owned(),
1672 style: ResolvedRunStyle {
1673 font_size: Some(6.0),
1674 ..ResolvedRunStyle::default()
1675 },
1676 }],
1677 ..ResolvedParagraph::default()
1678 }],
1679 }
1680 }
1681
1682 fn table_border() -> ResolvedTableBorder {
1683 ResolvedTableBorder {
1684 stroke: Some(Stroke::new(Paint::Solid(Color::BLACK), 1.0)),
1685 priority: 1,
1686 }
1687 }
1688
1689 fn linked_field_shape(group_transform: Transform) -> ResolvedShape {
1690 let mut linked = shape(
1691 Rect {
1692 x: 10.0,
1693 y: 20.0,
1694 width: 120.0,
1695 height: 40.0,
1696 },
1697 ResolvedGeometry::Rectangle,
1698 None,
1699 None,
1700 );
1701 linked.group_transform = group_transform;
1702 linked.content = ResolvedContent::Text(ResolvedTextBody {
1703 insets: TextInsets::default(),
1704 anchor: TextAnchor::Top,
1705 wrap: true,
1706 vertical: TextDirection::Horizontal,
1707 space_first_last_paragraph: false,
1708 autofit: ResolvedAutofit::None,
1709 paragraphs: vec![ResolvedParagraph {
1710 runs: vec![
1711 ResolvedTextRun::Field {
1712 text: "stored".to_owned(),
1713 field_type: Some("slidenum".to_owned()),
1714 style: ResolvedRunStyle {
1715 font_size: Some(12.0),
1716 ..ResolvedRunStyle::default()
1717 },
1718 },
1719 ResolvedTextRun::Text {
1720 text: " linked".to_owned(),
1721 style: ResolvedRunStyle {
1722 font_size: Some(12.0),
1723 hyperlink_url: Some("https://example.com/deck".to_owned()),
1724 ..ResolvedRunStyle::default()
1725 },
1726 },
1727 ],
1728 ..ResolvedParagraph::default()
1729 }],
1730 });
1731 linked
1732 }
1733
1734 fn rendered_text_and_links(
1735 page: &PageFrame,
1736 ) -> (Vec<(String, Option<FieldKind>)>, Vec<String>) {
1737 let mut text = Vec::new();
1738 let mut links = Vec::new();
1739 walk(&page.elements, &mut |element, _| match element {
1740 PositionedElement::Text(run) => text.push((run.text.clone(), run.field_kind)),
1741 PositionedElement::LinkAnnotation { url, .. } => links.push(url.clone()),
1742 _ => {}
1743 });
1744 (text, links)
1745 }
1746
1747 fn transformed_link_rect(page: &PageFrame) -> Rect {
1748 let mut result = None;
1749 walk(&page.elements, &mut |element, transform| {
1750 if let PositionedElement::LinkAnnotation { rect, .. } = element {
1751 let top_left = transform.apply(Point {
1752 x: rect.x,
1753 y: rect.y,
1754 });
1755 let bottom_right = transform.apply(Point {
1756 x: rect.x + rect.width,
1757 y: rect.y + rect.height,
1758 });
1759 result = Some(Rect {
1760 x: top_left.x,
1761 y: top_left.y,
1762 width: bottom_right.x - top_left.x,
1763 height: bottom_right.y - top_left.y,
1764 });
1765 }
1766 });
1767 result.expect("rendered hyperlink annotation")
1768 }
1769
1770 #[test]
1771 fn slide_number_field_renders_current_page_and_hyperlink_emits_annotation() {
1772 let input = render_input(vec![
1773 slide((200.0, 100.0), Vec::new()),
1774 slide(
1775 (200.0, 100.0),
1776 vec![linked_field_shape(Transform::IDENTITY)],
1777 ),
1778 ]);
1779 let mut fonts = FontManager::new_deterministic().expect("deterministic fonts");
1780
1781 let page = layout_slide_with_fonts(&input, 1, &mut fonts).expect("render slide two");
1782 let (text, links) = rendered_text_and_links(&page);
1783
1784 assert_eq!(text[0], ("2".to_owned(), Some(FieldKind::Page)));
1785 assert!(!links.is_empty());
1786 assert!(links.iter().all(|url| url == "https://example.com/deck"));
1787 }
1788
1789 #[test]
1790 fn grouped_hyperlink_annotation_keeps_transformed_run_bounds() {
1791 let translation = Transform {
1792 e: 30.0,
1793 f: 40.0,
1794 ..Transform::IDENTITY
1795 };
1796 let plain_input = render_input(vec![slide(
1797 (240.0, 180.0),
1798 vec![linked_field_shape(Transform::IDENTITY)],
1799 )]);
1800 let grouped_input = render_input(vec![slide(
1801 (240.0, 180.0),
1802 vec![linked_field_shape(translation)],
1803 )]);
1804 let mut plain_fonts = FontManager::new_deterministic().expect("deterministic fonts");
1805 let mut grouped_fonts = FontManager::new_deterministic().expect("deterministic fonts");
1806 let plain = layout_slide_with_fonts(&plain_input, 0, &mut plain_fonts)
1807 .expect("render ungrouped hyperlink");
1808 let grouped = layout_slide_with_fonts(&grouped_input, 0, &mut grouped_fonts)
1809 .expect("render grouped hyperlink");
1810 let plain_rect = transformed_link_rect(&plain);
1811 let grouped_rect = transformed_link_rect(&grouped);
1812
1813 assert!((grouped_rect.x - plain_rect.x - 30.0).abs() < 1.0e-10);
1814 assert!((grouped_rect.y - plain_rect.y - 40.0).abs() < 1.0e-10);
1815 assert!((grouped_rect.width - plain_rect.width).abs() < 1.0e-10);
1816 assert!((grouped_rect.height - plain_rect.height).abs() < 1.0e-10);
1817 }
1818
1819 #[test]
1820 fn banded_merged_table_renders_correct_fills_without_duplicated_borders() {
1821 let table = ResolvedTable {
1822 right_to_left: false,
1823 column_widths: vec![10.0, 10.0],
1824 rows: vec![
1825 ResolvedTableRow {
1826 height: 10.0,
1827 cells: vec![
1828 ResolvedTableCell {
1829 fill: Some(Paint::Solid(Color::from_hex("FF0000"))),
1830 text: Some(table_text("merged")),
1831 left: Some(table_border()),
1832 right: Some(table_border()),
1833 top: Some(table_border()),
1834 bottom: Some(table_border()),
1835 grid_span: 2,
1836 row_span: 1,
1837 ..ResolvedTableCell::default()
1838 },
1839 ResolvedTableCell {
1840 horizontal_merge: true,
1841 ..ResolvedTableCell::default()
1842 },
1843 ],
1844 },
1845 ResolvedTableRow {
1846 height: 10.0,
1847 cells: vec![
1848 ResolvedTableCell {
1849 fill: Some(Paint::Solid(Color::from_hex("00FF00"))),
1850 left: Some(table_border()),
1851 right: Some(table_border()),
1852 top: Some(table_border()),
1853 bottom: Some(table_border()),
1854 ..ResolvedTableCell::default()
1855 },
1856 ResolvedTableCell {
1857 fill: Some(Paint::Solid(Color::from_hex("0000FF"))),
1858 left: Some(table_border()),
1859 right: Some(table_border()),
1860 top: Some(table_border()),
1861 bottom: Some(table_border()),
1862 ..ResolvedTableCell::default()
1863 },
1864 ],
1865 },
1866 ],
1867 };
1868 let layout = layout_presentation(&render_input(vec![slide(
1869 (20.0, 20.0),
1870 vec![table_shape(table)],
1871 )]))
1872 .unwrap();
1873 let png = oxml_pdf::render_page_to_png(&layout, 0, 72.0).unwrap();
1874 let pixmap = tiny_skia::Pixmap::decode_png(&png).unwrap();
1875
1876 let red = rgb_at(&pixmap, 5, 5);
1877 assert!(red.0 > 200 && red.1 < 30 && red.2 < 30, "{red:?}");
1878 assert_eq!(rgb_at(&pixmap, 5, 15), (0, 255, 0));
1879 assert_eq!(rgb_at(&pixmap, 15, 15), (0, 0, 255));
1880 let group = only_group(&layout.pages[0].elements[0]);
1881 assert_eq!(
1882 group
1883 .children
1884 .iter()
1885 .filter(|element| matches!(element, PositionedElement::Path(path) if path.stroke.is_some()))
1886 .count(),
1887 11,
1888 "the merged top row removes its internal vertical segment"
1889 );
1890 assert!(group.children.iter().any(
1891 |element| matches!(element, PositionedElement::Text(run) if run.text == "merged")
1892 ));
1893 }
1894
1895 #[test]
1896 fn merged_continuation_cells_do_not_render_fill_border_or_text_twice() {
1897 let table = ResolvedTable {
1898 right_to_left: false,
1899 column_widths: vec![10.0, 10.0],
1900 rows: vec![ResolvedTableRow {
1901 height: 10.0,
1902 cells: vec![
1903 ResolvedTableCell {
1904 fill: Some(Paint::Solid(Color::BLACK)),
1905 grid_span: 2,
1906 ..ResolvedTableCell::default()
1907 },
1908 ResolvedTableCell {
1909 fill: Some(Paint::Solid(Color::WHITE)),
1910 horizontal_merge: true,
1911 ..ResolvedTableCell::default()
1912 },
1913 ],
1914 }],
1915 };
1916 let page = layout_slide(
1917 &render_input(vec![slide((20.0, 10.0), vec![table_shape(table)])]),
1918 0,
1919 )
1920 .unwrap();
1921 let group = only_group(&page.elements[0]);
1922
1923 assert_eq!(group.children.iter().filter(|element| matches!(element, PositionedElement::Path(path) if path.fill.is_some())).count(), 1);
1924 }
1925
1926 #[test]
1927 fn right_to_left_table_keeps_unequal_logical_column_widths() {
1928 let table = ResolvedTable {
1929 right_to_left: true,
1930 column_widths: vec![10.0, 20.0],
1931 rows: vec![ResolvedTableRow {
1932 height: 10.0,
1933 cells: vec![
1934 ResolvedTableCell {
1935 fill: Some(Paint::Solid(Color::from_hex("FF0000"))),
1936 ..ResolvedTableCell::default()
1937 },
1938 ResolvedTableCell {
1939 fill: Some(Paint::Solid(Color::from_hex("0000FF"))),
1940 ..ResolvedTableCell::default()
1941 },
1942 ],
1943 }],
1944 };
1945 let layout = layout_presentation(&render_input(vec![slide(
1946 (30.0, 10.0),
1947 vec![table_shape(table)],
1948 )]))
1949 .unwrap();
1950 let png = oxml_pdf::render_page_to_png(&layout, 0, 72.0).unwrap();
1951 let pixmap = tiny_skia::Pixmap::decode_png(&png).unwrap();
1952
1953 assert_eq!(rgb_at(&pixmap, 5, 5), (0, 0, 255));
1954 assert_eq!(rgb_at(&pixmap, 25, 5), (255, 0, 0));
1955 }
1956
1957 #[test]
1958 fn merged_table_uses_far_continuation_border() {
1959 let outer = ResolvedTableBorder {
1960 stroke: Some(Stroke::new(Paint::Solid(Color::from_hex("FF0000")), 3.0)),
1961 priority: 2,
1962 };
1963 let table = ResolvedTable {
1964 right_to_left: false,
1965 column_widths: vec![10.0, 10.0],
1966 rows: vec![ResolvedTableRow {
1967 height: 10.0,
1968 cells: vec![
1969 ResolvedTableCell {
1970 grid_span: 2,
1971 right: Some(table_border()),
1972 ..ResolvedTableCell::default()
1973 },
1974 ResolvedTableCell {
1975 horizontal_merge: true,
1976 right: Some(outer),
1977 ..ResolvedTableCell::default()
1978 },
1979 ],
1980 }],
1981 };
1982 let page = layout_slide(
1983 &render_input(vec![slide((20.0, 10.0), vec![table_shape(table)])]),
1984 0,
1985 )
1986 .unwrap();
1987 let group = only_group(&page.elements[0]);
1988 let far_border = group.children.iter().find_map(|element| {
1989 let PositionedElement::Path(path) = element else {
1990 return None;
1991 };
1992 match path.path.commands.as_slice() {
1993 [PathCommand::MoveTo(start), PathCommand::LineTo(end)]
1994 if start.x == 20.0 && end.x == 20.0 =>
1995 {
1996 path.stroke.as_ref()
1997 }
1998 _ => None,
1999 }
2000 });
2001
2002 let far_border = far_border.expect("merged far edge should be emitted");
2003 assert_eq!(far_border.width, 3.0);
2004 assert_eq!(far_border.paint, Paint::Solid(Color::from_hex("FF0000")));
2005 }
2006
2007 #[test]
2008 fn table_cell_margins_place_text_in_the_fixed_content_box() {
2009 let table = ResolvedTable {
2010 right_to_left: false,
2011 column_widths: vec![20.0],
2012 rows: vec![ResolvedTableRow {
2013 height: 20.0,
2014 cells: vec![ResolvedTableCell {
2015 text: Some(table_text("cell")),
2016 margins: TextInsets {
2017 left: 2.0,
2018 top: 3.0,
2019 right: 4.0,
2020 bottom: 5.0,
2021 },
2022 ..ResolvedTableCell::default()
2023 }],
2024 }],
2025 };
2026 let page = layout_slide(
2027 &render_input(vec![slide((20.0, 20.0), vec![table_shape(table)])]),
2028 0,
2029 )
2030 .unwrap();
2031 let group = only_group(&page.elements[0]);
2032 let PositionedElement::Text(run) = group
2033 .children
2034 .iter()
2035 .find(|element| matches!(element, PositionedElement::Text(_)))
2036 .expect("cell text should remain visible")
2037 else {
2038 unreachable!()
2039 };
2040
2041 assert!(run.origin.x >= 2.0);
2042 assert!(run.origin.y >= 3.0);
2043 }
2044
2045 fn slide(size: (f64, f64), shapes: Vec<ResolvedShape>) -> ResolvedSlide {
2046 ResolvedSlide {
2047 size,
2048 background: None,
2049 shapes,
2050 diagnostics: Vec::new(),
2051 }
2052 }
2053
2054 fn render_input(slides: Vec<ResolvedSlide>) -> RenderInput {
2055 RenderInput {
2056 slides,
2057 media: HashMap::new(),
2058 fonts: Vec::new(),
2059 metadata: None,
2060 }
2061 }
2062
2063 fn only_group(element: &PositionedElement) -> &GroupElement {
2064 let PositionedElement::Group(group) = element else {
2065 panic!("shape should lower to one group");
2066 };
2067 group
2068 }
2069
2070 #[test]
2071 fn timeline_lowering_retains_hidden_identity_slots_and_uses_shape_local_clips() {
2072 let first = shape(
2073 Rect {
2074 x: 10.0,
2075 y: 20.0,
2076 width: 40.0,
2077 height: 20.0,
2078 },
2079 ResolvedGeometry::Rectangle,
2080 Some(Paint::Solid(Color::WHITE)),
2081 None,
2082 );
2083 let second = shape(
2084 Rect {
2085 x: 60.0,
2086 y: 20.0,
2087 width: 20.0,
2088 height: 20.0,
2089 },
2090 ResolvedGeometry::Rectangle,
2091 Some(Paint::Solid(Color::BLACK)),
2092 None,
2093 );
2094 let input = render_input(vec![slide((100.0, 60.0), vec![first, second])]);
2095 let mut hidden_state = rpptx_layout::timeline::EvaluatedShapeState::default();
2096 hidden_state.visible = false;
2097 hidden_state.clip = Some(Rect {
2098 x: 0.0,
2099 y: 0.0,
2100 width: 0.25,
2101 height: 1.0,
2102 });
2103 let states = [
2104 hidden_state,
2105 rpptx_layout::timeline::EvaluatedShapeState::default(),
2106 ];
2107 let mut fonts = FontManager::new_deterministic().unwrap();
2108 let page = layout_slide_with_fonts_text_directions_and_states(
2109 &input,
2110 0,
2111 &mut fonts,
2112 None,
2113 Some(&states),
2114 )
2115 .unwrap();
2116
2117 assert_eq!(page.elements.len(), 2);
2118 let hidden = only_group(&page.elements[0]);
2119 assert_eq!(hidden.opacity, 0.0);
2120 assert_eq!(hidden.transform.e, 10.0);
2121 assert_eq!(
2122 hidden.clip,
2123 Some(Path::rect(Rect {
2124 x: 0.0,
2125 y: 0.0,
2126 width: 10.0,
2127 height: 20.0,
2128 }))
2129 );
2130 assert_eq!(only_group(&page.elements[1]).opacity, 1.0);
2131 }
2132
2133 #[test]
2134 fn resolved_outer_shadow_is_lowered_to_the_shape_group() {
2135 let effect = Effect::OuterShadow {
2136 dx: 3.0,
2137 dy: 4.0,
2138 blur: 2.0,
2139 color: Color {
2140 r: 0.5,
2141 g: 0.25,
2142 b: 0.0,
2143 a: 0.75,
2144 },
2145 };
2146 let mut shadowed = shape(
2147 Rect {
2148 x: 2.0,
2149 y: 3.0,
2150 width: 8.0,
2151 height: 6.0,
2152 },
2153 ResolvedGeometry::Rectangle,
2154 Some(Paint::Solid(Color::WHITE)),
2155 None,
2156 );
2157 shadowed.shadow = Some(effect.clone());
2158
2159 let page =
2160 layout_slide(&render_input(vec![slide((20.0, 20.0), vec![shadowed])]), 0).unwrap();
2161
2162 assert_eq!(only_group(&page.elements[0]).effects, vec![effect]);
2163 }
2164
2165 fn assert_point_close(actual: Point, expected: Point) {
2166 const EPSILON: f64 = 1.0e-10;
2167 assert!(
2168 (actual.x - expected.x).abs() < EPSILON && (actual.y - expected.y).abs() < EPSILON,
2169 "expected ({}, {}), got ({}, {})",
2170 expected.x,
2171 expected.y,
2172 actual.x,
2173 actual.y
2174 );
2175 }
2176
2177 #[test]
2178 fn rotated_shape_corners_match_hand_computed_coordinates() {
2179 let mut rotated = shape(
2180 Rect {
2181 x: 10.0,
2182 y: 20.0,
2183 width: 8.0,
2184 height: 4.0,
2185 },
2186 ResolvedGeometry::Rectangle,
2187 Some(Paint::Solid(Color::BLACK)),
2188 None,
2189 );
2190 rotated.rotation_deg = 30.0;
2191 let page = layout_slide(&render_input(vec![slide((40.0, 40.0), vec![rotated])]), 0)
2192 .expect("lower rotated shape");
2193 let transform = only_group(&page.elements[0]).transform;
2194 let radians = 30.0_f64.to_radians();
2195 let (sin, cos) = radians.sin_cos();
2196
2197 for corner in [
2198 Point { x: 0.0, y: 0.0 },
2199 Point { x: 8.0, y: 0.0 },
2200 Point { x: 0.0, y: 4.0 },
2201 Point { x: 8.0, y: 4.0 },
2202 ] {
2203 let dx = corner.x - 4.0;
2204 let dy = corner.y - 2.0;
2205 let expected = Point {
2206 x: 10.0 + 4.0 + cos * dx - sin * dy,
2207 y: 20.0 + 2.0 + sin * dx + cos * dy,
2208 };
2209 assert_point_close(transform.apply(corner), expected);
2210 }
2211 }
2212
2213 #[test]
2214 fn horizontal_and_vertical_flips_are_about_the_shape_centre() {
2215 let bounds = Rect {
2216 x: 10.0,
2217 y: 20.0,
2218 width: 8.0,
2219 height: 4.0,
2220 };
2221 let mut horizontal = shape(
2222 bounds,
2223 ResolvedGeometry::Rectangle,
2224 Some(Paint::Solid(Color::BLACK)),
2225 None,
2226 );
2227 horizontal.flip_h = true;
2228 let mut vertical = horizontal.clone();
2229 vertical.flip_h = false;
2230 vertical.flip_v = true;
2231 let page = layout_slide(
2232 &render_input(vec![slide((40.0, 40.0), vec![horizontal, vertical])]),
2233 0,
2234 )
2235 .expect("lower flipped shapes");
2236 let horizontal = only_group(&page.elements[0]).transform;
2237 let vertical = only_group(&page.elements[1]).transform;
2238
2239 assert_point_close(
2240 horizontal.apply(Point { x: 4.0, y: 2.0 }),
2241 Point { x: 14.0, y: 22.0 },
2242 );
2243 assert_point_close(
2244 horizontal.apply(Point { x: 0.0, y: 0.0 }),
2245 Point { x: 18.0, y: 20.0 },
2246 );
2247 assert_point_close(
2248 horizontal.apply(Point { x: 8.0, y: 4.0 }),
2249 Point { x: 10.0, y: 24.0 },
2250 );
2251 assert_point_close(
2252 vertical.apply(Point { x: 4.0, y: 2.0 }),
2253 Point { x: 14.0, y: 22.0 },
2254 );
2255 assert_point_close(
2256 vertical.apply(Point { x: 0.0, y: 0.0 }),
2257 Point { x: 10.0, y: 24.0 },
2258 );
2259 assert_point_close(
2260 vertical.apply(Point { x: 8.0, y: 4.0 }),
2261 Point { x: 18.0, y: 20.0 },
2262 );
2263 }
2264
2265 #[test]
2266 fn nested_group_transform_applies_child_before_parent() {
2267 let mut nested = shape(
2268 Rect {
2269 x: 10.0,
2270 y: 20.0,
2271 width: 8.0,
2272 height: 4.0,
2273 },
2274 ResolvedGeometry::Rectangle,
2275 Some(Paint::Solid(Color::BLACK)),
2276 None,
2277 );
2278 nested.rotation_deg = 90.0;
2279 nested.flip_h = true;
2280 nested.group_transform = Transform {
2281 a: 2.0,
2282 b: 0.0,
2283 c: 0.0,
2284 d: 3.0,
2285 e: 5.0,
2286 f: 7.0,
2287 };
2288 let page = layout_slide(&render_input(vec![slide((80.0, 100.0), vec![nested])]), 0)
2289 .expect("lower nested shape");
2290 let transform = only_group(&page.elements[0]).transform;
2291
2292 assert_point_close(
2293 transform.apply(Point { x: 0.0, y: 0.0 }),
2294 Point { x: 29.0, y: 61.0 },
2295 );
2296 assert_point_close(
2297 transform.apply(Point { x: 8.0, y: 4.0 }),
2298 Point { x: 37.0, y: 85.0 },
2299 );
2300 }
2301
2302 #[test]
2303 fn group_mapping_does_not_clip_a_child_outside_group_bounds() {
2304 let mut outside = shape(
2305 Rect {
2306 x: 40.0,
2307 y: 20.0,
2308 width: 8.0,
2309 height: 4.0,
2310 },
2311 ResolvedGeometry::Rectangle,
2312 Some(Paint::Solid(Color::BLACK)),
2313 None,
2314 );
2315 outside.group_transform = Transform {
2316 e: 30.0,
2317 f: 10.0,
2318 ..Transform::IDENTITY
2319 };
2320 let page = layout_slide(&render_input(vec![slide((60.0, 40.0), vec![outside])]), 0)
2321 .expect("lower grouped child outside nominal group bounds");
2322 let group = only_group(&page.elements[0]);
2323
2324 assert_eq!(group.clip, None);
2325 assert_point_close(
2326 group.transform.apply(Point { x: 0.0, y: 0.0 }),
2327 Point { x: 70.0, y: 30.0 },
2328 );
2329 }
2330
2331 #[test]
2332 fn rotated_gradient_and_outline_share_the_shape_transform() {
2333 let red = color(1.0, 0.0, 0.0);
2334 let blue = color(0.0, 0.0, 1.0);
2335 let mut rotated = shape(
2336 Rect {
2337 x: 8.0,
2338 y: 8.0,
2339 width: 12.0,
2340 height: 6.0,
2341 },
2342 ResolvedGeometry::Rectangle,
2343 Some(Paint::linear(
2344 Point { x: 0.0, y: 0.0 },
2345 Point { x: 12.0, y: 0.0 },
2346 vec![
2347 GradientStop {
2348 offset: 0.0,
2349 color: red,
2350 },
2351 GradientStop {
2352 offset: 0.49,
2353 color: red,
2354 },
2355 GradientStop {
2356 offset: 0.51,
2357 color: blue,
2358 },
2359 GradientStop {
2360 offset: 1.0,
2361 color: blue,
2362 },
2363 ],
2364 (true, true),
2365 )),
2366 Some(Stroke::new(Paint::Solid(Color::BLACK), 2.0)),
2367 );
2368 rotated.rotation_deg = 90.0;
2369 let layout = layout_presentation(&render_input(vec![slide((28.0, 24.0), vec![rotated])]))
2370 .expect("lower rotated gradient");
2371 let png =
2372 oxml_pdf::render_page_to_png(&layout, 0, 72.0).expect("rasterise rotated gradient");
2373 let pixmap = tiny_skia::Pixmap::decode_png(&png).expect("decode rotated gradient");
2374 let rgb = |x, y| {
2375 let pixel = pixmap.pixel(x, y).expect("sample lies inside page");
2376 (pixel.red(), pixel.green(), pixel.blue())
2377 };
2378
2379 assert_eq!(rgb(14, 7), (255, 0, 0));
2380 assert_eq!(rgb(14, 15), (0, 0, 255));
2381 assert_eq!(rgb(11, 11), (0, 0, 0));
2382 assert_eq!(rgb(8, 8), (255, 255, 255));
2383 }
2384
2385 #[test]
2386 fn solid_gradient_and_outlined_shapes_rasterise_at_sampled_pixels() {
2387 let red = color(1.0, 0.0, 0.0);
2388 let blue = color(0.0, 0.0, 1.0);
2389 let green = color(0.0, 1.0, 0.0);
2390 let gradient = Paint::linear(
2391 Point { x: 0.0, y: 0.0 },
2392 Point { x: 8.0, y: 0.0 },
2393 vec![
2394 GradientStop {
2395 offset: 0.0,
2396 color: red,
2397 },
2398 GradientStop {
2399 offset: 0.49,
2400 color: red,
2401 },
2402 GradientStop {
2403 offset: 0.51,
2404 color: blue,
2405 },
2406 GradientStop {
2407 offset: 1.0,
2408 color: blue,
2409 },
2410 ],
2411 (true, true),
2412 );
2413 let input = render_input(vec![slide(
2414 (40.0, 14.0),
2415 vec![
2416 shape(
2417 Rect {
2418 x: 2.0,
2419 y: 2.0,
2420 width: 8.0,
2421 height: 8.0,
2422 },
2423 ResolvedGeometry::Rectangle,
2424 Some(Paint::Solid(red)),
2425 None,
2426 ),
2427 shape(
2428 Rect {
2429 x: 14.0,
2430 y: 2.0,
2431 width: 8.0,
2432 height: 8.0,
2433 },
2434 ResolvedGeometry::Rectangle,
2435 Some(gradient),
2436 None,
2437 ),
2438 shape(
2439 Rect {
2440 x: 26.0,
2441 y: 2.0,
2442 width: 8.0,
2443 height: 8.0,
2444 },
2445 ResolvedGeometry::Rectangle,
2446 None,
2447 Some(Stroke::new(Paint::Solid(green), 2.0)),
2448 ),
2449 ],
2450 )]);
2451
2452 let layout = layout_presentation(&input).expect("lower shape slide");
2453 let png = oxml_pdf::render_page_to_png(&layout, 0, 72.0).expect("rasterise shape slide");
2454 let pixmap = tiny_skia::Pixmap::decode_png(&png).expect("decode shape slide");
2455 let rgb = |x, y| {
2456 let pixel = pixmap.pixel(x, y).expect("sample lies inside page");
2457 (pixel.red(), pixel.green(), pixel.blue())
2458 };
2459
2460 assert_eq!(rgb(5, 5), (255, 0, 0));
2461 assert_eq!(rgb(15, 5), (255, 0, 0));
2462 assert_eq!(rgb(20, 5), (0, 0, 255));
2463 assert_eq!(rgb(26, 5), (0, 255, 0));
2464 assert_eq!(rgb(30, 5), (255, 255, 255));
2465 assert_eq!(rgb(38, 5), (255, 255, 255));
2466 }
2467
2468 #[test]
2469 fn preset_and_custom_geometry_lower_to_ordered_paths() {
2470 let first = Path {
2471 commands: vec![
2472 PathCommand::MoveTo(Point { x: 0.0, y: 0.0 }),
2473 PathCommand::LineTo(Point { x: 4.0, y: 0.0 }),
2474 ],
2475 fill_rule: FillRule::NonZero,
2476 };
2477 let second = Path {
2478 commands: vec![
2479 PathCommand::MoveTo(Point { x: 0.0, y: 1.0 }),
2480 PathCommand::LineTo(Point { x: 4.0, y: 1.0 }),
2481 ],
2482 fill_rule: FillRule::EvenOdd,
2483 };
2484 let fill = Paint::Solid(Color::BLACK);
2485 let line = Stroke::new(Paint::Solid(Color::WHITE), 2.0);
2486 let input = render_input(vec![slide(
2487 (20.0, 20.0),
2488 vec![
2489 shape(
2490 Rect {
2491 x: 2.0,
2492 y: 3.0,
2493 width: 4.0,
2494 height: 5.0,
2495 },
2496 ResolvedGeometry::Rectangle,
2497 Some(fill.clone()),
2498 Some(line.clone()),
2499 ),
2500 shape(
2501 Rect {
2502 x: 8.0,
2503 y: 9.0,
2504 width: 4.0,
2505 height: 5.0,
2506 },
2507 ResolvedGeometry::Custom {
2508 paths: vec![first.clone(), second.clone()],
2509 text_rect: None,
2510 },
2511 Some(fill.clone()),
2512 Some(line.clone()),
2513 ),
2514 ],
2515 )]);
2516
2517 let page = layout_slide(&input, 0).expect("lower first slide");
2518 assert_eq!(page.elements.len(), 2);
2519 let rectangle = only_group(&page.elements[0]);
2520 assert_eq!(
2521 rectangle.transform,
2522 Transform {
2523 e: 2.0,
2524 f: 3.0,
2525 ..Transform::IDENTITY
2526 }
2527 );
2528 let PositionedElement::Path(rectangle) = &rectangle.children[0] else {
2529 panic!("rectangle should lower to a path");
2530 };
2531 assert_eq!(
2532 rectangle.path,
2533 Path::rect(Rect {
2534 x: 0.0,
2535 y: 0.0,
2536 width: 4.0,
2537 height: 5.0
2538 })
2539 );
2540 assert_eq!(rectangle.fill, Some(fill.clone()));
2541 assert_eq!(rectangle.stroke, Some(line.clone()));
2542
2543 let custom = only_group(&page.elements[1]);
2544 assert_eq!(custom.children.len(), 2);
2545 for (element, expected) in custom.children.iter().zip([first, second]) {
2546 let PositionedElement::Path(element) = element else {
2547 panic!("custom geometry should lower to paths");
2548 };
2549 assert_eq!(element.path, expected);
2550 assert_eq!(element.fill, Some(fill.clone()));
2551 assert_eq!(element.stroke, Some(line.clone()));
2552 }
2553 }
2554
2555 #[test]
2556 fn bounds_fallback_emits_a_visible_black_outline() {
2557 let input = render_input(vec![slide(
2558 (20.0, 20.0),
2559 vec![shape(
2560 Rect {
2561 x: 2.0,
2562 y: 3.0,
2563 width: 4.0,
2564 height: 5.0,
2565 },
2566 ResolvedGeometry::BoundsFallback,
2567 None,
2568 None,
2569 )],
2570 )]);
2571
2572 let page = layout_slide(&input, 0).expect("lower fallback slide");
2573 let group = only_group(&page.elements[0]);
2574 let PositionedElement::Path(path) = &group.children[0] else {
2575 panic!("fallback should lower to a path");
2576 };
2577 assert_eq!(path.fill, None);
2578 assert_eq!(
2579 path.stroke,
2580 Some(Stroke::new(Paint::Solid(Color::BLACK), 1.0))
2581 );
2582 }
2583
2584 #[test]
2585 fn triangular_tail_end_emits_an_extra_filled_path() {
2586 let paint = Paint::Solid(color(1.0, 0.0, 0.0));
2587 let mut arrow = shape(
2588 Rect {
2589 x: 2.0,
2590 y: 3.0,
2591 width: 10.0,
2592 height: 10.0,
2593 },
2594 ResolvedGeometry::Custom {
2595 paths: vec![open_line(
2596 Point { x: 0.0, y: 5.0 },
2597 Point { x: 10.0, y: 5.0 },
2598 )],
2599 text_rect: None,
2600 },
2601 None,
2602 Some(Stroke::new(paint.clone(), 2.0)),
2603 );
2604 arrow.tail_end = Some(line_end(ResolvedLineEndKind::Triangle));
2605
2606 let layout = layout_presentation(&render_input(vec![slide((20.0, 20.0), vec![arrow])]))
2607 .expect("lower triangular tail");
2608 let group = only_group(&layout.pages[0].elements[0]);
2609 assert_eq!(group.children.len(), 2);
2610 let PositionedElement::Path(end) = &group.children[1] else {
2611 panic!("tail end should lower to a path");
2612 };
2613 assert_eq!(end.fill, Some(paint));
2614 assert_eq!(end.stroke, None);
2615 assert!(matches!(end.path.commands.last(), Some(PathCommand::Close)));
2616 assert_eq!(
2617 end.path.commands.first(),
2618 Some(&PathCommand::MoveTo(Point { x: 10.0, y: 5.0 }))
2619 );
2620
2621 let png =
2622 oxml_pdf::render_page_to_png(&layout, 0, 72.0).expect("rasterise triangular tail");
2623 let pixmap = tiny_skia::Pixmap::decode_png(&png).expect("decode triangular tail");
2624 let endpoint_pixel = pixmap.pixel(7, 6).expect("sample lies inside page");
2625 assert_eq!(
2626 (
2627 endpoint_pixel.red(),
2628 endpoint_pixel.green(),
2629 endpoint_pixel.blue()
2630 ),
2631 (255, 0, 0)
2632 );
2633 }
2634
2635 #[test]
2636 fn head_end_uses_the_reversed_start_tangent() {
2637 let mut arrow = shape(
2638 Rect {
2639 x: 0.0,
2640 y: 0.0,
2641 width: 20.0,
2642 height: 10.0,
2643 },
2644 ResolvedGeometry::Custom {
2645 paths: vec![open_line(
2646 Point { x: 5.0, y: 5.0 },
2647 Point { x: 15.0, y: 5.0 },
2648 )],
2649 text_rect: None,
2650 },
2651 None,
2652 Some(Stroke::new(Paint::Solid(Color::BLACK), 2.0)),
2653 );
2654 arrow.head_end = Some(line_end(ResolvedLineEndKind::Triangle));
2655 arrow.tail_end = Some(line_end(ResolvedLineEndKind::Triangle));
2656
2657 let page = layout_slide(&render_input(vec![slide((20.0, 10.0), vec![arrow])]), 0)
2658 .expect("lower opposite ends");
2659 let group = only_group(&page.elements[0]);
2660 let PositionedElement::Path(head) = &group.children[1] else {
2661 panic!("head end should be a path");
2662 };
2663 let PositionedElement::Path(tail) = &group.children[2] else {
2664 panic!("tail end should be a path");
2665 };
2666 assert_eq!(
2667 head.path.commands[1],
2668 PathCommand::LineTo(Point { x: 11.0, y: 2.0 })
2669 );
2670 assert_eq!(
2671 tail.path.commands[1],
2672 PathCommand::LineTo(Point { x: 9.0, y: 8.0 })
2673 );
2674 }
2675
2676 #[test]
2677 fn all_supported_line_end_kinds_produce_finite_geometry() {
2678 let tangent = EndpointTangent {
2679 point: Point { x: 10.0, y: 5.0 },
2680 outward: Point { x: 1.0, y: 0.0 },
2681 };
2682 for kind in [
2683 ResolvedLineEndKind::Triangle,
2684 ResolvedLineEndKind::Stealth,
2685 ResolvedLineEndKind::Diamond,
2686 ResolvedLineEndKind::Oval,
2687 ResolvedLineEndKind::Arrow,
2688 ] {
2689 let path = line_end_path(&line_end(kind), tangent, 2.0)
2690 .expect("supported endpoint should produce geometry");
2691 assert!(path_is_finite(&path));
2692 assert!(matches!(path.commands.last(), Some(PathCommand::Close)));
2693 let bounds = path.bounds().expect("endpoint should have bounds");
2694 assert!(bounds.x >= 4.0 && bounds.x + bounds.width <= 10.0);
2695 assert!(bounds.y >= 2.0 && bounds.y + bounds.height <= 8.0);
2696 }
2697 assert_eq!(line_end_factor(ResolvedLineEndSize::Small), 2.0);
2698 assert_eq!(line_end_factor(ResolvedLineEndSize::Medium), 3.0);
2699 assert_eq!(line_end_factor(ResolvedLineEndSize::Large), 5.0);
2700 }
2701
2702 #[test]
2703 fn zero_length_segment_omits_arrowhead_without_panicking() {
2704 let point = Point { x: 5.0, y: 5.0 };
2705 let mut arrow = shape(
2706 Rect {
2707 x: 0.0,
2708 y: 0.0,
2709 width: 10.0,
2710 height: 10.0,
2711 },
2712 ResolvedGeometry::Custom {
2713 paths: vec![open_line(point, point)],
2714 text_rect: None,
2715 },
2716 None,
2717 Some(Stroke::new(Paint::Solid(Color::BLACK), 2.0)),
2718 );
2719 arrow.tail_end = Some(line_end(ResolvedLineEndKind::Triangle));
2720
2721 let page = layout_slide(&render_input(vec![slide((10.0, 10.0), vec![arrow])]), 0)
2722 .expect("lower zero-length line");
2723 assert_eq!(only_group(&page.elements[0]).children.len(), 1);
2724 }
2725
2726 #[test]
2727 fn cropped_picture_renders_only_its_crop_region() {
2728 let png = horizontal_png(&[
2729 [255, 0, 0, 255],
2730 [0, 255, 0, 255],
2731 [0, 255, 0, 255],
2732 [0, 255, 0, 255],
2733 ]);
2734 let media_id = MediaId::from_bytes(&png);
2735 let picture = picture_shape(
2736 Rect {
2737 x: 2.0,
2738 y: 2.0,
2739 width: 8.0,
2740 height: 4.0,
2741 },
2742 media_id,
2743 Some(CropRect {
2744 left: 0.25,
2745 ..CropRect::default()
2746 }),
2747 ResolvedImagePlacement::default(),
2748 None,
2749 );
2750 let input = render_input_with_media(
2751 vec![slide((12.0, 8.0), vec![picture])],
2752 HashMap::from([(media_id, media(&png))]),
2753 );
2754
2755 let layout = layout_presentation(&input).expect("lower cropped picture");
2756 let rendered =
2757 oxml_pdf::render_page_to_png(&layout, 0, 72.0).expect("rasterise cropped picture");
2758 let pixmap = tiny_skia::Pixmap::decode_png(&rendered).expect("decode cropped picture");
2759 let rgb = |x, y| {
2760 let pixel = pixmap.pixel(x, y).expect("sample lies inside page");
2761 (pixel.red(), pixel.green(), pixel.blue())
2762 };
2763
2764 assert_eq!(rgb(3, 4), (0, 255, 0));
2765 assert_eq!(rgb(8, 4), (0, 255, 0));
2766 assert_eq!(rgb(1, 4), (255, 255, 255));
2767 }
2768
2769 #[test]
2770 fn crop_lowers_to_clipped_source_image_geometry() {
2771 let media_id = MediaId(21);
2772 let mut picture = picture_shape(
2773 Rect {
2774 x: 2.0,
2775 y: 3.0,
2776 width: 10.0,
2777 height: 8.0,
2778 },
2779 media_id,
2780 Some(CropRect {
2781 left: 0.25,
2782 right: 0.25,
2783 ..CropRect::default()
2784 }),
2785 ResolvedImagePlacement::default(),
2786 None,
2787 );
2788 picture.line = Some(Stroke::new(Paint::Solid(Color::BLACK), 1.0));
2789 let input = render_input_with_media(
2790 vec![slide((20.0, 20.0), vec![picture])],
2791 HashMap::from([(media_id, media(b"image"))]),
2792 );
2793
2794 let page = layout_slide(&input, 0).expect("lower crop geometry");
2795 let shape_group = only_group(&page.elements[0]);
2796 assert_eq!(shape_group.children.len(), 2, "outline must follow picture");
2797 let crop_clip = only_group(&shape_group.children[0]);
2798 assert_eq!(
2799 crop_clip.clip,
2800 Some(Path::rect(Rect {
2801 x: 0.0,
2802 y: 0.0,
2803 width: 10.0,
2804 height: 8.0,
2805 }))
2806 );
2807 let PositionedElement::Image {
2808 rect, media_id: id, ..
2809 } = &crop_clip.children[0]
2810 else {
2811 panic!("crop group should contain the expanded source image");
2812 };
2813 assert_eq!(*id, media_id);
2814 assert_eq!(
2815 *rect,
2816 Rect {
2817 x: -5.0,
2818 y: 0.0,
2819 width: 20.0,
2820 height: 8.0,
2821 }
2822 );
2823 let PositionedElement::Path(outline) = &shape_group.children[1] else {
2824 panic!("picture outline should remain above image content");
2825 };
2826 assert!(outline.stroke.is_some());
2827 }
2828
2829 #[test]
2830 fn shape_picture_fill_is_clipped_below_stroke_and_text() {
2831 let media_id = MediaId(22);
2832 let clip_path = Path {
2833 commands: vec![
2834 PathCommand::MoveTo(Point { x: 0.0, y: 0.0 }),
2835 PathCommand::LineTo(Point { x: 10.0, y: 0.0 }),
2836 PathCommand::LineTo(Point { x: 5.0, y: 10.0 }),
2837 PathCommand::Close,
2838 ],
2839 fill_rule: FillRule::NonZero,
2840 };
2841 let mut filled = shape(
2842 Rect {
2843 x: 0.0,
2844 y: 0.0,
2845 width: 10.0,
2846 height: 10.0,
2847 },
2848 ResolvedGeometry::Custom {
2849 paths: vec![clip_path.clone()],
2850 text_rect: None,
2851 },
2852 None,
2853 Some(Stroke::new(Paint::Solid(Color::BLACK), 1.0)),
2854 );
2855 filled.image_fill = Some(ResolvedImage {
2856 media: media_id,
2857 src_rect: None,
2858 placement: ResolvedImagePlacement::default(),
2859 dpi: None,
2860 rotate_with_shape: true,
2861 });
2862 filled.content = ResolvedContent::Text(table_text("caption"));
2863
2864 let mut fallback = shape(
2865 Rect {
2866 x: 10.0,
2867 y: 0.0,
2868 width: 10.0,
2869 height: 10.0,
2870 },
2871 ResolvedGeometry::BoundsFallback,
2872 None,
2873 None,
2874 );
2875 fallback.image_fill = filled.image_fill.clone();
2876 let input = render_input_with_media(
2877 vec![slide((20.0, 10.0), vec![filled, fallback])],
2878 HashMap::from([(media_id, media(b"image"))]),
2879 );
2880
2881 let page = layout_slide(&input, 0).expect("lower shape picture fill");
2882 let filled_group = only_group(&page.elements[0]);
2883 assert!(
2884 filled_group.children.len() > 2,
2885 "text must follow image and stroke"
2886 );
2887 let image_clip = only_group(&filled_group.children[0]);
2888 assert_eq!(image_clip.clip, Some(clip_path));
2889 assert!(matches!(
2890 image_clip.children.as_slice(),
2891 [PositionedElement::Image { media_id: id, .. }] if *id == media_id
2892 ));
2893 let PositionedElement::Path(outline) = &filled_group.children[1] else {
2894 panic!("shape stroke must follow its image fill");
2895 };
2896 assert!(outline.fill.is_none());
2897 assert!(outline.stroke.is_some());
2898
2899 let fallback_group = only_group(&page.elements[1]);
2900 let PositionedElement::Path(bounds) = &fallback_group.children[1] else {
2901 panic!("bounds path must follow its image fill");
2902 };
2903 assert!(
2904 bounds.stroke.is_none(),
2905 "image fill suppresses synthetic border"
2906 );
2907 }
2908
2909 #[test]
2910 fn tile_picture_repeats_media_in_row_major_order_inside_shape_clip() {
2911 let png = horizontal_png(&[[255, 0, 0, 255]]);
2912 let media_id = MediaId::from_bytes(&png);
2913 let picture = picture_shape(
2914 Rect {
2915 x: 2.0,
2916 y: 2.0,
2917 width: 3.0,
2918 height: 2.0,
2919 },
2920 media_id,
2921 None,
2922 ResolvedImagePlacement::Tile(ResolvedTilePlacement {
2923 translation: Point { x: 0.0, y: 0.0 },
2924 scale_x: 1.0,
2925 scale_y: 1.0,
2926 flip: ResolvedTileFlip::None,
2927 alignment: ResolvedRectAlignment::TopLeft,
2928 }),
2929 Some(72.0),
2930 );
2931 let input = render_input_with_media(
2932 vec![slide((8.0, 6.0), vec![picture])],
2933 HashMap::from([(media_id, media(&png))]),
2934 );
2935
2936 let layout = layout_presentation(&input).expect("lower tiled picture");
2937 let shape_group = only_group(&layout.pages[0].elements[0]);
2938 let tiles = only_group(&shape_group.children[0]);
2939 let rects = tiles
2940 .children
2941 .iter()
2942 .map(|tile| match tile {
2943 PositionedElement::Image { rect, .. } => *rect,
2944 _ => panic!("unflipped tile should be one image"),
2945 })
2946 .collect::<Vec<_>>();
2947 assert_eq!(
2948 rects,
2949 [
2950 Rect {
2951 x: 0.0,
2952 y: 0.0,
2953 width: 1.0,
2954 height: 1.0
2955 },
2956 Rect {
2957 x: 1.0,
2958 y: 0.0,
2959 width: 1.0,
2960 height: 1.0
2961 },
2962 Rect {
2963 x: 2.0,
2964 y: 0.0,
2965 width: 1.0,
2966 height: 1.0
2967 },
2968 Rect {
2969 x: 0.0,
2970 y: 1.0,
2971 width: 1.0,
2972 height: 1.0
2973 },
2974 Rect {
2975 x: 1.0,
2976 y: 1.0,
2977 width: 1.0,
2978 height: 1.0
2979 },
2980 Rect {
2981 x: 2.0,
2982 y: 1.0,
2983 width: 1.0,
2984 height: 1.0
2985 },
2986 ]
2987 );
2988 let rendered =
2989 oxml_pdf::render_page_to_png(&layout, 0, 72.0).expect("rasterise tiled picture");
2990 let pixmap = tiny_skia::Pixmap::decode_png(&rendered).expect("decode tiled picture");
2991 assert_eq!(rgb_at(&pixmap, 3, 3), (255, 0, 0));
2992 assert_eq!(rgb_at(&pixmap, 1, 3), (255, 255, 255));
2993 assert_eq!(rgb_at(&pixmap, 5, 3), (255, 255, 255));
2994
2995 assert_eq!(
2996 tile_alignment_origin(
2997 Rect {
2998 x: 0.0,
2999 y: 0.0,
3000 width: 3.0,
3001 height: 2.0,
3002 },
3003 1.0,
3004 1.0,
3005 ResolvedRectAlignment::BottomRight,
3006 ),
3007 Point { x: 2.0, y: 1.0 }
3008 );
3009 assert_eq!(repeated_origin(3.5, 0.0, 1.0), -0.5);
3010 assert_eq!(repeated_origin(0.0, -2.1, 1.0), -3.0);
3011 assert_eq!(repeated_tile_index(-0.5, 2.5, 1.0, media_id).unwrap(), -3);
3012 let flipped = flip_tile(
3013 picture_image(media_id, input.media.get(&media_id).unwrap(), rects[4]),
3014 rects[4],
3015 ResolvedTileFlip::Both,
3016 true,
3017 true,
3018 );
3019 let flipped = only_group(&flipped);
3020 assert_eq!(
3021 flipped.transform,
3022 Transform {
3023 a: -1.0,
3024 b: 0.0,
3025 c: 0.0,
3026 d: -1.0,
3027 e: 3.0,
3028 f: 3.0,
3029 }
3030 );
3031 }
3032
3033 #[test]
3034 fn picture_rotation_policy_counter_rotates_only_image_content() {
3035 let png = horizontal_png(&[[255, 0, 0, 255]]);
3036 let media_id = MediaId::from_bytes(&png);
3037 let mut picture = picture_shape(
3038 Rect {
3039 x: 5.0,
3040 y: 5.0,
3041 width: 10.0,
3042 height: 10.0,
3043 },
3044 media_id,
3045 None,
3046 ResolvedImagePlacement::default(),
3047 None,
3048 );
3049 picture.rotation_deg = 45.0;
3050 let ResolvedContent::Image(image) = &mut picture.content else {
3051 unreachable!();
3052 };
3053 image.rotate_with_shape = false;
3054 let mut tiled_picture = picture_shape(
3055 Rect {
3056 x: 25.0,
3057 y: 5.0,
3058 width: 10.0,
3059 height: 10.0,
3060 },
3061 media_id,
3062 None,
3063 ResolvedImagePlacement::Tile(ResolvedTilePlacement {
3064 translation: Point { x: 0.0, y: 0.0 },
3065 scale_x: 1.0,
3066 scale_y: 1.0,
3067 flip: ResolvedTileFlip::None,
3068 alignment: ResolvedRectAlignment::TopLeft,
3069 }),
3070 Some(72.0),
3071 );
3072 tiled_picture.rotation_deg = 45.0;
3073 let ResolvedContent::Image(image) = &mut tiled_picture.content else {
3074 unreachable!();
3075 };
3076 image.rotate_with_shape = false;
3077 let input = render_input_with_media(
3078 vec![slide((40.0, 20.0), vec![picture, tiled_picture])],
3079 HashMap::from([(media_id, media(&png))]),
3080 );
3081
3082 let layout = layout_presentation(&input).expect("lower picture rotation policy");
3083 let shape = only_group(&layout.pages[0].elements[0]);
3084 let clip = only_group(&shape.children[0]);
3085 assert_eq!(
3086 clip.clip,
3087 Some(Path::rect(local_shape_rect(&input.slides[0].shapes[0])))
3088 );
3089 let image = only_group(&clip.children[0]);
3090 assert_eq!(image.transform, Transform::rotate_about(-45.0, 5.0, 5.0));
3091 assert!(matches!(
3092 image.children.as_slice(),
3093 [PositionedElement::Image { .. }]
3094 ));
3095
3096 let rendered =
3097 oxml_pdf::render_page_to_png(&layout, 0, 72.0).expect("rasterise picture rotation");
3098 let pixmap = tiny_skia::Pixmap::decode_png(&rendered).expect("decode picture rotation");
3099 assert_eq!(rgb_at(&pixmap, 10, 4), (255, 0, 0));
3100 assert_eq!(rgb_at(&pixmap, 4, 4), (255, 255, 255));
3101 assert_eq!(rgb_at(&pixmap, 30, 4), (255, 0, 0));
3102 assert_eq!(rgb_at(&pixmap, 24, 4), (255, 255, 255));
3103 }
3104
3105 #[test]
3106 fn tile_dpi_prefers_declared_then_embedded_then_96() {
3107 let embedded = oxml_media::ImageInfo {
3108 format: oxml_media::ImageFormat::Png,
3109 width_px: 144,
3110 height_px: 72,
3111 dpi_x: Some(72.0),
3112 dpi_y: Some(72.0),
3113 bit_depth: 8,
3114 channels: 4,
3115 has_alpha: true,
3116 };
3117 assert_eq!(
3118 tile_native_size_points(embedded, Some(144.0), MediaId(1)).unwrap(),
3119 (72.0, 36.0)
3120 );
3121 assert_eq!(
3122 tile_native_size_points(embedded, None, MediaId(1)).unwrap(),
3123 (144.0, 72.0)
3124 );
3125 assert_eq!(
3126 tile_native_size_points(
3127 oxml_media::ImageInfo {
3128 dpi_x: None,
3129 dpi_y: None,
3130 ..embedded
3131 },
3132 None,
3133 MediaId(1),
3134 )
3135 .unwrap(),
3136 (108.0, 54.0)
3137 );
3138 }
3139
3140 #[test]
3141 fn equal_picture_bytes_reuse_one_media_id_across_elements() {
3142 let bytes = b"same picture";
3143 let media_id = MediaId::from_bytes(bytes);
3144 let pictures = [0.0, 5.0]
3145 .map(|x| {
3146 picture_shape(
3147 Rect {
3148 x,
3149 y: 0.0,
3150 width: 4.0,
3151 height: 4.0,
3152 },
3153 media_id,
3154 None,
3155 ResolvedImagePlacement::default(),
3156 None,
3157 )
3158 })
3159 .to_vec();
3160 let input = render_input_with_media(
3161 vec![slide((10.0, 5.0), pictures)],
3162 HashMap::from([(media_id, media(bytes))]),
3163 );
3164
3165 let layout = layout_presentation(&input).expect("lower repeated picture media");
3166 let mut ids = Vec::new();
3167 collect_image_ids(&layout.pages[0].elements, &mut ids);
3168 assert_eq!(ids, [media_id, media_id]);
3169 assert_eq!(input.media.len(), 1);
3170 }
3171
3172 #[test]
3173 fn missing_external_media_and_empty_crop_are_contextual() {
3174 let missing_id = MediaId(404);
3175 let missing = picture_shape(
3176 Rect {
3177 x: 0.0,
3178 y: 0.0,
3179 width: 4.0,
3180 height: 4.0,
3181 },
3182 missing_id,
3183 None,
3184 ResolvedImagePlacement::default(),
3185 None,
3186 );
3187 assert_eq!(
3188 layout_slide(&render_input(vec![slide((5.0, 5.0), vec![missing])]), 0).unwrap_err(),
3189 RenderInputError::MissingMedia { media: missing_id }
3190 );
3191
3192 let media_id = MediaId(405);
3193 let empty = picture_shape(
3194 Rect {
3195 x: 0.0,
3196 y: 0.0,
3197 width: 4.0,
3198 height: 4.0,
3199 },
3200 media_id,
3201 Some(CropRect {
3202 left: 0.5,
3203 right: 0.5,
3204 ..CropRect::default()
3205 }),
3206 ResolvedImagePlacement::default(),
3207 None,
3208 );
3209 let input = render_input_with_media(
3210 vec![slide((5.0, 5.0), vec![empty])],
3211 HashMap::from([(media_id, media(b"image"))]),
3212 );
3213 assert_eq!(
3214 layout_slide(&input, 0).unwrap_err(),
3215 RenderInputError::InvalidPicture {
3216 media: media_id,
3217 detail: "source crop",
3218 }
3219 );
3220
3221 let png = horizontal_png(&[[0, 0, 0, 255]]);
3222 let media_id = MediaId::from_bytes(&png);
3223 let excessive = picture_shape(
3224 Rect {
3225 x: 0.0,
3226 y: 0.0,
3227 width: 10.0,
3228 height: 10.0,
3229 },
3230 media_id,
3231 None,
3232 ResolvedImagePlacement::Tile(ResolvedTilePlacement {
3233 translation: Point { x: 0.0, y: 0.0 },
3234 scale_x: 0.000_1,
3235 scale_y: 0.000_1,
3236 flip: ResolvedTileFlip::None,
3237 alignment: ResolvedRectAlignment::TopLeft,
3238 }),
3239 Some(72.0),
3240 );
3241 let input = render_input_with_media(
3242 vec![slide((10.0, 10.0), vec![excessive])],
3243 HashMap::from([(media_id, media(&png))]),
3244 );
3245 assert!(matches!(
3246 layout_slide(&input, 0),
3247 Err(RenderInputError::TileLimitExceeded { media, .. }) if media == media_id
3248 ));
3249 }
3250
3251 fn picture_shape(
3252 bounds: Rect,
3253 media: MediaId,
3254 src_rect: Option<CropRect>,
3255 placement: ResolvedImagePlacement,
3256 dpi: Option<f64>,
3257 ) -> ResolvedShape {
3258 let mut picture = shape(bounds, ResolvedGeometry::Rectangle, None, None);
3259 picture.content = ResolvedContent::Image(ResolvedImage {
3260 media,
3261 src_rect,
3262 placement,
3263 dpi,
3264 rotate_with_shape: true,
3265 });
3266 picture
3267 }
3268
3269 fn render_input_with_media(
3270 slides: Vec<ResolvedSlide>,
3271 media: HashMap<MediaId, MediaData>,
3272 ) -> RenderInput {
3273 RenderInput {
3274 slides,
3275 media,
3276 fonts: Vec::new(),
3277 metadata: None,
3278 }
3279 }
3280
3281 fn horizontal_png(colors: &[[u8; 4]]) -> Vec<u8> {
3282 let mut pixmap = tiny_skia::Pixmap::new(colors.len() as u32, 1).expect("fixture pixmap");
3283 for (pixel, color) in pixmap.pixels_mut().iter_mut().zip(colors) {
3284 *pixel =
3285 tiny_skia::PremultipliedColorU8::from_rgba(color[0], color[1], color[2], color[3])
3286 .expect("premultiplied fixture colour");
3287 }
3288 pixmap.encode_png().expect("encode fixture PNG")
3289 }
3290
3291 fn rgb_at(pixmap: &tiny_skia::Pixmap, x: u32, y: u32) -> (u8, u8, u8) {
3292 let pixel = pixmap.pixel(x, y).expect("sample lies inside page");
3293 (pixel.red(), pixel.green(), pixel.blue())
3294 }
3295
3296 fn collect_image_ids(elements: &[PositionedElement], ids: &mut Vec<MediaId>) {
3297 for element in elements {
3298 match element {
3299 PositionedElement::Image { media_id, .. } => ids.push(*media_id),
3300 PositionedElement::Group(group) => collect_image_ids(&group.children, ids),
3301 _ => {}
3302 }
3303 }
3304 }
3305
3306 fn open_line(from: Point, to: Point) -> Path {
3307 Path {
3308 commands: vec![PathCommand::MoveTo(from), PathCommand::LineTo(to)],
3309 fill_rule: FillRule::NonZero,
3310 }
3311 }
3312
3313 fn line_end(kind: ResolvedLineEndKind) -> ResolvedLineEnd {
3314 ResolvedLineEnd {
3315 kind,
3316 width: ResolvedLineEndSize::Medium,
3317 length: ResolvedLineEndSize::Medium,
3318 }
3319 }
3320
3321 #[test]
3322 fn layout_slide_rejects_an_out_of_range_index() {
3323 let input = render_input(vec![slide((20.0, 10.0), Vec::new())]);
3324
3325 assert_eq!(
3326 layout_slide(&input, 4).unwrap_err(),
3327 RenderInputError::SlideIndexOutOfBounds {
3328 index: 4,
3329 slide_count: 1,
3330 }
3331 );
3332 }
3333
3334 #[test]
3335 fn resolved_rtl_numeric_forced_breaks_reach_rich_line_layout() {
3336 const P_NS: &str = "http://schemas.openxmlformats.org/presentationml/2006/main";
3337 const A_NS: &str = "http://schemas.openxmlformats.org/drawingml/2006/main";
3338 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>"#;
3339 let slide = CT_Slide::from_xml(
3340 format!(
3341 "<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>"
3342 )
3343 .as_bytes(),
3344 )
3345 .unwrap();
3346 let empty_tree = "<p:spTree><p:nvGrpSpPr/><p:grpSpPr/></p:spTree>";
3347 let layout = CT_SlideLayout::from_xml(
3348 format!(
3349 "<p:sldLayout xmlns:p=\"{P_NS}\" xmlns:a=\"{A_NS}\"><p:cSld>{empty_tree}</p:cSld></p:sldLayout>"
3350 )
3351 .as_bytes(),
3352 )
3353 .unwrap();
3354 let master = CT_SlideMaster::from_xml(
3355 format!(
3356 "<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>"
3357 )
3358 .as_bytes(),
3359 )
3360 .unwrap();
3361 let theme = CT_OfficeStyleSheet::office_default();
3362 let default_text_style = CT_TextListStyle::default();
3363 let media = rpptx_layout::ScopedMediaIds::default();
3364 let hyperlinks = rpptx_layout::ScopedHyperlinkTargets::default();
3365 let charts = rpptx_layout::ScopedChartResources::default();
3366 let mut fonts = FontManager::new_deterministic().expect("deterministic fonts");
3367 let (resolved, directions) = ResolveCtx::new(
3368 &theme,
3369 ColorMap::default(),
3370 &master,
3371 &layout,
3372 &slide,
3373 &default_text_style,
3374 )
3375 .resolve_slide_with_chart_resources_and_text_directions(
3376 (720.0, 144.0),
3377 &media,
3378 &hyperlinks,
3379 &charts,
3380 &mut fonts,
3381 )
3382 .unwrap();
3383 let rendered = layout_presentation_with_font_manager_and_text_directions(
3384 &render_input(vec![resolved]),
3385 fonts,
3386 &[directions],
3387 )
3388 .unwrap();
3389 let mut runs = Vec::new();
3390 walk(&rendered.pages[0].elements, &mut |element, _| {
3391 if let PositionedElement::MultilingualText(run) = element {
3392 runs.push((run.logical_text.clone(), run.bidi_level, run.origin.y));
3393 }
3394 });
3395
3396 assert_eq!(
3397 runs.iter()
3398 .map(|(text, level, _)| (text.as_str(), *level))
3399 .collect::<Vec<_>>(),
3400 [("123", 2), ("456", 2)]
3401 );
3402 assert!(runs[0].2 < runs[1].2);
3403 }
3404
3405 #[test]
3406 fn master_gradient_background_renders_when_slide_and_layout_omit_one() {
3407 const P_NS: &str = "http://schemas.openxmlformats.org/presentationml/2006/main";
3408 const A_NS: &str = "http://schemas.openxmlformats.org/drawingml/2006/main";
3409 let shape_tree = "<p:spTree><p:nvGrpSpPr/><p:grpSpPr/></p:spTree>";
3410 let slide = CT_Slide::from_xml(
3411 format!(
3412 "<p:sld xmlns:p=\"{P_NS}\" xmlns:a=\"{A_NS}\"><p:cSld>{shape_tree}</p:cSld></p:sld>"
3413 )
3414 .as_bytes(),
3415 )
3416 .unwrap();
3417 let layout = CT_SlideLayout::from_xml(
3418 format!(
3419 "<p:sldLayout xmlns:p=\"{P_NS}\" xmlns:a=\"{A_NS}\"><p:cSld>{shape_tree}</p:cSld></p:sldLayout>"
3420 )
3421 .as_bytes(),
3422 )
3423 .unwrap();
3424 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>"#;
3425 let master = CT_SlideMaster::from_xml(
3426 format!(
3427 "<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>"
3428 )
3429 .as_bytes(),
3430 )
3431 .unwrap();
3432 let theme = CT_OfficeStyleSheet::office_default();
3433 let default_text_style = CT_TextListStyle::default();
3434 let resolved = ResolveCtx::new(
3435 &theme,
3436 ColorMap::default(),
3437 &master,
3438 &layout,
3439 &slide,
3440 &default_text_style,
3441 )
3442 .resolve_slide((40.0, 20.0))
3443 .unwrap();
3444 let Some(ResolvedBackground::Paint(resolved_background)) = resolved.background.clone()
3445 else {
3446 panic!("expected resolved paint background");
3447 };
3448 let rendered = layout_presentation(&render_input(vec![resolved])).unwrap();
3449
3450 assert_eq!(rendered.pages[0].background, Some(resolved_background));
3451 assert!(rendered.pages[0].elements.is_empty());
3452 let png = oxml_pdf::render_page_to_png(&rendered, 0, 72.0)
3453 .expect("rasterise inherited master gradient");
3454 let pixmap = tiny_skia::Pixmap::decode_png(&png).expect("decode background raster");
3455 let left = rgb_at(&pixmap, 2, 10);
3456 let right = rgb_at(&pixmap, 37, 10);
3457 assert!(
3458 left.0 > left.2,
3459 "left sample should be red-dominant: {left:?}"
3460 );
3461 assert!(
3462 right.2 > right.0,
3463 "right sample should be blue-dominant: {right:?}"
3464 );
3465 }
3466
3467 #[test]
3468 fn absent_background_keeps_the_default_white_raster() {
3469 const P_NS: &str = "http://schemas.openxmlformats.org/presentationml/2006/main";
3470 const A_NS: &str = "http://schemas.openxmlformats.org/drawingml/2006/main";
3471 let shape_tree = "<p:spTree><p:nvGrpSpPr/><p:grpSpPr/></p:spTree>";
3472 let slide = CT_Slide::from_xml(
3473 format!(
3474 "<p:sld xmlns:p=\"{P_NS}\" xmlns:a=\"{A_NS}\"><p:cSld>{shape_tree}</p:cSld></p:sld>"
3475 )
3476 .as_bytes(),
3477 )
3478 .unwrap();
3479 let layout = CT_SlideLayout::from_xml(
3480 format!(
3481 "<p:sldLayout xmlns:p=\"{P_NS}\" xmlns:a=\"{A_NS}\"><p:cSld>{shape_tree}</p:cSld></p:sldLayout>"
3482 )
3483 .as_bytes(),
3484 )
3485 .unwrap();
3486 let master = CT_SlideMaster::from_xml(
3487 format!(
3488 "<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>"
3489 )
3490 .as_bytes(),
3491 )
3492 .unwrap();
3493 let mut theme = CT_OfficeStyleSheet::office_default();
3494 theme
3495 .theme_elements
3496 .format_scheme
3497 .background_fill_styles[0] = oxml_drawing::fill::Fill::from_xml(
3498 br#"<a:solidFill xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><a:srgbClr val="000000"/></a:solidFill>"#,
3499 )
3500 .unwrap();
3501 let default_text_style = CT_TextListStyle::default();
3502 let resolved = ResolveCtx::new(
3503 &theme,
3504 ColorMap::default(),
3505 &master,
3506 &layout,
3507 &slide,
3508 &default_text_style,
3509 )
3510 .resolve_slide((10.0, 10.0))
3511 .unwrap();
3512
3513 assert!(resolved.background.is_none());
3514 let rendered = layout_presentation(&render_input(vec![resolved])).unwrap();
3515 assert!(rendered.pages[0].background.is_none());
3516 let png = oxml_pdf::render_page_to_png(&rendered, 0, 72.0)
3517 .expect("rasterise absent presentation background");
3518 let pixmap = tiny_skia::Pixmap::decode_png(&png).expect("decode white background raster");
3519 assert_eq!(rgb_at(&pixmap, 5, 5), (255, 255, 255));
3520 }
3521
3522 #[test]
3523 fn background_is_not_duplicated_in_page_elements() {
3524 let mut resolved = slide((20.0, 10.0), Vec::new());
3525 resolved.background = Some(ResolvedBackground::Paint(Paint::Solid(Color::from_hex(
3526 "102030",
3527 ))));
3528
3529 let page = layout_slide(&render_input(vec![resolved]), 0).unwrap();
3530
3531 assert_eq!(
3532 page.background,
3533 Some(Paint::Solid(Color::from_hex("102030")))
3534 );
3535 assert!(page.elements.is_empty());
3536 }
3537
3538 #[test]
3539 fn background_image_is_lowered_before_slide_shapes() {
3540 let png = horizontal_png(&[[0, 0, 255, 255]]);
3541 let media_id = MediaId::from_bytes(&png);
3542 let foreground = shape(
3543 Rect {
3544 x: 2.0,
3545 y: 2.0,
3546 width: 6.0,
3547 height: 6.0,
3548 },
3549 ResolvedGeometry::Rectangle,
3550 Some(Paint::Solid(Color::from_hex("FF0000"))),
3551 None,
3552 );
3553 let mut resolved = slide((10.0, 10.0), vec![foreground]);
3554 resolved.background = Some(ResolvedBackground::Image(ResolvedImage {
3555 media: media_id,
3556 src_rect: None,
3557 placement: ResolvedImagePlacement::default(),
3558 dpi: None,
3559 rotate_with_shape: true,
3560 }));
3561 let input =
3562 render_input_with_media(vec![resolved], HashMap::from([(media_id, media(&png))]));
3563
3564 let page = layout_slide(&input, 0).unwrap();
3565 assert!(matches!(
3566 page.elements.first(),
3567 Some(PositionedElement::Image { media_id: id, .. }) if *id == media_id
3568 ));
3569 assert!(matches!(
3570 page.elements.get(1),
3571 Some(PositionedElement::Group(_))
3572 ));
3573 let rendered = oxml_pdf::render_page_to_png(
3574 &LayoutResult::new(vec![page.into()], Vec::new(), None, Vec::new()),
3575 0,
3576 72.0,
3577 )
3578 .expect("rasterise background image ordering");
3579 let pixmap = tiny_skia::Pixmap::decode_png(&rendered).unwrap();
3580 assert_eq!(rgb_at(&pixmap, 0, 0), (0, 0, 255));
3581 assert_eq!(rgb_at(&pixmap, 5, 5), (255, 0, 0));
3582 }
3583
3584 #[test]
3585 fn layout_presentation_preserves_page_order_and_diagnostics() {
3586 let mut first = slide((20.0, 10.0), Vec::new());
3587 first.diagnostics.push(Diagnostic {
3588 message: "first diagnostic".to_owned(),
3589 });
3590 let mut second = slide((30.0, 15.0), Vec::new());
3591 second.diagnostics.push(Diagnostic {
3592 message: "second diagnostic".to_owned(),
3593 });
3594 let mut input = render_input(vec![first, second]);
3595 input.metadata = Some(DocumentMetadata {
3596 title: Some("shape deck".to_owned()),
3597 author: Some("rpptx-render".to_owned()),
3598 ..DocumentMetadata::default()
3599 });
3600
3601 let layout = layout_presentation(&input).expect("lower presentation");
3602 assert_eq!(layout.pages.len(), 2);
3603 assert_eq!(
3604 (
3605 layout.pages[0].page_number,
3606 layout.pages[0].width,
3607 layout.pages[0].height
3608 ),
3609 (1, 20.0, 10.0)
3610 );
3611 assert_eq!(
3612 (
3613 layout.pages[1].page_number,
3614 layout.pages[1].width,
3615 layout.pages[1].height
3616 ),
3617 (2, 30.0, 15.0)
3618 );
3619 assert_eq!(
3620 layout
3621 .metadata
3622 .as_ref()
3623 .and_then(|metadata| metadata.title.as_deref()),
3624 Some("shape deck")
3625 );
3626 assert_eq!(
3627 layout
3628 .diagnostics
3629 .iter()
3630 .map(|diagnostic| diagnostic.message.as_str())
3631 .collect::<Vec<_>>(),
3632 vec!["first diagnostic", "second diagnostic"]
3633 );
3634 assert!(layout.fonts.is_empty());
3635 assert!(layout.outlines.is_empty());
3636 assert!(layout.structure.is_none());
3637 }
3638
3639 #[test]
3640 fn same_relationship_id_resolves_independently_in_all_three_scopes() {
3641 let relationships = RelScopes {
3642 slide: HashMap::from([("rId2".to_owned(), relationship("slide.png"))]),
3643 layout: HashMap::from([("rId2".to_owned(), relationship("layout.png"))]),
3644 master: HashMap::from([("rId2".to_owned(), relationship("master.png"))]),
3645 };
3646 let package_media = HashMap::from([
3647 ("slide.png".to_owned(), media(b"slide image")),
3648 ("layout.png".to_owned(), media(b"layout image")),
3649 ("master.png".to_owned(), media(b"master image")),
3650 ]);
3651 let mut deck_media = HashMap::new();
3652
3653 let slide = resolve_media_relationship(
3654 &relationships,
3655 RelScope::Slide,
3656 "rId2",
3657 &package_media,
3658 &mut deck_media,
3659 )
3660 .unwrap();
3661 let layout = resolve_media_relationship(
3662 &relationships,
3663 RelScope::Layout,
3664 "rId2",
3665 &package_media,
3666 &mut deck_media,
3667 )
3668 .unwrap();
3669 let master = resolve_media_relationship(
3670 &relationships,
3671 RelScope::Master,
3672 "rId2",
3673 &package_media,
3674 &mut deck_media,
3675 )
3676 .unwrap();
3677
3678 assert_eq!(slide, MediaId::from_bytes(b"slide image"));
3679 assert_eq!(layout, MediaId::from_bytes(b"layout image"));
3680 assert_eq!(master, MediaId::from_bytes(b"master image"));
3681 assert_eq!(deck_media.len(), 3);
3682 }
3683
3684 #[test]
3685 fn external_hyperlink_projection_keeps_scopes_and_excludes_internal_targets() {
3686 let relationships = RelScopes {
3687 slide: HashMap::from([
3688 (
3689 "rId7".to_owned(),
3690 hyperlink_relationship("https://slide.example", Some("External")),
3691 ),
3692 (
3693 "rId8".to_owned(),
3694 hyperlink_relationship("../slides/slide2.xml", None),
3695 ),
3696 ]),
3697 layout: HashMap::from([(
3698 "rId7".to_owned(),
3699 hyperlink_relationship("https://layout.example", Some("External")),
3700 )]),
3701 master: HashMap::from([(
3702 "rId7".to_owned(),
3703 hyperlink_relationship("https://master.example", Some("External")),
3704 )]),
3705 };
3706
3707 let targets = relationships.external_hyperlink_targets();
3708
3709 assert_eq!(
3710 targets.slide.get("rId7").map(String::as_str),
3711 Some("https://slide.example")
3712 );
3713 assert!(!targets.slide.contains_key("rId8"));
3714 assert_eq!(
3715 targets.layout.get("rId7").map(String::as_str),
3716 Some("https://layout.example")
3717 );
3718 assert_eq!(
3719 targets.master.get("rId7").map(String::as_str),
3720 Some("https://master.example")
3721 );
3722 }
3723
3724 #[test]
3725 fn equal_media_bytes_deduplicate_to_one_media_entry() {
3726 let relationships = RelScopes {
3727 slide: HashMap::from([
3728 ("rId1".to_owned(), relationship("logo-a.png")),
3729 ("rId2".to_owned(), relationship("logo-b.png")),
3730 ]),
3731 ..RelScopes::default()
3732 };
3733 let package_media = HashMap::from([
3734 ("logo-a.png".to_owned(), media(b"shared logo")),
3735 ("logo-b.png".to_owned(), media(b"shared logo")),
3736 ]);
3737 let mut deck_media = HashMap::new();
3738
3739 let first = resolve_media_relationship(
3740 &relationships,
3741 RelScope::Slide,
3742 "rId1",
3743 &package_media,
3744 &mut deck_media,
3745 )
3746 .unwrap();
3747 let second = resolve_media_relationship(
3748 &relationships,
3749 RelScope::Slide,
3750 "rId2",
3751 &package_media,
3752 &mut deck_media,
3753 )
3754 .unwrap();
3755
3756 assert_eq!(first, second);
3757 assert_eq!(deck_media.len(), 1);
3758 }
3759
3760 #[test]
3761 fn missing_relationship_reports_scope_and_id() {
3762 let error = resolve_media_relationship(
3763 &RelScopes::default(),
3764 RelScope::Layout,
3765 "rId9",
3766 &HashMap::new(),
3767 &mut HashMap::new(),
3768 )
3769 .unwrap_err();
3770
3771 assert_eq!(
3772 error,
3773 RenderInputError::MissingRelationship {
3774 scope: RelScope::Layout,
3775 relationship_id: "rId9".to_owned(),
3776 }
3777 );
3778 assert!(error.to_string().contains("layout"));
3779 assert!(error.to_string().contains("rId9"));
3780 }
3781
3782 #[test]
3783 fn render_input_contains_only_resolved_slides() {
3784 let input = RenderInput {
3785 slides: Vec::<ResolvedSlide>::new(),
3786 media: HashMap::new(),
3787 fonts: Vec::new(),
3788 metadata: None,
3789 };
3790
3791 assert!(input.slides.is_empty());
3792 assert_eq!(
3793 std::any::type_name_of_val(&input.slides),
3794 "alloc::vec::Vec<rpptx_layout::ResolvedSlide>"
3795 );
3796 }
3797
3798 #[test]
3799 fn rpptx_render_dependency_direction_is_one_way() {
3800 let manifest = include_str!("../Cargo.toml");
3801 let rpptx_manifest = include_str!("../../rpptx/Cargo.toml");
3802 let binding_manifest = include_str!("../../rpptx-py/Cargo.toml");
3803 assert!(manifest.contains(
3804 "[features]\ndefault = [\"system-fonts\"]\nsystem-fonts = [\"oxml-layout/system-fonts\"]"
3805 ));
3806 assert!(manifest.contains("oxml-layout = { workspace = true, default-features = false }"));
3807 assert!(
3808 rpptx_manifest
3809 .contains("default = [\"default-template\", \"render\", \"system-fonts\"]")
3810 );
3811 assert!(rpptx_manifest.contains(
3812 "system-fonts = [\"oxml-layout/system-fonts\", \"rpptx-render?/system-fonts\"]"
3813 ));
3814 assert!(rpptx_manifest.contains(
3815 "rpptx-render = { workspace = true, default-features = false, optional = true }"
3816 ));
3817 assert!(
3818 binding_manifest.contains(
3819 "rpptx = { workspace = true, features = [\"default-template\", \"render\", \"system-fonts\"] }"
3820 )
3821 );
3822 for dependency in [
3823 "oxml-drawing.workspace = true",
3824 "oxml-media.workspace = true",
3825 "rpptx-layout.workspace = true",
3826 "rpptx-oxml.workspace = true",
3827 ] {
3828 assert!(manifest.contains(dependency), "missing {dependency}");
3829 }
3830 for oxml_manifest in [
3831 include_str!("../../oxml-core/Cargo.toml"),
3832 include_str!("../../oxml-drawing/Cargo.toml"),
3833 include_str!("../../oxml-layout/Cargo.toml"),
3834 include_str!("../../oxml-media/Cargo.toml"),
3835 include_str!("../../oxml-opc/Cargo.toml"),
3836 include_str!("../../oxml-pdf/Cargo.toml"),
3837 ] {
3838 assert!(!oxml_manifest.contains("rpptx-render"));
3839 }
3840 assert!(manifest.contains("version = \"0.9.0\""));
3841 assert!(manifest.contains("publish = true"));
3842 }
3843}