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