diff --git a/crates/model/src/style.rs b/crates/model/src/style.rs index c7cd148..b1b2a53 100644 --- a/crates/model/src/style.rs +++ b/crates/model/src/style.rs @@ -157,6 +157,12 @@ pub struct PictureAdjust { /// Make a colour transparent (Set Transparent Color). pub clear_color: Option, pub sharpen: f64, + /// `a:biLevel`: black and white, split at this luminance (0–1). + #[serde(skip_serializing_if = "Option::is_none")] + pub bilevel: Option, + /// `a:alphaInv`: opacity inverted. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub alpha_inv: bool, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/pptx/src/read/dml.rs b/crates/pptx/src/read/dml.rs index 004cd4b..b0f7a80 100644 --- a/crates/pptx/src/read/dml.rs +++ b/crates/pptx/src/read/dml.rs @@ -135,6 +135,8 @@ pub fn blip_fill(imp: &mut Imp, part: &Part, bf: &El) -> Option { pf.adjust.contrast = pct(e, "contrast").unwrap_or(0.0).clamp(-1.0, 1.0); } "grayscl" => pf.adjust.grayscale = true, + "biLevel" => pf.adjust.bilevel = Some(pct(e, "thresh").unwrap_or(0.5).clamp(0.0, 1.0)), + "alphaInv" => pf.adjust.alpha_inv = true, "duotone" => { let cs: Vec = e.elements().filter_map(color_el).collect(); if let [a, b] = cs.as_slice() { diff --git a/crates/pptx/src/write/dml.rs b/crates/pptx/src/write/dml.rs index c1168ca..2aa8693 100644 --- a/crates/pptx/src/write/dml.rs +++ b/crates/pptx/src/write/dml.rs @@ -98,8 +98,14 @@ pub fn blip_fill(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, pf: &PictureFil let rid = x.media_rel(o, pf.media, "image").unwrap_or_else(|| x.placeholder_rel(o)); w.open(tag, A::new().a("rotWithShape", "1")); let adj = &pf.adjust; - let has_kids = - pf.alpha.is_some() || adj.brightness != 0.0 || adj.contrast != 0.0 || adj.grayscale || adj.duotone.is_some() || adj.clear_color.is_some(); + let has_kids = pf.alpha.is_some() + || adj.brightness != 0.0 + || adj.contrast != 0.0 + || adj.grayscale + || adj.duotone.is_some() + || adj.clear_color.is_some() + || adj.bilevel.is_some() + || adj.alpha_inv; let a = A::new().a("r:embed", &rid); if has_kids { w.open("a:blip", a); @@ -116,6 +122,12 @@ pub fn blip_fill(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, pf: &PictureFil if let Some(al) = pf.alpha { w.empty("a:alphaModFix", A::new().a("amt", pct(al.clamp(0.0, 1.0)))); } + if adj.alpha_inv { + w.empty0("a:alphaInv"); + } + if let Some(t) = adj.bilevel { + w.empty("a:biLevel", A::new().a("thresh", pct(t.clamp(0.0, 1.0)))); + } if let Some((d, l)) = &adj.duotone { w.open0("a:duotone"); color(w, d); diff --git a/crates/pptx/tests/fills.rs b/crates/pptx/tests/fills.rs index 77e719e..98e6d1b 100644 --- a/crates/pptx/tests/fills.rs +++ b/crates/pptx/tests/fills.rs @@ -203,3 +203,28 @@ fn a_table_naming_no_style_is_drawn_with_the_table_grid() { }); assert_eq!(style(&bare), "grid"); } + +#[test] +fn black_and_white_and_inverted_alpha_pictures_round_trip() { + let mut p = defaults::new_presentation(None); + let media = p.add_media("tile.png", "image/png", png()); + let id = ShapeId(p.alloc_id()); + let first = p.slides[0].id; + let mut pf = PictureFill { media, ..Default::default() }; + pf.adjust.bilevel = Some(0.3); + pf.adjust.alpha_inv = true; + p.slide_mut(first).expect("slide").shapes.push(Shape { + id, + name: "BW".into(), + xfrm: Some(Xfrm::new(10.0, 10.0, 200.0, 100.0)), + fill: Some(Fill::Picture(pf)), + ..Default::default() + }); + let bytes = deckcraft_pptx::export(&p).expect("export"); + let q = deckcraft_pptx::import(&bytes).expect("import"); + let s = q.slides[0].shapes.iter().find(|s| s.name == "BW").expect("shape"); + match &s.fill { + Some(Fill::Picture(pf)) => assert_eq!((pf.adjust.bilevel, pf.adjust.alpha_inv), (Some(0.3), true)), + other => panic!("{other:?}"), + } +} diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs index f3010da..f7d56c5 100644 --- a/crates/render/src/images.rs +++ b/crates/render/src/images.rs @@ -75,6 +75,8 @@ fn adjust_key(a: &PictureAdjust) -> u64 { mix(a.contrast.to_bits()); mix(a.saturation.unwrap_or(1.0).to_bits()); mix(a.grayscale as u64); + mix(a.bilevel.map_or(u64::MAX, f64::to_bits)); + mix(a.alpha_inv as u64); mix(a.sharpen.to_bits()); if let Some(c) = a.clear_color { mix(((c.r as u64) << 16) | ((c.g as u64) << 8) | c.b as u64 | 1 << 40); @@ -306,7 +308,15 @@ pub(crate) fn gif_pixmap(bytes: &Arc>, adj: &PictureAdjust, t: f64) -> O fn apply_adjust(img: &mut image::RgbaImage, a: &PictureAdjust) { let sat = a.saturation.unwrap_or(1.0); - if a.brightness == 0.0 && a.contrast == 0.0 && sat == 1.0 && !a.grayscale && a.clear_color.is_none() && a.duotone.is_none() { + if a.brightness == 0.0 + && a.contrast == 0.0 + && sat == 1.0 + && !a.grayscale + && a.clear_color.is_none() + && a.duotone.is_none() + && a.bilevel.is_none() + && !a.alpha_inv + { return; } let duo = duotone_rgb(a); @@ -323,7 +333,7 @@ fn apply_adjust(img: &mut image::RgbaImage, a: &PictureAdjust) { continue; } let mut rgb = [p[0] as f64, p[1] as f64, p[2] as f64]; - let y = 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]; + let y = luma(rgb); let s = if a.grayscale { 0.0 } else { sat }; for v in &mut rgb { *v = y + (*v - y) * s; @@ -334,18 +344,32 @@ fn apply_adjust(img: &mut image::RgbaImage, a: &PictureAdjust) { } // 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); + let t = (luma(rgb) / 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]; } + // Black and white: white where the luminance reaches the threshold. + if let Some(t) = a.bilevel { + let v = if luma(rgb) >= t * 255.0 { 255.0 } else { 0.0 }; + rgb = [v; 3]; + } 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; } } + if a.alpha_inv { + p[3] = 255 - p[3]; + } } } +/// Luminance as PowerPoint weighs it for grayscale, black-and-white and duotone pictures +/// (measured: Rec. 709 weights on the stored values; pure red greys to 54, green to 182). +fn luma(rgb: [f64; 3]) -> f64 { + 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2] +} + #[cfg(test)] mod tests { #![allow(clippy::expect_used)] @@ -389,6 +413,29 @@ mod tests { assert!(near(run(0.0, -0.8, &[0, 128, 255]), &[102, 127, 152])); } + #[test] + fn grayscale_black_and_white_and_inverted_alpha_like_powerpoint() { + let swatches = [[255u8, 0, 0], [0, 255, 0], [0, 0, 255], [200, 100, 50], [60, 160, 110], [120, 120, 130]]; + let run = |adj: PictureAdjust| -> Vec<[u8; 4]> { + let mut img = image::RgbaImage::from_fn(swatches.len() as u32, 1, |x, _| { + let c = swatches[x as usize]; + image::Rgba([c[0], c[1], c[2], 200]) + }); + apply_adjust(&mut img, &adj); + img.pixels().map(|p| p.0).collect() + }; + // PowerPoint's export of these swatches in grayscale: 54, 182, 18, 117, 135, 120. + let g = run(PictureAdjust { grayscale: true, ..Default::default() }); + for (got, want) in g.iter().zip([54u8, 182, 18, 117, 135, 120]) { + assert!((got[0] as i32 - want as i32).abs() <= 1 && got[0] == got[1] && got[1] == got[2], "{got:?} vs {want}"); + } + // Black and white at 50%: only green and the light green reach mid-grey. + let bw: Vec = run(PictureAdjust { bilevel: Some(0.5), ..Default::default() }).iter().map(|p| p[0]).collect(); + assert_eq!(bw, [0, 255, 0, 0, 255, 0]); + // Inverted alpha: 200 becomes 55. + assert!(run(PictureAdjust { alpha_inv: true, ..Default::default() }).iter().all(|p| p[3] == 55)); + } + #[test] fn opposite_brightness_and_contrast_are_cached_apart() { let a = PictureAdjust { brightness: 0.4, contrast: 0.4, ..Default::default() };