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    Ok(elements)
888}
889
890#[allow(clippy::too_many_arguments)]
891fn lower_tiled_picture(
892    shape: &ResolvedShape,
893    paths: &[Path],
894    media_id: MediaId,
895    media: &MediaData,
896    crop: Option<CropRect>,
897    tile: &ResolvedTilePlacement,
898    declared_dpi: Option<f64>,
899    rotate_with_shape: bool,
900) -> Result<Vec<PositionedElement>, RenderInputError> {
901    let info = oxml_media::probe(&media.bytes).ok_or(RenderInputError::InvalidPicture {
902        media: media_id,
903        detail: "tile image metadata is unavailable",
904    })?;
905    let (native_width, native_height) = tile_native_size_points(info, declared_dpi, media_id)?;
906    let tile_width = native_width * tile.scale_x;
907    let tile_height = native_height * tile.scale_y;
908    if !tile_width.is_finite()
909        || !tile_height.is_finite()
910        || tile_width <= 0.0
911        || tile_height <= 0.0
912        || !tile.translation.x.is_finite()
913        || !tile.translation.y.is_finite()
914    {
915        return Err(RenderInputError::InvalidPicture {
916            media: media_id,
917            detail: "tile size or translation is not finite and positive",
918        });
919    }
920    let shape_rect = local_shape_rect(shape);
921    let coverage_rect = picture_coverage_rect(shape, rotate_with_shape);
922    let anchor = tile_alignment_origin(shape_rect, tile_width, tile_height, tile.alignment);
923    let translated_anchor = Point {
924        x: anchor.x + tile.translation.x,
925        y: anchor.y + tile.translation.y,
926    };
927    let origin_x = repeated_origin(translated_anchor.x, coverage_rect.x, tile_width);
928    let origin_y = repeated_origin(translated_anchor.y, coverage_rect.y, tile_height);
929    if !origin_x.is_finite() || !origin_y.is_finite() {
930        return Err(RenderInputError::InvalidPicture {
931            media: media_id,
932            detail: "tile origin is not finite",
933        });
934    }
935    let first_column = repeated_tile_index(origin_x, translated_anchor.x, tile_width, media_id)?;
936    let first_row = repeated_tile_index(origin_y, translated_anchor.y, tile_height, media_id)?;
937    let columns = repeat_count(
938        origin_x,
939        coverage_rect.x + coverage_rect.width,
940        tile_width,
941        media_id,
942    )?;
943    let rows = repeat_count(
944        origin_y,
945        coverage_rect.y + coverage_rect.height,
946        tile_height,
947        media_id,
948    )?;
949    let requested = columns
950        .checked_mul(rows)
951        .ok_or(RenderInputError::TileLimitExceeded {
952            media: media_id,
953            requested: usize::MAX,
954            limit: MAX_TILE_ELEMENTS,
955        })?;
956    let byte_limit = MAX_TILED_IMAGE_BYTES / media.bytes.len().max(1);
957    let limit = MAX_TILE_ELEMENTS.min(byte_limit.max(1));
958    if requested > limit {
959        return Err(RenderInputError::TileLimitExceeded {
960            media: media_id,
961            requested,
962            limit,
963        });
964    }
965
966    let mut tiles = Vec::with_capacity(requested);
967    for row in 0..rows {
968        for column in 0..columns {
969            let rect = Rect {
970                x: origin_x + column as f64 * tile_width,
971                y: origin_y + row as f64 * tile_height,
972                width: tile_width,
973                height: tile_height,
974            };
975            let image = picture_image(media_id, media, expanded_crop_rect(rect, crop));
976            let image = if crop.is_some() {
977                clipped_group(Path::rect(rect), vec![image])
978            } else {
979                image
980            };
981            tiles.push(flip_tile(
982                image,
983                rect,
984                tile.flip,
985                (first_column.rem_euclid(2) == 1) != (column % 2 == 1),
986                (first_row.rem_euclid(2) == 1) != (row % 2 == 1),
987            ));
988        }
989    }
990    let tiles = if rotate_with_shape || shape.rotation_deg == 0.0 {
991        tiles
992    } else {
993        vec![PositionedElement::Group(GroupElement {
994            transform: Transform::rotate_about(
995                -shape.rotation_deg,
996                shape.bounds.width / 2.0,
997                shape.bounds.height / 2.0,
998            ),
999            clip: None,
1000            opacity: 1.0,
1001            effects: Vec::new(),
1002            children: tiles,
1003        })]
1004    };
1005    Ok(vec![clip_to_picture_shape(shape, paths, tiles)])
1006}
1007
1008fn normalized_insets(
1009    crop: Option<CropRect>,
1010    media: MediaId,
1011    detail: &'static str,
1012) -> Result<Option<CropRect>, RenderInputError> {
1013    let Some(crop) = crop else {
1014        return Ok(None);
1015    };
1016    let values = [crop.left, crop.top, crop.right, crop.bottom];
1017    if values.iter().any(|value| !value.is_finite()) {
1018        return Err(RenderInputError::InvalidPicture { media, detail });
1019    }
1020    let crop = CropRect {
1021        left: crop.left.clamp(0.0, 1.0),
1022        top: crop.top.clamp(0.0, 1.0),
1023        right: crop.right.clamp(0.0, 1.0),
1024        bottom: crop.bottom.clamp(0.0, 1.0),
1025    };
1026    if crop.left + crop.right >= 1.0 || crop.top + crop.bottom >= 1.0 {
1027        return Err(RenderInputError::InvalidPicture { media, detail });
1028    }
1029    Ok((crop != CropRect::default()).then_some(crop))
1030}
1031
1032fn local_shape_rect(shape: &ResolvedShape) -> Rect {
1033    Rect {
1034        x: 0.0,
1035        y: 0.0,
1036        width: shape.bounds.width,
1037        height: shape.bounds.height,
1038    }
1039}
1040
1041fn picture_coverage_rect(shape: &ResolvedShape, rotate_with_shape: bool) -> Rect {
1042    let rect = local_shape_rect(shape);
1043    if rotate_with_shape || shape.rotation_deg == 0.0 {
1044        return rect;
1045    }
1046    Transform::rotate_about(
1047        shape.rotation_deg,
1048        shape.bounds.width / 2.0,
1049        shape.bounds.height / 2.0,
1050    )
1051    .transform_rect_bbox(rect)
1052}
1053
1054fn inset_rect(rect: Rect, insets: Option<CropRect>) -> Rect {
1055    let Some(insets) = insets else {
1056        return rect;
1057    };
1058    Rect {
1059        x: rect.x + rect.width * insets.left,
1060        y: rect.y + rect.height * insets.top,
1061        width: rect.width * (1.0 - insets.left - insets.right),
1062        height: rect.height * (1.0 - insets.top - insets.bottom),
1063    }
1064}
1065
1066fn expanded_crop_rect(destination: Rect, crop: Option<CropRect>) -> Rect {
1067    let Some(crop) = crop else {
1068        return destination;
1069    };
1070    let retained_width = 1.0 - crop.left - crop.right;
1071    let retained_height = 1.0 - crop.top - crop.bottom;
1072    let width = destination.width / retained_width;
1073    let height = destination.height / retained_height;
1074    Rect {
1075        x: destination.x - width * crop.left,
1076        y: destination.y - height * crop.top,
1077        width,
1078        height,
1079    }
1080}
1081
1082fn picture_image(media_id: MediaId, media: &MediaData, rect: Rect) -> PositionedElement {
1083    PositionedElement::Image {
1084        rect,
1085        data: media.bytes.clone(),
1086        content_type: media.content_type.clone(),
1087        media_id,
1088    }
1089}
1090
1091fn counter_rotate_image(
1092    image: PositionedElement,
1093    shape: &ResolvedShape,
1094    rotate_with_shape: bool,
1095) -> PositionedElement {
1096    if rotate_with_shape || shape.rotation_deg == 0.0 {
1097        return image;
1098    }
1099    PositionedElement::Group(GroupElement {
1100        transform: Transform::rotate_about(
1101            -shape.rotation_deg,
1102            shape.bounds.width / 2.0,
1103            shape.bounds.height / 2.0,
1104        ),
1105        clip: None,
1106        opacity: 1.0,
1107        effects: Vec::new(),
1108        children: vec![image],
1109    })
1110}
1111
1112fn clip_to_picture_shape(
1113    shape: &ResolvedShape,
1114    paths: &[Path],
1115    children: Vec<PositionedElement>,
1116) -> PositionedElement {
1117    if matches!(shape.geometry, ResolvedGeometry::Rectangle) {
1118        return clipped_group(Path::rect(local_shape_rect(shape)), children);
1119    }
1120    clipped_group(combined_clip_path(paths), children)
1121}
1122
1123fn combined_clip_path(paths: &[Path]) -> Path {
1124    Path {
1125        commands: paths
1126            .iter()
1127            .flat_map(|path| path.commands.iter().cloned())
1128            .collect(),
1129        fill_rule: paths
1130            .first()
1131            .map_or(oxml_layout::FillRule::NonZero, |path| path.fill_rule),
1132    }
1133}
1134
1135fn clipped_group(clip: Path, children: Vec<PositionedElement>) -> PositionedElement {
1136    PositionedElement::Group(GroupElement {
1137        transform: Transform::IDENTITY,
1138        clip: Some(clip),
1139        opacity: 1.0,
1140        effects: Vec::new(),
1141        children,
1142    })
1143}
1144
1145fn tile_native_size_points(
1146    mut info: oxml_media::ImageInfo,
1147    declared_dpi: Option<f64>,
1148    media: MediaId,
1149) -> Result<(f64, f64), RenderInputError> {
1150    if let Some(dpi) = declared_dpi {
1151        if !dpi.is_finite() || dpi <= 0.0 {
1152            return Err(RenderInputError::InvalidPicture {
1153                media,
1154                detail: "declared picture DPI is not finite and positive",
1155            });
1156        }
1157        info.dpi_x = Some(dpi);
1158        info.dpi_y = Some(dpi);
1159    }
1160    let size = info
1161        .native_size(96.0)
1162        .ok_or(RenderInputError::InvalidPicture {
1163            media,
1164            detail: "picture DPI cannot produce a native size",
1165        })?;
1166    Ok((
1167        size.width_emu as f64 / 12_700.0,
1168        size.height_emu as f64 / 12_700.0,
1169    ))
1170}
1171
1172fn tile_alignment_origin(
1173    rect: Rect,
1174    tile_width: f64,
1175    tile_height: f64,
1176    alignment: ResolvedRectAlignment,
1177) -> Point {
1178    let center_x = rect.x + (rect.width - tile_width) / 2.0;
1179    let right = rect.x + rect.width - tile_width;
1180    let center_y = rect.y + (rect.height - tile_height) / 2.0;
1181    let bottom = rect.y + rect.height - tile_height;
1182    match alignment {
1183        ResolvedRectAlignment::TopLeft => Point {
1184            x: rect.x,
1185            y: rect.y,
1186        },
1187        ResolvedRectAlignment::Top => Point {
1188            x: center_x,
1189            y: rect.y,
1190        },
1191        ResolvedRectAlignment::TopRight => Point {
1192            x: right,
1193            y: rect.y,
1194        },
1195        ResolvedRectAlignment::Left => Point {
1196            x: rect.x,
1197            y: center_y,
1198        },
1199        ResolvedRectAlignment::Center => Point {
1200            x: center_x,
1201            y: center_y,
1202        },
1203        ResolvedRectAlignment::Right => Point {
1204            x: right,
1205            y: center_y,
1206        },
1207        ResolvedRectAlignment::BottomLeft => Point {
1208            x: rect.x,
1209            y: bottom,
1210        },
1211        ResolvedRectAlignment::Bottom => Point {
1212            x: center_x,
1213            y: bottom,
1214        },
1215        ResolvedRectAlignment::BottomRight => Point {
1216            x: right,
1217            y: bottom,
1218        },
1219    }
1220}
1221
1222fn repeated_origin(anchor: f64, coverage_start: f64, tile_size: f64) -> f64 {
1223    coverage_start - (coverage_start - anchor).rem_euclid(tile_size)
1224}
1225
1226fn repeated_tile_index(
1227    origin: f64,
1228    anchor: f64,
1229    tile_size: f64,
1230    media: MediaId,
1231) -> Result<isize, RenderInputError> {
1232    let index = ((origin - anchor) / tile_size).round();
1233    if !index.is_finite() || index < isize::MIN as f64 || index > isize::MAX as f64 {
1234        return Err(RenderInputError::InvalidPicture {
1235            media,
1236            detail: "tile translation cannot preserve flip phase",
1237        });
1238    }
1239    Ok(index as isize)
1240}
1241
1242fn repeat_count(
1243    origin: f64,
1244    coverage_end: f64,
1245    tile_size: f64,
1246    media: MediaId,
1247) -> Result<usize, RenderInputError> {
1248    let count = ((coverage_end - origin) / tile_size).ceil().max(0.0);
1249    if !count.is_finite() || count > usize::MAX as f64 {
1250        return Err(RenderInputError::TileLimitExceeded {
1251            media,
1252            requested: usize::MAX,
1253            limit: MAX_TILE_ELEMENTS,
1254        });
1255    }
1256    Ok(count as usize)
1257}
1258
1259fn flip_tile(
1260    tile: PositionedElement,
1261    rect: Rect,
1262    flip: ResolvedTileFlip,
1263    odd_column: bool,
1264    odd_row: bool,
1265) -> PositionedElement {
1266    let flip_h =
1267        matches!(flip, ResolvedTileFlip::Horizontal | ResolvedTileFlip::Both) && odd_column;
1268    let flip_v = matches!(flip, ResolvedTileFlip::Vertical | ResolvedTileFlip::Both) && odd_row;
1269    if !flip_h && !flip_v {
1270        return tile;
1271    }
1272    PositionedElement::Group(GroupElement {
1273        transform: Transform {
1274            a: if flip_h { -1.0 } else { 1.0 },
1275            b: 0.0,
1276            c: 0.0,
1277            d: if flip_v { -1.0 } else { 1.0 },
1278            e: if flip_h {
1279                2.0 * rect.x + rect.width
1280            } else {
1281                0.0
1282            },
1283            f: if flip_v {
1284                2.0 * rect.y + rect.height
1285            } else {
1286                0.0
1287            },
1288        },
1289        clip: None,
1290        opacity: 1.0,
1291        effects: Vec::new(),
1292        children: vec![tile],
1293    })
1294}
1295
1296#[derive(Clone, Copy)]
1297struct EndpointTangent {
1298    point: Point,
1299    outward: Point,
1300}
1301
1302fn endpoint_tangents(paths: &[Path]) -> (Option<EndpointTangent>, Option<EndpointTangent>) {
1303    let mut head = None;
1304    let mut tail = None;
1305    for path in paths {
1306        let mut current = None;
1307        let mut subpath_start = None;
1308        for command in &path.commands {
1309            match *command {
1310                PathCommand::MoveTo(point) => {
1311                    current = Some(point);
1312                    subpath_start = Some(point);
1313                }
1314                PathCommand::LineTo(to) => {
1315                    if let Some(from) = current
1316                        && let Some(direction) = unit_direction(from, to)
1317                    {
1318                        head.get_or_insert(EndpointTangent {
1319                            point: from,
1320                            outward: Point {
1321                                x: -direction.x,
1322                                y: -direction.y,
1323                            },
1324                        });
1325                        tail = Some(EndpointTangent {
1326                            point: to,
1327                            outward: direction,
1328                        });
1329                    }
1330                    current = Some(to);
1331                }
1332                PathCommand::CurveTo { c1, c2, to } => {
1333                    if let Some(from) = current {
1334                        let start_direction = [c1, c2, to]
1335                            .into_iter()
1336                            .find_map(|candidate| unit_direction(from, candidate));
1337                        let end_direction = [c2, c1, from]
1338                            .into_iter()
1339                            .find_map(|candidate| unit_direction(candidate, to));
1340                        if let Some(direction) = start_direction {
1341                            head.get_or_insert(EndpointTangent {
1342                                point: from,
1343                                outward: Point {
1344                                    x: -direction.x,
1345                                    y: -direction.y,
1346                                },
1347                            });
1348                        }
1349                        if let Some(direction) = end_direction {
1350                            tail = Some(EndpointTangent {
1351                                point: to,
1352                                outward: direction,
1353                            });
1354                        }
1355                    }
1356                    current = Some(to);
1357                }
1358                PathCommand::Close => {
1359                    if let (Some(from), Some(to)) = (current, subpath_start)
1360                        && let Some(direction) = unit_direction(from, to)
1361                    {
1362                        head.get_or_insert(EndpointTangent {
1363                            point: from,
1364                            outward: Point {
1365                                x: -direction.x,
1366                                y: -direction.y,
1367                            },
1368                        });
1369                        tail = Some(EndpointTangent {
1370                            point: to,
1371                            outward: direction,
1372                        });
1373                    }
1374                    current = subpath_start;
1375                }
1376            }
1377        }
1378    }
1379    (head, tail)
1380}
1381
1382fn unit_direction(from: Point, to: Point) -> Option<Point> {
1383    let dx = to.x - from.x;
1384    let dy = to.y - from.y;
1385    let length = dx.hypot(dy);
1386    (length.is_finite() && length > 1.0e-10).then_some(Point {
1387        x: dx / length,
1388        y: dy / length,
1389    })
1390}
1391
1392fn line_end_path(
1393    end: &ResolvedLineEnd,
1394    tangent: EndpointTangent,
1395    stroke_width: f64,
1396) -> Option<Path> {
1397    if !stroke_width.is_finite() || stroke_width <= 0.0 {
1398        return None;
1399    }
1400    let width = line_end_factor(end.width) * stroke_width;
1401    let length = line_end_factor(end.length) * stroke_width;
1402    let point = |along: f64, across: f64| Point {
1403        x: tangent.point.x + tangent.outward.x * along - tangent.outward.y * across,
1404        y: tangent.point.y + tangent.outward.y * along + tangent.outward.x * across,
1405    };
1406    let path = match end.kind {
1407        ResolvedLineEndKind::Triangle => closed_polygon(vec![
1408            point(0.0, 0.0),
1409            point(-length, width / 2.0),
1410            point(-length, -width / 2.0),
1411        ]),
1412        ResolvedLineEndKind::Stealth => closed_polygon(vec![
1413            point(0.0, 0.0),
1414            point(-length, width / 2.0),
1415            point(-length / 2.0, 0.0),
1416            point(-length, -width / 2.0),
1417        ]),
1418        ResolvedLineEndKind::Diamond => closed_polygon(vec![
1419            point(0.0, 0.0),
1420            point(-length / 2.0, width / 2.0),
1421            point(-length, 0.0),
1422            point(-length / 2.0, -width / 2.0),
1423        ]),
1424        ResolvedLineEndKind::Oval => oval_line_end(&point, width, length),
1425        ResolvedLineEndKind::Arrow => {
1426            let arm = stroke_width.min(width / 2.0);
1427            closed_polygon(vec![
1428                point(0.0, 0.0),
1429                point(-length, width / 2.0),
1430                point(-length, width / 2.0 - arm),
1431                point(-arm, 0.0),
1432                point(-length, -width / 2.0 + arm),
1433                point(-length, -width / 2.0),
1434            ])
1435        }
1436    };
1437    path_is_finite(&path).then_some(path)
1438}
1439
1440fn line_end_factor(size: ResolvedLineEndSize) -> f64 {
1441    match size {
1442        ResolvedLineEndSize::Small => 2.0,
1443        ResolvedLineEndSize::Medium => 3.0,
1444        ResolvedLineEndSize::Large => 5.0,
1445    }
1446}
1447
1448fn closed_polygon(points: Vec<Point>) -> Path {
1449    let mut points = points.into_iter();
1450    let mut commands = points
1451        .next()
1452        .map(PathCommand::MoveTo)
1453        .into_iter()
1454        .collect::<Vec<_>>();
1455    commands.extend(points.map(PathCommand::LineTo));
1456    commands.push(PathCommand::Close);
1457    Path {
1458        commands,
1459        fill_rule: oxml_layout::FillRule::NonZero,
1460    }
1461}
1462
1463fn oval_line_end(point: &impl Fn(f64, f64) -> Point, width: f64, length: f64) -> Path {
1464    const KAPPA: f64 = 0.552_284_749_830_793_6;
1465    let rx = length / 2.0;
1466    let ry = width / 2.0;
1467    let center = -rx;
1468    Path {
1469        commands: vec![
1470            PathCommand::MoveTo(point(0.0, 0.0)),
1471            PathCommand::CurveTo {
1472                c1: point(0.0, KAPPA * ry),
1473                c2: point(center + KAPPA * rx, ry),
1474                to: point(center, ry),
1475            },
1476            PathCommand::CurveTo {
1477                c1: point(center - KAPPA * rx, ry),
1478                c2: point(-length, KAPPA * ry),
1479                to: point(-length, 0.0),
1480            },
1481            PathCommand::CurveTo {
1482                c1: point(-length, -KAPPA * ry),
1483                c2: point(center - KAPPA * rx, -ry),
1484                to: point(center, -ry),
1485            },
1486            PathCommand::CurveTo {
1487                c1: point(center + KAPPA * rx, -ry),
1488                c2: point(0.0, -KAPPA * ry),
1489                to: point(0.0, 0.0),
1490            },
1491            PathCommand::Close,
1492        ],
1493        fill_rule: oxml_layout::FillRule::NonZero,
1494    }
1495}
1496
1497fn path_is_finite(path: &Path) -> bool {
1498    path.commands.iter().all(|command| match command {
1499        PathCommand::MoveTo(point) | PathCommand::LineTo(point) => point_is_finite(*point),
1500        PathCommand::CurveTo { c1, c2, to } => {
1501            point_is_finite(*c1) && point_is_finite(*c2) && point_is_finite(*to)
1502        }
1503        PathCommand::Close => true,
1504    })
1505}
1506
1507fn point_is_finite(point: Point) -> bool {
1508    point.x.is_finite() && point.y.is_finite()
1509}
1510
1511fn filled_line_end(path: Path, paint: &Paint) -> PositionedElement {
1512    PositionedElement::Path(PathElement {
1513        path,
1514        fill: Some(paint.clone()),
1515        stroke: None,
1516    })
1517}
1518
1519fn shape_transform(shape: &ResolvedShape) -> Transform {
1520    let center_x = shape.bounds.width / 2.0;
1521    let center_y = shape.bounds.height / 2.0;
1522    let rotation = Transform::rotate_about(shape.rotation_deg, center_x, center_y);
1523    let flip = Transform {
1524        a: if shape.flip_h { -1.0 } else { 1.0 },
1525        b: 0.0,
1526        c: 0.0,
1527        d: if shape.flip_v { -1.0 } else { 1.0 },
1528        e: if shape.flip_h {
1529            shape.bounds.width
1530        } else {
1531            0.0
1532        },
1533        f: if shape.flip_v {
1534            shape.bounds.height
1535        } else {
1536            0.0
1537        },
1538    };
1539    let translation = Transform {
1540        e: shape.bounds.x,
1541        f: shape.bounds.y,
1542        ..Transform::IDENTITY
1543    };
1544    rotation
1545        .then(flip)
1546        .then(translation)
1547        .then(shape.group_transform)
1548}
1549
1550/// Resolve one scoped media relationship into the deck's content-addressed store.
1551pub fn resolve_media_relationship(
1552    relationships: &RelScopes,
1553    scope: RelScope,
1554    relationship_id: &str,
1555    package_media: &HashMap<String, MediaData>,
1556    deck_media: &mut HashMap<MediaId, MediaData>,
1557) -> Result<MediaId, RenderInputError> {
1558    let relationship = relationships.get(scope, relationship_id)?;
1559    let media = package_media.get(&relationship.target).ok_or_else(|| {
1560        RenderInputError::MissingMediaTarget {
1561            scope,
1562            relationship_id: relationship_id.to_owned(),
1563            target: relationship.target.clone(),
1564        }
1565    })?;
1566    let media_id = MediaId::from_bytes(&media.bytes);
1567    deck_media.entry(media_id).or_insert_with(|| media.clone());
1568    Ok(media_id)
1569}
1570
1571#[cfg(test)]
1572mod tests {
1573    use super::*;
1574    use oxml_drawing::color::ColorMap;
1575    use oxml_drawing::text::CT_TextListStyle;
1576    use oxml_layout::{
1577        Color, Diagnostic, Effect, FieldKind, FillRule, GradientStop, GroupElement, Paint, Path,
1578        PathCommand, Point, PositionedElement, Rect, Stroke, Transform, walk,
1579    };
1580    use rpptx_layout::{
1581        ResolveCtx, ResolvedAutofit, ResolvedContent, ResolvedGeometry, ResolvedParagraph,
1582        ResolvedRunStyle, ResolvedShape, ResolvedTable, ResolvedTableBorder, ResolvedTableCell,
1583        ResolvedTableRow, ResolvedTextBody, ResolvedTextRun, TextAnchor, TextDirection, TextInsets,
1584    };
1585
1586    const IMAGE_RELATIONSHIP: &str =
1587        "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image";
1588
1589    fn media(bytes: &[u8]) -> MediaData {
1590        MediaData {
1591            bytes: bytes.to_vec(),
1592            content_type: "image/png".to_owned(),
1593        }
1594    }
1595
1596    fn relationship(target: &str) -> ResolvedRel {
1597        ResolvedRel {
1598            target: target.to_owned(),
1599            relationship_type: IMAGE_RELATIONSHIP.to_owned(),
1600            target_mode: None,
1601        }
1602    }
1603
1604    fn hyperlink_relationship(target: &str, target_mode: Option<&str>) -> ResolvedRel {
1605        ResolvedRel {
1606            target: target.to_owned(),
1607            relationship_type: HYPERLINK_RELATIONSHIP.to_owned(),
1608            target_mode: target_mode.map(str::to_owned),
1609        }
1610    }
1611
1612    fn color(red: f64, green: f64, blue: f64) -> Color {
1613        Color {
1614            r: red,
1615            g: green,
1616            b: blue,
1617            a: 1.0,
1618        }
1619    }
1620
1621    fn shape(
1622        bounds: Rect,
1623        geometry: ResolvedGeometry,
1624        fill: Option<Paint>,
1625        line: Option<Stroke>,
1626    ) -> ResolvedShape {
1627        ResolvedShape {
1628            group_transform: Transform::IDENTITY,
1629            bounds,
1630            rotation_deg: 0.0,
1631            flip_h: false,
1632            flip_v: false,
1633            geometry,
1634            fill,
1635            image_fill: None,
1636            line,
1637            head_end: None,
1638            tail_end: None,
1639            shadow: None,
1640            content: ResolvedContent::None,
1641            unsupported: None,
1642        }
1643    }
1644
1645    fn table_shape(table: ResolvedTable) -> ResolvedShape {
1646        let mut table_shape = shape(
1647            Rect {
1648                x: 0.0,
1649                y: 0.0,
1650                width: 20.0,
1651                height: 20.0,
1652            },
1653            ResolvedGeometry::Rectangle,
1654            None,
1655            None,
1656        );
1657        table_shape.content = ResolvedContent::Table(table);
1658        table_shape
1659    }
1660
1661    fn table_text(value: &str) -> ResolvedTextBody {
1662        ResolvedTextBody {
1663            insets: TextInsets::default(),
1664            anchor: TextAnchor::Top,
1665            wrap: true,
1666            vertical: TextDirection::Horizontal,
1667            space_first_last_paragraph: false,
1668            autofit: ResolvedAutofit::None,
1669            paragraphs: vec![ResolvedParagraph {
1670                runs: vec![ResolvedTextRun::Text {
1671                    text: value.to_owned(),
1672                    style: ResolvedRunStyle {
1673                        font_size: Some(6.0),
1674                        ..ResolvedRunStyle::default()
1675                    },
1676                }],
1677                ..ResolvedParagraph::default()
1678            }],
1679        }
1680    }
1681
1682    fn table_border() -> ResolvedTableBorder {
1683        ResolvedTableBorder {
1684            stroke: Some(Stroke::new(Paint::Solid(Color::BLACK), 1.0)),
1685            priority: 1,
1686        }
1687    }
1688
1689    fn linked_field_shape(group_transform: Transform) -> ResolvedShape {
1690        let mut linked = shape(
1691            Rect {
1692                x: 10.0,
1693                y: 20.0,
1694                width: 120.0,
1695                height: 40.0,
1696            },
1697            ResolvedGeometry::Rectangle,
1698            None,
1699            None,
1700        );
1701        linked.group_transform = group_transform;
1702        linked.content = ResolvedContent::Text(ResolvedTextBody {
1703            insets: TextInsets::default(),
1704            anchor: TextAnchor::Top,
1705            wrap: true,
1706            vertical: TextDirection::Horizontal,
1707            space_first_last_paragraph: false,
1708            autofit: ResolvedAutofit::None,
1709            paragraphs: vec![ResolvedParagraph {
1710                runs: vec![
1711                    ResolvedTextRun::Field {
1712                        text: "stored".to_owned(),
1713                        field_type: Some("slidenum".to_owned()),
1714                        style: ResolvedRunStyle {
1715                            font_size: Some(12.0),
1716                            ..ResolvedRunStyle::default()
1717                        },
1718                    },
1719                    ResolvedTextRun::Text {
1720                        text: " linked".to_owned(),
1721                        style: ResolvedRunStyle {
1722                            font_size: Some(12.0),
1723                            hyperlink_url: Some("https://example.com/deck".to_owned()),
1724                            ..ResolvedRunStyle::default()
1725                        },
1726                    },
1727                ],
1728                ..ResolvedParagraph::default()
1729            }],
1730        });
1731        linked
1732    }
1733
1734    fn rendered_text_and_links(
1735        page: &PageFrame,
1736    ) -> (Vec<(String, Option<FieldKind>)>, Vec<String>) {
1737        let mut text = Vec::new();
1738        let mut links = Vec::new();
1739        walk(&page.elements, &mut |element, _| match element {
1740            PositionedElement::Text(run) => text.push((run.text.clone(), run.field_kind)),
1741            PositionedElement::LinkAnnotation { url, .. } => links.push(url.clone()),
1742            _ => {}
1743        });
1744        (text, links)
1745    }
1746
1747    fn transformed_link_rect(page: &PageFrame) -> Rect {
1748        let mut result = None;
1749        walk(&page.elements, &mut |element, transform| {
1750            if let PositionedElement::LinkAnnotation { rect, .. } = element {
1751                let top_left = transform.apply(Point {
1752                    x: rect.x,
1753                    y: rect.y,
1754                });
1755                let bottom_right = transform.apply(Point {
1756                    x: rect.x + rect.width,
1757                    y: rect.y + rect.height,
1758                });
1759                result = Some(Rect {
1760                    x: top_left.x,
1761                    y: top_left.y,
1762                    width: bottom_right.x - top_left.x,
1763                    height: bottom_right.y - top_left.y,
1764                });
1765            }
1766        });
1767        result.expect("rendered hyperlink annotation")
1768    }
1769
1770    #[test]
1771    fn slide_number_field_renders_current_page_and_hyperlink_emits_annotation() {
1772        let input = render_input(vec![
1773            slide((200.0, 100.0), Vec::new()),
1774            slide(
1775                (200.0, 100.0),
1776                vec![linked_field_shape(Transform::IDENTITY)],
1777            ),
1778        ]);
1779        let mut fonts = FontManager::new_deterministic().expect("deterministic fonts");
1780
1781        let page = layout_slide_with_fonts(&input, 1, &mut fonts).expect("render slide two");
1782        let (text, links) = rendered_text_and_links(&page);
1783
1784        assert_eq!(text[0], ("2".to_owned(), Some(FieldKind::Page)));
1785        assert!(!links.is_empty());
1786        assert!(links.iter().all(|url| url == "https://example.com/deck"));
1787    }
1788
1789    #[test]
1790    fn grouped_hyperlink_annotation_keeps_transformed_run_bounds() {
1791        let translation = Transform {
1792            e: 30.0,
1793            f: 40.0,
1794            ..Transform::IDENTITY
1795        };
1796        let plain_input = render_input(vec![slide(
1797            (240.0, 180.0),
1798            vec![linked_field_shape(Transform::IDENTITY)],
1799        )]);
1800        let grouped_input = render_input(vec![slide(
1801            (240.0, 180.0),
1802            vec![linked_field_shape(translation)],
1803        )]);
1804        let mut plain_fonts = FontManager::new_deterministic().expect("deterministic fonts");
1805        let mut grouped_fonts = FontManager::new_deterministic().expect("deterministic fonts");
1806        let plain = layout_slide_with_fonts(&plain_input, 0, &mut plain_fonts)
1807            .expect("render ungrouped hyperlink");
1808        let grouped = layout_slide_with_fonts(&grouped_input, 0, &mut grouped_fonts)
1809            .expect("render grouped hyperlink");
1810        let plain_rect = transformed_link_rect(&plain);
1811        let grouped_rect = transformed_link_rect(&grouped);
1812
1813        assert!((grouped_rect.x - plain_rect.x - 30.0).abs() < 1.0e-10);
1814        assert!((grouped_rect.y - plain_rect.y - 40.0).abs() < 1.0e-10);
1815        assert!((grouped_rect.width - plain_rect.width).abs() < 1.0e-10);
1816        assert!((grouped_rect.height - plain_rect.height).abs() < 1.0e-10);
1817    }
1818
1819    #[test]
1820    fn banded_merged_table_renders_correct_fills_without_duplicated_borders() {
1821        let table = ResolvedTable {
1822            right_to_left: false,
1823            column_widths: vec![10.0, 10.0],
1824            rows: vec![
1825                ResolvedTableRow {
1826                    height: 10.0,
1827                    cells: vec![
1828                        ResolvedTableCell {
1829                            fill: Some(Paint::Solid(Color::from_hex("FF0000"))),
1830                            text: Some(table_text("merged")),
1831                            left: Some(table_border()),
1832                            right: Some(table_border()),
1833                            top: Some(table_border()),
1834                            bottom: Some(table_border()),
1835                            grid_span: 2,
1836                            row_span: 1,
1837                            ..ResolvedTableCell::default()
1838                        },
1839                        ResolvedTableCell {
1840                            horizontal_merge: true,
1841                            ..ResolvedTableCell::default()
1842                        },
1843                    ],
1844                },
1845                ResolvedTableRow {
1846                    height: 10.0,
1847                    cells: vec![
1848                        ResolvedTableCell {
1849                            fill: Some(Paint::Solid(Color::from_hex("00FF00"))),
1850                            left: Some(table_border()),
1851                            right: Some(table_border()),
1852                            top: Some(table_border()),
1853                            bottom: Some(table_border()),
1854                            ..ResolvedTableCell::default()
1855                        },
1856                        ResolvedTableCell {
1857                            fill: Some(Paint::Solid(Color::from_hex("0000FF"))),
1858                            left: Some(table_border()),
1859                            right: Some(table_border()),
1860                            top: Some(table_border()),
1861                            bottom: Some(table_border()),
1862                            ..ResolvedTableCell::default()
1863                        },
1864                    ],
1865                },
1866            ],
1867        };
1868        let layout = layout_presentation(&render_input(vec![slide(
1869            (20.0, 20.0),
1870            vec![table_shape(table)],
1871        )]))
1872        .unwrap();
1873        let png = oxml_pdf::render_page_to_png(&layout, 0, 72.0).unwrap();
1874        let pixmap = tiny_skia::Pixmap::decode_png(&png).unwrap();
1875
1876        let red = rgb_at(&pixmap, 5, 5);
1877        assert!(red.0 > 200 && red.1 < 30 && red.2 < 30, "{red:?}");
1878        assert_eq!(rgb_at(&pixmap, 5, 15), (0, 255, 0));
1879        assert_eq!(rgb_at(&pixmap, 15, 15), (0, 0, 255));
1880        let group = only_group(&layout.pages[0].elements[0]);
1881        assert_eq!(
1882            group
1883                .children
1884                .iter()
1885                .filter(|element| matches!(element, PositionedElement::Path(path) if path.stroke.is_some()))
1886                .count(),
1887            11,
1888            "the merged top row removes its internal vertical segment"
1889        );
1890        assert!(group.children.iter().any(
1891            |element| matches!(element, PositionedElement::Text(run) if run.text == "merged")
1892        ));
1893    }
1894
1895    #[test]
1896    fn merged_continuation_cells_do_not_render_fill_border_or_text_twice() {
1897        let table = ResolvedTable {
1898            right_to_left: false,
1899            column_widths: vec![10.0, 10.0],
1900            rows: vec![ResolvedTableRow {
1901                height: 10.0,
1902                cells: vec![
1903                    ResolvedTableCell {
1904                        fill: Some(Paint::Solid(Color::BLACK)),
1905                        grid_span: 2,
1906                        ..ResolvedTableCell::default()
1907                    },
1908                    ResolvedTableCell {
1909                        fill: Some(Paint::Solid(Color::WHITE)),
1910                        horizontal_merge: true,
1911                        ..ResolvedTableCell::default()
1912                    },
1913                ],
1914            }],
1915        };
1916        let page = layout_slide(
1917            &render_input(vec![slide((20.0, 10.0), vec![table_shape(table)])]),
1918            0,
1919        )
1920        .unwrap();
1921        let group = only_group(&page.elements[0]);
1922
1923        assert_eq!(group.children.iter().filter(|element| matches!(element, PositionedElement::Path(path) if path.fill.is_some())).count(), 1);
1924    }
1925
1926    #[test]
1927    fn right_to_left_table_keeps_unequal_logical_column_widths() {
1928        let table = ResolvedTable {
1929            right_to_left: true,
1930            column_widths: vec![10.0, 20.0],
1931            rows: vec![ResolvedTableRow {
1932                height: 10.0,
1933                cells: vec![
1934                    ResolvedTableCell {
1935                        fill: Some(Paint::Solid(Color::from_hex("FF0000"))),
1936                        ..ResolvedTableCell::default()
1937                    },
1938                    ResolvedTableCell {
1939                        fill: Some(Paint::Solid(Color::from_hex("0000FF"))),
1940                        ..ResolvedTableCell::default()
1941                    },
1942                ],
1943            }],
1944        };
1945        let layout = layout_presentation(&render_input(vec![slide(
1946            (30.0, 10.0),
1947            vec![table_shape(table)],
1948        )]))
1949        .unwrap();
1950        let png = oxml_pdf::render_page_to_png(&layout, 0, 72.0).unwrap();
1951        let pixmap = tiny_skia::Pixmap::decode_png(&png).unwrap();
1952
1953        assert_eq!(rgb_at(&pixmap, 5, 5), (0, 0, 255));
1954        assert_eq!(rgb_at(&pixmap, 25, 5), (255, 0, 0));
1955    }
1956
1957    #[test]
1958    fn merged_table_uses_far_continuation_border() {
1959        let outer = ResolvedTableBorder {
1960            stroke: Some(Stroke::new(Paint::Solid(Color::from_hex("FF0000")), 3.0)),
1961            priority: 2,
1962        };
1963        let table = ResolvedTable {
1964            right_to_left: false,
1965            column_widths: vec![10.0, 10.0],
1966            rows: vec![ResolvedTableRow {
1967                height: 10.0,
1968                cells: vec![
1969                    ResolvedTableCell {
1970                        grid_span: 2,
1971                        right: Some(table_border()),
1972                        ..ResolvedTableCell::default()
1973                    },
1974                    ResolvedTableCell {
1975                        horizontal_merge: true,
1976                        right: Some(outer),
1977                        ..ResolvedTableCell::default()
1978                    },
1979                ],
1980            }],
1981        };
1982        let page = layout_slide(
1983            &render_input(vec![slide((20.0, 10.0), vec![table_shape(table)])]),
1984            0,
1985        )
1986        .unwrap();
1987        let group = only_group(&page.elements[0]);
1988        let far_border = group.children.iter().find_map(|element| {
1989            let PositionedElement::Path(path) = element else {
1990                return None;
1991            };
1992            match path.path.commands.as_slice() {
1993                [PathCommand::MoveTo(start), PathCommand::LineTo(end)]
1994                    if start.x == 20.0 && end.x == 20.0 =>
1995                {
1996                    path.stroke.as_ref()
1997                }
1998                _ => None,
1999            }
2000        });
2001
2002        let far_border = far_border.expect("merged far edge should be emitted");
2003        assert_eq!(far_border.width, 3.0);
2004        assert_eq!(far_border.paint, Paint::Solid(Color::from_hex("FF0000")));
2005    }
2006
2007    #[test]
2008    fn table_cell_margins_place_text_in_the_fixed_content_box() {
2009        let table = ResolvedTable {
2010            right_to_left: false,
2011            column_widths: vec![20.0],
2012            rows: vec![ResolvedTableRow {
2013                height: 20.0,
2014                cells: vec![ResolvedTableCell {
2015                    text: Some(table_text("cell")),
2016                    margins: TextInsets {
2017                        left: 2.0,
2018                        top: 3.0,
2019                        right: 4.0,
2020                        bottom: 5.0,
2021                    },
2022                    ..ResolvedTableCell::default()
2023                }],
2024            }],
2025        };
2026        let page = layout_slide(
2027            &render_input(vec![slide((20.0, 20.0), vec![table_shape(table)])]),
2028            0,
2029        )
2030        .unwrap();
2031        let group = only_group(&page.elements[0]);
2032        let PositionedElement::Text(run) = group
2033            .children
2034            .iter()
2035            .find(|element| matches!(element, PositionedElement::Text(_)))
2036            .expect("cell text should remain visible")
2037        else {
2038            unreachable!()
2039        };
2040
2041        assert!(run.origin.x >= 2.0);
2042        assert!(run.origin.y >= 3.0);
2043    }
2044
2045    fn slide(size: (f64, f64), shapes: Vec<ResolvedShape>) -> ResolvedSlide {
2046        ResolvedSlide {
2047            size,
2048            background: None,
2049            shapes,
2050            diagnostics: Vec::new(),
2051        }
2052    }
2053
2054    fn render_input(slides: Vec<ResolvedSlide>) -> RenderInput {
2055        RenderInput {
2056            slides,
2057            media: HashMap::new(),
2058            fonts: Vec::new(),
2059            metadata: None,
2060        }
2061    }
2062
2063    fn only_group(element: &PositionedElement) -> &GroupElement {
2064        let PositionedElement::Group(group) = element else {
2065            panic!("shape should lower to one group");
2066        };
2067        group
2068    }
2069
2070    #[test]
2071    fn timeline_lowering_retains_hidden_identity_slots_and_uses_shape_local_clips() {
2072        let first = shape(
2073            Rect {
2074                x: 10.0,
2075                y: 20.0,
2076                width: 40.0,
2077                height: 20.0,
2078            },
2079            ResolvedGeometry::Rectangle,
2080            Some(Paint::Solid(Color::WHITE)),
2081            None,
2082        );
2083        let second = shape(
2084            Rect {
2085                x: 60.0,
2086                y: 20.0,
2087                width: 20.0,
2088                height: 20.0,
2089            },
2090            ResolvedGeometry::Rectangle,
2091            Some(Paint::Solid(Color::BLACK)),
2092            None,
2093        );
2094        let input = render_input(vec![slide((100.0, 60.0), vec![first, second])]);
2095        let mut hidden_state = rpptx_layout::timeline::EvaluatedShapeState::default();
2096        hidden_state.visible = false;
2097        hidden_state.clip = Some(Rect {
2098            x: 0.0,
2099            y: 0.0,
2100            width: 0.25,
2101            height: 1.0,
2102        });
2103        let states = [
2104            hidden_state,
2105            rpptx_layout::timeline::EvaluatedShapeState::default(),
2106        ];
2107        let mut fonts = FontManager::new_deterministic().unwrap();
2108        let page = layout_slide_with_fonts_text_directions_and_states(
2109            &input,
2110            0,
2111            &mut fonts,
2112            None,
2113            Some(&states),
2114        )
2115        .unwrap();
2116
2117        assert_eq!(page.elements.len(), 2);
2118        let hidden = only_group(&page.elements[0]);
2119        assert_eq!(hidden.opacity, 0.0);
2120        assert_eq!(hidden.transform.e, 10.0);
2121        assert_eq!(
2122            hidden.clip,
2123            Some(Path::rect(Rect {
2124                x: 0.0,
2125                y: 0.0,
2126                width: 10.0,
2127                height: 20.0,
2128            }))
2129        );
2130        assert_eq!(only_group(&page.elements[1]).opacity, 1.0);
2131    }
2132
2133    #[test]
2134    fn resolved_outer_shadow_is_lowered_to_the_shape_group() {
2135        let effect = Effect::OuterShadow {
2136            dx: 3.0,
2137            dy: 4.0,
2138            blur: 2.0,
2139            color: Color {
2140                r: 0.5,
2141                g: 0.25,
2142                b: 0.0,
2143                a: 0.75,
2144            },
2145        };
2146        let mut shadowed = shape(
2147            Rect {
2148                x: 2.0,
2149                y: 3.0,
2150                width: 8.0,
2151                height: 6.0,
2152            },
2153            ResolvedGeometry::Rectangle,
2154            Some(Paint::Solid(Color::WHITE)),
2155            None,
2156        );
2157        shadowed.shadow = Some(effect.clone());
2158
2159        let page =
2160            layout_slide(&render_input(vec![slide((20.0, 20.0), vec![shadowed])]), 0).unwrap();
2161
2162        assert_eq!(only_group(&page.elements[0]).effects, vec![effect]);
2163    }
2164
2165    fn assert_point_close(actual: Point, expected: Point) {
2166        const EPSILON: f64 = 1.0e-10;
2167        assert!(
2168            (actual.x - expected.x).abs() < EPSILON && (actual.y - expected.y).abs() < EPSILON,
2169            "expected ({}, {}), got ({}, {})",
2170            expected.x,
2171            expected.y,
2172            actual.x,
2173            actual.y
2174        );
2175    }
2176
2177    #[test]
2178    fn rotated_shape_corners_match_hand_computed_coordinates() {
2179        let mut rotated = shape(
2180            Rect {
2181                x: 10.0,
2182                y: 20.0,
2183                width: 8.0,
2184                height: 4.0,
2185            },
2186            ResolvedGeometry::Rectangle,
2187            Some(Paint::Solid(Color::BLACK)),
2188            None,
2189        );
2190        rotated.rotation_deg = 30.0;
2191        let page = layout_slide(&render_input(vec![slide((40.0, 40.0), vec![rotated])]), 0)
2192            .expect("lower rotated shape");
2193        let transform = only_group(&page.elements[0]).transform;
2194        let radians = 30.0_f64.to_radians();
2195        let (sin, cos) = radians.sin_cos();
2196
2197        for corner in [
2198            Point { x: 0.0, y: 0.0 },
2199            Point { x: 8.0, y: 0.0 },
2200            Point { x: 0.0, y: 4.0 },
2201            Point { x: 8.0, y: 4.0 },
2202        ] {
2203            let dx = corner.x - 4.0;
2204            let dy = corner.y - 2.0;
2205            let expected = Point {
2206                x: 10.0 + 4.0 + cos * dx - sin * dy,
2207                y: 20.0 + 2.0 + sin * dx + cos * dy,
2208            };
2209            assert_point_close(transform.apply(corner), expected);
2210        }
2211    }
2212
2213    #[test]
2214    fn horizontal_and_vertical_flips_are_about_the_shape_centre() {
2215        let bounds = Rect {
2216            x: 10.0,
2217            y: 20.0,
2218            width: 8.0,
2219            height: 4.0,
2220        };
2221        let mut horizontal = shape(
2222            bounds,
2223            ResolvedGeometry::Rectangle,
2224            Some(Paint::Solid(Color::BLACK)),
2225            None,
2226        );
2227        horizontal.flip_h = true;
2228        let mut vertical = horizontal.clone();
2229        vertical.flip_h = false;
2230        vertical.flip_v = true;
2231        let page = layout_slide(
2232            &render_input(vec![slide((40.0, 40.0), vec![horizontal, vertical])]),
2233            0,
2234        )
2235        .expect("lower flipped shapes");
2236        let horizontal = only_group(&page.elements[0]).transform;
2237        let vertical = only_group(&page.elements[1]).transform;
2238
2239        assert_point_close(
2240            horizontal.apply(Point { x: 4.0, y: 2.0 }),
2241            Point { x: 14.0, y: 22.0 },
2242        );
2243        assert_point_close(
2244            horizontal.apply(Point { x: 0.0, y: 0.0 }),
2245            Point { x: 18.0, y: 20.0 },
2246        );
2247        assert_point_close(
2248            horizontal.apply(Point { x: 8.0, y: 4.0 }),
2249            Point { x: 10.0, y: 24.0 },
2250        );
2251        assert_point_close(
2252            vertical.apply(Point { x: 4.0, y: 2.0 }),
2253            Point { x: 14.0, y: 22.0 },
2254        );
2255        assert_point_close(
2256            vertical.apply(Point { x: 0.0, y: 0.0 }),
2257            Point { x: 10.0, y: 24.0 },
2258        );
2259        assert_point_close(
2260            vertical.apply(Point { x: 8.0, y: 4.0 }),
2261            Point { x: 18.0, y: 20.0 },
2262        );
2263    }
2264
2265    #[test]
2266    fn nested_group_transform_applies_child_before_parent() {
2267        let mut nested = shape(
2268            Rect {
2269                x: 10.0,
2270                y: 20.0,
2271                width: 8.0,
2272                height: 4.0,
2273            },
2274            ResolvedGeometry::Rectangle,
2275            Some(Paint::Solid(Color::BLACK)),
2276            None,
2277        );
2278        nested.rotation_deg = 90.0;
2279        nested.flip_h = true;
2280        nested.group_transform = Transform {
2281            a: 2.0,
2282            b: 0.0,
2283            c: 0.0,
2284            d: 3.0,
2285            e: 5.0,
2286            f: 7.0,
2287        };
2288        let page = layout_slide(&render_input(vec![slide((80.0, 100.0), vec![nested])]), 0)
2289            .expect("lower nested shape");
2290        let transform = only_group(&page.elements[0]).transform;
2291
2292        assert_point_close(
2293            transform.apply(Point { x: 0.0, y: 0.0 }),
2294            Point { x: 29.0, y: 61.0 },
2295        );
2296        assert_point_close(
2297            transform.apply(Point { x: 8.0, y: 4.0 }),
2298            Point { x: 37.0, y: 85.0 },
2299        );
2300    }
2301
2302    #[test]
2303    fn group_mapping_does_not_clip_a_child_outside_group_bounds() {
2304        let mut outside = shape(
2305            Rect {
2306                x: 40.0,
2307                y: 20.0,
2308                width: 8.0,
2309                height: 4.0,
2310            },
2311            ResolvedGeometry::Rectangle,
2312            Some(Paint::Solid(Color::BLACK)),
2313            None,
2314        );
2315        outside.group_transform = Transform {
2316            e: 30.0,
2317            f: 10.0,
2318            ..Transform::IDENTITY
2319        };
2320        let page = layout_slide(&render_input(vec![slide((60.0, 40.0), vec![outside])]), 0)
2321            .expect("lower grouped child outside nominal group bounds");
2322        let group = only_group(&page.elements[0]);
2323
2324        assert_eq!(group.clip, None);
2325        assert_point_close(
2326            group.transform.apply(Point { x: 0.0, y: 0.0 }),
2327            Point { x: 70.0, y: 30.0 },
2328        );
2329    }
2330
2331    #[test]
2332    fn rotated_gradient_and_outline_share_the_shape_transform() {
2333        let red = color(1.0, 0.0, 0.0);
2334        let blue = color(0.0, 0.0, 1.0);
2335        let mut rotated = shape(
2336            Rect {
2337                x: 8.0,
2338                y: 8.0,
2339                width: 12.0,
2340                height: 6.0,
2341            },
2342            ResolvedGeometry::Rectangle,
2343            Some(Paint::linear(
2344                Point { x: 0.0, y: 0.0 },
2345                Point { x: 12.0, y: 0.0 },
2346                vec![
2347                    GradientStop {
2348                        offset: 0.0,
2349                        color: red,
2350                    },
2351                    GradientStop {
2352                        offset: 0.49,
2353                        color: red,
2354                    },
2355                    GradientStop {
2356                        offset: 0.51,
2357                        color: blue,
2358                    },
2359                    GradientStop {
2360                        offset: 1.0,
2361                        color: blue,
2362                    },
2363                ],
2364                (true, true),
2365            )),
2366            Some(Stroke::new(Paint::Solid(Color::BLACK), 2.0)),
2367        );
2368        rotated.rotation_deg = 90.0;
2369        let layout = layout_presentation(&render_input(vec![slide((28.0, 24.0), vec![rotated])]))
2370            .expect("lower rotated gradient");
2371        let png =
2372            oxml_pdf::render_page_to_png(&layout, 0, 72.0).expect("rasterise rotated gradient");
2373        let pixmap = tiny_skia::Pixmap::decode_png(&png).expect("decode rotated gradient");
2374        let rgb = |x, y| {
2375            let pixel = pixmap.pixel(x, y).expect("sample lies inside page");
2376            (pixel.red(), pixel.green(), pixel.blue())
2377        };
2378
2379        assert_eq!(rgb(14, 7), (255, 0, 0));
2380        assert_eq!(rgb(14, 15), (0, 0, 255));
2381        assert_eq!(rgb(11, 11), (0, 0, 0));
2382        assert_eq!(rgb(8, 8), (255, 255, 255));
2383    }
2384
2385    #[test]
2386    fn solid_gradient_and_outlined_shapes_rasterise_at_sampled_pixels() {
2387        let red = color(1.0, 0.0, 0.0);
2388        let blue = color(0.0, 0.0, 1.0);
2389        let green = color(0.0, 1.0, 0.0);
2390        let gradient = Paint::linear(
2391            Point { x: 0.0, y: 0.0 },
2392            Point { x: 8.0, y: 0.0 },
2393            vec![
2394                GradientStop {
2395                    offset: 0.0,
2396                    color: red,
2397                },
2398                GradientStop {
2399                    offset: 0.49,
2400                    color: red,
2401                },
2402                GradientStop {
2403                    offset: 0.51,
2404                    color: blue,
2405                },
2406                GradientStop {
2407                    offset: 1.0,
2408                    color: blue,
2409                },
2410            ],
2411            (true, true),
2412        );
2413        let input = render_input(vec![slide(
2414            (40.0, 14.0),
2415            vec![
2416                shape(
2417                    Rect {
2418                        x: 2.0,
2419                        y: 2.0,
2420                        width: 8.0,
2421                        height: 8.0,
2422                    },
2423                    ResolvedGeometry::Rectangle,
2424                    Some(Paint::Solid(red)),
2425                    None,
2426                ),
2427                shape(
2428                    Rect {
2429                        x: 14.0,
2430                        y: 2.0,
2431                        width: 8.0,
2432                        height: 8.0,
2433                    },
2434                    ResolvedGeometry::Rectangle,
2435                    Some(gradient),
2436                    None,
2437                ),
2438                shape(
2439                    Rect {
2440                        x: 26.0,
2441                        y: 2.0,
2442                        width: 8.0,
2443                        height: 8.0,
2444                    },
2445                    ResolvedGeometry::Rectangle,
2446                    None,
2447                    Some(Stroke::new(Paint::Solid(green), 2.0)),
2448                ),
2449            ],
2450        )]);
2451
2452        let layout = layout_presentation(&input).expect("lower shape slide");
2453        let png = oxml_pdf::render_page_to_png(&layout, 0, 72.0).expect("rasterise shape slide");
2454        let pixmap = tiny_skia::Pixmap::decode_png(&png).expect("decode shape slide");
2455        let rgb = |x, y| {
2456            let pixel = pixmap.pixel(x, y).expect("sample lies inside page");
2457            (pixel.red(), pixel.green(), pixel.blue())
2458        };
2459
2460        assert_eq!(rgb(5, 5), (255, 0, 0));
2461        assert_eq!(rgb(15, 5), (255, 0, 0));
2462        assert_eq!(rgb(20, 5), (0, 0, 255));
2463        assert_eq!(rgb(26, 5), (0, 255, 0));
2464        assert_eq!(rgb(30, 5), (255, 255, 255));
2465        assert_eq!(rgb(38, 5), (255, 255, 255));
2466    }
2467
2468    #[test]
2469    fn preset_and_custom_geometry_lower_to_ordered_paths() {
2470        let first = Path {
2471            commands: vec![
2472                PathCommand::MoveTo(Point { x: 0.0, y: 0.0 }),
2473                PathCommand::LineTo(Point { x: 4.0, y: 0.0 }),
2474            ],
2475            fill_rule: FillRule::NonZero,
2476        };
2477        let second = Path {
2478            commands: vec![
2479                PathCommand::MoveTo(Point { x: 0.0, y: 1.0 }),
2480                PathCommand::LineTo(Point { x: 4.0, y: 1.0 }),
2481            ],
2482            fill_rule: FillRule::EvenOdd,
2483        };
2484        let fill = Paint::Solid(Color::BLACK);
2485        let line = Stroke::new(Paint::Solid(Color::WHITE), 2.0);
2486        let input = render_input(vec![slide(
2487            (20.0, 20.0),
2488            vec![
2489                shape(
2490                    Rect {
2491                        x: 2.0,
2492                        y: 3.0,
2493                        width: 4.0,
2494                        height: 5.0,
2495                    },
2496                    ResolvedGeometry::Rectangle,
2497                    Some(fill.clone()),
2498                    Some(line.clone()),
2499                ),
2500                shape(
2501                    Rect {
2502                        x: 8.0,
2503                        y: 9.0,
2504                        width: 4.0,
2505                        height: 5.0,
2506                    },
2507                    ResolvedGeometry::Custom {
2508                        paths: vec![first.clone(), second.clone()],
2509                        text_rect: None,
2510                    },
2511                    Some(fill.clone()),
2512                    Some(line.clone()),
2513                ),
2514            ],
2515        )]);
2516
2517        let page = layout_slide(&input, 0).expect("lower first slide");
2518        assert_eq!(page.elements.len(), 2);
2519        let rectangle = only_group(&page.elements[0]);
2520        assert_eq!(
2521            rectangle.transform,
2522            Transform {
2523                e: 2.0,
2524                f: 3.0,
2525                ..Transform::IDENTITY
2526            }
2527        );
2528        let PositionedElement::Path(rectangle) = &rectangle.children[0] else {
2529            panic!("rectangle should lower to a path");
2530        };
2531        assert_eq!(
2532            rectangle.path,
2533            Path::rect(Rect {
2534                x: 0.0,
2535                y: 0.0,
2536                width: 4.0,
2537                height: 5.0
2538            })
2539        );
2540        assert_eq!(rectangle.fill, Some(fill.clone()));
2541        assert_eq!(rectangle.stroke, Some(line.clone()));
2542
2543        let custom = only_group(&page.elements[1]);
2544        assert_eq!(custom.children.len(), 2);
2545        for (element, expected) in custom.children.iter().zip([first, second]) {
2546            let PositionedElement::Path(element) = element else {
2547                panic!("custom geometry should lower to paths");
2548            };
2549            assert_eq!(element.path, expected);
2550            assert_eq!(element.fill, Some(fill.clone()));
2551            assert_eq!(element.stroke, Some(line.clone()));
2552        }
2553    }
2554
2555    #[test]
2556    fn bounds_fallback_emits_a_visible_black_outline() {
2557        let input = render_input(vec![slide(
2558            (20.0, 20.0),
2559            vec![shape(
2560                Rect {
2561                    x: 2.0,
2562                    y: 3.0,
2563                    width: 4.0,
2564                    height: 5.0,
2565                },
2566                ResolvedGeometry::BoundsFallback,
2567                None,
2568                None,
2569            )],
2570        )]);
2571
2572        let page = layout_slide(&input, 0).expect("lower fallback slide");
2573        let group = only_group(&page.elements[0]);
2574        let PositionedElement::Path(path) = &group.children[0] else {
2575            panic!("fallback should lower to a path");
2576        };
2577        assert_eq!(path.fill, None);
2578        assert_eq!(
2579            path.stroke,
2580            Some(Stroke::new(Paint::Solid(Color::BLACK), 1.0))
2581        );
2582    }
2583
2584    #[test]
2585    fn triangular_tail_end_emits_an_extra_filled_path() {
2586        let paint = Paint::Solid(color(1.0, 0.0, 0.0));
2587        let mut arrow = shape(
2588            Rect {
2589                x: 2.0,
2590                y: 3.0,
2591                width: 10.0,
2592                height: 10.0,
2593            },
2594            ResolvedGeometry::Custom {
2595                paths: vec![open_line(
2596                    Point { x: 0.0, y: 5.0 },
2597                    Point { x: 10.0, y: 5.0 },
2598                )],
2599                text_rect: None,
2600            },
2601            None,
2602            Some(Stroke::new(paint.clone(), 2.0)),
2603        );
2604        arrow.tail_end = Some(line_end(ResolvedLineEndKind::Triangle));
2605
2606        let layout = layout_presentation(&render_input(vec![slide((20.0, 20.0), vec![arrow])]))
2607            .expect("lower triangular tail");
2608        let group = only_group(&layout.pages[0].elements[0]);
2609        assert_eq!(group.children.len(), 2);
2610        let PositionedElement::Path(end) = &group.children[1] else {
2611            panic!("tail end should lower to a path");
2612        };
2613        assert_eq!(end.fill, Some(paint));
2614        assert_eq!(end.stroke, None);
2615        assert!(matches!(end.path.commands.last(), Some(PathCommand::Close)));
2616        assert_eq!(
2617            end.path.commands.first(),
2618            Some(&PathCommand::MoveTo(Point { x: 10.0, y: 5.0 }))
2619        );
2620
2621        let png =
2622            oxml_pdf::render_page_to_png(&layout, 0, 72.0).expect("rasterise triangular tail");
2623        let pixmap = tiny_skia::Pixmap::decode_png(&png).expect("decode triangular tail");
2624        let endpoint_pixel = pixmap.pixel(7, 6).expect("sample lies inside page");
2625        assert_eq!(
2626            (
2627                endpoint_pixel.red(),
2628                endpoint_pixel.green(),
2629                endpoint_pixel.blue()
2630            ),
2631            (255, 0, 0)
2632        );
2633    }
2634
2635    #[test]
2636    fn head_end_uses_the_reversed_start_tangent() {
2637        let mut arrow = shape(
2638            Rect {
2639                x: 0.0,
2640                y: 0.0,
2641                width: 20.0,
2642                height: 10.0,
2643            },
2644            ResolvedGeometry::Custom {
2645                paths: vec![open_line(
2646                    Point { x: 5.0, y: 5.0 },
2647                    Point { x: 15.0, y: 5.0 },
2648                )],
2649                text_rect: None,
2650            },
2651            None,
2652            Some(Stroke::new(Paint::Solid(Color::BLACK), 2.0)),
2653        );
2654        arrow.head_end = Some(line_end(ResolvedLineEndKind::Triangle));
2655        arrow.tail_end = Some(line_end(ResolvedLineEndKind::Triangle));
2656
2657        let page = layout_slide(&render_input(vec![slide((20.0, 10.0), vec![arrow])]), 0)
2658            .expect("lower opposite ends");
2659        let group = only_group(&page.elements[0]);
2660        let PositionedElement::Path(head) = &group.children[1] else {
2661            panic!("head end should be a path");
2662        };
2663        let PositionedElement::Path(tail) = &group.children[2] else {
2664            panic!("tail end should be a path");
2665        };
2666        assert_eq!(
2667            head.path.commands[1],
2668            PathCommand::LineTo(Point { x: 11.0, y: 2.0 })
2669        );
2670        assert_eq!(
2671            tail.path.commands[1],
2672            PathCommand::LineTo(Point { x: 9.0, y: 8.0 })
2673        );
2674    }
2675
2676    #[test]
2677    fn all_supported_line_end_kinds_produce_finite_geometry() {
2678        let tangent = EndpointTangent {
2679            point: Point { x: 10.0, y: 5.0 },
2680            outward: Point { x: 1.0, y: 0.0 },
2681        };
2682        for kind in [
2683            ResolvedLineEndKind::Triangle,
2684            ResolvedLineEndKind::Stealth,
2685            ResolvedLineEndKind::Diamond,
2686            ResolvedLineEndKind::Oval,
2687            ResolvedLineEndKind::Arrow,
2688        ] {
2689            let path = line_end_path(&line_end(kind), tangent, 2.0)
2690                .expect("supported endpoint should produce geometry");
2691            assert!(path_is_finite(&path));
2692            assert!(matches!(path.commands.last(), Some(PathCommand::Close)));
2693            let bounds = path.bounds().expect("endpoint should have bounds");
2694            assert!(bounds.x >= 4.0 && bounds.x + bounds.width <= 10.0);
2695            assert!(bounds.y >= 2.0 && bounds.y + bounds.height <= 8.0);
2696        }
2697        assert_eq!(line_end_factor(ResolvedLineEndSize::Small), 2.0);
2698        assert_eq!(line_end_factor(ResolvedLineEndSize::Medium), 3.0);
2699        assert_eq!(line_end_factor(ResolvedLineEndSize::Large), 5.0);
2700    }
2701
2702    #[test]
2703    fn zero_length_segment_omits_arrowhead_without_panicking() {
2704        let point = Point { x: 5.0, y: 5.0 };
2705        let mut arrow = shape(
2706            Rect {
2707                x: 0.0,
2708                y: 0.0,
2709                width: 10.0,
2710                height: 10.0,
2711            },
2712            ResolvedGeometry::Custom {
2713                paths: vec![open_line(point, point)],
2714                text_rect: None,
2715            },
2716            None,
2717            Some(Stroke::new(Paint::Solid(Color::BLACK), 2.0)),
2718        );
2719        arrow.tail_end = Some(line_end(ResolvedLineEndKind::Triangle));
2720
2721        let page = layout_slide(&render_input(vec![slide((10.0, 10.0), vec![arrow])]), 0)
2722            .expect("lower zero-length line");
2723        assert_eq!(only_group(&page.elements[0]).children.len(), 1);
2724    }
2725
2726    #[test]
2727    fn cropped_picture_renders_only_its_crop_region() {
2728        let png = horizontal_png(&[
2729            [255, 0, 0, 255],
2730            [0, 255, 0, 255],
2731            [0, 255, 0, 255],
2732            [0, 255, 0, 255],
2733        ]);
2734        let media_id = MediaId::from_bytes(&png);
2735        let picture = picture_shape(
2736            Rect {
2737                x: 2.0,
2738                y: 2.0,
2739                width: 8.0,
2740                height: 4.0,
2741            },
2742            media_id,
2743            Some(CropRect {
2744                left: 0.25,
2745                ..CropRect::default()
2746            }),
2747            ResolvedImagePlacement::default(),
2748            None,
2749        );
2750        let input = render_input_with_media(
2751            vec![slide((12.0, 8.0), vec![picture])],
2752            HashMap::from([(media_id, media(&png))]),
2753        );
2754
2755        let layout = layout_presentation(&input).expect("lower cropped picture");
2756        let rendered =
2757            oxml_pdf::render_page_to_png(&layout, 0, 72.0).expect("rasterise cropped picture");
2758        let pixmap = tiny_skia::Pixmap::decode_png(&rendered).expect("decode cropped picture");
2759        let rgb = |x, y| {
2760            let pixel = pixmap.pixel(x, y).expect("sample lies inside page");
2761            (pixel.red(), pixel.green(), pixel.blue())
2762        };
2763
2764        assert_eq!(rgb(3, 4), (0, 255, 0));
2765        assert_eq!(rgb(8, 4), (0, 255, 0));
2766        assert_eq!(rgb(1, 4), (255, 255, 255));
2767    }
2768
2769    #[test]
2770    fn crop_lowers_to_clipped_source_image_geometry() {
2771        let media_id = MediaId(21);
2772        let mut picture = picture_shape(
2773            Rect {
2774                x: 2.0,
2775                y: 3.0,
2776                width: 10.0,
2777                height: 8.0,
2778            },
2779            media_id,
2780            Some(CropRect {
2781                left: 0.25,
2782                right: 0.25,
2783                ..CropRect::default()
2784            }),
2785            ResolvedImagePlacement::default(),
2786            None,
2787        );
2788        picture.line = Some(Stroke::new(Paint::Solid(Color::BLACK), 1.0));
2789        let input = render_input_with_media(
2790            vec![slide((20.0, 20.0), vec![picture])],
2791            HashMap::from([(media_id, media(b"image"))]),
2792        );
2793
2794        let page = layout_slide(&input, 0).expect("lower crop geometry");
2795        let shape_group = only_group(&page.elements[0]);
2796        assert_eq!(shape_group.children.len(), 2, "outline must follow picture");
2797        let crop_clip = only_group(&shape_group.children[0]);
2798        assert_eq!(
2799            crop_clip.clip,
2800            Some(Path::rect(Rect {
2801                x: 0.0,
2802                y: 0.0,
2803                width: 10.0,
2804                height: 8.0,
2805            }))
2806        );
2807        let PositionedElement::Image {
2808            rect, media_id: id, ..
2809        } = &crop_clip.children[0]
2810        else {
2811            panic!("crop group should contain the expanded source image");
2812        };
2813        assert_eq!(*id, media_id);
2814        assert_eq!(
2815            *rect,
2816            Rect {
2817                x: -5.0,
2818                y: 0.0,
2819                width: 20.0,
2820                height: 8.0,
2821            }
2822        );
2823        let PositionedElement::Path(outline) = &shape_group.children[1] else {
2824            panic!("picture outline should remain above image content");
2825        };
2826        assert!(outline.stroke.is_some());
2827    }
2828
2829    #[test]
2830    fn shape_picture_fill_is_clipped_below_stroke_and_text() {
2831        let media_id = MediaId(22);
2832        let clip_path = Path {
2833            commands: vec![
2834                PathCommand::MoveTo(Point { x: 0.0, y: 0.0 }),
2835                PathCommand::LineTo(Point { x: 10.0, y: 0.0 }),
2836                PathCommand::LineTo(Point { x: 5.0, y: 10.0 }),
2837                PathCommand::Close,
2838            ],
2839            fill_rule: FillRule::NonZero,
2840        };
2841        let mut filled = shape(
2842            Rect {
2843                x: 0.0,
2844                y: 0.0,
2845                width: 10.0,
2846                height: 10.0,
2847            },
2848            ResolvedGeometry::Custom {
2849                paths: vec![clip_path.clone()],
2850                text_rect: None,
2851            },
2852            None,
2853            Some(Stroke::new(Paint::Solid(Color::BLACK), 1.0)),
2854        );
2855        filled.image_fill = Some(ResolvedImage {
2856            media: media_id,
2857            src_rect: None,
2858            placement: ResolvedImagePlacement::default(),
2859            dpi: None,
2860            rotate_with_shape: true,
2861        });
2862        filled.content = ResolvedContent::Text(table_text("caption"));
2863
2864        let mut fallback = shape(
2865            Rect {
2866                x: 10.0,
2867                y: 0.0,
2868                width: 10.0,
2869                height: 10.0,
2870            },
2871            ResolvedGeometry::BoundsFallback,
2872            None,
2873            None,
2874        );
2875        fallback.image_fill = filled.image_fill.clone();
2876        let input = render_input_with_media(
2877            vec![slide((20.0, 10.0), vec![filled, fallback])],
2878            HashMap::from([(media_id, media(b"image"))]),
2879        );
2880
2881        let page = layout_slide(&input, 0).expect("lower shape picture fill");
2882        let filled_group = only_group(&page.elements[0]);
2883        assert!(
2884            filled_group.children.len() > 2,
2885            "text must follow image and stroke"
2886        );
2887        let image_clip = only_group(&filled_group.children[0]);
2888        assert_eq!(image_clip.clip, Some(clip_path));
2889        assert!(matches!(
2890            image_clip.children.as_slice(),
2891            [PositionedElement::Image { media_id: id, .. }] if *id == media_id
2892        ));
2893        let PositionedElement::Path(outline) = &filled_group.children[1] else {
2894            panic!("shape stroke must follow its image fill");
2895        };
2896        assert!(outline.fill.is_none());
2897        assert!(outline.stroke.is_some());
2898
2899        let fallback_group = only_group(&page.elements[1]);
2900        let PositionedElement::Path(bounds) = &fallback_group.children[1] else {
2901            panic!("bounds path must follow its image fill");
2902        };
2903        assert!(
2904            bounds.stroke.is_none(),
2905            "image fill suppresses synthetic border"
2906        );
2907    }
2908
2909    #[test]
2910    fn tile_picture_repeats_media_in_row_major_order_inside_shape_clip() {
2911        let png = horizontal_png(&[[255, 0, 0, 255]]);
2912        let media_id = MediaId::from_bytes(&png);
2913        let picture = picture_shape(
2914            Rect {
2915                x: 2.0,
2916                y: 2.0,
2917                width: 3.0,
2918                height: 2.0,
2919            },
2920            media_id,
2921            None,
2922            ResolvedImagePlacement::Tile(ResolvedTilePlacement {
2923                translation: Point { x: 0.0, y: 0.0 },
2924                scale_x: 1.0,
2925                scale_y: 1.0,
2926                flip: ResolvedTileFlip::None,
2927                alignment: ResolvedRectAlignment::TopLeft,
2928            }),
2929            Some(72.0),
2930        );
2931        let input = render_input_with_media(
2932            vec![slide((8.0, 6.0), vec![picture])],
2933            HashMap::from([(media_id, media(&png))]),
2934        );
2935
2936        let layout = layout_presentation(&input).expect("lower tiled picture");
2937        let shape_group = only_group(&layout.pages[0].elements[0]);
2938        let tiles = only_group(&shape_group.children[0]);
2939        let rects = tiles
2940            .children
2941            .iter()
2942            .map(|tile| match tile {
2943                PositionedElement::Image { rect, .. } => *rect,
2944                _ => panic!("unflipped tile should be one image"),
2945            })
2946            .collect::<Vec<_>>();
2947        assert_eq!(
2948            rects,
2949            [
2950                Rect {
2951                    x: 0.0,
2952                    y: 0.0,
2953                    width: 1.0,
2954                    height: 1.0
2955                },
2956                Rect {
2957                    x: 1.0,
2958                    y: 0.0,
2959                    width: 1.0,
2960                    height: 1.0
2961                },
2962                Rect {
2963                    x: 2.0,
2964                    y: 0.0,
2965                    width: 1.0,
2966                    height: 1.0
2967                },
2968                Rect {
2969                    x: 0.0,
2970                    y: 1.0,
2971                    width: 1.0,
2972                    height: 1.0
2973                },
2974                Rect {
2975                    x: 1.0,
2976                    y: 1.0,
2977                    width: 1.0,
2978                    height: 1.0
2979                },
2980                Rect {
2981                    x: 2.0,
2982                    y: 1.0,
2983                    width: 1.0,
2984                    height: 1.0
2985                },
2986            ]
2987        );
2988        let rendered =
2989            oxml_pdf::render_page_to_png(&layout, 0, 72.0).expect("rasterise tiled picture");
2990        let pixmap = tiny_skia::Pixmap::decode_png(&rendered).expect("decode tiled picture");
2991        assert_eq!(rgb_at(&pixmap, 3, 3), (255, 0, 0));
2992        assert_eq!(rgb_at(&pixmap, 1, 3), (255, 255, 255));
2993        assert_eq!(rgb_at(&pixmap, 5, 3), (255, 255, 255));
2994
2995        assert_eq!(
2996            tile_alignment_origin(
2997                Rect {
2998                    x: 0.0,
2999                    y: 0.0,
3000                    width: 3.0,
3001                    height: 2.0,
3002                },
3003                1.0,
3004                1.0,
3005                ResolvedRectAlignment::BottomRight,
3006            ),
3007            Point { x: 2.0, y: 1.0 }
3008        );
3009        assert_eq!(repeated_origin(3.5, 0.0, 1.0), -0.5);
3010        assert_eq!(repeated_origin(0.0, -2.1, 1.0), -3.0);
3011        assert_eq!(repeated_tile_index(-0.5, 2.5, 1.0, media_id).unwrap(), -3);
3012        let flipped = flip_tile(
3013            picture_image(media_id, input.media.get(&media_id).unwrap(), rects[4]),
3014            rects[4],
3015            ResolvedTileFlip::Both,
3016            true,
3017            true,
3018        );
3019        let flipped = only_group(&flipped);
3020        assert_eq!(
3021            flipped.transform,
3022            Transform {
3023                a: -1.0,
3024                b: 0.0,
3025                c: 0.0,
3026                d: -1.0,
3027                e: 3.0,
3028                f: 3.0,
3029            }
3030        );
3031    }
3032
3033    #[test]
3034    fn picture_rotation_policy_counter_rotates_only_image_content() {
3035        let png = horizontal_png(&[[255, 0, 0, 255]]);
3036        let media_id = MediaId::from_bytes(&png);
3037        let mut picture = picture_shape(
3038            Rect {
3039                x: 5.0,
3040                y: 5.0,
3041                width: 10.0,
3042                height: 10.0,
3043            },
3044            media_id,
3045            None,
3046            ResolvedImagePlacement::default(),
3047            None,
3048        );
3049        picture.rotation_deg = 45.0;
3050        let ResolvedContent::Image(image) = &mut picture.content else {
3051            unreachable!();
3052        };
3053        image.rotate_with_shape = false;
3054        let mut tiled_picture = picture_shape(
3055            Rect {
3056                x: 25.0,
3057                y: 5.0,
3058                width: 10.0,
3059                height: 10.0,
3060            },
3061            media_id,
3062            None,
3063            ResolvedImagePlacement::Tile(ResolvedTilePlacement {
3064                translation: Point { x: 0.0, y: 0.0 },
3065                scale_x: 1.0,
3066                scale_y: 1.0,
3067                flip: ResolvedTileFlip::None,
3068                alignment: ResolvedRectAlignment::TopLeft,
3069            }),
3070            Some(72.0),
3071        );
3072        tiled_picture.rotation_deg = 45.0;
3073        let ResolvedContent::Image(image) = &mut tiled_picture.content else {
3074            unreachable!();
3075        };
3076        image.rotate_with_shape = false;
3077        let input = render_input_with_media(
3078            vec![slide((40.0, 20.0), vec![picture, tiled_picture])],
3079            HashMap::from([(media_id, media(&png))]),
3080        );
3081
3082        let layout = layout_presentation(&input).expect("lower picture rotation policy");
3083        let shape = only_group(&layout.pages[0].elements[0]);
3084        let clip = only_group(&shape.children[0]);
3085        assert_eq!(
3086            clip.clip,
3087            Some(Path::rect(local_shape_rect(&input.slides[0].shapes[0])))
3088        );
3089        let image = only_group(&clip.children[0]);
3090        assert_eq!(image.transform, Transform::rotate_about(-45.0, 5.0, 5.0));
3091        assert!(matches!(
3092            image.children.as_slice(),
3093            [PositionedElement::Image { .. }]
3094        ));
3095
3096        let rendered =
3097            oxml_pdf::render_page_to_png(&layout, 0, 72.0).expect("rasterise picture rotation");
3098        let pixmap = tiny_skia::Pixmap::decode_png(&rendered).expect("decode picture rotation");
3099        assert_eq!(rgb_at(&pixmap, 10, 4), (255, 0, 0));
3100        assert_eq!(rgb_at(&pixmap, 4, 4), (255, 255, 255));
3101        assert_eq!(rgb_at(&pixmap, 30, 4), (255, 0, 0));
3102        assert_eq!(rgb_at(&pixmap, 24, 4), (255, 255, 255));
3103    }
3104
3105    #[test]
3106    fn tile_dpi_prefers_declared_then_embedded_then_96() {
3107        let embedded = oxml_media::ImageInfo {
3108            format: oxml_media::ImageFormat::Png,
3109            width_px: 144,
3110            height_px: 72,
3111            dpi_x: Some(72.0),
3112            dpi_y: Some(72.0),
3113            bit_depth: 8,
3114            channels: 4,
3115            has_alpha: true,
3116        };
3117        assert_eq!(
3118            tile_native_size_points(embedded, Some(144.0), MediaId(1)).unwrap(),
3119            (72.0, 36.0)
3120        );
3121        assert_eq!(
3122            tile_native_size_points(embedded, None, MediaId(1)).unwrap(),
3123            (144.0, 72.0)
3124        );
3125        assert_eq!(
3126            tile_native_size_points(
3127                oxml_media::ImageInfo {
3128                    dpi_x: None,
3129                    dpi_y: None,
3130                    ..embedded
3131                },
3132                None,
3133                MediaId(1),
3134            )
3135            .unwrap(),
3136            (108.0, 54.0)
3137        );
3138    }
3139
3140    #[test]
3141    fn equal_picture_bytes_reuse_one_media_id_across_elements() {
3142        let bytes = b"same picture";
3143        let media_id = MediaId::from_bytes(bytes);
3144        let pictures = [0.0, 5.0]
3145            .map(|x| {
3146                picture_shape(
3147                    Rect {
3148                        x,
3149                        y: 0.0,
3150                        width: 4.0,
3151                        height: 4.0,
3152                    },
3153                    media_id,
3154                    None,
3155                    ResolvedImagePlacement::default(),
3156                    None,
3157                )
3158            })
3159            .to_vec();
3160        let input = render_input_with_media(
3161            vec![slide((10.0, 5.0), pictures)],
3162            HashMap::from([(media_id, media(bytes))]),
3163        );
3164
3165        let layout = layout_presentation(&input).expect("lower repeated picture media");
3166        let mut ids = Vec::new();
3167        collect_image_ids(&layout.pages[0].elements, &mut ids);
3168        assert_eq!(ids, [media_id, media_id]);
3169        assert_eq!(input.media.len(), 1);
3170    }
3171
3172    #[test]
3173    fn missing_external_media_and_empty_crop_are_contextual() {
3174        let missing_id = MediaId(404);
3175        let missing = picture_shape(
3176            Rect {
3177                x: 0.0,
3178                y: 0.0,
3179                width: 4.0,
3180                height: 4.0,
3181            },
3182            missing_id,
3183            None,
3184            ResolvedImagePlacement::default(),
3185            None,
3186        );
3187        assert_eq!(
3188            layout_slide(&render_input(vec![slide((5.0, 5.0), vec![missing])]), 0).unwrap_err(),
3189            RenderInputError::MissingMedia { media: missing_id }
3190        );
3191
3192        let media_id = MediaId(405);
3193        let empty = picture_shape(
3194            Rect {
3195                x: 0.0,
3196                y: 0.0,
3197                width: 4.0,
3198                height: 4.0,
3199            },
3200            media_id,
3201            Some(CropRect {
3202                left: 0.5,
3203                right: 0.5,
3204                ..CropRect::default()
3205            }),
3206            ResolvedImagePlacement::default(),
3207            None,
3208        );
3209        let input = render_input_with_media(
3210            vec![slide((5.0, 5.0), vec![empty])],
3211            HashMap::from([(media_id, media(b"image"))]),
3212        );
3213        assert_eq!(
3214            layout_slide(&input, 0).unwrap_err(),
3215            RenderInputError::InvalidPicture {
3216                media: media_id,
3217                detail: "source crop",
3218            }
3219        );
3220
3221        let png = horizontal_png(&[[0, 0, 0, 255]]);
3222        let media_id = MediaId::from_bytes(&png);
3223        let excessive = picture_shape(
3224            Rect {
3225                x: 0.0,
3226                y: 0.0,
3227                width: 10.0,
3228                height: 10.0,
3229            },
3230            media_id,
3231            None,
3232            ResolvedImagePlacement::Tile(ResolvedTilePlacement {
3233                translation: Point { x: 0.0, y: 0.0 },
3234                scale_x: 0.000_1,
3235                scale_y: 0.000_1,
3236                flip: ResolvedTileFlip::None,
3237                alignment: ResolvedRectAlignment::TopLeft,
3238            }),
3239            Some(72.0),
3240        );
3241        let input = render_input_with_media(
3242            vec![slide((10.0, 10.0), vec![excessive])],
3243            HashMap::from([(media_id, media(&png))]),
3244        );
3245        assert!(matches!(
3246            layout_slide(&input, 0),
3247            Err(RenderInputError::TileLimitExceeded { media, .. }) if media == media_id
3248        ));
3249    }
3250
3251    fn picture_shape(
3252        bounds: Rect,
3253        media: MediaId,
3254        src_rect: Option<CropRect>,
3255        placement: ResolvedImagePlacement,
3256        dpi: Option<f64>,
3257    ) -> ResolvedShape {
3258        let mut picture = shape(bounds, ResolvedGeometry::Rectangle, None, None);
3259        picture.content = ResolvedContent::Image(ResolvedImage {
3260            media,
3261            src_rect,
3262            placement,
3263            dpi,
3264            rotate_with_shape: true,
3265        });
3266        picture
3267    }
3268
3269    fn render_input_with_media(
3270        slides: Vec<ResolvedSlide>,
3271        media: HashMap<MediaId, MediaData>,
3272    ) -> RenderInput {
3273        RenderInput {
3274            slides,
3275            media,
3276            fonts: Vec::new(),
3277            metadata: None,
3278        }
3279    }
3280
3281    fn horizontal_png(colors: &[[u8; 4]]) -> Vec<u8> {
3282        let mut pixmap = tiny_skia::Pixmap::new(colors.len() as u32, 1).expect("fixture pixmap");
3283        for (pixel, color) in pixmap.pixels_mut().iter_mut().zip(colors) {
3284            *pixel =
3285                tiny_skia::PremultipliedColorU8::from_rgba(color[0], color[1], color[2], color[3])
3286                    .expect("premultiplied fixture colour");
3287        }
3288        pixmap.encode_png().expect("encode fixture PNG")
3289    }
3290
3291    fn rgb_at(pixmap: &tiny_skia::Pixmap, x: u32, y: u32) -> (u8, u8, u8) {
3292        let pixel = pixmap.pixel(x, y).expect("sample lies inside page");
3293        (pixel.red(), pixel.green(), pixel.blue())
3294    }
3295
3296    fn collect_image_ids(elements: &[PositionedElement], ids: &mut Vec<MediaId>) {
3297        for element in elements {
3298            match element {
3299                PositionedElement::Image { media_id, .. } => ids.push(*media_id),
3300                PositionedElement::Group(group) => collect_image_ids(&group.children, ids),
3301                _ => {}
3302            }
3303        }
3304    }
3305
3306    fn open_line(from: Point, to: Point) -> Path {
3307        Path {
3308            commands: vec![PathCommand::MoveTo(from), PathCommand::LineTo(to)],
3309            fill_rule: FillRule::NonZero,
3310        }
3311    }
3312
3313    fn line_end(kind: ResolvedLineEndKind) -> ResolvedLineEnd {
3314        ResolvedLineEnd {
3315            kind,
3316            width: ResolvedLineEndSize::Medium,
3317            length: ResolvedLineEndSize::Medium,
3318        }
3319    }
3320
3321    #[test]
3322    fn layout_slide_rejects_an_out_of_range_index() {
3323        let input = render_input(vec![slide((20.0, 10.0), Vec::new())]);
3324
3325        assert_eq!(
3326            layout_slide(&input, 4).unwrap_err(),
3327            RenderInputError::SlideIndexOutOfBounds {
3328                index: 4,
3329                slide_count: 1,
3330            }
3331        );
3332    }
3333
3334    #[test]
3335    fn resolved_rtl_numeric_forced_breaks_reach_rich_line_layout() {
3336        const P_NS: &str = "http://schemas.openxmlformats.org/presentationml/2006/main";
3337        const A_NS: &str = "http://schemas.openxmlformats.org/drawingml/2006/main";
3338        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>"#;
3339        let slide = CT_Slide::from_xml(
3340            format!(
3341                "<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>"
3342            )
3343            .as_bytes(),
3344        )
3345        .unwrap();
3346        let empty_tree = "<p:spTree><p:nvGrpSpPr/><p:grpSpPr/></p:spTree>";
3347        let layout = CT_SlideLayout::from_xml(
3348            format!(
3349                "<p:sldLayout xmlns:p=\"{P_NS}\" xmlns:a=\"{A_NS}\"><p:cSld>{empty_tree}</p:cSld></p:sldLayout>"
3350            )
3351            .as_bytes(),
3352        )
3353        .unwrap();
3354        let master = CT_SlideMaster::from_xml(
3355            format!(
3356                "<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>"
3357            )
3358            .as_bytes(),
3359        )
3360        .unwrap();
3361        let theme = CT_OfficeStyleSheet::office_default();
3362        let default_text_style = CT_TextListStyle::default();
3363        let media = rpptx_layout::ScopedMediaIds::default();
3364        let hyperlinks = rpptx_layout::ScopedHyperlinkTargets::default();
3365        let charts = rpptx_layout::ScopedChartResources::default();
3366        let mut fonts = FontManager::new_deterministic().expect("deterministic fonts");
3367        let (resolved, directions) = ResolveCtx::new(
3368            &theme,
3369            ColorMap::default(),
3370            &master,
3371            &layout,
3372            &slide,
3373            &default_text_style,
3374        )
3375        .resolve_slide_with_chart_resources_and_text_directions(
3376            (720.0, 144.0),
3377            &media,
3378            &hyperlinks,
3379            &charts,
3380            &mut fonts,
3381        )
3382        .unwrap();
3383        let rendered = layout_presentation_with_font_manager_and_text_directions(
3384            &render_input(vec![resolved]),
3385            fonts,
3386            &[directions],
3387        )
3388        .unwrap();
3389        let mut runs = Vec::new();
3390        walk(&rendered.pages[0].elements, &mut |element, _| {
3391            if let PositionedElement::MultilingualText(run) = element {
3392                runs.push((run.logical_text.clone(), run.bidi_level, run.origin.y));
3393            }
3394        });
3395
3396        assert_eq!(
3397            runs.iter()
3398                .map(|(text, level, _)| (text.as_str(), *level))
3399                .collect::<Vec<_>>(),
3400            [("123", 2), ("456", 2)]
3401        );
3402        assert!(runs[0].2 < runs[1].2);
3403    }
3404
3405    #[test]
3406    fn master_gradient_background_renders_when_slide_and_layout_omit_one() {
3407        const P_NS: &str = "http://schemas.openxmlformats.org/presentationml/2006/main";
3408        const A_NS: &str = "http://schemas.openxmlformats.org/drawingml/2006/main";
3409        let shape_tree = "<p:spTree><p:nvGrpSpPr/><p:grpSpPr/></p:spTree>";
3410        let slide = CT_Slide::from_xml(
3411            format!(
3412                "<p:sld xmlns:p=\"{P_NS}\" xmlns:a=\"{A_NS}\"><p:cSld>{shape_tree}</p:cSld></p:sld>"
3413            )
3414            .as_bytes(),
3415        )
3416        .unwrap();
3417        let layout = CT_SlideLayout::from_xml(
3418            format!(
3419                "<p:sldLayout xmlns:p=\"{P_NS}\" xmlns:a=\"{A_NS}\"><p:cSld>{shape_tree}</p:cSld></p:sldLayout>"
3420            )
3421            .as_bytes(),
3422        )
3423        .unwrap();
3424        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>"#;
3425        let master = CT_SlideMaster::from_xml(
3426            format!(
3427                "<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>"
3428            )
3429            .as_bytes(),
3430        )
3431        .unwrap();
3432        let theme = CT_OfficeStyleSheet::office_default();
3433        let default_text_style = CT_TextListStyle::default();
3434        let resolved = ResolveCtx::new(
3435            &theme,
3436            ColorMap::default(),
3437            &master,
3438            &layout,
3439            &slide,
3440            &default_text_style,
3441        )
3442        .resolve_slide((40.0, 20.0))
3443        .unwrap();
3444        let Some(ResolvedBackground::Paint(resolved_background)) = resolved.background.clone()
3445        else {
3446            panic!("expected resolved paint background");
3447        };
3448        let rendered = layout_presentation(&render_input(vec![resolved])).unwrap();
3449
3450        assert_eq!(rendered.pages[0].background, Some(resolved_background));
3451        assert!(rendered.pages[0].elements.is_empty());
3452        let png = oxml_pdf::render_page_to_png(&rendered, 0, 72.0)
3453            .expect("rasterise inherited master gradient");
3454        let pixmap = tiny_skia::Pixmap::decode_png(&png).expect("decode background raster");
3455        let left = rgb_at(&pixmap, 2, 10);
3456        let right = rgb_at(&pixmap, 37, 10);
3457        assert!(
3458            left.0 > left.2,
3459            "left sample should be red-dominant: {left:?}"
3460        );
3461        assert!(
3462            right.2 > right.0,
3463            "right sample should be blue-dominant: {right:?}"
3464        );
3465    }
3466
3467    #[test]
3468    fn absent_background_keeps_the_default_white_raster() {
3469        const P_NS: &str = "http://schemas.openxmlformats.org/presentationml/2006/main";
3470        const A_NS: &str = "http://schemas.openxmlformats.org/drawingml/2006/main";
3471        let shape_tree = "<p:spTree><p:nvGrpSpPr/><p:grpSpPr/></p:spTree>";
3472        let slide = CT_Slide::from_xml(
3473            format!(
3474                "<p:sld xmlns:p=\"{P_NS}\" xmlns:a=\"{A_NS}\"><p:cSld>{shape_tree}</p:cSld></p:sld>"
3475            )
3476            .as_bytes(),
3477        )
3478        .unwrap();
3479        let layout = CT_SlideLayout::from_xml(
3480            format!(
3481                "<p:sldLayout xmlns:p=\"{P_NS}\" xmlns:a=\"{A_NS}\"><p:cSld>{shape_tree}</p:cSld></p:sldLayout>"
3482            )
3483            .as_bytes(),
3484        )
3485        .unwrap();
3486        let master = CT_SlideMaster::from_xml(
3487            format!(
3488                "<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>"
3489            )
3490            .as_bytes(),
3491        )
3492        .unwrap();
3493        let mut theme = CT_OfficeStyleSheet::office_default();
3494        theme
3495            .theme_elements
3496            .format_scheme
3497            .background_fill_styles[0] = oxml_drawing::fill::Fill::from_xml(
3498            br#"<a:solidFill xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><a:srgbClr val="000000"/></a:solidFill>"#,
3499        )
3500        .unwrap();
3501        let default_text_style = CT_TextListStyle::default();
3502        let resolved = ResolveCtx::new(
3503            &theme,
3504            ColorMap::default(),
3505            &master,
3506            &layout,
3507            &slide,
3508            &default_text_style,
3509        )
3510        .resolve_slide((10.0, 10.0))
3511        .unwrap();
3512
3513        assert!(resolved.background.is_none());
3514        let rendered = layout_presentation(&render_input(vec![resolved])).unwrap();
3515        assert!(rendered.pages[0].background.is_none());
3516        let png = oxml_pdf::render_page_to_png(&rendered, 0, 72.0)
3517            .expect("rasterise absent presentation background");
3518        let pixmap = tiny_skia::Pixmap::decode_png(&png).expect("decode white background raster");
3519        assert_eq!(rgb_at(&pixmap, 5, 5), (255, 255, 255));
3520    }
3521
3522    #[test]
3523    fn background_is_not_duplicated_in_page_elements() {
3524        let mut resolved = slide((20.0, 10.0), Vec::new());
3525        resolved.background = Some(ResolvedBackground::Paint(Paint::Solid(Color::from_hex(
3526            "102030",
3527        ))));
3528
3529        let page = layout_slide(&render_input(vec![resolved]), 0).unwrap();
3530
3531        assert_eq!(
3532            page.background,
3533            Some(Paint::Solid(Color::from_hex("102030")))
3534        );
3535        assert!(page.elements.is_empty());
3536    }
3537
3538    #[test]
3539    fn background_image_is_lowered_before_slide_shapes() {
3540        let png = horizontal_png(&[[0, 0, 255, 255]]);
3541        let media_id = MediaId::from_bytes(&png);
3542        let foreground = shape(
3543            Rect {
3544                x: 2.0,
3545                y: 2.0,
3546                width: 6.0,
3547                height: 6.0,
3548            },
3549            ResolvedGeometry::Rectangle,
3550            Some(Paint::Solid(Color::from_hex("FF0000"))),
3551            None,
3552        );
3553        let mut resolved = slide((10.0, 10.0), vec![foreground]);
3554        resolved.background = Some(ResolvedBackground::Image(ResolvedImage {
3555            media: media_id,
3556            src_rect: None,
3557            placement: ResolvedImagePlacement::default(),
3558            dpi: None,
3559            rotate_with_shape: true,
3560        }));
3561        let input =
3562            render_input_with_media(vec![resolved], HashMap::from([(media_id, media(&png))]));
3563
3564        let page = layout_slide(&input, 0).unwrap();
3565        assert!(matches!(
3566            page.elements.first(),
3567            Some(PositionedElement::Image { media_id: id, .. }) if *id == media_id
3568        ));
3569        assert!(matches!(
3570            page.elements.get(1),
3571            Some(PositionedElement::Group(_))
3572        ));
3573        let rendered = oxml_pdf::render_page_to_png(
3574            &LayoutResult::new(vec![page.into()], Vec::new(), None, Vec::new()),
3575            0,
3576            72.0,
3577        )
3578        .expect("rasterise background image ordering");
3579        let pixmap = tiny_skia::Pixmap::decode_png(&rendered).unwrap();
3580        assert_eq!(rgb_at(&pixmap, 0, 0), (0, 0, 255));
3581        assert_eq!(rgb_at(&pixmap, 5, 5), (255, 0, 0));
3582    }
3583
3584    #[test]
3585    fn layout_presentation_preserves_page_order_and_diagnostics() {
3586        let mut first = slide((20.0, 10.0), Vec::new());
3587        first.diagnostics.push(Diagnostic {
3588            message: "first diagnostic".to_owned(),
3589        });
3590        let mut second = slide((30.0, 15.0), Vec::new());
3591        second.diagnostics.push(Diagnostic {
3592            message: "second diagnostic".to_owned(),
3593        });
3594        let mut input = render_input(vec![first, second]);
3595        input.metadata = Some(DocumentMetadata {
3596            title: Some("shape deck".to_owned()),
3597            author: Some("rpptx-render".to_owned()),
3598            ..DocumentMetadata::default()
3599        });
3600
3601        let layout = layout_presentation(&input).expect("lower presentation");
3602        assert_eq!(layout.pages.len(), 2);
3603        assert_eq!(
3604            (
3605                layout.pages[0].page_number,
3606                layout.pages[0].width,
3607                layout.pages[0].height
3608            ),
3609            (1, 20.0, 10.0)
3610        );
3611        assert_eq!(
3612            (
3613                layout.pages[1].page_number,
3614                layout.pages[1].width,
3615                layout.pages[1].height
3616            ),
3617            (2, 30.0, 15.0)
3618        );
3619        assert_eq!(
3620            layout
3621                .metadata
3622                .as_ref()
3623                .and_then(|metadata| metadata.title.as_deref()),
3624            Some("shape deck")
3625        );
3626        assert_eq!(
3627            layout
3628                .diagnostics
3629                .iter()
3630                .map(|diagnostic| diagnostic.message.as_str())
3631                .collect::<Vec<_>>(),
3632            vec!["first diagnostic", "second diagnostic"]
3633        );
3634        assert!(layout.fonts.is_empty());
3635        assert!(layout.outlines.is_empty());
3636        assert!(layout.structure.is_none());
3637    }
3638
3639    #[test]
3640    fn same_relationship_id_resolves_independently_in_all_three_scopes() {
3641        let relationships = RelScopes {
3642            slide: HashMap::from([("rId2".to_owned(), relationship("slide.png"))]),
3643            layout: HashMap::from([("rId2".to_owned(), relationship("layout.png"))]),
3644            master: HashMap::from([("rId2".to_owned(), relationship("master.png"))]),
3645        };
3646        let package_media = HashMap::from([
3647            ("slide.png".to_owned(), media(b"slide image")),
3648            ("layout.png".to_owned(), media(b"layout image")),
3649            ("master.png".to_owned(), media(b"master image")),
3650        ]);
3651        let mut deck_media = HashMap::new();
3652
3653        let slide = resolve_media_relationship(
3654            &relationships,
3655            RelScope::Slide,
3656            "rId2",
3657            &package_media,
3658            &mut deck_media,
3659        )
3660        .unwrap();
3661        let layout = resolve_media_relationship(
3662            &relationships,
3663            RelScope::Layout,
3664            "rId2",
3665            &package_media,
3666            &mut deck_media,
3667        )
3668        .unwrap();
3669        let master = resolve_media_relationship(
3670            &relationships,
3671            RelScope::Master,
3672            "rId2",
3673            &package_media,
3674            &mut deck_media,
3675        )
3676        .unwrap();
3677
3678        assert_eq!(slide, MediaId::from_bytes(b"slide image"));
3679        assert_eq!(layout, MediaId::from_bytes(b"layout image"));
3680        assert_eq!(master, MediaId::from_bytes(b"master image"));
3681        assert_eq!(deck_media.len(), 3);
3682    }
3683
3684    #[test]
3685    fn external_hyperlink_projection_keeps_scopes_and_excludes_internal_targets() {
3686        let relationships = RelScopes {
3687            slide: HashMap::from([
3688                (
3689                    "rId7".to_owned(),
3690                    hyperlink_relationship("https://slide.example", Some("External")),
3691                ),
3692                (
3693                    "rId8".to_owned(),
3694                    hyperlink_relationship("../slides/slide2.xml", None),
3695                ),
3696            ]),
3697            layout: HashMap::from([(
3698                "rId7".to_owned(),
3699                hyperlink_relationship("https://layout.example", Some("External")),
3700            )]),
3701            master: HashMap::from([(
3702                "rId7".to_owned(),
3703                hyperlink_relationship("https://master.example", Some("External")),
3704            )]),
3705        };
3706
3707        let targets = relationships.external_hyperlink_targets();
3708
3709        assert_eq!(
3710            targets.slide.get("rId7").map(String::as_str),
3711            Some("https://slide.example")
3712        );
3713        assert!(!targets.slide.contains_key("rId8"));
3714        assert_eq!(
3715            targets.layout.get("rId7").map(String::as_str),
3716            Some("https://layout.example")
3717        );
3718        assert_eq!(
3719            targets.master.get("rId7").map(String::as_str),
3720            Some("https://master.example")
3721        );
3722    }
3723
3724    #[test]
3725    fn equal_media_bytes_deduplicate_to_one_media_entry() {
3726        let relationships = RelScopes {
3727            slide: HashMap::from([
3728                ("rId1".to_owned(), relationship("logo-a.png")),
3729                ("rId2".to_owned(), relationship("logo-b.png")),
3730            ]),
3731            ..RelScopes::default()
3732        };
3733        let package_media = HashMap::from([
3734            ("logo-a.png".to_owned(), media(b"shared logo")),
3735            ("logo-b.png".to_owned(), media(b"shared logo")),
3736        ]);
3737        let mut deck_media = HashMap::new();
3738
3739        let first = resolve_media_relationship(
3740            &relationships,
3741            RelScope::Slide,
3742            "rId1",
3743            &package_media,
3744            &mut deck_media,
3745        )
3746        .unwrap();
3747        let second = resolve_media_relationship(
3748            &relationships,
3749            RelScope::Slide,
3750            "rId2",
3751            &package_media,
3752            &mut deck_media,
3753        )
3754        .unwrap();
3755
3756        assert_eq!(first, second);
3757        assert_eq!(deck_media.len(), 1);
3758    }
3759
3760    #[test]
3761    fn missing_relationship_reports_scope_and_id() {
3762        let error = resolve_media_relationship(
3763            &RelScopes::default(),
3764            RelScope::Layout,
3765            "rId9",
3766            &HashMap::new(),
3767            &mut HashMap::new(),
3768        )
3769        .unwrap_err();
3770
3771        assert_eq!(
3772            error,
3773            RenderInputError::MissingRelationship {
3774                scope: RelScope::Layout,
3775                relationship_id: "rId9".to_owned(),
3776            }
3777        );
3778        assert!(error.to_string().contains("layout"));
3779        assert!(error.to_string().contains("rId9"));
3780    }
3781
3782    #[test]
3783    fn render_input_contains_only_resolved_slides() {
3784        let input = RenderInput {
3785            slides: Vec::<ResolvedSlide>::new(),
3786            media: HashMap::new(),
3787            fonts: Vec::new(),
3788            metadata: None,
3789        };
3790
3791        assert!(input.slides.is_empty());
3792        assert_eq!(
3793            std::any::type_name_of_val(&input.slides),
3794            "alloc::vec::Vec<rpptx_layout::ResolvedSlide>"
3795        );
3796    }
3797
3798    #[test]
3799    fn rpptx_render_dependency_direction_is_one_way() {
3800        let manifest = include_str!("../Cargo.toml");
3801        let rpptx_manifest = include_str!("../../rpptx/Cargo.toml");
3802        let binding_manifest = include_str!("../../rpptx-py/Cargo.toml");
3803        assert!(manifest.contains(
3804            "[features]\ndefault = [\"system-fonts\"]\nsystem-fonts = [\"oxml-layout/system-fonts\"]"
3805        ));
3806        assert!(manifest.contains("oxml-layout = { workspace = true, default-features = false }"));
3807        assert!(
3808            rpptx_manifest
3809                .contains("default = [\"default-template\", \"render\", \"system-fonts\"]")
3810        );
3811        assert!(rpptx_manifest.contains(
3812            "system-fonts = [\"oxml-layout/system-fonts\", \"rpptx-render?/system-fonts\"]"
3813        ));
3814        assert!(rpptx_manifest.contains(
3815            "rpptx-render = { workspace = true, default-features = false, optional = true }"
3816        ));
3817        assert!(
3818            binding_manifest.contains(
3819                "rpptx = { workspace = true, features = [\"default-template\", \"render\", \"system-fonts\"] }"
3820            )
3821        );
3822        for dependency in [
3823            "oxml-drawing.workspace = true",
3824            "oxml-media.workspace = true",
3825            "rpptx-layout.workspace = true",
3826            "rpptx-oxml.workspace = true",
3827        ] {
3828            assert!(manifest.contains(dependency), "missing {dependency}");
3829        }
3830        for oxml_manifest in [
3831            include_str!("../../oxml-core/Cargo.toml"),
3832            include_str!("../../oxml-drawing/Cargo.toml"),
3833            include_str!("../../oxml-layout/Cargo.toml"),
3834            include_str!("../../oxml-media/Cargo.toml"),
3835            include_str!("../../oxml-opc/Cargo.toml"),
3836            include_str!("../../oxml-pdf/Cargo.toml"),
3837        ] {
3838            assert!(!oxml_manifest.contains("rpptx-render"));
3839        }
3840        assert!(manifest.contains("version = \"0.9.0\""));
3841        assert!(manifest.contains("publish = true"));
3842    }
3843}