From 92d3a8158ba7896d1219b74bab052c2b1b7d9562 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 20:14:36 -0800 Subject: [PATCH 01/14] i18n: Turkish Draw labels use the drawing verb instead of a tied game MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Turkish catalog translated the toolbar's Draw category and the freeform gradient's Draw: row as "Beraberlik", which means a tied game. Both now read "Çiz", the verb the catalog already uses for Draw Behind, Draw Inside and Draw Normal. The Turkish "unknown control" message also listed the control names as "colouring,drawMode": it now lists "coloring, drawMode", the names the command accepts. The two dimension scale rows read the English word order wrongly. The hint under the Dimension dialog's scale field now says that 1:10 labels 1 unit on the page as 10, and the scale error keeps "çizim:gerçek" as one form instead of splitting it around the verb. --- crates/ui-egui/src/i18n/tests.rs | 22 ++++++++++++++++++++++ crates/ui-egui/src/i18n/tr.tsv | 10 +++++----- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 705fc68db2..e49c33baf0 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -280,6 +280,28 @@ fn languages_resolve_from_their_tags_and_name_themselves() { } } +/// Turkish `Draw` labels use the drawing verb of the catalog's other drawing modes, and the +/// accepted control names in its messages keep the spelling the command reads. The dimension +/// scale rows keep the `drawing:real` form and say that the ratio labels a page unit. +#[test] +fn turkish_draw_labels_use_the_drawing_verb() { + let tr_lang = Lang::from_code("tr").expect("tr registered"); + assert_eq!(tr(tr_lang, "Draw"), "Çiz"); + assert_eq!(tr(tr_lang, "Draw:"), "Çiz:"); + assert_eq!(tr(tr_lang, "Draw Behind"), "Arkaya Çiz"); + let (entries, _) = parse_entries(tr_lang.0.source); + let tie: Vec<_> = + entries.iter().filter(|(_, _, t)| t.to_lowercase().contains("beraber")).map(|(_, src, t)| format!("{src:?} -> {t:?}")).collect(); + assert!(tie.is_empty(), "Turkish rows read as a tied game:\n{}", tie.join("\n")); + let unknown = message(tr_lang, "unknown control `x`: fillStroke, coloring, drawMode or screenMode"); + assert!(unknown.contains("fillStroke, coloring, drawMode veya screenMode"), "{unknown}"); + assert_eq!( + tr(tr_lang, "Drawing : real size — 1:10 labels 1 unit on the page as 10."), + "Çizim : gerçek boyut — 1:10, sayfadaki 1 birimi 10 olarak etiketler." + ); + assert_eq!(message(tr_lang, "Scale must be drawing:real, for example 1:10"), "Ölçek çizim:gerçek biçiminde olmalıdır, örneğin 1:10"); +} + /// The Catalan catalog registers under `ca`, claims complete menus and messages, and passes /// locale resolution, vocabulary and count formatting checks. #[test] diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 2b3e843ba3..09b9d27210 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -903,12 +903,12 @@ font-technology Variable Değişken Drag art here, or select it and click +. Hold Alt for one asset of several objects. Resmi buraya sürükleyin veya seçip + işaretine tıklayın. Birkaç nesneden oluşan bir varlık için Alt tuşunu basılı tutun. Drag onto art to apply this appearance Bu görünümü uygulamak için resmin üzerine sürükleyin Drag the art to move it on the paper Resmi kağıt üzerinde taşımak için sürükleyin - Draw Beraberlik + Draw Çiz Draw Behind Arkaya Çiz Draw in Perspective Perspektifte Çiz Draw Inside İçini Çiz Draw Normal Normal Çiz - Draw: Beraberlik: + Draw: Çiz: Drawing Mode Çizim Modu Drop Shadow Alt Gölge Drop Shadow… Alt Gölge… @@ -5714,7 +5714,7 @@ theme Appearance Görünüm Drawer menu Çekmece menüsü Toolbar: Show Controls Araç Çubuğu: Kontrolleri Göster @msg control must be fillStroke, coloring, drawMode or screenMode control fillStroke, coloring, drawMode veya screenMode olmalıdır -@msg unknown control `{name}`: fillStroke, coloring, drawMode or screenMode bilinmeyen kontrol `{name}`: fillStroke, colouring,drawMode veya screenMode +@msg unknown control `{name}`: fillStroke, coloring, drawMode or screenMode bilinmeyen kontrol `{name}`: fillStroke, coloring, drawMode veya screenMode @msg give a fraction `t` from 0 to 1 0'dan 1'e kadar bir `t` kesri verin @msg give the path `id` `id` yolunu ver @msg invalid stroke widths geçersiz kontur genişlikleri @@ -5734,7 +5734,7 @@ theme Appearance Görünüm Below Object Nesnenin altında Registration: Kayıt: Dimension Boyut - Drawing : real size — 1:10 labels 1 unit on the page as 10. Çizim : Gerçek boyut — 1:10 etiketler sayfada 10 adet olarak 1 adettir. + Drawing : real size — 1:10 labels 1 unit on the page as 10. Çizim : gerçek boyut — 1:10, sayfadaki 1 birimi 10 olarak etiketler. Preview (1 inch on the page) Önizleme (sayfada 1 inç) Set as Default Varsayılan Olarak Ayarla out from a highlighted edge to place its dimension | boyutunu yerleştirmek için vurgulanan kenardan dışarı | @@ -5772,7 +5772,7 @@ theme Appearance Görünüm @msg Measured segment no longer exists Ölçülen segment artık mevcut değil @msg Non-finite dimension Sonlu olmayan boyut @msg Only linear dimensions are horizontal or vertical Yalnızca doğrusal boyutlar yatay veya dikey olabilir -@msg Scale must be drawing:real, for example 1:10 Ölçek çizim olmalıdır:gerçek, örneğin 1:10 +@msg Scale must be drawing:real, for example 1:10 Ölçek çizim:gerçek biçiminde olmalıdır, örneğin 1:10 @msg Scale ratio must be between 1:1000000 and 1000000:1 Ölçek oranı 1:1000000 ile 1000000:1 arasında olmalıdır @msg Second measured segment no longer exists Ölçülen ikinci segment artık mevcut değil @msg Unknown dimension mode Bilinmeyen boyut modu From ba3c5b0a3c7a159d099db6d217029ae068c2f039 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 21:04:08 -0800 Subject: [PATCH 02/14] .ai save: a top object with uneven sides stays a live envelope A top object whose top and bottom sides, or left and right sides, have different numbers of segments was written to the editing data as its distorted art. The writer now splits the side with fewer segments into as many as the side across from it has, each segment into equal spans of its curve, up to the 50 columns or rows the grid holds. The reader opens the grid's edge as the top object's outline with the same corners and the same mapping of the contents; the outline holds the added anchors. A split that would share out the contents another way, or a side of more than 50 segments, still writes the envelope as its art with the warning. --- crates/eps/src/ai_editing/tests.rs | 121 ++++++++++++++++-- crates/eps/src/ai_editing/writer/envelopes.rs | 90 ++++++++++--- docs/target-app-parity.md | 1 + 3 files changed, 184 insertions(+), 28 deletions(-) diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 967c982ad4..bc9d3a5332 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -9689,7 +9689,7 @@ fn an_envelope_is_written_in_the_order_the_reader_reads_it() { /// Envelopes the reader would open in another shape, or not at all, are written as their distorted /// art with a warning, and open as that art: an envelope of type, one in a turned frame, a warp drawn /// without its style's net, a mesh the reader would lay out the other way round, a top object -/// whose sides don't pair up, a Fidelity that isn't a whole number and bounds margins kept with +/// with a side of more segments than the grid has columns, a Fidelity that isn't a whole number and bounds margins kept with /// Distort Appearance off. Other live objects keep their own warning. #[test] fn envelopes_the_reader_cannot_open_live_are_written_as_their_art() { @@ -9726,15 +9726,10 @@ fn envelopes_the_reader_cannot_open_live_are_written_as_their_art() { **kind = EnvelopeKind::Mesh { rows: 2, cols: 3, points: transpose(points, 3, 2), handles: vec![] }; } }); - let mut uneven = kurbo::BezPath::new(); - uneven.move_to((30.0, 10.0)); - for p in [(100.0, 0.0), (170.0, 10.0), (170.0, 90.0), (30.0, 90.0)] { - uneven.line_to(p); - } - uneven.close_path(); - let lopsided = change(&|k| { + // A top side of 51 segments: more grid columns than the reader reads. + let crowded = change(&|k| { if let NodeKind::Envelope { kind, .. } = k { - **kind = EnvelopeKind::TopObject { path: PathData::from_bezpath(&uneven), corners: None }; + **kind = EnvelopeKind::TopObject { path: PathData::from_bezpath(&uneven_top_object(51)), corners: None }; } }); let fractional = change(&|k| { @@ -9753,7 +9748,7 @@ fn envelopes_the_reader_cannot_open_live_are_written_as_their_art() { ("turned", turned), ("old warp", old_warp), ("sideways mesh", sideways), - ("lopsided top object", lopsided), + ("top object past the grid's limit", crowded), ("fractional fidelity", fractional), ("margin kept", margin_kept), ]; @@ -9774,6 +9769,112 @@ fn transpose(points: Vec, rows: usize, cols: usize) -> Vec kurbo::BezPath { + let mut path = kurbo::BezPath::new(); + path.move_to((30.0, 10.0)); + for k in 1..top { + path.line_to((30.0 + 140.0 * k as f64 / top as f64, if k % 2 == 1 { 20.0 } else { 10.0 })); + } + for p in [(170.0, 10.0), (170.0, 90.0), (30.0, 90.0)] { + path.line_to(p); + } + path.close_path(); + path +} + +/// Do the patches of top objects `a` and `b` lie within 0.001 pt of each other at every place of a +/// 32 × 32 grid? +fn same_top_object_patch(a: &vectorcraft_doc::live::EnvelopeKind, b: &vectorcraft_doc::live::EnvelopeKind) -> bool { + use vectorcraft_doc::live::{Coons, EnvelopeKind}; + let (EnvelopeKind::TopObject { path, corners }, EnvelopeKind::TopObject { path: p, corners: c }) = (a, b) else { return false }; + let (Some(a), Some(b)) = (Coons::from_top_object(path, *corners), Coons::from_top_object(p, *c)) else { return false }; + let at = |i: u32| f64::from(i) / 32.0; + (0..=32).all(|j| (0..=32).all(|i| a.eval(at(i), at(j)).distance(b.eval(at(i), at(j))) < 1e-3)) +} + +/// A top object whose top and bottom sides have different numbers of segments is written live, its +/// side with fewer segments split into as many as the side across from it has, with Edit Contents off +/// and on: two straight segments over one straight one, three over one, and two over one curve. +/// VectorCraft's reader opens it as a top object with its corners on the same four points, the same +/// patch, contents, Fidelity, Edit Contents and Envelope Options, and saved again it opens the same. +#[test] +fn a_top_object_with_uneven_sides_is_read_back_live() { + use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions}; + let mut curved = kurbo::BezPath::new(); + curved.move_to((30.0, 10.0)); + curved.line_to((100.0, 0.0)); + curved.line_to((170.0, 10.0)); + curved.line_to((170.0, 90.0)); + curved.curve_to((130.0, 105.0), (70.0, 105.0), (30.0, 90.0)); + curved.close_path(); + let shapes = [("two over one", uneven_top_object(2), 6), ("three over one", uneven_top_object(3), 8), ("two over a curve", curved, 6)]; + for (name, path, anchors) in shapes { + let kind = EnvelopeKind::TopObject { path: PathData::from_bezpath(&path), corners: None }; + for editing in [false, true] { + let what = format!("{name}, editing {editing}"); + let doc = envelope_doc(kind.clone(), 50.0, editing, EnvelopeOptions::NEW); + let text = program(&doc); + assert!(text.contains("AIDeformOptionsKey") && text.contains("%_3 /Int (AIDeformStyle) ,\n"), "{what}: {text}"); + let (back, warnings, notes) = envelope_back(&doc); + assert!(!warnings.iter().any(|w| w == writer::ENVELOPES), "{what}: {warnings:?}"); + assert!(!notes.iter().any(|w| w == crate::import::ENVELOPE_DRAWN), "{what}: {notes:?}"); + let NodeKind::Envelope { content, kind: got, fidelity, editing: e, options, frame } = &back.kind else { panic!("{what}: {back:#?}") }; + assert_eq!((*fidelity, *e, *options, **frame), (50.0, editing, EnvelopeOptions::NEW, Affine::IDENTITY), "{what}"); + let EnvelopeKind::TopObject { path: outline, corners: Some(corners) } = &**got else { panic!("{what}: {got:?}") }; + let sp = &outline.subpaths[0]; + assert_eq!(sp.anchors.len(), anchors, "{what}: {outline:?}"); + let points = corners.map(|i| sp.anchors[i as usize].p); + let want = [(30.0, 10.0), (170.0, 10.0), (170.0, 90.0), (30.0, 90.0)].map(kurbo::Point::from); + assert!(points.iter().zip(want).all(|(a, b)| a.distance(b) < 1e-6), "{what}: {points:?}"); + assert!(same_top_object_patch(&kind, got), "{what}: {got:?}"); + let colors: Vec> = content.iter().map(|c| c.appearance.fill_paint().color()).collect(); + assert_eq!(colors, [Some(Color::rgb(1.0, 0.0, 0.0)), Some(Color::rgb(0.0, 0.0, 1.0))], "{what}"); + let mut again = Document::new(200.0, 100.0); + let layer = again.layers[0].id; + again.insert(Some(layer), usize::MAX, back.clone()).unwrap(); + let (twice, warnings, notes) = envelope_back(&again); + assert!(!warnings.iter().any(|w| w == writer::ENVELOPES), "{what}, saved again: {warnings:?}"); + let NodeKind::Envelope { kind: twice, .. } = &twice.kind else { panic!("{what}, saved again: {twice:#?} {notes:?}") }; + assert!(same_top_object_patch(&kind, twice), "{what}, saved again: {twice:?}"); + } + } +} + +/// A top object with a top side of 50 segments over a bottom side of one is written live with a grid +/// of 50 columns, the most the reader reads. +#[test] +fn a_top_object_split_to_the_grids_limit_is_read_back_live() { + use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions}; + let kind = EnvelopeKind::TopObject { path: PathData::from_bezpath(&uneven_top_object(50)), corners: None }; + let doc = envelope_doc(kind.clone(), 50.0, false, EnvelopeOptions::NEW); + let text = program(&doc); + assert!(text.contains("[50 1] /Size X#"), "{text}"); + let (back, warnings, notes) = envelope_back(&doc); + assert!(!warnings.iter().any(|w| w == writer::ENVELOPES), "{warnings:?}"); + let NodeKind::Envelope { kind: got, .. } = &back.kind else { panic!("{back:#?} {notes:?}") }; + assert!(same_top_object_patch(&kind, got), "{got:?}"); +} + +/// A top object whose side with fewer segments would share out the content another way once split +/// (two curves that aren't one curve, under three segments) is written as its art, with a warning. +#[test] +fn a_top_object_whose_split_side_would_map_another_way_is_written_as_its_art() { + use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions}; + let mut path = uneven_top_object(3); + path.truncate(5); + path.curve_to((150.0, 110.0), (120.0, 110.0), (100.0, 90.0)); + path.curve_to((80.0, 95.0), (40.0, 80.0), (30.0, 90.0)); + path.close_path(); + let kind = EnvelopeKind::TopObject { path: PathData::from_bezpath(&path), corners: None }; + let doc = envelope_doc(kind, 50.0, false, EnvelopeOptions::NEW); + assert!(!program(&doc).contains("AIDeformOptionsKey")); + let (back, warnings, _) = envelope_back(&doc); + assert!(warnings.iter().any(|w| w == writer::ENVELOPES), "{warnings:?}"); + assert!(matches!(back.kind, NodeKind::Group { .. }), "{back:#?}"); +} + /// An envelope with its contents in an opacity mask's art is written as its art there, and an /// envelope in a group stays live. #[test] diff --git a/crates/eps/src/ai_editing/writer/envelopes.rs b/crates/eps/src/ai_editing/writer/envelopes.rs index dcd7274291..bf01c276de 100644 --- a/crates/eps/src/ai_editing/writer/envelopes.rs +++ b/crates/eps/src/ai_editing/writer/envelopes.rs @@ -8,13 +8,14 @@ //! contents in an `X=` … `X+` block (while they are edited, the grid) as `AIDeformArtKey`, and the //! kind (`AIDeformStyle`: 1 a warp, 2 a mesh, 3 a top object); then the distorted art on plain //! lines, and the plugin's `XP` after it. A top object's outline is the edge of the grid written -//! for it, one grid edge to each segment of its sides. An envelope the reader would open in another -//! shape is written as its distorted art. +//! for it, one grid edge to each segment of its sides, a side with fewer segments than the side +//! across from it split into more. An envelope the reader would open in another shape is written as +//! its distorted art. use std::fmt::Write; use std::sync::Arc; -use kurbo::{Point, Vec2}; +use kurbo::{CubicBez, ParamCurve, Point, Vec2}; use vectorcraft_doc::live::{ Coons, EnvelopeKind, EnvelopeOptions, GradientMesh, H_DOWN, H_LEFT, H_RIGHT, H_UP, MeshPoint, PreserveShape, WARP_NET, envelope_content_boxes, envelope_grid, envelope_stroke_margin, warp_grid, @@ -173,11 +174,44 @@ fn reads_as_written(rows: usize, cols: usize, pts: &[GridPoint]) -> bool { envelope_upright(&mesh).is_some_and(|(r, c, read)| (r as usize, c as usize) == (rows, cols) && read == pts) } +/// `pieces`, one side of a top object, split into `n` pieces: each piece is split into equal spans +/// of its curve parameter, the extra pieces going one at a time to the piece with the longest +/// control polygon for each of its spans. `None` where `n` is fewer pieces than it has, more than +/// [`MAX_GRID`], or a split leaves a piece of no length. +fn split_to(pieces: &[CubicBez], n: usize) -> Option> { + if n < pieces.len() || n > MAX_GRID { + return None; + } + if n == pieces.len() { + return Some(pieces.to_vec()); + } + let polygon = |c: &CubicBez| c.p0.distance(c.p1) + c.p1.distance(c.p2) + c.p2.distance(c.p3); + let lengths: Vec = pieces.iter().map(polygon).collect(); + let mut parts = vec![1usize; pieces.len()]; + for _ in pieces.len()..n { + let most = (0..pieces.len()).max_by(|a, b| (lengths[*a] / parts[*a] as f64).total_cmp(&(lengths[*b] / parts[*b] as f64)))?; + parts[most] += 1; + } + let mut out = Vec::with_capacity(n); + for (c, p) in pieces.iter().zip(parts) { + for j in 0..p { + let piece = c.subsegment(j as f64 / p as f64..(j + 1) as f64 / p as f64); + if [piece.p1, piece.p2, piece.p3].iter().all(|q| q.distance(piece.p0) <= 1e-9) { + return None; + } + out.push(piece); + } + } + Some(out) +} + /// The grid a top object with outline `path` and corners `corners` is written with: its edge runs -/// through the outline's anchors clockwise from the top left corner, one grid edge to a segment, so -/// the top and bottom sides need as many segments as each other, as do the left and right sides; -/// the points inside blend the sides. `None` where that grid's edge, read back as the top object's -/// outline, doesn't give the same patch ([`Coons::from_top_object`]) at every place checked. +/// through the outline's anchors clockwise from the top left corner, one grid edge to a segment, +/// the points inside blending the sides. The top and bottom sides need as many grid edges as each +/// other, as do the left and right sides, so where one side of a pair has fewer segments than the +/// other, its segments are split ([`split_to`]) without changing its shape. `None` where that +/// grid's edge, read back as the top object's outline, doesn't give the same patch +/// ([`Coons::from_top_object`]) at every place checked. fn top_object_grid(path: &PathData, corners: Option<[u32; 4]>) -> Option<(usize, usize, Vec)> { let coons = Coons::from_top_object(path, corners)?; // The outline, as the patch takes it: the largest subpath. @@ -214,10 +248,35 @@ fn top_object_grid(path: &PathData, corners: Option<[u32; 4]>) -> Option<(usize, } }; let gap = |k: usize| (at[(k + 1) % 4] + count - at[k]) % count; - let (cols, rows) = (gap(0), gap(1)); - if gap(2) != cols || gap(3) != rows || cols > MAX_GRID || rows > MAX_GRID { + if (0..4).any(|k| gap(k) > MAX_GRID) { return None; } + // Each side's segments from its corner on, a straight one as a cubic with its handles a third of + // the way along, which the patch spreads evenly along, as it does a straight segment. + let side = |k: usize| -> Vec { + (0..gap(k)) + .map(|j| { + let i = (at[k] + j) % count; + let seg = sp.segment(i); + if sp.segment_is_line(i) { + CubicBez::new(seg.p0, seg.p0.lerp(seg.p3, 1.0 / 3.0), seg.p0.lerp(seg.p3, 2.0 / 3.0), seg.p3) + } else { + seg + } + }) + .collect() + }; + let mut sides = [side(0), side(1), side(2), side(3)]; + // Opposite sides take as many segments as each other: the one with fewer has some split. + for k in 0..2 { + let n = sides[k].len().max(sides[k + 2].len()); + for s in [k, k + 2] { + sides[s] = split_to(&sides[s], n)?; + } + } + let (cols, rows) = (sides[0].len(), sides[1].len()); + let edge: Vec = sides.concat(); + let total = edge.len(); let width = cols + 1; let mut pts: Vec = vec![(Point::ZERO, [Vec2::ZERO; 4]); (rows + 1) * width]; // Where the `k`th anchor from the top left corner goes, and the slots of the handles of the @@ -233,20 +292,15 @@ fn top_object_grid(path: &PathData, corners: Option<[u32; 4]>) -> Option<(usize, (rows - (k - 2 * cols - rows), 0, H_UP, H_DOWN) } }; - for k in 0..count { - let i = (at[0] + k) % count; - let seg = sp.segment(i); - // A straight segment's patch spreads evenly along it, as a cubic's with its handles a third - // of the way does. - let (p1, p2) = if sp.segment_is_line(i) { (seg.p0.lerp(seg.p3, 1.0 / 3.0), seg.p0.lerp(seg.p3, 2.0 / 3.0)) } else { (seg.p1, seg.p2) }; + for (k, seg) in edge.iter().enumerate() { let (r, c, out, into) = place(k); - let (nr, nc, _, _) = place((k + 1) % count); + let (nr, nc, _, _) = place((k + 1) % total); if let Some(q) = pts.get_mut(r * width + c) { q.0 = seg.p0; - q.1[out] = p1 - seg.p0; + q.1[out] = seg.p1 - seg.p0; } if let Some(q) = pts.get_mut(nr * width + nc) { - q.1[into] = p2 - seg.p3; + q.1[into] = seg.p2 - seg.p3; } } // The points inside, blended from the sides. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 296bd84b2b..342eb492b2 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1639,6 +1639,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur SVG import keeps attributes in other applications' XML namespaces (`plotter:pen="2"`) on the objects and layers they belong to, as it keeps their `data-*` attributes: copies, duplicates and `.vectorcraft` files keep them, and SVG export writes them back with each namespace declared once on the root (the rest of #522; `crates/svg/tests/it/foreign_attributes.rs`, `tests/attributes.rs`). A PDF-compatible `.ai` saved with Include Editing Data for Other Apps defines every symbol in its editing copy with its art, registration point and place in the Symbols panel's order, and writes its instances as symbol instances, which VectorCraft's `.ai` reader opens as symbols and instances again (`symbols_and_their_instances_are_read_back` in `crates/eps/src/ai_editing/tests.rs`; [file-format-parity.md](file-format-parity.md) lists the instances written as their art). A PDF-compatible `.ai` saved with Include Editing Data for Other Apps keeps envelopes made with a warp, a mesh or a top object editable: its editing copy holds each envelope's contents, shape, Fidelity, Envelope Options and Edit Contents state in the form VectorCraft's `.ai` envelope reader reads, beside its distorted art, so the file opens with the same live envelope, Edit Contents still switching between the contents and the envelope ([Edit the contents of envelopes](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/edit-the-contents-of-envelopes.html)). Envelopes of type, envelopes in opacity masks, pattern tiles or another envelope's contents, and envelopes the reader would open with another shape or other settings (a Fidelity that isn't a whole number, bounds margins kept with Distort Appearance off), and envelopes after the first 256 in a file are written as their distorted art, with a warning (`envelopes_are_read_back_live` and `envelopes_the_reader_cannot_open_live_are_written_as_their_art` in `crates/eps/src/ai_editing/tests.rs`). + A top object whose top and bottom sides, or left and right sides, have different numbers of segments is also saved live: the side with fewer segments is split into as many as the side across from it has (each segment into equal spans of its curve, a straight one at even steps), up to the 50 a side the grid holds, so the file opens with the same corners and the same mapping of the contents, its outline holding the added anchors. Where the split side would share out the contents another way, as a side of two curves that aren't one curve can, or a side has more than 50 segments, the envelope is written as its distorted art, with the warning (`a_top_object_with_uneven_sides_is_read_back_live`, `a_top_object_split_to_the_grids_limit_is_read_back_live` and `a_top_object_whose_split_side_would_map_another_way_is_written_as_its_art` in `crates/eps/src/ai_editing/tests.rs`). A PDF-compatible `.ai` saved with Include Editing Data for Other Apps writes eligible paths, compound paths and groups with live effects with an art style when the writer supports their appearance and effects (not a filled path that uses the even-odd rule, and none on a large canvas), which VectorCraft's `.ai` reader opens as the same appearance with its effects live (`objects_with_live_effects_are_read_back` and `every_effect_setting_written_on_an_object_is_read_back` in `crates/eps/src/ai_editing/tests.rs`; [file-format-parity.md](file-format-parity.md) lists the objects written without one and the effects they lose). The same holds for the objects of an opacity mask's art, a mask inside another mask's art and a hidden art root included, so a mask whose art has Offset Path opens drawing the image it masks as it did, with the effect live; geometry effects that no art style holds on a mask's art, such as Roughen, are written applied to the art, which opens drawing what it drew (`a_masked_image_with_live_offset_path_round_trips`, `hidden_and_nested_mask_art_keep_live_offset_path` and `a_masks_art_with_an_effect_no_style_holds_is_written_as_it_draws` in `crates/eps/src/ai_editing/tests.rs`). A PDF-compatible `.ai` saved with Include Editing Data for Other Apps writes point type, area type and type on a path as type in its editing copy, and objects next to it keep their own transparency (`crates/eps/src/ai_editing/tests.rs`, `cmd::fileio::tests_pdfedit::type_comes_back_from_the_editing_copy`; [file-format-parity.md](file-format-parity.md) lists what is written as outlines). From 90a2bd0494ed1997e4f169baad01747e7b10161e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 08:54:28 -0800 Subject: [PATCH 03/14] Graphs: aligned paint and stroke rows given with a design's art are rows of its stack paint.setFill, paint.setStroke, paint.invert and paint.complement with an item, stroke.set, stroke.setAdvanced, paint.editGradient, paint.setGradientGeom and the paint.freeform edits read an item given with aligned: true and the art of a graph design in a series first as a row of the art's stack, as appearance.setItem and transparency.set do. Each other object edits the row equal to it and an object without one is left as it is. A row the art does not have, a row of the other kind, or a row no other object has is an error, also when the art is the only object given, and the document and its undo history stay as they are. Without stroke, the gradient commands edit the kind of the art's row. The objects and ids the commands report, a gradient spanning the selection, and the last color and last gradient count only the objects whose row was edited. --- crates/engine/src/cmd/appearance.rs | 63 ++++++++-- crates/engine/src/cmd/effectcmd.rs | 12 +- crates/engine/src/cmd/freeform.rs | 8 +- crates/engine/src/cmd/gradient.rs | 20 +++- crates/engine/src/cmd/graph/tests.rs | 167 +++++++++++++++++++++++++++ crates/engine/src/cmd/paint.rs | 35 ++++-- crates/engine/src/cmd/stroke.rs | 11 +- docs/mcp.md | 10 +- 8 files changed, 280 insertions(+), 46 deletions(-) diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 09b03f2d63..439661b209 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -422,6 +422,15 @@ pub(crate) fn design_reference(s: &Session, p: &Value, cmd: &str) -> Result Result> { + if matches!(item, ItemTarget::Item { explicit: false, .. }) { + return Ok(None); + } + design_reference(s, p, cmd) +} + /// `ids` after `of` (see [`design_reference`]), when there is one. pub(crate) fn with_reference(of: Option, ids: &[NodeId]) -> Vec { of.into_iter().chain(ids.iter().copied()).collect() @@ -606,16 +615,22 @@ fn require_targets(s: &Session, p: &Value, cmd: &str) -> Result> { /// What the edit changes on the topmost visible fill and stroke of graph series marks is stored on /// their graphs in the same step ([`capture_series_changes`]). Nothing targeted is not an edit, and /// when `f` leaves every target as it was, the document and its undo history stay as they are. +/// With `of` (see [`reference_of`]), an explicit aligned `item` is a row of the stack of `of`, which +/// is not among `ids`: it must be there and of the edited kind, each target edits the row equal to +/// it, a target without one is left as it is, and no target with one is an error. Returns the +/// targets `f` was aimed at, in the order of `ids`. +#[allow(clippy::too_many_arguments)] pub(crate) fn edit_items( s: &mut Session, ids: &[NodeId], item: ItemTarget, + of: Option, cmd: &str, label: &str, fill: bool, f: impl FnMut(&mut Node, Option) -> Result<()>, -) -> Result<()> { - edit_items_then(s, ids, item, cmd, label, fill, f, |before, d, edited| Ok(capture_series_changes(before, d, edited))) +) -> Result> { + edit_items_then(s, ids, item, of, cmd, label, fill, f, |before, d, edited| Ok(capture_series_changes(before, d, edited))) } /// [`edit_items`], then `after` on the document as it was before the edit, the edited document and @@ -627,17 +642,29 @@ pub(crate) fn edit_items_then( s: &mut Session, ids: &[NodeId], item: ItemTarget, + of: Option, cmd: &str, label: &str, fill: bool, mut f: impl FnMut(&mut Node, Option) -> Result<()>, mut after: impl FnMut(&vectorcraft_doc::Document, &mut vectorcraft_doc::Document, &[NodeId]) -> Result, -) -> Result<()> { - if ids.is_empty() { - return Ok(()); - } +) -> Result> { + let art = of.and_then(|of| s.doc().ok()?.doc.node(of).map(|n| n.appearance.clone())); + let per = match (item, art) { + // Checked even when the art is the only object given, which leaves `ids` empty. + (ItemTarget::Item { index, explicit: true, aligned: true }, Some(art)) => { + item.resolve(&art, fill, cmd)?; + let per = item.per_target_of(s, of, ids); + if per.iter().all(Option::is_none) { + return Err(bad(cmd, format!("no appearance item {index}"))); + } + per + } + _ if ids.is_empty() => return Ok(Vec::new()), + _ => item.per_target(s, ids), + }; + let aimed: Vec = ids.iter().zip(&per).filter(|(_, item)| item.is_some()).map(|(id, _)| *id).collect(); let before = s.doc()?.doc.clone(); - let per = item.per_target(s, ids); super::overprint::edit_counted(s, label, |d| { let mut edited = Vec::new(); // The objects whose rows `f` edited: on type, no row means its characters. @@ -656,7 +683,13 @@ pub(crate) fn edit_items_then( let labels = super::graph::capture_label_rows(d, &rows); Ok(count_changed(d, &before, &edited) + usize::from(more || labels)) })?; - Ok(()) + Ok(aimed) +} + +/// The first of `ids` that [`edit_items`] aimed at `item` with `of` changes, and the item it changes there. Read it +/// before the edit: once edited, a row that was equal to a row of the art of `of` no longer is. +pub(crate) fn first_edited(s: &Session, item: ItemTarget, of: Option, ids: &[NodeId]) -> Option<(NodeId, ItemTarget)> { + ids.iter().zip(item.per_target_of(s, of, ids)).find_map(|(id, it)| Some((*id, it?))) } /// How many of `ids` differ in `d` from `before`. @@ -665,8 +698,8 @@ pub(crate) fn count_changed(d: &vectorcraft_doc::Document, before: &vectorcraft_ } /// Whether an edit aimed at `item` changes a stroke: the `stroke` param, else the kind of the -/// targeted item (on the object whose row it is, see [`ItemTarget::anchor`]), else `default`. A `stroke` other than true, false or -/// null is an error of command `cmd`. +/// targeted item (on the object whose row it is, see [`ItemTarget::anchor`] and [`reference_of`]), else `default`. A `stroke` other than +/// true, false or null is an error of command `cmd`. pub(crate) fn edits_stroke(s: &Session, p: &Value, item: ItemTarget, default: bool, cmd: &str) -> Result { check_switches(p, &["stroke"], cmd)?; if let Some(b) = p.get("stroke").and_then(Value::as_bool) { @@ -674,8 +707,14 @@ pub(crate) fn edits_stroke(s: &Session, p: &Value, item: ItemTarget, default: bo } let ItemTarget::Item { index, .. } = item else { return Ok(default) }; let d = &s.doc()?.doc; - let ids = drop_design_art(d, appearance_targets(s, p)?); - Ok(ids.get(item.anchor(s, &ids)).and_then(|id| d.node(*id)?.appearance.items.get(index)).map_or(default, |it| !it.is_fill())) + let holder = match reference_of(s, p, item, cmd)? { + Some(of) => Some(of), + None => { + let ids = drop_design_art(d, appearance_targets(s, p)?); + ids.get(item.anchor(s, &ids)).copied() + } + }; + Ok(holder.and_then(|id| d.node(id)?.appearance.items.get(index)).map_or(default, |it| !it.is_fill())) } /// The whole number of 0 or more that `key` holds, read with [`u64_req`] and [`index`]. diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 7619444c74..798b635e65 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -14,7 +14,8 @@ use vectorcraft_geom::{FillRule, PathData}; use vectorcraft_render::effects; use super::appearance::{ - ItemTarget, aligned, appearance_targets, design_reference, edit_targets, index_param, item_indices, item_target, item_target_at, with_reference, + ItemTarget, aligned, appearance_targets, design_reference, edit_targets, index_param, item_indices, item_target, item_target_at, reference_of, + with_reference, }; use super::expand::has_seeded; use super::rasterfx; @@ -336,15 +337,6 @@ pub(crate) fn effect_targets(s: &Session, p: &Value, item: ItemTarget, index: us /// have. const APPENDED: usize = usize::MAX; -/// The [`design_reference`] of an effect edit aimed at `item`: none for the Appearance panel's active row, which is a -/// row of the first selected object's stack. -fn reference_of(s: &Session, p: &Value, item: ItemTarget, cmd: &str) -> Result> { - if matches!(item, ItemTarget::Item { explicit: false, .. }) { - return Ok(None); - } - design_reference(s, p, cmd) -} - #[allow(clippy::too_many_arguments)] fn edit_effects( s: &mut Session, diff --git a/crates/engine/src/cmd/freeform.rs b/crates/engine/src/cmd/freeform.rs index a37cce38d0..c0cb1b5292 100644 --- a/crates/engine/src/cmd/freeform.rs +++ b/crates/engine/src/cmd/freeform.rs @@ -201,7 +201,9 @@ fn edit(s: &mut Session, p: &Value, cmd: &str, mut f: impl FnMut(&mut Freefor first.get_or_insert(r); Ok(()) }; - edit_items(s, &ids, item, cmd, "Gradient", !stroke, |n, index| { + let of = super::appearance::reference_of(s, p, item, cmd)?; + let shown_at = super::appearance::first_edited(s, item, of, &ids); + edit_items(s, &ids, item, of, cmd, "Gradient", !stroke, |n, index| { if index.is_none() && let NodeKind::Text(t) = &mut n.kind { @@ -222,7 +224,9 @@ fn edit(s: &mut Session, p: &Value, cmd: &str, mut f: impl FnMut(&mut Freefor })?; let r = first.ok_or_else(|| bad(cmd, NOT_FREEFORM))?; // The edited gradient is the last one used (`paint.lastGradient`). - let shown = s.doc()?.doc.node(ids[0]).map(|n| super::paint::proxy_paint(n, stroke, item.resolve(&n.appearance, !stroke, cmd).ok().flatten())); + let shown = shown_at.and_then(|(id, item)| { + s.doc().ok()?.doc.node(id).map(|n| super::paint::proxy_paint(n, stroke, item.resolve(&n.appearance, !stroke, cmd).ok().flatten())) + }); if let Some(p) = shown { s.remember_paint(&p); } diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index 7c7724f0f9..a3ebacf42f 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -580,7 +580,10 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result { let new_default = apply_gradient_edit_in_model(&default_paint, &with_stored_stops(&for_default, &default_paint, &stored), None, &|_| true, model) .map_err(|e| bad(C, e))?; let freeform = str_param(p, "kind").and_then(GradientKind::parse) == Some(GradientKind::Freeform); - edit_items(s, &ids, item, C, "Gradient", !stroke, |n, index| { + let of = super::appearance::reference_of(s, p, item, C)?; + let shown_at = super::appearance::first_edited(s, item, of, &ids); + // The objects returned: with a design's art, those with a row equal to the art's. + let edited = edit_items(s, &ids, item, of, C, "Gradient", !stroke, |n, index| { if index.is_none() && let NodeKind::Text(t) = &mut n.kind { @@ -616,9 +619,12 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result { st.gradient_mode = m; } // The last gradient is the one the first object now shows (the defaults' without objects). - let shown = match ids.first() { - Some(id) => s.doc()?.doc.node(*id).map(|n| super::paint::proxy_paint(n, stroke, item.resolve(&n.appearance, !stroke, C).ok().flatten())), - None => Some(new_default.clone()), + let shown = match shown_at { + Some((id, item)) => { + s.doc()?.doc.node(id).map(|n| super::paint::proxy_paint(n, stroke, item.resolve(&n.appearance, !stroke, C).ok().flatten())) + } + None if ids.is_empty() => Some(new_default.clone()), + None => None, }; if let Some(shown) = shown { s.remember_paint(&shown); @@ -628,7 +634,7 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result { } else { s.paint.fill = new_default; } - Ok(json!({"ids": ids.iter().map(|i| i.0).collect::>()})) + Ok(json!({"ids": edited.iter().map(|i| i.0).collect::>()})) } /// A painted stroke on a type run without a weight gets 1 pt. @@ -766,7 +772,9 @@ fn set_gradient_geom(s: &mut Session, p: &Value) -> Result { } let targets = super::appearance::drop_design_art(&s.doc()?.doc, targets); let model = super::paint::new_color_model(s, &Value::Null); - edit_items(s, &targets, item, C, "Gradient", !stroke, |n, index| { + let of = super::appearance::reference_of(s, p, item, C)?; + // The objects returned: with a design's art, those with a row equal to the art's. + let targets = edit_items(s, &targets, item, of, C, "Gradient", !stroke, |n, index| { if index.is_none() && let NodeKind::Text(t) = &mut n.kind { diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index df002ae526..ba059aac4a 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -859,6 +859,173 @@ fn aligned_rows_of_a_graph_designs_art_and_a_different_stack_are_not_moved() { assert_eq!(paints, [art_was.items[1].paint().clone(), art_was.items[0].paint().clone(), art_was.items[1].paint().clone()]); } +/// An `item` given with `aligned: true` and the art of a graph design in a series first is a row of the art's stack in the +/// paint and stroke commands: each other object edits the row equal to it, and a row the art does not have as a fill or +/// stroke as edited, or that no other object has, is an error that leaves the document and its undo history as they are. +#[test] +fn aligned_paint_rows_of_a_graph_designs_art_edit_the_equal_rows_of_the_other_objects() { + let rows = |s: &Session, id: NodeId| (*s.doc().unwrap().doc.node(id).unwrap().appearance).clone(); + let green = Paint::solid(vectorcraft_color::Color::rgb(0.0, 1.0, 0.0)); + // The art's row 1 is the blue fill the mark has as row 0, and its row 2 the black stroke the mark has as row 1. + let (mut s, _, art, mark) = aligned_design_art(); + let black = rows(&s, mark).items[1].clone(); + s.edit("Stroke", |d, _| { + d.node_mut(art).unwrap().appearance.items.push(black.clone()); + Ok(()) + }) + .unwrap(); + let (art_was, mark_was) = (rows(&s, art), rows(&s, mark)); + let fill = |s: &mut Session, cmd: &str, p: serde_json::Value| { + s.execute(cmd, &p).unwrap(); + let now = rows(s, mark); + assert_eq!(rows(s, art), art_was, "{cmd}"); + assert_ne!(now.items[0], mark_was.items[0], "{cmd} edits the mark's blue fill"); + assert_eq!(now.items[1], mark_was.items[1], "{cmd} leaves the mark's stroke"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(rows(s, mark), mark_was, "{cmd} is one undo step"); + now + }; + let ids = [art.0, mark.0]; + let now = fill(&mut s, "paint.setFill", json!({"ids": ids, "item": 1, "aligned": true, "color": "#00ff00"})); + assert_eq!(now.items[0].paint(), &green); + let now = fill(&mut s, "paint.invert", json!({"ids": ids, "item": 1, "aligned": true})); + assert_eq!(Some(s.last_solid), now.items[0].paint().color(), "the inverted color is the last color, not the art's"); + let now = fill(&mut s, "paint.editGradient", json!({"ids": ids, "item": 1, "aligned": true, "kind": "linear"})); + let Paint::Gradient(g) = now.items[0].paint() else { panic!("{:?}", now.items[0].paint()) }; + assert_eq!(&s.last_gradient, &**g, "the mark's new gradient is the last gradient"); + + let stroke = |s: &mut Session, cmd: &str, p: serde_json::Value| { + s.execute(cmd, &p).unwrap(); + let now = rows(s, mark); + assert_eq!(rows(s, art), art_was, "{cmd}"); + assert_eq!(now.items[0], mark_was.items[0], "{cmd} leaves the mark's fill"); + assert_ne!(now.items[1], mark_was.items[1], "{cmd} edits the mark's stroke"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(rows(s, mark), mark_was, "{cmd} is one undo step"); + now + }; + let now = stroke(&mut s, "paint.setStroke", json!({"ids": ids, "item": 2, "aligned": true, "color": "#00ff00"})); + assert_eq!(now.items[1].paint(), &green); + let now = stroke(&mut s, "stroke.set", json!({"ids": ids, "item": 2, "aligned": true, "weight": 5})); + assert_eq!(now.stroke_at(Some(1)).unwrap().width, 5.0); + let now = stroke(&mut s, "stroke.setAdvanced", json!({"ids": ids, "item": 2, "aligned": true, "arrowScale": [200, 200]})); + assert_eq!(now.stroke_at(Some(1)).unwrap().arrow_scale, (200.0, 200.0)); + // Without `stroke`, the kind of the art's row is edited. + let now = stroke(&mut s, "paint.editGradient", json!({"ids": ids, "item": 2, "aligned": true, "kind": "linear"})); + assert!(matches!(now.items[1].paint(), Paint::Gradient(_))); + fill(&mut s, "paint.complement", json!({"ids": ids, "item": 1, "aligned": true, "stroke": false})); + + let steps = s.doc().unwrap().history.undo.len(); + for (cmd, p, said) in [ + ("stroke.set", json!({"ids": ids, "item": 1, "aligned": true, "weight": 5}), "appearance item 1 is not a stroke"), + ("paint.setFill", json!({"ids": ids, "item": 2, "aligned": true, "color": "#00ff00"}), "appearance item 2 is not a fill"), + ("paint.setFill", json!({"ids": ids, "item": 0, "aligned": true, "color": "#00ff00"}), "no appearance item 0"), + ("paint.setFill", json!({"ids": ids, "item": 9, "aligned": true, "color": "#00ff00"}), "no appearance item 9"), + // The art alone: no other object has the row. + ("paint.setFill", json!({"ids": [art.0], "item": 9, "aligned": true, "color": "#00ff00"}), "no appearance item 9"), + ("paint.setFill", json!({"ids": [art.0], "item": 1, "aligned": true, "color": "#00ff00"}), "no appearance item 1"), + ("stroke.set", json!({"ids": [art.0], "item": 1, "aligned": true, "weight": 5}), "appearance item 1 is not a stroke"), + ("paint.editGradient", json!({"ids": [art.0], "item": 1, "aligned": true, "kind": "linear"}), "no appearance item 1"), + ] { + let defaults = format!("{:?}", (&s.paint.fill, &s.paint.stroke, &s.last_gradient)); + let e = s.execute(cmd, &p).unwrap_err().to_string(); + assert!(e.contains(said), "{cmd}: {e}"); + assert_eq!((rows(&s, art), rows(&s, mark)), (art_was.clone(), mark_was.clone()), "{cmd}"); + assert_eq!(s.doc().unwrap().history.undo.len(), steps, "{cmd}"); + assert_eq!(format!("{:?}", (&s.paint.fill, &s.paint.stroke, &s.last_gradient)), defaults, "{cmd}"); + } + + // An object with no row equal to the art's is left out of what the commands report as edited. + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + let rect = *s.doc().unwrap().selection.subjects().first().unwrap(); + s.execute("paint.setFill", &json!({"ids": [rect.0], "color": "#ff00ff"})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [rect.0], "color": "#ff00ff"})).unwrap(); + let rect_was = rows(&s, rect); + let ids = [art.0, mark.0, rect.0]; + for (cmd, p, key, said) in [ + ("paint.setFill", json!({"ids": ids, "item": 1, "aligned": true, "color": "#00ff00"}), "objects", json!(1)), + ("stroke.set", json!({"ids": ids, "item": 2, "aligned": true, "weight": 5}), "objects", json!(1)), + ("paint.editGradient", json!({"ids": ids, "item": 1, "aligned": true, "kind": "linear"}), "ids", json!([mark.0])), + ("paint.setGradientGeom", json!({"ids": ids, "item": 1, "aligned": true, "start": [0, 0], "end": [10, 40]}), "ids", json!([mark.0])), + ] { + if cmd == "paint.setGradientGeom" { + s.execute("paint.editGradient", &json!({"ids": [mark.0], "item": 0, "kind": "linear"})).unwrap(); + let gradient = rows(&s, mark).items[0].clone(); + s.edit("Gradient", |d, _| { + d.node_mut(art).unwrap().appearance.items[1] = gradient.clone(); + Ok(()) + }) + .unwrap(); + } + let r = s.execute(cmd, &p).unwrap(); + assert_eq!(r[key], said, "{cmd}: {r}"); + assert_eq!(rows(&s, rect), rect_was, "{cmd}"); + s.execute("edit.undo", &json!({})).unwrap(); + } + + // A gradient spanning the selection spans only the objects that take it: a distant object without an equal row does + // not move it. + s.execute("shape.rectangle", &json!({"x": 5000, "y": 5000, "width": 10, "height": 10})).unwrap(); + let far = *s.doc().unwrap().selection.subjects().first().unwrap(); + s.execute("paint.setFill", &json!({"ids": [far.0], "color": "#ff00ff"})).unwrap(); + let far_was = rows(&s, far); + let gradient = json!({"stops": [{"offset": 0, "color": "#000000"}, {"offset": 1, "color": "#ffffff"}]}); + let mut placed = Vec::new(); + for ids in [vec![art.0, mark.0], vec![art.0, mark.0, far.0]] { + s.execute("paint.setFill", &json!({"ids": ids, "item": 1, "aligned": true, "gradient": gradient, "span": "selection"})).unwrap(); + placed.push(rows(&s, mark).items[0].clone()); + assert_eq!(rows(&s, far), far_was); + s.execute("edit.undo", &json!({})).unwrap(); + } + assert!(matches!(placed[0].paint(), Paint::Gradient(_))); + assert_eq!(placed[1], placed[0]); +} + +/// The gradient and freeform gradient commands read an `item` given with `aligned: true` and the art of a graph design in +/// a series first as a row of the art's stack, as the paint commands do. +#[test] +fn aligned_gradient_rows_of_a_graph_designs_art_edit_the_equal_rows_of_the_other_objects() { + let rows = |s: &Session, id: NodeId| (*s.doc().unwrap().doc.node(id).unwrap().appearance).clone(); + for kind in ["linear", "freeform"] { + let (mut s, _, art, mark) = aligned_design_art(); + // The mark's fill and the art's equal row become the same gradient. + s.execute("paint.editGradient", &json!({"ids": [mark.0], "item": 0, "kind": kind})).unwrap(); + let gradient = rows(&s, mark).items[0].clone(); + // A yellow fill above the mark's gradient is the mark's row 1, as the gradient is the art's row 1. + let mut yellow = gradient.clone(); + *yellow.paint_mut() = Paint::solid(vectorcraft_color::Color::rgb(1.0, 1.0, 0.0)); + s.edit("Gradient", |d, _| { + d.node_mut(art).unwrap().appearance.items[1] = gradient.clone(); + d.node_mut(mark).unwrap().appearance.items.insert(1, yellow.clone()); + Ok(()) + }) + .unwrap(); + let ids = [art.0, mark.0]; + let edits = if kind == "linear" { + vec![ + ("paint.setGradientGeom", json!({"ids": ids, "item": 1, "aligned": true, "start": [0, 0], "end": [10, 40]})), + ("paint.editGradient", json!({"ids": ids, "item": 1, "aligned": true, "kind": "radial"})), + ] + } else { + vec![("paint.freeform.addPoint", json!({"ids": ids, "item": 1, "aligned": true, "at": [5, 5]}))] + }; + for (cmd, p) in edits { + let (art_was, mark_was) = (rows(&s, art), rows(&s, mark)); + s.execute(cmd, &p).unwrap(); + let now = rows(&s, mark); + assert_eq!(rows(&s, art), art_was, "{cmd}"); + assert_ne!(now.items[0], mark_was.items[0], "{cmd} edits the mark's gradient fill"); + assert_eq!(now.items[1..], mark_was.items[1..], "{cmd}"); + s.execute("edit.undo", &json!({})).unwrap(); + // The edited gradient is the last gradient (paint.setGradientGeom does not set it). + if cmd != "paint.setGradientGeom" { + let Paint::Gradient(g) = now.items[0].paint() else { panic!("{cmd}") }; + assert_eq!(&s.last_gradient, &**g, "{cmd}: the mark's edited gradient is the last gradient"); + } + } + } +} + /// The paint commands leave the art of a graph design in a series out, a copy of the design given by id with an item /// included. #[test] diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 02875517f7..b4090cad29 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -538,7 +538,14 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) let item = item_target(s, p, cmd)?.of_kind(s, fill); let selected = item.targets(s, p)?; let ids = super::graph::paint_targets(&s.doc()?.doc, &selected); - let paint = if span { placed_across(&s.doc()?.doc, &ids, paint, p, fill) } else { paint }; + let of = super::appearance::reference_of(s, p, item, cmd)?; + let paint = if span { + // With a design's art, the gradient spans only the objects with a row equal to the art's, which take it. + let spanned: Vec = ids.iter().zip(item.per_target_of(s, of, &ids)).filter_map(|(id, it)| it.map(|_| *id)).collect(); + placed_across(&s.doc()?.doc, &spanned, paint, p, fill) + } else { + paint + }; // The gradient mesh points picked with the Mesh tool or Direct Selection: a solid fill // recolours just those points of their meshes. let mesh_points = if fill && matches!(paint, Paint::Solid { .. }) { picked_mesh_points(s, &ids) } else { vec![] }; @@ -555,10 +562,12 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) // The type objects whose characters took the paint (no row targeted): only those change what a graph's labels keep // for their characters; a row edit is kept with the label's rows. let characters = std::cell::RefCell::new(Vec::new()); - edit_items_then( + // The objects counted: with a design's art, those with a row equal to the art's. + let ids = edit_items_then( s, &ids, item, + of, cmd, label, fill, @@ -1004,26 +1013,34 @@ fn proxy_recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn( return Ok(json!({ "changed": changed as usize })); } let item = item_target(s, p, cmd)?.of_kind(s, !stroke); - let (r, shown_item) = match item { + let (r, shown_at) = match item { ItemTarget::Item { index, .. } => { - let ids = super::appearance::drop_design_art(&s.doc()?.doc, item.targets(s, p)?); + let targets = super::appearance::drop_design_art(&s.doc()?.doc, item.targets(s, p)?); + let of = super::appearance::reference_of(s, p, item, cmd)?; + // With design art first, the row shown is the one edited in the first object that has it. + let shown_at = match of { + Some(_) => super::appearance::first_edited(s, item, of, &targets).and_then(|(id, it)| match it { + ItemTarget::Item { index, .. } => Some((id, Some(index))), + ItemTarget::Top => None, + }), + None => ids.first().map(|id| (*id, Some(index))), + }; let mut changed = 0; - edit_items(s, &ids, item, cmd, label, !stroke, |n, i| { + edit_items(s, &targets, item, of, cmd, label, !stroke, |n, i| { let paint = if stroke { n.appearance.stroke_at_mut(i).map(|l| &mut l.paint) } else { n.appearance.fill_at_mut(i).map(|l| &mut l.paint) }; changed += paint.is_some_and(|paint| super::colorcmds::map_paint(paint, f)) as usize; Ok(()) })?; - (json!({ "changed": changed }), Some(index)) + (json!({ "changed": changed }), shown_at) } // The proxy recolors its paints, including a colorized image's fill, without changing image pixels or pattern tiles. ItemTarget::Top => { let q = json!({"fill": !stroke, "stroke": stroke, "includeImages": false, "includePatterns": false, "ids": ids.iter().map(|i| i.0).collect::>()}); - (super::colorcmds::recolor(s, &q, cmd, label, f)?, None) + (super::colorcmds::recolor(s, &q, cmd, label, f)?, leaf_targets(s, &ids)?.first().map(|id| (*id, None))) } }; - let shown_ids = if shown_item.is_some() { ids } else { leaf_targets(s, &ids)? }; - if let Some(shown) = shown_ids.first().and_then(|id| s.doc().ok()?.doc.node(*id).map(|n| proxy_paint(n, stroke, shown_item))) { + if let Some(shown) = shown_at.and_then(|(id, at)| s.doc().ok()?.doc.node(id).map(|n| proxy_paint(n, stroke, at))) { s.remember_paint(&shown); } Ok(r) diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index 2a0159930d..5ee137b399 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -22,7 +22,7 @@ pub fn specs() -> Vec { "Stroke Options", ["Window", "Stroke"], None, - "{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one; refused when the path fills no area, parts thinner than a millionth of a head unit counting as none, or has more than 4096 anchors, more than 4096 subpaths or a point more than 64 head units from the tip) (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does). On a path directly in a graph series, the graph also stores a weight it changes on the topmost visible stroke (docs/mcp.md); on a graph label's stroke row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind → {objects: how many objects took the stroke options}", + "{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one; refused when the path fills no area, parts thinner than a millionth of a head unit counting as none, or has more than 4096 anchors, more than 4096 subpaths or a point more than 64 head units from the tip) (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection; with the art of a graph design in a series given first, rows of the art's stack: docs/mcp.md)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does). On a path directly in a graph series, the graph also stores a weight it changes on the topmost visible stroke (docs/mcp.md); on a graph label's stroke row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind → {objects: how many objects took the stroke options}", has_doc, stroke_set ), @@ -303,7 +303,9 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result { st.profile = pr.clone(); } }; - edit_items(s, &ids, item, C, "Stroke", false, |n, index| { + let of = super::appearance::reference_of(s, p, item, C)?; + // The objects counted: with a design's art, those with a row equal to the art's. + let ids = edit_items(s, &ids, item, of, C, "Stroke", false, |n, index| { if index.is_none() && let NodeKind::Text(t) = &mut n.kind { @@ -372,7 +374,7 @@ fn stroke_step(s: &mut Session, p: &Value, up: bool) -> Result { } let ids = super::appearance::drop_design_art(&s.doc()?.doc, ids); let label = if up { "Stroke Weight Up" } else { "Stroke Weight Down" }; - edit_items(s, &ids, item, c, label, false, |n, index| { + edit_items(s, &ids, item, None, c, label, false, |n, index| { if index.is_none() && let NodeKind::Text(t) = &mut n.kind { @@ -535,7 +537,8 @@ fn stroke_advanced(s: &mut Session, p: &Value) -> Result { { arrows_and_profile(st); } - edit_items(s, &ids, item, C, "Stroke", false, |n, index| { + let of = super::appearance::reference_of(s, p, item, C)?; + edit_items(s, &ids, item, of, C, "Stroke", false, |n, index| { let Some(st) = n.appearance.stroke_at_mut(index) else { return Ok(()) }; arrows_and_profile(st); if let Some(b) = &brush { diff --git a/docs/mcp.md b/docs/mcp.md index 7b13e9eb68..435e1d9629 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -829,9 +829,13 @@ given), `threeD.mapGraphic`, do not use the Appearance panel's active row. When the first object given is design art, rows and effects given with `aligned: true` are rows of its stack in `appearance.setItem`, `appearance.removeItem`, `appearance.duplicateItem`, `transparency.set` with an `item`, `effect.apply`, `appearance.addEffect` and the other `effect.*` commands that edit, -move or remove an effect: each other object edits the row or effect equal to it, and an object without one is left as -it is. When no object has one, `appearance.setItem` and `appearance.removeItem` leave the document as it is, as they do -for a row no object has, and the other commands are an error. `appearance.moveItem`, `effect.move` and +move or remove an effect, `paint.setFill`, `paint.setStroke`, `paint.invert` and `paint.complement` with an `item`, +`stroke.set`, `stroke.setAdvanced`, `paint.editGradient`, `paint.setGradientGeom` and the `paint.freeform.*` commands +that edit points and lines: each other object edits the row or effect equal to it, and an object without one is left +as it is. When no object has one, `appearance.setItem` and `appearance.removeItem` leave the document as it is, as they +do for a row no object has, and the other commands are an error. The paint, stroke and gradient commands are also an +error when the art's row is not the kind of row they edit, and `paint.editGradient`, `paint.setGradientGeom` and the +`paint.freeform.*` commands without `stroke` edit the kind of the art's row. `appearance.moveItem`, `effect.move` and `appearance.duplicateItem` with `to` are an error when the art's stack differs from the other objects', and `appearance.duplicateItem` with `to` is also an error when two or more other objects are given, as it is without the art. To change the art, edit the design with `graph.design`. From 9578b61d286e34ce7c8cb73da5dfaba9aa69c970 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 09:27:38 -0800 Subject: [PATCH 04/14] Graphs: invert and complement remember the color of the first object they recolor With a graph design's art given first and an item without aligned, the art is left out, but paint.invert and paint.complement remembered the art's unchanged color as the last color. They now read the first object they recolor. --- crates/engine/src/cmd/graph/tests.rs | 6 ++++++ crates/engine/src/cmd/paint.rs | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index cefadcda76..a48f012e40 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -890,6 +890,9 @@ fn aligned_paint_rows_of_a_graph_designs_art_edit_the_equal_rows_of_the_other_ob assert_eq!(now.items[0].paint(), &green); let now = fill(&mut s, "paint.invert", json!({"ids": ids, "item": 1, "aligned": true})); assert_eq!(Some(s.last_solid), now.items[0].paint().color(), "the inverted color is the last color, not the art's"); + // Without `aligned`, the art is left out and the item is a row of the mark. + let now = fill(&mut s, "paint.invert", json!({"ids": ids, "item": 0})); + assert_eq!(Some(s.last_solid), now.items[0].paint().color(), "the mark's inverted color is the last color, not the art's"); let now = fill(&mut s, "paint.editGradient", json!({"ids": ids, "item": 1, "aligned": true, "kind": "linear"})); let Paint::Gradient(g) = now.items[0].paint() else { panic!("{:?}", now.items[0].paint()) }; assert_eq!(&s.last_gradient, &**g, "the mark's new gradient is the last gradient"); @@ -926,6 +929,9 @@ fn aligned_paint_rows_of_a_graph_designs_art_edit_the_equal_rows_of_the_other_ob ("paint.setFill", json!({"ids": [art.0], "item": 1, "aligned": true, "color": "#00ff00"}), "no appearance item 1"), ("stroke.set", json!({"ids": [art.0], "item": 1, "aligned": true, "weight": 5}), "appearance item 1 is not a stroke"), ("paint.editGradient", json!({"ids": [art.0], "item": 1, "aligned": true, "kind": "linear"}), "no appearance item 1"), + // The weight steps take no row: they step each object's active or top stroke. + ("stroke.weightUp", json!({"ids": ids, "item": 2}), "unknown parameter `item`"), + ("stroke.weightDown", json!({"ids": ids, "aligned": true}), "unknown parameter `aligned`"), ] { let defaults = format!("{:?}", (&s.paint.fill, &s.paint.stroke, &s.last_gradient)); let e = s.execute(cmd, &p).unwrap_err().to_string(); diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 91e1efdbe3..6e2071d7f8 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -1025,7 +1025,7 @@ fn proxy_recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn( ItemTarget::Item { index, .. } => Some((id, Some(index))), ItemTarget::Top => None, }), - None => ids.first().map(|id| (*id, Some(index))), + None => targets.first().map(|id| (*id, Some(index))), }; let mut changed = 0; edit_items(s, &targets, item, of, cmd, label, !stroke, |n, i| { From 56cbdbb09c8372d08bd33b978100d97b64304006 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 11:11:20 -0800 Subject: [PATCH 05/14] MCP: SVG output under read roots refuses to name a linked image file outside them With automation read roots in force, an SVG or SVGZ that would name a linked image's file outside them is refused before any file, folder or reply is written, with "a linked image file is outside the read roots", which names no file or folder. An names the file with images: link, and with embed when the document holds no pixels for the image. With preserveEditing, the native document the SVG carries names every linked image and placed file, the relative paths kept of them and each data set's image, so those are checked too, and a stored brush entry that does not read as a brush is refused because its links cannot be checked. - The check runs where the linked JavaScript check runs: document.serialize, document.export, document.exportSelection, document.exportForScreens and assets.export, every SVG and SVGZ save, file.exportAs and the SVG Options dialog's Show Code. It also covers clipboard.exportSvg, and a Copy that an automation request asks for puts no SVG on the system clipboard when it would name such a file. - Only the art each file writes is checked: the selected objects with selectedOnly and Export Selection, the art over each chosen artboard, hidden layers and objects only when hiddenLayers is on (a save keeps them by default), never template layers or the symbol or opacity-mask editing layer, and the symbols, patterns and brushes that art uses. An image colorized with a solid fill whose pixels are gray or missing names no file, so it is not checked outside Preserve Editing. - Embedded pixels, a file inside the roots, a relative path that leads inside them and a URL are written as before, and the person at the keyboard exports every link as stored. Docs: docs/mcp.md, docs/target-app-parity.md and the params text; de and nl rows for the new message, next to the JavaScript message's rows. --- crates/engine/src/cmd/clipboard.rs | 29 +- crates/engine/src/cmd/fileio.rs | 3 +- crates/engine/src/cmd/fileio/access.rs | 43 +++ crates/engine/src/cmd/fileio/commands.rs | 8 +- crates/engine/src/cmd/fileio/export.rs | 8 +- crates/engine/src/cmd/fileio/save.rs | 16 +- crates/engine/src/cmd/fileio/screens.rs | 7 + crates/engine/src/cmd/fileio/svg.rs | 14 + crates/engine/src/cmd/links.rs | 107 ++++++- crates/engine/src/tests/links.rs | 347 ++++++++++++++++++++++ crates/mcp/src/ui_catalog.rs | 2 +- crates/ui-egui/src/dialogs/svg_options.rs | 50 +++- crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/io/export.rs | 1 + crates/ui-egui/src/menus/ui_commands.rs | 2 +- crates/ui-egui/src/sysclip.rs | 5 +- crates/ui-egui/src/tests/automation.rs | 40 +++ docs/mcp.md | 15 + docs/target-app-parity.md | 1 + 20 files changed, 678 insertions(+), 22 deletions(-) diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index 446634d21c..4e086569ae 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -32,7 +32,7 @@ use crate::DocState; pub fn specs() -> Vec { let mut v = vec![ - cmd!(query "clipboard.exportSvg", "Clipboard as SVG", [], None, "{} → {svg} the copied objects as standalone SVG (null when the clipboard is empty)", always, export_svg), + cmd!(query "clipboard.exportSvg", "Clipboard as SVG", [], None, "{} → {svg} the copied objects as standalone SVG (null when the clipboard is empty). Under automation read roots it is refused while the SVG would name a linked image's file outside them (an image whose pixels the clipboard doesn't hold; a relative path is followed from the document the objects came from while it is open)", always, export_svg), cmd!( query "clipboard.importSvg", "Load SVG into Clipboard", @@ -435,14 +435,37 @@ impl Clipboard { } impl Session { - /// The internal clipboard as standalone SVG (`None` when it is empty). + /// The internal clipboard as standalone SVG (`None` when it is empty, and when the SVG would name + /// a linked image's file the caller asking now may not be shown: [`Self::clipboard_svg_refused`]). pub fn clipboard_svg(&self) -> Option { + if self.clipboard_svg_refused() { + return None; + } self.clipboard.svg() } + + /// Would the internal clipboard's SVG name a linked image's file outside the read roots in + /// force? It names the file of an image whose pixels the clipboard doesn't hold. A relative + /// path is followed from the document the objects came from while it is open; otherwise it + /// counts as outside. + pub fn clipboard_svg_refused(&self) -> bool { + self.check_clipboard_svg("clipboard.exportSvg").is_err() + } + + /// [`Self::clipboard_svg_refused`] as `cmd`'s error, which names no file or folder. + fn check_clipboard_svg(&self, cmd: &str) -> Result<()> { + if crate::file_access::current().is_none() || !self.clipboard.has_art() { + return Ok(()); + } + let from = self.docs.iter().find(|d| Some(d.uid) == self.clipboard.source_doc); + let base = from.map_or_else(super::links::LinkBase::default, super::links::LinkBase::of); + super::fileio::check_images_in(cmd, "svg", &self.clipboard.to_document(), base, &json!({})) + } } fn export_svg(s: &mut Session, _: &Value) -> Result { - Ok(json!({ "svg": s.clipboard_svg() })) + s.check_clipboard_svg("clipboard.exportSvg")?; + Ok(json!({ "svg": s.clipboard.svg() })) } fn import_svg(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 84fb121d0c..aa811b06b0 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -121,9 +121,10 @@ mod commands; mod export_names; mod formats; +pub(crate) use access::check_images_in; #[cfg(not(target_arch = "wasm32"))] pub(crate) use access::list_dir; -pub use access::{check_linked_scripts, write_file_with, write_save_file, write_save_file_with}; +pub use access::{check_linked_images, check_linked_scripts, write_file_with, write_save_file, write_save_file_with}; #[cfg(all(test, not(target_arch = "wasm32")))] mod tests_readonly; diff --git a/crates/engine/src/cmd/fileio/access.rs b/crates/engine/src/cmd/fileio/access.rs index 203a2262b0..cb5b4e85cc 100644 --- a/crates/engine/src/cmd/fileio/access.rs +++ b/crates/engine/src/cmd/fileio/access.rs @@ -139,6 +139,49 @@ pub fn check_linked_scripts(cmd: &str, format: &str, st: &crate::DocState) -> Re Ok(()) } +/// The error for an SVG that would name a linked image's file outside the read roots. +pub(crate) const IMAGE_OUTSIDE: &str = "a linked image file is outside the read roots"; + +/// `cmd` may write `d`, the document an output of `st` writes (all of it, or the objects it +/// exports), in format `format` (a format id) with options `p`: any format but SVG and SVGZ, and +/// those too unless read roots are in force and the file would name a linked image's file outside +/// them (a relative path taken from `st`'s folder; a URL is not checked). An `` names the +/// file with SVG Options › Images set to Link, and with Embed when the document holds no pixels for +/// the image, for the images of the art each file draws (with chosen artboards, the art over each +/// one; hidden layers and objects only with `hiddenLayers`, template layers and the opacity-mask +/// and symbol editing layers never) and of the symbols, patterns and brushes that art uses; with +/// Preserve Editing Capabilities, the native document the file carries names every linked file, the relative paths kept of them +/// and each data set's image. The error names no file or folder. +pub fn check_linked_images(cmd: &str, format: &str, st: &crate::DocState, d: &vectorcraft_doc::Document, p: &Value) -> Result<()> { + check_images_in(cmd, format, d, crate::cmd::links::LinkBase::of(st), p) +} + +/// [`check_linked_images`] for `d`, a document whose links are looked for from `base` (the +/// objects on the clipboard, looked for from the document they came from). +pub(crate) fn check_images_in(cmd: &str, format: &str, d: &vectorcraft_doc::Document, base: crate::cmd::links::LinkBase, p: &Value) -> Result<()> { + if !matches!(format, "svg" | "svgz") || crate::file_access::current().is_none() { + return Ok(()); + } + // Options that don't read as the encoder's choices count as Link, Preserve Editing and hidden + // layers kept: the encoder refuses them anyway. + let (link, preserve, hidden) = match super::svg::options_map(p) { + Ok(m) => { + let on = |k: &str| m.get(k).is_some_and(|v| v.as_bool() != Some(false)); + (m.get("images").is_some_and(|v| v.as_str() != Some("embed")), on("preserveEditing"), on("hiddenLayers")) + } + Err(_) => (true, true, true), + }; + // The opacity-mask and symbol editing layers are never written. + let d = &*d.without_edit_modes(); + let names = |d: &vectorcraft_doc::Document| crate::cmd::links::svg_names_hidden_image(d, base, link, hidden); + if preserve && crate::cmd::links::names_hidden_file(d, base) + || names(d) && super::svg::drawn(d, p).is_none_or(|files| files.iter().any(|f| names(f))) + { + return Err(bad(cmd, IMAGE_OUTSIDE)); + } + Ok(()) +} + /// `path` checked for writing against the automation roots in force. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn may_write(path: &str) -> Result<()> { diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index ca74243699..b1c9cf5e2d 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -27,7 +27,7 @@ pub fn specs() -> Vec { "Serialize Document", [], None, - "{format?: vectorcraft (default)|template|svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd (ait, a .ait template, is written by file.saveAsTemplate), …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is not used, and warnings name it. Under automation read roots, svg and svgz are refused while the document links a JavaScript file outside them (svgInteractivity; a URL is not checked)", + "{format?: vectorcraft (default)|template|svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd (ait, a .ait template, is written by file.saveAsTemplate), …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is not used, and warnings name it. Under automation read roots, svg and svgz are refused while the document links a JavaScript file outside them (svgInteractivity; a URL is not checked), or while the file would name a linked image's file outside them (images: link, preserveEditing, or an image whose pixels the document doesn't hold; a URL is not checked)", has_doc, export::serialize ), @@ -36,7 +36,7 @@ pub fn specs() -> Vec { "Export Document", [], None, - "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin., which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; pdf/ai: without editing data, with a warning, and ai refuses pdfCompatible: false; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts, variableData} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), …hpgl writes a plot of the artboard as pen moves for plotters and cutters (each visible path once along its line in plotter units; useArtboards: true one file per chosen artboard, false the bounds of the visible art), hpglVersion?: hpgl (default: IN, SP, PU, PD) | hpgl2 (also NP and PC pen colors, each pen's paths as one encoded polyline PE), curveTolerance?: 0.1 (points, 0.01 to 10), pens?: [\"#rrggbb\"…] (pen 1 first, up to 255; each path takes the nearest; default a pen per color in the order the art first draws in it, numbered alike in every file), origin?: bottomLeft (default) | center, sortPaths?: false (each pen's next path the nearest), insideFirst?: false (a path after the pen's paths that lie inside it), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is not used, and warnings name it. Under automation read roots, svg and svgz are refused while the document links a JavaScript file outside them (svgInteractivity; a URL is not checked). Never changes the document's path", + "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin., which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; pdf/ai: without editing data, with a warning, and ai refuses pdfCompatible: false; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts, variableData} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), …hpgl writes a plot of the artboard as pen moves for plotters and cutters (each visible path once along its line in plotter units; useArtboards: true one file per chosen artboard, false the bounds of the visible art), hpglVersion?: hpgl (default: IN, SP, PU, PD) | hpgl2 (also NP and PC pen colors, each pen's paths as one encoded polyline PE), curveTolerance?: 0.1 (points, 0.01 to 10), pens?: [\"#rrggbb\"…] (pen 1 first, up to 255; each path takes the nearest; default a pen per color in the order the art first draws in it, numbered alike in every file), origin?: bottomLeft (default) | center, sortPaths?: false (each pen's next path the nearest), insideFirst?: false (a path after the pen's paths that lie inside it), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is not used, and warnings name it. Under automation read roots, svg and svgz are refused while the document links a JavaScript file outside them (svgInteractivity; a URL is not checked), or while the file would name a linked image's file outside them (images: link, preserveEditing, or an image whose pixels the document doesn't hold; a URL is not checked). Never changes the document's path", has_doc, export::export ), @@ -45,7 +45,7 @@ pub fn specs() -> Vec { "Export Selection…", ["File"], None, - "{path?, format?: png|jpg|webp|svg|svgz|pdf|ai|gif|png8|tiff|bmp|tga|psd|txt|dxf|eps|emf|wmf|hpgl|vectorcraft|template (default: from the extension, else png), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), …the format's options} the selected objects (template layers left out), each in the layers and groups that hold it, whose clipping paths, opacity masks, effects, opacity and blend modes apply as on the canvas (an object of a blend, envelope, repeat or intertwine on its own; txt: the selected type alone), cropped to the bounds the renderer measures for that art (a clip group by its clip) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is not used, and warnings name it. Under automation read roots, svg and svgz are refused while the document links a JavaScript file outside them, as document.export refuses them. In the app, with no params or {}, the save panel picks the file and its format's options dialog follows (Save PDF, SVG Options, the raster options, EPS Options, DXF Options, Text Export Options or an exporter plug-in's parameters; other formats are written at once), whose OK writes the selection: the call returns null while the dialog is open, and OK returns what file.exportAs returns, without format or bounds (Text Export Options: {path})", + "{path?, format?: png|jpg|webp|svg|svgz|pdf|ai|gif|png8|tiff|bmp|tga|psd|txt|dxf|eps|emf|wmf|hpgl|vectorcraft|template (default: from the extension, else png), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), …the format's options} the selected objects (template layers left out), each in the layers and groups that hold it, whose clipping paths, opacity masks, effects, opacity and blend modes apply as on the canvas (an object of a blend, envelope, repeat or intertwine on its own; txt: the selected type alone), cropped to the bounds the renderer measures for that art (a clip group by its clip) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is not used, and warnings name it. Under automation read roots, svg and svgz are refused while the document links a JavaScript file outside them, or while the file would name a linked image's file outside them (an image among the selected objects), as document.export refuses them. In the app, with no params or {}, the save panel picks the file and its format's options dialog follows (Save PDF, SVG Options, the raster options, EPS Options, DXF Options, Text Export Options or an exporter plug-in's parameters; other formats are written at once), whose OK writes the selection: the call returns null while the dialog is open, and OK returns what file.exportAs returns, without format or bounds (Text Export Options: {path})", has_selection, export::export_selection ), @@ -54,7 +54,7 @@ pub fn specs() -> Vec { "Export for Screens", ["File", "Export"], None, - "{folder?, zip?: false (one .zip of every file, each deflated or stored, whichever is smaller: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboard?: index | artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), includeArtboardFill?: true (Include Artboard Fill: each artboard's background color behind its art; false leaves it out), pdfs?: multiple|single (Export PDFs As: with several artboards, single writes them as the pages of one PDF per PDF row, named after the document), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with for the document, for the artboard, asset, layer or object, from 1, #### for the counter zero-padded to the run of #, , , (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, ,