Merge main into fix/plugin-grants-delete-test-portable

This commit is contained in:
dotCooCoo
2026-11-11 21:01:50 -08:00
106 changed files with 7417 additions and 463 deletions
+1
View File
@@ -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;
+4
View File
@@ -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());
+598
View File
@@ -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))
}
+480
View File
@@ -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]}), &not_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}");
}
+101 -19
View File
@@ -19,12 +19,12 @@
//! shape that a Convert to Shape or Outline Object effect on them gives them, below or above the
//! image as its Contents row says; new ones go right below that row.
use std::sync::Arc;
use std::{collections::HashMap, sync::Arc};
use serde_json::{Value, json};
use vectorcraft_color::{BlendMode, Paint};
use vectorcraft_doc::appearance::SharedStack;
use vectorcraft_doc::{Appearance, AppearanceItem, CharStyle, FillLayer, Node, NodeKind, ParaStyle, StrokeLayer};
use vectorcraft_doc::{Appearance, AppearanceItem, CharStyle, Document, FillLayer, Node, NodeKind, ParaStyle, StrokeLayer};
use vectorcraft_geom::Rect;
use super::edit::{outermost, roots_of, selected_roots};
@@ -126,7 +126,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Eyedropper",
[],
None,
"{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art); with an object of a dynamic symbol instance selected by Direct Selection and no ids, reverse, append or defaultsOnly, that object takes the source's fill and stroke (solid, none, gradient or pattern) for this instance only, as the Eyedropper Options allow, and a source that is not a path, compound path or type, or options that pick up neither color, are refused. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {ids}",
"{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes using effective character dimensions while preserving target transforms) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art); with an object of a dynamic symbol instance selected by Direct Selection and no ids, reverse, append or defaultsOnly, that object takes the source's fill and stroke (solid, none, gradient or pattern) for this instance only, as the Eyedropper Options allow, and a source that is not a path, compound path or type, or options that pick up neither color, are refused. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {ids}",
has_doc,
copy_from
),
@@ -1198,7 +1198,7 @@ fn target_contents(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
/// Sample enabled attributes, refusing object transparency that a text range cannot store.
/// Sample enabled attributes with transform-aware character dimensions and atomic validation.
fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "appearance.copyFrom";
let clicked = id_param(p, "source").ok_or_else(|| bad(C, "missing `source` id"))?;
@@ -1270,9 +1270,6 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
None => s.eyedropper_sample.clone().ok_or_else(|| bad(C, "select the object to copy from"))?,
};
let picked = Picked::of(&node);
if !reverse && !defaults_only {
s.eyedropper_sample = Some(node);
}
// The highlighted characters' type objects and ranges: after Select All in threaded text, all
// the text of every frame of the story, except a frame the attributes come from, in place of
// the object selection.
@@ -1285,6 +1282,15 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
Some((id, start, end)) if targets.contains(&id) => vec![(id, Some((start, end)))],
_ => vec![],
};
let target_ids = || targets.iter().copied().filter(|_| ranges.is_empty()).chain(ranges.iter().map(|(id, _)| *id));
// Prepare every dimensional copy before changing defaults or the remembered sample. A bad
// source or target transform must refuse the entire pick, including a multi-frame range.
let characters = if attrs.character && !append {
let d = &s.doc()?.doc;
copied_character_styles(&node, target_ids().filter_map(|id| d.node(id)))?
} else {
HashMap::new()
};
// Character styles cannot represent object transparency. Refuse before even changing
// paint defaults; applying it to the object would also alter the unselected characters.
if attrs.appearance.transparency {
@@ -1298,11 +1304,33 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
}
}
}
if !reverse {
picked.to_defaults(s, &attrs.appearance);
}
// The session's bounds check follows the selection. Check text targets reached through
// explicit IDs, a reverse pick, or a highlighted story as well. Paragraph changes can
// move point text even when character copying is disabled.
let text_ids: Vec<_> = if attrs.paragraph && !append && picked.text.is_some() {
let d = &s.doc()?.doc;
target_ids().filter(|id| d.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)))).collect()
} else {
characters.keys().copied().collect()
};
let check_text_bounds = |d: &Document| {
if !text_ids.is_empty() && !crate::sanity::objects_in_range(d, &text_ids) {
return Err(bad(C, "sampled text would exceed the canvas (coordinates out of range)"));
}
Ok(())
};
// Remember the pick only after the document edit has passed its own validation.
let finish = |s: &mut Session, ids: &[NodeId]| {
if !reverse {
if !defaults_only {
s.eyedropper_sample = Some(node);
}
picked.to_defaults(s, &attrs.appearance);
}
json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() })
};
if (targets.is_empty() && ranges.is_empty()) || attrs.is_empty() {
return Ok(json!({ "ids": [] }));
return Ok(finish(s, &[]));
}
if !ranges.is_empty() {
let ids: Vec<NodeId> = ranges.iter().map(|(f, _)| *f).collect();
@@ -1312,24 +1340,26 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) };
let (start, end) = range.unwrap_or((0, vectorcraft_text::edit::runs_len(&t.runs)));
let placed = picked.placed(Some(t.local_bounds()));
vectorcraft_text::edit::style_range(&mut t.runs, start, end, |st| picked.run(st, &placed, &attrs));
vectorcraft_text::edit::style_range(&mut t.runs, start, end, |st| Picked::run(st, &placed, &attrs, characters.get(&id)));
if let Some((_, para)) = picked.text.as_ref().filter(|_| attrs.paragraph) {
t.edit_paras(Some(t.paragraphs_in(start, end)), |p| *p = para.clone());
}
super::typecmd::refresh_bounds(t);
}
check_text_bounds(d)?;
Ok(count_changed(d, &before, &ids))
})?;
return Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }));
return Ok(finish(s, &ids));
}
// When every target comes out as it was, the document and its undo history stay as they are.
let before = s.doc()?.doc.clone();
super::overprint::edit_counted(s, "Eyedropper", |d| {
for id in &targets {
if let Some(n) = d.node_mut(*id) {
picked.apply(n, &attrs, append);
picked.apply(n, &attrs, append, characters.get(id));
}
}
check_text_bounds(d)?;
// The paints the pick wrote, with a weight going with the paint of its stroke, as in `set_item`. `append` adds
// only the colors of the visible rows the source has.
let (fill, stroke) = (attrs.appearance.fill, attrs.appearance.stroke);
@@ -1369,7 +1399,59 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
let object = super::graph::capture_series_object_opacity(d, &objects);
Ok(count_changed(d, &before, &targets) + usize::from(kept || painted || object))
})?;
Ok(json!({ "ids": targets.iter().map(|i| i.0).collect::<Vec<_>>() }))
Ok(finish(s, &targets))
}
/// Read source typography in document space, then compensate for each target's transform.
/// Paint stays in its existing sampling path; character scale never changes object placement.
fn copied_character_styles<'a>(source: &Node, targets: impl Iterator<Item = &'a Node>) -> Result<HashMap<NodeId, CharStyle>> {
const C: &str = "appearance.copyFrom";
let mut styles = HashMap::new();
let NodeKind::Text(source) = &source.kind else { return Ok(styles) };
let Some(run) = source.runs.first() else { return Ok(styles) };
let mut targets = targets
.filter_map(|n| match &n.kind {
NodeKind::Text(t) => Some((n.id, t)),
_ => None,
})
.peekable();
if targets.peek().is_none() {
return Ok(styles);
}
let effective = source.effective_char_style(&run.style).ok_or_else(|| bad(C, "source text transform is collapsed or unrepresentable"))?;
if !copied_dimensions_fit(&run.style, &effective) {
return Err(bad(C, "character dimensions cannot be represented with the source text transform"));
}
for (id, text) in targets {
let scale = text.style_scale().ok_or_else(|| bad(C, "target text transform is collapsed or unrepresentable"))?;
let mut local = effective.clone();
local.size /= scale.points;
local.leading = local.leading.map(|v| v / scale.points);
local.baseline_shift /= scale.points;
local.tcy_up_down /= scale.points;
local.tcy_left_right /= scale.points;
local.h_scale /= scale.horizontal;
// As with dimensional type commands, reject overflow and underflow before layout.
if !copied_dimensions_fit(&effective, &local) {
return Err(bad(C, "character dimensions cannot be represented with the target text transform"));
}
styles.insert(id, local);
}
Ok(styles)
}
/// Bound each converted type dimension and refuse loss to zero before layout reads it.
fn copied_dimensions_fit(from: &CharStyle, to: &CharStyle) -> bool {
[
(from.size, to.size),
(from.leading.unwrap_or(0.0), to.leading.unwrap_or(0.0)),
(from.baseline_shift, to.baseline_shift),
(from.tcy_up_down, to.tcy_up_down),
(from.tcy_left_right, to.tcy_left_right),
(from.h_scale, to.h_scale),
]
.iter()
.all(|(from, to)| to.is_finite() && to.abs() <= crate::MAX_COORD && (*from == 0.0 || *to != 0.0))
}
/// What the Eyedropper picks up from an object.
@@ -1422,8 +1504,8 @@ impl Picked {
/// Copy character attributes and the enabled paint attributes through the same path for
/// whole text objects and highlighted character ranges.
fn run(&self, st: &mut CharStyle, placed: &Appearance, a: &EyedropperAttrs) {
if let Some((cs, _)) = self.text.as_ref().filter(|_| a.character) {
fn run(st: &mut CharStyle, placed: &Appearance, a: &EyedropperAttrs, character: Option<&CharStyle>) {
if let Some(cs) = character {
*st = CharStyle {
fill: std::mem::take(&mut st.fill),
stroke: std::mem::take(&mut st.stroke),
@@ -1439,7 +1521,7 @@ impl Picked {
/// Apply attributes `a` to `n`. `append` adds the fills and strokes (and effects) on top of its
/// stack instead. Type takes the paints on its characters, and character and paragraph
/// attributes from type.
fn apply(&self, n: &mut Node, a: &EyedropperAttrs, append: bool) {
fn apply(&self, n: &mut Node, a: &EyedropperAttrs, append: bool, character: Option<&CharStyle>) {
let ap = &a.appearance;
if ap.transparency {
n.opacity = self.opacity;
@@ -1456,7 +1538,7 @@ impl Picked {
let placed = self.placed(Some(t.local_bounds()));
let text = self.text.as_ref();
for r in &mut t.runs {
self.run(&mut r.style, &placed, a);
Self::run(&mut r.style, &placed, a, character);
}
if let Some((_, para)) = text.filter(|_| a.paragraph) {
// The source's first paragraph's attributes, on every paragraph.
+3 -1
View File
@@ -2,7 +2,7 @@
//! save, export and serialize by path or bytes (base64), the format table, `command.batch` and
//! `action.batch` (the Actions panel menu's Batch…).
//!
//! - `load`: `document.open` (native, legacy, SVG/SVGZ, PDF/.ai/.ait, DXF, raster images).
//! - `load`: `document.open` (native, legacy, SVG/SVGZ, PDF/.ai/.ait, DXF, HP-GL, raster images).
//! - `action_batch`: play an action on every file in a folder or every data set.
//! - `encode`: one encoder per writable format, with typed options parsed from the params.
//! - `svg`: the SVG Options (styling, fonts, images, object ids, artboards…).
@@ -205,6 +205,8 @@ mod tests_eps;
#[cfg(test)]
mod tests_dxfimport;
#[cfg(test)]
mod tests_hpgl;
#[cfg(test)]
mod tests_metafile;
#[cfg(test)]
File diff suppressed because one or more lines are too long
+12 -1
View File
@@ -479,6 +479,8 @@ pub const FORMATS: &[Format] = &[
},
Format { id: "emf", label: "EMF", extensions: &["emf"], mime: "image/emf", read: true, write: true, raster: false, options: metafile::OPTIONS },
Format { id: "wmf", label: "WMF", extensions: &["wmf"], mime: "image/wmf", read: true, write: true, raster: false, options: metafile::OPTIONS },
// HP-GL/2 plot files, read by `vectorcraft_cad::hpgl` into paths; exports don't write them.
reader("hpgl", "HP-GL", &["plt", "hpgl", "hgl", "hpg"], "application/vnd.hp-hpgl", false),
Format {
id: "tga",
label: "Targa",
@@ -552,6 +554,10 @@ pub const OPEN_EXTS: &[&str] = &[
"dxf",
"emf",
"wmf",
"plt",
"hpgl",
"hgl",
"hpg",
"eps",
"epsf",
"ps",
@@ -565,9 +571,10 @@ pub const OPEN_EXTS: &[&str] = &[
/// `.epsf`, `.ps`, Targa, PCX, Pixar, JPEG 2000, HEIC/HEIF or JPEG XL type, nor one for the `.pns`, `.dib` and `.rle` names of
/// PNG and BMP files (File › Open and Place still read them all). On Linux the desktop entry's EPS
/// type (shared-mime-info's) also covers `.epsf`, so file managers offer VectorCraft for it.
/// The packaging declares no type for HP-GL plot files either.
pub const UNASSOCIATED_EXTS: &[&str] = &[
"psd", "pdd", "psb", "epsf", "ps", "pns", "dib", "rle", "tga", "vda", "icb", "vst", "pcx", "pxr", "jpf", "jpx", "jp2", "j2k", "j2c", "jpc",
"heic", "heif", "jxl",
"heic", "heif", "jxl", "plt", "hpgl", "hgl", "hpg",
];
/// The extension that picks each writable format when exporting (the format's first; PNG-8 shares
@@ -641,6 +648,10 @@ pub const PLACE_EXTS: &[&str] = &[
"dxf",
"emf",
"wmf",
"plt",
"hpgl",
"hgl",
"hpg",
"eps",
"epsf",
"ps",
+15
View File
@@ -134,6 +134,17 @@ pub fn detect(name: &str, bytes: &[u8]) -> Option<&'static Format> {
if clipboard::looks_like_svg(&String::from_utf8_lossy(&bytes[..bytes.len().min(4096)])) {
return format("svg");
}
// HP-GL has no signature: a file that starts with plotter instructions, named as one or with an
// extension no other format (text, the formats VectorCraft names as unsupported, and the files
// File › Open takes in other ways included) reads.
let ext = super::extension(name);
let other = by_name.is_some_and(|f| f.id != "hpgl")
|| super::TEXT_EXTS.contains(&ext.as_str())
|| super::unsupported(&ext).is_some()
|| super::opens_as_other(&ext);
if !other && vectorcraft_cad::hpgl::is_hpgl(bytes) {
return format("hpgl");
}
by_name
}
@@ -487,6 +498,10 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
let (doc, warnings) = super::metafile::import(bytes)?;
(doc, warnings, false)
}
"hpgl" => {
let i = vectorcraft_cad::hpgl::import(bytes).map_err(err)?;
(i.document, i.warnings, false)
}
"affinity" => {
let i = super::affinity::import(&title, bytes)?;
preview_only = i.preview_only;
File diff suppressed because one or more lines are too long
+104
View File
@@ -0,0 +1,104 @@
//! HP-GL plotter files through the commands: `document.open` by extension or by content, File ›
//! Place as one group of paths, the dialogs' filters and the format list, and files that aren't
//! HP-GL refused with a message.
use serde_json::json;
use vectorcraft_doc::NodeKind;
use super::*;
fn b64(bytes: &[u8]) -> String {
vectorcraft_format::base64_encode(bytes)
}
/// An inch-wide red square and a black diagonal, with the thinnest pen.
const PLOT: &[u8] = b"IN;SP2;PW0;PA0,0;PD1016,0,1016,1016,0,1016,0,0;PU;SP1;PD1016,1016;PU;";
#[test]
fn document_open_reads_a_plot_file() {
let mut s = Session::new();
let r = s.execute("document.open", &json!({"name": "part.plt", "dataBase64": b64(PLOT)})).unwrap();
assert_eq!((r["format"].as_str(), r["title"].as_str()), (Some("hpgl"), Some("part.plt")), "{r}");
let st = s.doc().unwrap();
let board = st.doc.artboards[0].rect;
assert!((board.width() - 72.25).abs() < 1e-9 && (board.height() - 72.25).abs() < 1e-9, "{board:?}");
let mut paths = vec![];
st.doc.walk(|n| {
if let NodeKind::Path { .. } = n.kind {
paths.push(n.clone());
}
});
assert_eq!(paths.len(), 2);
// Opened as artwork: Save asks for a name.
assert!(st.path.is_none());
// A plot file opens by its content in any of its names, and under a name no format reads.
for name in ["drawing.prn", "PART.HPGL", "part.hgl", "part.hpg"] {
let r = Session::new().execute("document.open", &json!({"name": name, "dataBase64": b64(PLOT)})).unwrap();
assert_eq!(r["format"].as_str(), Some("hpgl"), "{name}: {r}");
}
}
#[test]
fn file_place_places_a_plot_file_as_one_group() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
let p = json!({"name": "part.plt", "dataBase64": b64(PLOT), "thumbnail": 16});
let info = s.execute("file.place.info", &p).unwrap();
assert_eq!(info["format"], "hpgl", "{info}");
assert!((info["width"].as_f64().unwrap() - 72.25).abs() < 1e-9, "{info}");
let r = s.execute("file.place", &p).unwrap();
assert_eq!((&r["format"], r["ids"].as_array().map(Vec::len)), (&json!("hpgl"), Some(1)), "{r}");
let doc = &s.doc().unwrap().doc;
let group = doc.node(vectorcraft_doc::NodeId(r["ids"][0].as_u64().unwrap())).unwrap();
let NodeKind::Group { children, clip, .. } = &group.kind else { panic!("a group, got {:?}", group.kind) };
assert_eq!((children.len(), *clip), (2, false), "the art's bounds, not clipped");
}
#[test]
fn files_that_are_not_hpgl_are_refused() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 50})).unwrap();
let before = s.doc().unwrap().doc.artboards[0].rect.size();
let script = b"set terminal png\nplot sin(x)\n";
let e = s.execute("document.open", &json!({"name": "graph.plt", "dataBase64": b64(script)})).expect_err("refused");
assert!(e.to_string().contains("not an HP-GL file"), "{e}");
assert_eq!(s.doc().unwrap().doc.artboards[0].rect.size(), before);
assert!(s.execute("file.place", &json!({"name": "graph.plt", "dataBase64": b64(script)})).is_err());
// Text that isn't a plot opens by its name, not as HP-GL.
assert!(detect("notes.txt", b"IN THE BEGINNING").is_none_or(|f| f.id != "hpgl"));
// Only a plot file's own names, and names no other format reads, are read by their content:
// text, other formats' files and the formats named as unsupported (a DWG drawing starts with
// `AC` and digits) aren't, even when they start like a plot.
for name in ["notes.txt", "notes.svg", "picture.tga", "drawing.dxf", "site.dwg", "old.pct"] {
for bytes in [&b"IN"[..], b"in 5;", b"AC1032\0\0", PLOT] {
assert!(detect(name, bytes).is_none_or(|f| f.id != "hpgl"), "{name}: {:?}", String::from_utf8_lossy(bytes));
}
}
let text = s.execute("file.place", &json!({"name": "notes.txt", "dataBase64": b64(b"IN 5; PA 10;")})).unwrap();
assert_eq!(text["format"], "txt", "{text}");
}
/// A plot file named as one opens when its first instruction is one a plot file seldom starts with;
/// under another name it isn't taken for HP-GL.
#[test]
fn a_plot_file_by_its_name_may_start_with_any_instruction() {
let plot = b"SI0.2,0.3;SP1;PA0,0;PD1016,0;";
let r = Session::new().execute("document.open", &json!({"name": "part.plt", "dataBase64": b64(plot)})).unwrap();
assert_eq!(r["format"].as_str(), Some("hpgl"), "{r}");
assert!(detect("drawing.prn", plot).is_none_or(|f| f.id != "hpgl"));
}
#[test]
fn the_dialogs_and_the_format_list_name_hpgl() {
let exts = ["plt", "hpgl", "hgl", "hpg"];
for e in exts {
assert!(OPEN_EXTS.contains(&e) && PLACE_EXTS.contains(&e) && UNASSOCIATED_EXTS.contains(&e), "{e}");
assert_eq!(format_for_name(&format!("a.{e}")).map(|f| f.id), Some("hpgl"), "{e}");
}
let filter = ("HP-GL", &exts[..]);
assert!(open_filters().any(|f| f == filter) && place_filters().any(|f| f == filter));
let r = Session::new().execute("document.formats", &json!({})).unwrap();
let f = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "hpgl").unwrap().clone();
assert_eq!((f["read"].as_bool(), f["write"].as_bool(), f["raster"].as_bool()), (Some(true), Some(false), Some(false)));
}
+48 -16
View File
@@ -41,7 +41,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Check Links",
[],
None,
"{ids?: [image or placed document ids] (default: every linked image and placed document)} where each linked file is and whether it changed since it was read → {links: [{name, path, status: ok|modified|missing, ids, found?: path (found away from its path: relative to the document, or by name in its folder), preview: true (showing the low-resolution preview, not the file), refused?: why the path is not followed (another computer, a device or a URL: it counts as missing)}], missing, modified}",
"{ids?: [image or placed document ids] (default: every linked image and placed document)} where each linked file is and whether it changed since it was read → {links: [{name, path?, status: ok|modified|missing, ids, found?: path (found away from its path: relative to the document, or by name in its folder), preview: true (showing the low-resolution preview, not the file), refused?: why the path is not followed (another computer, a device or a URL: it counts as missing)}], missing, modified}; under automation read roots, path and found are left out when the file they name is outside the roots",
has_doc,
check
),
@@ -77,7 +77,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Links",
[],
None,
"{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded); default: stacking order; top first} every image object and placed document in the layers, as the Links panel lists them → {links: [{id, name, linked, status: ok|modified|missing|embedded, format, pixelWidth, pixelHeight, path?, found?: path (found away from its path), page?, preview?: true (showing the saved preview), note?: said of an image past the decode allowance (shown at reduced size, or too large to draw), document?: true (a placed document: pageWidth and pageHeight in pt instead of pixels)}], missing, modified, embedded}",
"{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded); default: stacking order; top first} every image object and placed document in the layers, as the Links panel lists them → {links: [{id, name, linked, status: ok|modified|missing|embedded, format, pixelWidth, pixelHeight, path?, found?: path (found away from its path), page?, preview?: true (showing the saved preview), note?: said of an image past the decode allowance (shown at reduced size, or too large to draw), document?: true (a placed document: pageWidth and pageHeight in pt instead of pixels)}], missing, modified, embedded}; under automation read roots, path and found are left out when the file they name is outside the roots",
has_doc,
list
),
@@ -113,7 +113,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Link Info",
[],
None,
"{id?} an image's or placed document's Link Info (default: the one selected) → image.info's fields (id, name, linked, link, colorMode, pixelWidth, pixelHeight, width, height) and status: ok|modified|missing|embedded, format, ppi: [x, y] (the file's), effectivePpi: [x, y] (at its placed size), scale: [x%, y%] (of its 100% size), rotation (degrees, counter-clockwise), placement: {preserve, align, clip}; linked images also fileName, location (its folder), page?, fileSize? (bytes), modified?, created? (ms since the Unix epoch), refused? (why the path is not followed); a placed document has document: true, pageWidth and pageHeight (pt) instead of pixels, ppi and colorMode, and its scale is of its artboard's or page's 100% size",
"{id?} an image's or placed document's Link Info (default: the one selected) → image.info's fields (id, name, linked, link, colorMode, pixelWidth, pixelHeight, width, height) and status: ok|modified|missing|embedded, format, ppi: [x, y] (the file's), effectivePpi: [x, y] (at its placed size), scale: [x%, y%] (of its 100% size), rotation (degrees, counter-clockwise), placement: {preserve, align, clip}; linked images also fileName, location (its folder), page?, fileSize? (bytes), modified?, created? (ms since the Unix epoch), refused? (why the path is not followed); a placed document has document: true, pageWidth and pageHeight (pt) instead of pixels, ppi and colorMode, and its scale is of its artboard's or page's 100% size; under automation read roots, a link whose file is outside the roots has no link, path or location, and fileSize and modified come only from the file",
has_doc,
info
),
@@ -882,17 +882,35 @@ fn install(d: &mut Document, ids: &[NodeId], (link, img): FileRead, force: Optio
}
/// Groups (of the document saved at `doc_path`) as `{name, path, ids}` rows (those with objects in
/// the layers), with `refused`: why the path isn't followed, when it isn't.
/// the layers), with `refused`: why the path isn't followed, when it isn't. `path` is left out
/// where [`path_shown`] says so.
fn rows<'a>(gs: impl Iterator<Item = &'a Group>, doc_path: Option<&str>) -> Vec<Value> {
gs.filter(|g| !g.ids.is_empty())
.map(|g| {
let mut row = json!({ "name": g.link.name(), "path": g.link.path, "ids": ids_json(&g.ids) });
let mut row = json!({ "name": g.link.name(), "ids": ids_json(&g.ids) });
note_path(&mut row, "path", &g.link.path, doc_path);
note_refused(&mut row, &g.link, doc_path);
row
})
.collect()
}
/// May a reply to the caller asking now carry the link path `path` (of the document saved at
/// `doc_path`)? Always without read roots in force; with them, only when the file it names (a
/// relative path taken from the document's folder) lies inside them.
fn path_shown(path: &str, doc_path: Option<&str>) -> bool {
crate::file_access::current().is_none() || fileio::document_path(path, doc_path).is_ok_and(|p| crate::file_access::check_read(&p).is_ok())
}
/// `row[key] = path` when [`path_shown`] says so; otherwise `row` has no `key`.
fn note_path(row: &mut Value, key: &str, path: &str, doc_path: Option<&str>) {
if path_shown(path, doc_path) {
row[key] = json!(path);
} else if let Some(o) = row.as_object_mut() {
o.remove(key);
}
}
/// `row["refused"]`: why the path of `link` isn't followed, when it isn't ([`refused`]).
fn note_refused(row: &mut Value, link: &LinkInfo, doc_path: Option<&str>) {
if let Some(r) = refused(link, doc_path) {
@@ -1188,10 +1206,12 @@ fn check(s: &mut Session, p: &Value) -> Result<Value> {
let (status, found) = probe(g, LinkBase::of(st));
missing += usize::from(status == Status::Missing);
modified += usize::from(status == Status::Modified);
let mut row = json!({ "name": g.link.name(), "path": g.link.path, "status": status.as_str(), "ids": ids_json(&g.ids) });
note_refused(&mut row, &g.link, LinkBase::of(st).doc);
let doc_path = LinkBase::of(st).doc;
let mut row = json!({ "name": g.link.name(), "status": status.as_str(), "ids": ids_json(&g.ids) });
note_path(&mut row, "path", &g.link.path, doc_path);
note_refused(&mut row, &g.link, doc_path);
if let Some(f) = found.filter(|f| f.path != g.link.path) {
row["found"] = json!(f.path);
note_path(&mut row, "found", &f.path, doc_path);
}
if stale(&st.doc, g) {
row["preview"] = json!(true);
@@ -1663,14 +1683,15 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
o.insert("linked".into(), json!(link.is_some()));
o.insert("status".into(), json!(status));
}
let doc_path = LinkBase::of(st).doc;
if let Some(l) = link {
row["path"] = json!(l.path);
note_path(&mut row, "path", &l.path, doc_path);
if let Some(page) = l.page {
row["page"] = json!(page);
}
}
if let Some(f) = found {
row["found"] = json!(f);
note_path(&mut row, "found", f, doc_path);
}
if preview {
row["preview"] = json!(true);
@@ -1811,13 +1832,17 @@ fn placement_json(o: PlacementOptions) -> Value {
/// Link Info's fields of the linked file `l` (of object `id` of `st`) into `out`.
fn file_fields(st: &crate::DocState, id: NodeId, l: &LinkInfo, out: &mut Value) {
// Whether the path is shown is judged for the caller asking, before the opener's roots apply.
let shown = path_shown(&l.path, LinkBase::of(st).doc);
// A confined caller reads within its own roots; anyone else within what the caller that
// opened the document may read (never wider than the caller asking now).
let roots = crate::file_access::current().or_else(|| st.opened_by.clone());
crate::file_access::confine(roots.as_ref(), || file_fields_here(st, id, l, out));
crate::file_access::confine(roots.as_ref(), || file_fields_here(st, id, l, shown, out));
}
fn file_fields_here(st: &crate::DocState, id: NodeId, l: &LinkInfo, out: &mut Value) {
/// [`file_fields`]; without `shown`, the link's path (`path`, `link`) and folder (`location`)
/// and the size and date the document keeps of its file are left out.
fn file_fields_here(st: &crate::DocState, id: NodeId, l: &LinkInfo, shown: bool, out: &mut Value) {
let base = LinkBase::of(st);
let status = groups(&st.doc, Some(&[id])).first().map_or(Status::Missing, |g| probe(g, base).0);
let doc_path = base.doc;
@@ -1825,13 +1850,20 @@ fn file_fields_here(st: &crate::DocState, id: NodeId, l: &LinkInfo, out: &mut Va
let stamp = path.as_deref().and_then(file_stamp);
out["status"] = json!(status.as_str());
note_refused(out, l, doc_path);
out["path"] = json!(l.path);
if shown {
out["path"] = json!(l.path);
out["location"] = json!(l.folder());
} else if let Some(o) = out.as_object_mut() {
for k in ["path", "link", "location"] {
o.remove(k);
}
}
out["fileName"] = json!(l.name());
out["location"] = json!(l.folder());
let kept = |v: Option<u64>| v.filter(|_| shown);
for (k, v) in [
("page", l.page.map(u64::from)),
("fileSize", stamp.map(|s| s.0).or(l.size)),
("modified", stamp.and_then(|s| s.1).or(l.modified)),
("fileSize", stamp.map(|s| s.0).or(kept(l.size))),
("modified", stamp.and_then(|s| s.1).or(kept(l.modified))),
("created", path.as_deref().and_then(file_created)),
] {
if let Some(v) = v {
+1 -1
View File
@@ -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(_)))) {
+3 -3
View File
@@ -5,7 +5,7 @@
//! the file declares ([`fileio::ppi`]; 72 ppi when it declares none), linked to its file with
//! `link` ([`super::links`]).
//! - An SVG becomes one group of its art; so does a DXF drawing, read with the DXF options
//! (`dxf`, [`fileio::dxfimport`]).
//! (`dxf`, [`fileio::dxfimport`]), and an HP-GL plot file (`vectorcraft_cad::hpgl`).
//! - An EMF or WMF picture becomes one group, clipped to its frame (`crop: "crop"`) or bounded by
//! its art (`crop: "bounding"`).
//! - An EPS file (or a PostScript .ai) becomes one group, clipped to its bounding box or bounded by
@@ -69,7 +69,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Place…",
["File"],
Some("Cmd+Shift+P"),
"{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect; refused while an object of a symbol instance's art is selected), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed",
"{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect; refused while an object of a symbol instance's art is selected), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, HP-GL (.plt, .hpgl, .hgl, .hpg), PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed",
has_doc,
place
),
@@ -264,7 +264,7 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>, link_pdf: bool) -> Result<Loa
let board = doc.artboards.first().map(|a| a.rect).filter(|_| anchored);
let layers = std::mem::take(&mut doc.layers);
let symbols = doc.symbol_boxes();
let (nodes, clip) = if matches!(format.id, "svg" | "svgz" | "dxf") {
let (nodes, clip) = if matches!(format.id, "svg" | "svgz" | "dxf" | "hpgl") {
// An SVG with `crop: "crop"` asked for is framed and clipped by its viewBox (its artboard),
// as a PDF by its page: art drawn on a shared canvas keeps its place and scale (#788). By
// default, as Illustrator places one, it is its art's bounds.
+7 -6
View File
@@ -741,17 +741,18 @@ fn install(s: &mut Session, p: &Value) -> Result<Value> {
}
// Installed plug-ins stay installed after a restart (#2541). The module is written first and its
// grant only once it is there; when the grant can't be written, the store gets the module it
// held back. A failed write of that module is only logged, and the grant then doesn't cover
// the new module. A stored module that can't be read back isn't replaced by one that needs a
// grant.
// held back. A failed write of that module is only logged: the store then holds the new module,
// which the grant doesn't cover. A write that reports a failure may still have put the old
// module back, so the store is read again to tell which module it holds. A stored module that
// can't be read back isn't replaced by one that needs a grant.
let previous = s.stored_plugin_bytes(plugin.id());
let mut stored = (previous.is_ok() || !m.asks_permissions()) && s.store_plugin(plugin.id(), &bytes);
let holds_new = |s: &Session| s.stored_plugin_bytes(plugin.id()).is_ok_and(|held| held.as_deref() == Some(&bytes[..]));
if m.asks_permissions() {
let g = Grant { id: m.id.clone(), hash: plugin.hash().to_string(), permissions: wanted };
registry::grant(g.clone());
if stored && !s.store_grant(&m.id, Some(g)) {
s.put_back_plugin(plugin.id(), previous.ok().flatten().as_deref());
stored = false;
stored = !s.put_back_plugin(plugin.id(), previous.ok().flatten().as_deref()) && holds_new(s);
}
} else {
// This module takes the id over, so nothing the user approved for the id applies any more.
@@ -764,7 +765,7 @@ fn install(s: &mut Session, p: &Value) -> Result<Value> {
// stays; one that can't be opened or read may approve the old bytes once it reads again.
if stored && !s.store_grant(&m.id, None) && s.holds_plugin_grant(&m.id) {
let back = previous.as_ref().is_ok_and(|held| s.put_back_plugin(plugin.id(), held.as_deref()));
stored = !back;
stored = !back && holds_new(s);
if !back || matches!(previous, Ok(None)) {
s.ignore_stored_grant(&m.id);
}
@@ -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",
+3
View File
@@ -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)>,
+38 -16
View File
@@ -189,11 +189,15 @@ fn home_folder() -> String {
}
/// The folders `Folder.temp`, `Folder.desktop` and the others name, `null` where there is none.
/// On the web every one is `null`, and no platform folder is looked up.
fn locations(home: &str) -> Data {
if cfg!(target_arch = "wasm32") {
return Data::Object(PLACES.iter().map(|k| (k.to_string(), Data::Null)).collect());
}
let env = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty());
let join = |a: &str, b: &str| Path::new(a).join(b).to_string_lossy().into_owned();
let exe = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_string_lossy().into_owned()));
let temp = std::env::temp_dir().to_string_lossy().into_owned();
let temp = temp_folder().to_string_lossy().into_owned();
let (user_data, app_data, common, system, trash) = if WINDOWS {
(env("APPDATA"), env("PROGRAMDATA"), env("CommonProgramFiles"), env("SystemRoot"), None)
} else if cfg!(target_os = "macos") {
@@ -209,7 +213,6 @@ fn locations(home: &str) -> Data {
(Some(data.clone()), Some("/usr/share".to_string()), Some("/usr/share".to_string()), Some("/".to_string()), Some(join(&data, "Trash")))
};
let s = |v: Option<String>| v.map_or(Data::Null, Data::String);
let web = cfg!(target_arch = "wasm32");
let places = vec![
("appData", app_data),
("appPackage", exe.clone()),
@@ -222,7 +225,15 @@ fn locations(home: &str) -> Data {
("trash", trash),
("userData", user_data),
];
Data::Object(places.into_iter().map(|(k, v)| (k.to_string(), if web { Data::Null } else { s(v) })).collect())
Data::Object(places.into_iter().map(|(k, v)| (k.to_string(), s(v))).collect())
}
/// The names [`locations`] gives.
const PLACES: [&str; 10] = ["appData", "appPackage", "commonFiles", "desktop", "myDocuments", "startup", "system", "temp", "trash", "userData"];
/// The folder for temporary files: the platform's, or on the web the root, where nothing is read.
fn temp_folder() -> std::path::PathBuf {
if cfg!(target_arch = "wasm32") { std::path::PathBuf::from("/") } else { std::env::temp_dir() }
}
/// The start of an absolute path.
@@ -349,12 +360,12 @@ impl Pathname {
}
}
/// `s` with every character but letters, digits and `-_.!~*'()` written as `%xx` escapes of its
/// `s` with every character but letters, digits and `/-_.!~*'()` written as `%xx` escapes of its
/// UTF-8 bytes (RFC 2396).
fn encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
if b.is_ascii_alphanumeric() || b"-_.!~*'()".contains(&b) {
if b.is_ascii_alphanumeric() || b"/-_.!~*'()".contains(&b) {
out.push(b as char);
} else {
out.push_str(&format!("%{b:02X}"));
@@ -842,7 +853,23 @@ fn temp_name(host: &Host, _: &[Data]) -> Result<Data, HostError> {
let mut disk = host.disk.borrow_mut();
disk.temps += 1;
let name = format!("vc{:05}.tmp", disk.temps);
Ok(Data::String(std::env::temp_dir().join(name).to_string_lossy().into_owned()))
Ok(Data::String(temp_folder().join(name).to_string_lossy().into_owned()))
}
fn locations_of(host: &Host, _: &[Data]) -> Result<Data, HostError> {
Ok(locations(&host.disk.borrow().home))
}
fn encode_text(_: &Host, a: &[Data]) -> Result<Data, HostError> {
Ok(Data::String(encode(&arg_text(a, 0))))
}
fn decode_text(_: &Host, a: &[Data]) -> Result<Data, HostError> {
Ok(Data::String(decode(&arg_text(a, 0))))
}
fn encoding_available(_: &Host, a: &[Data]) -> Result<Data, HostError> {
Ok(Data::Bool(Encoding::parse(&arg_text(a, 0)).is_some()))
}
/// The folder `Folder.current` starts at in the run `host` serves: the script's own folder.
@@ -851,10 +878,6 @@ fn start_folder(host: &Host, _: &[Data]) -> Result<Data, HostError> {
Ok(Data::String(disk.pathname(&disk.start, &disk.start, false).fs()))
}
fn locations_of(host: &Host, _: &[Data]) -> Result<Data, HostError> {
Ok(locations(&host.disk.borrow().home))
}
/// Adds `File` and `Folder` to the realm, their host functions acting for the run `link` holds.
pub(super) fn install(realm: &mut Realm, link: &super::Link) -> Result<(), RunError> {
let unchecked = |f: fn(&Host, &[Data]) -> Result<Data, HostError>| {
@@ -871,7 +894,6 @@ pub(super) fn install(realm: &mut Realm, link: &super::Link) -> Result<(), RunEr
("noPicker".into(), value(NO_PICKER)),
("badMode".into(), value(BAD_MODE)),
]);
let pure = |f: fn(&str) -> Data| HostItem::Function(Rc::new(move |a| Ok(f(&arg_text(&a, 0)))));
let system = if WINDOWS {
"Windows"
} else if cfg!(target_os = "macos") {
@@ -884,10 +906,10 @@ pub(super) fn install(realm: &mut Realm, link: &super::Link) -> Result<(), RunEr
("system".into(), value(system)),
("lineFeed".into(), value(if WINDOWS { "Windows" } else { "Unix" })),
("current".into(), unchecked(start_folder)),
("locations".into(), unchecked(locations_of)),
("encode".into(), pure(|s| Data::String(encode(s)))),
("decode".into(), pure(|s| Data::String(decode(s)))),
("encodingAvailable".into(), pure(|s| Data::Bool(Encoding::parse(s).is_some()))),
("locations".into(), call(link, locations_of)),
("encode".into(), call(link, encode_text)),
("decode".into(), call(link, decode_text)),
("encodingAvailable".into(), call(link, encoding_available)),
("parse".into(), call(link, parse)),
("relative".into(), call(link, relative)),
("tempName".into(), call(link, temp_name)),
@@ -955,7 +977,7 @@ mod tests {
#[test]
fn uri_escapes_are_utf8_both_ways() {
assert_eq!(encode("caf\u{e9} 1"), "caf%C3%A9%201");
assert_eq!(encode("a-_.!~*'()/b"), "a-_.!~*'()%2Fb");
assert_eq!(encode("a-_.!~*'()/b"), "a-_.!~*'()/b");
assert_eq!(decode("caf%C3%A9%20x"), "caf\u{e9} x");
assert_eq!(decode("100%"), "100%");
assert_eq!(decode("%E9"), "%E9", "a lone Latin-1 escape is not UTF-8");
+1
View File
@@ -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,
+2
View File
@@ -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()
+10 -2
View File
@@ -193,6 +193,9 @@ thread_local! {
/// Tests: removing [`GRANTS_FILE`] fails on this thread while the file still reads, as when
/// another program holds it open.
pub(crate) static GRANTS_DELETE_FAILS: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
/// Tests: [`Session::put_back_plugin`] writes the old module back on this thread and then
/// reports a failure, as when the last flush of a copy fails.
pub(crate) static PUT_BACK_FAILS_AFTER_WRITE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
/// Tests: moving a plug-in's settings and cache back from `<folder>.removed` ([`move_back`])
/// fails on this thread, as when another program holds a file in the folder.
pub(crate) static MOVE_BACK_FAILS: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
@@ -515,16 +518,21 @@ impl Session {
/// Return the store's module for the plug-in `id` to `previous`, the bytes it held before a
/// write ([`Session::stored_plugin_bytes`]): with `None`, the file is removed. → whether the
/// store now holds `previous`.
/// write reported success; a write that reports a failure may still have put `previous` back.
pub(crate) fn put_back_plugin(&self, id: &str, previous: Option<&[u8]>) -> bool {
#[cfg(test)]
if PUT_BACK_FAILS.get() {
return false;
}
match previous {
let back = match previous {
Some(bytes) => self.store_plugin(id, bytes),
None => self.unstore_plugin(id),
};
#[cfg(test)]
if PUT_BACK_FAILS_AFTER_WRITE.get() {
return false;
}
back
}
/// The file the plug-in `id` is stored in, spelled as [`Session::load_stored_plugins`] names it
@@ -115,6 +115,9 @@ fn constrain_angle_draws_boxes_along_the_turned_axes() {
let mut s = new_doc();
set_pref(&mut s, "constrainAngle", json!(90));
for tool in ["rectangle", "ellipse"] {
// The shape drawn before stays selected, and a press on its box's corner handle would
// scale it instead of drawing.
s.execute("select.none", &json!({})).unwrap();
s.select_tool(tool, ViewInfo::default()).unwrap();
// 60 right and 20 down on the page: along axes turned 90°, 20 wide and 60 high.
gesture(&mut s, Mods::default(), &[(Down, 100.0, 100.0), (Drag, 130.0, 110.0), (Drag, 160.0, 120.0), (Up, 160.0, 120.0)]);
+285
View File
@@ -215,6 +215,291 @@ fn type_picks_up_font_and_size() {
assert_eq!((st.size, justify, st.fill.color().unwrap().to_hex()), (12.0, preserved_alignment, "#00ff00".into()));
}
/// Sample document-space typography between text objects with different local transforms.
#[test]
fn scaled_type_pick_matches_effective_character_dimensions() {
for source_area in [false, true] {
for target_area in [false, true] {
let mut s = session();
let create = |s: &mut Session, area: bool, x: f64| {
let mut p = json!({"x": x, "y": 100, "text": "Sample", "size": 12});
if area {
p["area"] = json!({"width": 160, "height": 80});
}
id_of(s.execute("text.create", &p).unwrap())
};
let src = create(&mut s, source_area, 100.0);
s.execute("object.transform", &json!({"matrix": [3, 0, 0, 2, 0, 0]})).unwrap();
let dst = create(&mut s, target_area, 300.0);
s.execute("object.transform", &json!({"matrix": [0.5, 0, 0, 1.5, 0, 0]})).unwrap();
let before = node(&s, dst);
let source = node(&s, src);
let history = undo_len(&s);
s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap();
let after = node(&s, dst);
let NodeKind::Text(t) = &after.kind else { panic!("not text") };
let NodeKind::Text(old) = &before.kind else { panic!("not text") };
let effective = t.effective_char_style(&t.first_style()).unwrap();
assert!((effective.size - 24.0).abs() < 1e-8, "sampled font size: {}", effective.size);
assert!((effective.h_scale - 150.0).abs() < 1e-8, "sampled horizontal scale: {}", effective.h_scale);
assert_eq!(t.xf, old.xf, "sampling keeps the target's transform");
assert_eq!(node(&s, src), source);
assert_eq!(undo_len(&s), history + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(node(&s, dst), before);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(node(&s, dst), after);
}
}
}
/// A text object snapshot, including the local attributes and retained affine transform.
fn sampled_type(s: &Session, id: NodeId) -> vectorcraft_doc::TextObject {
let NodeKind::Text(t) = node(s, id).kind else { panic!("not text") };
*t
}
/// Compare the dimensional character attributes that text controls display in document units.
fn assert_sampled_dimensions(actual: &vectorcraft_doc::CharStyle, expected: &vectorcraft_doc::CharStyle) {
assert_eq!(actual.leading.is_some(), expected.leading.is_some());
for (a, e) in [
(actual.size, expected.size),
(actual.h_scale, expected.h_scale),
(actual.v_scale, expected.v_scale),
(actual.leading.unwrap_or(0.0), expected.leading.unwrap_or(0.0)),
(actual.baseline_shift, expected.baseline_shift),
(actual.tcy_up_down, expected.tcy_up_down),
(actual.tcy_left_right, expected.tcy_left_right),
] {
assert!((a - e).abs() < 1e-8, "sampled dimension: {a} != {e}");
}
}
/// Rotation, shear, reflection and grouping stay on the objects while typography is copied.
#[test]
fn scaled_type_pick_preserves_affines_and_document_dimensions() {
for grouped in [false, true] {
let mut s = session();
let src = id_of(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Source", "size": 12})).unwrap());
s.execute("text.setStyle", &json!({"leading": 18})).unwrap();
s.execute("text.setFormat", &json!({"hScale": 80, "vScale": 125, "baselineShift": 3, "tcyUpDown": 2, "tcyLeftRight": -1})).unwrap();
if grouped {
s.execute("object.group", &json!({})).unwrap();
}
s.execute("object.transform", &json!({"matrix": [0, -3, -2, 1, 0, 0]})).unwrap();
let src_text = sampled_type(&s, src);
let expected = src_text.effective_char_style(&src_text.first_style()).unwrap();
let dst = id_of(s.execute("text.create", &json!({"x": 300, "y": 100, "text": "Target", "size": 20})).unwrap());
if grouped {
s.execute("object.group", &json!({})).unwrap();
}
s.execute("object.transform", &json!({"matrix": [-1, 0.5, 1, 2, 0, 0]})).unwrap();
let before = sampled_type(&s, dst);
s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap();
let after = sampled_type(&s, dst);
assert_sampled_dimensions(&after.effective_char_style(&after.first_style()).unwrap(), &expected);
assert_eq!(after.xf, before.xf);
assert_eq!(sampled_type(&s, src), src_text);
let bytes = vectorcraft_format::save_file(&s.doc().unwrap().doc);
let reopened = vectorcraft_format::load(&bytes).unwrap();
let NodeKind::Text(t) = &reopened.node(dst).unwrap().kind else { panic!("not text") };
assert_sampled_dimensions(&t.effective_char_style(&t.first_style()).unwrap(), &expected);
}
}
/// A transformed target compensates only the highlighted characters, retaining the others.
#[test]
fn scaled_type_pick_changes_only_the_highlighted_range() {
let mut s = session();
let src = id_of(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Source", "size": 12})).unwrap());
s.execute("object.transform", &json!({"matrix": [3, 0, 0, 2, 0, 0]})).unwrap();
let source = sampled_type(&s, src);
let expected = source.effective_char_style(&source.first_style()).unwrap();
let dst = id_of(s.execute("text.create", &json!({"x": 300, "y": 100, "text": "Sample", "size": 10})).unwrap());
s.execute("object.transform", &json!({"matrix": [0.5, 0, 0, 3, 0, 0]})).unwrap();
let before = sampled_type(&s, dst);
s.select_tool("type", ViewInfo::default()).unwrap();
s.set_tool_option("textSelection", &json!({"id": dst.0, "start": 1, "end": 4}));
s.select_tool("eyedropper", ViewInfo::default()).unwrap();
let history = undo_len(&s);
s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap();
let after = sampled_type(&s, dst);
assert_eq!(after.runs.len(), 3);
assert_eq!((&*after.runs[0].text, &*after.runs[1].text, &*after.runs[2].text), ("S", "amp", "le"));
assert_eq!(after.runs[0].style, before.first_style());
assert_eq!(after.runs[2].style, before.first_style());
assert_sampled_dimensions(&after.effective_char_style(&after.runs[1].style).unwrap(), &expected);
assert_eq!(after.xf, before.xf);
assert_eq!(undo_len(&s), history + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(sampled_type(&s, dst), before);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(sampled_type(&s, dst), after);
}
/// Reject unusable source or target transforms before any part of a multi-object pick changes.
#[test]
fn scaled_type_pick_rejects_unrepresentable_dimensions_atomically() {
for (bad_source, factor) in [(true, 0.0), (false, 0.0), (false, 1e-306), (true, f64::from_bits(1))] {
let mut s = session();
let size = if bad_source && factor > 0.0 { 0.1 } else { 24.0 };
let src = id_of(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Source", "size": size, "color": "#00ff00"})).unwrap());
if bad_source {
s.execute("object.transform", &json!({"matrix": [factor, 0, 0, factor, 0, 0]})).unwrap();
}
let good = id_of(s.execute("text.create", &json!({"x": 300, "y": 100, "text": "Good", "size": 10})).unwrap());
let other = id_of(s.execute("text.create", &json!({"x": 300, "y": 200, "text": "Other", "size": 12})).unwrap());
if !bad_source {
s.execute("object.transform", &json!({"matrix": [factor, 0, 0, factor, 0, 0]})).unwrap();
}
s.execute("select.set", &json!({"ids": [good.0, other.0]})).unwrap();
let doc = s.doc().unwrap().doc.clone();
let selection = s.doc().unwrap().selection.clone();
let paint = s.paint.clone();
let sample = s.eyedropper_sample.clone();
let history = undo_len(&s);
let error = s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap_err();
assert!(error.to_string().contains("transform"), "{error}");
assert_eq!(s.doc().unwrap().doc, doc);
assert_eq!(s.doc().unwrap().selection, selection);
assert_eq!(s.paint, paint);
assert_eq!(s.eyedropper_sample, sample);
assert_eq!(undo_len(&s), history);
// A paint-only pick does not need a usable text transform.
s.execute("appearance.copyFrom", &json!({"source": src.0, "apply": {"character": false, "paragraph": false}})).unwrap();
}
}
/// A failed dimensional pick retains highlighted characters and the previously remembered sample.
#[test]
fn scaled_type_pick_rejection_preserves_the_highlighted_range_and_sample() {
let mut s = session();
let saved = id_of(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Saved", "size": 18})).unwrap());
s.execute("select.none", &json!({})).unwrap();
s.execute("appearance.copyFrom", &json!({"source": saved.0})).unwrap();
let src = id_of(s.execute("text.create", &json!({"x": 100, "y": 200, "text": "Source", "size": 24, "color": "#00ff00"})).unwrap());
s.execute("object.transform", &json!({"matrix": [0, 0, 0, 0, 0, 0]})).unwrap();
let dst = id_of(s.execute("text.create", &json!({"x": 300, "y": 100, "text": "Sample", "size": 10})).unwrap());
s.select_tool("type", ViewInfo::default()).unwrap();
s.set_tool_option("textSelection", &json!({"id": dst.0, "start": 1, "end": 4}));
s.select_tool("eyedropper", ViewInfo::default()).unwrap();
let doc = s.doc().unwrap().doc.clone();
let selection = s.doc().unwrap().selection.clone();
let range = s.tool_options();
let paint = s.paint.clone();
let sample = s.eyedropper_sample.clone();
let history = undo_len(&s);
assert!(s.execute("appearance.copyFrom", &json!({"source": src.0})).is_err());
assert_eq!(s.doc().unwrap().doc, doc);
assert_eq!(s.doc().unwrap().selection, selection);
assert_eq!(s.tool_options(), range);
assert_eq!(s.paint, paint);
assert_eq!(s.eyedropper_sample, sample);
assert_eq!(undo_len(&s), history);
// Alt-click can still apply the last successful sample after the rejected pick.
s.execute("select.none", &json!({})).unwrap();
s.execute("appearance.copyFrom", &json!({"source": dst.0, "reverse": true})).unwrap();
assert_eq!(sampled_type(&s, dst).first_style().size, 18.0);
}
/// A pick that would extend text beyond the canvas keeps the previous sample and defaults.
#[test]
fn scaled_type_pick_canvas_rejection_preserves_sample_and_defaults() {
for mode in ["selection", "ids", "reverse", "range"] {
let mut s = session();
let saved = id_of(s.execute("text.create", &json!({"x": 40, "y": 40, "text": "Saved", "size": 18, "color": "#0000ff"})).unwrap());
let src = id_of(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Source", "size": 96, "color": "#ff0000"})).unwrap());
let dst = id_of(s.execute("text.create", &json!({"x": MAX_COORD - 100.0, "y": 200, "text": "WWWW", "size": 10})).unwrap());
s.execute("appearance.copyFrom", &json!({"source": saved.0, "ids": []})).unwrap();
let pick = match mode {
"ids" | "reverse" => {
s.execute("select.none", &json!({})).unwrap();
if mode == "ids" { json!({"source": src.0, "ids": [dst.0]}) } else { json!({"source": dst.0, "ids": [src.0], "reverse": true}) }
}
"range" => {
s.select_tool("type", ViewInfo::default()).unwrap();
s.set_tool_option("textSelection", &json!({"id": dst.0, "start": 0, "end": 3}));
s.select_tool("eyedropper", ViewInfo::default()).unwrap();
json!({"source": src.0})
}
_ => json!({"source": src.0}),
};
let doc = s.doc().unwrap().doc.clone();
let selection = s.doc().unwrap().selection.clone();
let range = s.tool_options();
let paint = s.paint.clone();
let sample = s.eyedropper_sample.clone();
let history = undo_len(&s);
let error = s.execute("appearance.copyFrom", &pick).unwrap_err();
assert!(error.to_string().contains("coordinates out of range"), "{mode}: {error}");
assert_eq!(s.doc().unwrap().doc, doc, "{mode}");
assert_eq!(s.doc().unwrap().selection, selection, "{mode}");
assert_eq!(s.tool_options(), range, "{mode}");
assert_eq!(s.paint, paint, "{mode}");
assert_eq!(s.eyedropper_sample, sample, "{mode}");
assert_eq!(undo_len(&s), history, "{mode}");
}
}
/// Changing justification can move explicitly targeted point text outside the canvas even
/// when character copying is disabled.
#[test]
fn paragraph_only_pick_rejects_out_of_bounds_explicit_target() {
let mut s = session();
let saved = id_of(s.execute("text.create", &json!({"x": 40, "y": 40, "text": "Saved", "color": "#0000ff"})).unwrap());
let src = id_of(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Source", "color": "#ff0000"})).unwrap());
s.execute("text.setStyle", &json!({"justify": "left"})).unwrap();
let dst = id_of(s.execute("text.create", &json!({"x": 100, "y": 200, "text": "WWWW", "size": 16})).unwrap());
s.execute("text.setStyle", &json!({"justify": "right"})).unwrap();
s.execute("object.transform", &json!({"matrix": [1, 0, 0, 1, MAX_COORD - 101.0, 0]})).unwrap();
s.execute("select.none", &json!({})).unwrap();
s.execute("appearance.copyFrom", &json!({"source": saved.0, "ids": []})).unwrap();
let doc = s.doc().unwrap().doc.clone();
let selection = s.doc().unwrap().selection.clone();
let paint = s.paint.clone();
let sample = s.eyedropper_sample.clone();
let history = undo_len(&s);
let pick = json!({"source": src.0, "ids": [dst.0], "apply": {"character": false}});
let error = s.execute("appearance.copyFrom", &pick).unwrap_err();
assert!(error.to_string().contains("coordinates out of range"), "{error}");
assert_eq!(s.doc().unwrap().doc, doc);
assert_eq!(s.doc().unwrap().selection, selection);
assert_eq!(s.paint, paint);
assert_eq!(s.eyedropper_sample, sample);
assert_eq!(undo_len(&s), history);
}
/// Disabled character copying and append preserve typography; reverse and cached picks convert it.
#[test]
fn scaled_type_pick_respects_options_and_reverse_sampling() {
let mut s = session();
let src = id_of(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Source", "size": 12})).unwrap());
s.execute("object.scale", &json!({"sx": 300, "sy": 200})).unwrap();
let source = sampled_type(&s, src);
let expected = source.effective_char_style(&source.first_style()).unwrap();
let dst = id_of(s.execute("text.create", &json!({"x": 300, "y": 100, "text": "Target", "size": 20})).unwrap());
s.execute("object.scale", &json!({"sx": 50, "sy": 150})).unwrap();
let before = sampled_type(&s, dst);
for options in [json!({"pickUp": {"character": false}}), json!({"apply": {"character": false}}), json!({"append": true})] {
let mut p = json!({"source": src.0, "ids": [dst.0]});
p.as_object_mut().unwrap().extend(options.as_object().unwrap().clone());
s.execute("appearance.copyFrom", &p).unwrap();
assert_sampled_dimensions(&sampled_type(&s, dst).first_style(), &before.first_style());
}
s.execute("select.set", &json!({"ids": [src.0]})).unwrap();
s.execute("appearance.copyFrom", &json!({"source": dst.0, "reverse": true})).unwrap();
let after = sampled_type(&s, dst);
assert_sampled_dimensions(&after.effective_char_style(&after.first_style()).unwrap(), &expected);
s.execute("edit.undo", &json!({})).unwrap();
s.execute("select.none", &json!({})).unwrap();
s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap();
s.execute("appearance.copyFrom", &json!({"source": dst.0, "reverse": true})).unwrap();
let cached = sampled_type(&s, dst);
assert_sampled_dimensions(&cached.effective_char_style(&cached.first_style()).unwrap(), &expected);
}
#[test]
fn alt_click_applies_the_selection_to_the_clicked_object() {
let mut s = session();
+53
View File
@@ -296,6 +296,59 @@ fn link_info_stays_within_the_asking_callers_roots() {
assert_ne!(i["status"], "ok", "{i}");
}
/// A confined caller's `links.list`, `links.check` and `links.info` replies, and its
/// `document.open` reply, leave out the path and folder of a linked file outside its read roots
/// and keep the row with the file's name. A file found by name inside the roots shows its path.
#[test]
fn link_paths_outside_the_read_roots_are_left_out() {
let away = Folder::new("lrr-away");
let home = Folder::new("lrr-home");
let pic = away.file("photo.png");
let bytes = png(60, 30, RED);
write(&pic, &bytes);
let mut s = session();
let id = place(&mut s, &pic);
let roots = crate::file_access::AutomationRoots::new(&[&home.0], &[]).unwrap().unwrap();
let ask = |s: &mut Session, cmd: &str, p: Value| crate::file_access::confine(Some(&roots), || run(s, cmd, p));
let hidden = |r: &Value| assert!(!r.to_string().contains("lrr-away"), "{r}");
let list = ask(&mut s, "links.list", json!({}));
hidden(&list);
let row = &list["links"][0];
assert_eq!((row["id"].as_u64(), row["name"].as_str(), row["status"].as_str()), (Some(id.0), Some("photo.png"), Some("missing")), "{list}");
let check = ask(&mut s, "links.check", json!({}));
hidden(&check);
assert_eq!((check["links"][0]["name"].as_str(), check["missing"].as_u64()), (Some("photo.png"), Some(1)), "{check}");
let info = ask(&mut s, "links.info", json!({"id": id.0}));
hidden(&info);
assert_eq!(
(info["fileName"].as_str(), info["status"].as_str(), info["linked"].as_bool()),
(Some("photo.png"), Some("missing"), Some(true)),
"{info}"
);
for k in ["path", "link", "location", "fileSize", "modified", "created"] {
assert!(info.get(k).is_none(), "{k}: {info}");
}
// Unconfined, the same replies carry the paths.
assert_eq!(run(&mut s, "links.list", json!({}))["links"][0]["path"], json!(pic));
let i = run(&mut s, "links.info", json!({"id": id.0}));
assert_eq!((&i["path"], &i["link"], i["fileSize"].as_u64()), (&json!(pic), &json!(pic), Some(bytes.len() as u64)), "{i}");
// Saved inside the roots next to a copy of the file: the copy is found by name and shown.
save(&mut s, &home.file("poster.vectorcraft"));
let copy = home.file("photo.png");
write(&copy, &bytes);
let check = ask(&mut s, "links.check", json!({}));
hidden(&check);
assert_eq!((check["links"][0]["status"].as_str(), &check["links"][0]["found"]), (Some("ok"), &json!(copy)), "{check}");
let list = ask(&mut s, "links.list", json!({}));
hidden(&list);
assert_eq!(list["links"][0]["found"], json!(copy), "{list}");
// Opened again without the copy: the missing link's row has no path.
std::fs::remove_file(&copy).unwrap();
let opened = ask(&mut Session::new(), "document.open", json!({"path": home.file("poster.vectorcraft")}));
hidden(&opened);
assert_eq!(opened["missingLinks"][0]["name"], "photo.png", "{opened}");
}
/// A 600×300 pt image at the centre of the artboard, its placement options set to `options`,
/// relinked to a 300×100 px file → the image's id and session.
fn relinked(name: &str, options: Value) -> (Session, NodeId, Folder) {
+3 -4
View File
@@ -73,14 +73,13 @@ fn arrowheads_are_part_of_the_outline() {
assert_eq!(path_of(&n).subpaths.len(), 1, "line and head are one shape");
}
/// Outline Stroke of a Lens-profile line covers 7/12 of the full-width rectangle.
/// Outline Stroke of a Lens-profile line covers 2/3 of the full-width rectangle.
#[test]
fn a_lens_profile_outlines_to_seven_twelfths_of_the_rectangle() {
fn a_lens_profile_outlines_to_two_thirds_of_the_rectangle() {
let mut s = session();
line(&mut s, json!({"weight": 10, "profile": "lens"}));
let n = outline(&mut s);
// Each half of the lens swells on a cubic, flat at the middle (#5714).
assert!(near(area(&n), 7.0 / 12.0 * 10.0 * 100.0, 2.0), "{}", area(&n));
assert!(near(area(&n), 2.0 / 3.0 * 10.0 * 100.0, 2.0), "{}", area(&n));
}
#[test]
@@ -1060,6 +1060,30 @@ fn link_info_gives_the_artboards_size_not_pixels() {
);
}
/// A confined caller's Link Info of a placed document whose file is outside its read roots has no
/// `link`, `path` or `location`, and keeps the file's name.
#[test]
fn link_info_leaves_out_a_placed_documents_path_outside_the_read_roots() {
let away = Folder::new("placed-lrr-away");
let home = Folder::new("placed-lrr-home");
let src = away.file("logo.vectorcraft");
source(&src, 100.0, 50.0, RED, 0);
let mut s = session();
let id = place(&mut s, &src);
let roots = crate::file_access::AutomationRoots::new(&[&home.0], &[]).unwrap().unwrap();
let i = crate::file_access::confine(Some(&roots), || s.execute("links.info", &json!({"id": id.0}))).unwrap();
assert!(!i.to_string().contains("placed-lrr-away"), "{i}");
assert_eq!(
(i["fileName"].as_str(), i["document"].as_bool(), i["status"].as_str()),
(Some("logo.vectorcraft"), Some(true), Some("missing")),
"{i}"
);
let l = crate::file_access::confine(Some(&roots), || s.execute("links.list", &json!({}))).unwrap();
assert!(!l.to_string().contains("placed-lrr-away") && l["links"][0]["name"] == "logo.vectorcraft", "{l}");
let i = s.execute("links.info", &json!({"id": id.0})).unwrap();
assert_eq!((&i["link"], &i["path"]), (&json!(src), &json!(src)), "unconfined: {i}");
}
/// Save As reads its defaults from the catalog; using them must retain current editable links.
#[test]
fn native_save_catalog_defaults_keep_placed_documents_editable() {
+188
View File
@@ -2326,6 +2326,194 @@ fn a_failed_store_write_keeps_the_stored_module_with_its_grant() {
vectorcraft_plugins::registry::revoke(id);
}
/// Runs the test `name` of this module again in a process of its own → whether this is that
/// process. The list of disabled plug-ins is kept for the whole process, and the other tests here
/// read it again each time they attach a plug-in store.
fn in_own_process(name: &str) -> bool {
const CHILD: &str = "VECTORCRAFT_TEST_PLUGIN_DISABLED_LIST";
if std::env::var_os(CHILD).is_some() {
return true;
}
let output = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", &format!("tests::plugins::{name}"), "--nocapture"])
.env(CHILD, "1")
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
output.status.success() && stdout.contains("test result: ok. 1 passed; 0 failed;"),
"{name} failed in its own process:\n{stdout}\n{}",
String::from_utf8_lossy(&output.stderr),
);
false
}
/// An update whose grant can't be written, and whose old module can't be put back either, stays in
/// the store: a disabled plug-in is turned on, as for any update the store holds. The old grant
/// doesn't cover the new module, so the next start skips it until it is approved.
#[test]
fn an_update_left_in_the_store_by_a_failed_put_back_turns_a_disabled_plugin_on() {
if !in_own_process("an_update_left_in_the_store_by_a_failed_put_back_turns_a_disabled_plugin_on") {
return;
}
use vectorcraft_plugins::{permissions::sha256_hex, registry, safe_mode};
let folder = crate::tests_links::Folder::new("plugin-store-failed-put-back-update");
let id = "test.engine.permissions.putbackupdate";
let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []}));
let changed = asking(id, r#"["doc.read","doc.write"]"#, &json!({"objects": []}));
let mut s = session();
s.attach_plugin_store(folder.0.clone());
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&first), "grant": ["doc.read"]}));
run(&mut s, "plugin.setEnabled", json!({"id": id, "enabled": false}));
let grants = folder.0.join("grants.json");
let recorded = std::fs::read(&grants).unwrap();
std::fs::remove_file(&grants).unwrap();
std::fs::create_dir_all(grants.join("block")).unwrap();
crate::store::PUT_BACK_FAILS.set(true);
let update = json!({"dataBase64": vectorcraft_format::base64_encode(&changed), "grant": ["doc.read", "doc.write"], "replace": true});
let installed = s.execute("plugin.install", &update);
crate::store::PUT_BACK_FAILS.set(false);
assert_eq!(installed.unwrap()["hash"], sha256_hex(&changed), "the new module runs in this session");
assert_eq!(registry::get(id).map(|p| p.hash().to_string()), Some(sha256_hex(&changed)));
assert_eq!(std::fs::read(s.stored_plugin_file(id).unwrap()).unwrap(), changed, "the store holds the new module");
assert!(safe_mode::disabled().is_empty(), "the module the store holds is turned on");
let saved = std::fs::read(folder.0.join(crate::store::DISABLED_FILE)).unwrap();
assert!(safe_mode::parse_disabled_file(&saved).is_empty(), "nothing disabled is saved");
std::fs::remove_dir_all(&grants).unwrap();
std::fs::write(&grants, &recorded).unwrap();
registry::remove(id);
registry::revoke(id);
let mut next = session();
next.attach_plugin_store(folder.0.clone());
assert!(registry::get(id).is_none(), "the old grant doesn't cover the new module");
safe_mode::set_disabled(vec![]);
}
/// An update whose grant can't be written, and whose put-back writes the old module and then
/// reports a failure, leaves the old module in the store: a disabled plug-in stays disabled, and the
/// next start doesn't load it.
#[test]
fn an_update_put_back_that_fails_after_writing_keeps_a_disabled_plugin_off() {
if !in_own_process("an_update_put_back_that_fails_after_writing_keeps_a_disabled_plugin_off") {
return;
}
use vectorcraft_plugins::{registry, safe_mode};
let folder = crate::tests_links::Folder::new("plugin-store-put-back-fails-after-write");
let id = "test.engine.permissions.putbacklate";
let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []}));
let changed = asking(id, r#"["doc.read","doc.write"]"#, &json!({"objects": []}));
let mut s = session();
s.attach_plugin_store(folder.0.clone());
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&first), "grant": ["doc.read"]}));
run(&mut s, "plugin.setEnabled", json!({"id": id, "enabled": false}));
let disabled = safe_mode::disabled();
assert!(!disabled.is_empty());
let grants = folder.0.join("grants.json");
let recorded = std::fs::read(&grants).unwrap();
std::fs::remove_file(&grants).unwrap();
std::fs::create_dir_all(grants.join("block")).unwrap();
crate::store::PUT_BACK_FAILS_AFTER_WRITE.set(true);
let update = json!({"dataBase64": vectorcraft_format::base64_encode(&changed), "grant": ["doc.read", "doc.write"], "replace": true});
let installed = s.execute("plugin.install", &update);
crate::store::PUT_BACK_FAILS_AFTER_WRITE.set(false);
installed.unwrap();
assert_eq!(std::fs::read(s.stored_plugin_file(id).unwrap()).unwrap(), first, "the store holds the old module");
assert_eq!(safe_mode::disabled(), disabled, "the plug-in stays disabled");
let saved = std::fs::read(folder.0.join(crate::store::DISABLED_FILE)).unwrap();
assert!(!safe_mode::parse_disabled_file(&saved).is_empty(), "the disabled list on disk is kept");
std::fs::remove_dir_all(&grants).unwrap();
std::fs::write(&grants, &recorded).unwrap();
registry::remove(id);
registry::revoke(id);
let mut next = session();
next.attach_plugin_store(folder.0.clone());
assert!(registry::get(id).is_none(), "the disabled old module doesn't load");
safe_mode::set_disabled(vec![]);
}
/// A module without permissions whose install can't remove the old grant, and whose put-back writes
/// the old module and then reports a failure, leaves the old module in the store: a disabled plug-in
/// stays disabled.
#[test]
fn a_plain_update_put_back_that_fails_after_writing_keeps_a_disabled_plugin_off() {
if !in_own_process("a_plain_update_put_back_that_fails_after_writing_keeps_a_disabled_plugin_off") {
return;
}
use vectorcraft_plugins::safe_mode;
let folder = crate::tests_links::Folder::new("plugin-store-plain-put-back-fails-after-write");
let id = "test.engine.permissions.plainputbacklate";
let empty = json!({"objects": []});
let approved = asking(id, r#"["doc.read"]"#, &empty);
let plain = asking(id, "null", &empty);
let grants = folder.0.join("grants.json");
let mut s = session();
s.attach_plugin_store(folder.0.clone());
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&approved), "grant": ["doc.read"]}));
run(&mut s, "plugin.setEnabled", json!({"id": id, "enabled": false}));
let disabled = safe_mode::disabled();
assert!(!disabled.is_empty());
let file = s.stored_plugin_file(id).unwrap();
// `1e9` is written back as `1000000000.0`, which makes the rewritten file larger than the 1 MiB that is read.
let entry = serde_json::from_slice::<Vec<Value>>(&std::fs::read(&grants).unwrap()).unwrap().remove(0);
let full = format!(r#"[{entry},{{"n":[{}]}}]"#, vec!["1e9"; 200_000].join(",")).into_bytes();
std::fs::write(&grants, &full).unwrap();
crate::store::PUT_BACK_FAILS_AFTER_WRITE.set(true);
let installed = s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&plain), "replace": true}));
crate::store::PUT_BACK_FAILS_AFTER_WRITE.set(false);
installed.unwrap();
assert_eq!(std::fs::read(&file).unwrap(), approved, "the store holds the old module");
assert_eq!(safe_mode::disabled(), disabled, "the plug-in stays disabled");
let saved = std::fs::read(folder.0.join(crate::store::DISABLED_FILE)).unwrap();
assert!(!safe_mode::parse_disabled_file(&saved).is_empty(), "the disabled list on disk is kept");
std::fs::remove_file(&grants).unwrap();
run(&mut s, "plugin.remove", json!({"id": id}));
safe_mode::set_disabled(vec![]);
}
/// A first install whose grant can't be written, and whose module can't be taken out of the store
/// again, stays in the store: the plug-in's file is turned on. No grant covers the module, so the
/// next start skips it until it is approved.
#[test]
fn a_first_install_left_in_the_store_by_a_failed_put_back_turns_its_file_on() {
if !in_own_process("a_first_install_left_in_the_store_by_a_failed_put_back_turns_its_file_on") {
return;
}
use vectorcraft_plugins::{registry, safe_mode};
let folder = crate::tests_links::Folder::new("plugin-store-failed-put-back-first");
let id = "test.engine.permissions.putbackfirst";
let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []}));
let mut s = session();
s.attach_plugin_store(folder.0.clone());
let module = s.stored_plugin_file(id).unwrap();
run(&mut s, "plugin.setEnabled", json!({"file": module, "enabled": false}));
let grants = folder.0.join("grants.json");
std::fs::create_dir_all(grants.join("block")).unwrap();
crate::store::PUT_BACK_FAILS.set(true);
let installed = s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&first), "grant": ["doc.read"]}));
crate::store::PUT_BACK_FAILS.set(false);
installed.unwrap();
assert!(registry::get(id).is_some(), "the module runs in this session");
assert_eq!(std::fs::read(&module).unwrap(), first, "the store holds the module");
assert!(safe_mode::disabled().is_empty(), "the file the store holds is turned on");
let saved = std::fs::read(folder.0.join(crate::store::DISABLED_FILE)).unwrap();
assert!(safe_mode::parse_disabled_file(&saved).is_empty(), "nothing disabled is saved");
std::fs::remove_dir_all(&grants).unwrap();
registry::remove(id);
registry::revoke(id);
let mut next = session();
next.attach_plugin_store(folder.0.clone());
assert!(registry::get(id).is_none(), "no grant covers the module");
safe_mode::set_disabled(vec![]);
}
/// A plug-in folder reports a changed module as needing approval and still loads the other files;
/// granted on its own, the changed module loads from the folder. A session without a store uses the
/// grants held by the process.
+37 -1
View File
@@ -55,6 +55,42 @@ fn file_constructor_identity_and_folder_path() {
let _ = std::fs::remove_dir_all(base);
}
/// `exists` is true only when the path names an entry of the object's own kind.
#[test]
fn script_file_exists_checks_the_kind() {
let base = folder("kind");
write(&base.join("main/data.txt"), b"x");
std::fs::create_dir_all(base.join("main/sub")).unwrap();
let log =
logged(&base, "vc.log([new File('sub').exists, new Folder('data.txt').exists, new File('data.txt').exists, new Folder('sub').exists]);");
assert_eq!(log, json!([[false, false, true, true]]));
let _ = std::fs::remove_dir_all(base);
}
/// A property a script puts on `Object.prototype` is not read as part of what the app returns.
#[test]
fn script_file_ignores_inherited_result_fields() {
let base = folder("inherited");
write(&base.join("main/data.txt"), b"data");
let log = logged(
&base,
"Object.prototype.error = 'inherited'; Object.prototype.exists = true;\n\
var f = new File('data.txt'); var opened = f.open('r');\n\
vc.log([f.exists, new File('nope').exists, opened, f.error, f.read()]);",
);
assert_eq!(log, json!([[true, false, true, "", "data"]]));
let _ = std::fs::remove_dir_all(base);
}
/// `File.encode`, `File.decode`, `File.isEncodingAvailable` and the `Folder` locations are counted as
/// calls to the app.
#[test]
fn script_file_class_functions_are_counted() {
let source = "File.encode('x'); File.decode('y'); Folder.isEncodingAvailable('UTF-8'); Folder.temp;";
let r = Session::new().execute("script.run", &json!({"source": source})).unwrap();
assert_eq!(r["hostCallCounts"]["fileCalls"], 4, "{r}");
}
#[test]
fn file_names_are_uri_encoded_and_current_relative() {
let base = folder("names");
@@ -74,7 +110,7 @@ fn file_names_are_uri_encoded_and_current_relative() {
assert_eq!(
log,
json!([
["caf%C3%A9%20x.txt", "caf\u{e9} x.txt", "caf%C3%A9%20x.txt", true, "%C3%A9%20%2F", "\u{e9}", "sub/y.txt", true, true],
["caf%C3%A9%20x.txt", "caf\u{e9} x.txt", "caf%C3%A9%20x.txt", true, "%C3%A9%20/", "\u{e9}", "sub/y.txt", true, true],
["sub", "w", "w"],
["literal%20name.txt", "literal%2520name.txt", "literal%2520name.txt", true, "p%41", "p%41"]
])
+3 -3
View File
@@ -221,9 +221,9 @@ fn new_width_point_adjust_adjoining_uses_sampled_widths() {
let (mut s, id) = line_session();
let original = vec![(0.0, 9.0, 9.0), (0.25, 1.0, 0.0), (0.75, 3.0, 0.0), (1.0, 7.0, 7.0)];
s.execute("stroke.widthProfile.set", &json!({"ids": [id.0], "points": original})).unwrap();
// At t=0.5 the old left side is where the profile's curve is there (#5714) and the right
// side is 0pt. Indexed editing of that sampled point is the existing convention: scale the
// nonzero side, add from zero.
// At t=0.5 the old left side is where the profile's curve is there and the right side is
// 0pt. Indexed editing of that sampled point is the existing convention: scale the nonzero
// side, add from zero.
let sampled = vectorcraft_doc::WidthProfile { points: original }.at(0.5).0;
let index = if indexed { Some(set(&mut s, json!({"id": id.0, "t": 0.5, "left": sampled * 5.0, "right": 0}))) } else { None };
set(&mut s, json!({"id": id.0, "index": index, "t": 0.5, "left": 20, "right": 5, "adjustAdjoining": true}));
+68 -1
View File
@@ -1,4 +1,4 @@
//! Untrusted files never crash the app: garbage, truncated, mutated and hostile SVG, PDF and DXF input,
//! Untrusted files never crash the app: garbage, truncated, mutated and hostile SVG, PDF, DXF and HP-GL input,
//! mutated raster images (Photoshop documents and JPEG XL images among them) placed with File → Place, and swatch (`.vcswatches`, `.gpl`, `.ase`), graphic style (`.vcstyles`), flattener preset
//! (`.vcflattener`) and export preset (`.vcexportpresets`) libraries, shortcut sets and action sets other apps save as text (`.kys`, `.aia`), and native files (compressed, damaged, saved for older versions),
//! must load as an error or as a document that then renders and exports, without a panic; nor may
@@ -2935,6 +2935,73 @@ proptest! {
}
}
// ---------- HP-GL plot files: opened and placed ----------
/// A plot file with every kind of instruction the reader plays, printer escapes around it.
const HPGL_SAMPLE: &[u8] = b"\x1b%-12345X@PJL ENTER LANGUAGE=HPGL2\r\n\x1bE\x1b%0BIN;BP1,\"job\";CO\"sample\";NP16;PC9,10,20,30;CR0,100,0,100,0,100;\
SP1;PW0.5;WU0;LA1,4,2,4,3,7;LT2,5,1;PA0,0;PD1000,0,1000,1000;PU;LT;PR;PD-500,0,0,-500;PA2000,2000;CI300,5;AA2000,2500,120;AR0,100,-90;\
AT2500,2500,3000,2000;RT100,100,200,0;BZ0,100,100,100,100,0;BR10,10,20,10,30,0;UL3,1,2,1;LT-3,2;EA3000,3000;ER100,-100;FT10,40;RA3500,3500;\
RR200,200;WG400,30,120;EW-400,0,360;PM0;PD4000,4000,4500,4000,4500,4500;PM1;PU4100,4100;PD4200,4100,4200,4200;PM2;FP1;EP;FT3,10,45;FP;\
IP0,0,10160,7620;SC0,100,0,100,1,20,80;PU10,10;PD20,20;SC0,10,0,10,2;PD5,5;SC;RO90;PD100,100;RO;IR10,10,90,90;IW0,0,5000,5000;\
DT*,1;LBlabel*SMx;PA0,0;SM;PE:\xc1<=\xbf\xc0\xc2\xc2;LB\x03PG;\x1b%0A\x1b*r1A\x1b*b4W\x01\x02\x03\x04\x1b*rC\x1b%1BPD1,1;\x1b%-12345X";
/// The sample the mutations start from opens as a plot with art.
#[test]
fn the_hpgl_sample_opens() {
let l = vectorcraft_engine::cmd::fileio::load("x.plt", HPGL_SAMPLE).unwrap();
assert_eq!(l.format.id, "hpgl");
let mut paths = 0;
l.doc.walk(|n| paths += usize::from(matches!(n.kind, vectorcraft_doc::NodeKind::Path { .. })));
assert!(paths > 15, "{paths} paths, warnings {:?}", l.warnings);
}
/// A random instruction: a mnemonic the reader plays (or one it doesn't) with hostile numbers.
fn arb_hpgl_instruction() -> impl Strategy<Value = String> {
let op = prop::sample::select(vec![
"IN", "DF", "PU", "PD", "PA", "PR", "SP", "PW", "WU", "NP", "PC", "CR", "LT", "UL", "LA", "FT", "SC", "IP", "IR", "RO", "CI", "AA", "AR",
"AT", "RT", "BZ", "BR", "EA", "ER", "RA", "RR", "EW", "WG", "PM", "FP", "EP", "LB", "DT", "SM", "CO", "PG", "IW", "PE", "ZZ",
]);
let number = prop_oneof![
Just("0".to_string()),
Just("-1".to_string()),
(-3000i32..3000).prop_map(|v| v.to_string()),
Just("99999999999999999999999".to_string()),
Just("0.000001".to_string()),
Just("32767".to_string()),
Just("-32768".to_string()),
Just("360".to_string()),
];
(op, prop::collection::vec(number, 0..8)).prop_map(|(o, n)| format!("{o}{};", n.join(",")))
}
proptest! {
#![proptest_config(config())]
#[test]
fn random_hpgl_instructions_never_panic(instructions in prop::collection::vec(arb_hpgl_instruction(), 0..40)) {
let bytes = format!("IN;SP1;{}", instructions.concat()).into_bytes();
survive_metafile("HP-GL instructions", "x.plt", &bytes)?;
}
#[test]
fn mutated_hpgl_files_never_panic(cut in 0usize..1200, edits in prop::collection::vec((0usize..1200, any::<u8>()), 0..16)) {
let mut bytes = HPGL_SAMPLE.to_vec();
let n = bytes.len();
for &(at, b) in &edits {
bytes[at % n] = b;
}
bytes.truncate(cut.max(4));
survive_metafile("mutated HP-GL", "x.plt", &bytes)?;
}
#[test]
fn hpgl_garbage_never_panics(bytes in prop::collection::vec(any::<u8>(), 0..400)) {
let mut b = b"IN;SP1;PD".to_vec();
b.extend(bytes);
survive_metafile("HP-GL garbage", "x.hpgl", &b)?;
}
}
// ---------- print presets files ----------
/// Import print presets from `data`, then lay out (and print, when short) the rich document with