mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 07:17:08 +00:00
Image Tools: Remove Background clears an image's background in its pixels
A new built-in add-on, Image Tools (Object › Plug-ins › Image Tools), adds Remove Background… (`addon.image.removeBackground`). It makes the background of the selected images transparent by lowering the alpha of their own pixels, in one undo step, with no service or model. The background is the most common color along the image's edges, the average of the image's corners, or colors the user picks. Contiguous (on by default) clears it only where it reaches in from an edge; off, it is cleared wherever it is. Tolerance and Softness (0 to 100, 10 by default) set how near a color must be to be cleared and the band over which pixels fade out. Match measures that nearness as the RGB distance or as the largest difference on one channel. It is a VectorCraft addition. - The image stays an image: an embedded PNG with alpha, the source's ICC profile and resolution, and 16 bits or gray when the source is. A linked image is embedded and keeps its Placement Options. - When the edges hold at least as many clear pixels as pixels of their most common color, edge mode clears nothing, so a second run leaves the subject alone. Corner mode leaves clear corners out of the average and clears nothing when all four are clear. - With Match set to Each Channel, a tolerance of t / 255 × 100 clears the colors within t 8-bit steps on every channel. - A CMYK image, a linked image that shows its preview, an image that would need more than the decode allowance (checked from its header) and a result with no pixel left are refused, and the document stays as it was. When no pixel changes, no undo step is added. The result reports `cleared`, the pixels made fully clear. - Picked colors are hex colors, with or without `#`, or black, white, red, green or blue. - The dialog opens with Preview off; with Preview on, the canvas shows the result, and Cancel puts the image back. While the dialog previews, the session keeps the images' decoded pixels (at most 128 MiB, 32 MiB in the web app) so each preview step does not decode them again, and releases them when the dialog closes with OK or Cancel. A run that is not a preview step keeps nothing. Docs: docs/mcp.md (Image Tools add-on), docs/target-app-parity.md.
This commit is contained in:
@@ -749,6 +749,7 @@ pub(crate) fn unique_name_from(base: &str, next: &mut std::collections::HashMap<
|
||||
/// The most presets one presets file imports: each is looked up among the saved ones.
|
||||
pub(crate) const MAX_IMPORTED_PRESETS: usize = 1000;
|
||||
|
||||
pub(crate) use addons::DecodedImages;
|
||||
pub use docmenu::guide_indexes;
|
||||
pub(crate) use edit::{settle_interaction, typing_in_progress};
|
||||
pub(crate) use object::bounds_rewrite;
|
||||
|
||||
@@ -6,12 +6,15 @@ mod cleanup;
|
||||
mod color;
|
||||
mod contour;
|
||||
mod document;
|
||||
mod image;
|
||||
mod pathtools;
|
||||
mod pen;
|
||||
mod select;
|
||||
mod shapes;
|
||||
mod typetools;
|
||||
|
||||
pub(crate) use image::DecodedImages;
|
||||
|
||||
use super::CommandSpec;
|
||||
|
||||
/// The commands of every built-in add-on.
|
||||
@@ -21,6 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
v.extend(color::specs());
|
||||
v.extend(contour::specs());
|
||||
v.extend(document::specs());
|
||||
v.extend(image::specs());
|
||||
v.extend(pathtools::specs());
|
||||
v.extend(pen::specs());
|
||||
v.extend(select::specs());
|
||||
|
||||
@@ -0,0 +1,598 @@
|
||||
//! Image Tools (`addon.image.*`), a built-in add-on. Remove Background makes the background of the
|
||||
//! selected images transparent in their own pixels, as one undo step. The background is the most
|
||||
//! common color along an image's edges, the average of its four corners, or colors the caller
|
||||
//! picks. It is cleared where it reaches in from an edge, or wherever it is. The work is done here,
|
||||
//! on the pixels; nothing is sent anywhere. It is a VectorCraft addition. Its submenu (Object ›
|
||||
//! Plug-ins › Image Tools) and dialog are the UI's.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::io::Cursor;
|
||||
use std::sync::{Arc, Weak};
|
||||
|
||||
use image::{DynamicImage, ImageBuffer, LumaA, Rgba};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::Color;
|
||||
use vectorcraft_doc::{ImageBlob, NodeId, NodeKind};
|
||||
use vectorcraft_geom::Affine;
|
||||
|
||||
use super::super::fileio::{MAX_RASTER_ALLOC, ppi};
|
||||
use super::super::menucmds::has_image;
|
||||
use super::super::{CommandSpec, Result, Session, bad, checked_ids_param, cmd, hex_with_alpha, number_or, switch_or, text_param};
|
||||
|
||||
/// Remove Background's command.
|
||||
pub const REMOVE_BACKGROUND: &str = "addon.image.removeBackground";
|
||||
const C: &str = REMOVE_BACKGROUND;
|
||||
|
||||
/// The most colors `colors` lists.
|
||||
const MAX_COLORS: usize = 16;
|
||||
|
||||
/// Bytes a pixel takes while it is worked on, beside its pixels: its level, whether the edge search
|
||||
/// reached it, and its place in the search's stack (4 bytes, twice that while the stack grows).
|
||||
const WORK_BYTES: u64 = 10;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
"addon.image.removeBackground",
|
||||
"Remove Background…",
|
||||
["Object", "Plug-ins", "Image Tools"],
|
||||
None,
|
||||
"{background?: \"edges\" (default: the most common color along the image's edges; nothing is cleared when the edges hold at least as many clear pixels as pixels of that color) | \"corners\" (the average of the image's four corner pixels that are not clear; nothing is cleared when all four are clear) | \"colors\" (the colors in `colors`), colors?: [color…] or one string of them split by commas or spaces (1 to 16 colors, each a hex color \"#rrggbb\" or \"#rgb\" with or without the # or one of black, white, red, green and blue; needed with background: colors, and checked but not used with edges or corners), contiguous?: bool (true: only background pixels that reach in from an edge are cleared; false: they are cleared wherever they are; true), match?: \"distance\" (default: a color's RGB distance from a background color) | \"channels\" (its largest difference on one of red, green and blue), tolerance?: 0..100 (with distance, % of the largest RGB distance; with channels, % of 255 steps on one channel; a pixel this near a background color is cleared; 10), softness?: 0..100 (% past the tolerance over which pixels fade from cleared to kept; 10), ids?: [id…] (default: the selected images)} make the background of each image transparent in its own pixels, as one undo step: the image stays an image, embedded as a PNG with an alpha channel at the depth of its pixels (16-bit images keep 16 bits and grayscale images stay gray) with the source's color profile and resolution, and a linked image is embedded; a CMYK image, a linked image that shows its preview (embed or relink it first), an image too large to change, and a result with no pixel left are refused, and an image whose pixels do not change is left as it is (no undo step when none changes) → {changed: the images changed, ids, cleared: the pixels made fully clear in them}",
|
||||
has_image,
|
||||
remove_background
|
||||
)]
|
||||
}
|
||||
|
||||
/// Where the background's colors come from.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) enum Background {
|
||||
/// The most common color along the image's edges.
|
||||
Edges,
|
||||
/// The average of the image's four corner pixels that are not clear.
|
||||
Corners,
|
||||
/// These colors.
|
||||
Colors(Vec<[u8; 3]>),
|
||||
}
|
||||
|
||||
/// How near a color is to a background color.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) enum Match {
|
||||
/// The RGB distance, as a percentage of the largest one (black to white).
|
||||
Distance,
|
||||
/// The largest difference on one of red, green and blue, as a percentage of a channel's range.
|
||||
Channels,
|
||||
}
|
||||
|
||||
/// What Remove Background clears: the background, whether only where it reaches in from an edge,
|
||||
/// and how near a color must be to it (a percentage, measured as `measure` says) to be cleared, and
|
||||
/// to fade out past that.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct Settings {
|
||||
pub background: Background,
|
||||
pub contiguous: bool,
|
||||
pub measure: Match,
|
||||
pub tolerance: f64,
|
||||
pub softness: f64,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// The settings `p` gives, each checked.
|
||||
fn read(p: &Value) -> Result<Self> {
|
||||
let percent = |key: &str| -> Result<f64> {
|
||||
let v = number_or(p, key, 10.0, C)?;
|
||||
if v.is_finite() && (0.0..=100.0).contains(&v) { Ok(v) } else { Err(bad(C, format!("`{key}` must be from 0 to 100"))) }
|
||||
};
|
||||
let (tolerance, softness) = (percent("tolerance")?, percent("softness")?);
|
||||
let colors = match p.get("colors") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(colors(v)?),
|
||||
};
|
||||
let background = match text_param(p, "background", C)? {
|
||||
None | Some("edges") => Background::Edges,
|
||||
Some("corners") => Background::Corners,
|
||||
Some("colors") => Background::Colors(colors.ok_or_else(colors_error)?),
|
||||
Some(_) => return Err(bad(C, "`background` must be edges, corners or colors")),
|
||||
};
|
||||
let measure = match text_param(p, "match", C)? {
|
||||
None | Some("distance") => Match::Distance,
|
||||
Some("channels") => Match::Channels,
|
||||
Some(_) => return Err(bad(C, "`match` must be distance or channels")),
|
||||
};
|
||||
let contiguous = switch_or(p, "contiguous", true, C)?;
|
||||
Ok(Self { background, contiguous, measure, tolerance, softness })
|
||||
}
|
||||
}
|
||||
|
||||
fn colors_error() -> crate::EngineError {
|
||||
bad(C, "`colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue")
|
||||
}
|
||||
|
||||
/// The colors `v` lists: an array of strings, or one string of them split by commas or spaces.
|
||||
/// Each is read as [`Color::from_hex`] reads it; one with an alpha part is refused.
|
||||
fn colors(v: &Value) -> Result<Vec<[u8; 3]>> {
|
||||
// One past the most is enough to tell a list that is too long.
|
||||
let texts: Vec<&str> = match v {
|
||||
Value::String(s) => s.split([',', ' ', '\t', '\n']).filter(|t| !t.is_empty()).take(MAX_COLORS + 1).collect(),
|
||||
Value::Array(a) => a.iter().take(MAX_COLORS + 1).map(Value::as_str).collect::<Option<_>>().ok_or_else(colors_error)?,
|
||||
_ => return Err(colors_error()),
|
||||
};
|
||||
if texts.is_empty() || texts.len() > MAX_COLORS {
|
||||
return Err(colors_error());
|
||||
}
|
||||
texts.into_iter().map(|t| rgb8(t).ok_or_else(colors_error)).collect()
|
||||
}
|
||||
|
||||
/// The 8-bit RGB of the color `t` writes.
|
||||
fn rgb8(t: &str) -> Option<[u8; 3]> {
|
||||
if hex_with_alpha(t) {
|
||||
return None;
|
||||
}
|
||||
let [r, g, b] = Color::from_hex(t)?.to_rgb_uncalibrated();
|
||||
Some([r, g, b].map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8))
|
||||
}
|
||||
|
||||
fn remove_background(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let settings = Settings::read(p)?;
|
||||
// A preview step keeps the pixels it decodes for the next step; other runs keep none.
|
||||
let mut kept = std::mem::take(&mut s.decoded_images);
|
||||
let previewing = s.previewing;
|
||||
let found = changes(s, p, &settings, previewing.then_some(&mut kept));
|
||||
s.decoded_images = kept;
|
||||
let changes = found?;
|
||||
let ids: Vec<u64> = changes.iter().map(|(id, ..)| id.0).collect();
|
||||
let made_clear: u64 = changes.iter().map(|(_, out, _)| out.cleared).sum();
|
||||
if !changes.is_empty() {
|
||||
s.edit("Remove Background", |d, _| {
|
||||
for (id, out, xf) in changes {
|
||||
let key = out.blob.content_key();
|
||||
d.images.entry(key.clone()).or_insert(out.blob);
|
||||
if let Some(NodeKind::Image(im)) = d.node_mut(id).map(|n| &mut n.kind) {
|
||||
im.key = key;
|
||||
(im.width, im.height, im.xf) = (out.width, out.height, xf);
|
||||
im.link = None;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(json!({ "changed": ids.len(), "ids": ids, "cleared": made_clear }))
|
||||
}
|
||||
|
||||
/// The images `p` names (`ids`, or the selected images) whose pixels change, each with its pixels
|
||||
/// cleared as `settings` say and the transform that keeps its place and size. Decoded pixels are
|
||||
/// taken from and kept in `kept` when it is given.
|
||||
fn changes(s: &Session, p: &Value, settings: &Settings, mut kept: Option<&mut DecodedImages>) -> Result<Vec<(NodeId, Cleared, Affine)>> {
|
||||
let st = s.doc()?;
|
||||
let ids: Vec<NodeId> = if p.get("ids").is_some_and(|v| !v.is_null()) {
|
||||
// Each image once, however often `ids` names it.
|
||||
let mut given = HashSet::new();
|
||||
let ids: Vec<NodeId> = checked_ids_param(s, p, "ids", C)?.into_iter().filter(|id| given.insert(*id)).collect();
|
||||
if let Some(id) = ids.iter().find(|id| !matches!(st.doc.node(**id).map(|n| &n.kind), Some(NodeKind::Image(_)))) {
|
||||
return Err(bad(C, format!("object {} is not an image", id.0)));
|
||||
}
|
||||
ids
|
||||
} else {
|
||||
st.selection.objects.iter().copied().filter(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Image(_)))).collect()
|
||||
};
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "select an image"));
|
||||
}
|
||||
// Every image is checked before any is decoded.
|
||||
let mut images = vec![];
|
||||
for id in ids {
|
||||
let Some(NodeKind::Image(im)) = st.doc.node(id).map(|n| &n.kind) else { continue };
|
||||
let blob = st.doc.images.get(&im.key).ok_or_else(|| bad(C, "the image's pixels are missing"))?;
|
||||
// The preview stands in for a file that hasn't been read, or that is kept at its preview.
|
||||
if im.link.is_some() && blob.is_proxy() {
|
||||
return Err(bad(C, "the linked image shows its preview: embed or relink it first"));
|
||||
}
|
||||
if blob.is_cmyk() {
|
||||
return Err(bad(C, "a CMYK image has no transparency to set"));
|
||||
}
|
||||
images.push((id, im, blob));
|
||||
}
|
||||
// Each image's pixels once, however many objects show them.
|
||||
let mut cleared: HashMap<&str, Option<Cleared>> = HashMap::new();
|
||||
let mut changes = vec![];
|
||||
for (id, im, blob) in images {
|
||||
let out = match cleared.get(im.key.as_str()) {
|
||||
Some(out) => out.clone(),
|
||||
None => {
|
||||
let out = clear(blob, settings, kept.as_deref_mut())?;
|
||||
cleared.insert(&im.key, out.clone());
|
||||
out
|
||||
}
|
||||
};
|
||||
if let Some(out) = out {
|
||||
// A file read at another size than the object's keeps the object's place and size.
|
||||
let xf = if (out.width, out.height) == (im.width, im.height) {
|
||||
im.xf
|
||||
} else {
|
||||
im.xf * Affine::scale_non_uniform(f64::from(im.width) / f64::from(out.width), f64::from(im.height) / f64::from(out.height))
|
||||
};
|
||||
changes.push((id, out, xf));
|
||||
}
|
||||
}
|
||||
Ok(changes)
|
||||
}
|
||||
|
||||
/// An image with its background cleared: its PNG, its size in pixels, and how many of its pixels
|
||||
/// were made fully clear.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Cleared {
|
||||
pub blob: ImageBlob,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub cleared: u64,
|
||||
}
|
||||
|
||||
/// An image's pixels as straight RGBA, at 8 or 16 bits a sample.
|
||||
#[derive(Clone)]
|
||||
enum Pixels {
|
||||
Eight(image::RgbaImage),
|
||||
Sixteen(ImageBuffer<Rgba<u16>, Vec<u16>>),
|
||||
}
|
||||
|
||||
/// The most bytes of decoded pixels and ICC profiles [`DecodedImages`] keeps (128 MiB, or 32 MiB in
|
||||
/// the web app).
|
||||
const KEEP_BYTES: u64 = MAX_RASTER_ALLOC / 16;
|
||||
|
||||
/// The images Remove Background decoded during an interaction's preview steps, the most recently
|
||||
/// used first, at most [`KEEP_BYTES`] in all. The next preview step runs the command again on the
|
||||
/// same bytes and takes their pixels from here instead of decoding them again. The session holds
|
||||
/// it and empties it when the interaction ends (OK or Cancel). A run that is not a preview step
|
||||
/// does not read or fill it.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct DecodedImages(Vec<Decoded>);
|
||||
|
||||
/// An image decoded by [`clear`]: the bytes it was decoded from (held weakly, so they are not kept
|
||||
/// alive for it, and an entry whose bytes are gone is dropped), its pixels, whether it was gray,
|
||||
/// and its ICC profile.
|
||||
struct Decoded {
|
||||
source: Weak<Vec<u8>>,
|
||||
pixels: Arc<Pixels>,
|
||||
gray: bool,
|
||||
icc: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// How many times this thread decoded an image in [`clear`].
|
||||
pub(crate) static DECODES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
impl DecodedImages {
|
||||
/// Drops every image kept.
|
||||
pub(crate) fn empty(&mut self) {
|
||||
self.0.clear();
|
||||
}
|
||||
|
||||
/// How many images are kept.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn len(&self) -> usize {
|
||||
self.0.len()
|
||||
}
|
||||
|
||||
/// The pixels, gray flag and ICC profile kept for `bytes`, if they are kept. Images whose bytes
|
||||
/// are gone are dropped first.
|
||||
fn get(&mut self, bytes: &Arc<Vec<u8>>) -> Option<(Arc<Pixels>, bool, Option<Vec<u8>>)> {
|
||||
self.0.retain(|d| d.source.strong_count() > 0);
|
||||
let at = self.0.iter().position(|d| std::ptr::eq(d.source.as_ptr(), Arc::as_ptr(bytes)))?;
|
||||
let d = self.0.remove(at);
|
||||
let found = (Arc::clone(&d.pixels), d.gray, d.icc.clone());
|
||||
self.0.insert(0, d);
|
||||
Some(found)
|
||||
}
|
||||
|
||||
/// Keeps `pixels` decoded from `bytes` first, in place of anything kept for the same bytes, and
|
||||
/// drops images whose bytes are gone and the least recently used past [`KEEP_BYTES`].
|
||||
fn keep(&mut self, bytes: &Arc<Vec<u8>>, pixels: Arc<Pixels>, gray: bool, icc: Option<Vec<u8>>) {
|
||||
self.0.retain(|d| d.source.strong_count() > 0 && !std::ptr::eq(d.source.as_ptr(), Arc::as_ptr(bytes)));
|
||||
self.0.insert(0, Decoded { source: Arc::downgrade(bytes), pixels, gray, icc });
|
||||
let mut total = 0u64;
|
||||
self.0.retain(|d| {
|
||||
total = total.saturating_add(d.bytes());
|
||||
total <= KEEP_BYTES
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Decoded {
|
||||
/// How many bytes the entry keeps: its pixels and its ICC profile.
|
||||
fn bytes(&self) -> u64 {
|
||||
self.pixels.bytes().saturating_add(self.icc.as_ref().map_or(0, |icc| icc.len() as u64))
|
||||
}
|
||||
}
|
||||
|
||||
impl Pixels {
|
||||
/// How many bytes the pixels take.
|
||||
fn bytes(&self) -> u64 {
|
||||
match self {
|
||||
Self::Eight(img) => img.as_raw().len() as u64,
|
||||
Self::Sixteen(img) => img.as_raw().len() as u64 * 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// The width and height in pixels.
|
||||
fn dimensions(&self) -> (u32, u32) {
|
||||
match self {
|
||||
Self::Eight(img) => img.dimensions(),
|
||||
Self::Sixteen(img) => img.dimensions(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pixel `i` (row by row from the top left) at 16 bits a sample (an 8-bit sample `v` is
|
||||
/// `v * 257`).
|
||||
fn rgba16(&self, i: usize) -> [u16; 4] {
|
||||
let mut out = [0u16; 4];
|
||||
match self {
|
||||
Self::Eight(img) => {
|
||||
for (o, v) in out.iter_mut().zip(img.as_raw().get(i * 4..i * 4 + 4).unwrap_or_default()) {
|
||||
*o = u16::from(*v) * 257;
|
||||
}
|
||||
}
|
||||
Self::Sixteen(img) => {
|
||||
for (o, v) in out.iter_mut().zip(img.as_raw().get(i * 4..i * 4 + 4).unwrap_or_default()) {
|
||||
*o = *v;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// `blob` with its background cleared as `settings` say, or `None` when no pixel changes. A CMYK
|
||||
/// image, one that can't be decoded or is too large to change, and a result with no pixel left are
|
||||
/// errors. The PNG keeps the source's ICC profile and resolution. The decoded pixels are taken
|
||||
/// from and kept in `kept` when it is given.
|
||||
pub(crate) fn clear(blob: &ImageBlob, settings: &Settings, mut kept: Option<&mut DecodedImages>) -> Result<Option<Cleared>> {
|
||||
if blob.is_cmyk() {
|
||||
return Err(bad(C, "a CMYK image has no transparency to set"));
|
||||
}
|
||||
let found = kept.as_deref_mut().and_then(|k| k.get(&blob.bytes));
|
||||
let (source, gray, icc) = match found {
|
||||
Some(found) => found,
|
||||
None => decode(blob, kept)?,
|
||||
};
|
||||
// The pixels kept for the next run stay as they are; ones not kept are worked on in place.
|
||||
let mut px = Arc::try_unwrap(source).unwrap_or_else(|kept| Pixels::clone(&kept));
|
||||
let (w, h) = px.dimensions();
|
||||
let levels = levels(w as usize, h as usize, &|i| px.rgba16(i), settings);
|
||||
let Some(cleared) = apply(&mut px, &levels) else { return Ok(None) };
|
||||
let left = match &px {
|
||||
Pixels::Eight(img) => img.pixels().any(|p| p.0[3] > 0),
|
||||
Pixels::Sixteen(img) => img.pixels().any(|p| p.0[3] > 0),
|
||||
};
|
||||
if !left {
|
||||
return Err(bad(C, "every pixel matches the background: no image would be left"));
|
||||
}
|
||||
let out = match (px, gray) {
|
||||
(Pixels::Eight(img), false) => DynamicImage::ImageRgba8(img),
|
||||
(Pixels::Sixteen(img), false) => DynamicImage::ImageRgba16(img),
|
||||
(Pixels::Eight(img), true) => {
|
||||
DynamicImage::ImageLumaA8(ImageBuffer::from_fn(w, h, |x, y| LumaA([img.get_pixel(x, y).0[0], img.get_pixel(x, y).0[3]])))
|
||||
}
|
||||
(Pixels::Sixteen(img), true) => {
|
||||
DynamicImage::ImageLumaA16(ImageBuffer::from_fn(w, h, |x, y| LumaA([img.get_pixel(x, y).0[0], img.get_pixel(x, y).0[3]])))
|
||||
}
|
||||
};
|
||||
let mut png = Vec::new();
|
||||
let mut encoder = image::codecs::png::PngEncoder::new(&mut png);
|
||||
if let Some(icc) = icc {
|
||||
use image::ImageEncoder as _;
|
||||
encoder.set_icc_profile(icc).map_err(|e| bad(C, e.to_string()))?;
|
||||
}
|
||||
out.write_with_encoder(encoder).map_err(|e| bad(C, e.to_string()))?;
|
||||
// The resolution the file declares, which sizes the image at 100% when it is replaced or relinked.
|
||||
if let Some(r) = ppi::resolution(&blob.bytes) {
|
||||
png = ppi::with_png_resolution(&png, r);
|
||||
}
|
||||
Ok(Some(Cleared { blob: ImageBlob::png(png), width: w, height: h, cleared }))
|
||||
}
|
||||
|
||||
/// `blob`'s pixels as straight RGBA, whether they were gray, and its ICC profile. An image that
|
||||
/// can't be decoded or is too large to change is an error. The pixels are kept in `kept`, when it
|
||||
/// is given, if a copy of them fits the decode allowance beside the work.
|
||||
fn decode(blob: &ImageBlob, kept: Option<&mut DecodedImages>) -> Result<(Arc<Pixels>, bool, Option<Vec<u8>>)> {
|
||||
let reader =
|
||||
|| image::ImageReader::new(Cursor::new(blob.bytes.as_slice())).with_guessed_format().map_err(|_| bad(C, "the image's pixels can't be read"));
|
||||
let mut decoder = reader()?.into_decoder().map_err(|e| bad(C, format!("the image's pixels can't be read: {e}")))?;
|
||||
// The ICC profile the colors are in goes with them into the PNG.
|
||||
let (w, h, kind, icc) = {
|
||||
use image::ImageDecoder as _;
|
||||
let (w, h) = decoder.dimensions();
|
||||
(w, h, decoder.color_type(), decoder.icc_profile().ok().flatten())
|
||||
};
|
||||
drop(decoder);
|
||||
// The file's pixels, those worked on (as many again for the PNG made of them) and the work,
|
||||
// checked from the header before anything is decoded.
|
||||
let deep = kind.bytes_per_pixel() > kind.channel_count();
|
||||
let per_pixel = u64::from(kind.bytes_per_pixel()) + 2 * if deep { 8 } else { 4 } + WORK_BYTES;
|
||||
if u64::from(w).checked_mul(u64::from(h)).and_then(|n| n.checked_mul(per_pixel)).is_none_or(|n| n > MAX_RASTER_ALLOC) {
|
||||
return Err(bad(C, format!("an image of {w} × {h} pixels is too large to change")));
|
||||
}
|
||||
let mut reader = reader()?;
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_alloc = Some(MAX_RASTER_ALLOC);
|
||||
reader.limits(limits);
|
||||
let img = reader.decode().map_err(|e| bad(C, format!("the image's pixels can't be read: {e}")))?;
|
||||
#[cfg(test)]
|
||||
DECODES.with(|c| c.set(c.get() + 1));
|
||||
let gray = matches!(img.color(), image::ColorType::L8 | image::ColorType::La8 | image::ColorType::L16 | image::ColorType::La16);
|
||||
let px = Arc::new(if deep { Pixels::Sixteen(img.into_rgba16()) } else { Pixels::Eight(img.into_rgba8()) });
|
||||
// A kept copy is worked on as a copy: one more set of the pixels worked on.
|
||||
let with_copy = per_pixel + if deep { 8 } else { 4 };
|
||||
if let Some(kept) = kept
|
||||
&& u64::from(w).checked_mul(u64::from(h)).and_then(|n| n.checked_mul(with_copy)).is_some_and(|n| n <= MAX_RASTER_ALLOC)
|
||||
&& px.bytes().saturating_add(icc.as_ref().map_or(0, |icc| icc.len() as u64)) <= KEEP_BYTES
|
||||
{
|
||||
kept.keep(&blob.bytes, Arc::clone(&px), gray, icc.clone());
|
||||
}
|
||||
Ok((px, gray, icc))
|
||||
}
|
||||
|
||||
/// Each pixel's alpha scaled by its level (255 keeps it). Returns how many pixels were made fully
|
||||
/// clear, or `None` when no pixel changed.
|
||||
fn apply(px: &mut Pixels, levels: &[u8]) -> Option<u64> {
|
||||
let (mut changed, mut cleared) = (false, 0u64);
|
||||
match px {
|
||||
Pixels::Eight(img) => {
|
||||
for (p, &l) in img.pixels_mut().zip(levels) {
|
||||
let a = ((u32::from(p.0[3]) * u32::from(l) + 127) / 255) as u8;
|
||||
changed |= a != p.0[3];
|
||||
cleared += u64::from(a == 0 && p.0[3] != 0);
|
||||
p.0[3] = a;
|
||||
}
|
||||
}
|
||||
Pixels::Sixteen(img) => {
|
||||
for (p, &l) in img.pixels_mut().zip(levels) {
|
||||
let a = ((u32::from(p.0[3]) * u32::from(l) + 127) / 255) as u16;
|
||||
changed |= a != p.0[3];
|
||||
cleared += u64::from(a == 0 && p.0[3] != 0);
|
||||
p.0[3] = a;
|
||||
}
|
||||
}
|
||||
}
|
||||
changed.then_some(cleared)
|
||||
}
|
||||
|
||||
/// How much of each of the `w` × `h` pixels `px` gives (row by row, straight RGBA at 16 bits a
|
||||
/// sample) stays, from 0 (cleared) to 255 (kept), as `settings` say. Each pixel's distance to the
|
||||
/// background is worked out once. A pixel past the first `u32::MAX` is left kept by the edge search.
|
||||
pub(crate) fn levels(w: usize, h: usize, px: &dyn Fn(usize) -> [u16; 4], settings: &Settings) -> Vec<u8> {
|
||||
let n = w.saturating_mul(h);
|
||||
let refs: Vec<[u16; 3]> = match &settings.background {
|
||||
Background::Colors(refs) => refs.iter().map(|c| c.map(|v| u16::from(v) * 257)).collect(),
|
||||
Background::Edges => match edge_color(w, h, px) {
|
||||
Some(edge) => vec![edge],
|
||||
None => return vec![255; n],
|
||||
},
|
||||
Background::Corners => match corner_color(w, h, px) {
|
||||
Some(corner) => vec![corner],
|
||||
None => return vec![255; n],
|
||||
},
|
||||
};
|
||||
let mut out: Vec<u8> = (0..n)
|
||||
.map(|i| {
|
||||
let [r, g, b, _] = px(i);
|
||||
level([r, g, b], &refs, settings)
|
||||
})
|
||||
.collect();
|
||||
if settings.contiguous {
|
||||
// Out from the edges, through clear pixels and pixels that lose some alpha; the pixels it
|
||||
// doesn't reach are kept.
|
||||
let reaches = |out: &[u8], i: usize| out.get(i).is_some_and(|l| *l < 255) || px(i)[3] == 0;
|
||||
let mut seen = vec![false; n];
|
||||
let mut stack: Vec<u32> = vec![];
|
||||
for i in border(w, h).filter(|&i| reaches(&out, i)) {
|
||||
if let (Some(s), Ok(at)) = (seen.get_mut(i), u32::try_from(i)) {
|
||||
*s = true;
|
||||
stack.push(at);
|
||||
}
|
||||
}
|
||||
while let Some(at) = stack.pop() {
|
||||
let i = at as usize;
|
||||
let (x, y) = (i % w, i / w);
|
||||
let next = [(x > 0).then(|| i - 1), (x + 1 < w).then(|| i + 1), (y > 0).then(|| i - w), (y + 1 < h).then(|| i + w)];
|
||||
for j in next.into_iter().flatten() {
|
||||
if let Some(s) = seen.get_mut(j)
|
||||
&& !*s
|
||||
&& reaches(&out, j)
|
||||
&& let Ok(at) = u32::try_from(j)
|
||||
{
|
||||
*s = true;
|
||||
stack.push(at);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (l, s) in out.iter_mut().zip(&seen) {
|
||||
if !*s {
|
||||
*l = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The level of a pixel of color `c`: 0 within the tolerance of the nearest of `refs`, 255 from
|
||||
/// the tolerance plus the softness on, and in proportion between them.
|
||||
fn level(c: [u16; 3], refs: &[[u16; 3]], settings: &Settings) -> u8 {
|
||||
let Settings { tolerance, softness, measure, .. } = *settings;
|
||||
let d = refs.iter().map(|r| apart(c, *r, measure)).fold(f64::INFINITY, f64::min);
|
||||
if d <= tolerance {
|
||||
0
|
||||
} else if d >= tolerance + softness {
|
||||
255
|
||||
} else {
|
||||
((d - tolerance) / softness * 255.0).round().clamp(0.0, 255.0) as u8
|
||||
}
|
||||
}
|
||||
|
||||
/// How far apart `a` and `b` are, as a percentage, measured as `measure` says: the RGB distance of
|
||||
/// the largest one (black to white), or the largest difference on one channel of the channel's range.
|
||||
/// A difference of `k` 8-bit steps on one channel is exactly `k / 255 × 100` with channels.
|
||||
fn apart(a: [u16; 3], b: [u16; 3], measure: Match) -> f64 {
|
||||
match measure {
|
||||
Match::Distance => {
|
||||
let sq: f64 = a.iter().zip(b).map(|(x, y)| (f64::from(*x) - f64::from(y)).powi(2)).sum();
|
||||
sq.sqrt() / (65_535.0 * 3f64.sqrt()) * 100.0
|
||||
}
|
||||
Match::Channels => f64::from(a.iter().zip(b).map(|(x, y)| x.abs_diff(y)).max().unwrap_or(0)) / 65_535.0 * 100.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The pixels on the edges of a `w` × `h` image, each once.
|
||||
fn border(w: usize, h: usize) -> impl Iterator<Item = usize> {
|
||||
let top = (0..w).filter(move |_| h > 0);
|
||||
let bottom = (0..w).filter(move |_| h > 1).map(move |x| (h - 1) * w + x);
|
||||
let sides = (1..h.saturating_sub(1)).flat_map(move |y| [Some(y * w), (w > 1).then(|| y * w + w - 1)].into_iter().flatten());
|
||||
top.chain(bottom).chain(sides)
|
||||
}
|
||||
|
||||
/// The most common color along an image's edges: its edge pixels grouped by 16 levels a channel,
|
||||
/// with the clear ones as a group of their own, as the average of the largest group. `None` when
|
||||
/// the clear edge pixels are at least as many as that group: the background is already cleared.
|
||||
fn edge_color(w: usize, h: usize, px: &dyn Fn(usize) -> [u16; 4]) -> Option<[u16; 3]> {
|
||||
let mut groups: HashMap<[u16; 3], (u64, [u64; 3])> = HashMap::new();
|
||||
let mut clear = 0u64;
|
||||
for i in border(w, h) {
|
||||
let [r, g, b, a] = px(i);
|
||||
if a == 0 {
|
||||
clear += 1;
|
||||
continue;
|
||||
}
|
||||
let e = groups.entry([r >> 12, g >> 12, b >> 12]).or_default();
|
||||
e.0 += 1;
|
||||
for (s, v) in e.1.iter_mut().zip([r, g, b]) {
|
||||
*s += u64::from(v);
|
||||
}
|
||||
}
|
||||
// Equal groups go by color, so the result doesn't depend on the map's order.
|
||||
let (_, (n, sums)) = groups.into_iter().max_by(|a, b| a.1.0.cmp(&b.1.0).then(b.0.cmp(&a.0)))?;
|
||||
if clear >= n {
|
||||
return None;
|
||||
}
|
||||
Some(sums.map(|s| ((s + n / 2) / n.max(1)) as u16))
|
||||
}
|
||||
|
||||
/// The average of the four corner pixels of a `w` × `h` image that are not clear (a corner of a
|
||||
/// one-pixel-wide image counts twice). `None` when all four are clear: the background is already
|
||||
/// cleared.
|
||||
fn corner_color(w: usize, h: usize, px: &dyn Fn(usize) -> [u16; 4]) -> Option<[u16; 3]> {
|
||||
if w == 0 || h == 0 {
|
||||
return None;
|
||||
}
|
||||
let corners = [0, w - 1, (h - 1) * w, (h - 1) * w + w - 1];
|
||||
let (mut n, mut sums) = (0u64, [0u64; 3]);
|
||||
for [r, g, b, a] in corners.map(px) {
|
||||
if a > 0 {
|
||||
n += 1;
|
||||
for (s, v) in sums.iter_mut().zip([r, g, b]) {
|
||||
*s += u64::from(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
(n > 0).then(|| sums.map(|s| ((s + n / 2) / n) as u16))
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
use std::io::Cursor;
|
||||
use std::sync::Arc;
|
||||
|
||||
use image::{DynamicImage, ImageBuffer, Luma, Rgb};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::Affine;
|
||||
|
||||
use super::{Background, Match, REMOVE_BACKGROUND as C, Settings, level, levels};
|
||||
use crate::Session;
|
||||
use crate::cmd::fileio::ppi;
|
||||
|
||||
const WHITE: [u8; 4] = [255, 255, 255, 255];
|
||||
const RED: [u8; 4] = [200, 0, 0, 255];
|
||||
|
||||
/// The command's defaults with `background`.
|
||||
fn settings(background: Background) -> Settings {
|
||||
Settings { background, contiguous: true, measure: Match::Distance, tolerance: 10.0, softness: 10.0 }
|
||||
}
|
||||
|
||||
fn png(img: DynamicImage) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
img.write_to(&mut Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
/// 12 × 10 pixels: white, with a red block from (3, 3) to (9, 7) that holds one white pixel at
|
||||
/// (6, 5), which no white edge pixel reaches.
|
||||
fn framed() -> image::RgbaImage {
|
||||
image::RgbaImage::from_fn(12, 10, |x, y| {
|
||||
let block = (3..9).contains(&x) && (3..7).contains(&y);
|
||||
image::Rgba(if block && (x, y) != (6, 5) { RED } else { WHITE })
|
||||
})
|
||||
}
|
||||
|
||||
/// A session with `bytes` opened as a document, its image selected.
|
||||
fn open(bytes: &[u8]) -> (Session, NodeId) {
|
||||
let mut s = Session::new();
|
||||
s.execute("document.open", &json!({"name": "art.png", "dataBase64": vectorcraft_format::base64_encode(bytes)})).unwrap();
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
let id = s.doc().unwrap().selection.objects[0];
|
||||
(s, id)
|
||||
}
|
||||
|
||||
fn image_of(s: &Session, id: NodeId) -> ImageObject {
|
||||
let NodeKind::Image(im) = &s.doc().unwrap().doc.node(id).unwrap().kind else { panic!("an image") };
|
||||
(**im).clone()
|
||||
}
|
||||
|
||||
fn bytes_of(s: &Session, id: NodeId) -> Arc<Vec<u8>> {
|
||||
s.doc().unwrap().doc.images[&image_of(s, id).key].bytes.clone()
|
||||
}
|
||||
|
||||
fn pixels(s: &Session, id: NodeId) -> DynamicImage {
|
||||
image::load_from_memory(&bytes_of(s, id)).unwrap()
|
||||
}
|
||||
|
||||
fn alpha(img: &DynamicImage, x: u32, y: u32) -> u8 {
|
||||
img.to_rgba8().get_pixel(x, y).0[3]
|
||||
}
|
||||
|
||||
fn undo_len(s: &Session) -> usize {
|
||||
s.doc().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_edge_colors_are_cleared_where_they_reach_in_from_an_edge() {
|
||||
let (mut s, id) = open(&png(DynamicImage::ImageRgba8(framed())));
|
||||
let before = (image_of(&s, id), undo_len(&s));
|
||||
let r = s.execute(C, &json!({})).unwrap();
|
||||
assert_eq!(r, json!({"changed": 1, "ids": [id.0], "cleared": 12 * 10 - 6 * 4}));
|
||||
let out = pixels(&s, id);
|
||||
assert_eq!(out.color(), image::ColorType::Rgba8);
|
||||
assert_eq!((alpha(&out, 0, 0), alpha(&out, 11, 9), alpha(&out, 2, 4)), (0, 0, 0), "the white around the block");
|
||||
assert_eq!(out.to_rgba8().get_pixel(4, 4).0, RED, "the block");
|
||||
assert_eq!(alpha(&out, 6, 5), 255, "white inside the block, which no edge reaches");
|
||||
let after = image_of(&s, id);
|
||||
assert_eq!((after.width, after.height, after.xf), (12, 10, before.0.xf), "the same place and size");
|
||||
assert_eq!(undo_len(&s), before.1 + 1, "one undo step");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(image_of(&s, id), before.0);
|
||||
}
|
||||
|
||||
/// Picked colors, like the other backgrounds, are cleared where they reach in from an edge, and with
|
||||
/// `contiguous: false` wherever they are.
|
||||
#[test]
|
||||
fn picked_colors_are_cleared_from_the_edges_or_wherever_they_are() {
|
||||
let (mut s, id) = open(&png(DynamicImage::ImageRgba8(framed())));
|
||||
s.execute(C, &json!({"background": "colors", "colors": ["#fff"], "tolerance": 0, "softness": 0})).unwrap();
|
||||
let out = pixels(&s, id);
|
||||
assert_eq!((alpha(&out, 0, 0), alpha(&out, 6, 5), alpha(&out, 4, 4)), (0, 255, 255), "not inside the block");
|
||||
let (mut s, id) = open(&png(DynamicImage::ImageRgba8(framed())));
|
||||
s.execute(C, &json!({"background": "colors", "colors": ["#fff"], "contiguous": false, "tolerance": 0, "softness": 0})).unwrap();
|
||||
let out = pixels(&s, id);
|
||||
assert_eq!((alpha(&out, 0, 0), alpha(&out, 6, 5), alpha(&out, 4, 4)), (0, 0, 255), "inside the block too; the red stays");
|
||||
}
|
||||
|
||||
/// With `contiguous: false` the edge color is cleared inside the subject too.
|
||||
#[test]
|
||||
fn the_edge_color_can_be_cleared_wherever_it_is() {
|
||||
let (mut s, id) = open(&png(DynamicImage::ImageRgba8(framed())));
|
||||
let r = s.execute(C, &json!({"contiguous": false})).unwrap();
|
||||
assert_eq!(r["cleared"], 12 * 10 - 6 * 4 + 1);
|
||||
let out = pixels(&s, id);
|
||||
assert_eq!((alpha(&out, 0, 0), alpha(&out, 6, 5), alpha(&out, 4, 4)), (0, 0, 255));
|
||||
}
|
||||
|
||||
/// `colors` as one string split by commas and spaces, written as other color params take them.
|
||||
#[test]
|
||||
fn picked_colors_can_be_one_string_of_colors() {
|
||||
for colors in ["#00ff00, ffffff", "green white", "#0f0,#FFF"] {
|
||||
let (mut s, id) = open(&png(DynamicImage::ImageRgba8(framed())));
|
||||
s.execute(C, &json!({"background": "colors", "colors": colors, "contiguous": false, "tolerance": 0, "softness": 0})).unwrap();
|
||||
let out = pixels(&s, id);
|
||||
assert_eq!((alpha(&out, 0, 0), alpha(&out, 6, 5), alpha(&out, 4, 4)), (0, 0, 255), "{colors}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A subject along a whole edge stays: only the most common edge color is the background.
|
||||
#[test]
|
||||
fn a_subject_along_one_edge_is_not_the_background() {
|
||||
let img = image::RgbaImage::from_fn(10, 10, |_, y| image::Rgba(if y >= 7 { RED } else { WHITE }));
|
||||
let (mut s, id) = open(&png(DynamicImage::ImageRgba8(img)));
|
||||
s.execute(C, &json!({})).unwrap();
|
||||
let out = pixels(&s, id);
|
||||
assert_eq!((alpha(&out, 5, 0), alpha(&out, 5, 9), alpha(&out, 0, 8)), (0, 255, 255));
|
||||
}
|
||||
|
||||
/// The preview steps of one interaction, as the dialog's are, decode an image once and give what a
|
||||
/// run on freshly decoded pixels gives. The pixels are kept only until the interaction ends (OK or
|
||||
/// Cancel), and a run that is not a preview step keeps none.
|
||||
#[test]
|
||||
fn preview_steps_on_the_same_pixels_decode_them_once() {
|
||||
let decodes = || super::DECODES.with(std::cell::Cell::get);
|
||||
let (mut s, id) = open(&png(DynamicImage::ImageRgba8(framed())));
|
||||
let start = decodes();
|
||||
let p = json!({"background": "colors", "colors": "#c80000", "contiguous": false, "tolerance": 0, "softness": 0});
|
||||
s.begin_interaction("Remove Background").unwrap();
|
||||
s.preview(C, &json!({"tolerance": 0, "softness": 0})).unwrap();
|
||||
s.preview(C, &json!({"tolerance": 20, "softness": 0})).unwrap();
|
||||
s.preview(C, &p).unwrap();
|
||||
assert_eq!((decodes() - start, s.decoded_images.len()), (1, 1), "decoded once for three preview steps");
|
||||
s.commit_interaction().unwrap();
|
||||
assert_eq!(s.decoded_images.len(), 0, "OK lets the pixels go");
|
||||
let (mut fresh, fresh_id) = open(&png(DynamicImage::ImageRgba8(framed())));
|
||||
fresh.execute(C, &p).unwrap();
|
||||
assert_eq!((decodes() - start, fresh.decoded_images.len()), (2, 0), "a run that is not a preview step keeps nothing");
|
||||
assert_eq!(bytes_of(&s, id), bytes_of(&fresh, fresh_id), "the same result");
|
||||
assert_eq!((alpha(&pixels(&s, id), 4, 4), alpha(&pixels(&s, id), 0, 0)), (0, 255), "the red cleared, the white kept");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.begin_interaction("Remove Background").unwrap();
|
||||
s.preview(C, &json!({})).unwrap();
|
||||
assert_eq!(s.decoded_images.len(), 1);
|
||||
s.cancel_interaction().unwrap();
|
||||
assert_eq!(s.decoded_images.len(), 0, "Cancel lets the pixels go");
|
||||
s.execute(C, &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute(C, &json!({})).unwrap();
|
||||
assert_eq!(decodes() - start, 5, "each run that is not a preview step decodes");
|
||||
}
|
||||
|
||||
/// Pixels a preview step takes from the step before keep what they were decoded with: a 16-bit gray
|
||||
/// image with an ICC profile comes out as a run on freshly decoded pixels gives it.
|
||||
#[test]
|
||||
fn reused_pixels_keep_their_depth_gray_and_profile() {
|
||||
use image::{ImageDecoder as _, ImageEncoder as _};
|
||||
let icc = b"a profile's bytes".to_vec();
|
||||
let gray16 = ImageBuffer::from_fn(8, 8, |x, y| Luma([if (2..6).contains(&x) && (2..6).contains(&y) { 7_000u16 } else { 61_000 }]));
|
||||
let mut bytes = Vec::new();
|
||||
let mut encoder = image::codecs::png::PngEncoder::new(&mut bytes);
|
||||
encoder.set_icc_profile(icc.clone()).unwrap();
|
||||
DynamicImage::ImageLuma16(gray16).write_with_encoder(encoder).unwrap();
|
||||
let decodes = || super::DECODES.with(std::cell::Cell::get);
|
||||
let (mut s, id) = open(&bytes);
|
||||
let start = decodes();
|
||||
s.begin_interaction("Remove Background").unwrap();
|
||||
s.preview(C, &json!({"tolerance": 0, "softness": 0})).unwrap();
|
||||
s.preview(C, &json!({})).unwrap();
|
||||
assert_eq!(decodes() - start, 1, "the second step took the pixels of the first");
|
||||
let (mut fresh, fresh_id) = open(&bytes);
|
||||
fresh.execute(C, &json!({})).unwrap();
|
||||
assert_eq!(bytes_of(&s, id), bytes_of(&fresh, fresh_id), "the same result");
|
||||
let DynamicImage::ImageLumaA16(out) = pixels(&s, id) else { panic!("16-bit gray with alpha") };
|
||||
assert_eq!((out.get_pixel(0, 0).0, out.get_pixel(3, 3).0), ([61_000, 0], [7_000, 65_535]));
|
||||
let result = bytes_of(&s, id);
|
||||
let mut decoder = image::codecs::png::PngDecoder::new(Cursor::new(result.as_slice())).unwrap();
|
||||
assert_eq!(decoder.icc_profile().unwrap(), Some(icc));
|
||||
}
|
||||
|
||||
/// Once the background is cleared, most edge pixels are clear and a second run clears nothing: the
|
||||
/// subject's color along an edge is not taken for the background.
|
||||
#[test]
|
||||
fn a_second_run_leaves_the_subject() {
|
||||
let img = image::RgbaImage::from_fn(10, 10, |x, y| {
|
||||
image::Rgba(if y >= 7 {
|
||||
RED
|
||||
} else if (4..6).contains(&x) && (3..5).contains(&y) {
|
||||
[0, 0, 200, 255]
|
||||
} else {
|
||||
WHITE
|
||||
})
|
||||
});
|
||||
let (mut s, id) = open(&png(DynamicImage::ImageRgba8(img)));
|
||||
s.execute(C, &json!({})).unwrap();
|
||||
let (first, steps) = (image_of(&s, id), undo_len(&s));
|
||||
let r = s.execute(C, &json!({})).unwrap();
|
||||
assert_eq!(r, json!({"changed": 0, "ids": [], "cleared": 0}));
|
||||
assert_eq!((image_of(&s, id), undo_len(&s)), (first, steps), "no change and no undo step");
|
||||
let out = pixels(&s, id);
|
||||
assert_eq!((alpha(&out, 5, 0), alpha(&out, 5, 9), alpha(&out, 4, 3)), (0, 255, 255), "the red and the blue stay");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tolerance_and_softness_set_how_near_a_color_is_cleared() {
|
||||
// White; 1.96 % from white; 21.6 % from white.
|
||||
let px = [WHITE, [250, 250, 250, 255], [200, 200, 200, 255]];
|
||||
let at = |i: usize| px[i].map(|v| u16::from(v) * 257);
|
||||
let run = |tolerance, softness| levels(3, 1, &at, &Settings { tolerance, softness, ..settings(Background::Colors(vec![[255; 3]])) });
|
||||
assert_eq!(run(0.0, 0.0), [0, 255, 255], "only white itself at 0");
|
||||
assert_eq!(run(2.0, 0.0), [0, 0, 255]);
|
||||
assert_eq!(run(0.0, 10.0), [0, 50, 255], "1.96 % into a 10 % fade");
|
||||
assert_eq!(run(100.0, 0.0), [0, 0, 0], "everything at 100");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_result_with_no_pixel_left_is_refused_and_changes_nothing() {
|
||||
let (mut s, id) = open(&png(DynamicImage::ImageRgba8(framed())));
|
||||
let before = (image_of(&s, id), undo_len(&s));
|
||||
let e = s.execute(C, &json!({"background": "colors", "colors": "#ffffff", "tolerance": 100})).unwrap_err();
|
||||
assert!(e.to_string().contains("every pixel matches the background"), "{e}");
|
||||
assert_eq!((image_of(&s, id), undo_len(&s)), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_whose_pixels_do_not_change_is_left_with_no_undo_step() {
|
||||
let (mut s, id) = open(&png(DynamicImage::ImageRgba8(framed())));
|
||||
let before = (image_of(&s, id), undo_len(&s));
|
||||
let r = s.execute(C, &json!({"background": "colors", "colors": "#00ff00", "tolerance": 0, "softness": 0})).unwrap();
|
||||
assert_eq!(r, json!({"changed": 0, "ids": [], "cleared": 0}));
|
||||
assert_eq!((image_of(&s, id), undo_len(&s)), before);
|
||||
// An image whose edges and corners are already clear has no edge or corner color to clear,
|
||||
// whatever color the clear pixels hold.
|
||||
let ring =
|
||||
image::RgbaImage::from_fn(6, 6, |x, y| image::Rgba(if (1..5).contains(&x) && (1..5).contains(&y) { WHITE } else { [255, 255, 255, 0] }));
|
||||
for p in [json!({}), json!({"background": "corners", "contiguous": false})] {
|
||||
let (mut s, _) = open(&png(DynamicImage::ImageRgba8(ring.clone())));
|
||||
assert_eq!(s.execute(C, &p).unwrap()["changed"], 0, "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The corners' color: 40 × 30 pixels of light gray around a red block, cleared within 5 steps on
|
||||
/// each channel (`match: channels`, a tolerance of `5 / 255 × 100`, no softness), from the edges
|
||||
/// or wherever it is.
|
||||
#[test]
|
||||
fn the_corners_color_is_cleared() {
|
||||
let img = image::RgbaImage::from_fn(40, 30, |x, y| {
|
||||
image::Rgba(if (10..30).contains(&x) && (8..22).contains(&y) { [200, 40, 40, 255] } else { [240, 240, 240, 255] })
|
||||
});
|
||||
for contiguous in [false, true] {
|
||||
let (mut s, id) = open(&png(DynamicImage::ImageRgba8(img.clone())));
|
||||
let p = json!({"background": "corners", "contiguous": contiguous, "match": "channels", "tolerance": 5.0 / 255.0 * 100.0, "softness": 0});
|
||||
let r = s.execute(C, &p).unwrap();
|
||||
assert_eq!(r["cleared"], 40 * 30 - 14 * 20, "{p}");
|
||||
let out = pixels(&s, id).to_rgba8();
|
||||
assert_eq!((out.get_pixel(0, 0).0[3], out.get_pixel(15, 15).0), (0, [200, 40, 40, 255]), "{p}");
|
||||
}
|
||||
// A clear corner is left out of the average: the other three are white, and white is cleared.
|
||||
let mut img = image::RgbaImage::from_pixel(8, 8, image::Rgba(WHITE));
|
||||
img.put_pixel(0, 0, image::Rgba([0; 4]));
|
||||
img.put_pixel(4, 4, image::Rgba(RED));
|
||||
let (mut s, id) = open(&png(DynamicImage::ImageRgba8(img)));
|
||||
let r = s.execute(C, &json!({"background": "corners", "tolerance": 0, "softness": 0})).unwrap();
|
||||
let out = pixels(&s, id);
|
||||
assert_eq!((alpha(&out, 7, 7), alpha(&out, 3, 3), alpha(&out, 4, 4)), (0, 0, 255));
|
||||
assert_eq!(r["cleared"], 8 * 8 - 2, "the pixels made clear, not the corner that was clear already");
|
||||
}
|
||||
|
||||
/// One picked color matched by channel at a tolerance of 0: cleared wherever it is, and a pixel a
|
||||
/// long way from it kept.
|
||||
#[test]
|
||||
fn one_picked_color_is_cleared_by_channel() {
|
||||
let mut img = image::RgbaImage::from_pixel(4, 4, image::Rgba([10, 20, 30, 255]));
|
||||
img.put_pixel(2, 2, image::Rgba([250, 250, 250, 255]));
|
||||
let (mut s, id) = open(&png(DynamicImage::ImageRgba8(img)));
|
||||
let p = json!({"background": "colors", "colors": "#0a141e", "contiguous": false, "match": "channels", "tolerance": 0, "softness": 0});
|
||||
assert_eq!(s.execute(C, &p).unwrap()["cleared"], 15);
|
||||
let out = pixels(&s, id);
|
||||
assert_eq!((alpha(&out, 0, 0), alpha(&out, 2, 2)), (0, 255));
|
||||
}
|
||||
|
||||
/// With `match: channels`, a tolerance of `t / 255 × 100` clears a color `k` 8-bit steps from the
|
||||
/// background on one channel exactly when `k ≤ t`, for every `t` and `k` from 0 to 255; with
|
||||
/// `distance` the same color is nearer when the others match.
|
||||
#[test]
|
||||
fn channels_match_the_largest_difference_on_one_channel() {
|
||||
for t in 0..=255u16 {
|
||||
let s = Settings { measure: Match::Channels, tolerance: f64::from(t) / 255.0 * 100.0, softness: 0.0, ..settings(Background::Edges) };
|
||||
for k in 0..=255u16 {
|
||||
for c in [[k * 257, 0, 0], [0, k * 257, 0], [0, 0, k * 257]] {
|
||||
assert_eq!(level(c, &[[0; 3]], &s) == 0, k <= t, "t {t}, k {k}");
|
||||
}
|
||||
}
|
||||
}
|
||||
let at = |measure| {
|
||||
level([0, 40 * 257, 0], &[[0; 3]], &Settings { measure, tolerance: 32.0 / 255.0 * 100.0, softness: 0.0, ..settings(Background::Edges) })
|
||||
};
|
||||
assert_eq!((at(Match::Channels), at(Match::Distance)), (255, 0), "40 steps on green: past 32 on one channel; 9 % of the RGB distance");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sixteen_bit_and_gray_images_keep_their_depth_and_channels() {
|
||||
let deep = ImageBuffer::from_fn(8, 8, |x, y| Rgb(if (2..6).contains(&x) && (2..6).contains(&y) { [51_400, 0, 1] } else { [65_535; 3] }));
|
||||
let (mut s, id) = open(&png(DynamicImage::ImageRgb16(deep)));
|
||||
s.execute(C, &json!({})).unwrap();
|
||||
let DynamicImage::ImageRgba16(out) = pixels(&s, id) else { panic!("16-bit RGBA") };
|
||||
assert_eq!(out.get_pixel(0, 0).0[3], 0);
|
||||
assert_eq!(out.get_pixel(3, 3).0, [51_400, 0, 1, 65_535], "16-bit samples kept as they were");
|
||||
// Samples one 8-bit step apart are told apart: only the background is cleared at 0.
|
||||
let near = ImageBuffer::from_fn(4, 4, |x, y| Rgb(if (1..3).contains(&x) && (1..3).contains(&y) { [65_280; 3] } else { [65_535; 3] }));
|
||||
let (mut s, id) = open(&png(DynamicImage::ImageRgb16(near)));
|
||||
s.execute(C, &json!({"tolerance": 0, "softness": 0})).unwrap();
|
||||
let DynamicImage::ImageRgba16(out) = pixels(&s, id) else { panic!("16-bit RGBA") };
|
||||
assert_eq!((out.get_pixel(0, 0).0[3], out.get_pixel(1, 1).0[3]), (0, 65_535));
|
||||
let gray = ImageBuffer::from_fn(8, 8, |x, y| Luma([if (2..6).contains(&x) && (2..6).contains(&y) { 30u8 } else { 240 }]));
|
||||
let (mut s, id) = open(&png(DynamicImage::ImageLuma8(gray)));
|
||||
s.execute(C, &json!({})).unwrap();
|
||||
let DynamicImage::ImageLumaA8(out) = pixels(&s, id) else { panic!("gray with alpha") };
|
||||
assert_eq!((out.get_pixel(0, 0).0, out.get_pixel(3, 3).0), ([240, 0], [30, 255]));
|
||||
let gray16 = ImageBuffer::from_fn(8, 8, |x, y| Luma([if (2..6).contains(&x) && (2..6).contains(&y) { 7_000u16 } else { 61_000 }]));
|
||||
let (mut s, id) = open(&png(DynamicImage::ImageLuma16(gray16)));
|
||||
s.execute(C, &json!({})).unwrap();
|
||||
let DynamicImage::ImageLumaA16(out) = pixels(&s, id) else { panic!("16-bit gray with alpha") };
|
||||
assert_eq!((out.get_pixel(0, 0).0, out.get_pixel(3, 3).0), ([61_000, 0], [7_000, 65_535]));
|
||||
}
|
||||
|
||||
/// The ICC profile the colors are in stays with them.
|
||||
#[test]
|
||||
fn the_color_profile_is_kept() {
|
||||
use image::{ImageDecoder as _, ImageEncoder as _};
|
||||
let icc = b"a profile's bytes".to_vec();
|
||||
let mut bytes = Vec::new();
|
||||
let mut encoder = image::codecs::png::PngEncoder::new(&mut bytes);
|
||||
encoder.set_icc_profile(icc.clone()).unwrap();
|
||||
DynamicImage::ImageRgba8(framed()).write_with_encoder(encoder).unwrap();
|
||||
let blob = ImageBlob::png(bytes);
|
||||
let out = super::clear(&blob, &settings(Background::Edges), None).unwrap().unwrap();
|
||||
let mut decoder = image::codecs::png::PngDecoder::new(Cursor::new(out.blob.bytes.as_slice())).unwrap();
|
||||
assert_eq!(decoder.icc_profile().unwrap(), Some(icc));
|
||||
}
|
||||
|
||||
/// The resolution the file declares stays with the pixels, so a later Replace or relink sizes the
|
||||
/// image as before.
|
||||
#[test]
|
||||
fn the_resolution_is_kept() {
|
||||
let bytes = ppi::with_png_resolution(&png(DynamicImage::ImageRgba8(framed())), (300.0, 150.0));
|
||||
let (mut s, id) = open(&bytes);
|
||||
s.execute(C, &json!({})).unwrap();
|
||||
let (x, y) = ppi::resolution(&bytes_of(&s, id)).expect("a resolution");
|
||||
assert_eq!((x.round(), y.round()), (300.0, 150.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cmyk_image_is_refused() {
|
||||
let inks = vectorcraft_doc::cmyk::Inks::new(4, 2, [0, 0, 0, 0].repeat(8)).unwrap();
|
||||
let tiff = ImageBlob::cmyk_tiff(&inks).unwrap();
|
||||
let (mut s, id) = open(&tiff.bytes);
|
||||
let before = (image_of(&s, id), undo_len(&s));
|
||||
let e = s.execute(C, &json!({})).unwrap_err();
|
||||
assert!(e.to_string().contains("a CMYK image has no transparency to set"), "{e}");
|
||||
assert_eq!((image_of(&s, id), undo_len(&s)), before);
|
||||
}
|
||||
|
||||
/// A linked image of `blob`, `width` × `height` pixels as the object records it, beside the open
|
||||
/// document's image.
|
||||
fn place_linked(s: &mut Session, key: &str, blob: ImageBlob, (width, height): (u32, u32)) -> NodeId {
|
||||
s.edit("Place", |d, sel| {
|
||||
d.images.insert(key.into(), blob);
|
||||
let id = d.alloc_id();
|
||||
let im = ImageObject {
|
||||
key: key.into(),
|
||||
width,
|
||||
height,
|
||||
xf: Default::default(),
|
||||
link: Some(LinkInfo::new("/nowhere/art.png")),
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
};
|
||||
d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?;
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_linked_image_is_embedded_and_one_that_shows_its_preview_is_refused() {
|
||||
let bytes = png(DynamicImage::ImageRgba8(framed()));
|
||||
let (mut s, _) = open(&bytes);
|
||||
let linked = place_linked(&mut s, "linked", ImageBlob::png(bytes.clone()), (12, 10));
|
||||
let clip = vectorcraft_doc::links::PlacementOptions { clip: true, ..Default::default() };
|
||||
s.edit("Placement", |d, _| {
|
||||
if let Some(NodeKind::Image(im)) = d.node_mut(linked).map(|n| &mut n.kind) {
|
||||
im.placement = clip;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let r = s.execute(C, &json!({"ids": [linked.0, linked.0]})).unwrap();
|
||||
assert_eq!(r, json!({"changed": 1, "ids": [linked.0], "cleared": 12 * 10 - 6 * 4}), "an image named twice changes once");
|
||||
let im = image_of(&s, linked);
|
||||
assert_eq!((im.link, alpha(&pixels(&s, linked), 0, 0)), (None, 0), "embedded, with its background cleared");
|
||||
assert_eq!(im.placement, clip, "its Placement Options kept, as Embed keeps them");
|
||||
// Pixels the file wasn't read for: the preview, as an image whose file is missing shows it.
|
||||
let preview = Arc::new(bytes);
|
||||
let missing = place_linked(&mut s, "missing", ImageBlob { mime: "image/png".into(), bytes: preview.clone(), proxy: Some(preview) }, (12, 10));
|
||||
let before = (image_of(&s, missing), undo_len(&s));
|
||||
let e = s.execute(C, &json!({})).unwrap_err();
|
||||
assert!(e.to_string().contains("the linked image shows its preview: embed or relink it first"), "{e}");
|
||||
assert_eq!((image_of(&s, missing), undo_len(&s)), before);
|
||||
}
|
||||
|
||||
/// An object whose pixels were read at another size than it records keeps its place and size.
|
||||
#[test]
|
||||
fn an_image_read_at_another_size_keeps_its_place_and_size() {
|
||||
let bytes = png(DynamicImage::ImageRgba8(framed()));
|
||||
let (mut s, _) = open(&bytes);
|
||||
let id = place_linked(&mut s, "linked", ImageBlob::png(bytes), (24, 20));
|
||||
s.execute(C, &json!({"ids": [id.0]})).unwrap();
|
||||
let im = image_of(&s, id);
|
||||
assert_eq!((im.width, im.height, im.xf), (12, 10, Affine::scale_non_uniform(2.0, 2.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_params_are_refused_and_change_nothing() {
|
||||
let (mut s, id) = open(&png(DynamicImage::ImageRgba8(framed())));
|
||||
let rect = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap()["id"].clone();
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
let before = (image_of(&s, id), undo_len(&s));
|
||||
// With `ids`, the command is available when one of them is an image.
|
||||
let not_image = format!("object {rect} is not an image");
|
||||
let cases: [(Value, &str); 18] = [
|
||||
(json!({"background": "sky"}), "`background` must be edges, corners or colors"),
|
||||
(json!({"background": 5}), "`background` must be a string"),
|
||||
(json!({"match": "rgb"}), "`match` must be distance or channels"),
|
||||
(json!({"match": true}), "`match` must be a string"),
|
||||
(json!({"contiguous": "yes"}), "`contiguous` must be true or false"),
|
||||
(json!({"contiguous": 1}), "`contiguous` must be true or false"),
|
||||
(json!({"tolerance": 101}), "`tolerance` must be from 0 to 100"),
|
||||
(json!({"softness": -1}), "`softness` must be from 0 to 100"),
|
||||
(json!({"tolerance": "5"}), "`tolerance` must be a number"),
|
||||
(json!({"background": "colors"}), "`colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue"),
|
||||
(json!({"background": "colors", "colors": []}), "`colors` must list"),
|
||||
(json!({"background": "colors", "colors": "#12"}), "`colors` must list"),
|
||||
(json!({"background": "colors", "colors": "#ffffff80"}), "`colors` must list"),
|
||||
(json!({"colors": ["#ffffff", 7]}), "`colors` must list"),
|
||||
(json!({"background": "colors", "colors": vec!["#000"; 17]}), "`colors` must list"),
|
||||
(json!({"background": "colors", "colors": "#000 ".repeat(100_000)}), "`colors` must list"),
|
||||
(json!({"ids": [id.0, rect]}), ¬_image),
|
||||
(json!({"ids": [rect]}), "select an image"),
|
||||
];
|
||||
for (p, msg) in cases {
|
||||
let e = s.execute(C, &p).unwrap_err().to_string();
|
||||
assert!(e.contains(msg), "{p}: {e}");
|
||||
}
|
||||
assert_eq!((image_of(&s, id), undo_len(&s)), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_too_large_to_change_is_refused_before_it_is_decoded() {
|
||||
let run = |w: u32, h: u32| {
|
||||
let (mut s, _) = open(&crate::tests_linkspanel::png_header(w, h));
|
||||
s.execute(C, &json!({})).unwrap_err().to_string()
|
||||
};
|
||||
// As gray samples, RGBA worked on and kept for the PNG, and the work: 19 bytes a pixel.
|
||||
let over = run(10_632, 10_631);
|
||||
assert!(over.contains("an image of 10632 × 10631 pixels is too large to change"), "{over}");
|
||||
// Within the allowance it is decoded, and its missing rows can't be read.
|
||||
let within = run(10_631, 10_631);
|
||||
assert!(within.contains("the image's pixels can't be read"), "{within}");
|
||||
}
|
||||
@@ -871,7 +871,7 @@ fn rasterize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn has_image(s: &Session) -> std::result::Result<(), String> {
|
||||
pub(crate) fn has_image(s: &Session) -> std::result::Result<(), String> {
|
||||
has_selection(s)?;
|
||||
let st = s.active().ok_or("no document open")?;
|
||||
if st.selection.objects.iter().any(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Image(_)))) {
|
||||
|
||||
@@ -32,6 +32,7 @@ pub(super) const ALLOWED: &[&str] = &[
|
||||
"addon.color.strokeToFill",
|
||||
"addon.color.swatchLegend",
|
||||
"addon.contour.set",
|
||||
"addon.image.removeBackground",
|
||||
"addon.pathtools.addPoints",
|
||||
"addon.pathtools.close",
|
||||
"addon.pathtools.corner",
|
||||
|
||||
@@ -282,6 +282,9 @@ pub struct Session {
|
||||
pub(crate) in_tool_actions: bool,
|
||||
/// While [`Session::preview`] runs its command: its edits are the interaction's own.
|
||||
previewing: bool,
|
||||
/// The images Remove Background decoded during the interaction's preview steps, emptied when
|
||||
/// it ends ([`cmd::DecodedImages`]).
|
||||
pub(crate) decoded_images: cmd::DecodedImages,
|
||||
/// Envelope Options with no envelope selected: the options and fidelity new envelopes get
|
||||
/// (`None`: the reference app's defaults, fidelity 50); not saved.
|
||||
pub(crate) envelope_defaults: Option<(vectorcraft_doc::live::EnvelopeOptions, f64)>,
|
||||
|
||||
@@ -115,6 +115,7 @@ impl Session {
|
||||
recovery: Default::default(),
|
||||
in_tool_actions: false,
|
||||
previewing: false,
|
||||
decoded_images: Default::default(),
|
||||
envelope_defaults: None,
|
||||
liquify_stroke: None,
|
||||
plane_widget_press: false,
|
||||
|
||||
@@ -335,6 +335,7 @@ impl Session {
|
||||
|
||||
pub fn commit_interaction(&mut self) -> Result<()> {
|
||||
self.clear_deferred_overlay_cache();
|
||||
self.decoded_images.empty();
|
||||
if let Some(paint) = self.active().and_then(|st| st.interaction.as_ref()).and_then(|it| it.preview_paint.clone()) {
|
||||
self.paint = paint;
|
||||
}
|
||||
@@ -414,6 +415,7 @@ impl Session {
|
||||
|
||||
pub fn cancel_interaction(&mut self) -> Result<()> {
|
||||
self.clear_deferred_overlay_cache();
|
||||
self.decoded_images.empty();
|
||||
self.pending_paint = None;
|
||||
if let Some(paint) = self
|
||||
.active()
|
||||
|
||||
Reference in New Issue
Block a user