Files
wordcraft/crates/engine/src/cmd/objects/picture.rs
T

498 lines
22 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::select::{obj_size, selected, with_obj};
use super::{CmdError, CmdResult, InlineObject, Rgb, Session, Value, Wrap, p};
/// `picture.crop`: the crop, and optionally the new display size, as one edit. With a size, a
/// floating picture positioned by offsets moves by what the crop on its left/top side on the page
/// trims (or restores) — for a flipped picture that's the source's right/bottom — so what stays
/// visible keeps its place on the page: a crop handle drop is one command.
pub(super) fn crop(s: &mut Session, v: &Value) -> CmdResult {
let mut c = [p::f32(v, "left"), p::f32(v, "top"), p::f32(v, "right"), p::f32(v, "bottom")].map(|x| x.unwrap_or(0.0).clamp(0.0, 0.95));
// The two sides of an axis keep at least 5% of the source between them, as the renderer
// draws it: the second side stops where the first one already left less than that.
c[2] = c[2].min(0.95 - c[0]);
c[3] = c[3].min(0.95 - c[1]);
let size = |k: &str| p::f32(v, k).map(|x| x.clamp(1.0, 4000.0));
let (nw, nh) = (size("width"), size("height"));
with_obj(s, |o| {
if let InlineObject::Image { crop, w, h, float, .. } = o {
// The source's full size at the new display size (the visible part is 1-lo-hi of it).
let full = |len: f32, lo: f32, hi: f32| len / (1.0 - lo - hi).max(0.05);
// Flipped, the source's right (bottom) edge is the one on the page's left (top).
let (left, top) = (if float.flip_h { 2 } else { 0 }, if float.flip_v { 3 } else { 1 });
if let Some(nw) = nw {
if float.wrap != Wrap::Inline && float.h_align.is_none() {
float.x += (c[left] - crop[left]) * full(nw, c[0], c[2]);
}
*w = nw;
}
if let Some(nh) = nh {
if float.wrap != Wrap::Inline && float.v_align.is_none() {
float.y += (c[top] - crop[top]) * full(nh, c[1], c[3]);
}
*h = nh;
}
*crop = c;
}
})
}
/// A picture's original size: its image's pixels at 96 ppi (the size Insert › Pictures gives it)
/// or a metafile's own frame, before any crop.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct OriginalSize {
/// Points.
pub w: f32,
pub h: f32,
/// A bitmap's pixels (None for a metafile).
pub pixels: Option<(u32, u32)>,
}
/// The original size of the picture showing `media`, read from the image its Picture Format
/// adjustments started from (Compress Pictures may have resampled the current one); None when the
/// image can't be read.
pub fn original_size(s: &Session, media: &str) -> Option<OriginalSize> {
let key = s.originals.get(media).map_or(media, String::as_str);
let bytes = s.doc.media.get(key).or_else(|| s.doc.media.get(media))?;
if let Some((pw, ph)) = wordcraft_render::image_size(bytes) {
return Some(OriginalSize { w: pw as f32 * 0.75, h: ph as f32 * 0.75, pixels: Some((pw, ph)) });
}
let (w, h, _) = wordcraft_render::metafile_size(bytes)?;
Some(OriginalSize { w, h, pixels: None })
}
/// What a picture's crop leaves of its original size: the size Reset gives back and what a scale
/// of the original counts from. None for anything but a picture whose image can be read.
pub fn cropped_original_size(s: &Session, o: &InlineObject) -> Option<(f32, f32)> {
let InlineObject::Image { media, crop, .. } = o else { return None };
let orig = original_size(s, media)?;
let part = |lo: f32, hi: f32| (1.0 - lo - hi).max(0.05);
Some((orig.w * part(crop[0], crop[2]), orig.h * part(crop[1], crop[3])))
}
/// Size: `scale` is a percentage of the current size, or with `original` of what the crop leaves
/// of the original size (`original` alone gives that back: the Size dialog's Reset); then `width`
/// and `height`, where one alone keeps the proportions unless `lockAspect` is false.
pub(super) fn size(s: &mut Session, v: &Value) -> CmdResult {
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no object".into()))?;
let lock = p::bool(v, "lockAspect").unwrap_or(true);
let (mut w, mut h) = obj_size(&o);
let scale = p::f32(v, "scale");
if p::bool(v, "original").unwrap_or(false) {
let (ow, oh) =
cropped_original_size(s, &o).ok_or_else(|| CmdError::Disabled("only a picture whose image can be read has an original size".into()))?;
let k = scale.unwrap_or(100.0) / 100.0;
(w, h) = (ow * k, oh * k);
} else if let Some(sc) = scale {
w *= sc / 100.0;
h *= sc / 100.0;
}
let (nw, nh) = (p::f32(v, "width"), p::f32(v, "height"));
match (nw, nh) {
(Some(nw), Some(nh)) => {
w = nw;
h = nh;
}
(Some(nw), None) => {
if lock && w > 0.0 {
h *= nw / w;
}
w = nw;
}
(None, Some(nh)) => {
if lock && h > 0.0 {
w *= nh / h;
}
h = nh;
}
_ => {}
}
// A size that keeps the proportions shrinks on both sides to fit, rather than flattening.
let keep = match (nw, nh) {
(Some(_), Some(_)) => false,
(None, None) => true,
_ => lock,
};
let (w, h) = fit_size(w, h, keep);
// Any drawing: a group's members scale with its frame (its child space is kept); a chart or
// SmartArt graphic lays itself out again at the new size.
let theme = s.doc.settings.theme_colors.clone();
with_obj(s, |o| {
o.set_size(w, h);
crate::cmd::chart::redraw(o, &theme);
crate::cmd::smart_art::redraw(o, &theme);
})
}
/// An object size within 1–4000 pt. With `keep`, both sides scale by one factor, the one nearest
/// 1 that brings them within the limits, so the proportions hold; a shape too long and thin for any
/// such factor is clamped side by side.
pub fn fit_size(w: f32, h: f32, keep: bool) -> (f32, f32) {
let side = |x: f32| if x.is_nan() { 1.0 } else { x.clamp(1.0, 4000.0) };
if keep && w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0 {
let (lo, hi) = ((1.0 / w).max(1.0 / h), (4000.0 / w).min(4000.0 / h));
if lo <= hi {
let k = if lo > 1.0 {
lo
} else if hi < 1.0 {
hi
} else {
1.0
};
return (side(w * k), side(h * k));
}
}
(side(w), side(h))
}
/// Apply an image operation to the selected picture's bitmap (stored as a new PNG).
pub(super) fn adjust(s: &mut Session, f: impl Fn(image::DynamicImage) -> Result<image::DynamicImage, CmdError>) -> CmdResult {
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
adjust_media(s, &media, f)
}
/// Like [`adjust`], but decodes `base` (a media key, usually the pre-edit original) instead
/// of the picture's current bitmap. The result still chains onto the originals map, so Reset
/// Picture keeps working.
pub(super) fn adjust_media(s: &mut Session, base: &str, f: impl Fn(image::DynamicImage) -> Result<image::DynamicImage, CmdError>) -> CmdResult {
let bytes = s.doc.media.get(base).cloned().ok_or_else(|| CmdError::Failed("picture data missing".into()))?;
// Within the budget pictures are drawn within, checked from the header first (#3644, #3647).
let img = wordcraft_geom::picture::decode(&bytes, wordcraft_geom::MAX_DECODED_BYTES).map_err(|e| match e {
wordcraft_geom::picture::Refusal::TooLarge { .. } => too_large(),
e => CmdError::Failed(format!("can't decode the picture: {e}")),
})?;
let out = f(img)?;
let mut png = Vec::new();
out.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).map_err(|e| CmdError::Failed(e.to_string()))?;
let key = s.doc.add_media(png, "png");
// Chain onto the ultimate original (the base's, or the current picture's when the base
// is itself derived), so Reset Picture always finds it.
let cur = selected(s).and_then(|(_, o)| match o {
InlineObject::Image { media, .. } => Some(media),
_ => None,
});
let orig = s
.originals
.get(base)
.cloned()
.or_else(|| cur.as_ref().and_then(|m| s.originals.get(m).cloned()))
.unwrap_or_else(|| cur.clone().unwrap_or_else(|| key.clone()));
s.originals.entry(key.clone()).or_insert(orig);
with_obj(s, |o| {
if let InlineObject::Image { media, .. } = o {
*media = key.clone();
}
})
}
fn too_large() -> CmdError {
CmdError::Failed("picture is too large to edit".into())
}
/// A `w` × `h` bitmap of `fill` for an edit to draw on, refused when the picture it makes would be
/// too large to draw (two RGBA buffers within the decode budget), before it is allocated (#3611).
fn canvas(w: u32, h: u32, fill: [u8; 4]) -> Result<image::RgbaImage, CmdError> {
if !wordcraft_geom::decode_fits(w, h, wordcraft_geom::MAX_DECODED_BYTES) {
return Err(too_large());
}
Ok(image::RgbaImage::from_pixel(w, h, image::Rgba(fill)))
}
/// `w` and `h` grown by `pad` on every side, None past `u32`.
pub(super) fn grown(w: u32, h: u32, pad: u32) -> Option<(u32, u32)> {
let twice = pad.checked_mul(2)?;
Some((w.checked_add(twice)?, h.checked_add(twice)?))
}
/// What Artistic Effects, Picture Styles and Picture Border drew on a picture's bitmap: the
/// bitmap they started from and the effect, style and border on it, so the next one draws again
/// from that bitmap instead of over the last result. Picking a second style replaces the first,
/// as in Word, instead of framing the frame (#2052). Kept in [`Session::picture_looks`] under the
/// media key of the drawn bitmap.
#[derive(Clone, Debug, Default)]
pub struct PictureLook {
/// The media key of the bitmap before any of them.
pub base: String,
pub effect: Option<String>,
pub style: Option<String>,
pub border: Option<(Rgb, u32)>,
/// The drawn bitmap: a media key that names other bytes later (undo then a new picture
/// reusing the key) is not this look's.
pub drawn: std::sync::Arc<Vec<u8>>,
}
/// Change the selected picture's effect, style or border and draw it again from the bitmap
/// under them (effect, then style, then border).
pub(super) fn relook(s: &mut Session, change: impl FnOnce(&mut PictureLook)) -> CmdResult {
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
let cur = s.doc.media.get(&media).cloned();
let mut look = s
.picture_looks
.get(&media)
.filter(|l| cur.as_ref().is_some_and(|c| std::sync::Arc::ptr_eq(c, &l.drawn)) && s.doc.media.contains_key(&l.base))
.cloned()
.unwrap_or_else(|| PictureLook { base: media.clone(), ..Default::default() });
change(&mut look);
if look.effect.is_none() && look.style.is_none() && look.border.is_none() {
// Nothing left on it: the bitmap as it was.
let base = look.base.clone();
return with_obj(s, |o| {
if let InlineObject::Image { media, .. } = o {
*media = base.clone();
}
});
}
let l = look.clone();
let out = adjust_media(s, &look.base, move |img| {
let mut img = match &l.effect {
Some(e) => artistic_effect(img, e),
None => img,
};
if let Some(st) = &l.style {
img = picture_style(img, st)?;
}
if let Some((c, w)) = l.border {
img = frame(img, w, [c.0, c.1, c.2, 255])?;
}
Ok(img)
})?;
if let Some((_, InlineObject::Image { media, .. })) = selected(s)
&& let Some(drawn) = s.doc.media.get(&media).cloned()
{
s.picture_looks.insert(media, PictureLook { drawn, ..look });
}
Ok(out)
}
fn artistic_effect(img: image::DynamicImage, effect: &str) -> image::DynamicImage {
match effect {
"blur" => img.blur(3.0),
"sharpen" => img.unsharpen(2.0, 2),
"invert" => {
let mut i = img;
i.invert();
i
}
"posterize" => {
let mut rgba = img.to_rgba8();
for px in rgba.pixels_mut() {
for c in px.0.iter_mut().take(3) {
*c = (*c / 64) * 85;
}
}
image::DynamicImage::ImageRgba8(rgba)
}
"pixelate" => {
let (w, h) = (img.width().max(1), img.height().max(1));
img.resize_exact((w / 12).max(1), (h / 12).max(1), image::imageops::FilterType::Nearest).resize_exact(
w,
h,
image::imageops::FilterType::Nearest,
)
}
_ => img,
}
}
pub(super) fn reset(s: &mut Session, _: &Value) -> CmdResult {
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
let orig = s.originals.get(&media).cloned().unwrap_or(media);
with_obj(s, |o| {
if let InlineObject::Image { media, crop, .. } = o {
*media = orig.clone();
*crop = [0.0; 4];
}
})
}
pub(super) fn recolor(img: image::DynamicImage, mode: &str, sat: f32) -> image::DynamicImage {
let mut rgba = img.to_rgba8();
for px in rgba.pixels_mut() {
let [r, g, b, a] = px.0;
let (rf, gf, bf) = (r as f32, g as f32, b as f32);
let l = 0.299 * rf + 0.587 * gf + 0.114 * bf;
let out = match mode {
"grayscale" => [l, l, l],
"sepia" => [
(0.393 * rf + 0.769 * gf + 0.189 * bf).min(255.0),
(0.349 * rf + 0.686 * gf + 0.168 * bf).min(255.0),
(0.272 * rf + 0.534 * gf + 0.131 * bf).min(255.0),
],
"washout" => [rf * 0.35 + 255.0 * 0.65, gf * 0.35 + 255.0 * 0.65, bf * 0.35 + 255.0 * 0.65],
"blackAndWhite" => {
let v = if l > 127.0 { 255.0 } else { 0.0 };
[v, v, v]
}
"tint" => [l * 0.6 + 0.4 * 0x3B as f32, l * 0.6 + 0.4 * 0x5B as f32, l * 0.6 + 0.4 * 0xDB as f32],
_ => {
let k = sat / 100.0;
[l + (rf - l) * k, l + (gf - l) * k, l + (bf - l) * k]
}
};
px.0 = [out[0].clamp(0.0, 255.0) as u8, out[1].clamp(0.0, 255.0) as u8, out[2].clamp(0.0, 255.0) as u8, a];
}
image::DynamicImage::ImageRgba8(rgba)
}
/// Flood the border-connected region close to the corner colour with transparency.
pub(super) fn remove_background(img: image::DynamicImage, tol: f32) -> image::DynamicImage {
let mut rgba = img.to_rgba8();
clear_background(&mut rgba, tol);
image::DynamicImage::ImageRgba8(rgba)
}
/// [`remove_background`] on `rgba`, a row span at a time: besides the border's seeds, the stack
/// holds one for each run of matching pixels beside a filled span, instead of four for every
/// pixel filled (#3647). Returns the most seeds it held.
pub(super) fn clear_background(rgba: &mut image::RgbaImage, tol: f32) -> usize {
let (w, h) = rgba.dimensions();
let Some(bg) = rgba.get_pixel_checked(0, 0).map(|p| p.0) else { return 0 };
let close = |p: [u8; 4]| (0..3).map(|i| (p[i] as f32 - bg[i] as f32).abs()).fold(0.0f32, f32::max) <= tol;
let at = |x: u32, y: u32| y as usize * w as usize + x as usize;
// Not cleared yet, and close to the corner colour.
let open = |rgba: &image::RgbaImage, seen: &[bool], x: u32, y: u32| {
!seen.get(at(x, y)).copied().unwrap_or(true) && rgba.get_pixel_checked(x, y).is_some_and(|p| close(p.0))
};
let mut seen = vec![false; (w as usize) * (h as usize)];
let mut stack = border_pixels(w, h);
let mut most = stack.len();
while let Some((x, y)) = stack.pop() {
if !open(rgba, &seen, x, y) {
continue;
}
let (x0, x1) = run_span(x, y, w, |xi, yi| open(rgba, &seen, xi, yi));
for xi in x0..=x1 {
if let Some(s) = seen.get_mut(at(xi, y)) {
*s = true;
}
if let Some(p) = rgba.get_pixel_mut_checked(xi, y) {
p.0[3] = 0;
}
}
for ny in [y.checked_sub(1), y.checked_add(1).filter(|&n| n < h)].into_iter().flatten() {
push_open_runs(&mut stack, x0, x1, ny, |xi, yi| open(rgba, &seen, xi, yi));
}
most = most.max(stack.len());
}
most
}
/// Every pixel on the image's edge, the flood-fill seeds.
fn border_pixels(w: u32, h: u32) -> Vec<(u32, u32)> {
let mut stack = Vec::new();
for x in 0..w {
stack.push((x, 0));
stack.push((x, h - 1));
}
for y in 0..h {
stack.push((0, y));
stack.push((w - 1, y));
}
stack
}
/// The horizontal run of open pixels around `(x, y)`, both ends included.
fn run_span(x: u32, y: u32, w: u32, open: impl Fn(u32, u32) -> bool) -> (u32, u32) {
let (mut x0, mut x1) = (x, x);
while x0 > 0 && open(x0 - 1, y) {
x0 -= 1;
}
while x1 + 1 < w && open(x1 + 1, y) {
x1 += 1;
}
(x0, x1)
}
/// Queues the first pixel of each open run in row `ny` between `x0` and `x1`.
fn push_open_runs(stack: &mut Vec<(u32, u32)>, x0: u32, x1: u32, ny: u32, open: impl Fn(u32, u32) -> bool) {
let mut xi = x0;
while xi <= x1 {
if open(xi, ny) {
stack.push((xi, ny));
while xi <= x1 && open(xi, ny) {
xi += 1;
}
} else {
xi += 1;
}
}
}
fn frame(img: image::DynamicImage, border: u32, color: [u8; 4]) -> Result<image::DynamicImage, CmdError> {
let (w, h) = grown(img.width(), img.height(), border).ok_or_else(too_large)?;
let mut out = canvas(w, h, color)?;
image::imageops::overlay(&mut out, &img.to_rgba8(), border as i64, border as i64);
Ok(image::DynamicImage::ImageRgba8(out))
}
pub(super) fn picture_style(img: image::DynamicImage, style: &str) -> Result<image::DynamicImage, CmdError> {
let m = (img.width().max(img.height()) / 40).max(4);
Ok(match style {
"thickFrame" => frame(frame(img, m * 2, [255, 255, 255, 255])?, m / 2, [40, 40, 40, 255])?,
"rounded" | "softEdge" => {
let mut rgba = img.to_rgba8();
let (w, h) = rgba.dimensions();
let r = (w.min(h) as f32) * if style == "rounded" { 0.12 } else { 0.0 };
let feather = if style == "softEdge" { (w.min(h) as f32 * 0.08).max(2.0) } else { 1.5 };
for y in 0..h {
for x in 0..w {
let (fx, fy) = (x as f32 + 0.5, y as f32 + 0.5);
let dx = (r - fx).max(fx - (w as f32 - r)).max(0.0);
let dy = (r - fy).max(fy - (h as f32 - r)).max(0.0);
let edge = (fx.min(w as f32 - fx)).min(fy.min(h as f32 - fy));
let inside = if r > 0.0 && dx > 0.0 && dy > 0.0 { r - (dx * dx + dy * dy).sqrt() } else { edge };
let k = (inside / feather).clamp(0.0, 1.0);
let p = rgba.get_pixel_mut(x, y);
p.0[3] = (p.0[3] as f32 * k) as u8;
}
}
image::DynamicImage::ImageRgba8(rgba)
}
"shadow" => {
let (w, h) = (img.width(), img.height());
let off = m;
let (pw, ph) = grown(w, h, off).ok_or_else(too_large)?;
let mut out = canvas(pw, ph, [0, 0, 0, 0])?;
// A rectangle of alpha 90 the picture's size, one and a half margins in, blurred. Its
// blur is the product of its sides' blurs along each axis, so it is drawn from two
// profiles, not by blurring every pixel with a kernel that grows with the picture (#3647).
let (sigma, start) = (off as f32 / 2.0, off + off / 2);
let (xs, ys) = (blurred_span(pw, start, w, sigma), blurred_span(ph, start, h, sigma));
for (x, y, p) in out.enumerate_pixels_mut() {
let k = xs.get(x as usize).copied().unwrap_or(0.0) * ys.get(y as usize).copied().unwrap_or(0.0);
p.0[3] = (90.0 * k).round() as u8;
}
image::imageops::overlay(&mut out, &img.to_rgba8(), 0, 0);
image::DynamicImage::ImageRgba8(out)
}
_ => frame(img, m, [255, 255, 255, 255])?,
})
}
/// How much of a span `len` long from `start` covers each of `n` positions once blurred with the
/// Gaussian `image::imageops::blur` uses for `sigma` (outside the `n` positions counts as clear):
/// 0 to 1. Each position is one difference of the kernel's running sums.
fn blurred_span(n: u32, start: u32, len: u32, sigma: f32) -> Vec<f32> {
let kernel = gaussian_kernel(sigma);
let mut sums = vec![0.0f32];
for k in &kernel {
sums.push(sums.last().copied().unwrap_or(0.0) + k);
}
let (size, r) = (kernel.len() as i64, (kernel.len() / 2) as i64);
let sum_to = |k: i64| sums.get(k.clamp(0, size) as usize).copied().unwrap_or(0.0);
let (start, end) = (i64::from(start), i64::from(start) + i64::from(len));
// Position i takes weight k from i + k - r, inside the span for k in [start - i + r, end - i + r).
(0..i64::from(n)).map(|i| sum_to(end - i + r) - sum_to(start - i + r)).collect()
}
/// The normalized kernel `image::imageops::blur` (0.25) uses for `sigma`: odd, about 6.7 σ wide.
fn gaussian_kernel(sigma: f32) -> Vec<f32> {
let size = ((((sigma - 0.8) / 0.3 + 1.0) * 2.0 + 1.0).max(3.0) as usize) | 1;
let mean = (size / 2) as f32;
let weights: Vec<f32> = (0..size).map(|x| (-0.5 * ((x as f32 - mean) / sigma).powi(2)).exp()).collect();
let total: f32 = weights.iter().sum();
weights.into_iter().map(|v| v / total).collect()
}