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