deckcraft/crates/model/src/resolve.rs

351 lines
14 KiB
Rust

//! 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<Ctx<'a>> {
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>) -> 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<Fill>, Option<Rgba>) {
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<Rgba>) {
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<Effects>, Option<Rgba>) {
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<Rgba>) {
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)
}