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