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