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