mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-13 23:08:23 +00:00
Pictures: black and white, inverted alpha, and PowerPoint's grey weights
- a:biLevel (Color > Black and White) was ignored. It is now read, written and drawn: white where the luminance reaches the threshold, else black (observed thresholds at 30/50/70% on colour swatches). - a:alphaInv was ignored; an opaque picture with it is invisible in PowerPoint. Now read, written and drawn. - Grayscale, duotone and saturation weighed channels 0.299/0.587/0.114; PowerPoint's export greys pure red to 54 and green to 182: Rec. 709 weights (0.2126/0.7152/0.0722). Wild synthetic set: recolour slide 7.60 -> 1.15, picture corrections 13.36 -> 7.36. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
754841aaa4
commit
675d20417e
5 files changed
+97
-5
No files matched your search
@@ -157,6 +157,12 @@ pub struct PictureAdjust {
|
||||
/// Make a colour transparent (Set Transparent Color).
|
||||
pub clear_color: Option<Rgba>,
|
||||
pub sharpen: f64,
|
||||
/// `a:biLevel`: black and white, split at this luminance (0–1).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bilevel: Option<f64>,
|
||||
/// `a:alphaInv`: opacity inverted.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub alpha_inv: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
|
||||
@@ -135,6 +135,8 @@ pub fn blip_fill(imp: &mut Imp, part: &Part, bf: &El) -> Option<PictureFill> {
|
||||
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<ColorRef> = e.elements().filter_map(color_el).collect();
|
||||
if let [a, b] = cs.as_slice() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
@@ -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<Vec<u8>>, 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<u8> = 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() };
|
||||
|
||||
Reference in new issue
Block a user