//! Inheritance: effective position, fill, line, body and text properties of a shape, following //! slide → layout placeholder → master placeholder → master text styles → theme. use deckcraft_color::{ColorScheme, Rgba, SchemeSlot}; use deckcraft_geom::Xfrm; use crate::style::{ColorRef, Effects, Fill, Line}; use crate::text::{Anchor, AutoFit, BodyProps, LevelStyle, ListStyle, ParaProps, RunProps}; use crate::{Background, Layout, Master, PhType, Presentation, Shape, Slide}; /// Where a shape lives, for resolving inherited values. #[derive(Clone, Copy)] pub enum Owner { Slide, Layout, Master, } pub struct Ctx<'a> { pub pres: &'a Presentation, pub master: &'a Master, pub layout: Option<&'a Layout>, pub owner: Owner, pub scheme: ColorScheme, /// The colour map in force: the slide's override, else its layout's, else the master's. pub color_map: Vec<(String, String)>, } impl<'a> Ctx<'a> { pub fn for_slide(pres: &'a Presentation, slide: &Slide) -> Option> { let (master, layout) = pres.master_for(slide)?; let map = slide.color_map.as_ref().or(layout.and_then(|l| l.color_map.as_ref())).unwrap_or(&master.color_map).clone(); Some(Ctx { pres, master, layout, owner: Owner::Slide, scheme: master.scheme_with(&map), color_map: map }) } pub fn for_layout(pres: &'a Presentation, master: &'a Master, layout: &'a Layout) -> Ctx<'a> { let map = layout.color_map.as_ref().unwrap_or(&master.color_map).clone(); Ctx { pres, master, layout: Some(layout), owner: Owner::Layout, scheme: master.scheme_with(&map), color_map: map } } pub fn for_master(pres: &'a Presentation, master: &'a Master) -> Ctx<'a> { Ctx { pres, master, layout: None, owner: Owner::Master, scheme: master.scheme(), color_map: master.color_map.clone() } } pub fn color(&self, c: &ColorRef, ph: Option) -> Rgba { let c = match &c.base { crate::ColorBase::Scheme { slot } if matches!(slot, SchemeSlot::Bg1 | SchemeSlot::Tx1 | SchemeSlot::Bg2 | SchemeSlot::Tx2) => { ColorRef { base: crate::ColorBase::Scheme { slot: crate::map_slot(&self.color_map, *slot) }, mods: c.mods.clone() } } _ => c.clone(), }; c.resolve(&self.master.theme.colors, ph) } } fn compatible(slide: PhType, other: PhType) -> bool { use PhType::*; match slide { Title | CtrTitle => matches!(other, Title | CtrTitle), SubTitle | Body | Obj | Chart | Table | ClipArt | Diagram | Media | Picture => { !matches!(other, Title | CtrTitle | Date | Footer | SlideNum | Header) } k => k == other, } } fn find_ph<'s>(shapes: &'s [Shape], shape: &Shape, by_idx: bool) -> Option<&'s Shape> { let ph = shape.ph.as_ref()?; if by_idx && ph.idx != 0 && let Some(s) = shapes.iter().find(|s| s.ph.as_ref().is_some_and(|p| p.idx == ph.idx && compatible(ph.kind, p.kind))) { return Some(s); } // Same type first, then a compatible one. shapes .iter() .find(|s| s.ph.as_ref().is_some_and(|p| p.kind == ph.kind && (!by_idx || ph.idx == 0 || p.idx == ph.idx || ph.kind.is_title()))) .or_else(|| shapes.iter().find(|s| s.ph.as_ref().is_some_and(|p| p.kind == ph.kind))) .or_else(|| shapes.iter().find(|s| s.ph.as_ref().is_some_and(|p| compatible(ph.kind, p.kind)))) } fn master_ph(master: &Master, kind: PhType) -> Option<&Shape> { let want = match kind { PhType::Title | PhType::CtrTitle => PhType::Title, PhType::Date | PhType::Footer | PhType::SlideNum | PhType::Header => kind, _ => PhType::Body, }; master.shapes.iter().find(|s| s.ph_type() == Some(want)) } /// The layout and master placeholders a shape inherits from. pub fn parents<'a>(ctx: &Ctx<'a>, shape: &Shape) -> (Option<&'a Shape>, Option<&'a Shape>) { let Some(ph) = shape.ph.as_ref() else { return (None, None) }; match ctx.owner { Owner::Slide => { let lp = ctx.layout.and_then(|l| find_ph(&l.shapes, shape, true)); let kind = lp.and_then(Shape::ph_type).unwrap_or(ph.kind); (lp, master_ph(ctx.master, kind)) } Owner::Layout => (None, master_ph(ctx.master, ph.kind)), Owner::Master => (None, None), } } /// The text style group (title / body / other) a shape draws from. pub fn master_list_style<'a>(ctx: &Ctx<'a>, shape: &Shape) -> &'a ListStyle { match shape.ph_type() { Some(PhType::Title | PhType::CtrTitle) => &ctx.master.title_style, Some( PhType::Body | PhType::Obj | PhType::SubTitle | PhType::Table | PhType::Chart | PhType::Diagram | PhType::Media | PhType::Picture | PhType::ClipArt, ) => &ctx.master.body_style, Some(_) => &ctx.master.other_style, None => &ctx.pres.default_text_style, } } pub fn xfrm(ctx: &Ctx, shape: &Shape) -> Xfrm { if let Some(x) = shape.xfrm { return x; } let (lp, mp) = parents(ctx, shape); lp.and_then(|s| s.xfrm).or_else(|| mp.and_then(|s| s.xfrm)).unwrap_or_else(|| { let sz = ctx.pres.slide_size; Xfrm::new(sz.width * 0.1, sz.height * 0.1, sz.width * 0.8, sz.height * 0.2) }) } /// Effective fill and the style colour (`phClr`) to resolve it with. pub fn fill(ctx: &Ctx, shape: &Shape) -> (Option, Option) { let (lp, mp) = parents(ctx, shape); let style_fill = |s: &Shape| { s.style.as_ref().map(|st| { let (idx, col) = &st.fill_ref; let ph = ctx.color(col, None); let f = match *idx { 0 => Some(Fill::None), i if i >= 1001 => ctx.master.theme.format.bg_fills.get((i - 1001) as usize).cloned(), i => ctx.master.theme.format.fills.get((i - 1) as usize).cloned(), }; (f, Some(ph)) }) }; for s in [Some(shape), lp, mp].into_iter().flatten() { if let Some(f) = &s.fill { // A gradient without stops (`a:gradFill` with no `a:gsLst`) takes them from the // gradient its style refers to, keeping its own direction, as PowerPoint draws it. if let Fill::Gradient(g) = f && g.stops.is_empty() && let Some((Some(Fill::Gradient(sg)), ph)) = style_fill(s) { let mut g = g.clone(); g.stops = sg.stops; return (Some(Fill::Gradient(g)), ph); } return (Some(f.clone()), None); } if let Some(r) = style_fill(s) { return r; } } if shape.text_box { return (Some(Fill::None), None); } (None, None) } pub fn line(ctx: &Ctx, shape: &Shape) -> (Line, Option) { let (lp, mp) = parents(ctx, shape); let mut out = Line::default(); let mut ph = None; for s in [Some(shape), lp, mp].into_iter().flatten() { if let Some(l) = &s.line { out.inherit(l); } if let Some(st) = &s.style { let (idx, col) = &st.line_ref; if ph.is_none() { ph = Some(ctx.color(col, None)); } if *idx == 0 { out.inherit(&Line::none()); } else if let Some(l) = ctx.master.theme.format.lines.get((*idx - 1) as usize) { out.inherit(l); } break; } } (out, ph) } pub fn effects(ctx: &Ctx, shape: &Shape) -> (Option, Option) { let (lp, mp) = parents(ctx, shape); for s in [Some(shape), lp, mp].into_iter().flatten() { if let Some(e) = &s.effects { return (Some(e.clone()), None); } if let Some(st) = &s.style { let (idx, col) = &st.effect_ref; if *idx == 0 { return (None, None); } return (ctx.master.theme.format.effects.get((*idx - 1) as usize).cloned(), Some(ctx.color(col, None))); } } (None, None) } /// Body properties with defaults filled in. pub fn body(ctx: &Ctx, shape: &Shape) -> BodyProps { let (lp, mp) = parents(ctx, shape); let mut b = shape.text.as_ref().map(|t| t.body.clone()).unwrap_or_default(); for s in [lp, mp].into_iter().flatten() { if let Some(t) = &s.text { b.inherit(&t.body); } } b.inherit(&BodyProps { inset_l: Some(7.2), inset_t: Some(3.6), inset_r: Some(7.2), inset_b: Some(3.6), anchor: Some(if shape.ph.is_none() && !shape.text_box && !matches!(shape.kind, crate::ShapeKind::Table(_)) { Anchor::Middle } else { Anchor::Top }), anchor_ctr: Some(false), wrap: Some(true), autofit: Some(if shape.text_box { AutoFit::Shape } else { AutoFit::None }), vert: Some(crate::text::TextDir::Horizontal), rot: Some(0.0), columns: Some(1), col_spacing: Some(0.0), warp: None, warp_adj: Vec::new(), rtl_col: Some(false), upright: Some(false), }); b } /// The merged level style (paragraph + run defaults) for paragraphs at `level` in `shape`. pub fn level_style(ctx: &Ctx, shape: &Shape, level: u8) -> LevelStyle { let (lp, mp) = parents(ctx, shape); let mut out = LevelStyle::default(); let add = |out: &mut LevelStyle, ls: Option<&LevelStyle>| { if let Some(l) = ls { out.para.inherit(&l.para); out.run.inherit(&l.run); } }; if let Some(t) = &shape.text { add(&mut out, t.list_style.level(level)); } for s in [lp, mp].into_iter().flatten() { if let Some(t) = &s.text { add(&mut out, t.list_style.level(level)); } } // Shape style font reference (theme font + colour) sits above the defaults. if let Some(st) = shape.style.as_ref().or(lp.and_then(|s| s.style.as_ref())) { let font = match st.font_ref.0.as_str() { "major" => Some("+mj-lt".to_string()), "minor" => Some("+mn-lt".to_string()), _ => None, }; let r = RunProps { font, fill: st.font_ref.1.clone().map(Fill::solid), ..Default::default() }; out.run.inherit(&r); } add(&mut out, master_list_style(ctx, shape).level(level)); add(&mut out, ctx.pres.default_text_style.level(level)); // Last resort. out.run.inherit(&RunProps { size: Some(18.0), font: Some("+mn-lt".into()), bold: Some(false), italic: Some(false), ..Default::default() }); out.para.inherit(&ParaProps { margin_left: Some(0.0), indent: Some(0.0), align: Some(crate::text::Align::Left), ..Default::default() }); out } /// Fully resolved run properties for `run` in paragraph `para` (explicit → paragraph style). pub fn run(ctx: &Ctx, shape: &Shape, para: &crate::text::Paragraph, run: &RunProps) -> RunProps { let mut r = run.clone(); let lvl = level_style(ctx, shape, para.level); r.inherit(&lvl.run); // Hyperlinked text is drawn in the theme's hyperlink colour and underlined, as in PowerPoint, // unless the run itself sets a colour (honoured since PowerPoint 2019) or an underline // (`u="none"` removes it). Colours and underlines from list styles don't count. if r.link.is_some() { if run.fill.is_none() { r.fill = Some(Fill::solid(ColorRef::scheme(SchemeSlot::Hlink))); } if run.underline.is_none() { r.underline = Some("sng".into()); } } r } pub fn para(ctx: &Ctx, shape: &Shape, para: &crate::text::Paragraph) -> ParaProps { let mut p = para.props.clone(); let lvl = level_style(ctx, shape, para.level); p.inherit(&lvl.para); p } /// Concrete font family for a run (theme references resolved). pub fn font_family(ctx: &Ctx, r: &RunProps) -> String { let f = r.font.as_deref().unwrap_or("+mn-lt"); let name = ctx.master.theme.font(f); if name.is_empty() { ctx.master.theme.fonts.minor.latin.clone() } else { name } } /// Text colour of resolved run props. pub fn text_color(ctx: &Ctx, r: &RunProps) -> Rgba { match &r.fill { Some(Fill::Solid { color }) => ctx.color(color, None), Some(Fill::Gradient(g)) => g.stops.first().map(|s| ctx.color(&s.color, None)).unwrap_or(Rgba::BLACK), Some(Fill::None) => Rgba::TRANSPARENT, _ => ctx.color(&ColorRef::scheme(SchemeSlot::Tx1), None), } } /// The background fill (and its style colour) of a slide. pub fn background(ctx: &Ctx, slide: Option<&Slide>) -> (Fill, Option) { let bg = slide.and_then(|s| s.background.as_ref()).or(ctx.layout.and_then(|l| l.background.as_ref())).or(ctx.master.background.as_ref()); match bg { Some(Background::Fill { fill }) => (fill.clone(), None), Some(Background::Ref { idx, color }) => { let ph = ctx.color(color, None); let f = if *idx >= 1001 { ctx.master.theme.format.bg_fills.get((*idx - 1001) as usize).cloned() } else { ctx.master.theme.format.fills.get(idx.saturating_sub(1) as usize).cloned() }; (f.unwrap_or(Fill::solid(ColorRef::rgb(ph))), Some(ph)) } None => (Fill::solid(ColorRef::scheme(SchemeSlot::Bg1)), None), } } /// Should the layout/master graphics (non-placeholder shapes) show behind this slide? pub fn show_master_shapes(slide: &Slide, layout: Option<&Layout>) -> (bool, bool) { let show_layout = slide.show_master_shapes; let show_master = show_layout && layout.is_none_or(|l| l.show_master_shapes); (show_layout, show_master) }