Skip to main content

deckcraft_model/
resolve.rs

1//! Inheritance: effective position, fill, line, body and text properties of a shape, following
2//! slide → layout placeholder → master placeholder → master text styles → theme.
3
4use deckcraft_color::{ColorScheme, Rgba, SchemeSlot};
5use deckcraft_geom::Xfrm;
6
7use crate::style::{ColorRef, Effects, Fill, Line};
8use crate::text::{Anchor, AutoFit, BodyProps, LevelStyle, ListStyle, ParaProps, RunProps};
9use crate::{Background, Layout, Master, PhType, Presentation, Shape, Slide};
10
11/// Where a shape lives, for resolving inherited values.
12#[derive(Clone, Copy)]
13pub enum Owner {
14    Slide,
15    Layout,
16    Master,
17}
18
19pub struct Ctx<'a> {
20    pub pres: &'a Presentation,
21    pub master: &'a Master,
22    pub layout: Option<&'a Layout>,
23    pub owner: Owner,
24    pub scheme: ColorScheme,
25    /// The colour map in force: the slide's override, else its layout's, else the master's.
26    pub color_map: Vec<(String, String)>,
27}
28
29impl<'a> Ctx<'a> {
30    pub fn for_slide(pres: &'a Presentation, slide: &Slide) -> Option<Ctx<'a>> {
31        let (master, layout) = pres.master_for(slide)?;
32        let map = slide
33            .color_map
34            .as_ref()
35            .or(layout.and_then(|l| l.color_map.as_ref()))
36            .unwrap_or(&master.color_map)
37            .clone();
38        Some(Ctx {
39            pres,
40            master,
41            layout,
42            owner: Owner::Slide,
43            scheme: master.scheme_with(&map),
44            color_map: map,
45        })
46    }
47    pub fn for_layout(pres: &'a Presentation, master: &'a Master, layout: &'a Layout) -> Ctx<'a> {
48        let map = layout
49            .color_map
50            .as_ref()
51            .unwrap_or(&master.color_map)
52            .clone();
53        Ctx {
54            pres,
55            master,
56            layout: Some(layout),
57            owner: Owner::Layout,
58            scheme: master.scheme_with(&map),
59            color_map: map,
60        }
61    }
62    pub fn for_master(pres: &'a Presentation, master: &'a Master) -> Ctx<'a> {
63        Ctx {
64            pres,
65            master,
66            layout: None,
67            owner: Owner::Master,
68            scheme: master.scheme(),
69            color_map: master.color_map.clone(),
70        }
71    }
72    pub fn color(&self, c: &ColorRef, ph: Option<Rgba>) -> Rgba {
73        let c = match &c.base {
74            crate::ColorBase::Scheme { slot }
75                if matches!(
76                    slot,
77                    SchemeSlot::Bg1 | SchemeSlot::Tx1 | SchemeSlot::Bg2 | SchemeSlot::Tx2
78                ) =>
79            {
80                ColorRef {
81                    base: crate::ColorBase::Scheme {
82                        slot: crate::map_slot(&self.color_map, *slot),
83                    },
84                    mods: c.mods.clone(),
85                }
86            }
87            _ => c.clone(),
88        };
89        c.resolve(&self.master.theme.colors, ph)
90    }
91}
92
93fn compatible(slide: PhType, other: PhType) -> bool {
94    use PhType::*;
95    match slide {
96        Title | CtrTitle => matches!(other, Title | CtrTitle),
97        SubTitle | Body | Obj | Chart | Table | ClipArt | Diagram | Media | Picture => {
98            !matches!(other, Title | CtrTitle | Date | Footer | SlideNum | Header)
99        }
100        k => k == other,
101    }
102}
103
104fn find_ph<'s>(shapes: &'s [Shape], shape: &Shape, by_idx: bool) -> Option<&'s Shape> {
105    let ph = shape.ph.as_ref()?;
106    if by_idx
107        && ph.idx != 0
108        && let Some(s) = shapes.iter().find(|s| {
109            s.ph.as_ref()
110                .is_some_and(|p| p.idx == ph.idx && compatible(ph.kind, p.kind))
111        })
112    {
113        return Some(s);
114    }
115    // Same type first, then a compatible one.
116    shapes
117        .iter()
118        .find(|s| {
119            s.ph.as_ref().is_some_and(|p| {
120                p.kind == ph.kind
121                    && (!by_idx || ph.idx == 0 || p.idx == ph.idx || ph.kind.is_title())
122            })
123        })
124        .or_else(|| {
125            shapes
126                .iter()
127                .find(|s| s.ph.as_ref().is_some_and(|p| p.kind == ph.kind))
128        })
129        .or_else(|| {
130            shapes
131                .iter()
132                .find(|s| s.ph.as_ref().is_some_and(|p| compatible(ph.kind, p.kind)))
133        })
134}
135
136fn master_ph(master: &Master, kind: PhType) -> Option<&Shape> {
137    let want = match kind {
138        PhType::Title | PhType::CtrTitle => PhType::Title,
139        PhType::Date | PhType::Footer | PhType::SlideNum | PhType::Header => kind,
140        _ => PhType::Body,
141    };
142    master.shapes.iter().find(|s| s.ph_type() == Some(want))
143}
144
145/// The layout and master placeholders a shape inherits from.
146pub fn parents<'a>(ctx: &Ctx<'a>, shape: &Shape) -> (Option<&'a Shape>, Option<&'a Shape>) {
147    let Some(ph) = shape.ph.as_ref() else {
148        return (None, None);
149    };
150    match ctx.owner {
151        Owner::Slide => {
152            let lp = ctx.layout.and_then(|l| find_ph(&l.shapes, shape, true));
153            let kind = lp.and_then(Shape::ph_type).unwrap_or(ph.kind);
154            (lp, master_ph(ctx.master, kind))
155        }
156        Owner::Layout => (None, master_ph(ctx.master, ph.kind)),
157        Owner::Master => (None, None),
158    }
159}
160
161/// The text style group (title / body / other) a shape draws from.
162pub fn master_list_style<'a>(ctx: &Ctx<'a>, shape: &Shape) -> &'a ListStyle {
163    match shape.ph_type() {
164        Some(PhType::Title | PhType::CtrTitle) => &ctx.master.title_style,
165        Some(
166            PhType::Body
167            | PhType::Obj
168            | PhType::SubTitle
169            | PhType::Table
170            | PhType::Chart
171            | PhType::Diagram
172            | PhType::Media
173            | PhType::Picture
174            | PhType::ClipArt,
175        ) => &ctx.master.body_style,
176        Some(_) => &ctx.master.other_style,
177        None => &ctx.pres.default_text_style,
178    }
179}
180
181pub fn xfrm(ctx: &Ctx, shape: &Shape) -> Xfrm {
182    if let Some(x) = shape.xfrm {
183        return x;
184    }
185    let (lp, mp) = parents(ctx, shape);
186    lp.and_then(|s| s.xfrm)
187        .or_else(|| mp.and_then(|s| s.xfrm))
188        .unwrap_or_else(|| {
189            let sz = ctx.pres.slide_size;
190            Xfrm::new(
191                sz.width * 0.1,
192                sz.height * 0.1,
193                sz.width * 0.8,
194                sz.height * 0.2,
195            )
196        })
197}
198
199/// Effective fill and the style colour (`phClr`) to resolve it with.
200pub fn fill(ctx: &Ctx, shape: &Shape) -> (Option<Fill>, Option<Rgba>) {
201    let (lp, mp) = parents(ctx, shape);
202    let style_fill = |s: &Shape| {
203        s.style.as_ref().map(|st| {
204            let (idx, col) = &st.fill_ref;
205            let ph = ctx.color(col, None);
206            let f = match *idx {
207                0 => Some(Fill::None),
208                i if i >= 1001 => ctx
209                    .master
210                    .theme
211                    .format
212                    .bg_fills
213                    .get((i - 1001) as usize)
214                    .cloned(),
215                i => ctx.master.theme.format.fills.get((i - 1) as usize).cloned(),
216            };
217            (f, Some(ph))
218        })
219    };
220    for s in [Some(shape), lp, mp].into_iter().flatten() {
221        if let Some(f) = &s.fill {
222            // A gradient without stops (`a:gradFill` with no `a:gsLst`) takes them from the
223            // gradient its style refers to, keeping its own direction, as PowerPoint draws it.
224            if let Fill::Gradient(g) = f
225                && g.stops.is_empty()
226                && let Some((Some(Fill::Gradient(sg)), ph)) = style_fill(s)
227            {
228                let mut g = g.clone();
229                g.stops = sg.stops;
230                return (Some(Fill::Gradient(g)), ph);
231            }
232            return (Some(f.clone()), None);
233        }
234        if let Some(r) = style_fill(s) {
235            return r;
236        }
237    }
238    if shape.text_box {
239        return (Some(Fill::None), None);
240    }
241    (None, None)
242}
243
244pub fn line(ctx: &Ctx, shape: &Shape) -> (Line, Option<Rgba>) {
245    let (lp, mp) = parents(ctx, shape);
246    let mut out = Line::default();
247    let mut ph = None;
248    for s in [Some(shape), lp, mp].into_iter().flatten() {
249        if let Some(l) = &s.line {
250            out.inherit(l);
251        }
252        if let Some(st) = &s.style {
253            let (idx, col) = &st.line_ref;
254            if ph.is_none() {
255                ph = Some(ctx.color(col, None));
256            }
257            if *idx == 0 {
258                out.inherit(&Line::none());
259            } else if let Some(l) = ctx.master.theme.format.lines.get((*idx - 1) as usize) {
260                out.inherit(l);
261            }
262            break;
263        }
264    }
265    (out, ph)
266}
267
268pub fn effects(ctx: &Ctx, shape: &Shape) -> (Option<Effects>, Option<Rgba>) {
269    let (lp, mp) = parents(ctx, shape);
270    for s in [Some(shape), lp, mp].into_iter().flatten() {
271        if let Some(e) = &s.effects {
272            return (Some(e.clone()), None);
273        }
274        if let Some(st) = &s.style {
275            let (idx, col) = &st.effect_ref;
276            if *idx == 0 {
277                return (None, None);
278            }
279            return (
280                ctx.master
281                    .theme
282                    .format
283                    .effects
284                    .get((*idx - 1) as usize)
285                    .cloned(),
286                Some(ctx.color(col, None)),
287            );
288        }
289    }
290    (None, None)
291}
292
293/// Body properties with defaults filled in.
294pub fn body(ctx: &Ctx, shape: &Shape) -> BodyProps {
295    let (lp, mp) = parents(ctx, shape);
296    let mut b = shape
297        .text
298        .as_ref()
299        .map(|t| t.body.clone())
300        .unwrap_or_default();
301    for s in [lp, mp].into_iter().flatten() {
302        if let Some(t) = &s.text {
303            b.inherit(&t.body);
304        }
305    }
306    b.inherit(&BodyProps {
307        inset_l: Some(7.2),
308        inset_t: Some(3.6),
309        inset_r: Some(7.2),
310        inset_b: Some(3.6),
311        anchor: Some(
312            if shape.ph.is_none()
313                && !shape.text_box
314                && !matches!(shape.kind, crate::ShapeKind::Table(_))
315            {
316                Anchor::Middle
317            } else {
318                Anchor::Top
319            },
320        ),
321        anchor_ctr: Some(false),
322        wrap: Some(true),
323        autofit: Some(if shape.text_box {
324            AutoFit::Shape
325        } else {
326            AutoFit::None
327        }),
328        vert: Some(crate::text::TextDir::Horizontal),
329        rot: Some(0.0),
330        columns: Some(1),
331        col_spacing: Some(0.0),
332        warp: None,
333        warp_adj: Vec::new(),
334        rtl_col: Some(false),
335        upright: Some(false),
336    });
337    b
338}
339
340/// The merged level style (paragraph + run defaults) for paragraphs at `level` in `shape`.
341pub fn level_style(ctx: &Ctx, shape: &Shape, level: u8) -> LevelStyle {
342    let (lp, mp) = parents(ctx, shape);
343    let mut out = LevelStyle::default();
344    let add = |out: &mut LevelStyle, ls: Option<&LevelStyle>| {
345        if let Some(l) = ls {
346            out.para.inherit(&l.para);
347            out.run.inherit(&l.run);
348        }
349    };
350    if let Some(t) = &shape.text {
351        add(&mut out, t.list_style.level(level));
352    }
353    for s in [lp, mp].into_iter().flatten() {
354        if let Some(t) = &s.text {
355            add(&mut out, t.list_style.level(level));
356        }
357    }
358    // Shape style font reference (theme font + colour) sits above the defaults.
359    if let Some(st) = shape.style.as_ref().or(lp.and_then(|s| s.style.as_ref())) {
360        let font = match st.font_ref.0.as_str() {
361            "major" => Some("+mj-lt".to_string()),
362            "minor" => Some("+mn-lt".to_string()),
363            _ => None,
364        };
365        let r = RunProps {
366            font,
367            fill: st.font_ref.1.clone().map(Fill::solid),
368            ..Default::default()
369        };
370        out.run.inherit(&r);
371    }
372    add(&mut out, master_list_style(ctx, shape).level(level));
373    add(&mut out, ctx.pres.default_text_style.level(level));
374    // Last resort.
375    out.run.inherit(&RunProps {
376        size: Some(18.0),
377        font: Some("+mn-lt".into()),
378        bold: Some(false),
379        italic: Some(false),
380        ..Default::default()
381    });
382    out.para.inherit(&ParaProps {
383        margin_left: Some(0.0),
384        indent: Some(0.0),
385        align: Some(crate::text::Align::Left),
386        ..Default::default()
387    });
388    out
389}
390
391/// Fully resolved run properties for `run` in paragraph `para` (explicit → paragraph style).
392pub fn run(ctx: &Ctx, shape: &Shape, para: &crate::text::Paragraph, run: &RunProps) -> RunProps {
393    let mut r = run.clone();
394    let lvl = level_style(ctx, shape, para.level);
395    r.inherit(&lvl.run);
396    // Hyperlinked text is drawn in the theme's hyperlink colour and underlined, as in PowerPoint,
397    // unless the run itself sets a colour (honoured since PowerPoint 2019) or an underline
398    // (`u="none"` removes it). Colours and underlines from list styles don't count.
399    if r.link.is_some() {
400        if run.fill.is_none() {
401            r.fill = Some(Fill::solid(ColorRef::scheme(SchemeSlot::Hlink)));
402        }
403        if run.underline.is_none() {
404            r.underline = Some("sng".into());
405        }
406    }
407    r
408}
409
410pub fn para(ctx: &Ctx, shape: &Shape, para: &crate::text::Paragraph) -> ParaProps {
411    let mut p = para.props.clone();
412    let lvl = level_style(ctx, shape, para.level);
413    p.inherit(&lvl.para);
414    p
415}
416
417/// Concrete font family for a run (theme references resolved).
418pub fn font_family(ctx: &Ctx, r: &RunProps) -> String {
419    let f = r.font.as_deref().unwrap_or("+mn-lt");
420    let name = ctx.master.theme.font(f);
421    if name.is_empty() {
422        ctx.master.theme.fonts.minor.latin.clone()
423    } else {
424        name
425    }
426}
427
428/// Text colour of resolved run props.
429pub fn text_color(ctx: &Ctx, r: &RunProps) -> Rgba {
430    match &r.fill {
431        Some(Fill::Solid { color }) => ctx.color(color, None),
432        Some(Fill::Gradient(g)) => g
433            .stops
434            .first()
435            .map(|s| ctx.color(&s.color, None))
436            .unwrap_or(Rgba::BLACK),
437        Some(Fill::None) => Rgba::TRANSPARENT,
438        _ => ctx.color(&ColorRef::scheme(SchemeSlot::Tx1), None),
439    }
440}
441
442/// The background fill (and its style colour) of a slide.
443pub fn background(ctx: &Ctx, slide: Option<&Slide>) -> (Fill, Option<Rgba>) {
444    let bg = slide
445        .and_then(|s| s.background.as_ref())
446        .or(ctx.layout.and_then(|l| l.background.as_ref()))
447        .or(ctx.master.background.as_ref());
448    match bg {
449        Some(Background::Fill { fill }) => (fill.clone(), None),
450        Some(Background::Ref { idx, color }) => {
451            let ph = ctx.color(color, None);
452            let f = if *idx >= 1001 {
453                ctx.master
454                    .theme
455                    .format
456                    .bg_fills
457                    .get((*idx - 1001) as usize)
458                    .cloned()
459            } else {
460                ctx.master
461                    .theme
462                    .format
463                    .fills
464                    .get(idx.saturating_sub(1) as usize)
465                    .cloned()
466            };
467            (f.unwrap_or(Fill::solid(ColorRef::rgb(ph))), Some(ph))
468        }
469        None => (Fill::solid(ColorRef::scheme(SchemeSlot::Bg1)), None),
470    }
471}
472
473/// Should the layout/master graphics (non-placeholder shapes) show behind this slide?
474pub fn show_master_shapes(slide: &Slide, layout: Option<&Layout>) -> (bool, bool) {
475    let show_layout = slide.show_master_shapes;
476    let show_master = show_layout && layout.is_none_or(|l| l.show_master_shapes);
477    (show_layout, show_master)
478}