Skip to main content

rpptx_render/
lib.rs

1//! Presentation rendering inputs and package assembly helpers.
2
3use 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/// The source part whose relationship map owns an identifier.
25#[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/// A package relationship after its target has been resolved against its source part.
43#[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/// Relationship maps kept separate by their source-part scope.
51#[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    /// Look up a relationship only in the explicitly selected source-part scope.
60    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    /// Project external hyperlink relationships into layout's source-scoped map.
79    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/// Media bytes available to a renderer, with their package content type.
105#[derive(Clone, Debug, Eq, PartialEq)]
106pub struct MediaData {
107    pub bytes: Vec<u8>,
108    pub content_type: String,
109}
110
111/// Package assembly failures that retain relationship source context.
112#[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/// Raw package parts assembled before inheritance resolution.
186#[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/// Frozen, format-neutral input consumed by the rendering stage.
198#[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
206/// Lower every resolved slide to one fixed-size page in presentation order.
207pub 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
213/// Lower every resolved slide using only bundled and presentation-embedded fonts.
214///
215/// Unlike [`layout_presentation`], this entry point never discovers host system
216/// fonts, so its raster output is suitable for deterministic comparison gates.
217pub 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
228/// Lowers every slide with the same manager that shaped any frozen group content.
229pub 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
251/// Lower one zero-based resolved slide to a fixed-size page.
252pub 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
1388/// Resolve one scoped media relationship into the deck's content-addressed store.
1389pub 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.2.0\""));
3544        assert!(manifest.contains("publish = true"));
3545    }
3546}