mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 10:45:45 +00:00
Merge main into feat/plugin-importers
This commit is contained in:
@@ -144,7 +144,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Make",
|
||||
["Object", "Image Trace"],
|
||||
None,
|
||||
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), savedPreset?: {settings} (a saved preset's settings, which the journal records so a replay traces with them after the preset is changed, renamed or deleted; used in place of the saved preset `preset` names), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, white is always its own colour, at most ten; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white unless ignoreWhite makes them holes; fills/strokes are ignored; gradients and photos are refused)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at)}",
|
||||
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), savedPreset?: {settings} (a saved preset's settings, which the journal records so a replay traces with them after the preset is changed, renamed or deleted; used in place of the saved preset `preset` names), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, at most ten; white is its own color, and a named color with every channel 245 or more is taken as that white, except with logoColors named, logoWhite false and no ignoreWhite; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white while white is its own color, and stay holes with ignoreWhite or without a white of its own; fills/strokes are ignored; gradients and photos are refused), logoWhite: true (mode logo with logoColors named; false traces the named colors only: white areas take the nearest of them, a named white is kept and enclosed transparent areas stay holes; with ignoreWhite, white areas are left out either way)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at)}",
|
||||
has_selection_or_ids,
|
||||
|s, p| trace_make(s, p, false)
|
||||
),
|
||||
|
||||
@@ -41,6 +41,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?, ids?: [id…] (list just these nodes, of any level, in this order, under `nodes` in place of `layers`; an unknown id is an error; at most 10000), kind?: panel label such as Layer; Group; Path; Type or Image (exact, ignoring case; list every such node under `nodes`, each with `path`, its ancestor ids with the layer first, searching the subtrees of `ids` when given), limit?: at most this many entries of the list, offset?: entries skipped first (default 0; with `ids`, `kind`, `limit` or `offset` the reply adds `total`, the entries before paging)} → layer tree (each node: id, name, kind, visible, locked, bounds, opacity (0–1) and opacityPct (0–100 %, the scale object.setProps takes) when below 1, blend when not Normal, fill, stroke and strokeWidth of objects and compound paths; a compound path's members have compoundMember: true instead of paint, which is the compound's), artboards, selection, history (a sliced tree reports childCount); without the scope options the reply is the whole layer tree", has_doc, doc_inspect),
|
||||
cmd!(query "document.node", "Inspect Object", [], None, "{id, summary?: compact summary, depth?: child levels in the summary (default all), childLimit?: children shown per node (default all; a level that shows fewer reports childCount)} → one object", has_doc, doc_node),
|
||||
cmd!(query "document.find", "Find Objects", [], None, "{name?: substring of the Layers panel name, kind?: panel label such as Group; Path; Type or Image (exact), text?: substring of type content, within?: [x, y, width, height] (an object's geometric bounds inside this rectangle, edges included; document points as document.inspect's bounds; layers never match this or the size filters), intersects?: [x, y, width, height] (bounds touching or overlapping it), minWidth?, maxWidth?, minHeight?, maxHeight?: pt (the bounds' size), minThickness?, maxThickness?: pt (the smaller of the bounds' width and height), minArea?, maxArea?: pt² (the bounds' width × height), minAnchors?, maxAnchors?: anchor points of a path (a compound path's members together; other objects never match), select?: bool (also select the matches, leaving out hidden and locked ones, those inside another match, and a compound path's members unless the compound path is isolated; Select > Reselect repeats it), limit?: max matches listed (default 100, 0 counts only)} → {matches: [{id, name, kind, path}], total, selected (with select: the count selected)}; at least one filter, all of them matching", has_doc, doc_find),
|
||||
cmd!(
|
||||
query "document.compare",
|
||||
"Compare with Image",
|
||||
[],
|
||||
None,
|
||||
"{path: an image file (PNG, JPEG, WebP, TIFF or BMP), artboard?: index=0, background?: \"#rrggbb\"=\"#ffffff\" (the render and the image are both flattened on it), threshold?: 0.08 (0–1), diffPath?: a PNG to write (each pixel's absolute R, G and B differences)} render the artboard at the image's size (the image's width in pixels over the artboard's width in points), drawn as document.export draws a PNG (linked images and placed documents from their files), and compare it with the image; an image whose height differs by more than a pixel from the artboard's height at that scale is refused (not in the browser) → {width, height, rmse: the root mean square of the R, G and B differences, 0–1 (6 decimals), over: the pixels with a channel that differs by more than threshold, total: the pixels, threshold, diffPath?, warnings?: as a PNG export's (type that doesn't fit its frame, an image that couldn't be decoded, type in a font or style that isn't available, previews drawn because a linked file can't be read)}",
|
||||
has_doc,
|
||||
doc_compare
|
||||
),
|
||||
cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model", has_doc, |s, _| Ok(serde_json::to_value(&*s.doc()?.doc).unwrap_or(Value::Null))),
|
||||
cmd!(
|
||||
"document.setUnits",
|
||||
@@ -385,6 +394,155 @@ fn select_found(s: &mut Session, p: &Value, ids: &[NodeId]) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Render an artboard at an image's size, drawn as a PNG export draws it (linked images and placed
|
||||
/// documents from their files, with the export's warnings), and measure how far the
|
||||
/// two are apart: both flattened on `background`, the root mean square of the R, G and B
|
||||
/// differences (0–1), and the pixels with a channel that differs by more than `threshold`.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn doc_compare(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use vectorcraft_render::encode::{RasterExportOptions, RasterFormat, png};
|
||||
const C: &str = "document.compare";
|
||||
let path = str_param(p, "path").ok_or_else(|| bad(C, "missing `path`"))?;
|
||||
let diff_path = match p.get("diffPath") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(v)) => Some(v.as_str()),
|
||||
Some(_) => {
|
||||
let key = "diffPath";
|
||||
return Err(bad(C, format!("`{key}` must be a string")));
|
||||
}
|
||||
};
|
||||
let background = match p.get("background") {
|
||||
None | Some(Value::Null) => [255; 3],
|
||||
Some(v) => match super::fileio::background(v) {
|
||||
Ok(Some(rgb)) => rgb,
|
||||
_ => return Err(bad(C, format!("background {v}: a color such as \"#808080\""))),
|
||||
},
|
||||
};
|
||||
let threshold = match p.get("threshold") {
|
||||
None | Some(Value::Null) => 0.08,
|
||||
Some(v) => v.as_f64().filter(|t| (0.0..=1.0).contains(t)).ok_or_else(|| bad(C, "`threshold` must be a number from 0 to 1"))?,
|
||||
};
|
||||
let doc = &s.doc()?.doc;
|
||||
let count = doc.artboards.len();
|
||||
let i = opt_u64(p, C, "artboard")?.unwrap_or(0);
|
||||
let region = usize::try_from(i)
|
||||
.ok()
|
||||
.and_then(|i| doc.artboards.get(i))
|
||||
.map(|a| a.rect)
|
||||
.ok_or_else(|| bad(C, format!("no artboard {i} (0-based; the document has {count})")))?;
|
||||
if let Some(d) = diff_path {
|
||||
crate::file_access::check_write(d).map_err(EngineError::Other)?;
|
||||
}
|
||||
let image = compare_image(path)?;
|
||||
let (iw, ih) = image.dimensions();
|
||||
// The image's width sets the scale; its height may differ from the artboard's at that scale by
|
||||
// the pixel the rounding of a render takes.
|
||||
let scale = f64::from(iw) / region.width();
|
||||
let rendered_height = region.height() * scale;
|
||||
if iw == 0 || ih == 0 || !rendered_height.is_finite() || (rendered_height - f64::from(ih)).abs() > 1.0 {
|
||||
let (rw, rh) = (iw, rendered_height.round());
|
||||
return Err(bad(
|
||||
C,
|
||||
format!("the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ"),
|
||||
));
|
||||
}
|
||||
vectorcraft_render::raster_size(region, scale).map_err(|e| bad(C, e))?;
|
||||
vectorcraft_render::raster_size(vectorcraft_geom::Rect::new(0.0, 0.0, f64::from(iw), f64::from(ih)), 1.0).map_err(|e| bad(C, e))?;
|
||||
let view = Affine::scale(scale) * Affine::translate((-region.x0, -region.y0));
|
||||
let opts = RasterExportOptions { background: Some(background), ..Default::default() }.render_options(RasterFormat::Png);
|
||||
// As a PNG export: linked images and placed documents drawn from their files, not the
|
||||
// previews saved for them.
|
||||
let (full, linked) = super::place::document::full_documents(doc);
|
||||
let failed = vectorcraft_render::decode_failures();
|
||||
let rendered = vectorcraft_render::Renderer::new().render(&full, iw, ih, view, &opts);
|
||||
// A canceled render stops drawing: the picture is not whole.
|
||||
vectorcraft_doc::cancel::check()?;
|
||||
// As a PNG export says, in its order: type on the artboard that doesn't fit its frame, an image
|
||||
// the renderer couldn't decode (missing from the render), type in a missing font or style (drawn
|
||||
// in another one), and linked files drawn from their previews.
|
||||
let mut warnings: Vec<String> = super::fonts::export_overflow_warning(&full, &full.layers, Some(&[region])).into_iter().collect();
|
||||
if let Some(w) = super::rasterfx::undecoded_warning(failed) {
|
||||
warnings.push(w.into());
|
||||
}
|
||||
if let Some(w) = super::fonts::substitution_warning(&full) {
|
||||
warnings.push(w);
|
||||
}
|
||||
warnings.extend(linked);
|
||||
let rendered = rendered.to_straight();
|
||||
let flat = |px: &[u8; 4]| {
|
||||
let a = u32::from(px[3]);
|
||||
[0, 1, 2].map(|k| ((u32::from(px[k]) * a + u32::from(background[k]) * (255 - a) + 127) / 255) as u8)
|
||||
};
|
||||
let row = iw as usize * 4;
|
||||
let limit = threshold * 255.0;
|
||||
let (mut squares, mut over) = (0.0f64, 0u64);
|
||||
let mut diff = diff_path.map(|_| Vec::with_capacity(row * ih as usize));
|
||||
for (a, b) in rendered.chunks_exact(row).zip(image.as_raw().chunks_exact(row)) {
|
||||
vectorcraft_doc::cancel::check()?;
|
||||
for (pa, pb) in a.as_chunks::<4>().0.iter().zip(b.as_chunks::<4>().0) {
|
||||
let (fa, fb) = (flat(pa), flat(pb));
|
||||
let d = [0, 1, 2].map(|k| fa[k].abs_diff(fb[k]));
|
||||
squares += d.iter().map(|&x| f64::from(x) * f64::from(x)).sum::<f64>();
|
||||
if d.iter().any(|&x| f64::from(x) > limit) {
|
||||
over += 1;
|
||||
}
|
||||
if let Some(out) = &mut diff {
|
||||
out.extend_from_slice(&[d[0], d[1], d[2], 255]);
|
||||
}
|
||||
}
|
||||
}
|
||||
let total = u64::from(iw) * u64::from(ih);
|
||||
let rmse = (squares / (total as f64 * 3.0)).sqrt() / 255.0;
|
||||
let mut out = json!({
|
||||
"width": iw,
|
||||
"height": ih,
|
||||
"rmse": (rmse * 1e6).round() / 1e6,
|
||||
"over": over,
|
||||
"total": total,
|
||||
"threshold": threshold,
|
||||
});
|
||||
if let (Some(d), Some(pixels)) = (diff_path, diff) {
|
||||
let bytes = png::encode(&pixels, iw, ih, &png::PngOptions { ppi: None, interlaced: false }).map_err(EngineError::Other)?;
|
||||
super::fileio::write_file(d, &bytes)?;
|
||||
out["diffPath"] = json!(d);
|
||||
}
|
||||
if !warnings.is_empty() {
|
||||
out["warnings"] = json!(warnings);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The image `document.compare` reads from `path` (through the automation read root), as straight
|
||||
/// RGBA: a PNG, JPEG, WebP, TIFF or BMP within the allocation an opened image may take.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn compare_image(path: &str) -> Result<image::RgbaImage> {
|
||||
use image::ImageDecoder as _;
|
||||
const C: &str = "document.compare";
|
||||
let bytes = super::fileio::read_file(path)?;
|
||||
if let Some((w, h)) = vectorcraft_doc::cmyk::oversized_tiff(&bytes) {
|
||||
return Err(bad(C, format!("an image of {w} × {h} pixels is too large to draw")));
|
||||
}
|
||||
let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().map_err(|e| bad(C, format!("{path}: {e}")))?;
|
||||
let readable = [image::ImageFormat::Png, image::ImageFormat::Jpeg, image::ImageFormat::WebP, image::ImageFormat::Tiff, image::ImageFormat::Bmp];
|
||||
if !reader.format().is_some_and(|f| readable.contains(&f)) {
|
||||
return Err(bad(C, format!("{path}: not a PNG, JPEG, WebP, TIFF or BMP image")));
|
||||
}
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_alloc = Some(vectorcraft_doc::MAX_RASTER_ALLOC);
|
||||
reader.limits(limits);
|
||||
let decoder = reader.into_decoder().map_err(|e| bad(C, format!("{path}: {e}")))?;
|
||||
let (w, h) = decoder.dimensions();
|
||||
if decoder.total_bytes() > vectorcraft_doc::MAX_RASTER_ALLOC || u64::from(w) * u64::from(h) * 4 > vectorcraft_doc::MAX_RASTER_ALLOC {
|
||||
return Err(bad(C, format!("an image of {w} × {h} pixels is too large to draw")));
|
||||
}
|
||||
Ok(image::DynamicImage::from_decoder(decoder).map_err(|e| bad(C, format!("{path}: {e}")))?.to_rgba8())
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn doc_compare(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
Err(bad("document.compare", "comparing with an image file isn't available in the browser"))
|
||||
}
|
||||
|
||||
/// Document Setup's units (also `document.setup {units}`).
|
||||
fn set_units(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let u = str_param(p, "units").and_then(Unit::named).ok_or_else(|| bad("document.setUnits", "unknown units"))?;
|
||||
|
||||
@@ -31,6 +31,7 @@ mod colorize;
|
||||
pub(super) mod colormgmt;
|
||||
mod colorthemes;
|
||||
mod colourfonts;
|
||||
mod compare;
|
||||
mod compound_shape;
|
||||
mod compoundpaint;
|
||||
mod constrain_new;
|
||||
|
||||
@@ -3825,6 +3825,43 @@ fn image_trace_flat_logo_refuses_a_gradient_and_a_bad_palette() {
|
||||
assert_eq!(r["colors"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_trace_flat_logo_can_trace_only_the_named_colors() {
|
||||
// A light disc in a black ring, a blue square on it, and a white spot.
|
||||
let art = vectorcraft_trace::Raster::from_fn(120, 120, |x, y| {
|
||||
let (fx, fy) = (f64::from(x) + 0.5, f64::from(y) + 0.5);
|
||||
let d = (fx - 60.0).hypot(fy - 60.0);
|
||||
if (fx - 32.0).hypot(fy - 40.0) <= 6.0 {
|
||||
[255, 255, 255, 255]
|
||||
} else if (45..75).contains(&x) && (45..75).contains(&y) {
|
||||
[40, 120, 240, 255]
|
||||
} else if d <= 46.0 {
|
||||
[240, 240, 220, 255]
|
||||
} else if d <= 50.0 {
|
||||
[0, 0, 0, 255]
|
||||
} else {
|
||||
[0, 0, 0, 0]
|
||||
}
|
||||
});
|
||||
let fills = |logo_white: bool| {
|
||||
let mut s = session();
|
||||
add_image(&mut s, &art, Affine::IDENTITY);
|
||||
let params = json!({"params": {"mode": "logo", "logoColors": ["#000000", "#F0F0DC", "#2878F0"], "logoWhite": logo_white}});
|
||||
let r = s.execute("imageTrace.makeAndExpand", ¶ms).unwrap();
|
||||
let g = node(&s, NodeId(r["id"].as_u64().unwrap()));
|
||||
let mut hex: Vec<String> = g.children().unwrap().iter().filter_map(|n| n.appearance.fill_paint().color()).map(|c| c.to_hex()).collect();
|
||||
hex.sort();
|
||||
hex.dedup();
|
||||
(r["colors"].clone(), hex)
|
||||
};
|
||||
let (colors, hex) = fills(true);
|
||||
assert_eq!(colors, 4);
|
||||
assert!(hex.contains(&"#ffffff".to_string()), "{hex:?}");
|
||||
let (colors, hex) = fills(false);
|
||||
assert_eq!(colors, 3);
|
||||
assert_eq!(hex, ["#000000", "#2878f0", "#f0f0dc"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_trace_object_remembers_its_settings() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
//! `document.compare`: an artboard rendered at an image's size, as a PNG export draws it, and
|
||||
//! measured against the image (RMSE, the pixels over a threshold, a difference image), with the
|
||||
//! image and the render flattened on one background; images of other proportions, files that
|
||||
//! can't be read and paths outside the automation roots are errors.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// A 40 × 20 pt document with a black 10 × 10 pt square at (10, 5), no stroke.
|
||||
fn session() -> (Session, Value) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 40, "height": 20})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": "#000000"})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
|
||||
let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 5, "width": 10, "height": 10})).unwrap()["id"].clone();
|
||||
(s, id)
|
||||
}
|
||||
|
||||
fn path_in(dir: &std::path::Path, name: &str) -> String {
|
||||
dir.join(name).to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
/// The artboard exported as a PNG at 2 pixels a point on white.
|
||||
fn export(s: &mut Session, path: &str) {
|
||||
s.execute("document.export", &json!({"path": path, "format": "png", "scale": 2, "background": "white"})).unwrap();
|
||||
}
|
||||
|
||||
fn compare(s: &mut Session, p: Value) -> Value {
|
||||
s.execute("document.compare", &p).unwrap_or_else(|e| panic!("document.compare {p}: {e}"))
|
||||
}
|
||||
|
||||
fn error(s: &mut Session, p: Value) -> String {
|
||||
s.execute("document.compare", &p).map(|v| panic!("document.compare {p} answered {v}")).unwrap_err().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_export_compares_equal_and_a_moved_square_counts_the_pixels_it_changed() {
|
||||
let dir = vectorcraft_testkit::temp_dir("compare-moved");
|
||||
let png = path_in(&dir, "art.png");
|
||||
let (mut s, id) = session();
|
||||
export(&mut s, &png);
|
||||
let before = (s.doc().unwrap().revision, s.doc().unwrap().history.undo.len());
|
||||
let r = compare(&mut s, json!({"path": png}));
|
||||
assert_eq!(r, json!({"width": 80, "height": 40, "rmse": 0.0, "over": 0, "total": 3200, "threshold": 0.08}));
|
||||
assert_eq!((s.doc().unwrap().revision, s.doc().unwrap().history.undo.len()), before, "comparing doesn't edit the document");
|
||||
// 4 pt is 8 pixels: 8 columns of the square's 20 rows turn white and 8 turn black, every
|
||||
// channel by 255.
|
||||
s.execute("object.move", &json!({"ids": [id], "dx": 4, "dy": 0})).unwrap();
|
||||
let r = compare(&mut s, json!({"path": png}));
|
||||
let over = 2 * 8 * 20;
|
||||
assert_eq!((r["over"].as_u64(), r["total"].as_u64()), (Some(over), Some(3200)), "{r}");
|
||||
let rmse = ((over as f64 / 3200.0).sqrt() * 1e6).round() / 1e6;
|
||||
assert_eq!(r["rmse"].as_f64(), Some(rmse), "{r}");
|
||||
// Above every difference, nothing is over.
|
||||
assert_eq!(compare(&mut s, json!({"path": png, "threshold": 1}))["over"], json!(0));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_image_and_the_render_are_flattened_on_the_background() {
|
||||
let dir = vectorcraft_testkit::temp_dir("compare-background");
|
||||
let png = path_in(&dir, "clear.png");
|
||||
// Transparent everywhere, with red in the color channels that the alpha hides.
|
||||
image::RgbaImage::from_pixel(80, 40, image::Rgba([255, 0, 0, 0])).save(&png).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 40, "height": 20})).unwrap();
|
||||
// Nothing drawn: both are the background alone.
|
||||
for background in ["#808080", "#ffffff"] {
|
||||
let r = compare(&mut s, json!({"path": png, "background": background}));
|
||||
assert_eq!((r["rmse"].as_f64(), r["over"].as_u64()), (Some(0.0), Some(0)), "{background}: {r}");
|
||||
}
|
||||
// A black left half: 128 from gray in every channel of half the pixels, 255 from white.
|
||||
s.execute("paint.setFill", &json!({"color": "#000000"})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap();
|
||||
let r = compare(&mut s, json!({"path": png, "background": "#808080"}));
|
||||
let rmse = |d: f64| ((0.5f64).sqrt() * d / 255.0 * 1e6).round() / 1e6;
|
||||
assert_eq!((r["rmse"].as_f64(), r["over"].as_u64()), (Some(rmse(128.0)), Some(1600)), "{r}");
|
||||
let r = compare(&mut s, json!({"path": png}));
|
||||
assert_eq!((r["rmse"].as_f64(), r["over"].as_u64()), (Some(rmse(255.0)), Some(1600)), "{r}");
|
||||
// A threshold just above 128/255 leaves the gray differences out.
|
||||
assert_eq!(compare(&mut s, json!({"path": png, "background": "#808080", "threshold": 0.51}))["over"], json!(0));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_of_other_proportions_is_refused_and_one_pixel_is_allowed() {
|
||||
let dir = vectorcraft_testkit::temp_dir("compare-size");
|
||||
let (mut s, _) = session();
|
||||
let square = path_in(&dir, "square.png");
|
||||
image::RgbaImage::from_pixel(30, 30, image::Rgba([255; 4])).save(&square).unwrap();
|
||||
let e = error(&mut s, json!({"path": square}));
|
||||
assert!(e.contains("30 × 30 pixels") && e.contains("30 × 15 pixels"), "{e}");
|
||||
let taller = path_in(&dir, "taller.png");
|
||||
image::RgbaImage::from_pixel(80, 41, image::Rgba([255; 4])).save(&taller).unwrap();
|
||||
let r = compare(&mut s, json!({"path": taller}));
|
||||
assert_eq!((r["width"].as_u64(), r["height"].as_u64(), r["total"].as_u64()), (Some(80), Some(41), Some(3280)), "{r}");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_export_whose_height_was_rounded_compares_equal() {
|
||||
let dir = vectorcraft_testkit::temp_dir("compare-rounded");
|
||||
let png = path_in(&dir, "art.png");
|
||||
// 20.25 pt at 2 pixels a point is 40.5 rows, which the export rounds to 41.
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 40, "height": 20.25})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": "#000000"})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 5, "width": 10, "height": 10})).unwrap();
|
||||
export(&mut s, &png);
|
||||
let r = compare(&mut s, json!({"path": png}));
|
||||
assert_eq!(r, json!({"width": 80, "height": 41, "rmse": 0.0, "over": 0, "total": 3280, "threshold": 0.08}));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_linked_image_is_drawn_from_its_file_as_a_png_export_draws_it() {
|
||||
use crate::tests_links::{BLUE, Folder, RED, open, place, png, save, session, write};
|
||||
let dir = Folder::new("compare-linked");
|
||||
let pic = dir.file("photo.png");
|
||||
write(&pic, &png(600, 300, RED));
|
||||
let mut s = session();
|
||||
place(&mut s, &pic);
|
||||
let doc_path = dir.file("poster.vectorcraft");
|
||||
save(&mut s, &doc_path);
|
||||
// The file changes after the save: the reopened document shows the red preview saved for it,
|
||||
// and a PNG export reads the blue file.
|
||||
write(&pic, &png(600, 300, BLUE));
|
||||
open(&mut s, &doc_path);
|
||||
let out = dir.file("export.png");
|
||||
s.execute("document.export", &json!({"path": out, "format": "png", "background": "white"})).unwrap();
|
||||
let r = compare(&mut s, json!({"path": out}));
|
||||
assert_eq!((r["rmse"].as_f64(), r["over"].as_u64(), r.get("warnings")), (Some(0.0), Some(0), None), "{r}");
|
||||
// Without the file the preview is drawn, and the reply says so.
|
||||
std::fs::remove_file(&pic).unwrap();
|
||||
let r = compare(&mut s, json!({"path": out}));
|
||||
assert_eq!(r["over"].as_u64(), r["total"].as_u64(), "{r}");
|
||||
let warnings = r["warnings"].as_array().unwrap();
|
||||
assert!(warnings.len() == 1 && warnings[0].as_str().unwrap().contains("1 linked image output as its preview"), "{r}");
|
||||
// With type that doesn't fit its frame as well, the warnings come in the export's order.
|
||||
let overset =
|
||||
s.execute("text.create", &json!({"x": 10, "y": 10, "text": "Ab\nCd\nEf\nGh", "size": 12, "area": {"width": 100, "height": 20}})).unwrap();
|
||||
assert_eq!(overset["overflow"], json!([overset["id"]]), "{overset}");
|
||||
let exported = s.execute("document.export", &json!({"path": dir.file("now.png"), "format": "png", "background": "white"})).unwrap();
|
||||
let r = compare(&mut s, json!({"path": out}));
|
||||
assert_eq!(r["warnings"].as_array().map(Vec::len), Some(2), "{r}");
|
||||
assert_eq!(r["warnings"], exported["warnings"], "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_that_cannot_be_decoded_and_a_missing_font_warn_as_a_png_export_does() {
|
||||
let dir = vectorcraft_testkit::temp_dir("compare-warnings");
|
||||
let png = path_in(&dir, "art.png");
|
||||
let (mut s, _) = session();
|
||||
export(&mut s, &png);
|
||||
assert_eq!(compare(&mut s, json!({"path": png})).get("warnings"), None);
|
||||
crate::tests_menucmds::place_undecodable_png(&mut s);
|
||||
s.execute("text.create", &json!({"x": 2, "y": 18, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap();
|
||||
let overset = s.execute("text.create", &json!({"x": 22, "y": 2, "text": "Ab\nCd\nEf", "size": 6, "area": {"width": 15, "height": 8}})).unwrap();
|
||||
assert_eq!(overset["overflow"], json!([overset["id"]]), "{overset}");
|
||||
let exported = s.execute("document.export", &json!({"path": path_in(&dir, "now.png"), "format": "png", "scale": 2})).unwrap();
|
||||
let r = compare(&mut s, json!({"path": png}));
|
||||
assert_eq!(r["warnings"], exported["warnings"], "{r}");
|
||||
let warnings = r["warnings"].as_array().unwrap();
|
||||
assert!(warnings.iter().any(|w| w == "an image couldn't be decoded and is missing from the picture"), "{r}");
|
||||
assert!(warnings.iter().any(|w| w.as_str().unwrap().contains("Nonexistent Sans Probe")), "{r}");
|
||||
assert!(warnings.iter().any(|w| w.as_str().unwrap().contains("doesn't fit its frame")), "{r}");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_path_writes_each_pixels_differences() {
|
||||
let dir = vectorcraft_testkit::temp_dir("compare-diff");
|
||||
let png = path_in(&dir, "art.png");
|
||||
let diff = path_in(&dir, "diff.png");
|
||||
let (mut s, id) = session();
|
||||
export(&mut s, &png);
|
||||
s.execute("object.move", &json!({"ids": [id], "dx": 4, "dy": 0})).unwrap();
|
||||
let r = compare(&mut s, json!({"path": png, "diffPath": diff}));
|
||||
assert_eq!(r["diffPath"], json!(diff));
|
||||
let img = image::open(&diff).unwrap().to_rgba8();
|
||||
assert_eq!(img.dimensions(), (80, 40));
|
||||
// The square spanned pixels 20–39 and now spans 28–47, rows 10–29.
|
||||
for (x, y, want) in [(24, 20, 255), (44, 20, 255), (30, 20, 0), (24, 5, 0), (60, 20, 0)] {
|
||||
assert_eq!(img.get_pixel(x, y).0, [want, want, want, 255], "({x}, {y})");
|
||||
}
|
||||
let changed = img.pixels().filter(|p| p.0[0] != 0).count();
|
||||
assert_eq!(changed as u64, r["over"].as_u64().unwrap());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_unreadable_and_malformed_inputs_are_errors() {
|
||||
let dir = vectorcraft_testkit::temp_dir("compare-errors");
|
||||
let (mut s, _) = session();
|
||||
let png = path_in(&dir, "art.png");
|
||||
export(&mut s, &png);
|
||||
let missing = path_in(&dir, "missing.png");
|
||||
assert!(error(&mut s, json!({"path": missing})).contains(&missing));
|
||||
let text = path_in(&dir, "notes.png");
|
||||
std::fs::write(&text, "not an image\n").unwrap();
|
||||
assert!(error(&mut s, json!({"path": text})).contains("not a PNG, JPEG, WebP, TIFF or BMP image"));
|
||||
let gif = path_in(&dir, "art.gif");
|
||||
image::RgbaImage::from_pixel(80, 40, image::Rgba([255; 4])).save(&gif).unwrap();
|
||||
assert!(error(&mut s, json!({"path": gif})).contains("not a PNG, JPEG, WebP, TIFF or BMP image"));
|
||||
for (p, want) in [
|
||||
(json!({}), "missing `path`"),
|
||||
(json!({"path": png, "artboard": 3}), "no artboard 3"),
|
||||
(json!({"path": png, "threshold": 2}), "`threshold` must be a number from 0 to 1"),
|
||||
(json!({"path": png, "threshold": "low"}), "`threshold` must be a number from 0 to 1"),
|
||||
(json!({"path": png, "background": "transparent"}), "a color such as"),
|
||||
(json!({"path": png, "diffPath": 5}), "`diffPath` must be a string"),
|
||||
] {
|
||||
let e = error(&mut s, p.clone());
|
||||
assert!(e.contains(want), "{p}: {e}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// A planar TIFF whose header claims more strips than the decoder can count (2^31 + 1 rows a strip
|
||||
/// each, times 5 planes) is refused as too large to draw, without a panic.
|
||||
#[test]
|
||||
fn a_tiff_with_more_strips_than_can_be_counted_is_too_large() {
|
||||
let dir = vectorcraft_testkit::temp_dir("compare-strips");
|
||||
let (mut s, _) = session();
|
||||
let plane = |v: u16| vec![v; 6];
|
||||
let channels = [plane(200), plane(40), plane(10), plane(0), plane(90)];
|
||||
let t = vectorcraft_testkit::tiff::Tiff {
|
||||
width: 3,
|
||||
height: 2,
|
||||
photometric: 5,
|
||||
bits: 8,
|
||||
extra: &[0],
|
||||
planar: true,
|
||||
ppi: Some(150),
|
||||
channels: &channels,
|
||||
};
|
||||
let mut file = vectorcraft_testkit::tiff::tiff(&t);
|
||||
// ImageLength (the directory's second entry, its value at offset 30) and RowsPerStrip (the
|
||||
// eighth, at offset 102).
|
||||
file[30..34].copy_from_slice(&(1u32 << 31 | 1).to_le_bytes());
|
||||
file[102..106].copy_from_slice(&1u32.to_le_bytes());
|
||||
assert_eq!(vectorcraft_doc::cmyk::oversized_tiff(&file), Some((3, 1 << 31 | 1)));
|
||||
let tif = path_in(&dir, "strips.tif");
|
||||
std::fs::write(&tif, &file).unwrap();
|
||||
let e = error(&mut s, json!({"path": tif}));
|
||||
assert_eq!(e, "invalid parameters for `document.compare`: an image of 3 × 2147483649 pixels is too large to draw");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_image_and_the_difference_image_stay_inside_the_automation_roots() {
|
||||
let dir = vectorcraft_testkit::temp_dir("compare-roots");
|
||||
for d in ["in", "out", "elsewhere"] {
|
||||
std::fs::create_dir_all(dir.join(d)).unwrap();
|
||||
}
|
||||
let (mut s, _) = session();
|
||||
let inside = path_in(&dir.join("in"), "art.png");
|
||||
let outside = path_in(&dir.join("elsewhere"), "art.png");
|
||||
export(&mut s, &inside);
|
||||
export(&mut s, &outside);
|
||||
let roots = crate::file_access::AutomationRoots::new(&[&dir.join("in")], &[&dir.join("out")]).unwrap().unwrap();
|
||||
let mut run = |p: Value| crate::file_access::confine(Some(&roots), || s.execute("document.compare", &p));
|
||||
assert!(run(json!({"path": inside})).is_ok());
|
||||
let e = run(json!({"path": outside})).unwrap_err().to_string();
|
||||
assert!(e.contains("outside the read root"), "{e}");
|
||||
let stray = path_in(&dir.join("elsewhere"), "diff.png");
|
||||
let e = run(json!({"path": inside, "diffPath": stray})).unwrap_err().to_string();
|
||||
assert!(e.contains("outside the write root"), "{e}");
|
||||
assert!(!std::path::Path::new(&stray).exists());
|
||||
let kept = path_in(&dir.join("out"), "diff.png");
|
||||
assert_eq!(run(json!({"path": inside, "diffPath": kept})).unwrap()["diffPath"], json!(kept));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
Reference in New Issue
Block a user