diff --git a/crates/engine/src/cmd/design.rs b/crates/engine/src/cmd/design.rs index 641ddff..3cad3f0 100644 --- a/crates/engine/src/cmd/design.rs +++ b/crates/engine/src/cmd/design.rs @@ -483,6 +483,7 @@ fn insert_layout(s: &mut Session, p: &Value) -> Result { background: None, show_master_shapes: true, preserve: true, + color_map: None, raw_ext: None, }, ); diff --git a/crates/model/src/defaults.rs b/crates/model/src/defaults.rs index 8a2b5b4..9b50ae2 100644 --- a/crates/model/src/defaults.rs +++ b/crates/model/src/defaults.rs @@ -243,6 +243,7 @@ pub fn build_master(size: Size, theme: Theme, first_id: u32) -> (Master, u32) { background: None, show_master_shapes: true, preserve: true, + color_map: None, raw_ext: None, }); }; diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 6940fa4..ef65a79 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -488,6 +488,10 @@ pub struct Slide { /// Speaker notes. pub notes: TextBody, pub comments: Vec, + /// `clrMapOvr`: a colour map replacing the layout's or master's (`bg1`→`dk1`…); None follows + /// the layout. + #[serde(skip_serializing_if = "Option::is_none")] + pub color_map: Option>, /// Unmodelled XML kept for round-trip (custom data, tags). #[serde(skip_serializing_if = "Option::is_none")] pub raw_ext: Option, @@ -507,6 +511,7 @@ impl Default for Slide { animations: vec![], notes: TextBody::default(), comments: vec![], + color_map: None, raw_ext: None, } } @@ -596,6 +601,10 @@ pub struct Layout { pub background: Option, pub show_master_shapes: bool, pub preserve: bool, + /// `clrMapOvr`: a colour map replacing the master's for this layout and the slides using it + /// (dark "section" layouts map `bg1` to `dk1`); None follows the master. + #[serde(skip_serializing_if = "Option::is_none")] + pub color_map: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub raw_ext: Option, } @@ -641,24 +650,17 @@ impl Master { } /// Resolve `bg1`/`tx1`/`bg2`/`tx2` through the colour map. pub fn map_slot(&self, slot: SchemeSlot) -> SchemeSlot { - let key = match slot { - SchemeSlot::Bg1 => "bg1", - SchemeSlot::Tx1 => "tx1", - SchemeSlot::Bg2 => "bg2", - SchemeSlot::Tx2 => "tx2", - other => return other, - }; - self.color_map.iter().find(|(k, _)| k == key).and_then(|(_, v)| SchemeSlot::from_xml(v)).unwrap_or(match slot { - SchemeSlot::Bg1 => SchemeSlot::Lt1, - SchemeSlot::Tx1 => SchemeSlot::Dk1, - SchemeSlot::Bg2 => SchemeSlot::Lt2, - _ => SchemeSlot::Dk2, - }) + map_slot(&self.color_map, slot) } /// The theme's colour scheme with the colour map applied (`bg1`… resolve to mapped colours). pub fn scheme(&self) -> ColorScheme { + self.scheme_with(&self.color_map) + } + /// The theme's colour scheme with `map` (a layout's or slide's override, or the master's own) + /// applied. + pub fn scheme_with(&self, map: &[(String, String)]) -> ColorScheme { let mut s = self.theme.colors.clone(); - if self.color_map.is_empty() { + if map.is_empty() { return s; } let base = self.theme.colors.clone(); @@ -668,12 +670,30 @@ impl Master { (SchemeSlot::Bg2, SchemeSlot::Lt2), (SchemeSlot::Tx2, SchemeSlot::Dk2), ] { - s.set(slot, base.get(self.map_slot(alias))); + s.set(slot, base.get(map_slot(map, alias))); } s } } +/// Resolve `bg1`/`tx1`/`bg2`/`tx2` through the colour map `map` (the default mapping when it has +/// no entry). +pub fn map_slot(map: &[(String, String)], slot: SchemeSlot) -> SchemeSlot { + let key = match slot { + SchemeSlot::Bg1 => "bg1", + SchemeSlot::Tx1 => "tx1", + SchemeSlot::Bg2 => "bg2", + SchemeSlot::Tx2 => "tx2", + other => return other, + }; + map.iter().find(|(k, _)| k == key).and_then(|(_, v)| SchemeSlot::from_xml(v)).unwrap_or(match slot { + SchemeSlot::Bg1 => SchemeSlot::Lt1, + SchemeSlot::Tx1 => SchemeSlot::Dk1, + SchemeSlot::Bg2 => SchemeSlot::Lt2, + _ => SchemeSlot::Dk2, + }) +} + #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct Section { diff --git a/crates/model/src/resolve.rs b/crates/model/src/resolve.rs index 8769b57..3ea7f99 100644 --- a/crates/model/src/resolve.rs +++ b/crates/model/src/resolve.rs @@ -22,23 +22,27 @@ pub struct Ctx<'a> { 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)?; - Some(Ctx { pres, master, layout, owner: Owner::Slide, scheme: master.scheme() }) + 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> { - Ctx { pres, master, layout: Some(layout), owner: Owner::Layout, scheme: master.scheme() } + 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() } + 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: self.master.map_slot(*slot) }, mods: c.mods.clone() } + ColorRef { base: crate::ColorBase::Scheme { slot: crate::map_slot(&self.color_map, *slot) }, mods: c.mods.clone() } } _ => c.clone(), }; diff --git a/crates/model/src/tests.rs b/crates/model/src/tests.rs index c34fa17..2d028f8 100644 --- a/crates/model/src/tests.rs +++ b/crates/model/src/tests.rs @@ -159,3 +159,31 @@ fn hyperlinked_runs_take_the_theme_hyperlink_look() { let dbl = resolve::run(&ctx, title, ¶, &RunProps { link, underline: Some("dbl".into()), ..Default::default() }); assert_eq!((dbl.fill, dbl.underline.as_deref()), (hlink, Some("dbl"))); } + +#[test] +fn a_layout_colour_map_override_applies_to_its_slides() { + // A dark "section" layout maps bg1→dk1 and tx1→lt1; a slide on it that keeps the master + // mapping (`masterClrMapping`) follows the layout, as PowerPoint draws it. + let mut p = Presentation::default(); + let dark: Vec<(String, String)> = + [("bg1", "dk1"), ("tx1", "lt1"), ("bg2", "dk2"), ("tx2", "lt2")].iter().map(|(a, b)| (a.to_string(), b.to_string())).collect(); + let layout = p.slides[0].layout; + let m = std::sync::Arc::make_mut(&mut p.masters[0]); + let colors = m.theme.colors.clone(); + m.layouts.iter_mut().find(|l| l.id == layout).unwrap().color_map = Some(dark.clone()); + let s: Slide = (*p.slides[0]).clone(); + let ctx = Ctx::for_slide(&p, &s).unwrap(); + assert_eq!(ctx.color(&ColorRef::scheme(SchemeSlot::Bg1), None), colors.get(SchemeSlot::Dk1)); + assert_eq!(ctx.color(&ColorRef::scheme(SchemeSlot::Tx2), None), colors.get(SchemeSlot::Lt2)); + assert_eq!(ctx.scheme.get(SchemeSlot::Lt1), colors.get(SchemeSlot::Dk1)); + // The slide's own override wins over the layout's. + let mut own = s.clone(); + own.color_map = Some([("bg1", "lt1"), ("tx1", "dk1")].iter().map(|(a, b)| (a.to_string(), b.to_string())).collect()); + let ctx = Ctx::for_slide(&p, &own).unwrap(); + assert_eq!(ctx.color(&ColorRef::scheme(SchemeSlot::Bg1), None), colors.get(SchemeSlot::Lt1)); + // The layout itself draws with its override; the master keeps its own map. + let m = &p.masters[0]; + let l = m.layout(layout).unwrap(); + assert_eq!(Ctx::for_layout(&p, m, l).color(&ColorRef::scheme(SchemeSlot::Bg1), None), colors.get(SchemeSlot::Dk1)); + assert_eq!(Ctx::for_master(&p, m).color(&ColorRef::scheme(SchemeSlot::Bg1), None), colors.get(SchemeSlot::Lt1)); +} diff --git a/crates/pptx/src/read/mod.rs b/crates/pptx/src/read/mod.rs index b8c403b..0a42dd2 100644 --- a/crates/pptx/src/read/mod.rs +++ b/crates/pptx/src/read/mod.rs @@ -523,12 +523,18 @@ fn read_layout(imp: &mut Imp, part: &Part, doc: &Doc) -> Layout { l.shapes = shapes::sp_tree(imp, part, &mut ctx, tree, 0); } } + l.color_map = color_map_override(root); if let Some(ext) = root.child("extLst").filter(|e| !has_rel_refs(e)) { l.raw_ext = Some(ext.elements().map(|e| part.keep(e)).collect()); } l } +/// A layout's or slide's `clrMapOvr/overrideClrMapping` (None for `masterClrMapping` or none). +fn color_map_override(root: &El) -> Option> { + root.child("clrMapOvr")?.child("overrideClrMapping").map(color_map) +} + fn read_slide(imp: &mut Imp, part: &Part, doc: &Doc, id: SlideId, layout: LayoutId) -> Slide { let root = &doc.root; let mut s = Slide { @@ -536,6 +542,7 @@ fn read_slide(imp: &mut Imp, part: &Part, doc: &Doc, id: SlideId, layout: Layout layout, hidden: root.bool("show") == Some(false), show_master_shapes: root.bool("showMasterSp").unwrap_or(true), + color_map: color_map_override(root), ..Default::default() }; let mut ctx = shapes::IdCtx::default(); diff --git a/crates/pptx/src/write/mod.rs b/crates/pptx/src/write/mod.rs index 5d15246..0329e2e 100644 --- a/crates/pptx/src/write/mod.rs +++ b/crates/pptx/src/write/mod.rs @@ -227,6 +227,21 @@ const CLR_KEYS: [(&str, &str); 12] = [ ]; fn clr_map(w: &mut W, map: &[(String, String)]) { + w.empty("p:clrMap", clr_map_attrs(map)); +} + +/// `p:clrMapOvr`: the layout's or slide's own colour map, else `a:masterClrMapping`. +fn clr_map_ovr(w: &mut W, map: Option<&[(String, String)]>) { + w.open0("p:clrMapOvr"); + match map { + Some(m) => w.empty("a:overrideClrMapping", clr_map_attrs(m)), + None => w.empty0("a:masterClrMapping"), + } + w.close("p:clrMapOvr"); +} + +/// The twelve colour map attributes, each a theme slot (the default for anything else). +fn clr_map_attrs(map: &[(String, String)]) -> A { let mut a = A::new(); for (k, d) in CLR_KEYS { let v = map @@ -237,7 +252,7 @@ fn clr_map(w: &mut W, map: &[(String, String)]) { .unwrap_or(d); a = a.a(k, v); } - w.empty("p:clrMap", a); + a } fn ext_list(w: &mut W, raw: Option<&str>) { @@ -522,9 +537,7 @@ fn layout_xml(x: &mut Exp, o: &mut Out, l: &Layout) -> Vec { A::new().o("showMasterSp", (!l.show_master_shapes).then_some("0")).a("type", l.kind.xml()).t("preserve", l.preserve), ); csld(&mut w, x, o, &l.name, l.background.as_ref(), &l.shapes); - w.open0("p:clrMapOvr"); - w.empty0("a:masterClrMapping"); - w.close("p:clrMapOvr"); + clr_map_ovr(&mut w, l.color_map.as_deref()); ext_list(&mut w, ext_children(l.raw_ext.as_deref()).as_deref()); w.close("p:sldLayout"); w.finish() @@ -556,9 +569,7 @@ fn slide_xml(x: &mut Exp, o: &mut Out, s: &Slide) -> Vec { let mut w = W::new(); root_open(&mut w, "p:sld", A::new().o("showMasterSp", (!s.show_master_shapes).then_some("0")).o("show", s.hidden.then_some("0"))); let ids = csld(&mut w, x, o, &s.name, s.background.as_ref(), &s.shapes); - w.open0("p:clrMapOvr"); - w.empty0("a:masterClrMapping"); - w.close("p:clrMapOvr"); + clr_map_ovr(&mut w, s.color_map.as_deref()); if let Some(t) = &s.transition { timing::transition(&mut w, x, o, t); } diff --git a/crates/pptx/tests/roundtrip.rs b/crates/pptx/tests/roundtrip.rs index 420a392..fddb0d9 100644 --- a/crates/pptx/tests/roundtrip.rs +++ b/crates/pptx/tests/roundtrip.rs @@ -730,3 +730,39 @@ fn date_fields_keep_their_type_and_saved_text() { assert_eq!(by_name(&q.slides[0], name).text, by_name(&p.slides[0], name).text, "{name}"); } } + +#[test] +fn colour_map_overrides_round_trip() { + let mut p = defaults::new_presentation(None); + let dark: Vec<(String, String)> = + [("bg1", "dk1"), ("tx1", "lt1"), ("bg2", "dk2"), ("tx2", "lt2")].iter().map(|(a, b)| (a.to_string(), b.to_string())).collect(); + let layout = p.slides[0].layout; + std::sync::Arc::make_mut(&mut p.masters[0]).layouts.iter_mut().find(|l| l.id == layout).expect("layout").color_map = Some(dark.clone()); + let first = p.slides[0].id; + p.slide_mut(first).expect("slide").color_map = Some(dark.clone()); + let q = round(&p); + let get = |m: &Option>, k: &str| m.as_ref().and_then(|m| m.iter().find(|(a, _)| a == k).map(|(_, v)| v.clone())); + let ql = q.masters[0].layouts.iter().find(|l| l.color_map.is_some()).expect("the layout keeps its override"); + assert_eq!(get(&ql.color_map, "bg1").as_deref(), Some("dk1")); + assert_eq!(get(&q.slides[0].color_map, "tx1").as_deref(), Some("lt1")); + // Layouts and slides without one keep following the master. + assert!(q.masters[0].layouts.iter().filter(|l| l.color_map.is_none()).count() >= 1); +} + +#[test] +fn a_picture_duotone_round_trips() { + let mut p = defaults::new_presentation(None); + let img = p.add_media("image1.png", "image/png", png(4, 4, [128, 128, 128])); + let mut pic = shape(&mut p, "Duotone", 10.0, 10.0, 100.0, 100.0); + let mut pf = PictureFill { media: img, ..Default::default() }; + pf.adjust.duotone = Some((ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::Shade(30000)), ColorRef::rgb(Rgba::rgb(250, 240, 200)))); + pic.kind = deckcraft_model::ShapeKind::Picture { fill: pf }; + let first = p.slides[0].id; + p.slide_mut(first).expect("slide").shapes.push(pic); + let q = round(&p); + let sh = by_name(&q.slides[0], "Duotone"); + let deckcraft_model::ShapeKind::Picture { fill } = &sh.kind else { panic!("picture") }; + let (d, l) = fill.adjust.duotone.as_ref().expect("duotone kept"); + assert!(matches!(d.base, deckcraft_model::ColorBase::Scheme { slot: SchemeSlot::Accent1 })); + assert!(matches!(l.base, deckcraft_model::ColorBase::Rgb { rgb } if rgb == Rgba::rgb(250, 240, 200))); +} diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs index f95102a..09e1baa 100644 --- a/crates/render/src/images.rs +++ b/crates/render/src/images.rs @@ -79,9 +79,26 @@ fn adjust_key(a: &PictureAdjust) -> u64 { if let Some(c) = a.clear_color { mix(((c.r as u64) << 16) | ((c.g as u64) << 8) | c.b as u64 | 1 << 40); } + if let Some((d, l)) = duotone_rgb(a) { + for c in [d, l] { + mix(((c.r as u64) << 24) | ((c.g as u64) << 16) | ((c.b as u64) << 8) | c.a as u64 | 1 << 41); + } + } h } +/// The duotone's dark and light colours. The painter resolves them to plain RGB first (see +/// `paint::resolved`); a colour that is still a scheme reference falls back to black and white. +fn duotone_rgb(a: &PictureAdjust) -> Option<(deckcraft_color::Rgba, deckcraft_color::Rgba)> { + use deckcraft_model::style::ColorBase; + let (d, l) = a.duotone.as_ref()?; + let rgb = |c: &deckcraft_model::style::ColorRef, or: deckcraft_color::Rgba| match &c.base { + ColorBase::Rgb { rgb } if c.mods.is_empty() => *rgb, + _ => or, + }; + Some((rgb(d, deckcraft_color::Rgba::BLACK), rgb(l, deckcraft_color::Rgba::WHITE))) +} + /// Decode encoded bytes (PNG, JPEG, GIF, WebP, BMP, TIFF) to straight RGBA. pub fn decode(bytes: &[u8]) -> Option { if bytes.len() > 512 << 20 { @@ -233,6 +250,7 @@ fn apply_adjust(img: &mut image::RgbaImage, a: &PictureAdjust) { if a.brightness == 0.0 && a.contrast == 0.0 && sat == 1.0 && !a.grayscale && a.clear_color.is_none() && a.duotone.is_none() { return; } + let duo = duotone_rgb(a); let b = a.brightness.clamp(-1.0, 1.0) * 255.0; let c = a.contrast.clamp(-1.0, 1.0); let cf = if c >= 0.0 { 1.0 / (1.0 - c * 0.99) } else { 1.0 + c }; @@ -252,6 +270,12 @@ fn apply_adjust(img: &mut image::RgbaImage, a: &PictureAdjust) { *v = y + (*v - y) * s; *v = (*v - 128.0) * cf + 128.0 + b; } + // Duotone: the pixel's luminance picks a colour between the dark and the light one. + if let Some((d, l)) = duo { + let t = ((0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]) / 255.0).clamp(0.0, 1.0); + rgb = + [d.r as f64 + (l.r as f64 - d.r as f64) * t, d.g as f64 + (l.g as f64 - d.g as f64) * t, d.b as f64 + (l.b as f64 - d.b as f64) * t]; + } for (i, v) in rgb.iter().enumerate() { if let Some(ch) = p.0.get_mut(i) { *ch = v.round().clamp(0.0, 255.0) as u8; @@ -266,6 +290,25 @@ mod tests { use super::*; + #[test] + fn duotone_maps_luminance_between_its_two_colours() { + use deckcraft_color::Rgba; + use deckcraft_model::style::ColorRef; + let mut img = image::RgbaImage::from_fn(3, 1, |x, _| image::Rgba([[0, 0, 0, 255], [128, 128, 128, 200], [255, 255, 255, 255]][x as usize])); + let adj = + PictureAdjust { duotone: Some((ColorRef::rgb(Rgba::rgb(100, 40, 0)), ColorRef::rgb(Rgba::rgb(250, 240, 200)))), ..Default::default() }; + apply_adjust(&mut img, &adj); + assert_eq!(img.get_pixel(0, 0).0, [100, 40, 0, 255], "black takes the dark colour"); + assert_eq!(img.get_pixel(2, 0).0, [250, 240, 200, 255], "white takes the light colour"); + let mid = img.get_pixel(1, 0).0; + assert!((mid[0] as i32 - 175).abs() <= 1 && (mid[1] as i32 - 140).abs() <= 1 && (mid[2] as i32 - 100).abs() <= 1, "{mid:?}"); + assert_eq!(mid[3], 200, "alpha is kept"); + // Different duotones are cached separately. + let other = PictureAdjust { duotone: Some((ColorRef::rgb(Rgba::rgb(0, 0, 0)), ColorRef::rgb(Rgba::rgb(255, 0, 0)))), ..Default::default() }; + assert_ne!(adjust_key(&adj), adjust_key(&other)); + assert_ne!(adjust_key(&adj), adjust_key(&PictureAdjust::default())); + } + #[test] fn a_gif_of_many_tiny_frames_keeps_only_the_frame_cap() { use image::{Delay, Frame, Rgba, RgbaImage}; diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 6dde577..b4470d6 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -683,7 +683,7 @@ impl Renderer { let bounds = Rect::new(0.0, 0.0, w, h); ctx.push_clip_layer(&outline); let t = f.opts.gif_times.iter().find(|g| g.0 == s.id).map(|g| g.1); - paint::picture(ctx, f.pres, pf, bounds, m, t); + paint::picture(ctx, f.pres, &paint::resolved(rctx, pf, None), bounds, m, t); ctx.pop_layer(); } ShapeKind::Media(mc) => { diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index 1dcbcd8..af8c768 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -3,7 +3,7 @@ use deckcraft_color::Rgba; use deckcraft_geom::preset::FillMode; use deckcraft_model::resolve::Ctx; -use deckcraft_model::style::{Fill, GradientShape, PictureFill, PictureMode}; +use deckcraft_model::style::{ColorRef, Fill, GradientShape, PictureFill, PictureMode}; use deckcraft_model::{PhType, Presentation}; use kurbo::{Affine, BezPath, Point, Rect, Shape as _}; use vello_cpu::{RenderContext, peniko}; @@ -65,7 +65,7 @@ pub fn fill_path( } Fill::Picture(pf) => { ctx.push_clip_layer(path); - picture(ctx, pres, pf, bounds, m, None); + picture(ctx, pres, &resolved(rctx, pf, ph), bounds, m, None); ctx.pop_layer(); } Fill::Pattern(p) => { @@ -120,6 +120,16 @@ fn p_rect(x: f64, y: f64, w: f64, h: f64) -> BezPath { } fn p_add(_a: &mut BezPath, _b: &mut BezPath) {} +/// `pf` with its duotone colours resolved to plain RGB against the slide's colours and the +/// placeholder colour `ph` (theme background pictures recolour with `phClr`), so the decoded-image +/// cache can apply and key them without a colour scheme. +pub fn resolved<'a>(rctx: &Ctx, pf: &'a PictureFill, ph: Option) -> std::borrow::Cow<'a, PictureFill> { + let Some((d, l)) = &pf.adjust.duotone else { return std::borrow::Cow::Borrowed(pf) }; + let mut out = pf.clone(); + out.adjust.duotone = Some((ColorRef::rgb(rctx.color(d, ph)), ColorRef::rgb(rctx.color(l, ph)))); + std::borrow::Cow::Owned(out) +} + /// Draw a picture fill into `bounds` (shape-local) honouring crop and stretch/tile modes; an animated /// GIF shows its frame at `gif_time` seconds. pub fn picture(ctx: &mut RenderContext, pres: &Presentation, pf: &PictureFill, bounds: Rect, m: Affine, gif_time: Option) {