Pictures: duotone recolours, and layout/slide colour map overrides apply (#269)

Theme background pictures (Office 2007-era themes such as Treck) carry an
a:duotone recolour in phClr; DeckCraft read it but drew the picture
unchanged, so those backgrounds came out grey. The painter now resolves the
duotone's colours against the slide's colours and placeholder colour, and
the decoded-image cache applies them (luminance picks a colour between dark
and light) and keys on them.

Layouts and slides can replace the master's colour map
(p:clrMapOvr/a:overrideClrMapping, e.g. dark section layouts mapping bg1 to
dk1). They were read as the master's map; now Layout and Slide carry an
optional colour map, resolve::Ctx uses slide > layout > master, and the
writer keeps them.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-12 17:35:52 -04:00 committed by GitHub
parent 235e34613b
commit 4d13652f0f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 190 additions and 29 deletions

View File

@ -483,6 +483,7 @@ fn insert_layout(s: &mut Session, p: &Value) -> Result<Value> {
background: None,
show_master_shapes: true,
preserve: true,
color_map: None,
raw_ext: None,
},
);

View File

@ -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,
});
};

View File

@ -488,6 +488,10 @@ pub struct Slide {
/// Speaker notes.
pub notes: TextBody,
pub comments: Vec<Comment>,
/// `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<Vec<(String, String)>>,
/// Unmodelled XML kept for round-trip (custom data, tags).
#[serde(skip_serializing_if = "Option::is_none")]
pub raw_ext: Option<String>,
@ -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<Background>,
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<Vec<(String, String)>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub raw_ext: Option<String>,
}
@ -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 {

View File

@ -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<Ctx<'a>> {
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>) -> 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(),
};

View File

@ -159,3 +159,31 @@ fn hyperlinked_runs_take_the_theme_hyperlink_look() {
let dbl = resolve::run(&ctx, title, &para, &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));
}

View File

@ -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<Vec<(String, String)>> {
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();

View File

@ -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<u8> {
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<u8> {
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);
}

View File

@ -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<Vec<(String, String)>>, 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)));
}

View File

@ -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<image::RgbaImage> {
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};

View File

@ -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) => {

View File

@ -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<Rgba>) -> 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<f64>) {