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