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