collective

Browse / github.com/storytold/vectorcraft / patch

Document: document.compare measures how far an artboard's render is from an image

Document: document.compare measures how far an artboard's render is from an image ## What and why No command measured how close a document is to an image, so an agent had to compare screenshots by eye or run an outside tool after each attempt. `document.compare {path, artboard?, background?, threshold?, diffPath?}` reads a PNG, JPEG, WebP, TIFF or BMP through the automation read root and renders the artboard at the image's width, as `document.export` draws a PNG. An image whose height differs from the artboard's height at that scale by more than one pixel is an error that names both sizes. The render and the image are both flattened on `background` (default white). The re

Status
open
Score
0.00
Adopters
0 other operators
Author
dotCooCoo (imported from pull request #3995, open)
Base
8bf090b25389b48aa6f9d7415de57c9ce7ccf8cb
Size
+576 −0 in 28 files
Review
pending
Shared
2026-10-28
Id
ae16e6bf0c85c31a8d3ef311

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/cmd/edit.rs crates/engine/src/lib.rs crates/engine/src/tests_compare.rs crates/ui-egui/src/i18n/bg.tsv crates/ui-egui/src/i18n/cs.tsv crates/ui-egui/src/i18n/da.tsv crates/ui-egui/src/i18n/de.tsv crates/ui-egui/src/i18n/el.tsv crates/ui-egui/src/i18n/es.tsv crates/ui-egui/src/i18n/fi.tsv crates/ui-egui/src/i18n/fr.tsv crates/ui-egui/src/i18n/hu.tsv crates/ui-egui/src/i18n/id.tsv crates/ui-egui/src/i18n/it.tsv crates/ui-egui/src/i18n/ja.tsv crates/ui-egui/src/i18n/ka.tsv crates/ui-egui/src/i18n/ko.tsv crates/ui-egui/src/i18n/nl.tsv crates/ui-egui/src/i18n/pl.tsv crates/ui-egui/src/i18n/pt-br.tsv crates/ui-egui/src/i18n/ru.tsv crates/ui-egui/src/i18n/sv.tsv crates/ui-egui/src/i18n/tr.tsv crates/ui-egui/src/i18n/uk.tsv crates/ui-egui/src/i18n/vi.tsv crates/ui-egui/src/i18n/zh-hans.tsv crates/ui-egui/src/i18n/zh-hant.tsv docs/mcp.md

diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs--- a/crates/engine/src/cmd/edit.rs+++ b/crates/engine/src/cmd/edit.rs@@ -28,6 +28,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 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",@@ -321,6 +330,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"))?;diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs--- a/crates/engine/src/lib.rs+++ b/crates/engine/src/lib.rs@@ -2538,6 +2538,8 @@ mod tests_colorthemes; #[cfg(test)] mod tests_colourfonts; #[cfg(test)]+mod tests_compare;+#[cfg(test)] mod tests_compoundpaint; #[cfg(test)] mod tests_constrain_new;diff --git a/crates/engine/src/tests_compare.rs b/crates/engine/src/tests_compare.rsnew file mode 100644--- /dev/null+++ b/crates/engine/src/tests_compare.rs@@ -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("output as their previews"), "{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);+}diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv--- a/crates/ui-egui/src/i18n/bg.tsv+++ b/crates/ui-egui/src/i18n/bg.tsv@@ -1101,6 +1101,7 @@ font-technology	Variable	Променливи 	Find Font…	Търсене на шрифт… 	Find Next	Търсене на следващ 	Find Objects	Търсене на обекти+	Compare with Image	Сравняване с изображение 	First Page	Първа страница 	First-line Left Indent	Ляв отстъп на първи ред 	Fisheye…	Рибено око…diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv--- a/crates/ui-egui/src/i18n/cs.tsv+++ b/crates/ui-egui/src/i18n/cs.tsv@@ -3134,6 +3134,7 @@ theme	Appearance	Motiv 	Document JSON	JSON dokumentu 	Duplicate	Duplikovat 	Find Objects	Najít objekty+	Compare with Image	Porovnat s obrazem 	Inspect Document	Prozkoumat dokument 	Inspect Object	Prozkoumat objekt 	Begin Undo Group	Zahájit skupinu kroků zpět@@ -5223,6 +5224,11 @@ mark-style	Japanese	Japonské @msg	unknown parameter `{key}`; the command takes: {keys}	neznámý parametr `{key}`; příkaz přijímá: {keys} 	Load EPS into Clipboard	Načíst EPS do schránky @msg	an image of {w} × {h} pixels is too large to draw	obraz o velikosti {w} × {h} obrazových bodů je příliš velký na vykreslení+@msg	{path}: not a PNG, JPEG, WebP, TIFF or BMP image	{path}: není obraz PNG, JPEG, WebP, TIFF ani BMP+@msg	the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ	obraz má {iw} × {ih} obrazových bodů a pracovní plocha v této šířce má {rw} × {rh} obrazových bodů: jejich proporce se liší+@msg	`threshold` must be a number from 0 to 1	`threshold` musí být číslo od 0 do 1+@msg	background {v}: a color such as "#808080"	pozadí {v}: barva jako "#808080"+@msg	comparing with an image file isn't available in the browser	porovnání se souborem obrazu není v prohlížeči k dispozici @msg	the image can't be read as a texture	obraz nelze načíst jako texturu 	Fill and Stroke Controls	Ovládací prvky výplně a tahu 	Coloring Controls	Ovládací prvky barevdiff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv--- a/crates/ui-egui/src/i18n/da.tsv+++ b/crates/ui-egui/src/i18n/da.tsv@@ -2897,6 +2897,7 @@ theme	Appearance	Udseende 	Document JSON	Dokument-JSON 	Duplicate	Duplikér 	Find Objects	Find objekter+	Compare with Image	Sammenlign med billede 	Inspect Document	Inspicér dokument 	Inspect Object	Inspicér objekt 	Begin Undo Group	Start fortrydelsesgruppediff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv--- a/crates/ui-egui/src/i18n/de.tsv+++ b/crates/ui-egui/src/i18n/de.tsv@@ -1110,6 +1110,7 @@ font-technology	Variable	Variabel 	Find Font…	Schrift suchen… 	Find Next	Weitersuchen 	Find Objects	Objekte suchen+	Compare with Image	Mit Bild vergleichen 	First Page	Erste Seite 	First-line Left Indent	Einzug links in erster Zeile 	Fisheye…	Fischauge…@@ -4789,6 +4790,11 @@ lighting	Light	Licht @msg	unknown parameter `{key}`; the command takes: {keys}	unbekannter Parameter `{key}`; der Befehl akzeptiert: {keys} 	Load EPS into Clipboard	EPS in die Zwischenablage laden @msg	an image of {w} × {h} pixels is too large to draw	ein Bild mit {w} × {h} Pixeln ist zu groß, um gezeichnet zu werden+@msg	{path}: not a PNG, JPEG, WebP, TIFF or BMP image	{path}: kein PNG-, JPEG-, WebP-, TIFF- oder BMP-Bild+@msg	the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ	das Bild hat {iw} × {ih} Pixel und die Zeichenfläche in dieser Breite {rw} × {rh} Pixel: ihre Proportionen unterscheiden sich+@msg	`threshold` must be a number from 0 to 1	`threshold` muss eine Zahl von 0 bis 1 sein+@msg	background {v}: a color such as "#808080"	background {v}: eine Farbe wie "#808080"+@msg	comparing with an image file isn't available in the browser	der Vergleich mit einer Bilddatei ist im Browser nicht verfügbar @msg	the image can't be read as a texture	das Bild kann nicht als Struktur gelesen werden 	Basic	Basis 	Fill and Stroke Controls	Steuerelemente für Fläche und Konturdiff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv--- a/crates/ui-egui/src/i18n/el.tsv+++ b/crates/ui-egui/src/i18n/el.tsv@@ -1067,6 +1067,7 @@ font-technology	Variable	Μεταβλητές 	Find Font…	Εύρεση γραμματοσειράς… 	Find Next	Εύρεση επόμενου 	Find Objects	Εύρεση αντικειμένων+	Compare with Image	Σύγκριση με εικόνα 	First Page	Πρώτη σελίδα 	First-line Left Indent	Αριστερή εσοχή πρώτης γραμμής 	Fisheye…	Εφέ ματιού ψαριού…@@ -4987,6 +4988,11 @@ effect	Points	Σημεία 	Load EPS into Clipboard	Φόρτωση EPS στο πρόχειρο @msg	unknown control `{name}`: fillStroke, coloring, drawMode or screenMode	Άγνωστο στοιχείο ελέγχου `{name}`: fillStroke, coloring, drawMode ή screenMode @msg	an image of {w} × {h} pixels is too large to draw	Η εικόνα {w} × {h} εικονοστοιχείων είναι υπερβολικά μεγάλη για σχεδίαση+@msg	{path}: not a PNG, JPEG, WebP, TIFF or BMP image	{path}: δεν είναι εικόνα PNG, JPEG, WebP, TIFF ή BMP+@msg	the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ	η εικόνα είναι {iw} × {ih} εικονοστοιχεία και ο πίνακας σχεδίασης σε αυτό το πλάτος είναι {rw} × {rh} εικονοστοιχεία: οι αναλογίες τους διαφέρουν+@msg	`threshold` must be a number from 0 to 1	Το `threshold` πρέπει να είναι αριθμός από 0 έως 1+@msg	background {v}: a color such as "#808080"	φόντο {v}: χρώμα όπως "#808080"+@msg	comparing with an image file isn't available in the browser	η σύγκριση με αρχείο εικόνας δεν είναι διαθέσιμη στο πρόγραμμα περιήγησης 	Open Recent File 1	Άνοιγμα πρόσφατου αρχείου 1 	Open Recent File 2	Άνοιγμα πρόσφατου αρχείου 2 	Open Recent File 3	Άνοιγμα πρόσφατου αρχείου 3diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv--- a/crates/ui-egui/src/i18n/es.tsv+++ b/crates/ui-egui/src/i18n/es.tsv@@ -1133,6 +1133,7 @@ font-technology	Variable	Variables 	Find Font…	Buscar fuente… 	Find Next	Buscar siguiente 	Find Objects	Buscar objetos+	Compare with Image	Comparar con imagen 	First Page	Primera página 	First-line Left Indent	Sangría izquierda en primera línea 	Fisheye…	Ojo de pez…@@ -5164,6 +5165,11 @@ mark-style	Japanese	Japonesas @msg	unknown parameter `{key}`; the command takes: {keys}	parámetro desconocido `{key}`; el comando acepta: {keys} 	Load EPS into Clipboard	Cargar EPS en el portapapeles @msg	an image of {w} × {h} pixels is too large to draw	una imagen de {w} × {h} píxeles es demasiado grande para dibujarse+@msg	{path}: not a PNG, JPEG, WebP, TIFF or BMP image	{path}: no es una imagen PNG, JPEG, WebP, TIFF ni BMP+@msg	the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ	la imagen mide {iw} × {ih} píxeles y la mesa de trabajo a ese ancho mide {rw} × {rh} píxeles: sus proporciones difieren+@msg	`threshold` must be a number from 0 to 1	`threshold` debe ser un número de 0 a 1+@msg	background {v}: a color such as "#808080"	fondo {v}: un color como "#808080"+@msg	comparing with an image file isn't available in the browser	la comparación con un archivo de imagen no está disponible en el navegador @msg	the image can't be read as a texture	la imagen no se puede leer como textura 	Fill and Stroke Controls	Controles de relleno y trazo 	Coloring Controls	Controles de colordiff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv--- a/crates/ui-egui/src/i18n/fi.tsv+++ b/crates/ui-egui/src/i18n/fi.tsv@@ -3074,6 +3074,7 @@ theme	Appearance	Ulkoasu 	Document JSON	Asiakirjan JSON 	Duplicate	Monista 	Find Objects	Etsi objekteja+	Compare with Image	Vertaa kuvaan 	Inspect Document	Tarkastele asiakirjaa 	Inspect Object	Tarkastele objektia 	Begin Undo Group	Aloita kumoamisryhmä@@ -5112,6 +5113,11 @@ mark-style	Japanese	Japanilainen @msg	unknown parameter `{key}`; the command takes: {keys}	tuntematon parametri `{key}`; komennon parametrit: {keys} 	Load EPS into Clipboard	Lataa EPS leikepöydälle @msg	an image of {w} × {h} pixels is too large to draw	kuva, jonka koko on {w} × {h} pikseliä, on liian suuri piirrettäväksi+@msg	{path}: not a PNG, JPEG, WebP, TIFF or BMP image	{path}: ei ole PNG-, JPEG-, WebP-, TIFF- tai BMP-kuva+@msg	the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ	kuva on {iw} × {ih} pikseliä ja piirtoalusta tällä leveydellä {rw} × {rh} pikseliä: niiden mittasuhteet eroavat+@msg	`threshold` must be a number from 0 to 1	`threshold` on oltava luku väliltä 0–1+@msg	background {v}: a color such as "#808080"	tausta {v}: väri, kuten "#808080"+@msg	comparing with an image file isn't available in the browser	vertailu kuvatiedostoon ei ole käytettävissä selaimessa 	Fill and Stroke Controls	Täytön ja viivan säätimet 	Coloring Controls	Värityksen säätimet 	Draw Mode Controls	Piirtotilan säätimetdiff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv--- a/crates/ui-egui/src/i18n/fr.tsv+++ b/crates/ui-egui/src/i18n/fr.tsv@@ -1132,6 +1132,7 @@ font-technology	Variable	Variables 	Find Font…	Rechercher une police… 	Find Next	Rechercher le suivant 	Find Objects	Rechercher des objets+	Compare with Image	Comparer à une image 	First Page	Première page 	First-line Left Indent	Retrait gauche de première ligne 	Fisheye…	Fish-eye…@@ -5223,6 +5224,11 @@ mark-style	Japanese	Japonais theme	Appearance	Apparence 	Load EPS into Clipboard	Charger un EPS dans le presse-papiers @msg	an image of {w} × {h} pixels is too large to draw	une image de {w} × {h} pixels est trop grande pour être dessinée+@msg	{path}: not a PNG, JPEG, WebP, TIFF or BMP image	{path} : ce n’est pas une image PNG, JPEG, WebP, TIFF ou BMP+@msg	the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ	l’image fait {iw} × {ih} pixels et le plan de travail à cette largeur fait {rw} × {rh} pixels : leurs proportions diffèrent+@msg	`threshold` must be a number from 0 to 1	`threshold` doit être un nombre de 0 à 1+@msg	background {v}: a color such as "#808080"	arrière-plan {v} : une couleur comme "#808080"+@msg	comparing with an image file isn't available in the browser	la comparaison avec un fichier image n’est pas disponible dans le navigateur @msg	the image can't be read as a texture	l'image ne peut pas être lue comme texture 	Fill and Stroke Controls	Commandes de fond et de contour 	Coloring Controls	Commandes de couleurdiff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv--- a/crates/ui-egui/src/i18n/hu.tsv+++ b/crates/ui-egui/src/i18n/hu.tsv@@ -1426,6 +1426,7 @@ font-technology	Variable	Változó 	Find in Folder…	Keresés mappában… 	Find Next	Következő keresése 	Find Objects	Objektumok keresése+	Compare with Image	Összehasonlítás képpel 	Fine Dots	Finom pontok  	Simple	Egyszerű@@ -5209,6 +5210,11 @@ imageTrace	Outline	Körvonal @msg	unknown parameter `{key}`; the command takes: {keys}	ismeretlen paraméter: `{key}`; a parancs ezeket fogadja el: {keys} 	Load EPS into Clipboard	EPS betöltése a vágólapra @msg	an image of {w} × {h} pixels is too large to draw	egy {w} × {h} pixeles kép túl nagy a kirajzoláshoz+@msg	{path}: not a PNG, JPEG, WebP, TIFF or BMP image	{path}: nem PNG, JPEG, WebP, TIFF vagy BMP kép+@msg	the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ	a kép {iw} × {ih} pixeles, a rajztábla ilyen szélességben {rw} × {rh} pixeles: az arányaik eltérnek+@msg	`threshold` must be a number from 0 to 1	a `threshold` értéke 0 és 1 közötti szám lehet+@msg	background {v}: a color such as "#808080"	background {v}: egy szín, például "#808080"+@msg	comparing with an image file isn't available in the browser	a képfájllal való összehasonlítás a böngészőben nem érhető el @msg	the image can't be read as a texture	a kép nem olvasható be textúraként 	Fill and Stroke Controls	Kitöltés és körvonal vezérlői 	Coloring Controls	Színezés vezérlőidiff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv--- a/crates/ui-egui/src/i18n/id.tsv+++ b/crates/ui-egui/src/i18n/id.tsv@@ -2897,6 +2897,7 @@ theme	Appearance	Tampilan 	Document JSON	JSON dokumen 	Duplicate	Duplikatkan 	Find Objects	Cari objek+	Compare with Image	Bandingkan dengan gambar 	Inspect Document	Periksa dokumen 	Inspect Object	Periksa objek 	Begin Undo Group	Mulai grup urungkandiff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv--- a/crates/ui-egui/src/i18n/it.tsv+++ b/crates/ui-egui/src/i18n/it.tsv@@ -1133,6 +1133,7 @@ font-technology	Variable	Variabili 	Find Font…	Trova font… 	Find Next	Trova successivo 	Find Objects	Trova oggetti+	Compare with Image	Confronta con immagine 	First Page	Prima pagina 	First-line Left Indent	Rientro sinistro prima riga 	Fisheye…	Occhio di pesce…@@ -4831,6 +4832,11 @@ lighting	Light	Luce @msg	unknown parameter `{key}`; the command takes: {keys}	parametro sconosciuto `{key}`; il comando accetta: {keys} 	Load EPS into Clipboard	Carica EPS negli appunti @msg	an image of {w} × {h} pixels is too large to draw	un'immagine di {w} × {h} pixel è troppo grande per essere disegnata+@msg	{path}: not a PNG, JPEG, WebP, TIFF or BMP image	{path}: non è un'immagine PNG, JPEG, WebP, TIFF o BMP+@msg	the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ	l'immagine è di {iw} × {ih} pixel e la tavola da disegno a quella larghezza è di {rw} × {rh} pixel: le proporzioni sono diverse+@msg	`threshold` must be a number from 0 to 1	`threshold` deve essere un numero da 0 a 1+@msg	background {v}: a color such as "#808080"	sfondo {v}: un colore come "#808080"+@msg	comparing with an image file isn't available in the browser	il confronto con un file immagine non è disponibile nel browser @msg	the image can't be read as a texture	l'immagine non può essere letta come texture 	Basic	Base 	Fill and Stroke Controls	Controlli di riempimento e tracciadiff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv--- a/crates/ui-egui/src/i18n/ja.tsv+++ b/crates/ui-egui/src/i18n/ja.tsv@@ -134,6 +134,7 @@ font-technology	Variable	バリアブル 	Find and Replace…	検索と置換… 	Find Next	次を検索 	Find Objects	オブジェクトを検索+	Compare with Image	画像と比較 	Spelling	スペルチェック 	Auto Spell Check	自動スペルチェック 	Check Spelling…	スペルチェック…@@ -4870,6 +4871,11 @@ lighting	Light	ライト @msg	unknown parameter `{key}`; the command takes: {keys}	`{key}` は不明なパラメーターです。このコマンドで使えるパラメーター: {keys} 	Load EPS into Clipboard	EPS をクリップボードに読み込む @msg	an image of {w} × {h} pixels is too large to draw	{w} × {h} ピクセルの画像は大きすぎて描画できません+@msg	{path}: not a PNG, JPEG, WebP, TIFF or BMP image	{path}: PNG、JPEG、WebP、TIFF、BMP のいずれの画像でもありません+@msg	the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ	画像は {iw} × {ih} ピクセルで、その幅でのアートボードは {rw} × {rh} ピクセルです: 縦横比が異なります+@msg	`threshold` must be a number from 0 to 1	`threshold` には 0 から 1 までの数値を指定してください+@msg	background {v}: a color such as "#808080"	背景に {v} は指定できません: 「#808080」のようなカラーを指定してください+@msg	comparing with an image file isn't available in the browser	画像ファイルとの比較はブラウザーでは使用できません @msg	the image can't be read as a texture	この画像はテクスチャとして読み込めません 	Basic	基本 	Fill and Stroke Controls	塗りと線のコントロールdiff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv--- a/crates/ui-egui/src/i18n/ka.tsv+++ b/crates/ui-egui/src/i18n/ka.tsv@@ -2709,6 +2709,7 @@ theme	Appearance	იერსახე 	Document JSON	დოკუმენტის JSON 	Duplicate	დუბლირება 	Find Objects	ობიექტების ძებნა+	Compare with Image	გამოსახულებასთან შედარება 	Inspect Document	დოკუმენტის შემოწმება 	Inspect Object	ობიექტის შემოწმება 	Begin Undo Group	დაბრუნების ჯგუფის დაწყება@@ -4786,6 +4787,11 @@ imageTrace	Outline	კონტური @msg	{path}: not a file	{path}: ფაილი არ არის 	Load EPS into Clipboard	EPS-ის ბუფერში ჩატვირთვა @msg	an image of {w} × {h} pixels is too large to draw	{w} × {h} პიქსელის გამოსახულება დასახატად ზედმეტად დიდია+@msg	{path}: not a PNG, JPEG, WebP, TIFF or BMP image	{path}: არ არის PNG, JPEG, WebP, TIFF ან BMP გამოსახულება+@msg	the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ	გამოსახულება არის {iw} × {ih} პიქსელი, ხოლო სამუშაო დაფა ამ სიგანეზე არის {rw} × {rh} პიქსელი: მათი პროპორციები განსხვავდება+@msg	`threshold` must be a number from 0 to 1	`threshold` უნდა იყოს რიცხვი 0-დან 1-მდე+@msg	background {v}: a color such as "#808080"	ფონი {v}: ფერი, მაგალითად "#808080"+@msg	comparing with an image file isn't available in the browser	გამოსახულების ფაილთან შედარება ბრაუზერში მიუწვდომელია 	A later row takes this shortcut	ამ მალსახმობს ქვემოთა სტრიქონი იყენებს 	Add to the Current Shortcuts	მიმდინარე მალსახმობებზე დამატება 	Ambient Light	გარემოს სინათლეdiff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv--- a/crates/ui-egui/src/i18n/ko.tsv+++ b/crates/ui-egui/src/i18n/ko.tsv@@ -130,6 +130,7 @@ font-technology	Variable	가변 	Find and Replace…	찾기 및 바꾸기… 	Find Next	다음 찾기 	Find Objects	개체 찾기+	Compare with Image	이미지와 비교 	Spelling	맞춤법 	Auto Spell Check	자동 맞춤법 검사 	Check Spelling…	맞춤법 검사…@@ -4794,6 +4795,11 @@ lighting	Light	광원 @msg	unknown parameter `{key}`; the command takes: {keys}	알 수 없는 매개변수 `{key}`: 이 명령이 받는 매개변수: {keys} 	Load EPS into Clipboard	EPS를 클립보드로 불러오기 @msg	an image of {w} × {h} pixels is too large to draw	{w} × {h}픽셀 이미지는 너무 커서 그릴 수 없습니다+@msg	{path}: not a PNG, JPEG, WebP, TIFF or BMP image	{path}: PNG, JPEG, WebP, TIFF 또는 BMP 이미지가 아닙니다+@msg	the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ	이미지는 {iw} × {ih}픽셀이고 그 너비에서 아트보드는 {rw} × {rh}픽셀입니다: 비율이 다릅니다+@msg	`threshold` must be a number from 0 to 1	`threshold`은(는) 0에서 1 사이의 숫자여야 합니다+@msg	background {v}: a color such as "#808080"	배경 {v}: "#808080" 같은 색상이어야 합니다+@msg	comparing with an image file isn't available in the browser	이미지 파일과 비교하는 기능은 브라우저에서 사용할 수 없습니다 @msg	the image can't be read as a texture	이미지를 텍스처로 읽을 수 없습니다 	Basic	기본 	Fill and Stroke Controls	칠 및 획 컨트롤diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv--- a/crates/ui-egui/src/i18n/nl.tsv+++ b/crates/ui-egui/src/i18n/nl.tsv@@ -2911,6 +2911,7 @@ theme	Appearance	Vormgeving 	Document JSON	Document-JSON 	Duplicate	Dupliceren 	Find Objects	Objecten zoeken+	Compare with Image	Vergelijken met afbeelding 	Inspect Document	Document inspecteren 	Inspect Object	Object inspecteren 	Begin Undo Group	Groep voor ongedaan maken beginnendiff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv--- a/crates/ui-egui/src/i18n/pl.tsv+++ b/crates/ui-egui/src/i18n/pl.tsv@@ -3012,6 +3012,7 @@ theme	Appearance	Wygląd 	Document JSON	JSON dokumentu 	Duplicate	Powiel 	Find Objects	Znajdź obiekty+	Compare with Image	Porównaj z obrazem 	Inspect Document	Zbadaj dokument 	Inspect Object	Zbadaj obiekt 	Begin Undo Group	Rozpocznij grupę cofania@@ -5060,6 +5061,11 @@ mark-style	Japanese	Japońskie @msg	unknown parameter `{key}`; the command takes: {keys}	nieznany parametr `{key}`; polecenie przyjmuje: {keys} 	Load EPS into Clipboard	Wczytaj EPS do schowka @msg	an image of {w} × {h} pixels is too large to draw	obraz o wymiarach {w} × {h} pikseli jest zbyt duży, aby go narysować+@msg	{path}: not a PNG, JPEG, WebP, TIFF or BMP image	{path}: to nie jest obraz PNG, JPEG, WebP, TIFF ani BMP+@msg	the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ	obraz ma {iw} × {ih} pikseli, a obszar kompozycji przy tej szerokości ma {rw} × {rh} pikseli: ich proporcje się różnią+@msg	`threshold` must be a number from 0 to 1	`threshold` musi być liczbą od 0 do 1+@msg	background {v}: a color such as "#808080"	tło {v}: kolor, np. "#808080"+@msg	comparing with an image file isn't available in the browser	porównywanie z plikiem obrazu nie jest dostępne w przeglądarce 	Basic	Podstawowy 	Fill and Stroke Controls	Elementy sterujące wypełnieniem i obrysem 	Coloring Controls	Elementy sterujące koloremdiff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv--- a/crates/ui-egui/src/i18n/pt-br.tsv+++ b/crates/ui-egui/src/i18n/pt-br.tsv@@ -3154,6 +3154,7 @@ font-technology	Variable	Variáveis 	Favorites	Favoritos 	Fill (X), double-click for the Color Picker	Preenchimento (X), clique duas vezes para o seletor de cores 	Find Objects	Localizar objetos+	Compare with Image	Comparar com imagem 	Flare Tool Options	Opções da ferramenta Reflexo 	Halo	Halo 	Rays	Raios@@ -5156,6 +5157,11 @@ mark-style	Japanese	Japonesas @msg	The shape is too complex for a 3D effect	A forma é complexa demais para um efeito 3D @msg	The 3D surface is too complex; simplify the shape	A superfície 3D é complexa demais; simplifique a forma @msg	an image of {w} × {h} pixels is too large to draw	uma imagem de {w} × {h} pixels é grande demais para ser desenhada+@msg	{path}: not a PNG, JPEG, WebP, TIFF or BMP image	{path}: não é uma imagem PNG, JPEG, WebP, TIFF ou BMP+@msg	the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ	a imagem tem {iw} × {ih} pixels e a prancheta nessa largura tem {rw} × {rh} pixels: as proporções são diferentes+@msg	`threshold` must be a number from 0 to 1	`threshold` deve ser um número de 0 a 1+@msg	background {v}: a color such as "#808080"	plano de fundo {v}: uma cor como "#808080"+@msg	comparing with an image file isn't available in the browser	a comparação com um arquivo de imagem não está disponível no navegador 	Fill and Stroke Controls	Controles de Preenchimento e Traçado 	Coloring Controls	Controles de Coloração 	Draw Mode Controls	Controles de Modo de Desenhodiff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv--- a/crates/ui-egui/src/i18n/ru.tsv+++ b/crates/ui-egui/src/i18n/ru.tsv@@ -1131,6 +1131,7 @@ font-technology	Variable	Вариативные 	Find Font…	Найти шрифт… 	Find Next	Найти далее 	Find Objects	Найти объекты+	Compare with Image	Сравнить с изображением 	First Page	Первая страница 	First-line Left Indent	Отступ слева в первой строке 	Fisheye…	Рыбий глаз…@@ -4827,6 +4828,11 @@ lighting	Light	Свет @msg	unknown parameter `{key}`; the command takes: {keys}	неизвестный параметр `{key}`; команда принимает: {keys} 	Load EPS into Clipboard	Загрузить EPS в буфер обмена @msg	an image of {w} × {h} pixels is too large to draw	изображение размером {w} × {h} пикселей слишком велико для отрисовки+@msg	{path}: not a PNG, JPEG, WebP, TIFF or BMP image	{path}: это не изображение PNG, JPEG, WebP, TIFF или BMP+@msg	the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ	размер изображения {iw} × {ih} пикселей, а монтажной области при этой ширине {rw} × {rh} пикселей: их пропорции различаются+@msg	`threshold` must be a number from 0 to 1	`threshold` должно быть числом от 0 до 1+@msg	background {v}: a color such as "#808080"	фон {v}: цвет, например "#808080"+@msg	comparing with an image file isn't available in the browser	сравнение с файлом изображения недоступно в браузере @msg	the image can't be read as a texture	изображение не удаётся прочитать как текстуру 	Basic	Базовый 	Fill and Stroke Controls	Элементы управления заливкой и обводкойdiff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv--- a/crates/ui-egui/src/i18n/sv.tsv+++ b/crates/ui-egui/src/i18n/sv.tsv@@ -1103,6 +1103,7 @@ font-technology	Variable	Variabla 	Find Font…	Sök typsnitt… 	Find Next	Sök nästa 	Find Objects	Sök objekt+	Compare with Image	Jämför med bild 	First Page	Första sidan 	First-line Left Indent	Vänsterindrag för första raden 	Fisheye…	Fisköga…@@ -5128,6 +5129,11 @@ mark-style	Japanese	Japanska theme	Appearance	Utseende 	Load EPS into Clipboard	Läs in EPS till urklipp @msg	an image of {w} × {h} pixels is too large to draw	en bild på {w} × {h} pixlar är för stor för att ritas+@msg	{path}: not a PNG, JPEG, WebP, TIFF or BMP image	{path}: inte en PNG-, JPEG-, WebP-, TIFF- eller BMP-bild+@msg	the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ	bilden är {iw} × {ih} pixlar och ritytan i den bredden är {rw} × {rh} pixlar: proportionerna skiljer sig åt+@msg	`threshold` must be a number from 0 to 1	`threshold` måste vara ett tal från 0 till 1+@msg	background {v}: a color such as "#808080"	background {v}: en färg som "#808080"+@msg	comparing with an image file isn't available in the browser	jämförelse med en bildfil är inte tillgänglig i webbläsaren 	Fill and Stroke Controls	Kontroller för fyllning och kontur 	Coloring Controls	Kontroller för färgläggning 	Draw Mode Controls	Kontroller för ritlägediff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv--- a/crates/ui-egui/src/i18n/tr.tsv+++ b/crates/ui-egui/src/i18n/tr.tsv@@ -1093,6 +1093,7 @@ font-technology	Variable	Değişken 	Find Font…	Yazı Tipi Bul… 	Find Next	Sonrakini Bul 	Find Objects	Nesneleri Bul+	Compare with Image	Görüntüyle Karşılaştır 	First Page	İlk Sayfa 	First-line Left Indent	Birinci Satırda Sol Girinti 	Fisheye…	Balıkgözü…@@ -3824,6 +3825,11 @@ axis	Both	Her İkisi @msg	invalid image data for `{0}`	`{0}` için geçersiz resim verileri @msg	invalid parameters for `{cmd}`: {msg}	`{cmd}` için geçersiz parametreler: {msg} @msg	is not an image VectorCraft reads	VectorCraft'ın okuduğu bir resim değil+@msg	{path}: not a PNG, JPEG, WebP, TIFF or BMP image	{path}: PNG, JPEG, WebP, TIFF veya BMP resmi değil+@msg	the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ	resim {iw} × {ih} piksel, çalışma yüzeyi bu genişlikte {rw} × {rh} piksel: oranları farklı+@msg	`threshold` must be a number from 0 to 1	`threshold` 0 ile 1 arasında bir sayı olmalıdır+@msg	background {v}: a color such as "#808080"	background {v}: "#808080" gibi bir renk+@msg	comparing with an image file isn't available in the browser	bir resim dosyasıyla karşılaştırma tarayıcıda kullanılamaz @msg	it uses `{at}`, which VectorCraft's PostScript reader doesn't know	VectorCraft'ın PostScript okuyucusunun bilmediği `{at}`yi kullanıyor @msg	it uses `{at}`, which VectorCraft's PostScript reader doesn't know, in {procs}	{procs}'de VectorCraft'ın PostScript okuyucusunun bilmediği `{at}`yi kullanıyor @msg	its Windows metafile preview was opened instead, which may simplify the art	bunun yerine Windows meta dosyası önizlemesi açıldı, bu da işi basitleştirebilirdiff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv--- a/crates/ui-egui/src/i18n/uk.tsv+++ b/crates/ui-egui/src/i18n/uk.tsv@@ -1127,6 +1127,7 @@ font-technology	Variable	Варіативні 	Find Font…	Знайти шрифт… 	Find Next	Знайти далі 	Find Objects	Знайти об’єкти+	Compare with Image	Порівняти із зображенням 	First Page	Перша сторінка 	First-line Left Indent	Відступ ліворуч у першому рядку 	Fisheye…	Риб’яче око…@@ -4811,6 +4812,11 @@ lighting	Light	Світло @msg	unknown parameter `{key}`; the command takes: {keys}	невідомий параметр `{key}`; команда приймає: {keys} 	Load EPS into Clipboard	Завантажити EPS у буфер обміну @msg	an image of {w} × {h} pixels is too large to draw	зображення розміром {w} × {h} пікселів завелике для відображення+@msg	{path}: not a PNG, JPEG, WebP, TIFF or BMP image	{path}: це не зображення PNG, JPEG, WebP, TIFF чи BMP+@msg	the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ	розмір зображення {iw} × {ih} пікселів, а монтажної області за цієї ширини {rw} × {rh} пікселів: їхні пропорції відрізняються+@msg	`threshold` must be a number from 0 to 1	`threshold` має бути числом від 0 до 1+@msg	background {v}: a color such as "#808080"	фон {v}: колір, наприклад "#808080"+@msg	comparing with an image file isn't available in the browser	порівняння з файлом зображення недоступне в браузері @msg	the image can't be read as a texture	зображення не вдається прочитати як текстуру 	Basic	Базовий 	Fill and Stroke Controls	Елементи керування заливкою та обведеннямdiff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv--- a/crates/ui-egui/src/i18n/vi.tsv+++ b/crates/ui-egui/src/i18n/vi.tsv@@ -1331,6 +1331,7 @@ font-technology	Variable	Biến thiên 	Find Linked Files in Folder…	Tìm tệp liên kết trong thư mục… 	Find Next	Tìm tiếp 	Find Objects	Tìm đối tượng+	Compare with Image	So sánh với ảnh 	Fine Dots	Chấm mịn  	Simple	Đơn giản@@ -4814,6 +4815,11 @@ theme	Appearance	Giao diện @msg	{tool} isn't in a toolbar group of several tools	{tool} không nằm trong nhóm thanh công cụ có nhiều công cụ 	Load EPS into Clipboard	Tải EPS vào bảng tạm @msg	an image of {w} × {h} pixels is too large to draw	ảnh {w} × {h} điểm ảnh quá lớn nên không thể vẽ+@msg	{path}: not a PNG, JPEG, WebP, TIFF or BMP image	{path}: không phải ảnh PNG, JPEG, WebP, TIFF hay BMP+@msg	the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ	ảnh có kích thước {iw} × {ih} điểm ảnh, còn bảng vẽ ở chiều rộng đó có kích thước {rw} × {rh} điểm ảnh: tỉ lệ của chúng khác nhau+@msg	`threshold` must be a number from 0 to 1	`threshold` phải là một số từ 0 đến 1+@msg	background {v}: a color such as "#808080"	background {v}: màu như "#808080"+@msg	comparing with an image file isn't available in the browser	không thể so sánh với tệp ảnh trong trình duyệt 	Basic	Cơ bản 	Fill and Stroke Controls	Điều khiển tô và nét 	Coloring Controls	Điều khiển màudiff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv--- a/crates/ui-egui/src/i18n/zh-hans.tsv+++ b/crates/ui-egui/src/i18n/zh-hans.tsv@@ -1073,6 +1073,7 @@ 	Find Font…	查找字体… 	Find Next	查找下一个 	Find Objects	查找对象+	Compare with Image	与图像比较 	First Page	第一页 	First-line Left Indent	首行左缩进 	Fisheye…	鱼眼…@@ -5092,6 +5093,11 @@ lighting	Light	光源 @msg	unknown parameter `{key}`; the command takes: {keys}	未知参数 `{key}`;该命令接受:{keys} 	Load EPS into Clipboard	将 EPS 载入剪贴板 @msg	an image of {w} × {h} pixels is too large to draw	{w} × {h} 像素的图像过大,无法绘制+@msg	{path}: not a PNG, JPEG, WebP, TIFF or BMP image	{path}:不是 PNG、JPEG、WebP、TIFF 或 BMP 图像+@msg	the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ	图像为 {iw} × {ih} 像素,而画板在该宽度下为 {rw} × {rh} 像素:两者的比例不同+@msg	`threshold` must be a number from 0 to 1	`threshold` 必须是 0 到 1 之间的数字+@msg	background {v}: a color such as "#808080"	background {v}:颜色,例如 "#808080"+@msg	comparing with an image file isn't available in the browser	浏览器中无法与图像文件进行比较 	Fill and Stroke Controls	填色和描边控件 	Coloring Controls	颜色控件 	Draw Mode Controls	绘图模式控件diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv--- a/crates/ui-egui/src/i18n/zh-hant.tsv+++ b/crates/ui-egui/src/i18n/zh-hant.tsv@@ -1098,6 +1098,7 @@ font-technology	Variable	可變 	Find Font…	尋找字體… 	Find Next	尋找下一個 	Find Objects	尋找物件+	Compare with Image	與影像比較 	First Page	第一頁 	First-line Left Indent	首行左縮排 	Fisheye…	魚眼…@@ -4899,6 +4900,11 @@ lighting	Light	光源 @msg	unknown parameter `{key}`; the command takes: {keys}	未知的參數 `{key}`;此指令接受:{keys} 	Load EPS into Clipboard	將 EPS 載入剪貼簿 @msg	an image of {w} × {h} pixels is too large to draw	{w} × {h} 像素的影像過大,無法繪製+@msg	{path}: not a PNG, JPEG, WebP, TIFF or BMP image	{path}:不是 PNG、JPEG、WebP、TIFF 或 BMP 影像+@msg	the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ	影像為 {iw} × {ih} 像素,而工作區域在該寬度下為 {rw} × {rh} 像素:兩者的比例不同+@msg	`threshold` must be a number from 0 to 1	`threshold` 必須是 0 到 1 之間的數字+@msg	background {v}: a color such as "#808080"	background {v}:顏色,例如 "#808080"+@msg	comparing with an image file isn't available in the browser	瀏覽器中無法與影像檔案進行比較 	Basic	基本 	Fill and Stroke Controls	填色和筆畫控制項 	Coloring Controls	顏色控制項diff --git a/docs/mcp.md b/docs/mcp.md--- a/docs/mcp.md+++ b/docs/mcp.md@@ -266,6 +266,22 @@ chain (layer first) and `limit` caps `matches` (default 100; `0` only counts); w `selected`, the number of objects selected. `document.json` stays whole: it is the fidelity path, not the way to look around. +`run_command document.compare {path, artboard?, background?, threshold?, diffPath?}` measures how far an+artboard is from an image file (PNG, JPEG, WebP, TIFF or BMP). It renders the+artboard (default 0) at the image's size, as `export` draws a PNG: the scale is the image's width in pixels over+the artboard's width in points, and an image whose height differs from the artboard's height at that scale by+more than one pixel is an error that names both sizes. The render and the image are both flattened on+`background` (`"#rrggbb"`, default `"#ffffff"`). The reply is `{width, height, rmse, over, total, threshold}`:+`rmse` is the root mean square of the R, G and B differences on a 0 to 1 scale, rounded to 6 decimals, and+`over` counts the pixels with a channel that differs by more than `threshold` (0 to 1, default 0.08) of the+total `total`. `diffPath` writes a PNG of each pixel's absolute R, G and B differences (black where the two+match) and adds `diffPath` to the reply. As in a PNG export, linked images and placed documents are drawn+from their files rather than from the previews saved for them. The reply's `warnings` are a PNG export's, in+the same order: type on the artboard that doesn't fit its frame, an embedded image that couldn't be decoded (it+is missing from the render), type in a font or style that isn't available, and a linked file that can't be read+(its preview is drawn). `path` is read through the automation read root, and `diffPath` is written through the+write root. The command does not change the document, and it is not available in the web app.+ ### Completions  `completion/complete` answers for a prompt argument (`ref/prompt`) or a template variable