diff --git a/Cargo.lock b/Cargo.lock index 32fcfc62ad..267130fc41 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6824,6 +6824,7 @@ version = "0.24.1" dependencies = [ "craft-docpath", "craft-svg", + "data-url", "flate2", "image", "kurbo 0.13.1", diff --git a/ROADMAP.md b/ROADMAP.md index 283d7d7dff..1de46b4b4d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: Raster effects 85% → 88%, gallery thumbnails and in-dialog preview in; headline numbers unchanged; Symbols area ~87% → ~88%: dynamic instance parts take opacity, blend, gradient and pattern on the canvas; G5 docs sync for Overlap and Pattern auto corners; previously minor: Tools stays beside the canvas with left-side panels; Hindi catalog draft complete under the translation tests; contributor loanword terminology reviewed; wider proofreading welcome; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-14 · **Change:** minor (Tech Circuit brush library, G5; previously: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: Raster effects 85% → 88%, gallery thumbnails and in-dialog preview in; headline numbers unchanged; Symbols area ~87% → ~88%: dynamic instance parts take opacity, blend, gradient and pattern on the canvas; G5 docs sync for Overlap and Pattern auto corners; previously minor: Tools stays beside the canvas with left-side panels; Hindi catalog draft complete under the translation tests; contributor loanword terminology reviewed; wider proofreading welcome; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON @@ -125,6 +125,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap ## Progress log - 2026-11-14: Built-in symbol library Weather Icons: adds a sixth procedural symbol library (Window › Symbol Libraries, the Symbols panel's Symbol Libraries Menu) containing six original vector symbols (Sun, Moon, Cloud, Rain, Thunder, Snow) generated in code without default 9-slice guides (part of Gap 5). Headline numbers unchanged. +- 2026-11-14: Built-in Tech Circuit brush library: Circuit Trace (chamfered art with 45-degree elbows and terminal pads), Bus Lines (parallel conductor pattern) and Terminal Nodes (repeating solder pads, test points and leads), all original geometry generated in code. Ten built-in brush libraries; G5 remaining estimate 3–13 h (estimated), headline numbers unchanged. - 2026-11-05: Window › Toolbars › Keep Tools Beside the Canvas, off by default, docks the Tools panel between left-side panels and the canvas instead of at the window's left edge, and the canvas's left edge then also docks a floating Tools panel (`window.toolsBesideCanvas`, #5395). Workspaces keep the setting; workspaces saved without it keep Tools at the window's edge. An optional VectorCraft layout choice; headline numbers unchanged. - 2026-11-12: Stencil Bridges in the Cutting Tools add-on (Object › Plug-ins › Cutting Tools): strips of a set width are cut out of the selected art so that every island of a stencil cut from it (the inside of an O, a hole in a shape) stays attached to the sheet, by the shortest crossings first, vertical, horizontal or the shortest among 12 directions 15° apart, with up to 16 bridges per island; type is measured by its glyph outlines and stays type, and the original goes to a hidden layer or is replaced. One undo step (`addon.cutting.bridges`). A VectorCraft addition: headline numbers unchanged. @@ -224,6 +225,7 @@ The full earlier record of what landed is kept in [docs/roadmap.md](docs/roadmap | Date | Change | Summary | |---|---|---| +| 2026-11-14 | minor | Tech Circuit adds three original procedural brushes (Circuit Trace, Bus Lines, Terminal Nodes); ten built-in brush libraries. G5 remaining estimate 3–13 h (estimated); parity scores and headline totals unchanged. | | 2026-11-14 | minor | Gujarati (#6613): contributor proofreading completed for all 387 additions, with 11 wording corrections; 4,673 reviewed catalog rows. Status/error messages remain partial; web selection requires a bundled Gujarati face. Scores and estimates unchanged. | | 2026-11-10 | minor | Tidy-up, no content change: the unheaded change bullets at the top of the file and a revision row inside the headline-numbers table move to this table under their commit dates. Scores and estimates unchanged. | | 2026-11-10 | minor | Raster effects: Effect Gallery thumbnails and an in-dialog preview (an original drawing through each filter). Features-table Raster effects 85% → 88%, hours 1–2 → none. Headline numbers unchanged, so the progress log is unchanged. | diff --git a/crates/color/src/cms/icc.rs b/crates/color/src/cms/icc.rs index 0b09062b84..f6a2316b89 100644 --- a/crates/color/src/cms/icc.rs +++ b/crates/color/src/cms/icc.rs @@ -172,6 +172,21 @@ impl IccProfile { Some(dst.map(|x| x.clamp(0.0, 1.0))) } + /// Device pixels `v` (RGB / CMYK / Gray, 0..1, the profile's channels a pixel, any number of + /// pixels) → sRGB in `dst` (three values a pixel), converted with `intent` in one pass of the + /// cached transform. `None` when `v` doesn't hold whole pixels, `dst` doesn't hold three values + /// for each, or no transform can be built. + pub fn to_srgb_pixels(&self, v: &[f32], dst: &mut [f32], intent: Intent) -> Option<()> { + let n = self.channels(); + if !v.len().is_multiple_of(n) || dst.len() != v.len() / n * 3 { + return None; + } + let xf = self.to_xf(intent)?; + xf.transform(v, dst).ok()?; + dst.iter_mut().for_each(|x| *x = x.clamp(0.0, 1.0)); + Some(()) + } + /// sRGB → device values. pub fn from_srgb(&self, rgb: [f32; 3], intent: Intent) -> Option> { let xf = self.inverse_xf(intent)?; diff --git a/crates/color/src/cms/tests_icc.rs b/crates/color/src/cms/tests_icc.rs index 1d312c09c3..b2920c7c7b 100644 --- a/crates/color/src/cms/tests_icc.rs +++ b/crates/color/src/cms/tests_icc.rs @@ -48,6 +48,28 @@ fn gray_is_srgb_toned() { } } +#[test] +fn many_pixels_convert_as_each_one_does() { + let p = IccProfile::from_bytes(None, &icc_bytes(DISPLAY_P3).unwrap()).unwrap(); + let px = [[1.0f32, 0.0, 0.0], [0.2, 0.6, 0.9], [0.5, 0.5, 0.5], [0.0, 1.0, 0.0]]; + for intent in Intent::ALL { + let v: Vec = px.iter().flatten().copied().collect(); + let mut dst = vec![0.0; v.len()]; + p.to_srgb_pixels(&v, &mut dst, intent).unwrap(); + for (i, rgb) in px.iter().enumerate() { + let one = p.to_srgb(rgb, intent).unwrap(); + assert!(dst[i * 3..i * 3 + 3].iter().zip(one).all(|(a, b)| (a - b).abs() < 1e-5), "{intent:?} {rgb:?}: {dst:?} vs {one:?}"); + } + } + // Lengths that aren't whole pixels, or a destination of another size, are refused. + assert!(p.to_srgb_pixels(&[0.5; 4], &mut [0.0; 3], Intent::Perceptual).is_none()); + assert!(p.to_srgb_pixels(&[0.5; 3], &mut [0.0; 6], Intent::Perceptual).is_none()); + let gray = IccProfile::from_bytes(None, &icc_bytes(GRAY).unwrap()).unwrap(); + let mut dst = [0.0; 6]; + gray.to_srgb_pixels(&[0.25, 0.75], &mut dst, Intent::RelativeColorimetric).unwrap(); + assert!(dst.iter().zip([0.25, 0.25, 0.25, 0.75, 0.75, 0.75]).all(|(a, b)| (a - b).abs() < 0.01), "{dst:?}"); +} + #[test] fn encoded_profiles_are_reproducible() { assert_eq!(icc::builtin_rgb_bytes(DISPLAY_P3).unwrap(), icc::builtin_rgb_bytes(DISPLAY_P3).unwrap()); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index d6b797a371..b32a4485f4 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -120,6 +120,7 @@ pub use style_libs::StyleLibrary; pub use text::FontHeightReference; pub use text::GlyphChoice; pub use text::GlyphForm; +pub use text::PATH_LEAD_MAX; pub use text::WarichuAlign; pub use text::{ AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, HyphenationOptions, InlineArt, diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index 20d0623d4d..6b16973cf1 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -358,6 +358,34 @@ mod tests { assert!((p.x - 25.0).abs() < 1e-6 && (p.y - 25.0).abs() < 1e-6, "{p:?}"); } + #[test] + fn coons_places_are_where_the_patch_bends() { + // A rectangle's sides have 65 evenly spaced points. + let [u, v] = Coons::rect(Rect::new(0.0, 0.0, 100.0, 50.0)).places(); + assert_eq!((u.len(), v.len()), (65, 65)); + assert!(u.iter().enumerate().all(|(i, x)| (x - i as f64 / 64.0).abs() < 1e-12), "{u:?}"); + // A top object with a curved top: between two places next to each other the patch is + // straight, in `u` and in `v`. + let mut path = kurbo::BezPath::new(); + path.move_to((0.0, 0.0)); + path.curve_to((30.0, -20.0), (70.0, 10.0), (100.0, 0.0)); + path.line_to((100.0, 50.0)); + path.line_to((40.0, 50.0)); + path.line_to((0.0, 50.0)); + path.close_path(); + let c = Coons::from_top_object(&PathData::from_bezpath(&path), Some([0, 1, 2, 4])).unwrap(); + let [u, v] = c.places(); + assert!(u.first() == Some(&0.0) && u.last() == Some(&1.0) && u.windows(2).all(|w| w[0] < w[1]), "{u:?}"); + assert!(v.first() == Some(&0.0) && v.last() == Some(&1.0) && v.windows(2).all(|w| w[0] < w[1]), "{v:?}"); + for w in u.windows(2) { + for y in [0.0, 0.3, 1.0] { + let mid = c.eval((w[0] + w[1]) / 2.0, y); + let half = c.eval(w[0], y).midpoint(c.eval(w[1], y)); + assert!(mid.distance(half) < 1e-9, "{w:?} {y}: {mid:?} {half:?}"); + } + } + } + #[test] fn maps_go_on_past_the_content_box() { // A stroke outline reaches past the box: a flat mesh and a rectangle stay the identity there. diff --git a/crates/doc/src/live/mesh.rs b/crates/doc/src/live/mesh.rs index b6d5bbc88d..ef0a9a908d 100644 --- a/crates/doc/src/live/mesh.rs +++ b/crates/doc/src/live/mesh.rs @@ -338,6 +338,31 @@ impl Coons { let end = |side: &[Point]| side.last().copied().unwrap_or_default(); [self.top[0], end(&self.top), end(&self.bottom), self.bottom[0]] } + + /// The places in `u` where the top or bottom side has a point, and the places in `v` where the + /// left or right side has one, rising from 0 to 1. Between two places next to each other, each + /// side runs straight, so [`Self::eval`] changes linearly in `u` and in `v` inside each cell of + /// the grid they make. + pub fn places(&self) -> [Vec; 2] { + let sides = [&self.top, &self.right, &self.bottom, &self.left]; + let at = |which: usize| -> Vec { + match self.knots.as_deref().and_then(|k| k.get(which)) { + Some(knots) => knots.clone(), + None => { + let last = sides[which].len().saturating_sub(1).max(1); + (0..=last).map(|i| i as f64 / last as f64).collect() + } + } + }; + let merge = |a: usize, b: usize| { + let mut v = at(a); + v.extend(at(b)); + v.sort_by(f64::total_cmp); + v.dedup(); + v + }; + [merge(0, 2), merge(1, 3)] + } } /// The most points one side of [`Coons::from_top_object`] holds while it adds points between its diff --git a/crates/doc/src/pattern.rs b/crates/doc/src/pattern.rs index d1333bef0b..b2c00d8ab3 100644 --- a/crates/doc/src/pattern.rs +++ b/crates/doc/src/pattern.rs @@ -173,6 +173,10 @@ pub struct PatternDef { /// Size Tile to Art: the tile is the art bounds grown by the spacing. #[serde(default)] pub size_tile_to_art: bool, + /// Move Tile with Art: in pattern editing mode, a move of all of the tile art moves the tile by + /// the same offset. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub move_tile_with_art: bool, #[serde(default)] pub h_spacing: f64, #[serde(default)] @@ -202,6 +206,7 @@ impl PatternDef { art, overlap: Overlap::default(), size_tile_to_art: false, + move_tile_with_art: false, h_spacing: 0.0, v_spacing: 0.0, copies: 5, diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 89388d8598..06b71f8fc8 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -29,6 +29,7 @@ pub use para_style::{ Burasagari, Composer, FirstBaseline, HyphenationOptions, JustificationOptions, Justify, Kashida, Kinsoku, KinsokuShoriType, LeadingModel, Mojikumi, ParaDirection, ParaStyle, SingleWordJustify, }; +pub use runs::PATH_LEAD_MAX; pub use runs::{INLINE_CHAR, INLINE_MAX_SCALE, InlineArt, RubyAlign, RubyPosition, RubySpan, TextKind, TextRun, TextStyleDef, TypeAntiAlias}; pub use tabs_lists::{ Bullet, DEFAULT_TAB_INTERVAL, LIST_HANGING_INDENT, LIST_LEVEL_INDENT, LIST_LEVELS, ListAlign, ListCounts, NumberStyle, ParaList, TabAlign, diff --git a/crates/doc/src/text/char_style.rs b/crates/doc/src/text/char_style.rs index 97f744dba8..41fbf168ae 100644 --- a/crates/doc/src/text/char_style.rs +++ b/crates/doc/src/text/char_style.rs @@ -298,6 +298,15 @@ impl GlyphForm { GlyphForm::QuarterWidth => b"qwid", }) } + + /// Is this one of the monospaced width forms (`fwid`, `hwid`, `twid`, `qwid`)? A font may + /// draw them with other glyphs (`GSUB`) or with other widths for the same glyphs (`GPOS`), and + /// they set the characters on a fixed width, so Proportional Metrics (`palt`) is not applied + /// with them. + /// + pub fn is_width(self) -> bool { + matches!(self, GlyphForm::FullWidth | GlyphForm::HalfWidth | GlyphForm::ThirdWidth | GlyphForm::QuarterWidth) + } } /// A glyph chosen for one character ([`CharStyle::glyph_alternate`]): the glyph id and the face it @@ -785,6 +794,15 @@ impl CharStyle { self.position = self.position.in_range(); self.small_caps = self.small_caps.map(small_caps_in_range); } + /// Sets the East Asian glyph form ([`CharStyle::glyph_form`]). A form other than the default + /// also takes away the character's glyph choice ([`CharStyle::glyph_alternate`]), which would + /// otherwise keep drawing its own glyph; the default form keeps it. + pub fn set_glyph_form(&mut self, form: GlyphForm) { + self.glyph_form = form; + if form != GlyphForm::Default { + self.glyph_alternate = None; + } + } } impl crate::Document { diff --git a/crates/doc/src/text/object.rs b/crates/doc/src/text/object.rs index 9af4d9c5dc..b787cebfe0 100644 --- a/crates/doc/src/text/object.rs +++ b/crates/doc/src/text/object.rs @@ -352,7 +352,10 @@ impl TextObject { } /// Flip type on a path to the other side of its path (Type on a Path Options › Flip, or its /// centre bracket dragged across the path): the path runs the other way and the brackets swap - /// ends, so the type keeps its stretch of the path. False, changing nothing, for other type. + /// ends, so the type keeps its stretch of the path. Type that starts before an open path's start + /// ([`TextKind::path_text_start`]) starts at its start bracket after the flip: the part of its + /// span before the path's start would come after the path's end. False, changing nothing, for + /// other type. pub fn flip_on_path(&mut self) -> bool { let Some((s, e)) = self.kind.path_span() else { return false }; let TextKind::OnPath { path, start, end } = &mut self.kind else { return false }; diff --git a/crates/doc/src/text/runs.rs b/crates/doc/src/text/runs.rs index 0bf62ed155..5bf6d1bf79 100644 --- a/crates/doc/src/text/runs.rs +++ b/crates/doc/src/text/runs.rs @@ -93,8 +93,10 @@ pub enum TextKind { /// Area type flowed inside `frame` (document coordinates, untransformed by `xf`). Area { frame: PathData }, /// Type on a path, flowing from its start bracket `start` to its end bracket `end` (0..1 of the - /// path length; no `end`: the end of the path, or once round a closed path). See - /// [`TextKind::path_span`]. + /// path length; no `end`: the end of the path, or once round a closed path). On an open path + /// `start` may be below 0: the type starts that far before the path's start, and the characters + /// whose middle falls before the path's start are not drawn ([`TextKind::path_text_start`]). + /// See [`TextKind::path_span`]. OnPath { path: PathData, start: f64, @@ -117,8 +119,22 @@ impl TextKind { (s, end.map_or(1.0, |e| fraction(e).max(s))) }) } + /// Where type on a path's text starts, as a fraction of its path's length: its start bracket + /// ([`Self::path_span`]), or on an open path a start before the path's start (below 0, at most + /// [`PATH_LEAD_MAX`] path lengths before it). None for other type. + pub fn path_text_start(&self) -> Option { + let TextKind::OnPath { path, start, .. } = self else { return None }; + if !path.is_closed() && start.is_finite() && *start < 0.0 { Some(start.max(-PATH_LEAD_MAX)) } else { self.path_span().map(|(s, _)| s) } + } + /// Does type on an open path start before its path's start ([`Self::path_text_start`])? + pub fn starts_before_path(&self) -> bool { + self.path_text_start().is_some_and(|s| s < 0.0) + } } +/// How far before its path's start type on an open path may start, in lengths of the path. +pub const PATH_LEAD_MAX: f64 = 1e6; + /// Move anchors `refs` of `path`, in text space, by `d` in document space (`to_text`: the /// document → text map). False when an anchor doesn't exist. pub(super) fn move_anchors(path: &mut PathData, to_text: Affine, refs: &[(usize, usize)], d: Vec2) -> bool { diff --git a/crates/doc/src/text/tests.rs b/crates/doc/src/text/tests.rs index c3afafb6e1..0976edbb93 100644 --- a/crates/doc/src/text/tests.rs +++ b/crates/doc/src/text/tests.rs @@ -563,6 +563,33 @@ fn area_anchors_move_in_document_space() { assert_eq!(t, before); } +/// Type on an open path may start before the path's start: the start is kept, saved and read back, +/// while the start bracket stays at the path's start. On a closed path, and for a start that is not +/// finite, the start is the start bracket as before. +#[test] +fn type_on_an_open_path_starts_before_the_path() { + let pts = [Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0)]; + let on = |closed: bool, start: f64| TextKind::OnPath { + path: PathData::new(vec![vectorcraft_geom::SubPath::polyline(&pts, closed)]), + start, + end: None, + }; + let open = on(false, -0.25); + assert_eq!(open.path_text_start(), Some(-0.25)); + assert!(open.starts_before_path()); + assert_eq!(open.path_span(), Some((0.0, 1.0))); + let saved = serde_json::to_value(&open).unwrap(); + assert_eq!(saved["start"], -0.25); + assert_eq!(serde_json::from_value::(saved).unwrap(), open); + assert_eq!(on(false, -1e9).path_text_start(), Some(-PATH_LEAD_MAX)); + assert_eq!(on(false, f64::NEG_INFINITY).path_text_start(), Some(0.0)); + assert_eq!(on(false, 0.25).path_text_start(), Some(0.25)); + assert!(!on(false, 0.0).starts_before_path()); + assert_eq!(on(true, -0.25).path_text_start(), Some(0.0)); + assert!(!on(true, -0.25).starts_before_path()); + assert_eq!(TextKind::Point.path_text_start(), None); +} + /// Builds from before [`Justify::Auto`] read new type's alignment as Left or Right (and ignore /// `justify_auto`); this build reads Auto back. #[test] diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 99b8e1ecdd..313531bb99 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -828,6 +828,7 @@ pub(crate) use edit::{settle_interaction, typing_in_progress}; pub(crate) use object::bounds_rewrite; pub(crate) use object::{keep_aligned_in, refresh_pixel_aligned}; pub use plugin_call::{Location, ParamsPlace, key_location, location_in, location_keys}; +pub use select::SIMILAR; pub(crate) use vectorcraft_doc::range::parse_range; /// A date param in Unix seconds that defaults to the clock: `None` when absent (now, see diff --git a/crates/engine/src/cmd/addons/typetools.rs b/crates/engine/src/cmd/addons/typetools.rs index 10a00b1b45..d0c5447c42 100644 --- a/crates/engine/src/cmd/addons/typetools.rs +++ b/crates/engine/src/cmd/addons/typetools.rs @@ -61,7 +61,7 @@ pub fn specs() -> Vec { "Release Type on a Path…", ["Object", "Plug-ins", "Type Tools"], None, - "{mode?: \"both\" (point type and the path) | \"path\" (the path only) | \"text\" (point type only) (\"both\"), keepOriginal?: false (true: the type on a path stays and the released objects go just above it), ids?: [id…], id? (instead of ids; neither: the selection)} split each type on a path among the objects (inside groups too) into point type and the path it was set on. The point type holds the same text with the same character and paragraph attributes, on one straight line whose baseline starts where the first character sat on the path; Type on a Path Options (effect, align to path, spacing, flip, brackets) don't apply to point type and are dropped; type with Dynamic Text on (type.dynamicText) comes out with it off, at the sizes it was drawn at. The path keeps its exact shape (open or closed, anchors and handles, the object's transform) and comes out with no fill and no stroke, in the type's layer and place in the stacking order. Other objects are left alone, and with no type on a path among them nothing changes and no undo step is made. One undo step; the released objects are selected → {ids (released, bottom to top), paths, texts}", + "{mode?: \"both\" (point type and the path) | \"path\" (the path only) | \"text\" (point type only) (\"both\"), keepOriginal?: false (true: the type on a path stays and the released objects go just above it), ids?: [id…], id? (instead of ids; neither: the selection)} split each type on a path among the objects (inside groups too) into point type and the path it was set on. The point type holds the same text with the same character and paragraph attributes, on one straight line whose baseline starts where the first character sat on the path (when the type starts before an open path's start, the first character drawn on the path stays where it was drawn and the characters that were not drawn come before it); Type on a Path Options (effect, align to path, spacing, flip, brackets) don't apply to point type and are dropped; type with Dynamic Text on (type.dynamicText) comes out with it off, at the sizes it was drawn at. The path keeps its exact shape (open or closed, anchors and handles, the object's transform) and comes out with no fill and no stroke, in the type's layer and place in the stacking order. Other objects are left alone, and with no type on a path among them nothing changes and no undo step is made. One undo step; the released objects are selected → {ids (released, bottom to top), paths, texts}", has_selection, release_path ), @@ -229,6 +229,28 @@ fn first_pen(lay: &vectorcraft_text::TextLayout) -> Option { lay.glyphs.iter().filter(|g| g.origin.x.is_finite() && g.origin.y.is_finite()).min_by_key(|g| (g.byte, g.len != 0)).map(|g| g.origin) } +/// A glyph of a character: its byte, whether it stands for characters (a list marker does not), +/// and its place among the glyphs of that byte and kind (an All Caps ß is two glyphs). +type GlyphKey = (usize, bool, usize); + +/// The key and pen position of the first glyph drawn in `lay`, in text order: characters before an +/// open path's start are not drawn ([`vectorcraft_text::PositionedGlyph::off_path`]). None without +/// a drawn glyph. +fn first_drawn_pen(lay: &vectorcraft_text::TextLayout) -> Option<(GlyphKey, Point)> { + let (gi, g) = (lay.glyphs.iter().enumerate()) + .filter(|(_, g)| !g.off_path && g.origin.x.is_finite() && g.origin.y.is_finite()) + .min_by_key(|(gi, g)| (g.byte, g.len != 0, *gi))?; + let kind = g.len != 0; + let nth = lay.glyphs.iter().take(gi).filter(|h| h.byte == g.byte && (h.len != 0) == kind).count(); + Some(((g.byte, kind, nth), g.origin)) +} + +/// The pen position of the glyph `key` names in `lay`. None without one. +fn pen_at(lay: &vectorcraft_text::TextLayout, (byte, kind, nth): GlyphKey) -> Option { + let g = lay.glyphs.iter().filter(|g| g.byte == byte && (g.len != 0) == kind).nth(nth)?; + (g.origin.x.is_finite() && g.origin.y.is_finite()).then_some(g.origin) +} + /// `t`, type on a path, as point type on one straight line: the same runs and character and /// paragraph attributes, its baseline starting where its first character sat on the path (in the /// object's own text space, so its transform stays). Type on a Path Options are dropped, and @@ -237,13 +259,14 @@ fn first_pen(lay: &vectorcraft_text::TextLayout) -> Option { fn released_text(t: &TextObject) -> Option { let TextKind::OnPath { path, .. } = &t.kind else { return None }; let db = vectorcraft_text::FontDb::global(); - // Where the first character sat. Text that didn't fit on the path has no glyph there: it - // starts at the start bracket. - let at = first_pen(&vectorcraft_text::layout(db, t)).or_else(|| { + // Where the first drawn character sat; characters before an open path's start come before it + // on the line. Text that didn't fit on the path has no glyph there: it starts at the start + // bracket. + let (key, at) = first_drawn_pen(&vectorcraft_text::layout(db, t)).map(|(b, p)| (Some(b), p)).or_else(|| { let ap = ArcPath::new(path); let (from, _) = t.kind.path_span()?; let (p, _) = ap.at(from * ap.len()); - (p.x.is_finite() && p.y.is_finite()).then_some(p) + (p.x.is_finite() && p.y.is_finite()).then_some((None, p)) })?; let mut out = t.clone(); // Point type has no brackets to fit: it keeps the sizes the path drew it at. @@ -254,9 +277,10 @@ fn released_text(t: &TextObject) -> Option { out.path_spacing = 0.0; // Point type is in no thread: its first paragraph starts in it. out.first_para_continues = false; - // Where the point type puts its own first character; the origin moves so it lands at `at`, - // whatever the paragraph's alignment and direction. - let own = first_pen(&vectorcraft_text::layout(db, &out)).unwrap_or(Point::ZERO); + // Where the point type puts that glyph; the origin moves so it lands at `at`, whatever the + // paragraph's alignment and direction. + let own_lay = vectorcraft_text::layout(db, &out); + let own = key.and_then(|k| pen_at(&own_lay, k)).or_else(|| first_pen(&own_lay)).unwrap_or(Point::ZERO); let shift = at - own; if !(shift.x.is_finite() && shift.y.is_finite()) { return None; diff --git a/crates/engine/src/cmd/addons/typetools/tests.rs b/crates/engine/src/cmd/addons/typetools/tests.rs index e597525c97..a8518719be 100644 --- a/crates/engine/src/cmd/addons/typetools/tests.rs +++ b/crates/engine/src/cmd/addons/typetools/tests.rs @@ -482,6 +482,44 @@ mod release { } } + /// Type that starts before its open path: the first drawn character stays where it was drawn, + /// and the characters that were not drawn come before it on the line. + #[test] + fn type_starting_before_its_open_path_keeps_its_drawn_characters_in_place() { + let mut s = session(); + let id = path_type(&mut s, "M100 300 L500 300", [100.0, 300.0], "Hidden start"); + s.execute("type.pathOptions", &json!({"start": -0.05})).unwrap(); + let before = text(&s, id); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &before); + let first = lay.glyphs.iter().filter(|g| !g.off_path).min_by_key(|g| g.byte).unwrap(); + let (byte, drawn) = (first.byte, before.xf * first.origin); + assert!(byte > 0, "some characters are before the path"); + s.execute(C, &json!({"mode": "text"})).unwrap(); + let after = text(&s, id); + assert_eq!(after.kind, TextKind::Point); + assert_eq!(after.plain_text(), before.plain_text()); + let back = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &after); + let same = back.glyphs.iter().find(|g| g.byte == byte).unwrap(); + assert!(close(after.xf * same.origin, drawn), "{:?} vs {drawn:?}", after.xf * same.origin); + let lead = back.glyphs.iter().find(|g| g.byte == 0).unwrap(); + assert!((after.xf * lead.origin).x < drawn.x, "the hidden characters come before it"); + // An All Caps ß is two glyphs: with the first before the path's start, the second stays + // where it was drawn. + let id = path_type(&mut s, "M100 400 L500 400", [100.0, 400.0], "ße"); + s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 4, "allCaps": true})).unwrap(); + let adv = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(&s, id)).glyphs[0].advance; + s.execute("type.pathOptions", &json!({"start": -1.2 * adv / 400.0})).unwrap(); + let before = text(&s, id); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &before); + assert!(lay.glyphs[0].byte == 0 && lay.glyphs[1].byte == 0 && lay.glyphs[0].off_path && !lay.glyphs[1].off_path); + let drawn = before.xf * lay.glyphs[1].origin; + s.execute(C, &json!({"mode": "text"})).unwrap(); + let after = text(&s, id); + let back = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &after); + let second = back.glyphs.iter().filter(|g| g.byte == 0).nth(1).unwrap(); + assert!(close(after.xf * second.origin, drawn), "{:?} vs {drawn:?}", after.xf * second.origin); + } + #[test] fn flipped_type_releases_its_path_as_it_runs() { let mut s = session(); diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index b9b517ae85..256e585923 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -48,7 +48,7 @@ pub fn specs() -> Vec { "Add New Fill", ["Window", "Appearance"], Some("Cmd+/"), - "{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row, with its top fill's paint (type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids`, no `id` and no `target: \"contents\"`, the new fill takes that row's paint, goes right above it, and becomes the active row; appearance.duplicateItem copies a whole row. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", + "{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row, with its top fill's paint (type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids`, no `id` and no `target: \"contents\"`, the new fill takes that row's paint, goes right above it, and becomes the active row; appearance.duplicateItem copies a whole row. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, |s, p| add_item(s, p, true) ), @@ -57,7 +57,7 @@ pub fn specs() -> Vec { "Add New Stroke", ["Window", "Appearance"], Some("Cmd+Alt+/"), - "{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row, with its top stroke's paint (else the default stroke) and that stroke's weight when it has paint, at least 1 pt. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", + "{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row, with its top stroke's paint (else the default stroke) and that stroke's weight when it has paint, at least 1 pt. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, |s, p| add_item(s, p, false) ), @@ -66,7 +66,7 @@ pub fn specs() -> Vec { "Clear Appearance", ["Window", "Appearance"], None, - "{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", + "{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, clear_appearance ), @@ -75,7 +75,7 @@ pub fn specs() -> Vec { "Reduce to Basic Appearance", ["Window", "Appearance"], None, - "{ids?|id?, target?} keep only one visible fill and stroke (without ids: the Appearance panel's active row in place of the topmost one of its kind; else the topmost), without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", + "{ids?|id?, target?} keep only one visible fill and stroke (without ids: the Appearance panel's active row in place of the topmost one of its kind; else the topmost), without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, reduce_basic ), @@ -84,7 +84,7 @@ pub fn specs() -> Vec { "Appearance Item", [], None, - "{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), 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)} edit one fill/stroke of each target object's own appearance stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", + "{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), 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)} edit one fill/stroke of each target object's own appearance stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, set_item ), @@ -93,7 +93,7 @@ pub fn specs() -> Vec { "Remove Item", [], None, - "{index | indices: [..] (paint-order item indices), ids?|id?, target?, 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)} remove those fills/strokes from each target object's own stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", + "{index | indices: [..] (paint-order item indices), ids?|id?, target?, 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)} remove those fills/strokes from each target object's own stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, remove_item ), @@ -102,7 +102,7 @@ pub fn specs() -> Vec { "Add Effect", [], None, - "{…as effect.apply} same as effect.apply (an alias): {effect: id, params?: {…}, item?: appearance item index|null, ids?, target?} → {ids, index, item}", + "{…as effect.apply} same as effect.apply (an alias): {effect: id, params?: {…}, item?: appearance item index|null, ids?, target?, aligned?} → {ids, index, item}", has_doc, super::effectcmd::apply ), @@ -111,7 +111,7 @@ pub fn specs() -> Vec { "Duplicate Item", ["Window", "Appearance"], None, - "{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, 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)} copy fills/strokes with their effects, as Alt-dragging rows places them. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", + "{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, 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)} copy fills/strokes with their effects, as Alt-dragging rows places them. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, duplicate_item ), @@ -120,7 +120,7 @@ pub fn specs() -> Vec { "Reorder Appearance Item", [], None, - "{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above. On a path directly in a graph series (a series group's id stands for those paths, except with from \"contents\", which moves the group's own row), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {index: where the item landed | contents: the row's slot}", + "{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ, or differ from that of a graph design's art given first)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above. On a path directly in a graph series (a series group's id stands for those paths, except with from \"contents\", which moves the group's own row), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {index: where the item landed | contents: the row's slot}", has_doc, move_item ), @@ -129,7 +129,7 @@ pub fn specs() -> Vec { "Eyedropper", [], None, - "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes using effective character dimensions while preserving target transforms) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art); with an object of a dynamic symbol instance selected by Direct Selection and no ids, reverse, append or defaultsOnly, that object takes the source's fill and stroke (solid, none, gradient or pattern) for this instance only, as the Eyedropper Options allow, and a source that is not a path, compound path or type, or options that pick up neither color, are refused. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores, for every label of its kind, the character attributes the pick gives all of its text, its object opacity and blend mode, and with `append` its topmost visible fill and stroke rows → {ids}", + "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes using effective character dimensions while preserving target transforms) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art); with an object of a dynamic symbol instance selected by Direct Selection and no ids, reverse, append or defaultsOnly, that object takes the source's fill and stroke (solid, none, gradient or pattern) for this instance only, as the Eyedropper Options allow, and a source that is not a path, compound path or type, or options that pick up neither color, are refused. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores, for every label of its kind, the character attributes the pick gives all of its text, its object opacity and blend mode, and with `append` its topmost visible fill and stroke rows → {ids}", has_doc, copy_from ), @@ -147,7 +147,7 @@ pub fn specs() -> Vec { "Show All Hidden Attributes", ["Window", "Appearance"], None, - "{ids?|id?, target?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {ids}", + "{ids?|id?, target?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {ids}", has_doc, show_all_hidden ), @@ -165,7 +165,7 @@ pub fn specs() -> Vec { "Move Appearance", [], None, - "{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds. On a path directly in a graph series (a series group's id stands for those paths; as `source`, when the group has no effect, opacity, blend mode, isolation, knockout or style link of its own, the topmost one is read and a move clears them all; as `target` each takes the appearance and the group is cleared, a `source` among them keeps its look, and a `source` that is the group's only path is an error), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {source, target}", + "{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds. On a path directly in a graph series (a series group's id stands for those paths; as `source`, when the group has no effect, opacity, blend mode, isolation, knockout or style link of its own, the topmost one is read and a move clears them all; as `target` each takes the appearance and the group is cleared, a `source` among them keeps its look, and a `source` that is the group's only path is an error), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {source, target}", has_doc, transfer ), @@ -183,14 +183,21 @@ pub(crate) struct ActiveItem { impl Session { /// The method returns explicit target parameters and the selected appearance effect list. pub fn appearance_effect_target(&self, params: &Value) -> Option<(Value, &[vectorcraft_doc::Effect])> { - let mut ids = appearance_targets(self, params).ok()?; + let mut ids = drop_design_art(&self.active()?.doc, appearance_targets(self, params).ok()?); let item = item_target(self, params, "effect.apply").ok()?; let anchor = item.anchor(self, &ids); - let node = self.active()?.doc.node(*ids.get(anchor)?)?; + // Aligned effects given with a graph design's art first are effects of the art's stack, which stays first; the + // Appearance panel's active row is a row of the first selected object's. + let of = match item { + ItemTarget::Item { explicit: false, .. } => None, + _ => design_reference(self, params, "effect.apply").ok()?, + }; + let own = *ids.get(anchor)?; + let node = self.active()?.doc.node(of.unwrap_or(own))?; let at = item.effects_item(&node.appearance, "effect.apply").ok()?; let effects = node.appearance.effects_at(at)?; ids.swap(0, anchor); - let ids: Vec = ids.into_iter().map(|id| id.0).collect(); + let ids: Vec = with_reference(of, &ids).into_iter().map(|id| id.0).collect(); let aligned = match item { ItemTarget::Item { aligned, .. } => aligned, ItemTarget::Top => aligned(params, "effect.apply").ok()?, @@ -253,6 +260,10 @@ pub(crate) fn item_target_at(s: &Session, p: &Value, key: &str, cmd: &str) -> Re .ok_or_else(|| bad(cmd, format!("`{key}` must be an appearance item index (paint order) or null"))), // The active item is a row of the first selected object's own stack. None if p.get("ids").is_some() || p.get("id").is_some() || targets_contents(p)? => Ok(ItemTarget::Top), + // A row of a graph design's art in a series stands for nothing: the commands leave that art out. + None if s.active().and_then(|st| st.selection.subjects().first().map(|id| super::graph::is_design_art(&st.doc, *id))) == Some(true) => { + Ok(ItemTarget::Top) + } None => Ok(s.appearance_item().map_or(ItemTarget::Top, |index| ItemTarget::Item { index, explicit: false, aligned: true })), } } @@ -300,6 +311,21 @@ impl ItemTarget { .collect() } + /// [`ItemTarget::per_target`], with an explicit aligned item a row of the stack of `of` (see [`design_reference`]), + /// which is not among `ids`, when there is one: each target edits the item equal to it there, and a target without + /// an equal item is left out (`None`). + pub(crate) fn per_target_of(self, s: &Session, of: Option, ids: &[NodeId]) -> Vec> { + let ItemTarget::Item { index, explicit: true, aligned: true } = self else { return self.per_target(s, ids) }; + let Some(d) = s.active().map(|st| &st.doc) else { return self.per_target(s, ids) }; + let Some(r) = of.and_then(|of| d.node(of)) else { return self.per_target(s, ids) }; + ids.iter() + .map(|id| { + let index = SharedStack::of(&[&*r.appearance, &*d.node(*id)?.appearance]).item_in(index, 1)?; + Some(ItemTarget::Item { index, explicit: true, aligned: true }) + }) + .collect() + } + /// Where among `ids` (the targets, in paint order) the object whose stack this item is a row of /// is: the first selected object (or the compound path holding it) for the active item, which /// need not paint first; the first target otherwise, or when that object is not among them. @@ -384,6 +410,41 @@ pub(crate) fn items_per_target(d: &vectorcraft_doc::Document, ids: &[NodeId], id } } +/// The art of a graph design in a graph series whose stack the aligned rows of `p` (`aligned: true`) are rows of: the +/// first of the [`appearance_targets`] when it is such art, which the edit leaves out ([`without_design_art`]). A +/// command reads it after its own target checks. +pub(crate) fn design_reference(s: &Session, p: &Value, cmd: &str) -> Result> { + if !aligned(p, cmd)? { + return Ok(None); + } + let ids = appearance_targets(s, p)?; + let d = &s.doc()?.doc; + Ok(ids.first().copied().filter(|id| super::graph::is_design_art(d, *id))) +} + +/// The [`design_reference`] of an edit aimed at `item`: none for the Appearance panel's active row, which is a row of the +/// first selected object's stack. +pub(crate) 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) +} + +/// `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() +} + +/// [`items_per_target`] of `ids`, with the aligned rows `idx` taken from the stack of `of` (see [`design_reference`]) +/// when there is one: a row a target has no equal of is left out of that target. +pub(crate) fn rows_per_target(d: &Document, of: Option, ids: &[NodeId], idx: &[usize], aligned: bool) -> Vec> { + match of { + Some(of) => items_per_target(d, &with_reference(Some(of), ids), idx, true).into_iter().skip(1).collect(), + None => items_per_target(d, ids, idx, aligned), + } +} + /// Whether the `target` param asks for the contents of groups and layers (see the module docs). fn targets_contents(p: &Value) -> Result { match p.get("target") { @@ -424,22 +485,44 @@ const DESIGN_ART: &str = "the art of a graph design in a graph series is drawn a /// The [`appearance_targets`] whose rows and effects a command edits: the art of graph designs drawn in graph series /// ([`super::graph::is_design_art`]) is left out, as the graph draws it again from the design. A command whose targets -/// are all such art is refused. -pub(crate) fn edit_targets(s: &Session, p: &Value) -> Result> { +/// are all such art is refused as an error of `cmd`. +pub(crate) fn edit_targets(s: &Session, p: &Value, cmd: &str) -> Result> { let ids = appearance_targets(s, p)?; - without_design_art(&s.doc()?.doc, ids, "appearance") + without_design_art(&s.doc()?.doc, ids, cmd) } /// `ids` without the art of graph designs drawn in graph series; `cmd` is refused when `ids` held nothing else. -pub(crate) fn without_design_art(d: &Document, mut ids: Vec, cmd: &str) -> Result> { +pub(crate) fn without_design_art(d: &Document, ids: Vec, cmd: &str) -> Result> { let given = ids.len(); - ids.retain(|id| !super::graph::is_design_art(d, *id)); + let ids = drop_design_art(d, ids); if given > 0 && ids.is_empty() { return Err(bad(cmd, DESIGN_ART)); } Ok(ids) } +/// `ids` without the art of graph designs drawn in graph series, for the paint commands, which leave it out without an +/// error. +pub(crate) fn drop_design_art(d: &Document, mut ids: Vec) -> Vec { + ids.retain(|id| !super::graph::is_design_art(d, *id)); + ids +} + +/// The outermost art of graph designs drawn in graph series inside `roots`: the objects directly in a series group that +/// [`super::graph::is_design_art`] accepts. +pub(crate) fn design_art_in(d: &Document, roots: &[NodeId]) -> Vec { + let mut out = vec![]; + for id in roots { + let Some(n) = d.node(*id) else { continue }; + n.walk(&mut |m| { + if m.extras().series_index.is_some() { + out.extend(m.children().into_iter().flatten().map(|c| c.id).filter(|c| super::graph::is_design_art(d, *c))); + } + }); + } + out +} + /// `ids` with each graph series group that has no fill or stroke row of its own replaced by the paths directly inside /// it ([`super::graph::series_group_marks`]). A series group with rows, as a document saved earlier can hold, stays: /// the Appearance panel lists those rows and edits them by index. @@ -476,15 +559,15 @@ pub(crate) fn look_holder<'a>(d: &'a Document, n: &'a Node) -> &'a Node { /// Objects whose fill and stroke rows a command edits: the [`edit_targets`], with a graph series group standing for the /// paths directly inside it ([`rowless_series_marks`]), so the graph can store the edit. -pub(crate) fn row_targets(s: &Session, p: &Value) -> Result> { - let ids = edit_targets(s, p)?; +pub(crate) fn row_targets(s: &Session, p: &Value, cmd: &str) -> Result> { + let ids = edit_targets(s, p, cmd)?; Ok(rowless_series_marks(&s.doc()?.doc, &ids)) } /// Objects whose whole appearance a command edits: the [`row_targets`], then each graph series group they stand for, /// which can hold effects and a graphic style link of its own. -pub(crate) fn whole_targets(s: &Session, p: &Value) -> Result> { - let own = edit_targets(s, p)?; +pub(crate) fn whole_targets(s: &Session, p: &Value, cmd: &str) -> Result> { + let own = edit_targets(s, p, cmd)?; let mut ids = rowless_series_marks(&s.doc()?.doc, &own); let groups = stood_for(&own, &ids); ids.extend(groups); @@ -513,7 +596,7 @@ pub(crate) fn clear_stood_for(d: &mut Document, groups: &[NodeId]) -> bool { /// [`whole_targets`] of `cmd`, which needs at least one. fn require_whole_targets(s: &Session, p: &Value, cmd: &str) -> Result> { - let ids = whole_targets(s, p)?; + let ids = whole_targets(s, p, cmd)?; if ids.is_empty() { return Err(bad(cmd, "select objects or give ids")); } @@ -522,7 +605,7 @@ fn require_whole_targets(s: &Session, p: &Value, cmd: &str) -> Result Result> { - let ids = row_targets(s, p)?; + let ids = row_targets(s, p, cmd)?; if ids.is_empty() { return Err(bad(cmd, "select objects or give ids")); } @@ -536,16 +619,23 @@ fn require_targets(s: &Session, p: &Value, cmd: &str) -> Result> { /// ([`capture_series_changes`], [`super::graph::capture_label_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| { +) -> Result> { + edit_items_then(s, ids, item, of, cmd, label, fill, f, |before, d, edited| { let series = capture_series_changes(before, d, edited); // The rows `f` edited are kept by `edit_items_then`. let labels = super::graph::capture_label_changes(before, d, edited, false); @@ -562,17 +652,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. @@ -591,7 +693,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`. @@ -600,17 +708,23 @@ 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) { return Ok(b); } let ItemTarget::Item { index, .. } = item else { return Ok(default) }; - let ids = appearance_targets(s, p)?; let d = &s.doc()?.doc; - 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`]. @@ -1032,7 +1146,7 @@ fn set_item(s: &mut Session, p: &Value) -> Result { check_numbers(p, &["weight", "opacity"], "appearance.setItem")?; check_switches(p, &["visible"], "appearance.setItem")?; let paint = paint_from(s, p)?; - let ids = row_targets(s, p)?; + let ids = row_targets(s, p, "appearance.setItem")?; if ids.is_empty() { return Err(bad("appearance.setItem", "select objects or give ids")); } @@ -1040,7 +1154,8 @@ fn set_item(s: &mut Session, p: &Value) -> Result { // When every object comes out as it was (for example, when none has item `index`), the document // and its undo history stay as they are. let before = s.doc()?.doc.clone(); - let per = items_per_target(&before, &ids, &[idx], aligned(p, "appearance.setItem")?); + let of = design_reference(s, p, "appearance.setItem")?; + let per = rows_per_target(&before, of, &ids, &[idx], aligned(p, "appearance.setItem")?); super::overprint::edit_counted(s, "Appearance", |d| { let mut edited = Vec::new(); for (id, at) in ids.iter().zip(&per) { @@ -1127,7 +1242,8 @@ fn remove_item(s: &mut Session, p: &Value) -> Result { // When no object has an item at any of the indices, the document and its undo history stay as // they are. let before = s.doc()?.doc.clone(); - let per = items_per_target(&before, &ids, &idx, aligned(p, "appearance.removeItem")?); + let of = design_reference(s, p, "appearance.removeItem")?; + let per = rows_per_target(&before, of, &ids, &idx, aligned(p, "appearance.removeItem")?); super::overprint::edit_counted(s, "Remove Item", |d| { for (id, idx) in ids.iter().zip(&per) { if let Some(n) = d.node_mut(*id) { @@ -1168,12 +1284,19 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result { let mut first_at = None; let active = s.active_appearance_item.as_ref().and_then(|a| a.objects.first().copied()); let mut active_at = None; - // Aligned rows of a mixed selection are copied right above themselves in each object. - if aligned(p, C)? && to.is_some() && shared_stack(&s.doc()?.doc, &ids).is_some() { + // Aligned rows of a mixed selection are copied right above themselves in each object. Rows of a graph design's art, + // which is left out, go to `to` in an object whose stack is the same as the art's. + let of = design_reference(s, p, C)?; + if aligned(p, C)? + && to.is_some() + && (shared_stack(&s.doc()?.doc, &ids).is_some() || (of.is_some() && appearances_differ(&s.doc()?.doc, &with_reference(of, &ids)))) + { return Err(bad(C, "`to` takes the rows of one object's stack, not aligned rows")); } let before = s.doc()?.doc.clone(); - let per = items_per_target(&before, &ids, &idx, aligned(p, C)?); + // With `to`, the art's stack is the same as the object's, so its rows are the object's, each of which must exist. + let per = + if to.is_some() { items_per_target(&before, &ids, &idx, aligned(p, C)?) } else { rows_per_target(&before, of, &ids, &idx, aligned(p, C)?) }; s.edit("Duplicate Item", |d, _| { let mut any = false; // Copies that became the topmost visible row of their kind: the series stores their paint even when the object @@ -1280,15 +1403,19 @@ fn move_item(s: &mut Session, p: &Value) -> Result { let aligned = aligned(p, C)?; if p.get("from").and_then(Value::as_str) == Some("contents") { // A graph series group's own Contents row, not its paths, which have none. - let ids = edit_targets(s, p)?; + let ids = edit_targets(s, p, C)?; if ids.is_empty() { return Err(bad(C, "select objects or give ids")); } + // The Contents row's place counts the rows below it, which isn't one place in several different stacks either. + if aligned && appearances_differ(&s.doc()?.doc, &with_reference(design_reference(s, p, C)?, &ids)) { + return Err(bad(C, "rows of objects whose appearances differ can't be reordered together")); + } return move_contents(s, &ids, to); } let ids = require_targets(s, p, C)?; // Where an aligned row would go in each of several different stacks isn't one place. - if aligned && appearances_differ(&s.doc()?.doc, &ids) { + if aligned && appearances_differ(&s.doc()?.doc, &with_reference(design_reference(s, p, C)?, &ids)) { return Err(bad(C, "rows of objects whose appearances differ can't be reordered together")); } if p.get("indices").is_some() { diff --git a/crates/engine/src/cmd/brushlib.rs b/crates/engine/src/cmd/brushlib.rs index 16ab1c76d4..34becd7c02 100644 --- a/crates/engine/src/cmd/brushlib.rs +++ b/crates/engine/src/cmd/brushlib.rs @@ -178,6 +178,7 @@ pub const BUILTIN: &[BuiltinBrushLibrary] = &[ BuiltinBrushLibrary { id: "game-trails", name: "Game Trails", make: game_trails }, BuiltinBrushLibrary { id: "energy-beams", name: "Energy Beams", make: energy_beams }, BuiltinBrushLibrary { id: "dry-media", name: "Dry Media", make: dry_media }, + BuiltinBrushLibrary { id: "tech-circuit", name: "Tech Circuit", make: tech_circuit }, ]; /// Built-in library `id`, made once. @@ -397,6 +398,59 @@ fn border(name: &str, side: Node, corner: Option, fit: PatternFit) -> Brus Brush { name: name.into(), kind } } +/// Tech Circuit: original conductor geometry, drawn as filled paths so widths and chamfers +/// survive brush warping. Pattern tiles meet at x = 0 and 24 with identical lead cross-sections. +fn tech_circuit() -> BrushLibrary { + let pad = |x: f64| { + black(polygon(&[ + (x - 4.0, -2.0), + (x - 2.0, -4.0), + (x + 2.0, -4.0), + (x + 4.0, -2.0), + (x + 4.0, 2.0), + (x + 2.0, 4.0), + (x - 2.0, 4.0), + (x - 4.0, 2.0), + ])) + }; + // 45-degree jogs lift the conductor to a parallel run and bring it back. + let trace = group(vec![ + black(polygon(&[ + (4.0, -1.0), + (23.0, -1.0), + (33.0, -11.0), + (61.0, -11.0), + (71.0, -1.0), + (96.0, -1.0), + (96.0, 1.0), + (70.0, 1.0), + (60.0, -9.0), + (34.0, -9.0), + (24.0, 1.0), + (4.0, 1.0), + ])), + pad(4.0), + pad(96.0), + ]); + let bus = group([-4.0, 0.0, 4.0].into_iter().map(|y| black(shapes::rectangle(Rect::new(0.0, y - 0.75, 24.0, y + 0.75)))).collect()); + let terminals = group(vec![ + // Overlap the ring's rim without filling the test point's hole. + black(shapes::rectangle(Rect::new(0.0, -0.6, 15.5, 0.6))), + black(shapes::rectangle(Rect::new(18.5, -0.6, 24.0, 0.6))), + pad(6.0), + ring(Point::new(17.0, 0.0), 3.0, 1.4), + ]); + built( + "Tech Circuit", + Document::new(100.0, 100.0), + vec![ + art("Circuit Trace", trace, Colorization::Tints), + border("Bus Lines", bus, None, PatternFit::Stretch), + border("Terminal Nodes", terminals, None, PatternFit::Stretch), + ], + ) +} + /// An invisible rectangle that fixes a tile's width. fn spacer(r: Rect) -> Node { Node::path(NodeId(0), shapes::rectangle(r), Appearance::default()) 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/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index 8bf6841e17..5922787723 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -64,7 +64,7 @@ pub fn specs() -> Vec { "Assign Profile…", ["Edit"], None, - "{rgb?: profile|null, cmyk?: profile|null} tag the document with profiles (color numbers are kept; null: the working space). Colors then show and convert through the profiles assigned; the working spaces stay as edit.colorSettings set them → {rgb, cmyk}", + "{rgb?: profile|null, cmyk?: profile|null} tag the document with profiles (color numbers are kept; null: no profile, Don't Color Manage This Document, so colors show through the working space; the working space's name: that profile stays assigned when the working space changes). Colors then show and convert through the profiles assigned; the working spaces stay as edit.colorSettings set them → {rgb, cmyk}", has_doc, assign_profile ), diff --git a/crates/engine/src/cmd/draw2/commands.rs b/crates/engine/src/cmd/draw2/commands.rs index ffa11e362c..49e277e5a0 100644 --- a/crates/engine/src/cmd/draw2/commands.rs +++ b/crates/engine/src/cmd/draw2/commands.rs @@ -92,7 +92,7 @@ pub fn specs() -> Vec { "Eraser", [], None, - "{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1), size?: pt (10), sizeMode?: \"fixed\" (default) | \"pressure\" (Eraser Tool Options' Size set to Pressure: the tip is size − sizeVariation across where the pen presses lightest and size + sizeVariation where it presses hardest, as path.blob), sizeVariation?: pt (0), angle?: ° (0: the tip's long axis counter-clockwise from level), roundness?: % (100, kept within 1–100)} erase the stroke of an elliptical tip from paths (selected, or all) → {ids}. The tip is `size` across its long axis, which runs at `angle` counter-clockwise from horizontal; its short axis is `roundness` percent of the long one", + "{points: [[x,y]…] or [[x,y,pressure]…] or [[x,y,pressure,tiltX,tiltY,rotation]…] (pen pressure 0..1, tilt −90..90°, barrel rotation ° clockwise, as path.freehand), size?: pt (10), angle?: ° (0: the tip's long axis counter-clockwise from level), roundness?: % (100, kept within 1–100), angleMode?, roundnessMode?, sizeMode?: \"fixed\" (default) | \"random\" | \"pressure\" | \"tilt\" | \"bearing\" | \"rotation\" (Eraser Tool Options' variation menus; a varied angle turns either way, clockwise or counter-clockwise), angleVariation?: ° (0–180, default 0, either way: clockwise or counter-clockwise), roundnessVariation?: % (0–100, default 0), sizeVariation?: pt (0 or more, default 0)} erase the stroke of an elliptical tip from paths (selected, or all) → {ids}. The tip is `size` across its long axis, which runs at `angle` counter-clockwise from horizontal; its short axis is `roundness` percent of the long one. A value whose mode is not fixed varies by up to its variation either way: random draws one value for the drag; pressure goes from the value less the variation where the pen presses lightest to the value plus it where it presses hardest (size on pressure as path.blob); tilt from less with the pen lying flat to more with it upright; bearing and rotation from less at −180° to more at 180° of the pen's lean direction or barrel turn. A drag without that pen reading (a mouse) erases at the value itself. An unknown mode, or a variation below 0 or past its range, is an error and erases nothing", has_doc, erase_region ), diff --git a/crates/engine/src/cmd/draw2/cut.rs b/crates/engine/src/cmd/draw2/cut.rs index 645c184b14..64b8bdee16 100644 --- a/crates/engine/src/cmd/draw2/cut.rs +++ b/crates/engine/src/cmd/draw2/cut.rs @@ -1,11 +1,14 @@ use super::common::{ - dist_to_polyline, fill_targets, ids_json, path_mut, path_of, points_param, poly_bounds, pressure_region, replace_with_pieces, samples_param, - sibling_with, size_pressure, target_paths, usize_opt, + PenRecord, Sample, dist_to_polyline, fill_targets, ids_json, path_mut, path_of, points_param, poly_bounds, pressure_region, replace_with_pieces, + samples_param, sibling_with, size_pressure, target_paths, usize_opt, }; use super::{ - Anchor, AnchorKind, Beside, BezPath, BoolOp, Cap, EngineError, FillRule, Join, NodeId, ParamCurve, PathData, Point, Result, Session, SubPath, - Value, bad, eraser_area, eraser_tip, f64_or, fill_contains, json, number_or, object_param, po, + Anchor, AnchorKind, Beside, BezPath, BoolOp, Cap, EngineError, FillRule, Join, NodeId, Paint, ParamCurve, PathData, Point, Result, Session, + SubPath, Value, Vec2, bad, eraser_area, eraser_tip, f64_or, fill_contains, json, number_or, object_param, po, }; +use vectorcraft_brush::{Brush, BrushKind, Calligraphic, Variation}; +use vectorcraft_doc::StrokeLayer; +use vectorcraft_tools::draw2::MIN_BRUSH_SIZE; /// Split a filled path into connected pieces (each outer contour with its holes). pub(in super::super) fn components(pd: &PathData) -> Vec { @@ -278,7 +281,11 @@ pub(super) fn erase_region(s: &mut Session, p: &Value) -> Result { } let angle = number_or(p, "angle", 0.0, C)?; let roundness = number_or(p, "roundness", 100.0, C)?; - let pressure = size_pressure(p, &samples, C)?; + let vary = eraser_variations(p, C)?; + if varies_beyond_size_pressure(&vary) { + return erase_varied(s, &samples, [angle, roundness, size], vary); + } + let pressure = if vary[2].0 == Variation::Pressure { size_pressure(p, &samples, C)? } else { None }; let pts: Vec = samples.into_iter().map(|s| s.pt).collect(); let r = size / 2.0; // The tip's short axis (its narrowest with Pressure): an open path is sampled at least every half of it. @@ -318,6 +325,101 @@ pub(super) fn erase_region(s: &mut Session, p: &Value) -> Result { apply_plan(s, "Eraser", plan) } +/// The variation modes `path.eraseRegion` takes for each of `angleMode`, `roundnessMode` and +/// `sizeMode`. +const ERASER_MODES: &str = "\"fixed\", \"random\", \"pressure\", \"tilt\", \"bearing\" or \"rotation\""; + +/// Eraser Tool Options' Angle, Roundness and Size variations: for each of angle (`angleMode` with +/// `angleVariation` in degrees, at most 180), roundness (`roundnessMode` with `roundnessVariation` +/// in percent, at most 100) and size (`sizeMode` with `sizeVariation` in points), its mode and how +/// far it varies either way. A mode left out or null is Fixed, and a variation left out or null +/// is 0; any other mode, or a variation that is negative or past its most, is refused. +fn eraser_variations(p: &Value, cmd: &str) -> Result<[(Variation, f64); 3]> { + const KEYS: [(&str, &str, f64); 3] = + [("angleMode", "angleVariation", 180.0), ("roundnessMode", "roundnessVariation", 100.0), ("sizeMode", "sizeVariation", f64::INFINITY)]; + let mut out = [(Variation::Fixed, 0.0); 3]; + for (slot, (mode_key, var_key, most)) in out.iter_mut().zip(KEYS) { + let mode = match p.get(mode_key).filter(|v| !v.is_null()) { + None => Variation::Fixed, + Some(Value::String(id)) => Variation::parse(id).ok_or_else(|| bad(cmd, format!("{mode_key} is one of {ERASER_MODES}, not `{id}`")))?, + Some(other) => return Err(bad(cmd, format!("{mode_key} is one of {ERASER_MODES}, not {other}"))), + }; + let variation = number_or(p, var_key, 0.0, cmd)?; + if !(variation.is_finite() && variation >= 0.0) { + return Err(bad(cmd, format!("{var_key} must be 0 or more"))); + } + if variation > most { + return Err(bad(cmd, format!("{var_key} must be at most {most}"))); + } + *slot = (mode, variation); + } + Ok(out) +} + +/// Does any of the Eraser's Angle, Roundness and Size vary with something other than Size on +/// Pressure? A mode whose variation is 0 does not vary. +fn varies_beyond_size_pressure(vary: &[(Variation, f64); 3]) -> bool { + vary.iter().enumerate().any(|(i, (mode, v))| *v > 0.0 && *mode != Variation::Fixed && !(i == 2 && *mode == Variation::Pressure)) +} + +/// `path.eraseRegion` with its tip varying along the drag ([`eraser_variations`]): the area the +/// tip covers is swept as a Calligraphic brush nib of the same angle, roundness, size, modes and +/// variations, with what the pen recorded along the drag. Random draws one value for the drag +/// and the pen modes follow the pen at each point; a drag without that recording (a mouse) takes +/// the value itself. `tip`: the angle, roundness and size. +fn erase_varied(s: &mut Session, samples: &[Sample], tip: [f64; 3], vary: [(Variation, f64); 3]) -> Result { + let [angle, roundness, size] = tip; + let mut pts: Vec = samples.iter().map(|s| s.pt).collect(); + // A press without a drag is a dab: a sweep needs some length. + if let [only] = pts[..] { + pts.push(only + Vec2::new(0.01, 0.0)); + } + // A nib's size is kept within 1296 pt and the stroke's width scales it: a larger tip is swept + // at a fraction of its size on a stroke as many times wider. + let [(am, av), (rm, rv), (sm, sv)] = vary; + let sv = if sm == Variation::Fixed { 0.0 } else { sv }; + let scale = ((size + sv) / Calligraphic::RANGES[2].1).max(1.0); + let nib = Calligraphic { + angle, + roundness: roundness.clamp(1.0, 100.0), + size: size / scale, + variation: [av, rv, sv / scale], + modes: Some([am, rm, sm]), + }; + let brush = Brush { name: "Eraser".into(), kind: BrushKind::Calligraphic(nib) }; + let mut stroke = StrokeLayer::new(Paint::None, scale); + PenRecord::of_samples(samples).write(&mut stroke); + let spine = SubPath::polyline(&pts, false); + let st = s.doc()?; + let pieces = vectorcraft_brush::stroke_pieces(&st.doc, &brush, &PathData::single(spine).to_bezpath(), &stroke); + let areas: Vec = pieces.iter().filter_map(|n| n.path_data().cloned()).collect(); + if areas.is_empty() { + return Ok(json!({ "ids": [] })); + } + let region = + po::try_unite_all(&areas.iter().map(|a| (a, FillRule::NonZero)).collect::>()).map_err(|e| EngineError::Other(e.to_string()))?; + let bp = region.to_bezpath(); + // The narrowest the tip gets across: an open path is sampled at least every half of it. + let low = |i: usize| match vary[i] { + (Variation::Fixed, _) => tip[i], + (_, v) => tip[i] - v, + }; + let short = low(2).max(MIN_BRUSH_SIZE) * low(1).clamp(1.0, 100.0) / 100.0; + let reach = (size + sv) / 2.0; + let mut plan: Vec<(NodeId, Vec)> = vec![]; + for (id, pd, rule) in fill_targets(&st.doc, &st.selection, poly_bounds(&pts, reach))? { + if pd.is_closed() { + if po::area(&po::boolean(&pd, rule, ®ion, FillRule::NonZero, BoolOp::Intersect), FillRule::NonZero) <= 1e-6 { + continue; + } + plan.push((id, components(&po::boolean(&pd, rule, ®ion, FillRule::NonZero, BoolOp::Difference)))); + } else if let Some(pieces) = erase_outline(&pd, short / 2.0, &|q| fill_contains(&bp, FillRule::NonZero, q)) { + plan.push((id, pieces)); + } + } + apply_plan(s, "Eraser", plan) +} + fn apply_plan(s: &mut Session, label: &str, plan: Vec<(NodeId, Vec)>) -> Result { if plan.is_empty() { return Ok(json!({ "ids": [] })); diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 6aba8d4ca6..42113c9578 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -13,7 +13,10 @@ use vectorcraft_doc::{Document, Effect, Node, NodeKind, StrokeLayer}; use vectorcraft_geom::{FillRule, PathData}; use vectorcraft_render::effects; -use super::appearance::{ItemTarget, aligned, appearance_targets, edit_targets, index_param, item_indices, item_target, item_target_at}; +use super::appearance::{ + 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; use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, check_switches, cmd, fileio, has_doc, str_param, switch_or}; @@ -49,7 +52,7 @@ fn effect_specs() -> Vec { "Apply Effect", [], None, - "{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\", or \"plugin.\" for an installed effect plug-in), params?: {…} (missing keys take the dialog defaults), item?: appearance item index|null (apply to that fill/stroke only; omitted: the Appearance panel's active item, else the whole object), ids?|id?: [..] (layers too), target?: \"object\"|\"contents\" (contents: the objects inside groups and layers)} append a live effect to each selected object's appearance (a group's or layer's apply to its members as one piece: one combined shadow; a Pathfinder effect, which combines a group's contents, groups several loose selected objects first, in the same undo step); the art of a graph design in a graph series is left out, and objects that are all such art are an error (docs/mcp.md) → {ids, index, item, grouped?}", + "{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\", or \"plugin.\" for an installed effect plug-in), params?: {…} (missing keys take the dialog defaults), item?: appearance item index|null (apply to that fill/stroke only; omitted: the Appearance panel's active item, else the whole object), ids?|id?: [..] (layers too), target?: \"object\"|\"contents\" (contents: the objects inside groups and layers), aligned?: bool (item is a row of the first target's stack: each other target gets the effect on its equal one, as the Appearance panel does for a mixed selection; with the art of a graph design in a series given first, a row of the art's stack: docs/mcp.md)} append a live effect to each selected object's appearance (a group's or layer's apply to its members as one piece: one combined shadow; a Pathfinder effect, which combines a group's contents, groups several loose selected objects first, in the same undo step); the art of a graph design in a graph series is left out, and objects that are all such art are an error (docs/mcp.md) → {ids, index, item, grouped?}", has_doc, apply ), @@ -67,7 +70,7 @@ fn effect_specs() -> Vec { "Remove Effect", [], None, - "{index: int (position in the effect list), indices?: [int] (several positions in the same list instead of index, removed in one undo step; every one must exist), item?: appearance item index|null (that fill/stroke's effects; omitted: the active item, else the object's), 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)} → {ids}", + "{index: int (position in the effect list), indices?: [int] (several positions in the same list instead of index, removed in one undo step; every one must exist), item?: appearance item index|null (that fill/stroke's effects; omitted: the active item, else the object's), 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)} → {ids}", has_doc, remove ), @@ -76,7 +79,7 @@ fn effect_specs() -> Vec { "Effect Options", [], None, - "{index: int, params?: {…} (merged into the current parameters), warpStyle?: string (an existing Warp style suffix, e.g. arc or flag; changes only an applied Warp in place; params are checked against the new style), threeDType?: plane|extrude|revolve|inflate (the 3D and Materials panel's object type: changes only an applied 3D effect in place into Rotate, Extrude & Bevel, Revolve or Inflate; the settings the new effect also takes keep their values, rotation angles wrapped into -180..180 outside Revolve, and the others start at its defaults; params then merge into the new effect's; the type the effect already has, with nothing else given, changes nothing and records no undo step), visible?: bool (any other value but null is refused, when a step plays too), item?: appearance item index|null (as effect.remove), 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)} → {ids}", + "{index: int, params?: {…} (merged into the current parameters), warpStyle?: string (an existing Warp style suffix, e.g. arc or flag; changes only an applied Warp in place; params are checked against the new style), threeDType?: plane|extrude|revolve|inflate (the 3D and Materials panel's object type: changes only an applied 3D effect in place into Rotate, Extrude & Bevel, Revolve or Inflate; the settings the new effect also takes keep their values, rotation angles wrapped into -180..180 outside Revolve, and the others start at its defaults; params then merge into the new effect's; the type the effect already has, with nothing else given, changes nothing and records no undo step), visible?: bool (any other value but null is refused, when a step plays too), item?: appearance item index|null (as effect.remove), 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)} → {ids}", has_doc, set_params ), @@ -85,7 +88,7 @@ fn effect_specs() -> Vec { "3D Lighting Preset", [], None, - "{index: int, preset: standard|diffuse|topLeft|right, item?: appearance item index|null (omitted: the active item, else the object's), 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)} replace an applied 3D effect's light rig and ambient with an original lighting preset; preserve shading, geometry and view; all targets must have a 3D effect at index other than Extrude & Bevel (Classic), Revolve (Classic) or Rotate (Classic), which have no lighting presets; one undo step → {ids}", + "{index: int, preset: standard|diffuse|topLeft|right, item?: appearance item index|null (omitted: the active item, else the object's), 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)} replace an applied 3D effect's light rig and ambient with an original lighting preset; preserve shading, geometry and view; all targets must have a 3D effect at index other than Extrude & Bevel (Classic), Revolve (Classic) or Rotate (Classic), which have no lighting presets; one undo step → {ids}", has_doc, lighting_preset ), @@ -103,7 +106,7 @@ fn effect_specs() -> Vec { "Duplicate Effect", [], None, - "{index: int, indices?: [int] (several positions in the same list instead of index; every one must exist), item?: appearance item index|null (as effect.remove), 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)} insert a copy of each effect right after it, in one undo step → {ids}", + "{index: int, indices?: [int] (several positions in the same list instead of index; every one must exist), item?: appearance item index|null (as effect.remove), 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)} insert a copy of each effect right after it, in one undo step → {ids}", has_doc, duplicate ), @@ -112,7 +115,7 @@ fn effect_specs() -> Vec { "Move Effect", [], None, - "{from: int|[int] (position in the source list; a list moves several effects of that list, which keep their order and land together), to: int (the position of the (first) moved effect in the destination list afterwards), fromItem?: appearance item index|null (the source list: that fill/stroke's effects, null the object's; omitted: the active item, else the object's), toItem?: item index|null (the destination list; default: the source list), copy?: bool (copy instead of move, as Alt-dragging the row), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (refused when the targets' appearances differ)} reorder an effect or move it between the object and its fills/strokes, as one undo step → {ids, index, item}", + "{from: int|[int] (position in the source list; a list moves several effects of that list, which keep their order and land together), to: int (the position of the (first) moved effect in the destination list afterwards), fromItem?: appearance item index|null (the source list: that fill/stroke's effects, null the object's; omitted: the active item, else the object's), toItem?: item index|null (the destination list; default: the source list), copy?: bool (copy instead of move, as Alt-dragging the row), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (refused when the targets' appearances differ, or differ from that of a graph design's art given first)} reorder an effect or move it between the object and its fills/strokes, as one undo step → {ids, index, item}", has_doc, move_effect ), @@ -296,14 +299,20 @@ fn ids_json(ids: &[NodeId]) -> Value { /// effects it edits (`None`: the object's own) and the effect's index there. With `aligned` (see /// [`ItemTarget::per_target`]) `item` and `index` are the first target's, and each other target /// edits its equal item, or its equal object-level effect, each target matched on its own whatever -/// the other targets have; in a target without them, `item` and `index` stand for themselves. +/// the other targets have; in a target without them, `item` and `index` stand for themselves. When the first object +/// given is the art of a graph design in a series ([`design_reference`]), which is left out, they are rows of its stack, +/// and a target without them is left out. pub(crate) fn effect_targets(s: &Session, p: &Value, item: ItemTarget, index: usize, cmd: &str) -> Result, usize)>> { - let roots = edit_targets(s, p)?; + let roots = edit_targets(s, p, cmd)?; + let of = reference_of(s, p, item, cmd)?; let d = &s.doc()?.doc; - let per = item.per_target(s, &roots); - let first = roots.first().and_then(|f| d.node(*f)); + let per = item.per_target_of(s, of, &roots); + let first = of.or_else(|| roots.first().copied()).and_then(|f| d.node(f)); // Whether `item` names an item in the stack it is a row of (else the edit is object-level there). - let anchor = roots.get(item.anchor(s, &roots)).and_then(|a| d.node(*a)); + let anchor = match of { + Some(of) => d.node(of), + None => roots.get(item.anchor(s, &roots)).and_then(|a| d.node(*a)), + }; let anchor_level = anchor.map(|a| item.effects_item(&a.appearance, cmd)).transpose()?.flatten(); let aligned = aligned(p, cmd)?; let mut out = Vec::with_capacity(roots.len()); @@ -312,7 +321,11 @@ pub(crate) fn effect_targets(s: &Session, p: &Value, item: ItemTarget, index: us let at = item.effects_item(&n.appearance, cmd)?; // An object-level effect this target shares with the first is where it is in this target. let index = match (first.filter(|_| aligned), at, anchor_level) { - (Some(f), None, None) => SharedStack::of(&[&*f.appearance, &*n.appearance]).effect_in(index, 1).unwrap_or(index), + (Some(f), None, None) => match SharedStack::of(&[&*f.appearance, &*n.appearance]).effect_in(index, 1) { + Some(i) => i, + None if of.is_some() && index != APPENDED => continue, + None => index, + }, _ => index, }; out.push((*id, at, index)); @@ -320,6 +333,10 @@ pub(crate) fn effect_targets(s: &Session, p: &Value, item: ItemTarget, index: us Ok(out) } +/// The effect index `effect.apply` gives [`effect_targets`] for the effect it appends, which names no effect the targets +/// have. +const APPENDED: usize = usize::MAX; + #[allow(clippy::too_many_arguments)] fn edit_effects( s: &mut Session, @@ -362,6 +379,20 @@ fn edit_effects_at( indices: &[usize], mut f: impl FnMut(&mut Vec, &[usize]) -> bool, ) -> Result> { + // With a graph design's art given first, the indices are effects of its list: one it doesn't have names no effect, + // and the edit is refused as a whole, as it is in a list that lacks one of them. + if let Some(of) = reference_of(s, p, item, cmd)? + && let Some(n) = s.doc()?.doc.node(of) + { + let ap = &n.appearance; + let len = match item.effects_item(ap, cmd)? { + None => ap.effects.len(), + Some(i) => ap.items.get(i).map_or(0, |it| it.effects().len()), + }; + if indices.iter().any(|i| *i >= len) { + return Err(EngineError::Other(none.into())); + } + } // Each target's list and the positions in it, in the order the targets first come up. let mut targets: Vec<(NodeId, Option, Vec)> = vec![]; for &index in indices { @@ -453,13 +484,18 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result { return Ok(json!({ "ids": [gid.0], "index": 0, "item": null, "grouped": true })); } let item = item_target(s, p, C)?; + // The item as each target has it, read before the effect changes their rows: a row of a graph design's art, which is + // left out of the targets, is the row equal to it there, and a target without one is left out. + let targets = edit_targets(s, p, C)?; + let of = reference_of(s, p, item, C)?; + let per: Vec<(NodeId, Option)> = targets.iter().copied().zip(item.per_target_of(s, of, &targets)).collect(); if effects::is_three_d(id) { - let targets = edit_targets(s, p)?; - for (target, at) in targets.iter().copied().zip(item.per_target(s, &targets)) { + for &(target, at) in &per { + let Some(at) = at else { continue }; let d = &s.doc()?.doc; let n = d.node(target).ok_or(EngineError::NoNode(target))?; // A new effect goes after the item's (or the object's) others. - let on = at.and_then(|at| at.effects_item(&n.appearance, C).ok()).flatten(); + let on = at.effects_item(&n.appearance, C).ok().flatten(); let on = on.and_then(|i| Some((Some(i), n.appearance.items.get(i)?.effects().len()))); let on = on.unwrap_or((None, n.appearance.effects.len())); effects::validate_three_d_at_in(Some(d), n, id, &effect.params, on).map_err(|e| bad(C, e))?; @@ -474,14 +510,19 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result { } } } - let ids = edit_effects(s, p, item, C, &label, "Apply Effect: select objects", 0, |fx, _| { + let ids = edit_effects(s, p, item, C, &label, "Apply Effect: select objects", APPENDED, |fx, _| { fx.push(effect.clone()); true })?; // The item the effect landed on in the object whose row the item is (the first selected object // for the active item, which applies where it fits), and the effect's index in that object. - let first = ids.get(item.anchor(s, &ids)).and_then(|id| s.doc().ok()?.doc.node(*id)); - let landed = first.and_then(|n| item.effects_item(&n.appearance, C).ok().flatten()); + let k = item.anchor(s, &ids); + let first = ids.get(k).and_then(|id| s.doc().ok()?.doc.node(*id)); + let at = match of { + Some(_) => ids.get(k).and_then(|id| per.iter().find(|(t, _)| t == id)).and_then(|(_, at)| *at).unwrap_or(item), + None => item, + }; + let landed = first.and_then(|n| at.effects_item(&n.appearance, C).ok().flatten()); let index = first.and_then(|n| n.appearance.effects_at(landed)).map_or(0, |fx| fx.len().saturating_sub(1)); Ok(json!({ "ids": ids_json(&ids), "index": index, "item": landed })) } @@ -495,7 +536,7 @@ fn loose_for_pathfinder(s: &Session, p: &Value, id: &str) -> Result= 2 && !grouped).then_some(roots)) } @@ -608,9 +649,9 @@ fn move_effect(s: &mut Session, p: &Value) -> Result { let src = item_target_at(s, p, "fromItem", C)?; let dst = if p.get("toItem").is_some() { item_target_at(s, p, "toItem", C)? } else { src }; let copy = switch_or(p, "copy", false, C)?; - let roots = edit_targets(s, p)?; + let roots = edit_targets(s, p, C)?; // Where an aligned effect would go in each of several different stacks isn't one place. - if aligned(p, C)? && super::appearance::appearances_differ(&s.doc()?.doc, &roots) { + if aligned(p, C)? && super::appearance::appearances_differ(&s.doc()?.doc, &with_reference(design_reference(s, p, C)?, &roots)) { return Err(bad(C, "effects of objects whose appearances differ can't be moved together")); } // Where the effect landed in each object it moved in, in the order of `ids`. @@ -898,8 +939,11 @@ fn can_expand(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; let ids = appearance_targets(s, &Value::Null).map_err(|e| e.to_string())?; let d = &s.doc().map_err(|e| e.to_string())?.doc; + // The art of a graph design in a series, selected or inside a selected graph, is drawn again from the design. + let ids = super::appearance::drop_design_art(d, ids); // A dimension selected with the art it measures is left as it is. - let kept = super::dimension::with_their_art(d, &ids); + let mut kept = super::dimension::with_their_art(d, &ids); + kept.extend(super::appearance::design_art_in(d, &ids)); if ids.iter().filter_map(|id| d.node(*id)).any(|n| expandable_except(n, &kept)) { Ok(()) } else { @@ -1225,12 +1269,22 @@ fn holds_plugin_effect(d: &Document, n: &Node, in_art: bool, seen: &mut std::col } fn expand_appearance(s: &mut Session, p: &Value) -> Result { - let roots = appearance_targets(s, p)?; + let roots = edit_targets(s, p, "effect.expandAppearance")?; // In Safe Mode a plug-in effect draws nothing: expanding it would drop the effect and keep the // geometry it hasn't changed. if vectorcraft_plugins::safe_mode::active() { - // A dimension selected with the art it measures is left as it is. - let d = &*super::dimension::without_kept(&s.doc()?.doc, &roots); + // A dimension selected with the art it measures is left as it is, and so is the art of a graph design in a + // series. + let mut d = super::dimension::without_kept(&s.doc()?.doc, &roots); + let designs = super::appearance::design_art_in(&d, &roots); + if !designs.is_empty() { + let view = d.to_mut(); + for id in designs { + // Art already gone (inside other art removed) needs nothing more. + let _ = view.remove(id); + } + } + let d = &*d; let mut seen = std::collections::HashSet::new(); if roots.iter().filter_map(|id| d.node(*id)).any(|n| holds_plugin_effect(d, n, false, &mut seen)) { return Err(EngineError::Other("Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode".into())); @@ -1240,6 +1294,10 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result { let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok(); let ids = s.edit("Expand Appearance", |d, _| { let before = d.clone(); + // The art of graph designs in the graphs and series among the roots stays as the graph draws it. A group whose + // own effect turns it into other art as a whole (a blur, feather or inner glow makes it an image) takes its + // art into that art, which draws it, so that art has no place to go back to. + let designs = super::appearance::design_art_in(d, &roots); // A dimension selected with the art it measures keeps its own lines and label: it is out // of the document while the art expands. let lifted = super::dimension::lift(d, &roots); @@ -1251,6 +1309,15 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result { for id in &roots { expand_node(d, *id, &brushes, &mut out); } + for id in &designs { + if let (Some(was), Some(slot)) = (before.node(*id), d.node_mut(*id)) { + *slot = was.clone(); + } + } + if !designs.is_empty() { + // A mark that expands into a group stays listed: only what lies in the restored art is left out. + out.retain(|id| d.node(*id).is_some() && !d.ancestry(*id).is_some_and(|up| up.iter().any(|a| designs.contains(a)))); + } super::dimension::put_back(d, lifted); // A dimension whose source was replaced stays where it was, no longer linked. super::dimension::restore_unchanged(&before, d, ended); diff --git a/crates/engine/src/cmd/effectcmd/graphics.rs b/crates/engine/src/cmd/effectcmd/graphics.rs index 9f4b89d2f4..e80c1b2d36 100644 --- a/crates/engine/src/cmd/effectcmd/graphics.rs +++ b/crates/engine/src/cmd/effectcmd/graphics.rs @@ -95,7 +95,7 @@ fn map_graphic(s: &mut Session, p: &Value) -> Result { effect_targets(s, p, item, index, C)? } else { let mut out = vec![]; - for id in edit_targets(s, p)? { + for id in edit_targets(s, p, C)? { let (item, index) = d.node(id).and_then(first_three_d).ok_or_else(|| bad(C, format!("object {} needs a 3D effect", id.0)))?; out.push((id, item, index)); } diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 6d972beda8..28cd71804a 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -91,6 +91,7 @@ pub(crate) use mesh3d::{mesh_file_names, mesh_files}; pub use naming::{NameParts as FileNameParts, parse_date as parse_file_date, render as render_file_name}; pub(crate) use native::preview_png; pub use native::with_compression_pref; +pub(crate) use pdfimport::opened_page_document; pub use pdfimport::{LoadOptions, page_document}; pub(crate) use save::job_for; pub(crate) use save::save as save_document; @@ -124,9 +125,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 a46f46492a..27024ef8eb 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -9,7 +9,7 @@ pub fn specs() -> Vec { "Open Document", [], None, - "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page; a PSD file's layers become layers holding their pixels, with their names, visibility, opacity and blend modes, groups as layers of sublayers, layer masks as opacity masks, clipped layers and layers in blend modes VectorCraft doesn't have merged with the layers below) | false (one layer per page, without the art that is off; Save then asks for a name; a PSD file opens as its merged image), hiddenLayers?: false (a PSD file's hidden layers come in hidden instead of being left out), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), legacyNameEncoding?: windows-1252 (default) | macintosh | windows-949 (also euc-kr) | shift_jis | gbk | big5 (the code page names in the editing data of a .ai or an EPS are read in when they aren't UTF-8: layers, objects, artboards, colors, gradients, patterns, graphic styles, swatches, brushes, symbols, variables and data sets, and data sets' text values; warnings list the first three such names as read, and count the rest), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name), linkPages?: false (true: each page `pages` picks of a PDF or PDF-compatible .ai opens as a placed document linked to that page of the file, as file.place with link places one, on an artboard of its cropTo box (bounding: its art's bounds), in a row, and a layer named after the page; links.embed makes a page editable art; refused for any other file, without a path, with a password or with textAs: outlines; Save then asks for a name), importer?: \"plugin.\" (an importer plug-in's format id, see document.formats: it opens the file, which must have an extension it lists, also one a built-in format reads), params?: {…} (the importer's own parameters, see plugin.info)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), PICT .pct/.pic/.pict (QuickDraw versions 1 and 2, with or without the file's 512-byte header: one artboard, the picture's frame; shapes, lines and regions as paths, text as point type, bitmaps, pixel maps and QuickTime JPEG images as images; what isn't read is listed in warnings), HP-GL plot files .plt/.hpgl/.hgl/.hpg (one artboard round the art; what isn't drawn is listed in warnings), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. A file that no built-in format reads, by its content or its extension, opens with the installed importer plug-in that lists its extension (the one whose id sorts first, when several do): it is given the file, at most 128 MiB, and its objects become a new document whose artboard is their bounds; Save then asks for a name, and the warnings are the importer's. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks). An SVG's variable data (SVG Options › Include Variable Data, a variableSets element) gives the document its text variables and data sets, each text variable bound to the type object its docRef names, unless the SVG restores the native document; variable data variable.loadLibrary would refuse is left out with a warning", + "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page; a PSD file's layers become layers holding their pixels, with their names, visibility, opacity and blend modes, groups as layers of sublayers, layer masks as opacity masks, clipped layers and layers in blend modes VectorCraft doesn't have merged with the layers below) | false (one layer per page, without the art that is off; Save then asks for a name; a PSD file opens as its merged image), hiddenLayers?: false (a PSD file's hidden layers come in hidden instead of being left out), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), legacyNameEncoding?: windows-1252 (default) | macintosh | windows-949 (also euc-kr) | shift_jis | gbk | big5 (the code page names in the editing data of a .ai or an EPS are read in when they aren't UTF-8: layers, objects, artboards, colors, gradients, patterns, graphic styles, swatches, brushes, symbols, variables and data sets, and data sets' text values; warnings list the first three such names as read, and count the rest), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name), linkPages?: false (true: each page `pages` picks of a PDF or PDF-compatible .ai opens as a placed document linked to that page of the file, as file.place with link places one, on an artboard of its cropTo box (bounding: its art's bounds; a blank page: its crop box; a PDF-compatible .ai: its trim box for crop; on its trim box a page shows its bleed box round the artboard and gives the document its bleed), in a row 36 pt apart (on trim boxes, plus the bleed on both sides), and a layer named after the page; links.embed makes a page editable art; refused for any other file, without a path, with a password or with textAs: outlines; Save then asks for a name), importer?: \"plugin.\" (an importer plug-in's format id, see document.formats: it opens the file, which must have an extension it lists, also one a built-in format reads), params?: {…} (the importer's own parameters, see plugin.info)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), PICT .pct/.pic/.pict (QuickDraw versions 1 and 2, with or without the file's 512-byte header: one artboard, the picture's frame; shapes, lines and regions as paths, text as point type, bitmaps, pixel maps and QuickTime JPEG images as images; what isn't read is listed in warnings), HP-GL plot files .plt/.hpgl/.hgl/.hpg (one artboard round the art; what isn't drawn is listed in warnings), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. A file that no built-in format reads, by its content or its extension, opens with the installed importer plug-in that lists its extension (the one whose id sorts first, when several do): it is given the file, at most 128 MiB, and its objects become a new document whose artboard is their bounds; Save then asks for a name, and the warnings are the importer's. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks). An SVG's variable data (SVG Options › Include Variable Data, a variableSets element) gives the document its text variables and data sets, each text variable bound to the type object its docRef names, unless the SVG restores the native document; variable data variable.loadLibrary would refuse is left out with a warning", always, load::open ), @@ -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: of the layers the file draws, 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 art it plots, without placed images, unpainted paths and art at 0% opacity), 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: of the layers the file draws, 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 art it plots, without placed images, unpainted paths and art at 0% opacity), 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, ,